next.js/packages/next/src/build/lockfile.ts
lockfile.ts267 lines9.1 KB
import fs from 'fs'
import nodePath from 'path'
import { bold, cyan } from '../lib/picocolors'
import * as Log from './output/log'
import { getBindingsSync } from './swc'

import type { Binding, Lockfile as NativeLockfile } from './swc/types'

const RETRY_DELAY_MS = 10
const MAX_RETRY_MS = 1000

/**
 * Information about a running dev server, stored inside the lock file itself.
 * This is a common pattern in Unix applications (e.g., storing PID in a lockfile).
 */
export interface DevServerInfo {
  pid: number
  port: number
  hostname: string
  appUrl: string
  startedAt: number
}

/**
 * Reads dev server info from a lockfile.
 * Returns undefined if the file doesn't exist or can't be parsed.
 *
 * Uses Node's fs.readFileSync which works on both Unix (with advisory flock)
 * and Windows (with FILE_SHARE_READ flag set by the lock holder).
 */
export function readLockfileContent(lockfilePath: string): string | undefined {
  try {
    return fs.readFileSync(lockfilePath, 'utf-8')
  } catch {
    return undefined
  }
}

/**
 * Parses dev server info from lockfile content.
 */
export function parseDevServerInfo(content: string): DevServerInfo | undefined {
  try {
    return JSON.parse(content) as DevServerInfo
  } catch {
    return undefined
  }
}

/**
 * A cross-platform on-disk best-effort advisory exclusive lockfile
 * implementation.
 *
 * On Windows, this opens a file in write mode with the `FILE_SHARE_WRITE` flag
 * unset, so it still allows reading the lockfile. This avoids breaking tools
 * that read the contents of `.next`.
 *
 * On POSIX platforms, this uses `flock()` via `std::fs::File::try_lock`:
 * https://doc.rust-lang.org/std/fs/struct.File.html#method.try_lock
 *
 * On WASM, a dummy implementation is used which always "succeeds" in acquiring
 * the lock.
 *
 * This provides a more idiomatic wrapper around the lockfile APIs exposed on
 * the native bindings object.
 *
 * If this lock is not explicitly closed with `unlock`, we will:
 * - If `unlockOnExit` is set (the default), it will make a best-effort attempt
 *   to unlock the lockfile using `process.on('exit', ...)`. This is preferrable
 *   on Windows where it may take some time after process exit for the operating
 *   system to clean up locks that are not explicitly released by the process.
 * - If we fail to ever release the lockfile, the operating system will clean up
 *   the lock and file descriptor upon process exit.
 */
export class Lockfile {
  /**
   * The underlying `Lockfile` object returned by the native bindings.
   *
   * This can be `undefined` on wasm, where we don't acquire a real lockfile.
   */
  private bindings: Binding
  private nativeLockfile: NativeLockfile | undefined
  private listener: NodeJS.ExitListener | undefined

  private constructor(
    bindings: Binding,
    nativeLockfile: NativeLockfile | undefined
  ) {
    this.bindings = bindings
    this.nativeLockfile = nativeLockfile
  }

  /**
   * Attempts to create or acquire an exclusive lockfile on disk. Lockfiles are
   * best-effort, depending on the platform.
   *
   * - If we fail to acquire the lock, we return `undefined`.
   * - If we're on wasm, this always returns a dummy `Lockfile` object.
   *
   * @param path - Path to the lock file
   * @param unlockOnExit - Whether to unlock the file on process exit
   * @param content - Optional content to write to the lockfile (e.g., JSON with server info)
   */
  static tryAcquire(
    path: string,
    unlockOnExit: boolean = true,
    content?: string
  ): Lockfile | undefined {
    const bindings = getBindingsSync()
    if (bindings.isWasm) {
      Log.info(
        `Skipping creating a lockfile at ${cyan(path)} because we're using WASM bindings`
      )
      return new Lockfile(bindings, undefined)
    } else {
      let nativeLockfile
      try {
        nativeLockfile = bindings.lockfileTryAcquireSync(path, content)
      } catch (e) {
        // this happens if there's an IO error (e.g. `ENOENT`), which is
        // different than if we just didn't acquire the lock
        throw new Error(
          'An IO error occurred while attempting to create and acquire the lockfile',
          { cause: e }
        )
      }
      if (nativeLockfile != null) {
        const jsLockfile = new Lockfile(bindings, nativeLockfile)
        if (unlockOnExit) {
          const exitListener = () => {
            // Best-Effort: If we don't do this, the operating system will
            // release the lock for us. This gives an opportunity to delete the
            // unlocked lockfile (which is not otherwise deleted on POSIX).
            //
            // This must be synchronous because `process.on('exit', ...)` is
            // synchronous.
            jsLockfile.unlockSync()
          }
          process.on('exit', exitListener)
          jsLockfile.listener = exitListener
        }
        return jsLockfile
      } else {
        return undefined
      }
    }
  }

