From bbc99df29798c78773fe5e9b3c5d9f6b6dfb4d45 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Thu, 22 Oct 2026 21:51:38 +0000 Subject: [PATCH 1/2] fix(desktop): a stuck quit no longer leaves the app unkillable Once a quit starts, the app sets `quitting`, hides its windows and waits for shutdown. If that shutdown never finishes, every later Quit and SIGTERM was swallowed, so only SIGKILL could end the app. Now a Quit or SIGTERM that arrives after shutdown has been pending for 10 seconds exits the app. A repeat inside that window is ignored as before, so a double Cmd-Q during a normal quit cannot orphan the backend. Refs #23843 Co-Authored-By: Claude Opus 5.5 --- apps/desktop/src/app/DesktopLifecycle.test.ts | 52 +++++++++++++- apps/desktop/src/app/DesktopLifecycle.ts | 71 ++++++++++++++----- 2 files changed, 105 insertions(+), 18 deletions(-) diff --git a/apps/desktop/src/app/DesktopLifecycle.test.ts b/apps/desktop/src/app/DesktopLifecycle.test.ts index f9f38dea3dd4..ec79324cc79c 100644 --- a/apps/desktop/src/app/DesktopLifecycle.test.ts +++ b/apps/desktop/src/app/DesktopLifecycle.test.ts @@ -3,6 +3,7 @@ import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Ref from "effect/Ref"; +import * as TestClock from "effect/testing/TestClock"; import type * as Electron from "electron"; @@ -18,6 +19,7 @@ import * as DesktopWindow from "../window/DesktopWindow.ts"; function layerElectronApp( appListeners: Map void>, quit: Effect.Effect = Effect.void, + exit: (code: number) => Effect.Effect = () => Effect.void, ) { const registerListener = (eventName: string, listener: (...args: readonly unknown[]) => void) => Effect.acquireRelease( @@ -37,7 +39,7 @@ function layerElectronApp( whenReady: Effect.void, requestSingleInstanceLock: Effect.succeed(true), quit, - exit: () => Effect.void, + exit, relaunch: () => Effect.void, setPath: () => Effect.void, setName: () => Effect.void, @@ -221,6 +223,54 @@ describe("DesktopLifecycle", () => { }), ); + it.effect("exits on a repeated quit only once shutdown has stalled", () => + Effect.gen(function* () { + const appListeners = new Map void>(); + const exitCodes: number[] = []; + const exit = (code: number) => + Effect.sync(() => { + exitCodes.push(code); + }); + const layerEnvironment = Layer.succeed(DesktopEnvironment.DesktopEnvironment, { + platform: "darwin", + isDevelopment: false, + } as DesktopEnvironment.DesktopEnvironment["Service"]); + const layer = DesktopLifecycle.layer.pipe( + Layer.provideMerge(layerElectronApp(appListeners, Effect.void, exit)), + Layer.provideMerge(layerElectronTheme), + Layer.provideMerge(layerElectronWindow()), + Layer.provideMerge(layerDesktopWindow()), + Layer.provideMerge(layerEnvironment), + // Nothing marks this shutdown complete, so it stays pending. + Layer.provideMerge(DesktopShutdown.layer), + Layer.provideMerge(DesktopState.layer), + ); + + yield* Effect.scoped( + Effect.gen(function* () { + const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; + yield* lifecycle.register; + + let prevented = 0; + const event = { + preventDefault: () => { + prevented += 1; + }, + } as Electron.Event; + appListeners.get("before-quit")?.(event); + appListeners.get("before-quit")?.(event); + assert.deepEqual(exitCodes, [], "a quick repeat must not cut a normal shutdown short"); + + yield* TestClock.adjust("10 seconds"); + appListeners.get("before-quit")?.(event); + + assert.deepEqual(exitCodes, [0]); + assert.equal(prevented, 3); + }), + ).pipe(Effect.provide(layer)); + }), + ); + it.effect("ignores app activation while quitting", () => Effect.gen(function* () { const appListeners = new Map void>(); diff --git a/apps/desktop/src/app/DesktopLifecycle.ts b/apps/desktop/src/app/DesktopLifecycle.ts index 924341781aa6..ae0575f57685 100644 --- a/apps/desktop/src/app/DesktopLifecycle.ts +++ b/apps/desktop/src/app/DesktopLifecycle.ts @@ -1,4 +1,6 @@ +import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Ref from "effect/Ref"; @@ -94,15 +96,46 @@ const requestDesktopShutdownAndWait = Effect.fn("desktop.lifecycle.requestShutdo }, ); +interface QuitState { + phase: "running" | "waiting" | "allowed"; + waitingSince: number; +} + +// Longer than a backend's stop budget (SIGTERM, 5s grace, SIGKILL, 1s wait), +// so a repeated Cmd-Q during a normal quit cannot orphan the backend. +const SHUTDOWN_STALL_THRESHOLD = Duration.seconds(10); + +const startWaitingForShutdown = (quit: QuitState) => + Effect.map(Clock.currentTimeMillis, (now) => { + quit.phase = "waiting"; + quit.waitingSince = now; + }); + +// While a quit waits on shutdown, `quitting` makes activation open nothing. If +// that shutdown stalls, a repeated Quit or SIGTERM is the user's only way out +// short of SIGKILL. +const exitIfShutdownStalled = Effect.fn("desktop.lifecycle.exitIfShutdownStalled")(function* ( + quit: QuitState, + source: string, +) { + const pendingMs = (yield* Clock.currentTimeMillis) - quit.waitingSince; + if (pendingMs < Duration.toMillis(SHUTDOWN_STALL_THRESHOLD)) { + yield* logLifecycleInfo("quit already in progress", { source, pendingMs }); + return; + } + yield* logLifecycleError("desktop shutdown stalled; exiting without it", { source, pendingMs }); + const electronApp = yield* ElectronApp.ElectronApp; + yield* electronApp.exit(0); +}); + function handleBeforeQuit( event: Electron.Event, runEffect: ( effect: Effect.Effect, ) => Promise, - allowQuit: () => boolean, - markQuitAllowed: () => void, + quit: QuitState, ): void { - if (allowQuit()) { + if (quit.phase === "allowed") { void runEffect( Effect.gen(function* () { const state = yield* DesktopState.DesktopState; @@ -114,10 +147,15 @@ function handleBeforeQuit( } event.preventDefault(); + if (quit.phase === "waiting") { + void runEffect(exitIfShutdownStalled(quit, "before-quit")); + return; + } void runEffect( Effect.gen(function* () { const state = yield* DesktopState.DesktopState; const electronWindow = yield* ElectronWindow.ElectronWindow; + yield* startWaitingForShutdown(quit); yield* Ref.set(state.quitting, true); yield* logLifecycleInfo("before-quit received"); yield* requestDesktopShutdownAndWait( @@ -129,7 +167,7 @@ function handleBeforeQuit( ); }).pipe(Effect.withSpan("desktop.lifecycle.beforeQuit")), ).finally(() => { - markQuitAllowed(); + quit.phase = "allowed"; void runEffect( Effect.gen(function* () { const electronApp = yield* ElectronApp.ElectronApp; @@ -144,7 +182,12 @@ function quitFromSignal( runEffect: ( effect: Effect.Effect, ) => Promise, + quit: QuitState, ): void { + if (quit.phase === "waiting") { + void runEffect(exitIfShutdownStalled(quit, signal)); + return; + } void runEffect( Effect.gen(function* () { yield* Effect.annotateCurrentSpan({ signal }); @@ -152,8 +195,10 @@ function quitFromSignal( const state = yield* DesktopState.DesktopState; const wasQuitting = yield* Ref.getAndSet(state.quitting, true); if (wasQuitting) return; + yield* startWaitingForShutdown(quit); yield* logLifecycleInfo("process signal received", { signal }); yield* requestDesktopShutdownAndWait(); + quit.phase = "allowed"; yield* electronApp.quit; }).pipe(Effect.withSpan("desktop.lifecycle.processSignal")), ); @@ -196,8 +241,7 @@ export const make = DesktopLifecycle.of({ const environment = yield* DesktopEnvironment.DesktopEnvironment; const context = yield* Effect.context(); const runEffect = Effect.runPromiseWith(context); - let quitAllowed = false; - let updaterQuitAllowed = false; + const quit: QuitState = { phase: "running", waitingSince: 0 }; yield* electronTheme.onUpdated(() => { void runEffect( desktopWindow.syncAppearance.pipe(Effect.withSpan("desktop.lifecycle.themeUpdated")), @@ -207,7 +251,7 @@ export const make = DesktopLifecycle.of({ // Electron's updater owns the remaining quit/install/relaunch sequence. // Cancelling the following app "before-quit" event breaks that sequence, // most visibly on macOS where the native updater performs the relaunch. - updaterQuitAllowed = true; + quit.phase = "allowed"; // This event is synchronous and the updater's quit proceeds as soon as // the listener returns, so a forked destroyAll would race the quit // and windows could still be open when the process exits (visible on @@ -223,14 +267,7 @@ export const make = DesktopLifecycle.of({ ); }); yield* electronApp.on("before-quit", (event: Electron.Event) => { - handleBeforeQuit( - event, - runEffect, - () => quitAllowed || updaterQuitAllowed, - () => { - quitAllowed = true; - }, - ); + handleBeforeQuit(event, runEffect, quit); }); yield* electronApp.on("activate", () => { void runEffect( @@ -255,10 +292,10 @@ export const make = DesktopLifecycle.of({ if (environment.platform !== "win32") { yield* addScopedListener(process, "SIGINT", () => { - quitFromSignal("SIGINT", runEffect); + quitFromSignal("SIGINT", runEffect, quit); }); yield* addScopedListener(process, "SIGTERM", () => { - quitFromSignal("SIGTERM", runEffect); + quitFromSignal("SIGTERM", runEffect, quit); }); } }).pipe(Effect.withSpan("desktop.lifecycle.register")), From 91e08f662c7e6eb993238e7ad02528cf0e256e52 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Thu, 22 Oct 2026 21:57:49 +0000 Subject: [PATCH 2/2] fix(desktop): time a stalled quit on the monotonic clock Review follow-up. A wall-clock jump could force an early exit or keep ignoring Quit for longer, so measure the pending shutdown with Clock.monotonicTimeNanos. Name startWaitingForShutdown with Effect.fn, and test a repeat just under the 10 second threshold. Co-Authored-By: Claude Opus 5.5 --- apps/desktop/src/app/DesktopLifecycle.test.ts | 12 +++++++--- apps/desktop/src/app/DesktopLifecycle.ts | 22 +++++++++++-------- 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/apps/desktop/src/app/DesktopLifecycle.test.ts b/apps/desktop/src/app/DesktopLifecycle.test.ts index ec79324cc79c..6a6e991fc6b4 100644 --- a/apps/desktop/src/app/DesktopLifecycle.test.ts +++ b/apps/desktop/src/app/DesktopLifecycle.test.ts @@ -259,13 +259,19 @@ describe("DesktopLifecycle", () => { } as Electron.Event; appListeners.get("before-quit")?.(event); appListeners.get("before-quit")?.(event); - assert.deepEqual(exitCodes, [], "a quick repeat must not cut a normal shutdown short"); + yield* TestClock.adjust("9999 millis"); + appListeners.get("before-quit")?.(event); + assert.deepEqual( + exitCodes, + [], + "a repeat before the threshold must not cut shutdown short", + ); - yield* TestClock.adjust("10 seconds"); + yield* TestClock.adjust("1 millis"); appListeners.get("before-quit")?.(event); assert.deepEqual(exitCodes, [0]); - assert.equal(prevented, 3); + assert.equal(prevented, 4); }), ).pipe(Effect.provide(layer)); }), diff --git a/apps/desktop/src/app/DesktopLifecycle.ts b/apps/desktop/src/app/DesktopLifecycle.ts index ae0575f57685..7512d2bc1741 100644 --- a/apps/desktop/src/app/DesktopLifecycle.ts +++ b/apps/desktop/src/app/DesktopLifecycle.ts @@ -98,18 +98,20 @@ const requestDesktopShutdownAndWait = Effect.fn("desktop.lifecycle.requestShutdo interface QuitState { phase: "running" | "waiting" | "allowed"; - waitingSince: number; + waitingSinceNanos: bigint; } // Longer than a backend's stop budget (SIGTERM, 5s grace, SIGKILL, 1s wait), -// so a repeated Cmd-Q during a normal quit cannot orphan the backend. +// so a double Cmd-Q at the start of a normal quit cannot cut the backend stop +// short and orphan it. const SHUTDOWN_STALL_THRESHOLD = Duration.seconds(10); -const startWaitingForShutdown = (quit: QuitState) => - Effect.map(Clock.currentTimeMillis, (now) => { - quit.phase = "waiting"; - quit.waitingSince = now; - }); +const startWaitingForShutdown = Effect.fn("desktop.lifecycle.startWaitingForShutdown")(function* ( + quit: QuitState, +) { + quit.phase = "waiting"; + quit.waitingSinceNanos = yield* Clock.monotonicTimeNanos; +}); // While a quit waits on shutdown, `quitting` makes activation open nothing. If // that shutdown stalls, a repeated Quit or SIGTERM is the user's only way out @@ -118,7 +120,9 @@ const exitIfShutdownStalled = Effect.fn("desktop.lifecycle.exitIfShutdownStalled quit: QuitState, source: string, ) { - const pendingMs = (yield* Clock.currentTimeMillis) - quit.waitingSince; + const pendingMs = Number( + ((yield* Clock.monotonicTimeNanos) - quit.waitingSinceNanos) / 1_000_000n, + ); if (pendingMs < Duration.toMillis(SHUTDOWN_STALL_THRESHOLD)) { yield* logLifecycleInfo("quit already in progress", { source, pendingMs }); return; @@ -241,7 +245,7 @@ export const make = DesktopLifecycle.of({ const environment = yield* DesktopEnvironment.DesktopEnvironment; const context = yield* Effect.context(); const runEffect = Effect.runPromiseWith(context); - const quit: QuitState = { phase: "running", waitingSince: 0 }; + const quit: QuitState = { phase: "running", waitingSinceNanos: 0n }; yield* electronTheme.onUpdated(() => { void runEffect( desktopWindow.syncAppearance.pipe(Effect.withSpan("desktop.lifecycle.themeUpdated")),