Skip to content

feat(plc): honor cancellation in PLC reads (#120) - #167

Merged
mrcsin merged 1 commit into
masterfrom
plc-cancellation-plumbing
Aug 3, 2026
Merged

mrcsin merged 1 commit into
masterfrom
plc-cancellation-plumbing

Conversation

@mrcsin

@mrcsin mrcsin commented Aug 3, 2026

Copy link
Copy Markdown
Member

Problem

Slice 6b closed the second half of #120 (exception type/stack preserved via PlcCommandFailedError.CausedBy). This closes the first half: CancellationToken was not threaded through IS7Reader, so PLC reads ran un-cancellable.

  • The transport (IS7Transport) and PlcTransactionExecutor already took a token — but S7Service called the executor with no token, so it defaulted to CancellationToken.None. The break was the IS7Reader seam in the middle.
  • PerformReconnectReconciliationAsync compensated with five scattered IsCancellationRequested polling checks — but a read that hangs between checks (unresponsive PLC) blocked anyway, because the token never reached the in-flight transport read.
  • EnableSync's catch swallowed OperationCanceledException into a generic Result.Fail(ex.Message), conflating a shutdown-cancel with a real failure.

What changed

Thread the token through the reader and use it where cancellation matters.

  • IS7Reader — the three reads take CancellationToken ct = default; S7Service forwards it to the executor, so a read now reaches _transport.ReadBytesAsync(..., ct) with the token.
  • PerformReconnectReconciliationAsync — passes the lifetime token to every read, drops all five polling checks, and a genuine-cancel wrapper (catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)) returns quietly; a timeout still propagates to the fault logger.
  • EnableSync — captures the lifetime token once at entry and splits its catch: a genuine cancel rolls back and returns a plain internal result, a real failure is reported as before.

The load-bearing distinction: S7.Net surfaces a read timeout as a TaskCanceledException even though no token was cancelled. So both catch sites classify on token.IsCancellationRequested, not the exception type — that keeps the existing timeout test unchanged and separates a shutdown race from a real read failure.

A genuine EnableSync cancellation is a shutdown race, not an operator fault, so its result stays plain English and is not typed or localized (it would otherwise wrongly enrol in the localization coverage gate). Deliberately left out of scope (documented in error-reporting.md): ConnectAsync (token-less by design), the user-initiated load path, the short protocol-version-read diagnostic, and the codec decode-fail forwarding.

Verification

  • dotnet build SemiStep.slnx — 0 warnings; dotnet test — 1663 passed, 0 failed; dotnet format clean.
  • Reconnect-cancel test (PlcLifecycleManagerReconnectTests) — a TCS-gated managing-area read is cancelled by Dispose; ReadRecipeFromPlcCallCount stays 0 after the gate is released. A token-ignoring regression would advance to the recipe read → count 1, so the test is non-vacuous.
  • Enable-cancel test (PlcLifecycleManagerOwnershipTests) — asserts the cancel result equals the shared SyncEnableCancelledMessage constant; deleting the production genuine-cancel branch routes the OCE to the generic catch ("A task was canceled.") and fails the test. The :171 timeout test stays green (token not cancelled → failure branch).
  • Review chain: comprehensive (5 agents) → smells → comment audit → critical, all clean after fixes. The reviews caught a vacuous enable-cancel test (the interrupt is a framework TaskCanceledException, so message != timeout-string held under both catch arms) — fixed with the shared-constant discriminator; plus a dead param, a stub-ordering trap, an unbounded await that would hang a regression, and a method-length/duplication refactor.
  • Manual test (operator): passed.

Closes #120.

Per-task commits before collapse
94e8778 docs(plan): record exec branch and verify steps for 6c
1296aa3 docs(test): drop restating comments in cancellation tests
df1e096 refactor(plc): extract reconcile body and sync-enable rollback
217feae fix: discriminate enable-cancel test, drop dead param, doc drift
3603d14 test(plc): cover reader cancellation in reconnect and enable
0f6779c feat(plc): honor cancellation in reconnect and enable paths
76f13fd feat(plc): thread CancellationToken through IS7Reader

🤖 Generated with Claude Code

Slice 6b closed the second half of #120 (exception type and stack
preserved via PlcCommandFailedError.CausedBy). This closes the first
half: CancellationToken was not threaded through IS7Reader, so PLC reads
ran un-cancellable. The transport and PlcTransactionExecutor already took
a token; the break was the IS7Reader seam in the middle, where S7Service
called the executor with no token so it defaulted to None. Downstream,
PerformReconnectReconciliationAsync compensated with scattered
IsCancellationRequested polling that a read hanging between checks
defeated, and EnableSync's catch swallowed OperationCanceledException
into a generic failure.

Thread the token through the reader and use it where cancellation
matters:

- IS7Reader's three reads take CancellationToken; S7Service forwards it
  to the executor, so a read now reaches the transport with the token.
- PerformReconnectReconciliationAsync passes the lifetime token to every
  read and drops all five polling checks; a genuine-cancel wrapper
  (catch OperationCanceledException when the token is cancelled) returns
  quietly, while a timeout still propagates to the fault logger.
- EnableSync captures the lifetime token once at entry and splits its
  catch: a genuine cancel rolls back and returns a plain internal result,
  a real failure is reported as before.

The load-bearing distinction: S7.Net surfaces a read timeout as a
TaskCanceledException even though no token was cancelled, so both catch
sites classify on token.IsCancellationRequested, not the exception type.
That keeps the existing timeout test unchanged and separates a shutdown
race from a real read failure.

Cancellation is a shutdown-race concern, not an operator fault, so the
genuine-cancel result stays plain English and is not typed or localized.
Two deterministic TCS-gated tests prove the reads honor the token: the
reconnect test asserts a cancelled token stops reconciliation at the
in-flight read (the downstream recipe read is never reached), and the
enable test asserts the genuine-cancel branch runs on a dispose landing
mid-read.

Closes #120.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@mrcsin
mrcsin marked this pull request as ready for review August 3, 2026 10:28
@mrcsin
mrcsin merged commit 93426c5 into master Aug 3, 2026
1 check passed
@mrcsin
mrcsin deleted the plc-cancellation-plumbing branch August 3, 2026 10:30
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.

PLC I/O plumbing: CancellationToken not threaded through IS7Reader; exception type/stack lost at Result.Fail(ex.Message)

1 participant