Skip to content

Commit 35280ba

Browse files
committed
refactor: extract settings write queue
1 parent ca42235 commit 35280ba

2 files changed

Lines changed: 132 additions & 121 deletions

File tree

lib/codex-manager/settings-hub.ts

Lines changed: 25 additions & 121 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,11 @@ import {
6363
mapExperimentalMenuHotkey,
6464
mapExperimentalStatusHotkey,
6565
} from "./experimental-settings-schema.js";
66+
import {
67+
RETRYABLE_SETTINGS_WRITE_CODES,
68+
SETTINGS_WRITE_MAX_ATTEMPTS,
69+
withQueuedRetry,
70+
} from "./settings-write-queue.js";
6671
import { promptStatuslineSettingsPanel } from "./statusline-settings-panel.js";
6772
import { promptThemeSettingsPanel } from "./theme-settings-panel.js";
6873

@@ -196,18 +201,6 @@ type SettingsHubAction =
196201

197202
type DashboardSettingKey = keyof DashboardDisplaySettings;
198203

199-
const RETRYABLE_SETTINGS_WRITE_CODES = new Set([
200-
"EBUSY",
201-
"EPERM",
202-
"EAGAIN",
203-
"ENOTEMPTY",
204-
"EACCES",
205-
]);
206-
const SETTINGS_WRITE_MAX_ATTEMPTS = 4;
207-
const SETTINGS_WRITE_BASE_DELAY_MS = 20;
208-
const SETTINGS_WRITE_MAX_DELAY_MS = 30_000;
209-
const settingsWriteQueues = new Map<string, Promise<void>>();
210-
211204
const ACCOUNT_LIST_PANEL_KEYS = [
212205
"menuShowStatusBadge",
213206
"menuShowCurrentBadge",
@@ -239,103 +232,6 @@ const THEME_PANEL_KEYS = [
239232
"uiAccentColor",
240233
] as const satisfies readonly DashboardSettingKey[];
241234

242-
function readErrorNumber(value: unknown): number | undefined {
243-
if (typeof value === "number" && Number.isFinite(value)) return value;
244-
if (typeof value === "string" && value.trim().length > 0) {
245-
const parsed = Number.parseInt(value, 10);
246-
if (Number.isFinite(parsed)) return parsed;
247-
}
248-
return undefined;
249-
}
250-
251-
function getErrorStatusCode(error: unknown): number | undefined {
252-
if (!error || typeof error !== "object") return undefined;
253-
const record = error as Record<string, unknown>;
254-
return readErrorNumber(record.status) ?? readErrorNumber(record.statusCode);
255-
}
256-
257-
function getRetryAfterMs(error: unknown): number | undefined {
258-
if (!error || typeof error !== "object") return undefined;
259-
const record = error as Record<string, unknown>;
260-
return (
261-
readErrorNumber(record.retryAfterMs) ??
262-
readErrorNumber(record.retry_after_ms) ??
263-
readErrorNumber(record.retryAfter) ??
264-
readErrorNumber(record.retry_after)
265-
);
266-
}
267-
268-
function isRetryableSettingsWriteError(error: unknown): boolean {
269-
const statusCode = getErrorStatusCode(error);
270-
if (statusCode === 429) return true;
271-
const code = (error as NodeJS.ErrnoException | undefined)?.code;
272-
return typeof code === "string" && RETRYABLE_SETTINGS_WRITE_CODES.has(code);
273-
}
274-
275-
function resolveRetryDelayMs(error: unknown, attempt: number): number {
276-
const retryAfterMs = getRetryAfterMs(error);
277-
if (
278-
typeof retryAfterMs === "number" &&
279-
Number.isFinite(retryAfterMs) &&
280-
retryAfterMs > 0
281-
) {
282-
return Math.max(
283-
10,
284-
Math.min(SETTINGS_WRITE_MAX_DELAY_MS, Math.round(retryAfterMs)),
285-
);
286-
}
287-
return Math.min(
288-
SETTINGS_WRITE_MAX_DELAY_MS,
289-
SETTINGS_WRITE_BASE_DELAY_MS * 2 ** attempt,
290-
);
291-
}
292-
293-
async function enqueueSettingsWrite<T>(
294-
pathKey: string,
295-
task: () => Promise<T>,
296-
): Promise<T> {
297-
const previous = settingsWriteQueues.get(pathKey) ?? Promise.resolve();
298-
const queued = previous.catch(() => {}).then(task);
299-
const queueTail = queued.then(
300-
() => undefined,
301-
() => undefined,
302-
);
303-
settingsWriteQueues.set(pathKey, queueTail);
304-
try {
305-
return await queued;
306-
} finally {
307-
if (settingsWriteQueues.get(pathKey) === queueTail) {
308-
settingsWriteQueues.delete(pathKey);
309-
}
310-
}
311-
}
312-
313-
async function withQueuedRetry<T>(
314-
pathKey: string,
315-
task: () => Promise<T>,
316-
): Promise<T> {
317-
return enqueueSettingsWrite(pathKey, async () => {
318-
let lastError: unknown;
319-
for (let attempt = 0; attempt < SETTINGS_WRITE_MAX_ATTEMPTS; attempt += 1) {
320-
try {
321-
return await task();
322-
} catch (error) {
323-
lastError = error;
324-
if (
325-
!isRetryableSettingsWriteError(error) ||
326-
attempt + 1 >= SETTINGS_WRITE_MAX_ATTEMPTS
327-
) {
328-
throw error;
329-
}
330-
await sleep(resolveRetryDelayMs(error, attempt));
331-
}
332-
}
333-
throw lastError instanceof Error
334-
? lastError
335-
: new Error("settings save retry exhausted");
336-
});
337-
}
338-
339235
function copyDashboardSettingValue(
340236
target: DashboardDisplaySettings,
341237
source: DashboardDisplaySettings,
@@ -394,14 +290,18 @@ async function persistDashboardSettingsSelection(
394290
): Promise<DashboardDisplaySettings> {
395291
const fallback = cloneDashboardSettings(selected);
396292
try {
397-
return await withQueuedRetry(getDashboardSettingsPath(), async () => {
398-
const latest = cloneDashboardSettings(
399-
await loadDashboardDisplaySettings(),
400-
);
401-
const merged = mergeDashboardSettingsForKeys(latest, selected, keys);
402-
await saveDashboardDisplaySettings(merged);
403-
return merged;
404-
});
293+
return await withQueuedRetry(
294+
getDashboardSettingsPath(),
295+
async () => {
296+
const latest = cloneDashboardSettings(
297+
await loadDashboardDisplaySettings(),
298+
);
299+
const merged = mergeDashboardSettingsForKeys(latest, selected, keys);
300+
await saveDashboardDisplaySettings(merged);
301+
return merged;
302+
},
303+
{ sleep },
304+
);
405305
} catch (error) {
406306
warnPersistFailure(scope, error);
407307
return fallback;
@@ -435,9 +335,13 @@ async function persistBackendConfigSelection(
435335
): Promise<PluginConfig> {
436336
const fallback = cloneBackendPluginConfig(selected);
437337
try {
438-
await withQueuedRetry(resolvePluginConfigSavePathKey(), async () => {
439-
await savePluginConfig(buildBackendConfigPatch(selected));
440-
});
338+
await withQueuedRetry(
339+
resolvePluginConfigSavePathKey(),
340+
async () => {
341+
await savePluginConfig(buildBackendConfigPatch(selected));
342+
},
343+
{ sleep },
344+
);
441345
return fallback;
442346
} catch (error) {
443347
warnPersistFailure(scope, error);
@@ -676,7 +580,7 @@ async function withQueuedRetryForTests<T>(
676580
pathKey: string,
677581
task: () => Promise<T>,
678582
): Promise<T> {
679-
return withQueuedRetry(pathKey, task);
583+
return withQueuedRetry(pathKey, task, { sleep });
680584
}
681585

682586
async function persistDashboardSettingsSelectionForTests(
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
export const SETTINGS_WRITE_MAX_ATTEMPTS = 4;
2+
export const SETTINGS_WRITE_BASE_DELAY_MS = 50;
3+
export const SETTINGS_WRITE_MAX_DELAY_MS = 1_000;
4+
export const RETRYABLE_SETTINGS_WRITE_CODES = new Set([
5+
"EBUSY",
6+
"EPERM",
7+
"EAGAIN",
8+
"ENOTEMPTY",
9+
]);
10+
11+
const settingsWriteQueues = new Map<string, Promise<void>>();
12+
13+
export function readErrorNumber(value: unknown): number | undefined {
14+
if (typeof value === "number" && Number.isFinite(value)) return value;
15+
if (typeof value === "string" && value.trim().length > 0) {
16+
const parsed = Number.parseInt(value, 10);
17+
if (Number.isFinite(parsed)) return parsed;
18+
}
19+
return undefined;
20+
}
21+
22+
export function getErrorStatusCode(error: unknown): number | undefined {
23+
if (!error || typeof error !== "object") return undefined;
24+
const record = error as Record<string, unknown>;
25+
return readErrorNumber(record.status) ?? readErrorNumber(record.statusCode);
26+
}
27+
28+
export function getRetryAfterMs(error: unknown): number | undefined {
29+
if (!error || typeof error !== "object") return undefined;
30+
const record = error as Record<string, unknown>;
31+
return (
32+
readErrorNumber(record.retryAfterMs) ??
33+
readErrorNumber(record.retry_after_ms) ??
34+
readErrorNumber(record.retryAfter) ??
35+
readErrorNumber(record.retry_after)
36+
);
37+
}
38+
39+
export function isRetryableSettingsWriteError(error: unknown): boolean {
40+
const statusCode = getErrorStatusCode(error);
41+
if (statusCode === 429) return true;
42+
const code = (error as NodeJS.ErrnoException | undefined)?.code;
43+
return typeof code === "string" && RETRYABLE_SETTINGS_WRITE_CODES.has(code);
44+
}
45+
46+
export function resolveRetryDelayMs(error: unknown, attempt: number): number {
47+
const retryAfterMs = getRetryAfterMs(error);
48+
if (
49+
typeof retryAfterMs === "number" &&
50+
Number.isFinite(retryAfterMs) &&
51+
retryAfterMs > 0
52+
) {
53+
return Math.max(
54+
10,
55+
Math.min(SETTINGS_WRITE_MAX_DELAY_MS, Math.round(retryAfterMs)),
56+
);
57+
}
58+
return Math.min(
59+
SETTINGS_WRITE_MAX_DELAY_MS,
60+
SETTINGS_WRITE_BASE_DELAY_MS * 2 ** attempt,
61+
);
62+
}
63+
64+
export async function enqueueSettingsWrite<T>(
65+
pathKey: string,
66+
task: () => Promise<T>,
67+
): Promise<T> {
68+
const previous = settingsWriteQueues.get(pathKey) ?? Promise.resolve();
69+
const queued = previous.catch(() => {}).then(task);
70+
const queueTail = queued.then(
71+
() => undefined,
72+
() => undefined,
73+
);
74+
settingsWriteQueues.set(pathKey, queueTail);
75+
try {
76+
return await queued;
77+
} finally {
78+
if (settingsWriteQueues.get(pathKey) === queueTail) {
79+
settingsWriteQueues.delete(pathKey);
80+
}
81+
}
82+
}
83+
84+
export async function withQueuedRetry<T>(
85+
pathKey: string,
86+
task: () => Promise<T>,
87+
deps: { sleep: (ms: number) => Promise<void> },
88+
): Promise<T> {
89+
return enqueueSettingsWrite(pathKey, async () => {
90+
let lastError: unknown;
91+
for (let attempt = 0; attempt < SETTINGS_WRITE_MAX_ATTEMPTS; attempt += 1) {
92+
try {
93+
return await task();
94+
} catch (error) {
95+
lastError = error;
96+
if (!isRetryableSettingsWriteError(error)) {
97+
throw error;
98+
}
99+
if (attempt >= SETTINGS_WRITE_MAX_ATTEMPTS - 1) {
100+
break;
101+
}
102+
await deps.sleep(resolveRetryDelayMs(error, attempt));
103+
}
104+
}
105+
throw lastError;
106+
});
107+
}

0 commit comments

Comments
 (0)