From c545543b5bcf864755e4aa13e66c0e505f6bec3c Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Fri, 27 Mar 2026 15:04:14 +1100 Subject: [PATCH 1/5] feat(cli): add lock file for dev and build commands When running inside an AI agent environment, write a lock file to `/nuxt.lock` containing process info (PID, port, URL, command). On startup, check for existing locks and show actionable error messages with the running server URL and kill command. Stale locks from crashed processes are auto-cleaned via PID liveness checking. Gated behind `isAgent` from std-env, completely invisible to normal users. --- packages/nuxi/src/commands/build.ts | 15 +++ packages/nuxi/src/dev/utils.ts | 26 +++- packages/nuxi/src/utils/lockfile.ts | 149 +++++++++++++++++++++++ packages/nuxi/test/unit/lockfile.spec.ts | 131 ++++++++++++++++++++ 4 files changed, 320 insertions(+), 1 deletion(-) create mode 100644 packages/nuxi/src/utils/lockfile.ts create mode 100644 packages/nuxi/test/unit/lockfile.spec.ts diff --git a/packages/nuxi/src/commands/build.ts b/packages/nuxi/src/commands/build.ts index 818139d3..a2036776 100644 --- a/packages/nuxi/src/commands/build.ts +++ b/packages/nuxi/src/commands/build.ts @@ -9,6 +9,7 @@ import { showVersions } from '../utils/banner' import { overrideEnv } from '../utils/env' import { clearBuildDir } from '../utils/fs' import { loadKit } from '../utils/kit' +import { checkLock, formatLockError, writeLock } from '../utils/lockfile' import { logger } from '../utils/logger' import { startCpuProfile, stopCpuProfile } from '../utils/profile' import { cwdArgs, dotEnvArgs, envNameArgs, extendsArgs, legacyRootDirArgs, logLevelArgs, profileArgs } from './_shared' @@ -46,6 +47,7 @@ export default defineCommand({ await startCpuProfile() } + let lockCleanup: (() => void) | undefined try { intro(colors.cyan('Building Nuxt for production...')) @@ -79,6 +81,18 @@ export default defineCommand({ }, }) + // Check for an existing process using the lock file (agent-only) + const existing = checkLock(nuxt.options.buildDir) + if (existing) { + console.error(formatLockError(existing, cwd)) + process.exit(1) + } + lockCleanup = await writeLock(nuxt.options.buildDir, { + pid: process.pid, + command: 'build', + startedAt: Date.now(), + }) + let nitro: ReturnType | undefined // In Bridge, if Nitro is not enabled, useNitro will throw an error try { @@ -121,6 +135,7 @@ export default defineCommand({ } } finally { + lockCleanup?.() if (profileArg) { await stopCpuProfile(cwd, 'build') } diff --git a/packages/nuxi/src/dev/utils.ts b/packages/nuxi/src/dev/utils.ts index 9ad83e93..dcdbc006 100644 --- a/packages/nuxi/src/dev/utils.ts +++ b/packages/nuxi/src/dev/utils.ts @@ -25,6 +25,7 @@ import { joinURL } from 'ufo' import { showVersionsFromConfig } from '../utils/banner' import { clearBuildDir } from '../utils/fs' import { loadKit } from '../utils/kit' +import { checkLock, formatLockError, writeLock } from '../utils/lockfile' import { loadNuxtManifest, resolveNuxtManifest, writeNuxtManifest } from '../utils/nuxt' import { withNodePath } from '../utils/paths' import { renderError } from './error' @@ -131,6 +132,7 @@ export class NuxtDevServer extends EventEmitter { #fileChangeTracker = new FileChangeTracker() #cwd: string #websocketConnections = new Set() + #lockCleanup?: () => void loadDebounced: (reload?: boolean, reason?: string) => void handler: RequestListener @@ -194,6 +196,14 @@ export class NuxtDevServer extends EventEmitter { await this.#loadNuxtInstance() + // Check for an existing process using the lock file (agent-only) + const buildDir = this.#currentNuxt!.options.buildDir + const existing = checkLock(buildDir) + if (existing) { + console.error(formatLockError(existing, this.options.cwd)) + process.exit(1) + } + if (this.options.showBanner) { showVersionsFromConfig(this.options.cwd, this.#currentNuxt!.options) } @@ -459,10 +469,24 @@ export class NuxtDevServer extends EventEmitter { // Emit ready with the server URL const proto = this.listener.https ? 'https' : 'http' - this.emit('ready', `${proto}://127.0.0.1:${addr.port}`) + const serverUrl = `${proto}://127.0.0.1:${addr.port}` + + // Write lock file so other processes know a dev server is running (agent-only) + this.#lockCleanup?.() + this.#lockCleanup = await writeLock(this.#currentNuxt.options.buildDir, { + pid: process.pid, + command: 'dev', + port: addr.port, + hostname: addr.address, + url: serverUrl, + startedAt: Date.now(), + }) + + this.emit('ready', serverUrl) } async close(): Promise { + this.#lockCleanup?.() if (this.#currentNuxt) { await this.#currentNuxt.close() } diff --git a/packages/nuxi/src/utils/lockfile.ts b/packages/nuxi/src/utils/lockfile.ts new file mode 100644 index 00000000..87e11b56 --- /dev/null +++ b/packages/nuxi/src/utils/lockfile.ts @@ -0,0 +1,149 @@ +import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' +import { mkdir } from 'node:fs/promises' +import process from 'node:process' + +import { dirname, join } from 'pathe' +import { isAgent } from 'std-env' + +export interface LockInfo { + pid: number + startedAt: number + command: 'dev' | 'build' + port?: number + hostname?: string + url?: string +} + +const LOCK_FILENAME = 'nuxt.lock' + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } + catch { + return false + } +} + +function readLockFile(lockPath: string): LockInfo | undefined { + try { + const content = readFileSync(lockPath, 'utf-8') + return JSON.parse(content) as LockInfo + } + catch { + return undefined + } +} + +/** + * Check if a Nuxt process is already running for this project. + * Only active when running inside an AI agent environment. + * Stale lock files (from crashed processes) are automatically cleaned up. + */ +export function checkLock(buildDir: string): LockInfo | undefined { + if (!isAgent) { + return undefined + } + + const lockPath = join(buildDir, LOCK_FILENAME) + + if (!existsSync(lockPath)) { + return undefined + } + + const info = readLockFile(lockPath) + if (!info) { + try { + unlinkSync(lockPath) + } + catch {} + return undefined + } + + if (!isProcessAlive(info.pid)) { + try { + unlinkSync(lockPath) + } + catch {} + return undefined + } + + // Don't block ourselves (fork pool scenario) + if (info.pid === process.pid) { + return undefined + } + + return info +} + +/** + * Write a lock file. Returns a cleanup function. + * Only writes when running inside an AI agent environment. + */ +export async function writeLock(buildDir: string, info: LockInfo): Promise<() => void> { + const noop = () => {} + if (!isAgent) { + return noop + } + + const lockPath = join(buildDir, LOCK_FILENAME) + + await mkdir(dirname(lockPath), { recursive: true }) + writeFileSync(lockPath, JSON.stringify(info, null, 2)) + + let cleaned = false + function cleanup() { + if (cleaned) + return + cleaned = true + try { + unlinkSync(lockPath) + } + catch {} + } + + process.on('exit', cleanup) + for (const signal of ['SIGTERM', 'SIGINT', 'SIGQUIT', 'SIGHUP'] as const) { + process.once(signal, () => { + cleanup() + process.exit() + }) + } + + return cleanup +} + +/** + * Format an error message when a Nuxt process is already running. + * Designed to be actionable for both humans and LLM agents. + */ +export function formatLockError(info: LockInfo, cwd: string): string { + const isWindows = process.platform === 'win32' + const killCmd = isWindows ? `taskkill /PID ${info.pid} /F` : `kill ${info.pid}` + const label = info.command === 'dev' ? 'dev server' : 'build' + + const lines = [ + '', + `Another Nuxt ${label} is already running:`, + '', + ] + + if (info.url) { + lines.push(` URL: ${info.url}`) + } + lines.push(` PID: ${info.pid}`) + lines.push(` Dir: ${cwd}`) + lines.push(` Started: ${new Date(info.startedAt).toLocaleString()}`) + lines.push('') + + if (info.command === 'dev' && info.url) { + lines.push(`Run \`${killCmd}\` to stop it, or connect to ${info.url}`) + } + else { + lines.push(`Run \`${killCmd}\` to stop it.`) + } + lines.push('') + + return lines.join('\n') +} diff --git a/packages/nuxi/test/unit/lockfile.spec.ts b/packages/nuxi/test/unit/lockfile.spec.ts new file mode 100644 index 00000000..fa8f4ab3 --- /dev/null +++ b/packages/nuxi/test/unit/lockfile.spec.ts @@ -0,0 +1,131 @@ +import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import { mkdir, mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import process from 'node:process' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('std-env', async (importOriginal) => { + const original = await importOriginal() + return { ...original, isAgent: true } +}) + +const { checkLock, formatLockError, writeLock } = await import('../../src/utils/lockfile') + +describe('lockfile', () => { + let tempDir: string + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'nuxt-lockfile-test-')) + }) + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }) + }) + + describe('checkLock', () => { + it('returns undefined when no lock file exists', () => { + expect(checkLock(tempDir)).toBeUndefined() + }) + + it('returns undefined for own PID (self-check)', async () => { + await mkdir(tempDir, { recursive: true }) + writeFileSync(join(tempDir, 'nuxt.lock'), JSON.stringify({ + pid: process.pid, + port: 3000, + hostname: '127.0.0.1', + url: 'http://127.0.0.1:3000', + command: 'dev', + startedAt: Date.now(), + })) + + expect(checkLock(tempDir)).toBeUndefined() + }) + + it('cleans up stale lock files from dead processes', async () => { + writeFileSync(join(tempDir, 'nuxt.lock'), JSON.stringify({ + pid: 999999999, + port: 3000, + hostname: '127.0.0.1', + url: 'http://127.0.0.1:3000', + command: 'dev', + startedAt: Date.now(), + })) + + expect(checkLock(tempDir)).toBeUndefined() + expect(existsSync(join(tempDir, 'nuxt.lock'))).toBe(false) + }) + + it('cleans up corrupted lock files', async () => { + writeFileSync(join(tempDir, 'nuxt.lock'), 'not valid json') + + expect(checkLock(tempDir)).toBeUndefined() + expect(existsSync(join(tempDir, 'nuxt.lock'))).toBe(false) + }) + }) + + describe('writeLock', () => { + it('writes lock file and returns cleanup function', async () => { + const cleanup = await writeLock(tempDir, { + pid: process.pid, + command: 'dev', + port: 3000, + hostname: '127.0.0.1', + url: 'http://127.0.0.1:3000', + startedAt: Date.now(), + }) + const lockPath = join(tempDir, 'nuxt.lock') + + expect(existsSync(lockPath)).toBe(true) + const written = JSON.parse(readFileSync(lockPath, 'utf-8')) + expect(written.pid).toBe(process.pid) + expect(written.port).toBe(3000) + expect(written.command).toBe('dev') + + cleanup() + expect(existsSync(lockPath)).toBe(false) + }) + + it('cleanup is idempotent', async () => { + const cleanup = await writeLock(tempDir, { + pid: process.pid, + command: 'build', + startedAt: Date.now(), + }) + cleanup() + cleanup() // should not throw + }) + }) + + describe('formatLockError', () => { + it('includes actionable dev server info', () => { + const message = formatLockError({ + pid: 12345, + command: 'dev', + port: 3000, + hostname: '127.0.0.1', + url: 'http://127.0.0.1:3000', + startedAt: Date.now(), + }, '/my/project') + + expect(message).toContain('dev server') + expect(message).toContain('http://127.0.0.1:3000') + expect(message).toContain('12345') + expect(message).toContain('/my/project') + expect(message).toContain('kill 12345') + expect(message).toContain('connect to') + }) + + it('formats build lock without URL', () => { + const message = formatLockError({ + pid: 12345, + command: 'build', + startedAt: Date.now(), + }, '/my/project') + + expect(message).toContain('build') + expect(message).toContain('12345') + expect(message).not.toContain('connect to') + }) + }) +}) From 0a8c35902cf6ad12cfa431893a6e614f00fadf78 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Fri, 27 Mar 2026 15:07:38 +1100 Subject: [PATCH 2/5] fix: unexport LockInfo interface to satisfy knip --- packages/nuxi/src/utils/lockfile.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nuxi/src/utils/lockfile.ts b/packages/nuxi/src/utils/lockfile.ts index 87e11b56..ce136879 100644 --- a/packages/nuxi/src/utils/lockfile.ts +++ b/packages/nuxi/src/utils/lockfile.ts @@ -5,7 +5,7 @@ import process from 'node:process' import { dirname, join } from 'pathe' import { isAgent } from 'std-env' -export interface LockInfo { +interface LockInfo { pid: number startedAt: number command: 'dev' | 'build' From 6ac1f44f06a2de4dc56d4023533700ff3c130c60 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Fri, 27 Mar 2026 15:12:31 +1100 Subject: [PATCH 3/5] fix: make lockfile test platform-aware for Windows kill command --- packages/nuxi/test/unit/lockfile.spec.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/nuxi/test/unit/lockfile.spec.ts b/packages/nuxi/test/unit/lockfile.spec.ts index fa8f4ab3..d4d0ddc4 100644 --- a/packages/nuxi/test/unit/lockfile.spec.ts +++ b/packages/nuxi/test/unit/lockfile.spec.ts @@ -5,6 +5,8 @@ import { join } from 'node:path' import process from 'node:process' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +const isWindows = process.platform === 'win32' + vi.mock('std-env', async (importOriginal) => { const original = await importOriginal() return { ...original, isAgent: true } @@ -112,7 +114,7 @@ describe('lockfile', () => { expect(message).toContain('http://127.0.0.1:3000') expect(message).toContain('12345') expect(message).toContain('/my/project') - expect(message).toContain('kill 12345') + expect(message).toContain(isWindows ? 'taskkill /PID 12345 /F' : 'kill 12345') expect(message).toContain('connect to') }) From fabedc53a602eb1b3dff8ea1ba0e920e3a0cca41 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Fri, 27 Mar 2026 15:13:35 +1100 Subject: [PATCH 4/5] feat: add NUXT_IGNORE_LOCK env var to bypass lock check --- packages/nuxi/src/utils/lockfile.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/nuxi/src/utils/lockfile.ts b/packages/nuxi/src/utils/lockfile.ts index ce136879..0a7eaf8a 100644 --- a/packages/nuxi/src/utils/lockfile.ts +++ b/packages/nuxi/src/utils/lockfile.ts @@ -36,13 +36,18 @@ function readLockFile(lockPath: string): LockInfo | undefined { } } +function isLockEnabled(): boolean { + return isAgent && !process.env.NUXT_IGNORE_LOCK +} + /** * Check if a Nuxt process is already running for this project. * Only active when running inside an AI agent environment. + * Set NUXT_IGNORE_LOCK=1 to bypass. * Stale lock files (from crashed processes) are automatically cleaned up. */ export function checkLock(buildDir: string): LockInfo | undefined { - if (!isAgent) { + if (!isLockEnabled()) { return undefined } @@ -83,7 +88,7 @@ export function checkLock(buildDir: string): LockInfo | undefined { */ export async function writeLock(buildDir: string, info: LockInfo): Promise<() => void> { const noop = () => {} - if (!isAgent) { + if (!isLockEnabled()) { return noop } @@ -143,6 +148,7 @@ export function formatLockError(info: LockInfo, cwd: string): string { else { lines.push(`Run \`${killCmd}\` to stop it.`) } + lines.push(`Set NUXT_IGNORE_LOCK=1 to bypass this check.`) lines.push('') return lines.join('\n') From e34082bba501fc5016680e93b0569585cfb5b8f4 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Fri, 27 Mar 2026 15:33:51 +1100 Subject: [PATCH 5/5] fix: address CodeRabbit review feedback - Use atomic file creation (`wx` flag) to prevent race conditions between concurrent checkLock/writeLock calls - Detach process exit/signal handlers in cleanup() to prevent listener accumulation during dev server reloads - Move writeLock() after clearBuildDir() in build command so the lock file isn't deleted immediately after creation - Don't remove lock during dev server reloads (only on final shutdown via releaseLock()) --- packages/nuxi/src/commands/build.ts | 12 ++++++---- packages/nuxi/src/dev/index.ts | 1 + packages/nuxi/src/dev/utils.ts | 6 ++++- packages/nuxi/src/utils/lockfile.ts | 30 ++++++++++++++++++++---- packages/nuxi/test/unit/lockfile.spec.ts | 25 ++++++++++++++++++++ 5 files changed, 63 insertions(+), 11 deletions(-) diff --git a/packages/nuxi/src/commands/build.ts b/packages/nuxi/src/commands/build.ts index a2036776..ffc3869a 100644 --- a/packages/nuxi/src/commands/build.ts +++ b/packages/nuxi/src/commands/build.ts @@ -87,11 +87,6 @@ export default defineCommand({ console.error(formatLockError(existing, cwd)) process.exit(1) } - lockCleanup = await writeLock(nuxt.options.buildDir, { - pid: process.pid, - command: 'build', - startedAt: Date.now(), - }) let nitro: ReturnType | undefined // In Bridge, if Nitro is not enabled, useNitro will throw an error @@ -108,6 +103,13 @@ export default defineCommand({ await clearBuildDir(nuxt.options.buildDir) + // Write lock after clearing build dir so it doesn't get deleted + lockCleanup = await writeLock(nuxt.options.buildDir, { + pid: process.pid, + command: 'build', + startedAt: Date.now(), + }) + await kit.writeTypes(nuxt) nuxt.hook('build:error', async (err) => { diff --git a/packages/nuxi/src/dev/index.ts b/packages/nuxi/src/dev/index.ts index c000c2fd..25642c14 100644 --- a/packages/nuxi/src/dev/index.ts +++ b/packages/nuxi/src/dev/index.ts @@ -138,6 +138,7 @@ export async function initialize(devContext: NuxtDevContext, ctx: InitializeOpti devServer.listener.close(), devServer.close(), ]) + devServer.releaseLock() }, onReady: (callback: (address: string) => void) => { if (address) { diff --git a/packages/nuxi/src/dev/utils.ts b/packages/nuxi/src/dev/utils.ts index dcdbc006..b28d7ea2 100644 --- a/packages/nuxi/src/dev/utils.ts +++ b/packages/nuxi/src/dev/utils.ts @@ -486,12 +486,16 @@ export class NuxtDevServer extends EventEmitter { } async close(): Promise { - this.#lockCleanup?.() if (this.#currentNuxt) { await this.#currentNuxt.close() } } + /** Release the lock file. Call only on final shutdown, not during reloads. */ + releaseLock(): void { + this.#lockCleanup?.() + } + #closeWebSocketConnections(): void { for (const socket of this.#websocketConnections) { socket.destroy() diff --git a/packages/nuxi/src/utils/lockfile.ts b/packages/nuxi/src/utils/lockfile.ts index 0a7eaf8a..c8e436e6 100644 --- a/packages/nuxi/src/utils/lockfile.ts +++ b/packages/nuxi/src/utils/lockfile.ts @@ -83,8 +83,9 @@ export function checkLock(buildDir: string): LockInfo | undefined { } /** - * Write a lock file. Returns a cleanup function. + * Write a lock file atomically. Returns a cleanup function. * Only writes when running inside an AI agent environment. + * Uses exclusive file creation (`wx` flag) to prevent race conditions. */ export async function writeLock(buildDir: string, info: LockInfo): Promise<() => void> { const noop = () => {} @@ -95,25 +96,44 @@ export async function writeLock(buildDir: string, info: LockInfo): Promise<() => const lockPath = join(buildDir, LOCK_FILENAME) await mkdir(dirname(lockPath), { recursive: true }) - writeFileSync(lockPath, JSON.stringify(info, null, 2)) + + try { + writeFileSync(lockPath, JSON.stringify(info, null, 2), { flag: 'wx' }) + } + catch (error) { + // Lock already exists, another process won the race + if ((error as NodeJS.ErrnoException).code === 'EEXIST') { + return noop + } + throw error + } let cleaned = false + const exitHandler = () => cleanup() + const signalHandlers: Array<[string, () => void]> = [] + function cleanup() { if (cleaned) return cleaned = true + process.off('exit', exitHandler) + for (const [signal, handler] of signalHandlers) { + process.off(signal, handler) + } try { unlinkSync(lockPath) } catch {} } - process.on('exit', cleanup) + process.on('exit', exitHandler) for (const signal of ['SIGTERM', 'SIGINT', 'SIGQUIT', 'SIGHUP'] as const) { - process.once(signal, () => { + const handler = () => { cleanup() process.exit() - }) + } + signalHandlers.push([signal, handler]) + process.once(signal, handler) } return cleanup diff --git a/packages/nuxi/test/unit/lockfile.spec.ts b/packages/nuxi/test/unit/lockfile.spec.ts index d4d0ddc4..02513294 100644 --- a/packages/nuxi/test/unit/lockfile.spec.ts +++ b/packages/nuxi/test/unit/lockfile.spec.ts @@ -88,6 +88,31 @@ describe('lockfile', () => { expect(existsSync(lockPath)).toBe(false) }) + it('returns noop when lock already exists (atomic write)', async () => { + // First lock succeeds + const cleanup1 = await writeLock(tempDir, { + pid: process.pid, + command: 'dev', + startedAt: Date.now(), + }) + expect(existsSync(join(tempDir, 'nuxt.lock'))).toBe(true) + + // Second lock returns noop (file exists) + const cleanup2 = await writeLock(tempDir, { + pid: process.pid, + command: 'dev', + startedAt: Date.now(), + }) + + // cleanup2 is noop, should not remove the file + cleanup2() + expect(existsSync(join(tempDir, 'nuxt.lock'))).toBe(true) + + // cleanup1 still works + cleanup1() + expect(existsSync(join(tempDir, 'nuxt.lock'))).toBe(false) + }) + it('cleanup is idempotent', async () => { const cleanup = await writeLock(tempDir, { pid: process.pid,