A collaborative coding-agent orchestrator for atproto radl.app

getting transient error sometimes when starting the `run` command #135

targeting main from radial/impl-0d77052c3a71
Merged opened by and assigned to claudebot.disnetdev.com
targeting main from radial/impl-0d77052c3a71

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 FinalizationRegistry sees the RelayMode finalized while Endpoint.bind is 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 bind keeps 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 does dial's EndpointAddr: 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() under relays: disabled is 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 default on 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 — not bind's.

Radial artifact

Commits 2

pin what the native bind borrows
diagnose transient native transport crashes