@studnicky/file-lock
Acquire exclusive access to a file with rename-based advisory locking and automatic release.
Install
pnpm add @studnicky/file-lockRequires @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:
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:
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
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
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:
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.
| Hook | When it fires | Args |
|---|---|---|
onAcquireStart(path) | Once, before the first rename attempt | path: string — the file being locked |
onAcquireWait(path, attempt) | Before each poll sleep when the lock is not yet available | path: string, attempt: number — 1-based wait count |
onContended(path) | Every time a rename attempt fails because another holder has the file | path: string |
onAcquire(path) | Once, when the rename succeeds and the lock is held | path: string |
onRelease(path) | Once, after the file is renamed back to its original path | path: string |
onStaleDetected(path) | When a stale lock file from a dead process is detected | path: 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 broken | path: string — not fired by the base class |
onTimeout(path) | Once, when the acquisition deadline elapses | path: string |
onError(path, error) | When a filesystem error other than contention is caught during acquisition | path: string, error: Error |
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
/** 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');
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.
/** 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');
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
| Export | Type | Description |
|---|---|---|
FileLock | class | Advisory file lock; acquired via FileLock.create or FileLock.builder() |
FileLockBuilder | class | Fluent builder for FileLock; returned by FileLock.builder() |
FileLockTimeoutError | class | Thrown when lock cannot be acquired within timeoutMs |
FileLockOptionsEntity | namespace | Schema and type for FileLock options |
FileLock
| Member | Signature | Description |
|---|---|---|
create | static (options) => Promise<FileLock> | Acquires the lock; throws FileLockTimeoutError on timeout or FileLockConfigError on invalid options |
builder | static () => FileLockBuilder | Returns a fluent builder for configuring and acquiring a lock |
acquire | static (path, options?) => Promise<FileLock> | Alias for create({ path, ...options }); retained for compatibility |
read | () => string | Reads the locked file as UTF-8 |
write | (content: string) => void | Writes content to the locked file |
release | () => void | Releases the lock; safe to call multiple times |
[Symbol.dispose] | () => void | Calls release; enables using syntax |
hookErrorCount | get hookErrorCount(): number | Count of hook failures recorded since construction |
getHookErrors | () => readonly { hookName: string; cause: unknown }[] | Defensive copy of every hook failure recorded since construction |
FileLockBuilder
| Member | Signature | Description |
|---|---|---|
withPath | (value: string) => this | Sets the file path to lock |
withPollMs | (value: number) => this | Sets the poll interval in milliseconds |
withTimeoutMs | (value: number) => this | Sets the acquisition timeout in milliseconds |
build | () => Promise<FileLock> | Acquires and returns the lock |
FileLockTimeoutError
| Property | Type | Description |
|---|---|---|
path | string | Path that could not be locked |
timeoutMs | number | Timeout that elapsed |