Skip to content

updater: in-place npm install races exec → ENOEXEC on launch, hang on retry #41495

Description

@kahlos

Summary

Launching opencode2 while the auto-updater is running npm install -g @opencode-ai/cli@<next> (or launching concurrently with a second instance's update) hits ENOEXEC: unknown error, posix_spawn '.../bin/opencode2.exe' because the updater replaces the live executable in place, non-atomically. A retry then hangs until the install finishes, and a killed install leaves the CLI unable to start at all until the next update cycle.

Environment

  • opencode version: 0.0.0-next-17082 at failure (current 0.0.0-next-17086; next channel advances several builds/hour)
  • OS: macOS Darwin 25.5.0 (arm64)
  • Terminal: ghostty (TERM=xterm-ghostty, COLORTERM=truecolor)
  • Shell: /bin/zsh
  • Install/channel: npm global install (/opt/homebrew/lib/node_modules/@opencode-ai/cli), next channel
  • Active plugins: ~/.config/opencode/plugins/notify.ts

Reproduction

  1. Install via npm install -g @opencode-ai/cli@next (auto-update active).
  2. While an update is in flight — either the updater's own background cycle (observed running on its own roughly every 13–20 min on the next channel) or a user-initiated npm install -g in another terminal — run opencode2.
  3. Observe immediate failure: Error: ENOEXEC: unknown error, posix_spawn '.../bin/opencode2.exe' (raw effect error, no hint that an update is in progress).
  4. Retry: process hangs ("Starting background server..." with no progress) until the install completes, then succeeds.

Alternative trigger with worse impact: kill the npm install mid-reify (SIGTERM/Ctrl-C). The package is left half-replaced; subsequent launches keep failing with ENOEXEC until the background updater happens to re-run and finish the install.

Expected Behavior

  • The updater should replace the executable atomically (install to a staging path then rename, or use versioned binary paths), so a spawn never observes a half-written binary.
  • The launcher should detect a transiently unexecutable target (file changing, ENOEXEC) and either retry briefly or print a clear "updating, retry in a moment" message instead of a raw error and a hang.

Actual Behavior

Timeline from npm debug logs and file mtimes (all times local):

  • 07:13:26 — auto-updater starts npm install -g @opencode-ai/cli@0.0.0-next-17082; process is terminated (SIGTERM) mid-reify (~/.npm/_logs/2026-08-10T06_13_26_476Z-debug-0.log:88), leaving the package half-moved.
  • 07:13:31 — user launches opencode2ENOEXEC: unknown error, posix_spawn '/opt/homebrew/lib/node_modules/@opencode-ai/cli/bin/opencode2.exe' (file present but not a valid executable).
  • 07:13:50 — updater re-runs (npm list then install); completes 07:13:51 (binary mtime = 155 MB valid arm64 Mach-O).
  • Second launch hung through this window; third succeeded.

Updater npm log pattern (every cycle: version check then exact-version install):

verbose title npm list @opencode-ai/cli
verbose title npm install @opencode-ai/cli@0.0.0-next-17082
error process terminated / signal SIGTERM   (first cycle, mid-reify)

Full first-failure stack trace:

ERROR (#1): Error: Failed to start server
    at service.ensure (src/services/server-connection.ts:66:53)
    at cli.server-connection.resolve (src/commands/handlers/default.ts:22:44)
  [cause]: Error: ENOEXEC: unknown error, posix_spawn '/opt/homebrew/lib/node_modules/@opencode-ai/cli/bin/opencode2.exe'
      at try (../client/src/effect/service.ts:71:23)

Additional Context

  • Occurred once here, but the race is inherent: any launch concurrent with an in-flight global install (another terminal's launch, the background poller, or a manual npm update) will hit it. The next channel makes this common because builds advance several times an hour.
  • Not related to network: registry fetches all returned HTTP 200 at the time of failure.
  • Workaround that helped: wait ~20–30 s and relaunch; if the CLI still fails with ENOEXEC, manually re-run npm install -g @opencode-ai/cli@next.
  • Root-cause sketch: npm's global reify replaces files in place (renames package dir to a staging name, extracts new one); the updater spawns/execs that same path while it is being replaced, and nothing retries on ENOEXEC.

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions