Skip to content

Long-lived experiment: the upstream iroh crate on components - #25

Draft
lann wants to merge 16 commits into
mainfrom
upstream-iroh
Draft

Long-lived experiment: the upstream iroh crate on components#25
lann wants to merge 16 commits into
mainfrom
upstream-iroh

Conversation

@lann

@lann lann commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

The standing branch for validating the upstream-iroh-crate-on-components route from #14 — long-lived by design, not for merging in this shape. Vendored/pinned upstream deps live here while validation continues and must never reach main: the endgame is upstreaming the wasi patches (as n0 already did for noq-udp in noq#773, convergent with ours) and porting only the minimal glue main needs.

Currently proven on this branch

  • Relay-only iroh on wasip2 under jco/JSPI (was Run the unmodified iroh crate relay-only on wasip2, relaying over polymorph-websocket #24; its review record still applies): the unmodified iroh crate — pinned to upstream main (735958f8) — running relay-only against a stock relay server, the relay websocket carried by the polymorph-websocket host module and delivered as synthetic datagrams. Connect-through-relay 23–30ms, echo RTT p50 ~0.5ms, guest 7.2MB. just iroh-relay-ws.
  • Live relay→WebRTC migration via the synthetic-address overlay (f4a5200; the Consider splitting the synthetic-address overlay approach into a generic project (polymorph-overlay) #26 approach): stock iroh IP transports — no custom transport, no unstable features — with the host bridge assigning each endpoint a synthetic address whose datagrams travel over a real node-datachannel unreliable/unordered channel. Endpoint::add_external_addr feeds the address into the NAT-traversal candidate exchange on the live relay connection; the holepunch probes are terminated by the shim (no NAT exists); the path validates and the selector migrates. One connection, one stream: relay p50 ~0.5ms → migration 51–53ms after the address is handed over → webrtc p50 0.53–0.54ms with a tighter tail than the relay (max 0.8–4ms vs 6.7–7.1ms), relay retained as backup. Total run ~4s.
  • The same migration in a real browser (dfb2139): identical component, shim, and bridge JS under Chromium/Playwright — browser WebSocket to the stock relay, browser RTCPeerConnection for the channels, JSPI stable with no flags. Channel pairing 36ms (browser ICE vs node-datachannel's ~1s), migration 52–53ms (identical to Node), phase 2 p50 0.39–0.45ms — faster than the browser's own relay path (~0.74ms). npm run test:browser.
  • WebRTC data channels as a CustomTransport (c2b4da4, superseded by the overlay route but the findings stand): QUIC end-to-end over the same channels via unstable-custom-transports. The upgrade model's migrate-mid-connection leg is upstream-impossible today for custom addrs — they are excluded from the NAT-traversal candidate exchange and nothing else opens paths on live connections; findings comment below has the full mechanism inventory. That demo reconnected after iroh's 60s per-remote path memory expiry (relay p50 ~0.5ms → webrtc p50 0.73–0.81ms).
  • A public two-browser demo on GitHub Pages with file drop (2eb6d8b, deployed at https://polymorph-components.github.io/polymorph-iroh/): visiting hosts a session and shows a QR; scanning joins; taps ripple on both canvases. Bootstraps over the n0 public relays (stock wire compat in production) with real cross-page signaling ferried over the authenticated iroh connection, then live-migrates onto the data channel — verified against the deployment: relay 530–760ms through a distant public relay → direct 1.2–30ms, pings mirrored both ways (web/live-check.mjs; local CI-able harness in web/test.mjs). Chromium/Android only (JSPI); sessions are single-pair. The "+" button / drag-and-drop sends files through stock iroh-blobs (MemStore, bao-verified) over a blobs-ALPN connection riding the migrated path — verified live at 8.7 MiB/s through the deployment.
  • Stock iroh-blobs on the stack (e43295a): unmodified iroh-blobs 0.103 (MemStore) compiled and ran on wasm32-wasip2 first try — bao-verified transfers through the stock BlobsProtocol/Router, fetched over the relay, then over the live-migrated data channel on the same connection. In-guest blake3 hashing 392–579 MiB/s; transfer 6–9 MiB/s with the per-datagram JS bridge hop as the plateau (browser native SCTP: direct 9.3 > relay 6.0; node-datachannel inverts it). The ecosystem-compatibility answer to Upstream iroh crate as endpoint core: feasibility and the ecosystem-compatibility ruling #14's driving question, now empirical. experiments/iroh-blobs/.
  • Stacked on Wake a parked tokio reactor from JS through a synthetic wasi:sockets shim #18's probes (single + wac-composed virtualization): the wake pattern everything above rides on.

Delta inventory vs upstream (the future upstreaming worklist)

crate source local delta
iroh git main 735958f8, re-vendored net_report HTTPS probe short-circuit (home-relay selection gates on it)
iroh-relay same rev (path dep) wasm_wasi connect() + DatagramPipe (the relay-dialer seam, concretely)
iroh-dns same rev (path dep) wasi arm for the missing system resolver config
netwatch vendored 0.19.1 wasi → netdev-free posix_minimal routing (upstream PR should target main's new netdev alias); UdpSocket::drop closes inline on posix_minimal (spawn_blocking panics on wasi, no worker threads)
noq family git f1ae905 (noq#773) none

Next validation targets

  • Overlay hardening toward Consider splitting the synthetic-address overlay approach into a generic project (polymorph-overlay) #26 (random-prefix ULA instead of 100.64/10, per-peer dynamic routes, keep-the-fiction-out-of-discovery knob, loss/MTU measurements on lossy channels)
  • Upstream contribution: mid-connection custom-path opens (for native iroh users without a controllable network surface: either custom addrs in the QNT candidate exchange, or a public open-path API — the internal machinery exists but is unreachable; see findings comment)
  • Identity/webcrypto split (SecretKey out of linear memory: rustls SigningKey seam + relay handshake + record signing)
  • Size/perf budgets per the repo's measured-claims policy

lann added 4 commits August 6, 2026 08:05
…ymorph-websocket

The spike issue #14 and PR #18 point at: upstream iroh v1.0.3 as a
wasm32-wasip2 component under jco/JSPI, relay connectivity only, with the
relay websocket owned by the host through the polymorph-websocket host
module — the same browser-first JS that serves the WIT package — and
delivered to the guest as datagrams on a synthetic UDP socket, so the
unmodified tokio reactor parks and wakes exactly as in the udp-wake
probes. iroh-relay's wasi branch replaces WsBytesFramed with a
DatagramPipe (one datagram = one ws message = one relay frame) behind a
tiny control protocol to the bridge at 127.0.0.1:1; the in-band
challenge-response handshake and everything above it — relay frames, QUIC
(noq + rustls/ring, RFC 7250 raw public keys), streams — runs unmodified
in-guest against a stock iroh-relay server.

Vendored wasi-enablement patches (cargo-vendored sources, iroh untouched
except one probe): noq-udp routes wasi to posix_minimal with a
non-vectored recv_from (functional single-datagram UDP over wasi:sockets,
not a stub); netwatch routes wasi to its netdev-free posix_minimal
branch; iroh-dns tolerates the missing system resolver config; iroh-relay
gains the wasm_wasi connect(); iroh short-circuits the net_report HTTPS
relay probe on wasi (no HTTP client path exists; the websocket dial
itself proves reachability), because home-relay selection gates on it.

Measured (Node 24.18, two endpoints in one component, stock relay,
--dev, loopback): dial-through-relay 23-30ms; echo RTT over the relay
p50 ~0.5ms, p90 1.0-1.9ms (50 rounds, 512B); guest component 7.3MB
(opt-level=s + lto). Shutdown logs an expected burst of
relay_recv_channel-closed errors after done (teardown ordering), which
is cosmetic.

Manual recipe (research probe, not a ci gate): just iroh-relay-ws.
The wasip2 enablement this spike wrote into vendor/noq-udp turns out to
have landed upstream ten days ago as n0-computer/noq#773 (merged
2026-07-27), structurally identical — posix_minimal alias, non-vectored
recv_from, AsFd-based UdpSockRef construction — plus one fix this
spike's compile-only usage could not catch: set_nonblocking must go
through std (ioctl FIONBIO) rather than socket2 (fcntl F_SETFL), which
WASI rejects. The vendored copy is now a verbatim backport of #773, and
this patch disappears entirely once iroh consumes a noq-udp release
containing it.
The noq-udp wasi fix is merged upstream but unreleased (the published
1.1.1 was cut from a pre-merge base), so the vendored backport gives way
to a [patch.crates-io] git pin. Released noq 1.1.1 requires noq-udp
^1.1.1 while the git tree identifies as 1.1.0, so a lone noq-udp patch
would split the graph (noq keeping the unpatched registry copy); noq and
noq-proto ride the same rev to keep one coherent noq-udp. Verified: the
spike runs unchanged on the pinned rev (connect 25ms, echo p50 472us).

The other four vendored patches have no upstream counterpart to pin
(checked at HEAD: netwatch is still espidf-only, iroh-dns has no wasi
arm, iroh-relay and iroh have no wasi handling).
The released crates lag upstream too far to keep chasing (noq#773
merged but unreleased, netwatch restructured on main, iroh main carrying
relay-path fixes like #4444's reconnect-backoff handling). The guest now
declares iroh as a git dependency pinned to main (735958f8), overridden
via [patch] with vendor/iroh — the same rev re-vendored plus the
net_report probe short-circuit. Its manifest's own path deps pull the
sibling vendor/iroh-relay and vendor/iroh-dns (same rev, same spike
patches); iroh-base resolves from crates.io, byte-identical to the
release at this rev. Every file this spike patches is unchanged between
v1.0.3 and main, so the patches ported by copy.

Verified end to end against the stock v1.0.3 relay server (a
cross-version interop datum in itself): connect 27ms, echo p50 508us,
guest 7.2MB.
The next validation target from PR #25's list: WebRTC data channels as
an upstream-iroh CustomTransport (unstable-custom-transports), with the
channels owned by the host through the polymorph-webrtc-datachannels
host module (node-datachannel's W3C polyfill under Node, the browser
global in a browser). The guest transport is a thin poll adapter over a
second synthetic UDP socket: CustomAddr data is the peer's EndpointId
(the TestTransport convention), and the bridge pairs endpoints eagerly
at registration over unreliable, unordered channels (label "quic",
maxRetransmits=0), with in-process loopback signaling standing in for
the real design's relay-borne signaling. The shim's single datagram
listener becomes a per-port bridge registry (1 = relay ws, 2 = webrtc).

The demo is a two-phase echo: B dials A relay-only and measures RTT,
then closes, waits out iroh's per-remote path memory, and redials with
only A's webrtc custom addr — phase 2 runs QUIC end-to-end over the
data channel. The reconnect-after-expiry shape is forced by upstream
(verified against the vendored rev, empirically and in source):

- Custom transport addrs are excluded from the n0 NAT-traversal
  candidate exchange: local_candidates() and the QNT frames are
  SocketAddr-typed end to end, so holepunching can never probe a custom
  path. Address lookup is bootstrap-only (trigger_address_lookup
  early-outs once a path is selected), and ResolveRemote's
  insert_multiple records addrs without scheduling path opens. No
  mechanism exists to open a custom path on a live connection.
- While any connection to a remote lives, handle_msg_send_datagram
  sends later dials' Initials only to the selected path, so a second
  connect cannot probe new addrs either.
- A fresh dial fans the Initial out to every path iroh remembers, and
  the race is winner-take-all: the server drops the same-DCID Initial
  arriving on a second path during the handshake and the loser is never
  revisited (upstream's own custom-transport tests pass because the
  in-memory TestTransport always wins that race). A local relay outruns
  the data channel, so the webrtc addr only carries the handshake once
  the per-remote actor's 60s idle expiry has dropped the relay path and
  the dial is custom-only. Endpoint::remove_relay is no lever: it edits
  the RelayMap and re-runs net_report but leaves the established home
  relay link carrying datagrams.

Measured (Node 24.18, both endpoints in one component, stock relay,
node-datachannel over loopback): phase 1 relay echo p50 ~0.5ms; phase 2
webrtc echo p50 0.73-0.81ms, p90 1.2-1.5ms (50 rounds, 512B); webrtc
selected at 0ms after the reconnect; ~178 datagrams each way over one
channel; channel pairing 90ms-1.1s from registration.

Transport id 0x57525443 ("WRTC") is spike-local; publishing it means a
PR against upstream's TRANSPORTS.md registry.
@lann

lann commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator Author

Findings: WebRTC CustomTransport and the mid-connection upgrade seam (c2b4da4)

The next validation target lands: QUIC end-to-end over real node-datachannel unreliable/unordered data channels (polymorph-webrtc-datachannels, W3C polyfill under Node) as an upstream CustomTransport. Two-phase echo with the same endpoint identities: relay p50 ~0.5ms → webrtc p50 0.73–0.81ms, p90 1.2–1.5ms (50 rounds, 512B, loopback), webrtc selected at 0ms after the reconnect, ~178 datagrams each way per run, repeatable across runs, clean exit.

The negative result is the bigger one. #14's browser-leg section said the upgrade model (dial via relay → signal over ALPN → migrate) was missing only "a public mid-connection addr-injection API (mechanism exists … but only insert_relay is public, and Custom-addr end-to-end is unverified)". Now verified: the mechanism does not exist — custom addrs handed to a live remote are never probed, by construction. Inventory against the vendored rev (735958f8; paths under vendor/iroh/src/socket/remote_map/), each confirmed empirically where a run could reach it:

  1. The candidate exchange is SocketAddr-typed end to end. local_candidates() (remote_state.rs:1094) is the endpoint's DirectAddr set; update_qnt_candidates feeds conn.add_nat_traversal_address(SocketAddr) (:1107–1126); do_holepunching probes initiate_nat_traversal_round() results (:924). n0's NAT-traversal frames cannot carry a CustomAddr, so holepunching — the designed mid-connection path opener — can never open a custom path.
  2. Every other opener is unreachable or relay-special-cased. trigger_address_lookup early-outs once a path is selected (lookups are bootstrap-only, :866–870); ResolveRemote's insert_multiple records addrs without scheduling opens (:856); open_path_on_all_conns (:738) is only drained from the CID-exhaustion retry deque (:1062); AddConnection's re-add branch is relay-only and asymmetric (:434–444: a direct/custom winner re-adds relay as backup, never the reverse); apply_selected_path (:695) opens only an already-selected addr, and the selector only sees open paths — chicken-and-egg.
  3. The selected_path Initial trap. While any connection to a remote lives, handle_msg_send_datagram sends later dials' Initials only to the selected path (:799). A second connect with the custom addr completes its handshake over the existing relay path; the custom addr sits inert. (Observed: zero datagrams toward the transport.)
  4. The fresh-dial fan-out race is winner-take-all. With no selected path, the Initial fans out to every remembered path (:818). The server drops the same-DCID Initial arriving on a second path during the handshake; the losing path is never revisited. A local relay outruns the data channel even with the channel pre-warmed (observed: webrtc Initial delivered, no response, conn over relay). Upstream's own test_custom_transport_wins_over_relay closes the relay conn before redialing with both addrs and passes because the in-memory TestTransport always wins that race — no upstream test exercises two live paths from a mixed dial, let alone mid-connection upgrade.
  5. Endpoint::remove_relay is not a lever. It edits the RelayMap and triggers a re-STUN (socket.rs:1249, :1763) but leaves the established home-relay link carrying datagrams (observed: redial after remove_relay still handshakes over the relay).
  6. The only deterministic route today is reconnect-after-expiry: once the per-remote actor idles out (ACTOR_MAX_IDLE_TIMEOUT = 60s, remote_state.rs:73) its path memory drops, and a custom-only dial has nothing else to fan out to. That is what the spike demo does.

Operational notes for the eventual real design: a channel still in ICE loses the dial race by construction (the bridge's buffered Initial arrives after a competing path already won, and, absent competitors, QUIC gives up or PTO-retransmits into the same buffer) — so signaling must complete and the channel be open before the dial, which the relay-borne signaling flow gives us naturally. The bridge pairs eagerly at registration for this reason. Selector-side, nothing is needed: custom transports are already Primary tier vs relay's Backup (biased_rtt_path_selector.rs:120–126), and cross-tier switching is immediate — path opening is the missing link, not selection.

Upstreaming implication: the upgrade model needs one of (a) custom addrs in the candidate exchange — a protocol change to the QNT frames, heavy; or (b) a small public API ("open this TransportAddr on live connections to this remote") — open_path_on_conn (:1029) already does the work and is simply unreachable from outside. (b) looks PR-sized.

…overlay

Replaces the CustomTransport route (c2b4da4) with the overlay approach
from issue #26: the guest runs stock iroh IP transports — no custom
transport, no unstable-custom-transports feature — and the host bridge
assigns each endpoint a synthetic IP address (100.64.0.N:4433,
spike-local fiction) whose datagrams it carries over a real
node-datachannel unreliable/unordered data channel. The guest feeds the
address to iroh through Endpoint::add_external_addr once the bridge
reports the channel ready; iroh advertises it to the peer through the
NAT-traversal candidate exchange on the live relay connection, the
peer's holepunch probes arrive over the channel (there is no NAT — the
shim terminates them), the path validates like any direct path, and the
biased selector migrates the connection off the relay, keeping the
relay as backup. One connection, one stream, echo RTT measured before
and after — the live-migration validation target the custom-transport
route could not reach, because custom addrs are excluded from the
candidate exchange (PR #25 findings).

Mechanics: the shim gains a bound-socket registry and address-keyed
routes consulted before the per-port bridges; the webrtc bridge speaks
a small control protocol on port 2 (register endpoint id + iroh UDP
port, receive assigned address, receive channel-ready), pairs channels
eagerly at registration, and pushes inbound channel datagrams straight
into the peer's iroh UDP socket sourced from the sender's synthetic
address. Address lookup stays cleared so the fiction is never published
to discovery. Unroutable destinations (net_report QAD probes toward the
relay's UDP port, unreachable through the pipe by design) log once
instead of per-datagram.

The vendored netwatch's UdpSocket::drop deferred the close to
spawn_blocking, which panics on wasi (no worker threads) and hung the
component's exit; posix_minimal targets now close inline.

Measured (Node 24.18, both endpoints in one component, stock relay,
node-datachannel over loopback): phase 1 relay echo p50 ~0.5ms;
migration 51-53ms after add_external_addr, repeatable; phase 2 echo on
the same connection and stream over the data channel p50 0.53-0.54ms
with a tighter tail than the relay (max 0.8-4ms vs 6.7-7.1ms); relay
retained as a backup path; ~171 datagrams each way; total run ~4s.
@lann

lann commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator Author

Live migration achieved via the synthetic-address overlay (f4a5200)

The mid-connection upgrade the custom-transport findings above declared upstream-impossible is now working — by sidestepping the custom-transport machinery entirely (#26). The guest runs stock iroh IP transports (no unstable-custom-transports); the host bridge assigns each endpoint a synthetic IP address and carries its datagrams over the real data channel. Endpoint::add_external_addr (public API) injects the address; it rides the NAT-traversal candidate exchange in-band on the live relay connection; the peer's holepunch probes are terminated by the wasi:sockets shim (there is no NAT to punch); the path validates as an ordinary direct path and the biased selector migrates the connection, keeping the relay as backup.

Measured, repeatable across runs (Node 24.18, both endpoints in one component, stock relay, node-datachannel loopback):

  • one connection, one stream, both phases
  • phase 1 (relay): p50 ~0.5ms
  • migration: 51–53ms after add_external_addr — advertise → candidate event → holepunch round → probe over channel → validate → selector switch, all on the live connection
  • phase 2 (webrtc, same stream): p50 0.53–0.54ms, tail tighter than the relay (max 0.8–4ms vs 6.7–7.1ms)
  • relay retained: paths() shows relay … selected=false + ip:100.64.0.1:4433 selected=true
  • total run ~4s (vs 63s for the reconnect-after-expiry demo)

Why this works where the custom transport couldn't: every closed door in the findings inventory is typed against non-IP addresses. Expressed as an IP address behind a network surface we control on every target (the shim is a wasip2 component's only network surface, browsers included), the address enters local_candidates() unconditionally (update_direct_addresses merges configured addrs with no range filtering, socket.rs:1874), and stock machinery does the rest — including the winner-take-all dial-race problem, which evaporates because IP addrs get post-handshake chances (candidate events, the 5s holepunch interval, the 60s health check).

Two incidental findings:

  • netwatch UdpSocket::drop panics on wasi: it defers the close to spawn_blocking (no worker threads on wasi), which panicked at component exit and hung the driver. Vendored fix: posix_minimal targets close inline. Belongs on the upstreaming worklist with the existing netwatch delta.
  • net_report's QAD probes toward the relay's UDP port are unroutable through the pipe by design (the relay leg is a websocket); they fail harmlessly and the shim now reports each unroutable destination once.

Standing caveats, tracked in #26 (the generalization issue — gated on exactly this validation, now done): the address fiction must stay scoped (this spike clears address lookup so it is never published to discovery; a real deployment needs that guarantee explicitly), 100.64/10 is a spike-local placeholder for a random-prefix ULA, and loss/MTU behavior over lossy channels (SCTP chunk-loss amplification for >MTU packets) is still an unmeasured claim.

The custom-transport findings comment above remains the upstream-facing record: native iroh users without a controllable network surface still need the seam it proposes.

The same transpiled component, shim, and bridge JS that run under Node
now run in Chromium: browser WebSocket to the stock relay, browser
RTCPeerConnection for the data channels (the polymorph modules'
browser-first paths, previously exercised only through polyfills), JSPI
stable, no flags. The shim drops its Node imports for standard globals
(performance, crypto.getRandomValues), keeping process.hrtime's
nanosecond clock under Node and taking RUST_LOG from
globalThis.RUST_LOG in a browser.

browser.html + browser-run.mjs mirror the run.mjs driver, reporting
into globalThis.__spike; browser-test.mjs is the Playwright harness —
serves the repository root (bridge imports reach ../../../.deps, and
COOP/COEP buys 5us timers for the guest's RTT measurements), reuses or
starts the relay, mirrors the console, and asserts the migration and
phase-2-over-ip lines. `npm run test:browser`.

Measured (Chromium via Playwright 1.62, same machine as the Node
numbers): channel pairing 36ms (browser ICE vs node-datachannel's
~1s), migration 52-53ms after add_external_addr — identical to Node —
phase 2 echo p50 390-445us, faster than the browser's own relay path
(p50 ~735us) and than the Node data-channel path (p50 ~520-540us).
Total run ~4s, repeatable.
@lann

lann commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator Author

Browser validation (dfb2139)

The overlay live-migration now runs in a real Chromium under Playwright (npm run test:browser), with the identical transpiled component, shim, and bridge JS as the Node path — the only change was making the shim's three Node-isms environment-detected (nanosecond clock, getRandomValues, RUST_LOG source). This exercises the polymorph modules' browser-first paths for real: browser WebSocket to the stock relay server, browser RTCPeerConnection for the data channels (Node had been running node-datachannel's polyfill), JSPI stable in Chromium with no flags.

Measured (Chromium via Playwright 1.62, same machine, repeatable):

Node 24 Chromium
channel pairing (eager, at registration) ~1.05s 36ms
phase 1 echo p50 (relay) ~0.5ms ~0.74ms
migration after add_external_addr 51–53ms 52–53ms
phase 2 echo p50 (data channel) 0.52–0.54ms 0.39–0.45ms
total run ~4s ~4.2s

Two observations worth keeping:

  • The migration latency is runtime-invariant (52ms both sides) — it's iroh's candidate-advertise → holepunch-round → validate → select pipeline, not transport setup, that sets the floor.
  • The browser is the faster webrtc host: native ICE pairs in 36ms where node-datachannel takes ~1s, and the in-page data channel beats the relay websocket on the same machine. Phase 2 in the browser is the best RTT measured in the spike so far.

Harness shape: browser-test.mjs serves the repository root (COOP/COEP for 5us timers), reuses or starts the relay, mirrors the page console, waits on globalThis.__spike, and asserts the migration and phase-2-over-ip lines — exits nonzero otherwise, so it can gate CI later if this spike graduates.

lann added 2 commits August 6, 2026 23:15
…ages

Two browsers, one iroh connection: visiting the page hosts a session
and shows a QR of `#j=<endpoint id>&r=<relay url>`; scanning it joins.
Clicks and taps ripple on a square window-fit canvas, mirrored to the
peer. The connection bootstraps through the relay (n0 public map by
default, `?relay=` override for local runs) and live-migrates onto a
WebRTC data channel via the synthetic-address overlay (issue #26) —
the demo's status line shows the path switching from relay to direct.

Pieces:

- guest/: one iroh endpoint per page, stock IP transports. The page
  owns all demo semantics; the guest ferries opaque JSON frames over
  one bi stream (u16-framed), reports status events (ready, connected,
  path changes from polling the selected path, closed), runs the
  overlay control client from the spike, and hands the synthetic addr
  to iroh when the page's bridge reports the channel ready. The joiner
  writes an empty frame at open — a fresh QUIC stream is invisible to
  the acceptor until bytes flow, and the host's accept_bi would
  otherwise deadlock the signaling ferry against the page's offer.
- web/overlay.mjs: the spike's overlay bridge reshaped for two real
  browsers: synthetic addresses derive from endpoint ids (both pages
  agree without an exchange), signaling (SDP + trickled candidates)
  rides the ferry over the authenticated relay connection, host
  initiates (no glare), STUN for cross-network ICE.
- web/demo.mjs + index.html: canvas, ripples, QR (vendored
  qrcode-generator), status line, GUEST_ENV plumbing (role, peer,
  relay) through the shim's new environment passthrough.
- coi-serviceworker (vendored): injects COOP/COEP on Pages for 5us
  timers; everything is same-origin so isolation blocks nothing.
- build.sh assembles a fully self-contained static site (the polymorph
  webrtc/websocket modules vendored in; the relay bridge's .deps import
  rewritten) — Pages-ready, no runtime dependencies.
- web/test.mjs: Playwright harness — host + joiner + a third page
  against a local relay; asserts the join URL bootstrap, live migration
  to the direct path on both sides (relay ~13ms → direct ~1.4-2.2ms
  through two JSPI pages), pings mirrored both ways after migration,
  and the third participant refused (single-pair sessions).
Playwright against the Pages deployment: session bootstrap over the n0
public relays, live migration to the direct path on both pages, pings
mirrored both ways. First run measured relay rtt 530-760ms (distant
public relay) migrating to 1.2-30ms direct.
@lann

lann commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator Author

Public demo on GitHub Pages (2eb6d8b, 6fbe60d)

https://polymorph-components.github.io/polymorph-iroh/ — open it in Chrome/Edge (desktop or Android), scan the QR with a second device (or open the link in another window), and tap: ripples mirror between the two, with the status line showing the connection migrate from relay to direct.

This is the whole stack in production shape, end to end:

  • the wasip2 iroh component under jco/JSPI in a real browser page, served statically;
  • bootstrap over the stock n0 public relays (wss) — wire compatibility exercised against production infrastructure, not just the local dev relay;
  • real cross-machine signaling: SDP + trickled candidates ride the authenticated iroh relay connection (the page ferries opaque frames through the guest over one bi stream) — the "in-band signaling" design, so the QR carries only endpoint id + relay url;
  • live migration via the synthetic-address overlay (Consider splitting the synthetic-address overlay approach into a generic project (polymorph-overlay) #26): synthetic addrs derive from endpoint ids (no exchange needed), add_external_addr on channel-ready, holepunch terminated by the shim, selector switches, relay retained as backup;
  • coi-serviceworker injecting COOP/COEP (Pages has no headers) for 5µs timers; everything vendored, no runtime dependencies.

Verified against the live deployment (web/live-check.mjs): session bootstrap over an n0 relay, migration on both pages, pings mirrored both ways post-migration. Measured on the first live run: relay RTT 530–760ms (distant public relay from this runner) → direct 1.2–30ms after migration. The local harness (web/test.mjs, CI-able) additionally asserts a third participant is refused (single-pair sessions).

Notes and boundaries: JSPI limits the demo to Chromium desktop + Android (iOS Safari cannot run it — accepted for this demo); the deployment lives on the gh-pages branch, built from this branch by experiments/ping-demo/build.sh (vendored deps still never touch main); one bug found while building it — a QUIC stream-visibility deadlock between the host's accept_bi and the page-ferried offer, fixed by the joiner writing an empty frame at open (commit message has the mechanism).

Peer departure is now signalled as fast as the platform allows and
bounded when it does not: pagehide sends a best-effort bye (an empty
datagram; the guest closes the connection with it), chosen over
visibilitychange deliberately — task switches only fire the latter and
must keep the session alive — and the QUIC idle timeout drops from
quinn's default 30s to 8s (keepalives are on by default, so live
sessions never idle out; only dead peers do). bfcache restores reload
the page: a frozen guest's session is long dead, and the reboot rejoins
by itself.

Sessions are rejoinable from both sides. The guest now loops: the host
returns to accepting when a session ends (still refusing extras while
one is active) and the joiner redials every 2s with each attempt
bounded at 5s so a mid-reload host cannot stall the loop. Identity
persists per tab — the page keeps the ed25519 secret in sessionStorage
and passes it through GUEST_ENV — and the host writes the join payload
into its own URL fragment, so role detection is "does the fragment name
me?": the host's tab re-hosts on refresh, a fresh tab on the same URL
joins. The page shows "peer left · scan to rejoin" (host, QR returns)
or "peer left · reconnecting…" (joiner).

A small "i" button toggles a live debug panel: role, state, self and
peer endpoint ids, relay, selected path with RTT, channel and datagram
counters, ping counts.

The test grows a rejoin scenario: host reloads mid-session; asserts the
joiner sees the departure within the idle-timeout bound, the host
re-hosts under the same identity, the joiner reconnects and re-migrates
to the direct path, and pings mirror again.
lann added a commit that referenced this pull request Aug 7, 2026
Adds timely peer-left signalling, rejoinable sessions with per-tab
identity, and the debug panel. See PR #25 on the source repository.
@lann

lann commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator Author

Session lifecycle update (8bdd434, deployed)

The demo now handles departures and rejoins:

  • Timely peer-left: pagehide sends a best-effort bye (chosen over visibilitychange deliberately — task switches only fire the latter and must keep the session alive); the backstop for abrupt deaths (OS-killed tabs, crashes) is the QUIC idle timeout, dropped from quinn's default 30s to 8s. Live sessions never idle out — keepalives are on by default. bfcache restores reload the page (a frozen guest's session is long dead) and rejoin automatically.
  • Rejoinable sessions, both directions: the guest loops — host re-accepts after a session ends (still refusing extras while one is active), joiner redials every 2s (each dial bounded at 5s). Identity persists per tab (ed25519 secret in sessionStorage → GUEST_ENV), and the host writes the join payload into its own URL fragment, so role detection is "does the fragment name me?" — the host's tab re-hosts on refresh, a fresh tab on the same URL joins. UI shows "peer left · scan to rejoin" / "peer left · reconnecting…".
  • Debug panel: the "i" button toggles role, state, endpoint ids, relay, selected path + RTT, channel/datagram counters.

The Playwright harness grew a rejoin scenario (host reloads mid-session → joiner sees the departure within the idle-timeout bound → host re-hosts under the same identity → joiner reconnects, re-migrates to direct, pings mirror again) — all green locally, and the deployed site re-verified over the n0 public relays with live-check.mjs.

Known residual: Chrome's intensive timer throttling (tab hidden >5min) can starve the guest's keepalives and idle the session out even though the tab is alive; the session loops self-heal on foregrounding — same path as rejoin. Noted rather than engineered around.

…load

Joining burned ~3s before the dial even started. The bulk was
net_report: on wasi the QAD probes ride UDP straight to the relay's
QUIC socket, which no wasi host can deliver (the relay leg is a
websocket pipe), so every probe waited out the full 3s PROBES_TIMEOUT —
stalling the report, home-relay selection, and Endpoint::online behind
it. The vendored short-circuit that already covered the HTTPS probe now
also skips QAD on wasm_wasi; the report completes from the nominal
HTTPS latencies in ~200ms. External addresses come from the host via
add_external_addr, so nothing consumed the probe results anyway.

The demo joiner also no longer waits for online() at all: it dials the
host's relay directly (its own home relay only matters for inbound
dials, which never happen), taking boot-to-dial from ~3s to ~50ms
locally. The host still waits — its home relay is the rendezvous in the
QR. The coi-serviceworker goes: its first-visit reload cost more than
the 5us timers were worth (the RTT readout is fine at 100us), and the
test server drops its isolation headers to match production. The
joiner's dial bound widens 5s -> 10s for slow real-network relay
handshakes, still bounding hung attempts.

Measured locally (Playwright harness): host boot->QR 3098ms -> 295ms;
joiner boot->connected 3121ms -> 85ms, migrated by ~590ms; the status
console logs now carry t+ms for exactly this kind of reading. The
iroh-relay-ws spike browser test still passes with the vendor change
(and boots faster for the same reason).
@lann

lann commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator Author

Join latency (9be9393, deployed)

Joining burned ~3s before the dial even started. Breakdown and fixes, measured with the harness's new t+ms status timeline:

  • ~3.0s: net_report QAD probes timing out. On wasi the QAD probes ride UDP straight to the relay's QUIC socket — undeliverable by design (the relay leg is a websocket pipe) — so every probe waited out the full 3s PROBES_TIMEOUT, stalling the report, home-relay selection, and Endpoint::online() behind it. The vendored short-circuit that already covered the HTTPS probe now also skips QAD on wasm_wasi (external addresses come from the host via add_external_addr; nothing consumed the probe results anyway). Report completes in ~200ms from the nominal HTTPS latencies.
  • Joiner no longer waits for online() at all: it dials the host's relay directly; its own home relay matters only for inbound dials, which never happen. Boot→dial: ~50ms.
  • coi-serviceworker removed: its first-visit page reload cost more than the 5µs timers were worth (the RTT readout is fine at 100µs granularity). One less moving part; the test server drops its isolation headers to match production.
  • Joiner dial bound widened 5s→10s (slow real-network relay handshakes) while still bounding hung attempts mid-host-reload.

Local timeline, before → after: host boot→QR 3098ms → 295ms; joiner boot→connected 3121ms → 85ms, migrated to direct by ~590ms. The remaining real-world join cost is honest work: wasm download (~8MB site, streamed compile) plus the relay wss + QUIC handshakes. Deployed and re-verified live over the n0 public relays (this runner homes to an aps1 relay at 500–760ms RTT; migration takes the session to 0.6–29ms — the status line's relay→direct switch is very visible at that distance). The iroh-relay-ws spike inherits the vendor change and its browser test still passes, booting faster for the same reason.

…ation

The ecosystem-compatibility validation target from PR #25 (and the
driving question of #14): unmodified iroh-blobs 0.103 (default-features
off: MemStore, no fs-store, no rpc) compiled to wasm32-wasip2 against
the vendored iroh and ran end to end on the first attempt — the mem
path has no spawn_blocking/thread use, and tokio's "sync" feature is
all it asks for. The crates-io iroh dependency is substituted with the
vendored copy via [patch.crates-io]; the sibling spike's shim, bridges,
and webrtc overlay are shared through re-export stubs.

The guest runs a provider (stock MemStore + BlobsProtocol behind the
stock Router) and a fetcher in one component: bao-verified fetch of
blob 1 over the relay, live migration onto the data channel via the
synthetic-address overlay, then a verified fetch of blob 2 over the
migrated connection. BLOB_MB sizes the blobs (default 4MiB).

Measured (local relay, loopback):

- in-guest blake3/bao hashing: 392-579 MiB/s (wasm, single thread)
- Node 24: relay 7.8-8.8 MiB/s; direct (node-datachannel) 6.1-6.3 MiB/s
- Chromium: relay 6.0 MiB/s; direct (native RTCDataChannel) 9.3 MiB/s
- migration mid-session: 51-53ms, same as every prior measurement

The plateau is the per-datagram JS bridge hop, not the protocol stack;
the browser's native SCTP outruns its relay path, node-datachannel's
does not — the same host-quality split the ping demo showed.
`./run.sh` (Node), `node browser-test.mjs` (Chromium).
@lann

lann commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator Author

Ecosystem validation: stock iroh-blobs runs on the stack (e43295a)

The driving question of #14 — can unmodified ecosystem crates run on this endpoint — now has an empirical yes for iroh-blobs. iroh-blobs 0.103, default features off (MemStore, no fs-store, no rpc), compiled to wasm32-wasip2 against the vendored iroh and ran end to end on the first attempt. No patches to blobs; the mem path has no spawn_blocking/thread usage and asks tokio only for "sync". [patch.crates-io] substitutes the vendored iroh under blobs' crates-io dependency.

The spike (experiments/iroh-blobs/) runs a provider (stock MemStore + BlobsProtocol behind the stock Router) and a fetcher in one component: bao-verified fetch over the relay, live migration onto the data channel (51–53ms, invariant as ever), then a verified fetch of a second blob over the migrated connection.

Measured (local relay, loopback, 4–16MiB blobs):

Node 24 Chromium
in-guest blake3/bao hashing 392–579 MiB/s 401 MiB/s
fetch over relay 7.8–8.8 MiB/s 6.0 MiB/s
fetch over data channel 6.1–6.3 MiB/s 9.3 MiB/s

Two observations:

  • The plateau is the per-datagram JS bridge hop, not the protocol stack — QUIC, bao verification, and blake3 all have headroom (hashing alone runs at ~50x the transfer rate). Real-network transfers will be network-bound below these numbers anyway; if the loopback ceiling ever matters, the hop is where to look (batching datagrams across the boundary).
  • The browser is again the better webrtc host: native SCTP makes direct beat relay (9.3 vs 6.0 MiB/s), while node-datachannel inverts the ranking — the same host-quality split the ping demo showed at RTT level, now at throughput level.

This retires the "Ecosystem crates" validation target. The natural follow-ups it opens: a file-drop demo on the Pages site (blobs + the existing session bootstrap), and the remaining targets (identity split, upstreaming, overlay hardening).

The "+" button (or a drag-and-drop) sends files to the session peer:
the page streams the bytes to its guest in tagged datagram chunks, the
guest adds them to an in-memory blob store and the page offers the hash
over the ferry; the receiving page asks its guest to fetch, which opens
a blobs-ALPN connection to the peer (no addresses — the live session
already gave iroh the path, so the transfer rides the migrated data
channel) and runs a stock bao-verified iroh-blobs fetch, streaming
progress and then the bytes back up as a download link. The receiver
acks over the ferry so the sender's card shows delivery.

Guest: iroh-blobs 0.103 (MemStore) + serde_json for the two page
commands it now intercepts ("send", "fetch"); a single acceptor
dispatches by ALPN — blobs connections go to the stock BlobsProtocol
handler on both roles (the joiner serves too: when it sends, the host
fetches), session connections to the host's session loop with the
busy-refusal folded in. Uploads are capped at 64 MiB and single-flight
per direction.

The harness sends 2MiB each way and asserts verified receipt (state
done implies bao verification passed, byte count matches) plus the
sender ack; the live check gains a 1MiB transfer against the deployed
site. Measured locally over the migrated channel: 10.8 MiB/s cold
(slow start), ~100 MiB/s warm loopback.
@lann

lann commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator Author

File drop shipped on the demo (250ea11, deployed)

The Pages demo now transfers files: "+" button or drag-and-drop, offered over the ferry, fetched by the receiving guest through stock iroh-blobs (MemStore, bao-verified) on a blobs-ALPN connection that rides the session's migrated data channel — the no-addresses dial works exactly as the machinery reading predicted (the live session's selected path carries new connections to the same peer, the 'Initial trap' as a feature). Receiver acks over the ferry; the sender card shows delivery; downloads land as object-URL links.

Measured: locally 2MiB each way at 10.8 MiB/s cold / ~100 MiB/s warm loopback; against the live deployment over the n0 relays, 1MiB verified at 8.7 MiB/s on the migrated channel. The harness asserts verified receipt (done implies bao verification, byte counts match) plus sender acks in both directions, alongside the whole existing suite (migration, pings, third-refusal, host-reload rejoin) — all green.

Guest changes: iroh-blobs 0.103 + serde_json; one acceptor dispatching by ALPN (blobs served on both roles — the joiner provides when it sends), session busy-refusal folded in; page↔guest datagrams grew two binary chunk tags for upload/download streaming, 64 MiB cap, single-flight.

With this, the demo exercises the full claim end to end in public: stock iroh + stock iroh-blobs, in browsers, bootstrapped by production relays, live-migrating onto WebRTC, moving real files with verified integrity.

The iroh/iroh-relay/iroh-dns deltas now live as reviewable PR branches
on the fork (lann/iroh#1 iroh-dns resolver-config fallback, #2 relay
connect over the datagram pipe, #3 net_report probe gate), merged into
its `polymorph-iroh` branch: upstream main at the same pinned rev
(735958f8) plus exactly the patches the vendor tree carried, verified
byte-identical before the swap. The three guests consume the branch as
a git dependency; iroh-base is patched to the same branch in the blobs
and ping-demo guests so iroh and iroh-blobs share one copy of its
types (crates-io iroh-base 1.0.3 otherwise coexists as a second,
type-incompatible instance).

netwatch stays vendored: its upstream is n0-computer/net-tools, not
the iroh repo, so it is out of scope for the fork.

Verified: all three guests build; iroh-relay-ws Node and browser runs
(migration 51ms, phase-2 p50 410-664us), iroh-blobs verified fetches
across the migration, and the full ping-demo Playwright suite pass.
@lann

lann commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator Author

De-vendored: the iroh deltas now live on a fork branch (61f4f5f)

The vendored iroh/iroh-relay/iroh-dns copies are gone. The deltas are now reviewable PR branches on lann/iroh — each one a logical unit of the future upstreaming worklist:

  • lann/iroh#1 feat(iroh-dns): fall back to default resolver config on wasi (build fix)
  • lann/iroh#2 feat(iroh-relay): connect over a host datagram pipe on wasm32-wasip2 (the relay-dialer seam)
  • lann/iroh#3 feat(iroh): skip undeliverable net_report probes on wasm32-wasip2 (HTTPS short-circuit + QAD skip — one unit, shared rationale; the PR body flags the nominal-latency form's home-relay-ranking cost for upstream design discussion)

The PRs stay open (not merged); their branches are merged into polymorph-iroh — upstream main at the same pinned rev (735958f8) plus exactly what the vendor tree carried, verified byte-identical before the swap. The three guests consume that branch as a git dependency; iroh-base is patched to the same branch in the blobs/ping-demo guests so iroh and iroh-blobs share one copy of its types (otherwise crates-io iroh-base 1.0.3 coexists as a second, type-incompatible instance — compiles today, trap later).

Still vendored: netwatch (upstream is n0-computer/net-tools, not the iroh repo — same treatment there is a follow-up if wanted). The delta table in the PR description now under-describes reality in the other direction: the iroh-side rows are the fork PRs.

Re-verified after the swap: all three guests build; iroh-relay-ws Node + browser runs (migration 51ms, phase-2 p50 410–664µs); iroh-blobs verified fetches across the migration; full ping-demo Playwright suite green (migration, pings, file transfer both ways, third-refusal, rejoin).

…oh branch

Same treatment as the iroh crates: the two netwatch deltas now live as
PR branches on the fork (lann/net-tools#1 wasi posix_minimal/netdev
routing, #2 inline socket close on drop), merged into its
`polymorph-iroh` branch — the netwatch-v0.19.1 tag (iroh main still
requires ^0.19.1) plus exactly the patches the vendor tree carried,
source files verified byte-identical before the swap. The guests
consume the branch through [patch.crates-io]; the vendor tree is gone.

Verified: all three guests build; iroh-relay-ws run (migration 53ms),
iroh-blobs verified fetches across the migration, and the full
ping-demo Playwright suite pass.
@lann

lann commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator Author

netwatch de-vendored too (4f2d711)

Same treatment as the iroh crates, on lann/net-tools:

  • lann/net-tools#1 feat(netwatch): route wasm32-wasip2 to the netdev-free posix_minimal branch — with the porting note that main has since restructured these cfgs around a netdev alias, so the eventual upstream PR targets that instead of this literal diff
  • lann/net-tools#2 fix(netwatch): close the socket inline on drop for single-threaded targets — the spawn_blocking panic fix; PR body carries the espidf-semantics review note

Branches base on the netwatch-v0.19.1 tag (iroh main still requires ^0.19.1), PRs stay open, and both merge into polymorph-iroh — verified byte-identical to what the vendor tree carried. The guests consume it via [patch.crates-io]; experiments/iroh-relay-ws/vendor/ is now gone entirely.

One correction vs. the netwatch delta discussion earlier in this thread: the old drop path did not leak the fd when no runtime was current (the returned socket drops and closes either way) — the fix only moves where the close runs. The fork PR describes it accurately.

With this, every local delta is a reviewable fork PR: iroh in lann/iroh#1–3, netwatch in lann/net-tools#1–2, noq already upstream (noq#773, awaiting release). Re-verified after the swap: all three guests build; relay-ws run (migration 53ms), blobs verified fetches across the migration, full ping-demo Playwright suite green.

Evaluated at dbad4d7d (previous pin 30186b2b). The branch's new work is
CM-async machinery: sync-lowered calls into async-lifted callees
(sync-start-call, caller task-register restore, spilled-result return
pointers), future.transfer and stream.transfer between guests,
concurrent task lifetimes within one instance, and composed
guest-to-guest regression tests for all of it — the jco half of what a
0.3-shaped world needs.

Test evaluation on this machine (arm64):
- packages/jco suite: 77 passed, 1 skipped, 0 failed.
- packages/jco-transpile suite (includes the P3/CM-async tests:
  futures, streams, transfers, backpressure, cancellation, deadlock
  regressions, inter-task wakeup): 366 passed, 51 skipped; the only
  failures are browser-launch environmentals — puppeteer's bundled
  x86-64 Chromium cannot run here. With an arm64 Chromium substituted,
  the JSPI async browser test passes; the three test/browser/general
  failures reproduce identically at the previous pin (pre-existing,
  environmental, not regressions).
- test/components extended suite: not runnable here (needs wasi-sdk,
  componentize-py, wac); polyglot issue reproductions, unrelated to
  the async work.

Regression against this repository's stack, all retranspiled at the
new head: iroh-relay-ws spike (Node) migrates in 52ms with phase 2
over the data channel; the ping-demo Playwright suite (migration,
pings, file transfer both ways, third-participant refusal,
host-reload rejoin) passes; the iroh-blobs spike passes both fetch
phases.
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.

1 participant