Repository navigation
feat(plc): honor cancellation in PLC reads (#120) - #167
Merged
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Slice 6b closed the second half of #120 (exception type/stack preserved via
PlcCommandFailedError.CausedBy). This closes the first half:CancellationTokenwas not threaded throughIS7Reader, so PLC reads ran un-cancellable.IS7Transport) andPlcTransactionExecutoralready took a token — butS7Servicecalled the executor with no token, so it defaulted toCancellationToken.None. The break was theIS7Readerseam in the middle.PerformReconnectReconciliationAsynccompensated with five scatteredIsCancellationRequestedpolling 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 swallowedOperationCanceledExceptioninto a genericResult.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 takeCancellationToken ct = default;S7Serviceforwards 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
TaskCanceledExceptioneven though no token was cancelled. So both catch sites classify ontoken.IsCancellationRequested, not the exception type — that keeps the existing timeout test unchanged and separates a shutdown race from a real read failure.A genuine
EnableSynccancellation 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 inerror-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 formatclean.PlcLifecycleManagerReconnectTests) — a TCS-gated managing-area read is cancelled byDispose;ReadRecipeFromPlcCallCountstays 0 after the gate is released. A token-ignoring regression would advance to the recipe read → count 1, so the test is non-vacuous.PlcLifecycleManagerOwnershipTests) — asserts the cancel result equals the sharedSyncEnableCancelledMessageconstant; deleting the production genuine-cancel branch routes the OCE to the generic catch ("A task was canceled.") and fails the test. The:171timeout test stays green (token not cancelled → failure branch).TaskCanceledException, somessage != timeout-stringheld 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.Closes #120.
Per-task commits before collapse
🤖 Generated with Claude Code