{"record":{"id":"dbba9966268c47de","repo":"Hmbown/CodeWhale","slug":"the-previous-pet-recording-changed-while-opening-it-was","errorCode":null,"errorMessage":"The previous pet recording changed while opening; it was preserved.","messagePattern":"The previous pet recording changed while opening; it was preserved\\.","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"pet/scripts/lib/pet-recorder.mjs","lineNumber":66,"sourceCode":"/** 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)\n    throw new Error('Invalid pet recording segment limit.');\n  path = resolve(await realpath(dirname(resolve(path))), basename(path));\n  let lock = await lockRecorder(path), output, sequence = 0, bytes = 0, segment = 0, busy = false, restart = false, expectedMtime;\n  try {\n    try { output = await open(path, 'wx', 0o600); }\n    catch (error) {\n      if (!resume || error.code !== 'EEXIST') throw error;\n      const original = await lstat(path, { bigint: true });\n      if (!original.isFile() || original.size > 64n * 1024n * 1024n)\n        throw new Error('The previous pet recording is not a bounded regular file; it was preserved.');\n      output = await open(path, constants.O_RDWR | constants.O_APPEND | constants.O_NOFOLLOW | constants.O_NONBLOCK);\n      const held = await output.stat({ bigint: true });\n      if (held.dev !== original.dev || held.ino !== original.ino || held.size !== original.size)\n        throw new Error('The previous pet recording changed while opening; it was preserved.');\n      // Read at most the size already checked, including a single growth byte.\n      const contents = Buffer.alloc(Number(held.size) + 1);\n      let length = 0;\n      while (length < contents.length) {\n        const { bytesRead } = await output.read(contents, length, contents.length - length, length);\n        if (!bytesRead) break;\n        length += bytesRead;\n      }\n      if (length !== Number(held.size)) throw new Error('The previous pet recording changed while reading; it was preserved.');\n      const text = new TextDecoder('utf-8', { fatal: true }).decode(contents.subarray(0, length));\n      if (text && !text.endsWith('\\n')) throw new Error('The previous pet recording has an incomplete final row; it was preserved.');\n      decodePetJSONL(text);\n      const unchanged = await output.stat({ bigint: true });\n      if (unchanged.size !== original.size || unchanged.mtimeNs !== original.mtimeNs)\n        throw new Error('The previous pet recording changed while reading; it was preserved.');\n      bytes = length; restart = true; expectedMtime = original.mtimeNs;\n      // Continue archive numbering without collecting a growing directory list.\n      const prefix = `${basename(path)}.segment-`;","sourceCodeStart":48,"sourceCodeEnd":84,"githubUrl":"https://github.com/Hmbown/CodeWhale/blob/433685b2024e7bc4c99e1e2e326bcad39b4d9d65/pet/scripts/lib/pet-recorder.mjs#L48-L84","documentation":"After lstat passes, resume mode reopens the file with O_NOFOLLOW and stats the opened handle, comparing dev, ino, and size against the lstat snapshot. A mismatch means the path was swapped (e.g. replaced by a new file or a symlink race) between the check and the open, or something appended to it concurrently. The library aborts, preserving whatever recording is there rather than corrupting it.","triggerScenarios":"createPetRecorder(path, { resume: true }) while another process concurrently: replaces the file via rename over the path, deletes and recreates it, swaps in a symlink, or appends data between the lstat and the open/stat of the handle.","commonSituations":"Two instances of the CLI started at once targeting the same recording path; a log-rotation tool renaming files in the recordings directory; an IDE/test runner cleaning and recreating output paths mid-launch.","solutions":["Ensure only one recorder process owns the path at a time; the recorder takes a lock via lockRecorder, so check for stale locks or competing processes and remove the redundant one.","Re-run the resume after the concurrent writer finishes, or point this recorder at a distinct output path.","If a rotation daemon touches the directory, exclude the active recording path from rotation or pause it during recording."],"exampleFix":"// before\n// two concurrent invocations:\nawait createPetRecorder(sharedPath, { resume: true });\nawait createPetRecorder(sharedPath, { resume: true });\n// after\nconst isLocked = await lockHeld(sharedPath);\nif (!isLocked) await createPetRecorder(sharedPath, { resume: true });","handlingStrategy":"try-catch","validationCode":"import { lstat } from 'node:fs/promises';\nasync function pathIsStable(p, ms = 250) {\n  const a = await lstat(p, { bigint: true });\n  await new Promise(r => setTimeout(r, ms));\n  const b = await lstat(p, { bigint: true });\n  return a.dev === b.dev && a.ino === b.ino && a.size === b.size;\n}","typeGuard":null,"tryCatchPattern":"try { await createPetRecorder(p, { resume: true }); } catch (e) { if (/changed while opening/.test(e.message)) { await backoff(() => createPetRecorder(p, { resume: true })); } else throw e; }","preventionTips":["Run at most one recorder per path; rely on the library's lock and surface 'already locked' to users.","Exclude recording directories from log-rotation/cleanup daemons.","Wait for size/mtime stability before starting a resume after a crash."],"tags":["race-condition","filesystem","resume","nodejs"],"backgroundTag":"file-changed-during-operation","analyzedSha":"433685b2024e7bc4c99e1e2e326bcad39b4d9d65","analyzedAt":"2026-09-15T12:24:24.634Z","contentChangedAt":"2026-09-15T12:24:24.634Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}