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