mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-08 12:33:05 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The server has about 1,100 test and helper files in `server/src/__tests__` > - `server/tsconfig.json` excludes that directory, Vitest does not check types, and no script runs the tsconfig in that directory > - So a test can call a function with the wrong arguments and still pass, and no tool reports it > - This gap hid 2,168 real type errors, and some of them hid tests that did not test what they claim > - This pull request fixes all of these errors and adds the directory to the server layers from paperclipai/paperclip#18737 > - The benefit is that a type error in a server test now fails the typecheck, with no baseline and no backlog ## Linked Issues or Issue Description Refs paperclipai/paperclip#18737, which is merged and added the server layers. Refs paperclipai/paperclip#18738, which is merged and checks the test projects outside `pnpm -r`, so it is not a duplicate. Refs paperclipai/paperclip#18137, which records that this directory has no check. **What happened?** No tool checked the types of 1,074 of the 1,101 files in `server/src/__tests__`. I searched `package.json`, the workflows, and all tsconfig files. 27 files are in the program of another tsconfig, and these 27 have 0 errors. 11 of the 27 are in `helpers/maintenance/tsconfig.json`. No script ran that project when I measured this. paperclipai/paperclip#18738 now runs it. **Expected behavior** The server typecheck checks these files, and a type error fails the check. **Steps to reproduce** 1. On the base commit, run `tsc -p server/src/__tests__/tsconfig.json --noEmit`. It reports 6,536 errors and needs 12.3 GiB. 2. Add a type error to any file in `server/src/__tests__`. 3. Run `pnpm --filter @paperclipai/server typecheck`. It passes. **Paperclip version or commit** I measured the error breakdown below on commit `8dfd632452`, with TypeScript 7.0.2. The branch now includes master up to paperclipai/paperclip#18865. **Deployment mode** Not applicable. This issue applies to development and CI. No production source file in the server changes. ### Error breakdown Each row adds one change to the row above. | Step | Total errors | Change | Cause | | --- | --- | --- | --- | | Config as committed | 6,536 | | | | Add `src/types/*.d.ts` | 4,447 | -2,089 | Config. The Express request types were missing. | | Set `rootDir` to the repository root | 2,436 | -2,011 | Config. | | Set `allowImportingTsExtensions` | 2,181 | -255 | Config. Vitest accepts `.ts` imports. | | Add `ES2024.Promise` to `lib` | 2,138 | -43 | Config. Node 24 has `Promise.withResolvers`. | | Set `allowJs` for `.mjs` imports | 2,148 | +10 | Config. It removes 8 `TS7016` errors and shows new real errors. | | Set `declaration` to false | 2,142 | -6 | Config. Tests emit nothing. | | Count only files in `server/src` | 2,058 | -84 | Other packages: 53 Daytona, 29 Runner test, 2 UI. | So 4,478 of the 6,536 errors (69%) came from config or scope, and 2,058 are real type errors in 314 files. The 29 Runner test errors are also real, which gives 2,087 errors. Tests that master added after this measurement had 81 more errors, so this pull request fixes 2,168 errors. This table classifies the first 2,058 errors. | Kind of real error | Errors | | --- | --- | | Wrong argument or assignment type | 1,023 | | Stale or partial fixture objects | 525 | | Property that the type does not have | 317 | | Missing narrowing of null or unknown | 101 | | Implicit any | 60 | | Missing name or module | 16 | | Other | 16 | ### What the type errors hid Most errors were stale fixtures and loose mocks. These errors hid real test problems, and this pull request fixes them: - **A test that never ran.** `instrumentation.test.ts` required `sdk-trace-base`, which is not installed. So 3 span tests were skipped. They now run and pass. - **Assertions that could not fail.** - `issue-thread-interactions-service` used a company ID as an issue ID. - `status-card-evaluation-work` compared `[]` with `[]` in a rollback check. - `slack-conversation-lifecycle` read `item.issueId`, which does not exist. - `email-channels` used a permission key that does not exist. - `heartbeat-project-env` imported `LOW_TRUST_REVIEW_PRESET`, which is not exported. The value was undefined. - **Postgres processes that did not stop.** 11 test files called `stop` on the test database helper. That method does not exist, and the helper method is `cleanup`. - **Arguments that the service ignored.** - `methodKey` is now `connectionMethodKey` in the Daytona, Unikraft, 1Password, and Railway tests. - 108 calls passed `"owner-user"` to a parameter that expects an options object. - 9 chat wizard fixtures passed `managementContext` as `source`, so they ran `existing_app` and not `manifest`. - 2 tests passed a `{ userId }` actor that the function ignores. - Inserts into `environments` passed a `companyId` column that the table does not have. - **Problems in tests that master added during this work.** - `sandbox-creation-operations` read `database.connectionString` when an external test database was set. In that mode `database` is undefined, so the fixture would stop with a TypeError. It now uses the external URL. - `idle-completed-history` set a Dot operation to `dispatched`. That is a Muse status, and the Dot type does not have it. The fixture now uses `admitted`, which is a real in-flight Dot status. ### Possible product problems, not changed These may be real problems in production code or types. I did not change them, because this pull request does not change production behavior: - `decisions-service` `service.create` does not validate the lenient `cancel_issue_tree` input. - `createConnection` ignores `input.connectionPurpose`. - The `createConnection` route schema default stores `{}` for `transportConfig`. - `prepareManagedAiRuntime` accepted the invalid value `mode: "connection"`. - `createBinding` accepts invalid `projectionClass` strings outside class 3. - `ensureManagedProjectWorkspace` forwards an untyped `repoRef` by spread. - `routineVariableSchema.label` is optional, but `RoutineVariable.label` is required. - `ToolRunContext.projectId` may be too strict. - `withdrawInteraction` reads `issue.status`, which its type omits. - `issueService.list` and `reconcileNativeFinalizations` omit fields that they return at runtime. - `createGitHubExternalObjectProvider` handles `tokenProvider: null`, but its type forbids this value. ## What Changed - Fixed the 2,168 type errors. The diff changes 322 test and helper files. - Corrected `server/src/__tests__/tsconfig.json`. Editors and the typecheck now read the same options and report the same errors. - Added three `test-tree` layers to `server/scripts/typecheck-layers.mjs`. They read the server layers as declarations, and any error fails them. A hash of the path selects the shard, so a new file does not move other files. - Added `server/scripts/typecheck-stubs/daytona-sdk.d.ts`. Server tests reach Daytona plugin source, and the root install does not have `@daytonaio/sdk`. - Changed two imports in `ui/src/lib/issue-execution-policy.ts` to use `.js` extensions, because server tests import this file. - Made `declarationsConfig` and `--declarations-only` skip the `test-tree` layers, because these layers emit nothing. Other projects read only the source layers. Added a test for this. - Updated `doc/DEVELOPING.md`. ## Verification Run these commands: ```sh pnpm --filter @paperclipai/server typecheck pnpm exec vitest run server/scripts/typecheck-layers.test.mjs ``` Results on macOS (arm64), Node 24, 16 cores. Each check ran after the merge of paperclipai/paperclip#18738 unless the row says otherwise: | Check | Commit | Result | | --- | --- | --- | | Server typecheck, `PAPERCLIP_TYPECHECK_LOW_MEMORY=1`, all 7 layers | `6e45a08573` | Exit 0, 0 errors, 62 seconds, peak 3.52 GiB | | `node scripts/typecheck-test-projects.mjs --low-memory` | `a3306bd13e` | Exit 0, 0 errors, 42 seconds, peak 3.25 GiB | | The same script with `--reuse-declarations` | `1b878aabc0` | Exit 0, 0 errors | | `typecheck-layers.test.mjs` and `live-recovery-work.test.ts` | `01db5634e4` | 90 passed. These include real compiler runs for a pass, a type error, and an invalid tsconfig. | | `tsconfig.declarations.json` after a full server typecheck | `0fb1e4fb0a` | It references only `below`, `cycle`, and `above`. No generated config names a `tsconfig.layers.json` file. | | Owned runtime test in `idle-completed-history` | `e8f2f448ac` | Passed, with the fix that master later replaced | | `idle-incoming-wake`, `plugin-idle-manager`, and the changed `chat-channels` test | `6e45a08573` | 30 passed | | Runner `native-execution.test.ts` | `eab4dcc1f4` | 114 passed | | The 19 server test files that changed since `eab4dcc1f4`, and `typecheck-layers.test.mjs` | `259737b700` | 4,275 of 4,277 passed. The 2 failures are in `chat-channels` and are in the list of 7 below. | The peak is the largest single process, from `/usr/bin/time -l`. Peaks for the server typecheck were between 3.42 GiB and 3.56 GiB across 8 runs. Peaks for the test projects were 3.25 GiB and 3.37 GiB across 2 runs. I did not measure a cgroup. **Runtime check of the fixes.** Types do not prove that a test still works. So I ran all 309 changed server test files, 11,929 tests, before the rebase onto paperclipai/paperclip#18737: | Result | Tests | Detail | | --- | --- | --- | | Passed | 11,913 | Includes the 2 tests and the 54 `dot-runner` tests that passed in a later run, as described below. | | Failed | 7 | The same 7 tests also fail on the commit before the fixes. | | Skipped | 9 | These tests have their own skip conditions: `sentry` 5, and 1 each in `bundled-plugin-registration`, `customer-success-cloud`, `onepassword-connection`, and `workspace-runtime-https-live-exercise`. | The 7 tests that fail with and without this change, on my machine: - 3 tests in `chat-channels` and `email-channels` stop with `ENOENT` on a `SKILL.md` file. The path is in the parent checkout, above the git worktree that I used, so I expect that the worktree is the cause. I did not prove this. - `workspace-instance-cleanup` compares `/private/var/...` with `/var/...`. This is a macOS symlink difference. - 2 tests in `git-reproducible-managed-checkouts` get `unknown_base_git_files` and expect `unpushed_managed_checkout`. I did not find the cause. - 1 more test in `chat-channels`, `materializes direct external-chat finals once`, gets `undefined`. I did not find the cause. CI is the check for these 7 tests. `ci / verify` passed on `eab4dcc1f4`. Failures during this work: - My fix to `agent-runtime-connector-access` changed an invalid permission key in the setup, but not in the assertion. The runtime check found this. I corrected the assertion, and the file passes. - `heartbeat-plugin-environment` failed one wait in the full run. It passed three times alone, so I think that load caused the failure. It can fail again under load. - `dot-runner` skipped all 54 tests in the full run because `cargo` was not on my `PATH`. It then passed 54 of 54 with the toolchain on the `PATH`. - My first `lib` value removed the default DOM types, and the error count went up by 375. I added the DOM libraries. - My Daytona stub with bare `any` left 48 errors, because generic helpers then inferred `unknown`. I added shapes that return promises. - One fix to `agent-secrets-routes` used `class_3_static_lease`, and the test failed. That class allows only specific paths. I reverted to `unclassified`. - `ci / General tests (server (8/12))` failed on `259737b700` because of the flaky port in `idle-completed-history`. paperclipai/paperclip#18853 fixed this test on master, so this pull request keeps that fix. **Audit of the diff.** I counted these patterns in added and removed lines, to find fixes that only hide an error: | Pattern | Added | Removed | | --- | --- | --- | | `@ts-ignore` | 0 | 0 | | `@ts-expect-error` | 0 | 1 | | `.skip` | 0 | 0 | | `as any` | 9 | 9 | | `: any` | 11 | 2 | | `as never` | 8 | 2 | | `as unknown as` | 35 | 4 | | `Promise<unknown>` | 16 | 5 | | `expect(` | 128 | 113 | No hunk removes more `expect(` calls than it adds. Three removals of test content were intentional, and I checked each one: - `muse-control` removed `signalToken`. Line 1012 already asserts that it is absent. - `merged-pr-confirmation-sweep` removed legacy aliases. Other tests cover them. - `heartbeat-workspace-branch-containment` removed a `finalize` branch that no test row can reach. Some partial fixtures stay unchecked through `as unknown as` and `Promise<unknown>`, mostly in `issue-agent-mutation-ownership-routes` and `issue-comment-reopen-routes`. **Accuracy of the check.** One program over the tree and the three shards reported the same errors at the same positions. Memory, as the peak of the largest process on one thread, was 3.93 GiB for one program and 2.43 GiB for the largest shard. I measured these two values before the fixes. Not run or not proven: - I did not run the full `pnpm test:run`, or a root `pnpm build`. I expect CI to run the full suites. - I did not run the 4 GiB container check, or the full `scripts/typecheck-low-memory.mjs`. The peaks above come from macOS, so they are not a Linux cgroup measurement. - I did not run the UI typecheck after the merges. - `workspace-runtime-https-live-exercise` is skipped, so its `cleanup` fix did not run. ## Risks - **A large diff in test files.** 328 files change. A pull request that edits the same tests can get merge conflicts. New tests on master can also add type errors before this merges. Any such error fails this check and needs a fix here. - **A higher cost for changes to source types.** A change to a type can add errors in test files that the author did not touch. The author must then fix them. This is the purpose of the check, but it is new work. - **Changed test behavior.** Some tests now run paths that they did not run before, such as the 9 chat wizard fixtures and the 3 span tests. They pass here, but they can be more sensitive to timing or to the environment. - **The Daytona stub can go stale.** The stub is written by hand. If the plugin uses a new SDK member, the typecheck reports an error until someone updates the stub. - **The release path.** `release-verify.yml` and `scripts/release.sh` run `pnpm -r typecheck`, so they now run this check. - **The tsconfig change.** The test transform reads `server/src/__tests__/tsconfig.json`. The options that I changed are for type checks, and the runs above passed. - **A gap that remains.** Tsconfigs in `packages/paperclip-runner` and in most sandbox provider plugins exclude `*.test.ts`. This check does not read most of those test files, so they can still drift. That work is separate from this pull request. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used Anthropic Claude Opus 5.5 (`claude-opus-5-5`) through Claude Code, with reasoning, tool use, code execution, and subagents. The runtime does not show the context window size. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [ ] I have run tests locally and they pass (7 tests fail locally, with and without this change. See Verification.) - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
965 lines
40 KiB
TypeScript
965 lines
40 KiB
TypeScript
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import type { PaperclipPluginManifestV1 } from "@paperclipai/shared";
|
|
import {
|
|
createDuplexRouteSlotController,
|
|
createPluginWorkerHandle,
|
|
} from "../services/plugin-worker-manager.js";
|
|
|
|
const FIXTURES_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures");
|
|
const DUPLEX_CHANNEL_WORKER_ENTRYPOINT = path.join(
|
|
FIXTURES_DIR,
|
|
"plugin-worker-duplex-channel.cjs",
|
|
);
|
|
|
|
const TEST_MANIFEST: PaperclipPluginManifestV1 = {
|
|
id: "test.plugin",
|
|
apiVersion: 1,
|
|
version: "1.0.0",
|
|
displayName: "Test plugin",
|
|
description: "Test plugin",
|
|
author: "Paperclip",
|
|
categories: ["automation"],
|
|
capabilities: [],
|
|
entrypoints: { worker: "dist/worker.js" },
|
|
};
|
|
|
|
function makeDuplexHandle(extra?: Record<string, unknown>) {
|
|
return createPluginWorkerHandle("test.plugin", {
|
|
entrypointPath: DUPLEX_CHANNEL_WORKER_ENTRYPOINT,
|
|
manifest: TEST_MANIFEST,
|
|
config: {},
|
|
instanceInfo: { instanceId: "instance-1", hostVersion: "1.0.0" },
|
|
apiVersion: 1,
|
|
hostHandlers: {},
|
|
...extra,
|
|
});
|
|
}
|
|
|
|
// The test directive rides in `providerLeaseId`, an opaque field the manager
|
|
// forwards to the worker unchanged. The duplex channel is generic, so the
|
|
// command is a plain fixed string with no allowlist.
|
|
function duplexOpenInput(directive: unknown, companyId = "company-1") {
|
|
return {
|
|
driverKey: "daytona",
|
|
companyId,
|
|
environmentId: "env-1",
|
|
providerLeaseId: JSON.stringify(directive),
|
|
command: ["bridge-callback"],
|
|
};
|
|
}
|
|
|
|
describe("plugin worker manager duplex channel route", () => {
|
|
it("fails closed and retires the worker on a forged worker session id", async () => {
|
|
const handle = makeDuplexHandle();
|
|
try {
|
|
await handle.start();
|
|
const session = await handle.openDuplexChannel(
|
|
duplexOpenInput({
|
|
workerSessionId: "ws-A",
|
|
// The frame carries the bound host route id but a forged worker session
|
|
// id, so its pair matches no live route.
|
|
data: [{ chunk: "forged", sid: "ws-EVIL" }],
|
|
}),
|
|
);
|
|
const chunks: string[] = [];
|
|
// The session streams raw `Uint8Array` chunks. Decode each one back to
|
|
// text, so the assertion below compares the plain-text payload the
|
|
// fixture directive scripted.
|
|
session.onData((chunk) => chunks.push(new TextDecoder().decode(chunk)));
|
|
// The forged pair is an ownership violation. The host reaches no listener and
|
|
// retires the worker, so the wait settles with the fixed non-secret null exit.
|
|
await expect(session.wait()).resolves.toEqual({ exitCode: null });
|
|
expect(chunks).toEqual([]);
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("fails closed and retires the worker on a forged host route id", async () => {
|
|
const handle = makeDuplexHandle();
|
|
try {
|
|
await handle.start();
|
|
const session = await handle.openDuplexChannel(
|
|
duplexOpenInput({
|
|
workerSessionId: "ws-A",
|
|
// The frame carries the bound worker session id but a forged host route
|
|
// id, so its pair matches no live route.
|
|
data: [{ chunk: "forged", rid: "duplex-route-forged" }],
|
|
}),
|
|
);
|
|
const chunks: string[] = [];
|
|
// The session streams raw `Uint8Array` chunks. Decode each one back to
|
|
// text, so the assertion below compares the plain-text payload the
|
|
// fixture directive scripted.
|
|
session.onData((chunk) => chunks.push(new TextDecoder().decode(chunk)));
|
|
await expect(session.wait()).resolves.toEqual({ exitCode: null });
|
|
expect(chunks).toEqual([]);
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("routes input to the worker and back to the listener", async () => {
|
|
const handle = makeDuplexHandle();
|
|
try {
|
|
await handle.start();
|
|
const session = await handle.openDuplexChannel(
|
|
duplexOpenInput({ workerSessionId: "ws-A", echoInput: true }),
|
|
);
|
|
const chunks: string[] = [];
|
|
// The session streams raw `Uint8Array` chunks. Decode each one back to
|
|
// text, so the assertion below compares the plain-text payload the
|
|
// fixture directive scripted.
|
|
session.onData((chunk) => chunks.push(new TextDecoder().decode(chunk)));
|
|
session.write(new TextEncoder().encode("callback-payload"));
|
|
// The worker echoes the input as one data notification for the bound
|
|
// session, so the listener receives it.
|
|
await vi.waitFor(() => expect(chunks).toContain("echo:callback-payload"));
|
|
await session.close();
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("buffers early data in order until a listener attaches and drains it in order", async () => {
|
|
const handle = makeDuplexHandle();
|
|
try {
|
|
await handle.start();
|
|
const session = await handle.openDuplexChannel(
|
|
duplexOpenInput({
|
|
data: [{ chunk: "one" }, { chunk: "two" }, { chunk: "three" }],
|
|
exitCode: 0,
|
|
}),
|
|
);
|
|
// The worker writes the three data notifications and the exit in one
|
|
// stdout write. The host reads worker stdout line by line, so it buffers
|
|
// all three data frames before it reads the exit. The exit settles the
|
|
// wait, so the wait is a deterministic barrier: once it resolves, the
|
|
// host holds all three frames and no listener has attached yet. This
|
|
// barrier replaces a fixed sleep, so the test does not race the
|
|
// subprocess start or the stdio latency.
|
|
await session.wait();
|
|
const chunks: string[] = [];
|
|
// The session streams raw `Uint8Array` chunks. Decode each one back to
|
|
// text, so the assertion below compares the plain-text payload the
|
|
// fixture directive scripted.
|
|
session.onData((chunk) => chunks.push(new TextDecoder().decode(chunk)));
|
|
expect(chunks).toEqual(["one", "two", "three"]);
|
|
await session.close();
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("isolates a throwing listener during live delivery so later chunks still route", async () => {
|
|
const handle = makeDuplexHandle();
|
|
try {
|
|
await handle.start();
|
|
const session = await handle.openDuplexChannel(
|
|
duplexOpenInput({
|
|
workerSessionId: "ws-A",
|
|
data: [{ chunk: "ok-1" }, { chunk: "boom" }, { chunk: "ok-2" }],
|
|
exitCode: 0,
|
|
}),
|
|
);
|
|
const chunks: string[] = [];
|
|
// The listener throws on one chunk. The manager catches the throw, so it
|
|
// does not escape the worker stdout notification handler. The later chunk
|
|
// still routes and the route still settles.
|
|
session.onData((chunk) => {
|
|
const text = new TextDecoder().decode(chunk);
|
|
chunks.push(text);
|
|
if (text === "boom") throw new Error("listener failure");
|
|
});
|
|
await expect(session.wait()).resolves.toEqual({ exitCode: 0 });
|
|
expect(chunks).toEqual(["ok-1", "boom", "ok-2"]);
|
|
await session.close();
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("carries a transport-close exit through to the wait result", async () => {
|
|
const handle = makeDuplexHandle();
|
|
try {
|
|
await handle.start();
|
|
const session = await handle.openDuplexChannel(
|
|
duplexOpenInput({
|
|
// Batch the exit with the open response to exercise the pre-bind hold
|
|
// and prove its normalized representation retains the discriminator.
|
|
batchWithOpenReply: true,
|
|
workerSessionId: "ws-A",
|
|
data: [{ chunk: "one" }],
|
|
// The worker reports a reason-less transport close with no exit code.
|
|
transportClosed: true,
|
|
}),
|
|
);
|
|
const chunks: string[] = [];
|
|
// The session streams raw `Uint8Array` chunks. Decode each one back to
|
|
// text, so the assertion below compares the plain-text payload the
|
|
// fixture directive scripted.
|
|
session.onData((chunk) => chunks.push(new TextDecoder().decode(chunk)));
|
|
// The discriminator survives the worker exit notification, so the host wait
|
|
// resolves with the transport-close mark and no exit code.
|
|
await expect(session.wait()).resolves.toEqual({ exitCode: null, transportClosed: true });
|
|
expect(chunks).toEqual(["one"]);
|
|
await session.close();
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("isolates a throwing listener during the buffered replay so every buffered chunk routes", async () => {
|
|
const handle = makeDuplexHandle();
|
|
try {
|
|
await handle.start();
|
|
const session = await handle.openDuplexChannel(
|
|
duplexOpenInput({
|
|
data: [{ chunk: "one" }, { chunk: "boom" }, { chunk: "three" }],
|
|
exitCode: 0,
|
|
}),
|
|
);
|
|
// The worker writes the three data notifications and the exit in one
|
|
// stdout write. The host reads worker stdout line by line, so it buffers
|
|
// all three data frames before it reads the exit. The exit settles the
|
|
// wait, so the wait is a deterministic barrier for "the host holds every
|
|
// pre-bind frame and no listener has attached". This barrier replaces a
|
|
// fixed sleep, so the test does not race the subprocess start or the
|
|
// stdio latency.
|
|
await session.wait();
|
|
const chunks: string[] = [];
|
|
// The listener throws on one buffered chunk. The manager catches the throw
|
|
// inside the drain, so it does not escape `onData` and every buffered chunk
|
|
// still routes.
|
|
expect(() =>
|
|
session.onData((chunk) => {
|
|
const text = new TextDecoder().decode(chunk);
|
|
chunks.push(text);
|
|
if (text === "boom") throw new Error("listener failure");
|
|
}),
|
|
).not.toThrow();
|
|
expect(chunks).toEqual(["one", "boom", "three"]);
|
|
await session.close();
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("binds the worker session id one time and ignores a duplicate open reply", async () => {
|
|
const handle = makeDuplexHandle();
|
|
try {
|
|
await handle.start();
|
|
const session = await handle.openDuplexChannel(
|
|
duplexOpenInput({
|
|
mode: "duplicate-open-reply",
|
|
workerSessionId: "ws-A",
|
|
data: [{ chunk: "hello" }],
|
|
exitCode: 0,
|
|
}),
|
|
);
|
|
const chunks: string[] = [];
|
|
// The session streams raw `Uint8Array` chunks. Decode each one back to
|
|
// text, so the assertion below compares the plain-text payload the
|
|
// fixture directive scripted.
|
|
session.onData((chunk) => chunks.push(new TextDecoder().decode(chunk)));
|
|
// The duplicate open reply never rebinds or reopens the route, so the
|
|
// session runs normally on the one bind.
|
|
await expect(session.wait()).resolves.toEqual({ exitCode: 0 });
|
|
expect(chunks).toEqual(["hello"]);
|
|
await session.close();
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("terminalizes and fails closed on a malformed open reply, then admits a later open", async () => {
|
|
const handle = makeDuplexHandle();
|
|
try {
|
|
await handle.start();
|
|
await expect(
|
|
handle.openDuplexChannel(duplexOpenInput({ mode: "malformed-open" })),
|
|
).rejects.toThrow("DUPLEX_CHANNEL_OPEN_FAILED");
|
|
// The terminalize closed the route by the host route id and the worker
|
|
// acknowledged the close, so a later open is admitted.
|
|
const session = await handle.openDuplexChannel(duplexOpenInput({ mode: "normal" }));
|
|
expect(session).toBeDefined();
|
|
await session.close();
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("routes two concurrent cross-company duplex routes by the exact pair with no cross-talk", async () => {
|
|
const handle = makeDuplexHandle();
|
|
try {
|
|
await handle.start();
|
|
const routeA = await handle.openDuplexChannel(
|
|
duplexOpenInput({ workerSessionId: "ws-A", data: [{ chunk: "a-1" }], exitCode: 0 }, "company-A"),
|
|
);
|
|
const routeB = await handle.openDuplexChannel(
|
|
duplexOpenInput({ workerSessionId: "ws-B", data: [{ chunk: "b-1" }], exitCode: 0 }, "company-B"),
|
|
);
|
|
const aChunks: string[] = [];
|
|
const bChunks: string[] = [];
|
|
routeA.onData((chunk) => aChunks.push(new TextDecoder().decode(chunk)));
|
|
routeB.onData((chunk) => bChunks.push(new TextDecoder().decode(chunk)));
|
|
await expect(routeA.wait()).resolves.toEqual({ exitCode: 0 });
|
|
await expect(routeB.wait()).resolves.toEqual({ exitCode: 0 });
|
|
// The host routes each frame by the exact pair, so each route receives only
|
|
// its own data. Neither route sees the other's chunk.
|
|
expect(aChunks).toEqual(["a-1"]);
|
|
expect(bChunks).toEqual(["b-1"]);
|
|
await routeA.close();
|
|
await routeB.close();
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("reports an explicit route-busy result when the aggregate ceiling is full", async () => {
|
|
// A process-scoped ceiling of one slot. The manager injects one shared
|
|
// controller into every worker; the test injects a small one directly.
|
|
const handle = makeDuplexHandle({ duplexRouteSlots: createDuplexRouteSlotController(1) });
|
|
try {
|
|
await handle.start();
|
|
const first = await handle.openDuplexChannel(duplexOpenInput({ workerSessionId: "ws-A" }));
|
|
// The ceiling is full, so the second open rejects with the fixed route-busy
|
|
// error before it reaches the worker. An active channel never downgrades.
|
|
await expect(
|
|
handle.openDuplexChannel(duplexOpenInput({ workerSessionId: "ws-B" })),
|
|
).rejects.toThrow("DUPLEX_CHANNEL_ROUTE_BUSY");
|
|
// Closing the first route releases its slot, so a later open is admitted.
|
|
await first.close();
|
|
const third = await handle.openDuplexChannel(duplexOpenInput({ workerSessionId: "ws-C" }));
|
|
await third.close();
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("drops a late frame for a tombstoned pair and keeps the worker for a new open", async () => {
|
|
const handle = makeDuplexHandle();
|
|
try {
|
|
await handle.start();
|
|
const first = await handle.openDuplexChannel(
|
|
duplexOpenInput({ workerSessionId: "ws-A", emitAfterCloseChunk: "after-close" }),
|
|
);
|
|
const chunks: string[] = [];
|
|
first.onData((chunk) => chunks.push(new TextDecoder().decode(chunk)));
|
|
// Close the route. The host installs the tombstone atomically before the slot
|
|
// frees. The worker then emits one late frame for the closed pair.
|
|
await first.close();
|
|
// Give the late frame time to arrive. The host drops it, so it reaches no
|
|
// listener and does not retire the worker.
|
|
await new Promise((resolve) => setTimeout(resolve, 60));
|
|
expect(chunks).toEqual([]);
|
|
// The worker is still alive: a new open on the same worker succeeds and
|
|
// delivers its own data.
|
|
const second = await handle.openDuplexChannel(
|
|
duplexOpenInput({ workerSessionId: "ws-B", data: [{ chunk: "b-1" }], exitCode: 0 }),
|
|
);
|
|
const secondChunks: string[] = [];
|
|
second.onData((chunk) => secondChunks.push(new TextDecoder().decode(chunk)));
|
|
await expect(second.wait()).resolves.toEqual({ exitCode: 0 });
|
|
expect(secondChunks).toEqual(["b-1"]);
|
|
await second.close();
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
// The five explicit bounds. Each bound ends the route when it is exceeded.
|
|
// -------------------------------------------------------------------------
|
|
|
|
it("ends the route when the post-bind buffered bytes pass the bound", async () => {
|
|
const handle = makeDuplexHandle({
|
|
duplexChannelLimits: { maxPreBindBufferedChars: 10 },
|
|
});
|
|
try {
|
|
await handle.start();
|
|
const session = await handle.openDuplexChannel(
|
|
duplexOpenInput({
|
|
// Hold these frames until the fixture acknowledges a host write. A
|
|
// write can only come from the returned session, so this makes the
|
|
// post-bind path deterministic instead of depending on pipe batching.
|
|
emitScriptedFramesAfterFirstWrite: true,
|
|
data: [
|
|
{ chunk: "aaaaa" }, // total 5 → buffered
|
|
{ chunk: "bbbbb" }, // total 10 → buffered
|
|
{ chunk: "ccccc" }, // total 15 > 10 → end route
|
|
],
|
|
}),
|
|
);
|
|
session.write(new TextEncoder().encode("emit"));
|
|
// No listener attaches, so the post-bind data buffers. The cumulative bytes
|
|
// pass the bound and the route ends. The channel wait resolves with a null
|
|
// exit code.
|
|
await expect(session.wait()).resolves.toEqual({ exitCode: null });
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("ends the route when the pre-bind buffered frame count passes the bound", async () => {
|
|
const handle = makeDuplexHandle({
|
|
duplexChannelLimits: { maxPreBindBufferedFrames: 2 },
|
|
});
|
|
try {
|
|
await handle.start();
|
|
const session = await handle.openDuplexChannel(
|
|
duplexOpenInput({
|
|
data: [{ chunk: "a" }, { chunk: "b" }, { chunk: "c" }],
|
|
}),
|
|
);
|
|
// No listener attaches, so the data buffers. The third frame passes the
|
|
// frame-count bound and the route ends.
|
|
await expect(session.wait()).resolves.toEqual({ exitCode: null });
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("ends the route when the pending request count passes the bound", async () => {
|
|
const handle = makeDuplexHandle({
|
|
duplexChannelLimits: { maxPendingRequests: 2 },
|
|
});
|
|
try {
|
|
await handle.start();
|
|
const session = await handle.openDuplexChannel(
|
|
duplexOpenInput({ mode: "no-write-reply", workerSessionId: "ws-A" }),
|
|
);
|
|
const waitResult = session.wait();
|
|
// The worker never replies to a write, so each write stays pending. The
|
|
// third write passes the pending-request bound and the route ends.
|
|
session.write(new TextEncoder().encode("one"));
|
|
session.write(new TextEncoder().encode("two"));
|
|
session.write(new TextEncoder().encode("three"));
|
|
await expect(waitResult).resolves.toEqual({ exitCode: null });
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("ends the route when the pending host-to-worker write bytes pass the route bound", async () => {
|
|
const handle = makeDuplexHandle({
|
|
duplexChannelLimits: { maxPendingWriteBytes: 10 },
|
|
});
|
|
try {
|
|
await handle.start();
|
|
const session = await handle.openDuplexChannel(
|
|
duplexOpenInput({ mode: "no-write-reply", workerSessionId: "ws-A" }),
|
|
);
|
|
const waitResult = session.wait();
|
|
// The worker never replies to a write, so each write's bytes stay charged
|
|
// against the route. The second write brings the cumulative bytes past
|
|
// the 10-byte bound and ends the route.
|
|
session.write(new TextEncoder().encode("aaaaa")); // 5 bytes → 5, under the bound
|
|
session.write(new TextEncoder().encode("bbbbbb")); // 6 bytes → 11 > 10, ends the route
|
|
await expect(waitResult).resolves.toEqual({ exitCode: null });
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("returns the pending write bytes to the route bound after a failed write", async () => {
|
|
const handle = makeDuplexHandle({
|
|
duplexChannelLimits: { maxPendingWriteBytes: 10, openTimeoutMs: 100 },
|
|
});
|
|
try {
|
|
await handle.start();
|
|
const session = await handle.openDuplexChannel(
|
|
duplexOpenInput({ mode: "no-write-reply", workerSessionId: "ws-A" }),
|
|
);
|
|
let routeEnded = false;
|
|
session.wait().then(() => {
|
|
routeEnded = true;
|
|
});
|
|
// The worker never replies, so each write's own request times out and
|
|
// rejects. The rejection must release this write's charged bytes, the
|
|
// same as a reply would. Wait past the first write's timeout before the
|
|
// second write sends.
|
|
session.write(new TextEncoder().encode("12345678")); // 8 bytes, under the 10-byte bound
|
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
// If the first write's bytes had not released on its timeout, this
|
|
// second 8-byte write would bring the route to 16 bytes, past the
|
|
// 10-byte bound, and end the route at once, synchronously, in this call.
|
|
session.write(new TextEncoder().encode("87654321"));
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
expect(routeEnded).toBe(false);
|
|
await session.close();
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("ends the route when one host-to-worker write passes the size bound", async () => {
|
|
const handle = makeDuplexHandle({
|
|
duplexChannelLimits: { maxWriteChars: 8 },
|
|
});
|
|
try {
|
|
await handle.start();
|
|
const session = await handle.openDuplexChannel(
|
|
duplexOpenInput({ workerSessionId: "ws-A" }),
|
|
);
|
|
const waitResult = session.wait();
|
|
// One write is larger than the size bound, so the host rejects it and ends
|
|
// the route before it reaches the worker.
|
|
session.write(new TextEncoder().encode("this-write-is-too-large"));
|
|
await expect(waitResult).resolves.toEqual({ exitCode: null });
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("ends the route when the protocol error count passes the bound", async () => {
|
|
const handle = makeDuplexHandle({
|
|
duplexChannelLimits: { maxProtocolErrors: 2 },
|
|
});
|
|
try {
|
|
await handle.start();
|
|
const session = await handle.openDuplexChannel(
|
|
duplexOpenInput({
|
|
workerSessionId: "ws-A",
|
|
data: [
|
|
{ chunk: "e1", sid: "ws-EVIL" },
|
|
{ chunk: "e2", sid: "ws-EVIL" },
|
|
{ chunk: "e3", sid: "ws-EVIL" },
|
|
],
|
|
}),
|
|
);
|
|
// Each mismatched-session data frame is a protocol error. The third frame
|
|
// passes the error bound and the route ends.
|
|
await expect(session.wait()).resolves.toEqual({ exitCode: null });
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("ends the route when the total data bytes pass the cap for a bound listener", async () => {
|
|
const handle = makeDuplexHandle({
|
|
duplexChannelLimits: { maxTotalDataBytes: 10 },
|
|
});
|
|
try {
|
|
await handle.start();
|
|
const session = await handle.openDuplexChannel(
|
|
duplexOpenInput({
|
|
workerSessionId: "ws-A",
|
|
data: [
|
|
{ chunk: "aaaaa" }, // total 5 → deliver
|
|
{ chunk: "bbbbb" }, // total 10 → deliver
|
|
{ chunk: "ccccc" }, // total 15 > 10 → end route
|
|
],
|
|
}),
|
|
);
|
|
const chunks: string[] = [];
|
|
// The session streams raw `Uint8Array` chunks. Decode each one back to
|
|
// text, so the assertion below compares the plain-text payload the
|
|
// fixture directive scripted.
|
|
session.onData((chunk) => chunks.push(new TextDecoder().decode(chunk)));
|
|
// A listener is bound, so the host forwards each chunk until the cumulative
|
|
// bytes pass the cap. The third chunk passes the cap, so the host drops it
|
|
// and ends the route. The listener never receives data past the cap.
|
|
await expect(session.wait()).resolves.toEqual({ exitCode: null });
|
|
expect(chunks).toEqual(["aaaaa", "bbbbb"]);
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("counts inbound bytes, not characters, against the total cap", async () => {
|
|
const handle = makeDuplexHandle({
|
|
duplexChannelLimits: { maxTotalDataBytes: 4 },
|
|
});
|
|
try {
|
|
await handle.start();
|
|
const session = await handle.openDuplexChannel(
|
|
duplexOpenInput({
|
|
workerSessionId: "ws-A",
|
|
// "€" is one character but three bytes in UTF-8. The first chunk is 3
|
|
// bytes (≤ 4), so the host delivers it. The second chunk brings the
|
|
// total to 6 bytes (> 4), so the host ends the route. A character count
|
|
// would admit both chunks (2 ≤ 4), so one delivered chunk proves the
|
|
// host counts bytes.
|
|
data: [{ chunk: "€" }, { chunk: "€" }],
|
|
}),
|
|
);
|
|
const chunks: string[] = [];
|
|
// The session streams raw `Uint8Array` chunks. Decode each one back to
|
|
// text, so the assertion below compares the plain-text payload the
|
|
// fixture directive scripted.
|
|
session.onData((chunk) => chunks.push(new TextDecoder().decode(chunk)));
|
|
await expect(session.wait()).resolves.toEqual({ exitCode: null });
|
|
expect(chunks).toEqual(["€"]);
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("drains a buffered valid chunk to a listener that binds after the byte cap ends the route", async () => {
|
|
const handle = makeDuplexHandle({
|
|
duplexChannelLimits: { maxTotalDataBytes: 4 },
|
|
});
|
|
try {
|
|
await handle.start();
|
|
const session = await handle.openDuplexChannel(
|
|
duplexOpenInput({
|
|
workerSessionId: "ws-A",
|
|
// "€" is three bytes in UTF-8. The first chunk is 3 bytes (≤ 4), so the
|
|
// host counts and buffers it. The second chunk brings the total to 6
|
|
// bytes (> 4), so the host ends the route.
|
|
data: [{ chunk: "€" }, { chunk: "€" }],
|
|
}),
|
|
);
|
|
// Wait so both data frames arrive and the route ends on the byte cap before
|
|
// a listener binds. The first chunk is a valid buffered frame. The host must
|
|
// keep it, so the late listener drains it. This proves the host does not
|
|
// drop a buffered valid chunk when the route ends before a listener binds.
|
|
await expect(session.wait()).resolves.toEqual({ exitCode: null });
|
|
const chunks: string[] = [];
|
|
// The session streams raw `Uint8Array` chunks. Decode each one back to
|
|
// text, so the assertion below compares the plain-text payload the
|
|
// fixture directive scripted.
|
|
session.onData((chunk) => chunks.push(new TextDecoder().decode(chunk)));
|
|
expect(chunks).toEqual(["€"]);
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("ends the active route when the lifetime timer expires", async () => {
|
|
const handle = makeDuplexHandle({
|
|
duplexChannelLimits: { maxDurationMs: 100 },
|
|
});
|
|
try {
|
|
await handle.start();
|
|
const session = await handle.openDuplexChannel(
|
|
duplexOpenInput({ mode: "normal", workerSessionId: "ws-A" }),
|
|
);
|
|
const waitResult = session.wait();
|
|
// The route sends no exit. The lifetime timer expires, so the host ends the
|
|
// route and resolves the wait with the fixed null exit code.
|
|
await expect(waitResult).resolves.toEqual({ exitCode: null });
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("ends the route at once when one inbound chunk passes the per-chunk limit before a listener binds", async () => {
|
|
const handle = makeDuplexHandle({
|
|
duplexChannelLimits: { maxChunkChars: 4 },
|
|
});
|
|
try {
|
|
await handle.start();
|
|
const session = await handle.openDuplexChannel(
|
|
duplexOpenInput({
|
|
workerSessionId: "ws-A",
|
|
data: [{ chunk: "this-one-chunk-is-too-large" }],
|
|
}),
|
|
);
|
|
// No listener attaches. One inbound chunk is larger than the per-chunk
|
|
// limit, so the host ends the route at once. The default protocol-error
|
|
// budget is far above one, so a single chunk that ends the route proves the
|
|
// host does not treat it as a protocol error.
|
|
await expect(session.wait()).resolves.toEqual({ exitCode: null });
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("ends the route at once when one inbound chunk passes the per-chunk limit after a listener binds", async () => {
|
|
const handle = makeDuplexHandle({
|
|
duplexChannelLimits: { maxChunkChars: 4 },
|
|
});
|
|
try {
|
|
await handle.start();
|
|
const session = await handle.openDuplexChannel(
|
|
duplexOpenInput({
|
|
workerSessionId: "ws-A",
|
|
data: [{ chunk: "this-one-chunk-is-too-large" }],
|
|
}),
|
|
);
|
|
const chunks: string[] = [];
|
|
// The session streams raw `Uint8Array` chunks. Decode each one back to
|
|
// text, so the assertion below compares the plain-text payload the
|
|
// fixture directive scripted.
|
|
session.onData((chunk) => chunks.push(new TextDecoder().decode(chunk)));
|
|
// A listener is bound. One inbound chunk is larger than the per-chunk
|
|
// limit, so the host ends the route at once and never forwards the chunk.
|
|
await expect(session.wait()).resolves.toEqual({ exitCode: null });
|
|
expect(chunks).toEqual([]);
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
// The open reply and a frame arrive in one read batch.
|
|
// -------------------------------------------------------------------------
|
|
|
|
it("holds and replays a data frame that arrives in the open-reply read batch", async () => {
|
|
const handle = makeDuplexHandle();
|
|
try {
|
|
await handle.start();
|
|
const session = await handle.openDuplexChannel(
|
|
duplexOpenInput({
|
|
// The worker writes the open reply and the data and exit frames in one
|
|
// stdout write. The host reads them in one batch, so the data and exit
|
|
// frames arrive before the route binds. The host must hold the frames
|
|
// and replay them after the bind, not drop them.
|
|
batchWithOpenReply: true,
|
|
workerSessionId: "ws-A",
|
|
data: [{ chunk: "batched-one" }, { chunk: "batched-two" }],
|
|
exitCode: 0,
|
|
}),
|
|
);
|
|
const chunks: string[] = [];
|
|
// The session streams raw `Uint8Array` chunks. Decode each one back to
|
|
// text, so the assertion below compares the plain-text payload the
|
|
// fixture directive scripted.
|
|
session.onData((chunk) => chunks.push(new TextDecoder().decode(chunk)));
|
|
await expect(session.wait()).resolves.toEqual({ exitCode: 0 });
|
|
expect(chunks).toEqual(["batched-one", "batched-two"]);
|
|
await session.close();
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("ends the route when a batched frame passes the per-chunk limit before the bind", async () => {
|
|
const handle = makeDuplexHandle({
|
|
duplexChannelLimits: { maxChunkChars: 4 },
|
|
});
|
|
try {
|
|
await handle.start();
|
|
const session = await handle.openDuplexChannel(
|
|
duplexOpenInput({
|
|
// The worker batches the data frame with the open reply, so the frame
|
|
// arrives before the route binds. The replay after the bind applies the
|
|
// per-chunk limit, so the one large chunk ends the route.
|
|
batchWithOpenReply: true,
|
|
workerSessionId: "ws-A",
|
|
data: [{ chunk: "this-one-chunk-is-too-large" }],
|
|
}),
|
|
);
|
|
await expect(session.wait()).resolves.toEqual({ exitCode: null });
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("ends the route when batched pre-bind frames pass the frame count bound", async () => {
|
|
const handle = makeDuplexHandle({
|
|
duplexChannelLimits: { maxPreBindBufferedFrames: 2 },
|
|
});
|
|
try {
|
|
await handle.start();
|
|
const session = await handle.openDuplexChannel(
|
|
duplexOpenInput({
|
|
// The worker batches the three data frames with the open reply, so all
|
|
// three frames arrive before the route binds. No listener attaches, so
|
|
// the replay buffers them. The third frame passes the frame-count bound
|
|
// and the route ends. The pre-open hold must not drop the third frame
|
|
// before the buffered bound can end the route.
|
|
batchWithOpenReply: true,
|
|
data: [{ chunk: "a" }, { chunk: "b" }, { chunk: "c" }],
|
|
}),
|
|
);
|
|
await expect(session.wait()).resolves.toEqual({ exitCode: null });
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("ends the route when the pre-bind hold bytes pass the route input bound", async () => {
|
|
const handle = makeDuplexHandle({
|
|
duplexChannelLimits: { maxPreBindBufferedChars: 10 },
|
|
});
|
|
try {
|
|
await handle.start();
|
|
// The worker batches the three data frames with the open reply, so all
|
|
// three frames arrive before the route binds and land in the pre-bind
|
|
// hold, not the post-bind buffered queue. The frame-count bound stays
|
|
// far above three frames, so only the byte bound can end the route
|
|
// here: this proves the hold itself counts bytes, not only frames. The
|
|
// hold ends the route before the bind completes, so the open call
|
|
// itself fails, the same way a malformed open reply fails it.
|
|
await expect(
|
|
handle.openDuplexChannel(
|
|
duplexOpenInput({
|
|
batchWithOpenReply: true,
|
|
data: [
|
|
{ chunk: "aaaaa" }, // total 5 → held
|
|
{ chunk: "bbbbb" }, // total 10 → held
|
|
{ chunk: "ccccc" }, // total 15 > 10 → end the route in the hold
|
|
],
|
|
}),
|
|
),
|
|
).rejects.toThrow("DUPLEX_CHANNEL_OPEN_FAILED");
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("ends the route on the frame count bound even when a caller raises that bound well past the module default", async () => {
|
|
// Regression test: the pre-open hold ceiling must track
|
|
// maxDuplexChannelPreBindFrames, not a fixed value. A fixed ceiling at or
|
|
// below this bound would drop the 13th frame in the hold before the replay
|
|
// ever applies the buffered bound to it, and the route would never end.
|
|
const handle = makeDuplexHandle({
|
|
duplexChannelLimits: { maxPreBindBufferedFrames: 12 },
|
|
});
|
|
try {
|
|
await handle.start();
|
|
const session = await handle.openDuplexChannel(
|
|
duplexOpenInput({
|
|
batchWithOpenReply: true,
|
|
data: Array.from({ length: 13 }, (_, i) => ({ chunk: String(i) })),
|
|
}),
|
|
);
|
|
await expect(session.wait()).resolves.toEqual({ exitCode: null });
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("ends the route on the frame count bound even when an exit notification arrives before the overflow data frame", async () => {
|
|
// Regression test: an exit notification must never share the pre-open hold's
|
|
// capacity with data frames. The worker here batches exactly
|
|
// maxPreBindBufferedFrames valid data frames (no violation), then its exit,
|
|
// then one more data frame that should trip the buffered-frame bound. If the
|
|
// exit consumed a hold slot, that last data frame would be dropped by the
|
|
// hold before the replay ever applies the buffered bound to it, and the
|
|
// route would end normally on the exit (exitCode: 0) instead of on the
|
|
// bound (exitCode: null).
|
|
const handle = makeDuplexHandle({
|
|
duplexChannelLimits: { maxPreBindBufferedFrames: 3 },
|
|
});
|
|
try {
|
|
await handle.start();
|
|
const session = await handle.openDuplexChannel(
|
|
duplexOpenInput({
|
|
batchWithOpenReply: true,
|
|
data: [{ chunk: "a" }, { chunk: "b" }, { chunk: "c" }],
|
|
exitCode: 0,
|
|
dataAfterExit: [{ chunk: "overflow" }],
|
|
}),
|
|
);
|
|
await expect(session.wait()).resolves.toEqual({ exitCode: null });
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("delivers a batched pre-bind chunk that a later listener drains before the byte cap ends the route", async () => {
|
|
const handle = makeDuplexHandle({
|
|
duplexChannelLimits: { maxTotalDataBytes: 4 },
|
|
});
|
|
try {
|
|
await handle.start();
|
|
const session = await handle.openDuplexChannel(
|
|
duplexOpenInput({
|
|
// The worker batches the two data frames with the open reply, so both
|
|
// frames arrive before the route binds and before a listener attaches.
|
|
// "€" is three bytes in UTF-8. The first chunk (3 bytes ≤ 4) buffers.
|
|
// The second chunk brings the total to 6 bytes (> 4), so the route ends.
|
|
// The route end must not discard the buffered first chunk. The listener
|
|
// attaches after the open resolves and drains the first chunk.
|
|
batchWithOpenReply: true,
|
|
workerSessionId: "ws-A",
|
|
data: [{ chunk: "€" }, { chunk: "€" }],
|
|
}),
|
|
);
|
|
const chunks: string[] = [];
|
|
// The session streams raw `Uint8Array` chunks. Decode each one back to
|
|
// text, so the assertion below compares the plain-text payload the
|
|
// fixture directive scripted.
|
|
session.onData((chunk) => chunks.push(new TextDecoder().decode(chunk)));
|
|
await expect(session.wait()).resolves.toEqual({ exitCode: null });
|
|
expect(chunks).toEqual(["€"]);
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Byte fidelity across the worker remote-procedure-call hop.
|
|
// -------------------------------------------------------------------------
|
|
|
|
it("test_worker_channel_preserves_all_byte_values", async () => {
|
|
const handle = makeDuplexHandle();
|
|
try {
|
|
await handle.start();
|
|
const session = await handle.openDuplexChannel(
|
|
duplexOpenInput({ workerSessionId: "ws-A", echoInput: true }),
|
|
);
|
|
const allByteValues = Uint8Array.from({ length: 256 }, (_, value) => value);
|
|
const received: Uint8Array[] = [];
|
|
session.onData((chunk) => received.push(chunk));
|
|
|
|
session.write(allByteValues);
|
|
|
|
// The fixture echoes the write as one data notification, prefixed with the
|
|
// five ASCII bytes "echo:". It builds the echo on the decoded byte buffer,
|
|
// not a string, so the round trip through the base64 JSON-RPC wire form
|
|
// (`ChannelBytesWireValue`) carries every one of the 256 byte values
|
|
// unchanged, including the byte value zero, which a UTF-8 string hop would
|
|
// not preserve reliably end to end.
|
|
await vi.waitFor(() => expect(received.length).toBe(1));
|
|
const echoPrefix = new TextEncoder().encode("echo:");
|
|
const echoed = received[0]!;
|
|
expect(echoed.byteLength).toBe(echoPrefix.byteLength + allByteValues.byteLength);
|
|
expect(echoed.subarray(0, echoPrefix.byteLength)).toEqual(echoPrefix);
|
|
expect(echoed.subarray(echoPrefix.byteLength)).toEqual(allByteValues);
|
|
await session.close();
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Authoritative closure and worker retirement.
|
|
// -------------------------------------------------------------------------
|
|
|
|
it("closes the route with a fixed exit when the worker exits", async () => {
|
|
const handle = makeDuplexHandle();
|
|
try {
|
|
await handle.start();
|
|
const session = await handle.openDuplexChannel(duplexOpenInput({ mode: "normal" }));
|
|
const waitResult = session.wait();
|
|
await handle.stop();
|
|
// A worker exit closes the one route and resolves the wait with the fixed
|
|
// non-secret exit.
|
|
await expect(waitResult).resolves.toEqual({ exitCode: null });
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("retires the worker on an unconfirmed close acknowledgement", async () => {
|
|
const handle = makeDuplexHandle({
|
|
duplexChannelLimits: { closeTimeoutMs: 200 },
|
|
});
|
|
try {
|
|
await handle.start();
|
|
const exited = new Promise<void>((resolve) => {
|
|
handle.on("exit", () => resolve());
|
|
});
|
|
const session = await handle.openDuplexChannel(
|
|
duplexOpenInput({ mode: "normal", closeMode: "bad-ack" }),
|
|
);
|
|
await session.close();
|
|
// The close acknowledgement carried a mismatched host route id, so the host
|
|
// fails closed and retires the worker before any reuse.
|
|
await exited;
|
|
await expect(
|
|
handle.openDuplexChannel(duplexOpenInput({ mode: "normal" })),
|
|
).rejects.toThrow();
|
|
} finally {
|
|
await handle.stop().catch(() => undefined);
|
|
}
|
|
});
|
|
});
|