From 2fe077f0c5297301a2afeb363a6b97c39f099064 Mon Sep 17 00:00:00 2001 From: Lann Martin Date: Sun, 9 Aug 2026 22:06:31 -0400 Subject: [PATCH] ports/webrtc: latch owned channels on peer-connection close MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found running polymorph-webrtc-datachannels' driver-ct loopback suite under the upstreamed copy of this port (the #19 run, delivered as their PR #149): PeerConnection.close() closed only the native channels, so a post-close send could still observe readyState "open" inside node-datachannel's asynchronous transition window — their solo/post-close-send case raced its 1000-send bound against the 1 s close-drain and failed intermittently under the callback ABI's await cadence (JSPI's slower awaits hide the same latent race in their jco-impl reference). close() now closes the owned channels through their DataChannel wrappers, whose local-close flag is the synchronous gate the WIT contract's "observed locally at once" requires — the same discipline DataChannel.close() already applies. Regression test added; docs: consumers.md webrtc row gains the conformance-matrix gate. Gate: just test-webrtc (10/10). --- docs/consumers.md | 2 +- ports/webrtc/src/webrtc.ts | 26 ++++++++++++++++++++++---- ports/webrtc/webrtc.test.ts | 29 +++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 5 deletions(-) diff --git a/docs/consumers.md b/docs/consumers.md index 56bd90d..afa58f7 100644 --- a/docs/consumers.md +++ b/docs/consumers.md @@ -91,7 +91,7 @@ Reference implementations developed here, pending upstreaming |---|---|---| | `ports/websocket` | `polymorph:websocket/connections` host module | their conformance suite 55/55 incl. TLS (`conformance/run.ts`) | | `ports/webcrypto` | `polymorph:webcrypto` host module (11 families; completion [#3](https://github.com/lann/deltic/issues/3)) | KATs vs their vectors + iroh exec-model integration | -| `ports/webrtc` | `polymorph:webrtc-datachannels/connections` host module | their echo-demo component over real data channels | +| `ports/webrtc` | `polymorph:webrtc-datachannels/connections` host module | their echo-demo component over real data channels; their full driver-ct loopback matrix (solo+pair, 37/37) runs under the upstreamed copy ([polymorph-webrtc-datachannels#149](https://github.com/polymorph-components/polymorph-webrtc-datachannels/pull/149)) | | `exams/iroh-endpoint` | the endpoint exit exam | 5/5: bind+identity, relay echo, WebRTC upgrade, jco#11/#13 assertions, teardown | | `ct-runner` | L3 runner for the polymorph-test L1 contract | golden-tested L4 JSONL; drives the websocket suite | | `tools/smoke-c0` | C0 smoke legs + report | legs 1–4 (`REPORT.md`) | diff --git a/ports/webrtc/src/webrtc.ts b/ports/webrtc/src/webrtc.ts index 10d5eba..26a153f 100644 --- a/ports/webrtc/src/webrtc.ts +++ b/ports/webrtc/src/webrtc.ts @@ -570,6 +570,14 @@ export class PeerConnection { #closeHooks = new Set<() => void>(); // deno-lint-ignore no-explicit-any #ownedChannels = new Set(); + /** + * The `DataChannel` wrappers over `#ownedChannels`, latched closed by + * `close()`: the wrapper's local-close flag is the synchronous gate the + * WIT contract's "observed locally at once" requires, because a backend + * may transition the native `readyState` asynchronously (node-datachannel + * does — see `DataChannel.send`'s gate comment). + */ + #ownedWrappers = new Set(); #stateTaken = false; #statePokes = new Set<() => void>(); @@ -653,7 +661,9 @@ export class PeerConnection { // deno-lint-ignore no-explicit-any this.#pc.addEventListener("datachannel", ({ channel }: any) => { this.#ownedChannels.add(channel); - push(new DataChannel(channel)); + const wrapper = new DataChannel(channel); + this.#ownedWrappers.add(wrapper); + push(wrapper); }); }); } @@ -695,7 +705,9 @@ export class PeerConnection { try { const channel = this.#pc.createDataChannel(options.label(), options.toInit()); this.#ownedChannels.add(channel); - return new DataChannel(channel); + const wrapper = new DataChannel(channel); + this.#ownedWrappers.add(wrapper); + return wrapper; } catch (err) { throw new WitError({ tag: "other", val: String(err) }); } @@ -849,9 +861,15 @@ export class PeerConnection { this.#candidates.end(); this.#channels.end(); for (const poke of this.#statePokes) poke(); - for (const channel of this.#ownedChannels) { + // Close the owned channels through their wrappers, so the close is + // observed locally at once (the wrapper latches its local-close flag + // and closes the native channel; `DataChannel.close` is idempotent). + // Gating on the native `readyState` alone is not enough: a backend may + // transition it asynchronously, leaving a post-close `send` a window + // in which it still sees `"open"`. + for (const wrapper of this.#ownedWrappers) { try { - channel.close(); + wrapper.close(); } catch { // Already closed. } diff --git a/ports/webrtc/webrtc.test.ts b/ports/webrtc/webrtc.test.ts index 8243aee..3ec2bda 100644 --- a/ports/webrtc/webrtc.test.ts +++ b/ports/webrtc/webrtc.test.ts @@ -286,6 +286,35 @@ Deno.test("loopback: close propagation + post-close error cases", NO_SANITIZE, a assertEquals((connErr as WitError).payload, { tag: "closed" }); }); +Deno.test( + "loopback: peer-connection close latches its channels synchronously", + NO_SANITIZE, + async () => { + const { a, b } = await connectPair(); + const chA = a.createDataChannel(new DataChannelOptions()); + const chB = await firstIncoming(b); + + // Closing the PEER CONNECTION closes its owned channels with the close + // observed locally at once (the WIT contract): the very first send() + // after close() fails `closed`, on the locally created channel and on + // the incoming (remote-created) one alike — regardless of how lazily + // the backend transitions the native readyState. + a.close(); + const errA = await assertRejects( + () => chA.send({ tag: "string", val: "after-close" }), + WitError, + ); + assertEquals((errA as WitError).payload, { tag: "closed" }); + + b.close(); + const errB = await assertRejects( + () => chB.send({ tag: "string", val: "after-close" }), + WitError, + ); + assertEquals((errB as WitError).payload, { tag: "closed" }); + }, +); + Deno.test("loopback: wait-connected resolves and is latched", NO_SANITIZE, async () => { const { a, b } = await connectPair(); try {