fix(builder): a game dump Windows refuses to read says why and what to do, not "Permission denied" #641

Merged
HumanBean merged 1 commit from llun/Longshot:fix/nup-read-error into main 2026-11-15 03:25:39 +00:00
Member

Type: fix · Risk: low · Tested on: Windows 11 (the build reading the game files; no GPU involved) · Needs: none

Why

A player's build can stop at "Read the game files" with only nup.py stopped (exit status 1): PermissionError: [Errno 13] Permission denied. It reads like a folder permission problem, but it isn't one: Windows opened the game dump fine and then
refused to read one part of it. That happens when another program holds a lock on that part of the file, or when the
drive can't read it back (a bad spot on a disk, a USB drive that drops out). The player gets no hint of either, or of
what to do. See #295, where a player hit this partway through a 25.7 GB dump.

How to reproduce

Seen on: Windows 11 (any GPU), the launcher building nightly 170 (main @ 6c9edffe), and v3.0.0-rc.3 in #295
Settings and switches: defaults
Steps: have another process hold an exclusive byte-range lock (LockFileEx) on part of the .nup's romfs data, then
build from that .nup in the launcher
Expected: a message that says the file is locked by another program and what to do
Actual: nup.py stopped (exit status 1): PermissionError: [Errno 13] Permission denied

What changed

