mirror of
https://github.com/stablyai/orca.git
synced 2026-10-05 00:02:33 +00:00
refactor(pty): make renderer delivery optional for headless runtimes (#23117)
Co-authored-by: m4air <m4air@m4airs-Air.localdomain>
This commit is contained in:
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,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' })
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user