mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 16:03:17 +00:00
fix(app): close wake recovery timeout gaps
This commit is contained in:
+9
-4
@@ -2487,11 +2487,16 @@ app.on('will-quit', (e) => {
|
||||
// Why: a wedged transport (half-open post-sleep socket) can leave one
|
||||
// member unsettled forever and block app.quit() until Force Quit (#9447).
|
||||
settleTeardownWithinDeadline([
|
||||
daemonTeardown,
|
||||
rpcStopAndClear,
|
||||
watcherShutdown,
|
||||
emulatorShutdown
|
||||
{ name: 'daemon', promise: daemonTeardown },
|
||||
{ name: 'runtime-rpc', promise: rpcStopAndClear },
|
||||
{ name: 'watchers', promise: watcherShutdown },
|
||||
{ name: 'emulator', promise: emulatorShutdown }
|
||||
])
|
||||
.then((pendingTeardowns) => {
|
||||
if (pendingTeardowns.length > 0) {
|
||||
console.warn('[shutdown] Quit teardown deadline reached', { pendingTeardowns })
|
||||
}
|
||||
})
|
||||
.then(() => shutdownTelemetry())
|
||||
.then(() => shutdownObservability())
|
||||
.catch(() => {
|
||||
|
||||
@@ -13,27 +13,32 @@ describe('settleTeardownWithinDeadline', () => {
|
||||
vi.useFakeTimers()
|
||||
let resolved = false
|
||||
const pending = settleTeardownWithinDeadline([
|
||||
Promise.resolve(),
|
||||
Promise.reject(new Error('daemon disconnect failed'))
|
||||
{ name: 'daemon', promise: Promise.resolve() },
|
||||
{ name: 'runtime-rpc', promise: Promise.reject(new Error('daemon disconnect failed')) }
|
||||
]).then(() => {
|
||||
resolved = true
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
await pending
|
||||
expect(resolved).toBe(true)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('resolves at the deadline when a teardown never settles', async () => {
|
||||
it('reports the teardowns still pending at the deadline', async () => {
|
||||
vi.useFakeTimers()
|
||||
const pending = settleTeardownWithinDeadline([
|
||||
{ name: 'daemon', promise: Promise.resolve() },
|
||||
{ name: 'runtime-rpc', promise: new Promise(() => {}) }
|
||||
])
|
||||
await vi.advanceTimersByTimeAsync(WILL_QUIT_TEARDOWN_DEADLINE_MS - 1)
|
||||
let resolved = false
|
||||
const pending = settleTeardownWithinDeadline([new Promise(() => {})]).then(() => {
|
||||
void pending.then(() => {
|
||||
resolved = true
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(WILL_QUIT_TEARDOWN_DEADLINE_MS - 1)
|
||||
expect(resolved).toBe(false)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await pending
|
||||
expect(resolved).toBe(true)
|
||||
await expect(pending).resolves.toEqual(['runtime-rpc'])
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
// Why: pin the magnitude so the wedge escape hatch cannot be silently
|
||||
|
||||
@@ -7,15 +7,29 @@
|
||||
// enough that a wedged teardown never needs Force Quit.
|
||||
export const WILL_QUIT_TEARDOWN_DEADLINE_MS = 20_000
|
||||
|
||||
export function settleTeardownWithinDeadline(
|
||||
teardowns: Promise<unknown>[],
|
||||
export type NamedQuitTeardown = {
|
||||
name: string
|
||||
promise: Promise<unknown>
|
||||
}
|
||||
|
||||
export async function settleTeardownWithinDeadline(
|
||||
teardowns: readonly NamedQuitTeardown[],
|
||||
deadlineMs: number = WILL_QUIT_TEARDOWN_DEADLINE_MS
|
||||
): Promise<void> {
|
||||
const deadline = new Promise<void>((resolve) => {
|
||||
const timer = setTimeout(resolve, deadlineMs)
|
||||
if (typeof timer.unref === 'function') {
|
||||
timer.unref()
|
||||
}
|
||||
): Promise<string[]> {
|
||||
const pendingNames = new Set(teardowns.map(({ name }) => name))
|
||||
const settled = Promise.allSettled(
|
||||
teardowns.map(({ name, promise }) =>
|
||||
promise.finally(() => {
|
||||
pendingNames.delete(name)
|
||||
})
|
||||
)
|
||||
).then(() => 'settled' as const)
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
const deadline = new Promise<'deadline'>((resolve) => {
|
||||
timer = setTimeout(() => resolve('deadline'), deadlineMs)
|
||||
timer.unref?.()
|
||||
})
|
||||
return Promise.race([Promise.allSettled(teardowns).then(() => undefined), deadline])
|
||||
const outcome = await Promise.race([settled, deadline])
|
||||
clearTimeout(timer)
|
||||
return outcome === 'deadline' ? [...pendingNames] : []
|
||||
}
|
||||
|
||||
@@ -102,6 +102,12 @@ describe('CloudRelayTransport', () => {
|
||||
existing.push(fn)
|
||||
listeners.set(event, existing)
|
||||
}
|
||||
const removeListener = (event: string, fn: (...args: unknown[]) => void): void => {
|
||||
listeners.set(
|
||||
event,
|
||||
(listeners.get(event) ?? []).filter((listener) => listener !== fn)
|
||||
)
|
||||
}
|
||||
const emit = (event: string, ...args: unknown[]): void => {
|
||||
for (const fn of listeners.get(event) ?? []) {
|
||||
fn(...args)
|
||||
@@ -115,14 +121,17 @@ describe('CloudRelayTransport', () => {
|
||||
CLOSED: 3,
|
||||
on: addListener,
|
||||
once: addListener,
|
||||
off: removeListener,
|
||||
send: () => {},
|
||||
terminate: () => {}
|
||||
}
|
||||
const onConnectionClosed = vi.fn()
|
||||
const transport = new CloudRelayTransport({
|
||||
cellUrl: 'http://127.0.0.1:9',
|
||||
relayHostId: 'AbCdEf0123_-xyZ9',
|
||||
generation: 1,
|
||||
createSocket: () => fakeSocket as unknown as WebSocketClient
|
||||
createSocket: () => fakeSocket as unknown as WebSocketClient,
|
||||
onConnectionClosed
|
||||
})
|
||||
const opening = transport.openConnection({
|
||||
connId: 'conn-1',
|
||||
@@ -143,6 +152,57 @@ describe('CloudRelayTransport', () => {
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await stopPromise
|
||||
expect(stopped).toBe(true)
|
||||
expect(onConnectionClosed).toHaveBeenCalledWith('conn-1')
|
||||
expect(() => transport.metadataFor(fakeSocket as unknown as WebSocketClient)).toThrow(
|
||||
'unknown_relay_socket'
|
||||
)
|
||||
expect(() => transport.setGeneration(2)).not.toThrow()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('observes a synchronous close emitted by terminate without waiting for the deadline', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const listeners = new Map<string, ((...args: unknown[]) => void)[]>()
|
||||
const addListener = (event: string, fn: (...args: unknown[]) => void): void => {
|
||||
listeners.set(event, [...(listeners.get(event) ?? []), fn])
|
||||
}
|
||||
const emit = (event: string): void => {
|
||||
for (const fn of listeners.get(event) ?? []) {
|
||||
fn()
|
||||
}
|
||||
}
|
||||
const fakeSocket = {
|
||||
readyState: 1,
|
||||
OPEN: 1,
|
||||
CLOSED: 3,
|
||||
on: addListener,
|
||||
once: addListener,
|
||||
off: vi.fn(),
|
||||
send: () => {},
|
||||
terminate: () => emit('close')
|
||||
}
|
||||
const transport = new CloudRelayTransport({
|
||||
cellUrl: 'http://127.0.0.1:9',
|
||||
relayHostId: 'AbCdEf0123_-xyZ9',
|
||||
generation: 1,
|
||||
createSocket: () => fakeSocket as unknown as WebSocketClient
|
||||
})
|
||||
const opening = transport.openConnection({
|
||||
connId: 'conn-sync-close',
|
||||
connTicket: 'ticket-1',
|
||||
kind: 'resume',
|
||||
relayDeviceId: 'device-1',
|
||||
attachDeadlineMs: 1_000
|
||||
})
|
||||
emit('open')
|
||||
await opening
|
||||
|
||||
await transport.stop()
|
||||
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
|
||||
@@ -117,10 +117,11 @@ export class CloudRelayTransport implements RpcTransport, MobileSocketTransport
|
||||
async stop(): Promise<void> {
|
||||
this.stopped = true
|
||||
const sockets = [...this.metadataBySocket.keys()]
|
||||
const closePromises = sockets.map((socket) => this.waitForClose(socket))
|
||||
for (const socket of sockets) {
|
||||
socket.terminate()
|
||||
}
|
||||
await Promise.all(sockets.map((socket) => this.waitForClose(socket)))
|
||||
await Promise.all(closePromises)
|
||||
}
|
||||
|
||||
async openConnection(connection: RelayConnectionOpen): Promise<void> {
|
||||
@@ -158,14 +159,7 @@ export class CloudRelayTransport implements RpcTransport, MobileSocketTransport
|
||||
}
|
||||
finalized = true
|
||||
clearTimeout(deadline)
|
||||
this.socketsByConnectionId.delete(connection.connId)
|
||||
this.metadataBySocket.delete(socket)
|
||||
const clientId = this.clientIds.get(socket) ?? null
|
||||
this.clientIds.delete(socket)
|
||||
this.onConnectionClosed?.(connection.connId)
|
||||
const hasOtherConnections =
|
||||
clientId !== null && [...this.clientIds.values()].includes(clientId)
|
||||
this.closeHandler?.(clientId, socket, hasOtherConnections)
|
||||
this.finalizeConnection(connection.connId, socket)
|
||||
}
|
||||
socket.on('message', (raw, isBinary) => {
|
||||
if (!attached) {
|
||||
@@ -213,16 +207,49 @@ export class CloudRelayTransport implements RpcTransport, MobileSocketTransport
|
||||
|
||||
private waitForClose(socket: WebSocket): Promise<void> {
|
||||
if (socket.readyState === socket.CLOSED) {
|
||||
const connectionId = this.connectionIdForSocket(socket)
|
||||
if (connectionId) {
|
||||
this.finalizeConnection(connectionId, socket)
|
||||
}
|
||||
return Promise.resolve()
|
||||
}
|
||||
// Why: a half-open relay socket after system sleep can never emit 'close';
|
||||
// an unbounded wait here wedges stop() and blocks app quit (#9447).
|
||||
return new Promise((resolve) => {
|
||||
const deadline = setTimeout(resolve, RELAY_SOCKET_CLOSE_TIMEOUT_MS)
|
||||
socket.once('close', () => {
|
||||
const onClose = (): void => {
|
||||
clearTimeout(deadline)
|
||||
resolve()
|
||||
})
|
||||
}
|
||||
const deadline = setTimeout(() => {
|
||||
socket.off('close', onClose)
|
||||
const connectionId = this.connectionIdForSocket(socket)
|
||||
if (connectionId) {
|
||||
this.finalizeConnection(connectionId, socket)
|
||||
}
|
||||
resolve()
|
||||
}, RELAY_SOCKET_CLOSE_TIMEOUT_MS)
|
||||
socket.once('close', onClose)
|
||||
if (socket.readyState === socket.CLOSED) {
|
||||
onClose()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private connectionIdForSocket(socket: WebSocket): string | undefined {
|
||||
const metadata = this.metadataBySocket.get(socket)
|
||||
return metadata?.transport === 'relay' ? metadata.basisConnId : undefined
|
||||
}
|
||||
|
||||
private finalizeConnection(connectionId: string, socket: WebSocket): void {
|
||||
if (this.socketsByConnectionId.get(connectionId) !== socket) {
|
||||
return
|
||||
}
|
||||
this.socketsByConnectionId.delete(connectionId)
|
||||
this.metadataBySocket.delete(socket)
|
||||
const clientId = this.clientIds.get(socket) ?? null
|
||||
this.clientIds.delete(socket)
|
||||
this.onConnectionClosed?.(connectionId)
|
||||
const hasOtherConnections = clientId !== null && [...this.clientIds.values()].includes(clientId)
|
||||
this.closeHandler?.(clientId, socket, hasOtherConnections)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,7 +60,11 @@ vi.mock('../browser/browser-manager', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
import { createMainWindow, loadMainWindow } from './createMainWindow'
|
||||
import {
|
||||
createMainWindow,
|
||||
loadMainWindow,
|
||||
WINDOW_QUIT_RENDERER_ACK_TIMEOUT_MS
|
||||
} from './createMainWindow'
|
||||
import { ipcMain } from 'electron'
|
||||
import { shouldRecoverRendererAfterProcessGone } from '../crash-reporting/process-gone-classification'
|
||||
|
||||
@@ -1804,6 +1808,101 @@ describe('createMainWindow', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('destroys an already-unresponsive renderer after an app-wide quit deadline', async () => {
|
||||
vi.useFakeTimers()
|
||||
const windowHandlers: Record<string, (...args: any[]) => void> = {}
|
||||
const webContents = {
|
||||
id: 42,
|
||||
on: vi.fn((event, handler) => {
|
||||
windowHandlers[event] = handler
|
||||
}),
|
||||
setZoomLevel: vi.fn(),
|
||||
setBackgroundThrottling: vi.fn(),
|
||||
invalidate: vi.fn(),
|
||||
setWindowOpenHandler: vi.fn(),
|
||||
send: vi.fn(),
|
||||
isCrashed: vi.fn(() => false)
|
||||
}
|
||||
const destroy = vi.fn()
|
||||
browserWindowMock.mockImplementation(function () {
|
||||
return {
|
||||
webContents,
|
||||
on: vi.fn((event, handler) => {
|
||||
windowHandlers[event] = handler
|
||||
}),
|
||||
isDestroyed: vi.fn(() => false),
|
||||
isMaximized: vi.fn(() => true),
|
||||
isFullScreen: vi.fn(() => false),
|
||||
getSize: vi.fn(() => [1200, 800]),
|
||||
setSize: vi.fn(),
|
||||
maximize: vi.fn(),
|
||||
show: vi.fn(),
|
||||
destroy,
|
||||
loadFile: vi.fn(),
|
||||
loadURL: vi.fn()
|
||||
}
|
||||
})
|
||||
createMainWindow(null, { getIsQuitting: () => true })
|
||||
|
||||
windowHandlers.close({ preventDefault: vi.fn() } as never)
|
||||
await vi.advanceTimersByTimeAsync(WINDOW_QUIT_RENDERER_ACK_TIMEOUT_MS - 1)
|
||||
expect(destroy).not.toHaveBeenCalled()
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
|
||||
expect(destroy).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps the renderer-owned close flow after the quit request is acknowledged', async () => {
|
||||
vi.useFakeTimers()
|
||||
const windowHandlers: Record<string, (...args: any[]) => void> = {}
|
||||
const ipcHandlers: Record<string, (...args: any[]) => void> = {}
|
||||
vi.mocked(ipcMain.on).mockImplementation((channel, handler) => {
|
||||
ipcHandlers[channel] = handler as (...args: any[]) => void
|
||||
return ipcMain
|
||||
})
|
||||
const webContents = {
|
||||
id: 42,
|
||||
on: vi.fn((event, handler) => {
|
||||
windowHandlers[event] = handler
|
||||
}),
|
||||
setZoomLevel: vi.fn(),
|
||||
setBackgroundThrottling: vi.fn(),
|
||||
invalidate: vi.fn(),
|
||||
setWindowOpenHandler: vi.fn(),
|
||||
send: vi.fn(),
|
||||
isCrashed: vi.fn(() => false)
|
||||
}
|
||||
const destroy = vi.fn()
|
||||
browserWindowMock.mockImplementation(function () {
|
||||
return {
|
||||
webContents,
|
||||
on: vi.fn((event, handler) => {
|
||||
windowHandlers[event] = handler
|
||||
}),
|
||||
isDestroyed: vi.fn(() => false),
|
||||
isMaximized: vi.fn(() => true),
|
||||
isFullScreen: vi.fn(() => false),
|
||||
getSize: vi.fn(() => [1200, 800]),
|
||||
setSize: vi.fn(),
|
||||
maximize: vi.fn(),
|
||||
show: vi.fn(),
|
||||
destroy,
|
||||
loadFile: vi.fn(),
|
||||
loadURL: vi.fn()
|
||||
}
|
||||
})
|
||||
createMainWindow(null, { getIsQuitting: () => true })
|
||||
|
||||
windowHandlers.close({ preventDefault: vi.fn() } as never)
|
||||
ipcHandlers['window:close-request-received']?.({ sender: { id: 99 } })
|
||||
await vi.advanceTimersByTimeAsync(WINDOW_QUIT_RENDERER_ACK_TIMEOUT_MS - 1)
|
||||
expect(destroy).not.toHaveBeenCalled()
|
||||
ipcHandlers['window:close-request-received']?.({ sender: { id: 42 } })
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
|
||||
expect(destroy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores traffic light sync IPC on non-macOS', () => {
|
||||
const windowHandlers: Record<string, (...args: any[]) => void> = {}
|
||||
const webContents = {
|
||||
|
||||
@@ -51,6 +51,7 @@ import { installPrivilegedWindowNavigationPolicy } from './privileged-window-nav
|
||||
|
||||
// Why: show/restore/resume can overlap before the size nudge resets; never capture the temporary width as the next baseline.
|
||||
const activeRepaintJiggles = new WeakSet<BrowserWindow>()
|
||||
export const WINDOW_QUIT_RENDERER_ACK_TIMEOUT_MS = 10_000
|
||||
|
||||
function forceRepaint(window: BrowserWindow): void {
|
||||
// Why: webContents can be destroyed a beat before the BrowserWindow during close, and this runs from timers/focus events in that gap.
|
||||
@@ -849,6 +850,36 @@ export function createMainWindow(
|
||||
// Intercept close so the renderer can confirm killing running-process terminals (replies window:confirm-close to proceed).
|
||||
let windowCloseConfirmed = false
|
||||
const confirmCloseChannel = 'window:confirm-close'
|
||||
const closeRequestReceivedChannel = 'window:close-request-received'
|
||||
let quitRendererAckTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const clearQuitRendererAckTimer = (): void => {
|
||||
if (quitRendererAckTimer) {
|
||||
clearTimeout(quitRendererAckTimer)
|
||||
quitRendererAckTimer = null
|
||||
}
|
||||
}
|
||||
const armQuitRendererAckTimer = (): void => {
|
||||
if (quitRendererAckTimer) {
|
||||
return
|
||||
}
|
||||
// Why: will-quit cannot run until the renderer-backed window closes; an
|
||||
// already-frozen renderer otherwise makes Force Quit the only escape.
|
||||
quitRendererAckTimer = setTimeout(() => {
|
||||
quitRendererAckTimer = null
|
||||
if (mainWindow.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
console.warn('[window] Renderer did not acknowledge quit; destroying unresponsive window')
|
||||
freezeBoundsOnQuit()
|
||||
mainWindow.destroy()
|
||||
}, WINDOW_QUIT_RENDERER_ACK_TIMEOUT_MS)
|
||||
quitRendererAckTimer.unref?.()
|
||||
}
|
||||
const onCloseRequestReceived = (event: Electron.IpcMainEvent): void => {
|
||||
if (event.sender.id === rendererWebContentsId) {
|
||||
clearQuitRendererAckTimer()
|
||||
}
|
||||
}
|
||||
|
||||
// Windows minimize-to-tray: hide instead of close when enabled; returns true when it hid so callers skip their close path.
|
||||
const hideToTrayIfEnabled = (): boolean => {
|
||||
@@ -909,19 +940,25 @@ export function createMainWindow(
|
||||
return
|
||||
}
|
||||
e.preventDefault()
|
||||
const isQuitting = opts?.getIsQuitting?.() ?? false
|
||||
if (isQuitting) {
|
||||
armQuitRendererAckTimer()
|
||||
}
|
||||
// Why: renderer owns the close decision; the always-mounted App root subscription lets even pre-workspace states reply (#5144).
|
||||
mainWindow.webContents.send('window:close-requested', {
|
||||
isQuitting: opts?.getIsQuitting?.() ?? false
|
||||
isQuitting
|
||||
})
|
||||
})
|
||||
mainWindow.webContents.on('will-prevent-unload', () => {
|
||||
// Why: a prevented beforeunload cancels the quit; release the bounds-persistence freeze so later resizing still saves.
|
||||
windowClosing = false
|
||||
clearQuitRendererAckTimer()
|
||||
opts?.onQuitAborted?.()
|
||||
mainWindow.webContents.send('window:unload-prevented')
|
||||
})
|
||||
|
||||
const onConfirmClose = (): void => {
|
||||
clearQuitRendererAckTimer()
|
||||
windowCloseConfirmed = true
|
||||
if (!mainWindow.isDestroyed()) {
|
||||
mainWindow.close()
|
||||
@@ -980,12 +1017,14 @@ export function createMainWindow(
|
||||
ipcMain.handle(isMaximizedChannel, onIsMaximized)
|
||||
|
||||
ipcMain.on(confirmCloseChannel, onConfirmClose)
|
||||
ipcMain.on(closeRequestReceivedChannel, onCloseRequestReceived)
|
||||
mainWindow.on('closed', () => {
|
||||
// Why: the dashboard pop-out is a companion of the main window — close it
|
||||
// alongside so it never orphans as a lone window after the app window is
|
||||
// gone (e.g. on macOS where the app stays alive after the window closes).
|
||||
closeDashboardPopout()
|
||||
clearInitialRevealFallbackTimer()
|
||||
clearQuitRendererAckTimer()
|
||||
// Why: default-deny the Cmd+B carve-out after the window is gone so a stale-true flag can't leak into later state.
|
||||
markdownEditorFocused = false
|
||||
terminalInputFocused = false
|
||||
@@ -1000,6 +1039,7 @@ export function createMainWindow(
|
||||
ipcMain.removeListener(popupMenuChannel, onPopupMenu)
|
||||
ipcMain.removeHandler(isMaximizedChannel)
|
||||
ipcMain.removeListener(confirmCloseChannel, onConfirmClose)
|
||||
ipcMain.removeListener(closeRequestReceivedChannel, onCloseRequestReceived)
|
||||
ipcMain.removeListener(markdownFocusChannel, onMarkdownEditorFocused)
|
||||
ipcMain.removeListener(terminalInputFocusChannel, onTerminalInputFocused)
|
||||
ipcMain.removeListener(floatingTerminalInputFocusChannel, onFloatingTerminalInputFocused)
|
||||
|
||||
@@ -18,8 +18,10 @@ export type WindowCloseState = {
|
||||
* therefore cannot answer — bypassing it for a merely-unresponsive renderer is
|
||||
* what silently destroyed other sessions in #5787. An unresponsive-but-alive
|
||||
* renderer (rendererProcessGone=false, isRendererCrashed=false) still resolves
|
||||
* to 'request-confirmation' so the save guard runs. A genuinely gone renderer
|
||||
* still bypasses so the window stays closable (#5144/#5314).
|
||||
* to 'request-confirmation' so the save guard runs. App-wide quit separately
|
||||
* bounds failure to acknowledge that request; ordinary window close does not.
|
||||
* A genuinely gone renderer still bypasses so the window stays closable
|
||||
* (#5144/#5314).
|
||||
*/
|
||||
export function resolveWindowCloseAction(state: WindowCloseState): WindowCloseAction {
|
||||
if (state.windowCloseConfirmed) {
|
||||
|
||||
@@ -3838,8 +3838,11 @@ const api = {
|
||||
/** Fired by main when the user tries to close the window; renderer confirms running
|
||||
* terminals then calls confirmWindowClose(). isQuitting (Cmd+Q / app.quit) skips that dialog. */
|
||||
onWindowCloseRequested: (callback: (data: { isQuitting: boolean }) => void): (() => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, data: { isQuitting: boolean }) =>
|
||||
const listener = (_event: Electron.IpcRendererEvent, data: { isQuitting: boolean }): void => {
|
||||
// Why: main cannot reach will-quit while a frozen renderer owns the window close handshake.
|
||||
ipcRenderer.send('window:close-request-received')
|
||||
callback(data ?? { isQuitting: false })
|
||||
}
|
||||
ipcRenderer.on('window:close-requested', listener)
|
||||
return () => ipcRenderer.removeListener('window:close-requested', listener)
|
||||
},
|
||||
|
||||
@@ -121,6 +121,25 @@ describe('terminal-fit-restore', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('bounds bulk restore to one deadline and stops launching work after the transport wedges', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
vi.mocked(getRemoteRuntimeTerminalHandle).mockReturnValue(null)
|
||||
restoreTerminalFit.mockReturnValue(new Promise(() => {}))
|
||||
const ptyIds = Array.from({ length: 100 }, (_, index) => `pty-${index}`)
|
||||
|
||||
const pending = restoreTerminalFitsToDesktop(ptyIds, undefined)
|
||||
expect(restoreTerminalFit).toHaveBeenCalledTimes(8)
|
||||
await vi.advanceTimersByTimeAsync(15_000)
|
||||
|
||||
await expect(pending).resolves.toBe(false)
|
||||
expect(restoreTerminalFit).toHaveBeenCalledTimes(8)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('treats failed remote RPC restore transport as not restored', async () => {
|
||||
vi.mocked(getRemoteRuntimeTerminalHandle).mockReturnValue('terminal-fail')
|
||||
vi.mocked(getRemoteRuntimePtyEnvironmentId).mockReturnValue('env-fail')
|
||||
@@ -128,4 +147,21 @@ describe('terminal-fit-restore', () => {
|
||||
|
||||
await expect(restoreTerminalFitToDesktop('remote:pty-fail', undefined)).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('bounds a remote restore even when the RPC client does not enforce its timeout', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
vi.mocked(getRemoteRuntimeTerminalHandle).mockReturnValue('terminal-stuck')
|
||||
vi.mocked(getRemoteRuntimePtyEnvironmentId).mockReturnValue('env-stuck')
|
||||
vi.mocked(callRuntimeRpc).mockReturnValue(new Promise(() => {}))
|
||||
|
||||
const pending = restoreTerminalFitToDesktop('remote:pty-stuck', undefined)
|
||||
await vi.advanceTimersByTimeAsync(15_000)
|
||||
|
||||
await expect(pending).resolves.toBe(false)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -26,11 +26,12 @@ const restoreFailedResult = (): { restored: boolean } => {
|
||||
// forever, which pins the held-fit modal's buttons disabled (#9447). Fail the
|
||||
// restore instead so the user can retry.
|
||||
const withRestoreFitTimeout = async (
|
||||
pending: Promise<{ restored: boolean }>
|
||||
pending: Promise<{ restored: boolean }>,
|
||||
timeoutMs: number
|
||||
): Promise<{ restored: boolean }> => {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
const timedOut = new Promise<{ restored: boolean }>((resolve) => {
|
||||
timer = setTimeout(() => resolve(restoreFailedResult()), RESTORE_FIT_TIMEOUT_MS)
|
||||
timer = setTimeout(() => resolve(restoreFailedResult()), timeoutMs)
|
||||
})
|
||||
try {
|
||||
return await Promise.race([pending, timedOut])
|
||||
@@ -39,35 +40,51 @@ const withRestoreFitTimeout = async (
|
||||
}
|
||||
}
|
||||
|
||||
export async function restoreTerminalFitToDesktop(
|
||||
async function restoreTerminalFitToDesktopWithinDeadline(
|
||||
ptyId: string,
|
||||
settings: TerminalFitRestoreSettings
|
||||
settings: TerminalFitRestoreSettings,
|
||||
deadlineAt: number
|
||||
): Promise<boolean> {
|
||||
const timeoutMs = Math.max(0, deadlineAt - Date.now())
|
||||
if (timeoutMs === 0) {
|
||||
return false
|
||||
}
|
||||
const remoteHandle = getRemoteRuntimeTerminalHandle(ptyId)
|
||||
const environmentId =
|
||||
getRemoteRuntimePtyEnvironmentId(ptyId) ?? settings?.activeRuntimeEnvironmentId ?? null
|
||||
const result =
|
||||
const pending =
|
||||
remoteHandle && environmentId
|
||||
? await callRuntimeRpc<{ restored: boolean }>(
|
||||
? callRuntimeRpc<{ restored: boolean }>(
|
||||
{ kind: 'environment', environmentId },
|
||||
'terminal.restoreFit',
|
||||
{ terminal: remoteHandle },
|
||||
{ timeoutMs: RESTORE_FIT_TIMEOUT_MS }
|
||||
{ timeoutMs }
|
||||
).catch(restoreFailedResult)
|
||||
: await withRestoreFitTimeout(
|
||||
window.api.runtime.restoreTerminalFit(ptyId).catch(restoreFailedResult)
|
||||
)
|
||||
: window.api.runtime.restoreTerminalFit(ptyId).catch(restoreFailedResult)
|
||||
const result = await withRestoreFitTimeout(pending, timeoutMs)
|
||||
|
||||
return result.restored
|
||||
}
|
||||
|
||||
export function restoreTerminalFitToDesktop(
|
||||
ptyId: string,
|
||||
settings: TerminalFitRestoreSettings
|
||||
): Promise<boolean> {
|
||||
return restoreTerminalFitToDesktopWithinDeadline(
|
||||
ptyId,
|
||||
settings,
|
||||
Date.now() + RESTORE_FIT_TIMEOUT_MS
|
||||
)
|
||||
}
|
||||
|
||||
export async function restoreTerminalFitsToDesktop(
|
||||
ptyIds: Iterable<string>,
|
||||
settings: TerminalFitRestoreSettings
|
||||
): Promise<boolean> {
|
||||
const uniquePtyIds = [...new Set(ptyIds)]
|
||||
const deadlineAt = Date.now() + RESTORE_FIT_TIMEOUT_MS
|
||||
const results = await mapWithConcurrency(uniquePtyIds, RESTORE_FIT_CONCURRENCY, (ptyId) =>
|
||||
restoreTerminalFitToDesktop(ptyId, settings)
|
||||
restoreTerminalFitToDesktopWithinDeadline(ptyId, settings, deadlineAt)
|
||||
)
|
||||
return results.some(Boolean)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user