Skip to content

fix(desktop): quit retirement always times out before the Host can exit #4730

Closed

Description

@Astro-Han

What happened

Quitting Desktop fails with "Runtime Host did not exit before retirement". The quit is cancelled and the window comes back, while the Host actually exits a moment later — so a second quit attempt succeeds.

This is not intermittent. Whenever the Host needs its shutdown grace period, the Desktop side is guaranteed to give up first.

Two independent 10s budgets, neither aware of the other:

  • Host: DEFAULT_SHUTDOWN_GRACE_MS = 10_000 (packages/runtime-host/src/server/host-kernel.ts), armed inside #requestDrain() — that is, while host.upgrade.prepare is still being handled.
  • Desktop: waitForProcessExit deadline Date.now() + 10_000 (apps/desktop/src/main/runtime-host-desktop-manager.ts), started only after the prepare response arrives.

The Host timer starts strictly earlier, and after it fires the process still has to run process.exit and be reaped by the OS. Desktop polls process.kill(pid, 0), so it observes termination strictly later than the Host decides on it. Whenever the Host reaches its grace-period fallback, Desktop's deadline has already expired. This holds by construction, not by timing luck.

The Host reaches that fallback when composition.close() cannot settle in time: RootTurnCoordinator.close() stops every active Turn and then awaits disposition.active.done, which has no timeout of its own. One Turn blocked in a backend call is enough.

On failure, prepareToQuit() rejects, app-quit-coordinator sets the phase back to running and calls focusOrCreateWindow() — hence the dialog plus the reappearing window.

How to reproduce

  1. Start a turn that will not stop quickly (a long or stalled model call is enough).
  2. Quit Desktop while that turn is still active.
  3. The failure dialog appears; the window returns. Quitting again succeeds.

Environment

  • Surface: Desktop (quit path) + Runtime Host
  • Reported by a user on Windows; the code path is shared, so this is not platform-specific.
  • Present on current main.

Logs, screenshots, or additional context

Introduced by #3706, which added the synchronous retirement this timeout lives in.

Related architecture: #3709. Its convergence step 7 — "observe writer release rather than infer retirement from stale registration" — would remove the PID polling this bug lives in. Its acceptance criterion "retire, or surface a bounded, actionable failure" is currently satisfied by a failure that is guaranteed rather than exceptional.

Activity

  1. me2seeks commented on Sep 4, 2026

    @me2seeks
    Contributor

    Taking this one. Agreed with the framing in #3709: the fix should remove the root cause, not tune either timeout.

    Direction. The quit path should not drive retirement at all. RuntimeHostDesktopManager.#close() releases candidateLaunchBarrier unconditionally before closing IPC, which detaches the launch-owner guard at the exact moment it should be the close authority — the guard then ignores the IPC disconnect that would otherwise have closed the Host. If the guard's disconnect path stays intact, the Host closes on its own on quit, and #3706's synchronous retirement (including the waitForProcessExit PID polling) becomes a transitional wrapper to delete rather than a path to keep.

    Repro plan to pin the exact mechanism before changing code:

    1. Owned ephemeral Host with a drain that needs its full grace period (e.g. a residency that releases only near the deadline, and a second run with a 'process-retention'-style residency that never releases).
    2. Drive host.upgrade.prepare through the real Desktop quit path, capture timestamps for: drain request, prepare response, Host deadline fire, process exit, and Desktop deadline expiry.
    3. Confirm where the process death is delayed past the Desktop waitForProcessExit deadline — my read of the code is that prepare responds immediately after #requestDrain(), so the guaranteed timeout likely needs the post-deadline exit path (or a stuck residency) to delay the actual exit.

    Two independent budgets for one operation (Host shutdownGraceMs armed inside #requestDrain() vs Desktop waitForProcessExit starting after the response) is wrong regardless of which mechanism the repro confirms; the root-cause fix removes the Desktop budget entirely.

    Sequencing: this issue tracks the guaranteed-timeout bug until the guard-disconnect fix lands; I'll post the repro results here first.

  2. Astro-Han commented on Sep 4, 2026

    @Astro-Han
    ContributorAuthor

    One correction before you spend time on the repro: the timing claim in my description is stale.

    #4709 landed earlier today and already moved waitForProcessExit to 12s, with an explicit comment that the margin exists so Desktop cannot race the Host's final process.exit. With #requestDrain() being synchronous and process-lifecycle.ts exiting the moment the deadline fires, the "Desktop necessarily gives up first" claim does not hold on current main — your read was right. I audited a snapshot of main from before #4709 and did not re-check before filing. The user report was the pre-#4709 behavior.

    The guard-detach root cause is untouched by #4709, so the direction you outlined still stands. Corrected my comment on #3709 as well; this is yours.

  3. me2seeks commented on Sep 4, 2026

    @me2seeks
    Contributor

    Confirmed the correction against current main: #4709 widened the observation margin to 12s with an explicit "cannot race the final process.exit" comment, and process-lifecycle.ts exits the moment the Host's own deadline fires. The guaranteed-timeout symptom is gone on main; the user report predates #4709. Dropping the timing repro from my earlier plan.

    Scope update for this issue: the guard-detach root cause is untouched — #close() still releases candidateLaunchBarrier unconditionally, so the quit path still drives a retirement it should not need. Converging this issue to the root-cause fix: keep the launch-owner guard's disconnect path as the close authority on quit, and delete #3706's transitional synchronous retirement including the waitForProcessExit polling.

  4. added 5 commits that reference this issue on Sep 5, 2026
    f98e93a
    851407f
    c346b76
    eb6b7c2
    6aabc76
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions