Files
rea/tests/boundary/providers/hopper/hopperClient.deadlines.test.ts
T
e8939ca220 fix(hopper): enforce caller-supplied request deadlines (#1119)
* fix(hopper): enforce caller-supplied request deadlines

* fix(hopper): correct idle deadline state and name timeout bound

Zero-ms deadlines never enter the FIFO, so report providerState
not_started instead of busy; the busy wording tells callers to wait
for an active request that does not exist. Name the Node timer
ceiling once and reuse it in validation and the error message.

* fix(hopper): enforce elapsed deadlines before dispatch and replies

* fix(hopper): distinguish unsent requests in timeout diagnostics

* test(hopper): exercise request deadlines against native Hopper

---------

Co-authored-by: Vilmer Frost <frost.vilmer@gmail.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-09 06:52:07 +08:00

152 lines
4.7 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import { access } from "node:fs/promises";
import {
HopperFixtureLauncher,
startHopperFixtureClient,
} from "./hopperClient.fixture.js";
describe("HopperClient request deadlines", () => {
it("propagates deadlines through the socket client and accepts late replies safely", async () => {
const launcher = new HopperFixtureLauncher();
const client = await startHopperFixtureClient(launcher);
const settled = vi.fn();
const pending = client
.callTool("echo", { gate: "deadline" }, { timeoutMs: 100 })
.then(settled);
const request = await launcher.waitForRequest("echo", "deadline");
try {
await vi.waitFor(
() =>
expect(settled).toHaveBeenCalledWith({
ok: false,
error: expect.objectContaining({
_tag: "HopperTimeoutError",
timeoutMs: 100,
operation: "echo",
}),
}),
{ timeout: 1000 },
);
expect(client.requestActivity()).toMatchObject({
callerState: "cancelled",
operation: "echo",
});
} finally {
await launcher.release(request);
await pending;
}
await expect(
client.callTool("echo", { value: "alive" }, { timeoutMs: 1000 }),
).resolves.toEqual({
ok: true,
value: { value: "alive" },
});
expect(settled).toHaveBeenCalledTimes(1);
expect(client.requestActivity()).toBeNull();
});
it("never transmits a request whose deadline expired in the FIFO", async () => {
const launcher = new HopperFixtureLauncher();
const client = await startHopperFixtureClient(launcher);
const active = client.callTool("echo", { gate: "hold-active" });
const request = await launcher.waitForRequest("echo", "hold-active");
try {
await expect(
client.callTool("echo", { value: "expired" }, { timeoutMs: 25 }),
).resolves.toMatchObject({
ok: false,
error: { _tag: "HopperTimeoutError" },
});
expect(
launcher.requests.some(({ params }) => params?.value === "expired"),
).toBe(false);
} finally {
await launcher.release(request);
await active;
}
await expect(client.callTool("echo", { value: "next" })).resolves.toEqual({
ok: true,
value: { value: "next" },
});
expect(
launcher.requests.some(({ params }) => params?.value === "expired"),
).toBe(false);
});
it("closes and releases pending callers and private resources after an active timeout", async () => {
const launcher = new HopperFixtureLauncher();
const client = await startHopperFixtureClient(launcher);
const pending = client.callTool(
"echo",
{ gate: "timeout-close" },
{ timeoutMs: 100 },
);
await launcher.waitForRequest("echo", "timeout-close");
await expect(pending).resolves.toMatchObject({
ok: false,
error: { _tag: "HopperTimeoutError" },
});
const queued = client.callTool(
"echo",
{ value: "queued" },
{ timeoutMs: 1000 },
);
await vi.waitFor(() =>
expect(client.requestActivity()?.queuedRequests).toBe(1),
);
await client.close();
await expect(queued).resolves.toMatchObject({
ok: false,
error: { _tag: "HopperProcessError" },
});
expect(client.requestActivity()).toBeNull();
for (const directory of launcher.directories)
await expect(access(directory)).rejects.toBeDefined();
expect(
launcher.processes.every(
(child) => child.exitCode !== null || child.signalCode !== null,
),
).toBe(true);
});
});
it("does not send a mutation after its synchronous progress observer consumes the deadline", async () => {
const launcher = new HopperFixtureLauncher();
const client = await startHopperFixtureClient(launcher);
const result = await client.callTool(
"set_comment",
{ comment: "must not reach the native boundary" },
{
timeoutMs: 10,
progress: {
report: () => {
const started = performance.now();
while (performance.now() - started < 30) {
// Exercise an observer delaying dispatch of the actual deadline timer.
}
return Promise.resolve();
},
},
},
);
expect(result).toMatchObject({
ok: false,
error: {
_tag: "HopperTimeoutError",
operation: "set_comment",
providerState: "not_started",
},
});
await expect(
client.callTool("echo", { value: "recovered" }),
).resolves.toEqual({
ok: true,
value: { value: "recovered" },
});
expect(launcher.requests.some(({ method }) => method === "set_comment")).toBe(
false,
);
expect(client.requestActivity()).toBeNull();
});