{"record":{"id":"0b9e8ad507c409c4","repo":"Hmbown/CodeWhale","slug":"the-previous-pet-recording-changed-while-reading-it-was","errorCode":null,"errorMessage":"The previous pet recording changed while reading; it was preserved.","messagePattern":"The previous pet recording changed while reading; it was preserved\\.","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"pet/scripts/lib/pet-recorder.mjs","lineNumber":75,"sourceCode":"    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-`;\n      for await (const entry of await opendir(dirname(path))) {\n        if (!entry.name.startsWith(prefix)) continue;\n        const suffix = entry.name.slice(prefix.length);\n        if (!/^[0-9]{6,}\\.jsonl$/.test(suffix)) continue;\n        const number = Number(suffix.slice(0, -6));\n        if (!Number.isSafeInteger(number) || number >= Number.MAX_SAFE_INTEGER)\n          throw new Error('Pet archive numbering is exhausted; existing files were preserved.');\n        segment = Math.max(segment, number);\n      }","sourceCodeStart":57,"sourceCodeEnd":93,"githubUrl":"https://github.com/Hmbown/CodeWhale/blob/433685b2024e7bc4c99e1e2e326bcad39b4d9d65/pet/scripts/lib/pet-recorder.mjs#L57-L93","documentation":"During resume, the library reads the existing file in full, then re-stats the open handle and compares size and mtimeNs against the original lstat values. If the length read back does not equal the statted size, or the file's size/mtime changed since it was first inspected, a concurrent writer modified it during the read. The library throws and preserves the file rather than appending to a moving target.","triggerScenarios":"createPetRecorder(path, { resume: true }) while another process appends to or truncates the recording between the initial stat and the post-read re-stat; reads returning fewer bytes than stat reported mid-read loop; a writer flushing more data after the initial open/stat but before the unchanged check.","commonSituations":"A second CLI instance or tail-like consumer writing/rotating the file concurrently; a watchdog appending status lines; an interrupted previous run being written to by a recovered process.","solutions":["Stop all other writers to the recording path, verify with lsof/fuser that nothing holds the file open for write, then retry the resume.","Snapshot/copy the current file to a stable location and resume from the copy, so the live path is untouched.","If the concurrent writer is another recorder instance, kill it or let it finish and reuse its output rather than double-attaching."],"exampleFix":"// before\nawait createPetRecorder(activePath, { resume: true }); // other process still appending\n// after\nawait stopOtherRecorder(activePath);\nawait waitUntilStable(activePath, 1000); // size/mtime unchanged for 1s\nawait createPetRecorder(activePath, { resume: true });","handlingStrategy":"retry","validationCode":"async function quiescent(p, ms = 1000) {\n  const { stat } = await import('node:fs/promises');\n  const a = await stat(p, { bigint: true });\n  await new Promise(r => setTimeout(r, ms));\n  const b = await stat(p, { bigint: true });\n  return a.size === b.size && a.mtimeNs === b.mtimeNs;\n}","typeGuard":null,"tryCatchPattern":"try { await createPetRecorder(p, { resume: true }); } catch (e) { if (/changed while (opening|reading)/.test(e.message)) { await waitForQuiescence(p); return createPetRecorder(p, { resume: true }); } throw e; }","preventionTips":["Ensure no other process holds the file open for append (check lsof) before resuming.","Quiesce or pause concurrent writers/rotation for the duration of the resume.","Resume from a frozen copy when the live path must keep receiving writes."],"tags":["race-condition","filesystem","resume","concurrency"],"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"}