Skip to content

fix: release the update lock when the update launcher rejects (#6036) - #6128

Merged
atomantic merged 1 commit into
mainfrom
claim/issue-6036
Sep 3, 2026
Merged

fix: release the update lock when the update launcher rejects (#6036)#6128
atomantic merged 1 commit into
mainfrom
claim/issue-6036

Conversation

@atomantic

Copy link
Copy Markdown
Owner

Summary

POST /api/update/execute acquires the atomic update lock, then fires executeUpdate without awaiting it. Both of executeUpdate's resolved outcomes clear the lock through recordUpdateResult, but a rejection — e.g. spawnDetached throwing on a permissions or missing-binary failure, before any child listener is attached — skipped that path entirely. The route's .catch emitted portos:update:error and left updateInProgress set.

That stuck lock wedged the whole install: every later update answered 409 UPDATE_IN_PROGRESS, and isUpdateInProgress() blocked every CoS agent spawn, until the 30-minute stale timeout aged it out or the server was restarted.

  • server/routes/update.js — the .catch now releases the lock. The rejection is not swallowed: it is logged and still reaches the socket error channel first (the HTTP response is long since sent), and a failure of the release itself is logged rather than left as an unhandled rejection.
  • server/routes/update.test.js — backfills the execute route's untested branches. makeApp() never attached an io, so every socket emission the route makes was a silent no-op that no test could observe.

New coverage, each naming a distinct regression:

Test Catches
executeUpdate rejects → lock released + portos:update:error the bug above
409 UPDATE_IN_PROGRESS when the lock is held ignoring the atomic check-and-set's false, allowing two concurrent update.sh runs
400 INVALID_TAG for a non-semver tag, lock untouched removal of the option/argument-injection guard on the tag handed to update.sh
portos:update:complete with the resolved version the client's only channel for the outcome
versionKnown: false fallback to the triggering tag a guessed version being presented as confirmed
portos:update:error with the real failedStep the resolved-failure path collapsing to 'unknown'
portos:update:step forwarding the progress callback wiring that drives the update progress bar

One non-obvious detail worth flagging for review: attaching io also routes every error response through errorEvents, and a Node EventEmitter throws its payload when 'error' is emitted with no listener — which broke the error envelope for every non-200 case in the file. The real server always has a subscriber, so the suite registers a no-op stand-in.

Test plan

  • Verified the regression test fails on the unpatched route (1 failed | 40 passed) and passes with the fix.
  • cd server && npx vitest run routes/update.test.js41 passed.
  • Full server suite (also globs ../scripts, ../lib, ../autofixer) → 1917 files passed, 38680 tests passed, 0 failed.
  • Local code reviewer: no findings.
  • No DB-backed suites run.

Closes #6036

https://claude.ai/code/session_01VjkWVTfzKyRuAv3HEsspwN

POST /api/update/execute acquires the atomic update lock, then fires
executeUpdate without awaiting it. Both of executeUpdate's resolved
outcomes clear the lock via recordUpdateResult, but a REJECTION (e.g.
spawnDetached throwing on a permissions or missing-binary failure, before
any child listener is attached) skipped that path entirely — the route's
.catch emitted portos:update:error and left updateInProgress set.

The stuck lock wedged the install: every later update answered 409
UPDATE_IN_PROGRESS, and isUpdateInProgress() blocked every CoS agent
spawn, until the 30-minute stale timeout aged it out or the server was
restarted. The .catch now releases the lock (logging, not swallowing, a
failure to release) after the error still reaches the socket channel.

Also backfills the execute route's untested branches. makeApp() never
attached an `io`, so every socket emission the route makes was a silent
no-op no test could see; it now attaches a mock, covering the step,
complete and error events, the version fallback when the script reports
none, the 409 when the lock is already held, and the 400 INVALID_TAG
option-injection guard. Attaching `io` routes error responses through
errorEvents, which throws its payload when emitted with no listener, so
the suite registers the no-op subscriber the real server always has.

Claude-Session: https://claude.ai/code/session_01VjkWVTfzKyRuAv3HEsspwN
@atomantic
atomantic merged commit c2566b9 into main Sep 3, 2026
7 checks passed
@atomantic
atomantic deleted the claim/issue-6036 branch September 3, 2026 22:30
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.

Release updateInProgress lock on executeUpdate rejection and backfill missing POST /api/update/execute route test branches

1 participant