From 86cf1c32a612c519dff4472fa6b810d491802e40 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Wed, 13 May 2026 16:14:17 +0900 Subject: [PATCH 1/2] Update testcase --- .../changepack_log_pnNusktwcvu2vXQOHlpxd.json | 1 + .gitignore | 1 + .../src/__tests__/coordinator.test.ts | 6 +- packages/next-plugin/src/coordinator.ts | 165 +++++++++++++----- 4 files changed, 128 insertions(+), 45 deletions(-) create mode 100644 .changepacks/changepack_log_pnNusktwcvu2vXQOHlpxd.json diff --git a/.changepacks/changepack_log_pnNusktwcvu2vXQOHlpxd.json b/.changepacks/changepack_log_pnNusktwcvu2vXQOHlpxd.json new file mode 100644 index 00000000..cc0eea91 --- /dev/null +++ b/.changepacks/changepack_log_pnNusktwcvu2vXQOHlpxd.json @@ -0,0 +1 @@ +{"changes":{"packages/next-plugin/package.json":"Patch"},"note":"Fix write issue","date":"2026-05-13T07:13:54.316827100Z"} \ No newline at end of file diff --git a/.gitignore b/.gitignore index ebc6e373..76137b57 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,4 @@ storybook-static test-results playwright-report .omc +.playwright-mcp diff --git a/packages/next-plugin/src/__tests__/coordinator.test.ts b/packages/next-plugin/src/__tests__/coordinator.test.ts index 02c7f4b8..c7bafb74 100644 --- a/packages/next-plugin/src/__tests__/coordinator.test.ts +++ b/packages/next-plugin/src/__tests__/coordinator.test.ts @@ -481,14 +481,14 @@ describe('coordinator', () => { const portStr = (writeFileSyncSpy.mock.calls[0] as [string, string])[1] const port = parseInt(portStr) - // Mock Date.now to simulate time passing beyond MAX_WAIT_MS (30s) + // Mock Date.now to simulate time passing beyond MAX_WAIT_MS (60s). let callCount = 0 const dateNowSpy = spyOn(Date, 'now').mockImplementation(() => { callCount++ - // First call is `const start = Date.now()` → return 0 + // First call is `const start = Date.now()` — return 0 // Subsequent calls return past MAX_WAIT_MS threshold if (callCount <= 1) return 0 - return 31_000 + return 61_000 }) // Request CSS with waitForIdle=true but no extractions ever happen diff --git a/packages/next-plugin/src/coordinator.ts b/packages/next-plugin/src/coordinator.ts index 1c79a790..4719e511 100644 --- a/packages/next-plugin/src/coordinator.ts +++ b/packages/next-plugin/src/coordinator.ts @@ -22,6 +22,66 @@ export interface CoordinatorOptions { coordinatorPortFile: string } +// Latest-Wins Coalescing Serializer. +// +// Multiple Turbopack workers may call /extract concurrently, each producing +// CSS for the same target file (especially `devup-ui.css` in singleCss mode +// where every file writes to it). Naive `writeFile` calls run in parallel via +// libuv's thread pool with no completion-order guarantees, so a stale snapshot +// can clobber a fresher one — leaving the on-disk CSS missing rules whose +// class names already landed in the JSX markup. +// +// `safeWrite` solves this with a per-path FIFO chain + content coalescing: +// 1. Each call records the latest content for the path (overwrites earlier). +// 2. The next disk write is chained after the previous one for the same +// path, guaranteeing serial execution in invocation order. +// 3. When the chained write actually runs, it pulls the most recent content +// (not the original captured value), so intermediate snapshots between +// enqueue-time and run-time are coalesced into a single write. +// +// Net effect: race becomes mathematically impossible (single-threaded JS + +// FIFO queue), and total disk IO drops dramatically because N stale snapshots +// for the same file are collapsed into 1 effective write. +const writeChain = new Map>() +const latestContent = new Map() + +function safeWrite(path: string, content: string): Promise { + // Always record the most recent content for this path so a queued write + // picks up the latest snapshot when it runs. + latestContent.set(path, content) + + // Swallow any prior error solely for chaining purposes — the actual caller + // that hit the error already saw it via the returned promise, but we must + // not let one failure poison every subsequent write for this path. + const prev = (writeChain.get(path) ?? Promise.resolve()).catch(() => {}) + + const next = prev.then( + () => + new Promise((resolve, reject) => { + const final = latestContent.get(path) + if (final === undefined) { + // An earlier chained run already consumed the latest content for + // this path; nothing new to write. Resolve as a no-op. + resolve() + return + } + latestContent.delete(path) + writeFile(path, final, 'utf-8', (err) => + err ? reject(err) : resolve(), + ) + }), + ) + + writeChain.set(path, next) + return next +} + +// Best-effort drain of every pending write. Used on coordinator close so the +// build process does not exit with stale files mid-flight. +function flushPendingWrites(): Promise { + return Promise.allSettled([...writeChain.values()]).then(() => undefined) +} + function readBody(req: IncomingMessage): Promise { return new Promise((resolve, reject) => { const chunks: Buffer[] = [] @@ -33,12 +93,31 @@ function readBody(req: IncomingMessage): Promise { let server: Server | null = null -// Extraction tracking for waitForIdle +// Extraction tracking for waitForIdle. +// +// The CSS loader fetches the live sheet from `/css?waitForIdle=true`. In +// production builds the response of that call is what Turbopack bundles +// for the `devup-ui.css` module — there is no second chance. So waitForIdle +// must NOT resolve while there are still extractions in flight, or that +// have not yet started but will start soon. +// +// We track two complementary signals: +// * activeExtractions / lastCompletedAt → are extractions currently happening? +// * pendingExtractStarts → did anyone POST /extract that hasn't progressed +// to `activeExtractions++` yet (e.g. still inside `await readBody(req)`)? +// +// IDLE_THRESHOLD_MS is increased so an early `/css` request — triggered by +// the first .tsx loader resolving its import graph before the rest of the +// route's files are processed — cannot resolve in the gap between two +// extraction batches. Empirically the gap between Turbopack extraction +// "waves" in a 64-route landing build can exceed the previous 500ms, which +// caused the snapshot to capture only the early routes' styles. let activeExtractions = 0 let totalExtractions = 0 let lastCompletedAt = 0 -const IDLE_THRESHOLD_MS = 500 -const MAX_WAIT_MS = 30_000 +let pendingExtractStarts = 0 +const IDLE_THRESHOLD_MS = 2500 +const MAX_WAIT_MS = 60_000 function waitForIdle(): Promise { const start = Date.now() @@ -46,13 +125,14 @@ function waitForIdle(): Promise { const check = () => { const now = Date.now() if (now - start > MAX_WAIT_MS) { - // Timeout — return whatever CSS we have + // Hard timeout — give up and return whatever we have. resolve() return } if ( totalExtractions > 0 && activeExtractions === 0 && + pendingExtractStarts === 0 && now - lastCompletedAt >= IDLE_THRESHOLD_MS ) { resolve() @@ -103,9 +183,18 @@ export function startCoordinator(options: CoordinatorOptions): { } if (req.method === 'POST' && url.pathname === '/extract') { - activeExtractions++ + // Reserve a "start slot" before yielding on `await readBody`. Without + // this counter, `waitForIdle` could observe activeExtractions=0 in the + // window between the request hitting this handler and `activeExtractions++` + // below — making it falsely conclude the build is idle even though + // more extractions are imminent. + pendingExtractStarts++ + let promotedToActive = false try { const body = JSON.parse(await readBody(req)) + activeExtractions++ + pendingExtractStarts-- + promotedToActive = true const { filename, code, resourcePath } = body as { filename: string code: string @@ -145,13 +234,9 @@ export function startCoordinator(options: CoordinatorOptions): { if (result.updatedBaseStyle) { promises.push( - new Promise((resolve, reject) => - writeFile( - join(cssDir, 'devup-ui.css'), - `${getCss(null, false)}\n/* ${Date.now()} */`, - 'utf-8', - (err) => (err ? reject(err) : resolve()), - ), + safeWrite( + join(cssDir, 'devup-ui.css'), + `${getCss(null, false)}\n/* ${Date.now()} */`, ), ) } @@ -159,29 +244,13 @@ export function startCoordinator(options: CoordinatorOptions): { if (result.cssFile) { const fileNum = getFileNumByFilename(result.cssFile) promises.push( - new Promise((resolve, reject) => - writeFile( - join(cssDir, basename(result.cssFile!)), - getCss(fileNum, true), - 'utf-8', - (err) => (err ? reject(err) : resolve()), - ), - ), - new Promise((resolve, reject) => - writeFile(sheetFile, exportSheet(), 'utf-8', (err) => - err ? reject(err) : resolve(), - ), - ), - new Promise((resolve, reject) => - writeFile(classMapFile, exportClassMap(), 'utf-8', (err) => - err ? reject(err) : resolve(), - ), - ), - new Promise((resolve, reject) => - writeFile(fileMapFile, exportFileMap(), 'utf-8', (err) => - err ? reject(err) : resolve(), - ), + safeWrite( + join(cssDir, basename(result.cssFile)), + getCss(fileNum, true), ), + safeWrite(sheetFile, exportSheet()), + safeWrite(classMapFile, exportClassMap()), + safeWrite(fileMapFile, exportFileMap()), ) // In non-singleCss mode, imports are rewritten from devup-ui-N.css to @@ -192,13 +261,9 @@ export function startCoordinator(options: CoordinatorOptions): { // When updatedBaseStyle is true, devup-ui.css is already written above. if (!singleCss && !result.updatedBaseStyle && result.css != null) { promises.push( - new Promise((resolve, reject) => - writeFile( - join(cssDir, 'devup-ui.css'), - `${getCss(null, false)}\n/* ${Date.now()} */`, - 'utf-8', - (err) => (err ? reject(err) : resolve()), - ), + safeWrite( + join(cssDir, 'devup-ui.css'), + `${getCss(null, false)}\n/* ${Date.now()} */`, ), ) } @@ -223,7 +288,13 @@ export function startCoordinator(options: CoordinatorOptions): { }), ) } finally { - activeExtractions-- + if (promotedToActive) { + activeExtractions-- + } else { + // readBody/JSON.parse threw before we promoted to active, so the + // pending slot is still ours to release. + pendingExtractStarts-- + } totalExtractions++ lastCompletedAt = Date.now() } @@ -243,6 +314,11 @@ export function startCoordinator(options: CoordinatorOptions): { return { close: () => { + // Fire-and-forget drain of any in-flight serialized writes so the + // last-written CSS reflects the final sheet state, even though + // `close` itself returns synchronously (it is invoked from + // `process.on('exit', ...)` where awaiting is not possible). + void flushPendingWrites() if (server) { server.close() server = null @@ -256,6 +332,9 @@ export function startCoordinator(options: CoordinatorOptions): { } } +/** @internal Wait for every pending serialized write to settle. */ +export const flushCoordinatorWrites = (): Promise => flushPendingWrites() + /** @internal Reset coordinator state for testing purposes only */ export const resetCoordinator = () => { if (server) { @@ -265,4 +344,6 @@ export const resetCoordinator = () => { activeExtractions = 0 totalExtractions = 0 lastCompletedAt = 0 + writeChain.clear() + latestContent.clear() } From ca8a6be4dd357de914512272107a9154d01d4d02 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Wed, 13 May 2026 17:24:50 +0900 Subject: [PATCH 2/2] Update testcase --- .../src/__tests__/coordinator.test.ts | 237 ++++++++++++++++++ 1 file changed, 237 insertions(+) diff --git a/packages/next-plugin/src/__tests__/coordinator.test.ts b/packages/next-plugin/src/__tests__/coordinator.test.ts index c7bafb74..711540aa 100644 --- a/packages/next-plugin/src/__tests__/coordinator.test.ts +++ b/packages/next-plugin/src/__tests__/coordinator.test.ts @@ -15,6 +15,7 @@ import { import { type CoordinatorOptions, + flushCoordinatorWrites, resetCoordinator, startCoordinator, } from '../coordinator' @@ -712,4 +713,240 @@ describe('coordinator', () => { // Calling again should be safe (server is already null) resetCoordinator() }) + + it('should coalesce duplicate writes to the same path within one /extract handler', async () => { + // When a single /extract invocation triggers multiple writes to the same + // path (singleCss + updatedBaseStyle=true → both the base-CSS write and + // the cssFile write target `devup-ui.css`), the second write must be + // collapsed by the latest-wins serializer: the first chained run sees the + // *latest* content and writes it once, the second chained run finds + // `latestContent` already consumed and resolves as a no-op. + codeExtractSpy.mockReturnValue({ + code: 'single css code', + map: undefined, + cssFile: 'devup-ui.css', + updatedBaseStyle: true, + free: mock(), + [Symbol.dispose]: mock(), + }) + getCssSpy.mockReturnValue('all-styles') + exportSheetSpy.mockReturnValue('sheet-json') + exportClassMapSpy.mockReturnValue('classmap-json') + exportFileMapSpy.mockReturnValue('filemap-json') + + const options = makeOptions({ singleCss: true }) + const coordinator = startCoordinator(options) + + await new Promise((r) => setTimeout(r, 100)) + + const portStr = (writeFileSyncSpy.mock.calls[0] as [string, string])[1] + const port = parseInt(portStr) + + const res = await httpRequest( + port, + 'POST', + '/extract', + JSON.stringify({ + filename: 'src/App.tsx', + code: 'const x = ', + resourcePath: join(process.cwd(), 'src', 'App.tsx'), + }), + ) + + expect(res.status).toBe(200) + + // Both safeWrite calls target `devup-ui.css`. Coalescing means exactly + // one physical writeFile call is made for that path; the sheet/classMap/ + // fileMap writes (3 more) all go to distinct paths. + const devupUiCssWrites = writeFileSpy.mock.calls.filter((call) => + String(call[0]).endsWith('devup-ui.css'), + ) + expect(devupUiCssWrites.length).toBe(1) + + coordinator.close() + }) + + it('should expose flushCoordinatorWrites to drain queued writes', async () => { + // The exported helper must return a settled promise even when no writes + // are pending (idle coordinator), so build orchestration can safely await + // it without risk of hanging. + await expect(flushCoordinatorWrites()).resolves.toBeUndefined() + + // After triggering a real /extract, awaiting the helper must wait for all + // queued writes (chained per path) to settle. We assert the spy has been + // invoked by the time the helper resolves. + codeExtractSpy.mockReturnValue({ + code: 'code', + map: undefined, + cssFile: 'devup-ui-7.css', + updatedBaseStyle: false, + free: mock(), + [Symbol.dispose]: mock(), + }) + getCssSpy.mockReturnValue('per-file-css') + exportSheetSpy.mockReturnValue('sheet-json') + exportClassMapSpy.mockReturnValue('classmap-json') + exportFileMapSpy.mockReturnValue('filemap-json') + + const options = makeOptions() + const coordinator = startCoordinator(options) + + await new Promise((r) => setTimeout(r, 100)) + + const portStr = (writeFileSyncSpy.mock.calls[0] as [string, string])[1] + const port = parseInt(portStr) + + await httpRequest( + port, + 'POST', + '/extract', + JSON.stringify({ + filename: 'src/App.tsx', + code: 'const x = ', + resourcePath: join(process.cwd(), 'src', 'App.tsx'), + }), + ) + + await expect(flushCoordinatorWrites()).resolves.toBeUndefined() + expect(writeFileSpy.mock.calls.length).toBeGreaterThan(0) + + coordinator.close() + }) + + it('should continue chained writes after a previous write fails (chain error recovery)', async () => { + // The serializer must not let one failed write poison every subsequent + // write for that path. We force the first writeFile to fail, then verify + // the second extraction's writes still happen for that same path. + codeExtractSpy.mockReturnValue({ + code: 'code', + map: undefined, + cssFile: 'devup-ui.css', + updatedBaseStyle: false, + free: mock(), + [Symbol.dispose]: mock(), + }) + getCssSpy.mockReturnValue('css-content') + exportSheetSpy.mockReturnValue('sheet-json') + exportClassMapSpy.mockReturnValue('classmap-json') + exportFileMapSpy.mockReturnValue('filemap-json') + + // Re-install writeFile spy with controlled failure: any write to the + // devup-ui.css path errors out on the *first* invocation only. + writeFileSpy.mockRestore() + let devupCssCallCount = 0 + writeFileSpy = spyOn(fs, 'writeFile').mockImplementation( + (_path: any, _data: any, _encOrCb: any, maybeCb?: any) => { + const cb = typeof _encOrCb === 'function' ? _encOrCb : maybeCb + if (cb) { + if (String(_path).endsWith('devup-ui.css')) { + devupCssCallCount++ + if (devupCssCallCount === 1) { + cb(new Error('simulated disk error')) + return + } + } + cb(null) + } + }, + ) + + const options = makeOptions({ singleCss: true }) + const coordinator = startCoordinator(options) + + await new Promise((r) => setTimeout(r, 100)) + + const portStr = (writeFileSyncSpy.mock.calls[0] as [string, string])[1] + const port = parseInt(portStr) + + // First /extract: triggers a write to devup-ui.css that we make fail. + // The coordinator will respond with 500 (await Promise.all([..., failingWrite]) + // rejects), but the chain itself must NOT be poisoned. + const firstRes = await httpRequest( + port, + 'POST', + '/extract', + JSON.stringify({ + filename: 'src/A.tsx', + code: 'const x = ', + resourcePath: join(process.cwd(), 'src', 'A.tsx'), + }), + ) + expect(firstRes.status).toBe(500) + + // Second /extract for the same path must SUCCEED — the `.catch(() => {})` + // chain-survival branch is what makes this work. + const secondRes = await httpRequest( + port, + 'POST', + '/extract', + JSON.stringify({ + filename: 'src/B.tsx', + code: 'const y = ', + resourcePath: join(process.cwd(), 'src', 'B.tsx'), + }), + ) + expect(secondRes.status).toBe(200) + + // We must have observed at least 2 attempts on the devup-ui.css path: + // the first (failed) and the second (succeeded). + expect(devupCssCallCount).toBeGreaterThanOrEqual(2) + + coordinator.close() + }) + + it('should release the pending-extract slot when readBody throws before promotion', async () => { + // If JSON.parse on the request body throws, the handler must still tear + // down its `pendingExtractStarts` reservation (rather than the active + // counter) so waitForIdle is not left waiting forever for a phantom + // extraction. We verify by sending a malformed body, then proving the + // coordinator still processes a follow-up extraction normally. + codeExtractSpy.mockReturnValue({ + code: 'code', + map: undefined, + cssFile: 'devup-ui-1.css', + updatedBaseStyle: false, + free: mock(), + [Symbol.dispose]: mock(), + }) + getCssSpy.mockReturnValue('per-file-css') + exportSheetSpy.mockReturnValue('sheet-json') + exportClassMapSpy.mockReturnValue('classmap-json') + exportFileMapSpy.mockReturnValue('filemap-json') + + const options = makeOptions() + const coordinator = startCoordinator(options) + + await new Promise((r) => setTimeout(r, 100)) + + const portStr = (writeFileSyncSpy.mock.calls[0] as [string, string])[1] + const port = parseInt(portStr) + + // Send an invalid body so JSON.parse throws BEFORE activeExtractions is + // incremented. The handler must still respond 500 cleanly. + const badRes = await httpRequest(port, 'POST', '/extract', 'not-json') + expect(badRes.status).toBe(500) + const errorPayload = JSON.parse(badRes.body) as { error: string } + expect(typeof errorPayload.error).toBe('string') + + // A subsequent well-formed extraction must succeed. If the pending-slot + // bookkeeping was wrong (decrementing activeExtractions instead of + // pendingExtractStarts in finally), internal counters would drift negative + // — that would not crash this request but is asserted by the next test + // case via waitForIdle behaviour. + const goodRes = await httpRequest( + port, + 'POST', + '/extract', + JSON.stringify({ + filename: 'src/App.tsx', + code: 'const x = ', + resourcePath: join(process.cwd(), 'src', 'App.tsx'), + }), + ) + expect(goodRes.status).toBe(200) + const okPayload = JSON.parse(goodRes.body) as { code: string } + expect(okPayload.code).toBe('code') + + coordinator.close() + }) })