{"record":{"id":"0b2ba2ae8b2f71e2","repo":"666ghj/MiroFish","slug":"output-path-changed-during-update","errorCode":null,"errorMessage":"output path changed during update","messagePattern":"output path changed during update","errorType":"exception","errorClass":"StarHistoryError","httpStatus":null,"severity":"error","filePath":"scripts/star_history.py","lineNumber":1333,"sourceCode":"                dir=target.parent,\n                prefix=f\".{target.name}.\",\n                suffix=\".tmp\",\n                delete=False,\n            ) as handle:\n                handle.write(payloads[relative])\n                handle.flush()\n                os.fsync(handle.fileno())\n                temporary_paths[relative] = Path(handle.name)\n            os.chmod(temporary_paths[relative], 0o644)\n\n        _validate_svg(temporary_paths[LIGHT_SVG_RELATIVE].read_bytes())\n        _validate_svg(temporary_paths[DARK_SVG_RELATIVE].read_bytes())\n        json.loads(temporary_paths[STATE_RELATIVE].read_bytes())\n\n        for relative in OUTPUT_RELATIVES:\n            checked_target = _safe_target(workspace, relative, create_parent=False)\n            if checked_target != targets[relative]:\n                raise StarHistoryError(\"output path changed during update\")\n            os.replace(temporary_paths[relative], targets[relative])\n            temporary_paths.pop(relative, None)\n    except OSError as exc:\n        raise StarHistoryError(\"could not atomically replace Star History outputs\") from exc\n    finally:\n        for temporary in temporary_paths.values():\n            try:\n                temporary.unlink(missing_ok=True)\n            except OSError:\n                pass\n\n    check_workspace(workspace)\n    return True\n\n\ndef check_workspace(workspace: Path) -> None:\n    state = load_state(workspace, require_canonical=True)\n    expected = _output_payloads(state)","sourceCodeStart":1315,"sourceCodeEnd":1351,"githubUrl":"https://github.com/666ghj/MiroFish/blob/b5b53acc57189a4a42e44a23e149dc655c98fe82/scripts/star_history.py#L1315-L1351","documentation":"Raised by _write_outputs during the atomic publish loop: for each output (history.json, star-history-light.svg, star-history-dark.svg) it re-resolves the destination with _safe_target and compares against the path captured before writing temp files. A mismatch means the workspace layout moved underneath the update — typically a symlink swap or directory replacement between the two resolutions — and the update aborts before os.replace to avoid writing through a redirected path.","triggerScenarios":"A symlink at static/image/star-history-light.svg (or a parent dir like .github/star-history) is retargeted while the job runs; a concurrent process (another CI job, a deploy hook) rewrites/replaces directories during the seconds between target capture (line 1300) and re-check (line 1331); renaming the workspace root concurrently.","commonSituations":"Two CI jobs or scheduled runs executing simultaneously in one checkout; build tooling that swaps static/ via symlink for atomic site deploys; developers re-arranging directories while a long backfill runs; workspace on a network mount with path instability.","solutions":["Serialize runs: only one star-history update per checkout at a time (CI mutex, lockfile around the execute() entry point).","Remove symlinks from the output paths — keep static/image/ and .github/star-history/ as real directories tracked by git.","Retry the command once after the race clears; the write is idempotent (payloads are deterministic from history.json) so a clean retry is safe.","If a deploy pipeline must swap directories, make it wait on the generator's completion marker rather than racing it."],"exampleFix":"# before: two concurrent jobs race the same workspace\njob1: python scripts/star_history.py update   #\njob2: python scripts/star_history.py update   # path re-resolves differently -> raises\n\n# after: serialize with a lock around execute()\nwith filelock.FileLock(\".star-history.lock\", timeout=600):\n    result = execute(\"update\", github=gh, clock=clock, workspace=root)","handlingStrategy":"retry","validationCode":"from pathlib import Path\n\ndef outputs_are_stable(workspace: Path) -> bool:\n    # resolve twice; if paths move between resolutions, another actor is mutating the tree\n    first = {r: _safe_target(workspace, r, create_parent=False) for r in OUTPUT_RELATIVES}\n    second = {r: _safe_target(workspace, r, create_parent=False) for r in OUTPUT_RELATIVES}\n    return first == second and not any(p.is_symlink() for p in first.values())","typeGuard":null,"tryCatchPattern":"for attempt in range(2):\n    try:\n        changed = _write_outputs(root, state)\n        break\n    except StarHistoryError as exc:\n        if \"output path changed\" not in str(exc) or attempt == 1:\n            raise\n    # race with another writer; release and retry once","preventionTips":["Serialize all star-history runs against one checkout with a lock.","Keep output locations as plain git-tracked directories, never symlinks.","Don't reorganize static/ or .github/star-history/ while a run is in flight."],"tags":["concurrency","filesystem","symlink","atomic-write"],"backgroundTag":null,"analyzedSha":"b5b53acc57189a4a42e44a23e149dc655c98fe82","analyzedAt":"2026-08-14T22:29:33.146Z","schemaVersion":2},"datasetVersion":"2026-08-15T22:17:37.221Z"}