paperclipai/paperclip · error · Error

ACPX module changed before snapshot

Error message

ACPX module changed before snapshot

What it means

Immediately after opening a module file with O_RDONLY|O_NOFOLLOW, copy() compares the open handle's stat against the pre-open lstat. If they differ — the file was swapped, resized, or rewritten between the initial lstat and open — it throws 'ACPX module changed before snapshot'. This closes the classic TOCTOU window before any bytes are read.

Source

Thrown at packages/paperclip-runner/src/drivers/acpx/private-snapshot.ts:130

      directories.push(target);
      for (const entry of await readdir(source))
        await copy(join(source, entry), join(target, entry), root);
      if (!same(before, await lstat(source, { bigint: true })))
        throw new Error("ACPX package directory changed during snapshot");
      return;
    }
    if (!before.isFile() || before.size > 16n * 1024n * 1024n)
      throw new Error("ACPX module must be a bounded regular file");
    bytesCopied += Number(before.size);
    if (bytesCopied > MAX_PACKAGE_SNAPSHOT_BYTES)
      throw new Error("ACPX package snapshot exceeds its byte bound");
    const handle = await open(
      source,
      constants.O_RDONLY | constants.O_NOFOLLOW,
    );
    try {
      if (!same(before, await handle.stat({ bigint: true })))
        throw new Error("ACPX module changed before snapshot");
      const bytes = await readSnapshotBytes(handle, Number(before.size));
      if (
        !same(before, await handle.stat({ bigint: true })) ||
        !same(before, await lstat(source, { bigint: true }))
      ) {
        throw new Error("ACPX module changed during snapshot");
      }
      await writeFile(target, bytes, { flag: "wx", mode: 0o400 });
      digests[target] = digest(bytes);
    } finally {
      await handle.close();
    }
  };
  try {
    for (let index = 0; index < sourceRoots.length; index++) {
      await copy(sourceRoots[index]!, roots[index]!, sourceRoots[index]!);
      if (
        !same(

View on GitHub (pinned to 01ad858492)

Solutions

  1. Ensure no installs/builds run concurrently with snapshot creation, then retry
  2. Snapshot from an immutable copy (clean checkout, frozen container layer)
  3. Exclude directories subject to atomic rename-replace from the admitted roots
  4. Re-run after the mutating process exits — the error indicates a race, not corruption

Example fix

// before: snapshot during deploy
await deploy(); // rewrites package files
await snapshot(roots);
// after
await deploy();
await waitForIdle(roots);
await snapshot(roots);
Defensive patterns

Strategy: retry

Validate before calling

const st1 = await fs.stat(file);
const st2 = await fs.stat(file);
if (st1.ino !== st2.ino || st1.mtimeMs !== st2.mtimeMs || st1.size !== st2.size) {
  throw new Error('file is being rewritten; defer snapshot');
}

Try / catch

try {
  await createAcpxPrivateSnapshot({ roots });
} catch (e) {
  if (e.message.includes('changed before snapshot')) {
    await new Promise(r => setTimeout(r, 1000));
    await createAcpxPrivateSnapshot({ roots });
  } else throw e;
}

Prevention

When it happens

Trigger: A concurrent rename/replace (e.g. atomic install via rename), truncate, or chmod on a package file between the lstat that sized it and the open() call inside createAcpxPrivateSnapshot.

Common situations: Package manager performing an in-place upgrade during snapshot; build tooling rewriting output files; container image layer being updated while the runner snapshots.

Related errors


AI-assisted analysis of paperclipai/paperclip@01ad858492 (2026-09-10). Data as JSON: /api/errors/330d5d037fdd821f. Report an issue: GitHub.