Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/consumers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`) |
Expand Down
26 changes: 22 additions & 4 deletions ports/webrtc/src/webrtc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -570,6 +570,14 @@ export class PeerConnection {
#closeHooks = new Set<() => void>();
// deno-lint-ignore no-explicit-any
#ownedChannels = new Set<any>();
/**
* 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<DataChannel>();
#stateTaken = false;
#statePokes = new Set<() => void>();

Expand Down Expand Up @@ -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);
});
});
}
Expand Down Expand Up @@ -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<WebrtcError>({ tag: "other", val: String(err) });
}
Expand Down Expand Up @@ -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.
}
Expand Down
29 changes: 29 additions & 0 deletions ports/webrtc/webrtc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,35 @@ Deno.test("loopback: close propagation + post-close error cases", NO_SANITIZE, a
assertEquals((connErr as WitError<WebrtcError>).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<WebrtcError>).payload, { tag: "closed" });

b.close();
const errB = await assertRejects(
() => chB.send({ tag: "string", val: "after-close" }),
WitError,
);
assertEquals((errB as WitError<WebrtcError>).payload, { tag: "closed" });
},
);

Deno.test("loopback: wait-connected resolves and is latched", NO_SANITIZE, async () => {
const { a, b } = await connectPair();
try {
Expand Down
Loading