Found and fixed the intermittent radiald run startup crash. It is a use-after-free at the one
native boundary in the repository, and it is now reproduced, fixed, regression-tested and documented.
What it was #
@number0/iroh@1.1.0 declares async fn bind(_, relay_mode: Option<&RelayMode>). The Rust future
borrows the box the JavaScript RelayMode owns and keeps running after the call has returned to
JavaScript, and napi holds nothing reachable on this side meanwhile. IrohTransport.bind wrote the
RelayMode straight into the argument list, so it was a temporary the instant the call was made:
a collection landing in the window frees the box under the running bind.
That is one fault wearing three signals — SIGSEGV, SIGBUS (the operator's darwin/arm64 report), and
SIGABRT out of the allocator (malloc(): unsorted double linked list corrupted) — always inside
bind, and gone on a rerun because the window is a few microseconds wide.
Evidence #
- A
FinalizationRegistrysees theRelayModefinalized whileEndpoint.bindis still pending. - Three arms over one script, 12 fresh starts each: passing the temporary crashed 11, holding it for the length of the call crashed 0, and a control that perturbed the same statement without holding anything crashed 9. So the difference is the reference, not the timing.
- Narrowed by elimination first: raw bind, ALPN length, ESM vs CJS load, immediate accept, and
endpoint close were each ruled out before the argument's lifetime was implicated. An async
method keeps its receiver alive (probed and confirmed); the static async
bindkeeps nothing. - 1.1.0 is the current release and the defect is in how the binding lends the argument, so there is
no version to move to. The reproducer is in the tree for a report to
n0-computer/iroh-ffi.
Changes #
packages/transport-iroh/src/index.ts—pinned()holds a native object in a module-level set for the length of a native async call.bind's relay mode goes through it (confirmed), and so doesdial'sEndpointAddr: the same borrow, never observed being collected, pinned by the same rule rather than by trusting a signature difference. Each call pins a holder of its own, so overlapping calls handed one object cannot unpin each other.packages/transport-iroh/test/argument-lifetime.test.mjs+test/bind-once.mjs— the regression. The failure is a signal, not an exception, so each start is a fresh child and the assertion is that it exited rather than died. The fixture died in 9 of 12 starts pre-fix and 0 of 12 after; the test runs five starts (~0.4 s). The fixture's comment says why its shape is load-bearing — the window only opens on the first bind of a fresh process, and "tidying" it stops it reproducing.packages/transport-iroh/test/startup-probe.mjs— the probe hung on the first successful start, which was invisible while every start crashed:online()underrelays: disabledis deliberately abandoned when the timeout wins its race and goes on holding libuv open. The child now exits on a clean close, the race's timer is cleared, and the parent kills and reports a start that neither exits nor dies (--start-timeout-ms, default 120000) instead of wedging the whole run.docs/adr-private-mode-iroh.md§32,packages/transport-iroh/README.md,packages/daemon/README.md— the rule ("a native object handed to a native async call is pinned for the call; do not re-derive which signatures are safe"), the evidence, and the operator-facing resolution. The daemon's troubleshooting section now names the fixed fault and keeps the procedure for characterising a new one, plus a caveat the report itself illustrates: under pnpm the daemon's stdout is a pipe, Node writes to a pipe asynchronously, so the last phase logged is a lower bound.
Nothing touches the wire, the fold, the endpoint identity, or the dependency pin. No protocol version bump: an endpoint that survived its bind was always correct, and one that did not never reached a peer.
Checks #
pnpm lint, pnpm test (core 410, daemon 596, ui 93 + 860, sidecar 126, ingest 49, atproto 35,
transport-iroh 7, transport-iroh-wasm 13 — 0 failures), pnpm build, and pnpm codegen clean.
Beyond the suite, on Linux/arm64 with Node 24: the fixture 0 crashes in 20 starts; the probe 25/25
with relays disabled and 8/8 with default relays, reaching a real n0 relay and reporting
online: true.
Remaining risks #
- Verified on Linux/arm64 only. The operator's darwin/arm64 signal (SIGBUS) is the same fault, but the
before/after counts above are from this container — worth one confirming run of
pnpm --filter @radial/transport-iroh probe -- --iterations 100 --relays defaulton the affected Mac before considering the report closed. - The defect is upstream and unreported. Radial is safe without a fix landing there, but any new call
handing the binding a native object needs
pinned(); the READMEs and ADR §32 say so, and nothing mechanically enforces it. dial's pin is precautionary. A finalizer probe never caught that address being collected, so if a reviewer wants the smallest possible change, that is the line to argue about — notbind's.