Files
orca/src/main/runtime/workspace-layout-projection.ts
T
Jinwoo HongandE2E Test a2e0f20b1e Main sends one layout update per action, with only what changed (dark, layout-owner build only) (#31109)
* A respawned terminal's pane rebinds in one write, and the window shows a binding-only frame without a rebuild (dark, layout-owner build only)

Main: when the window's spawn for a restored pane finds its process gone, the spawn that holds
the pane's reservation keeps the dead binding and its fresh bind swaps it (the restart swap), so
the pane is never published unbound: a same-id respawn publishes no frame, a new id one. A failed
spawn still retires the dead binding. Other callers and window-owned builds retire early as before.

Window: a frame that differs from the shown one only in panes' terminal bindings updates the
store's pane trees as a rebuild would, falling back to a rebuild for holds and shared terminals.

* Dead-owner respawn tests expect the layout-owner build's one swapped bind instead of the early retire

* Main publishes one records frame per layout operation, and the window resubscribes on a missed frame (dark, layout-owner build only)

Main: a durable mutation (a layout.apply, the renameTabs batch, a binding write, a repair) is
one operation. It names the partitions it wrote, and at its end the layout stream publishes
once: one `records` frame holding only the changed records (tab rows, workspace groups, a pane
tree per tab, a binding record per pane, editor files, browser tabs, removed workspaces), each
keyed by main's own ids. Writes no operation scopes (window saves) still publish when their
task ends. The snapshot and every records frame carry a stream epoch. Each pane's binding
carries `ptyGeneration`, which rises on every binding write in this process, so a same-id
respawn is one frame with one binding record. Readers beside the stream get the end of each
operation and the stream's own per-partition loaded model (`workspaceLayoutReads`), so they
need no second Loader pass, with or without a window.

Window: the cache applies records frames to its full layouts, so the existing flush and the
binding-only path are unchanged. A records frame of another epoch or out of sequence, a
malformed frame, or one the cache cannot apply resubscribes at once for a fresh snapshot.

Tests install main's model check, which applies each frame to what subscribers held and
compares it with a fresh projection; dev builds log a mismatch, release builds run none.

* E1 review fixes: keep a swapped binding, bound the window's resyncs, guard operation listeners (dark, layout-owner build only)

- A spawn whose swap bind persisted no longer retires that pane's binding when a later commit
  step fails; a pane on an SSH host keeps the early retire, since its dead owner lives there.
- The window resubscribes at once at most three times before a frame applies cleanly, then takes
  the loss path's backoff. A skipped frame of a newer type still advances its position.
- A throwing operation listener is logged; it no longer fails a committed mutation.
- Tests freeze the shared partition model a reader gets; the dev-build model check skips frames
  that only change live titles. Generation and model-check comments say what is true.

* Keep the layout stream's backoff reset after a single resync; only a resync storm holds it

* A layout stream that recovers from a resync storm reopens on the normal delay again

* Move the replaced pane owner into its own module so the stable owner no longer imports its replacement (dark, layout-owner build only)

---------

Co-authored-by: E2E Test <e2e@test.local>
2026-10-20 08:19:53 -04:00

198 lines
7.4 KiB
TypeScript

// Each workspace's published layout, projected from the Store's session partitions: a partition
// is re-run through the Loader only when a write may have changed it, and each workspace is
// published from the partition that owns it.
import type { ExecutionHostId } from '../../shared/execution-host'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../shared/layout-owner-switch'
import { loadWorkspaceLayoutWithoutReport } from '../../shared/workspace-layout/workspace-layout-load'
import { stableJson } from '../../shared/workspace-layout/workspace-layout-load-report'
import type {
LoadedWorkspaceLayout,
WorkspaceLayoutLoadContext
} from '../../shared/workspace-layout/workspace-layout-load-types'
import { nameBasedLoadContext } from '../../shared/workspace-layout/workspace-layout-minted-ids'
import {
publishWorkspaceLayout,
type PublishedWorkspaceLayout
} from '../../shared/workspace-layout/workspace-layout-published'
import type { RuntimeStore } from './runtime-store-contract'
export type ProjectedLayout = { layout: PublishedWorkspaceLayout; json: string }
export type ProjectionStore = Pick<
RuntimeStore,
'getWorkspaceSession' | 'getWorkspaceSessionHostIds' | 'getPtyBindingGeneration'
>
/** Partitions to re-project; 'all' when a write did not name its partition. */
export type DirtyPartitions = Set<ExecutionHostId> | 'all'
/** The context main loads a partition with: its panes' binding generations ride the leaves. */
export function runtimeLayoutLoadContext(
store: Pick<RuntimeStore, 'getPtyBindingGeneration'> | null,
hostId: ExecutionHostId
): WorkspaceLayoutLoadContext {
const generationOf = store?.getPtyBindingGeneration
// Switch point: a window-owned build publishes no generation.
return ORCA_LAYOUT_OWNER_IS_MAIN && generationOf
? {
...nameBasedLoadContext(),
ptyGenerationOf: (leafId) => generationOf.call(store, hostId, leafId)
}
: nameBasedLoadContext()
}
/** One partition as loaded once: its model, and each workspace published from it on first ask. */
class ProjectedPartition {
private published: Map<string, ProjectedLayout> | null = null
constructor(
private readonly hostId: ExecutionHostId,
readonly loaded: LoadedWorkspaceLayout | null
) {}
/** Every workspace it holds, its own copy of a workspace it does not own included. */
workspaces(): Map<string, ProjectedLayout> {
if (!this.published) {
this.published = new Map()
for (const [key, workspace] of Object.entries(this.loaded?.layout.workspaces ?? {})) {
const published = publishWorkspaceLayout(workspace, this.hostId)
this.published.set(key, { layout: published, json: stableJson(published) })
}
}
return this.published
}
}
export class WorkspaceLayoutProjection {
/** Workspace key → its published layout, with its JSON for the by-value comparison. */
published = new Map<string, ProjectedLayout>()
private partitions = new Map<ExecutionHostId, ProjectedPartition>()
/** Partitions a write may have changed since they were loaded. */
private stale: DirtyPartitions = 'all'
/** Its own flag, so a routing warning never hides a later projection or listener failure. */
private routingFailureLogged = false
constructor(
private readonly deps: {
store: () => ProjectionStore | null
/** The partition that owns a workspace key; null when none does. */
homeHostId: (key: string) => ExecutionHostId | null
}
) {}
/** Forgets every projection: the next read re-loads every partition. */
clear(): void {
this.published.clear()
this.forgetPartitions()
}
/** After a failed projection too: the next read re-loads every partition. */
forgetPartitions(): void {
this.partitions.clear()
this.stale = 'all'
}
/** A write may have changed `hostId`'s partition; null: any partition. */
invalidate(hostId: ExecutionHostId | null): void {
if (hostId === null || this.stale === 'all') {
this.stale = 'all'
} else {
this.stale.add(hostId)
}
}
/**
* One partition's loaded model, re-loaded only if a write may have changed it; null with no
* Store. Only a reader that hears every write (`invalidate`) may `keep` it for the next read.
*/
loaded(hostId: ExecutionHostId, keep: boolean): LoadedWorkspaceLayout | null {
return keep ? this.cachedPartition(hostId).loaded : this.loadPartition(hostId).loaded
}
/** Every workspace as published, each stale partition re-loaded; becomes `published`. */
project(): Map<string, ProjectedLayout> {
const hostIds = this.deps.store()?.getWorkspaceSessionHostIds?.() ?? []
const partitions = new Map(hostIds.map((hostId) => [hostId, this.cachedPartition(hostId)]))
this.partitions = partitions
this.published = this.route(partitions)
return this.published
}
/** What a projection of every partition from scratch publishes now; changes no cache. */
projectFresh(): Map<string, ProjectedLayout> {
const hostIds = this.deps.store()?.getWorkspaceSessionHostIds?.() ?? []
return this.route(new Map(hostIds.map((hostId) => [hostId, this.loadPartition(hostId)])))
}
private cachedPartition(hostId: ExecutionHostId): ProjectedPartition {
if (this.stale === 'all') {
this.stale = new Set(this.deps.store()?.getWorkspaceSessionHostIds?.() ?? [])
this.partitions.clear()
}
const cached = this.stale.has(hostId) ? undefined : this.partitions.get(hostId)
if (cached) {
return cached
}
const loaded = this.loadPartition(hostId)
this.partitions.set(hostId, loaded)
this.stale.delete(hostId)
return loaded
}
private loadPartition(hostId: ExecutionHostId): ProjectedPartition {
const store = this.deps.store()
const session = store?.getWorkspaceSession?.(hostId)
return new ProjectedPartition(
hostId,
session
? loadWorkspaceLayoutWithoutReport(hostId, session, runtimeLayoutLoadContext(store, hostId))
: null
)
}
private route(
partitions: Map<ExecutionHostId, ProjectedPartition>
): Map<string, ProjectedLayout> {
const projected = new Map<string, ProjectedLayout>()
const unroutable = new Set<string>()
for (const [hostId, partition] of partitions) {
for (const [key, workspace] of partition.workspaces()) {
// Only the owning partition's copy is published; a stray copy elsewhere is not shown.
if (this.homeOf(key, unroutable) === hostId) {
projected.set(key, workspace)
}
}
}
for (const key of unroutable) {
const kept = this.published.get(key)
// Kept as last published: a 'removed' frame would make the window drop its tabs.
if (kept && !projected.has(key)) {
projected.set(key, kept)
}
}
return projected
}
/**
* A workspace the router throws on is not guessed (`repoId::path` repeats across hosts, so a
* default could publish another host's copy); it is noted in `unroutable`. It never stops the rest.
*/
private homeOf(key: string, unroutable: Set<string>): ExecutionHostId | null {
try {
return this.deps.homeHostId(key)
} catch (error) {
// Switch point: a window-owned build keeps failing the whole projection, as today.
if (!ORCA_LAYOUT_OWNER_IS_MAIN) {
throw error
}
unroutable.add(key)
if (!this.routingFailureLogged) {
this.routingFailureLogged = true
console.warn('[workspace-layout] layout stream routing failed:', error)
}
return null
}
}
}