Skip to content

Commit 197f84f

Browse files
committed
test(gateway): pin the drain-persistence claim and turn two timeouts into assertions (#119, round 1 review)
C1: the e2e's drain test never re-read `workerBId`'s drained flag after the restart it claimed to survive -- add that assertion (worker-b's own machine reconnecting to prove `workers.json` was actually loaded, since a drained-but-absent worker has no view to read it off), plus a direct `FileDrainStore` round-trip test that closes the only-e2e-covers-this gap entirely. C2: script the excluded worker's client with a spare grant in both drain/disconnect unit tests, so a routing regression lands as a wrong-worker assertion instead of a 5s `noWait: false` hang. C3: gate the e2e's "both connected" waits on catalog + capacity, not just the connection flag -- `WorkerLink#start` flips to `connected` before its first refresh lands, a real race reproduced 7/10 under load. The same gate before draining doubles as a positive pre-drain assertion that worker-b's view already carries its model, so the later NO_CAPACITY can only mean the drain. P1: reword a test comment that credited a specific (dead) conjunct in `#forwardToWorker` with an outcome neither the fake nor production code can actually attribute to it. P2: give the original lease.request a `requesterId` distinct from its principal, so §27a's owner-vs-requester assertion actually exercises the distinction the ADR reserves it for. H2/H3/H4: dedupe the two near-clone drain/disconnect tests into one shared helper, fix two inaccurate comments, and flag the §29 test's re-assertive first half. Verified against the mutations round 1 named: both C1 mutations (FileDrainStore.load() -> [], and MemoryDrainStore wired into main.ts) now fail with a named assertion; both C2 mutations (routing.ts's drained/connection checks) now fail in ~20ms instead of timing out at 5s. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MA98m7ua7qvDFZjxFaww6Z
1 parent 5bf637a commit 197f84f

3 files changed

Lines changed: 260 additions & 69 deletions

File tree

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

Lines changed: 107 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,9 @@ import type { DaemonServer } from "./server.js";
3636

3737
const GATEWAY_PORT = 48173;
3838
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. */
39+
/** Distinct from `GATEWAY_PORT` above so this file's second `it` never races the first one's
40+
* own listener through TIME_WAIT on the same port (H3, round 1 review: both are `it`s in this
41+
* one `describe`, not two separate suites). */
4142
const RESTART_GATEWAY_PORT = 48174;
4243
const RESTART_GATEWAY_URL = `ws://127.0.0.1:${RESTART_GATEWAY_PORT}`;
4344

@@ -54,6 +55,33 @@ function agentSession(overrides: Partial<DispatchSession> = {}): DispatchSession
5455
};
5556
}
5657

58+
/**
59+
* C3 (round 1 review, #119): `connection === "connected"` alone does not mean a worker's view is
60+
* usable. `WorkerLink#start` (`worker-link.ts`) calls `registry.connected(...)` *before*
61+
* `events.subscribe` and before the first `refresh({ includeCatalog: true })` lands, so there is
62+
* a real window in which a worker reports `connected` while its view still carries an empty
63+
* `catalog` and an `undefined` `capacity` -- both of which make `routing.ts#isEligible` drop it.
64+
* Reproduced 7/10 under load (8 `yes` processes on a 4-core box). Gate on the view actually
65+
* being able to serve `model`, not merely on the connection flag, so a `lease.request` right
66+
* after this wait never races that window into a spurious `No worker in the fleet can currently
67+
* serve this request`.
68+
*/
69+
function workerServes(
70+
workers: readonly {
71+
readonly connection: string;
72+
readonly capacity?: unknown;
73+
readonly catalog: readonly { readonly models: readonly string[] }[];
74+
}[],
75+
model: string,
76+
): boolean {
77+
return workers.some(
78+
(worker) =>
79+
worker.connection === "connected" &&
80+
worker.capacity !== undefined &&
81+
worker.catalog.some((entry) => entry.models.includes(model)),
82+
);
83+
}
84+
5785
describe("gateway fleet smoke (ADR 0005 §35)", () => {
5886
const daemons: DaemonServer[] = [];
5987
const directories: string[] = [];
@@ -120,9 +148,20 @@ describe("gateway fleet smoke (ADR 0005 §35)", () => {
120148
readonly token: string;
121149
readonly stdout: string;
122150
readonly gatewayUrl?: string;
123-
}): Promise<DaemonServer> {
124-
const directory = await mkdtemp(join(tmpdir(), `simlock-e2e-${options.label}-`));
125-
directories.push(directory);
151+
/**
152+
* C1 (round 1 review, #119): reuse an already-`mkdtemp`'d directory and its `Filesystem`
153+
* instead of minting fresh ones -- what restarting *this same worker machine* after an
154+
* outage needs, so its `instance.json` (and therefore the `workerId` the gateway already
155+
* knows) survives the restart, exactly as `startRestartableGateway` reuses the gateway's own
156+
* directory and filesystem below. Omit for a worker that starts once and is never restarted.
157+
*/
158+
readonly existing?: { readonly filesystem: Filesystem; readonly directory: string };
159+
}): Promise<{ daemon: DaemonServer; filesystem: Filesystem; directory: string }> {
160+
const directory =
161+
options.existing?.directory ??
162+
(await mkdtemp(join(tmpdir(), `simlock-e2e-${options.label}-`)));
163+
if (options.existing === undefined) directories.push(directory);
164+
const filesystem = options.existing?.filesystem ?? new MemoryFilesystem();
126165
const daemon = await startDaemon({
127166
configOverrides: {
128167
gateway: {
@@ -152,13 +191,13 @@ describe("gateway fleet smoke (ADR 0005 §35)", () => {
152191
platform: "android",
153192
}),
154193
],
155-
filesystem: new MemoryFilesystem(),
194+
filesystem,
156195
logger: new NoopLogger(),
157196
statePath: join(directory, "state.json"),
158197
version: "1.0.0-e2e",
159198
});
160199
daemons.push(daemon);
161-
return daemon;
200+
return { daemon, directory, filesystem };
162201
}
163202

164203
it("leases a device on the right worker and execs a real command against it through the gateway", async () => {
@@ -189,10 +228,11 @@ describe("gateway fleet smoke (ADR 0005 §35)", () => {
189228

190229
// Both uplinks dial on their own schedule outside `startDaemon`'s own returned promise
191230
// (`main.ts` fires `gatewayUplink.start()` without awaiting it) -- wait for the gateway to
192-
// actually see both before leasing.
231+
// actually see both usably (C3, round 1 review) before leasing, not merely `connected`.
193232
await vi.waitFor(async () => {
194233
const { workers } = await gateway.dispatch("worker.list", {}, adminSession());
195-
expect(workers.filter((worker) => worker.connection === "connected")).toHaveLength(2);
234+
expect(workerServes(workers, "Pixel-A")).toBe(true);
235+
expect(workerServes(workers, "Pixel-B")).toBe(true);
196236
});
197237

198238
const grant = await gateway.dispatch(
@@ -263,19 +303,37 @@ describe("gateway fleet smoke (ADR 0005 §35)", () => {
263303
gatewayUrl: RESTART_GATEWAY_URL,
264304
});
265305

306+
// C3 (round 1 review, #119): gate on both views actually being usable, not merely
307+
// `connected` -- see `workerServes`'s own doc comment. This also doubles as the second-order
308+
// fix the review calls for: it is a positive assertion, *before* draining, that worker-b's
309+
// view already carries "Pixel-B" -- so the later `NO_CAPACITY` below can only mean the drain
310+
// excluded it, never that its catalog just had not landed yet.
266311
await vi.waitFor(async () => {
267312
const { workers } = await firstGateway.dispatch("worker.list", {}, adminSession());
268-
expect(workers.filter((worker) => worker.connection === "connected")).toHaveLength(2);
313+
expect(workerServes(workers, "Pixel-A")).toBe(true);
314+
expect(workerServes(workers, "Pixel-B")).toBe(true);
269315
});
270316
const beforeDrain = (await firstGateway.dispatch("worker.list", {}, adminSession())).workers;
271317
const workerBId = beforeDrain.find((worker) => worker.label === "worker-b")?.id;
272318
if (workerBId === undefined) throw new Error("worker-b never connected");
273319

274320
// Lease worker-a's own device -- the fleet client below must still be able to renew this
275321
// exact lease after everything that follows.
322+
// P2 (round 1 review, #119): `requesterId` set explicitly, distinct from the session
323+
// principal -- `agentSession({ principal: "fleet-owner" })` alone makes `ownerId` and
324+
// `requesterId` the same string (`dispatcher.ts` defaults both to `session.principal`), so
325+
// the §27a assertion below (owner authorizes, requester does not) could not tell the two
326+
// fields apart: mutating `lease-index.ts` to re-derive `ownerId` from `requesterId` on
327+
// rebuild would still pass. With a distinct `requesterId`, only a genuinely separate
328+
// `ownerId` on the rebuilt entry can authorize the renew below.
276329
const grant = await firstGateway.dispatch(
277330
"lease.request",
278-
{ model: "Pixel-A", platform: "android", noWait: true },
331+
{
332+
model: "Pixel-A",
333+
platform: "android",
334+
noWait: true,
335+
requesterId: "fleet-owner-session-1",
336+
},
279337
agentSession({ principal: "fleet-owner" }),
280338
);
281339
expect(grant.lease.worker?.label).toBe("worker-a");
@@ -287,8 +345,10 @@ describe("gateway fleet smoke (ADR 0005 §35)", () => {
287345
const { workers } = await firstGateway.dispatch("worker.list", {}, adminSession());
288346
expect(workers.find((worker) => worker.id === workerBId)?.drained).toBe(true);
289347
});
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.
348+
// No new dispatch reaches a drained worker: a `noWait: true` request only worker-b could
349+
// serve is rejected outright (H3, round 1 review: it does not queue -- with worker-b the
350+
// only worker for "Pixel-B" now excluded, there is no eligible worker at all) rather than
351+
// landing on worker-b -- proven over the real uplink, not a scripted directory.
292352
await expect(
293353
firstGateway.dispatch(
294354
"lease.request",
@@ -298,8 +358,8 @@ describe("gateway fleet smoke (ADR 0005 §35)", () => {
298358
).rejects.toMatchObject({ code: "NO_CAPACITY" });
299359

300360
// 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);
361+
await workerB.daemon.stop("test");
362+
daemons.splice(daemons.indexOf(workerB.daemon), 1);
303363

304364
// Restart the gateway. Everything it held only in memory -- worker views, the lease index,
305365
// the fleet queue -- is gone (ADR §30); only what it persisted survives, because this reuses
@@ -334,6 +394,37 @@ describe("gateway fleet smoke (ADR 0005 §35)", () => {
334394
{ timeout: 15_000 },
335395
);
336396

397+
// C1 (round 1 review, #119): this is the assertion this test's own header comment promises
398+
// and never made -- that `workers.json` actually survived the restart. A drained-but-absent
399+
// worker has no `WorkerView` at all (a view is only ever built by `connected()`/`refresh()`,
400+
// never synthesized from the persisted drain set on its own), so the only way to observe the
401+
// restarted gateway's own on-disk state through the same `worker.list` the rest of this test
402+
// uses is a worker bearing `workerBId` actually reconnecting to it -- "worker-b's machine
403+
// comes back up after maintenance" reusing the *same* directory and `Filesystem` worker-b
404+
// had before (so its `instance.json`, and therefore its `workerId`, survives too), exactly as
405+
// `restartedGateway` reuses the gateway's own. Its view is built from nothing but this fresh
406+
// process's own `connected()` call plus whatever `restartedGateway` loaded from
407+
// `workers.json` at startup -- there is no other way `drained` could come back `true` here.
408+
const restartedWorkerB = await startWorker({
409+
label: "worker-b",
410+
model: "Pixel-B",
411+
token: tokenB,
412+
stdout: "hello-from-b",
413+
gatewayUrl: RESTART_GATEWAY_URL,
414+
existing: { directory: workerB.directory, filesystem: workerB.filesystem },
415+
});
416+
await vi.waitFor(
417+
async () => {
418+
const { workers } = await restartedGateway.dispatch("worker.list", {}, adminSession());
419+
const view = workers.find((worker) => worker.id === workerBId);
420+
expect(view?.connection).toBe("connected");
421+
expect(view?.drained).toBe(true);
422+
},
423+
{ timeout: 15_000 },
424+
);
425+
await restartedWorkerB.daemon.stop("test");
426+
daemons.splice(daemons.indexOf(restartedWorkerB.daemon), 1);
427+
337428
// The lease survives the restart (ADR §30) and renewing resumes for its original requester
338429
// once the reconnect rebuild has run -- retried because the rebuild races the reconnect
339430
// itself becoming visible above.
@@ -364,5 +455,5 @@ describe("gateway fleet smoke (ADR 0005 §35)", () => {
364455
{ leaseId: grant.lease.id },
365456
agentSession({ principal: "fleet-owner" }),
366457
);
367-
}, 40_000);
458+
}, 55_000);
368459
});

src/gateway/drain-store.test.ts

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import type { Logger } from "../ports/index.js";
4+
import { MemoryFilesystem } from "../ports/index.js";
5+
import { FileDrainStore } from "./drain-store.js";
6+
7+
/** A minimal `Logger` that only records `error` calls -- everything this suite needs to assert
8+
* the corrupt-file fallback logs loudly rather than swallowing silently. */
9+
function recordingLogger(): {
10+
logger: Logger;
11+
errors: Array<{ message: string; fields?: Record<string, unknown> }>;
12+
} {
13+
const errors: Array<{ message: string; fields?: Record<string, unknown> }> = [];
14+
const logger: Logger = {
15+
debug: () => undefined,
16+
info: () => undefined,
17+
warn: () => undefined,
18+
error: (message, fields) => {
19+
errors.push(fields === undefined ? { message } : { fields, message });
20+
},
21+
child: () => logger,
22+
};
23+
return { errors, logger };
24+
}
25+
26+
/**
27+
* C1 (round 1 review, #119): `FileDrainStore` -- the on-disk half of ADR §8a/§9's "the only
28+
* gateway state that survives a restart" -- had no test of its own anywhere in the repo. Every
29+
* registry-level persistence test injects `MemoryDrainStore`, which exercises the `#drained` set,
30+
* not the file; `gateway-fleet.e2e.test.ts`'s restart test is the only place the real store was
31+
* even constructed, and it never read the file back either. This file closes that gap directly,
32+
* over a `MemoryFilesystem` rather than the real disk, the same way every other persistence test
33+
* in this codebase does.
34+
*/
35+
describe("FileDrainStore", () => {
36+
const PATH = "/home/.simlock/workers.json";
37+
38+
it("round-trips an empty store as an empty array without touching the filesystem", async () => {
39+
const filesystem = new MemoryFilesystem();
40+
const store = new FileDrainStore({ filesystem, path: PATH });
41+
42+
expect(await filesystem.exists(PATH)).toBe(false);
43+
await expect(store.load()).resolves.toEqual([]);
44+
});
45+
46+
it("saves drained worker ids as `{drained: [...]}` and loads them back, mode 0o600", async () => {
47+
const filesystem = new MemoryFilesystem();
48+
const store = new FileDrainStore({ filesystem, path: PATH });
49+
50+
await store.save(["wrk_a", "wrk_b"]);
51+
52+
expect(await filesystem.exists(PATH)).toBe(true);
53+
const raw = await filesystem.readFile(PATH);
54+
expect(JSON.parse(raw)).toEqual({ drained: ["wrk_a", "wrk_b"] });
55+
expect((await filesystem.stat(PATH)).mode).toBe(0o600);
56+
57+
// A fresh store instance -- the same file, not the same object -- reading it back is the
58+
// shape a real gateway restart depends on: a new process, same directory.
59+
const reloaded = new FileDrainStore({ filesystem, path: PATH });
60+
await expect(reloaded.load()).resolves.toEqual(["wrk_a", "wrk_b"]);
61+
});
62+
63+
it("a later save overwrites the file rather than merging with what was there before", async () => {
64+
const filesystem = new MemoryFilesystem();
65+
const store = new FileDrainStore({ filesystem, path: PATH });
66+
67+
await store.save(["wrk_a", "wrk_b"]);
68+
await store.save(["wrk_b"]);
69+
70+
await expect(store.load()).resolves.toEqual(["wrk_b"]);
71+
});
72+
73+
it("falls back to an empty array, logging loudly, when the file holds invalid JSON", async () => {
74+
const filesystem = new MemoryFilesystem();
75+
await filesystem.mkdirp("/home/.simlock");
76+
await filesystem.writeFileAtomic(PATH, "not json at all");
77+
const { errors, logger } = recordingLogger();
78+
const store = new FileDrainStore({ filesystem, logger, path: PATH });
79+
80+
await expect(store.load()).resolves.toEqual([]);
81+
expect(errors).toHaveLength(1);
82+
expect(errors[0]?.fields).toMatchObject({ path: PATH });
83+
});
84+
85+
it("falls back to an empty array when the file is valid JSON but `drained` is not an array", async () => {
86+
const filesystem = new MemoryFilesystem();
87+
await filesystem.mkdirp("/home/.simlock");
88+
await filesystem.writeFileAtomic(PATH, JSON.stringify({ drained: "wrk_a" }));
89+
const store = new FileDrainStore({ filesystem, path: PATH });
90+
91+
await expect(store.load()).resolves.toEqual([]);
92+
});
93+
94+
it("drops non-string entries from `drained` rather than throwing", async () => {
95+
const filesystem = new MemoryFilesystem();
96+
await filesystem.mkdirp("/home/.simlock");
97+
await filesystem.writeFileAtomic(
98+
PATH,
99+
JSON.stringify({ drained: ["wrk_a", 7, null, "wrk_b"] }),
100+
);
101+
const store = new FileDrainStore({ filesystem, path: PATH });
102+
103+
await expect(store.load()).resolves.toEqual(["wrk_a", "wrk_b"]);
104+
});
105+
});

0 commit comments

Comments
 (0)