Conversation
…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)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
installUpdateretires 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()andcleanup()each wait on Host retirement, MCP child processes, peer mesh teardown and native resources, any of which can stop settling. When one did,phasestayed inpreparing/cleaningand every later quit request was swallowed byif (phase !== 'running') returnafter 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.onQuitAbandoned, which releases the install handoff via the newAppUpdateService.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
onQuitTimeoutchannel 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
Confirmed the tests fail without the fix: reverting
app-quit-coordinator.tsalone (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 lintclean on the five touched files;biome formatdoes not coversrc/mainperbiome.jsonc. ASF header check passes.Not run: full
npm run typecheckand the complete suite. On this branch's base,tsc -p tsconfig.main.jsonreports two pre-existingDesktopRuntimeHostClient/ClientPluginQueryClienterrors inruntime-host-boot.tsthat 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:
Which stage hangs first. The window was still visible when the diagnostic report was captured, so
prepareToQuit()had not reached itsdestroy(). I could not determine statically whether it hangs in preparation or incleanup(); candidates includemcpManager.close()(annpx-spawned server) andguestSessionMountService.close(). The bound applies to both stages, so the fix does not depend on the answer, andonQuitTimeoutwill name the stage the next time it happens.The remaining window.
installUpdatestill retires the Host beforequitAndInstall, so a few seconds of "Host retired, process still up" remain by design. Making that atomic — demotingprepareInstallto a read-only admission check viahost.status/isHostActivityIdleand pushing retirement into the quit path — is a larger change to retirement timing and is better done separately.Also worth a second opinion:
forceExitmaps toapp.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:
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
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?