Skip to content

fix(j1939): back off before Cannot Claim and a re-claim, fault a second claim in arbitration, and send without a pool hop - #153

Open
dborgards wants to merge 5 commits into
mainfrom
fix/j1939-node-collected-findings
Open

dborgards wants to merge 5 commits into
mainfrom
fix/j1939-node-collected-findings

Conversation

@dborgards

@dborgards dborgards commented Sep 22, 2026

Copy link
Copy Markdown
Owner

What does this change?

Closes #58 — the node findings; the transport ones landed on #152 and the docs bullet on #145.

  • Pseudo-random backoff (SAE J1939-81 §4.4.4.3): a node that lost arbitration sends its Cannot Claim, and an arbitrary-address node its next claim, after 0..153 ms derived from its NAME (the low byte of the bytes summed, times 0.6 ms) — so two nodes colliding on an address do not answer each other in lockstep. Scheduled on the actor, deterministic per NAME.
  • A second ClaimAddressAsync while one is in arbitration faults with InvalidOperationException instead of silently cancelling the first, whose caller is waiting on it. A pending claim its caller already cancelled — the cancel still on its way to the loop — is swept, so awaiting the cancellation and claiming again is race-free. The J1939: the self-traffic guard is bounded by the arbitration window, not by the transmission it exists for #121 test that pinned the silent replacement cancels the first claim explicitly now.
  • No pool hop per claim round: the claim and Cannot Claim sends were wrapped in Task.Run in front of a SendConfirmed whose continuation already runs off the loop — 240 hops for a full arbitrary-address scan. They are called directly.
  • J1939NodeOptions.TransportOptions no longer documents a factory overload taking a pre-built channel, which does not exist.

Type of change

  • feat — new behaviour (minor release)
  • fix / perf — bug or performance fix (patch release)
  • docs / test / refactor / chore / ci — no release
  • Breaking change (! in the title, plus a BREAKING CHANGE: footer explaining the migration) — a second claim in arbitration faulted the first before and faults the second now; a behaviour that was a defect, not a documented contract

Checklist

  • dotnet build CanKit.Pro.sln -c Release succeeds
  • dotnet test CanKit.Pro.sln -c Release passes (net10.0, all 959)
  • Public API changes are documented with XML comments — no API change; IJ1939Node.ClaimAddressAsync documents the backoff and the new exception
  • New behaviour is covered by a test — CannotClaim_Is_Sent_After_The_Names_Pseudo_Random_Backoff (a NAME chosen for a 78 ms backoff; the gap after the claim it lost to is a lower bound), A_Second_Claim_During_Arbitration_Faults_And_Leaves_The_First_Alone, An_Unseated_Node_Loses_The_Address_At_Once_And_Waits_Only_To_Reclaim, A_Claim_During_The_Backoff_Before_A_Reclaim_Faults_And_The_Reclaim_Completes, A_Delayed_Cannot_Claim_Is_Dropped_Once_A_New_Claim_Has_Started, A_Second_Loss_Waits_Its_Own_Full_Backoff_Before_Cannot_Claim, A_Request_During_The_Backoff_Starts_The_Round_With_A_Single_Announcement; each fails with its change reverted (mutation-checked). The pool hop is by construction.
  • The requirement or ADR this relates to is referenced — FR-J1939-003/004, SAE J1939-81 §4.4.4.3, J1939: collected minor findings (PGN normalization, CTS retransmit, claim delay, thread churn) #58

🤖 Generated with Claude Code

…nd claim in arbitration, and send without a pool hop

