Skip to content

@studnicky/file-lock

Acquire exclusive access through Node filesystem locks or native browser Web Locks.

Install

bash
pnpm add @studnicky/file-lock

Requires @studnicky:registry=https://npm.pkg.github.com in .npmrc.

@studnicky/file-lock exports the shared LockInterface and package errors. Import the filesystem adapter from ./node and the Web Locks adapter from ./browser.

Usage

Acquire a lock, read and write the file while holding it, then release in a try/finally block:

ts
import { FileLock } from '../src/node/index.js';

const lock = await FileLock.create({ 'path': filePath });

let original: string;
let updated: string;

try {
  original = lock.read();
  console.log(`Original content: ${original}`);

  lock.write('updated content');
  updated = lock.read();
  console.log(`Updated content: ${updated}`);
} finally {
  lock.release();
}

console.log(`File exists after release: ${String(existsSync(filePath))}`);

With using (explicit resource management)

FileLock implements Symbol.dispose, so it can be released automatically at block exit. Call lock[Symbol.dispose]() directly or use the using keyword with TypeScript's explicit resource management:

ts
import { FileLock } from '../src/node/index.js';

const lock = await FileLock.create({ 'path': filePath });

lock.write('written via lock');
const content = lock.read();
console.log(`Content: ${content}`);

// Explicitly invoke Symbol.dispose — same as release
lock[Symbol.dispose]();
console.log(`File exists after dispose: ${String(existsSync(filePath))}`);

// Calling release again is safe (idempotent)
lock.release();
console.log(`File exists after redundant release: ${String(existsSync(filePath))}`);

Custom poll interval and timeout

typescript
const lock = await FileLock.create({
  path: '/var/data/queue.json',
  pollMs: 100,     // how often to retry when file is locked (default 50 ms)
  timeoutMs: 3000, // give up after 3 s (default 5000 ms)
});

Deterministic acquisition timing

FileLock.create composes @studnicky/clock and @studnicky/scheduler rather than owning a timer. Supply a shared virtual clock and scheduler for deterministic contention tests; the same providers measure the deadline and defer every retry.

typescript
import { VirtualClockProvider, VirtualTimeCounter } from '@studnicky/clock';
import { VirtualScheduler } from '@studnicky/scheduler';

const counter = VirtualTimeCounter.create({ startMs: 0 });
const clock = VirtualClockProvider.create(counter);
const scheduler = VirtualScheduler.create({ counter });

const lock = await FileLock.create({ clock, path: '/var/data/queue.json', scheduler });

Error handling

FileLock.create throws FileLockTimeoutError when the lock cannot be acquired within timeoutMs:

ts
import { FileLock, FileLockTimeoutError } from '../src/node/index.js';

// Hold the first lock
const firstLock = await FileLock.create({ 'path': filePath });

let caught: FileLockTimeoutError | undefined;

try {
  // Try to acquire again with a short timeout — throws because the file is at the lock path
  try {
    await FileLock.create({ 'path': filePath, 'pollMs': 50, 'timeoutMs': 200 });
  } catch (error) {
    if (error instanceof FileLockTimeoutError) {
      caught = error;
      console.log(`Timed out after ${String(error.timeoutMs)}ms on ${error.path}`);
    }
  }
} finally {
  firstLock.release();
}

console.log(`File exists after release: ${String(existsSync(filePath))}`);

Observability hooks

FileLock exposes protected lifecycle hooks at every stage of acquisition, contention, and release. Subclass FileLock and override any hook to add logging, metrics, or tracing without touching the core acquire/release logic.

HookWhen it firesArgs
onAcquireStart(path)Once, before the first rename attemptpath: string — the file being locked
onAcquireWait(path, attempt)Before each poll sleep when the lock is not yet availablepath: string, attempt: number — 1-based wait count
onContended(path)Every time a rename attempt fails because another holder has the filepath: string
onAcquire(path)Once, when the rename succeeds and the lock is heldpath: string
onRelease(path)Once, after the file is renamed back to its original pathpath: string
onStaleDetected(path)When a stale lock file from a dead process is detectedpath: string — not fired by the base class; implement in a subclass that adds stale-lock recovery
onStaleBreak(path)After a stale lock file has been brokenpath: string — not fired by the base class
onTimeout(path)Once, when the acquisition deadline elapsespath: string
onError(path, error)When a filesystem error other than contention is caught during acquisitionpath: string, error: Error
ts
import { EventRecorder } from '@studnicky/errors';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import os from 'node:os';
import path from 'node:path';

