Skip to content

fix(cli): keep Windows upgrades and uninstalls from fighting the running binary - #50819

Merged
Hona merged 1 commit into
anomalyco:v2from
Hona:windows-upgrade-lock
Sep 23, 2026
Merged

Hona merged 1 commit into
anomalyco:v2from
Hona:windows-upgrade-lock

Conversation

@Hona

@Hona Hona commented Sep 23, 2026 •

Copy link
Copy Markdown
Member

opencode upgrade on Windows hangs for five minutes and then prints Failed to update with bun. This PR makes upgrade, uninstall and manual package-manager upgrades work while OpenCode is running on Windows. Unix and macOS paths are untouched.

Root cause

sequenceDiagram
  participant U as opencode upgrade (running opencode.exe)
  participant B as bun install -g --cache-dir <tmp>
  participant N as NTFS
  U->>B: spawn (updater.ts)
  B->>N: rename @opencode/cli → .old-<hex>  (PackageInstall.rs:1941)
  B->>N: delete .old-<hex>/bin/opencode.exe (worker thread)
  N-->>B: STATUS_CANNOT_DELETE — last link of a mapped image
  Note over B: windows_errno.rs:847 maps it to EPERM
  Note over B: dir.rs:173-184 EPERM ⇒ "must be a dir" ⇒ ENOTDIR ⇒ "must be a file" ⇒ EPERM … forever
  U-->>U: 5 min timeout → "Failed to update with bun"
