fix(desktop): bound Windows Electron quit finalization

This commit is contained in:
joaomarcos
2026-09-19 21:03:32 -03:00
committed by Teknium
parent c2ccad49a6
commit a598d5273e
3 changed files with 159 additions and 0 deletions

View File

@@ -358,6 +358,7 @@ import {
tagRegistrySessionResponse
} from './profile-session-routing'
import { createQuickEntryShortcut, quickEntryWindowBounds, sanitizeQuickEntrySettings } from './quick-entry'
import { createQuitFinalization } from './quit-finalization'
import { type ActiveWork, mergeActiveWork, normalizeActiveWork, quitPromptFor } from './quit-guard'
import { backendQuitNeedsWait, createQuitTeardownCoordinator } from './quit-teardown'
import * as remoteLifecycle from './remote-lifecycle'
@@ -12709,6 +12710,13 @@ const backendShutdown = createBackendShutdownCoordinator(async () => {
})
const quitTeardown = createQuitTeardownCoordinator(() => app.quit())
const quitFinalization = createQuitFinalization({
isWindows: IS_WINDOWS,
hardExit: code => {
rememberLog(`[quit] forcing Windows process exit after Electron quit finalization stalled`)
app.exit(code)
}
})
async function teardownSshForQuit() {
const scopes = [...sshConnections.keys()]
@@ -17222,6 +17230,11 @@ app.on('before-quit', () => {
app.on('will-quit', () => {
sshIsolatedKeepalives.stopAll()
destroyKeepaliveAgents()
quitFinalization.arm()
})
app.on('quit', () => {
quitFinalization.cancel()
})
// Answered synchronously so preload can publish the verdict before the

View File

@@ -0,0 +1,76 @@
import assert from 'node:assert/strict'
import { test, vi } from 'vitest'
import { createQuitFinalization } from './quit-finalization'
test('does not arm a finalization fallback outside Windows', () => {
const schedule = vi.fn()
const hardExit = vi.fn()
const finalization = createQuitFinalization({ isWindows: false, schedule, hardExit })
finalization.arm()
assert.equal(schedule.mock.calls.length, 0)
assert.equal(hardExit.mock.calls.length, 0)
})
test('forces a Windows exit once the admitted quit exceeds its deadline', () => {
let onTimeout: (() => void) | undefined
const hardExit = vi.fn()
const finalization = createQuitFinalization({
isWindows: true,
schedule: callback => {
onTimeout = callback
return 'timer'
},
hardExit
})
finalization.arm()
finalization.arm()
assert.ok(onTimeout)
onTimeout()
onTimeout()
assert.deepEqual(hardExit.mock.calls, [[0]])
})
test('cancels the fallback when Electron reports a completed quit', () => {
let onTimeout: (() => void) | undefined
const cancel = vi.fn()
const hardExit = vi.fn()
const finalization = createQuitFinalization({
isWindows: true,
schedule: callback => {
onTimeout = callback
return 'timer'
},
cancel,
hardExit
})
finalization.arm()
finalization.cancel()
onTimeout?.()
assert.deepEqual(cancel.mock.calls, [['timer']])
assert.equal(hardExit.mock.calls.length, 0)
})
test('does not re-arm after finalization has been cancelled', () => {
const schedule = vi.fn(() => 'timer')
const hardExit = vi.fn()
const finalization = createQuitFinalization({ isWindows: true, schedule, hardExit })
finalization.arm()
finalization.cancel()
finalization.arm()
assert.equal(schedule.mock.calls.length, 1)
})

View File

@@ -0,0 +1,70 @@
const DEFAULT_TIMEOUT_MS = 10_000
type TimerHandle = unknown
type Schedule = (callback: () => void, timeoutMs: number) => TimerHandle
type Cancel = (handle: TimerHandle) => void
export interface QuitFinalizationOptions {
isWindows: boolean
hardExit: (code: number) => void
timeoutMs?: number
schedule?: Schedule
cancel?: Cancel
}
export interface QuitFinalization {
arm: () => void
cancel: () => void
}
/**
* Provides a bounded escape hatch for a Windows Electron process that has
* entered its final quit phase but never emits the completed quit event.
*
* The fallback is deliberately armed only from `will-quit`, after the normal
* before-quit teardown has been admitted. A successful `quit` event cancels it.
*/
export function createQuitFinalization({
isWindows,
hardExit,
timeoutMs = DEFAULT_TIMEOUT_MS,
schedule = (callback, delay) => setTimeout(callback, delay),
cancel = handle => clearTimeout(handle as ReturnType<typeof setTimeout>)
}: QuitFinalizationOptions): QuitFinalization {
let timer: TimerHandle | null = null
let finished = false
return {
arm() {
if (!isWindows || finished || timer !== null) {
return
}
timer = schedule(() => {
timer = null
if (finished) {
return
}
finished = true
hardExit(0)
}, timeoutMs)
},
cancel() {
if (finished) {
return
}
finished = true
if (timer !== null) {
cancel(timer)
timer = null
}
}
}
}