refactor(pty): make renderer delivery optional for headless runtimes (#23117)

Co-authored-by: m4air <m4air@m4airs-Air.localdomain>
This commit is contained in:
OrcaWin
2026-09-26 02:25:16 -07:00
committed by GitHub
co-authored by m4air
parent d3434a2db4
commit 9700030689
21 changed files with 329 additions and 85 deletions
+1 -1
View File
@@ -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')
}
+5 -1
View File
@@ -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
+5 -4
View File
@@ -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),
+3 -3
View File
@@ -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 })
}
}
+7 -3
View File
@@ -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()
+7 -2
View File
@@ -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) {
+7 -1
View File
@@ -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
+1 -1
View File
@@ -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.
+4 -3
View File
@@ -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<SerializeResult> {
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)
})
}
+11 -12
View File
@@ -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<boolean>
writePtyInputAccepted: (args: PtyWritePayload) => boolean | Promise<boolean>
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<boolean> => {
// 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.
+6 -10
View File
@@ -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<string, Promise<boolean>>()
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<boolean> => {
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)
@@ -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())
@@ -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 {
+13 -12
View File
@@ -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<void> | 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).
@@ -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()
+1 -15
View File
@@ -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<void> {
// 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,
@@ -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' })
)
})
})
+2 -2
View File
@@ -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<string>
retiredRejectedPtyIds: Map<string, NodeJS.Timeout>
reversibleStopOwnersByPtyId: Map<string, number>
mainWindow: BrowserWindow
mainWindow?: PtyRendererDelivery
transitionSpawnHiddenRendererPtyDeliveryState?: (id: string, hidden: boolean) => void
syncPtyBackgroundedDelivery?: (id: string, caller: string) => void
}
+3 -1
View File
@@ -169,7 +169,9 @@ export async function clearBufferFromRuntimeController(
ptyId: string
): Promise<void> {
// 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 {
+10 -3
View File
@@ -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<WebContents, 'id' | 'isDestroyed' | 'send' | 'on' | 'removeListener'>
}
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
+3 -4
View File
@@ -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.
*/