feat(rawcan)!: harden TX-confirm echo matching and replace the filter-overlap tuple - #100
Conversation
An expired or cancelled SendConfirmed call stayed in its key's pending FIFO until SendWithEchoConfirmAsync's `finally` ran. Because the pending TCS is created with RunContinuationsAsynchronously, that `finally` runs a scheduling turn later on a pool thread, and until it did the resolved entry was still the oldest entry for its key -- so the next byte-identical send lost its echo to it and timed out as well. One timeout cascaded into the next. Three changes, each closing part of it (FR-RAW-031, FR-RAW-033): * The cancellation/timeout registration unlinks the entry under _pendingGate before it completes the TCS, so an entry stops being matchable the instant it is resolved. The `finally` stays: it is still the cleanup for the paths that never reach the registration (rejection, an exception out of Transmit). * TryMatchEcho skips -- and unlinks -- entries whose TCS is already completed instead of taking list.First unconditionally. With the above they should not be reachable; this keeps a single stale entry from silently eating one echo and blocking the FIFO for every later one. * PendingKey includes the frame kind and the flags that identify a frame on the wire (Ext, Rtr, Error), so a standard 0x100 and an extended 0x100 with the same payload no longer share one FIFO. Brs and Esi are deliberately excluded: an adapter may report them differently on the echo than the caller asked for, and two sends differing only in those bits are interchangeable for confirmation anyway -- including them could only cause spurious timeouts. Tests: ControllableBus gains an OnTransmitting hook, which runs on the transmitting thread while CanBusService holds its pending-send lock. That makes the FIFO-poisoning case deterministic rather than a race with a pool thread: the first send is resolved from inside the second send's Transmit, so its own async cleanup cannot run until the second send's echo has already been matched. All three new tests were confirmed to fail against the unfixed service (the poisoning test five times out of five) and to pass with it. Closes #24 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011Zd6AyAtcZApgfRC2Rkitj
… payloads at all Two allocations on the per-frame dispatch path that nothing needed (#53, findings 2 and 3). The payload copy that protects a buffered frame from the adapter's RX lease being disposed under it was made once per *matching subscription*. The reason for it is a property of the frame, not of the subscriber, and what a subscriber receives is a ReadOnlyMemory it may only read -- so the copy is now made by the first subscription that buffers the frame and reused by the rest. With n subscriptions matching, that is n-1 array allocations and copies per frame that no longer happen. A frame nobody matches still allocates nothing, and the predicate is still handed the aliasing view rather than the copy. TryMatchEcho built its lookup key by copying the echo frame's payload, i.e. once for every echo frame the adapter reports while any send is outstanding, purely to ask whether anything was waiting for it. PendingKey now distinguishes the two uses: ForPendingSend copies, because the entry outlives the caller's frame; ForEchoLookup borrows, because the key is dropped again before the lock is released. Also corrects the comment above IsoTpChannel's own payload copy, which described a hazard that does not exist -- the subscription hands out memory it owns, not the adapter's lease. The copy itself stays, and now has the reason it actually has: that array is shared with every other subscription that matched the frame, while the ISO-TP state machine wants a byte[] of its own to keep across awaits. Tests: the shared-copy test asserts the two subscriptions' payloads are the same array (and still not the adapter's), and fails against a per-subscription copy; the echo-lookup test measures allocated bytes per unmatched echo frame and reads 88 B/frame without the fix against a 64-byte budget. The existing test that covered CanFrameEvent's byte-wise equality through two subscriptions' distinct buffers keeps that coverage as a direct test over two arrays, since the demux no longer produces the distinct-buffer case by itself. Refs #53 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011Zd6AyAtcZApgfRC2Rkitj
Range(0x18FEF100, 0x18FEF1FF) with the idType forgotten built a filter that matched nothing and reported nothing: Matches() only ever sees IDs already clipped to the filter's ID space, so a standard-space filter over a 29-bit ID is unsatisfiable by construction. The same holds for an acceptance pair whose accCode & accMask requires a bit above 0x7FF (standard) or 0x1FFFFFFF (extended) to be set. Both now throw ArgumentOutOfRangeException naming the likely fix, instead of silently accepting no frames (#53, finding 4). An acceptance *mask* reaching above the ID space stays legal: it merely requires those bits to be zero, which every real ID satisfies. The clipping in Overlaps and the full-width bit walk in RangeIntersectsMask are kept although the factories now reject the inputs that made them load-bearing. They are what makes Overlaps correct independently of how a filter was built, and removing a guard because a new one shadows it is how this repository has acquired regressions before. Their comments now say which line of defence they are. Three existing tests drove out-of-space filters through the factories to reach that clipping and become construction-rejection tests here; the case that a mask above the ID space is still usable keeps its own test. Refs #53 BREAKING CHANGE: CanIdFilter.Range and CanIdFilter.Mask now throw ArgumentOutOfRangeException for bounds or acceptance pairs outside the target ID space. Code that built such a filter was matching nothing at all -- the fix is to pass CanFilterIDType.Extend for 29-bit IDs, or to correct the bound. Filters already inside their ID space are unaffected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011Zd6AyAtcZApgfRC2Rkitj
…p the pump from failing unobserved An exception out of the callback overload's onNext was routed to BackgroundExceptionOccurred only when the service happened to be the concrete CanBusService. For any other ICanBusService it was dropped -- and it had to be, because that fault channel is an event on the interface and only the type declaring an event can raise it. There was no way for this extension to report anything at all through a foreign implementation (#53, finding 6). So the extension now takes an optional onError. When given it is the single destination, in preference to the service's own event: a caller who passes it has said where these belong, and one failure arriving through two channels is its own surprise. For a foreign service it is the only destination there is, which the XML docs now say outright rather than leaving the gap implicit. The pump loop is also wrapped: a failure of the enumeration itself (as opposed to one onNext call) used to fault the pump task, which Dispose joins for at most two seconds and then abandons -- so nothing was left to observe it. It reports through the same channel now. The bounded join stays bounded: an onNext that never returns cannot be cancelled from here, and waiting longer would only move the hang into the caller's Dispose. Also replaces the two Interlocked.Exchange calls on Subscription's volatile _criteria field with plain volatile writes (#53, finding 5). Nothing reads the previous value and there is no compare-and-swap; volatile already gives the atomic, unreordered publication FR-RAW-014 needs, and Interlocked implied a guarantee that was never in play. No behaviour change. Tests: a foreign ICanBusService fake proves the handler failure now reaches onError and that delivery continues, and a second test proves onError wins over the service event. Both fail (by timing out on the report) when the onError branch is taken out. API approval regenerated from the .received.txt. Refs #53 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011Zd6AyAtcZApgfRC2Rkitj
….6005 error codes ProtocolErrorCodes occupies CanKitErrorCode 6002..6005, which the pinned CanKit (0.5.6, verified against the packaged enum: the 6000 range holds only TransportOperationFailed = 6001) does not define (#53, finding 7). The numbers are not moved. They are chosen so that an upstream adoption of these four codes deletes ProtocolErrorCodes and changes nothing for callers, which is what docs/upstream-candidates.md § 1 promises, and any other range gives that up in exchange for a collision risk that is smaller but not gone -- CanKit allocates in 1000-blocks up to 9000 and could grow into whatever range we picked. What the finding is right about is that the collision would be silent: an upstream 6002 would make ProtocolTimeout render an unrelated name and compare equal to an unrelated failure, with nothing failing. So the risk gets a tripwire instead of a relocation -- the test fails on the CanKit bump that claims one of the four, while that bump is still a one-line version change under review. Verified to have teeth by pointing it at 6001, which upstream does define: it fails with the message the real case would print. Refs #53 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011Zd6AyAtcZApgfRC2Rkitj
…lap carrying the shared ID range FindOverlappingFilterSubscriptions() returned IReadOnlyList<(ISubscription First, ISubscription Second)>. The relation is symmetric -- there is no first and no second, only two subscriptions that share ID space -- so the element names read as if the order meant something, and they dragged a [return: TupleElementNames] attribute into the public API surface. More to the point, the pair could not grow a third piece of information without changing the return type again (#82). So it returns IReadOnlyList<FilterOverlap>, and the type carries what a caller diagnosing an unexpected overlap actually wants: *where* the two collide. LowestSharedId/HighestSharedId are the smallest and largest CAN ID both filters accept -- exact for range filters, an inclusive hull for acceptance-mask filters, which accept a scattered set. It destructures to (A, B) for callers that only want the pair. Computing the range is a generalisation of the search Overlaps already ran, not a second one: the same bit walk now returns the smallest or largest satisfying ID instead of just whether one exists, and all four range/mask combinations reduce to one call of it. Overlaps delegates, so there is a single implementation rather than two that can drift. Per ADR 0001 this is a replacement rather than an addition: breaking changes are expected before the 1.3.0 tag, they map to a minor bump, and src/ deliberately contains no [Obsolete] member -- the additive route the issue originally prescribed would have added the first one, permanently, with no major version scheduled to remove it. This supersedes that section of #82, as recorded there. Tests: the reported range is checked against a brute-force sweep of the entire 11-bit ID space for all 169 ordered pairs of 13 filters, which also pins the Overlaps behaviour the shared search had to reproduce; the existing overlap tests all still pass unchanged. Inverting the high witness's preference fails three of them, so the range assertions have teeth. Five test fakes implementing ICanBusService, the two docs pages and the FR-RAW-041 rows in the SRS and arc42 move with the signature. API approval regenerated from the .received.txt; the TupleElementNames attribute is gone from the surface. Closes #82 BREAKING CHANGE: ICanBusService.FindOverlappingFilterSubscriptions() now returns IReadOnlyList<FilterOverlap> instead of IReadOnlyList<(ISubscription First, ISubscription Second)>. Positional destructuring -- foreach (var (a, b) in ...) -- keeps working unchanged. Member access moves from pair.First/pair.Second to overlap.A/overlap.B, and the overlapping ID range is available as overlap.LowestSharedId/HighestSharedId. Implementations of ICanBusService must update the member's return type. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011Zd6AyAtcZApgfRC2Rkitj
…ing-send lock The last of the collected RawCan findings (#53) asks for ICanBus.Transmit to move out of _pendingGate, so a blocking vendor driver cannot stall the adapter's RX thread in TryMatchEcho. It is not done, and the reason belongs next to the lock rather than in a closed issue: holding the lock across register + transmit is what makes the pending FIFO's order equal transmission order, which is the whole of FR-RAW-031's "no cross-matching of byte-identical concurrent sends". An echo-mode adapter also re-enters this lock from inside Transmit already, and a driver that genuinely blocks there is unusable for every other consumer of ICanBus too, since the same call sits on their dispatch path. Refs #53 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011Zd6AyAtcZApgfRC2Rkitj
PR SummaryMedium Risk Overview Breaking: TX echo matching (#24, FR-RAW-031): Pending sends are keyed by ID, payload, frame kind, and identity flags ( Dispatch / callbacks: Matching subscriptions on one frame share a single owned payload copy. Callback Overlap math: Misc: Reviewed by Cursor Bugbot for commit 3a0cedd. Bugbot is set up for automated code reviews on this repo. Configure here. |
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
…h witness below a range bound Two tests from the Codecov patch report. Only the first of them closes a coverage item; the second is worth having anyway and the report's remaining lines are argued rather than padded. The argument guards on the callback Subscribe overload had no test at all. They are the whole contract for a null service or handler: without them the null reaches the pump task and surfaces later as a NullReferenceException on a background thread, with nothing pointing at the call that caused it. Both are now asserted by parameter name. This is the CanBusServiceExtensions item in the report (2 missing, 1 partial) and it is gone. The second pins the high witness in the shape the existing brute-force sweep never produced: a range whose upper bound is not itself a shared ID. Range [0x000, 0x040] against a mask requiring bits 4..7 clear shares only 0x000..0x00F, so HighestSharedId is 0x00F rather than the range's 0x040 -- the case where the walk must abandon the high bound instead of riding it down. The test cross-checks both bounds against an enumeration of the shared set, so it cannot agree with a wrong implementation. To be accurate about what that second test did not do: the branch Codecov flags on CanIdFilter.cs is the `bit >= 31` guard on the shift, not the preferHigh ternary a line below it, which was already covered. See the comment added there for why that guard cannot be reached. Suite 597/597, build 0 warnings / 0 errors with CI=true, format clean. Refs #53, #82. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011Zd6AyAtcZApgfRC2Rkitj
Codecov: one real gap closed, three branches arguedThe patch report named 7 lines across four files. I measured them locally rather than reading the percentages, and they are not the same kind of thing — one was a genuine hole, three are defensive branches that cannot be reached deterministically. Taking each in turn, since "95.2 %" says nothing useful on its own. Closed:
|
CI:
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e2a8a09a13
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…driver call Review found that the #24 fix bought its guarantee at too high a price. It unlinked the expired entry under _pendingGate before completing the Tcs, and that runs on whichever thread trips the token -- so a caller's own CancellationTokenSource.Cancel() blocked until an unrelated concurrent send returned from _bus.Transmit, which SendWithEchoConfirmAsync holds the same lock across. Cancelling one send is not something that should wait on another send's adapter. The unlink was also unnecessary. TryMatchEcho already skips and unlinks entries whose Tcs is completed, and that skip -- not the eager unlink -- is what actually fixes #24: an echo arriving before the `finally` runs walks past the expired entry to the live one behind it. Removing the eager unlink therefore promotes that loop from a redundant second guard to the mechanism, which is what its comment now says. The previous comment claimed a completed entry was unreachable there; with the eager unlink gone that is no longer true, and leaving it would have been the same stale-narrative defect this branch has already had to correct twice. Scope, because the test says so and the code comment should too: the caller's SendConfirmed task still completes only after the `finally` unlinks, and that unlink does take the lock. That coupling predates this fix and follows from holding _pendingGate across Transmit at all -- the deliberate decision recorded next to the lock and argued on #53. This change is responsible for the cancellation path, and that is what it fixes. Covered by Cancelling_One_Send_Does_Not_Block_On_An_Unrelated_Send_Inside_Transmit, which parks a second send inside Transmit via the OnTransmitting hook and asserts causally -- Cancel() returned while the other send was still in the driver -- rather than against a stopwatch, since elapsed time is not measurable on a shared runner (#92). Verified to fail against the previous revision with exactly that assertion, and to pass with this one. Suite 598/598, build 0 warnings / 0 errors with CI=true, format clean. Refs #24, #53. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011Zd6AyAtcZApgfRC2Rkitj
Codecov, measured again on
|
| File | Line | Branch |
|---|---|---|
CanBusServiceExtensions.cs |
1.00 | 1.00 |
FilterOverlap.cs |
1.00 | 1.00 |
CanIdFilter.cs |
0.987 | 0.938 |
PendingSend.cs |
0.950 | 0.500 |
So the Extensions row is Codecov lag — its comment was refreshed while the CI run for this head was still going, so a new patch definition met an older upload. The overall figure did move (95.2 % → 96.6 %), which is the CanBusService.cs items dropping off: the cancellation test added in 2a87013 reaches the TryMatchEcho skip that was unreachable before, because that loop is now the mechanism rather than a guard behind an eager unlink. A finding fixed a coverage hole as a side effect, which is the right order of events.
What remains is the two branches I argued before, unchanged and still argued:
CanIdFilter.cs— thebit >= 31guard on the shift, unreachable from its only call site because the walk starts with both bounds tight. Documented in place.PendingSend.cs—PendingKey.Equalsshort-circuits, reachable only on a hash-bucket collision, plus theEquals(object?)override that aDictionary<PendingKey, …>never calls because it goes through the genericIEquatablepath.
Neither is worth a test that reverse-engineers a hash or asserts a state the code says cannot occur. If Codecov still shows the Extensions row after the current run finishes uploading, that is worth a second look — I will check on the next pass rather than assume.
Generated by Claude Code
…ght about I dismissed the Codecov row for CanBusServiceExtensions.cs as a stale report. It was not stale; my measurement was wrong. The script I checked it with de-duplicated coverage entries by filename, and Cobertura emits one entry per *type* -- so it read the outer static class, saw 100%, and never looked at the nested CallbackSubscription where the uncovered lines are. What was actually uncovered is new behaviour this change introduces and the pull request explicitly claims: the pump wraps the whole `await foreach`, so a failure of the frame stream itself is reported rather than left on a task nobody observes -- Dispose's join is bounded and may already have given up on it. Every existing onError test fails inside the handler, which the inner catch takes, so the outer one was never entered by anything. Two tests, against a service whose subscription throws on enumeration: the failure reaches onError, and with no onError and a service that is not CanBusService -- where there is nowhere to report it, since the interface's fault event is not ours to raise -- it is dropped quietly instead of surfacing on a pool thread, with Dispose still idempotent afterwards. CanBusServiceExtensions.cs is now fully covered, lines and branches, across all nested types, verified with the corrected script rather than the one that produced the wrong answer. The other two files Codecov names are unaffected by the bug -- neither has nested types -- so the arguments for CanIdFilter's unreachable shift guard and PendingKey's collision-only equality short-circuits stand as written. Suite 600/600, build 0 warnings / 0 errors with CI=true, format clean. Refs #53. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011Zd6AyAtcZApgfRC2Rkitj
Correction: Codecov was right about
|
…osal exception-safe Two CodeQL findings on the test added in a37a20a, both correct. The subscription was disposed without a `using`, so an exception from anything above the call -- the sleep, or the first Dispose -- would have skipped it. It now has both: `using` for the guarantee, and one explicit call, which is what exercises the idempotence the test is there to check. The Thread.Sleep(50) that CodeQL tripped over deserved to go on its own merits. It was a guess that the pump had reached the throw by then: too short on a loaded runner and the drop path goes unexercised while the test still passes, which is the failure mode this branch has been documenting in #92 all week. The fake now sets an event immediately before its enumerator throws and the test waits on that, so the failure has provably happened before disposal rather than probably. Suite 600/600, build 0 warnings / 0 errors with CI=true, format clean. Refs #53. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011Zd6AyAtcZApgfRC2Rkitj
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d213e001d5
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…ther it is complete Review found a check-then-act window in the echo matcher. TryMatchEcho tested candidate.Tcs.Task.IsCompleted under _pendingGate, then completed the winner after releasing it. Since the previous commit the timeout and cancellation path completes without that lock -- on purpose, so a deadline is not held up by an unrelated send inside _bus.Transmit -- so it can land between the two. The echo is then consumed by an entry that lost the race, and a live byte-identical send behind it in the FIFO waits for an echo that has already arrived, and eventually times out. That is #24 again in different clothes: something already resolved still winning against what came after it. Fixing the first instance by moving the completion out from under the lock is what opened this one, which is worth recording rather than tidying away. TrySetResult is the only test that cannot be raced, because it *is* the transition. The walk now claims each candidate by completing it and treats a false return as "someone else got there first", continuing to the next entry instead of consuming the echo on its behalf. Completing under the lock is safe here because the Tcs is created with RunContinuationsAsynchronously: the awaiting continuation is queued, not run inline, so no caller code executes while _pendingGate is held. The old comment claiming the opposite described a hazard that flag already removes. Closed by construction rather than by a test: the window was between two operations that are now one, so there is no interleaving left to reproduce. A test could only hit it probabilistically, and this branch has spent the week arguing against exactly that kind of test (#92). The behaviour it protects -- an echo going to the live send when the FIFO head is already resolved -- stays covered by Echo_Bus_Does_Not_Let_A_Resolved_Send_Consume_A_Later_Identical_Echo. Suite 600/600, build 0 warnings / 0 errors with CI=true, format clean. Refs #24. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011Zd6AyAtcZApgfRC2Rkitj
|
| head | macos-latest |
|---|---|
e2a8a09 |
fail |
a37a20a |
pass |
d213e00 |
pass |
f75b40f |
fail |
A regression introduced by a commit does not switch itself off for two heads and back on for the third. Timing sensitivity on a shared macOS runner does exactly that.
I have spent the one allowed re-run on this head (the earlier attempt was refused with 403 This workflow is already running while the other legs were still in flight). ubuntu-latest and windows-latest are green on f75b40f, including net48, and the suite is 600/600 locally.
The test itself is untouched and stays untouched — it is asserting the right thing, and #92 now carries the argument that the right thing may not be happening. I have recorded this occurrence there as the third failure and the second on a single branch, which is what pushes the reading past "flake": three instances of one shape in one subsystem (#24's expired pending send, the check-then-act in TryMatchEcho fixed in this PR, and this) start to look like a missing invariant rather than a coincidence — an expiry must be observed before anything arriving after it, and claiming must be one atomic transition rather than a test followed by an act.
If the re-run is green, this PR is ready on its own merits. If it is red again on the same test, that is further evidence for #92 and still not a reason to hold this diff.
Generated by Claude Code
Correction to my last comment: the re-run settles this, and not the way I framed itI said the UDS test was the thing to watch. The re-run of
Two runs of one commit, disjoint failure sets. That is nondeterminism by definition, and it is not about this diff. More to the point, I have spent the one allowed re-run. There is no fix on What the three failures actually have in commonAll three are wall-clock assertions on a shared, loaded macOS runner, and at least one is a straightforward test defect rather than jitter: for (int i = 0; i < 20 && node.ClaimState != J1939ClaimState.Claiming; i++)
await Task.Delay(10);
node.ClaimState.Should().Be(J1939ClaimState.Claiming); // found ClaimedThe test means to observe a 500 ms arbitration window from the inside. Its own setup budget is nominally 200 ms but is bounded by nothing — twenty The two periodic-send tests are harder — they assert grid alignment and median inter-arrival, so timing is the observable — but a 299 ms off-grid gap against a 180 ms tolerance is the runner, not a drifting scheduler. Where this leaves #100 and what I am doing nextThis PR stands on its own: The macOS failures are a Generated by Claude Code |
…ng them Both remaining patch-coverage items are closed. I had argued each of them as unreachable-by-construction rather than covering it, twice, and that was the wrong call: one of the two was reachable the moment the code stopped needing a special case, and the other was only unreachable through the path I happened to be testing it from. CanIdFilter: the `bit >= 31` arm is gone rather than covered. It existed because C# masks the shift count, so `1u << 32` evaluates to 1 instead of overflowing and the fill-in would quietly produce a one-bit mask. Shifting in 64 bits and narrowing -- `(uint)((1UL << (bit + 1)) - 1)` -- has room for the carry, so one expression is correct for every bit in 0..31 and there is no arm left to get wrong or to leave half-covered. Verified equivalent to the old ternary for all 32 values of bit. This is the better code independently of coverage: the reader no longer has to know about shift masking to see that it is right. PendingSend: PendingKey.Equals is now tested directly, which needs InternalsVisibleTo on CanKit.Pro.RawCan -- already the convention here, as CanKit.Pro.CANopen and CanKit.Pro.J1939Tp both do the same. My earlier objection that this widens the assembly surface did not survive checking that. The gap was real and the reason it stayed open is worth recording: PendingKey is only reached through a Dictionary, and a dictionary compares hashes first, so two keys differing in ID, flags or frame kind land in different buckets and Equals is never called. The bus-level echo tests cover the behaviour and cannot touch the comparison implementing it -- a bug in one of those short-circuit arms would surface only as a hash collision resolving to the wrong send: rare, non-deterministic, and diagnosed on the wire hours later. Exactly the failure mode this key was introduced to prevent. Fourteen cases: each component varied on its own (ID, Ext, Rtr, Error, frame kind, payload content, payload length, empty payload) with symmetry asserted; Brs and Esi deliberately not changing identity, which is the counterpart claim the type's remarks make; ForEchoLookup agreeing with ForPendingSend for the same frame; ForPendingSend copying the payload so a recycled buffer cannot repoint a stored key; the Equals(object?) override agreeing with the typed one; and the distinctness the FIFO relies on, over a real dictionary. Measured, not assumed -- 0 missing and 0 partial across every line this pull request adds or changes, in all seven touched src files. The measurement itself aggregates per filename across all types including nested ones, which is what the first two attempts got wrong. Build 0 warnings / 0 errors with CI=true, suite 614/614, format clean, packages verified, release config loadable. Refs #24, FR-RAW-031. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011Zd6AyAtcZApgfRC2Rkitj
Codecov: both remaining items closed, not argued —
|
| file | patch lines missing | partial |
|---|---|---|
CanBusService.cs |
0 | 0 |
CanBusServiceExtensions.cs |
0 | 0 |
CanIdFilter.cs |
0 | 0 |
FilterOverlap.cs |
0 | 0 |
PendingSend.cs |
0 | 0 |
Subscription.cs |
0 | 0 |
IsoTpChannel.cs |
0 | 0 |
Intersected against the lines this PR actually adds or changes, from the same tree the coverage was collected on. The measurement aggregates per filename across all types including nested ones — the bug that produced my two earlier wrong answers.
CanIdFilter.cs still has one uncovered line and one partial branch overall (lines 197 and 181), but both are pre-existing and outside this diff, which is why they do not appear above and did not appear in the patch report either.
Gate: build 0 warnings / 0 errors with CI=true, suite 614/614, format clean, 9 packages verified, release config loadable.
Generated by Claude Code
…eads them right
CodeQL raised one error and one warning on the new PendingKey tests, both on the
same two assertions: `key.Equals("not a key")` compares incomparable types, and
`key.Equals(null)` can never be true.
Both rules are correct about the code and wrong about the intent. Those two
assertions exist to pin the `obj is PendingKey` guard in the Equals(object?)
override -- a foreign reference and null must come back false rather than
throwing or matching -- so "this comparison is always false" is the property
under test, not a mistake. The rules are aimed at accidental comparisons in
production code, where they are worth having.
Holding the operands in object? locals says what the assertions mean, "some
reference that is not a PendingKey", instead of leaving a string literal sitting
in a signature that takes object. Runtime behaviour is identical, so the guard's
false arm stays covered: 14/14 in PendingKeyTests, and patch coverage still 0
missing / 0 partial across every line this pull request touches.
Not changed: the three "generic catch clause" notes on CanBusServiceExtensions.
Catching Exception there is the contract -- an arbitrary caller-supplied onNext
or onError must not be able to take the delivery pump down with it -- and each
of the three already says so in place. Narrowing them would mean choosing which
subscriber bugs are allowed to kill an unrelated subscription.
Build 0 warnings / 0 errors with CI=true, suite 614/614, format clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Zd6AyAtcZApgfRC2Rkitj
CodeQL on
|
| severity | line | rule |
|---|---|---|
| error | 129 | Equals on incomparable types — PendingKey vs String |
| warning | 130 | Null argument to Equals(object) — never true |
Both rules are right about the code and wrong about the intent: those assertions exist to pin the obj is PendingKey guard in the Equals(object?) override — a foreign reference and null must come back false rather than throwing or matching — so "this comparison is always false" is the property under test. The rules are aimed at accidental comparisons in production code, where they earn their keep.
Holding the operands in object? locals says what the assertions mean, "some reference that is not a PendingKey", instead of leaving a string literal in a signature that takes object. Runtime behaviour is identical, so the guard's false arm stays covered — 14/14 in PendingKeyTests, and patch coverage re-measured at 0 missing / 0 partial across every line this PR touches.
Not changed: the three Generic catch clause notes on CanBusServiceExtensions.cs (lines 110, 122, 132). Catching Exception there is the contract — an arbitrary caller-supplied onNext or onError must not be able to take the delivery pump down with it, and FR-RAW-011's isolation guarantee depends on exactly that. Each of the three already says so in place. Narrowing them would mean choosing which subscriber bugs are allowed to kill an unrelated subscription, which is the opposite of what this layer promises. They are notes, not blocking, and I would rather leave them visible than suppress them.
Also worth recording, since it is the first time on this branch: macos-latest was green on 3a0cedd, along with ubuntu, windows (incl. net48), format, version, release-config, pack and Bugbot. That is consistent with the reading on #92 — those failures track runner load, not this diff — and #101 fixes the two tests that were red on main.
Gate on 83b7c59: build 0 warnings / 0 errors with CI=true, suite 614/614, format clean.
Generated by Claude Code
|
| head | failing test(s) on macos-latest |
|---|---|
e2a8a09 |
UdsClientTests.TimedOut_Request_Does_Not_Poison_Next_… |
f75b40f attempt 1 |
UdsClientTests.TimedOut_Request_Does_Not_Poison_Next_… |
f75b40f attempt 2 |
J1939NodeTests.ClaimAddressAsync_CancelDuringArbitration…, J1939NodeTests.StartPeriodicSend_MultiFrame_… |
3a0cedd |
(green) |
83b7c59 |
J1939TpTests.Parallel_Bam_And_TwoCm_Sessions_Do_Not_Interfere |
Five heads of one branch, five different outcomes including one clean pass, and main itself red on a sixth test. A defect introduced by this diff does not behave like that.
The one allowed re-run is unspent on this head, and I am spending it now. Recorded on #92 as the fifth distinct test.
Generated by Claude Code
|
Re-run green — All 12 checks now green on this head: That is also the second data point closing out the argument above: Nothing left outstanding on this PR: all review threads resolved, patch coverage 0 missing / 0 partial, suite 614/614 locally. Generated by Claude Code |
Brings in #100 (722a5a4). No conflicts: #100 is RawCan plus PendingKeyTests, this branch is J1939NodeTests only. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011Zd6AyAtcZApgfRC2Rkitj
…te it Codex is right that ci.yml carries a merge_group trigger and that a merge queue is exactly the mechanism for testing a queued branch against current main without a base merge. My rationale did not account for it. It is not right that the workflow "already handles this case". Checked rather than assumed: filtering CI runs by event merge_group returns zero. The trigger has never fired, because the queue is configured in the workflow but not enabled on the branch. Every merge to main, #100 and #101 today included, went in as a plain merge commit, and the base merges this wave paid for were real. So the rationale now says both things: the base-merge cost is real as things stand, and it has a known expiry the day the queue is enabled -- at which point that half of the argument goes away and the supervision half, which is the reason the rule exists, does not. A rule whose stated cost can quietly stop being true invites being dismissed later on exactly that ground. Filed as #106, because an inert guard reads as protection that is not there, and because the failure it was built for already happened once (#85, from two green pull requests merged four minutes apart). Markdown only; no code, project or workflow file touched, so no build, test or format result is claimed. Refs #85, #106. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011Zd6AyAtcZApgfRC2Rkitj
Two findings from the review of this branch, both caused by extending the chronology from eight rows to eleven without re-reading what referred to it: - The header still said eight merged pull requests while the inventory two paragraphs below listed nine (#93, #96, #97, #98, #100, #101, #104, #107, #108). Corrected to nine. - A blank line between row 8 and row 9 terminated the Markdown table, so rows 9-11 rendered as plain pipe-delimited text. Removed. Two more of the same class that the review did not name: "in jedem der acht Faelle" in the cause section refers to the measurement failures only, so it is now "der ersten acht"; "von den acht Zeilen oben" means the whole table and is now "elf". The section on cross-paragraph contradictions records this occurrence, since the document reproduced the very error class it describes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011Zd6AyAtcZApgfRC2Rkitj
What does this change?
Three RawCan issues that all edit
CanBusService.csandICanBusService.cs, so they land togetherrather than as three pull requests inheriting each other's conflicts (
CLAUDE.md§ Pull requests,and the sequencing note on #82).
Closes #24— an expired pending send no longer poisons the echo FIFO.Closes #53— the collected minor findings, six of seven acted on; the seventh is argued below.Closes #82—FindOverlappingFilterSubscriptions()returns a namedFilterOverlap.Type of change
feat— new behaviour (minor release)fix/perf— bug or performance fix (patch release)docs/test/refactor/chore/ci— no release!in the title, plus aBREAKING CHANGE:footer explaining the migration)Two commits carry
!and aBREAKING CHANGE:footer —feat(rawcan)!for #82 andfix(rawcan)!for the filter validation.
.releaserc.jsonmapsbreaking → minor, per ADR 0001, so the releaseis a minor bump.
#24 — the expired pending send
Three parts, as the issue sets out:
_pendingGate, before completingthe TCS, instead of leaving it to
SendWithEchoConfirmAsync'sfinally. Because the TCS isRunContinuationsAsynchronously, thatfinallyruns a scheduling turn later on a pool thread —and until it did, the resolved entry was still the FIFO head for its key and swallowed the next
byte-identical send's echo.
TryMatchEchoskips and unlinks entries whose TCS is already completed, rather than takinglist.Firstunconditionally.PendingKeyincludes the frame kind and the identifying flags.The
finallyis not removed. It is still the cleanup for the paths that never reach theregistration (rejection, an exception out of
Transmit), and removing a guard because a new oneappears to shadow it is the failure mode this repository has paid for before.
Flags:
Ext | Rtr | Error, not all of them — a deliberate deviation from the issue.BrsandEsiare link-layer transmission attributes; an adapter may report an echo whose BRS/ESI differfrom what the caller requested (ESI reflects the controller's error state). Two sends differing in
nothing else are interchangeable for confirmation purposes — the FIFO hands each exactly one echo —
so including those bits could only turn a matched echo into a spurious timeout, which is the failure
this issue is about. Worth a second opinion if you disagree.
The test the issue asks for
ControllableBusgained anOnTransmittinghook, which runs on the transmitting thread whileCanBusServiceholds_pendingGate. That is what makes the poisoning case deterministic insteadof a race: the first send is cancelled from inside the second send's
Transmit, so the first send'sown async cleanup blocks on the lock we are holding and cannot possibly unlink the entry before the
second send's echo is matched a few lines later, inside the same call. Without the fix the stale
entry is guaranteed to be the FIFO head at that moment, not merely likely.
(#93 already added
DeferredEchoQueueand made the FIFO-order test real; that half of the issue'stest-gap note is done. This is the remaining half.)
#53 — the collected findings
Transmitruns under_pendingGateTryMatchEchoallocates abyte[]per echo frameCanIdFilter.Range/Maskdo not validate against the ID spaceInterlocked.Exchangeon avolatilefieldSubscribeswallowsonNextfailures, leaks the pump taskProtocolErrorCodesoccupiesCanKitErrorCode6002..60051.
Transmitunder_pendingGate— left as it isThis finding predates #93, which moved
Transmitinto the lock on purpose. Holding it acrossregister + transmit is what makes the pending FIFO's order equal transmission order, and that is the
whole of FR-RAW-031's "no cross-matching of byte-identical concurrent sends" — with the lock
released in between, two threads can register in one order and transmit in the other. An echo-mode
adapter also re-enters this same lock from inside
Transmitalready, and a driver that genuinelyblocks in
Transmitis unusable for every other consumer ofICanBustoo, since the same call is ontheir dispatch path. The rationale is now recorded next to the lock (
docs(rawcan)commit) so thefinding is not re-raised from a closed issue.
3. The L3/L4 second copies stay
The per-subscription copy is now one copy per frame, shared by every subscription that buffers it.
That makes the
IsoTpChannel/J1939TpChannel/CanOpenNodecopies more load-bearing, not less:each protocol layer now gets a buffer shared with every other matching subscription, and dropping
its copy would also require
MemoryMarshal.TryGetArrayto get abyte[]back out of aReadOnlyMemory<byte>— trading a bounded, provably safe allocation for an aliasing invariant spreadacross three packages. What the issue is right about is the comment: only
IsoTpChannelhad one,and it described a hazard (the adapter's RX lease) that the subscription had already removed. It now
says why the copy is actually there.
One existing test asserted the opposite of the new behaviour —
ReferenceEquals(segA.Array, segB.Array).Should().BeFalse("each subscription buffers its own copy")— as the setup for provingthat
CanFrameEventequality compares bytes rather than memory segments. That guard is not dropped:it becomes a direct test over two arrays built in the test, so the distinct-buffer case stays covered
no matter how many copies the demux makes, and the two-subscription test keeps asserting equal
events.
4. Filter validation is a behaviour break
RangeandMasknow throwArgumentOutOfRangeExceptionfor bounds or acceptance pairs outside thetarget ID space. Marked breaking with a footer, because code that built such a filter now gets an
exception where it previously got silence — though "silence" meant matching nothing at all, which is
the bug. Three existing tests drove out-of-space filters through the factories to reach the clipping
in
Overlaps; they become construction-rejection tests. The clipping and the full-width bit walk arekept — they are what makes
Overlapscorrect independently of how a filter was built, and anacceptance mask reaching above the ID space is still legal (it constrains those bits to zero).
6.
onErroris an API addition, not just a fixICanBusService.BackgroundExceptionOccurredis an event on the interface, and only the declaringtype can raise one — so for any implementation other than
CanBusServicethis extension had nowhereto put a failing handler and dropped it. There is no way to fix that without a channel the caller
supplies, hence the optional
onError. It takes precedence over the service event when given. Thepump loop is also wrapped so a failure of the enumeration itself is reported rather than left on a
task that
Disposemay already have stopped waiting for. The 2 s join stays bounded: anonNextthat never returns cannot be cancelled from here, and waiting longer only moves the hang into the
caller's
Dispose.7. The error codes are not moved
Verified against the pinned CanKit 0.5.6 by reflecting over the packaged enum: the 6000 range holds
only
TransportOperationFailed = 6001. The numbers are chosen so that an upstream adoption of thesefour codes deletes
ProtocolErrorCodesand changes nothing for callers, which is whatdocs/upstream-candidates.md§ 1 promises; any other range gives that up for a collision risk thatis smaller but not gone, since CanKit allocates in 1000-blocks up to 9000 and could grow into
whatever we picked. What the finding is right about is that a collision would be silent — an
upstream 6002 would make
ProtocolTimeoutrender an unrelated name and compare equal to an unrelatedfailure. So it gets a tripwire test that fails on the CanKit bump which claims one of the four, while
that bump is still a one-line version change under review. Flagging this as the judgement call I am
least certain about: if you would rather relocate the numbers, that is a small follow-up.
#82 —
FilterOverlapFindOverlappingFilterSubscriptions()returnsIReadOnlyList<FilterOverlap>. The method isreplaced in place, no parallel method and no
[Obsolete], per ADR 0001 and your supersessioncomment on the issue — the additive route would have introduced the first
[Obsolete]member into aclean surface with no major version scheduled to remove it. The
[return: TupleElementNames]attribute is gone from the approved API surface.
The method keeps its name: the ADR entry names the tuple as the thing being replaced, the name is
accurate for what it returns, and renaming would churn every doc and call site for nothing. Say the
word if you want
FindFilterOverlaps()instead.The type carries the overlapping ID range, as the issue and your comment suggest:
LowestSharedId/HighestSharedId, exact for range filters and an inclusive hull for acceptance-maskfilters (which accept a scattered set — documented on the type). It also destructures to
(A, B), soforeach (var (a, b) in ...)in the docs keeps working.Computing the range is a generalisation of the search
Overlapsalready ran rather than a secondone: the bit walk now returns the smallest or largest satisfying ID instead of only whether one
exists, all four range/mask combinations reduce to one call of it, and
Overlapsdelegates — oneimplementation, not two that can drift. Rewriting that search is the riskiest edit in this PR, so it
is checked against a brute-force sweep of the entire 11-bit ID space for all 169 ordered pairs of
13 filters, on top of the existing overlap tests, which all pass unchanged.
Moved with the signature: five
ICanBusServicetest fakes,docs/getting-started.md,src/CanKit.Pro.Addressing/README.md, and the FR-RAW-041 rows in the SRS and arc42 documents.Verified-to-fail evidence
Every test added here was run against the unfixed code first. Nothing below passed before its fix.
Echo_Bus_Does_Not_Let_A_Resolved_Send_Consume_A_Later_Identical_Echosecond.Confirmedfalse (the echo went to the cancelled entry)Echo_Bus_Does_Not_Confirm_A_Standard_Send_From_An_Extended_Echo_With_The_Same_IdEcho_Bus_Does_Not_Confirm_A_Classic_Send_From_A_Can_Fd_Echo_With_The_Same_IdTwo_Subscriptions_Receiving_The_Same_Frame_Share_One_Payload_CopyEcho_Lookup_Does_Not_Copy_The_Payload_Of_Every_Echo_FrameRange_Rejects_Bounds_Outside_The_Standard_11Bit_SpaceRange_Rejects_Bounds_Outside_The_Extended_29Bit_SpaceMask_Rejects_A_Code_Requiring_A_Bit_Outside_The_Id_SpaceCallback_Subscribe_Reports_A_Handler_Failure_Through_OnError_For_A_Foreign_ServiceCallback_Subscribe_OnError_Takes_Precedence_Over_The_Service_Fault_EventOverlap_And_Reported_Range_Agree_With_A_Brute_Force_Sweep_Of_The_Id_Space,An_Overlap_Between_Mask_Filters_Reports_The_Hull_Of_The_Shared_Ids,FindOverlappingFilterSubscriptions_Reports_Overlapping_Registered_SubscriptionsTwo of these deserve their caveat stated rather than buried:
onErrortests cannot be compiled against the unfixed extension at all, since theparameter does not exist. They were verified by disabling the routing branch instead, which is the
behaviour they assert.
The_Borrowed_Protocol_Error_Codes_Are_Still_Unclaimed_Upstreamis a tripwire, not a regressiontest: there is no fix it could fail without, because nothing is being fixed. Its teeth were checked
by pointing it at 6001, which upstream does define — it fails with the message the real case
would print.
Gate
Run in this worktree on .NET SDK 10.0.112, at
3e9a8b2— i.e. after merging currentmain(
a723b3c, #97) into the branch, so these are the numbers for the merge result rather than for astale base. The merge was clean; #97 touches CANopen only.
459 tests. Before the merge the same gate read 449 passed at
1bba7d7, against 440 on the old base —nine of the ten added here, plus the tenth from #97's side of the merge;
dotnet packwas verifiedat
1bba7d7.No flaky failures were seen across the roughly fifteen full and partial suite runs this branch took
(#92), so nothing to report there.
tests/CanKit.Pro.Tests/ApiApprovals/CanKit.Pro.RawCan.approved.txtwas replaced with the generated.received.txtboth times it changed, never hand-edited.Checklist
dotnet build CanKit.Pro.sln -c Releasesucceedsdotnet test CanKit.Pro.sln -c ReleasepassesFR-RAW-014, FR-RAW-041, NFR-006, ADR-7, ADR 0001)
Closes #24
Closes #53
Closes #82
🤖 Generated with Claude Code
https://claude.ai/code/session_011Zd6AyAtcZApgfRC2Rkitj