Skip to main content
jg-packages

Search

    Locks

    jg-locks

    Machine-wide named locks for Node.js scripts, agents, and apps: waits, a status of every holder, explicit recovery, and a CLI.

    v1.0.0Node.jsESMMIT

    npm

    npm install jg-locks

    pnpm

    pnpm add jg-locks

    Usage

    Hold a lock while a job runs, such as one that uses a port or a browser profile only one process can use at a time:

    ts
    import { withLock } from 'jg-locks';
    
    await withLock('acme.app.browser-profile', { waitSeconds: 60 }, async () => {
      // only one process on this machine runs this at a time
    });

    Every program on the machine shares one locks folder, so a lock taken by a script in one repository also keeps an agent in another checkout, or an app, from taking it. Name a project’s locks <owner>.<repository>.<resource>, such as acme.app.browser-profile, and a machine resource plainly, such as port-3101. A name is 1 to 128 lowercase letters, digits, ., _, and -, starting with a letter or a digit.

    acquireLock takes a lock and returns it, for code that releases it somewhere else, or keeps it after a teardown it couldn’t confirm:

    ts
    import { acquireLock } from 'jg-locks';
    
    const lock = await acquireLock('port-3101', {
      waitSeconds: Infinity,
      signal: AbortSignal.timeout(300_000),
      onWait: ({ owner }) => console.log(`Waiting for ${owner?.program} (pid ${owner?.pid})`),
      metadata: { runId: 'run-42' },
    });
    // ... start the server on port 3101
    await lock.release();

    Without waitSeconds, it fails at once with jg-fs’s LOCK_BUSY while another process holds the lock. With it, it checks again every 250 ms, and calls onWait once for each new holder; Infinity waits until signal aborts, which rejects with the signal’s reason. A lock whose owner is still writing its record is waited on for up to 16 seconds. It fails at once, whatever the wait, when the holder’s process is gone, runs on another host, or left unexpected files, because waiting can’t change them: LOCK_BUSY’s data.reason is owner-gone, other-host, ownerless, recovery-in-progress, or unexpected-entries.

    A lock is a jg-fs lock folder with an owner.json that names the holder: its pid, host, nonce, program, folder, and start time, and the metadata you pass. The program is the entry script or executable without its arguments, which can hold secrets. A lock isn’t re-entrant: a process that waits for a lock it already holds waits for itself.

    See who holds what, without taking anything:

    ts
    import { inspectLock, listLocks } from 'jg-locks';
    
    const { state, owner, isOwnerAlive } = await inspectLock('port-3101'); // state: 'free', 'held', ...
    const { locks, isTruncated } = await listLocks(); // every present lock, sorted by name, at most 256

    A lock whose holder died stays busy, because only you know whether the holder left work half done. Check what it left, then take the lock over from exactly that holder with recoverFrom, which never takes it from another one:

    ts
    import { acquireLock, inspectLock } from 'jg-locks';
    
    const { state, owner } = await inspectLock('acme.app.fixture');
    if (state === 'owner-gone' && owner !== null) {
      // check what the dead holder left, then:
      const lock = await acquireLock('acme.app.fixture', { recoverFrom: owner });
      await lock.release();
    }

    The locks folder

    getLocksRoot() gives the folder: JG_LOCKS_ROOT when it’s set, which must be an absolute path, otherwise $XDG_STATE_HOME/jg-locks, or ~/.local/state/jg-locks, on Linux and macOS, and %LOCALAPPDATA%\jg-locks on Windows. It’s created with mode 0700 the first time a lock is taken, and an existing folder other users can open is refused with jg-fs’s DIRECTORY_NOT_PRIVATE. Every function also takes a root, such as a temporary folder in tests. Use it on local filesystems only.

    The CLI

    bash
    jg-locks run port-5173 -- pnpm dev            # exits 75 at once when the lock is busy
    jg-locks run acme.app.e2e --wait 600 -- pnpm test:e2e
    jg-locks status                               # every lock, its holder, and its nonce
    jg-locks status --json                        # one line with schemaVersion 1
    jg-locks recover acme.app.fixture --nonce <nonce>

    run holds the lock while the command runs, without a shell, forwards SIGINT and SIGTERM to it, releases the lock once it exits, and exits with its code, or 128 plus the signal’s number when a signal ended it. --wait <seconds> or --wait forever waits for the lock first. A command that isn’t found exits 127. On Windows, run a .cmd script through cmd /c. recover takes the lock over from the dead holder with that nonce, as status shows it, and releases it, or exits 1 when the lock isn’t that dead holder’s. Usage errors exit 2.

    Errors

    Code When
    LOCK_NAME_INVALID A lock name doesn’t follow the naming rule
    LOCK_ROOT_INVALID root, JG_LOCKS_ROOT, %LOCALAPPDATA% on Windows, or the home folder isn’t an absolute path
    LOCK_OPTIONS_INVALID waitSeconds is negative or not a number

    jg-fs’s lock errors pass through unchanged: LOCK_BUSY, LOCK_ACQUISITION_FAILED, LOCK_NOT_OWNED, LOCK_RELEASE_FAILED, NOT_A_DIRECTORY, DIRECTORY_NOT_PRIVATE, and PATH_NOT_CANONICAL.

    Generated from TSDoc

    API reference

    Every export of [email protected], 14 in all, with its signature, its errors, and an example.

    Functions

    acquireLock

    function

    Acquires a named lock that only one process on this machine holds at a time, such as one that guards a port, a browser profile, or a script that mustn't run twice. The lock is a jg-fs lock folder in the locks folder, with an owner.json that names the process: its pid, host, nonce, program, folder, start time, and the caller's metadata, never its arguments, which can hold secrets.

    With waitSeconds, it waits while a live process holds the lock, checking every 250 ms, and calls onWait once for each new holder; Infinity waits until signal aborts. A lock without a readable owner.json is waited on for up to 16 seconds, because a new owner is still writing it. It fails at once, with jg-fs's LOCK_BUSY and its data.reason, for an owner whose process is gone, one on another host, a takeover in progress, or unexpected files, because waiting can't change them, and with the last reason when the wait runs out. It never takes a lock over by itself: pass the dead owner inspectLock gives as recoverFrom once what it left is checked. An aborted signal rejects with its reason. A lock isn't re-entrant: a process that waits for a lock it already holds waits for itself.

    Signature
    acquireLock(name: string, options?: IAcquireLockOptions): Promise<ILock>

    Parameters

    namestring

    The lock's name, such as acme.app.port-3000.

    options?IAcquireLockOptions

    The wait, the owner's metadata, the dead owner to take over from, and the folder.

    Defaults to {}.

    Returns

    Promise<ILock>

    A promise that resolves to the lock, released with release().

    Throws

    • LOCK_NAME_INVALIDThe lock name must be 1 to 128 lowercase letters, digits, '.', '_' and '-', start with a letter or a digit, not end with '.', and not be a Windows device name.
    • LOCK_OPTIONS_INVALIDThe lock's waitSeconds must be 0 or more, or Infinity.
    • LOCK_ROOT_INVALIDThe locks folder must be an absolute path.
    • LOCK_BUSYThe lock '<path>' is busy.
    • LOCK_ACQUISITION_FAILEDThe lock '<path>' could not be acquired.
    • NOT_A_DIRECTORYThe path '<path>' is not a directory.
    • DIRECTORY_NOT_PRIVATEThe directory '<path>' can be accessed by other users.
    • PATH_NOT_CANONICALThe path '<path>' goes through a symbolic link.
    Example
    import { acquireLock } from 'jg-locks';
    
    const lock = await acquireLock('acme.app.browser-profile', {
      waitSeconds: 60,
      onWait: ({ owner }) => console.log(`Waiting for pid ${owner?.pid} to finish`),
    });
    try {
      // use the browser profile
    } finally {
      await lock.release();
    }

    getLocksRoot

    function

    Resolves the folder that holds every lock on this machine: the JG_LOCKS_ROOT environment variable when it's set, which must be an absolute path, otherwise jg-locks in the operating system's state folder: $XDG_STATE_HOME/jg-locks, or ~/.local/state/jg-locks, on Linux and macOS, and %LOCALAPPDATA%\jg-locks on Windows. Nothing is created until a lock is taken.

    Signature
    getLocksRoot(): string

    Returns

    string

    The folder's absolute path.

    Throws

    • LOCK_ROOT_INVALIDThe locks folder must be an absolute path.
    Example
    import { getLocksRoot } from 'jg-locks';
    
    getLocksRoot(); // such as '/home/ada/.local/state/jg-locks'

    inspectLock

    function

    Reads a lock's state without taking it: free, held by a live owner on this host, or why it can't be taken, with its owner as owner.json names it.

    Signature
    inspectLock(name: string, options?: ILocksRootOptions): Promise<ILockState>

    Parameters

    namestring

    The lock's name.

    options?ILocksRootOptions

    The locks folder.

    Defaults to {}.

    Returns

    Promise<ILockState>

    A promise that resolves to the lock's state.

    Throws

    • LOCK_NAME_INVALIDThe lock name must be 1 to 128 lowercase letters, digits, '.', '_' and '-', start with a letter or a digit, not end with '.', and not be a Windows device name.
    • LOCK_ROOT_INVALIDThe locks folder must be an absolute path.
    • NOT_A_DIRECTORYThe path '<path>' is not a directory.
    Example
    import { inspectLock } from 'jg-locks';
    
    const { state, owner } = await inspectLock('acme.app.port-3000');
    if (state === 'held') {
      console.log(`${owner?.program} (pid ${owner?.pid}) holds the port`);
    }

    listLocks

    function

    Lists the locks present in the locks folder, with their states and owners, sorted by name. A folder that doesn't exist yet holds none.

    Signature
    listLocks(options?: ILocksRootOptions): Promise<ILockList>

    Parameters

    options?ILocksRootOptions

    The locks folder.

    Defaults to {}.

    Returns

    Promise<ILockList>

    A promise that resolves to at most 256 locks, and whether there were more.

    Throws

    • LOCK_ROOT_INVALIDThe locks folder must be an absolute path.
    Example
    import { listLocks } from 'jg-locks';
    
    const { locks } = await listLocks();
    for (const { name, state, owner } of locks) {
      console.log(`${name}: ${state}, pid ${owner?.pid ?? 'unknown'}`);
    }

    withLock

    function

    Runs a function while holding a lock, and releases the lock when it settles. When the function throws and the release fails too, the release's error is thrown, because the lock stays held. Code that must keep a lock after an uncertain teardown uses acquireLock and doesn't release it.

    Signature
    withLock<T>(
      name: string,
      options: IAcquireLockOptions,
      fn: (lock: ILock) => T | Promise<T>,
    ): Promise<T>

    Parameters

    namestring

    The lock's name.

    optionsIAcquireLockOptions

    What acquireLock takes.

    fn(lock: ILock) => T | Promise<T>

    The function, given the lock.

    Returns

    Promise<T>

    A promise that resolves to what the function returns.

    Throws

    • LOCK_NAME_INVALIDThe lock name must be 1 to 128 lowercase letters, digits, '.', '_' and '-', start with a letter or a digit, not end with '.', and not be a Windows device name.
    • LOCK_OPTIONS_INVALIDThe lock's waitSeconds must be 0 or more, or Infinity.
    • LOCK_ROOT_INVALIDThe locks folder must be an absolute path.
    • LOCK_BUSYThe lock '<path>' is busy.
    • LOCK_ACQUISITION_FAILEDThe lock '<path>' could not be acquired.
    • LOCK_NOT_OWNEDThe lock '<path>' is no longer owned by this process.
    • LOCK_RELEASE_FAILEDThe lock '<path>' could not be released.
    • NOT_A_DIRECTORYThe path '<path>' is not a directory.
    • DIRECTORY_NOT_PRIVATEThe directory '<path>' can be accessed by other users.
    • PATH_NOT_CANONICALThe path '<path>' goes through a symbolic link.
    Example
    import { withLock } from 'jg-locks';
    
    const report = await withLock('acme.app.nightly-report', { waitSeconds: 30 }, async () => {
      return 'written';
    }); // 'written'

    Constants

    ERRORS

    const

    the codes of the errors jg-locks throws itself; jg-fs's lock errors, such as LOCK_BUSY, pass through

    Declaration
    const ERRORS: { LOCK_NAME_INVALID: "LOCK_NAME_INVALID"; LOCK_ROOT_INVALID: "LOCK_ROOT_INVALID"; LOCK_OPTIONS_INVALID: "LOCK_OPTIONS_INVALID" }

    Types

    IAcquireLockOptions

    type

    what acquireLock and withLock take

    Declaration
    type IAcquireLockOptions = ILocksRootOptions & {
        waitSeconds?: number | undefined;
        signal?: AbortSignal | undefined;
        onWait?: ((state: ILockState) => void) | undefined;
        metadata?: Record<string, string> | undefined;
        recoverFrom?: ILockOwner | undefined;
    };

    ILock

    type

    a lock the caller holds until it's released

    Declaration
    type ILock = {
        name: string;
        owner: ILockOwner;
        release: () => Promise<void>;
    };

    Members

    • nameproperty
      string

      the lock's name

    • ownerproperty
      ILockOwner

      the caller, as owner.json names it

    • releaseproperty
      () => Promise<void>

      releases the lock; calling it again after it succeeded does nothing

    ILockBusyReason

    type

    why a lock that exists can't be taken: every state but free

    Declaration
    type ILockBusyReason = Exclude<ILockStateKind, 'free'>;

    ILockList

    type

    the locks listLocks found

    Declaration
    type ILockList = {
        locks: ILockState[];
        isTruncated: boolean;
    };

    Members

    • locksproperty
      ILockState[]

      the present locks, sorted by name

    • isTruncatedproperty
      boolean

      whether there were more locks than listLocks returns

    ILockOwner

    type

    the process that holds a lock, as its owner.json names it

    Declaration
    type ILockOwner = {
        pid: number;
        nonce: string;
        hostname: string;
        program: string | null;
        cwd: string | null;
        startedAt: string | null;
        metadata: Record<string, string>;
    };

    Members

    • pidproperty
      number

      the process id

    • nonceproperty
      string

      a random id that tells this acquisition apart from any other by the same process id

    • hostnameproperty
      string

      the host the process runs on

    • programproperty
      string | null

      the entry script or executable that took the lock, without its arguments, which can hold secrets; null when a tool other than jg-locks took it

    • cwdproperty
      string | null

      the folder the process ran in when it took the lock, or null

    • startedAtproperty
      string | null

      when the lock was taken, as an ISO 8601 date, or null

    • metadataproperty
      Record<string, string>

      what the caller wrote about itself, such as a run id

    ILocksRootOptions

    type

    where the locks are, for every function that reads or takes one

    Declaration
    type ILocksRootOptions = {
        root?: string | undefined;
    };

    Members

    • rootproperty
      string

      the absolute path of the locks folder. Defaults to getLocksRoot()

    ILockState

    type

    a lock and its holder, as inspectLock and listLocks read them

    Declaration
    type ILockState = {
        name: string;
        state: ILockStateKind;
        owner: ILockOwner | null;
        isOwnerAlive: boolean | null;
    };

    Members

    • nameproperty
      string

      the lock's name

    • stateproperty
      ILockStateKind

      whether it's free, held, or why it can't be taken

    • ownerproperty
      ILockOwner | null

      its owner, or null when it's free or owner.json can't be read

    • isOwnerAliveproperty
      boolean | null

      whether the owner's process is running, or null when there's no owner or it runs on another host

    ILockStateKind

    type

    What a lock is, as inspectLock reads it: - free: no process holds it - held: its owner's process is alive on this host - owner-gone: its owner's process is gone, as after a crash; jg-locks recover or recoverFrom takes it over once what the owner left is checked - ownerless: its owner.json is missing or invalid, as while a new owner writes it, or after a crash at that moment - other-host: its owner runs on another host, such as through a shared folder - recovery-in-progress: another process is taking it over, or crashed while doing so - unexpected-entries: its folder holds files besides owner.json, such as an interrupted write's

    Declaration
    type ILockStateKind = 'free' | 'held' | 'owner-gone' | 'ownerless' | 'other-host' | 'recovery-in-progress' | 'unexpected-entries';