Skip to content

Commit 8444812

Browse files
authored
fix(miner): compare-and-set the orphan reclaim so a stale probe cannot free a live re-acquired slot (#8992)
* fix(miner): compare-and-set the orphan reclaim so a stale probe cannot free a live re-acquired slot reclaimOrphanedAllocations probed active slots with one SELECT and then freed each by bare slot_index. Since #8859 the sweep runs on every acquire(), so under real concurrency a peer can free-and-re-acquire a slot between the probe and the apply — the stale reclaim then force- freed the peer's LIVE lease and a third process double-booked the same worktree path (reproduced in CI: the collisions test returned 4 of 5 distinct paths). The reclaim UPDATE now guards on the exact probed lease evidence (status, allocated_at, owner_pid, owner_host), making a stale apply a no-op. * fix(test): hold the worktree lease until observed — the collision flake's true mechanism The acquire-child fixture exited immediately after acquiring, violating the lease contract: later children's on-acquire sweeps (#8859) then CORRECTLY reclaimed the dead winners' slots via the same-host dead-pid fast path and re-issued the same paths — CI's 4-of-5 and 2-of-5 distinct failures, and a potential third success at a 2-slot cap. Children now hold their lease (process alive, allocator open) until the harness has observed every result, matching how production owners live for the whole worktree lifetime; results resolve from stdout instead of exit, and the release broadcast tolerates already-exited children.
1 parent 7fd84ff commit 8444812

4 files changed

Lines changed: 102 additions & 20 deletions

File tree

packages/loopover-miner/lib/worktree-allocator.ts

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -239,17 +239,26 @@ function isSlotOrphaned(row: OrphanProbeRow, nowMs: number, maxLeaseMs: number,
239239
return false;
240240
}
241241

242-
function reclaimOrphanedAllocations(db: DatabaseSync, nowMs: number, maxLeaseMs: number, hostId: string): void {
243-
const orphans = db
244-
.prepare("SELECT slot_index, owner_pid, owner_host, allocated_at FROM worktree_slots WHERE status = 'active'")
245-
.all() as OrphanProbeRow[];
242+
/** Exported for the CAS regression test only — production callers go through the allocator handle. */
243+
export function reclaimOrphanedAllocations(db: DatabaseSync, nowMs: number, maxLeaseMs: number, hostId: string, probedRows?: OrphanProbeRow[]): void {
244+
const orphans =
245+
probedRows ??
246+
(db
247+
.prepare("SELECT slot_index, owner_pid, owner_host, allocated_at FROM worktree_slots WHERE status = 'active'")
248+
.all() as OrphanProbeRow[]);
249+
// COMPARE-AND-SET on the exact lease evidence the probe saw: between the SELECT above and this UPDATE, a
250+
// peer process can legitimately free-and-re-acquire the same slot (the sweep runs on EVERY acquire since
251+
// #8859, so the window is hit under real concurrency — CI reproduced duplicate paths). A re-acquired slot
252+
// carries a fresh allocated_at (and usually a new owner), so guarding on the probed values makes a stale
253+
// reclaim a no-op instead of force-freeing a live peer's allocation and double-booking the worktree.
246254
const reclaim = db.prepare(`
247255
UPDATE worktree_slots
248256
SET status = 'free', attempt_id = NULL, repo_full_name = NULL, owner_pid = NULL, owner_host = NULL, allocated_at = NULL
249-
WHERE slot_index = ?
257+
WHERE slot_index = ? AND status = 'active'
258+
AND allocated_at IS ? AND owner_pid IS ? AND owner_host IS ?
250259
`);
251260
for (const row of orphans) {
252-
if (isSlotOrphaned(row, nowMs, maxLeaseMs, hostId)) reclaim.run(row.slot_index);
261+
if (isSlotOrphaned(row, nowMs, maxLeaseMs, hostId)) reclaim.run(row.slot_index, row.allocated_at, row.owner_pid, row.owner_host);
253262
}
254263
}
255264

test/fixtures/miner-worktree-allocator/acquire-child.mjs

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@
22
// Cross-process helper for worktree-allocator collision tests (#4298).
33
// Opens the shared store, waits for a stdin "go" signal, then calls acquire() so
44
// multiple Node processes contend on BEGIN IMMEDIATE against the same dbPath.
5+
//
6+
// LEASE LIFECYCLE (#8992): a child that acquires successfully HOLDS its lease (process alive, allocator
7+
// open) until the test sends "done". Exiting right after acquire — the previous behavior — violated the
8+
// lease contract the allocator is built around: the same-host dead-pid fast path in every LATER child's
9+
// on-acquire sweep (#8859) then correctly reclaimed the dead child's slot and re-issued the same path,
10+
// which is exactly what the distinct-paths and capacity tests flaked on under CI load (4-of-5, then
11+
// 2-of-5 distinct). Production owners stay alive for the whole worktree lifetime; the fixture now does too.
512
import { openWorktreeAllocator } from "../../../packages/loopover-miner/dist/lib/worktree-allocator.js";
613

714
const [dbPath, worktreeBaseDir, maxConcurrencyStr, attemptId, repoFullName] = process.argv.slice(2);
@@ -17,23 +24,36 @@ const allocator = openWorktreeAllocator({
1724
});
1825

1926
let started = false;
27+
let holdingLease = false;
2028

2129
function runAcquire() {
2230
if (started) return;
2331
started = true;
2432
try {
2533
const allocation = allocator.acquire(attemptId, repoFullName);
34+
holdingLease = true;
2635
process.stdout.write(`${JSON.stringify({ ok: true, allocation })}\n`);
27-
process.exit(0);
36+
// Stay alive: the lease is held until the test says "done".
2837
} catch (error) {
2938
const message = error instanceof Error ? error.message : String(error);
3039
process.stdout.write(`${JSON.stringify({ ok: false, message })}\n`);
31-
process.exit(1);
32-
} finally {
3340
allocator.close();
41+
process.exit(1);
3442
}
3543
}
3644

45+
function finish() {
46+
if (!holdingLease) return;
47+
holdingLease = false;
48+
allocator.close();
49+
process.exit(0);
50+
}
51+
3752
process.stdin.setEncoding("utf8");
38-
process.stdin.on("data", () => runAcquire());
53+
let stdinBuffer = "";
54+
process.stdin.on("data", (chunk) => {
55+
stdinBuffer += chunk;
56+
if (stdinBuffer.includes("go\n")) runAcquire();
57+
if (stdinBuffer.includes("done\n")) finish();
58+
});
3959
process.stdout.write("READY\n");

test/unit/miner-worktree-allocator-collisions.test.ts

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ function spawnAcquireChild(
6262
attemptId: string,
6363
maxConcurrency: number,
6464
): ChildProcessWithoutNullStreams {
65-
return spawn(
65+
const child = spawn(
6666
process.execPath,
6767
[
6868
acquireChildScript,
@@ -74,6 +74,10 @@ function spawnAcquireChild(
7474
],
7575
{ stdio: ["pipe", "pipe", "pipe"] },
7676
);
77+
// The done-broadcast can race a child that already exited (a rejected acquire exits immediately) — an
78+
// unhandled EPIPE on its stdin must not crash the test run.
79+
child.stdin.on("error", () => {});
80+
return child;
7781
}
7882

7983
async function waitForReady(child: ChildProcessWithoutNullStreams): Promise<void> {
@@ -102,29 +106,49 @@ async function runBarrieredAcquires(
102106
const children = attemptIds.map((attemptId) => spawnAcquireChild(paths, attemptId, maxConcurrency));
103107
await Promise.all(children.map((child) => waitForReady(child)));
104108
for (const child of children) child.stdin.write("go\n");
105-
return Promise.all(
109+
// #8992: results resolve from STDOUT, not exit — a successful child HOLDS its lease (process alive) until
110+
// every result is observed. The previous exit-after-acquire lifecycle let later children's on-acquire
111+
// sweeps legitimately reclaim the dead winners' slots and re-issue the same paths (the CI 4-of-5 /
112+
// 2-of-5-distinct flake), and could hand the capacity test a third success at a 2-slot cap.
113+
const results = await Promise.all(
106114
children.map(
107115
(child) =>
108116
new Promise<AcquireChildResult>((resolve, reject) => {
109117
let stdout = "";
110-
child.stdout.on("data", (chunk) => {
118+
const onData = (chunk: Buffer | string) => {
111119
stdout += chunk.toString();
112-
});
113-
child.once("error", reject);
114-
child.once("exit", () => {
115120
const line = stdout
116121
.split("\n")
117122
.map((entry) => entry.trim())
118123
.find((entry) => entry.startsWith("{"));
119-
if (!line) {
120-
reject(new Error(`child produced no JSON result: ${stdout}`));
121-
return;
124+
if (line) {
125+
child.stdout.off("data", onData);
126+
resolve(JSON.parse(line) as AcquireChildResult);
122127
}
123-
resolve(JSON.parse(line) as AcquireChildResult);
128+
};
129+
child.stdout.on("data", onData);
130+
child.once("error", reject);
131+
child.once("exit", () => {
132+
if (!stdout.includes("{")) reject(new Error(`child exited with no JSON result: ${stdout}`));
124133
});
125134
}),
126135
),
127136
);
137+
// Every lease observed — release the survivors and wait for them to exit cleanly.
138+
await Promise.all(
139+
children.map(
140+
(child) =>
141+
new Promise<void>((resolve) => {
142+
if (child.exitCode !== null) {
143+
resolve();
144+
return;
145+
}
146+
child.once("exit", () => resolve());
147+
child.stdin.write("done\n");
148+
}),
149+
),
150+
);
151+
return results;
128152
}
129153

130154
afterEach(() => {

test/unit/miner-worktree-allocator.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
55
import { DatabaseSync } from "node:sqlite";
66
import {
77
acquireWorktree,
8+
reclaimOrphanedAllocations,
89
closeDefaultWorktreeAllocator,
910
isProcessAlive,
1011
openWorktreeAllocator,
@@ -231,3 +232,31 @@ describe("loopover-miner worktree allocator scaffolding (#4298)", () => {
231232
});
232233
});
233234
});
235+
236+
describe("reclaim compare-and-set (#8918 follow-up: the duplicate-path race)", () => {
237+
it("a stale probe snapshot must NOT free a slot a peer re-acquired between probe and apply", () => {
238+
const dir = mkdtempSync(join(tmpdir(), "worktree-cas-"));
239+
const dbPath = join(dir, "worktree-allocator.sqlite3");
240+
const nowMs = Date.parse("2026-01-02T00:00:00.000Z");
241+
const allocator = openWorktreeAllocator({ dbPath, worktreeBaseDir: join(dir, "wt"), maxConcurrency: 2, maxLeaseMs: 100, nowMs });
242+
// An ancient lease: genuinely orphaned (a day past a 100ms lease) at probe time.
243+
const db = new DatabaseSync(dbPath);
244+
db.prepare("UPDATE worktree_slots SET status='active', attempt_id='ghost', owner_pid=999999, owner_host='other-host', allocated_at='2026-01-01T00:00:00.000Z' WHERE slot_index = 0").run();
245+
const staleProbe = db.prepare("SELECT slot_index, owner_pid, owner_host, allocated_at FROM worktree_slots WHERE status = 'active'").all() as never;
246+
// Between the probe and the apply, a PEER frees and re-acquires slot 0 — a fresh, live lease.
247+
db.prepare("UPDATE worktree_slots SET attempt_id='live-peer', owner_pid=4242, owner_host='peer-host', allocated_at='2026-01-01T23:59:59.999Z' WHERE slot_index = 0").run();
248+
// Applying the STALE snapshot must be a no-op: the lease evidence no longer matches.
249+
reclaimOrphanedAllocations(db, nowMs, 100, "this-host", staleProbe);
250+
const row = db.prepare("SELECT status, attempt_id FROM worktree_slots WHERE slot_index = 0").get() as { status: string; attempt_id: string };
251+
expect(row.status).toBe("active");
252+
expect(row.attempt_id).toBe("live-peer");
253+
// And when the CURRENT row really is the aged-out lease (probe evidence matches), it frees.
254+
db.prepare("UPDATE worktree_slots SET allocated_at='2026-01-01T00:00:00.000Z' WHERE slot_index = 0").run();
255+
reclaimOrphanedAllocations(db, nowMs, 100, "this-host");
256+
const freed = db.prepare("SELECT status FROM worktree_slots WHERE slot_index = 0").get() as { status: string };
257+
expect(freed.status).toBe("free");
258+
db.close();
259+
allocator.close();
260+
rmSync(dir, { recursive: true, force: true });
261+
});
262+
});

0 commit comments

Comments
 (0)