You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
fix(desktop): quit retirement always times out before the Host can exit #4730
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
Start a turn that will not stop quickly (a long or stalled model call is enough).
Quit Desktop while that turn is still active.
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.
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:
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).
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.
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.
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.
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.
Activity
me2seeks commentedon Sep 4, 2026
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()releasescandidateLaunchBarrierunconditionally 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 thewaitForProcessExitPID polling) becomes a transitional wrapper to delete rather than a path to keep.Repro plan to pin the exact mechanism before changing code:
'process-retention'-style residency that never releases).host.upgrade.preparethrough the real Desktop quit path, capture timestamps for: drain request, prepare response, Host deadline fire, process exit, and Desktop deadline expiry.waitForProcessExitdeadline — 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
shutdownGraceMsarmed inside#requestDrain()vs DesktopwaitForProcessExitstarting 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.
Astro-Han commentedon Sep 4, 2026
One correction before you spend time on the repro: the timing claim in my description is stale.
#4709 landed earlier today and already moved
waitForProcessExitto 12s, with an explicit comment that the margin exists so Desktop cannot race the Host's finalprocess.exit. With#requestDrain()being synchronous andprocess-lifecycle.tsexiting the moment the deadline fires, the "Desktop necessarily gives up first" claim does not hold on currentmain— your read was right. I audited a snapshot ofmainfrom 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.
me2seeks commentedon Sep 4, 2026
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.tsexits 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 releasescandidateLaunchBarrierunconditionally, 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 thewaitForProcessExitpolling.fix(desktop): let the launch-owner guard retire the owned Host on quit
fix(desktop): let the launch-owner guard retire the owned Host on quit
fix(desktop): let the launch-owner guard retire the owned Host on quit
fix(desktop): let the launch-owner guard retire the owned Host on quit
fix(desktop): let the launch-owner guard retire the owned Host on quit