Add updater diagnostics and refine daemon teardown policy on quit (#7544)
* Add updater diagnostics and refine daemon teardown policy on quit
Introduce detailed logging, crash breadcrumbs, and telemetry tracing
events for the update quit-and-install lifecycle. This improves
visibility into macOS Squirrel.Mac state transitions, install guard
timeouts, and pre-quit cleanup failures.
Also, formalize and unit test the daemon teardown policy on quit to
explicitly keep the daemon alive (via disconnect) during updater and
normal quits so that daemon-backed PTYs are preserved for warm reattach.
* Remove updater lifecycle logging
Remove calls to `recordUpdaterLifecycle` across the auto-updater modules. This cleans up unused or redundant telemetry events during macOS update download, ready, and pre-quit installation phases.
* Remove redundant quit daemon teardown policy helper
Inline the daemon teardown check directly into the `will-quit` event
handler in the main process. The deleted `shouldShutdownDaemonForQuit`
helper simply returned `isDevParentShutdownRequested`, so using that
check directly simplifies the codebase and allows removing the redundant
policy and test files.
* Clarify PTY cleanup targets only local in-process PTYs
Update doc comments, test descriptions, and telemetry events to make
it explicit that only local, in-process PTYs are killed on quit.
Daemon-backed PTYs are preserved via daemon disconnect.
* Recover quit-for-update state on autoUpdater error events
- Reset update flags when the installer fails asynchronously via the
autoUpdater 'error' event (such as "no staged update" errors).
- Prevents the app from getting stuck in a half-transitioned state
where PTYs are killed and windows refuse to reopen.
- Migrate legacy updater warning logs and crash breadcrumbs to use
the structured recordUpdaterLifecycle diagnostic system.
* Simplify duplicate isMacInstallerReady() call in update-downloaded handl
Cache the result in a local variable so the deferred-status check reuses
the value from the log call instead of recomputing it.
* fix: tighten quit-and-install failure recovery
Only recover stuck quit-for-update flags after native quitAndInstall is
invoked and before install is committed. Defer PTY kill and close-listener
removal until after a successful native handoff so sync install failures keep
the session intact, and ignore post-commit updater errors for UI/mac state.
* fix: typecheck login-shell args check in shell-ready test
Avoid args.includes('-l') on a const tuple union that only admits '-i'
on one branch; compare args[0] instead so merge-base typecheck passes.
This commit is contained in:
parent
d43dcc7f2a
commit
5d5e2c4b95
|
|
@ -51,6 +51,7 @@ import {
|
|||
rebuildAppMenu
|
||||
} from './menu/register-app-menu'
|
||||
import { checkForUpdatesFromMenu, isQuittingForUpdate } from './updater'
|
||||
import { recordUpdaterLifecycle } from './updater-lifecycle-diagnostics'
|
||||
import {
|
||||
configureElectronNetworkCompatibility,
|
||||
configureDevUserDataPath,
|
||||
|
|
@ -2210,6 +2211,11 @@ app.whenReady().then(async () => {
|
|||
})
|
||||
|
||||
app.on('before-quit', () => {
|
||||
if (isQuittingForUpdate()) {
|
||||
recordUpdaterLifecycle('before_quit_allowed', undefined, {
|
||||
message: 'before-quit allowed for update install'
|
||||
})
|
||||
}
|
||||
isQuitting = true
|
||||
unsubscribeSystemResumeBroadcast?.()
|
||||
unsubscribeSystemResumeBroadcast = null
|
||||
|
|
@ -2231,6 +2237,14 @@ app.on('before-quit', () => {
|
|||
// async work and let Electron exit.
|
||||
let daemonDisconnectDone = false
|
||||
app.on('will-quit', (e) => {
|
||||
const updateQuitInProgress = isQuittingForUpdate()
|
||||
if (updateQuitInProgress) {
|
||||
recordUpdaterLifecycle(
|
||||
'will_quit_cleanup_started',
|
||||
{ daemonTeardown: 'disconnect' },
|
||||
{ message: 'will-quit cleanup for update install; daemonTeardown=disconnect' }
|
||||
)
|
||||
}
|
||||
// Why: before-quit can still be aborted by renderer beforeunload; wait until
|
||||
// the committed quit path before removing the Windows notification icon.
|
||||
destroySystemTray()
|
||||
|
|
|
|||
|
|
@ -3928,7 +3928,7 @@ export function registerHeadlessPtyRuntime(
|
|||
}
|
||||
|
||||
/**
|
||||
* Kill all PTY processes. Call on app quit.
|
||||
* Kill in-process local PTYs. Daemon-backed PTYs are preserved by daemon disconnect.
|
||||
*/
|
||||
export function killAllPty(): void {
|
||||
if (localProvider instanceof LocalPtyProvider) {
|
||||
|
|
|
|||
|
|
@ -1044,7 +1044,7 @@ export class LocalPtyProvider implements IPtyProvider {
|
|||
return ptyProcesses.get(id)
|
||||
}
|
||||
|
||||
/** Kill all PTYs. Call on app quit. */
|
||||
/** Kill all in-process local PTYs. Call on app quit. */
|
||||
killAll(): void {
|
||||
for (const [id, proc] of ptyProcesses) {
|
||||
safeKillAndClean(id, proc)
|
||||
|
|
|
|||
|
|
@ -883,7 +883,9 @@ path=(/custom/bin $path)
|
|||
expect(result.status, `zsh ${args.join(' ')} failed: ${result.stderr}`).toBe(0)
|
||||
expect(result.stdout).toContain('USER_ZSHRC_LOADED=yes')
|
||||
expect(result.stdout).toContain(`FINAL_ZDOTDIR=${testHome}`)
|
||||
expect(result.stdout).toContain(args.includes('-l') ? 'IS_LOGIN=yes' : 'IS_LOGIN=no')
|
||||
// Why: `as const` makes .includes('-l') reject the union of tuple
|
||||
// element types; check the login flag by position instead.
|
||||
expect(result.stdout).toContain(args[0] === '-l' ? 'IS_LOGIN=yes' : 'IS_LOGIN=no')
|
||||
}
|
||||
} finally {
|
||||
rmSync(movedUserData, { recursive: true, force: true })
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
import { compareVersions } from './updater-fallback'
|
||||
import { fetchChangelog } from './updater-changelog'
|
||||
import type { ElectronAutoUpdater } from './electron-updater-loader'
|
||||
import { recordUpdaterLifecycle } from './updater-lifecycle-diagnostics'
|
||||
|
||||
const AUTO_UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000
|
||||
const AUTO_UPDATE_RETRY_INTERVAL_MS = 60 * 60 * 1000
|
||||
|
|
@ -27,6 +28,8 @@ type UpdaterHandlerContext = {
|
|||
getKnownReleaseUrl: () => string | undefined
|
||||
getPendingInstallVersion: () => string
|
||||
getUserInitiatedCheck: () => boolean
|
||||
handleQuitAndInstallFailure: () => boolean
|
||||
isQuitAndInstallHandoffActive: () => boolean
|
||||
hasNewerDownloadedVersion: () => boolean
|
||||
shouldHandleUpdaterErrorEvent: () => boolean
|
||||
clearUpdateAvailableEventPending: (attemptId: number | null) => void
|
||||
|
|
@ -64,6 +67,8 @@ export function registerAutoUpdaterHandlers({
|
|||
getKnownReleaseUrl,
|
||||
getPendingInstallVersion,
|
||||
getUserInitiatedCheck,
|
||||
handleQuitAndInstallFailure,
|
||||
isQuitAndInstallHandoffActive,
|
||||
hasNewerDownloadedVersion,
|
||||
shouldHandleUpdaterErrorEvent,
|
||||
clearUpdateAvailableEventPending,
|
||||
|
|
@ -89,7 +94,8 @@ export function registerAutoUpdaterHandlers({
|
|||
// Track Squirrel readiness so we don't show "ready to install" prematurely.
|
||||
if (process.platform === 'darwin') {
|
||||
nativeUpdater.on('update-downloaded', () => {
|
||||
handleMacInstallerReady(hasNewerDownloadedVersion(), performQuitAndInstall, () => {
|
||||
const hasNewerVersion = hasNewerDownloadedVersion()
|
||||
handleMacInstallerReady(hasNewerVersion, performQuitAndInstall, () => {
|
||||
// If we were holding the 'downloaded' status, send it now — but only
|
||||
// when the staged version is actually newer than what's running.
|
||||
sendStatus({
|
||||
|
|
@ -102,7 +108,11 @@ export function registerAutoUpdaterHandlers({
|
|||
}
|
||||
|
||||
app.on('before-quit', (event) => {
|
||||
if (consumeMacInstallGuardBypass() || isMacQuitAndInstallInFlight()) {
|
||||
if (consumeMacInstallGuardBypass()) {
|
||||
recordUpdaterLifecycle('macos_before_quit_guard_bypassed')
|
||||
return
|
||||
}
|
||||
if (isMacQuitAndInstallInFlight()) {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -119,6 +129,9 @@ export function registerAutoUpdaterHandlers({
|
|||
sendStatus
|
||||
)
|
||||
) {
|
||||
recordUpdaterLifecycle('macos_before_quit_deferred', {
|
||||
version: getPendingInstallVersion()
|
||||
})
|
||||
event.preventDefault()
|
||||
}
|
||||
})
|
||||
|
|
@ -255,12 +268,15 @@ export function registerAutoUpdaterHandlers({
|
|||
sendStatus({ state: 'not-available' })
|
||||
return
|
||||
}
|
||||
const macInstallerReady = process.platform === 'darwin' ? isMacInstallerReady() : true
|
||||
recordUpdaterLifecycle('update_downloaded', { version: info.version, macInstallerReady })
|
||||
// On macOS, defer the 'downloaded' status until Squirrel.Mac has finished
|
||||
// processing the update via the localhost proxy. On other platforms,
|
||||
// the update is ready immediately after electron-updater downloads it.
|
||||
if (process.platform === 'darwin' && !isMacInstallerReady()) {
|
||||
if (process.platform === 'darwin' && !macInstallerReady) {
|
||||
// Squirrel is still processing. Keep the UI at 100% downloaded so the
|
||||
// user sees the handoff instead of a misleading "ready to install".
|
||||
recordUpdaterLifecycle('macos_waiting_for_squirrel', { version: info.version })
|
||||
sendStatus({ state: 'downloading', percent: 100, version: info.version })
|
||||
return
|
||||
}
|
||||
|
|
@ -269,6 +285,18 @@ export function registerAutoUpdaterHandlers({
|
|||
|
||||
autoUpdater.on('error', (err) => {
|
||||
const message = err?.message ?? 'Unknown error'
|
||||
// Why: quitAndInstall reports the common "no staged update" failure through
|
||||
// this event (often sync on Win/Linux, async on macOS/spawn). Recover
|
||||
// quit-for-update flags before any suppression guard can early-return, but
|
||||
// only after native invoke and only when install is not yet committed.
|
||||
if (handleQuitAndInstallFailure()) {
|
||||
return
|
||||
}
|
||||
// Why: handoff still owns the process (cleanup, native in-flight, or
|
||||
// post-commit). Do not treat as check/download error or reset mac install.
|
||||
if (isQuitAndInstallHandoffActive()) {
|
||||
return
|
||||
}
|
||||
// Why: primary/fallback promise handlers may already own this failure; do
|
||||
// not let their delayed paired error event consume fallback context.
|
||||
if (shouldSuppressMissingManifestPrereleaseFallbackEvent(message, err)) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
import type { CrashReportBreadcrumbData } from '../shared/crash-reporting'
|
||||
import { recordCrashBreadcrumb } from './crash-reporting/crash-breadcrumb-store'
|
||||
|
||||
export function recordUpdaterLifecycle(
|
||||
event: string,
|
||||
data?: CrashReportBreadcrumbData,
|
||||
options?: { level?: 'info' | 'warn' | 'error'; message?: string }
|
||||
): void {
|
||||
recordCrashBreadcrumb(`updater_${event}`, data)
|
||||
const suffix = data && Object.keys(data).length > 0 ? ` ${JSON.stringify(data)}` : ''
|
||||
console[options?.level ?? 'info'](`[updater] ${options?.message ?? event}${suffix}`)
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { app } from 'electron'
|
||||
import type { UpdateStatus } from '../shared/types'
|
||||
import { recordUpdaterLifecycle } from './updater-lifecycle-diagnostics'
|
||||
|
||||
const MAC_INSTALL_READY_TIMEOUT_MS = 15000
|
||||
|
||||
|
|
@ -96,8 +97,13 @@ export function deferMacQuitUntilInstallerReady(
|
|||
return
|
||||
}
|
||||
|
||||
console.warn(
|
||||
`[updater] macOS installer was not ready after ${MAC_INSTALL_READY_TIMEOUT_MS}ms; allowing quit without install`
|
||||
recordUpdaterLifecycle(
|
||||
'macos_install_guard_timeout',
|
||||
{ timeoutMs: MAC_INSTALL_READY_TIMEOUT_MS },
|
||||
{
|
||||
level: 'warn',
|
||||
message: `macOS installer was not ready after ${MAC_INSTALL_READY_TIMEOUT_MS}ms; allowing quit without install`
|
||||
}
|
||||
)
|
||||
installRequestedAfterSquirrelReady = false
|
||||
// This is a safety valve. The updater path should wait for ShipIt so the
|
||||
|
|
@ -117,14 +123,19 @@ export function handleMacInstallerReady(
|
|||
): void {
|
||||
squirrelReady = true
|
||||
clearPendingInstallTimeout()
|
||||
recordUpdaterLifecycle('macos_installer_ready', {
|
||||
deferredInstallRequested: installRequestedAfterSquirrelReady,
|
||||
hasNewerDownloadedVersion
|
||||
})
|
||||
|
||||
if (installRequestedAfterSquirrelReady && hasNewerDownloadedVersion) {
|
||||
void Promise.resolve()
|
||||
.then(() => onReadyToInstall())
|
||||
.catch((error) => {
|
||||
console.warn(
|
||||
'[updater] Deferred macOS install handoff failed:',
|
||||
error instanceof Error ? error.name : typeof error
|
||||
recordUpdaterLifecycle(
|
||||
'macos_deferred_install_handoff_failed',
|
||||
{ errorType: error instanceof Error ? error.name : typeof error },
|
||||
{ level: 'warn', message: 'Deferred macOS install handoff failed' }
|
||||
)
|
||||
})
|
||||
return
|
||||
|
|
|
|||
|
|
@ -258,9 +258,9 @@ describe('updater mac install handoff', () => {
|
|||
|
||||
expect(reportDownloaded).not.toHaveBeenCalled()
|
||||
await vi.waitFor(() => {
|
||||
// Why: recordUpdaterLifecycle packs metadata into one console line.
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'[updater] Deferred macOS install handoff failed:',
|
||||
'Error'
|
||||
'[updater] Deferred macOS install handoff failed {"errorType":"Error"}'
|
||||
)
|
||||
})
|
||||
expect(JSON.stringify(warn.mock.calls)).not.toContain('handoff-secret')
|
||||
|
|
|
|||
|
|
@ -1177,7 +1177,7 @@ describe('updater', () => {
|
|||
expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledWith(false, true)
|
||||
})
|
||||
|
||||
it('runs pre-quit cleanup before killing PTYs during update install', async () => {
|
||||
it('runs pre-quit cleanup before local PTY cleanup during update install', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
const onBeforeQuit = vi.fn()
|
||||
|
|
@ -1240,6 +1240,145 @@ describe('updater', () => {
|
|||
expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('recovers quit-for-update state on sync quitAndInstall error event without killing PTYs', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
autoUpdaterMock.quitAndInstall.mockImplementation(() => {
|
||||
// Why: BaseUpdater dispatches 'error' synchronously inside install() for
|
||||
// the common "no staged update filepath" path.
|
||||
autoUpdaterMock.emit(
|
||||
'error',
|
||||
new Error("No update filepath provided, can't quit and install")
|
||||
)
|
||||
})
|
||||
|
||||
const sendMock = vi.fn()
|
||||
const mainWindow = { webContents: { send: sendMock } }
|
||||
const { setupAutoUpdater, quitAndInstall, isQuittingForUpdate } = await import('./updater')
|
||||
|
||||
setupAutoUpdater(mainWindow as never)
|
||||
quitAndInstall()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
|
||||
expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1)
|
||||
expect(isQuittingForUpdate()).toBe(false)
|
||||
// Why: destructive prep runs only after quitAndInstall returns still in
|
||||
// progress; sync recovery clears flags first so PTYs stay alive.
|
||||
expect(killAllPtyMock).not.toHaveBeenCalled()
|
||||
expect(sendMock).toHaveBeenCalledWith(
|
||||
'updater:status',
|
||||
expect.objectContaining({
|
||||
state: 'error',
|
||||
message: 'Could not restart to install the update. Quit and reopen Orca, then try again.'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('does not recover quit-for-update state from late errors after install commit', async () => {
|
||||
const sendMock = vi.fn()
|
||||
const mainWindow = { webContents: { send: sendMock } }
|
||||
fetchNewerReleaseTagsMock.mockResolvedValue({ tags: ['v1.0.61'], state: 'ready' })
|
||||
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
|
||||
autoUpdaterMock.emit('checking-for-update')
|
||||
queueMicrotask(() => {
|
||||
autoUpdaterMock.emit('update-available', { version: '1.0.61' })
|
||||
})
|
||||
return Promise.resolve(undefined)
|
||||
})
|
||||
|
||||
const { setupAutoUpdater, checkForUpdatesFromMenu, quitAndInstall, isQuittingForUpdate } =
|
||||
await import('./updater')
|
||||
|
||||
setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() })
|
||||
checkForUpdatesFromMenu()
|
||||
|
||||
// Why: put status in downloaded so a naive error handler would otherwise
|
||||
// treat a late post-commit error as a download/install UI failure.
|
||||
await vi.waitFor(() => {
|
||||
expect(sendMock).toHaveBeenCalledWith('updater:status', {
|
||||
state: 'available',
|
||||
version: '1.0.61',
|
||||
changelog: null
|
||||
})
|
||||
})
|
||||
|
||||
autoUpdaterMock.emit('update-downloaded', { version: '1.0.61' })
|
||||
|
||||
// Why: on macOS install is only "committed" once Squirrel is ready; mark
|
||||
// it ready so this test covers the post-commit path on all platforms and
|
||||
// the UI can leave the "waiting for Squirrel" downloading state.
|
||||
if (process.platform === 'darwin') {
|
||||
const nativeDownloadedHandler = nativeUpdaterMock.on.mock.calls.find(
|
||||
([eventName]) => eventName === 'update-downloaded'
|
||||
)?.[1] as (() => void) | undefined
|
||||
expect(nativeDownloadedHandler).toBeTypeOf('function')
|
||||
nativeDownloadedHandler?.()
|
||||
}
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(sendMock).toHaveBeenCalledWith(
|
||||
'updater:status',
|
||||
expect.objectContaining({ state: 'downloaded', version: '1.0.61' })
|
||||
)
|
||||
})
|
||||
|
||||
quitAndInstall()
|
||||
await vi.waitFor(() => {
|
||||
expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
expect(killAllPtyMock).toHaveBeenCalledTimes(1)
|
||||
expect(isQuittingForUpdate()).toBe(true)
|
||||
|
||||
sendMock.mockClear()
|
||||
autoUpdaterMock.emit('error', new Error('late post-commit install error'))
|
||||
|
||||
expect(isQuittingForUpdate()).toBe(true)
|
||||
// Why: handoff still owns the process after commit — no recovery message
|
||||
// and no general check/download error status either.
|
||||
expect(sendMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not treat pre-native autoUpdater errors as quitAndInstall recovery', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
let finishCleanup!: () => void
|
||||
const onBeforeQuit = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
finishCleanup = resolve
|
||||
})
|
||||
)
|
||||
const sendMock = vi.fn()
|
||||
const mainWindow = { webContents: { send: sendMock } }
|
||||
const { setupAutoUpdater, quitAndInstall, isQuittingForUpdate } = await import('./updater')
|
||||
|
||||
setupAutoUpdater(mainWindow as never, {
|
||||
onBeforeQuit,
|
||||
getLastUpdateCheckAt: () => Date.now()
|
||||
})
|
||||
quitAndInstall()
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
|
||||
expect(onBeforeQuit).toHaveBeenCalledTimes(1)
|
||||
expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled()
|
||||
expect(isQuittingForUpdate()).toBe(true)
|
||||
|
||||
sendMock.mockClear()
|
||||
// Why: an unrelated error during pre-quit cleanup must not clear
|
||||
// quittingForUpdate or emit the install-recovery status (native not invoked).
|
||||
autoUpdaterMock.emit('error', new Error('pre-native concurrent error'))
|
||||
|
||||
expect(isQuittingForUpdate()).toBe(true)
|
||||
expect(sendMock).not.toHaveBeenCalled()
|
||||
|
||||
finishCleanup()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1)
|
||||
expect(isQuittingForUpdate()).toBe(true)
|
||||
})
|
||||
|
||||
it('runs a startup check immediately when the last background check is stale', async () => {
|
||||
const mainWindow = { webContents: { send: vi.fn() } }
|
||||
const setLastUpdateCheckAt = vi.fn()
|
||||
|
|
|
|||
|
|
@ -9,9 +9,12 @@ import { writeMainThreadDiagnosticMarker } from './diagnostics/main-thread-churn
|
|||
import {
|
||||
beginMacUpdateDownload,
|
||||
deferMacQuitUntilInstallerReady,
|
||||
markMacQuitAndInstallInFlight
|
||||
isMacInstallerReady,
|
||||
markMacQuitAndInstallInFlight,
|
||||
resetMacInstallState
|
||||
} from './updater-mac-install'
|
||||
import { registerAutoUpdaterHandlers } from './updater-events'
|
||||
import { recordUpdaterLifecycle } from './updater-lifecycle-diagnostics'
|
||||
import {
|
||||
compareVersions,
|
||||
isBenignCheckFailure,
|
||||
|
|
@ -64,6 +67,14 @@ let autoUpdateCheckTimer: ReturnType<typeof setTimeout> | null = null
|
|||
let nudgeCheckTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let pendingQuitAndInstallTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let quitAndInstallInProgress = false
|
||||
// Why: once quitAndInstall has committed (Win/Linux install, or macOS with
|
||||
// Squirrel ready), late autoUpdater 'error' events must not clear
|
||||
// quittingForUpdate — that would re-enable dock activate mid-installer.
|
||||
let updateInstallCommitted = false
|
||||
// Why: quit-and-install recovery must only run after the native
|
||||
// quitAndInstall call. Pre-native cleanup-time autoUpdater errors must not
|
||||
// clear quittingForUpdate or look like install recovery.
|
||||
let quitAndInstallNativeInvoked = false
|
||||
let persistLastUpdateCheckAt: ((timestamp: number) => void) | null = null
|
||||
let _getLastUpdateCheckAt: (() => number | null) | null = null
|
||||
let backgroundCheckLaunchPending = false
|
||||
|
|
@ -547,6 +558,7 @@ function clearPrereleaseFallbackContextIfSettled(): void {
|
|||
|
||||
async function performQuitAndInstall(): Promise<void> {
|
||||
if (quitAndInstallInProgress) {
|
||||
recordUpdaterLifecycle('quit_and_install_ignored', { reason: 'already-in-progress' })
|
||||
return
|
||||
}
|
||||
quitAndInstallInProgress = true
|
||||
|
|
@ -566,14 +578,112 @@ async function performQuitAndInstall(): Promise<void> {
|
|||
// either can't replace it or the user ends up on the old version.
|
||||
quittingForUpdate = true
|
||||
|
||||
await runBeforeUpdateQuitCleanup()
|
||||
killAllPty()
|
||||
const pendingVersion = getPendingInstallVersion()
|
||||
try {
|
||||
await withUpdaterSpan({ stage: 'install' }, async (span) => {
|
||||
span.setAttribute('updater.version', pendingVersion || 'unknown')
|
||||
span.setAttribute('updater.platform', process.platform)
|
||||
span.setAttribute(
|
||||
'updater.macosInstallerReady',
|
||||
process.platform === 'darwin' ? isMacInstallerReady() : true
|
||||
)
|
||||
recordUpdaterLifecycle('quit_and_install_started', {
|
||||
version: pendingVersion || null,
|
||||
macInstallerReady: process.platform === 'darwin' ? isMacInstallerReady() : true
|
||||
})
|
||||
span.addEvent('pre_quit_cleanup_start')
|
||||
await runBeforeUpdateQuitCleanup()
|
||||
span.addEvent('pre_quit_cleanup_done')
|
||||
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
win.removeAllListeners('close')
|
||||
recordUpdaterLifecycle('quit_and_install_invoking_native', {
|
||||
version: pendingVersion || null
|
||||
})
|
||||
// Why: defensive — state should stay in-progress until native invoke, but
|
||||
// never call quitAndInstall if recovery/reset already cleared the handoff.
|
||||
if (!quitAndInstallInProgress) {
|
||||
return
|
||||
}
|
||||
// Why: mark before the call so a sync 'error' during quitAndInstall can
|
||||
// recover; pre-native errors must not look like install failure.
|
||||
quitAndInstallNativeInvoked = true
|
||||
// Why: invoke quitAndInstall before killAllPty/remove close listeners so a
|
||||
// sync 'error' (common "no filepath" path) recovers while windows and
|
||||
// local PTYs are still intact.
|
||||
getAutoUpdater().quitAndInstall(false, true)
|
||||
span.addEvent('native_quit_and_install_invoked')
|
||||
|
||||
// Why: handleQuitAndInstallFailure may clear quitAndInstallInProgress
|
||||
// synchronously during quitAndInstall (Win/Linux dispatchError). Skip
|
||||
// destructive prep if recovery already ran.
|
||||
if (!quitAndInstallInProgress) {
|
||||
return
|
||||
}
|
||||
|
||||
killAllPty()
|
||||
span.addEvent('local_pty_kill_all')
|
||||
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
win.removeAllListeners('close')
|
||||
}
|
||||
span.addEvent('window_close_listeners_removed', {
|
||||
windowCount: BrowserWindow.getAllWindows().length
|
||||
})
|
||||
|
||||
// Why: committed installs must keep quittingForUpdate true so dock
|
||||
// activate cannot reopen the old process mid-ShipIt/installer. macOS
|
||||
// without Squirrel ready stays uncommitted so late native errors can
|
||||
// still recover flags (PTYs may already be dead — residual OK).
|
||||
if (process.platform !== 'darwin' || isMacInstallerReady()) {
|
||||
updateInstallCommitted = true
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
resetQuitForUpdateState()
|
||||
recordUpdaterLifecycle(
|
||||
'quit_and_install_failed',
|
||||
{ errorType: error instanceof Error ? error.name : typeof error },
|
||||
{
|
||||
level: 'warn',
|
||||
message: 'Could not start update install'
|
||||
}
|
||||
)
|
||||
sendErrorStatus(
|
||||
'Could not restart to install the update. Quit and reopen Orca, then try again.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
getAutoUpdater().quitAndInstall(false, true)
|
||||
function resetQuitForUpdateState(): void {
|
||||
quitAndInstallInProgress = false
|
||||
quittingForUpdate = false
|
||||
updateInstallCommitted = false
|
||||
quitAndInstallNativeInvoked = false
|
||||
resetMacInstallState()
|
||||
}
|
||||
|
||||
// Why: electron-updater often reports quitAndInstall failures via the 'error'
|
||||
// event. On Win/Linux this is frequently synchronous (dispatchError inside
|
||||
// install()); on macOS/spawn it can be async. Recover only after native invoke
|
||||
// and only when install has not been committed — after commit, clearing
|
||||
// quittingForUpdate would allow dock activate to reopen the old process
|
||||
// mid-installer.
|
||||
function handleQuitAndInstallFailure(): boolean {
|
||||
if (!quitAndInstallInProgress || !quitAndInstallNativeInvoked || updateInstallCommitted) {
|
||||
return false
|
||||
}
|
||||
resetQuitForUpdateState()
|
||||
recordUpdaterLifecycle('quit_and_install_failed_via_event', undefined, {
|
||||
level: 'warn',
|
||||
message: 'Update install could not start; recovered app state'
|
||||
})
|
||||
sendErrorStatus('Could not restart to install the update. Quit and reopen Orca, then try again.')
|
||||
return true
|
||||
}
|
||||
|
||||
// Why: while quit-and-install owns the process (pre-native cleanup through
|
||||
// post-commit handoff), general check/download error UI must not run.
|
||||
function isQuitAndInstallHandoffActive(): boolean {
|
||||
return quitAndInstallInProgress
|
||||
}
|
||||
|
||||
async function runBeforeUpdateQuitCleanup(): Promise<void> {
|
||||
|
|
@ -585,9 +695,13 @@ async function runBeforeUpdateQuitCleanup(): Promise<void> {
|
|||
const cleanup = Promise.resolve()
|
||||
.then(() => onBeforeQuitCleanup?.())
|
||||
.catch((error) => {
|
||||
console.warn(
|
||||
'[updater] Pre-quit cleanup failed; continuing update install:',
|
||||
error instanceof Error ? error.name : typeof error
|
||||
recordUpdaterLifecycle(
|
||||
'pre_quit_cleanup_failed',
|
||||
{ errorType: error instanceof Error ? error.name : typeof error },
|
||||
{
|
||||
level: 'warn',
|
||||
message: 'Pre-quit cleanup failed; continuing update install'
|
||||
}
|
||||
)
|
||||
})
|
||||
const timeoutResult = new Promise<'timeout'>((resolve) => {
|
||||
|
|
@ -596,8 +710,13 @@ async function runBeforeUpdateQuitCleanup(): Promise<void> {
|
|||
|
||||
const result = await Promise.race([cleanup.then(() => 'done' as const), timeoutResult])
|
||||
if (result === 'timeout') {
|
||||
console.warn(
|
||||
`[updater] Pre-quit cleanup exceeded ${PRE_QUIT_CLEANUP_TIMEOUT_MS}ms; continuing update install`
|
||||
recordUpdaterLifecycle(
|
||||
'pre_quit_cleanup_timeout',
|
||||
{ timeoutMs: PRE_QUIT_CLEANUP_TIMEOUT_MS },
|
||||
{
|
||||
level: 'warn',
|
||||
message: `Pre-quit cleanup exceeded ${PRE_QUIT_CLEANUP_TIMEOUT_MS}ms; continuing update install`
|
||||
}
|
||||
)
|
||||
return
|
||||
}
|
||||
|
|
@ -1304,6 +1423,8 @@ export function setupAutoUpdater(
|
|||
getKnownReleaseUrl,
|
||||
getPendingInstallVersion,
|
||||
getUserInitiatedCheck: () => userInitiatedCheck,
|
||||
handleQuitAndInstallFailure,
|
||||
isQuitAndInstallHandoffActive,
|
||||
hasNewerDownloadedVersion,
|
||||
shouldHandleUpdaterErrorEvent,
|
||||
performQuitAndInstall,
|
||||
|
|
|
|||
Loading…
Reference in New Issue