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
- Retry the exclusive operation later, after the reader process has finished.
- Increase the timeoutMs passed to acquireExclusive if readers legitimately run long.
- Verify no orphaned reader files remain in the lock directory and remove stale ones manually.
- 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
- Keep read-lock sections short so they finish well within the exclusive timeout.
- Use a generous timeoutMs for exclusive operations.
- Monitor for stale reader files and rely on sweepStaleReaders' staleness window.
- Serialize operations per data directory instead of mixing readers and writers concurrently.
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
- Timeouts: ETIMEDOUT, deadlines, and hung requests — what actually expires when a request times out.
Related errors
AI-assisted analysis of actualbudget/actual@d4334cb6e6 (2026-08-29).
Data as JSON: /api/errors/e14a6eae42aaf013.
Report an issue: GitHub.