Files
PaperClipAI/server/src/__tests__/plugin-worker-manager-duplex.test.ts
T
Nicky LeachandPaperclip 7c8064da1b test(runtime): assert order-independent port invariants for concurrent siblings (#11754)
## Thinking Path

> - Paperclip manages work for AI agents.
> - The workspace runtime starts isolated services for concurrent
workspaces.
> - A runtime test assumed that one concurrent lane always received the
base port.
> - The allocator guarantees distinct ports, but scheduling decides
which lane receives the base port.
> - This pull request changes the test to assert allocator guarantees
without lane-order assumptions.
> - The benefit is a stable test that still checks the complete bounded
port range.

## Linked Issues or Issue Description

**What happened?**

The concurrent sibling workspace runtime test failed intermittently
because it assumed array index 0 received the base port.

**Expected behavior**

The test must accept either lane as the base-port owner while it checks
the allocator invariants.

**Steps to reproduce**

1. Start two isolated workspace runtimes with `Promise.all`.
2. Force the second lane to start first.
3. Run the old assertions.
4. Observe that the test expects the wrong lane to receive the base
port.

**Paperclip version or commit**

This change targets the current `master` branch.

**Deployment mode**

Built from source test suite.

**Installation method**

Built from source with pnpm.

**Database mode**

Not database-related.

## What Changed

- Replace lane-order assertions with order-independent port invariants.
- Assert distinct ports, the base lower port, and the bounded upper
port.
- Keep concurrent startup, service URL checks, and persisted-row checks.

## Verification

- The target test passed 12 consecutive runs.
- The full test file passed 128 of 128 tests.
- Both forced lane orderings passed with the new invariants.
- TypeScript reported no errors in the changed file.
- CI will run after this pull request opens.

## Risks

Low risk. This pull request changes one test file and does not change
runtime code.

## Model Used

OpenAI Codex, GPT-5, tool use and code execution enabled. The model
reviewed and prepared the pull request metadata.

## 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
- [x] I have run tests locally and they pass
- [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
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-08-19 19:59:40 -07:00

558 lines
21 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 { 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) {
return {
driverKey: "daytona",
companyId: "company-1",
environmentId: "env-1",
providerLeaseId: JSON.stringify(directive),
command: "bridge-callback",
};
}
describe("plugin worker manager duplex channel route", () => {
it("delivers data only for the exact bound worker session id and drops a mismatch", async () => {
const handle = makeDuplexHandle();
try {
await handle.start();
const session = await handle.openDuplexChannel(
duplexOpenInput({
workerSessionId: "ws-A",
data: [
{ chunk: "good-1" },
{ chunk: "forged", sid: "ws-EVIL" },
{ chunk: "good-2" },
],
exitCode: 0,
}),
);
const chunks: string[] = [];
session.onData((chunk) => chunks.push(chunk));
await expect(session.wait()).resolves.toEqual({ exitCode: 0 });
// The forged notification carries a wrong worker session id, so the host
// drops it. Only the two bound chunks reach the listener, in order.
expect(chunks).toEqual(["good-1", "good-2"]);
await session.close();
} 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[] = [];
session.onData((chunk) => chunks.push(chunk));
session.write("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" }],
}),
);
// Wait so the three data notifications arrive and buffer before a listener
// attaches. The drain then delivers them in order.
await new Promise((resolve) => setTimeout(resolve, 60));
const chunks: string[] = [];
session.onData((chunk) => chunks.push(chunk));
await vi.waitFor(() => expect(chunks.length).toBe(3));
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) => {
chunks.push(chunk);
if (chunk === "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("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" }],
}),
);
// Wait so the three data notifications arrive and buffer before a listener
// attaches. The drain then delivers them in order.
await new Promise((resolve) => setTimeout(resolve, 60));
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) => {
chunks.push(chunk);
if (chunk === "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[] = [];
session.onData((chunk) => chunks.push(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("permits one active duplex channel per worker", async () => {
const handle = makeDuplexHandle();
try {
await handle.start();
const first = await handle.openDuplexChannel(duplexOpenInput({ mode: "normal" }));
// A second open while the first route is not closed rejects with one fixed
// non-secret error before it reaches the worker.
await expect(
handle.openDuplexChannel(duplexOpenInput({ mode: "normal" })),
).rejects.toThrow("DUPLEX_CHANNEL_ROUTE_BUSY");
await first.close();
// After the first route closes and the worker acknowledges the close, a new
// open is admitted.
const second = await handle.openDuplexChannel(duplexOpenInput({ mode: "normal" }));
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 pre-bind buffered bytes pass the bound", async () => {
const handle = makeDuplexHandle({
duplexChannelLimits: { maxPreBindBufferedChars: 10 },
});
try {
await handle.start();
const session = await handle.openDuplexChannel(
duplexOpenInput({
data: [
{ chunk: "aaaaa" }, // total 5 → buffered
{ chunk: "bbbbb" }, // total 10 → buffered
{ chunk: "ccccc" }, // total 15 > 10 → end route
],
}),
);
// No listener attaches, so the data buffers. The cumulative bytes pass the
// bound and the route ends. The login 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("one");
session.write("two");
session.write("three");
await expect(waitResult).resolves.toEqual({ exitCode: null });
} 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("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[] = [];
session.onData((chunk) => chunks.push(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[] = [];
session.onData((chunk) => chunks.push(chunk));
await expect(session.wait()).resolves.toEqual({ exitCode: null });
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[] = [];
session.onData((chunk) => chunks.push(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[] = [];
session.onData((chunk) => chunks.push(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);
}
});
// -------------------------------------------------------------------------
// 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);
}
});
});