@@ -22,6 +22,66 @@ export interface CoordinatorOptions {
2222 coordinatorPortFile : string
2323}
2424
25+ // Latest-Wins Coalescing Serializer.
26+ //
27+ // Multiple Turbopack workers may call /extract concurrently, each producing
28+ // CSS for the same target file (especially `devup-ui.css` in singleCss mode
29+ // where every file writes to it). Naive `writeFile` calls run in parallel via
30+ // libuv's thread pool with no completion-order guarantees, so a stale snapshot
31+ // can clobber a fresher one — leaving the on-disk CSS missing rules whose
32+ // class names already landed in the JSX markup.
33+ //
34+ // `safeWrite` solves this with a per-path FIFO chain + content coalescing:
35+ // 1. Each call records the latest content for the path (overwrites earlier).
36+ // 2. The next disk write is chained after the previous one for the same
37+ // path, guaranteeing serial execution in invocation order.
38+ // 3. When the chained write actually runs, it pulls the most recent content
39+ // (not the original captured value), so intermediate snapshots between
40+ // enqueue-time and run-time are coalesced into a single write.
41+ //
42+ // Net effect: race becomes mathematically impossible (single-threaded JS +
43+ // FIFO queue), and total disk IO drops dramatically because N stale snapshots
44+ // for the same file are collapsed into 1 effective write.
45+ const writeChain = new Map < string , Promise < void > > ( )
46+ const latestContent = new Map < string , string > ( )
47+
48+ function safeWrite ( path : string , content : string ) : Promise < void > {
49+ // Always record the most recent content for this path so a queued write
50+ // picks up the latest snapshot when it runs.
51+ latestContent . set ( path , content )
52+
53+ // Swallow any prior error solely for chaining purposes — the actual caller
54+ // that hit the error already saw it via the returned promise, but we must
55+ // not let one failure poison every subsequent write for this path.
56+ const prev = ( writeChain . get ( path ) ?? Promise . resolve ( ) ) . catch ( ( ) => { } )
57+
58+ const next = prev . then (
59+ ( ) =>
60+ new Promise < void > ( ( resolve , reject ) => {
61+ const final = latestContent . get ( path )
62+ if ( final === undefined ) {
63+ // An earlier chained run already consumed the latest content for
64+ // this path; nothing new to write. Resolve as a no-op.
65+ resolve ( )
66+ return
67+ }
68+ latestContent . delete ( path )
69+ writeFile ( path , final , 'utf-8' , ( err ) =>
70+ err ? reject ( err ) : resolve ( ) ,
71+ )
72+ } ) ,
73+ )
74+
75+ writeChain . set ( path , next )
76+ return next
77+ }
78+
79+ // Best-effort drain of every pending write. Used on coordinator close so the
80+ // build process does not exit with stale files mid-flight.
81+ function flushPendingWrites ( ) : Promise < void > {
82+ return Promise . allSettled ( [ ...writeChain . values ( ) ] ) . then ( ( ) => undefined )
83+ }
84+
2585function readBody ( req : IncomingMessage ) : Promise < string > {
2686 return new Promise ( ( resolve , reject ) => {
2787 const chunks : Buffer [ ] = [ ]
@@ -33,26 +93,46 @@ function readBody(req: IncomingMessage): Promise<string> {
3393
3494let server : Server | null = null
3595
36- // Extraction tracking for waitForIdle
96+ // Extraction tracking for waitForIdle.
97+ //
98+ // The CSS loader fetches the live sheet from `/css?waitForIdle=true`. In
99+ // production builds the response of that call is what Turbopack bundles
100+ // for the `devup-ui.css` module — there is no second chance. So waitForIdle
101+ // must NOT resolve while there are still extractions in flight, or that
102+ // have not yet started but will start soon.
103+ //
104+ // We track two complementary signals:
105+ // * activeExtractions / lastCompletedAt → are extractions currently happening?
106+ // * pendingExtractStarts → did anyone POST /extract that hasn't progressed
107+ // to `activeExtractions++` yet (e.g. still inside `await readBody(req)`)?
108+ //
109+ // IDLE_THRESHOLD_MS is increased so an early `/css` request — triggered by
110+ // the first .tsx loader resolving its import graph before the rest of the
111+ // route's files are processed — cannot resolve in the gap between two
112+ // extraction batches. Empirically the gap between Turbopack extraction
113+ // "waves" in a 64-route landing build can exceed the previous 500ms, which
114+ // caused the snapshot to capture only the early routes' styles.
37115let activeExtractions = 0
38116let totalExtractions = 0
39117let lastCompletedAt = 0
40- const IDLE_THRESHOLD_MS = 500
41- const MAX_WAIT_MS = 30_000
118+ let pendingExtractStarts = 0
119+ const IDLE_THRESHOLD_MS = 2500
120+ const MAX_WAIT_MS = 60_000
42121
43122function waitForIdle ( ) : Promise < void > {
44123 const start = Date . now ( )
45124 return new Promise ( ( resolve ) => {
46125 const check = ( ) => {
47126 const now = Date . now ( )
48127 if ( now - start > MAX_WAIT_MS ) {
49- // Timeout — return whatever CSS we have
128+ // Hard timeout — give up and return whatever we have.
50129 resolve ( )
51130 return
52131 }
53132 if (
54133 totalExtractions > 0 &&
55134 activeExtractions === 0 &&
135+ pendingExtractStarts === 0 &&
56136 now - lastCompletedAt >= IDLE_THRESHOLD_MS
57137 ) {
58138 resolve ( )
@@ -103,9 +183,18 @@ export function startCoordinator(options: CoordinatorOptions): {
103183 }
104184
105185 if ( req . method === 'POST' && url . pathname === '/extract' ) {
106- activeExtractions ++
186+ // Reserve a "start slot" before yielding on `await readBody`. Without
187+ // this counter, `waitForIdle` could observe activeExtractions=0 in the
188+ // window between the request hitting this handler and `activeExtractions++`
189+ // below — making it falsely conclude the build is idle even though
190+ // more extractions are imminent.
191+ pendingExtractStarts ++
192+ let promotedToActive = false
107193 try {
108194 const body = JSON . parse ( await readBody ( req ) )
195+ activeExtractions ++
196+ pendingExtractStarts --
197+ promotedToActive = true
109198 const { filename, code, resourcePath } = body as {
110199 filename : string
111200 code : string
@@ -145,43 +234,23 @@ export function startCoordinator(options: CoordinatorOptions): {
145234
146235 if ( result . updatedBaseStyle ) {
147236 promises . push (
148- new Promise < void > ( ( resolve , reject ) =>
149- writeFile (
150- join ( cssDir , 'devup-ui.css' ) ,
151- `${ getCss ( null , false ) } \n/* ${ Date . now ( ) } */` ,
152- 'utf-8' ,
153- ( err ) => ( err ? reject ( err ) : resolve ( ) ) ,
154- ) ,
237+ safeWrite (
238+ join ( cssDir , 'devup-ui.css' ) ,
239+ `${ getCss ( null , false ) } \n/* ${ Date . now ( ) } */` ,
155240 ) ,
156241 )
157242 }
158243
159244 if ( result . cssFile ) {
160245 const fileNum = getFileNumByFilename ( result . cssFile )
161246 promises . push (
162- new Promise < void > ( ( resolve , reject ) =>
163- writeFile (
164- join ( cssDir , basename ( result . cssFile ! ) ) ,
165- getCss ( fileNum , true ) ,
166- 'utf-8' ,
167- ( err ) => ( err ? reject ( err ) : resolve ( ) ) ,
168- ) ,
169- ) ,
170- new Promise < void > ( ( resolve , reject ) =>
171- writeFile ( sheetFile , exportSheet ( ) , 'utf-8' , ( err ) =>
172- err ? reject ( err ) : resolve ( ) ,
173- ) ,
174- ) ,
175- new Promise < void > ( ( resolve , reject ) =>
176- writeFile ( classMapFile , exportClassMap ( ) , 'utf-8' , ( err ) =>
177- err ? reject ( err ) : resolve ( ) ,
178- ) ,
179- ) ,
180- new Promise < void > ( ( resolve , reject ) =>
181- writeFile ( fileMapFile , exportFileMap ( ) , 'utf-8' , ( err ) =>
182- err ? reject ( err ) : resolve ( ) ,
183- ) ,
247+ safeWrite (
248+ join ( cssDir , basename ( result . cssFile ) ) ,
249+ getCss ( fileNum , true ) ,
184250 ) ,
251+ safeWrite ( sheetFile , exportSheet ( ) ) ,
252+ safeWrite ( classMapFile , exportClassMap ( ) ) ,
253+ safeWrite ( fileMapFile , exportFileMap ( ) ) ,
185254 )
186255
187256 // In non-singleCss mode, imports are rewritten from devup-ui-N.css to
@@ -192,13 +261,9 @@ export function startCoordinator(options: CoordinatorOptions): {
192261 // When updatedBaseStyle is true, devup-ui.css is already written above.
193262 if ( ! singleCss && ! result . updatedBaseStyle && result . css != null ) {
194263 promises . push (
195- new Promise < void > ( ( resolve , reject ) =>
196- writeFile (
197- join ( cssDir , 'devup-ui.css' ) ,
198- `${ getCss ( null , false ) } \n/* ${ Date . now ( ) } */` ,
199- 'utf-8' ,
200- ( err ) => ( err ? reject ( err ) : resolve ( ) ) ,
201- ) ,
264+ safeWrite (
265+ join ( cssDir , 'devup-ui.css' ) ,
266+ `${ getCss ( null , false ) } \n/* ${ Date . now ( ) } */` ,
202267 ) ,
203268 )
204269 }
@@ -223,7 +288,13 @@ export function startCoordinator(options: CoordinatorOptions): {
223288 } ) ,
224289 )
225290 } finally {
226- activeExtractions --
291+ if ( promotedToActive ) {
292+ activeExtractions --
293+ } else {
294+ // readBody/JSON.parse threw before we promoted to active, so the
295+ // pending slot is still ours to release.
296+ pendingExtractStarts --
297+ }
227298 totalExtractions ++
228299 lastCompletedAt = Date . now ( )
229300 }
@@ -243,6 +314,11 @@ export function startCoordinator(options: CoordinatorOptions): {
243314
244315 return {
245316 close : ( ) => {
317+ // Fire-and-forget drain of any in-flight serialized writes so the
318+ // last-written CSS reflects the final sheet state, even though
319+ // `close` itself returns synchronously (it is invoked from
320+ // `process.on('exit', ...)` where awaiting is not possible).
321+ void flushPendingWrites ( )
246322 if ( server ) {
247323 server . close ( )
248324 server = null
@@ -256,6 +332,9 @@ export function startCoordinator(options: CoordinatorOptions): {
256332 }
257333}
258334
335+ /** @internal Wait for every pending serialized write to settle. */
336+ export const flushCoordinatorWrites = ( ) : Promise < void > => flushPendingWrites ( )
337+
259338/** @internal Reset coordinator state for testing purposes only */
260339export const resetCoordinator = ( ) => {
261340 if ( server ) {
@@ -265,4 +344,6 @@ export const resetCoordinator = () => {
265344 activeExtractions = 0
266345 totalExtractions = 0
267346 lastCompletedAt = 0
347+ writeChain . clear ( )
348+ latestContent . clear ( )
268349}
0 commit comments