Hmbown/CodeWhale · error
The pet recorder lock was replaced; existing files were…
Error message
The pet recorder lock was replaced; existing files were preserved.
What it means
After opening the SQLite database on the lock file, lockRecorder installs a check() routine that re-lstats the lock path and confirms it is still the same file (same dev/ino), still a regular file with nlink 1 and size 0. This error is thrown when the lock file on disk was replaced or tampered with after acquisition — meaning another process deleted and recreated it, so exclusive ownership can no longer be trusted. Throwing preserves the existing output files rather than letting two writers corrupt them.
Solutions
- Find and stop the other recorder process (ps/pgrep for the recorder) before retrying.
- Do not delete the lock file while a recorder is running — that is what triggers this error in the live holder.
- Re-run lockRecorder cleanly once no other process touches the output path; it will recreate the lock.
- If cleanup scripts remove stale locks, make them verify the owning process is dead (e.g. flock or pid check) instead of unlinking blindly.
Example fix
// before: cleanup script that breaks live holders
find . -name '*.writer-lock' -delete
// after: only remove locks whose holder is gone
for f in ./*.writer-lock; do
pid=$(cat "${f%.writer-lock}.pid" 2>/dev/null)
[ -n "$pid" ] && kill -0 "$pid" 2>/dev/null && continue
rm -- "$f"
done Defensive patterns
Strategy: try-catch
Validate before calling
// ensure no other recorder is running for this output before starting
const { execFile } = await import('node:child_process');
const { promisify } = await import('node:util');
const out = await promisify(execFile)('pgrep', ['-f', 'recorder']).catch(() => ({ stdout: '' }));
if (out.stdout.trim()) throw new Error('another recorder process is running'); Try / catch
try {
const lock = await lockRecorder(path);
} catch (e) {
if (e.message.includes('was replaced')) {
// lock ownership was lost: stop, do NOT touch the lock file, surface to operator
throw new Error('output is contended; stop other recorders before retrying');
}
throw e;
} Prevention
- Never delete a .writer-lock file while any recorder may be live.
- Use a supervisor that guarantees a single recorder instance per output path.
- Make stale-lock cleanup scripts verify the owning process is dead first.
When it happens
Trigger: Between lock acquisition and any check() call (including right after BEGIN EXCLUSIVE), the lock path's dev/ino changed or it gained size/links: another process removed and recreated `<path>.writer-lock`, or truncated/appended to it, or hard-linked it.
Common situations: Two recorders started concurrently and one deleted the other's lock after a stale-lock cleanup script ran; a monitoring tool rotated or removed the lock file; an operator manually deleted the lock believing it stale while a live recorder held it.
Understand the failure class
Background: "This is a bug, please report it": internal invariant violations, unreachable panics, and SNH errors explained — this error's family across 47 libraries.
Related errors
- Another pet recorder is using this output.
- Automation lock must be a regular file
- Automation lock must not have hard links
- config lock was redirected while opening
- Invalid pet recorder lock; existing files were preserved.
AI-assisted analysis of Hmbown/CodeWhale@433685b202 (2026-09-15).
Data as JSON: /api/errors/21bc35c51892f8a8.
Report an issue: GitHub.
Appendix: source
Thrown at pet/scripts/lib/pet-recorder.mjs:34
} 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;
}
}
/** Replaces the CLI's unbounded append-only output. Each complete segment is
* replayable on its own; the same live pathname always holds the newest one. */
export async function createPetRecorder(path, { maxBuckets = 216_000, maxBytes = 64 * 1024 * 1024, report = () => {}, resume = false } = {}) {
if (!Number.isSafeInteger(maxBuckets) || maxBuckets < 1 || maxBuckets > 216_000
|| !Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > 64 * 1024 * 1024)View on GitHub (pinned to 433685b202)