# jg-locks

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

- **Version:** `1.0.0`
- **Runtimes:** Node.js
- **Install:** `npm install jg-locks`
- **On npm:** [jg-locks](https://www.npmjs.com/package/jg-locks)
- **Source:** [packages/jg-locks](https://github.com/jesusgraterol/jg-packages/tree/main/packages/jg-locks)
- **Recipes:** [jg-locks recipes](https://jg-packages.jesusgraterol.dev/packages/jg-locks/recipes/index.md)

## 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](https://jg-packages.jesusgraterol.dev/packages/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`.

## <a id="api-reference"></a>jg-locks API reference

Every export of `jg-locks@1.0.0`, 14 in all, with its signature, its errors, and an example. It's generated from the package's TSDoc.

- Functions: [`acquireLock`](#api-acquireLock), [`getLocksRoot`](#api-getLocksRoot), [`inspectLock`](#api-inspectLock), [`listLocks`](#api-listLocks), [`withLock`](#api-withLock)
- Constants: [`ERRORS`](#api-ERRORS)
- Types: [`IAcquireLockOptions`](#api-IAcquireLockOptions), [`ILock`](#api-ILock), [`ILockBusyReason`](#api-ILockBusyReason), [`ILockList`](#api-ILockList), [`ILockOwner`](#api-ILockOwner), [`ILocksRootOptions`](#api-ILocksRootOptions), [`ILockState`](#api-ILockState), [`ILockStateKind`](#api-ILockStateKind)

### <a id="api-functions"></a>Functions

#### <a id="api-acquireLock"></a>`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.

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

**Parameters**

- `name` `string`: 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_INVALID`: The 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_INVALID`: The lock's waitSeconds must be 0 or more, or Infinity.
- `LOCK_ROOT_INVALID`: The locks folder must be an absolute path.
- `LOCK_BUSY`: The lock '<path>' is busy.
- `LOCK_ACQUISITION_FAILED`: The lock '<path>' could not be acquired.
- `NOT_A_DIRECTORY`: The path '<path>' is not a directory.
- `DIRECTORY_NOT_PRIVATE`: The directory '<path>' can be accessed by other users.
- `PATH_NOT_CANONICAL`: The path '<path>' goes through a symbolic link.

**Example**

```ts
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();
}
```

#### <a id="api-getLocksRoot"></a>`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.

```ts
getLocksRoot(): string
```

**Returns** `string`: The folder's absolute path.

**Throws**

- `LOCK_ROOT_INVALID`: The locks folder must be an absolute path.

**Example**

```ts
import { getLocksRoot } from 'jg-locks';

getLocksRoot(); // such as '/home/ada/.local/state/jg-locks'
```

#### <a id="api-inspectLock"></a>`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.

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

**Parameters**

- `name` `string`: 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_INVALID`: The 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_INVALID`: The locks folder must be an absolute path.
- `NOT_A_DIRECTORY`: The path '<path>' is not a directory.

**Example**

```ts
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`);
}
```

#### <a id="api-listLocks"></a>`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.

```ts
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_INVALID`: The locks folder must be an absolute path.

**Example**

```ts
import { listLocks } from 'jg-locks';

const { locks } = await listLocks();
for (const { name, state, owner } of locks) {
  console.log(`${name}: ${state}, pid ${owner?.pid ?? 'unknown'}`);
}
```

#### <a id="api-withLock"></a>`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.

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

**Parameters**

- `name` `string`: The lock's name.
- `options` `IAcquireLockOptions`: 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_INVALID`: The 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_INVALID`: The lock's waitSeconds must be 0 or more, or Infinity.
- `LOCK_ROOT_INVALID`: The locks folder must be an absolute path.
- `LOCK_BUSY`: The lock '<path>' is busy.
- `LOCK_ACQUISITION_FAILED`: The lock '<path>' could not be acquired.
- `LOCK_NOT_OWNED`: The lock '<path>' is no longer owned by this process.
- `LOCK_RELEASE_FAILED`: The lock '<path>' could not be released.
- `NOT_A_DIRECTORY`: The path '<path>' is not a directory.
- `DIRECTORY_NOT_PRIVATE`: The directory '<path>' can be accessed by other users.
- `PATH_NOT_CANONICAL`: The path '<path>' goes through a symbolic link.

**Example**

```ts
import { withLock } from 'jg-locks';

const report = await withLock('acme.app.nightly-report', { waitSeconds: 30 }, async () => {
  return 'written';
}); // 'written'
```

### <a id="api-constants"></a>Constants

#### <a id="api-ERRORS"></a>`ERRORS`

*Constant*

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

```ts
const ERRORS: { LOCK_NAME_INVALID: "LOCK_NAME_INVALID"; LOCK_ROOT_INVALID: "LOCK_ROOT_INVALID"; LOCK_OPTIONS_INVALID: "LOCK_OPTIONS_INVALID" }
```

### <a id="api-types"></a>Types

#### <a id="api-IAcquireLockOptions"></a>`IAcquireLockOptions`

*Type*

what acquireLock and withLock take

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

#### <a id="api-ILock"></a>`ILock`

*Type*

a lock the caller holds until it's released

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

**Members**

- `name: string` (property): the lock's name
- `owner: ILockOwner` (property): the caller, as owner.json names it
- `release: () => Promise<void>` (property): releases the lock; calling it again after it succeeded does nothing

#### <a id="api-ILockBusyReason"></a>`ILockBusyReason`

*Type*

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

```ts
type ILockBusyReason = Exclude<ILockStateKind, 'free'>;
```

#### <a id="api-ILockList"></a>`ILockList`

*Type*

the locks listLocks found

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

**Members**

- `locks: ILockState[]` (property): the present locks, sorted by name
- `isTruncated: boolean` (property): whether there were more locks than listLocks returns

#### <a id="api-ILockOwner"></a>`ILockOwner`

*Type*

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

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

**Members**

- `pid: number` (property): the process id
- `nonce: string` (property): a random id that tells this acquisition apart from any other by the same process id
- `hostname: string` (property): the host the process runs on
- `program: string | null` (property): 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
- `cwd: string | null` (property): the folder the process ran in when it took the lock, or null
- `startedAt: string | null` (property): when the lock was taken, as an ISO 8601 date, or null
- `metadata: Record<string, string>` (property): what the caller wrote about itself, such as a run id

#### <a id="api-ILocksRootOptions"></a>`ILocksRootOptions`

*Type*

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

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

**Members**

- `root: string` (property): the absolute path of the locks folder. Defaults to getLocksRoot()

#### <a id="api-ILockState"></a>`ILockState`

*Type*

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

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

**Members**

- `name: string` (property): the lock's name
- `state: ILockStateKind` (property): whether it's free, held, or why it can't be taken
- `owner: ILockOwner | null` (property): its owner, or null when it's free or owner.json can't be read
- `isOwnerAlive: boolean | null` (property): whether the owner's process is running, or null when there's no owner or it runs on another host

#### <a id="api-ILockStateKind"></a>`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

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