Hmbown/CodeWhale · error
Invalid pet recorder lock; existing files were preserved.
Error message
Invalid pet recorder lock; existing files were preserved.
What it means
lockRecorder creates an exclusive `<path>.writer-lock` file with open(..., 'wx') and then verifies with lstat that what exists is really a regular file, hard-link count 1, and size 0. If the existing lock file fails that identity check (it was left behind by a crashed writer that wrote data to it, is hard-linked elsewhere, or is a symlink/special file), this error is thrown so the caller's existing output files are never clobbered. It is a stale-lock hygiene guard, not a concurrency error.
Solutions
- Inspect `<path>.writer-lock`: if it is a stale leftover from a dead process, delete it with rm and rerun.
- Never reuse the lock file for data; if your tooling writes to it, point that tooling elsewhere.
- Check for hard links (ls -l link count) and remove the extra links or the file.
- If it is a symlink, remove it and investigate what created it before retrying.
Example fix
// before: blind delete then rerun rm -f output.db.writer-lock && node recorder.js // after: verify it is stale (owning pid gone / not linked) before deleting ls -li output.db.writer-lock # check nlink, size [ -s output.db.writer-lock ] && cat output.db.writer-lock # inspect contents rm -- output.db.writer-lock # only after confirming no live recorder holds it
Defensive patterns
Strategy: try-catch
Validate before calling
import { lstat } from 'node:fs/promises';
// caller-side stale lock pre-check
try {
const st = await lstat(path + '.writer-lock', { bigint: true });
if (st.isFile() && st.nlink === 1n && st.size === 0n) throw new Error('live-looking lock present; verify holder before proceeding');
// otherwise inspect/delete the anomalous lock before calling lockRecorder
} catch (e) { if (e.code !== 'ENOENT') throw e; } Try / catch
try {
const lock = await lockRecorder(path);
} catch (e) {
if (e.message.includes('Invalid pet recorder lock')) {
// anomalous leftover lock — confirm no live writer, then remove and retry once
await confirmNoLiveWriter(path);
await rm(path + '.writer-lock');
return lockRecorder(path);
}
throw e;
} Prevention
- Never write data into the .writer-lock file; it must stay empty.
- After a crash, inspect and clean the lock before restarting the recorder.
- Exclude *.writer-lock from tools that copy/hard-link output directories.
When it happens
Trigger: A `<path>.writer-lock` file already exists at lock time AND is not a plain empty file with nlink==1: a previous run wrote bytes into the lock file, the file was hard-linked, or it is a symlink/device node that 'wx' happened not to create (e.g. pre-existing).
Common situations: A crashed recorder left a corrupted or non-empty lock file; a user manually created or copied the lock path; a backup/restore tool hard-linked output files including the lock; running on a filesystem where the lock path pre-exists from an older format.
Understand the failure class
Background: "already exists" / EEXIST / FileAlreadyExistsException: what the 'file already exists' error means and how to fix it — this error's family across 37 libraries.
Related errors
- Automation lock must be a regular file
- Automation lock must not have hard links
- config lock was redirected while opening
- refusing non-regular or reparse-point config lock at
- The pet recorder lock was replaced; existing files were…
AI-assisted analysis of Hmbown/CodeWhale@433685b202 (2026-09-15).
Data as JSON: /api/errors/1b696c6619bce3c4.
Report an issue: GitHub.
Appendix: source
Thrown at pet/scripts/lib/pet-recorder.mjs:28
let directory;
try { directory = await open(path, 'r'); await directory.sync(); }
catch (error) {
// Node cannot open/sync directory handles on Windows. File data is still
// synced before its atomic replacement on that platform.
if (process.platform !== 'win32' || !['EPERM', 'EISDIR', 'EINVAL', 'ENOTSUP'].includes(error.code)) throw error;
} finally { await directory?.close(); }
}
// SQLite's OS lock is released even after process death. This empty sidecar
// contains no events or recorder state; keep its pathname so later processes
// coordinate on the same inode. No PID files or stale-lock deletion are needed.
async function lockRecorder(path) {
const name = `${path}.writer-lock`;
try { const created = await open(name, 'wx', 0o600); await created.close(); }
catch (error) { if (error.code !== 'EEXIST') throw error; }
const identity = await lstat(name, { bigint: true });
if (!identity.isFile() || identity.nlink !== 1n || identity.size !== 0n)
throw new Error('Invalid pet recorder lock; existing files were preserved.');
let database;
const check = async () => {
const current = await lstat(name, { bigint: true });
if (!current.isFile() || current.nlink !== 1n || current.size !== 0n
|| current.dev !== identity.dev || current.ino !== identity.ino)
throw new Error('The pet recorder lock was replaced; existing files were preserved.');
};
try {
database = new DatabaseSync(name);
database.exec('PRAGMA busy_timeout = 0; BEGIN EXCLUSIVE');
await check();
return { check, close: () => { database.close(); } };
} catch (error) {
database?.close();
if (error.errcode === 5 || error.errcode === 6) throw new Error('Another pet recorder is using this output.');
throw error;
}
}View on GitHub (pinned to 433685b202)