#58, the node findings. A node that lost arbitration answered at once,
so two nodes colliding on an address could answer each other in lockstep
for ever; the Cannot Claim, and an arbitrary-address node's next claim,
go out after SAE J1939-81 §4.4.4.3's pseudo-random 0..153 ms backoff --
the NAME's bytes summed, modulo 255, times 0.6 ms -- scheduled on the
actor. A second ClaimAddressAsync while one is in arbitration silently
cancelled the first, whose caller was waiting on it; it faults with
InvalidOperationException now, and a pending claim already cancelled by
its caller, whose cancel is still on its way to the loop, is swept so that
awaiting the cancellation and claiming again is race-free. The claim and
Cannot Claim sends went through Task.Run, a pool hop per round -- 240 for
a full arbitrary-address scan -- in front of a SendConfirmed whose
continuation already runs off the loop; they are called directly. And
J1939NodeOptions.TransportOptions no longer documents a factory overload
taking a pre-built channel, which does not exist. The #121 test that
pinned the silent replacement cancels the first claim explicitly now.
Mutation-checked: without the backoff the Cannot Claim follows the claim
it lost to by half a millisecond; with the second claim cancelling the
first, no exception is thrown.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@cursor

cursor Bot commented Sep 22, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Changes core J1939-81 address-claim timing and concurrent ClaimAddressAsync behavior (callers that overlapped claims now get InvalidOperationException), though claim state and bus traffic are still actor-serialized.

Overview
Implements SAE J1939-81 §4.4.4.3 address-claim backoff so Cannot Claim and arbitrary-address re-claims are delayed by a NAME-derived 0..153 ms (low byte of the eight NAME bytes summed, × 0.6 ms), reducing lockstep collisions when multiple nodes lose the same address.

ClaimAddressAsync no longer silently replaces an in-flight arbitration: a second call while the first is still pending throws InvalidOperationException (cancelled/completed claims are swept so cancel-then-reclaim stays safe). Losing arbitration or being unseated clears the address immediately; only the next wire announcement waits the backoff. Request for Address Claimed can start a backed-off round or reuse a single scheduled Cannot Claim instead of duplicating SA 0xFE frames.

Address-claim and fire-and-forget Cannot Claim sends drop the extra Task.Run before SendConfirmed (important for long arbitrary-address scans). Docs/XML note the backoff, the new exception, and that the node always opens its own TP channel.

Reviewed by Cursor Bugbot for commit fe277da. Bugbot is set up for automated code reviews on this repo. Configure here.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 22, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-22T18:07:39.143014Z fe277da New commits
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

Comment thread src/CanKit.Pro.J1939/J1939NodeImpl.cs Fixed
Comment thread src/CanKit.Pro.J1939/J1939NodeImpl.cs Dismissed
Comment thread src/CanKit.Pro.J1939/J1939NodeImpl.cs Fixed
Comment thread src/CanKit.Pro.J1939/J1939NodeImpl.cs Dismissed

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 568dd5e373

ℹ️ 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".

Comment thread src/CanKit.Pro.J1939/J1939NodeImpl.cs Outdated
Comment thread src/CanKit.Pro.J1939/J1939NodeImpl.cs Outdated
Comment thread src/CanKit.Pro.J1939/J1939NodeImpl.cs Outdated
@codecov

codecov Bot commented Sep 22, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 69.31818% with 27 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/CanKit.Pro.J1939/J1939NodeImpl.cs 69.31% 18 Missing and 9 partials ⚠️

📢 Thoughts on this report? Let us know!

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread src/CanKit.Pro.J1939/J1939NodeImpl.cs Outdated
Comment thread src/CanKit.Pro.J1939/J1939NodeImpl.cs
… through the backoff, and drop a Cannot Claim a new claim overtook

Codex and Bugbot on #153, on the backoff's three consequences. A node
unseated from a claimed address kept sending on it until the delayed
round ran; the address is invalidated the instant the winning claim is
heard, and only the next claim waits. The pending claim was cleared for
the backoff, so a second ClaimAddressAsync slipped past the in-flight
guard and was then overwritten; the claim in hand -- re-registered for
the next candidate -- stays through the backoff, and the delayed round
runs only if it still is that claim. And a loser that claimed again
before its delayed Cannot Claim went out still had it go out; it is
dropped once the node has left CannotClaim. The exhaustion test uses a
NAME whose backoff is zero -- the scan pays the backoff before each of its
240 rounds, and its subject is the exhaustion. Mutation-checked, one per
consequence: invalidating in the delayed round makes the reaction 152 ms
against a 75 ms bound; clearing the pending claim lets the second claim
through; the unguarded Cannot Claim goes out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8b1bf4cef4

ℹ️ 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".

