Skip to content

Fix #113: a dead wire says so, the page re-dials on evidence, and subduction survives the rebind - #122

Merged
lannbot merged 5 commits into
mainfrom
fix/113-conn-gone
Aug 25, 2026
Merged

Fix #113: a dead wire says so, the page re-dials on evidence, and subduction survives the rebind#122
lannbot merged 5 commits into
mainfrom
fix/113-conn-gone

Conversation

@lannbot

@lannbot lannbot commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

Fixes #113 — all three expected-red scenarios are now green and promoted; the suite has no xfails left.

Three commits, three layers:

Engine: the gone marker (dc28186). Every connection past wire setup gets a monitor awaiting the iroh WIT's latched wait-closed; conn_results flips to Err("gone: …") — only ever clobbering an Ok (a handshake error is more informative and survives). conn-status's contract is documented in engine.wit: the gone: prefix is machine-readable. New headless gate just conn-gone: relay SIGKILL → gone on both sides in ~0.1s (the relay leg is a TCP websocket — synchronous; the QUIC idle timeout governs only no-socket-death shapes), latched.

Page: one wire-keeper (bad7ba9). resumeWire generalizes into the page's single standing keeper, entered from resumed boots and both ceremonies' finally. gone: is the only re-dial trigger (a missed death costs one 5s tick; an invented one costs #78's double-dial); rebind only on the Closed evidence a repair attempt itself raises; subscriptions die with their connection. Measured: peer-vanish detected ~35s (QUIC idle timeout + tick), relay-bounce wire live ~10s after the relay returns. one-sided-reload promoted to green (heal 37.8–38.1s ×3).

Engine again: the stale transport that could not fail (e66850d). The keeper exposed the residue: QueueTransport held its own channel ends so it could never fail; subduction's teardown fires only on transport failure; add_connection appends; sync_with_peer walks the list serially under our own NeverTimeout — parking forever on the corpse. Only the caller walks the list, which is the whole dialler/acceptor asymmetry. Fix: the gone monitor closes a dead connection's inbound queues (async-channel close drains first — nothing lost), and subduction's own removal machinery does the rest. Plus the review-caught race (conn dying between handshake success and the outcome write was latched alive forever) closed two ways. New headless gate just rebind-sync: red before (sync never settles either side), green after (settle 0.20s, cross 0.03–0.06s). relay-partition and relay-partition-asym promoted to green after three waves of red — engines converge 4.3–5.4s after the relay returns; banners keep the full history.

Flagged for follow-up, deliberately untouched: NeverTimeout (ours, not upstream) turns every slow peer into wedged-forever; a real bound deserves its own measured decision — issue to follow.

Gates: engine clippy -D warnings clean; conn-gone + rebind-sync ×3; full e2e 34/34; devstore matrix ALL REQUIRED ROWS PASS; soak seed 2/25 green. Two independent review rounds; all should-fixes applied.

Automerge armed (merge commit, org convention).

lann added 5 commits August 25, 2026 11:39
conn_results was write-once: the handshake outcome went in (wire error,
keyhive bad-peer, or Ok(peer)) and nothing ever observed the connection
dying afterwards -- iroh_writer/iroh_reader break out of their loops
silently on transport errors, and nobody called the iroh WIT's
wait-closed. A dead wire was indistinguishable from a healthy one, so
no page path could ever re-dial without risking the double-dial the
direction discipline forbids.

Now every connection that gets past wire setup has a fourth task:
conn_gone_monitor awaits conn.wait_closed() and writes Err('gone: ...')
into conn_results -- overwriting ONLY an Ok entry (a wire/handshake
error is more informative than 'gone' and survives; a missing entry
belongs to a handshake still settling). The gone message carries
close-info when the peer sent one; observed in practice it never does
('gone: transport closed (no close frame)') -- a peer whose relay died
sends nothing.

engine.wit's conn-status doc comment now spells the contract: Ok(Some)
= handshake ok and last known alive; Err starting with 'gone:' = the
wire came up and later died (latched; a re-dial mints a new conn id);
other Err = the handshake itself failed. The gone: prefix is a
machine-readable marker, not phrasing.

Measured (demo/host/conn-gone-check.ts, new committed gate + justfile
recipe): relay SIGKILL -> gone on both sides in ~0.1s, three runs. The
dispatch's idle-timeout theory was wrong for this case: the relay leg
is a websocket over TCP, so killing the relay closes the socket and the
endpoint learns synchronously; the QUIC idle timeout governs only the
no-socket-death shapes (blackhole, cut path). Both mechanisms belong to
the pinned endpoint; the probe's bound (20s) deliberately covers the
slow one.

What a TS caller sees through runtime/engine.ts: connStatus THROWS
'component error: gone: ...' -- match with includes('gone:'), not
startsWith.

Gates: engine just check (clippy -D warnings) clean; just compose;
conn-gone x3 green; solo-pairing device-pairing solo-resume-sync 3/3
(ceremony waits unregressed); relay-partition still red in the same
final act (the page half is what re-dials -- next commit).
…ence

The solo page acts on the engine's gone marker. The resumed-boot-only
resumeWire becomes the page's ONE standing wire-keeper, entered from
three places -- resumed boots, and both ceremonies' finally (a ceremony
that fails mid-flight is exactly the page that most needs the loop; the
success-only arming was a bug caught in development).

