Skip to content

fix(desktop): bound the quit sequence so a stuck stage cannot strand an update - #5540

Open
MasamiYui wants to merge 1 commit into
apache:mainfrom
MasamiYui:fix/bounded-quit-sequence
Open

MasamiYui wants to merge 1 commit into
apache:mainfrom
MasamiYui:fix/bounded-quit-sequence

Conversation

@MasamiYui

Copy link
Copy Markdown
Member

Summary

Installing a Desktop update could leave the app running forever with its Runtime Host already retired. The UI emptied out ("No project", a misleading network error on model refresh), the update was never applied, and only a manual relaunch recovered — after which the update failed the same way.

installUpdate retires the Host before handing off to the updater, and that retirement only unwinds through its rollback. The quit that was supposed to follow was awaited without any upper bound: prepareToQuit() and cleanup() each wait on Host retirement, MCP child processes, peer mesh teardown and native resources, any of which can stop settling. When one did, phase stayed in preparing/cleaning and every later quit request was swallowed by if (phase !== 'running') return after the event had already been prevented. Squirrel's ShipIt waits for this process to exit before replacing the bundle, so the install blocked indefinitely with nothing to read.

This bounds both stages and gives the sequence a way out:

  • prepareToQuit() is bounded (15s). Preparation is a courtesy to the Host; exceeding it reports and continues, because refusing to quit is the worse outcome.
  • cleanup() is bounded (10s). Exceeding it exits the process outright — nothing is left to wait for, and staying up would strand an in-flight install.
  • A quit that is cancelled or fails now reports through onQuitAbandoned, which releases the install handoff via the new AppUpdateService.abandonPendingInstall(). Previously the coordinator knew the quit was abandoned but had no way to tell the update service, so the Host stayed retired with nothing left to restart it.

Timeouts report through their own onQuitTimeout channel rather than the error sinks: a stage that stopped answering is not a Host that refused, and conflating them invents a failure the product then has to explain.

Fixes #5539

Verification

node --test dist/main/__tests__/app-quit-coordinator.test.js \
            dist/main/__tests__/app-update-service.test.js \
            dist/main/__tests__/app-update-install.test.js \
            dist/main/__tests__/app-update-focus-check.test.js \
            dist/main/__tests__/runtime-host-quit.test.js
# tests 48 | pass 48 | fail 0

Confirmed the tests fail without the fix: reverting app-quit-coordinator.ts alone (keeping the new tests) fails 6 of them — the two timeout paths, both abandonment paths, the forced-exit path, and the "not a failure" assertion.

npx biome lint clean on the five touched files; biome format does not cover src/main per biome.jsonc. ASF header check passes.

Not run: full npm run typecheck and the complete suite. On this branch's base, tsc -p tsconfig.main.json reports two pre-existing DesktopRuntimeHostClient / ClientPluginQueryClient errors in runtime-host-boot.ts that are present with this change reverted as well, plus unrelated pre-existing failures elsewhere. The three production files touched here typecheck clean.

Not yet verified end to end against a real installer run — see Review focus.

Review focus

Two things I could not pin down, and deliberately did not build the fix around:

  1. Which stage hangs first. The window was still visible when the diagnostic report was captured, so prepareToQuit() had not reached its destroy(). I could not determine statically whether it hangs in preparation or in cleanup(); candidates include mcpManager.close() (an npx-spawned server) and guestSessionMountService.close(). The bound applies to both stages, so the fix does not depend on the answer, and onQuitTimeout will name the stage the next time it happens.

  2. The remaining window. installUpdate still retires the Host before quitAndInstall, so a few seconds of "Host retired, process still up" remain by design. Making that atomic — demoting prepareInstall to a read-only admission check via host.status / isHostActivityIdle and pushing retirement into the quit path — is a larger change to retirement timing and is better done separately.

Also worth a second opinion: forceExit maps to app.exit(0), which skips remaining teardown. That is intentional as a last resort, but it does mean a stuck cleanup no longer gets to finish flushing.

AI use

Select exactly one:

  • No generative tool made a substantive contribution
  • Generative tooling made a substantive contribution

Tool(s) and scope: Maka (Claude). Diagnosis from the diagnostic report, ShipIt logs and Squirrel.Mac / VS Code sources; implementation of the bounded quit sequence and the abandoned-quit rollback; the accompanying tests. Reviewed and submitted by the contributor of record. The commit carries a Generated-by: Maka (Claude) trailer.

Checklist

  • Tests cover the change and fail without it
  • Lint, format, typecheck and the affected suites pass locally

Lint and the affected suites pass; format does not cover these paths; the full typecheck has pre-existing failures on the base unrelated to this change (detailed under Verification).

Does this PR entail a change in behavior?

  • Yes — described under Summary above
  • No

…an update

Installing a Desktop update could leave the app running forever with its
Runtime Host already retired. Every Host-backed IPC channel failed, the
Projects list emptied to "No project", model refresh reported a network
error that had nothing to do with the network, and the update was never
applied. Only a manual relaunch recovered, after which it failed again.

installUpdate retires the Host before handing off to the updater, and that
retirement only unwinds through its rollback. The quit that was supposed to
follow was awaited without any upper bound: prepareToQuit() and cleanup()
each wait on Host retirement, MCP child processes, peer mesh teardown and
native resources, any of which can stop settling. When one did, phase stayed
in preparing/cleaning and every later quit request was swallowed by the
"if (phase !== 'running') return" guard after the event had already been
prevented. Squirrel's ShipIt waits for this process to exit before replacing
the bundle, so the install blocked indefinitely with nothing to read.

Bound both stages and give the sequence a way out. Preparation is a courtesy
to the Host, so exceeding its bound reports and continues; refusing to quit
is the worse outcome. Cleanup has nothing left to wait for, so exceeding its
bound exits the process outright. A quit that is cancelled or fails now
reports through onQuitAbandoned, which releases the install handoff: the
coordinator knew the quit was abandoned but had no way to say so, leaving
the Host retired with nothing left to restart it.

Timeouts report through their own channel rather than the error sinks. A
stage that stopped answering is not a Host that refused, and conflating them
invents a failure the product then has to explain.

Generated-by: Maka (Claude)
@github-actions github-actions Bot added the effort/M Under 500 readable lines label Sep 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/M Under 500 readable lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Update install leaves the app running with a retired Runtime Host, and never applies the update

1 participant