Comment thread src/CanKit.Pro.J1939/J1939NodeImpl.cs Outdated
Comment thread src/CanKit.Pro.J1939/J1939NodeImpl.cs Outdated

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 8b1bf4c. Configure here.

Comment thread src/CanKit.Pro.J1939/J1939NodeImpl.cs Outdated
…ng-off round on a request or a contest

Codex and Bugbot on #153. The delayed Cannot Claim was guarded by state
alone, so a second loss inherited the remainder of the first's backoff;
the deadline is held, replaced by a later loss and cancelled by a new
claim, so each loss waits its own. And a round waiting its backoff was
treated as live arbitration: a Request for Address Claimed, or a
lower-priority peer claiming the candidate, made it announce at once,
without the round -- it announces by starting the round now, once, since
either is the moment to. Mutation-checked: without the deadline's
replacement the second loss's Cannot Claim goes out 45 ms after it; with
the request answered by a frame of its own, the candidate is announced
twice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 99cc8099d2

ℹ️ 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".

Comment thread src/CanKit.Pro.J1939/J1939NodeImpl.cs Outdated
…m, and read the lost address in the handler

Two findings on 99cc809 of #153.

Codex: the backoff was the NAME's byte sum modulo 255, which never reaches
the 153 ms endpoint the 0..153 ms range of SAE J1939-81 §4.4.4.3 documents
and maps a sum of exactly 255 to zero -- a claimant that answers at once
instead of last. The low byte of the sum (modulo 256) gives all 256 slots.
The backoff test now uses a NAME whose bytes sum to exactly 255 and expects
a gap of at least 148 ms, so the modulo-255 form fails it (found 0.5 ms);
the exhaustion test moves to a NAME summing to 256 to keep its zero backoff.

macOS CI: An_Unseated_Node_Loses_The_Address_At_Once_And_Waits_Only_To_Reclaim
read owner.Address after awaiting the Claiming event, and on a runner that
scheduled the continuation late the 150 ms backoff and the 80 ms round had
already re-claimed 0x80 (found 0x80, expected null). The handler now records
the address in the step that made the transition, and that is what the test
asserts on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 35c879935d

ℹ️ 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".

Comment thread src/CanKit.Pro.J1939/J1939NodeImpl.cs
…m already waiting

Codex on 35c8799 of #153: in CannotClaim state a Request for Address Claimed
took the default branch and sent a null-address claim at once, while the
backoff armed by the loss stayed armed and sent a second copy -- a scan both
bypassed the new collision-avoidance delay and got two announcements.

The delay matters for the answer in its own right: a Cannot Claim carries
source address 0xFE, so two nodes answering the same global request at the
same instant put identical CAN IDs with different NAME payloads on the bus,
which arbitration cannot separate. The answer now shares the loss's handle --
one waiting is the answer, none arms a fresh backoff -- and the state guard
moves from "is CannotClaim" to "is neither Claimed nor Claiming", so a node
that never claimed still answers a scan.

A_Request_During_The_Cannot_Claim_Backoff_Is_Answered_By_That_One_Frame pins
both halves: with the immediate send restored it sees two frames.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fe277dad5c

ℹ️ 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".

contendingSa: peerSa, contendingName: peerName);
SendAddressClaimFrame(sourceAddress: J1939Pgn.NullAddress);
ScheduleCannotClaim();
pending.Tcs.TrySetException(new J1939CannotClaimException(pending.PreferredAddress));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Complete the claim only after Cannot Claim is sent

When a non-arbitrary claim loses with a nonzero backoff, this faults ClaimAddressAsync immediately even though ScheduleCannotClaim has only armed a timer. If the caller disposes the node after catching J1939CannotClaimException—for example by leaving a using scope—the actor is disposed before the timer fires and the _disposed == 0 guard suppresses the frame entirely. This violates the documented contract that Cannot Claim is broadcast before the exception is thrown; keep the operation pending until the delayed send has at least been initiated.

Useful? React with 👍 / 👎.

This branch has not been deployed

No deployments
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.

J1939: collected minor findings (PGN normalization, CTS retransmit, claim delay, thread churn)

2 participants