Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changepacks/changepack_log_coordinator_port_isolation.json
Original file line number Diff line number Diff line change
@@ -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"
}
18 changes: 9 additions & 9 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

94 changes: 94 additions & 0 deletions packages/next-plugin/src/__tests__/coordinator-port.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'

import { afterEach, describe, expect, it } from 'bun:test'

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)
})
})
5 changes: 4 additions & 1 deletion packages/next-plugin/src/__tests__/css-loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
70 changes: 69 additions & 1 deletion packages/next-plugin/src/__tests__/loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -856,7 +856,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)

Expand Down Expand Up @@ -1049,3 +1053,67 @@ describe('devupUILoader', () => {
})
})
})

describe('devupUILoader connection diagnostics', () => {
it('reports the unreachable endpoint and port file on connection errors', async () => {
existsSyncSpy.mockReturnValue(true)
readFileSyncSpy.mockReturnValue('12346')

const requestSpy = spyOn(http, 'request').mockImplementation(() => {
const fakeReq = {
on: mock((event: string, handler: (...args: unknown[]) => void) => {
if (event === 'error') {
queueMicrotask(() =>
handler(
Object.assign(new Error('refused'), {
code: 'ECONNREFUSED',
}),
),
)
}
return fakeReq
}),
write: mock(),
end: mock(),
}
return asClientRequest(fakeReq)
})

try {
const asyncCallback = mock()
const t = {
getOptions: () => ({
package: 'package',
cssDir: 'cssDir',
sheetFile: 'sheetFile',
classMapFile: 'classMapFile',
fileMapFile: 'fileMapFile',
themeFile: 'themeFile',
watch: true,
singleCss: true,
coordinatorPortFile: 'coordinator.port',
}),
async: mock().mockReturnValue(asyncCallback),
resourcePath: join(process.cwd(), 'src', 'App.tsx'),
addDependency: mock(),
}

devupUILoader.bind(asLoaderContext(t))(
Buffer.from('source code'),
'src/App.tsx',
)

await waitFor(() => {
expect(asyncCallback).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringContaining(
'Coordinator unreachable at 127.0.0.1:12346 (unknown owner, port file coordinator.port): refused.',
),
}),
)
})
} finally {
requestSpy.mockRestore()
}
})
})
Loading
Loading