Skip to content

Commit 86cf1c3

Browse files
committed
Update testcase
1 parent d218dde commit 86cf1c3

4 files changed

Lines changed: 128 additions & 45 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"changes":{"packages/next-plugin/package.json":"Patch"},"note":"Fix write issue","date":"2026-05-13T07:13:54.316827100Z"}

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,4 @@ storybook-static
2121
test-results
2222
playwright-report
2323
.omc
24+
.playwright-mcp

packages/next-plugin/src/__tests__/coordinator.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -481,14 +481,14 @@ describe('coordinator', () => {
481481
const portStr = (writeFileSyncSpy.mock.calls[0] as [string, string])[1]
482482
const port = parseInt(portStr)
483483

484-
// Mock Date.now to simulate time passing beyond MAX_WAIT_MS (30s)
484+
// Mock Date.now to simulate time passing beyond MAX_WAIT_MS (60s).
485485
let callCount = 0
486486
const dateNowSpy = spyOn(Date, 'now').mockImplementation(() => {
487487
callCount++
488-
// First call is `const start = Date.now()` return 0
488+
// First call is `const start = Date.now()` return 0
489489
// Subsequent calls return past MAX_WAIT_MS threshold
490490
if (callCount <= 1) return 0
491-
return 31_000
491+
return 61_000
492492
})
493493

494494
// Request CSS with waitForIdle=true but no extractions ever happen

packages/next-plugin/src/coordinator.ts

Lines changed: 123 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
2585
function 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

3494
let 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.
37115
let activeExtractions = 0
38116
let totalExtractions = 0
39117
let 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

43122
function 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 */
260339
export 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

Comments
 (0)