  /**
   * Attempts to create or acquire a lockfile using `Lockfile.tryAcquire`. If
   * that returns `undefined`, indicating that another process or caller has the
   * lockfile, then this will output an error message and exit the process with
   * a non-zero exit code.
   *
   * This will retry a small number of times. This can be useful when running
   * processes in a loop, e.g. if cleanup isn't fully synchronous due to child
   * parent/processes.
   *
   * @param path - Path to the lock file
   * @param processName - Name of the process for error messages (e.g., 'next dev')
   * @param unlockOnExit - Whether to unlock the file on process exit
   * @param content - Optional content to write to the lockfile (e.g., JSON with server info)
   * @param projectDir - Optional project directory for enhanced error messages
   * @param relativeDistDir - Optional relative dist directory path (e.g., '.next/dev')
   */
  static async acquireWithRetriesOrExit(
    path: string,
    processName: string,
    unlockOnExit: boolean = true,
    content?: string,
    projectDir?: string,
    relativeDistDir?: string
  ): Promise<Lockfile> {
    const startMs = Date.now()
    let lockfile
    while (Date.now() - startMs < MAX_RETRY_MS) {
      lockfile = Lockfile.tryAcquire(path, unlockOnExit, content)
      if (lockfile !== undefined) break
      await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS))
    }
    if (lockfile === undefined) {
      const isDev = processName === 'next dev'

      if (isDev) {
        // For dev server, try to read server info from the lockfile itself
        const lockfileContent = readLockfileContent(path)
        const serverInfo = lockfileContent
          ? parseDevServerInfo(lockfileContent)
          : undefined

        if (serverInfo) {
          Log.error(`Another ${cyan(processName)} server is already running.`)
          console.error()
          console.error(`- Local:        ${cyan(serverInfo.appUrl)}`)
          console.error(`- PID:          ${serverInfo.pid}`)
          if (projectDir) {
            console.error(`- Dir:          ${projectDir}`)
          }
          if (relativeDistDir) {
            console.error(
              `- Log:          ${nodePath.join(relativeDistDir, 'logs', 'next-development.log')}`
            )
          }
          console.error()
          console.error(
            `You can access the existing server at ${cyan(serverInfo.appUrl)},`
          )
          // Use platform-appropriate kill command
          const killCommand =
            process.platform === 'win32'
              ? `taskkill /PID ${serverInfo.pid} /F`
              : `kill ${serverInfo.pid}`
          console.error(
            `or run ${cyan(killCommand)} to stop it and start a new one.`
          )
        } else {
          // Fallback when we can't read server info from the lockfile
          Log.error(
            `Another ${cyan(processName)} server is already running in this directory.`
          )
          console.error(`Stop the other server before starting a new one.`)
        }
      } else {
        // For build, show that a build is in progress
        Log.error(`Another ${cyan(processName)} process is already running.`)
        console.error()
        console.error(`  This could be:`)
        console.error(`  - A ${cyan('next build')} still in progress`)
        console.error(`  - A previous build that didn't exit cleanly`)
        console.error()
        Log.info(`${bold('Suggestion:')} Wait for the build to complete.`)
      }
      process.exit(1)
    }
    return lockfile
  }

  /**
   * Releases the lockfile and closes the file descriptor.
   *
   * If this is not called, the lock will be released by the operating system
   * when the file handle is closed during process exit.
   */
  async unlock(): Promise<void> {
    const { nativeLockfile, listener } = this
    if (nativeLockfile !== undefined) {
      await this.bindings.lockfileUnlock(nativeLockfile)
    }
    if (listener !== undefined) {
      process.off('exit', listener)
    }
  }

  /**
   * A blocking version of `unlock`.
   */
  unlockSync(): void {
    const { nativeLockfile, listener } = this
    if (nativeLockfile !== undefined) {
      this.bindings.lockfileUnlockSync(nativeLockfile)
    }
    if (listener !== undefined) {
      process.off('exit', listener)
    }
  }
}
Quest for Codev2.0.0
/
SIGN IN