fix(app): bound the wake/quit paths implicated in the phone-session-ended freeze (#9447) (#9853)

* fix(app): bound the wake/quit paths implicated in the phone-session-ended freeze (#9447)

- relay-transport: waitForClose now times out (5s) so a half-open post-sleep
  socket can't wedge runtimeRpc.stop()
- will-quit: race teardown against a 20s deadline so app.quit() always runs
  (Force Quit was the only escape when any teardown member never settled)
- terminal-fit-restore: local restoreTerminalFit invoke gets the same 15s
  bound as the remote path so the held-fit modal buttons can't pin disabled

* fix(app): close wake recovery timeout gaps

* fix(relay): drop late frames after forced teardown

* fix(relay): fence detached socket callbacks

* fix(app): close timeout resource gaps

* fix(relay): detach retired mobile transports

* fix(types): exclude absent stat overloads

* fix(runtime): expire wedged terminal restore dedupe

* fix(runtime): keep restore retries on one reclaim

* chore(skills): refresh bundled skill manifests

* fix(window): fence quit acknowledgements by request

* fix(relay): bound revoked device socket cleanup
This commit is contained in:
Brennan Benson
2026-07-23 18:03:43 -07:00
committed by GitHub
parent 87c59dd27d
commit bded1fb2c0
17 changed files with 938 additions and 60 deletions
+130 -13
View File
@@ -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'
@@ -1480,7 +1484,10 @@ describe('createMainWindow', () => {
const preventDefault = vi.fn()
windowHandlers.close({ preventDefault } as never)
expect(preventDefault).toHaveBeenCalledTimes(1)
expect(webContents.send).toHaveBeenCalledWith('window:close-requested', { isQuitting: true })
expect(webContents.send).toHaveBeenCalledWith('window:close-requested', {
isQuitting: true,
requestId: expect.any(Number)
})
windowHandlers['will-prevent-unload']()
expect(onQuitAborted).toHaveBeenCalledTimes(1)
@@ -1533,9 +1540,10 @@ describe('createMainWindow', () => {
windowHandlers.close({ preventDefault } as never)
expect(preventDefault).not.toHaveBeenCalled()
expect(webContents.send).not.toHaveBeenCalledWith('window:close-requested', {
isQuitting: true
})
expect(webContents.send).not.toHaveBeenCalledWith(
'window:close-requested',
expect.objectContaining({ isQuitting: true })
)
consoleError.mockRestore()
})
@@ -1707,7 +1715,8 @@ describe('createMainWindow', () => {
expect(preventDefault).toHaveBeenCalledTimes(1)
expect(webContents.send).toHaveBeenCalledWith('window:close-requested', {
isQuitting: true
isQuitting: true,
requestId: expect.any(Number)
})
consoleError.mockRestore()
@@ -1751,9 +1760,10 @@ describe('createMainWindow', () => {
windowHandlers.close({ preventDefault } as never)
expect(preventDefault).not.toHaveBeenCalled()
expect(webContents.send).not.toHaveBeenCalledWith('window:close-requested', {
isQuitting: true
})
expect(webContents.send).not.toHaveBeenCalledWith(
'window:close-requested',
expect.objectContaining({ isQuitting: true })
)
})
// Why (#5787): a hung-but-ALIVE renderer (never gone, never crashed) must NOT
@@ -1800,10 +1810,114 @@ describe('createMainWindow', () => {
expect(preventDefault).toHaveBeenCalledTimes(1)
expect(webContents.send).toHaveBeenCalledWith('window:close-requested', {
isQuitting: false
isQuitting: false,
requestId: expect.any(Number)
})
})
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)
windowHandlers.close({ preventDefault: vi.fn() } as never)
const closeRequests = vi
.mocked(webContents.send)
.mock.calls.filter(([channel]) => channel === 'window:close-requested')
.map(([, request]) => request as { requestId: number })
expect(closeRequests).toHaveLength(2)
const [staleRequest, currentRequest] = closeRequests
ipcHandlers['window:close-request-received']?.({ sender: { id: 99 } }, currentRequest.requestId)
ipcHandlers['window:close-request-received']?.({ sender: { id: 42 } }, staleRequest.requestId)
await vi.advanceTimersByTimeAsync(WINDOW_QUIT_RENDERER_ACK_TIMEOUT_MS - 1)
expect(destroy).not.toHaveBeenCalled()
ipcHandlers['window:close-request-received']?.({ sender: { id: 42 } }, currentRequest.requestId)
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 = {
@@ -3240,7 +3354,8 @@ describe('createMainWindow', () => {
expect(instance.hide).not.toHaveBeenCalled()
expect(webContents.send).toHaveBeenCalledWith('window:close-requested', {
isQuitting: false
isQuitting: false,
requestId: expect.any(Number)
})
})
@@ -3254,7 +3369,8 @@ describe('createMainWindow', () => {
expect(instance.hide).not.toHaveBeenCalled()
expect(webContents.send).toHaveBeenCalledWith('window:close-requested', {
isQuitting: true
isQuitting: true,
requestId: expect.any(Number)
})
})
@@ -3300,7 +3416,8 @@ describe('createMainWindow', () => {
expect(instance.hide).not.toHaveBeenCalled()
expect(webContents.send).toHaveBeenCalledWith('window:close-requested', {
isQuitting: false
isQuitting: false,
requestId: expect.any(Number)
})
})