diff --git a/packages/nuxi/src/commands/build.ts b/packages/nuxi/src/commands/build.ts index 818139d3..ffc3869a 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,13 @@ 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) + } + let nitro: ReturnType | undefined // In Bridge, if Nitro is not enabled, useNitro will throw an error try { @@ -94,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) => { @@ -121,6 +137,7 @@ export default defineCommand({ } } finally { + lockCleanup?.() if (profileArg) { await stopCpuProfile(cwd, 'build') } 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 9ad83e93..b28d7ea2 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,7 +469,20 @@ 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 { @@ -468,6 +491,11 @@ export class NuxtDevServer extends EventEmitter { } } + /** 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 new file mode 100644 index 00000000..c8e436e6 --- /dev/null +++ b/packages/nuxi/src/utils/lockfile.ts @@ -0,0 +1,175 @@ +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' + +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 + } +} + +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 (!isLockEnabled()) { + 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 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 = () => {} + if (!isLockEnabled()) { + return noop + } + + const lockPath = join(buildDir, LOCK_FILENAME) + + await mkdir(dirname(lockPath), { recursive: true }) + + 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', exitHandler) + for (const signal of ['SIGTERM', 'SIGINT', 'SIGQUIT', 'SIGHUP'] as const) { + const handler = () => { + cleanup() + process.exit() + } + signalHandlers.push([signal, handler]) + process.once(signal, handler) + } + + 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(`Set NUXT_IGNORE_LOCK=1 to bypass this check.`) + 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..02513294 --- /dev/null +++ b/packages/nuxi/test/unit/lockfile.spec.ts @@ -0,0 +1,158 @@ +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' + +const isWindows = process.platform === 'win32' + +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('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, + 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(isWindows ? 'taskkill /PID 12345 /F' : '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') + }) + }) +})