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 {