Skip to content

Make phase I's cancellation reliable, and cheap enough not to fight itself - #96

Merged
amrali-eg merged 6 commits into
masterfrom
fix/cancel-retry-and-probe
Sep 9, 2026
Merged

Make phase I's cancellation reliable, and cheap enough not to fight itself#96
amrali-eg merged 6 commits into
masterfrom
fix/cancel-retry-and-probe

Conversation

@amrali-eg

Copy link
Copy Markdown
Owner

Follow-ups from reviewing PR #95, which made phase I fail when it cannot prove it
interrupted a run. That is the right rule, but it left the phase depending on two
things that were not reliable. No production code changes; MainForm was read
and not edited.

What was wrong

One refused click was read as "the run finished". RequestCancel caught the
first ElementNotEnabledException and then waited for the run to report itself
finished - which it could not do for another ten seconds, because the run was
still going. The click is now retried, each attempt reacquiring the button, and
the wait ends on a click that was accepted, a status saying the run finished on
its own, or the timeout.

The refusal is no longer caught at the call site either. ElementNotEnabledException
derives from InvalidOperationException, which the shared retry loop already
handles, so catching it locally only turned every refusal into a clean "not ready"
answer - which is exactly what makes that loop drop the cause it would otherwise
report.

The trigger opened a thousand files, twenty times a second. writingHasBegun
asked RewrittenCount(...) >= 1, which counts the whole directory, while EC was
rewriting those same files. It now asks AnyRewritten, which stops at the first
one. The count is still taken once, after the run has stopped, where the
assertions compare it.

Phase A read the target encoding twice per assertion. Check builds its
message whether or not it fails, so the interpolated message drove the control on
every passing run, could fail a check whose condition had held, and could report a
value the assertion never tested. Both assertions now read once into a local and
compare with OrdinalIgnoreCase, matching what SelectCombo and
RequireDefaultTarget already use - an ordinal comparison would have let a
control reporting UTF-8 satisfy the driver and fail the phase.

Evidence

Control Result
Cancel refuses 5 clicks, then accepts Passes 2/2, still interrupts (135, 189 of 1000)
Same refusal against the previous shape Fails 2/2 - and blames completion for a refused click
SetTargetEncoding reduced to a no-op Fails 2/2: "The target encoding did not change: utf-8"
Phase A 3/3
Full suite 3/3, then 5/5
Unit tests 756 passed

Every mutation required a successful build and a changed binary hash before it ran,
with the source restored byte-for-byte afterwards.

The cheaper probe shows up in the phase's own line: cancellation used to land after
38-93 of the thousand files and now lands after 16-66.

What is not shown

No control here demonstrates the improved timeout message. Staging one needs a
conversion that outlives the thirty-second wait, and this workload ends in about
ten seconds - the run finishes, the button hides, and the wait leaves through the
"finished first" branch instead of expiring. A first attempt at such a control was
invalid and is recorded as such: blinding the completion check made the predicate
answer cleanly for the rest of the wait, which cleared the retained error in both
shapes and would have shown no difference for the wrong reason. That part rests on
the compiler rejecting a redundant catch with CS0160, and on the five lines of
WaitFor that clear the error on a clean poll.

🤖 Generated with Claude Code

amrali-eg and others added 6 commits September 9, 2026 23:57
Two defects in the cancellation path, both found by review of the last change.

RequestCancel gave up on the first ElementNotEnabledException and then waited
for the run to report itself finished - which it could not do for another ten
seconds, because the run was still going. The click is now retried, each
attempt reacquiring the button, and the wait ends on a click that was
accepted, a status saying the run finished on its own, or the timeout. A
control that refuses the first five clicks now passes and still interrupts the
run; against the previous shape a single refusal failed the phase twice out of
two - and failed it with the wrong explanation, reporting that the conversion
had finished when it was still writing.

The trigger asked RewrittenCount(...) >= 1, which opened all thousand files on
every 50 ms probe while EC was rewriting those same files. It now asks
AnyRewritten, which stops at the first one. The count is still taken once,
after the run has stopped, where it is what the assertions compare.

The margin this bought is visible in the phase's own line: cancellation used
to land after 38-93 of the thousand files and now lands after 16-60.

EC is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The refusal was caught inside the predicate and turned into "not yet". That
retried just as often, but the shared loop clears its retained error whenever a
probe answers cleanly, so every refusal erased the very thing a timeout would
have reported - while the remarks claimed the wait retained it.

