Skip to content

wasi-shims: client TCP for the à la carte wasi:sockets fragment; embedder A12 (future-typed import results) - #123

Merged
lannbot merged 3 commits into
mainfrom
wasi-sockets-tcp
Aug 13, 2026
Merged

wasi-shims: client TCP for the à la carte wasi:sockets fragment; embedder A12 (future-typed import results)#123
lannbot merged 3 commits into
mainfrom
wasi-sockets-tcp

Conversation

@lannbot

@lannbot lannbot commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

The decision

@deltic/wasi-shims/sockets grows the client TCP surface — the shape the wosh listener bridges through (listener-core/src/tcp.rs: create → connect → send(stream<u8>)receive()) and the smoke-c0 leg-4 composed-websocket shopping list names. Same à la carte stance as the UDP fragment (#122): subpath export, never in wasiShims(), absent from the release bundle. Over stable Deno.connect (TCP needs no unstable flag, only --allow-net).

The runtime half: amendment A12

The TCP WIT exposes a dispatch gap: send: func(data: stream<u8>) -> future<result<_, error-code>> is a sync WIT func whose returned future settles only after post-return guest action (the guest writes data after send returns). The import wrapper adopted any thenable return as the call's async completion — for this shape that's a livelock (park until a future that needs the guest to run), and it also silently mis-lowered returned Future handles (they're PromiseLike). The contract's producer table already promised "for future<T>, a Promise<T> or Future<T>" at lowering; dispatch preempted it.

A12 (contracts/embedder-api.md §"Streams and futures"): an import whose WIT result type is future<T> treats a thenable return as the future source — the import completes immediately; the future settles on the producer's schedule; rejections stay producer failures (site-named, host-failure channel), never a guest-visible err. Pinned by a new fixture guest (examples/guests/future-import) driving the three shapes: bare future<u32> return, the tcp-send shape with the post-return stream write (the livelock probe), and the tcp-receive tuple<stream<u8>, future<u32>> (which already worked via the recursive value adapter — now pinned).

TCP semantics (yardstick: wasmtime-wasi p3 + TcpSocketOperationalSemantics-0.3.0, client half)

  • send/receive never throw — no WIT result of their own; every failure rides the future as a result value ({ kind: \"err\", value: error-code }).
  • receive returns [async generator, promise] — generator deliberately, not ReadableStream: the runtime pump runs an abandoned generator's finally (releasing the conn), where a ReadableStream source is only unlocked, never cancelled.
  • Shared ownership per the WIT: the OS socket closes when the handle AND both pumps have retired — streams remain functional after the guest drops the tcp-socket.
  • The receive stream ends cleanly on both FIN and abnormal close (never fake data); the future distinguishes (ok/err). Reader drop settles ok (canceller-is-observer, the A8 logic).
  • bind/listen/socket options stay loudly absent: Deno.connect can't bind a local address; the accept path (stream<tcp-socket>) waits for a consumer that links it (iroh direct path: p3 wasi:sockets UDP providers #4).
  • Peer traps while consuming send's stream are producer failures, not socket errors.

Not in scope

The composed E2E — a real guest linking wasi:sockets/types@0.3 TCP through this provider — lands with the wosh consumer smoke; the runtime lowering shapes are pinned by the fixture, the provider semantics by 16 loopback unit tests against a real Deno.listen server.

Gates

Full just gates pass on this tree: build, test-rust, test-protocol, test-runtime (481, +4), test-wasi-shims (88, +17), test-ct-runner, test-bundle, publish-check, examples, test-translate, conformance, sched-seeds, test-ports, test-webrtc, shells (sm/jsc/node/bun), browsers (chromium+firefox), websocket-conformance (55/55), smoke-tls, smoke-c0.

Also rides along: ports deno.lock workspace-link refresh (protocol 0.2.0, pre-existing drift from the A10 publish).

Refs #4.

lann added 3 commits August 12, 2026 23:20
The A10 protocol rename moved @deltic/protocol to 0.2.0 (f0ca696); the
ports' lockfiles still recorded the 0.1.0 workspace link and refresh
themselves on any suite run. Committed separately so the tree stays
clean under 'just gates'.
…e future source

The dispatch wrapper adopted ANY thenable return as the call's async
completion — for a sync-typed import that is a JSPI park request, and for
one whose WIT result is future<T> it contradicted the contract's producer
table ("for future<T>, a Promise<T> or Future<T>") twice over: a Promise
parked the call until the future's own settlement, and a Future handle
(a PromiseLike) was silently awaited and re-lowered as an
already-resolved future.

The park is not just a semantics wrinkle: for the wasi:sockets@0.3 TCP
send shape — send: func(data: stream<u8>) -> future<result<_,
error-code>>, whose future settles only after the guest WRITES the
stream, which it does only after send returns — adoption is a livelock.

Fix: when the result type is future<T>, complete the import immediately
and lower the thenable as the future source. Rejections stay producer
failures on the host-failure channel (site-named, surfacing on the
consuming call), never a guest-visible err — a fallible payload rides
inside the future as a result value. Async-typed imports gain the same
rule; JS promise flattening means a returned promise could never have
expressed "async computation yielding a later-settling future" anyway.

New fixture guest examples/guests/future-import pins the three shapes
(bare future<u32> return; the tcp-send shape with the post-return stream
write — the livelock probe; the tcp-receive tuple<stream<u8>,
future<u32>>) in runtime/tests/embedder/future_result_test.ts; the
tuple shape already worked through the recursive value adapter and is
now pinned. Contract: amendment A12 in contracts/embedder-api.md
§"Streams and futures".

Gates: test-runtime (481 incl. 4 new), sched-seeds, conformance,
examples; full 'just gates' pass rides the branch.
The wosh listener is the prospective consumer (listener-core/src/tcp.rs
bridges iroh streams to a local TCP dial); the smoke-c0 leg-4
composed-websocket shopping list names the same four leaves. Implemented
over Deno.connect (stable API — TCP needs no unstable flag, only
--allow-net): create, connect, send, receive, plus the trivially honest
getters (get-local-address, get-remote-address, get-address-family,
get-is-listening). bind and listen stay loudly absent: Deno.connect
cannot bind a local address, and the accept path (a stream of resources)
waits for a consumer that links it.

Semantics (yardstick: wasmtime-wasi p3 +
TcpSocketOperationalSemantics-0.3.0, client half):

- send/receive NEVER throw — their WIT signatures carry no result, so
  every failure rides the returned future as a result value. send's
  async-method promise is the future source per amendment A12 (the
  sibling runtime commit); receive returns [async generator, promise].
  The generator source matters: the runtime pump runs an abandoned
  generator's finally (releasing the conn), where a ReadableStream
  source would only be unlocked, never cancelled.
- Shared ownership per the WIT: the OS socket closes when the resource
  handle AND both pumps have retired, so send/receive streams remain
  functional after the guest drops the tcp-socket handle (refcount).
- The receive stream ends cleanly on both graceful FIN and abnormal
  close — never fake data; the future distinguishes them (ok vs err).
  Dropping the reader settles the future ok (the canceller is the
  observer — the A8 cancelRead logic).
- Guest-side failures while consuming send's stream (peer trap) are not
  socket errors: they propagate as producer failures.
- connect: once-only, from unbound only; a failed dial closes the
  socket; the same address validation as UDP (family boundary,
  unspecified, port 0, non-zero scope-id not-supported).

16 new unit tests drive a real loopback Deno.listen server: echo, FIN
both directions, futures-not-throws, once-only send/receive/connect,
refused dial, peer-closed write errors, shared-ownership teardown,
reader drop, dispose-during-dial, onCall sequence. mapPlatformError
gains the EPIPE spelling ('broken pipe' as a plain Error) ->
connection-broken.

The composed E2E — a real guest linking wasi:sockets TCP through this
provider — lands with the wosh consumer smoke; the runtime-side lowering
shapes are pinned by the future-import fixture in the sibling commit.

Gates: test-wasi-shims (88), publish-check; full 'just gates' pass on
this tree.
@lannbot
lannbot enabled auto-merge August 13, 2026 03:21
@lannbot
lannbot merged commit 3bf1a2f into main Aug 13, 2026
3 checks passed
@lannbot
lannbot deleted the wasi-sockets-tcp branch August 23, 2026 16:51
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.

2 participants