{"record":{"id":"21bc35c51892f8a8","repo":"Hmbown/CodeWhale","slug":"the-pet-recorder-lock-was-replaced-existing-files-were","errorCode":null,"errorMessage":"The pet recorder lock was replaced; existing files were preserved.","messagePattern":"The pet recorder lock was replaced; existing files were preserved\\.","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"pet/scripts/lib/pet-recorder.mjs","lineNumber":34,"sourceCode":"  } finally { await directory?.close(); }\n}\n\n// SQLite's OS lock is released even after process death. This empty sidecar\n// contains no events or recorder state; keep its pathname so later processes\n// coordinate on the same inode. No PID files or stale-lock deletion are needed.\nasync function lockRecorder(path) {\n  const name = `${path}.writer-lock`;\n  try { const created = await open(name, 'wx', 0o600); await created.close(); }\n  catch (error) { if (error.code !== 'EEXIST') throw error; }\n  const identity = await lstat(name, { bigint: true });\n  if (!identity.isFile() || identity.nlink !== 1n || identity.size !== 0n)\n    throw new Error('Invalid pet recorder lock; existing files were preserved.');\n  let database;\n  const check = async () => {\n    const current = await lstat(name, { bigint: true });\n    if (!current.isFile() || current.nlink !== 1n || current.size !== 0n\n      || current.dev !== identity.dev || current.ino !== identity.ino)\n      throw new Error('The pet recorder lock was replaced; existing files were preserved.');\n  };\n  try {\n    database = new DatabaseSync(name);\n    database.exec('PRAGMA busy_timeout = 0; BEGIN EXCLUSIVE');\n    await check();\n    return { check, close: () => { database.close(); } };\n  } catch (error) {\n    database?.close();\n    if (error.errcode === 5 || error.errcode === 6) throw new Error('Another pet recorder is using this output.');\n    throw error;\n  }\n}\n\n/** Replaces the CLI's unbounded append-only output. Each complete segment is\n * replayable on its own; the same live pathname always holds the newest one. */\nexport async function createPetRecorder(path, { maxBuckets = 216_000, maxBytes = 64 * 1024 * 1024, report = () => {}, resume = false } = {}) {\n  if (!Number.isSafeInteger(maxBuckets) || maxBuckets < 1 || maxBuckets > 216_000\n    || !Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > 64 * 1024 * 1024)","sourceCodeStart":16,"sourceCodeEnd":52,"githubUrl":"https://github.com/Hmbown/CodeWhale/blob/433685b2024e7bc4c99e1e2e326bcad39b4d9d65/pet/scripts/lib/pet-recorder.mjs#L16-L52","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","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."],"exampleFix":"// before: cleanup script that breaks live holders\nfind . -name '*.writer-lock' -delete\n// after: only remove locks whose holder is gone\nfor f in ./*.writer-lock; do\n  pid=$(cat \"${f%.writer-lock}.pid\" 2>/dev/null)\n  [ -n \"$pid\" ] && kill -0 \"$pid\" 2>/dev/null && continue\n  rm -- \"$f\"\ndone","handlingStrategy":"try-catch","validationCode":"// ensure no other recorder is running for this output before starting\nconst { execFile } = await import('node:child_process');\nconst { promisify } = await import('node:util');\nconst out = await promisify(execFile)('pgrep', ['-f', 'recorder']).catch(() => ({ stdout: '' }));\nif (out.stdout.trim()) throw new Error('another recorder process is running');","typeGuard":null,"tryCatchPattern":"try {\n  const lock = await lockRecorder(path);\n} catch (e) {\n  if (e.message.includes('was replaced')) {\n    // lock ownership was lost: stop, do NOT touch the lock file, surface to operator\n    throw new Error('output is contended; stop other recorders before retrying');\n  }\n  throw e;\n}","preventionTips":["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."],"tags":["filesystem","locking","concurrency"],"backgroundTag":"internal-invariant-violation","analyzedSha":"433685b2024e7bc4c99e1e2e326bcad39b4d9d65","analyzedAt":"2026-09-15T12:24:24.634Z","contentChangedAt":"2026-09-15T12:24:24.634Z","schemaVersion":2},"datasetVersion":"2026-09-22T01:17:13.364Z"}