affaan-m/ECC · error · ValueError

artifact changed before reading

Error message

artifact changed before reading

What it means

Between the initial `lstat` (via `os.stat(path.name, dir_fd=parent, follow_symlinks=False)`) and the actual `os.open`, `_read_local` re-checks the file's identity tuple `(st_dev, st_ino, st_size, st_mtime_ns, st_ctime_ns)` via `_identity`. If the pre-open stat and the opened file descriptor's `fstat` disagree, the file was replaced or modified in that window — a classic TOCTOU race — and the library aborts rather than reading a half-swapped artifact.

Solutions

  1. Stop the producer before consuming: ensure the build step completes (and is quiescent) before running the application-request pipeline.
  2. Have the producer write atomically (write temp file, `os.replace`) so readers either see the old or the new complete file, then simply retry after the race.
  3. Re-run the load after the concurrent writer finishes; the file will be stable and identity will match.
  4. Serialize access with a lock file or job dependency so artifact writes and reads never overlap.

Example fix

// before
# build.sh runs continuously while load_application_request() reads its output
// after
run_build_and_wait()   # producer finishes before consumer starts
load_application_request("/out/request.json")
Defensive patterns

Strategy: retry

Validate before calling

import os, stat, time
def assert_stable(path: str, quiet_secs: float = 0.5) -> None:
    a = os.stat(path)
    time.sleep(quiet_secs)
    b = os.stat(path)
    if (a.st_size, a.st_mtime_ns) != (b.st_size, b.st_mtime_ns):
        raise ValueError(f"file still being written: {path}")

Try / catch

import time
for attempt in range(5):
    try:
        req = load_application_request(p)
        break
    except ValueError as e:
        if str(e) == "artifact changed before reading" and attempt < 4:
            wait_for_producer_quiet(p)
            time.sleep(2 ** attempt)
            continue
        raise

Prevention

When it happens

Trigger: Another process (a concurrent build, deploy script, or editor save) rewrites or replaces the artifact file after `_read_local` stats it but before it opens it; an atomic-rename deploy swaps in a new file at the same path mid-read; a self-updating tool touches its own output while the loader runs.

Common situations: Running the tasteforge pipeline in parallel with the build that produces its inputs; watching/editing the artifact directory during a session; CI steps racing on a shared workspace.

Related errors


AI-assisted analysis of affaan-m/ECC@8321021c54 (2026-09-16). Data as JSON: /api/errors/029a560496d43c5a. Report an issue: GitHub.

Appendix: source

Thrown at skills/taste-application/scripts/tasteforge/integration.py:132

    path = Path(raw)
    if not path.is_absolute() or str(path) != raw or ".." in path.parts:
        raise ValueError("artifact path must be canonical and absolute")
    parent = descriptor = None
    try:
        flags = os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK
        parent = _parent_fd(path)
        before = os.stat(path.name, dir_fd=parent, follow_symlinks=False)
        if not stat.S_ISREG(before.st_mode) or getattr(before, "st_flags", 0) & 0x40000000:
            raise ValueError("artifact must be a resident regular file")
        if expected_size is None:
            expected_size = before.st_size
        if parse_json and expected_size > _MAX_JSON:
            raise ValueError("JSON artifact exceeds local size limit")
        if before.st_size != expected_size:
            raise ValueError("artifact byte count mismatch")
        descriptor = os.open(path.name, flags, dir_fd=parent)
        if _identity(before) != _identity(os.fstat(descriptor)):
            raise ValueError("artifact changed before reading")
        digest, chunks, count = hashlib.sha256(), [], 0
        while data := os.read(descriptor, 65536):
            count += len(data)
            if count > expected_size:
                raise ValueError("artifact byte count exceeded during reading")
            digest.update(data)
            if parse_json:
                chunks.append(data)
        # Rewalk the named path: a pinned old directory fd can outlive a rename.
        fresh_parent = _parent_fd(path)
        try:
            after = os.stat(path.name, dir_fd=fresh_parent, follow_symlinks=False)
        finally:
            os.close(fresh_parent)
        if (_identity(before) != _identity(os.fstat(descriptor))
                or _identity(before) != _identity(after)):
            raise ValueError("artifact changed during reading")
        if expected_hash is not None and digest.hexdigest() != expected_hash:

View on GitHub (pinned to 8321021c54)