Skip to content

@studnicky/file-lock

Acquire exclusive access to a file with rename-based advisory locking and automatic release.

Install

bash
pnpm add @studnicky/file-lock

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

Usage

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

ts
import { FileLock } from '../src/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/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)
});

Builder API

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

const lock = await FileLock.builder()
  .withPath(filePath)
  .withPollMs(100)
  .withTimeoutMs(3000)
  .build();

let contents: string;

try {
  contents = lock.read();
  console.log(`Locked content: ${contents}`);
} finally {
  lock.release();
}

Error handling

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

ts
import { FileLock, FileLockTimeoutError } from '../src/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 (err) {
    if (err instanceof FileLockTimeoutError) {
      caught = err;
      console.log(`Timed out after ${String(err.timeoutMs)}ms on ${err.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/observers';
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/index.js';

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

  get events(): { '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 }) as TracedFileLock;
    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 }) as TracedFileLock;

    // 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
    }) as TracedFileLock;
    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 (err) {
      if (err instanceof FileLockTimeoutError) {
        timedOut = true;
        console.log(`[file-lock] caught timeout for missing path: path=${err.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.

Builder with injected VirtualFileSystem

FileLock with VirtualFileSystem — browser-safe lock
/** vfsLock — builder demo injecting VirtualFileSystem as the lock backend. Run: npx tsx examples/vfsLock.ts */

// #region usage
import { VirtualFileSystem } from '@studnicky/virtual-fs';
import assert from 'node:assert/strict';

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

const vfs = VirtualFileSystem.builder()
  .seed('/queue/jobs.json', '{"jobs":[]}')
  .build();

// Ensure the parent directory entry exists so FileLock can readdirSync it
// when checking for existing lock files during contention detection.
vfs.mkdirSync('/queue', { 'recursive': true });

const lock = await FileLock.builder()
  .withFileSystem(vfs)
  .withPath('/queue/jobs.json')
  .withPollMs(10)
  .withTimeoutMs(1000)
  .build();

console.log('lock acquired');

let contents: string;
try {
  contents = lock.read();
  console.log(`locked content: ${contents}`);

  lock.write('{"jobs":["task-1"]}');
  const updated = lock.read();
  console.log(`updated content: ${updated}`);
} finally {
  lock.release();
  console.log('lock released');
}
// #endregion usage

assert.equal(contents!, '{"jobs":[]}');
assert.ok(vfs.existsSync('/queue/jobs.json'), 'file should exist at original path after release');
assert.equal(vfs.readFileSync('/queue/jobs.json', 'utf8'), '{"jobs":["task-1"]}');

console.log('vfsLock: all assertions passed');
Output
Press Execute to run this example against the real library.

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.

Observed FileLock with VirtualFileSystem — contention trace
/** observedVfsLock — lifecycle hooks demo: two locks over a shared VirtualFileSystem so one contends. Run: npx tsx examples/observedVfsLock.ts */

import { EventRecorder } from '@studnicky/errors/observers';
// #region usage
import { VirtualFileSystem } from '@studnicky/virtual-fs';
import assert from 'node:assert/strict';

import type { LockEventEntity } from './entities/LockEventEntity.js';

import { FileLock } from '../src/index.js';
import { VfsLockFixtures } from './fixtures/VfsLockFixtures.js';

class TracedFileLock extends FileLock {
  readonly #recorder = new EventRecorder<LockEventEntity.Type>();

  get events(): LockEventEntity.Type[] { return this.#recorder.events; }

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

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

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

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

class VfsLockScenario {
  static async run(): Promise<{ readonly 'holder': TracedFileLock; readonly 'waiter': TracedFileLock }> {
    const vfs = VirtualFileSystem.builder()
      .seed('/shared/state.json', '{"version":0}')
      .build();

    // Ensure the parent directory entry exists so FileLock can readdirSync it
    // when checking for existing lock files during contention detection.
    vfs.mkdirSync('/shared', { 'recursive': true });

    const lockPath = VfsLockFixtures.LOCK_PATH;

    // Holder acquires first — TracedFileLock.create uses `new this(...)` so the
    // result is a genuine TracedFileLock instance with observable hooks.
    const holder = await TracedFileLock.create({
      'fileSystem': vfs,
      'path': lockPath,
      'pollMs': 5,
      'timeoutMs': 500
    }) as TracedFileLock;

    console.log('holder acquired — scheduling release in 20ms');
    setTimeout(() => { holder.release(); }, 20);

    // Waiter acquires second — will see contention until holder releases.
    const waiter = await TracedFileLock.create({
      'fileSystem': vfs,
      'path': lockPath,
      'pollMs': 5,
      'timeoutMs': 500
    }) as TracedFileLock;

    console.log('waiter acquired after holder released');
    waiter.release();

    return { 'holder': holder, 'waiter': waiter };
  }
}

const results = await VfsLockScenario.run();
// #endregion usage

// Assertions
const holderAcquires = results.holder.events.filter((e) => { return e.hook === 'onAcquire'; });
const holderReleases = results.holder.events.filter((e) => { return e.hook === 'onRelease'; });
const waiterContentions = results.waiter.events.filter((e) => { return e.hook === 'onContended'; });
const waiterAcquires = results.waiter.events.filter((e) => { return e.hook === 'onAcquire'; });

assert.equal(holderAcquires.length, 1, 'holder: onAcquire fires once');
assert.equal(holderReleases.length, 1, 'holder: onRelease fires once');
assert.ok(waiterContentions.length >= 1, 'waiter: onContended fires at least once');
assert.equal(waiterAcquires.length, 1, 'waiter: onAcquire fires once after holder releases');

console.log('observedVfsLock: all assertions passed');
Output
Press Execute to run this example against the real library.

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 via FileLock.create or FileLock.builder()
FileLockBuilderclassFluent builder for FileLock; returned by FileLock.builder()
FileLockTimeoutErrorclassThrown when lock cannot be acquired within timeoutMs
FileLockOptionsEntitynamespaceSchema and type for FileLock options

FileLock

MemberSignatureDescription
createstatic (options) => Promise<FileLock>Acquires the lock; throws FileLockTimeoutError on timeout or FileLockConfigError on invalid options
builderstatic () => FileLockBuilderReturns a fluent builder for configuring and acquiring a lock
acquirestatic (path, options?) => Promise<FileLock>Alias for create({ path, ...options }); retained for compatibility
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

FileLockBuilder

MemberSignatureDescription
withPath(value: string) => thisSets the file path to lock
withPollMs(value: number) => thisSets the poll interval in milliseconds
withTimeoutMs(value: number) => thisSets the acquisition timeout in milliseconds
build() => Promise<FileLock>Acquires and returns the lock

FileLockTimeoutError

PropertyTypeDescription
pathstringPath that could not be locked
timeoutMsnumberTimeout that elapsed

Source on GitHub