{"record":{"id":"029a560496d43c5a","repo":"affaan-m/ECC","slug":"artifact-changed-before-reading","errorCode":null,"errorMessage":"artifact changed before reading","messagePattern":"artifact changed before reading","errorType":"exception","errorClass":"ValueError","httpStatus":null,"severity":"error","filePath":"skills/taste-application/scripts/tasteforge/integration.py","lineNumber":132,"sourceCode":"    path = Path(raw)\n    if not path.is_absolute() or str(path) != raw or \"..\" in path.parts:\n        raise ValueError(\"artifact path must be canonical and absolute\")\n    parent = descriptor = None\n    try:\n        flags = os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK\n        parent = _parent_fd(path)\n        before = os.stat(path.name, dir_fd=parent, follow_symlinks=False)\n        if not stat.S_ISREG(before.st_mode) or getattr(before, \"st_flags\", 0) & 0x40000000:\n            raise ValueError(\"artifact must be a resident regular file\")\n        if expected_size is None:\n            expected_size = before.st_size\n        if parse_json and expected_size > _MAX_JSON:\n            raise ValueError(\"JSON artifact exceeds local size limit\")\n        if before.st_size != expected_size:\n            raise ValueError(\"artifact byte count mismatch\")\n        descriptor = os.open(path.name, flags, dir_fd=parent)\n        if _identity(before) != _identity(os.fstat(descriptor)):\n            raise ValueError(\"artifact changed before reading\")\n        digest, chunks, count = hashlib.sha256(), [], 0\n        while data := os.read(descriptor, 65536):\n            count += len(data)\n            if count > expected_size:\n                raise ValueError(\"artifact byte count exceeded during reading\")\n            digest.update(data)\n            if parse_json:\n                chunks.append(data)\n        # Rewalk the named path: a pinned old directory fd can outlive a rename.\n        fresh_parent = _parent_fd(path)\n        try:\n            after = os.stat(path.name, dir_fd=fresh_parent, follow_symlinks=False)\n        finally:\n            os.close(fresh_parent)\n        if (_identity(before) != _identity(os.fstat(descriptor))\n                or _identity(before) != _identity(after)):\n            raise ValueError(\"artifact changed during reading\")\n        if expected_hash is not None and digest.hexdigest() != expected_hash:","sourceCodeStart":114,"sourceCodeEnd":150,"githubUrl":"https://github.com/affaan-m/ECC/blob/8321021c54d670126ce3b2969d5deb880b4b0c2a/skills/taste-application/scripts/tasteforge/integration.py#L114-L150","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","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."],"exampleFix":"// before\n# build.sh runs continuously while load_application_request() reads its output\n// after\nrun_build_and_wait()   # producer finishes before consumer starts\nload_application_request(\"/out/request.json\")","handlingStrategy":"retry","validationCode":"import os, stat, time\ndef assert_stable(path: str, quiet_secs: float = 0.5) -> None:\n    a = os.stat(path)\n    time.sleep(quiet_secs)\n    b = os.stat(path)\n    if (a.st_size, a.st_mtime_ns) != (b.st_size, b.st_mtime_ns):\n        raise ValueError(f\"file still being written: {path}\")","typeGuard":null,"tryCatchPattern":"import time\nfor attempt in range(5):\n    try:\n        req = load_application_request(p)\n        break\n    except ValueError as e:\n        if str(e) == \"artifact changed before reading\" and attempt < 4:\n            wait_for_producer_quiet(p)\n            time.sleep(2 ** attempt)\n            continue\n        raise","preventionTips":["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."],"tags":["race-condition","filesystem","toctou"],"backgroundTag":"file-changed-during-read","analyzedSha":"8321021c54d670126ce3b2969d5deb880b4b0c2a","analyzedAt":"2026-09-16T10:08:13.343Z","contentChangedAt":"2026-09-16T10:08:13.343Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}