affaan-m/ECC · error · ValueError

artifact changed during reading

Error message

artifact changed during reading

What it means

After reading, `_read_local` re-walks the named path with a fresh parent directory fd (because a pinned old directory fd can outlive a rename) and compares three identity tuples: the original pre-open stat, the open descriptor's `fstat`, and the post-read re-stat. If any differ, the file was replaced or modified at some point during the read — including a rename swap via a new parent directory — so the bytes just read cannot be trusted and are discarded.

Solutions

  1. Pause artifact replacement (deploys, builds) until all loads complete, or coordinate with a lock/`.done` marker.
  2. Retry the load after the swap finishes — with a stable file all three identity checks will agree.
  3. Adopt atomic-rename publishing plus versioned filenames so consumers read immutable files that are never modified in place.
  4. Re-issue the application request with a fresh hash/size after the file settles, then load again.

Example fix

// before
mv /out/artifact.new.json /out/artifact.json &  # concurrent rename during read
req = load_application_request("/out/request.json")
// after
mv /out/artifact.new.json /out/artifact.json
wait_until_stable("/out/artifact.json")
req = load_application_request("/out/request.json")
Defensive patterns

Strategy: retry

Validate before calling

import os, time, hashlib
def sha256_file(path: str) -> str:
    h = hashlib.sha256()
    with open(path, "rb") as f:
        for chunk in iter(lambda: f.read(65536), b""):
            h.update(chunk)
    return h.hexdigest()
def assert_immutable_published(path: str) -> None:
    s1, d1 = os.stat(path), sha256_file(path)
    time.sleep(0.5)
    s2, d2 = os.stat(path), sha256_file(path)
    if (s1.st_ino, s1.st_mtime_ns, d1) != (s2.st_ino, s2.st_mtime_ns, d2):
        raise ValueError(f"artifact being replaced concurrently: {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 during reading" and attempt < 4:
            time.sleep(2 ** attempt)
            continue
        raise

Prevention

When it happens

Trigger: The artifact file is renamed away and replaced (atomic deploy, `mv`, build rewrite) while `_read_local` is reading it; the file's mtime/ctime/size changes mid-read; the containing directory is swapped so the fresh-path stat resolves to a different inode than the one read.

Common situations: Deploy pipelines that `mv` new artifacts into place while a consumer is loading them; editors with autosave rewriting the file mid-read; shared CI workspaces with overlapping jobs.

Related errors


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

Appendix: source

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

        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:
            raise ValueError("artifact SHA-256 mismatch")
        return _load_json(b"".join(chunks)) if parse_json else None
    except (OSError, AttributeError) as exc:
        raise ValueError("local artifact unavailable or unsafe") from exc
    finally:
        if descriptor is not None:
            os.close(descriptor)
        if parent is not None:
            os.close(parent)


def load_application_request(path: str | Path) -> dict:
    """Load only a bounded resident request; never follow a config symlink."""
    value = _read_local(str(Path(path).absolute()), parse_json=True)
    if not isinstance(value, dict):
        raise ValueError("application request must be a JSON object")
    return value

View on GitHub (pinned to 8321021c54)