actualbudget/actual · error

lockedMessage(timeoutMs)

Error message

lockedMessage(timeoutMs)

What it means

waitForReadersEmpty polls the readers directory waiting for all shared (read) locks to be released before an exclusive lock can proceed. If readers still hold the lock after timeoutMs, the timeout error from lockedMessage is thrown. This prevents an exclusive writer from mutating state while readers are active.

Source

Thrown at packages/cli/src/lock.ts:93

function sweepStaleReaders(dir: string) {
  const readers = readersDir(dir);
  for (const name of readReaderNames(readers)) {
    const pid = Number(name.split('-')[0]);
    if (!Number.isFinite(pid) || !pidIsAlive(pid)) {
      rmSync(join(readers, name), { force: true });
    }
  }
}

async function waitForReadersEmpty(dir: string, timeoutMs: number) {
  const readers = readersDir(dir);
  const deadline = Date.now() + timeoutMs;
  while (Date.now() < deadline) {
    sweepStaleReaders(dir);
    if (readReaderNames(readers).length === 0) return;
    await new Promise(resolve => setTimeout(resolve, READER_POLL_INTERVAL_MS));
  }
  throw new Error(lockedMessage(timeoutMs));
}

async function acquireGate(
  dir: string,
  timeoutMs: number,
): Promise<() => Promise<void>> {
  ensureDir(dir);
  try {
    return await lockfile.lock(dir, {
      lockfilePath: lockfilePath(dir),
      retries: retriesForTimeout(timeoutMs),
      stale: 30_000,
    });
  } catch (err) {
    if (isLockedError(err)) throw new Error(lockedMessage(timeoutMs));
    throw err;
  }
}

View on GitHub (pinned to d4334cb6e6)

Solutions

  1. Retry the exclusive operation later, after the reader process has finished.
  2. Increase the timeoutMs passed to acquireExclusive if readers legitimately run long.
  3. Verify no orphaned reader files remain in the lock directory and remove stale ones manually.
  4. Check for crashed/killed reader processes leaving stale lock entries beyond the 30s stale threshold.

Example fix

// before
await acquireExclusive(dir, 1000);
// after
await acquireExclusive(dir, 30000); // allow long-running readers to finish
Defensive patterns

Strategy: try-catch

Validate before calling

import { readdir } from 'fs/promises';
const readers = await readdir(readersDir).catch(() => []);
if (readers.length > 0) {
  throw new Error(`Cannot acquire exclusive lock: ${readers.length} reader(s) active`);
}

Type guard

function isLockTimeoutError(err: unknown): err is Error {
  return err instanceof Error && /lock/i.test(err.message) && /timed out|locked/i.test(err.message);
}

Try / catch

try {
  await acquireExclusive(dir, timeoutMs);
} catch (err) {
  if (isLockTimeoutError(err)) {
    // back off and retry later
    await sleep(5000);
    return acquireExclusive(dir, timeoutMs);
  }
  throw err;
}

Prevention

When it happens

Trigger: acquireExclusive is called while other processes hold read locks that never clear within timeoutMs, e.g. crashed readers whose sweepStaleReaders hasn't aged them out, or a long-running reader exceeding the timeout window.

Common situations: Concurrent CLI processes: one process reads while another tries an exclusive operation; stale reader files left behind by a killed process; READER_POLL_INTERVAL_MS polling never observing an empty directory before the deadline.

Understand the failure class

Related errors


AI-assisted analysis of actualbudget/actual@d4334cb6e6 (2026-08-29). Data as JSON: /api/errors/e14a6eae42aaf013. Report an issue: GitHub.