fix(build): explain missing prompt stamps and reclaim abandoned staging #2710
Closed
github-sync
wants to merge 4 commits from
github/pr/2710 into main
pull from: github/pr/2710
merge into: MagicBeanDev:main
MagicBeanDev:main
MagicBeanDev:github/pr/2893
MagicBeanDev:github/pr/2890
MagicBeanDev:github/pr/2887
MagicBeanDev:github/pr/2878
MagicBeanDev:github/pr/2875
MagicBeanDev:github/pr/2874
MagicBeanDev:github/pr/2873
MagicBeanDev:github/pr/2871
MagicBeanDev:github/pr/2756
MagicBeanDev:github/pr/2897
MagicBeanDev:github/pr/2896
MagicBeanDev:github/pr/2718
MagicBeanDev:github/pr/2842
MagicBeanDev:github/pr/2691
MagicBeanDev:github/pr/2895
MagicBeanDev:github/pr/2743
MagicBeanDev:github/pr/2765
MagicBeanDev:github/pr/2708
MagicBeanDev:github/pr/2732
MagicBeanDev:github/pr/2755
MagicBeanDev:github/pr/2817
MagicBeanDev:github/pr/2876
MagicBeanDev:release-please--branches--main
MagicBeanDev:release-please--branches--main--release-notes
MagicBeanDev:github/pr/2778
MagicBeanDev:github/pr/2888
MagicBeanDev:github/pr/2866
MagicBeanDev:github/pr/2877
MagicBeanDev:github/pr/2800
MagicBeanDev:github/pr/2829
MagicBeanDev:github/pr/2850
MagicBeanDev:github/pr/2889
MagicBeanDev:github/pr/2882
MagicBeanDev:github/pr/2860
MagicBeanDev:github/pr/2849
MagicBeanDev:github/pr/2886
MagicBeanDev:github/pr/2794
MagicBeanDev:github/pr/2840
MagicBeanDev:github/pr/2830
MagicBeanDev:github/pr/2834
MagicBeanDev:github/pr/2719
MagicBeanDev:github/pr/2742
MagicBeanDev:github/pr/2741
MagicBeanDev:github/pr/2820
MagicBeanDev:github/pr/2771
MagicBeanDev:github/pr/2859
MagicBeanDev:github/pr/2858
MagicBeanDev:github/pr/2823
MagicBeanDev:github/pr/2757
MagicBeanDev:github/pr/2769
MagicBeanDev:github/pr/2844
MagicBeanDev:github/pr/2843
MagicBeanDev:github/pr/2673
MagicBeanDev:github/pr/2855
MagicBeanDev:github/pr/2739
MagicBeanDev:github/pr/2807
MagicBeanDev:github/pr/2851
MagicBeanDev:github/pr/2789
MagicBeanDev:github/pr/2814
MagicBeanDev:github/pr/2818
MagicBeanDev:github/pr/2774
MagicBeanDev:github/pr/2772
MagicBeanDev:github/pr/2750
MagicBeanDev:github/pr/2786
MagicBeanDev:github/pr/2785
MagicBeanDev:github/pr/2782
MagicBeanDev:github/pr/2779
MagicBeanDev:github/pr/2775
MagicBeanDev:github/pr/2706
MagicBeanDev:github/pr/2766
MagicBeanDev:github/pr/2747
MagicBeanDev:github/pr/2759
MagicBeanDev:github/pr/2745
MagicBeanDev:github/pr/2740
MagicBeanDev:github/pr/2733
MagicBeanDev:github/pr/2725
MagicBeanDev:github/pr/2727
MagicBeanDev:github/pr/2728
MagicBeanDev:github/pr/2729
MagicBeanDev:github/pr/2721
MagicBeanDev:github/pr/2722
MagicBeanDev:github/pr/2715
MagicBeanDev:github/pr/2714
MagicBeanDev:github/pr/2707
MagicBeanDev:github/pr/2703
MagicBeanDev:github/pr/2705
MagicBeanDev:github/pr/2697
MagicBeanDev:github/pr/2679
MagicBeanDev:github/pr/2676
MagicBeanDev:github/pr/2669
MagicBeanDev:docs/what-hookshot-connects-to
MagicBeanDev:fix/hdr-encode-test
MagicBeanDev:fix/main-tests
MagicBeanDev:fix/play-cache-files
MagicBeanDev:perf/game-barrier-order
MagicBeanDev:feat/heap-lazy
MagicBeanDev:feat/texture-detail
MagicBeanDev:perf/astc-opaque-bc1
MagicBeanDev:fix/fs-dir-lock
MagicBeanDev:fix/framegen-native-kept
MagicBeanDev:fix/update-no-downgrade
MagicBeanDev:port/k10-gcn-f16-mirrors
MagicBeanDev:fix/framegen-mult-cap
MagicBeanDev:chore/release-as-2.1.1
MagicBeanDev:perf/pcache-load-switch
MagicBeanDev:fix/prepare-progress-log
MagicBeanDev:fix/update-closes-stale-game
MagicBeanDev:fix/hdr-scaled-sampled
MagicBeanDev:perf/first-start-preset
MagicBeanDev:perf/zero-upload
MagicBeanDev:fix/vcore-escape-2s
MagicBeanDev:test/fake-gh-atomic
MagicBeanDev:perf/batch-idle-flush
MagicBeanDev:test/finish-stub-a
MagicBeanDev:chore/record-2.1.0
MagicBeanDev:fix/main-red-tests
MagicBeanDev:fix/test-display-startup-driver-cache
MagicBeanDev:feat/mesh-shader-mode
MagicBeanDev:fix/menu-probes-at-startup
MagicBeanDev:fix/fps-counter-classic
MagicBeanDev:fix/test-log-batch-flake
MagicBeanDev:fix/narrow-screen-hud
MagicBeanDev:ci/prereleases
MagicBeanDev:release/v2.0.15
MagicBeanDev:hotfix/v2.0.15
MagicBeanDev:fix/present-ahead-replay
MagicBeanDev:release/v2.0.13
MagicBeanDev:fix/bracket-paths-2013
MagicBeanDev:fix/hdr-overlays-2013
MagicBeanDev:fix/fps-counter-scale-2013
MagicBeanDev:fix/stick-seen-at-rest
MagicBeanDev:hotfix/menu-fits-screen
MagicBeanDev:fix/f1-always-opens-menu
MagicBeanDev:fix/ini-keep-lines
MagicBeanDev:claude/cranky-pike-7cf247
MagicBeanDev:perf/texture-answer-flat
MagicBeanDev:fix/stamp-test-modules
MagicBeanDev:fix/appimage-fonts
MagicBeanDev:fix/display-awake
MagicBeanDev:release/v2.0.12
MagicBeanDev:fix/discord-windows
MagicBeanDev:rc/v2.0.12-tests
MagicBeanDev:fix/dlss-keep-feature
MagicBeanDev:rc/v2.0.12
MagicBeanDev:button-icons
No reviewers
Labels
Clear labels
accessibility
Barrier affecting people with disabilities
autorelease: pending
autorelease: tagged
bug
Something isn't working
dependencies
Pull requests that update a dependency file
documentation
Improvements or additions to documentation
duplicate
This issue or pull request already exists
enhancement
New feature or request
github_actions
Pull requests that update GitHub Actions code
good first issue
Good for newcomers
help wanted
Extra attention is needed
invalid
This doesn't seem right
question
Further information is requested
testing wanted
wontfix
This will not be worked on
No labels
accessibility
autorelease: pending
autorelease: tagged
bug
dependencies
documentation
duplicate
enhancement
github_actions
good first issue
help wanted
invalid
question
testing wanted
wontfix
Milestone
Clear milestone
No items
No milestone
Assignees
Clear assignees
No assignees
1 participant
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".
No due date set.
Dependencies
No dependencies set
Reference
MagicBeanDev/Hookshot!2710
Loading…
Reference in a new issue
No description provided.
Delete branch "github/pr/2710"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
What this is for: answer the two review notes on #2698: give an ordinary first build a clear message, and reclaim staging directories left by interrupted prompt generation. What was checked: Linux source checks and real finish-stage/hard-kill recovery; both Windows source-check shards passed on the fork before the new commit reached this PR. Not tested: a Windows build using game assets, Windows closed-console/power-loss recovery, or very long Windows paths.
Refs #2698.
What changed
prompts: regenerated (no stamp yet). Existing unreadable or malformed stamps keep their diagnostic..prompts-*directories directly beside its prompt output. Files, entries listed as links or Windows reparse points, the output itself and other neighbours are excluded. Cleanup trouble is logged rather than failing the build.rmtree. A live or uncertain owner is kept; a released, initialized owner permits immediate cleanup. Unmarked legacy directories get a 24-hour grace period. Without descriptor-safe removal, cleanup is skipped and says so.prompts: kept staging directory …line and the build continues.26edc2c1is 62 lines smaller overall: 87 added, 149 removed across code, tests and documentation.Asset generation, input fingerprints, output validation, failed-generation fallback and runtime-derived-file clearing are unchanged.
Review follow-up
Nitnek's parent-swap reproduction was confirmed on
47d9a1d0: the sweep opened and locked an external owner file after the checked directory was replaced by a link. In that interleaving,rmtreethen refused the top-level link and external contents remained unchanged, on Python 3.12.0 and 3.12.16. The POSIX fix observes no external owner open or lock in the same regression.The Windows identity-based attempt in
7ed87cedfailed its new cleanup test on the real Windows runner despite passing the Linux stand-ins. Following Joey-Magnus's suggestion,26edc2c1removes that Windows approach rather than adding another identity workaround. The replacement tests use real filesystem operations: fresh owned staging is kept, old owned and legacy staging is removed, and removal refusals are nonfatal. Open-file and read-only-file deletion cases run only on real Windows; Linux does not pretend to have those Windows semantics.Source-check evidence
Latest head:
26edc2c183241c03076b3c893cd9fe9cf6e176b1.Linux, Python 3.12.16: the focused file reports 39 passed and 2 Windows-only tests skipped. The complete source check reports:
These checks ran before the commit, on the exact source committed, through core-suppression wrappers. No core dumps were recorded. The POSIX swap, ownership and descriptor-safety checks remain covered.
The commit was then tested on a scratch draft in the fork before this PR received it. In the fork source-check run:
Python source checks (windows-2022, part 1/2): PASS.Python source checks (windows-2022, part 2/2): PASS, including the part that failed on7ed87ced.curl: (7) Couldn't connect to server. This was a fork-only download failure; the upstream AppImage job for this PR passed.The new commit is now on this PR. Upstream's own run on the new head is the authoritative result and is still in progress; the fork preflight is not a claim that every upstream check has finished.
Real Linux finish-stage evidence
These earlier runs are pinned to
7ed87ced, not relabelled as the latest head; the subsequent change is the Windows age policy and its tests, retaining the POSIX cleanup protections.2026-11-12; Arch Linux
7.2.15-1-omarchy, Core i9-13900K (32 logical CPUs), Python 3.12.16, all prompt packages present. The real finish-stage statements ran in a fresh reflink copy of an existing built game folder, with real game assets and no drawing/encoder stand-ins. Already-built binaries were reused; compilation, shader preparation and gameplay were not run. Other work was running, including another game and compilers.prompts: regenerated (no stamp yet)prompts: unchanged, keptprompts: unchanged, keptprompts: regenerated (no stamp yet)All four stages exited 0 and printed the build's completion line. All 16 prompt originals matched the stamp's recorded sizes and SHA-256 hashes after each stage. The two-day-old legacy directory was removed; the fresh one remained.
The forced real generation was killed after
prompts: ps4, the third face, 40.826 seconds into generation. Its live owner lock refused a second lock. SIGKILL left a partial staging directory and no stamp, while the previous originals still matched their saved manifest; the next real finish stage removed that directory and regenerated successfully. The sameSOURCE_DATE_EPOCHkept font-header timestamps comparable, and the regenerated output hashes matched the first run.Limits
Windows cleanup is age-based and path-based, not equivalent to POSIX descriptor anchoring or protection against coordinated malicious renames. A trusted build/output folder is required; someone able to rename its entries during a build can already replace the game itself. A failed removal can leave stale material for a later build; the build does not force access or change file permissions to remove it.
Windows native cleanup fixtures have now run, but no Windows game-asset generation, closed-console/power-loss recovery or very-long-path test is claimed. The real finish runs are Linux build-tool evidence, not gameplay evidence. No generated game files are included.
Assisted-by: GPT-6 Astra
Co-Authored-By: GPT-6 Astra [email protected]
Reproduced parent-directory race in
sweep_promptsOn head
47d9a1d0d7527c7ea2db7a8dfc57d712e5ffa161, I reproduced a deterministic interleaving that violates the sweeper's documented no-follow invariant:entry.stat(follow_symlinks=False)checks a real.prompts-*directory..ownerfile.prompt_ownerresolves the swapped parent and opens and locks the external.ownerinode. A second real descriptor's nonblockingflockis refused while this lock is held and succeeds again after the context exits.O_NOFOLLOWon the leaf does not protect its parent components.shutil.rmtree(entry.path)rejects the top-level symlink. No external deletion or content change was observed. This is evidence of an external open/lock, not a claim of demonstrated save loss or a remote exploit.The ordinary abandoned-directory cleanup and a symlink present before scanning are separate controls and behave as expected. The race probe executes the unchanged public
prompt_ownerandsweep_promptsfunction ASTs with standard-library dependencies and synthetic directories; it does not run the complete prompt-asset build or validate Windows locking.Could the traversal/owner locking/removal be anchored to checked directory handles, with relative no-follow operations and identity checks (or the appropriate Windows equivalent), and a regression covering the parent replacement? Rechecking only the leaf's regular-file status does not close this interval.
AI-assisted review and reproduction: GPT-5.6-terra and GPT-6.1-sol. No game data used.
@Nitnek thanks for the precise interleaving. I confirmed your result on
47d9a1d0: the external.ownerwas opened and locked; a second descriptor could not take its lock until the context closed.rmtreethen refused the top-level link, and the external contents were unchanged. That holds on Python 3.12.16 and 3.12.0, the oldest release allowed by our pinned NumPy dependency.44cb7eb9fixes the POSIX path: open the parent and candidate directories with no-follow flags, compare the candidate's device/inode, open.ownerrelative to that checked descriptor, and remove relative to the parent descriptor only with descriptor-safermtree. The swap regression now observes no external owner open or lock. With the Windows identity correction in7ed87ced, all 34 existing tests and seven new tests pass.Windows uses
os.statfor directory file IDs (DirEntry.statsupplies zero IDs), checks the opened owner's resolved parent identity before locking, and checks the candidate again before removal; unknown or changed identity is reported and keeps it. This is still path-based, not equivalent to POSIX: a transient external open before the identity check and later coordinated rename races remain possible. Native Windows execution is untested; stand-ins cover those identity/error paths and successful cleanup of both owned and old legacy directories.This is a local filesystem race, not evidence of external deletion or a remote exploit. An attacker able to rename entries inside the game folder during a build can already replace the game itself, but the cleanup should not make that trust boundary worse.
The description now has the finding, both commits and four real finish-stage runs on the new head, including the next build after a real mid-generation
kill -9.The head
7ed87cedfails its own new test on the Windows runner. In "Python source checks (windows-2022, part 2/2)", test_windows_zero_direntry_ids_use_stat_for_owned_and_legacy_cleanup fails at tests/test_prompt_skip.py:385: "owned Windows staging must not silently skip on DirEntry's zero ids". So on a real Windows file system the owned staging directory is still kept. Merged into current main on Linux, all 41 tests in test_prompt_skip.py pass. The failure is specific to the Windows path, and Windows is where most builds run and the one platform you note as untested natively. The job log should show which "prompts: kept staging directory ... (reason)" line it printed. My guess is the identity check against prompt_owner_parent or os.stat, or rmtree meeting a handle that is still open.On scope: this is about 280 lines of descriptor and identity handling to reclaim a temp folder that an interrupted build leaves behind. The POSIX half answers Nitnek's swap race well. If the Windows half stays hard to get right, consider limiting Windows to the 24-hour age rule and leaving locked or unknown directories alone. The new imports are all stdlib, so prompt_key() needs no change.
Thanks, and sorry for the red Windows job: I pushed that commit without waiting for its result. I am taking your suggestion: on Windows only the 24-hour age rule, a locked or unremovable directory left alone, and the Windows identity code removed, so the change gets smaller and the POSIX half stays as reviewed. This time the Windows job runs on my fork before the pull request sees the commit.
Thanks, I took your suggestion in
26edc2c1. On Windows only the 24-hour age rule is left; a directory Windows refuses to remove (locked, in use, read-only) is kept with the existing line and the build goes on. The Windows owner-identity code is gone and the POSIX half is as you reviewed it. The commit removes 149 lines and adds 87, 62 fewer overall; the Windows test cases that need real open-file and read-only behaviour now run only on Windows.Both
windows-2022parts passed on a run on my fork before the commit reached this pull request. This pull request's own run on26edc2c1: the Windows part that failed before (part 2/2) passes; part 1/2 was still running when this was written. The description is updated for the simpler rule and says which result ran where.Thanks, this addresses Joey-Magnus's CHANGES_REQUESTED at
7ed87ce: the red Windows test and the identity machinery behind it are gone, and Windows now only uses the 24-hour age rule, which is what he suggested. Windows part 2/2 and everything else is green on26edc2c.What I checked:
.ownerand only deletes when the lock is free and the owner wrote the ready byte. The builder creates the owner file, locks it and only then writes the ready byte, so a sweeper that sneaks in between sees "ownership not established" and keeps the directory. Legacy unmarked directories get 24 h.test_live_owner_is_kept_even_when_oldandtest_generation_holds_its_owner_lockcover a sweep during a running build.dir_fdremoval, with a regression test..prompts-*directories at least a day old and keeps anything the OS refuses to remove. A running build's staging is fresh, so it is not touched; a locked or open file makesrmtreefail and the directory is kept. The Windows-only open-file and read-only tests ran on the real runner..prompts-*directories next to the prompt output, never the output itself. Staging holds only generated prompt files, nothing leaves the PC.prompts: regenerated (no stamp yet)) is accurate and the docs paragraph describes the sweep correctly.Nits, none blocking:
no stamp yetis also what you see after an interrupted build or a manually deleted stamp, so "yet" reads a little odd; fine as is.The in-game side does not apply here: this is build-tool behaviour, and the real finish-stage runs in the description cover it. Good to merge from my side.