diff --git a/src/main/ipc/pty/delivery/accept.ts b/src/main/ipc/pty/delivery/accept.ts index 0582bed3353..979a50d3008 100644 --- a/src/main/ipc/pty/delivery/accept.ts +++ b/src/main/ipc/pty/delivery/accept.ts @@ -36,7 +36,7 @@ export function acceptPtyDataForRenderer( const preservesSeq = !payload.transformed && rawLength === payload.data.length const startSeq = typeof outputSeq === 'number' ? Math.max(0, outputSeq - rawLength) : undefined const projectionId = projection?.identity.projectionSemanticsId - if (session.mainWindow.isDestroyed()) { + if (!session.mainWindow || session.mainWindow.isDestroyed()) { if (projectionId) { session.sshOutputIntake?.transferProjections([projectionId], 'renderer-destroyed') } diff --git a/src/main/ipc/pty/delivery/accounting.ts b/src/main/ipc/pty/delivery/accounting.ts index eddc49be3f2..16fc3520f86 100644 --- a/src/main/ipc/pty/delivery/accounting.ts +++ b/src/main/ipc/pty/delivery/accounting.ts @@ -132,7 +132,11 @@ export function clearDeliveryResyncProbe(session: PtyIpcSession): void { } export function requestDeliveryResyncForGatedPty(session: PtyIpcSession): void { - if (session.deliveryResyncOutstandingRequestId !== null || session.mainWindow.isDestroyed()) { + if ( + session.deliveryResyncOutstandingRequestId !== null || + !session.mainWindow || + session.mainWindow.isDestroyed() + ) { return } session.deliveryResyncRequestSerial += 1 diff --git a/src/main/ipc/pty/delivery/debug-snapshot.ts b/src/main/ipc/pty/delivery/debug-snapshot.ts index facc04e4e74..3388ef153f7 100644 --- a/src/main/ipc/pty/delivery/debug-snapshot.ts +++ b/src/main/ipc/pty/delivery/debug-snapshot.ts @@ -46,13 +46,14 @@ export function buildMainDeliveryDiagnostics(session: PtyIpcSession): PtyMainDel }) } perPty.sort((a, b) => b.inFlightChars + b.pendingChars - (a.inFlightChars + a.pendingChars)) - const windowAlive = !session.mainWindow.isDestroyed() + const { mainWindow } = session + const windowAlive = mainWindow && !mainWindow.isDestroyed() return { appVersion: getAppEnvironment().getVersion(), mainUptimeMs: Math.round(process.uptime() * 1000), - windowFocused: windowAlive ? session.mainWindow.isFocused() : null, - windowVisible: windowAlive ? session.mainWindow.isVisible() : null, - windowMinimized: windowAlive ? session.mainWindow.isMinimized() : null, + windowFocused: windowAlive ? mainWindow.isFocused() : null, + windowVisible: windowAlive ? mainWindow.isVisible() : null, + windowMinimized: windowAlive ? mainWindow.isMinimized() : null, msSinceLastPowerSuspend: lastPowerSuspendAtMs === null ? null : now - lastPowerSuspendAtMs, msSinceLastPowerResume: lastPowerResumeAtMs === null ? null : now - lastPowerResumeAtMs, perPty: perPty.slice(0, DELIVERY_DIAGNOSTICS_MAX_PTYS), diff --git a/src/main/ipc/pty/delivery/exit.ts b/src/main/ipc/pty/delivery/exit.ts index 9aba356b3c3..a7575f7c2e6 100644 --- a/src/main/ipc/pty/delivery/exit.ts +++ b/src/main/ipc/pty/delivery/exit.ts @@ -105,7 +105,7 @@ export function preparePtyExitForRenderer( session: PtyIpcSession, payload: { id: string; code: number; incarnationId?: string } ): (() => void) | null { - if (session.mainWindow.isDestroyed()) { + if (!session.mainWindow || session.mainWindow.isDestroyed()) { session.sshOutputIntake?.transferPtyProjections(payload.id, 'renderer-destroyed') return () => {} } @@ -171,7 +171,7 @@ export function finalizePtyExitForRenderer( session: PtyIpcSession, payload: { id: string; code: number; incarnationId?: string } ): void { - if (session.mainWindow.isDestroyed()) { + if (!session.mainWindow || session.mainWindow.isDestroyed()) { session.rendererCreditBeforeExitByPty.delete(payload.id) return } @@ -229,7 +229,7 @@ export function sendPtyExitToRenderer( } export function sendPtySpawnedToRenderer(session: PtyIpcSession, id: string): void { - if (!session.mainWindow.isDestroyed()) { + if (session.mainWindow && !session.mainWindow.isDestroyed()) { session.mainWindow.webContents.send('pty:spawned', { id }) } } diff --git a/src/main/ipc/pty/delivery/flush.ts b/src/main/ipc/pty/delivery/flush.ts index 038d955a932..ffe3c3014e8 100644 --- a/src/main/ipc/pty/delivery/flush.ts +++ b/src/main/ipc/pty/delivery/flush.ts @@ -50,13 +50,17 @@ export function clearDispatcherReadyWatchdog(session: PtyIpcSession): void { export function armDispatcherReadyWatchdog(session: PtyIpcSession): void { clearDispatcherReadyWatchdog(session) - if (session.mainWindow.isDestroyed()) { + if (!session.mainWindow || session.mainWindow.isDestroyed()) { return } // Why: one-shot self-heal — force the gate open if the reloaded page never signals ready, so a dropped handshake can't hold it forever. Unref'd so it can't keep the process alive. session.dispatcherReadyWatchdogTimer = setTimeout(() => { session.dispatcherReadyWatchdogTimer = null - if (session.rendererPtyDispatcherReady || session.mainWindow.isDestroyed()) { + if ( + session.rendererPtyDispatcherReady || + !session.mainWindow || + session.mainWindow.isDestroyed() + ) { return } session.rendererPtyDispatcherReady = true @@ -77,7 +81,7 @@ export function clearFlushTimerIfIdle(session: PtyIpcSession): void { export function flushPendingData(session: PtyIpcSession): void { session.flushTimer = null - if (session.mainWindow.isDestroyed()) { + if (!session.mainWindow || session.mainWindow.isDestroyed()) { // Why release now: bookkeeping is being wiped, so no future drain can resume these producers — local shells would wedge. session.producerFlowControl.releaseAll() session.clearDeliveryResyncProbe() diff --git a/src/main/ipc/pty/delivery/lifecycle-reset.ts b/src/main/ipc/pty/delivery/lifecycle-reset.ts index 961c09188a1..475ddcadc0e 100644 --- a/src/main/ipc/pty/delivery/lifecycle-reset.ts +++ b/src/main/ipc/pty/delivery/lifecycle-reset.ts @@ -1,4 +1,4 @@ -import type { WebContents } from 'electron' +import type { PtyRendererDelivery } from '../session' import { didFinishLoadHandler, didFinishLoadWebContents, @@ -60,9 +60,14 @@ export function clearRendererLifecycleResetHandlers(): void { setRendererLifecycleResetState({ contents: null, handler: null, navigation: null }) } -export function registerRendererLifecycleResetHandlers(webContents: WebContents): void { +export function registerRendererLifecycleResetHandlers( + webContents?: PtyRendererDelivery['webContents'] +): void { clearRendererLifecycleResetHandlers() markRendererPtysHiddenForRendererLifecycleReset() + if (!webContents) { + return + } const handler = markRendererPtysHiddenForRendererLifecycleReset const navigationHandler = (details: { isMainFrame: boolean; isSameDocument: boolean }) => { if (!details.isMainFrame || details.isSameDocument) { diff --git a/src/main/ipc/pty/delivery/payload.ts b/src/main/ipc/pty/delivery/payload.ts index 119bb173ed7..8be6dfd11dd 100644 --- a/src/main/ipc/pty/delivery/payload.ts +++ b/src/main/ipc/pty/delivery/payload.ts @@ -38,7 +38,7 @@ export function sendModelRestoreNeededMarker( reason: PtyModelRestoreReason, markerSeq: number | undefined ): boolean { - if (session.mainWindow.isDestroyed()) { + if (!session.mainWindow || session.mainWindow.isDestroyed()) { return false } try { @@ -61,6 +61,12 @@ export function sendPtyDataToRenderer( payload: PtyDataPayload, projectionAdmissionIds?: readonly string[] ): { sent: boolean; projectionsTransferred: boolean } { + if (!session.mainWindow) { + if (projectionAdmissionIds) { + session.sshOutputIntake?.transferProjections(projectionAdmissionIds, 'renderer-destroyed') + } + return { sent: false, projectionsTransferred: projectionAdmissionIds !== undefined } + } const charCount = getPtyPayloadCharCount(payload) const accounting = session.rendererDeliveryAccountingByPty.get(id) const hadAccounting = accounting !== undefined diff --git a/src/main/ipc/pty/ipc/resize-visibility.ts b/src/main/ipc/pty/ipc/resize-visibility.ts index d3326a2df6e..c9f3e92c620 100644 --- a/src/main/ipc/pty/ipc/resize-visibility.ts +++ b/src/main/ipc/pty/ipc/resize-visibility.ts @@ -180,7 +180,7 @@ export function installPtyResizeVisibilityIpc(session: PtyIpcSession): void { ipcMain.removeAllListeners('pty:rendererDispatcherReady') ipcMain.on('pty:rendererDispatcherReady', (event) => { // Why: the reconcile below destructively clears delivery accounting, so a straggler handshake from a dying window must not reset the new window. - if (!isMainWindowPtyIpcEvent(event, mainWindow, mainWindow.webContents)) { + if (!isMainWindowPtyIpcEvent(event, mainWindow)) { return } // Why: a handshake while the gate is already open means a page load whose lifecycle reset was missed; clear the dead page's stale accounting so it can't permanently gate survivors. diff --git a/src/main/ipc/pty/ipc/serialize-buffer.ts b/src/main/ipc/pty/ipc/serialize-buffer.ts index d7b5d35d642..1c314875745 100644 --- a/src/main/ipc/pty/ipc/serialize-buffer.ts +++ b/src/main/ipc/pty/ipc/serialize-buffer.ts @@ -39,7 +39,7 @@ export function installPtySerializeBufferIpc(session: PtyIpcSession): void { ) => { // Why: the snapshot seeds terminal restore state, so only the main window may settle it. if ( - !isMainWindowPtyIpcEvent(event, session.mainWindow, session.mainWindow.webContents) || + !isMainWindowPtyIpcEvent(event, session.mainWindow) || typeof args?.requestId !== 'string' ) { return @@ -88,7 +88,8 @@ export function requestSerializedBuffer( ptyId: string, opts?: { scrollbackRows?: number } ): Promise { - if (session.mainWindow.isDestroyed()) { + const { mainWindow } = session + if (!mainWindow || mainWindow.isDestroyed()) { return Promise.resolve(null) } @@ -106,6 +107,6 @@ export function requestSerializedBuffer( if (opts) { payload.opts = opts } - session.mainWindow.webContents.send('pty:serializeBuffer:request', payload) + mainWindow.webContents.send('pty:serializeBuffer:request', payload) }) } diff --git a/src/main/ipc/pty/ipc/write-input.ts b/src/main/ipc/pty/ipc/write-input.ts index c7348ddfaef..77b27ef16f0 100644 --- a/src/main/ipc/pty/ipc/write-input.ts +++ b/src/main/ipc/pty/ipc/write-input.ts @@ -1,4 +1,5 @@ -import type { BrowserWindow, IpcMainEvent, IpcMainInvokeEvent, WebContents } from 'electron' +import type { IpcMainEvent, IpcMainInvokeEvent } from 'electron' +import type { PtyRendererDelivery } from '../session' import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' import type { IPtyProvider } from '../../../providers/types' import { isPtyWriteUnavailableError } from '../../../providers/pty-write-unavailable-error' @@ -12,10 +13,12 @@ import { interactiveOutputCharsByPty, lastInputAtByPty } from '../delivery/visib export function isMainWindowPtyIpcEvent( event: IpcMainEvent | IpcMainInvokeEvent, - mainWindow: BrowserWindow, - mainWebContents: WebContents + mainWindow: PtyRendererDelivery | undefined ): boolean { + const mainWebContents = mainWindow?.webContents return ( + !!mainWindow && + !!mainWebContents && event.sender === mainWebContents && !mainWindow.isDestroyed() && !(typeof mainWebContents.isDestroyed === 'function' && mainWebContents.isDestroyed()) @@ -26,23 +29,21 @@ export type PtyWritePayload = { id: string; data: string } export type PtyViewportClaimPayload = { id: string; cols: number; rows: number } export function createPtyWriteInput(deps: { - mainWindow: BrowserWindow + mainWindow?: PtyRendererDelivery runtime?: OrcaRuntimeService }): { writePtyInput: (args: PtyWritePayload) => boolean | Promise writePtyInputAccepted: (args: PtyWritePayload) => boolean | Promise isPtyWritePayload: (value: unknown) => value is PtyWritePayload isPtyViewportClaimPayload: (value: unknown) => value is PtyViewportClaimPayload - isPtyWriteEventFromMainWindow: ( - event: IpcMainEvent | IpcMainInvokeEvent, - mainWebContents: WebContents - ) => boolean + isPtyWriteEventFromMainWindow: (event: IpcMainEvent | IpcMainInvokeEvent) => boolean } { const { mainWindow, runtime } = deps const reportUnavailablePtyWrite = (id: string, error: unknown): void => { if ( !isPtyWriteUnavailableError(error) || + !mainWindow || mainWindow.isDestroyed() || (typeof mainWindow.webContents.isDestroyed === 'function' && mainWindow.webContents.isDestroyed()) @@ -144,10 +145,8 @@ export function createPtyWriteInput(deps: { (value as { cols: number }).cols > 0 && (value as { rows: number }).rows > 0 - const isPtyWriteEventFromMainWindow = ( - event: IpcMainEvent | IpcMainInvokeEvent, - mainWebContents: WebContents - ): boolean => isMainWindowPtyIpcEvent(event, mainWindow, mainWebContents) + const isPtyWriteEventFromMainWindow = (event: IpcMainEvent | IpcMainInvokeEvent): boolean => + isMainWindowPtyIpcEvent(event, mainWindow) const writePtyInput = (args: PtyWritePayload): boolean | Promise => { // Why: mobile-presence-lock defense-in-depth — the renderer's onData guard can let one keystroke slip during the state-flip lag, so catch it server-side. See docs/mobile-presence-lock.md. diff --git a/src/main/ipc/pty/ipc/write.ts b/src/main/ipc/pty/ipc/write.ts index 62adcd02113..04065921d07 100644 --- a/src/main/ipc/pty/ipc/write.ts +++ b/src/main/ipc/pty/ipc/write.ts @@ -1,14 +1,14 @@ -import type { BrowserWindow } from 'electron' +import type { PtyRendererDelivery } from '../session' import { getPtyIpc } from '../../pty-host-bindings' import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' import { createPtyWriteInput } from './write-input' export function installPtyWriteIpcHandlers(deps: { - mainWindow: BrowserWindow + mainWindow?: PtyRendererDelivery runtime?: OrcaRuntimeService }): void { const ipcMain = getPtyIpc() - const { mainWindow, runtime } = deps + const { runtime } = deps const { writePtyInput, writePtyInputAccepted, @@ -20,7 +20,7 @@ export function installPtyWriteIpcHandlers(deps: { const hostViewportClaimTails = new Map>() ipcMain.on('pty:write', (event, args: unknown) => { - if (!isPtyWriteEventFromMainWindow(event, mainWindow.webContents) || !isPtyWritePayload(args)) { + if (!isPtyWriteEventFromMainWindow(event) || !isPtyWritePayload(args)) { return } const claimTail = hostViewportClaimTails.get(args.id) @@ -31,7 +31,7 @@ export function installPtyWriteIpcHandlers(deps: { writePtyInput(args) }) ipcMain.handle('pty:writeAccepted', (event, args: unknown): boolean | Promise => { - if (!isPtyWriteEventFromMainWindow(event, mainWindow.webContents) || !isPtyWritePayload(args)) { + if (!isPtyWriteEventFromMainWindow(event) || !isPtyWritePayload(args)) { return false } const claimTail = hostViewportClaimTails.get(args.id) @@ -42,11 +42,7 @@ export function installPtyWriteIpcHandlers(deps: { ipcMain.removeAllListeners('pty:claimViewport') ipcMain.on('pty:claimViewport', (event, args: unknown) => { - if ( - !isPtyWriteEventFromMainWindow(event, mainWindow.webContents) || - !runtime || - !isPtyViewportClaimPayload(args) - ) { + if (!isPtyWriteEventFromMainWindow(event) || !runtime || !isPtyViewportClaimPayload(args)) { return } const prior = hostViewportClaimTails.get(args.id) diff --git a/src/main/ipc/pty/provider/bind-listeners.ts b/src/main/ipc/pty/provider/bind-listeners.ts index 935d7f60432..31bacec82f7 100644 --- a/src/main/ipc/pty/provider/bind-listeners.ts +++ b/src/main/ipc/pty/provider/bind-listeners.ts @@ -28,6 +28,7 @@ export function bindProviderListeners(session: PtyIpcSession): void { setLocalWriteUnavailableUnsub( localProvider.onWriteUnavailable?.((payload) => { if ( + !session.mainWindow || session.mainWindow.isDestroyed() || (typeof session.mainWindow.webContents.isDestroyed === 'function' && session.mainWindow.webContents.isDestroyed()) diff --git a/src/main/ipc/pty/provider/listener-lifecycle.ts b/src/main/ipc/pty/provider/listener-lifecycle.ts index 2b31257234e..815749582f8 100644 --- a/src/main/ipc/pty/provider/listener-lifecycle.ts +++ b/src/main/ipc/pty/provider/listener-lifecycle.ts @@ -1,4 +1,4 @@ -import type { WebContents } from 'electron' +import type { PtyRendererDelivery } from '../session' // Why: localProvider.onData/onExit return unsubscribe functions. Without // storing and calling these on re-registration, macOS app re-activation @@ -9,13 +9,13 @@ export let localExitUnsub: (() => void) | null = null export let localBackgroundStreamUnsub: (() => void) | null = null export let localWriteUnavailableUnsub: (() => void) | null = null export let didFinishLoadHandler: (() => void) | null = null -export let didFinishLoadWebContents: WebContents | null = null -export let rendererLifecycleResetWebContents: WebContents | null = null +export let didFinishLoadWebContents: PtyRendererDelivery['webContents'] | null = null +export let rendererLifecycleResetWebContents: PtyRendererDelivery['webContents'] | null = null export let rendererLifecycleResetHandler: (() => void) | null = null // Why: the hidden-delivery gate registries mirror renderer state; a reload/crash destroys owners without unregistering, so they reset when the renderer is replaced (drop memory preserved). export let rendererGateResetLoadHandler: (() => void) | null = null export let rendererGateResetGoneHandler: (() => void) | null = null -export let rendererGateResetWebContents: WebContents | null = null +export let rendererGateResetWebContents: PtyRendererDelivery['webContents'] | null = null // Why: the backgrounded-delivery dedupe map lives in the registerPtyHandlers closure but teardown funnels through module-scope clearProviderPtyState. // Why null-init + wrapper fn: see delivery/debug.ts — rolldown const-folds `export let fn = noop` bridges (STA-5661). let clearBackgroundedDeliverySyncForPtyImpl: ((id: string) => void) | null = null @@ -72,14 +72,14 @@ export function setLocalWriteUnavailableUnsub(fn: (() => void) | null): void { export function setDidFinishLoadHandler( handler: (() => void) | null, - contents: WebContents | null + contents: PtyRendererDelivery['webContents'] | null ): void { didFinishLoadHandler = handler didFinishLoadWebContents = contents } export function setRendererLifecycleResetState(args: { - contents: WebContents | null + contents: PtyRendererDelivery['webContents'] | null handler: (() => void) | null navigation: ((details: RendererNavigationDetails) => void) | null }): void { @@ -89,7 +89,7 @@ export function setRendererLifecycleResetState(args: { } export function setRendererGateResetState(args: { - contents: WebContents | null + contents: PtyRendererDelivery['webContents'] | null load: (() => void) | null gone: (() => void) | null }): void { diff --git a/src/main/ipc/pty/register-handlers.ts b/src/main/ipc/pty/register-handlers.ts index 7794a8ef60a..aaace9fac37 100644 --- a/src/main/ipc/pty/register-handlers.ts +++ b/src/main/ipc/pty/register-handlers.ts @@ -1,4 +1,3 @@ -import type { BrowserWindow } from 'electron' import { getAppEnvironment } from '../../../shared/app-environment' import type { OrcaRuntimeService } from '../../runtime/orca-runtime' import type { Store } from '../../persistence' @@ -48,7 +47,7 @@ import { clearRendererGateResetHandlers, clearDidFinishLoadHandler } from './delivery/lifecycle-reset' -import { createPtyIpcSession, type PtyIpcSessionOptions } from './session' +import { createPtyIpcSession, type PtyIpcSessionOptions, type PtyRendererDelivery } from './session' import { wirePtyIpcSession } from './delivery/wire-session' import { configureLocalPtyProvider } from './provider/local-configure' import { bindProviderListeners } from './provider/bind-listeners' @@ -67,7 +66,7 @@ import { import { ensureLinuxTerminalOrcaCliShimDir } from '../../cli/linux-terminal-orca-cli-shim' export function registerPtyHandlers( - mainWindow: BrowserWindow, + mainWindow?: PtyRendererDelivery, runtime?: OrcaRuntimeService, getSelectedCodexHomePath?: GetSelectedCodexHomePath, getSettings?: () => GlobalSettings, @@ -92,7 +91,7 @@ export function registerPtyHandlers( setInvalidatePendingPtyDrainPolicy(() => {}) // Why: neutralize rebind at the same moment as drain so a daemon replace in this window cannot attach the old accept/exit closures. setRebindProviderListeners(() => {}) - registerRendererLifecycleResetHandlers(mainWindow.webContents) + registerRendererLifecycleResetHandlers(mainWindow?.webContents) const getLocalPtyStartupPromise = (connectionId?: string | null): Promise | undefined => { if (connectionId) { @@ -169,17 +168,19 @@ export function registerPtyHandlers( // Why: the daemon pacer must not keep throttling ptys whose hidden marks died with the renderer; the fresh renderer's sync re-marks the still-hidden ones. session.resyncBackgroundedDeliveriesAfterGateReset() } - setRendererGateResetState({ - contents: mainWindow.webContents, - load: resetRendererPtyDeliveryGateState, - gone: resetRendererPtyDeliveryGateState - }) - mainWindow.webContents.on('did-finish-load', resetRendererPtyDeliveryGateState) - mainWindow.webContents.on('render-process-gone', resetRendererPtyDeliveryGateState) + if (mainWindow) { + setRendererGateResetState({ + contents: mainWindow.webContents, + load: resetRendererPtyDeliveryGateState, + gone: resetRendererPtyDeliveryGateState + }) + mainWindow.webContents.on('did-finish-load', resetRendererPtyDeliveryGateState) + mainWindow.webContents.on('render-process-gone', resetRendererPtyDeliveryGateState) + } // Why: only LocalPtyProvider PTYs (main-process) can be orphaned on reload; daemon sessions survive by design and cleanup would kill them. clearDidFinishLoadHandler() - if (localProvider instanceof LocalPtyProvider) { + if (mainWindow && localProvider instanceof LocalPtyProvider) { const lp = localProvider const finishLoadHandler = () => { // Why: always advance to keep the generation monotonic, but skip the sweep on crash/freeze-recovery reload — it would kill live local PTYs before session restore (#5787). diff --git a/src/main/ipc/pty/register-headless-runtime.test.ts b/src/main/ipc/pty/register-headless-runtime.test.ts index 7063ec2454d..a1b6f723483 100644 --- a/src/main/ipc/pty/register-headless-runtime.test.ts +++ b/src/main/ipc/pty/register-headless-runtime.test.ts @@ -33,6 +33,7 @@ describe('registerHeadlessPtyRuntime', () => { expect(events).toEqual(['handlers', 'hydrate']) expect(registerHandlersMock).toHaveBeenCalledOnce() + expect(registerHandlersMock.mock.calls[0]?.[0]).toBeUndefined() expect(hydrateMock).toHaveBeenCalledWith(store) resolveHydration() diff --git a/src/main/ipc/pty/register-headless-runtime.ts b/src/main/ipc/pty/register-headless-runtime.ts index 2212fbfac3a..424cb40facd 100644 --- a/src/main/ipc/pty/register-headless-runtime.ts +++ b/src/main/ipc/pty/register-headless-runtime.ts @@ -1,4 +1,3 @@ -import type { BrowserWindow } from 'electron' import type { OrcaRuntimeService } from '../../runtime/orca-runtime' import type { Store } from '../../persistence' import type { GlobalSettings } from '../../../shared/global-settings-types' @@ -23,21 +22,8 @@ export function registerHeadlessPtyRuntime( onPtyExit?: (id: string, exitSequence: number) => void } ): Promise { - // Why: headless `orca serve` has no renderer window but still needs the same PTY handlers so remote clients can drive terminals. - // Why a fake rather than null: `registerPtyHandlers` takes a non-null BrowserWindow. `isDestroyed: () => true` - // is what makes that safe — every renderer-liveness guard reads it and skips, so no send is ever attempted. - // Keep `webContents.isDestroyed` in step with it: guards check both, and a missing method reads as "alive". - const headlessWindow = { - isDestroyed: () => true, - webContents: { - isDestroyed: () => true, - send: () => {}, - on: () => {}, - removeListener: () => {} - } - } as unknown as BrowserWindow registerPtyHandlers( - headlessWindow, + undefined, runtime, getSelectedCodexHomePath, getSettings, diff --git a/src/main/ipc/pty/register-without-renderer.test.ts b/src/main/ipc/pty/register-without-renderer.test.ts new file mode 100644 index 00000000000..513d969f110 --- /dev/null +++ b/src/main/ipc/pty/register-without-renderer.test.ts @@ -0,0 +1,231 @@ +import { EventEmitter } from 'node:events' +import { describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from '../../runtime/orca-runtime' +import { setupPtyIpcSuite } from '../pty-ipc-test-harness' +import { + getLocalPtyProvider, + getPtyRendererDeliveryDebugSnapshot, + registerPtyHandlers, + registerSshPtyProvider, + setPtyOwnership, + unregisterSshPtyProvider +} from '../pty' +import { registerHeadlessPtyRuntime } from './register-headless-runtime' +import { onMock } from '../pty-ipc-mock-registry' + +vi.mock('electron', () => import('../pty-ipc-mock-registry').then((m) => m.electronModuleMock())) +vi.mock('fs', () => import('../pty-ipc-mock-registry').then((m) => m.fsModuleMock())) +vi.mock('node-pty', () => import('../pty-ipc-mock-registry').then((m) => m.nodePtyModuleMock())) +vi.mock('node:child_process', async (importOriginal) => + (await import('../pty-ipc-mock-registry')).childProcessModuleMock(await importOriginal()) +) +vi.mock('../../opencode/hook-service', () => + import('../pty-ipc-mock-registry').then((m) => m.openCodeHookServiceModuleMock()) +) +vi.mock('../../mimo/hook-service', () => + import('../pty-ipc-mock-registry').then((m) => m.mimoHookServiceModuleMock()) +) +vi.mock('../../agent-hooks/server', () => + import('../pty-ipc-mock-registry').then((m) => m.agentHookServerModuleMock()) +) +vi.mock('../../pi/titlebar-extension-service', () => + import('../pty-ipc-mock-registry').then((m) => m.piTitlebarExtensionModuleMock()) +) +vi.mock('../../pwsh', () => import('../pty-ipc-mock-registry').then((m) => m.pwshModuleMock())) +vi.mock('../../wsl', async (importOriginal) => + (await import('../pty-ipc-mock-registry')).wslModuleMock(await importOriginal()) +) +vi.mock('../../telemetry/client', () => + import('../pty-ipc-mock-registry').then((m) => m.telemetryClientModuleMock()) +) +vi.mock('../../telemetry/classify-error', () => + import('../pty-ipc-mock-registry').then((m) => m.classifyErrorModuleMock()) +) +vi.mock('../../cli/linux-terminal-orca-cli-shim', () => + import('../pty-ipc-mock-registry').then((m) => m.linuxCliShimModuleMock()) +) +vi.mock('../../memory/pty-registry', () => + import('../pty-ipc-mock-registry').then((m) => m.ptyRegistryModuleMock()) +) +vi.mock('../../agent-hooks/migration-unsupported-pty-state', () => + import('../pty-ipc-mock-registry').then((m) => m.migrationUnsupportedPtyModuleMock()) +) +vi.mock('../../codex/codex-pane-account-registry', () => + import('../pty-ipc-mock-registry').then((m) => m.codexPaneAccountRegistryModuleMock()) +) +vi.mock('../../codex/codex-state-db-backfill-recovery', () => + import('../pty-ipc-mock-registry').then((m) => m.codexBackfillRecoveryModuleMock()) +) + +describe('PTY registration without renderer delivery', () => { + const { + handlers, + mainWindow, + mainWindowIpcEvent, + installObservableDaemonTestProvider, + getPtyWriteListener + } = setupPtyIpcSuite() + + it('keeps daemon output and exits flowing to the runtime without renderer work', async () => { + vi.useFakeTimers() + const daemon = installObservableDaemonTestProvider() + const runtime = new OrcaRuntimeService() + const setController = vi.spyOn(runtime, 'setPtyController') + const onData = vi.spyOn(runtime, 'onPtyData').mockReturnValue(6) + const onExit = vi.spyOn(runtime, 'onPtyExit').mockImplementation(() => {}) + const onLifecycleExit = vi.fn() + setPtyOwnership('daemon-pty', null) + const initialTimerCount = vi.getTimerCount() + + await registerHeadlessPtyRuntime( + runtime, + undefined, + undefined, + undefined, + undefined, + undefined, + { onPtyExit: onLifecycleExit } + ) + const controller = setController.mock.calls[0]?.[0] + expect(controller).toBeDefined() + expect(vi.getTimerCount()).toBe(initialTimerCount) + daemon.emitData('daemon-pty', 'output') + daemon.emitDataGap('daemon-pty', 3) + daemon.emitExit('daemon-pty', 0) + + expect(onData).toHaveBeenCalledWith('daemon-pty', 'output', expect.any(Number), 6, undefined) + expect(onExit).toHaveBeenCalledWith('daemon-pty', 0, undefined, { providerExitObserved: true }) + expect(onLifecycleExit).toHaveBeenCalledWith('daemon-pty', expect.any(Number)) + await expect(controller?.serializeBuffer?.('daemon-pty')).resolves.toBeNull() + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + pendingChars: 0, + rendererInFlightChars: 0, + flushScheduled: false, + diagnostics: { windowFocused: null, windowVisible: null, windowMinimized: null } + }) + expect(mainWindow.webContents.on).not.toHaveBeenCalled() + expect(mainWindow.webContents.send).not.toHaveBeenCalled() + expect(daemon.pauseProducer).not.toHaveBeenCalled() + }) + + it('routes local and SSH control to their providers, including attach and buffer snapshots', async () => { + const remote = installObservableDaemonTestProvider() + const remoteProvider = getLocalPtyProvider() + registerSshPtyProvider('ssh-a', remoteProvider) + const local = installObservableDaemonTestProvider() + const localProvider = getLocalPtyProvider() + const localClear = vi.spyOn(localProvider, 'clearBuffer') + const remoteClear = vi.spyOn(remoteProvider, 'clearBuffer') + const attach = vi.spyOn(localProvider, 'attach').mockResolvedValue({}) + const runtime = new OrcaRuntimeService() + const setController = vi.spyOn(runtime, 'setPtyController') + const remoteId = 'ssh:ssh-a@@remote-pty' + local.getBufferSnapshot.mockResolvedValue({ data: 'local history', cols: 80, rows: 24 }) + remote.getBufferSnapshot.mockResolvedValue({ data: 'remote history', cols: 100, rows: 30 }) + await registerHeadlessPtyRuntime(runtime) + const controller = setController.mock.calls[0]?.[0] + if (!controller) { + throw new Error('missing runtime PTY controller') + } + + expect(controller.write('daemon-pty', 'local input')).toBe(true) + expect(controller.write(remoteId, 'remote input')).toBe(true) + await controller.clearBuffer?.('daemon-pty') + await controller.clearBuffer?.(remoteId) + await expect(controller.attach?.('daemon-pty')).resolves.toBe(true) + await expect(controller.attach?.(remoteId)).resolves.toBe(false) + await expect(controller.serializeProviderBuffer?.('daemon-pty')).resolves.toMatchObject({ + data: 'local history' + }) + await expect(controller.serializeProviderBuffer?.(remoteId)).resolves.toMatchObject({ + data: 'remote history' + }) + expect(local.write).toHaveBeenCalledExactlyOnceWith('daemon-pty', 'local input') + expect(remote.write).toHaveBeenCalledExactlyOnceWith(remoteId, 'remote input') + expect(localClear).toHaveBeenCalledExactlyOnceWith('daemon-pty') + expect(remoteClear).toHaveBeenCalledExactlyOnceWith(remoteId) + expect(attach).toHaveBeenCalledExactlyOnceWith('daemon-pty') + + unregisterSshPtyProvider('ssh-a') + expect(controller.write(remoteId, 'disconnected input')).toBe(false) + await expect(controller.probePtyLiveness?.(remoteId)).resolves.toBeNull() + expect(local.write).toHaveBeenCalledTimes(1) + }) + + it('rejects renderer input when no renderer owns the registration', async () => { + const daemon = installObservableDaemonTestProvider() + const runtime = new OrcaRuntimeService() + setPtyOwnership('daemon-pty', null) + await registerHeadlessPtyRuntime(runtime) + + getPtyWriteListener()(mainWindowIpcEvent, { id: 'daemon-pty', data: 'untrusted' }) + expect( + handlers.get('pty:writeAccepted')?.(mainWindowIpcEvent, { + id: 'daemon-pty', + data: 'untrusted' + }) + ).toBe(false) + expect(daemon.write).not.toHaveBeenCalled() + expect(mainWindow.webContents.send).not.toHaveBeenCalled() + }) + + it('detaches desktop lifecycle listeners and restores delivery after headless re-registration', async () => { + vi.useFakeTimers() + const daemon = installObservableDaemonTestProvider() + const provider = getLocalPtyProvider() + const subscribe = vi.mocked(provider.onData).getMockImplementation() + if (!subscribe) { + throw new Error('missing daemon data subscription') + } + const unsubscribe = vi.fn() + const onDataSubscribe = vi.spyOn(provider, 'onData').mockImplementation((listener) => { + const dispose = subscribe(listener) + return () => { + unsubscribe() + dispose() + } + }) + const runtime = new OrcaRuntimeService() + const onData = vi.spyOn(runtime, 'onPtyData').mockReturnValue(6) + const rendererEvents = new EventEmitter() + mainWindow.webContents.on.mockImplementation((event, listener) => + rendererEvents.on(event, listener) + ) + mainWindow.webContents.removeListener.mockImplementation((event, listener) => + rendererEvents.removeListener(event, listener) + ) + const renderer = { + ...mainWindow, + webContents: Object.assign(mainWindow.webContents, { id: 1 }) + } + registerPtyHandlers(renderer, runtime) + expect(rendererEvents.listenerCount('did-finish-load')).toBe(1) + expect(rendererEvents.listenerCount('render-process-gone')).toBe(2) + + await registerHeadlessPtyRuntime(runtime) + expect(rendererEvents.eventNames()).toEqual([]) + expect(onDataSubscribe).toHaveBeenCalledTimes(2) + expect(unsubscribe).toHaveBeenCalledOnce() + mainWindow.webContents.send.mockClear() + daemon.emitData('daemon-pty', 'output') + vi.advanceTimersByTime(20) + expect(onData).toHaveBeenCalledTimes(1) + expect(mainWindow.webContents.send).not.toHaveBeenCalled() + + registerPtyHandlers(renderer, runtime) + expect(unsubscribe).toHaveBeenCalledTimes(2) + const ready = onMock.mock.calls.findLast( + ([channel]) => channel === 'pty:rendererDispatcherReady' + )?.[1] + ready(mainWindowIpcEvent) + daemon.emitData('daemon-pty', 'output') + vi.advanceTimersByTime(20) + expect(onData).toHaveBeenCalledTimes(2) + expect(rendererEvents.listenerCount('did-finish-load')).toBe(1) + expect(rendererEvents.listenerCount('render-process-gone')).toBe(2) + expect(mainWindow.webContents.send).toHaveBeenCalledWith( + 'pty:data', + expect.objectContaining({ id: 'daemon-pty', data: 'output' }) + ) + }) +}) diff --git a/src/main/ipc/pty/runtime/controller-deps.ts b/src/main/ipc/pty/runtime/controller-deps.ts index db55b637e57..8f20245505c 100644 --- a/src/main/ipc/pty/runtime/controller-deps.ts +++ b/src/main/ipc/pty/runtime/controller-deps.ts @@ -1,4 +1,4 @@ -import type { BrowserWindow } from 'electron' +import type { PtyRendererDelivery } from '../session' import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' import type { Store } from '../../../persistence' import type { IPtyProvider } from '../../../providers/types' @@ -83,7 +83,7 @@ export type PtyRuntimeControllerDeps = { trustedTerminalHandleEnv: Set retiredRejectedPtyIds: Map reversibleStopOwnersByPtyId: Map - mainWindow: BrowserWindow + mainWindow?: PtyRendererDelivery transitionSpawnHiddenRendererPtyDeliveryState?: (id: string, hidden: boolean) => void syncPtyBackgroundedDelivery?: (id: string, caller: string) => void } diff --git a/src/main/ipc/pty/runtime/operations.ts b/src/main/ipc/pty/runtime/operations.ts index 703fa009b7d..f03892a6e31 100644 --- a/src/main/ipc/pty/runtime/operations.ts +++ b/src/main/ipc/pty/runtime/operations.ts @@ -169,7 +169,9 @@ export async function clearBufferFromRuntimeController( ptyId: string ): Promise { // Why: desktop xterm and daemon/SSH providers hold separate buffers; clear both so mobile resubscribe can't resurrect cleared history. - deps.mainWindow.webContents.send('pty:clearBuffer:request', { ptyId }) + if (deps.mainWindow && !deps.mainWindow.isDestroyed()) { + deps.mainWindow.webContents.send('pty:clearBuffer:request', { ptyId }) + } try { await getProviderForPty(ptyId).clearBuffer(ptyId) } catch { diff --git a/src/main/ipc/pty/session.ts b/src/main/ipc/pty/session.ts index 30c6735cde2..e5c59d9b81a 100644 --- a/src/main/ipc/pty/session.ts +++ b/src/main/ipc/pty/session.ts @@ -1,4 +1,4 @@ -import type { BrowserWindow } from 'electron' +import type { BrowserWindow, WebContents } from 'electron' import type { OrcaRuntimeService } from '../../runtime/orca-runtime' import type { Store } from '../../persistence' import type { GlobalSettings } from '../../../shared/global-settings-types' @@ -58,8 +58,15 @@ export type PtyIpcSessionOptions = { onPtyExit?: (id: string, exitSequence: number) => void } +export type PtyRendererDelivery = Pick< + BrowserWindow, + 'isDestroyed' | 'isFocused' | 'isVisible' | 'isMinimized' +> & { + webContents: Pick +} + export type PtyIpcSession = { - mainWindow: BrowserWindow + mainWindow?: PtyRendererDelivery runtime?: OrcaRuntimeService store?: Store getSettings?: () => GlobalSettings @@ -182,7 +189,7 @@ const unsetSessionFn = (): never => { } export function createPtyIpcSession(args: { - mainWindow: BrowserWindow + mainWindow?: PtyRendererDelivery runtime?: OrcaRuntimeService store?: Store getSettings?: () => GlobalSettings diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index 16df8754d76..02533588e9d 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -1,13 +1,12 @@ /** - * `orcad` — the Orca runtime served from plain Node, with no Electron. + * `orcad` — the Orca runtime served without Electron. * * Installs the Node host adapters, constructs the same `OrcaRuntimeService` the * desktop uses, installs a PTY controller via `registerHeadlessPtyRuntime`, and * serves runtime RPC. See docs/design/node-only-runtime-backend.html. * - * Desktop UI surfaces stay uninstalled: no native notifications, no renderer window. The - * renderer window is faked as a destroyed one because `registerPtyHandlers` takes a - * non-null `BrowserWindow`. Browser automation is different — it is installed through + * Desktop UI surfaces stay uninstalled: no native notifications or renderer delivery. + * Browser automation is installed through * the runtime factory, but only when an Electron serve sidecar or an operator-supplied * Chromium proves available at startup. */