One tick: health-sweep the dial handle and the acceptor (one enqueue
slot each, ms-sized -- #115's discipline), where 'gone:' -- and ONLY
'gone:' -- clears that wire's state so the next tick re-dials or
reposts; a non-gone error goes to onError; settling is left alone. A
missed death costs one tick; an invented one costs #78's double-dial,
which is why gone is the single trigger and why the rebind fires only
on the Closed evidence a repair attempt itself raises (a rebind takes
every live connection with it -- never on suspicion). Subscriptions die
with their connection: forgetting a wire also forgets its tasksWired
entries, or the transport heals and zero bytes move (hit in
development, kept as a comment).

Measured along the way: a relay death latches the device's own
endpoint Closed, so the re-dial path runs rebindEndpoint first (same
address; the key pair is persisted). A vanished peer (its page
reloaded) is detected in ~35s -- ~30s QUIC idle timeout + one 5s tick
-- and re-dialled in under a second.

one-sided-reload: PROMOTED to green (was expected: red since wave 1).
The reader now learns its handle is stale and re-dials; heal measured
at 35s, three consecutive runs 37.8-38.1s. Banner rewritten as the
positive claim with the red history kept.
…rebind, and the relay-partition pins go green

The wire-keeper moved #113's residue into the engine: after a relay
bounce both sides rebound, re-dialled, and reported LIVE connections --
and the reader's sync-start still never settled. Four links, all read
in the pinned subduction source, none assumed:

1. QueueTransport held its own channel ends (lib.rs), so when
   iroh_reader/iroh_writer died with the wire, recv_bytes parked
   forever and send_bytes succeeded into a void -- a transport that
   CANNOT fail.
2. Subduction's teardown is driven entirely by transport failure
   (connection_loop's exit is the only path to remove_connection), so
   a transport that cannot fail is never removed.
3. add_connection APPENDS to the peer's connection list -- dead at
   index 0, live at index 1.
4. sync_with_peer walks that list serially under this engine's own
   NeverTimeout: the call on the corpse parks forever and the live
   connection is never reached. Only the CALLER of sync_with_peer
   walks the list -- an acceptor replies on the connection the request
   arrived on -- which is the whole dialler/acceptor asymmetry, and
   why one-sided-reload was green while the relay pins stayed red.

The fix tells the queues the truth: conn_gone_monitor now closes a
dead connection's inbound senders (subduction + keyhive wires, tracked
in new conn_inbound state) BEFORE the status write -- async-channel
close drains queued frames first, so nothing is lost -- and
subduction's own machinery does the rest. Also closed, from review:
the monitor race where a conn dying between handshake success and the
outcome write was latched alive forever (the monitor now waits for the
entry; the outcome write consults conn.state() as belt and braces).

demo/host/rebind-sync-check.ts (+ just rebind-sync) is the headless
gate that pinned it: red before (sync never settled on either side,
30s x forever), green after (settle 0.20s, todos cross both ways in
0.03-0.06s, three runs).

relay-partition and relay-partition-asym: PROMOTED to green after
three waves of red, banners carrying the whole history (wave 1: no
loop, no signal -- #113 filed; wave 2: the page wire-keeper, which
moved the gap; wave 3: this chain). Engines converge 4.3-5.4s after
the relay returns; the rendered-rows read is its own act now, bounded
-- the engines agreeing and the screens following are different
claims. Five consecutive green runs of both.

Left deliberately alone, flagged for its own issue: NeverTimeout, ours
not upstream, turns every slow peer into wedged-forever; a real bound
deserves its own measured decision.

Gates: engine just check (clippy -D warnings) clean; conn-gone and
rebind-sync green x3; full e2e 34/34 (no xfails left); devstore matrix
ALL REQUIRED ROWS PASS; soak seed 2/25 green. Two independent review
rounds; all should-fixes applied.
@lannbot
lannbot enabled auto-merge August 25, 2026 18:18
@lannbot
lannbot merged commit e9fed29 into main Aug 25, 2026
3 checks passed
@lannbot
lannbot deleted the fix/113-conn-gone branch August 25, 2026 18:27
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.

conn-status is write-once: a dead wire is indistinguishable from a healthy one, so no page path may ever re-dial

2 participants