Skip to content

Commit f0987eb

Browse files
committed
Merge pull request #567 from ndycode/claude/audit-48-settings-write-queue-tests
test: cover the settings write queue retry and serialization contract
2 parents 4bba64b + c68b3b4 commit f0987eb

1 file changed

Lines changed: 254 additions & 0 deletions

File tree

test/settings-write-queue.test.ts

Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
// Everything is driven through withQueuedRetry on purpose: it is the one
3+
// entry point external callers use, and the module's helper exports are
4+
// internal surface that may be pruned.
5+
import { withQueuedRetry } from "../lib/codex-manager/settings-write-queue.js";
6+
7+
function errnoError(code: string): NodeJS.ErrnoException {
8+
return Object.assign(new Error(code), { code });
9+
}
10+
11+
// Records requested delays and resolves immediately: the retry schedule is
12+
// asserted, not waited for.
13+
function recordingSleep(): {
14+
sleep: (ms: number) => Promise<void>;
15+
delays: number[];
16+
} {
17+
const delays: number[] = [];
18+
return {
19+
delays,
20+
sleep: async (ms: number) => {
21+
delays.push(ms);
22+
},
23+
};
24+
}
25+
26+
// Unique key per test so the module-level queue map never couples tests.
27+
let keyCounter = 0;
28+
function uniqueKey(): string {
29+
keyCounter += 1;
30+
return `/settings/test-${keyCounter}.json`;
31+
}
32+
33+
describe("withQueuedRetry retries", () => {
34+
it("returns the task result without sleeping on first-try success", async () => {
35+
const { sleep, delays } = recordingSleep();
36+
const task = vi.fn().mockResolvedValue("written");
37+
38+
await expect(withQueuedRetry(uniqueKey(), task, { sleep })).resolves.toBe(
39+
"written",
40+
);
41+
expect(task).toHaveBeenCalledTimes(1);
42+
expect(delays).toEqual([]);
43+
});
44+
45+
it("retries Windows sharing violations with exponential backoff", async () => {
46+
const { sleep, delays } = recordingSleep();
47+
const task = vi
48+
.fn()
49+
.mockRejectedValueOnce(errnoError("EBUSY"))
50+
.mockRejectedValueOnce(errnoError("EACCES"))
51+
.mockResolvedValueOnce("written");
52+
53+
await expect(withQueuedRetry(uniqueKey(), task, { sleep })).resolves.toBe(
54+
"written",
55+
);
56+
expect(task).toHaveBeenCalledTimes(3);
57+
expect(delays).toEqual([50, 100]);
58+
});
59+
60+
it.each(["EPERM", "EAGAIN", "ENOTEMPTY"])(
61+
"treats %s as a retryable Windows lock code",
62+
async (code) => {
63+
// Windows file locks most often surface as EPERM, so the whole
64+
// retryable set is pinned, not just EBUSY/EACCES.
65+
const { sleep, delays } = recordingSleep();
66+
const task = vi
67+
.fn()
68+
.mockRejectedValueOnce(errnoError(code))
69+
.mockResolvedValueOnce("written");
70+
71+
await expect(
72+
withQueuedRetry(uniqueKey(), task, { sleep }),
73+
).resolves.toBe("written");
74+
expect(task).toHaveBeenCalledTimes(2);
75+
expect(delays).toEqual([50]);
76+
},
77+
);
78+
79+
it("honors a 429 retry-after hint, clamped into the sane range", async () => {
80+
const { sleep, delays } = recordingSleep();
81+
const task = vi
82+
.fn()
83+
.mockRejectedValueOnce(
84+
Object.assign(new Error("throttled"), {
85+
status: 429,
86+
retryAfterMs: 12_345,
87+
}),
88+
)
89+
.mockRejectedValueOnce(
90+
Object.assign(new Error("throttled"), {
91+
statusCode: 429,
92+
retryAfterMs: 2, // below the 10ms floor
93+
}),
94+
)
95+
.mockRejectedValueOnce(
96+
Object.assign(new Error("throttled"), {
97+
status: 429,
98+
retryAfterMs: 999_999_999, // above the 30s ceiling
99+
}),
100+
)
101+
.mockResolvedValueOnce("written");
102+
103+
await expect(withQueuedRetry(uniqueKey(), task, { sleep })).resolves.toBe(
104+
"written",
105+
);
106+
expect(delays).toEqual([12_345, 10, 30_000]);
107+
});
108+
109+
it("rethrows non-retryable errors immediately", async () => {
110+
const { sleep, delays } = recordingSleep();
111+
const task = vi.fn().mockRejectedValue(errnoError("ENOSPC"));
112+
113+
await expect(
114+
withQueuedRetry(uniqueKey(), task, { sleep }),
115+
).rejects.toThrow("ENOSPC");
116+
expect(task).toHaveBeenCalledTimes(1);
117+
expect(delays).toEqual([]);
118+
});
119+
120+
it("gives up with the last error after four retryable attempts", async () => {
121+
const { sleep, delays } = recordingSleep();
122+
const task = vi.fn().mockRejectedValue(errnoError("EBUSY"));
123+
124+
await expect(
125+
withQueuedRetry(uniqueKey(), task, { sleep }),
126+
).rejects.toThrow("EBUSY");
127+
expect(task).toHaveBeenCalledTimes(4);
128+
// No sleep after the final attempt.
129+
expect(delays).toEqual([50, 100, 200]);
130+
});
131+
});
132+
133+
describe("withQueuedRetry serialization", () => {
134+
it("runs tasks for the same path strictly in submission order", async () => {
135+
const { sleep } = recordingSleep();
136+
const key = uniqueKey();
137+
const order: string[] = [];
138+
let releaseFirst!: () => void;
139+
const firstGate = new Promise<void>((resolve) => {
140+
releaseFirst = resolve;
141+
});
142+
143+
const first = withQueuedRetry(
144+
key,
145+
async () => {
146+
order.push("first:start");
147+
await firstGate;
148+
order.push("first:end");
149+
return 1;
150+
},
151+
{ sleep },
152+
);
153+
const second = withQueuedRetry(
154+
key,
155+
async () => {
156+
order.push("second:start");
157+
return 2;
158+
},
159+
{ sleep },
160+
);
161+
162+
// Give the second task every chance to start early if the queue leaked.
163+
await new Promise<void>((resolve) => setImmediate(resolve));
164+
expect(order).toEqual(["first:start"]);
165+
166+
releaseFirst();
167+
await expect(first).resolves.toBe(1);
168+
await expect(second).resolves.toBe(2);
169+
expect(order).toEqual([
170+
"first:start",
171+
"first:end",
172+
"second:start",
173+
]);
174+
});
175+
176+
it("does not let a failed predecessor block the next write", async () => {
177+
const { sleep } = recordingSleep();
178+
const key = uniqueKey();
179+
180+
const failed = withQueuedRetry(
181+
key,
182+
async () => {
183+
throw errnoError("ENOSPC");
184+
},
185+
{ sleep },
186+
);
187+
const next = withQueuedRetry(key, async () => "recovered", { sleep });
188+
189+
await expect(failed).rejects.toThrow("ENOSPC");
190+
await expect(next).resolves.toBe("recovered");
191+
});
192+
193+
it("lets different paths proceed independently", async () => {
194+
const { sleep } = recordingSleep();
195+
const order: string[] = [];
196+
let releaseBlocked!: () => void;
197+
const gate = new Promise<void>((resolve) => {
198+
releaseBlocked = resolve;
199+
});
200+
201+
const blocked = withQueuedRetry(
202+
uniqueKey(),
203+
async () => {
204+
await gate;
205+
order.push("blocked");
206+
},
207+
{ sleep },
208+
);
209+
const independent = withQueuedRetry(
210+
uniqueKey(),
211+
async () => {
212+
order.push("independent");
213+
},
214+
{ sleep },
215+
);
216+
217+
await independent;
218+
expect(order).toEqual(["independent"]);
219+
releaseBlocked();
220+
await blocked;
221+
expect(order).toEqual(["independent", "blocked"]);
222+
});
223+
224+
it("keeps every retry of a task ahead of the next queued task", async () => {
225+
const { sleep } = recordingSleep();
226+
const key = uniqueKey();
227+
const order: string[] = [];
228+
const flaky = vi
229+
.fn()
230+
.mockImplementationOnce(async () => {
231+
order.push("flaky:1");
232+
throw errnoError("EBUSY");
233+
})
234+
.mockImplementationOnce(async () => {
235+
order.push("flaky:2");
236+
return "ok";
237+
});
238+
239+
const first = withQueuedRetry(key, flaky, { sleep });
240+
const second = withQueuedRetry(
241+
key,
242+
async () => {
243+
order.push("second");
244+
return "done";
245+
},
246+
{ sleep },
247+
);
248+
249+
await expect(first).resolves.toBe("ok");
250+
await expect(second).resolves.toBe("done");
251+
// The retry happened inside the queue slot, before the second task.
252+
expect(order).toEqual(["flaky:1", "flaky:2", "second"]);
253+
});
254+
});

0 commit comments

Comments
 (0)