Hmbown/CodeWhale · error

The previous pet recording changed while opening; it was…

Error message

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

What it means

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.

Solutions

  1. 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.
  2. Re-run the resume after the concurrent writer finishes, or point this recorder at a distinct output path.
  3. If a rotation daemon touches the directory, exclude the active recording path from rotation or pause it during recording.

Example fix

// before
// two concurrent invocations:
await createPetRecorder(sharedPath, { resume: true });
await createPetRecorder(sharedPath, { resume: true });
// after
const isLocked = await lockHeld(sharedPath);
if (!isLocked) await createPetRecorder(sharedPath, { resume: true });
Defensive patterns

Strategy: try-catch

Validate before calling

import { lstat } from 'node:fs/promises';
async function pathIsStable(p, ms = 250) {
  const a = await lstat(p, { bigint: true });
  await new Promise(r => setTimeout(r, ms));
  const b = await lstat(p, { bigint: true });
  return a.dev === b.dev && a.ino === b.ino && a.size === b.size;
}

Try / catch

try { await createPetRecorder(p, { resume: true }); } catch (e) { if (/changed while opening/.test(e.message)) { await backoff(() => createPetRecorder(p, { resume: true })); } else throw e; }

Prevention

When it happens

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

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

Related errors


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

Appendix: source

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

/** 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)
    throw new Error('Invalid pet recording segment limit.');
  path = resolve(await realpath(dirname(resolve(path))), basename(path));
  let lock = await lockRecorder(path), output, sequence = 0, bytes = 0, segment = 0, busy = false, restart = false, expectedMtime;
  try {
    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-`;

View on GitHub (pinned to 433685b202)