22 * @fileoverview Editable JSON file manipulation with formatting preservation.
33 */
44
5+ import { setTimeout as sleep } from 'node:timers/promises'
6+
57import {
68 INDENT_SYMBOL ,
79 NEWLINE_SYMBOL ,
@@ -58,23 +60,50 @@ async function retryWrite(
5860 try {
5961 // eslint-disable-next-line no-await-in-loop
6062 await fsPromises . writeFile ( filepath , content )
63+ // On Windows, add a delay and verify file exists to ensure it's fully flushed
64+ // This prevents ENOENT errors when immediately reading after write
65+ // Windows CI runners are significantly slower than local development
66+ if ( process . platform === 'win32' ) {
67+ // Initial delay to allow OS to flush the write
68+ // eslint-disable-next-line no-await-in-loop
69+ await sleep ( 50 )
70+ // Verify the file is actually readable with retries
71+ let accessRetries = 0
72+ const maxAccessRetries = 5
73+ while ( accessRetries < maxAccessRetries ) {
74+ try {
75+ // eslint-disable-next-line no-await-in-loop
76+ await fsPromises . access ( filepath )
77+ // Small final delay to ensure stability
78+ // eslint-disable-next-line no-await-in-loop
79+ await sleep ( 10 )
80+ break
81+ } catch {
82+ // If file isn't accessible yet, wait with increasing delays
83+ const delay = 20 * ( accessRetries + 1 )
84+ // eslint-disable-next-line no-await-in-loop
85+ await sleep ( delay )
86+ accessRetries ++
87+ }
88+ }
89+ }
6190 return
6291 } catch ( err ) {
6392 const isLastAttempt = attempt === retries
64- const isEperm =
93+ const isRetriableError =
6594 err instanceof Error &&
6695 'code' in err &&
67- ( err . code === 'EPERM' || err . code === 'EBUSY' )
96+ ( err . code === 'EPERM' || err . code === 'EBUSY' || err . code === 'ENOENT' )
6897
69- // Only retry on Windows EPERM/EBUSY errors , and not on the last attempt
70- if ( ! isEperm || isLastAttempt ) {
98+ // Only retry on Windows file system errors ( EPERM/EBUSY/ENOENT) , and not on the last attempt
99+ if ( ! isRetriableError || isLastAttempt ) {
71100 throw err
72101 }
73102
74103 // Exponential backoff: 10ms, 20ms, 40ms
75104 const delay = baseDelay * 2 ** attempt
76105 // eslint-disable-next-line no-await-in-loop
77- await new Promise ( resolve => setTimeout ( resolve , delay ) )
106+ await sleep ( delay )
78107 }
79108 }
80109}
@@ -88,12 +117,40 @@ function parseJson(content: string): unknown {
88117}
89118
90119/**
91- * Read file content from disk.
120+ * Read file content from disk with retry logic for ENOENT errors .
92121 * @private
93122 */
94123async function readFile ( filepath : string ) : Promise < string > {
95124 const { promises : fsPromises } = getFs ( )
96- return await fsPromises . readFile ( filepath , 'utf8' )
125+
126+ // Retry on ENOENT since files may not be immediately accessible after writes
127+ // Windows needs more retries due to slower filesystem operations
128+ const maxRetries = process . platform === 'win32' ? 5 : 1
129+ for ( let attempt = 0 ; attempt <= maxRetries ; attempt ++ ) {
130+ try {
131+ // eslint-disable-next-line no-await-in-loop
132+ return await fsPromises . readFile ( filepath , 'utf8' )
133+ } catch ( err ) {
134+ const isLastAttempt = attempt === maxRetries
135+ const isEnoent =
136+ err instanceof Error && 'code' in err && err . code === 'ENOENT'
137+
138+ // Only retry ENOENT and not on last attempt
139+ if ( ! isEnoent || isLastAttempt ) {
140+ throw err
141+ }
142+
143+ // Wait before retry with exponential backoff
144+ // Windows: 50ms, 100ms, 150ms, 200ms, 250ms (total 750ms + attempts)
145+ // Others: 20ms
146+ const delay = process . platform === 'win32' ? 50 * ( attempt + 1 ) : 20
147+ // eslint-disable-next-line no-await-in-loop
148+ await sleep ( delay )
149+ }
150+ }
151+
152+ // This line should never be reached but TypeScript requires it
153+ throw new Error ( 'Unreachable code' )
97154}
98155
99156/**
0 commit comments