Files
orca/src/main/ipc/runtime.ts
T
6e3ebf534c fix(terminal): arbitrate active desktop viewport ownership (#8252)
* fix(terminal): let remote desktop viewers own the shared PTY width

A remote (relay/shared-control) desktop viewer resizes the host source PTY to
its own width, but was never registered as a width owner. So the host's own fit
cascade (window resize, split drag, tab reveal, "+"-new-tab re-render) freely
resized the viewed PTY back to the host-local width with no signal to the
viewer. The viewer kept its narrower grid while the host streamed wider
alt-screen frames -> cell-layout garble ("porridge") until a manual resize.

The mobile "presence lock" already solves this shape for phones by suppressing
the host's pty:resize while the phone drives. This does the same for remote
desktop viewers WITHOUT joining the mobile driver state machine (a viewer needs
only resize suppression, not input lock / phone-fit / driver banners), and it
routes every PTY geometry change through the existing enqueueLayout/applyLayout
serialization path rather than resizing the PTY ad hoc.

Design:
- Registry `remoteDesktopViewers: Map<ptyId, Map<subscriptionKey, viewport>>`,
  keyed per SUBSCRIPTION so duplicate streams of one client cannot release each
  other. isPtyResizeDrivenRemotely() = mobile driver OR any viewer present;
  pty:resize (host fit cascade) bails on it. INPUT is never locked (shared
  control: host and viewers can both type), unlike mobile.
- New internal layout target { kind: 'remote-desktop' } in applyLayout: clears
  terminalFitOverrides like 'desktop', resizes only when dims changed, emits no
  fit-override notifications, and does not call onExternalPtyResize (so a
  viewer's width never pollutes desktop restore state). enqueueLayout remains
  the sole serialized writer of PTY dimensions.
- Smallest client wins: applyRemoteDesktopLayout() sizes the PTY to the SMALLEST
  attached viewer, so viewers with different screen sizes never overflow the
  narrowest grid.
- No snapshot/replay race: a viewport that arrives while the initial scrollback
  snapshot is being serialized is buffered and applied as ONE serialized resize
  before serialization, so a mid-repaint alt screen is never baked into the
  snapshot.
- Reclaim on detach: when the last viewer leaves, applyRemoteDesktopLayout
  resizes the PTY back to the host's own width via enqueueLayout({kind:'desktop'}),
  so the host reflows to full width without a manual window jiggle. The host
  width is captured at the pty:resize suppression point (the host's own resize
  attempts) — a source a remote viewport never pollutes.
- Mobile coexistence: a phone outranks a viewer; when the phone leaves, the
  mobile-release paths call applyRemoteDesktopLayout so a surviving viewer keeps
  the PTY, else the host reclaims.

Verified live (two local desktop instances): correct render on connect, no
garble on viewer-window resize (synchronous), and host reclaims full width on
disconnect. Unit tests cover the registry lifecycle, smallest-client-wins,
reclaim-via-layout, and mobile coexistence.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Codex <noreply@openai.com>

* fix(terminal): address remote-desktop PTY width review findings

Resolves four CodeRabbit findings on the remote-desktop viewer width
ownership change, plus a round-trip race surfaced while fixing the leak:

- Leak: the one-shot terminal.updateViewport RPC has no disconnect hook,
  so it must never create a width floor (nothing releases it, pinning the
  host at a stale width). Add refreshRemoteDesktopViewer, which only
  refreshes floors the client already owns via its stream subscription
  (matched by clientId), mirroring the mobile updateMobileViewport
  no-op-without-subscription invariant. The one-shot handler now refreshes
  instead of registering.
- Round-trip replay: with the one-shot fallback now refresh-only, a resize
  landing during the subscribe round-trip (connected, stream not yet
  current) was dropped. The transport now replays the latest viewport over
  the stream once it becomes current.
- Reclaim: keep the host reclaim target unless the reclaim resize actually
  landed (result.ok), so a failed reclaim can retry against true host
  geometry instead of a stale remote width.
- Snapshot drain: drain pendingRemoteDesktopViewport in the
  sendRequestedSnapshot finally block; a viewer resize parked during a
  SnapshotRequest buffering window was otherwise dropped until the next
  resize.
- Key scope: scope the multiplex width-floor key by connectionId so two
  connections reusing the same client-local streamId can't overwrite or
  release each other's floor.

Tests: refresh-never-creates-floor, reclaim-target-retained-on-failed-resize,
SnapshotRequest parked-resize drain, connection-scoped key assertions, and a
round-trip replay race test (verified to fail without the flush).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(terminal): arbitrate active desktop viewport ownership

Co-authored-by: Orca <help@stably.ai>

* test(terminal): expect viewport claim capability

Co-authored-by: Orca <help@stably.ai>

* fix(terminal): preserve input across ownership teardown

Co-authored-by: Orca <help@stably.ai>

* test(terminal): keep subscribe buffer coverage within lint limit

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: shady <shady2k@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Orca <help@stably.ai>
2026-07-11 03:02:29 -07:00

124 lines
4.3 KiB
TypeScript

import { BrowserWindow, ipcMain } from 'electron'
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
import type {
RuntimeBrowserDriverState,
RuntimeStatus,
RuntimeSyncWindowGraphResult,
RuntimeSyncWindowGraph,
RuntimeTerminalDriverState
} from '../../shared/runtime-types'
import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope'
import { RpcDispatcher } from '../runtime/rpc/dispatcher'
export function registerRuntimeHandlers(runtime: OrcaRuntimeService): void {
ipcMain.removeHandler('runtime:syncWindowGraph')
ipcMain.removeHandler('runtime:getStatus')
ipcMain.removeHandler('runtime:call')
ipcMain.handle(
'runtime:syncWindowGraph',
(event, graph: RuntimeSyncWindowGraph): RuntimeSyncWindowGraphResult => {
const window = BrowserWindow.fromWebContents(event.sender)
if (!window) {
throw new Error('Runtime graph sync must originate from a BrowserWindow')
}
return runtime.syncWindowGraph(window.id, graph)
}
)
ipcMain.handle('runtime:getStatus', (): RuntimeStatus => {
return runtime.getStatus()
})
ipcMain.handle(
'runtime:call',
async (
_event,
args: { method: string; params?: unknown }
): Promise<RuntimeRpcResponse<unknown>> => {
return (await new RpcDispatcher({ runtime }).dispatch({
id: 'desktop-ipc',
authToken: 'desktop-ipc',
method: args.method,
params: args.params
})) as RuntimeRpcResponse<unknown>
}
)
ipcMain.removeHandler('runtime:getTerminalFitOverrides')
ipcMain.handle(
'runtime:getTerminalFitOverrides',
(): {
ptyId: string
mode: 'mobile-fit' | 'remote-desktop-fit'
cols: number
rows: number
}[] => {
const overrides = runtime.getAllTerminalFitOverrides()
return Array.from(overrides.entries()).map(([ptyId, override]) => ({
ptyId,
...override
}))
}
)
ipcMain.removeHandler('runtime:getTerminalDrivers')
ipcMain.handle(
'runtime:getTerminalDrivers',
(): { ptyId: string; driver: RuntimeTerminalDriverState }[] => {
const drivers = runtime.getAllTerminalDrivers()
return Array.from(drivers.entries()).map(([ptyId, driver]) => ({ ptyId, driver }))
}
)
ipcMain.removeHandler('runtime:getBrowserDrivers')
ipcMain.handle(
'runtime:getBrowserDrivers',
(): { browserPageId: string; driver: RuntimeBrowserDriverState }[] => {
const drivers = runtime.getAllBrowserDrivers()
return Array.from(drivers.entries()).map(([browserPageId, driver]) => ({
browserPageId,
driver
}))
}
)
// Why: the desktop "Restore" button sets the display mode to 'desktop' and
// applies it, which restores the PTY to its original dimensions and emits
// a 'resized' event to any active mobile subscriber. This uses the same
// code path as the mobile toggle button (terminal.setDisplayMode RPC).
ipcMain.removeHandler('runtime:restoreTerminalFit')
ipcMain.handle('runtime:restoreTerminalFit', async (_event, args: { ptyId: string }) => {
// Why: this IPC powers the desktop "Take back" button. Beyond restoring
// PTY dims (the original semantic), it now also reclaims the input
// floor for the desktop via the driver state machine. The lock banner
// unmounts and desktop input/resize are unblocked until the next
// mobile interaction takes the floor again. See
// docs/mobile-presence-lock.md.
//
// Why async: reclaimTerminalForDesktop awaits applyMobileDisplayMode's
// PTY-resize chain. Returning the unresolved Promise to ipcMain made
// Electron try to structured-clone a Promise — "An object could not
// be cloned" error — and the renderer's restoreTerminalFit() rejected
// with no useful info.
try {
const reclaimed = await runtime.reclaimTerminalForDesktop(args.ptyId)
return { restored: reclaimed }
} catch {
return { restored: false }
}
})
ipcMain.removeHandler('runtime:reclaimBrowserForDesktop')
ipcMain.handle(
'runtime:reclaimBrowserForDesktop',
(_event, args: { browserPageId: string }): { reclaimed: boolean } => {
try {
return { reclaimed: runtime.reclaimBrowserForDesktop(args.browserPageId) }
} catch {
return { reclaimed: false }
}
}
)
}