{"record":{"id":"3bffe4bdd0521f81","repo":"affaan-m/ECC","slug":"artifact-changed-during-reading","errorCode":null,"errorMessage":"artifact changed during reading","messagePattern":"artifact changed during reading","errorType":"exception","errorClass":"ValueError","httpStatus":null,"severity":"error","filePath":"skills/taste-application/scripts/tasteforge/integration.py","lineNumber":149,"sourceCode":"        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:\n            raise ValueError(\"artifact SHA-256 mismatch\")\n        return _load_json(b\"\".join(chunks)) if parse_json else None\n    except (OSError, AttributeError) as exc:\n        raise ValueError(\"local artifact unavailable or unsafe\") from exc\n    finally:\n        if descriptor is not None:\n            os.close(descriptor)\n        if parent is not None:\n            os.close(parent)\n\n\ndef load_application_request(path: str | Path) -> dict:\n    \"\"\"Load only a bounded resident request; never follow a config symlink.\"\"\"\n    value = _read_local(str(Path(path).absolute()), parse_json=True)\n    if not isinstance(value, dict):\n        raise ValueError(\"application request must be a JSON object\")\n    return value","sourceCodeStart":131,"sourceCodeEnd":167,"githubUrl":"https://github.com/affaan-m/ECC/blob/8321021c54d670126ce3b2969d5deb880b4b0c2a/skills/taste-application/scripts/tasteforge/integration.py#L131-L167","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","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."],"exampleFix":"// before\nmv /out/artifact.new.json /out/artifact.json &  # concurrent rename during read\nreq = load_application_request(\"/out/request.json\")\n// after\nmv /out/artifact.new.json /out/artifact.json\nwait_until_stable(\"/out/artifact.json\")\nreq = load_application_request(\"/out/request.json\")","handlingStrategy":"retry","validationCode":"import os, time, hashlib\ndef sha256_file(path: str) -> str:\n    h = hashlib.sha256()\n    with open(path, \"rb\") as f:\n        for chunk in iter(lambda: f.read(65536), b\"\"):\n            h.update(chunk)\n    return h.hexdigest()\ndef assert_immutable_published(path: str) -> None:\n    s1, d1 = os.stat(path), sha256_file(path)\n    time.sleep(0.5)\n    s2, d2 = os.stat(path), sha256_file(path)\n    if (s1.st_ino, s1.st_mtime_ns, d1) != (s2.st_ino, s2.st_mtime_ns, d2):\n        raise ValueError(f\"artifact being replaced concurrently: {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 during reading\" and attempt < 4:\n            time.sleep(2 ** attempt)\n            continue\n        raise","preventionTips":["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."],"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"}