Skip to content
Open
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
17 changes: 17 additions & 0 deletions packages/nuxi/src/commands/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -46,6 +47,7 @@ export default defineCommand({
await startCpuProfile()
}

let lockCleanup: (() => void) | undefined
try {
intro(colors.cyan('Building Nuxt for production...'))

Expand Down Expand Up @@ -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<typeof kit.useNitro> | undefined
// In Bridge, if Nitro is not enabled, useNitro will throw an error
try {
Expand All @@ -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) => {
Expand Down Expand Up @@ -121,6 +137,7 @@ export default defineCommand({
}
}
finally {
lockCleanup?.()
if (profileArg) {
await stopCpuProfile(cwd, 'build')
}
Expand Down
1 change: 1 addition & 0 deletions packages/nuxi/src/dev/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
30 changes: 29 additions & 1 deletion packages/nuxi/src/dev/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -131,6 +132,7 @@ export class NuxtDevServer extends EventEmitter<DevServerEventMap> {
#fileChangeTracker = new FileChangeTracker()
#cwd: string
#websocketConnections = new Set<any>()
#lockCleanup?: () => void

loadDebounced: (reload?: boolean, reason?: string) => void
handler: RequestListener
Expand Down Expand Up @@ -194,6 +196,14 @@ export class NuxtDevServer extends EventEmitter<DevServerEventMap> {

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)
}
Expand Down Expand Up @@ -459,7 +469,20 @@ export class NuxtDevServer extends EventEmitter<DevServerEventMap> {

// 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<void> {
Expand All @@ -468,6 +491,11 @@ export class NuxtDevServer extends EventEmitter<DevServerEventMap> {
}
}

/** 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()
Expand Down
175 changes: 175 additions & 0 deletions packages/nuxi/src/utils/lockfile.ts
Original file line number Diff line number Diff line change
@@ -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')
}
Loading
Loading