ElementNotEnabledException derives from InvalidOperationException, which the
loop already catches, so the refusal now simply propagates: it is retried and
kept. Confirmed by compilation rather than assumed - catching it after
InvalidOperationException is CS0160, "a previous catch clause already catches
all exceptions of this or of a super type".

The behaviour is unchanged where it was already right: a control refusing the
first five clicks still passes and still interrupts the run.

No control here shows the improved message, because staging one needs a
conversion outliving the thirty-second wait, and this workload ends in about
ten seconds. The first attempt at such a control was invalid - blinding the
completion check made the predicate answer cleanly for the rest of the wait,
which cleared the error in both shapes and would have shown no difference for
the wrong reason.

Also drops a stranded summary left above AnyRewritten by the previous commit.

EC is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
It keeps the last error only while the probe is still failing: a poll that
answers cleanly clears it. Three comments said or implied otherwise, including
the parameter's own documentation, which is where the rule belongs.

RequestCancel now says the loop can report a refusal if refusals continue to
the timeout, and that catching it here would turn every refusal into a clean
"not ready" answer - which is precisely what makes the loop drop the cause. The
duplicate claim higher in the same remarks is gone.

Comments only; no behaviour changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Check builds its message whether or not the assertion fails, so each of these
asked the window for the target twice on every run: once to test it, once to
describe it. That drove the control on passing runs, and let a transient
automation error inside a message fail a check whose condition had held - the
defect the driver added Safely() to avoid. It could also report a value the
assertion never tested, since the second read is a separate observation.

The comparison was ordinal while SelectCombo and RequireDefaultTarget both use
OrdinalIgnoreCase, so a control reporting "UTF-8" would have satisfied the
driver and failed the phase. Both now read once into a local and compare
case-insensitively, and the message reports that same reading.

The assertions still catch what they exist for: with SetTargetEncoding reduced
to a no-op, phase A fails twice out of two with "The target encoding did not
change: utf-8".

EC is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Delivering a click is not proof it took effect, and failing to deliver one is
not proof it did not. RequestCancel tracked a cancelled flag and threw when it
was false, so a click that landed and then failed - because the window hid the
button in response, which is exactly what a successful cancel looks like -
reported "The conversion finished before cancellation could be exercised" while
the status bar read "Conversion stopped". EC writes that headline only for a
run that was stopped, so the failure contradicted its own evidence.

The flag is gone. RequestCancel asks and then waits for the run to report
something; WaitForStoppedConversion reads what it reported. Conversion stopped
means cancellation worked, Conversion complete means it was not exercised, and
no final status at all is the timeout.

The two ways a click can fail are no longer treated alike. A refusal happens
before anything is delivered, so it is retried. An element that vanishes
mid-call, or a COM failure, may follow a click that did land, so the attempt
stops rather than pressing the button a second time.

SelectedName absorbs a provider's null instead of handing it out, and every
comparison of its result uses the static string.Equals overload. The instance
call introduced a NullReferenceException where the previous == could not throw.

Controls: a click that lands and then throws now passes twice out of two and
still interrupts the run, where the previous shape failed twice with the false
message. Five refusals followed by acceptance still cancel. A no-op target
setter still fails phase A with "The target encoding did not change: utf-8".

EC is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two gaps in how RequestCancel classified a failed press.

A refusal was caught and answered "not ready", which is a clean answer, and the
shared loop clears its retained error on one of those - so the diagnostic was
erased again, the same way the earlier local catch erased it. The refusal is now
left to that loop, which retries it and keeps it as the cause a timeout can name.

Every other automation failure from the press is now treated as an uncertain
outcome, not only ElementNotAvailableException and COMException. A plain
InvalidOperationException escaped to the shared loop, which retried the whole
predicate and pressed a button whose outcome was unknown: instrumented, that
shape presses Cancel 35-38 times where one press is correct. It now presses once
and waits for the status.

Controls on the corrected shape: an uncertain press that never landed presses
once and the phase fails with "Cancellation was not exercised", five refusals
followed by acceptance still cancel and interrupt the run, and a press that
lands and then throws still passes.

EC is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@amrali-eg
amrali-eg merged commit 4ed6d67 into master Sep 9, 2026
3 checks passed
@amrali-eg
amrali-eg deleted the fix/cancel-retry-and-probe branch September 9, 2026 22:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant