Repository navigation
WIT review #13
Description
Basis: the WIT (wit/iroh.wit), its implementation (endpoint/src/endpoint_impl.rs), the sibling packages, issues #3/#6/#9/#10/#12, and a verified survey of iroh 1.0.3 (note: upstream renamed heavily for 1.0 — quinn is replaced by noq with multipath + QNT built in, NodeId→EndpointId, discovery→"address lookup", and the transports layer is iroh/src/socket/transports with an unstable public CustomTransport API).
Overall: the surface is small, honest, and unusually well-documented; the auth semantics (peer() states exactly what is proven, identity-mismatch connections are never surfaced — endpoint_impl.rs:558) are right and better-stated than upstream's docs. The shape connect(endpoint-addr, alpn) / accept() / two-level stream accept maps cleanly onto iroh 1.0's Endpoint. The findings below are ordered by how much they'd hurt.
A. Byte-stream integrity holes (highest severity)
-
Reset is unobservable on the streaming read path.
read-via-stream: func() -> result<stream<u8>, error>(iroh.wit:272) returns a barestream<u8>. A CM stream end carries no error: a peerRESET_STREAMis indistinguishable from a clean FIN. The implementation confirms it — endpoint_impl.rs:1509while let Ok(Some(bytes))ends the stream on reset, silently. wasi:sockets 0.3 solved exactly this withreceive() -> tuple<stream<u8>, future<result<_, error-code>>>(sockets.wit:378). Adopt that shape or delete the method; as specified it invites silent truncation. -
Connection close is surfaced as clean FIN. endpoint_impl.rs:1414-1417 maps
Error::ClosedtoOk(None)inread. A connection that dies (even gracefully) before a stream's FIN truncates that stream; quinn/noq surfaceReadError::ConnectionLostprecisely so applications can tell.writeresolves at acceptance, not delivery (iroh.wit:243), so "peer wrote, closed, receiver got EOF" can lose an arbitrary tail with no error anywhere. This contradicts the WIT's ownerror.closedtext ("closed before the operation completed"). One of the two — the doc or the mapping — is wrong; the mapping is. -
Reset codes are strings.
reset(string)"decimal-rendered" (iroh.wit:97) while the same doc says "do not match on their contents." Together those two sentences delete the application reset code from the interface. Upstream:ReadError::Reset(VarInt). Make itreset(u64). Relatedly,close/reset/stoptakeu32codes but QUIC codes are u62 — an upstream peer can send codes this surface can neither receive faithfully nor emit. Andwait-closed: async func()(iroh.wit:235) returns nothing: the peer's application close code/reason (which iroh'sclosed() -> ConnectionErrordelivers, and protocols do dispatch on) is unobservable.
B. Component-model concurrency model
The design's CM posture is fundamentally sound — async funcs for blocking ops, sync close + latched wait-closed, backpressured write through the async ABI, one-shot stream taps — and it's proven against real CM async runtimes (wasmtime matrix green; jco blocked on upstream scheduler defects #6/#10, correctly diagnosed as host bugs, not contract bugs). Remaining holes:
-
Concurrent same-stream operations are unspecified, and the implementation corrupts. The CM permits concurrent method calls on one resource handle (each call is its own task; that's the mechanism
accept's documented concurrency relies on). Two concurrentsend-stream.writecalls interleave viawrite_all's flow-control quanta (endpoint_impl.rs:1370-1402) — byte-level interleaving, i.e. corruption for any framed protocol; two concurrentreads dequeue chunks in arbitrary alternation. quinn forbids this with&mut self; WIT has no exclusive borrow, so the contract must say it: either "a second in-flight op fails/queues" or an explicit hazard warning. The siblings' "concurrent calls supported, implementation-defined order" wording is correct for message transports and wrong for byte streams — don't inherit it. -
Cancellation semantics are unstated everywhere. Every async import call is a cancellable subtask (
subtask.cancelis real; jco even traps on it, jco-transpile 0.5.2 traps on subtask.cancel: guests must resolve in-flight imports before returning #6). Per-operation post-conditions needed: cancelledwrite— is a prefix committed, and can the caller learn how much? (No; onlyresetrecovers the stream.) Cancelledconnect— does the attempt continue? (Implementation: theConnEntryleaks forever and the peer sees a live connection nobody owns — endpoint_impl.rs:1187's waiter is the only thing that ever surfaces it.) Cancelledaccept/write-via-stream? quinn documents cancel-safety per method; this interface, whose ABI makes cancellation more reachable than Rust drop, documents none. -
write-via-stream's one-shot rule is not enforced and its interactions are unspecified. The WIT says "after this call the resource accepts no further writes" (iroh.wit:253), butSendStreamReshas no guard (contrastRecvStreamRes.streaming, endpoint_impl.rs:976): a subsequentwritehappily interleaves with the streamed payload. Divergence-with-no-artifact by your own AGENTS.md standard. Also unspecified:finish/resetwhile awrite-via-streamis in flight, and what happens when the caller drops the data stream mid-way (implementation: breaks the loop and FINs — turning a producer crash into a clean-looking EOF for the remote peer; arguably it shouldreset). -
No backpressure on accept. Handshakes complete and queue unboundedly whether or not anyone calls
accept(endpoint_impl.rs:84, 552). The CM gives youtask.backpressure, and upstream givesIncoming::refuse/retry/ignoreplus incoming filters, for exactly this. As specified, any peer that can reach your relay can make you hold unbounded fully-handshaked connections. Related contract gap:acceptdoc doesn't say connections handshake before an acceptor exists (observable: peer'sconnectresolves while server app is busy). -
Poll-only observation is an anti-idiom here.
state()/path()are snapshot funcs; path flaps (webrtc→relay→webrtc) between polls are unobservable, and TOCTOU is inherent. Your own siblings established the coalescing-watch stream (state-changes: func() -> stream<...>, webrtc.wit:334) — andendpoint-demoalready busy-pollspath()to detect the upgrade. Addpath-changes/state-changes. This also replaces cross-component polling (a full canonical-ABI call per sample, on top of the implementation's 5ms quanta) with demand-driven reads.
One genuinely good CM call worth recording as a ruling: the accept-loop shape (consumer pulls) instead of iroh's ProtocolHandler callback shape keeps the wac composition acyclic — the endpoint never imports its consumers. Issue #3's worry is resolved by construction.
C. Fidelity to upstream iroh (1.0.3)
-
transport-addrmirrors upstream faithfully — verified:TransportAddr::{Relay(RelayUrl), Ip(SocketAddr), Custom(CustomAddr)}withCustomAddr { id: u64, data }matchescustom-addrexactly, including the id registry (TRANSPORTS.mdupstream). Two nits:path-kindhas nocustomcase (iroh.wit:79) — the enum can't report a path the address type can name; asymmetric and non-extensible. Andip(string)/relay(string)leave formats unspecified (IPv6 brackets? zone IDs? URL normalization — the pool dedups only trailing slashes, endpoint_impl.rs:927). -
The
connectcontract bakes v0 narrowings into normative text. "There is no fallback between dial paths" (iroh.wit:165) is implementation latitude promoted to contract. Upstream iroh 1.0 always races paths — QUIC multipath with QNT NAT traversal is in the protocol layer now. A future upstream-backed or multipath-capable implementation of this WIT would be non-conformant against this sentence. State dial-path selection as implementation-defined; keep the narrowing in the implementation's docs where the rest of it already lives (endpoint/src/lib.rs:12). -
Single-path model.
path: func() -> path-kindassumes exactly one wire at a time. iroh 1.0 connections hold a set of paths with a selected one (paths(),path_events(), per-path RTT). The current model can't even express "relay + webrtc simultaneously during upgrade," which is what the implementation actually does mid-flip. Fine for v0; will not survive direct-UDP-as-upgrade (Direct UDP as an upgrade target #12) without becominglist<path>+ events. -
Identity lifecycle.
bindmints a fresh identity per call; the doc claims persistence will be "not a surface change" (iroh.wit:146). Dubious: selecting a stored key needs some input (a key name/handle inendpoint-options, or a webcrypto key resource parameter — issue Design the exported endpoint WIT surface #3 explicitly lists injection). Upstream hasBuilder::secret_key. Every real deployment (stable addresses, discovery records) needs this; it's the largest functional gap after datagrams. -
Missing vs upstream, worth a roadmap line each: QUIC datagrams (acknowledged, Design the exported endpoint WIT surface #3),
stopped()/received_reseton streams, stream priority, RTT/stats (your own AGENTS.md demands measured claims —rtt()is the measuring instrument), 0-RTT,set_alpnsdynamism, additional-ALPNs on connect, self-endpoint-addrassembly (there is no way to learn observed addresses;direct-addris the local bind — publishing a dialable record is impossible until Direct UDP as an upgrade target #12's QAD), address-book/lookup injection,before_connect/after_handshakepolicy hooks (the accept-DoS mitigation upstream chose). -
Error taxonomy is thinner than both upstream and your siblings. No
timed-out(folded intoconnect-failed— but also used for relay-open timeouts onconnect), nonot-supported(needed the moment a host lacks UDP or WebRTC — see Bump polymorph-tls pin: QUIC descope + uniform hp masking #16), and the read-after-read-via-streamfailure is specified aserror.other(iroh.wit:270) where both siblings minted a dedicatedreceiving-via-streamcase.otherbeing load-bearing in a documented flow is a contract smell.
D. World-level issues
-
The world under-declares the component's imports.
iroh-endpoint(iroh.wit:282) omitslann:webcrypto/*(used viacore/src/crypto/sign.rs) andwasi:random(getrandom inbind, endpoint_impl.rs:1050). The doc comment says so, but a world that isn't the component's real import set defeats the stated "capability-bounded networking through narrow WIT imports" claim — auditors and composition tooling read worlds. Declare them; the crypto split is a feature, not something to hide from the manifest. -
All transports are mandatory imports. A browser deployment has no
wasi:sockets; a socket-only cloud deployment has no WebRTC — yet the world requires all four imports andendpoint-optionscan request any wire. Absent optional imports in the CM, the latitude ("this host stubs UDP;udp-bind-addrfails") must be written at the option's definition site, with an error case that isn'tother. Currently unrecorded. -
WIT/implementation divergences with no WIT-side artifact:
relay-url: option<string>butbindrejectsnone(endpoint_impl.rs:1035); emptyalpnsrejected but undocumented;read(max=0)returnssome([])undocumented. Each violates your recorded-latitude rule at the surface consumers actually read.
E. Sufficiency for iroh's transport abstractions
Two directions, opposite verdicts:
- Implementing iroh's transport abstraction on this surface: no, structurally. iroh 1.0's
CustomTransport/CustomEndpointis a datagram interface (poll_send(dst: CustomAddr, Transmit),poll_recv(..., RecvInfo),watch_local_addrs, GSO hints) below QUIC.lann:iroh/endpointsits above QUIC and deliberately hides packets, addresses, and paths. Nothing datagram-shaped can be built on it. That's the correct layering for a consumer surface — just don't expect it to serve both roles. - The inverse is the interesting one. Internally,
endpoint/reinvented upstream's transport seam almost exactly: your synthetic2001:db8::/48standins +routestable (endpoint_impl.rs:229, 92) are upstream'sMultipathMappedAddrULA mapping +Transportsdispatch, independently converged. That seam is currently hardcoded to three wires. If the family ever wants Tor/BLE/new wires as components (the thing upstream'sunstable-custom-transportsdoes natively), the move is alann:iroh/transportimport interface mirroringCustomEndpoint—send(addr, datagram),receive() -> (addr, datagram), local-addr watch — andpath-kind.custom(u64). The current WIT neither provides nor obstructs that; the world's fixed import list is the (closed) transport abstraction today. Worth an issue so the seam doesn't ossify. - Backing this WIT with upstream iroh (a native host serving
lann:iroh/endpointover the iroh crate) is ~90% mechanical — same connect/accept/stream shapes — except where this review already points: the no-fallback dial contract (jco: scheduler stops delivering waitable events once a detached task holds in-flight imports across export calls #10), u32 codes (Design the exported endpoint WIT surface #3), singlepath-kind(Endpoint: WebRTC data-channel and UDP wires behind the same surface #11), andwebrtcas a package-private addr kind. Keeping that door open is cheap and is exactly your conformance story's cross-check implementation.
Priority
If only three things change: A1/A2 (reset/close vs FIN — data integrity), B4/B5 (concurrent-op and cancellation contracts — the CM makes both reachable by any consumer), and A3 + wait-closed payload (stop laundering machine-readable QUIC codes through diagnostic strings). All are pre-publication cheap and post-publication breaking.
Activity
retire the jco host: deltic is the JS leg (#40)
lann commentedon Aug 11, 2026
Status against main at 19e8bf7: finding 12 is addressed by #31 (identity as a resource, identity-generate/identity-from-keys, options constructor takes a borrowed identity), the datagram line of finding 13 by #30, and the world now declares polymorph:webcrypto/signature (part of 15) — though wasi:random remains undeclared while getrandom is still used in bind (endpoint_impl.rs:1112). The priority items are all still open: read-via-stream is a bare stream (iroh.wit:370), reset codes are still strings/u32 (iroh.wit:95-97, 348, 364), wait-closed still carries no payload, and the B4/B5 concurrency and cancellation contracts have no WIT text. One unblocking since filing: #40 retired the jco host, so A3's u64 constraint (inherited from #9) no longer binds — those fixes are now pure WIT decisions, still pre-publication cheap.
lannbot commentedon Aug 12, 2026
Status against main at 6c18c78, plus the two open PRs (#59, #60). This supersedes the previous status comment.
Landed:
read-via-streamreturnstuple<stream<u8>, future<result<_, error>>>(wasi:sockets' shape).close/reset/stoptakeu64(saturating at 2^62−1);error.resetcarries the code; probe codes sit above u32 so truncation anywhere fails the matrix.connectentry leak and the relay-open claim wedge fixed with RAII guards; byte-stream halves refuse concurrent use; via-stream claims enforced; drop contracts implemented (connectionclose(0,""), sendreset(0)behind anendedlatch, recvstop(0)). One departure from the review recorded in the PR: reset-on-producer-drop is unimplementable — a dropped writer is the CM's only end-of-stream signal — so the truncation reality is contract text instead.ACCEPT_BACKLOG(16); past it, incoming attempts are refused; pre-connect deaths fold intoconnect-failed.path-changescoalescing watch (family convention);state-changesdeliberately NOT added — connections are minted post-handshake, so the one observable transition is served better bywait-closed.wait-closedpayload (endpoint WIT: wait-closed carries the peer's application close #49, finding A3's read half), the datagram-ceiling contract (Per-path datagram ceiling: bounded MTU discovery replaces the fixed 1200-byte profile #52), plus pre-batch items C12 (Endpoint identity: a resource with per-path constructor interfaces #31) and C13's datagram line (Expose QUIC datagrams on the connection surface #30).In review:
timed-out,not-supported,in-use; deployment-profile latitude recorded at the option sites; negative rows pin the taxonomy.read(max=0)documented.Routed elsewhere:
path-changesevolves with that model.path-kind.customasymmetry and the section-E transport seam → A transport import seam: polymorph:iroh/transport mirroring upstream's CustomEndpoint #61.before_connectpolicy hooks at this surface's scale.Left open here: nothing, once #59/#60 land — remaining threads live on #12, #14, and #61. Suggest closing this issue when those two merge, with #14 carrying the fidelity torch.
lannbot commentedon Aug 12, 2026
Closing per the status ledger above: the A and B findings landed (#49, #52–#57), C14/D16 and the truthfulness sweep merged (#59, #60 — main's push CI green on the merge), and the remaining threads have homes: C11 + self-address on #12, the transport seam and path-kind asymmetry on #61, the C13 roadmap remainder and fidelity questions on #14.