import { FileLock, FileLockTimeoutError } from '../src/node/index.js';

class TracedFileLock extends FileLock {
  readonly #recorder = new EventRecorder<{ 'extra'?: string; 'hook': string; 'path': string }>();

  get events(): readonly { 'extra'?: string; 'hook': string; 'path': string }[] { return this.#recorder.events; }

  protected override onAcquireStart(p: string): void {
    this.#recorder.record({ 'hook': 'onAcquireStart', 'path': p }, `[file-lock] acquireStart path=${p}`);
  }

  protected override onAcquireWait(p: string, attempt: number): void {
    this.#recorder.record(
      { 'extra': String(attempt), 'hook': 'onAcquireWait', 'path': p },
      `[file-lock] acquireWait path=${p} attempt=${String(attempt)}`
    );
  }

  protected override onContended(p: string): void {
    this.#recorder.record({ 'hook': 'onContended', 'path': p }, `[file-lock] contended path=${p}`);
  }

  protected override onAcquire(p: string): void {
    this.#recorder.record({ 'hook': 'onAcquire', 'path': p }, `[file-lock] acquired path=${p}`);
  }

  protected override onRelease(p: string): void {
    this.#recorder.record({ 'hook': 'onRelease', 'path': p }, `[file-lock] released path=${p}`);
  }

  protected override onTimeout(p: string): void {
    this.#recorder.record({ 'hook': 'onTimeout', 'path': p }, `[file-lock] timeout path=${p}`);
  }

  protected override onError(p: string, error: Error): void {
    this.#recorder.record(
      { 'extra': error.message, 'hook': 'onError', 'path': p },
      `[file-lock] error path=${p} message=${error.message}`
    );
  }
}

class FileLockScenarios {
  static async run(dir: string): Promise<{
    readonly 'holder': TracedFileLock;
    readonly 'lock1': TracedFileLock;
    readonly 'lock2': TracedFileLock;
    readonly 'timedOut': boolean;
  }> {
    const filePath = path.join(dir, 'lock.txt');

    // --- Scenario 1: clean acquire and release ---
    writeFileSync(filePath, 'scenario-1');
    const lock1 = await TracedFileLock.create({ 'path': filePath });
    lock1.write('modified');
    lock1.release();

    // --- Scenario 2: contended acquire (second lock waits, holder released before timeout) ---
    const filePath2 = path.join(dir, 'lock-2.txt');
    writeFileSync(filePath2, 'scenario-2');
    const holder = await TracedFileLock.create({ 'path': filePath2 });

    // Release the holder after a short delay so the second acquirer sees contention then succeeds.
    setTimeout(() => { holder.release(); }, 60);

    const lock2 = await TracedFileLock.create({
      'path': filePath2,
      'pollMs': 20,
      'timeoutMs': 500
    });
    lock2.release();

    // --- Scenario 3: timeout on a file that does not exist ---
    const missingPath = path.join(dir, 'missing.txt');
    let timedOut = false;
    try {
      await TracedFileLock.create({ 'path': missingPath, 'timeoutMs': 50 });
    } catch (error) {
      if (error instanceof FileLockTimeoutError) {
        timedOut = true;
        console.log(`[file-lock] caught timeout for missing path: path=${error.path}`);
      }
    }

    // Cleanup
    rmSync(filePath, { 'force': true });
    rmSync(filePath2, { 'force': true });

    return { 'holder': holder, 'lock1': lock1, 'lock2': lock2, 'timedOut': timedOut };
  }
}

const dir = mkdtempSync(path.join(os.tmpdir(), 'observed-file-lock-'));
const results = await FileLockScenarios.run(dir);

The base class never calls any logger or metrics library. All hooks are no-ops by default.

A hook override that throws or rejects does not abort acquisition or release — the failure is recorded instead of propagating; inspect it via hookErrorCount (a running total) and getHookErrors() (a defensive copy of every recorded { hookName, cause } entry), backed internally by @studnicky/errors's HookInvoker.

Try it in the browser

By default, FileLock performs all filesystem operations through the real Node.js fs module (atomic rename on disk). These demos inject an in-memory @studnicky/virtual-fs VirtualFileSystem so the exact same lock semantics — atomic rename-based acquisition, contention polling, release — run entirely in the browser.

