Files
orca/src/main/runtime/client-session-tab-selection.ts
T
Jinwoo Hong e2ccf52dc3 fix(remote-runtime): a paired terminal accepts input again after its host app relaunches (#25736)
* test(remote-runtime): reproduce dead input after a paired host relaunch

A relaunched desktop host accepts RPC before its renderer publishes a window
graph. During that gap session.tabs.list answers with an unpublished empty
graph (publicationEpoch "none", snapshotVersion 0, tabs []). The client's
reconnect inventory treats that as removal and retires the pane
(retireRemoteTerminalId(-1)): the pane goes to "ended", the reconnect overlay
disappears, and keystrokes never reach the surviving daemon PTY.

Both tests are red on main by design; they are the repro for the fix.

- e2e: holds the relaunched host's runtime:syncWindowGraph so the client's
  reconnect deterministically meets the unpublished host (4/4 red).
- unit: transport-level repro of the same retirement (red in ~2s).
- restart helper gains a beforeFirstWindow hook; LaunchOptions moves to its
  own module to stay under the max-lines budget.

* fix(remote-runtime): a paired terminal survives its host app relaunching

After the host app quit and relaunched (daemon still running), a paired
client's terminal cleared its reconnect overlay and then ignored all input.
The relaunched host answers session.tabs.list before its renderer publishes,
with a synthesized empty frame. A paired client sees that frame through its
navigation projection as epoch "none:client-navigation", which the shared
"does this frame answer for the worktree" check did not recognise, so the
pane read it as removal and retired itself.

- hostSnapshotAffirmsWorktreeContents treats the client-projected placeholder
  as no answer at any version (the projection adds its navigation revision).
- The reconnect inventory keeps polling on such a frame instead of retiring;
  a published frame lacking the surface still retires the pane.
- Pushed frames that affirm nothing no longer report the surface absent.
- When the bounded inventory wait ends without evidence, the first published
  snapshot carrying the same handle now reattaches the pane, rather than
  waiting out the ~3 minute auto-recovery deadline.

Client-only: the host already labels the frame, old hosts send the same
frame, and the wire is unchanged. The e2e helper drops its type assertions.

* refactor(remote-runtime): simplify the host-relaunch reconnect fix

- One epoch rule: the placeholder epoch, bare or with the shared
  client-navigation suffix, is no answer. Hosts only ever send it at
  version 0, so the version check is dropped.
- Unpublished frames are dropped where pushed snapshots enter, instead of
  a nullable per-subscriber update.
- A published same-handle snapshot fires the parked retry through
  retryNow(), which now also fires a retry parked while still
  'recovering' (nothing is in flight then). This replaces state-reading
  wiring in the listener and lets online/resume fire it too.
- The Windows e2e runs the fixture through PowerShell's call operator.

* test(remote-runtime): prove the e2e hold beat the relaunched host's first publish

The spec now asks the held host for its tab list and requires the
unpublished placeholder before releasing, so a hold installed too late
fails instead of passing against unfixed code. The helper keeps its gate
in a typed global instead of Reflect lookups.

* fix(remote-runtime): keep a fenced handle's parked retry revivable

A retry parked for a handle that needs a replacement was consumed by an
early external trigger and then refused by the epoch's snapshot-wait
guard, leaving nothing to revive the pane. An external trigger now takes
over that snapshot wait as a fresh attempt, and a republished fenced
handle no longer fires the retry. Also refreshes comments that still
paired the placeholder epoch with version 0.

* fix(remote-runtime): a host answers an empty worktree for real once it has published

The host sent its unpublished placeholder for any worktree it had no
entry for, including one its window had never opened, long after
startup. With the placeholder now read as "ask me later", a paired client
opening such a worktree never got its first terminal.

The host now sends the placeholder only until its graph first publishes.
Afterwards a worktree with no tabs gets a real empty answer, and a client
that was told "ask me later" during startup is sent that answer once on
publication. Against an older host the client withholds the automatic
first terminal for such a worktree, which the user can still create.

A host push now fires only a parked retry, never cutting a scheduled
backoff short.

* chore(reliability-gates): reference the empty-worktree tests in gate commands

* test(remote-runtime): find the never-opened worktree by repo id on every platform
2026-10-06 00:47:27 -04:00

318 lines
11 KiB
TypeScript

import {
CLIENT_NAVIGATION_PUBLICATION_EPOCH_SUFFIX,
type RuntimeMobileSessionClientTab,
type RuntimeMobileSessionTabsResult
} from '../../shared/runtime-types'
import type { PersistedMobileClientTabSelections } from '../../shared/persisted-state-types'
import {
forgetClosedClientSessionTabsByWorktree,
projectWithoutClosedClientSessionTabs
} from './client-session-tab-closed-selection'
import type { StoredClientSessionTabSelection } from './client-session-tab-closed-selection'
import { normalizePersistedMobileClientTabSelections } from './client-session-tab-selection-persistence'
export type ClientSessionTabSelection = {
activeTabId: string | null
activeGroupId: string | null
activeTabIdByGroupId: Readonly<Record<string, string>>
}
function emptyClientSessionTabSelection(): ClientSessionTabSelection {
return { activeTabId: null, activeGroupId: null, activeTabIdByGroupId: {} }
}
function topLevelTabId(tab: RuntimeMobileSessionClientTab): string {
if (tab.type === 'terminal') {
return tab.parentTabId
}
return tab.id
}
function findTabByTopLevelId(
snapshot: RuntimeMobileSessionTabsResult,
topLevelId: string | null | undefined
): RuntimeMobileSessionClientTab | null {
if (!topLevelId) {
return null
}
return snapshot.tabs.find((tab) => topLevelTabId(tab) === topLevelId) ?? null
}
export function deriveClientSessionTabSelection(
snapshot: RuntimeMobileSessionTabsResult
): ClientSessionTabSelection {
return {
activeTabId: snapshot.activeTabId,
activeGroupId: snapshot.activeGroupId,
activeTabIdByGroupId: Object.fromEntries(
snapshot.tabGroups?.flatMap((group) =>
group.activeTabId ? [[group.id, group.activeTabId] as const] : []
) ?? []
)
}
}
export function activateClientSessionTabSelection(
snapshot: RuntimeMobileSessionTabsResult,
selection: ClientSessionTabSelection,
activeTabId: string
): ClientSessionTabSelection {
const activeTab = snapshot.tabs.find((tab) => tab.id === activeTabId)
if (!activeTab) {
return selection
}
const activeTopLevelTabId = topLevelTabId(activeTab)
const activeGroup = snapshot.tabGroups?.find((group) =>
group.tabOrder.includes(activeTopLevelTabId)
)
return {
activeTabId,
activeGroupId: activeGroup?.id ?? selection.activeGroupId,
activeTabIdByGroupId: activeGroup
? { ...selection.activeTabIdByGroupId, [activeGroup.id]: activeTopLevelTabId }
: selection.activeTabIdByGroupId
}
}
export function projectClientSessionTabSelection(
snapshot: RuntimeMobileSessionTabsResult,
selection: ClientSessionTabSelection
): { snapshot: RuntimeMobileSessionTabsResult; selection: ClientSessionTabSelection } {
const selectedGroup = snapshot.tabGroups?.find((group) => group.id === selection.activeGroupId)
// Why: preserve the client's leaf and group choices before falling back to shared snapshot order.
const activeTab =
snapshot.tabs.find((tab) => tab.id === selection.activeTabId) ??
findTabByTopLevelId(
snapshot,
selectedGroup ? selection.activeTabIdByGroupId[selectedGroup.id] : null
) ??
findTabByTopLevelId(snapshot, selectedGroup?.tabOrder[0]) ??
snapshot.tabs[0] ??
null
const activeTopLevelTabId = activeTab ? topLevelTabId(activeTab) : null
const activeTabIdByGroupId: Record<string, string> = {}
const tabGroups = snapshot.tabGroups?.map((group) => {
const selected = selection.activeTabIdByGroupId[group.id]
const activeTabId =
(selected && group.tabOrder.includes(selected) ? selected : null) ?? group.tabOrder[0] ?? null
if (activeTabId) {
activeTabIdByGroupId[group.id] = activeTabId
}
return { ...group, activeTabId }
})
const activeGroupId =
tabGroups?.find((group) =>
activeTopLevelTabId ? group.tabOrder.includes(activeTopLevelTabId) : false
)?.id ??
(selection.activeGroupId && tabGroups?.some((group) => group.id === selection.activeGroupId)
? selection.activeGroupId
: null) ??
tabGroups?.[0]?.id ??
null
const nextSelection: ClientSessionTabSelection = {
activeTabId: activeTab?.id ?? null,
activeGroupId,
activeTabIdByGroupId
}
return {
selection: nextSelection,
snapshot: {
...snapshot,
activeGroupId,
activeTabId: activeTab?.id ?? null,
activeTabType: activeTab?.type ?? null,
...(tabGroups ? { tabGroups } : {}),
tabs: snapshot.tabs.map((tab) => ({ ...tab, isActive: tab.id === activeTab?.id }))
}
}
}
export class ClientSessionTabSelectionStore {
private statesByClient = new Map<string, Map<string, StoredClientSessionTabSelection>>()
private persistListener: ((state: PersistedMobileClientTabSelections) => void) | null = null
// Why: selections previously died with the process, so a host restart snapped every phone back to the first tab (deterministic-topology fallback).
hydrate(persisted: PersistedMobileClientTabSelections): void {
for (const [clientNavigationId, selectionsByWorktree] of Object.entries(
normalizePersistedMobileClientTabSelections(persisted)
)) {
const statesByWorktree = this.getStatesByWorktree(clientNavigationId)
for (const [worktreeId, selection] of Object.entries(selectionsByWorktree)) {
statesByWorktree.set(worktreeId, {
selection,
revision: 0,
shouldPersist: true,
closedTabIds: new Set()
})
}
}
}
setPersistListener(listener: (state: PersistedMobileClientTabSelections) => void): void {
this.persistListener = listener
}
serialize(): PersistedMobileClientTabSelections {
const persisted: PersistedMobileClientTabSelections = {}
for (const [clientNavigationId, statesByWorktree] of this.statesByClient) {
const entries: Record<string, ClientSessionTabSelection> = {}
for (const [worktreeId, state] of statesByWorktree) {
if (state.shouldPersist) {
entries[worktreeId] = state.selection
}
}
if (Object.keys(entries).length > 0) {
persisted[clientNavigationId] = entries
}
}
return persisted
}
private persistNow(): void {
this.persistListener?.(this.serialize())
}
private getStatesByWorktree(
clientNavigationId: string
): Map<string, StoredClientSessionTabSelection> {
let statesByWorktree = this.statesByClient.get(clientNavigationId)
if (!statesByWorktree) {
statesByWorktree = new Map()
this.statesByClient.set(clientNavigationId, statesByWorktree)
}
return statesByWorktree
}
project(
snapshot: RuntimeMobileSessionTabsResult,
clientNavigationId?: string
): RuntimeMobileSessionTabsResult {
if (!clientNavigationId) {
return snapshot
}
const statesByWorktree = this.getStatesByWorktree(clientNavigationId)
const existingState = statesByWorktree.get(snapshot.worktree)
const state = existingState ?? {
// Why: host focus is private navigation; a new paired device starts from deterministic topology instead of inheriting it.
selection: emptyClientSessionTabSelection(),
revision: 0,
shouldPersist: false,
closedTabIds: new Set<string>()
}
const closed = projectWithoutClosedClientSessionTabs(snapshot, state.closedTabIds)
if (closed.snapshot.tabs.length === 0) {
if (existingState) {
statesByWorktree.set(snapshot.worktree, {
...state,
closedTabIds: closed.retainedClosedTabIds
})
}
// Why: an empty snapshot has no topology to project; writing it back would wipe a restart-hydrated selection before tabs arrive.
return {
...closed.snapshot,
publicationEpoch: `${snapshot.publicationEpoch}${CLIENT_NAVIGATION_PUBLICATION_EPOCH_SUFFIX}`,
snapshotVersion: snapshot.snapshotVersion + state.revision
}
}
const projected = projectClientSessionTabSelection(closed.snapshot, state.selection)
// Why: a browser guest process swap drops its tab for one snapshot; the topology fallback
// must not overwrite (and persist) the device's explicit pick, or focus never returns.
const selectionSurvived =
!state.selection.activeTabId ||
closed.snapshot.tabs.some((tab) => tab.id === state.selection.activeTabId)
statesByWorktree.set(snapshot.worktree, {
selection: selectionSurvived ? projected.selection : state.selection,
revision: state.revision,
shouldPersist: state.shouldPersist,
closedTabIds: closed.retainedClosedTabIds
})
return {
...projected.snapshot,
publicationEpoch: `${snapshot.publicationEpoch}${CLIENT_NAVIGATION_PUBLICATION_EPOCH_SUFFIX}`,
snapshotVersion: snapshot.snapshotVersion + state.revision
}
}
activate(
snapshot: RuntimeMobileSessionTabsResult,
clientNavigationId: string,
activeTabId: string
): RuntimeMobileSessionTabsResult {
const statesByWorktree = this.getStatesByWorktree(clientNavigationId)
const state = statesByWorktree.get(snapshot.worktree) ?? {
selection: emptyClientSessionTabSelection(),
revision: 0,
shouldPersist: false,
closedTabIds: new Set<string>()
}
const nextSelection = activateClientSessionTabSelection(snapshot, state.selection, activeTabId)
const activeTab = snapshot.tabs.find((tab) => tab.id === activeTabId)
const closedTabIds = new Set(state.closedTabIds)
closedTabIds.delete(activeTabId)
if (activeTab) {
closedTabIds.delete(topLevelTabId(activeTab))
}
statesByWorktree.set(snapshot.worktree, {
selection: nextSelection,
revision: state.revision + 1,
shouldPersist: true,
closedTabIds
})
this.persistNow()
return this.project(snapshot, clientNavigationId)
}
forgetTabs(worktreeId: string, tabIds: readonly string[]): void {
if (tabIds.length === 0) {
return
}
if (forgetClosedClientSessionTabsByWorktree(this.statesByClient, worktreeId, tabIds)) {
this.persistNow()
}
}
forgetClient(clientNavigationId: string): void {
const statesByWorktree = this.statesByClient.get(clientNavigationId)
const hadPersistedState = [...(statesByWorktree?.values() ?? [])].some(
(state) => state.shouldPersist
)
if (this.statesByClient.delete(clientNavigationId) && hadPersistedState) {
this.persistNow()
}
}
migrateWorktree(oldWorktreeId: string, newWorktreeId: string): void {
if (oldWorktreeId === newWorktreeId) {
return
}
let changed = false
for (const statesByWorktree of this.statesByClient.values()) {
const state = statesByWorktree.get(oldWorktreeId)
if (!state) {
continue
}
statesByWorktree.set(newWorktreeId, state)
statesByWorktree.delete(oldWorktreeId)
changed = state.shouldPersist || changed
}
if (changed) {
this.persistNow()
}
}
forgetWorktree(worktreeId: string): void {
let changed = false
for (const [clientNavigationId, statesByWorktree] of this.statesByClient) {
const state = statesByWorktree.get(worktreeId)
changed = Boolean(state?.shouldPersist) || changed
statesByWorktree.delete(worktreeId)
if (statesByWorktree.size === 0) {
this.statesByClient.delete(clientNavigationId)
}
}
if (changed) {
this.persistNow()
}
}
}