From 766475cb291e904a38f103eaae79c6d28fb90262 Mon Sep 17 00:00:00 2001 From: Lann Martin Date: Wed, 12 Aug 2026 22:41:45 -0400 Subject: [PATCH] wasi-shims: a la carte wasi:sockets UDP over Deno.listenDatagram (#4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adopted from polymorph-components/polymorph-iroh#69 (that host's exam drives it over loopback QUIC end to end). The five-function udp-socket surface the iroh endpoint links — create, bind, send, receive, get-local-address — with wasmtime-wasi p3 as the behavioral yardstick: the 64 KiB datagram ceiling (both detection paths), the bind-once state machine, send's implicit wildcard bind, invalid-argument for an omitted remote on this connectionless surface, and the IPv4-mapped / deprecated-IPv4-compatible family rejections. Recorded divergences from wasmtime (both rooted in Deno exposing no socket options): non-zero scope-id fails not-supported; IPv6 sockets keep the OS dual-stack default, so the codec parses the ::ffff:a.b.c.d sender spelling. A la carte per the s2 WASI non-goal: a separate export (@deltic/wasi-shims/sockets), never merged into wasiShims() and absent from the release bundle — the root module stays host-agnostic web-platform code, and browsers/wasmtime own their respective UDP stories. When Deno.listenDatagram is absent (no Deno global, or --unstable-net off), create answers not-supported. Divergences from the adopted code: the track key (@0.3 — one provider serves every 0.3.x), a fragment-scoped onCall observer replacing the module-global call log (a published provider must not grow a string per datagram by default; the fragment returns its UdpSocket class so the hook stays per-fragment), globalThis-based feature detection with structural types for the unstable surface (no "unstable" flag needed to check or publish), and a ComponentException passthrough in mapPlatformError — branded errors thrown inside the platform-call try blocks (the codec in receive, capability re-detection in bind/send) no longer double-wrap into kind "other". Gates: test-wasi-shims (20 new tests), publish-check, test-bundle. --- docs/architecture.md | 5 +- wasi-shims/deno.json | 5 +- wasi-shims/src/mod.ts | 6 +- wasi-shims/src/sockets.ts | 584 +++++++++++++++++++++++++++++++ wasi-shims/tests/sockets_test.ts | 375 ++++++++++++++++++++ 5 files changed, 971 insertions(+), 4 deletions(-) create mode 100644 wasi-shims/src/sockets.ts create mode 100644 wasi-shims/tests/sockets_test.ts diff --git a/docs/architecture.md b/docs/architecture.md index e0c09b8..b69c30e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -88,7 +88,10 @@ contracts throughout the repo. Related documents: they are the ecosystem's most important interfaces and the conventions must serve them well — and a minimal WASI shim *package* (`wasi-shims/`, a separate deliverable; consumer-driven scope: p2 cli/io/clocks/random - baseline + p3 clocks). + baseline + p3 clocks, plus an à la carte p3 `wasi:sockets` UDP fragment + (`@deltic/wasi-shims/sockets`, adopted from polymorph-iroh's Deno host — + [#4](https://github.com/lann/deltic/issues/4)) that Deno-native hosts + opt into and the default `wasiShims()` never carries). - **Componentizing JS/TS.** Guests are components built by external toolchains (Rust + wit-bindgen is the reference). No embedded-JS-engine work. - **jco compatibility or reuse.** Ignored entirely — including at the diff --git a/wasi-shims/deno.json b/wasi-shims/deno.json index 06841e2..b6a01cf 100644 --- a/wasi-shims/deno.json +++ b/wasi-shims/deno.json @@ -2,10 +2,11 @@ "name": "@deltic/wasi-shims", "version": "0.1.0", "exports": { - ".": "./src/mod.ts" + ".": "./src/mod.ts", + "./sockets": "./src/sockets.ts" }, "tasks": { - "test": "deno test --allow-read tests/", + "test": "deno test --allow-read --allow-net --unstable-net tests/", "check": "deno check src tests" }, "publish": { diff --git a/wasi-shims/src/mod.ts b/wasi-shims/src/mod.ts index 34d4538..844a637 100644 --- a/wasi-shims/src/mod.ts +++ b/wasi-shims/src/mod.ts @@ -1,7 +1,11 @@ // wasi-shims — the minimal WASI shim package (contracts/embedder-api.md // C2 checklist item 7): the executable check that the embedder conventions // (`@deltic/runtime/embedder`) serve WASI. Scope: p2 baseline + -// p3 clocks (mission scope; wasi:sockets/wasi:http deferred). +// p3 clocks (mission scope; wasi:http deferred). p3 sockets UDP is à la +// carte at `@deltic/wasi-shims/sockets` (issue #4; Deno-native hosts +// only) — deliberately not merged here: this root module stays +// host-agnostic web-platform code, and `wasiShims()` never grows a +// fragment whose honest answer on most hosts is `not-supported`. // // `wasiShims(options)` returns one flat imports-record fragment, keyed by // compatibility-**track** keys per contracts/embedder-api.md §"Version diff --git a/wasi-shims/src/sockets.ts b/wasi-shims/src/sockets.ts new file mode 100644 index 0000000..32bd961 --- /dev/null +++ b/wasi-shims/src/sockets.ts @@ -0,0 +1,584 @@ +// `wasi:sockets/types@0.3` — the UDP direct path, served for real over +// `Deno.listenDatagram`. À la carte (issue #4): this module is a separate +// export (`@deltic/wasi-shims/sockets`), never merged into `wasiShims()` — +// the baseline package stays host-agnostic web-platform code, while this +// fragment is Deno-native by nature (browsers have no UDP; wasmtime owns +// the native story). Consumers that want the direct path spread it in: +// +// instantiate(artifacts, { ...wasiShims(), ...sockets().imports }) +// +// Adopted from polymorph-components/polymorph-iroh#69 (that host's exam +// drives it over loopback QUIC); divergences from the adopted code are +// the track key (`@0.3`, per this package's conventions — one provider +// serves every 0.3.x), the fragment-scoped `onCall` hook replacing a +// module-global call log (a published provider must not grow a string per +// datagram by default), and `globalThis`-based feature detection (the +// module evaluates and answers honestly on hosts with no `Deno` at all). +// +// The resource shape below is the one the iroh endpoint component actually +// links (transcribed from the artifact's own embedded WIT, which agrees +// with upstream `wit/deps/wasi-sockets`): +// +// resource udp-socket { +// create: static func(address-family: ip-address-family) -> result; +// bind: func(local-address: ip-socket-address) -> result<_, error-code>; +// send: async func(data: list, remote-address: option) -> result<_, error-code>; +// receive: async func() -> result, ip-socket-address>, error-code>; +// get-local-address: func() -> result; +// } +// +// That five-function surface is also exactly what this module implements: +// the runtime dispatches only the functions a component's plan imports, so +// the unlinked remainder of the WIT resource (connect/disconnect, socket +// options) stays absent, and a future guest that links more fails loudly +// with a trap naming the missing method rather than riding an untested +// emulation. +// +// The behavioral yardstick is wasmtime-wasi's p3 provider (the consumers' +// wasmtime hosts serve the same guests through it): the same 64 KiB +// datagram ceiling, the same state machine (`bind` once from unbound; +// `receive` and `get-local-address` demand a bound socket; `send` to a +// remote implicitly binds an unbound socket to a wildcard address; an +// omitted `send` remote is `invalid-argument` on this connectionless +// surface), and the same address-family validation (an IPv4-mapped or +// deprecated IPv4-compatible IPv6 address never crosses a family +// boundary). Recorded divergences, both rooted in `Deno.listenDatagram` +// exposing no socket options: +// +// * scope-id: a non-zero IPv6 `scope-id` fails `not-supported` (Deno +// hostnames cannot carry a zone; wasmtime binds it). +// * v6-only: wasmtime sets IPV6_V6ONLY on IPv6 sockets; Deno leaves the +// OS default, so an `::` wildcard bind on Linux is dual-stack and may +// receive IPv4 traffic, surfaced as IPv4-mapped sender addresses — +// which is also why the address codec parses the `::ffff:a.b.c.d` +// spelling. +// +// When `Deno.listenDatagram` is absent (no `Deno` global, or the `net` +// unstable feature off — it needs `--unstable-net` or deno.json +// `"unstable": ["net"]`), `create` fails `error-code.not-supported` — the +// honest capability answer a UDP-less deployment gives. + +import { ComponentException } from "@deltic/runtime/embedder"; + +/** `wasi:sockets/types@0.3`'s `ip-address-family` enum. */ +export type IpAddressFamily = "ipv4" | "ipv6"; + +/** `ipv4-address` = `tuple`. */ +export type Ipv4Address = [number, number, number, number]; + +/** `ipv6-address` = `tuple`. */ +export type Ipv6Address = [ + number, + number, + number, + number, + number, + number, + number, + number, +]; + +export interface Ipv4SocketAddress { + port: number; + address: Ipv4Address; +} + +export interface Ipv6SocketAddress { + port: number; + flowInfo: number; + address: Ipv6Address; + scopeId: number; +} + +/** The `ip-socket-address` variant, in `{ kind, value }` form (A10). */ +export type IpSocketAddress = + | { kind: "ipv4"; value: Ipv4SocketAddress } + | { kind: "ipv6"; value: Ipv6SocketAddress }; + +/** + * The `error-code` variant. Every case is listed so callers can switch + * exhaustively against the real WIT vocabulary. + */ +export type SocketErrorCode = + | { kind: "access-denied" } + | { kind: "not-supported" } + | { kind: "invalid-argument" } + | { kind: "out-of-memory" } + | { kind: "timeout" } + | { kind: "invalid-state" } + | { kind: "address-not-bindable" } + | { kind: "address-in-use" } + | { kind: "remote-unreachable" } + | { kind: "connection-refused" } + | { kind: "connection-broken" } + | { kind: "connection-reset" } + | { kind: "connection-aborted" } + | { kind: "datagram-too-large" } + | { kind: "other"; value?: string }; + +/** + * The datagram payload ceiling, matching wasmtime-wasi's + * `MAX_UDP_DATAGRAM_SIZE` (`u16::MAX`). Larger sends fail + * `datagram-too-large` before reaching the OS; receives use a buffer of + * this size so no datagram the OS delivers is ever truncated. + */ +export const MAX_UDP_DATAGRAM_SIZE = 65535; + +/** + * A WIT `err` the branded way (contracts/embedder-api.md, "Error model"): + * an unbranded throw would become a trap naming the import instead of a + * guest-visible err. + */ +function componentError( + payload: SocketErrorCode, + detail: string, +): ComponentException { + return new ComponentException( + payload, + `wasi:sockets/types@0.3: ${detail}`, + ); +} + +// --- the unstable Deno surface, typed structurally --------------------------- +// +// `Deno.listenDatagram` is unstable, so its declarations are absent from +// the stable type surface this package checks against; and on a non-Deno +// host the `Deno` global is absent entirely. Both are handled the same +// way: the surface is typed structurally here and looked up through +// `globalThis` at call time, so the module never assumes the API at +// evaluation and `create` can answer `not-supported` truthfully. + +/** The address shape `Deno.listenDatagram` speaks (structural `Deno.NetAddr`). */ +export interface NetAddr { + transport?: string; + hostname: string; + port: number; +} + +interface DatagramConn { + readonly addr: NetAddr; + send(p: Uint8Array, addr: NetAddr): Promise; + receive(p?: Uint8Array): Promise<[Uint8Array, NetAddr]>; + close(): void; +} + +type ListenDatagram = (options: { + transport: "udp"; + hostname: string; + port: number; +}) => DatagramConn; + +function denoNamespace(): Record | undefined { + const deno = (globalThis as { Deno?: unknown }).Deno; + return typeof deno === "object" && deno !== null + ? (deno as Record) + : undefined; +} + +function listenDatagram(): ListenDatagram | undefined { + const fn = denoNamespace()?.listenDatagram; + return typeof fn === "function" ? (fn as ListenDatagram) : undefined; +} + +// --- address codec ------------------------------------------------------------ + +/** Render the address part of `addr` as a Deno hostname string. */ +export function ipHostname(addr: IpSocketAddress): string { + if (addr.kind === "ipv4") return addr.value.address.join("."); + // The uncompressed spelling; Deno's address parser accepts it. + return addr.value.address.map((g) => g.toString(16)).join(":"); +} + +/** + * Parse a Deno `NetAddr` back into a WIT `ip-socket-address`. + * + * Handles the compressed (`::1`), full, IPv4-embedded (`::ffff:127.0.0.1` — + * what a dual-stack socket reports for IPv4 senders), and zoned + * (`fe80::1%3`) hostname spellings. A zone parses as the numeric scope-id + * when it is numeric and drops to 0 otherwise (interface names are not + * representable in the WIT shape); flow-info is not observable and is + * always 0. + */ +export function parseNetAddr(addr: NetAddr): IpSocketAddress { + const host = addr.hostname; + if (!host.includes(":")) { + const octets = host.split(".").map(Number); + if (octets.length !== 4 || octets.some((o) => !Number.isInteger(o) || o < 0 || o > 255)) { + throw componentError( + { kind: "other", value: `unparseable IPv4 hostname ${JSON.stringify(host)}` }, + `unparseable IPv4 hostname ${JSON.stringify(host)}`, + ); + } + return { + kind: "ipv4", + value: { port: addr.port, address: octets as Ipv4Address }, + }; + } + const { groups, scopeId } = parseIpv6Hostname(host); + return { + kind: "ipv6", + value: { port: addr.port, flowInfo: 0, address: groups, scopeId }, + }; +} + +function parseIpv6Hostname(hostname: string): { groups: Ipv6Address; scopeId: number } { + let host = hostname; + let scopeId = 0; + const pct = host.indexOf("%"); + if (pct >= 0) { + const zone = Number(host.slice(pct + 1)); + scopeId = Number.isInteger(zone) && zone >= 0 ? zone : 0; + host = host.slice(0, pct); + } + + const fail = (): never => { + throw componentError( + { kind: "other", value: `unparseable IPv6 hostname ${JSON.stringify(hostname)}` }, + `unparseable IPv6 hostname ${JSON.stringify(hostname)}`, + ); + }; + const dc = host.indexOf("::"); + let head: string[]; + let tail: string[]; + if (dc >= 0) { + if (host.indexOf("::", dc + 1) >= 0) fail(); + head = dc === 0 ? [] : host.slice(0, dc).split(":"); + tail = dc + 2 === host.length ? [] : host.slice(dc + 2).split(":"); + } else { + head = host.split(":"); + tail = []; + } + + // An embedded dotted quad ("::ffff:127.0.0.1") occupies the last two + // groups. + const last = tail.length > 0 ? tail : head; + const pieces = [...head]; + let tailPieces = [...tail]; + let v4Tail: [number, number] | undefined; + if (last.length > 0 && last[last.length - 1].includes(".")) { + const quad = last[last.length - 1].split(".").map(Number); + if (quad.length !== 4 || quad.some((o) => !Number.isInteger(o) || o < 0 || o > 255)) fail(); + v4Tail = [(quad[0] << 8) | quad[1], (quad[2] << 8) | quad[3]]; + if (tail.length > 0) tailPieces = tail.slice(0, -1); + else pieces.pop(); + } + + const parseGroup = (piece: string): number => { + if (!/^[0-9a-fA-F]{1,4}$/.test(piece)) fail(); + return parseInt(piece, 16); + }; + const headGroups = pieces.map(parseGroup); + const tailGroups = [...tailPieces.map(parseGroup), ...(v4Tail ?? [])]; + const total = headGroups.length + tailGroups.length; + if (dc >= 0) { + if (total > 7) fail(); + while (headGroups.length + tailGroups.length < 8) headGroups.push(0); + } else if (total !== 8) { + fail(); + } + return { groups: [...headGroups, ...tailGroups] as Ipv6Address, scopeId }; +} + +// --- validation (wasmtime-wasi `sockets/util.rs` parity) ---------------------- + +function isV4MappedV6(groups: Ipv6Address): boolean { + return groups[0] === 0 && groups[1] === 0 && groups[2] === 0 && + groups[3] === 0 && groups[4] === 0 && groups[5] === 0xffff; +} + +/** The deprecated IPv4-compatible range, excluding `::` and `::1`. */ +function isDeprecatedV4CompatibleV6(groups: Ipv6Address): boolean { + const headZero = groups.slice(0, 6).every((g) => g === 0); + if (!headZero) return false; + const unspecified = groups[6] === 0 && groups[7] === 0; + const localhost = groups[6] === 0 && groups[7] === 1; + return !unspecified && !localhost; +} + +/** + * Whether `addr` may cross this socket's family boundary: same family, and + * never an IPv4-mapped or deprecated IPv4-compatible IPv6 address. + */ +function isValidAddressFamily(family: IpAddressFamily, addr: IpSocketAddress): boolean { + if (family === "ipv4") return addr.kind === "ipv4"; + return addr.kind === "ipv6" && + !isV4MappedV6(addr.value.address) && + !isDeprecatedV4CompatibleV6(addr.value.address); +} + +function isUnspecified(addr: IpSocketAddress): boolean { + if (addr.kind === "ipv4") return addr.value.address.every((o) => o === 0); + return addr.value.address.every((g) => g === 0); +} + +// --- error mapping ------------------------------------------------------------ + +/** `e instanceof Deno.errors[name]`, tolerating hosts/versions lacking the class. */ +function isDenoError(e: unknown, name: string): boolean { + const errors = denoNamespace()?.errors; + if (typeof errors !== "object" || errors === null) return false; + const cls = (errors as Record)[name]; + return typeof cls === "function" && + e instanceof (cls as new () => Error); +} + +/** + * Map a platform failure onto the WIT `error-code` vocabulary, mirroring + * wasmtime-wasi's io-error table where Deno exposes the distinction. An + * already-branded error passes through unchanged — the codec and the + * capability re-detection throw branded errors from inside the same try + * blocks that guard the platform calls, and re-wrapping one would demote + * its payload to `other`. + */ +export function mapPlatformError(e: unknown, what: string): ComponentException { + if (e instanceof ComponentException) return e as ComponentException; + const message = e instanceof Error ? e.message : String(e); + const err = (payload: SocketErrorCode): ComponentException => + componentError(payload, `${what}: ${message}`); + if (isDenoError(e, "AddrInUse")) return err({ kind: "address-in-use" }); + if (isDenoError(e, "AddrNotAvailable")) return err({ kind: "address-not-bindable" }); + if (isDenoError(e, "ConnectionRefused")) return err({ kind: "connection-refused" }); + if (isDenoError(e, "ConnectionReset")) return err({ kind: "connection-reset" }); + if (isDenoError(e, "ConnectionAborted")) return err({ kind: "connection-aborted" }); + if (isDenoError(e, "NetworkUnreachable") || isDenoError(e, "HostUnreachable")) { + return err({ kind: "remote-unreachable" }); + } + if (isDenoError(e, "PermissionDenied") || isDenoError(e, "NotCapable")) { + return err({ kind: "access-denied" }); + } + if (isDenoError(e, "TimedOut")) return err({ kind: "timeout" }); + // A socket closed under a pending operation: the operation was not valid + // in the socket's (now closed) state. + if (isDenoError(e, "Interrupted") || isDenoError(e, "BadResource")) { + return err({ kind: "invalid-state" }); + } + if (isDenoError(e, "NotSupported")) return err({ kind: "not-supported" }); + // EMSGSIZE surfaces as a plain Error, not a Deno.errors class. + if (/message too long/i.test(message)) return err({ kind: "datagram-too-large" }); + if (e instanceof TypeError) return err({ kind: "invalid-argument" }); + return err({ kind: "other", value: message }); +} + +// --- the fragment -------------------------------------------------------------- + +export interface SocketsOptions { + /** + * Observe every `wasi:sockets` entry point the guest reaches, in call + * order (`"udp-socket.create"`, `"udp-socket.bind"`, …). For host-side + * test assertions — a relay-only scenario can assert zero calls, an exam + * can read back the guest's exact driving sequence. No default cost: when + * absent, nothing is recorded. + */ + onCall?: (call: string) => void; +} + +/** + * The host-implemented `udp-socket` resource surface: a plain class with + * camelCase methods and the WIT `static` as a JS static + * (contracts/embedder-api.md, "Resources"). The runtime calls + * `[Symbol.dispose]` when the guest drops its last handle; that closes the + * OS socket, settling any still-pending `receive` as a branded err. + */ +export interface UdpSocket { + bind(localAddress: IpSocketAddress): void; + send(data: Uint8Array, remoteAddress: IpSocketAddress | undefined): Promise; + receive(): Promise<[Uint8Array, IpSocketAddress]>; + getLocalAddress(): IpSocketAddress; + [Symbol.dispose](): void; +} + +/** The `udp-socket` resource class a fragment carries. */ +export interface UdpSocketClass { + create(addressFamily: IpAddressFamily): UdpSocket; +} + +export const SOCKETS_TYPES_INTERFACE = "wasi:sockets/types@0.3"; + +/** What `sockets()` returns: the imports fragment plus the fragment's class. */ +export interface SocketsShim { + imports: Record; + /** This fragment's resource class (exposed for direct/test use). */ + UdpSocket: UdpSocketClass; +} + +/** + * `wasi:sockets/types@0.3` provider fragment (track key: one provider + * serves every 0.3.x the resolver folds onto the track). The resource + * class is built per fragment so the `onCall` observer is scoped to it. + */ +export function sockets(options: SocketsOptions = {}): SocketsShim { + const onCall = options.onCall ?? ((): void => {}); + + class UdpSocket { + #family: IpAddressFamily; + #conn: DatagramConn | undefined; + + private constructor(family: IpAddressFamily) { + this.#family = family; + } + + static create(addressFamily: IpAddressFamily): UdpSocket { + onCall("udp-socket.create"); + if (listenDatagram() === undefined) { + throw componentError( + { kind: "not-supported" }, + "udp-socket.create: this host provides no datagram sockets " + + "(Deno.listenDatagram is unavailable; it needs the `net` unstable feature)", + ); + } + return new UdpSocket(addressFamily); + } + + bind(localAddress: IpSocketAddress): void { + onCall("udp-socket.bind"); + if (this.#conn !== undefined) { + throw componentError({ kind: "invalid-state" }, "udp-socket.bind: already bound"); + } + if (!isValidAddressFamily(this.#family, localAddress)) { + throw componentError( + { kind: "invalid-argument" }, + `udp-socket.bind: address family mismatch (an ${this.#family} socket)`, + ); + } + if (localAddress.kind === "ipv6" && localAddress.value.scopeId !== 0) { + throw componentError( + { kind: "not-supported" }, + "udp-socket.bind: non-zero scope-id (not expressible through Deno.listenDatagram)", + ); + } + try { + this.#conn = this.#listen({ + transport: "udp", + hostname: ipHostname(localAddress), + port: localAddress.value.port, + }); + } catch (e) { + throw mapPlatformError(e, "udp-socket.bind"); + } + } + + async send(data: Uint8Array, remoteAddress: IpSocketAddress | undefined): Promise { + onCall("udp-socket.send"); + if (data.length > MAX_UDP_DATAGRAM_SIZE) { + throw componentError( + { kind: "datagram-too-large" }, + `udp-socket.send: ${data.length} bytes exceeds the ${MAX_UDP_DATAGRAM_SIZE}-byte ceiling`, + ); + } + // This surface has no `connect`, so the socket is never in connected + // mode and an omitted remote has no destination to fall back to + // (POSIX EDESTADDRREQ). + if (remoteAddress === undefined) { + throw componentError( + { kind: "invalid-argument" }, + "udp-socket.send: no remote-address, and the socket is not connected", + ); + } + if (this.#conn === undefined) { + // "If the socket has not been explicitly bound, it will be implicitly + // bound to a random free port" — the wildcard bind wasmtime performs. + try { + this.#conn = this.#listen({ + transport: "udp", + hostname: this.#family === "ipv4" ? "0.0.0.0" : "::", + port: 0, + }); + } catch (e) { + throw mapPlatformError(e, "udp-socket.send (implicit bind)"); + } + } + if ( + !isValidAddressFamily(this.#family, remoteAddress) || + isUnspecified(remoteAddress) || + remoteAddress.value.port === 0 + ) { + throw componentError( + { kind: "invalid-argument" }, + "udp-socket.send: the remote address must be a specific address and " + + `non-zero port in the socket's family (${this.#family})`, + ); + } + if (remoteAddress.kind === "ipv6" && remoteAddress.value.scopeId !== 0) { + throw componentError( + { kind: "not-supported" }, + "udp-socket.send: non-zero scope-id (not expressible through Deno addresses)", + ); + } + let sent: number; + try { + sent = await this.#conn.send(data, { + transport: "udp", + hostname: ipHostname(remoteAddress), + port: remoteAddress.value.port, + }); + } catch (e) { + throw mapPlatformError(e, "udp-socket.send"); + } + if (sent !== data.length) { + throw componentError( + { kind: "other", value: `partial send: ${sent} of ${data.length} bytes` }, + `udp-socket.send: partial send: ${sent} of ${data.length} bytes`, + ); + } + } + + async receive(): Promise<[Uint8Array, IpSocketAddress]> { + onCall("udp-socket.receive"); + if (this.#conn === undefined) { + throw componentError( + { kind: "invalid-state" }, + "udp-socket.receive: the socket is not bound", + ); + } + // A fresh buffer per call: concurrent receives on one socket are legal, + // and Deno's default buffer size is not contractual. + const buffer = new Uint8Array(MAX_UDP_DATAGRAM_SIZE); + try { + const [payload, from] = await this.#conn.receive(buffer); + return [payload, parseNetAddr(from)]; + } catch (e) { + throw mapPlatformError(e, "udp-socket.receive"); + } + } + + getLocalAddress(): IpSocketAddress { + onCall("udp-socket.get-local-address"); + if (this.#conn === undefined) { + throw componentError( + { kind: "invalid-state" }, + "udp-socket.get-local-address: the socket is not bound", + ); + } + return parseNetAddr(this.#conn.addr); + } + + [Symbol.dispose](): void { + const conn = this.#conn; + this.#conn = undefined; + if (conn !== undefined) { + try { + conn.close(); + } catch { + // Already closed. + } + } + } + + /** Re-detect per call: `create`'s answer must not outlive a test's stub. */ + #listen(opts: { transport: "udp"; hostname: string; port: number }): DatagramConn { + const listen = listenDatagram(); + if (listen === undefined) { + throw componentError( + { kind: "not-supported" }, + "udp-socket: Deno.listenDatagram disappeared after create", + ); + } + return listen(opts); + } + } + + return { + imports: { [SOCKETS_TYPES_INTERFACE]: { UdpSocket } }, + UdpSocket, + }; +} diff --git a/wasi-shims/tests/sockets_test.ts b/wasi-shims/tests/sockets_test.ts new file mode 100644 index 0000000..7b4ff20 --- /dev/null +++ b/wasi-shims/tests/sockets_test.ts @@ -0,0 +1,375 @@ +// Unit tests for the à la carte `wasi:sockets/types@0.3` UDP provider +// (src/sockets.ts, adopted from polymorph-components/polymorph-iroh#69): +// the address codec, the wasmtime-parity state machine, and the error +// mapping — including the failure shapes a happy-path consumer exam never +// reaches. +// +// Every guest-visible failure must arrive as a BRANDED ComponentException +// (contracts/embedder-api.md, "Error model"): an assertion here failing +// with a bare Error means the guest would have seen a trap, not an err. +// +// These tests bind real UDP sockets on loopback, so the test task carries +// `--allow-net --unstable-net`; without `--unstable-net` the provider +// (correctly) answers `not-supported` and everything past `create` fails. + +import { ComponentException } from "@deltic/runtime/embedder"; +import { + ipHostname, + type IpSocketAddress, + MAX_UDP_DATAGRAM_SIZE, + parseNetAddr, + type SocketErrorCode, + sockets, + SOCKETS_TYPES_INTERFACE, + type UdpSocket, +} from "../src/sockets.ts"; +import { assertEq, assertRejects, assertThrows, assertTrue } from "./asserts.ts"; + +const { UdpSocket } = sockets(); + +const v4 = (address: [number, number, number, number], port: number): IpSocketAddress => ({ + kind: "ipv4", + value: { port, address }, +}); + +const v6 = ( + address: [number, number, number, number, number, number, number, number], + port: number, + scopeId = 0, +): IpSocketAddress => ({ + kind: "ipv6", + value: { port, flowInfo: 0, address, scopeId }, +}); + +/** Structural equality, the package test convention (io_test.ts). */ +function assertAddrEq(actual: IpSocketAddress, expected: IpSocketAddress, msg?: string): void { + assertEq(JSON.stringify(actual), JSON.stringify(expected), msg); +} + +/** The payload kind of a thrown, branded socket error. */ +function errKind(fn: () => unknown): string { + const e = assertThrows(fn); + assertTrue(e instanceof ComponentException, `expected ComponentException, got ${e}`); + return ((e as ComponentException).payload).kind; +} + +async function errKindAsync(p: Promise): Promise { + const e = await assertRejects(() => p); + assertTrue(e instanceof ComponentException, `expected ComponentException, got ${e}`); + return ((e as ComponentException).payload).kind; +} + +function dispose(socket: UdpSocket): void { + socket[Symbol.dispose](); +} + +/** A socket bound to an ephemeral IPv4 loopback port. */ +function boundV4(): { socket: UdpSocket; addr: IpSocketAddress } { + const socket = UdpSocket.create("ipv4"); + socket.bind(v4([127, 0, 0, 1], 0)); + return { socket, addr: socket.getLocalAddress() }; +} + +// --- address codec ----------------------------------------------------------- + +Deno.test("codec: IPv4 round trip", () => { + const addr = v4([127, 0, 0, 1], 4242); + assertEq(ipHostname(addr), "127.0.0.1"); + assertAddrEq(parseNetAddr({ transport: "udp", hostname: "127.0.0.1", port: 4242 }), addr); +}); + +Deno.test("codec: IPv6 hostname spellings", () => { + const port = 7; + assertAddrEq( + parseNetAddr({ transport: "udp", hostname: "::1", port }), + v6([0, 0, 0, 0, 0, 0, 0, 1], port), + ); + assertAddrEq( + parseNetAddr({ transport: "udp", hostname: "::", port }), + v6([0, 0, 0, 0, 0, 0, 0, 0], port), + ); + assertAddrEq( + parseNetAddr({ transport: "udp", hostname: "2001:db8::8a2e:370:7334", port }), + v6([0x2001, 0xdb8, 0, 0, 0, 0x8a2e, 0x370, 0x7334], port), + ); + assertAddrEq( + parseNetAddr({ transport: "udp", hostname: "0:0:0:0:0:0:0:1", port }), + v6([0, 0, 0, 0, 0, 0, 0, 1], port), + ); + // The dual-stack rendering of an IPv4 sender (see the module header). + assertAddrEq( + parseNetAddr({ transport: "udp", hostname: "::ffff:127.0.0.1", port }), + v6([0, 0, 0, 0, 0, 0xffff, 0x7f00, 0x0001], port), + ); + // Zones: numeric parses as the scope-id; a name is not representable. + assertAddrEq( + parseNetAddr({ transport: "udp", hostname: "fe80::1%3", port }), + v6([0xfe80, 0, 0, 0, 0, 0, 0, 1], port, 3), + ); + assertAddrEq( + parseNetAddr({ transport: "udp", hostname: "fe80::1%eth0", port }), + v6([0xfe80, 0, 0, 0, 0, 0, 0, 1], port, 0), + ); +}); + +Deno.test("codec: IPv6 hostname formatting is the uncompressed form", () => { + assertEq(ipHostname(v6([0, 0, 0, 0, 0, 0, 0, 1], 0)), "0:0:0:0:0:0:0:1"); + assertEq( + ipHostname(v6([0x2001, 0xdb8, 0, 0, 0, 0x8a2e, 0x370, 0x7334], 0)), + "2001:db8:0:0:0:8a2e:370:7334", + ); +}); + +// --- bind + local address ---------------------------------------------------- + +Deno.test("bind: ephemeral IPv4 loopback, get-local-address reports the port", () => { + const { socket, addr } = boundV4(); + try { + assertEq(addr.kind, "ipv4"); + if (addr.kind !== "ipv4") return; + assertEq(JSON.stringify(addr.value.address), JSON.stringify([127, 0, 0, 1])); + assertTrue(addr.value.port !== 0, "an ephemeral port was assigned"); + } finally { + dispose(socket); + } +}); + +Deno.test("bind: ephemeral IPv6 loopback", () => { + const socket = UdpSocket.create("ipv6"); + try { + socket.bind(v6([0, 0, 0, 0, 0, 0, 0, 1], 0)); + const addr = socket.getLocalAddress(); + assertEq(addr.kind, "ipv6"); + if (addr.kind !== "ipv6") return; + assertEq(JSON.stringify(addr.value.address), JSON.stringify([0, 0, 0, 0, 0, 0, 0, 1])); + assertTrue(addr.value.port !== 0); + } finally { + dispose(socket); + } +}); + +// --- the data path ----------------------------------------------------------- + +Deno.test("send/receive: a datagram crosses loopback with its source address", async () => { + const a = boundV4(); + const b = boundV4(); + try { + await a.socket.send(new Uint8Array([1, 2, 3]), b.addr); + const [payload, from] = await b.socket.receive(); + assertEq(JSON.stringify([...payload]), JSON.stringify([1, 2, 3])); + assertAddrEq(from, a.addr); + } finally { + dispose(a.socket); + dispose(b.socket); + } +}); + +Deno.test("send/receive: a zero-length datagram to self (a pump's self-wake)", async () => { + const { socket, addr } = boundV4(); + try { + const pending = socket.receive(); + await socket.send(new Uint8Array(0), addr); + const [payload, from] = await pending; + assertEq(payload.length, 0); + assertAddrEq(from, addr); + } finally { + dispose(socket); + } +}); + +Deno.test("send/receive: a 4096-byte datagram (a QUIC guest's packet ceiling)", async () => { + const a = boundV4(); + const b = boundV4(); + try { + const big = new Uint8Array(4096); + for (let i = 0; i < big.length; i++) big[i] = i % 251; + await a.socket.send(big, b.addr); + const [payload] = await b.socket.receive(); + assertEq(payload.length, big.length); + assertEq(JSON.stringify([...payload]), JSON.stringify([...big])); + } finally { + dispose(a.socket); + dispose(b.socket); + } +}); + +Deno.test("send: implicit bind on an unbound socket", async () => { + const listener = boundV4(); + const sender = UdpSocket.create("ipv4"); + try { + await sender.send(new Uint8Array([9]), listener.addr); + const local = sender.getLocalAddress(); + assertTrue(local.kind === "ipv4" && local.value.port !== 0, "send bound the socket"); + const [payload] = await listener.socket.receive(); + assertEq(JSON.stringify([...payload]), JSON.stringify([9])); + } finally { + dispose(sender); + dispose(listener.socket); + } +}); + +// --- error contract ---------------------------------------------------------- + +Deno.test("errors: the datagram-too-large ceiling, both detection paths", async () => { + const { socket, addr } = boundV4(); + try { + // Above the WIT ceiling: refused before the OS. + assertEq( + await errKindAsync(socket.send(new Uint8Array(MAX_UDP_DATAGRAM_SIZE + 1), addr)), + "datagram-too-large", + ); + // Under the ceiling but above the UDP payload maximum: the OS's + // EMSGSIZE, mapped. + assertEq( + await errKindAsync(socket.send(new Uint8Array(65508), addr)), + "datagram-too-large", + ); + } finally { + dispose(socket); + } +}); + +Deno.test("errors: the unbound state machine", async () => { + const socket = UdpSocket.create("ipv4"); + assertEq(await errKindAsync(socket.receive()), "invalid-state"); + assertEq(errKind(() => socket.getLocalAddress()), "invalid-state"); +}); + +Deno.test("errors: bind is once-only and surfaces address-in-use", () => { + const { socket, addr } = boundV4(); + const other = UdpSocket.create("ipv4"); + try { + assertEq(errKind(() => socket.bind(v4([127, 0, 0, 1], 0))), "invalid-state"); + assertEq(errKind(() => other.bind(addr)), "address-in-use"); + } finally { + dispose(socket); + dispose(other); + } +}); + +Deno.test("errors: send argument validation", async () => { + const { socket } = boundV4(); + const v6Socket = UdpSocket.create("ipv6"); + try { + assertEq( + await errKindAsync(socket.send(new Uint8Array([1]), undefined)), + "invalid-argument", + ); + // Family mismatch, unspecified address, port zero. + assertEq( + await errKindAsync(socket.send(new Uint8Array([1]), v6([0, 0, 0, 0, 0, 0, 0, 1], 9))), + "invalid-argument", + ); + assertEq( + await errKindAsync(socket.send(new Uint8Array([1]), v4([0, 0, 0, 0], 9))), + "invalid-argument", + ); + assertEq( + await errKindAsync(socket.send(new Uint8Array([1]), v4([127, 0, 0, 1], 0))), + "invalid-argument", + ); + // An IPv4-mapped IPv6 address never crosses the family boundary + // (wasmtime-wasi parity). + assertEq( + await errKindAsync( + v6Socket.send(new Uint8Array([1]), v6([0, 0, 0, 0, 0, 0xffff, 0x7f00, 1], 9)), + ), + "invalid-argument", + ); + } finally { + dispose(socket); + dispose(v6Socket); + } +}); + +Deno.test("errors: a non-zero scope-id is not-supported (recorded divergence)", () => { + const socket = UdpSocket.create("ipv6"); + try { + assertEq( + errKind(() => socket.bind(v6([0xfe80, 0, 0, 0, 0, 0, 0, 1], 0, 3))), + "not-supported", + ); + } finally { + dispose(socket); + } +}); + +Deno.test("errors: create without Deno.listenDatagram is not-supported", () => { + // The capability answer a UDP-less deployment gives (a browser host, or + // Deno without --unstable-net). + const ns = Deno as { listenDatagram?: unknown }; + const saved = ns.listenDatagram; + ns.listenDatagram = undefined; + try { + assertEq(errKind(() => UdpSocket.create("ipv4")), "not-supported"); + } finally { + ns.listenDatagram = saved; + } +}); + +Deno.test("errors: capability re-detection at bind survives the error mapper", () => { + // The branded not-supported from the disappeared-after-create path must + // pass through mapPlatformError unchanged, not demote to `other`. + const socket = UdpSocket.create("ipv4"); + const ns = Deno as { listenDatagram?: unknown }; + const saved = ns.listenDatagram; + ns.listenDatagram = undefined; + try { + assertEq(errKind(() => socket.bind(v4([127, 0, 0, 1], 0))), "not-supported"); + } finally { + ns.listenDatagram = saved; + dispose(socket); + } +}); + +// --- teardown ---------------------------------------------------------------- + +Deno.test("dispose: idempotent; a disposed socket is unbound again", async () => { + const { socket } = boundV4(); + dispose(socket); + dispose(socket); + assertEq(await errKindAsync(socket.receive()), "invalid-state"); +}); + +Deno.test("dispose: a pending receive settles as a branded err, never a trap", async () => { + const { socket } = boundV4(); + const pending = socket.receive(); + // Let the receive park before the close pulls the socket out from under + // it. + await new Promise((r) => setTimeout(r, 20)); + dispose(socket); + assertEq(await errKindAsync(pending), "invalid-state"); +}); + +// --- the fragment shape ------------------------------------------------------ + +Deno.test("fragment: registered under the track key; onCall observes the driving sequence", () => { + const calls: string[] = []; + const fragment = sockets({ onCall: (c) => calls.push(c) }); + assertEq(SOCKETS_TYPES_INTERFACE, "wasi:sockets/types@0.3"); + const iface = fragment.imports[SOCKETS_TYPES_INTERFACE] as { UdpSocket: typeof UdpSocket }; + assertEq(iface.UdpSocket, fragment.UdpSocket); + const socket = fragment.UdpSocket.create("ipv4"); + try { + socket.bind(v4([127, 0, 0, 1], 0)); + socket.getLocalAddress(); + assertEq( + JSON.stringify(calls), + JSON.stringify(["udp-socket.create", "udp-socket.bind", "udp-socket.get-local-address"]), + ); + } finally { + dispose(socket); + } +}); + +Deno.test("fragment: onCall is per fragment, not shared module state", () => { + const aCalls: string[] = []; + const a = sockets({ onCall: (c) => aCalls.push(c) }); + const b = sockets(); + const theirs = b.UdpSocket.create("ipv4"); + dispose(theirs); + assertEq(aCalls.length, 0, "a foreign fragment's calls never leak in"); + const mine = a.UdpSocket.create("ipv4"); + dispose(mine); + assertEq(JSON.stringify(aCalls), JSON.stringify(["udp-socket.create"])); +});