1+ import * as Clock from "effect/Clock" ;
12import * as Context from "effect/Context" ;
3+ import * as Duration from "effect/Duration" ;
24import * as Effect from "effect/Effect" ;
35import * as Layer from "effect/Layer" ;
46import * as Ref from "effect/Ref" ;
@@ -94,15 +96,50 @@ const requestDesktopShutdownAndWait = Effect.fn("desktop.lifecycle.requestShutdo
9496 } ,
9597) ;
9698
99+ interface QuitState {
100+ phase : "running" | "waiting" | "allowed" ;
101+ waitingSinceNanos : bigint ;
102+ }
103+
104+ // Longer than a backend's stop budget (SIGTERM, 5s grace, SIGKILL, 1s wait),
105+ // so a double Cmd-Q at the start of a normal quit cannot cut the backend stop
106+ // short and orphan it.
107+ const SHUTDOWN_STALL_THRESHOLD = Duration . seconds ( 10 ) ;
108+
109+ const startWaitingForShutdown = Effect . fn ( "desktop.lifecycle.startWaitingForShutdown" ) ( function * (
110+ quit : QuitState ,
111+ ) {
112+ quit . phase = "waiting" ;
113+ quit . waitingSinceNanos = yield * Clock . monotonicTimeNanos ;
114+ } ) ;
115+
116+ // While a quit waits on shutdown, `quitting` makes activation open nothing. If
117+ // that shutdown stalls, a repeated Quit or SIGTERM is the user's only way out
118+ // short of SIGKILL.
119+ const exitIfShutdownStalled = Effect . fn ( "desktop.lifecycle.exitIfShutdownStalled" ) ( function * (
120+ quit : QuitState ,
121+ source : string ,
122+ ) {
123+ const pendingMs = Number (
124+ ( ( yield * Clock . monotonicTimeNanos ) - quit . waitingSinceNanos ) / 1_000_000n ,
125+ ) ;
126+ if ( pendingMs < Duration . toMillis ( SHUTDOWN_STALL_THRESHOLD ) ) {
127+ yield * logLifecycleInfo ( "quit already in progress" , { source, pendingMs } ) ;
128+ return ;
129+ }
130+ yield * logLifecycleError ( "desktop shutdown stalled; exiting without it" , { source, pendingMs } ) ;
131+ const electronApp = yield * ElectronApp . ElectronApp ;
132+ yield * electronApp . exit ( 0 ) ;
133+ } ) ;
134+
97135function handleBeforeQuit (
98136 event : Electron . Event ,
99137 runEffect : < A , E > (
100138 effect : Effect . Effect < A , E , DesktopLifecycleRegistrationServices > ,
101139 ) => Promise < A > ,
102- allowQuit : ( ) => boolean ,
103- markQuitAllowed : ( ) => void ,
140+ quit : QuitState ,
104141) : void {
105- if ( allowQuit ( ) ) {
142+ if ( quit . phase === "allowed" ) {
106143 void runEffect (
107144 Effect . gen ( function * ( ) {
108145 const state = yield * DesktopState . DesktopState ;
@@ -114,10 +151,15 @@ function handleBeforeQuit(
114151 }
115152
116153 event . preventDefault ( ) ;
154+ if ( quit . phase === "waiting" ) {
155+ void runEffect ( exitIfShutdownStalled ( quit , "before-quit" ) ) ;
156+ return ;
157+ }
117158 void runEffect (
118159 Effect . gen ( function * ( ) {
119160 const state = yield * DesktopState . DesktopState ;
120161 const electronWindow = yield * ElectronWindow . ElectronWindow ;
162+ yield * startWaitingForShutdown ( quit ) ;
121163 yield * Ref . set ( state . quitting , true ) ;
122164 yield * logLifecycleInfo ( "before-quit received" ) ;
123165 yield * requestDesktopShutdownAndWait (
@@ -129,7 +171,7 @@ function handleBeforeQuit(
129171 ) ;
130172 } ) . pipe ( Effect . withSpan ( "desktop.lifecycle.beforeQuit" ) ) ,
131173 ) . finally ( ( ) => {
132- markQuitAllowed ( ) ;
174+ quit . phase = "allowed" ;
133175 void runEffect (
134176 Effect . gen ( function * ( ) {
135177 const electronApp = yield * ElectronApp . ElectronApp ;
@@ -144,16 +186,23 @@ function quitFromSignal(
144186 runEffect : < A , E > (
145187 effect : Effect . Effect < A , E , DesktopLifecycleRegistrationServices > ,
146188 ) => Promise < A > ,
189+ quit : QuitState ,
147190) : void {
191+ if ( quit . phase === "waiting" ) {
192+ void runEffect ( exitIfShutdownStalled ( quit , signal ) ) ;
193+ return ;
194+ }
148195 void runEffect (
149196 Effect . gen ( function * ( ) {
150197 yield * Effect . annotateCurrentSpan ( { signal } ) ;
151198 const electronApp = yield * ElectronApp . ElectronApp ;
152199 const state = yield * DesktopState . DesktopState ;
153200 const wasQuitting = yield * Ref . getAndSet ( state . quitting , true ) ;
154201 if ( wasQuitting ) return ;
202+ yield * startWaitingForShutdown ( quit ) ;
155203 yield * logLifecycleInfo ( "process signal received" , { signal } ) ;
156204 yield * requestDesktopShutdownAndWait ( ) ;
205+ quit . phase = "allowed" ;
157206 yield * electronApp . quit ;
158207 } ) . pipe ( Effect . withSpan ( "desktop.lifecycle.processSignal" ) ) ,
159208 ) ;
@@ -196,8 +245,7 @@ export const make = DesktopLifecycle.of({
196245 const environment = yield * DesktopEnvironment . DesktopEnvironment ;
197246 const context = yield * Effect . context < DesktopLifecycleRegistrationServices > ( ) ;
198247 const runEffect = Effect . runPromiseWith ( context ) ;
199- let quitAllowed = false ;
200- let updaterQuitAllowed = false ;
248+ const quit : QuitState = { phase : "running" , waitingSinceNanos : 0n } ;
201249 yield * electronTheme . onUpdated ( ( ) => {
202250 void runEffect (
203251 desktopWindow . syncAppearance . pipe ( Effect . withSpan ( "desktop.lifecycle.themeUpdated" ) ) ,
@@ -207,7 +255,7 @@ export const make = DesktopLifecycle.of({
207255 // Electron's updater owns the remaining quit/install/relaunch sequence.
208256 // Cancelling the following app "before-quit" event breaks that sequence,
209257 // most visibly on macOS where the native updater performs the relaunch.
210- updaterQuitAllowed = true ;
258+ quit . phase = "allowed" ;
211259 // This event is synchronous and the updater's quit proceeds as soon as
212260 // the listener returns, so a forked destroyAll would race the quit
213261 // and windows could still be open when the process exits (visible on
@@ -223,14 +271,7 @@ export const make = DesktopLifecycle.of({
223271 ) ;
224272 } ) ;
225273 yield * electronApp . on ( "before-quit" , ( event : Electron . Event ) => {
226- handleBeforeQuit (
227- event ,
228- runEffect ,
229- ( ) => quitAllowed || updaterQuitAllowed ,
230- ( ) => {
231- quitAllowed = true ;
232- } ,
233- ) ;
274+ handleBeforeQuit ( event , runEffect , quit ) ;
234275 } ) ;
235276 yield * electronApp . on ( "activate" , ( ) => {
236277 void runEffect (
@@ -255,10 +296,10 @@ export const make = DesktopLifecycle.of({
255296
256297 if ( environment . platform !== "win32" ) {
257298 yield * addScopedListener ( process , "SIGINT" , ( ) => {
258- quitFromSignal ( "SIGINT" , runEffect ) ;
299+ quitFromSignal ( "SIGINT" , runEffect , quit ) ;
259300 } ) ;
260301 yield * addScopedListener ( process , "SIGTERM" , ( ) => {
261- quitFromSignal ( "SIGTERM" , runEffect ) ;
302+ quitFromSignal ( "SIGTERM" , runEffect , quit ) ;
262303 } ) ;
263304 }
264305 } ) . pipe ( Effect . withSpan ( "desktop.lifecycle.register" ) ) ,
0 commit comments