diff --git a/packages/nuxi/src/commands/prepare.ts b/packages/nuxi/src/commands/prepare.ts index ed1cff5b..0f13f8c1 100644 --- a/packages/nuxi/src/commands/prepare.ts +++ b/packages/nuxi/src/commands/prepare.ts @@ -6,6 +6,7 @@ import { resolve } from 'pathe' import { clearBuildDir } from '../utils/fs' import { loadKit } from '../utils/kit' +import { readActiveLock } from '../utils/lockfile' import { logger } from '../utils/logger' import { relativeToProcess } from '../utils/paths' import { cwdArgs, dotEnvArgs, envNameArgs, extendsArgs, legacyRootDirArgs, logLevelArgs } from './_shared' @@ -43,7 +44,20 @@ export default defineCommand({ ...ctx.data?.overrides, }, }) - await clearBuildDir(nuxt.options.buildDir) + + // Only wipe when nothing owns the buildDir. `clearBuildDir` removes the + // generated artifacts a live dev server's watcher reloads against, so it + // would resolve a freshly-deleted file (ENOENT); wiping under a build is + // equally destructive. `buildNuxt` refreshes every template in place anyway, + // so reuse is safe. Stop the owner if a clean wipe is genuinely needed. + const owner = readActiveLock(nuxt.options.buildDir) + if (owner) { + const label = owner.command === 'dev' ? 'dev server' : 'build' + logger.info(`A ${label} (PID ${owner.pid}) owns ${colors.cyan(relativeToProcess(nuxt.options.buildDir))}; refreshing templates in place without clearing.`) + } + else { + await clearBuildDir(nuxt.options.buildDir) + } await buildNuxt(nuxt) await writeTypes(nuxt) diff --git a/packages/nuxi/src/commands/typecheck.ts b/packages/nuxi/src/commands/typecheck.ts index 4e8425e4..607fe5d9 100644 --- a/packages/nuxi/src/commands/typecheck.ts +++ b/packages/nuxi/src/commands/typecheck.ts @@ -1,3 +1,4 @@ +import { existsSync } from 'node:fs' import process from 'node:process' import { cancel, confirm, isCancel, spinner } from '@clack/prompts' @@ -5,12 +6,13 @@ import { defineCommand } from 'citty' import { colors } from 'consola/utils' import { resolveModulePath } from 'exsolve' import { addDevDependency, detectPackageManager } from 'nypm' -import { resolve } from 'pathe' +import { join, resolve } from 'pathe' import { readTSConfig } from 'pkg-types' import { hasTTY } from 'std-env' import { x } from 'tinyexec' import { loadKit } from '../utils/kit' +import { readActiveLocks } from '../utils/lockfile' import { logger } from '../utils/logger' import { cwdArgs, dotEnvArgs, extendsArgs, legacyRootDirArgs, logLevelArgs } from './_shared' @@ -42,19 +44,55 @@ export default defineCommand({ ...dotEnvArgs, ...extendsArgs, ...legacyRootDirArgs, + prepare: { + type: 'boolean', + description: 'Generate Nuxt types before checking. Defaults to auto: skipped when a dev server is already running for this project (default buildDir only). Use --no-prepare to force-reuse, --prepare to always prepare.', + }, }, async run(ctx) { process.env.NODE_ENV = process.env.NODE_ENV || 'production' const cwd = resolve(ctx.args.cwd || ctx.args.rootDir) + // Assume the default buildDir. Resolving a custom one means loading config, + // the cost we're trying to skip, so those fall through to preparing. + const buildDir = join(cwd, '.nuxt') + + const decision = resolvePrepareDecision(buildDir, { + prepare: ctx.args.prepare as boolean | undefined, + extends: ctx.args.extends, + }) + + if (!decision.prepare && !existsSync(join(buildDir, 'tsconfig.json'))) { + logger.error( + `Cannot type check without prepared types: no \`${join(buildDir, 'tsconfig.json')}\` found. Run \`nuxt prepare\` or start the dev server first, or drop \`--no-prepare\`.`, + ) + process.exitCode = 1 + return + } + + if (!decision.prepare && ctx.args.extends) { + logger.warn('`--extends` is ignored when prepare is skipped.') + } + + if (!decision.prepare && hasTTY) { + logger.info( + decision.reusingDevPid + ? `Reusing types from the running dev server (PID ${decision.reusingDevPid}); skipping prepare.` + : 'Skipping prepare; type checking against the existing `.nuxt`.', + ) + } + + const preparePromise: Promise = decision.prepare + ? writeTypes(cwd, ctx.args.dotenv, ctx.args.logLevel as 'silent' | 'info' | 'verbose', { + ...ctx.data?.overrides, + ...(ctx.args.extends && { extends: ctx.args.extends }), + }) + : Promise.resolve() const [supportsProjects, vueTsc] = await Promise.all([ readTSConfig(cwd).then(r => !!(r.references?.length)), ensureVueTsc(cwd, resolveDeps()), - writeTypes(cwd, ctx.args.dotenv, ctx.args.logLevel as 'silent' | 'info' | 'verbose', { - ...ctx.data?.overrides, - ...(ctx.args.extends && { extends: ctx.args.extends }), - }), + preparePromise, ]) if (!vueTsc) { @@ -82,6 +120,41 @@ export default defineCommand({ }, }) +export interface PrepareDecision { + prepare: boolean + /** PID of the live dev server we are reusing types from, when skipping. */ + reusingDevPid?: number +} + +/** + * Decide whether `typecheck` runs its own prepare. Skips it when a dev server + * owns this buildDir and has signalled `typesReady`, reusing its `.nuxt` rather + * than rebuilding (which would remove `.nuxt/dist` and restart dev). The + * `typesReady` gate avoids checking against mid-rebuild or stale types. Explicit + * `--prepare`/`--no-prepare` win; in auto mode `--extends` forces a prepare. + */ +export function resolvePrepareDecision( + buildDir: string, + opts: { prepare?: boolean, extends?: string }, +): PrepareDecision { + if (opts.prepare === true) { + return { prepare: true } + } + if (opts.prepare === false) { + return { prepare: false } + } + if (opts.extends) { + return { prepare: true } + } + + // Reuse types from any live dev server that has signalled readiness. + const devLock = readActiveLocks(buildDir).find(l => l.command === 'dev' && l.typesReady === true) + if (devLock && existsSync(join(buildDir, 'tsconfig.json'))) { + return { prepare: false, reusingDevPid: devLock.pid } + } + return { prepare: true } +} + async function ensureVueTsc(cwd: string, deps: Record): Promise { const missing = (Object.keys(REQUIRED_DEPS) as DepName[]).filter(name => !deps[name]) if (missing.length === 0) { diff --git a/packages/nuxi/src/dev/utils.ts b/packages/nuxi/src/dev/utils.ts index a4181596..7a75f741 100644 --- a/packages/nuxi/src/dev/utils.ts +++ b/packages/nuxi/src/dev/utils.ts @@ -198,8 +198,8 @@ export class NuxtDevServer extends EventEmitter { await this.#loadNuxtInstance() - // Acquire lock before binding a listener so parallel agent invocations - // fail fast without starting a second server (agent-only). + // Acquire the lock before binding a listener so a conflicting build fails + // fast, and so typecheck can detect this server. this.#acquireDevLock(this.#currentNuxt!.options.buildDir) if (this.options.showBanner) { @@ -345,6 +345,14 @@ export class NuxtDevServer extends EventEmitter { throw new Error('Nuxt must be loaded before configuration') } + // Mark types stale while (re)building so a concurrent typecheck won't reuse + // mid-rebuild output; set ready again after the build below. + updateLock(this.#currentNuxt.options.buildDir, { + command: 'dev', + cwd: this.options.cwd, + typesReady: false, + }) + // Connect Vite HMR if (!process.env.NUXI_DISABLE_VITE_HMR) { this.#currentNuxt.hooks.hook('vite:extend', ({ config }) => { @@ -487,6 +495,8 @@ export class NuxtDevServer extends EventEmitter { port: addr.port, hostname: addr.address, url: serverUrl, + // Types are built for the current instance; typecheck may reuse them. + typesReady: true, }) this.emit('ready', serverUrl) @@ -506,14 +516,23 @@ export class NuxtDevServer extends EventEmitter { } #acquireDevLock(buildDir: string): void { + // Advertise this dev server so `nuxt typecheck` can detect it and skip its + // prepare. Peer dev servers may run concurrently, so this never refuses + // another dev, though it still refuses a live build. const lock = acquireLock(buildDir, { command: 'dev', cwd: this.options.cwd, - }) + }, { enforce: false }) if (lock.existing) { console.error(formatLockError(lock.existing)) throw new Error(`Another Nuxt ${lock.existing.command} is already running (PID ${lock.existing.pid}).`) } + // We coexist with peer dev servers rather than refuse them, but they share + // this `.nuxt` — concurrent writes/watches can trigger conflicting reloads. + const peerDev = lock.peers?.find(l => l.command === 'dev') + if (peerDev) { + console.warn(`Another Nuxt dev server is already running (PID ${peerDev.pid}); reusing the same \`.nuxt\` build dir. Run on a separate buildDir to avoid conflicting reloads.`) + } // Swap atomically: install the new release before freeing the old one so // we're never unlocked in between. const previousRelease = this.#lockCleanup diff --git a/packages/nuxi/src/utils/fs.ts b/packages/nuxi/src/utils/fs.ts index 05972bcc..1ace8db0 100644 --- a/packages/nuxi/src/utils/fs.ts +++ b/packages/nuxi/src/utils/fs.ts @@ -20,7 +20,9 @@ export async function clearDir(path: string, exclude?: string[]) { } export function clearBuildDir(path: string) { - return clearDir(path, ['cache', 'analyze', 'nuxt.json', 'nuxt.lock']) + // Keep `locks/` (and the legacy `nuxt.lock`) so a wipe never deletes a + // presence marker a peer dev/build process holds (see utils/lockfile). + return clearDir(path, ['cache', 'analyze', 'nuxt.json', 'nuxt.lock', 'locks']) } export async function rmRecursive(paths: string[]) { diff --git a/packages/nuxi/src/utils/lockfile.ts b/packages/nuxi/src/utils/lockfile.ts index 89010bc0..47ed9d9d 100644 --- a/packages/nuxi/src/utils/lockfile.ts +++ b/packages/nuxi/src/utils/lockfile.ts @@ -1,10 +1,10 @@ -import { mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' +import { linkSync, mkdirSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs' import process from 'node:process' import { join } from 'pathe' import { isAgent } from 'std-env' -interface LockInfo { +export interface LockInfo { pid: number startedAt: number command: 'dev' | 'build' @@ -12,13 +12,77 @@ interface LockInfo { port?: number hostname?: string url?: string + /** Set once dev has built types; cleared while (re)building. Gates typecheck reuse. */ + typesReady?: boolean + /** Identifies one `acquireLock` call so its release only removes its own marker. */ + token?: string } -const LOCK_FILENAME = 'nuxt.lock' +let acquireCounter = 0 + +// Presence markers live one-file-per-process under `/locks/.json` +// so peer dev servers on one buildDir don't clobber, no-op, or unlink each +// other's record; each process only writes or removes its own path. A build is +// exclusive, so instead of a per-PID marker it claims the shared `build.lock` +// sentinel atomically (see acquireLock). +const LOCKS_DIRNAME = 'locks' +const BUILD_LOCK = 'build.lock' // PID recycling safety net. Locks older than this cannot be trusted because a // recycled PID could match a dead build's record. const MAX_LOCK_AGE_MS = 24 * 60 * 60 * 1000 +export function locksDir(buildDir: string): string { + return join(buildDir, LOCKS_DIRNAME) +} + +export function lockPathFor(buildDir: string, pid: number): string { + return join(locksDir(buildDir), `${pid}.json`) +} + +export function buildLockPath(buildDir: string): string { + return join(locksDir(buildDir), BUILD_LOCK) +} + +// Replace a marker atomically: write a sibling temp then rename over the target, +// so a concurrent reader never lands in a truncate/write window and sees the +// owner vanish. Used for the (overwriteable) per-process dev markers. +function writeMarkerAtomic(lockPath: string, info: LockInfo): void { + const tmp = `${lockPath}.${process.pid}.tmp` + writeFileSync(tmp, JSON.stringify(info, null, 2)) + renameSync(tmp, lockPath) +} + +// Claim a marker that must be exclusive (the build sentinel). `linkSync` is an +// atomic create-if-absent against an already-populated temp inode, so two +// racing builds can't both win the read-then-write. Returns the live holder +// when the sentinel is already taken, else `undefined` after claiming it. +function claimExclusive(lockPath: string, info: LockInfo): LockInfo | undefined { + const tmp = `${lockPath}.${process.pid}.tmp` + for (let attempt = 0; attempt < 2; attempt++) { + writeFileSync(tmp, JSON.stringify(info, null, 2)) + try { + linkSync(tmp, lockPath) + return undefined + } + catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'EEXIST') { + throw err + } + const holder = readLockFile(lockPath) + if (holder && isLockActive(holder)) { + return holder + } + tryUnlink(lockPath) // stale/dead holder — drop it and retry the claim + } + finally { + tryUnlink(tmp) + } + } + // Persistent staleness across both attempts: fall back to a plain replace. + writeMarkerAtomic(lockPath, info) + return undefined +} + function isProcessAlive(pid: number): boolean { try { process.kill(pid, 0) @@ -61,8 +125,59 @@ function isLockActive(info: LockInfo): boolean { } /** - * Locking is enabled for agents by default. `NUXT_LOCK=1` forces it on for - * non-agents; `NUXT_IGNORE_LOCK=1` forces it off. + * Enumerate the live markers in a buildDir, newest first, pruning dead/stale + * ones on the way. Excludes the current process (it never blocks itself). + */ +export function readActiveLocks(buildDir: string): LockInfo[] { + const dir = locksDir(buildDir) + let names: string[] + try { + names = readdirSync(dir) + } + catch { + return [] + } + const active: LockInfo[] = [] + for (const name of names) { + // `.tmp` files are mid-write stages of an atomic replace/claim; skip them. + // Everything else is a marker: per-process `.json` or the `build.lock` + // sentinel. + if (name.endsWith('.tmp')) { + continue + } + const lockPath = join(dir, name) + const info = readLockFile(lockPath) + if (!info || info.pid === process.pid) { + continue + } + if (isLockActive(info)) { + active.push(info) + } + else { + // Dead/stale/corrupted marker: prune so the directory doesn't accumulate. + tryUnlink(lockPath) + } + } + return active.sort((a, b) => b.startedAt - a.startedAt) +} + +/** + * A single representative active lock, or `undefined` when none is live: a build + * wins (it's exclusive), else the newest dev. Use {@link readActiveLocks} when + * every owner matters. + */ +export function readActiveLock(buildDir: string): LockInfo | undefined { + const active = readActiveLocks(buildDir) + return active.find(l => l.command === 'build') ?? active[0] +} + +type LockResult + = | { existing?: undefined, release: () => void, peers: LockInfo[] } + | { existing: LockInfo, release?: undefined, peers?: undefined } + +/** + * Default conflict-enforcement policy. On for agents; `NUXT_LOCK=1` forces it on, + * `NUXT_IGNORE_LOCK=1` off. `nuxt build` uses this; `nuxt dev` passes `enforce: false`. */ export function isLockEnabled(): boolean { if (process.env.NUXT_IGNORE_LOCK) { @@ -74,95 +189,105 @@ export function isLockEnabled(): boolean { return isAgent } -type LockResult - = | { existing?: undefined, release: () => void } - | { existing: LockInfo, release?: undefined } +// The marker is written unless explicitly opted out, even when enforcement is off, +// so other commands (e.g. typecheck) can detect a running dev server. +function isLockWriteEnabled(): boolean { + return !process.env.NUXT_IGNORE_LOCK +} /** - * Atomically acquire a build/dev lock. - * Returns `{ existing }` if another live process holds the lock, otherwise - * `{ release }` to be invoked on shutdown. No-op when locking is disabled. + * Acquire a build/dev lock. Returns `{ existing }` when a conflicting live lock + * blocks us, otherwise `{ release }` to invoke on shutdown. With `enforce` false + * (`nuxt dev`) peer dev servers coexist and only an active build is refused; + * with `enforce` true (`nuxt build`) any other live owner is refused. `enforce` + * defaults to `isLockEnabled()`. No-op when writing is disabled. */ export function acquireLock( buildDir: string, - info: Omit, + info: Omit, + opts: { enforce?: boolean } = {}, ): LockResult { - if (!isLockEnabled()) { - return { release: () => {} } + if (!isLockWriteEnabled()) { + return { release: () => {}, peers: [] } } - const lockPath = join(buildDir, LOCK_FILENAME) + const enforce = opts.enforce ?? isLockEnabled() + const token = `${process.pid}:${++acquireCounter}` const fullInfo: LockInfo = { pid: process.pid, startedAt: Date.now(), ...info, + token, } - // The build dir may not exist yet (e.g. `rimraf .nuxt && nuxt dev`); the - // lock is acquired before `clearBuildDir` runs, so create it lazily. + // The locks dir may not exist yet (e.g. `rimraf .nuxt && nuxt dev`); the lock + // is acquired before `clearBuildDir` runs, so create it lazily. try { - mkdirSync(buildDir, { recursive: true }) + mkdirSync(locksDir(buildDir), { recursive: true }) } catch {} - // Try exclusive-create up to twice: the first attempt may race with a stale - // lock that we then clean up and retry. - for (let attempt = 0; attempt < 2; attempt++) { - try { - writeFileSync(lockPath, JSON.stringify(fullInfo, null, 2), { flag: 'wx' }) - return { release: makeRelease(lockPath) } - } - catch (err) { - if ((err as NodeJS.ErrnoException).code !== 'EEXIST') { - throw err - } - const existing = readLockFile(lockPath) - if (existing && isLockActive(existing)) { - return { existing } - } - // Stale, corrupted, or self-owned; remove and retry. - tryUnlink(lockPath) - } + // A build is exclusive, so it refuses any other live owner; a dev refuses only + // an active build (peer dev servers are allowed to share the buildDir). + const others = readActiveLocks(buildDir) + const blocker = enforce ? others[0] : others.find(l => l.command === 'build') + if (blocker) { + return { existing: blocker } } - // Two failures in a row; surface whatever we can read. - const existing = readLockFile(lockPath) - if (existing && isLockActive(existing)) { - return { existing } + // A build claims the shared sentinel atomically: the read above is advisory, + // so two builds racing past it are still serialised here (the loser gets the + // live winner back). A dev just (re)writes its own per-process marker. + // `peers` are the live owners we coexist with — dev uses them to warn that the + // buildDir is shared, since the outputs (not just the lock) are then reused. + if (fullInfo.command === 'build') { + const sentinel = buildLockPath(buildDir) + const winner = claimExclusive(sentinel, fullInfo) + if (winner) { + return { existing: winner } + } + return { release: makeRelease(sentinel, token), peers: others } } - return { release: () => {} } + + const lockPath = lockPathFor(buildDir, process.pid) + writeMarkerAtomic(lockPath, fullInfo) + return { release: makeRelease(lockPath, token), peers: others } } /** - * Overwrite an existing lock we already own with updated metadata (e.g. port - * information learned after the listener binds). Callers must hold the lock - * via a prior successful `acquireLock`. Does nothing when locking is disabled. + * Overwrite this process's own marker with updated metadata (e.g. port learned + * after the listener binds, or toggling `typesReady`). Always targets our own + * marker regardless of peer dev servers. No-op when locking is disabled. */ export function updateLock( buildDir: string, - info: Omit, + info: Omit, ): void { - if (!isLockEnabled()) { + if (!isLockWriteEnabled()) { return } - const lockPath = join(buildDir, LOCK_FILENAME) + const lockPath = lockPathFor(buildDir, process.pid) const current = readLockFile(lockPath) - // Only overwrite our own lock; never touch another process's file. + // A recycled PID could leave a foreign file at our path; never adopt it. if (current && current.pid !== process.pid) { return } + // Merge so a partial update keeps existing fields (url, port, …) and the + // original acquisition's token survives for its release. const next: LockInfo = { + ...current, + ...info, pid: process.pid, startedAt: current?.startedAt ?? Date.now(), - ...info, + token: current?.token, } try { - writeFileSync(lockPath, JSON.stringify(next, null, 2)) + writeMarkerAtomic(lockPath, next) } catch {} } -function makeRelease(lockPath: string): () => void { +function makeRelease(lockPath: string, token: string): () => void { let released = false function release(): void { @@ -171,8 +296,10 @@ function makeRelease(lockPath: string): () => void { } released = true process.off('exit', release) + // A same-process re-acquire (dev reload) writes a new token, so only remove + // the file if it still carries ours. const current = readLockFile(lockPath) - if (!current || current.pid === process.pid) { + if (current?.token === token) { tryUnlink(lockPath) } } diff --git a/packages/nuxi/test/unit/lockfile-presence.spec.ts b/packages/nuxi/test/unit/lockfile-presence.spec.ts new file mode 100644 index 00000000..b028ccab --- /dev/null +++ b/packages/nuxi/test/unit/lockfile-presence.spec.ts @@ -0,0 +1,185 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { 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' + +// Force the "human" path: enforcement is off, but the presence marker should +// still be written so tooling can detect a running dev server. +vi.mock('std-env', async (importOriginal) => { + const original = await importOriginal() + return { ...original, isAgent: false } +}) + +const { acquireLock, lockPathFor, locksDir, readActiveLock, readActiveLocks } = await import('../../src/utils/lockfile') + +function writeMarker(buildDir: string, info: Record) { + mkdirSync(locksDir(buildDir), { recursive: true }) + writeFileSync(lockPathFor(buildDir, info.pid as number), JSON.stringify({ command: 'dev', cwd: '/other', startedAt: Date.now(), ...info })) +} + +function mockAlive(...pids: number[]) { + return vi.spyOn(process, 'kill').mockImplementation((pid) => { + if (pids.includes(pid as number)) { + return true as unknown as true + } + throw Object.assign(new Error('no such process'), { code: 'ESRCH' }) + }) +} + +describe('lockfile presence (enforcement off)', () => { + let tempDir: string + const ownPath = (dir: string) => lockPathFor(dir, process.pid) + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'nuxt-lock-presence-')) + delete process.env.NUXT_IGNORE_LOCK + delete process.env.NUXT_LOCK + }) + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }) + }) + + it('writes the presence marker on a clean dir even without enforcement', () => { + const lock = acquireLock(tempDir, { command: 'dev', cwd: '/project' }) + expect(lock.existing).toBeUndefined() + expect(lock.release).toBeDefined() + expect(existsSync(ownPath(tempDir))).toBe(true) + lock.release!() + expect(existsSync(ownPath(tempDir))).toBe(false) + }) + + it('coexists with a live foreign dev marker instead of refusing (detection-only)', () => { + const foreignPid = 424242 + const killSpy = mockAlive(foreignPid) + try { + writeMarker(tempDir, { pid: foreignPid, command: 'dev' }) + + const lock = acquireLock(tempDir, { command: 'dev', cwd: '/project' }) + // No enforcement → never refuses; we advertise ourselves alongside the peer. + expect(lock.existing).toBeUndefined() + expect(lock.release).toBeDefined() + expect(JSON.parse(readFileSync(ownPath(tempDir), 'utf-8')).pid).toBe(process.pid) + expect(existsSync(lockPathFor(tempDir, foreignPid))).toBe(true) + } + finally { + killSpy.mockRestore() + } + }) + + it('refuses to start dev alongside a live build even in detection-only mode', () => { + const buildPid = 424242 + const killSpy = mockAlive(buildPid) + try { + writeMarker(tempDir, { pid: buildPid, command: 'build' }) + + const lock = acquireLock(tempDir, { command: 'dev', cwd: '/project' }) + expect(lock.existing).toBeDefined() + expect(lock.existing!.command).toBe('build') + expect(lock.release).toBeUndefined() + // The build marker is left intact; we never wrote our own. + expect(existsSync(lockPathFor(tempDir, buildPid))).toBe(true) + expect(existsSync(ownPath(tempDir))).toBe(false) + } + finally { + killSpy.mockRestore() + } + }) + + it('explicit enforce:false never refuses a peer dev even when enforcement would be on', () => { + process.env.NUXT_LOCK = '1' // would enable enforcement by default + const foreignPid = 424242 + const killSpy = mockAlive(foreignPid) + try { + writeMarker(tempDir, { pid: foreignPid, command: 'dev' }) + + const lock = acquireLock(tempDir, { command: 'dev', cwd: '/project' }, { enforce: false }) + expect(lock.existing).toBeUndefined() + expect(lock.release).toBeDefined() + expect(JSON.parse(readFileSync(ownPath(tempDir), 'utf-8')).pid).toBe(process.pid) + } + finally { + killSpy.mockRestore() + } + }) + + it('writes nothing when NUXT_IGNORE_LOCK is set', () => { + process.env.NUXT_IGNORE_LOCK = '1' + const lock = acquireLock(tempDir, { command: 'dev', cwd: '/project' }) + expect(lock.release).toBeDefined() + expect(existsSync(ownPath(tempDir))).toBe(false) + }) + + describe('readActiveLock', () => { + it('returns the lock for a live process', () => { + const foreignPid = 525252 + const killSpy = mockAlive(foreignPid) + try { + writeMarker(tempDir, { pid: foreignPid, command: 'dev', cwd: '/project' }) + expect(readActiveLock(tempDir)?.pid).toBe(foreignPid) + } + finally { + killSpy.mockRestore() + } + }) + + it('returns undefined for a dead process', () => { + writeMarker(tempDir, { pid: 999999999, command: 'dev', cwd: '/project' }) + expect(readActiveLock(tempDir)).toBeUndefined() + }) + + it('returns undefined when no lock dir exists', () => { + expect(readActiveLock(tempDir)).toBeUndefined() + }) + + it('prefers a build lock over a dev lock', () => { + const devPid = 111111 + const buildPid = 222222 + const killSpy = mockAlive(devPid, buildPid) + try { + writeMarker(tempDir, { pid: devPid, command: 'dev' }) + writeMarker(tempDir, { pid: buildPid, command: 'build' }) + expect(readActiveLock(tempDir)?.command).toBe('build') + } + finally { + killSpy.mockRestore() + } + }) + }) + + describe('readActiveLocks', () => { + it('returns every live peer, newest first', () => { + const olderPid = 111111 + const newerPid = 222222 + const killSpy = mockAlive(olderPid, newerPid) + try { + writeMarker(tempDir, { pid: olderPid, command: 'dev', startedAt: Date.now() - 2000 }) + writeMarker(tempDir, { pid: newerPid, command: 'dev', startedAt: Date.now() - 1000 }) + const locks = readActiveLocks(tempDir) + expect(locks.map(l => l.pid)).toEqual([newerPid, olderPid]) + } + finally { + killSpy.mockRestore() + } + }) + + it('a dead peer never hides a live one (and is pruned)', () => { + const livePid = 333333 + const deadPid = 999999999 + const killSpy = mockAlive(livePid) + try { + writeMarker(tempDir, { pid: livePid, command: 'dev' }) + writeMarker(tempDir, { pid: deadPid, command: 'dev' }) + const locks = readActiveLocks(tempDir) + expect(locks.map(l => l.pid)).toEqual([livePid]) + // Dead marker pruned on read. + expect(existsSync(lockPathFor(tempDir, deadPid))).toBe(false) + } + finally { + killSpy.mockRestore() + } + }) + }) +}) diff --git a/packages/nuxi/test/unit/lockfile.spec.ts b/packages/nuxi/test/unit/lockfile.spec.ts index e5314438..9cda5c19 100644 --- a/packages/nuxi/test/unit/lockfile.spec.ts +++ b/packages/nuxi/test/unit/lockfile.spec.ts @@ -1,5 +1,5 @@ -import { existsSync, readFileSync, writeFileSync } from 'node:fs' -import { mkdir, mkdtemp, rm } from 'node:fs/promises' +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import process from 'node:process' @@ -12,10 +12,17 @@ vi.mock('std-env', async (importOriginal) => { return { ...original, isAgent: true } }) -const { acquireLock, formatLockError, isLockEnabled, updateLock } = await import('../../src/utils/lockfile') +const { acquireLock, buildLockPath, formatLockError, isLockEnabled, lockPathFor, locksDir, updateLock } = await import('../../src/utils/lockfile') + +/** Write a foreign presence marker the way another process would. */ +function writeMarker(buildDir: string, info: Record) { + mkdirSync(locksDir(buildDir), { recursive: true }) + writeFileSync(lockPathFor(buildDir, info.pid as number), JSON.stringify({ command: 'dev', cwd: '/other', startedAt: Date.now(), ...info })) +} describe('lockfile', () => { let tempDir: string + const ownPath = (dir: string) => lockPathFor(dir, process.pid) beforeEach(async () => { tempDir = await mkdtemp(join(tmpdir(), 'nuxt-lockfile-test-')) @@ -50,39 +57,61 @@ describe('lockfile', () => { }) describe('acquireLock', () => { - it('writes lock file and returns release function', () => { + it('writes a per-process marker and returns release function', () => { const lock = acquireLock(tempDir, { command: 'dev', cwd: '/project' }) - const lockPath = join(tempDir, 'nuxt.lock') expect(lock.existing).toBeUndefined() expect(lock.release).toBeDefined() - expect(existsSync(lockPath)).toBe(true) - const written = JSON.parse(readFileSync(lockPath, 'utf-8')) + expect(existsSync(ownPath(tempDir))).toBe(true) + const written = JSON.parse(readFileSync(ownPath(tempDir), 'utf-8')) expect(written.pid).toBe(process.pid) expect(written.command).toBe('dev') expect(written.cwd).toBe('/project') expect(typeof written.startedAt).toBe('number') + expect(typeof written.token).toBe('string') + expect(lock.peers).toEqual([]) lock.release!() - expect(existsSync(lockPath)).toBe(false) + expect(existsSync(ownPath(tempDir))).toBe(false) }) - it('creates the build dir if it does not exist yet', async () => { + it('creates the locks dir if it does not exist yet', async () => { const buildDir = join(tempDir, 'missing', '.nuxt') expect(existsSync(buildDir)).toBe(false) const lock = acquireLock(buildDir, { command: 'dev', cwd: '/project' }) - const lockPath = join(buildDir, 'nuxt.lock') expect(lock.existing).toBeUndefined() - expect(existsSync(lockPath)).toBe(true) + expect(existsSync(ownPath(buildDir))).toBe(true) lock.release!() }) - it('returns existing lock when another live process holds it', async () => { - // Stub process.kill so liveness is deterministic across OSes (Windows - // PID 1 semantics differ). + it('peer dev servers coexist (no clobber)', () => { + const peerPid = 424242 + const killSpy = vi.spyOn(process, 'kill').mockImplementation((pid) => { + if (pid === peerPid) { + return true as unknown as true + } + throw Object.assign(new Error('no such process'), { code: 'ESRCH' }) + }) + try { + writeMarker(tempDir, { pid: peerPid, command: 'dev' }) + + const lock = acquireLock(tempDir, { command: 'dev', cwd: '/project' }, { enforce: false }) + expect(lock.existing).toBeUndefined() + expect(lock.release).toBeDefined() + // Both markers live side by side, and the peer is reported back. + expect(existsSync(lockPathFor(tempDir, peerPid))).toBe(true) + expect(existsSync(ownPath(tempDir))).toBe(true) + expect(lock.peers?.map(p => p.pid)).toContain(peerPid) + } + finally { + killSpy.mockRestore() + } + }) + + it('returns existing lock when another live process holds it (enforced)', () => { const foreignPid = 424242 const killSpy = vi.spyOn(process, 'kill').mockImplementation((pid) => { if (pid === foreignPid) { @@ -91,53 +120,39 @@ describe('lockfile', () => { throw Object.assign(new Error('no such process'), { code: 'ESRCH' }) }) try { - await mkdir(tempDir, { recursive: true }) - writeFileSync(join(tempDir, 'nuxt.lock'), JSON.stringify({ - pid: foreignPid, - command: 'dev', - cwd: '/other', - startedAt: Date.now(), - })) + writeMarker(tempDir, { pid: foreignPid, command: 'dev', cwd: '/other' }) const lock = acquireLock(tempDir, { command: 'build', cwd: '/project' }) expect(lock.existing).toBeDefined() expect(lock.existing!.pid).toBe(foreignPid) expect(lock.existing!.cwd).toBe('/other') expect(lock.release).toBeUndefined() - // File untouched. - expect(existsSync(join(tempDir, 'nuxt.lock'))).toBe(true) + // Foreign marker untouched; we never wrote our own. + expect(existsSync(lockPathFor(tempDir, foreignPid))).toBe(true) + expect(existsSync(ownPath(tempDir))).toBe(false) } finally { killSpy.mockRestore() } }) - it('takes over a lock whose PID is dead', async () => { - writeFileSync(join(tempDir, 'nuxt.lock'), JSON.stringify({ - pid: 999999999, - command: 'dev', - cwd: '/other', - startedAt: Date.now(), - })) + it('prunes and takes over a marker whose PID is dead', () => { + const deadPid = 999999999 + writeMarker(tempDir, { pid: deadPid, command: 'dev' }) const lock = acquireLock(tempDir, { command: 'build', cwd: '/project' }) expect(lock.existing).toBeUndefined() expect(lock.release).toBeDefined() - const written = JSON.parse(readFileSync(join(tempDir, 'nuxt.lock'), 'utf-8')) - expect(written.pid).toBe(process.pid) + // Dead marker pruned, our build sentinel claimed. + expect(existsSync(lockPathFor(tempDir, deadPid))).toBe(false) + expect(JSON.parse(readFileSync(buildLockPath(tempDir), 'utf-8')).pid).toBe(process.pid) lock.release!() }) - it('takes over a lock older than the PID-recycling safety window', () => { - // Mock the PID as alive; age-based override should still kick in. + it('takes over a marker older than the PID-recycling safety window', () => { const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true as unknown as true) try { - writeFileSync(join(tempDir, 'nuxt.lock'), JSON.stringify({ - pid: 424242, - command: 'dev', - cwd: '/other', - startedAt: Date.now() - 25 * 60 * 60 * 1000, - })) + writeMarker(tempDir, { pid: 424242, command: 'dev', startedAt: Date.now() - 25 * 60 * 60 * 1000 }) const lock = acquireLock(tempDir, { command: 'build', cwd: '/project' }) expect(lock.existing).toBeUndefined() @@ -149,13 +164,8 @@ describe('lockfile', () => { } }) - it('takes over a lock owned by this process (re-entrancy)', () => { - writeFileSync(join(tempDir, 'nuxt.lock'), JSON.stringify({ - pid: process.pid, - command: 'dev', - cwd: '/project', - startedAt: Date.now(), - })) + it('ignores a marker owned by this process when scanning (re-entrancy)', () => { + writeMarker(tempDir, { pid: process.pid, command: 'dev', cwd: '/project' }) const lock = acquireLock(tempDir, { command: 'build', cwd: '/project' }) expect(lock.existing).toBeUndefined() @@ -163,14 +173,14 @@ describe('lockfile', () => { lock.release!() }) - it('cleans up corrupted lock files', () => { - writeFileSync(join(tempDir, 'nuxt.lock'), 'not valid json') + it('acquires despite a corrupted sibling marker', () => { + mkdirSync(locksDir(tempDir), { recursive: true }) + writeFileSync(join(locksDir(tempDir), '12345.json'), 'not valid json') const lock = acquireLock(tempDir, { command: 'dev', cwd: '/project' }) expect(lock.existing).toBeUndefined() expect(lock.release).toBeDefined() - const written = JSON.parse(readFileSync(join(tempDir, 'nuxt.lock'), 'utf-8')) - expect(written.pid).toBe(process.pid) + expect(JSON.parse(readFileSync(ownPath(tempDir), 'utf-8')).pid).toBe(process.pid) lock.release!() }) @@ -180,17 +190,64 @@ describe('lockfile', () => { lock.release!() // should not throw }) - it('release does not remove another process\'s lock', () => { + it('release does not remove a marker no longer carrying our token', () => { + const lock = acquireLock(tempDir, { command: 'dev', cwd: '/project' }) + // Simulate the file being replaced (e.g. recycled PID) so our token no longer matches. + writeFileSync(ownPath(tempDir), JSON.stringify({ pid: process.pid, command: 'dev', cwd: '/other', startedAt: Date.now(), token: 'someone-else' })) + lock.release!() + expect(existsSync(ownPath(tempDir))).toBe(true) + }) + + it('a build claims the shared sentinel, not a per-process marker', () => { const lock = acquireLock(tempDir, { command: 'build', cwd: '/project' }) - // Simulate another process replacing the file. - writeFileSync(join(tempDir, 'nuxt.lock'), JSON.stringify({ - pid: 1, - command: 'dev', - cwd: '/other', - startedAt: Date.now(), - })) + expect(lock.existing).toBeUndefined() + expect(existsSync(buildLockPath(tempDir))).toBe(true) + expect(existsSync(ownPath(tempDir))).toBe(false) + expect(JSON.parse(readFileSync(buildLockPath(tempDir), 'utf-8')).pid).toBe(process.pid) lock.release!() - expect(existsSync(join(tempDir, 'nuxt.lock'))).toBe(true) + expect(existsSync(buildLockPath(tempDir))).toBe(false) + }) + + it('a second build refuses a live sentinel', () => { + const foreignPid = 424242 + const killSpy = vi.spyOn(process, 'kill').mockImplementation((pid) => { + if (pid === foreignPid) { + return true as unknown as true + } + throw Object.assign(new Error('no such process'), { code: 'ESRCH' }) + }) + try { + mkdirSync(locksDir(tempDir), { recursive: true }) + writeFileSync(buildLockPath(tempDir), JSON.stringify({ pid: foreignPid, command: 'build', cwd: '/other', startedAt: Date.now() })) + + const lock = acquireLock(tempDir, { command: 'build', cwd: '/project' }) + expect(lock.existing?.pid).toBe(foreignPid) + expect(lock.release).toBeUndefined() + expect(JSON.parse(readFileSync(buildLockPath(tempDir), 'utf-8')).pid).toBe(foreignPid) + } + finally { + killSpy.mockRestore() + } + }) + + it('a build takes over a stale sentinel', () => { + mkdirSync(locksDir(tempDir), { recursive: true }) + writeFileSync(buildLockPath(tempDir), JSON.stringify({ pid: 999999999, command: 'build', cwd: '/other', startedAt: Date.now() })) + + const lock = acquireLock(tempDir, { command: 'build', cwd: '/project' }) + expect(lock.existing).toBeUndefined() + expect(lock.release).toBeDefined() + expect(JSON.parse(readFileSync(buildLockPath(tempDir), 'utf-8')).pid).toBe(process.pid) + lock.release!() + }) + + it('a stale release does not delete a newer same-process re-acquire (reload safety)', () => { + const first = acquireLock(tempDir, { command: 'dev', cwd: '/project' }, { enforce: false }) + const second = acquireLock(tempDir, { command: 'dev', cwd: '/project' }, { enforce: false }) + first.release!() + expect(existsSync(ownPath(tempDir))).toBe(true) + second.release!() + expect(existsSync(ownPath(tempDir))).toBe(false) }) it('is a no-op when locking is disabled', () => { @@ -198,7 +255,7 @@ describe('lockfile', () => { const lock = acquireLock(tempDir, { command: 'dev', cwd: '/project' }) expect(lock.existing).toBeUndefined() expect(lock.release).toBeDefined() - expect(existsSync(join(tempDir, 'nuxt.lock'))).toBe(false) + expect(existsSync(ownPath(tempDir))).toBe(false) lock.release!() }) @@ -212,9 +269,9 @@ describe('lockfile', () => { }) describe('updateLock', () => { - it('overwrites our own lock with new metadata', () => { + it('overwrites our own marker with new metadata', () => { const lock = acquireLock(tempDir, { command: 'dev', cwd: '/project' }) - const originalStart = JSON.parse(readFileSync(join(tempDir, 'nuxt.lock'), 'utf-8')).startedAt + const originalStart = JSON.parse(readFileSync(ownPath(tempDir), 'utf-8')).startedAt updateLock(tempDir, { command: 'dev', @@ -224,7 +281,7 @@ describe('lockfile', () => { url: 'http://127.0.0.1:3000', }) - const written = JSON.parse(readFileSync(join(tempDir, 'nuxt.lock'), 'utf-8')) + const written = JSON.parse(readFileSync(ownPath(tempDir), 'utf-8')) expect(written.port).toBe(3000) expect(written.url).toBe('http://127.0.0.1:3000') // Preserves startedAt from original acquisition. @@ -232,21 +289,14 @@ describe('lockfile', () => { lock.release!() }) - it('does not overwrite another process\'s lock', () => { - writeFileSync(join(tempDir, 'nuxt.lock'), JSON.stringify({ - pid: 1, - command: 'dev', - cwd: '/other', - startedAt: Date.now(), - })) + it('does not adopt a foreign marker left at our path by a recycled PID', () => { + // A previous process with our PID could leave a marker at our path. + mkdirSync(locksDir(tempDir), { recursive: true }) + writeFileSync(ownPath(tempDir), JSON.stringify({ pid: 1, command: 'dev', cwd: '/other', startedAt: Date.now() })) - updateLock(tempDir, { - command: 'dev', - cwd: '/project', - port: 3000, - }) + updateLock(tempDir, { command: 'dev', cwd: '/project', port: 3000 }) - const written = JSON.parse(readFileSync(join(tempDir, 'nuxt.lock'), 'utf-8')) + const written = JSON.parse(readFileSync(ownPath(tempDir), 'utf-8')) expect(written.pid).toBe(1) expect(written.port).toBeUndefined() }) @@ -254,7 +304,7 @@ describe('lockfile', () => { it('is a no-op when locking is disabled', () => { process.env.NUXT_IGNORE_LOCK = '1' updateLock(tempDir, { command: 'dev', cwd: '/project' }) - expect(existsSync(join(tempDir, 'nuxt.lock'))).toBe(false) + expect(existsSync(ownPath(tempDir))).toBe(false) }) }) diff --git a/packages/nuxi/test/unit/typecheck-prepare.spec.ts b/packages/nuxi/test/unit/typecheck-prepare.spec.ts new file mode 100644 index 00000000..3897039c --- /dev/null +++ b/packages/nuxi/test/unit/typecheck-prepare.spec.ts @@ -0,0 +1,102 @@ +import { mkdirSync, writeFileSync } from 'node:fs' +import { 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 { resolvePrepareDecision } = await import('../../src/commands/typecheck') +const { lockPathFor, locksDir } = await import('../../src/utils/lockfile') + +function writeLock(dir: string, info: Record) { + mkdirSync(locksDir(dir), { recursive: true }) + writeFileSync(lockPathFor(dir, info.pid as number), JSON.stringify({ cwd: '/project', startedAt: Date.now(), ...info })) +} + +describe('resolvePrepareDecision', () => { + let buildDir: string + let killSpy: ReturnType | undefined + + beforeEach(async () => { + buildDir = await mkdtemp(join(tmpdir(), 'nuxt-typecheck-prepare-')) + delete process.env.NUXT_IGNORE_LOCK + delete process.env.NUXT_LOCK + }) + + afterEach(async () => { + killSpy?.mockRestore() + killSpy = undefined + await rm(buildDir, { recursive: true, force: true }) + }) + + function mockAlive(pid: number) { + killSpy = vi.spyOn(process, 'kill').mockImplementation((p) => { + if (p === pid) { + return true as unknown as true + } + throw Object.assign(new Error('no such process'), { code: 'ESRCH' }) + }) + } + + it('prepares when no lock exists', () => { + expect(resolvePrepareDecision(buildDir, {})).toEqual({ prepare: true }) + }) + + it('skips prepare when a live dev server owns the buildDir, types are ready, and tsconfig exists', () => { + mockAlive(424242) + writeLock(buildDir, { pid: 424242, command: 'dev', typesReady: true }) + writeFileSync(join(buildDir, 'tsconfig.json'), '{}') + expect(resolvePrepareDecision(buildDir, {})).toEqual({ prepare: false, reusingDevPid: 424242 }) + }) + + it('prepares when the dev lock has not signalled typesReady (mid-rebuild / stale)', () => { + mockAlive(424242) + writeLock(buildDir, { pid: 424242, command: 'dev' }) // no typesReady + writeFileSync(join(buildDir, 'tsconfig.json'), '{}') + expect(resolvePrepareDecision(buildDir, {})).toEqual({ prepare: true }) + }) + + it('prepares when typesReady is explicitly false', () => { + mockAlive(424242) + writeLock(buildDir, { pid: 424242, command: 'dev', typesReady: false }) + writeFileSync(join(buildDir, 'tsconfig.json'), '{}') + expect(resolvePrepareDecision(buildDir, {})).toEqual({ prepare: true }) + }) + + it('prepares when a live dev lock exists but types are not yet written', () => { + mockAlive(424242) + writeLock(buildDir, { pid: 424242, command: 'dev', typesReady: true }) + expect(resolvePrepareDecision(buildDir, {})).toEqual({ prepare: true }) + }) + + it('prepares for a build lock (only dev servers keep types fresh)', () => { + mockAlive(424242) + writeLock(buildDir, { pid: 424242, command: 'build' }) + writeFileSync(join(buildDir, 'tsconfig.json'), '{}') + expect(resolvePrepareDecision(buildDir, {})).toEqual({ prepare: true }) + }) + + it('prepares when the lock belongs to a dead process', () => { + writeLock(buildDir, { pid: 999999999, command: 'dev' }) + writeFileSync(join(buildDir, 'tsconfig.json'), '{}') + expect(resolvePrepareDecision(buildDir, {})).toEqual({ prepare: true }) + }) + + it('honours --prepare even with a live dev server', () => { + mockAlive(424242) + writeLock(buildDir, { pid: 424242, command: 'dev' }) + writeFileSync(join(buildDir, 'tsconfig.json'), '{}') + expect(resolvePrepareDecision(buildDir, { prepare: true })).toEqual({ prepare: true }) + }) + + it('honours --no-prepare even with no lock', () => { + expect(resolvePrepareDecision(buildDir, { prepare: false })).toEqual({ prepare: false }) + }) + + it('forces a prepare when --extends is passed (even with reusable types)', () => { + mockAlive(424242) + writeLock(buildDir, { pid: 424242, command: 'dev', typesReady: true }) + writeFileSync(join(buildDir, 'tsconfig.json'), '{}') + expect(resolvePrepareDecision(buildDir, { extends: '../base' })).toEqual({ prepare: true }) + }) +})