Skip to content

Every relay dial carries a deadline; a stalling relay cannot pin the endpoint - #95

Merged
lannbot merged 1 commit into
mainfrom
dial-timeouts
Aug 23, 2026
Merged

Every relay dial carries a deadline; a stalling relay cannot pin the endpoint#95
lannbot merged 1 commit into
mainfrom
dial-timeouts

Conversation

@lannbot

@lannbot lannbot commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Every relay dial in the endpoint was unbounded (#93): a relay — or any middlebox — that accepts the TCP/websocket connection and then stalls the relay handshake pinned the dial forever. Per site: bind hung its caller; ensure_relay held the relay_opening claim so every later dial of that URL waited 30s and failed while the relay stayed permanently "opening"; the home-relay redial slot pinned, leaving the endpoint alive but home-dead until process restart.

Why it ships now

The dials were unbounded by ruling, not omission: bounding one means racing the connect against a timer and dropping the loser, and dropping an in-flight import future inside a live task wedged the polyengine host permanently (polymorph-components/polyengine#239). That chain is resolved as of polyengine 0.5.1:

  • polyengine#240 fixed the store-wide wedge;
  • A23 (polyengine#242) made a guest's cancellation of a host import a prompt discard — the drop returns immediately, the host promise's late settlement is discarded.

So resolution path 1 from #93 (“upstream fix first”) is taken: the timeout is a plain select, the shape written and reverted during the #88 work.

The fix

  • DIAL_TIMEOUT = 10s, matching upstream's relay connect timeout (iroh-1.0.3 src/socket/transports/relay/actor.rs, CONNECT_TIMEOUT) and sitting well under ensure_relay's 30s claim-wait, so a stalled dial resolves its waiters before they give up.
  • dial_relay races RelayConn::connect against the clock and drops the loser; bind, ensure_relay, and redial_home all dial through it. The deadline surfaces as the WIT timed-out variant; the dial's own failures stay connect-failed. A timed-out redial counts as a failed attempt and the pump rearms the next redial with backoff.
  • Doc repeals: the "do not add a timeout here" rulings on pump and redial_home, and the module doc's "never dropped mid-flight" claim, now state the A23 contract. The teardown resolve-before-return discipline is retained as hygiene, not a correctness requirement.
  • polyengine floor 0.5.1 (host-polyengine/deno.json caret floor, both deno.locks, the README pin note): under 0.5.0 the first timed-out dial would wedge the consumer's store, so the floor is correctness, not housekeeping. Protocol resolves to 0.2.3 in lockstep across both locks (the exam's identity gate asserts singularity).

#93's open item on the wasmtime host resolves by construction: the guest-side deadline bounds the dial regardless of host connect behavior, and on wasmtime the drop is a real cancellation of the host future. The matrix exercises that path — every successful dial now drops the losing timer future in-task.

Known residue (not this PR): on the polyengine host a timed-out dial's host-side connect keeps running until it settles naturally — A23's discard is delivery-only. The fix is marking polymorph:websocket's connect abortable() (A24, polyengine#243, which names this repo as the motivating consumer); that is a sibling PR to polymorph-websocket, queued next.

The regression guard

Exam scenario 8, "stalling relay": a stub that accepts TCP, drains, and never writes.

  • Leg Abind against the stub must reject timed-out inside [9.5s, 20s].
  • Leg B — a connect whose only path is a stalling foreign relay must reject timed-out in-window, and a second connect through the same relay must do the same — before this fix the first dial never released its claim, so the second waited out the 30s claim instead of dialing.
  • Leg C (owned relay only, scenario 7's gate) — with the real relay replaced by a stall stub on its port, the home-relay redial must keep redialing (≥2 accepts observed on the stub), and once the real relay returns a fresh connect + echo must succeed — the anti-pin assertion. No guest trap across any of the dropped dials.

Falsified against the unfixed endpoint: with the endpoint change stashed and the component rebuilt, scenario 8 fails both attempts with timed out after 30000 ms: leg A bind and the exam reports EXAM FAIL (1 failing scenario(s)).

Gates

  • just check (fmt, clippy, validate-wit, test): pass
  • just build: pass
  • just exam-polyengine: 9/9 scenarios pass with an owned relay — scenario 8: bind timed out at 10005 ms; leg B claim released; redial re-armed and recovery echo OK
  • just matrix: all 13 pairings pass

Fixes #93

…endpoint

A relay (or middlebox) that accepted TCP and then stalled the handshake
pinned all three dial sites forever: bind hung its caller, ensure-relay
held the relay-opening claim so the URL stayed permanently "opening",
and the home-relay redial slot pinned until process restart (#93). The
dials shipped unbounded because dropping an in-flight import future
in-task wedged the polyengine host (polymorph-components/polyengine#239);
that is fixed as of polyengine 0.5.1 (A23: a guest's cancel of a host
import is a prompt discard), so the timeout returns as the plain select
it always wanted to be.

- DIAL_TIMEOUT (10s) matches upstream's relay connect timeout
  (iroh-1.0.3 src/socket/transports/relay/actor.rs CONNECT_TIMEOUT) and
  sits under ensure-relay's 30s claim-wait. dial_relay races
  RelayConn::connect against the clock and drops the loser; all three
  sites use it. The deadline surfaces as the WIT `timed-out` variant;
  other dial failures stay `connect-failed`.
- The "do not add a timeout here" rulings on pump/redial_home (and the
  module doc's never-dropped-mid-flight claim) are repealed; the
  teardown resolve-before-return discipline stays as hygiene.
- polyengine floor 0.5.1 in host-polyengine (caret floor + both locks +
  the README pin note): under 0.5.0 the first timed-out dial would
  wedge the consumer's store, so the floor is correctness, not
  housekeeping.
- Exam scenario 8 is the gate: a stub that accepts and never writes;
  bind and two foreign-relay connects must fail timed-out inside the
  deadline window (the second connect proves the claim released), and
  the home-relay redial must keep redialing through the stall and
  recover once a real relay returns, with no guest trap across the
  dropped dials.

Known residue: on the polyengine host a timed-out dial's host-side
connect keeps running until it settles on its own (A23 discard is
delivery-only). The fix is marking polymorph:websocket's connect
abortable() (polyengine A24), a sibling PR.

Falsified against the unfixed endpoint: scenario 8 fails on both
attempts with `timed out after 30000 ms: leg A bind`.

Gates: just check, just build, just exam-polyengine (9/9, leg C
exercised with an owned relay), just matrix (13/13).

Fixes #93
@lannbot
lannbot enabled auto-merge August 23, 2026 19:09
@lannbot
lannbot merged commit 98db94b into main Aug 23, 2026
1 check passed
@lannbot
lannbot deleted the dial-timeouts branch August 23, 2026 19:15
lannbot pushed a commit that referenced this pull request Aug 23, 2026
Patch release carrying the relay-robustness line landed since 0.5.0:
home-relay failure is a redial rather than endpoint death (#91), every
relay dial carries a deadline (#95), and quiet relay wires are pinged
with unanswered pings retiring the connection (#97). No WIT or JS
surface change; the published @polyengine/runtime floor is already
^0.5.1 (A23 prompt-discard of dropped import futures, polyengine#239).

Gates: build-components + deno publish --dry-run clean; full CI on the
PR.
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.

endpoint: relay dials are unbounded — a relay that accepts and stalls pins bind, ensure-relay, and the home-relay redial

2 participants