Skip to content

fix(deps): survive a held file handle when installing deps, and report it honestly (#87) - #89

Merged
StuartCameronCode merged 2 commits into
mainfrom
fix/87-deps-install-rename-access-denied
Aug 31, 2026
Merged

fix(deps): survive a held file handle when installing deps, and report it honestly (#87)#89
StuartCameronCode merged 2 commits into
mainfrom
fix/87-deps-install-rename-access-denied

Conversation

@StuartCameronCode

Copy link
Copy Markdown
Owner

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):

scenario result
plain rename of the staged dir OK
any open handle on a descendant (read or write) PathAccessException … errno = 5
a child process whose cwd is inside the tree errno = 5
a running .exe inside the tree OK — does not block
destination directory already exists PathExistsException … errno = 183

So 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

  • Both swap renames, and the .new/.old cleanup that precedes them, go through a new retryTransientFsOperation (~7.5 s 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.
  • 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. Safe because version.json is 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.

  • The install phase now emits progress (0/0 → indeterminate bar, because the swap has no fraction to report), and onRetry names the wait on screen instead of only on stdout, which a packaged Windows app has nowhere to show.
  • remedyFor picks the advice from the failure — held handles, full disk, permissions, 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. Headings follow: Installation Failed, not "Download Failed".
  • The downloaded zip is cached in <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.dart and dependency_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 exactly errno = 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

StuartCameronCode and others added 2 commits August 31, 2026 12:20
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>
@StuartCameronCode
StuartCameronCode merged commit 1d7330e into main Aug 31, 2026
4 checks passed
@StuartCameronCode
StuartCameronCode deleted the fix/87-deps-install-rename-access-denied branch August 31, 2026 03:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

PathAccessException: Rename failed

1 participant