abhigyanpatwari/GitNexus · error · ValueError

workspace file changed while opening: {entry.path}

Error message

workspace file changed while opening: {entry.path}

What it means

Raised in workspace_snapshot (runner_artifacts.py:175) right after opening a file with O_NOFOLLOW: os.fstat on the descriptor is compared to the earlier entry.stat(), and if the type, device, or inode changed the file is considered to have been swapped between stat and open. This TOCTOU guard prevents hashing the wrong content if a symlink race replaces the file.

Source

Thrown at eval/workflow_bench/runner_artifacts.py:175

                continue
            if stat.S_ISLNK(metadata.st_mode):
                snapshot[relative.as_posix()] = f"l:{permissions:o}:{os.readlink(entry.path)}"
                continue
            if not stat.S_ISREG(metadata.st_mode):
                snapshot[relative.as_posix()] = f"s:{metadata.st_mode}"
                continue
            file_bytes += metadata.st_size
            if file_bytes > MAX_WORKSPACE_SNAPSHOT_FILE_BYTES:
                raise ValueError("workspace snapshot exceeds its bounded file-byte limit")
            descriptor = os.open(entry.path, os.O_RDONLY | nofollow)
            try:
                opened = os.fstat(descriptor)
                if (
                    not stat.S_ISREG(opened.st_mode)
                    or opened.st_dev != metadata.st_dev
                    or opened.st_ino != metadata.st_ino
                ):
                    raise ValueError(f"workspace file changed while opening: {entry.path}")
                digest = hashlib.sha256()
                while chunk := os.read(descriptor, 64 * 1024):
                    digest.update(chunk)
                after = os.fstat(descriptor)
                if (opened.st_size, opened.st_mtime_ns) != (after.st_size, after.st_mtime_ns):
                    raise ValueError(f"workspace file changed while hashing: {entry.path}")
            finally:
                os.close(descriptor)
            snapshot[relative.as_posix()] = f"f:{permissions:o}:{metadata.st_size}:{digest.hexdigest()}"
    return snapshot


def enforce_phase_workspace(
    worktree: Path,
    before: dict[str, str],
    *,
    allowed_artifact: Path,
) -> None:

View on GitHub (pinned to d540b00184)

Solutions

  1. Re-run the snapshot when no model/build process is active (phase boundaries are meant to be quiescent — if a background process is still writing, it violates the phase contract).
  2. Investigate the named {entry.path} to see which process rewrote it; gate the phase with a process supervisor that waits for the arm to exit before snapshotting.
  3. If legitimate atomic replacements are frequent, take the snapshot from a filesystem freeze/snapshot (e.g. btrfs subvolume snapshot) rather than the live tree.
Defensive patterns

Strategy: try-catch

Try / catch

try:
    snapshot = workspace_snapshot(root)
except ValueError as exc:
    if "changed while opening" in str(exc):
        # A file was swapped between stat and open — a writer is active.
        # Wait for quiescence (e.g. join the model/build process), then retry.
        wait_for_quiescence(root)
        snapshot = workspace_snapshot(root)
    else:
        raise

Prevention

When it happens

Trigger: Between entry.stat(follow_symlinks=False) and os.open(...|O_NOFOLLOW) the file is replaced: an attacker/model renames a new file over the path, or the OS recycles the inode. The opened descriptor's st_dev/st_ino no longer match metadata, or the opened type is not a regular file.

Common situations: A model or setup step rewriting files concurrently with the phase-boundary snapshot; a build tool that atomically replaces files (write temp + rename); running on a filesystem where inode reuse is aggressive.

Related errors


AI-assisted analysis of abhigyanpatwari/GitNexus@d540b00184 (2026-08-12). Data as JSON: /api/errors/9f8fe7495facf453. Report an issue: GitHub.