fix(deps): survive a held file handle when installing deps, and report it honestly (#87) - #89
Merged
StuartCameronCode merged 2 commits intoAug 31, 2026
Conversation
A Windows directory rename is refused while anything at all holds a handle on a descendant. Measured: an open read handle on one file, or a child process whose working directory is inside the tree, is enough — both surface as PathAccessException ... errno = 5. A *running* .exe inside the tree is not enough, and a destination that already exists gives errno 183 instead, so the reported failure was never the permissions problem it reads as. Straight after writing a ~200 MB bundle there is routinely something holding a handle for a few hundred milliseconds: a scanner, the search indexer, Explorer building a thumbnail. The swap was a single unretried rename at the very last step of the install, so one such handle threw away the entire download — and did so on every retry, which is what the reporter saw. Before 0.9.12 the installer extracted in place and there was no rename at all, so this is a regression from the staged swap. Three changes: - Both renames, and the .new/.old cleanup that precedes them, go through retryTransientFsOperation (~7.5s over 12 attempts). A leftover .new can still be held by whatever blocked the previous swap, so the unguarded delete would have failed the next attempt with a second, different error. PathExistsException/PathNotFoundException are rethrown immediately — those will not clear, and spending the budget on them only delays a fault the user can act on. - A first install extracts straight into <deps> and performs no rename at all. Staging exists to protect an install that is already there; on the reported path there is none. version.json is still written last, so an interrupted first install reads as missing rather than as installed. - executabilityProblem() runs after the swap on Windows. The quarantine case it guards (#50) is macOS-only, so on Windows all it can report is a generic "would not run" — while executing a freshly written, unsigned 100 MB binary is exactly what makes a scanner open the tree we are about to rename. A failure rolls the previous bundle back out of <deps>.old, so it still cannot leave the user worse off. dependency_install_retry_test.dart reproduces the real errno 5 on Windows and proves the retry rides out a handle released a moment later; it skips elsewhere, because POSIX renames a directory happily with its files open. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The dialog emitted nothing between the last extraction tick and "Complete", so the whole install phase — verify, stamp, swap — ran under a bar reading "Extracting... 100%". Adding a retry budget on top of that would have been indistinguishable from a hang, and the machines that need the retries are the ones that wait longest. - The install phase emits progress. _reportInstallStep sends 0/0 events, so the bar goes indeterminate: the swap genuinely has no fraction to report, and leaving the previous step's full bar up claimed progress that had not happened. retryTransientFsOperation gained an onRetry callback, so a wait for a held file handle is named on screen instead of only on stdout, which a packaged Windows app has nowhere to show. - The remedy is chosen from the failure. remedyFor maps the error to advice — held handles, a full disk, a permissions fault, connection as the fallback — and DependencyInstallException carries its own for messages that already say what to do (macOS quarantine ships its xattr command). One fixed line of *connection* advice under every failure is what sent the reporter auditing folder ACLs on a problem that was neither network nor permissions. The headings were wrong the same way: "Download Failed" becomes "Installation Failed", since most of what can fail here happens after the download. - The downloaded zip survives a failed install. It lands in <temp>/vapourbox-deps-cache/<filename> and is deleted only once the install succeeds, so Retry no longer re-fetches ~200 MB of bytes that were never the problem. Reused only when the sidecar gave a sha256 to check it against — without one a truncated download is indistinguishable from a complete one, and would surface as a corrupt bundle rather than as missing bytes. Other filenames in that directory are another version's leftovers and are pruned, which bounds the cache. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #87.
The failure
PathAccessException: Rename failed, path = 'D:\VapourBox-1.0.0-windows-x64\deps\windows-x64.new' (OS Error: Access is denied, errno = 5), after a complete download and extraction — the very last step of the install. It reads like a permissions problem and is not one.Measured on Windows 11 (Dart, Defender real-time on):
PathAccessException … errno = 5PathExistsException … errno = 183So errno 5 means something holds a file in the tree open — a scanner, the search indexer, Explorer building a thumbnail — which straight after writing ~200 MB is routine and momentary. Corroborating: the whole Windows bundle is 108 files / 11 folders and the reporter counted 101/11 in
.new, so everything up to the rename had succeeded.This is a regression from the staged swap (
fb75d32, first shipped in v0.9.12). Before that the installer extracted in place and there was no rename at all.The fix
Robustness
.new/.oldcleanup that precedes them, go through a newretryTransientFsOperation(~7.5 s over 12 attempts). A leftover.newcan still be held by whatever blocked the previous swap, so the unguarded delete would have failed the next attempt with a second, different error.PathExistsException/PathNotFoundExceptionare rethrown immediately — those will not clear.<deps>and performs no rename at all. Staging exists to protect an install that is already there; on the reported path there is none. Safe becauseversion.jsonis still written last, so an interrupted first install reads as missing rather than installed.executabilityProblem()runs after the swap on Windows. The quarantine case it guards (Minor feature update suggestions #50) is macOS-only, so on Windows all it can report is a generic "would not run" — while executing a freshly written, unsigned 100 MB binary is exactly what makes a scanner open the tree we are about to rename. A failure rolls the previous bundle back out of<deps>.old.Reporting — the dialog emitted nothing between the last extraction tick and
Complete, so the whole install phase ran under a bar reading "Extracting… 100%"; a retry budget on top of that would have been indistinguishable from a hang.onRetrynames the wait on screen instead of only on stdout, which a packaged Windows app has nowhere to show.remedyForpicks the advice from the failure — held handles, full disk, permissions, connection as the fallback — andDependencyInstallExceptioncarries its own for messages that already say what to do (macOS quarantine ships itsxattrcommand). One fixed line of connection advice under every failure is what sent the reporter auditing folder ACLs. Headings follow: Installation Failed, not "Download Failed".<temp>/vapourbox-deps-cache/and deleted only on success, so Retry no longer re-fetches ~200 MB of bytes that were never the problem. Reused only when the sidecar gave a sha256 to check it against.Tests
15 new tests across
dependency_install_retry_test.dartanddependency_install_feedback_test.dart, in the push gate. Two are Windows-only and use a real open file handle: one asserts a single rename fails with exactlyerrno = 5(documenting that it is not a permissions fault), the other proves the retry recovers when the handle is released 250 ms later. They skip elsewhere — POSIX renames a directory happily with its files open.🤖 Generated with Claude Code