Hmbown/CodeWhale · error

The previous pet recording changed while reading; it was…

Error message

The previous pet recording changed while reading; it was preserved.

What it means

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.

Solutions

  1. Stop all other writers to the recording path, verify with lsof/fuser that nothing holds the file open for write, then retry the resume.
  2. Snapshot/copy the current file to a stable location and resume from the copy, so the live path is untouched.
  3. If the concurrent writer is another recorder instance, kill it or let it finish and reuse its output rather than double-attaching.

Example fix

// before
await createPetRecorder(activePath, { resume: true }); // other process still appending
// after
await stopOtherRecorder(activePath);
await waitUntilStable(activePath, 1000); // size/mtime unchanged for 1s
await createPetRecorder(activePath, { resume: true });
Defensive patterns

Strategy: retry

Validate before calling

async function quiescent(p, ms = 1000) {
  const { stat } = await import('node:fs/promises');
  const a = await stat(p, { bigint: true });
  await new Promise(r => setTimeout(r, ms));
  const b = await stat(p, { bigint: true });
  return a.size === b.size && a.mtimeNs === b.mtimeNs;
}

Try / catch

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; }

Prevention

When it happens

Trigger: 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.

Common situations: 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.

Related errors


AI-assisted analysis of Hmbown/CodeWhale@433685b202 (2026-09-15). Data as JSON: /api/errors/0b9e8ad507c409c4. Report an issue: GitHub.

Appendix: source

Thrown at pet/scripts/lib/pet-recorder.mjs:75

    try { output = await open(path, 'wx', 0o600); }
    catch (error) {
      if (!resume || error.code !== 'EEXIST') throw error;
      const original = await lstat(path, { bigint: true });
      if (!original.isFile() || original.size > 64n * 1024n * 1024n)
        throw new Error('The previous pet recording is not a bounded regular file; it was preserved.');
      output = await open(path, constants.O_RDWR | constants.O_APPEND | constants.O_NOFOLLOW | constants.O_NONBLOCK);
      const held = await output.stat({ bigint: true });
      if (held.dev !== original.dev || held.ino !== original.ino || held.size !== original.size)
        throw new Error('The previous pet recording changed while opening; it was preserved.');
      // Read at most the size already checked, including a single growth byte.
      const contents = Buffer.alloc(Number(held.size) + 1);
      let length = 0;
      while (length < contents.length) {
        const { bytesRead } = await output.read(contents, length, contents.length - length, length);
        if (!bytesRead) break;
        length += bytesRead;
      }
      if (length !== Number(held.size)) throw new Error('The previous pet recording changed while reading; it was preserved.');
      const text = new TextDecoder('utf-8', { fatal: true }).decode(contents.subarray(0, length));
      if (text && !text.endsWith('\n')) throw new Error('The previous pet recording has an incomplete final row; it was preserved.');
      decodePetJSONL(text);
      const unchanged = await output.stat({ bigint: true });
      if (unchanged.size !== original.size || unchanged.mtimeNs !== original.mtimeNs)
        throw new Error('The previous pet recording changed while reading; it was preserved.');
      bytes = length; restart = true; expectedMtime = original.mtimeNs;
      // Continue archive numbering without collecting a growing directory list.
      const prefix = `${basename(path)}.segment-`;
      for await (const entry of await opendir(dirname(path))) {
        if (!entry.name.startsWith(prefix)) continue;
        const suffix = entry.name.slice(prefix.length);
        if (!/^[0-9]{6,}\.jsonl$/.test(suffix)) continue;
        const number = Number(suffix.slice(0, -6));
        if (!Number.isSafeInteger(number) || number >= Number.MAX_SAFE_INTEGER)
          throw new Error('Pet archive numbering is exhausted; existing files were preserved.');
        segment = Math.max(segment, number);
      }

View on GitHub (pinned to 433685b202)