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
6 changes: 3 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,14 @@ Layout (each directory's justfile module in parentheses):
the module reads no ambient configuration. Since the A22
protocol/runtime split, this published package (`@polymorph/websocket`)
depends on `@polyengine/protocol` only (`js/polyengine/deno.json`,
caret-pinned `^0.2.2`) — no `@polyengine/runtime` import anywhere in
caret-pinned `^0.2.3`) — no `@polyengine/runtime` import anywhere in
it; value recognition is brand-based (`@polyengine/protocol` predicates,
not `instanceof`), so its copy of `@polyengine/protocol` is harmless
even in a multi-copy graph. The runtime-linked application legs
(`conformance/driver-ct/polyengine/`, the WPT parity round trip, the
demo) separately exact-pin the `@polyengine/{runtime,translator,wasi,
ct-runner}` lockstep family (`0.5.0`) plus `@polyengine/protocol`
(`0.2.2`) in `conformance/driver-ct/polyengine/deno.json`; the root
ct-runner}` lockstep family (`0.5.1`) plus `@polyengine/protocol`
(`0.2.3`) in `conformance/driver-ct/polyengine/deno.json`; the root
justfile's `exam-polyengine` recipe (CI-wired) asserts one resolved
runtime-family version, one resolved protocol version, and that
`js/polyengine` names no `@polyengine/runtime` specifier. Bump
Expand Down
14 changes: 7 additions & 7 deletions conformance/driver-ct/polyengine/deno.json
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
{
"//": "A22 MODULE-IDENTITY POSTURE: @polyengine/wasi@0.5.0 is protocol-only internally (the old module-identity clause tying it to @polyengine/runtime/embedder is gone), so this file's runtime-family pins only need to be exact and mutually consistent with each other and with ../../../js/polyengine/deno.json when that package's caret range resolves the same commit — this repo's driver code (run.ts, examples/polyengine-demo/run.ts) that also loads @polyengine/protocol vocabulary must still resolve to ONE @polyengine/runtime version if it loads the embedder in more than one config, since stateful handles (streams/futures) minted by one copy are refused by another; the published @polymorph/websocket host module, by contrast, now couples only to @polyengine/protocol, whose copies are harmless by construction. Pin story: exact-pinned JSR releases (caret-honest upstream: within a minor line releases stay compatible, breaking bumps the minor; per-commit `<next>-pre.g<hash>` prereleases exist between releases — pin exactly); @polyengine/translator ships the translator wasm for that SAME commit; deno.lock carries integrity, --frozen enforced everywhere; the pin gate (justfile: exam-polyengine) asserts one resolved @polyengine/runtime version and one resolved @polyengine/protocol version repo-wide.",
"//": "A22 MODULE-IDENTITY POSTURE: @polyengine/wasi@0.5.1 is protocol-only internally (the old module-identity clause tying it to @polyengine/runtime/embedder is gone), so this file's runtime-family pins only need to be exact and mutually consistent with each other and with ../../../js/polyengine/deno.json when that package's caret range resolves the same commit — this repo's driver code (run.ts, examples/polyengine-demo/run.ts) that also loads @polyengine/protocol vocabulary must still resolve to ONE @polyengine/runtime version if it loads the embedder in more than one config, since stateful handles (streams/futures) minted by one copy are refused by another; the published @polymorph/websocket host module, by contrast, now couples only to @polyengine/protocol, whose copies are harmless by construction. Pin story: exact-pinned JSR releases (caret-honest upstream: within a minor line releases stay compatible, breaking bumps the minor; per-commit `<next>-pre.g<hash>` prereleases exist between releases — pin exactly); @polyengine/translator ships the translator wasm for that SAME commit; deno.lock carries integrity, --frozen enforced everywhere; the pin gate (justfile: exam-polyengine) asserts one resolved @polyengine/runtime version and one resolved @polyengine/protocol version repo-wide.",
"imports": {
"@polyengine/ct-runner": "jsr:@polyengine/ct-runner@0.5.0",
"@polyengine/runtime/embedder": "jsr:@polyengine/runtime@0.5.0/embedder",
"@polyengine/runtime/shim": "jsr:@polyengine/runtime@0.5.0/shim",
"@polyengine/wasi": "jsr:@polyengine/wasi@0.5.0",
"@polyengine/translator": "jsr:@polyengine/translator@0.5.0",
"@polyengine/protocol": "jsr:@polyengine/protocol@0.2.2",
"@polyengine/ct-runner": "jsr:@polyengine/ct-runner@0.5.1",
"@polyengine/runtime/embedder": "jsr:@polyengine/runtime@0.5.1/embedder",
"@polyengine/runtime/shim": "jsr:@polyengine/runtime@0.5.1/shim",
"@polyengine/wasi": "jsr:@polyengine/wasi@0.5.1",
"@polyengine/translator": "jsr:@polyengine/translator@0.5.1",
"@polyengine/protocol": "jsr:@polyengine/protocol@0.2.3",
"@polymorph/test/polyengine-worker-main": "jsr:@polymorph/test@0.2.1/polyengine-worker-main"
},
"minimumDependencyAge": { "age": "P1D", "exclude": ["jsr:@polyengine/*", "jsr:@polymorph/*"] },
Expand Down
54 changes: 27 additions & 27 deletions conformance/driver-ct/polyengine/deno.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion js/polyengine/deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"exports": "./websocket.ts",
"//": "A22 MODULE-IDENTITY POSTURE: this package couples only to @polyengine/protocol (the host-ABI vocabulary package) — no @polyengine/runtime import anywhere in this module, per polyengine's A22 rule that published host modules must not import @polyengine/runtime. websocket.ts recognizes polyengine values by @polyengine/protocol brand predicates, not instanceof, so its copies are harmless by construction: any application that also loads the embedder resolves its own runtime version independently, and this package neither constrains nor is constrained by that choice. Pin story: this published package takes a caret range on @polyengine/protocol (caret-honest upstream: within a minor line releases stay compatible, breaking bumps the minor); deno.lock carries integrity, --frozen enforced everywhere; the pin gate (justfile: exam-polyengine) asserts this file names no @polyengine/runtime specifier.",
"imports": {
"@polyengine/protocol": "jsr:@polyengine/protocol@^0.2.2"
"@polyengine/protocol": "jsr:@polyengine/protocol@^0.2.3"
},
"minimumDependencyAge": { "age": "P1D", "exclude": ["jsr:@polyengine/*"] },
"compilerOptions": {
Expand Down
8 changes: 4 additions & 4 deletions js/polyengine/deno.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

64 changes: 64 additions & 0 deletions js/polyengine/tests/echo_server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,3 +101,67 @@ function handle(req: Request, sockets: Set<WebSocket>): Response | Promise<Respo

return response;
}

/**
* A raw TCP stub that accepts a connection, drains whatever the client
* writes, and never answers — so a client's handshake stays pending until
* the client itself gives up. Unlike `/stall` above it is a bare
* `Deno.listen`, which is what lets a test observe the *socket* lifecycle:
* when the connection was accepted, and when the client's end went away.
*/
export interface StallStub {
base: string;
/** Resolves once a client connection has been accepted. */
accepted: Promise<void>;
/** Resolves once the accepted connection's read settles — the client closed. */
clientGone: Promise<void>;
close(): Promise<void>;
}

export function startStallStub(): StallStub {
const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 });
const port = (listener.addr as Deno.NetAddr).port;
const conns: Deno.Conn[] = [];
const readers: Promise<void>[] = [];

let markAccepted!: () => void;
const accepted = new Promise<void>((r) => (markAccepted = r));
let markGone!: () => void;
const clientGone = new Promise<void>((r) => (markGone = r));

const serving = (async () => {
try {
for await (const conn of listener) {
conns.push(conn);
markAccepted();
readers.push((async () => {
const buf = new Uint8Array(1024);
try {
// Drain the upgrade request and answer nothing. The read settles
// only when the peer closes or resets.
while (await conn.read(buf) !== null) { /* keep draining */ }
} catch { /* a reset is the client going away too */ }
markGone();
})());
}
} catch { /* the listener was closed */ }
})();

return {
base: `ws://127.0.0.1:${port}`,
accepted,
clientGone,
async close() {
try {
listener.close();
} catch { /* already closed */ }
for (const c of conns) {
try {
c.close();
} catch { /* already closed */ }
}
await serving;
await Promise.all(readers);
},
};
}
71 changes: 69 additions & 2 deletions js/polyengine/tests/websocket_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
// consumer's own conformance suite, executed by conformance/run.ts.

