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
- Stop the producer before consuming: ensure the build step completes (and is quiescent) before running the application-request pipeline.
- 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.
- Re-run the load after the concurrent writer finishes; the file will be stable and identity will match.
- 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
- Ensure producers finish (and signal via a `.done` marker or lock) before consumers load artifacts.
- Publish artifacts atomically: write to a temp file, then `os.replace` into place.
- Never run the loader concurrently with the build that generates its inputs.
- Make artifact files immutable after publishing so accidental touches are impossible.
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
- artifact changed during 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/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)