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
- Pause artifact replacement (deploys, builds) until all loads complete, or coordinate with a lock/`.done` marker.
- Retry the load after the swap finishes — with a stable file all three identity checks will agree.
- Adopt atomic-rename publishing plus versioned filenames so consumers read immutable files that are never modified in place.
- 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
- Publish via atomic rename to versioned, immutable filenames so in-flight reads never see a modified file.
- Coordinate deploys and loads: hold a lock or drain consumers before swapping artifacts.
- After any concurrent-swap incident, refresh the request's hash/size before retrying the load.
- Run consumers against a snapshot/checkpoint of the artifact directory, not the live build output.
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
- artifact changed before reading
- Invalid ECC repo root: unreadable package.json at
- Legacy sync path changed before removal
- Legacy sync path changed before removal; preserved…
- Legacy sync path changed while opening
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 valueView on GitHub (pinned to 8321021c54)