When the system refuses a read of the .nup's data, nup.py now stops with a message that names the file and the point
in it, says why (another program has that part locked, or the drive couldn't read it), and what to do. It gets the real
reason from the Windows error the C runtime kept, which Python's "Errno 13" hides.

  • nx2/nup.py: Nup._data() is the one read of the package's data (inner_is_zstd, is_packed, copy_to). On an
    OSError it raises SystemExit(unreadable(...)). windows_error() reads the error with the C runtime's
    _get_doserrno, which is looked up when nup.py loads: importing ctypes after the failure ran file calls of its own
    that replaced the error. unreadable() gives one of three messages:
    • errors 32 and 33 (sharing or lock violation): another program has that part of the file locked; close it, or copy
      the .nup to another folder and build from the copy
    • errors 21, 23, 27, 30, 31, 1117 and 1392, or EIO on Linux: the drive could not read it; copy the .nup to another
      drive, and if Windows can't copy it either, check the drive or dump the game again onto another drive
    • anything else: it could not be read, with the error, and the same advice
      The header and TOC reads when the package opens are unchanged. Errors from writing the extracted files (the full
      drive in out_of_space()) still reach extract() as they did.
  • nx2/test_nup_read_error.py (new, game-free): an ordinary read is unchanged. Each message comes out for its errors.
    The message names the file, not its folder. On Windows, a real lock held by another process gives the lock message
    with error 33.

Measurements

Nothing to measure: only the message of a failed read of the dump changes (a full read of a real dump through the changed reads matches main, see Testing).

Flags and settings

None

Testing

Tested:

  • Windows 11, in the launcher, building nightly 170's tools from a real dump, with another process locking 1 MB of it
    at 247 MB (a romfs file early in the extraction). Main's nup.py stops with PermissionError: [Errno 13] Permission denied. This PR's nup.py stops with "Windows would not let the build read <the .nup> at 247 of 24435 MB: another
    program has that part of the file locked (Windows error 33). ..." The screenshots are in the first comment.
  • The same dump read whole through the changed reads (nup.py --verify --title 1): 186,588 records, 159,537 unique
    extents, 0 bad, about 20 s, the same as main's nup.py on it.
  • python -I -B tests/run_all.py on this PR @ 0f7d98dd (main @ f5718d03), Python 3.15 with llvm-mingw on PATH:
    491 passed, 18 failed, 79 skipped. Main @ f5718d03 on this PC: 491 passed, 17 failed, the same 17 (main's own here:
    test_appimage_player_build, test_bake_portability, test_cpm_discovery, test_crash_dialog, test_env_on,
    test_fpx_sticky, test_lift_cache, test_lift_cleanup_error, test_linux_preflight, test_make_game_cache,
    test_oot_state_tap, test_readback_late, test_spawn_emit, test_tested_configurations, test_vk_pgo,
    test_vram_budget_row, test_walk_key). The 18th, tests/test_longshot_ctl.py, timed out a 1 s log wait
    (Sessions.test_utf8_cursor_keeps_incomplete_characters) under the full run's load; alone it passes 3 of 3 here and
    on main, and this PR doesn't touch it. The new check passes.
  • nx2/test_nup_read_error.py fails on main (the refused read comes out as the bare OSError) and passes here. All
    eight nx2/test_nup_*.py checks pass.

Not tested:

  • A real drive error (a bad sector, a drive that drops out). It can't be made safely here. The game-free check covers
    its message through the Windows error numbers and Linux's EIO.
  • Linux in a build. Nothing in the change is Windows-only except reading the Windows error, and the game-free check runs
    the same reads there.

Must be tested: None

Risks

What could break: a build from a .nup on another OS or Python, if Nup._data() changed an ordinary read. It doesn't:
it is the same read(), with tell() before it. The full verify above reads every file of a dump through it.

Turning it off: revert the commit

Compatibility: Nothing changes: saves, the options file, the caches, the vk ABI and the builder's tools are as they
were. Only the message of a failed read differs.

Issues

See #295

Checklist

Everyone

  • One topic.
  • The title is a Conventional Commit (type(scope): description) of the status line's type.
  • Why gives the reason plainly and links the issues that fit (the tracker searched).
  • Measurements has a verdict, or one line saying why there is nothing to measure.
  • Testing names what was tested, what wasn't, and what must still be tested, where and how.
  • Rebased on current main.
  • python -I -B tests/run_all.py passes (any failure that also fails on main is named).
  • No game files, dumps, build output or generated files.
  • third_party/ untouched (a new folder with a PROVENANCE.md instead).
  • The license is OK: GPL-3.0-or-later, nothing incompatible and nothing decompiled.

When it applies

  • AI assistance recorded with Assisted-by: / Co-Authored-By: trailers.
  • Screenshots of the same spot, captioned like the Measurements columns, for a change that shows on screen.
  • A game-free test added for the fix, failing on main and passing here.

🤖 Generated with Claude Code

**Type:** fix · **Risk:** low · **Tested on:** Windows 11 (the build reading the game files; no GPU involved) · **Needs:** none ## Why A player's build can stop at "Read the game files" with only `nup.py stopped (exit status 1): PermissionError: [Errno 13] Permission denied`. It reads like a folder permission problem, but it isn't one: Windows opened the game dump fine and then refused to read one part of it. That happens when another program holds a lock on that part of the file, or when the drive can't read it back (a bad spot on a disk, a USB drive that drops out). The player gets no hint of either, or of what to do. See #295, where a player hit this partway through a 25.7 GB dump. ## How to reproduce **Seen on:** Windows 11 (any GPU), the launcher building nightly 170 (main @ 6c9edffe), and v3.0.0-rc.3 in #295 **Settings and switches:** defaults **Steps:** have another process hold an exclusive byte-range lock (`LockFileEx`) on part of the .nup's romfs data, then build from that .nup in the launcher **Expected:** a message that says the file is locked by another program and what to do **Actual:** `nup.py stopped (exit status 1): PermissionError: [Errno 13] Permission denied` ## What changed When the system refuses a read of the .nup's data, `nup.py` now stops with a message that names the file and the point in it, says why (another program has that part locked, or the drive couldn't read it), and what to do. It gets the real reason from the Windows error the C runtime kept, which Python's "Errno 13" hides. - nx2/nup.py: `Nup._data()` is the one read of the package's data (`inner_is_zstd`, `is_packed`, `copy_to`). On an `OSError` it raises `SystemExit(unreadable(...))`. `windows_error()` reads the error with the C runtime's `_get_doserrno`, which is looked up when nup.py loads: importing ctypes after the failure ran file calls of its own that replaced the error. `unreadable()` gives one of three messages: - errors 32 and 33 (sharing or lock violation): another program has that part of the file locked; close it, or copy the .nup to another folder and build from the copy - errors 21, 23, 27, 30, 31, 1117 and 1392, or EIO on Linux: the drive could not read it; copy the .nup to another drive, and if Windows can't copy it either, check the drive or dump the game again onto another drive - anything else: it could not be read, with the error, and the same advice The header and TOC reads when the package opens are unchanged. Errors from writing the extracted files (the full drive in `out_of_space()`) still reach `extract()` as they did. - nx2/test_nup_read_error.py (new, game-free): an ordinary read is unchanged. Each message comes out for its errors. The message names the file, not its folder. On Windows, a real lock held by another process gives the lock message with error 33. ## Measurements Nothing to measure: only the message of a failed read of the dump changes (a full read of a real dump through the changed reads matches main, see Testing). ### Flags and settings None ## Testing **Tested:** - Windows 11, in the launcher, building nightly 170's tools from a real dump, with another process locking 1 MB of it at 247 MB (a romfs file early in the extraction). Main's `nup.py` stops with `PermissionError: [Errno 13] Permission denied`. This PR's `nup.py` stops with "Windows would not let the build read <the .nup> at 247 of 24435 MB: another program has that part of the file locked (Windows error 33). ..." The screenshots are in the first comment. - The same dump read whole through the changed reads (`nup.py --verify --title 1`): 186,588 records, 159,537 unique extents, 0 bad, about 20 s, the same as main's `nup.py` on it. - `python -I -B tests/run_all.py` on this PR @ 0f7d98dd (main @ f5718d03), Python 3.15 with llvm-mingw on PATH: 491 passed, 18 failed, 79 skipped. Main @ f5718d03 on this PC: 491 passed, 17 failed, the same 17 (main's own here: test_appimage_player_build, test_bake_portability, test_cpm_discovery, test_crash_dialog, test_env_on, test_fpx_sticky, test_lift_cache, test_lift_cleanup_error, test_linux_preflight, test_make_game_cache, test_oot_state_tap, test_readback_late, test_spawn_emit, test_tested_configurations, test_vk_pgo, test_vram_budget_row, test_walk_key). The 18th, `tests/test_longshot_ctl.py`, timed out a 1 s log wait (`Sessions.test_utf8_cursor_keeps_incomplete_characters`) under the full run's load; alone it passes 3 of 3 here and on main, and this PR doesn't touch it. The new check passes. - `nx2/test_nup_read_error.py` fails on main (the refused read comes out as the bare `OSError`) and passes here. All eight `nx2/test_nup_*.py` checks pass. **Not tested:** - A real drive error (a bad sector, a drive that drops out). It can't be made safely here. The game-free check covers its message through the Windows error numbers and Linux's EIO. - Linux in a build. Nothing in the change is Windows-only except reading the Windows error, and the game-free check runs the same reads there. **Must be tested:** None ## Risks **What could break:** a build from a .nup on another OS or Python, if `Nup._data()` changed an ordinary read. It doesn't: it is the same `read()`, with `tell()` before it. The full verify above reads every file of a dump through it. **Turning it off:** revert the commit **Compatibility:** Nothing changes: saves, the options file, the caches, the vk ABI and the builder's tools are as they were. Only the message of a failed read differs. ## Issues See #295 ## Checklist ### Everyone - [x] One topic. - [x] The title is a Conventional Commit (`type(scope): description`) of the status line's type. - [x] Why gives the reason plainly and links the issues that fit (the tracker searched). - [x] Measurements has a verdict, or one line saying why there is nothing to measure. - [x] Testing names what was tested, what wasn't, and what must still be tested, where and how. - [x] Rebased on current `main`. - [x] `python -I -B tests/run_all.py` passes (any failure that also fails on `main` is named). - [x] No game files, dumps, build output or generated files. - [x] `third_party/` untouched (a new folder with a `PROVENANCE.md` instead). - [x] The license is OK: GPL-3.0-or-later, nothing incompatible and nothing decompiled. ### When it applies - [x] AI assistance recorded with `Assisted-by:` / `Co-Authored-By:` trailers. - [x] Screenshots of the same spot, captioned like the Measurements columns, for a change that shows on screen. - [x] A game-free test added for the fix, failing on `main` and passing here. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
fix(builder): a game dump Windows refuses to read says why and what to do, not "Permission denied"
All checks were successful
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Has been skipped
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Has been skipped
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Has been skipped
Third-party parts / The same as upstream (pull_request) Has been skipped
CI / Conventional Commit title (pull_request) Successful in 3s
CI / Workflow lint (actionlint) (pull_request) Successful in 5s
Third-party parts / As their notes say (pull_request) Successful in 8s
PR description / Description follows the template (pull_request_target) Successful in 3s
543459cbb5
A read of the .nup that the system refuses partway through (another program's lock on that part of the file, a drive
that cannot read it back) stopped the build at "Read the game files" with a bare "PermissionError: [Errno 13]
Permission denied". Python reports Windows errors 19 to 36 alike as errno 13, so nup.py now reads the Windows error the
C runtime kept (_get_doserrno) and says, with the file and where in it: another program has it locked (32, 33), the
drive could not read it (21, 23, 27, 30, 31, 1117, 1392, or EIO on Linux), or that it could not be read, each with what
to do. nx2/test_nup_read_error.py checks each message, and on Windows a real lock held by another process.

See #295

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Author
Member

Screenshots: the launcher building nightly 170's tools from a real dump while another process locks 1 MB of it at 247 MB. Only nx2/nup.py differs between the two.

Before: main @ 6c9edffe

Before: main @ 6c9edffe

After: this PR @ 543459cb

After: this PR @ 543459cb

Screenshots: the launcher building nightly 170's tools from a real dump while another process locks 1 MB of it at 247 MB. Only `nx2/nup.py` differs between the two. **Before: main @ 6c9edffe** ![Before: main @ 6c9edffe](https://git.magicbean.dev/attachments/adbdb3fc-45a0-491f-a1ba-008f21b932a1) **After: this PR @ 543459cb** ![After: this PR @ 543459cb](https://git.magicbean.dev/attachments/7a6a4a1e-2ba8-4a62-ae11-c7528bca949c)
llun changed title from WIP: fix(builder): a game dump Windows refuses to read says why and what to do, not "Permission denied" to fix(builder): a game dump Windows refuses to read says why and what to do, not "Permission denied" 2026-11-15 02:06:12 +00:00
llun scheduled this pull request to auto merge when all checks succeed 2026-11-15 02:41:14 +00:00
llun self-assigned this 2026-11-15 02:41:29 +00:00
llun removed their assignment 2026-11-15 02:41:30 +00:00
llun scheduled this pull request to auto merge when all checks succeed 2026-11-15 02:41:40 +00:00
llun force-pushed fix/nup-read-error from 543459cbb5
All checks were successful
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Has been skipped
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Has been skipped
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Has been skipped
Third-party parts / The same as upstream (pull_request) Has been skipped
CI / Conventional Commit title (pull_request) Successful in 3s
CI / Workflow lint (actionlint) (pull_request) Successful in 5s
Third-party parts / As their notes say (pull_request) Successful in 8s
PR description / Description follows the template (pull_request_target) Successful in 3s
to 7baf5b7666
All checks were successful
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Has been skipped
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Has been skipped
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Has been skipped
Third-party parts / The same as upstream (pull_request) Has been skipped
Website / Website on Cloudflare (pull_request) Has been skipped
PR description / Description follows the template (pull_request_target) Successful in 4s
CI / Conventional Commit title (pull_request) Successful in 4s
CI / Workflow lint (actionlint) (pull_request) Successful in 9s
Third-party parts / As their notes say (pull_request) Successful in 13s
2026-11-15 03:13:25 +00:00
Compare
llun force-pushed fix/nup-read-error from 7baf5b7666
All checks were successful
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Has been skipped
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Has been skipped
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Has been skipped
Third-party parts / The same as upstream (pull_request) Has been skipped
Website / Website on Cloudflare (pull_request) Has been skipped
PR description / Description follows the template (pull_request_target) Successful in 4s
CI / Conventional Commit title (pull_request) Successful in 4s
CI / Workflow lint (actionlint) (pull_request) Successful in 9s
Third-party parts / As their notes say (pull_request) Successful in 13s
to 0f7d98ddf1
All checks were successful
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Has been skipped
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Has been skipped
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Has been skipped
Third-party parts / The same as upstream (pull_request) Has been skipped
CI / Conventional Commit title (pull_request) Successful in 13s
CI / Workflow lint (actionlint) (pull_request) Successful in 19s
Third-party parts / As their notes say (pull_request) Successful in 24s
PR description / Description follows the template (pull_request_target) Successful in 7s
2026-11-15 03:23:02 +00:00
Compare
Author
Member

Rebased onto main @ f5718d03 (#629): the head is now 0f7d98dd. No conflicts, and the patch is unchanged (it replaces 7baf5b76, the same commit on b80a68f1).

python -I -B tests/run_all.py on 0f7d98dd, Windows 11: 491 passed, 18 failed, 79 skipped. Main @ f5718d03 on the same PC: 491 passed, 17 failed, the same 17 (named in Testing). The 18th, tests/test_longshot_ctl.py, timed out a 1 s log wait under the full run's load and passes 3 of 3 alone, here and on main. nx2/test_nup_read_error.py passes. The Testing section now gives this run instead of the one on 6c9edffe.

Open PRs that touch nx2/nup.py (#218, #546) change other parts of it; ours merges cleanly with both.

Rebased onto main @ f5718d03 (#629): the head is now **0f7d98dd**. No conflicts, and the patch is unchanged (it replaces 7baf5b76, the same commit on b80a68f1). `python -I -B tests/run_all.py` on 0f7d98dd, Windows 11: 491 passed, 18 failed, 79 skipped. Main @ f5718d03 on the same PC: 491 passed, 17 failed, the same 17 (named in Testing). The 18th, `tests/test_longshot_ctl.py`, timed out a 1 s log wait under the full run's load and passes 3 of 3 alone, here and on main. `nx2/test_nup_read_error.py` passes. The Testing section now gives this run instead of the one on 6c9edffe. Open PRs that touch `nx2/nup.py` (#218, #546) change other parts of it; ours merges cleanly with both.
HumanBean deleted branch fix/nup-read-error 2026-11-15 03:25:39 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
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/Longshot!641
No description provided.