Loading
  • NTFS refuses to delete a running executable only when the name being removed is its last hard link; any other link deletes fine (OSR deleteex: "so long as it's not the last link to the file"; STATUS_CANNOT_DELETE = "existing mapped view", MS-FSCC 2.4.11.1; libuv fs.c says the same in its fs__unlink comment). Measured here: non-last link deletes from Win32, node and bun; the last link fails with EPERM.
  • updater.ts installs through a throwaway --cache-dir and deletes it, so node_modules holds the only links to the binary. The next upgrade removes the platform package's link first and the running @opencode/cli/bin/opencode.exe becomes the last link.
  • bun 1.4.2 src/sys/dir.rs delete_tree loops forever on that EPERM — a regression from 1.3.13, which left an orphan and finished (bun install hangs forever on Windows when the replaced package holds the last hard link of a running exe (regression from 1.3.13) oven-sh/bun#43831). Every .old-* orphan in ~/.bun/install/global/node_modules has links=1 — same mechanism.
  • The same fact bites the other installers differently: npm leaves a .cli-* orphan with npm warn cleanup … EPERM; Git Bash mv (curl installer) hits STATUS_ACCESS_DENIED on the in-use target and falls back to moving the 200 MB binary into the Recycle Bin (Cygwin syscalls.cc rename2 → unlink_nt → try_to_bin); uninstall reports Busy because libuv's kill(pid, 0) is GetExitCodeProcess != STILL_ACTIVE while TerminateProcess "is asynchronous" and the service's SQLite handles close a moment later.

Change (win32 only)

Keep a second hard link to the running image for as long as it must survive an installer. The installed path never disappears; installers delete a non-last link and succeed.

// services/retained-image.ts — scoped: link on acquire, best-effort remove on release
fs.link(realpath(process.execPath), `${directory}/opencode-${role}-${process.pid}-${random}.exe`)
// before linking, sweep links whose pid is gone: only ESRCH counts (uv_kill maps an
// inaccessible live process to EPERM), and the random suffix survives Windows pid reuse

// updater.ts — around every installer run (upgrade → cache, uninstall → %TEMP%\opencode)
Effect.scoped(RetainedImage.retain(directory, "upgrade").pipe(Effect.andThen(runInstaller)))

// server-process.ts — for the lifetime of `serve --service`, package-manager or curl installs only
if (mode === "service" && win32 && RetainedImage.installed(global.home)) yield* RetainedImage.retain(global.cache, "service")

// uninstall.ts — a link may be the last name of an image an older OpenCode still runs;
// renaming a running image is allowed, so move links out before deleting the cache
if (win32) yield* RetainedImage.relocate(global.cache, global.tmp)
// …and directory removal races the terminated service's handle teardown
Effect.retry({ while: (e) => process.platform === "win32" && e.reason._tag === "Busy", schedule: 250 ms × 40 })

Only binaries installed by a package manager or the curl installer are linked: never bun/node in a source checkout, never the Desktop app's bundled CLI (Desktop updates it itself). The link is skipped with a warning when the cache is on another volume, in which case behaviour is exactly as before this PR.

Results

Each cell is a real run in an isolated install root with its own managed-service port. Baseline = published @opencode/[email protected] (its updater.ts is identical to v2). Candidate = this branch compiled as 2.0.13. Every run upgrades 2.0.13 → 2.0.14 and checks the binary on disk, the manifest/lockfile, orphaned directories and the Recycle Bin.

✅ completed, consistent, nothing left behind · ⚠️ completed but left a 200 MB orphan dir or Recycle Bin deposit · ❌ hung or failed

opencode upgrade — service stopped (only the upgrader runs the binary)

Method Baseline (v2 today) Candidate (this PR)
bun ❌ hung (150s timeout) ✅ 28s
npm ⚠️ ok, 1 orphan dir ✅ 10s
pnpm ✅ 32s ✅ 30s
curl ⚠️ ok, old binary sent to Recycle Bin ✅ 20s

opencode upgrade — service running (same for TUI /upgrade: update-notification.tsx → updater.apply → Updater.upgrade)

Method Baseline (v2 today) Candidate (this PR)
bun ❌ hung (150s timeout) ✅ 26s
npm ⚠️ ok, 1 orphan dir ✅ 8s
pnpm ✅ 40s ✅ 27s
curl ⚠️ ok, old binary sent to Recycle Bin ✅ 20s

Manual package-manager upgrade in a shell — nothing running (control)

Method Baseline (v2 today) Candidate (this PR)
bun (bun install -g --trust @opencode/[email protected]) ✅ 27s ✅ 27s
npm (npm install -g --force @opencode/[email protected]) ✅ 9s ✅ 7s
pnpm (pnpm add -g --allow-build=@opencode/cli @opencode/[email protected]) ✅ 28s ✅ 27s
curl (bash install --version 2.0.14) ✅ 17s ✅ 19s

Manual package-manager upgrade in a shell — service running

Method Baseline (v2 today) Candidate (this PR)
bun (bun install -g --trust @opencode/[email protected]) ❌ hung (150s timeout) ✅ 26s
npm (npm install -g --force @opencode/[email protected]) ⚠️ ok, 1 orphan dir ✅ 7s
pnpm (pnpm add -g --allow-build=@opencode/cli @opencode/[email protected]) ✅ 71s ✅ 26s
curl (bash install --version 2.0.14) ⚠️ ok, old binary sent to Recycle Bin ✅ 16s

opencode uninstall --force — service running

Method Baseline (v2 today) Candidate (this PR)
bun ❌ exit 1 ✅ 1s
npm ❌ exit 1 ✅ 2s
pnpm ❌ exit 1 ✅ 2s
curl — n/a (no package manager) —

Notes on the grid:

  • The grid passes --method to pin the installer; the same four service running upgrades were also run without it, and auto-detection picked the right installer every time.
  • The TUI /upgrade shares the opencode upgrade — service running row: update-notification.tsx → updater.apply → Updater.install → Updater.upgrade.
  • pnpm never hung: its content-addressable store already keeps a second link alive — the same principle this PR applies for bun, npm and curl. pnpm keeps old .pnpm/@opencode+cli@x entries until pnpm store prune; that is normal and not counted.
  • The baseline uninstall failures are the Busy race (Failed to remove Data); it is timing-dependent — pnpm baseline passed in two runs and failed in a third.
  • Extra check, not in the grid: opencode uninstall while an older OpenCode still runs from a cache link (e.g. a TUI that ran /upgrade). Candidate: all directories removed, the old process keeps running, its link moves to %TEMP%\opencode. Same setup with the published 2.0.13: Failed to remove Cache.
  • The curl rows assume Git Bash is the bash on PATH; with WSL bash first, the installer runs under Linux, which is a pre-existing Windows issue not touched here.

Compared with macOS / Linux

Same user-visible semantics: opencode upgrade and /upgrade complete while OpenCode runs, already-running processes keep the old version until restarted, new launches get the new one, and a hand-typed package-manager upgrade works while the service runs. Windows-only differences:

Unix Windows (this PR)
While the service or an upgrade runs nothing one extra hard link in ~\.cache\opencode (0 bytes)
Old binary after upgrade inode freed when the last process exits the running process's link stays until it exits; the next upgrade or service start sweeps links of exited processes
Old binary after uninstall freed on exit up to ~200 MB stays in %TEMP%\opencode until Storage Sense (if enabled) or the user removes it; nothing in OpenCode runs after an uninstall
Cache on another volume n/a link fails, warning logged, behaviour as before this PR
%TEMP% on another volume than the cache n/a uninstall cannot move a running image's link out of the cache, so cache removal fails as before this PR

Not run

  • yarn and vp (not installed here); the change applies to them by construction because the link exists before any installer runs.
  • The TUI /upgrade was not driven interactively; it calls the same Updater.upgrade.
  • Desktop-managed service, ~\.cache on another volume, ARM64/baseline binaries.

Not in this PR

  • Uninstall stops managed services only; any other OpenCode still running (TUI, serve) keeps the database open and Windows refuses to delete Data. Unchanged by this PR.

  • Deleting the uninstaller's own binary after exit needs a self-deleting helper (as self-replace/rustup do); a bun-compiled binary has no hook for it. Possible follow-up.

  • An older client still replaces a newer running service (default.ts mismatch: "replace"), which is how a 2.0.8 shell client "downgraded" a Desktop-managed 2.0.14 service. Separate change.

  • bun's delete_tree EPERM loop is bun's bug, reported with a 10 MB self-contained repro and a 1.3.13-vs-1.4.2 measurement in bun install hangs forever on Windows when the replaced package holds the last hard link of a running exe (regression from 1.3.13) oven-sh/bun#43831; this PR only stops OpenCode from triggering it.

@Hona
Hona force-pushed the windows-upgrade-lock branch from 6be78c5 to 866a43d Compare September 23, 2026 04:09
@Hona
Hona marked this pull request as ready for review September 23, 2026 04:18
Copilot AI lite review requested due to automatic review settings September 23, 2026 04:18

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@Hona
Hona force-pushed the windows-upgrade-lock branch 2 times, most recently from 6f14928 to 9374fb9 Compare September 23, 2026 06:07
…ing binary

Windows refuses to delete the last hard link of a running executable, and bun 1.4 spins forever when its package replacement meets one (src/sys/dir.rs delete_tree treats the resulting EPERM as a directory). opencode upgrade installs through a throwaway bun cache, so node_modules holds the only links to the running binary and the next upgrade turns it into the last link. Any other link to the image can be deleted, so:

- RetainedImage keeps a second hard link to the running image for the lifetime of a scope and sweeps links of exited processes.
- updater: hold that link around every installer run (cache for upgrades, the OpenCode temporary directory for uninstall, which has already removed the cache); only for the installed binary.
- service: hold it while serve --service runs, so a package manager invoked by hand can still replace the installed copy.
- uninstall: retry directory removal while Windows still reports the terminated service's database handles as busy.

Unix and macOS paths are unchanged.
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.

2 participants