import { assert, assertEquals, assertRejects, assertThrows } from "jsr:@std/assert@^1.0.0";
import { ComponentException } from "@polyengine/protocol";
import { ComponentException, isAbortable } from "@polyengine/protocol";
import {
currentConfig,
resetConfig,
Expand All @@ -20,7 +20,12 @@ import {
type WebsocketError,
Websocket,
} from "../websocket.ts";
import { burstPayload, startEchoServer, type TestServer } from "./echo_server.ts";
import {
burstPayload,
startEchoServer,
startStallStub,
type TestServer,
} from "./echo_server.ts";

/** Assert `fn` throws a branded `ComponentException` whose payload kind is `kind`. */
function assertComponentKind(fn: () => unknown, kind: WebsocketError["kind"]): WebsocketError {
Expand Down Expand Up @@ -332,6 +337,68 @@ Deno.test("connect: the handshake bound fires as connect-failed", async () => {
});
});

/** Whether `p` settles (either way) within `ms`. */
async function settlesWithin(p: Promise<unknown>, ms: number): Promise<boolean> {
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<boolean>((r) => {
timer = setTimeout(() => r(false), ms);
});
try {
return await Promise.race([p.then(() => true, () => true), timeout]);
} finally {
clearTimeout(timer);
}
}

Deno.test("connect: carries the A24 abortable mark", () => {
assert(isAbortable(Websocket.connect));
});

Deno.test("connect: an abort mid-handshake fails connect-failed and drops the socket", async () => {
resetConfig();
const stub = startStallStub();
try {
const controller = new AbortController();
const connecting = Websocket.connect(stub.base, [], controller.signal);
await stub.accepted;

const started = performance.now();
controller.abort();
const payload = await assertRejectsComponentKind(() => connecting, "connect-failed");
const elapsed = performance.now() - started;

// Far inside the module's own 30s connect bound: the abort settled it,
// not the timer.
assert(elapsed < 2_000, `abort did not settle connect promptly (${elapsed}ms)`);
assert("value" in payload && payload.value === "connect aborted");
assert(
await settlesWithin(stub.clientGone, 2_000),
"the platform socket was not reclaimed by the abort",
);
} finally {
await stub.close();
resetConfig();
}
});

Deno.test("connect: a pre-aborted signal fails before any socket is opened", async () => {
resetConfig();
const stub = startStallStub();
try {
const controller = new AbortController();
controller.abort();
const payload = await assertRejectsComponentKind(
() => Websocket.connect(stub.base, [], controller.signal),
"connect-failed",
);
assert("value" in payload && payload.value === "connect aborted");
assertEquals(await settlesWithin(stub.accepted, 250), false);
} finally {
await stub.close();
resetConfig();
}
});

async function drainBytes(stream: ReadableStream<Uint8Array>): Promise<Uint8Array> {
const chunks: Uint8Array[] = [];
let total = 0;
Expand Down
Loading
Loading