wasi-shims: client TCP for the à la carte wasi:sockets fragment; embedder A12 (future-typed import results) - #123
Merged
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The decision
@deltic/wasi-shims/socketsgrows 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 inwasiShims(), absent from the release bundle. Over stableDeno.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 writesdataaftersendreturns). 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 returnedFuturehandles (they're PromiseLike). The contract's producer table already promised "forfuture<T>, aPromise<T>orFuture<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: barefuture<u32>return, the tcp-send shape with the post-return stream write (the livelock probe), and the tcp-receivetuple<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/receivenever throw — no WIT result of their own; every failure rides the future as a result value ({ kind: \"err\", value: error-code }).[async generator, promise]— generator deliberately, not ReadableStream: the runtime pump runs an abandoned generator'sfinally(releasing the conn), where a ReadableStream source is only unlocked, never cancelled.tcp-socket.ok/err). Reader drop settlesok(canceller-is-observer, the A8 logic).bind/listen/socket options stay loudly absent:Deno.connectcan'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).send's stream are producer failures, not socket errors.Not in scope
The composed E2E — a real guest linking
wasi:sockets/types@0.3TCP 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 realDeno.listenserver.Gates
Full
just gatespass 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.