Skip to content

Commit 5bf637a

Browse files
committed
test(gateway): prove drain lifecycle, WORKER_UNREACHABLE paths, and reconnect rebuild end to end (#119)
#118 already built the machinery this issue asks for: routing.ts drops a drained/disconnected worker at admission, #forwardToWorker/#classifyRelayedError map kind:"transport" to WORKER_UNREACHABLE, FleetLeaseIndex#rebuildFromWorker reconciles a worker's leases on every view change, and dispatcher.test.ts already pins §27a's ownership round-trip on a rebuilt lease. No production code needed to change for this PR -- what was missing was the tested proof that those pieces hold together under the specific failure shapes ADR 0005 §9/§28/§29/§30 describe, with call-count assertions rather than where-the-grant-landed assertions. Adds to src/gateway/fleet-coordinator.test.ts: - a drained worker keeps its existing lease and its own client's call count never grows, while a sibling with strictly less free capacity serves the next request (proving drain, not capacity, excluded it) - the same shape for a disconnected worker - device.exec on a directory-unreachable worker answers WORKER_UNREACHABLE without ever reaching the worker's client - a lease.request whose uplink drops mid-call surfaces WORKER_UNREACHABLE to the client, and once the worker's view later reports the lease it actually granted, a retry from the same requester is refused REQUESTER_ALREADY_LEASED rather than double-granted Adds to src/daemon/gateway-fleet.e2e.test.ts: the flagship two-worker, real-WebSocket scenario -- lease through the gateway, drain one worker, kill it, restart the gateway (reusing its own directory and Filesystem so instance.json/tokens.json/workers.json survive, exactly as a real restart would), and prove the surviving worker's lease renews for its original requester and is refused for anyone else post-restart. Every new test was verified by deleting the production code it covers and confirming the test fails first (routing.ts's drained/connected checks, the exec reachability precheck, request()'s one-lease admission check, and -- for the e2e -- swapping in a fresh Filesystem on restart to break identity continuity), then restored. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MA98m7ua7qvDFZjxFaww6Z
1 parent 4ce67bb commit 5bf637a

2 files changed

Lines changed: 360 additions & 1 deletion

File tree

src/daemon/gateway-fleet.e2e.test.ts

Lines changed: 183 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,13 +28,18 @@ import { join } from "node:path";
2828
import { afterEach, describe, expect, it, vi } from "vitest";
2929

3030
import { FakeDriver } from "../core/index.js";
31+
import type { Filesystem } from "../ports/index.js";
3132
import { MemoryFilesystem, NoopLogger, SystemClock } from "../ports/index.js";
3233
import type { DispatchSession } from "./dispatch.js";
3334
import { startDaemon } from "./main.js";
3435
import type { DaemonServer } from "./server.js";
3536

3637
const GATEWAY_PORT = 48173;
3738
const GATEWAY_URL = `ws://127.0.0.1:${GATEWAY_PORT}`;
39+
/** Distinct from `GATEWAY_PORT` above so this file's second suite never races the first test's
40+
* own listener through TIME_WAIT on the same port. */
41+
const RESTART_GATEWAY_PORT = 48174;
42+
const RESTART_GATEWAY_URL = `ws://127.0.0.1:${RESTART_GATEWAY_PORT}`;
3843

3944
function adminSession(): DispatchSession {
4045
return { manageEventSubscription: () => undefined, principal: "test-operator", role: "admin" };
@@ -78,17 +83,53 @@ describe("gateway fleet smoke (ADR 0005 §35)", () => {
7883
return daemon;
7984
}
8085

86+
/**
87+
* #119: a gateway that can be stopped and started again *as the same gateway* -- the same
88+
* `SIMLOCK_HOME` directory and the same `Filesystem` instance backing it, which is what
89+
* `instance.json`/`tokens.json`/`workers.json` actually persisting across the restart depends
90+
* on (ADR §30, §8a). `startGateway` above deliberately hands each call a fresh
91+
* `MemoryFilesystem`, which is right for a test that starts one gateway once; this one needs
92+
* the state to survive the restart it is about to perform.
93+
*/
94+
async function startRestartableGateway(): Promise<{
95+
daemon: DaemonServer;
96+
filesystem: Filesystem;
97+
directory: string;
98+
}> {
99+
const directory = await mkdtemp(join(tmpdir(), "simlock-e2e-gateway-restart-"));
100+
directories.push(directory);
101+
const filesystem = new MemoryFilesystem();
102+
const daemon = await startDaemon({
103+
configOverrides: {
104+
mode: "gateway",
105+
http: { enabled: true, host: "127.0.0.1", port: RESTART_GATEWAY_PORT },
106+
},
107+
dataDirectory: directory,
108+
filesystem,
109+
logger: new NoopLogger(),
110+
statePath: join(directory, "state.json"),
111+
version: "1.0.0-e2e",
112+
});
113+
daemons.push(daemon);
114+
return { daemon, filesystem, directory };
115+
}
116+
81117
async function startWorker(options: {
82118
readonly label: string;
83119
readonly model: string;
84120
readonly token: string;
85121
readonly stdout: string;
122+
readonly gatewayUrl?: string;
86123
}): Promise<DaemonServer> {
87124
const directory = await mkdtemp(join(tmpdir(), `simlock-e2e-${options.label}-`));
88125
directories.push(directory);
89126
const daemon = await startDaemon({
90127
configOverrides: {
91-
gateway: { url: GATEWAY_URL, token: options.token, label: options.label },
128+
gateway: {
129+
url: options.gatewayUrl ?? GATEWAY_URL,
130+
token: options.token,
131+
label: options.label,
132+
},
92133
},
93134
dataDirectory: directory,
94135
drivers: [
@@ -183,4 +224,145 @@ describe("gateway fleet smoke (ADR 0005 §35)", () => {
183224

184225
await gateway.dispatch("lease.release", { leaseId: grant.lease.id }, agentSession());
185226
}, 30_000);
227+
228+
/**
229+
* #119's own flagship: drain semantics, `WORKER_UNREACHABLE`-shaped exclusion, and reconnect
230+
* rebuild, proved together over real processes and a real WebSocket restart -- not the
231+
* scripted uplink `fleet-coordinator.test.ts` and `dispatcher.test.ts` already cover each
232+
* piece of in isolation (including §27a's ownership round trip, pinned there too). What only
233+
* this shape of test can catch: the gateway's own persisted state (`instance.json`,
234+
* `tokens.json`, `workers.json`) actually surviving a real `stop()`/`startDaemon()` cycle, and
235+
* a real worker's own `GatewayUplink` actually redialling and reconnecting on its own backoff
236+
* once the new process is listening again.
237+
*/
238+
it("drains one worker, kills it, restarts the gateway, and proves the surviving worker's lease outlives the restart with renewing resumed (ADR §9/§27a/§30, #119)", async () => {
239+
const { daemon: firstGateway, filesystem, directory } = await startRestartableGateway();
240+
const { secret: tokenA } = await firstGateway.dispatch(
241+
"token.create",
242+
{ role: "worker", label: "worker-a" },
243+
adminSession(),
244+
);
245+
const { secret: tokenB } = await firstGateway.dispatch(
246+
"token.create",
247+
{ role: "worker", label: "worker-b" },
248+
adminSession(),
249+
);
250+
251+
await startWorker({
252+
label: "worker-a",
253+
model: "Pixel-A",
254+
token: tokenA,
255+
stdout: "hello-from-a",
256+
gatewayUrl: RESTART_GATEWAY_URL,
257+
});
258+
const workerB = await startWorker({
259+
label: "worker-b",
260+
model: "Pixel-B",
261+
token: tokenB,
262+
stdout: "hello-from-b",
263+
gatewayUrl: RESTART_GATEWAY_URL,
264+
});
265+
266+
await vi.waitFor(async () => {
267+
const { workers } = await firstGateway.dispatch("worker.list", {}, adminSession());
268+
expect(workers.filter((worker) => worker.connection === "connected")).toHaveLength(2);
269+
});
270+
const beforeDrain = (await firstGateway.dispatch("worker.list", {}, adminSession())).workers;
271+
const workerBId = beforeDrain.find((worker) => worker.label === "worker-b")?.id;
272+
if (workerBId === undefined) throw new Error("worker-b never connected");
273+
274+
// Lease worker-a's own device -- the fleet client below must still be able to renew this
275+
// exact lease after everything that follows.
276+
const grant = await firstGateway.dispatch(
277+
"lease.request",
278+
{ model: "Pixel-A", platform: "android", noWait: true },
279+
agentSession({ principal: "fleet-owner" }),
280+
);
281+
expect(grant.lease.worker?.label).toBe("worker-a");
282+
283+
// Drain worker-b (ADR §9). It holds no lease of its own here; the point of draining it is
284+
// that the drain has to survive everything below, not just this call.
285+
await firstGateway.dispatch("worker.drain", { workerId: workerBId }, adminSession());
286+
await vi.waitFor(async () => {
287+
const { workers } = await firstGateway.dispatch("worker.list", {}, adminSession());
288+
expect(workers.find((worker) => worker.id === workerBId)?.drained).toBe(true);
289+
});
290+
// No new dispatch reaches a drained worker: a request only worker-b could serve queues
291+
// rather than landing on it -- proven over the real uplink, not a scripted directory.
292+
await expect(
293+
firstGateway.dispatch(
294+
"lease.request",
295+
{ model: "Pixel-B", platform: "android", noWait: true },
296+
agentSession({ principal: "someone-else" }),
297+
),
298+
).rejects.toMatchObject({ code: "NO_CAPACITY" });
299+
300+
// Kill worker-b outright: an operator taking a drained machine down for maintenance.
301+
await workerB.stop("test");
302+
daemons.splice(daemons.indexOf(workerB), 1);
303+
304+
// Restart the gateway. Everything it held only in memory -- worker views, the lease index,
305+
// the fleet queue -- is gone (ADR §30); only what it persisted survives, because this reuses
306+
// the *same* directory and the *same* `Filesystem` instance rather than fabricating a fresh
307+
// gateway that merely happens to listen on the same port.
308+
await firstGateway.stop("test");
309+
daemons.splice(daemons.indexOf(firstGateway), 1);
310+
const restartedGateway = await startDaemon({
311+
configOverrides: {
312+
mode: "gateway",
313+
http: { enabled: true, host: "127.0.0.1", port: RESTART_GATEWAY_PORT },
314+
},
315+
dataDirectory: directory,
316+
filesystem,
317+
logger: new NoopLogger(),
318+
statePath: join(directory, "state.json"),
319+
version: "1.0.0-e2e",
320+
});
321+
daemons.push(restartedGateway);
322+
323+
// worker-a's own uplink was never touched -- it keeps redialling on its own backoff and
324+
// finds the restarted gateway listening again on the same port.
325+
await vi.waitFor(
326+
async () => {
327+
const { workers } = await restartedGateway.dispatch("worker.list", {}, adminSession());
328+
expect(
329+
workers.some(
330+
(worker) => worker.label === "worker-a" && worker.connection === "connected",
331+
),
332+
).toBe(true);
333+
},
334+
{ timeout: 15_000 },
335+
);
336+
337+
// The lease survives the restart (ADR §30) and renewing resumes for its original requester
338+
// once the reconnect rebuild has run -- retried because the rebuild races the reconnect
339+
// itself becoming visible above.
340+
const renewed = await vi.waitFor(
341+
() =>
342+
restartedGateway.dispatch(
343+
"lease.renew",
344+
{ leaseId: grant.lease.id },
345+
agentSession({ principal: "fleet-owner" }),
346+
),
347+
{ timeout: 15_000 },
348+
);
349+
expect(renewed.ttlDeadline).toBeGreaterThan(grant.lease.ttlDeadline);
350+
351+
// ...and refused for anyone else (ADR §27a, pinned end to end across a real restart): the
352+
// owner this gateway forwarded before it died is what the rebuilt lease authorizes against,
353+
// not whichever principal happens to ask first.
354+
await expect(
355+
restartedGateway.dispatch(
356+
"lease.renew",
357+
{ leaseId: grant.lease.id },
358+
agentSession({ principal: "an-impostor" }),
359+
),
360+
).rejects.toMatchObject({ code: "FORBIDDEN" });
361+
362+
await restartedGateway.dispatch(
363+
"lease.release",
364+
{ leaseId: grant.lease.id },
365+
agentSession({ principal: "fleet-owner" }),
366+
);
367+
}, 40_000);
186368
});

0 commit comments

Comments
 (0)