You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
One defect class, found six times by hand. This issue states it once, tracks every issue and PR that addresses it, and defines what "done" means — so the individual PRs can stop restating the background and say only what they do.
The class
P — at every point where control escapes into unknown code (a dyn Fn, an Arc<dyn Fn>, an extern "C" fn), the calling thread holds no lock.
Violating it deadlocks whenever the callback re-enters. It is not a race: one thread, one call stack, reproduces every time the path runs, and adding logging changes nothing. A test that merely reaches the call site is enough to expose it — once something is watching.
Every fix in this family is the same rewrite:
acq · callout · rel ⟶ acq · rel · callout
Collect, release, call. That is why the six fixes look alike.
Methodology
Fixing six instances by hand does not close the class — the seventh is written next week. So the series is in two halves:
Fix the instances — one PR per defect, each with its own reproduction.
Siblings — a different defect class, found by the same reading
#292 and #293 are not re-entrancy. They came out of auditing this family's event paths and are recorded here so the audit's coverage is visible, not because the class fix touches them.
Their shape is declared-but-never-raised: rmw_event_type_is_supported returns true, rmw_event_set_callback installs a callback, rmw_take_event fills the status — and nothing ever raises the event, so the callback cannot fire and the status is permanently zero.
Audit of all 11 event types, verified in both directions (raise sites counted with a multi-line-aware search after a same-line grep undercounted three times):
#292's fix depended on this family. It raises through update_shared_event_status, which #260 introduced — raising it on main beforehand would have meant a callout under the manager lock, i.e. planting a fresh instance of the very defect #282 tracks. Resolved:#260 merged, #294 is retargeted to main and boundary-rebased, and the full CI suite now runs on it for the first time (it had only ever run the title check while its base was a branch).
#287 is the residual #260 left behind. It is not a latent pre-existing bug; that PR's fix shape creates it, and the same PR closes the analogous window for guard conditions by taking owned Arcs — which the event path cannot do, because no Arc can own a C pointer.
open, targets main, merge-base ae4f0f8f. Head 8724f1ae; CI re-running after a docs-only commit. Reviewed 2026-08-06 — one open tradeoff, see PR. Residuals split out to #296 and #302
#255 defined the tracked lock types the other three consume, so it was a genuine keystone — #257, #260 and #262 could not compile without it. It merged on 2026-08-04, so the graph has collapsed to:
✅ #255 ✅ #253 ✅ #257 ✅ #260 merged
⬜ #250 open, already rebased onto ae4f0f8f
⬜ #262 open, TWO BLOCKERS — see the PR
⬜ #294 open, sibling of #292
#260 merged 2026-08-06 as ae4f0f8f. It re-staled #250 and #262; both have since been rebased onto it and their merge-base is ae4f0f8f.
⚠️#294 needed a boundary rebase, not a plain one. It was based on #260's branch, and #260 was squash-merged — so a plain rebase would have replayed #260's seven commits onto the squashed result. git rebase --onto origin/main <260-tip> replayed only #294's own five. Its diff dropped from 1363 lines to 372, which is the check that the boundary was right. Same technique as the #255 case below.
#262 is no longer stacked — it targets main directly. The only remaining ordering is weak: #262 and #260 share crates/rmw-zenoh-rs/src/rmw.rs, so whichever lands second needs a rebase. There is no type dependency; #262 compiles on main without #260.
All three have now had an adversarial review.#250's (2026-08-06) left one open decision — the TransientLocal path replaces transport backpressure with an unbounded queue, which its breaking-change list described as bounded. Stated in the PR as an explicit ship-or-block rather than resolved.
⚠️Every merge re-stales the rest.MERGEABLE means "no conflicts", never "up to date", and a green run against the previous base proves nothing. After each merge the remaining PRs need a rebase and a fresh full CI run. This is not pedantry. #250 silently reverted #271. Nobody could see it until the branch was rebased, because git computes a stale branch's diff against a merge base where the regression does not yet exist.
⚠️#226 collides with #260 in crates/hiroz/src/graph.rs. It is not part of this family (a different startup-deadlock mechanism), but whichever of the two lands second needs a rebase.
⚠️ Merging the keystone auto-closed two of its children
Recorded because it cost real time and the recovery is not obvious.
pr/1-reentrancy-tripwire was deleted when #255 merged, and GitHub auto-closed every PR that targeted it — #257 and #260, unmerged. Two deadlock fixes silently left the tree: main gained reentrancy.rs but parameter/service.rs still had no tracked lock, and nothing reported the gap.
The rule: retarget stacked children to main BEFORE merging the parent, not after.
Recovery, if it happens again. GitHub refuses to retarget a closed PR. It also refuses to reopen a PR whose base branch is deleted. So restore the base first, from the permanent PR head ref:
git push origin refs/pull/<parent>/head:refs/heads/<deleted-base>
gh pr reopen <N>&& gh pr edit <N> --base main
git push origin --delete <deleted-base># the PRs survive this
Rebasing them then needs a boundary, not a plain rebase.#255 was squash-merged, so main holds one commit where the branch held eight; git rebase origin/main replays all eight onto the squashed result and conflicts immediately on the keystone's own first commit. Replay only each PR's own commits:
git rebase --onto origin/main <keystone-branch-tip>
Both rebased with zero conflicts. Each PR's diff came back at exactly its pre-close size.
Run that size check after every rebase. A mismatch means the boundary was wrong and foreign commits came along.
(Resolved: #262 was retargeted to main and rebased; all three are green on af00f1ed.)
Verification gate — per PR, before merge
The issue's reproduction fails on main and passes on the branch. Green CI proves a change is harmless, not that it fixes the issue.
Full CI on a main-based branch — 27 checks, not 4.
No vacuous suites. Several re-entrancy tests are behind feature flags; without them the binaries are empty and report 0 passed. A skip is a failure unless explicitly justified.
Definition of done
Merging all six does not close this issue. The monitor only sees lock types that were converted, and today it would see six of the eleven files that both take a lock and call through an opaque callee. Five files holding 27 acquisitions remain uninstrumented.
Spot-checked, those five are currently correct — each scopes its guard so it drops before the callout. That is the point: correctness there rests on someone having remembered the braces, with nothing that fails when the braces are removed. Which is exactly the state main was in before these six defects were found by hand.
Closing this issue requires #283 — convert the remaining files, and add a CI gate that fails when a file becomes callback-reachable without being instrumented. Its acceptance criteria live there; they are not repeated here, because two copies of a checklist drift and both look maintained while they do.
#283 is blocked by the six PRs above — it conflicts with all of them.
#284, #285, #287, #288 and #289 do not block closing this issue. They are the honest remainder: what stays true after the class is made unrepresentable. Each is tracked separately so that closing this one does not quietly close them — which is the whole reason an instance fix is never allowed to close a meta issue.
Known limitation, stated up front
The monitor sees hiroz-owned locks. A callout made while holding a zenoh, std or other third-party lock still self-deadlocks and is invisible to it. This is not hypothetical: the equivalent open bug in the C++ implementation is a library-internal wait, where undeclaring an entity blocks on in-flight callbacks of the very callback that is running.
So the completed work should be read as: this class cannot recur on any instrumented path — not as full coverage.
Tracked as #284, which is the largest residual once #283 lands, and the one neither a runtime counter nor a compile-time encoding reaches: it is closed by widening the definition of the event being guarded — from "control escapes to unknown code we were handed" to "control escapes anywhere it may re-enter us, or block on a callback we are running" — not by strengthening the mechanism that guards it.
One defect class, found six times by hand. This issue states it once, tracks every issue and PR that addresses it, and defines what "done" means — so the individual PRs can stop restating the background and say only what they do.
The class
Violating it deadlocks whenever the callback re-enters. It is not a race: one thread, one call stack, reproduces every time the path runs, and adding logging changes nothing. A test that merely reaches the call site is enough to expose it — once something is watching.
Every fix in this family is the same rewrite:
Collect, release, call. That is why the six fixes look alike.
Methodology
Fixing six instances by hand does not close the class — the seventh is written next week. So the series is in two halves:
Issues and PRs
One issue per role; each states its thing once and references rather than restates.
rmw_event_set_callback(.., null)and use freeduser_dataffifeature is built by no crate on the PR gate; its re-entrancy detector was deletedbaseline + NRMW_EVENT_MESSAGE_LOSTis plumbed and never raisedSUBSCRIPTION_/PUBLISHER_INCOMPATIBLE_TYPEdeclared supported and never raisedWhat the residuals have in common
Fixing the deadlock does not finish the job, and each residual is a distinct way of not being finished:
Arcowns the closure; nobody owns the C pointer it captured.fficode already reached the gate unnoticed.Siblings — a different defect class, found by the same reading
#292 and #293 are not re-entrancy. They came out of auditing this family's event paths and are recorded here so the audit's coverage is visible, not because the class fix touches them.
Their shape is declared-but-never-raised:
rmw_event_type_is_supportedreturnstrue,rmw_event_set_callbackinstalls a callback,rmw_take_eventfills the status — and nothing ever raises the event, so the callback cannot fire and the status is permanently zero.Audit of all 11 event types, verified in both directions (raise sites counted with a multi-line-aware search after a same-line grep undercounted three times):
SUBSCRIPTION_MATCHED,PUBLICATION_MATCHEDREQUESTED_QOS_INCOMPATIBLE,OFFERED_QOS_INCOMPATIBLEMESSAGE_LOSTSUBSCRIPTION_INCOMPATIBLE_TYPE,PUBLISHER_INCOMPATIBLE_TYPETODO) — #293LIVELINESS_CHANGED/_LOST, bothDEADLINE_MISSEDNone)#292's fix depended on this family. It raises through
update_shared_event_status, which #260 introduced — raising it onmainbeforehand would have meant a callout under the manager lock, i.e. planting a fresh instance of the very defect #282 tracks. Resolved: #260 merged, #294 is retargeted tomainand boundary-rebased, and the full CI suite now runs on it for the first time (it had only ever run the title check while its base was a branch).#287 is the residual #260 left behind. It is not a latent pre-existing bug; that PR's fix shape creates it, and the same PR closes the analogous window for guard conditions by taking owned
Arcs — which the event path cannot do, because noArccan own a C pointer.main, merge-baseae4f0f8f. Head8724f1ae; CI re-running after a docs-only commit. Reviewed 2026-08-06 — one open tradeoff, see PR. Residuals split out to #296 and #302get_stateduring its own transition callbackon_setcallback deadlocksae4f0f8f)main, ✅ 29 checks green oncfaa2725, 0 failedRelated but not this class, tracked here only to avoid confusion:
Dependency structure
#255 defined the tracked lock types the other three consume, so it was a genuine keystone — #257, #260 and #262 could not compile without it. It merged on 2026-08-04, so the graph has collapsed to:
#260 merged 2026-08-06 as
ae4f0f8f. It re-staled #250 and #262; both have since been rebased onto it and their merge-base isae4f0f8f.git rebase --onto origin/main <260-tip>replayed only #294's own five. Its diff dropped from 1363 lines to 372, which is the check that the boundary was right. Same technique as the #255 case below.#262 is no longer stacked — it targets
maindirectly. The only remaining ordering is weak: #262 and #260 sharecrates/rmw-zenoh-rs/src/rmw.rs, so whichever lands second needs a rebase. There is no type dependency; #262 compiles onmainwithout #260.All three have now had an adversarial review. #250's (2026-08-06) left one open decision — the TransientLocal path replaces transport backpressure with an unbounded queue, which its breaking-change list described as bounded. Stated in the PR as an explicit ship-or-block rather than resolved.
MERGEABLEmeans "no conflicts", never "up to date", and a green run against the previous base proves nothing. After each merge the remaining PRs need a rebase and a fresh full CI run. This is not pedantry. #250 silently reverted #271. Nobody could see it until the branch was rebased, because git computes a stale branch's diff against a merge base where the regression does not yet exist.crates/hiroz/src/graph.rs. It is not part of this family (a different startup-deadlock mechanism), but whichever of the two lands second needs a rebase.Recorded because it cost real time and the recovery is not obvious.
pr/1-reentrancy-tripwirewas deleted when #255 merged, and GitHub auto-closed every PR that targeted it — #257 and #260, unmerged. Two deadlock fixes silently left the tree:maingainedreentrancy.rsbutparameter/service.rsstill had no tracked lock, and nothing reported the gap.The rule: retarget stacked children to
mainBEFORE merging the parent, not after.Recovery, if it happens again. GitHub refuses to retarget a closed PR. It also refuses to reopen a PR whose base branch is deleted. So restore the base first, from the permanent PR head ref:
Rebasing them then needs a boundary, not a plain rebase. #255 was squash-merged, so
mainholds one commit where the branch held eight;git rebase origin/mainreplays all eight onto the squashed result and conflicts immediately on the keystone's own first commit. Replay only each PR's own commits:Both rebased with zero conflicts. Each PR's diff came back at exactly its pre-close size.
Run that size check after every rebase. A mismatch means the boundary was wrong and foreign commits came along.
(Resolved: #262 was retargeted to
mainand rebased; all three are green onaf00f1ed.)Verification gate — per PR, before merge
mainand passes on the branch. Green CI proves a change is harmless, not that it fixes the issue.main-based branch — 27 checks, not 4.0 passed. A skip is a failure unless explicitly justified.Definition of done
Merging all six does not close this issue. The monitor only sees lock types that were converted, and today it would see six of the eleven files that both take a lock and call through an opaque callee. Five files holding 27 acquisitions remain uninstrumented.
Spot-checked, those five are currently correct — each scopes its guard so it drops before the callout. That is the point: correctness there rests on someone having remembered the braces, with nothing that fails when the braces are removed. Which is exactly the state
mainwas in before these six defects were found by hand.Closing this issue requires #283 — convert the remaining files, and add a CI gate that fails when a file becomes callback-reachable without being instrumented. Its acceptance criteria live there; they are not repeated here, because two copies of a checklist drift and both look maintained while they do.
#283 is blocked by the six PRs above — it conflicts with all of them.
#284, #285, #287, #288 and #289 do not block closing this issue. They are the honest remainder: what stays true after the class is made unrepresentable. Each is tracked separately so that closing this one does not quietly close them — which is the whole reason an instance fix is never allowed to close a meta issue.
Known limitation, stated up front
The monitor sees hiroz-owned locks. A callout made while holding a zenoh,
stdor other third-party lock still self-deadlocks and is invisible to it. This is not hypothetical: the equivalent open bug in the C++ implementation is a library-internal wait, where undeclaring an entity blocks on in-flight callbacks of the very callback that is running.So the completed work should be read as: this class cannot recur on any instrumented path — not as full coverage.
Tracked as #284, which is the largest residual once #283 lands, and the one neither a runtime counter nor a compile-time encoding reaches: it is closed by widening the definition of the event being guarded — from "control escapes to unknown code we were handed" to "control escapes anywhere it may re-enter us, or block on a callback we are running" — not by strengthening the mechanism that guards it.