From 4fc1ec709d7b2306bf4e99657dce8026b7001af0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 22 Jul 2026 13:30:33 -0700 Subject: [PATCH] fix(app): close wake recovery timeout gaps --- src/main/index.ts | 13 ++- src/main/quit-teardown-deadline.test.ts | 19 ++-- src/main/quit-teardown-deadline.ts | 32 ++++-- src/main/runtime/rpc/relay-transport.test.ts | 62 ++++++++++- src/main/runtime/rpc/relay-transport.ts | 51 ++++++--- src/main/window/createMainWindow.test.ts | 101 +++++++++++++++++- src/main/window/createMainWindow.ts | 42 +++++++- src/main/window/window-close-decision.ts | 6 +- src/preload/index.ts | 5 +- .../terminal-fit-restore.test.ts | 36 +++++++ .../terminal-pane/terminal-fit-restore.ts | 39 +++++-- 11 files changed, 357 insertions(+), 49 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index 22cb17b7c9d..74479c270c7 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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(() => { diff --git a/src/main/quit-teardown-deadline.test.ts b/src/main/quit-teardown-deadline.test.ts index 3fa7eeb6715..6303aaf8651 100644 --- a/src/main/quit-teardown-deadline.test.ts +++ b/src/main/quit-teardown-deadline.test.ts @@ -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 diff --git a/src/main/quit-teardown-deadline.ts b/src/main/quit-teardown-deadline.ts index 888f2aeed58..70328fa2ffe 100644 --- a/src/main/quit-teardown-deadline.ts +++ b/src/main/quit-teardown-deadline.ts @@ -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[], +export type NamedQuitTeardown = { + name: string + promise: Promise +} + +export async function settleTeardownWithinDeadline( + teardowns: readonly NamedQuitTeardown[], deadlineMs: number = WILL_QUIT_TEARDOWN_DEADLINE_MS -): Promise { - const deadline = new Promise((resolve) => { - const timer = setTimeout(resolve, deadlineMs) - if (typeof timer.unref === 'function') { - timer.unref() - } +): Promise { + 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 | 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] : [] } diff --git a/src/main/runtime/rpc/relay-transport.test.ts b/src/main/runtime/rpc/relay-transport.test.ts index e350a098f48..2f739f8c5c4 100644 --- a/src/main/runtime/rpc/relay-transport.test.ts +++ b/src/main/runtime/rpc/relay-transport.test.ts @@ -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 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() } diff --git a/src/main/runtime/rpc/relay-transport.ts b/src/main/runtime/rpc/relay-transport.ts index b3bdb5bc34e..fe22fddbffb 100644 --- a/src/main/runtime/rpc/relay-transport.ts +++ b/src/main/runtime/rpc/relay-transport.ts @@ -117,10 +117,11 @@ export class CloudRelayTransport implements RpcTransport, MobileSocketTransport async stop(): Promise { 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 { @@ -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 { 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) + } } diff --git a/src/main/window/createMainWindow.test.ts b/src/main/window/createMainWindow.test.ts index 5b84fd1f020..3c086b70968 100644 --- a/src/main/window/createMainWindow.test.ts +++ b/src/main/window/createMainWindow.test.ts @@ -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 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 void> = {} + const ipcHandlers: Record 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 void> = {} const webContents = { diff --git a/src/main/window/createMainWindow.ts b/src/main/window/createMainWindow.ts index e35cd24d71a..55abf360df2 100644 --- a/src/main/window/createMainWindow.ts +++ b/src/main/window/createMainWindow.ts @@ -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() +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 | 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) diff --git a/src/main/window/window-close-decision.ts b/src/main/window/window-close-decision.ts index b4ca32c5289..f41e7efa9c4 100644 --- a/src/main/window/window-close-decision.ts +++ b/src/main/window/window-close-decision.ts @@ -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) { diff --git a/src/preload/index.ts b/src/preload/index.ts index af26f9c7ace..2ae0620a8a7 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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) }, diff --git a/src/renderer/src/components/terminal-pane/terminal-fit-restore.test.ts b/src/renderer/src/components/terminal-pane/terminal-fit-restore.test.ts index 8e034ca5129..29c524f5a8c 100644 --- a/src/renderer/src/components/terminal-pane/terminal-fit-restore.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-fit-restore.test.ts @@ -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() + } + }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-fit-restore.ts b/src/renderer/src/components/terminal-pane/terminal-fit-restore.ts index f8ddfb3bcde..e153012c261 100644 --- a/src/renderer/src/components/terminal-pane/terminal-fit-restore.ts +++ b/src/renderer/src/components/terminal-pane/terminal-fit-restore.ts @@ -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 | 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 { + 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 { + return restoreTerminalFitToDesktopWithinDeadline( + ptyId, + settings, + Date.now() + RESTORE_FIT_TIMEOUT_MS + ) +} + export async function restoreTerminalFitsToDesktop( ptyIds: Iterable, settings: TerminalFitRestoreSettings ): Promise { 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) }