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

  1. Find and stop the other recorder process (ps/pgrep for the recorder) before retrying.
  2. Do not delete the lock file while a recorder is running — that is what triggers this error in the live holder.
  3. Re-run lockRecorder cleanly once no other process touches the output path; it will recreate the lock.
  4. 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

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


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)