Skip to content

Commit 3642a1f

Browse files
committed
fix(dev): drop a dev lock whose recorded port is free
1 parent 036e2ca commit 3642a1f

3 files changed

Lines changed: 62 additions & 15 deletions

File tree

packages/nuxt-cli/src/dev/takeover.ts

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import colors from 'picocolors'
88
import { isCI } from 'std-env'
99

1010
import { restoreRawMode } from '../utils/console'
11-
import { clearTakeover, isInteractiveSession, isLockEnabled, isProcessAlive, markTakenOver, readLock } from '../utils/lockfile'
11+
import { clearStaleLock, clearTakeover, isInteractiveSession, isLockEnabled, isProcessAlive, markTakenOver, readLock } from '../utils/lockfile'
1212
import { logger } from '../utils/logger'
1313

1414
/** How long the outgoing dev server has to exit and release its port. */
@@ -30,7 +30,7 @@ export type TakeoverRefusalReason
3030
export type TakeoverResult
3131
/** Nothing to take over, or nothing we are willing to touch. */
3232
= | { action: 'none' }
33-
/** A lock was found but its owner is gone; it will be cleaned up on acquire. */
33+
/** A lock was found but its owner is gone, so it has been removed. */
3434
| { action: 'stale' }
3535
/** The previous server is gone and its port is ours. */
3636
| { action: 'taken', port: number, pid: number }
@@ -71,28 +71,33 @@ export async function takeOverDevServer(buildDir: string, options: TakeoverOptio
7171
return { action: 'none' }
7272
}
7373

74-
// Signalling a `build` is never on the table, and neither is a server on a
75-
// port we were not asked for: an explicit `--port` that differs skips the
76-
// takeover, and the ordinary lock check decides whether starting is allowed.
74+
// Signalling a `build` is never on the table, and a dev server that has not
75+
// bound a port yet cannot be identified well enough to touch.
7776
if (existing.command !== 'dev' || !existing.port) {
7877
return { action: 'none' }
7978
}
80-
if (options.requestedPort !== undefined && options.requestedPort !== existing.port) {
81-
return { action: 'none' }
82-
}
8379

84-
if (!isProcessAlive(existing.pid)) {
85-
if (!await isPortFree(existing.port, existing.hostname)) {
80+
const alive = isProcessAlive(existing.pid)
81+
const portFree = await isPortFree(existing.port, existing.hostname)
82+
83+
// Either signal alone means the recorded server is gone: an exited process
84+
// cannot come back, and a free port means the PID was recycled inside the
85+
// lock's trust window, so it now belongs to a stranger we must not signal.
86+
// The lock is dropped here because `acquireLock` only judges liveness by PID
87+
// and would otherwise refuse to start on behalf of a server that is not there.
88+
if (!alive || portFree) {
89+
if (!alive && !portFree) {
8690
logger.warn(`The dev server that was using port ${existing.port} is gone, but something is still listening there.`)
8791
}
92+
clearStaleLock(buildDir, existing)
8893
return { action: 'stale' }
8994
}
9095

91-
// An alive PID with a free port means the PID was recycled inside the lock's
92-
// trust window and belongs to an unrelated process. Signalling it would kill
93-
// a stranger.
94-
if (await isPortFree(existing.port, existing.hostname)) {
95-
return { action: 'stale' }
96+
// A live server on a port we were not asked for is left alone: an explicit
97+
// `--port` that differs skips the takeover, and the ordinary lock check
98+
// decides whether starting is allowed.
99+
if (options.requestedPort !== undefined && options.requestedPort !== existing.port) {
100+
return { action: 'none' }
96101
}
97102

98103
if (options.takeover === false) {

packages/nuxt-cli/src/utils/lockfile.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,25 @@ export function readActiveLock(buildDir: string): LockInfo | undefined {
6464
return info && isLockActive(info) ? info : undefined
6565
}
6666

67+
/**
68+
* Remove a lock whose holder has gone away, so the next `acquireLock` is not
69+
* refused on behalf of a process that cannot come back. Liveness is the
70+
* caller's judgement: a dead PID is not the only way for a holder to be gone,
71+
* and the port a dev server recorded is the more reliable signal.
72+
*
73+
* The lock is re-read and matched on identity, so one that has been replaced
74+
* since the caller inspected it is left alone.
75+
*/
76+
export function clearStaleLock(buildDir: string, info: LockInfo): boolean {
77+
const lockPath = join(buildDir, LOCK_FILENAME)
78+
const current = readLockFile(lockPath)
79+
if (!current || current.pid !== info.pid || current.startedAt !== info.startedAt) {
80+
return false
81+
}
82+
tryUnlink(lockPath)
83+
return true
84+
}
85+
6786
/**
6887
* Record that `byPid` is taking the lock over, so the outgoing holder can
6988
* explain its own shutdown. Only ever annotates a lock owned by another

packages/nuxt-cli/test/unit/takeover.spec.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,27 @@ describe('takeOverDevServer', () => {
9696
const proc = mockProcess()
9797
expect(await takeOverDevServer(buildDir, { requestedPort: 4000, interactive: false })).toEqual({ action: 'none' })
9898
expect(proc.signals).toHaveLength(0)
99+
expect(readLock(buildDir)).toBeDefined()
100+
})
101+
102+
it('drops a stale lock even when a different port was requested', async () => {
103+
writeLock(buildDir)
104+
checkPort.mockResolvedValue(3000)
105+
mockProcess()
106+
expect(await takeOverDevServer(buildDir, { requestedPort: 4000, interactive: false })).toEqual({ action: 'stale' })
107+
expect(readLock(buildDir)).toBeUndefined()
108+
})
109+
110+
it('leaves a lock that was replaced while it was inspected', async () => {
111+
writeLock(buildDir)
112+
checkPort.mockResolvedValue(3000)
113+
mockProcess({ alive: false })
114+
checkPort.mockImplementation(async () => {
115+
writeLock(buildDir, { pid: 555555, startedAt: Date.now() + 1 })
116+
return 3000
117+
})
118+
expect(await takeOverDevServer(buildDir, { interactive: false })).toEqual({ action: 'stale' })
119+
expect(readLock(buildDir)).toMatchObject({ pid: 555555 })
99120
})
100121

101122
it('takes over when the explicit port matches the holder\'s', async () => {
@@ -110,6 +131,7 @@ describe('takeOverDevServer', () => {
110131
const proc = mockProcess({ alive: false })
111132
expect(await takeOverDevServer(buildDir, { interactive: false })).toEqual({ action: 'stale' })
112133
expect(proc.signals).toHaveLength(0)
134+
expect(readLock(buildDir)).toBeUndefined()
113135
})
114136

115137
it('warns when the holder is gone but its port is still taken', async () => {
@@ -135,6 +157,7 @@ describe('takeOverDevServer', () => {
135157
const proc = mockProcess()
136158
expect(await takeOverDevServer(buildDir, { interactive: false })).toEqual({ action: 'stale' })
137159
expect(proc.signals).toHaveLength(0)
160+
expect(readLock(buildDir)).toBeUndefined()
138161
})
139162

140163
describe('decision matrix', () => {

0 commit comments

Comments
 (0)