fix(setup): the setup copies the Builder from a zip unpacked deeper than MAX_PATH #151
No reviewers
Labels
No labels
area/accessibility
area/audio
area/input
area/install
area/menu
area/renderer
area/saves
area/upscaling
good first issue
gpu/amd
gpu/igpu
gpu/intel
gpu/nvidia
help wanted
kind
bug
kind
chore
kind
docs
kind
feature
kind
performance
kind
question
needs measurements
platform/linux
platform/steam-deck
platform/windows
preview
priority
critical
priority
high
priority
low
status
blocked
status
confirmed
status
duplicate
status
invalid
status
needs-info
status
wontfix
testing wanted
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
MagicBeanDev/Longshot!151
Loading…
Reference in a new issue
No description provided.
Delete branch "d4nkn3ss/Longshot:fix/setup-long-paths"
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?
Why
The Windows setup can't install the Builder from a zip unpacked a little deep: it stops with "Could not copy the Longshot Builder from … (unpack the whole zip again)", and unpacking again doesn't help. The zip's deepest file is 163 characters below its folder, so any folder of about 100 characters or more is enough. Since #171 and #181 the setup copies the Builder only for
--cli(RecompLauncher, and the portable command line indocs/windows-portable-builder.md), and that's where it still applies.What changed
copy_part_tree()inbuilder/setup/setup.cworks on\\?\paths (\\?\UNC\for a share), which Windows' file calls take up to 32767 characters, built once per call in heap buffers (builder/setup/long_path.h); a path already in that form is kept. (dankman42)rmtreeuses the same\\?\paths, so it removes anything the copy creates, and no longer stops the setup with "too long". (HumanBean)\\?\form without case and without a trailing\(…\LongshotBuilder2isn't under…\LongshotBuilder). Both walks stop at 256 levels. (HumanBean)LongshotBuilder-data, beside the zip'sLongshotBuilderfolder, so--portablefrom an unpacked zip no longer copies that folder into itself. A zip folder an earlier run marked as a home stops the setup with a message to unpack into a new folder.docs/windows-portable-builder.mdsays so. (HumanBean)test_portable_setup_transaction.pycopies a tree more than 300 characters deep and removes it whole, refuses a self-copy in four spellings, and copies a sibling with the same prefix.test_setup_tool_merge.py,test_portable_setup_start.py,test_portable_builder_home.pyandportable_setup_home_test.cfollow the new home name.Flags and settings
None
Measurements
main:
fbd42024(2026-11-13); the setup there is the same as nightly-34'sthis PR: fix/setup-long-paths @
e9ade335Hardware: Windows 11, Python 3.12, llvm-mingw clang 23 (the Longshot Builder's)
Scene: n/a (no game run)
Method: nightly-34's zip unpacked under a folder deep enough that its longest file path is 306-310 characters; the zip's own setup and this PR's (built with
build-windows.yml's windres and clang commands), each run asLongshotBuilder.exe --portable --cli --versionwithLONGSHOT_SETUP_BASEset to a scratch folder, and once more for this PR with no base (the portable home beside the zip); each stopped once the copy had finished and the tool downloads beganHow to reproduce: unpack a release zip into a folder of 100+ characters and run its
LongshotBuilder.exe --portable --cli --version(or any--cli …)fbd42024(nightly-34's setup)e9ade335error Could not copy the Longshot Builder from <folder>\LongshotBuilder (unpack the whole zip again).--portablefrom the unpacked zip, no base<zip folder>\LongshotBuilder-data, 1605 of 1605 copied; the zip'sLongshotBuilderstill 1605 filesTesting
Tested: the runs above on Windows 11. The Windows-only setup checks, on Windows:
test_portable_setup_transaction.py,test_portable_setup_start.py,test_portable_builder_home.py, plustest_portable_setup_paths.py,test_setup_tool_merge.py,test_setup_runtime.py,test_setup_runtime_paths.py: 7 passed.Full suite:
python -I -B tests/run_all.pyon this branch @e9ade335, Windows 11: 494 passed, 2 failed, 33 skipped:tests/test_check_third_party.py(#80) andtests/test_release.py(#119, #144, #145), both failing onmainthe same way. HumanBean ran the suite on Linux: no new failures.Not tested:
\\server\...) as the unpack folder;LONGSHOT_SETUP_BASE, and the depth cap still bounds it;LongshotBuilder: it isn't picked up again.Needs other testers: None
Screenshots
Nothing on screen
Risk and rollback
The Builder copy, the portable part copy (
reuse_part) andrmtreechange; the portable home's name changes for new portable installs. Reverting the three commits is enough.Issues
None
Checklist
For everyone
type(scope): description).main.python -I -B tests/run_all.pyrun and passing (any failure that also fails onmainis named above).third_party/untouched (a new folder with aPROVENANCE.mdinstead).If applicable
Assisted-by:/Co-Authored-By:trailers.mainand passing here.tests/that reads changed source updated in this PR, not weakened.🤖 Generated with Claude Code
The diagnosis is right and the fix is small.
copy_part_tree()was the only place where the deep source tree hitMAX_PATH, since the default install under%LOCALAPPDATA%is short. The\\?\conversion is right for every path that can actually reach the function:g_appcomes fromGetModuleFileNameW, and the home fromhb_home_full(), which callsGetFullPathNameW. Both are absolute, use\, and have.,.., trailing dots and spaces already resolved. The UNC form (\\server\shareto\\?\UNC\server\share) is right. A path that already has the prefix is kept, so the recursion's own\\?\paths pass through unchanged. A relative path behaves as before. Every call on the copy path is the W version and accepts\\?\(GetFileAttributesW,CopyFileW,CreateDirectoryW,FindFirstFileW/FindNextFileW). There's noSHFileOperationin the setup, and the PR adds no UTF-8 to UTF-16 conversion. Moving the buffers to the heap also keeps the recursion's stack small.tests/test_portable_setup_transaction.pystill compiles the function as it stands, and on Windows it calls it with absolute temp paths, so the drive-letter branch runs.This still matters on main.
builder/setup/setup.candcopy_part_tree()are unchanged there since39647e1a. After #171 and #181, the setup copies the Builder only for--cli: RecompLauncher, and the portable command line indocs/windows-portable-builder.md. Started by hand, it now only points to the launcher. The Longshot Launcher installs the Builder itself and doesn't run this setup. The patch applies to current main without conflicts (offset -23 lines).Must change
With
--portablestarted from the unpacked zip, the copy now fills the disk instead of failing fast. With noLONGSHOT_SETUP_BASEand no marked folder above it,init_home(1)makes the portable home<setup folder>\LongshotBuilder. That is the same folder asg_app, the zip's ownLongshotBuilder\.work()then makes<home>\versions\<tag>.part, insideg_app, and callscopy_part_tree(g_app, g_app\versions\<tag>.part\LongshotBuilder), which copies the folder into itself. The recursion walksversions\<tag>.part\LongshotBuilderover and over, and at each level it copies everything sorted (on exFAT, created) beforeversions, which is nearly the whole Builder.MAX_PATHafter one or two levels with "Could not copy the Longshot Builder". That mode is already broken there.rmtree(install), becausehome_path()callsfail()("too long for the Windows setup") on the over-long leftovers. Explorer can't easily delete them either.This is exactly the documented portable SSD use (
docs/windows-portable-builder.md). The PR's runs avoided it because they setLONGSHOT_SETUP_BASE.dstissrcor lies undersrc\(compare the prefixed full paths case-insensitively, with the trailing\).versions,downloads,.longshot-portable) in that copy, so portable mode works at all.test_portable_setup_transaction.py(a copy into its own subfolder returns 0 quickly).The "Rebased on current
main" box is ticked but false. The branch forks at39647e1a, and main is about 30 commits ahead. The PR merges cleanly, and nothing it touches changed, so this is about the box only: rebase (and re-run the checks), or untick it.Should change
rmtree()can't remove what the copy can now create. It still buildsMAX_PATHpaths throughhome_path(). If a deep copy is stopped or fails (inreuse_part's cleanup, or in a.partleft for the next run), cleanup either can't reach the files (standard home) or stops the setup with "too long" (portable home). Givermtree()the same\\?\handling, or say in the PR that this is left for later.\\?\path) and assert the copy returns 1 and the files match. Otherwise a later edit can quietly bring back theMAX_PATHlimit.LongshotBuilder.exe. On main, started by hand, it only shows the launcher message. SayLongshotBuilder.exe --cli ...(as the measurements did).I didn't build or run anything (review only). Not checked on hardware: a UNC unpack folder, and exFAT ordering.
@ -403,0 +394,4 @@/* (paths past MAX_PATH: the builder zip's deepest file is 163 characters below its folder, so a zip unpacked a littledeep failed here with "Could not copy the Longshot Builder". An absolute path gets the \\?\ form (\\?\UNC\ for ashare), which Windows' file calls take up to 32767 characters, in heap buffers; a path already in it is kept) */enum { CAP = 32768 };With the limit raised to 32767, this function needs a guard against copying a folder into itself. In
--portablemode started from the unpacked zip (noLONGSHOT_SETUP_BASE),init_homemakes the home<setup folder>\LongshotBuilder, which is the same folder asg_app.work()then callscopy_part_tree(g_app, g_app\versions\<tag>.part\LongshotBuilder). On main that fails at MAX_PATH after a level or two. With this change it nests about 750 levels and copies most of the Builder at each one, until the disk is full. Later portable runs then stop inrmtreewith "too long". Please refuse the copy whendstequalssrcor starts withsrcplus\(case-insensitive, on the prefixed paths), and add a check for it totest_portable_setup_transaction.py.@ -412,0 +405,4 @@const wchar_t *p = in[i], *prefix = L"", *rest = p;if (wcsncmp(p, L"\\\\?\\", 4)) {if (p[0] && p[1] == L':' && p[2] == L'\\') prefix = L"\\\\?\\";else if (p[0] == L'\\' && p[1] == L'\\') prefix = L"\\\\?\\UNC", rest = p + 1;The UNC form is right (
\\server\sharebecomes\\?\UNC\server\share). Every input here comes fromGetModuleFileNameWorGetFullPathNameW(throughhb_home_full), so/,./..and trailing dots and spaces are already resolved before the prefix turns normalization off. Maybe say so in the comment, becauseC:/...or\\.\would be handled wrongly if a future caller passed one.@ -412,0 +421,4 @@else {do {if (!wcscmp(entry.cFileName, L".") || !wcscmp(entry.cFileName, L"..")) continue;if (!hb_home_join(source, CAP, src, entry.cFileName) ||The copy can now create paths past MAX_PATH, but
rmtree(), which cleans up after a failedreuse_partcopy and removes a leftover.partat the next run, still useshome_path()with MAX_PATH. In a portable home that callsfail()("too long"). In the standard home the files can't be removed. Please give it the same\\?\handling, or note it as follow-up work.Re-review. The follow-ups to my review are on a branch,
pr151-reviewon HumanBean/Longshot (199923a1): this PR rebased onto mainf647a072(your commit unchanged, as e1b3b013), plus two commits. Changes requested only until the PR head has them; with them in and a Windows test run, I approve.Resolved on that branch
--portableself-copy:copy_part_treerefuses a destination equal to or under the source. It compares the\\?\forms without case and ignores a trailing\, and…\LongshotBuilder2doesn't count as under…\LongshotBuilder.LongshotBuilder-data, so it's no longer the zip's Builder folder, and portable mode works from an unpacked zip.rmtreeuses the same\\?\paths, so it can remove anything the copy creates. In portable mode it no longer stops the setup with "too long".builder/setup/long_path.h). Both walks are capped at 256 levels.long_path.h: inputs come from GetModuleFileNameW/GetFullPathNameW, andC:/xor\\.\would be mishandled.Edge cases checked: UNC (
\\server\share→\\?\UNC\…, and an existing\\?\UNC\is kept), a drive root (\\?\C:\keeps its\, and joins don't double it), a shared prefix, case, a trailing backslash, and theC:\and\\?\spellings of one path comparing equal.Remaining, not blocking
LongshotBuilder-data, so a folder that an older setup made a home isn't picked up again.Checks:
-Wall -Wextra, with only the 5-Wmissing-field-initializerswarnings that main has too.tests/run_all.pyon Linux has no new failures; test_setup_tool_merge runs the new copy and rmtree through the POSIX stand-in.PR body:
LongshotBuilder.exe --cli …;@d4nkn3ss thanks for the fix! Maintainer edits are off on your branch, so the review follow-ups are on a branch of mine:
pr151-reviewon https://git.magicbean.dev/HumanBean/Longshot.It has your commit rebased onto current main (same author, message and content), plus two commits on top:
\\?\handling;--portable's home becomesLongshotBuilder-data, with docs and tests.To take them:
The force push is only needed because of the rebase. Then tick "Rebased on current main", and change "How to reproduce" to
LongshotBuilder.exe --cli ....Your PC runs Windows, so
python -I -B tests/run_all.pythere would also cover the three portable setup tests that haven't run anywhere yet: test_portable_setup_transaction, test_portable_setup_start and test_portable_builder_home.56335697f9e9ade33527Thanks for the review and for doing the follow-ups. Your branch is now this PR's head:
pr151-review(199923a1) rebased onto mainfbd42024without conflicts, ase9ade335(your two commits on mine, authors kept).The Windows run you asked for, on Windows 11 with the Longshot Builder's llvm-mingw:
test_portable_setup_transaction.py,test_portable_setup_start.py,test_portable_builder_home.py, withtest_portable_setup_paths.py,test_setup_tool_merge.py,test_setup_runtime.pyandtest_setup_runtime_paths.py: 7 passed.e9ade335withbuild-windows.yml's commands, on nightly-34's zip:--portable --cli --versionwith a scratchLONGSHOT_SETUP_BASE: all 1605 Builder files copied, then the tool downloads (stopped there);--portablefrom the unpacked zip with no base: the home is<zip folder>\LongshotBuilder-data, 1605 files copied into it, and the zip's ownLongshotBuilderstill has its 1605, so no self-copy.The description has your three points: Rebased ticked, "How to reproduce" with
LongshotBuilder.exe --portable --cli …, and the portable home's rename (in What changed, Not tested and Risk), plus your commits and the remaining spelling-only limit.Approved. The head
e9ade335is the review branch rebased onto main with no changes (range-diff: all three commits identical). The Windows run covered what nothing else could:--portablefrom the unpacked zip makesLongshotBuilder-dataand leaves the zip's ownLongshotBuilderintact.The description has the three points. The remaining spelling-only limit (a subst drive or junction) is documented and not blocking. Thanks!
View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.