From e50121d7e25586b82909fb83548799d0b61db0a2 Mon Sep 17 00:00:00 2001 From: Lann Martin Date: Wed, 12 Aug 2026 12:57:21 -0400 Subject: [PATCH 1/2] Error taxonomy names the documented failure modes: timed-out, not-supported, in-use MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #13 findings C14 and D16. Three failure modes the docs already described rode cases that misnamed them: - timed-out(string): a dial the peer never answers, and a relay open that never completes, kept their own case instead of folding into connect-failed (whose doc no longer claims timeouts) or riding error.other from the generic connection-lost mapping. A mid-life idle timeout surfaces the same way on stream operations. - not-supported(string): the deployment-profile latitude (D16) is live today — the deltic host stubs wasi:sockets with the honest error-code.not-supported, and the endpoint flattened it into invalid-argument. The udp bind path now carries the distinction, and the latitude is recorded at the option definition sites: udp-bind-addr fails bind with not-supported on a UDP-less host; a stubbed WebRTC import is not detected at bind and leaves upgrades non-functional (written, not implied). - in-use(string): the B4/B6 guards left error.other load-bearing in six documented flows (in-flight read/write refusals, via-stream claims, finish under a write). They get the dedicated case, in the spirit of the siblings' receiving-via-stream. Conformance sharpens accordingly: the absent-peer matrix row now pins TimedOut and the wrong-alpn row ConnectFailed (both previously grepped the word connect); the stream-negative probes match in-use exactly; the deltic exam's scenario 1 gains the browser-profile probe — bind with udp-bind-addr set must fail not-supported, the stub's error-code carried through unflattened. Addresses #13 findings C14 and D16. --- endpoint-demo/src/lib.rs | 10 ++++----- endpoint/src/endpoint_impl.rs | 37 +++++++++++++++++++-------------- endpoint/src/udp.rs | 23 +++++++++++++++----- host-deltic/src/run-endpoint.ts | 33 +++++++++++++++++++++++++++++ host-deltic/src/types.ts | 3 +++ scripts/matrix.sh | 16 +++++++------- wit/iroh.wit | 34 +++++++++++++++++++++++------- 7 files changed, 113 insertions(+), 43 deletions(-) diff --git a/endpoint-demo/src/lib.rs b/endpoint-demo/src/lib.rs index 578fc74..0c11906 100644 --- a/endpoint-demo/src/lib.rs +++ b/endpoint-demo/src/lib.rs @@ -671,11 +671,11 @@ async fn stream_negative_client( return Err("s6: the oversized write completed against an unread stream".into()); } match send.write(b"x".to_vec()).await { - Err(Error::Other(msg)) if msg.contains("in flight") => {} + Err(Error::InUse(_)) => {} other => return Err(format!("s6: concurrent write not refused: {other:?}")), } match send.finish() { - Err(Error::Other(msg)) if msg.contains("in flight") => {} + Err(Error::InUse(_)) => {} other => return Err(format!("s6: finish under a write not refused: {other:?}")), } // Dropping the parked future cancels the write: a prefix is @@ -696,7 +696,7 @@ async fn stream_negative_client( return Err("s7: the read did not park on a silent stream".into()); } match recv.read(READ_MAX).await { - Err(Error::Other(msg)) if msg.contains("in flight") => {} + Err(Error::InUse(_)) => {} other => return Err(format!("s7: concurrent read not refused: {other:?}")), } // Dropping the parked future cancels the read and releases @@ -724,9 +724,7 @@ async fn stream_negative_client( // The cancelled S7 read released its guard: a later read runs and // reports the connection's failure, not a refusal. match recv.read(READ_MAX).await { - Err(Error::Other(msg)) if msg.contains("in flight") => { - return Err("s7: the cancelled read left its guard claimed".into()) - } + Err(Error::InUse(_)) => return Err("s7: the cancelled read left its guard claimed".into()), Err(_) => {} other => return Err(format!("s7: post-close read got {other:?}")), } diff --git a/endpoint/src/endpoint_impl.rs b/endpoint/src/endpoint_impl.rs index 2cf9384..623a139 100644 --- a/endpoint/src/endpoint_impl.rs +++ b/endpoint/src/endpoint_impl.rs @@ -709,6 +709,7 @@ fn on_event( Error::Closed } ConnectionError::LocallyClosed => Error::Closed, + ConnectionError::TimedOut => Error::TimedOut("the connection timed out".into()), other => Error::Other(format!("connection lost: {other}")), }); } @@ -1048,6 +1049,10 @@ fn other(detail: impl std::fmt::Display) -> Error { Error::Other(detail.to_string()) } +fn in_use(detail: impl std::fmt::Display) -> Error { + Error::InUse(detail.to_string()) +} + /// A synthetic socket address under the IPv6 documentation prefix /// (RFC 3849, `2001:db8::/32`): `2001:db8::::`, port /// 4433. Documentation addresses are never routable, so standins @@ -1341,7 +1346,7 @@ impl EndpointRes { return Some(Err(Error::Closed)); } if started.elapsed() > Duration::from_secs(30) { - return Some(Err(Error::ConnectFailed("relay open timed out".into()))); + return Some(Err(Error::TimedOut("relay open timed out".into()))); } None }) @@ -1447,9 +1452,7 @@ impl GuestEndpoint for EndpointRes { ); let udp = match &udp_bind_addr { - Some(bind_addr) => Some(Rc::new( - UdpWire::bind(bind_addr).map_err(Error::InvalidArgument)?, - )), + Some(bind_addr) => Some(Rc::new(UdpWire::bind(bind_addr)?)), None => None, }; @@ -1578,11 +1581,13 @@ impl GuestEndpoint for EndpointRes { let entry = st.conns.get_mut(&handle).expect("connection entry"); if let Some(err) = &entry.error { // A dial that dies before connecting failed to - // connect, whatever the mechanism — the peer's refusal - // and a handshake timeout both arrive as transport - // closes. Local endpoint closure stays `closed`. + // connect: the peer's refusal arrives as a transport + // close and folds into `connect-failed`. Timeouts keep + // their own case; local endpoint closure stays + // `closed`. return Some(Err(match err.clone() { Error::Closed => Error::Closed, + Error::TimedOut(msg) => Error::TimedOut(msg), Error::Other(msg) => Error::ConnectFailed(msg), other => other, })); @@ -1999,10 +2004,10 @@ impl SendStreamRes { impl GuestSendStream for SendStreamRes { async fn write(&self, bytes: Vec) -> Result<(), Error> { if self.streaming.get() { - return Err(other("the stream's writes were taken by write-via-stream")); + return Err(in_use("the stream's writes were taken by write-via-stream")); } if self.writing.replace(true) { - return Err(other("a write is already in flight on this stream")); + return Err(in_use("a write is already in flight on this stream")); } let _claim = Unclaim(&self.writing); write_all(&self.shared, self.handle, self.id, bytes).await @@ -2010,11 +2015,11 @@ impl GuestSendStream for SendStreamRes { fn finish(&self) -> Result<(), Error> { if self.streaming.get() { - return Err(other("the stream's writes were taken by write-via-stream")); + return Err(in_use("the stream's writes were taken by write-via-stream")); } if self.writing.get() { // A FIN under an in-flight write would truncate it. - return Err(other("a write is in flight on this stream")); + return Err(in_use("a write is in flight on this stream")); } self.do_finish() } @@ -2030,11 +2035,11 @@ impl GuestSendStream for SendStreamRes { async fn write_via_stream(&self, mut data: StreamReader) -> Result<(), Error> { if self.streaming.get() { - return Err(other("write-via-stream may be called once")); + return Err(in_use("write-via-stream may be called once")); } if self.writing.replace(true) { // Refused without claiming: the call had no effect. - return Err(other("a write is already in flight on this stream")); + return Err(in_use("a write is already in flight on this stream")); } let _claim = Unclaim(&self.writing); self.streaming.set(true); @@ -2055,7 +2060,7 @@ impl GuestSendStream for SendStreamRes { impl GuestRecvStream for RecvStreamRes { async fn read(&self, max: u32) -> Result>, Error> { if self.streaming.get() { - return Err(other("the stream's bytes were taken by read-via-stream")); + return Err(in_use("the stream's bytes were taken by read-via-stream")); } if let Some(terminal) = self.terminal.borrow().clone() { return terminal.map(|()| None); @@ -2064,7 +2069,7 @@ impl GuestRecvStream for RecvStreamRes { return Ok(Some(Vec::new())); } if self.reading.replace(true) { - return Err(other("a read is already in flight on this stream")); + return Err(in_use("a read is already in flight on this stream")); } let _claim = Unclaim(&self.reading); match read_some(&self.shared, self.handle, self.id, max).await { @@ -2092,7 +2097,7 @@ impl GuestRecvStream for RecvStreamRes { &self, ) -> Result<(StreamReader, FutureReader>), Error> { if self.streaming.replace(true) { - return Err(other("read-via-stream may be called once")); + return Err(in_use("read-via-stream may be called once")); } let (mut writer, reader) = wit_stream::new(); // The default never surfaces: every pump path below writes an diff --git a/endpoint/src/udp.rs b/endpoint/src/udp.rs index 498bcc8..1d117c0 100644 --- a/endpoint/src/udp.rs +++ b/endpoint/src/udp.rs @@ -3,6 +3,7 @@ use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; +use crate::bindings::polymorph::iroh::types::Error; use crate::bindings::wasi::sockets::types::{ ErrorCode, IpAddressFamily, IpSocketAddress, Ipv4SocketAddress, Ipv6SocketAddress, UdpSocket, }; @@ -13,25 +14,37 @@ pub struct UdpWire { local: SocketAddr, } +/// A socket failure at bind time: `error-code.not-supported` is the +/// host's honest no-UDP answer (the browser profile) and surfaces as +/// `error.not-supported`; anything else rejects the arguments. +fn bind_error(what: &str, code: ErrorCode) -> Error { + match code { + ErrorCode::NotSupported => { + Error::NotSupported("this deployment provides no UDP socket".into()) + } + other => Error::InvalidArgument(format!("{what}: {other:?}")), + } +} + impl UdpWire { /// Create and bind a socket at `bind_addr` (`ip:port`; port 0 picks a /// free one). - pub fn bind(bind_addr: &str) -> Result { + pub fn bind(bind_addr: &str) -> Result { let addr: SocketAddr = bind_addr .parse() - .map_err(|e| format!("udp bind address {bind_addr:?}: {e}"))?; + .map_err(|e| Error::InvalidArgument(format!("udp bind address {bind_addr:?}: {e}")))?; let family = match addr { SocketAddr::V4(_) => IpAddressFamily::Ipv4, SocketAddr::V6(_) => IpAddressFamily::Ipv6, }; - let socket = UdpSocket::create(family).map_err(|e| format!("udp create: {e:?}"))?; + let socket = UdpSocket::create(family).map_err(|e| bind_error("udp create", e))?; socket .bind(to_wasi(addr)) - .map_err(|e| format!("udp bind: {e:?}"))?; + .map_err(|e| bind_error("udp bind", e))?; let local = from_wasi( socket .get_local_address() - .map_err(|e| format!("udp local address: {e:?}"))?, + .map_err(|e| bind_error("udp local address", e))?, ); Ok(Self { socket, local }) } diff --git a/host-deltic/src/run-endpoint.ts b/host-deltic/src/run-endpoint.ts index 61bb1f6..84c37f7 100644 --- a/host-deltic/src/run-endpoint.ts +++ b/host-deltic/src/run-endpoint.ts @@ -419,6 +419,39 @@ async function main(): Promise { `zero wasi:sockets calls (browser profile) — log: [${udpCallLog().join(", ")}]`, ); await deadline(ep.close(), 10_000, "close() after bind"); + + // The deployment-profile latitude (wit/iroh.wit, udp-bind-addr): + // this host has no UDP, so a bind that asks for the direct path + // must fail not-supported — the stub's honest error-code carried + // through. Probed after the zero-calls check above: this call is + // MEANT to reach the stub. + let unsupported = "no error"; + try { + await deadline( + bindEndpoint(inst, { + alpns: [ALPN], + relayUrl: relay.url, + udpBindAddr: "127.0.0.1:0", + webrtc: false, + }), + 30_000, + "bind with udp-bind-addr", + ); + unsupported = "bind succeeded"; + } catch (err) { + if (err instanceof ComponentException) { + const p = err.payload as { kind?: string } | undefined; + unsupported = p?.kind ?? "unknown"; + } else { + unsupported = describeError(err); + } + } + check( + v, + unsupported === "not-supported", + `udp-bind-addr on the browser profile fails not-supported: ${unsupported}`, + ); + await settle(); check(v, takeGuestPanics().length === 0, "no guest trap during bind/identity"); v.detail = `bind ${bindMs.toFixed(0)} ms, id ${shortId(id)}, 3 post-pump export calls`; diff --git a/host-deltic/src/types.ts b/host-deltic/src/types.ts index 52212b8..8209408 100644 --- a/host-deltic/src/types.ts +++ b/host-deltic/src/types.ts @@ -48,6 +48,9 @@ export type IrohError = | { kind: "closed" } | { kind: "reset"; value: bigint } | { kind: "connect-failed"; value: string } + | { kind: "timed-out"; value: string } + | { kind: "not-supported"; value: string } + | { kind: "in-use"; value: string } | { kind: "invalid-argument"; value: string } | { kind: "other"; value: string }; diff --git a/scripts/matrix.sh b/scripts/matrix.sh index 5bd0610..a258b9d 100755 --- a/scripts/matrix.sh +++ b/scripts/matrix.sh @@ -214,11 +214,11 @@ run_pair "interop-udp-theirs-client" \ # exist (the connect must time out, not hang). The wrong-key TLS pin # rejection itself is asserted by component-tls's rpk handshake tests. -# Start a server, run a client expected to FAIL, assert it fails with a -# connect-shaped error; the server never completes and is killed. -# run_client_failure +# Start a server, run a client expected to FAIL, assert it fails with +# the named error case; the server never completes and is killed. +# run_client_failure run_client_failure() { - local name=$1 with_server=$2; shift 2 + local name=$1 with_server=$2 pattern=$3; shift 3 local server_pid="" server_id="0000000000000000000000000000000000000000000000000000000000000000" if [ "$with_server" = 1 ]; then timeout 120 "$EHOST" "$COMPOSED_WASM" --role server --relay "$RELAY_URL" \ @@ -236,20 +236,20 @@ run_client_failure() { [ -n "$server_pid" ] && kill "$server_pid" 2>/dev/null if [ "$client_status" != 0 ] && [ "$client_status" != 124 ] \ - && grep -qi "connect" "$LOGDIR/$name-client.log" \ + && grep -q "$pattern" "$LOGDIR/$name-client.log" \ && ! grep -q "^OK:" "$LOGDIR/$name-client.log"; then echo "PASS $name" else - echo "FAIL $name (client=$client_status, expected a bounded connect failure; logs in $LOGDIR)" + echo "FAIL $name (client=$client_status, expected a bounded $pattern failure; logs in $LOGDIR)" FAILURES=$((FAILURES + 1)) fi } -run_client_failure "endpoint-negative-wrong-alpn" 1 \ +run_client_failure "endpoint-negative-wrong-alpn" 1 "ConnectFailed" \ timeout 60 "$EHOST" "$COMPOSED_WASM" --role client --relay "$RELAY_URL" \ --alpn "iroh-demo-negative/0" --peer -run_client_failure "endpoint-negative-absent-peer" 0 \ +run_client_failure "endpoint-negative-absent-peer" 0 "TimedOut" \ timeout 60 "$EHOST" "$COMPOSED_WASM" --role client --relay "$RELAY_URL" \ --peer diff --git a/wit/iroh.wit b/wit/iroh.wit index 3ca5d9b..636d3f8 100644 --- a/wit/iroh.wit +++ b/wit/iroh.wit @@ -114,9 +114,19 @@ interface types { /// reset code. reset(u64), /// Connection establishment failed: the relay was unreachable, - /// the handshake failed, the peer refused every offered ALPN, - /// or the attempt timed out. + /// the handshake failed, or the peer refused the connection or + /// every offered ALPN. connect-failed(string), + /// The attempt timed out: the peer, or the relay being opened, + /// did not answer within the deadline. + timed-out(string), + /// The deployment does not serve a capability the operation + /// needs: a transport this host does not provide. + not-supported(string), + /// The operation conflicts with another user of the stream + /// half: a `read` or `write` already in flight, or a + /// `read-via-stream`/`write-via-stream` claim. + in-use(string), /// A supplied argument is invalid: an `endpoint-id` that is not /// 32 bytes, or a malformed relay URL. The operation had no /// effect. @@ -221,6 +231,9 @@ interface endpoint { /// direct peers can dial in. Unset binds no socket. A port of /// zero picks a free port; read the result with /// `endpoint.direct-addr`. + /// + /// Not every deployment provides UDP (the browser profile has + /// none): there, `bind` with this set fails `not-supported`. udp-bind-addr: func(addr: string); /// Enables the WebRTC wire: `webrtc` address entries become @@ -228,6 +241,11 @@ interface endpoint { /// on its relay connections. When disabled (the default), /// `webrtc` entries are ignored for dialing and inbound /// signaling is discarded. + /// + /// A deployment that stubs the WebRTC import does not detect + /// that at `bind`: enabling this there leaves `webrtc` entries + /// non-functional — upgrades do not occur and connections stay + /// on their dial path. webrtc: func(enabled: bool); } @@ -258,7 +276,7 @@ interface endpoint { /// The dial path: the first parseable `ip` entry when a UDP /// socket is bound, the relay otherwise. There is no fallback /// between dial paths: a chosen path that does not answer fails - /// `connect-failed` by timeout. + /// `timed-out`. /// /// A `webrtc` entry (with `endpoint-options.webrtc` set) /// upgrades a relay-dialed connection in the background: the @@ -409,7 +427,7 @@ interface endpoint { /// The send half of a QUIC stream. /// /// One in-flight `write` (or `write-via-stream`) at a time: a - /// second concurrent call fails `error.other` immediately. + /// second concurrent call fails `error.in-use` immediately. /// Concurrent writes would interleave at flow-control boundaries — /// silent corruption for framed payloads — so the surface refuses /// them. Dropping the resource without `finish` or `reset` implies @@ -426,7 +444,7 @@ interface endpoint { write: async func(bytes: list) -> result<_, error>; /// Finish the stream: a FIN after all accepted writes. No - /// writes may follow. Fails `error.other` while a `write` is + /// writes may follow. Fails `error.in-use` while a `write` is /// in flight — a FIN under it would truncate the payload. finish: func() -> result<_, error>; @@ -438,7 +456,7 @@ interface endpoint { /// Send the whole remaining payload through a byte stream, /// then finish. One-shot: after it, `write` and `finish` fail - /// `error.other` (the claim is not taken when the call is + /// `error.in-use` (the claim is not taken when the call is /// refused for an in-flight `write`). Resolves when every byte /// has been accepted and the FIN sent. /// @@ -456,7 +474,7 @@ interface endpoint { /// The receive half of a QUIC stream. /// /// One in-flight `read` at a time: a second concurrent call fails - /// `error.other` immediately (concurrent reads would split the + /// `error.in-use` immediately (concurrent reads would split the /// byte sequence between callers). Dropping the resource before /// the stream's end implies `stop(0)`; a stream whose end was /// consumed, or whose bytes a `read-via-stream` owns, needs @@ -482,7 +500,7 @@ interface endpoint { /// Take the stream's remaining bytes as a byte stream, with a /// future reporting how the stream ended. May be called once: /// after it, any call to it or to `read` fails with - /// `error.other`. + /// `error.in-use`. /// /// The byte stream ends at the peer's FIN, at a reset, and at /// a connection close alike; the future tells them apart. It From 6986305024984b2d070acab7068b437267eaf39e Mon Sep 17 00:00:00 2001 From: Lann Martin Date: Wed, 12 Aug 2026 13:03:26 -0400 Subject: [PATCH 2/2] The contract stops over-promising and under-recording MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #13 findings C10, C9 (formats), D15, and the D17 remnant — all doc rulings, no behavior change. C10: connect no longer promotes the v0 dial narrowing to contract. Dial-path selection from addr.addrs is implementation-defined — prefer, race, or fall back are all conforming — so an upstream-backed implementation of this surface (which always races) is no longer non-conformant by construction. This implementation's actual selection stays recorded where the narrowings live (endpoint/src/lib.rs); the matrix rows that lean on no-fallback assert that recorded latitude, not the contract. C9: transport-addr's string formats are specified — relay is an http(s) base URL with one-spelling-per-relay guidance (normalization is implementation-defined beyond the exact string), ip is dotted-quad or bracketed IPv6 with scope-id support implementation-defined and unparseable entries ignored for dialing. D15, resolved as a ruling rather than a declaration: the built component's import set includes a toolchain wasi 0.2 tail (io, cli, clocks, filesystem, random — bind's reset and token keys ride wasi:random through the language's entropy source). Declaring one of those interfaces in the source world at a pinned version would fight toolchain drift and split the import in two on mismatch. The world's doc now states what the world is (the deliberately-bound surface) and names the artifact's embedded WIT as the authoritative import manifest. D17 remnant: read(max: 0) resolving some([]) is documented. Addresses #13 findings C9, C10, D15, D17. --- wit/iroh.wit | 44 +++++++++++++++++++++++++++++++------------- 1 file changed, 31 insertions(+), 13 deletions(-) diff --git a/wit/iroh.wit b/wit/iroh.wit index 636d3f8..8b0bc3c 100644 --- a/wit/iroh.wit +++ b/wit/iroh.wit @@ -23,9 +23,15 @@ interface types { /// transport ID, so they survive round trips through parties that /// do not understand them. variant transport-addr { - /// A relay server the peer keeps a connection open to (a URL). + /// A relay server the peer keeps a connection open to: an + /// `http` or `https` base URL. Use one spelling per relay — + /// implementations normalize at most minimally (equality is + /// implementation-defined beyond the exact string). relay(string), - /// A direct socket address, `ip:port`. + /// A direct socket address, `ip:port`: IPv4 dotted-quad or + /// bracketed IPv6 (`[2001:db8::1]:443`); IPv6 scope-id support + /// is implementation-defined. An entry that does not parse is + /// ignored for dialing but preserved. ip(string), /// Reachable over a WebRTC data channel, negotiated by signaling /// through the given relay server (a URL). Both peers must hold @@ -273,10 +279,12 @@ interface endpoint { /// key fails `connect-failed`, never connects. The connection's /// ALPN is `alpn`, refused by the peer if it does not serve it. /// - /// The dial path: the first parseable `ip` entry when a UDP - /// socket is bound, the relay otherwise. There is no fallback - /// between dial paths: a chosen path that does not answer fails - /// `timed-out`. + /// Dial-path selection from `addr.addrs` is + /// implementation-defined: an implementation may prefer one + /// entry, race several, or fall back between them. A dial + /// that exhausts its selection without an answer fails + /// `timed-out`; a refusal or a handshake failure fails + /// `connect-failed`. /// /// A `webrtc` entry (with `endpoint-options.webrtc` set) /// upgrades a relay-dialed connection in the background: the @@ -489,7 +497,9 @@ interface endpoint { /// FIN — bytes the connection already delivered drain first. /// Either outcome is terminal and repeats on every later call: /// a close after a consumed FIN never turns the clean end into - /// a failure. Cancelling a `read` consumes nothing. + /// a failure. Cancelling a `read` consumes nothing; a `read` + /// with `max` 0 resolves `some([])` without waiting or + /// consuming. read: async func(max: u32) -> result>, error>; /// Stop reading, notifying the peer with `code`. QUIC carries @@ -514,12 +524,20 @@ interface endpoint { /// One iroh endpoint: transports and time in, peer connectivity out. /// -/// The crypto imports (`polymorph:webcrypto`) are mostly consumed by -/// the implementation directly and do not appear here; the `signature` -/// interface does appear, because `identity-from-keys` accepts its key -/// handles. The UDP direct path uses `wasi:sockets/types@0.3.0`, whose -/// `udp-socket` resource carries the socket operations in the 0.3 -/// draft. +/// This world names the interfaces the implementation binds +/// deliberately; it is not the component's complete import set. The +/// language toolchain adds a `wasi` 0.2 tail (io, cli, clocks, +/// filesystem, random — `bind` draws its reset and token keys from +/// `wasi:random` through the language's entropy source), and further +/// `polymorph:webcrypto` interfaces arrive through the +/// implementation's own bindings. The composed artifact's embedded +/// WIT is the authoritative import manifest; read it with +/// `wasm-tools component wit `. +/// +/// The `signature` interface appears because `identity-from-keys` +/// accepts its key handles. The UDP direct path uses +/// `wasi:sockets/types@0.3.0`, whose `udp-socket` resource carries +/// the socket operations in the 0.3 draft. world iroh-endpoint { import polymorph:webcrypto/signature@0.1.0; import polymorph:webrtc-datachannels/connections@0.1.0;