From 383ca0543b32ade26dc963ba8fdd8a85ab62c082 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Fri, 2 Oct 2026 21:43:01 +0900 Subject: [PATCH] fix(next-plugin): isolate coordinator port file and explain unreachable coordinators --- ...gepack_log_coordinator_port_isolation.json | 7 + .../src/__tests__/coordinator-port.test.ts | 92 ++++++++++++ .../src/__tests__/css-loader.test.ts | 5 +- .../next-plugin/src/__tests__/loader.test.ts | 6 +- packages/next-plugin/src/coordinator-port.ts | 133 ++++++++++++++++++ packages/next-plugin/src/coordinator.ts | 11 +- packages/next-plugin/src/css-loader.ts | 22 ++- packages/next-plugin/src/loader.ts | 22 ++- packages/next-plugin/src/plugin.ts | 14 +- 9 files changed, 291 insertions(+), 21 deletions(-) create mode 100644 .changepacks/changepack_log_coordinator_port_isolation.json create mode 100644 packages/next-plugin/src/__tests__/coordinator-port.test.ts create mode 100644 packages/next-plugin/src/coordinator-port.ts diff --git a/.changepacks/changepack_log_coordinator_port_isolation.json b/.changepacks/changepack_log_coordinator_port_isolation.json new file mode 100644 index 000000000..667a75271 --- /dev/null +++ b/.changepacks/changepack_log_coordinator_port_isolation.json @@ -0,0 +1,7 @@ +{ + "changes": { + "packages/next-plugin/package.json": "Patch" + }, + "note": "Isolate the coordinator port file per owning process when another live next dev/build holds it, record the owner pid, and report port, pid and recovery when the coordinator is missing or unreachable", + "date": "2026-10-02T12:41:12.3693972Z" +} diff --git a/packages/next-plugin/src/__tests__/coordinator-port.test.ts b/packages/next-plugin/src/__tests__/coordinator-port.test.ts new file mode 100644 index 000000000..b934cf06c --- /dev/null +++ b/packages/next-plugin/src/__tests__/coordinator-port.test.ts @@ -0,0 +1,92 @@ +import { afterEach, describe, expect, it } from 'bun:test' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { + formatPortFile, + isConnectionError, + parsePortFile, + removeOwnPortFile, + removeStalePortFile, + resolveCoordinatorPortFile, + unreachableCoordinatorError, +} from '../coordinator-port' + +const dirs: string[] = [] +function tmp(): string { + const dir = mkdtempSync(join(tmpdir(), 'devup-port-')) + dirs.push(dir) + return dir +} +// A pid that cannot be alive: far above any OS limit. +const DEAD_PID = 2 ** 31 - 2 +// The parent process is alive and is not this one. +const LIVE_FOREIGN_PID = process.ppid + +afterEach(() => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }) +}) + +describe('coordinator port file', () => { + it('keeps the port on the first line so older readers still parse it', () => { + const text = formatPortFile(4321, 99) + expect(Number.parseInt(text, 10)).toBe(4321) + expect(parsePortFile(text)).toEqual({ port: 4321, pid: 99 }) + }) + + it('reads a bare port written by an older release', () => { + expect(parsePortFile('4321\n')).toEqual({ port: 4321, pid: undefined }) + }) + + it('uses the shared name unless another live process owns it', () => { + const dir = tmp() + const shared = join(dir, 'coordinator.port') + expect(resolveCoordinatorPortFile(dir)).toBe(shared) + + writeFileSync(shared, formatPortFile(1, DEAD_PID)) + expect(resolveCoordinatorPortFile(dir)).toBe(shared) + + writeFileSync(shared, formatPortFile(1, process.pid)) + expect(resolveCoordinatorPortFile(dir)).toBe(shared) + + writeFileSync(shared, formatPortFile(1, LIVE_FOREIGN_PID)) + expect(resolveCoordinatorPortFile(dir)).toBe( + join(dir, `coordinator.${process.pid}.port`), + ) + }) + + it('never deletes a live foreign owner file, but clears a dead one', () => { + const dir = tmp() + const file = join(dir, 'coordinator.port') + writeFileSync(file, formatPortFile(1, LIVE_FOREIGN_PID)) + removeStalePortFile(file) + removeOwnPortFile(file) + expect(Bun.file(file).size).toBeGreaterThan(0) + + writeFileSync(file, formatPortFile(1, DEAD_PID)) + removeStalePortFile(file) + expect(Bun.file(file).size).toBe(0) + }) + + it('names port, owner pid, state and recovery when the coordinator is unreachable', () => { + const dir = tmp() + const file = join(dir, 'coordinator.port') + writeFileSync(file, formatPortFile(4321, DEAD_PID)) + const message = unreachableCoordinatorError( + file, + Object.assign(new Error('connect ETIMEDOUT 127.0.0.1:4321'), { + code: 'ETIMEDOUT', + }), + ).message + expect(message).toContain('127.0.0.1:4321') + expect(message).toContain(`owner pid ${DEAD_PID} (not running)`) + expect(message).toContain('restart the dev server or build') + }) + + it('recognises connection failures, including aggregated ones', () => { + expect(isConnectionError({ code: 'ECONNREFUSED' })).toBe(true) + expect(isConnectionError({ errors: [{ code: 'ETIMEDOUT' }] })).toBe(true) + expect(isConnectionError(new Error('boom'))).toBe(false) + }) +}) diff --git a/packages/next-plugin/src/__tests__/css-loader.test.ts b/packages/next-plugin/src/__tests__/css-loader.test.ts index 1ff6f7187..af08e5a64 100644 --- a/packages/next-plugin/src/__tests__/css-loader.test.ts +++ b/packages/next-plugin/src/__tests__/css-loader.test.ts @@ -358,7 +358,10 @@ describe('devupUICssLoader', () => { } as unknown as CssLoaderThis)(Buffer.from(''), '', '') }) - expect(error.message).toBe('Coordinator port file not found') + expect(error.message).toContain( + 'Coordinator port file not found: nonexistent.port', + ) + expect(error.message).toContain('Restart the dev server or build') }) it('should error when coordinator returns non-200 status', async () => { diff --git a/packages/next-plugin/src/__tests__/loader.test.ts b/packages/next-plugin/src/__tests__/loader.test.ts index c7f33b496..7f885888b 100644 --- a/packages/next-plugin/src/__tests__/loader.test.ts +++ b/packages/next-plugin/src/__tests__/loader.test.ts @@ -850,7 +850,11 @@ describe('devupUILoader', () => { // Retries 20 times × 50ms = 1s max, then calls back with error await waitFor(() => { expect(asyncCallback).toHaveBeenCalledWith( - new Error('Coordinator port file not found'), + expect.objectContaining({ + message: expect.stringContaining( + 'Coordinator port file not found: nonexistent.port', + ), + }), ) }, 3000) diff --git a/packages/next-plugin/src/coordinator-port.ts b/packages/next-plugin/src/coordinator-port.ts new file mode 100644 index 000000000..1335a4c36 --- /dev/null +++ b/packages/next-plugin/src/coordinator-port.ts @@ -0,0 +1,133 @@ +import { existsSync, readFileSync, unlinkSync } from 'node:fs' +import { join } from 'node:path' + +/** + * What a coordinator writes to its port file: the port on the first line (all + * an older reader looks at, via `parseInt`) and the owning pid on the second. + */ +export interface CoordinatorPortInfo { + port: number + /** Process that owns the coordinator, when the file says. */ + pid?: number +} + +export function formatPortFile(port: number, pid = process.pid): string { + return `${port}\n${pid}` +} + +export function parsePortFile(text: string): CoordinatorPortInfo { + const [portLine = '', pidLine = ''] = text.trim().split(/\r?\n/) + const port = Number.parseInt(portLine.trim(), 10) + const pid = Number.parseInt(pidLine.trim(), 10) + return { port, pid: Number.isInteger(pid) && pid > 0 ? pid : undefined } +} + +/** Whether a process exists. EPERM means it exists but is not ours. */ +export function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch (error) { + return (error as NodeJS.ErrnoException).code === 'EPERM' + } +} + +function readOwner(portFile: string): CoordinatorPortInfo | undefined { + try { + return parsePortFile(readFileSync(portFile, 'utf-8')) + } catch { + return undefined + } +} + +/** A live coordinator owned by some other process, if the file names one. */ +export function foreignLiveOwner(portFile: string): number | undefined { + const pid = readOwner(portFile)?.pid + return pid !== undefined && pid !== process.pid && isProcessAlive(pid) + ? pid + : undefined +} + +/** + * The port file this process should use in `distDir`. + * + * `next dev` and `next build` in one directory used to share one file: the + * second one deleted the first one's port and overwrote it, and the first's + * loaders then talked to a dead port or the wrong coordinator. The shared name + * is kept when nobody else holds it (so a cache keyed on loader options stays + * stable in the common case); when a different live process owns it, this + * process gets its own `coordinator..port`. + */ +export function resolveCoordinatorPortFile(distDir: string): string { + const shared = join(distDir, 'coordinator.port') + return foreignLiveOwner(shared) === undefined + ? shared + : join(distDir, `coordinator.${process.pid}.port`) +} + +/** Remove the file unless a different live process owns it. */ +export function removeStalePortFile(portFile: string): void { + if (foreignLiveOwner(portFile) !== undefined) return + try { + unlinkSync(portFile) + } catch { + // Nothing to remove (first run). + } +} + +/** Remove the file only if this process wrote it. */ +export function removeOwnPortFile(portFile: string): void { + const owner = readOwner(portFile) + if (owner?.pid !== undefined && owner.pid !== process.pid) return + try { + unlinkSync(portFile) + } catch { + // Already gone. + } +} + +const CONNECTION_CODES = new Set([ + 'ECONNREFUSED', + 'ECONNRESET', + 'ETIMEDOUT', + 'EPIPE', + 'EHOSTUNREACH', +]) + +export function isConnectionError(error: unknown): boolean { + const code = (error as NodeJS.ErrnoException | undefined)?.code + if (code !== undefined && CONNECTION_CODES.has(code)) return true + // An AggregateError from `localhost` resolution carries its codes inside. + const inner = (error as { errors?: unknown[] } | undefined)?.errors + return Array.isArray(inner) && inner.some(isConnectionError) +} + +/** The port file is missing: say whose it should be and how to recover. */ +export function missingPortFileError(portFile: string): Error { + return new Error( + `Coordinator port file not found: ${portFile}. ` + + 'The devup-ui coordinator for this build is not running (or exited). ' + + 'Restart the dev server or build; if another `next dev`/`next build` ' + + 'uses the same distDir, stop it or give this one its own distDir.', + ) +} + +/** A coordinator that was running is not answering. */ +export function unreachableCoordinatorError( + portFile: string, + cause: unknown, +): Error { + const info = existsSync(portFile) ? readOwner(portFile) : undefined + const owner = + info?.pid === undefined + ? 'unknown owner' + : `owner pid ${info.pid} (${isProcessAlive(info.pid) ? 'running' : 'not running'})` + const detail = cause instanceof Error ? cause.message : String(cause) + const next = + info?.pid !== undefined && !isProcessAlive(info.pid) + ? 'The coordinator process has exited: restart the dev server or build.' + : 'Another process may have replaced the coordinator: restart the dev server or build, and avoid running `next dev` and `next build` in one distDir at the same time.' + return new Error( + `Coordinator unreachable at 127.0.0.1:${info?.port ?? '?'} (${owner}, port file ${portFile}): ${detail}. ${next}`, + ) +} diff --git a/packages/next-plugin/src/coordinator.ts b/packages/next-plugin/src/coordinator.ts index 71bf53476..830c65201 100644 --- a/packages/next-plugin/src/coordinator.ts +++ b/packages/next-plugin/src/coordinator.ts @@ -1,9 +1,10 @@ -import { unlinkSync, writeFile, writeFileSync } from 'node:fs' +import { writeFile, writeFileSync } from 'node:fs' import { createServer, type IncomingMessage, type Server } from 'node:http' import { basename, dirname, join, relative } from 'node:path' import { getFileNumByFilename } from '@devup-ui/plugin-utils' +import { formatPortFile, removeOwnPortFile } from './coordinator-port' import { elapsedMs, profileStart, reportProfile } from './profile' import { transformStaticVanillaExtract } from './static-vanilla' import type { DevupWasm } from './wasm' @@ -668,7 +669,7 @@ export function startCoordinator(options: CoordinatorOptions): { coordinatorServer.listen(0, '127.0.0.1', () => { const addr = coordinatorServer.address() if (addr && typeof addr !== 'string') { - writeFileSync(coordinatorPortFile, String(addr.port), 'utf-8') + writeFileSync(coordinatorPortFile, formatPortFile(addr.port), 'utf-8') } }) @@ -682,11 +683,7 @@ export function startCoordinator(options: CoordinatorOptions): { coordinatorServer.close() if (server === coordinatorServer) { server = null - try { - unlinkSync(coordinatorPortFile) - } catch { - // ignore if already deleted - } + removeOwnPortFile(coordinatorPortFile) } }, } diff --git a/packages/next-plugin/src/css-loader.ts b/packages/next-plugin/src/css-loader.ts index a404c2772..9dec9da21 100644 --- a/packages/next-plugin/src/css-loader.ts +++ b/packages/next-plugin/src/css-loader.ts @@ -4,6 +4,12 @@ import { Agent, request } from 'node:http' import { getFileNumByFilename } from '@devup-ui/plugin-utils' import type { RawLoaderDefinitionFunction } from 'webpack' +import { + isConnectionError, + missingPortFileError, + parsePortFile, + unreachableCoordinatorError, +} from './coordinator-port' import { loadWasm } from './wasm' export interface DevupUICssLoaderOptions { @@ -26,7 +32,7 @@ const keepAliveAgent = new Agent({ keepAlive: true }) function readCoordinatorPort(portFile: string): number { if (cachedPort !== null) return cachedPort - cachedPort = parseInt(readFileSync(portFile, 'utf-8').trim()) + cachedPort = parsePortFile(readFileSync(portFile, 'utf-8')).port return cachedPort } @@ -96,7 +102,7 @@ const devupUICssLoader: RawLoaderDefinitionFunction = setTimeout(() => tryFetch(retries - 1), 50) return } - callback(new Error('Coordinator port file not found')) + callback(missingPortFileError(coordinatorPortFile)) return } try { @@ -107,7 +113,17 @@ const devupUICssLoader: RawLoaderDefinitionFunction = importMainCss, !watch, (err, css) => { - if (err) return callback(err) + if (err) { + if (isConnectionError(err)) { + // The port may belong to a coordinator that has since been + // replaced; forget it so the next call re-reads the file. + cachedPort = null + return callback( + unreachableCoordinatorError(coordinatorPortFile, err), + ) + } + return callback(err) + } callback(null, css) }, ) diff --git a/packages/next-plugin/src/loader.ts b/packages/next-plugin/src/loader.ts index de9f1515a..8699814ee 100644 --- a/packages/next-plugin/src/loader.ts +++ b/packages/next-plugin/src/loader.ts @@ -5,6 +5,12 @@ import { basename, dirname, join, relative } from 'node:path' import type { RawLoaderDefinitionFunction } from 'webpack' +import { + isConnectionError, + missingPortFileError, + parsePortFile, + unreachableCoordinatorError, +} from './coordinator-port' import { loadWasm } from './wasm' export interface DevupUILoaderOptions { @@ -43,7 +49,7 @@ function readCoordinatorPort(portFile: string): number { const cachedPort = cachedPorts.get(portFile) if (cachedPort !== undefined) return cachedPort - const port = Number.parseInt(readFileSync(portFile, 'utf-8').trim(), 10) + const port = parsePortFile(readFileSync(portFile, 'utf-8')).port cachedPorts.set(portFile, port) return port } @@ -146,7 +152,7 @@ const devupUILoader: RawLoaderDefinitionFunction = return } // Port file never appeared — fall through to error - callback(new Error('Coordinator port file not found')) + callback(missingPortFileError(coordinatorPortFile)) return } try { @@ -164,7 +170,17 @@ const devupUILoader: RawLoaderDefinitionFunction = resourcePath: this.resourcePath, }) coordinatorExtract(port, body, (err, content, sourceMap) => { - if (err) return callback(err) + if (err) { + if (isConnectionError(err)) { + // Forget a port that no longer answers so the next file + // re-reads the port file instead of repeating the timeout. + cachedPorts.delete(coordinatorPortFile) + return callback( + unreachableCoordinatorError(coordinatorPortFile, err), + ) + } + return callback(err) + } callback(null, content, sourceMap as Parameters[2]) }) } catch (error) { diff --git a/packages/next-plugin/src/plugin.ts b/packages/next-plugin/src/plugin.ts index b520692ec..6ef697acd 100644 --- a/packages/next-plugin/src/plugin.ts +++ b/packages/next-plugin/src/plugin.ts @@ -29,6 +29,10 @@ import { startCoordinator, takeExtractOutput, } from './coordinator' +import { + removeStalePortFile, + resolveCoordinatorPortFile, +} from './coordinator-port' import { collectProductionPrewarmFiles } from './prewarm' import { elapsedMs, profileStart, reportProfile } from './profile' import { transformStaticVanillaExtract } from './static-vanilla' @@ -331,7 +335,7 @@ export function DevupUI( // disable turbo parallel const excludeRegex = createNodeModulesExcludeRegex(include, '.mdx.[tj]sx?$') - const coordinatorPortFile = join(distDir, 'coordinator.port') + const coordinatorPortFile = resolveCoordinatorPortFile(distDir) // Pre-pass: single-importer collapse ALWAYS runs (files with exactly one // importer merge into that importer's bucket, so their identical atoms share @@ -523,11 +527,9 @@ export function DevupUI( // Delete stale port file from previous session so loaders don't connect // to a dead coordinator port. The new coordinator writes a fresh port file // once it starts listening. - try { - unlinkSync(coordinatorPortFile) - } catch { - // Port file doesn't exist (first run) — safe to ignore - } + // A live coordinator owned by another process is left alone (see + // resolveCoordinatorPortFile). + removeStalePortFile(coordinatorPortFile) const coordinator = startCoordinator({ wasm,