Skip to content

Fix #123: every subduction call gets a real deadline, and a silent handshake closes its own wire - #124

Merged
lannbot merged 1 commit into
mainfrom
fix/123-real-timeout
Aug 26, 2026
Merged

Fix #123: every subduction call gets a real deadline, and a silent handshake closes its own wire#124
lannbot merged 1 commit into
mainfrom
fix/123-real-timeout

Conversation

@lannbot

@lannbot lannbot commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

Fixes #123.

NeverTimeout is retired. MonotonicTimeout races every subduction call against wasi:clocks/monotonic-clock@0.3.0's async wait-for — the composite already imported the interface (iroh.wit pulls it; @polyengine/wasi serves it in both hosts), so the guest world import is the only surface change. Every call site already passed CallTimeout::Default = the crate's 30s roundtrip bound. Abort is cancel-safe by subduction's own design (the multiplexer's PendingGuard; ingestion only in the success arm — cited and verified against the pinned source). Happy-path overhead: not measurable (rebind-sync settle 0.20s / cross 0.03s, unchanged).

Also bounded: the keyhive handshake's initiate side (30s; the responder's 300s anti-replay window is a different fact, untouched). A timed-out dial closes its connection explicitly — the peer being alive means nothing else ever would — and the close hands teardown to the ordinary conn_gone_monitor chain; the named timeout error outranks gone: in conn_results. A TimedOut outcome is deliberately NOT gone:: silent peer, live wire, no re-dial (#78 discipline; noted in the keeper and the wave-3 banner).

Plus the leak the fix would have created: with a real bound, abandoned sync handles now insert outcomes nobody reads (sync-status is one-shot) — capped at 256 with insertion-order eviction (~5min horizon vs the page's 30s read horizon).

New headless gate just timeout-check: control 0.20s; blackholed peer → bounded named outcome at ~30s. Its banner is honest that it cannot discriminate the call bound from the QUIC idle timeout (both 30s); the property it pins is bounded-not-parked, which is what #123 names.

Gates: engine clippy -D warnings clean; conn-gone/rebind-sync unchanged ×3; timeout-check ×3; full e2e 34/34 (relay-partition heal 5.3s, no regression); devstore ALL REQUIRED ROWS PASS; soak seed 2/25 green. Independently reviewed; the should-fix (the connection leak on a timed-out dial) applied.

Automerge armed (merge commit, org convention).

…ndshake closes its own wire

NeverTimeout -- the Timeout impl that returned Ok(fut.await), no bound
ever -- is retired. It co-conspired in the #113 hang (a sync call
parked forever on a dead-but-unfailable transport; #122 removed that
corpse) and still converted every reachable-but-silent peer into
wedged-forever: the page's own 30s waits masked the UX, but every
abandoned sync left an engine task parked for the life of the page,
holding Arc<Sd> and connection Rcs.

MonotonicTimeout races each call against wasi:clocks/monotonic-clock
@0.3.0's wait-for (the async track; 0.2.9's pollable shape is not
awaitable from this guest). The composite already imported the
interface -- iroh.wit pulls it, and @polyengine/wasi serves waitFor on
the 0.3 union provider in both hosts -- so the guest world import
(engine.wit) is the only surface change. Every call site already
passed CallTimeout::Default = the crate's 30s roundtrip bound;
smoke-measured 100ms->109ms, 250ms->252ms; happy-path overhead not
measurable (rebind-sync settle 0.20s, cross 0.03s, unchanged).

Abort is safe by subduction's own design: the multiplexer's
PendingGuard removes the pending entry when the call future drops, and
ingestion/subscription happen only in the success arm (cited at the
impl, verified against the pinned source). A TimedOut outcome is NOT
'gone:' -- the wire is alive, the peer is silent -- and the page
keeper's leave-alone arm deliberately does not re-dial on it (#78's
double-dial discipline; one-sentence notes in solo.ts and
relay-partition.ts's wave-3 history).

The keyhive handshake sat outside subduction's Timeout and parked the
same way; the initiate side now carries its own 30s bound (the
responder's 300s anti-replay window is a different fact, untouched).
A timed-out dial CLOSES its connection explicitly -- this is by
definition the case where the peer is alive, so nothing else ever
would: the close resolves wait-closed, which wakes conn_gone_monitor,
which closes the conn_inbound queues and finds the named timeout Err
already in conn_results and leaves it (a named error outranks gone:).
One teardown mechanism, the ordinary one.

And the leak the fix would have created: with a real bound every
abandoned handle INSERTS an outcome nobody will read (sync-status is
one-shot; the page polls 30s then gives up). SYNCS_CAP=256 with an
insertion-order deque evicts oldest -- eviction horizon ~5min at the
demo's 48 settles/min against the page's 30s read horizon.

demo/host/timeout-check.ts (+ just timeout-check): control settles
0.20s; a blackholed peer produces a BOUNDED named outcome at ~30s.
The probe's banner is honest that it cannot discriminate the call
bound from the QUIC idle timeout (both 30s; measured 30.03s fixed vs
30.20s reverted) -- true live-wire-silent-peer needs a cooperating
mute peer, which would mean test-only WIT surface on a shipping world.
The property it pins is bounded-not-parked, which is what #123 names.

Gates: engine just check (-D warnings) clean; conn-gone + rebind-sync
unchanged (x3); timeout-check x3; full e2e 34/34 (relay-partition
converge 5.3s, no regression); devstore ALL REQUIRED ROWS PASS; soak
seed 2/25 green. Independently reviewed; should-fix applied.
@lannbot
lannbot enabled auto-merge August 26, 2026 01:32
@lannbot
lannbot merged commit 1a5bd2e into main Aug 26, 2026
3 checks passed
@lannbot
lannbot deleted the fix/123-real-timeout branch August 26, 2026 01:41
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.

NeverTimeout turns every slow peer into wedged-forever: subduction calls need a real bound

2 participants