Native Web Locks

WebLock uses the browser Web Locks API and shares the LockInterface release contract with the Node adapter.

Loading example…

Injected VirtualFileSystem

Loading example…

Lifecycle hooks with contention

Two FileLock instances share the same VirtualFileSystem path. The holder acquires first; the waiter sees onContended and onAcquireWait events until the holder releases, then acquires and fires onAcquire.

Loading example…

How it works

FileLock.create uses renameSync to atomically move the target file to a PID-scoped lock path (<path>.lock.<pid>). Any process that cannot rename the file retries at pollMs intervals until timeoutMs elapses. On release, the file is renamed back to the original path. The mechanism is advisory: all participants must use FileLock for mutual exclusion to hold.

API

ExportTypeDescription
FileLockclassAdvisory file lock acquired through FileLock.create(options)
FileLockErrorclassBase package error
FileLockConfigErrorclassInvalid lock configuration
FileLockTimeoutErrorclassThrown when lock cannot be acquired within timeoutMs
FileLockOptionsEntitynamespaceSchema and type for FileLock options
FileLockCreateOptionsInterfaceinterfaceRuntime construction contract, including optional filesystem, clock, scheduler, and owner-token collaborators
OwnerTokenInterfaceinterfaceRuntime lock-owner identity contract

FileLock

MemberSignatureDescription
createstatic (options) => Promise<FileLock>Acquires the lock; throws FileLockTimeoutError on timeout or FileLockConfigError on invalid options
read() => stringReads the locked file as UTF-8
write(content: string) => voidWrites content to the locked file
release() => voidReleases the lock; safe to call multiple times
[Symbol.dispose]() => voidCalls release; enables using syntax
hookErrorCountget hookErrorCount(): numberCount of hook failures recorded since construction
getHookErrors() => readonly { hookName: string; cause: unknown }[]Defensive copy of every hook failure recorded since construction

FileLockTimeoutError

PropertyTypeDescription
pathstringPath that could not be locked
timeoutMsnumberTimeout that elapsed

Source on GitHub

Entities

@studnicky/file-lock/entities exports every schema namespace in src/entities.

typescript
import { FileLockOptionsEntity } from '@studnicky/file-lock/entities';

Exports

SymbolPurposeImport path
FileLockProvides filesystem lock functionality.@studnicky/file-lock/node
FileLockConfigErrorRepresents file lock config failures.@studnicky/file-lock
FileLockContentionErrorRepresents an unsuccessful atomic lock acquisition.@studnicky/file-lock
FileLockCreateOptionsInterfaceDefines the filesystem lock create options contract.@studnicky/file-lock/node
FileLockErrorRepresents file lock failures.@studnicky/file-lock
FileLockInspectionInspects a lock path without changing it.@studnicky/file-lock/node
FileLockInspectionOptionsInterfaceDefines the lock inspection input contract.@studnicky/file-lock/node
FileLockRecoveryRecovers an explicitly verified stale lock.@studnicky/file-lock/node
FileLockRecoveryConflictErrorRepresents recovery blocked by a changed lock state.@studnicky/file-lock
FileLockRecoveryOptionsInterfaceDefines the explicit stale-lock recovery contract.@studnicky/file-lock/node
FileLockTimeoutErrorRepresents file lock timeout failures.@studnicky/file-lock
FileRenameLockProvides atomic rename-based acquire and release.@studnicky/file-lock/node
FileRenameLockCreateOptionsInterfaceDefines the atomic rename-lock construction contract.@studnicky/file-lock/node
NodeOwnerLivenessChecks Node process liveness for a lock owner.@studnicky/file-lock/node
OwnerLivenessInterfaceDefines a lock-owner liveness check.@studnicky/file-lock/node
OwnerTokenInterfaceDefines the owner token contract.@studnicky/file-lock/node
LockInterfaceDefines the shared release lifecycle.@studnicky/file-lock
WebLockAcquires an exclusive native browser lock.@studnicky/file-lock/browser
WebLockCreateOptionsInterfaceDefines native browser lock acquisition options.@studnicky/file-lock/browser
WebLockManagerInterfaceDefines the native lock-manager dependency surface.@studnicky/file-lock/browser
WebLockOptionsEntityValidates browser lock acquisition options.@studnicky/file-lock/browser