@@ -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" ;
6671import { promptStatuslineSettingsPanel } from "./statusline-settings-panel.js" ;
6772import { promptThemeSettingsPanel } from "./theme-settings-panel.js" ;
6873
@@ -196,18 +201,6 @@ type SettingsHubAction =
196201
197202type 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-
211204const 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-
339235function 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
682586async function persistDashboardSettingsSelectionForTests (
0 commit comments