perf(layout): re-project only the written partition in the layout stream (#28845)

The layout stream (layout.subscribe, still unused) re-loaded every session
partition and ran the load's save-and-diff report after each write. A save
now names the partition it wrote, the stream re-loads only that partition
without the report, and the renderer layout client no longer re-subscribes
on a timer. Adds a latency spec variant with a subscriber attached.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jinwoo Hong
2026-10-14 17:13:29 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent b024043feb
commit 23103e511a
13 changed files with 383 additions and 155 deletions
@@ -314,6 +314,20 @@ describe('Store host-partitioned workspace sessions', () => {
expect(store.getWorkspaceSession('local').activeRepoId).toBe('repo-local')
})
it('names the one partition each session write changed', async () => {
const store = await createStore()
store.setWorkspaceSession(makeHostSession('repo-a'), 'runtime:env-a')
const written: (string | null)[] = []
store.onWorkspaceSessionWritten((hostId) => written.push(hostId))
store.setWorkspaceSession(makeHostSession('repo-local'), 'local')
store.setWorkspaceSession(makeHostSession('repo-a2'), 'runtime:env-a')
store.patchWorkspaceSession({ activeTabId: 'tab-a' }, 'runtime:env-a')
store.removeWorkspaceSessionHost('runtime:env-a')
expect(written).toEqual(['local', 'runtime:env-a', 'runtime:env-a', 'runtime:env-a'])
})
it('patches a single host partition without touching the others', async () => {
const store = await createStore()
store.setWorkspaceSession(makeHostSession('repo-local'), 'local')
@@ -93,9 +93,11 @@ export class SessionHostPartitionOperations {
const { [hostId]: _removed, ...remaining } = partitions
runtime.state.workspaceSessionsByHostId = remaining
invalidateLocalWorktreeMetadataPruneInputs()
scheduleSave(this[sessionHostPartitionOperationsContext].scheduling, [
'workspaceSessionsByHostId'
])
scheduleSave(
this[sessionHostPartitionOperationsContext].scheduling,
['workspaceSessionsByHostId'],
hostId
)
}
readTerminalScrollbackSnapshot(ref: string): string | null {
@@ -170,7 +172,8 @@ export function removeWorkspaceSessionOwnerInPartition(
}
scheduleSave(
owner[sessionHostPartitionOperationsContext].scheduling,
resolved === LOCAL_EXECUTION_HOST_ID ? ['workspaceSession'] : ['workspaceSessionsByHostId']
resolved === LOCAL_EXECUTION_HOST_ID ? ['workspaceSession'] : ['workspaceSessionsByHostId'],
resolved
)
}
@@ -234,9 +237,11 @@ export function setHostWorkspaceSession(
...owner[sessionHostPartitionOperationsContext].runtime.state.workspaceSessionsByHostId,
[hostId]: withRequiredWorkspaceSessionMaps(orderTerminalRowsByTabGroups(pruned))
}
scheduleSave(owner[sessionHostPartitionOperationsContext].scheduling, [
'workspaceSessionsByHostId'
])
scheduleSave(
owner[sessionHostPartitionOperationsContext].scheduling,
['workspaceSessionsByHostId'],
hostId
)
}
/**
@@ -128,7 +128,8 @@ export class SessionSnapshotOperations {
}
scheduleSave(
scheduling,
hostId === LOCAL_EXECUTION_HOST_ID ? ['workspaceSession'] : ['workspaceSessionsByHostId']
hostId === LOCAL_EXECUTION_HOST_ID ? ['workspaceSession'] : ['workspaceSessionsByHostId'],
hostId
)
}
@@ -1,6 +1,7 @@
import { removeStaleDurableWriteTempFiles } from '../../durable-file-write'
import type { GlobalSettings } from '../../../shared/global-settings-types'
import type { PersistedState } from '../../../shared/persisted-state-types'
import type { WorkspaceSessionWriteListener } from './workspace-session-write-listeners'
import type { ActiveViewPreference } from '../../active-view-preference'
import {
getProfileTerminalScrollbackSnapshotRoot,
@@ -84,7 +85,7 @@ export class StoreRuntimeState {
>()
uiChangeListeners = new Set<(ui: PersistedState['ui']) => void>()
/** Observers of persisted workspace-session writes; see notifyWorkspaceSessionWritten. */
readonly workspaceSessionWriteListeners = new Set<() => void>()
readonly workspaceSessionWriteListeners = new Set<WorkspaceSessionWriteListener>()
projectHostOperations: ProjectHostPersistenceOperations | null = null
projectGroupOperations: ProjectGroupPersistenceOperations | null = null
folderWorkspaceOperations: FolderWorkspacePersistenceOperations | null = null
@@ -124,11 +124,12 @@ export function setLocalWorkspaceSession(
remappedManualUnread.changed
) {
// UI remaps include protected fields, so keep the complete serializer boundary.
scheduleSave(context.scheduling)
scheduleSave(context.scheduling, undefined, LOCAL_EXECUTION_HOST_ID)
} else {
scheduleSave(
context.scheduling,
remappedLeases.changed ? ['workspaceSession', 'sshRemotePtyLeases'] : ['workspaceSession']
remappedLeases.changed ? ['workspaceSession', 'sshRemotePtyLeases'] : ['workspaceSession'],
LOCAL_EXECUTION_HOST_ID
)
}
}
@@ -1,19 +1,27 @@
import type { ExecutionHostId } from '../../../shared/execution-host'
import type { StoreRuntimeState } from './store-runtime-state'
/** Gets the one partition a write changed, or null when any partition may have changed. */
export type WorkspaceSessionWriteListener = (hostId: ExecutionHostId | null) => void
const WORKSPACE_SESSION_DOMAINS: readonly string[] = [
'workspaceSession',
'workspaceSessionsByHostId'
]
/** `dirtyDomains` undefined means a full save, which may carry an in-place session edit. */
/**
* `dirtyDomains` undefined means a full save, which may carry an in-place session edit.
* `hostId` names the only partition the write changed; without it every partition is suspect.
*/
export function notifyWorkspaceSessionWritten(
runtime: Pick<StoreRuntimeState, 'workspaceSessionWriteListeners'>,
dirtyDomains?: readonly string[]
dirtyDomains?: readonly string[],
hostId?: ExecutionHostId
): void {
if (dirtyDomains && !dirtyDomains.some((domain) => WORKSPACE_SESSION_DOMAINS.includes(domain))) {
return
}
for (const listener of runtime.workspaceSessionWriteListeners) {
listener()
listener(hostId ?? null)
}
}
@@ -1,7 +1,11 @@
import type { StoreRuntimeState } from './store-runtime-state'
import type { PrimaryStateWriteOperations } from './primary-state-writes'
import { enqueueWrite } from './primary-state-writes'
import { notifyWorkspaceSessionWritten } from './workspace-session-write-listeners'
import type { ExecutionHostId } from '../../../shared/execution-host'
import {
notifyWorkspaceSessionWritten,
type WorkspaceSessionWriteListener
} from './workspace-session-write-listeners'
const SAVE_DEBOUNCE_MS = 1_000
const SAVE_MAX_WAIT_MS = 5_000
@@ -40,7 +44,7 @@ export class WriteSchedulingOperations {
}
/** Called after each in-memory workspace-session write; listeners must not throw or block. */
onWorkspaceSessionWritten(listener: () => void): () => void {
onWorkspaceSessionWritten(listener: WorkspaceSessionWriteListener): () => void {
const { runtime } = this[writeSchedulingOperationsContext]
runtime.workspaceSessionWriteListeners.add(listener)
return () => {
@@ -49,12 +53,14 @@ export class WriteSchedulingOperations {
}
}
/** `writtenHostId` names the one session partition this save changed, if it changed only one. */
export function scheduleSave(
owner: WriteSchedulingOperations,
dirtyDomains?: readonly string[]
dirtyDomains?: readonly string[],
writtenHostId?: ExecutionHostId
): void {
const { runtime, writes } = owner[writeSchedulingOperationsContext]
notifyWorkspaceSessionWritten(runtime, dirtyDomains)
notifyWorkspaceSessionWritten(runtime, dirtyDomains, writtenHostId)
runtime.automationListProjectionCache = null
const trackedDomains = runtime.dirtyProfileStateDomains
if (dirtyDomains === undefined) {
@@ -3,39 +3,77 @@ import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/exec
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
import {
localDesktopSession,
relaySshSession
relaySshSession,
serverRuntimeSession
} from '../../shared/workspace-layout/workspace-layout-profile.test-fixture'
import {
emptySession,
FOLDER_KEY,
GIT_KEY,
SERVER_KEY,
SSH_KEY
} from '../../shared/workspace-layout/workspace-layout-session.test-fixture'
import type { WorkspaceLayoutEvent } from '../../shared/workspace-layout/workspace-layout-stream-frames'
import { loadWorkspaceLayout } from '../../shared/workspace-layout/workspace-layout-load'
import { nameBasedLoadContext } from '../../shared/workspace-layout/workspace-layout-minted-ids'
import {
publishWorkspaceLayout,
type PublishedWorkspaceLayout
} from '../../shared/workspace-layout/workspace-layout-published'
import { WorkspaceLayoutStream } from './workspace-layout-stream'
const SSH_HOST: ExecutionHostId = 'ssh:target-1'
const SERVER_HOST: ExecutionHostId = 'runtime:env-1'
function fakeStore(partitions: Map<ExecutionHostId, WorkspaceSessionState>) {
const listeners = new Set<() => void>()
const onWorkspaceSessionWritten = vi.fn((listener: () => void) => {
const listeners = new Set<(hostId: ExecutionHostId | null) => void>()
const onWorkspaceSessionWritten = vi.fn((listener: (hostId: ExecutionHostId | null) => void) => {
listeners.add(listener)
return () => listeners.delete(listener)
})
const getWorkspaceSession = vi.fn((hostId: ExecutionHostId) => partitions.get(hostId)!)
return {
store: {
getWorkspaceSession: (hostId: ExecutionHostId) => partitions.get(hostId)!,
getWorkspaceSession,
getWorkspaceSessionHostIds: () => [...partitions.keys()],
onWorkspaceSessionWritten
},
listeners,
/** A save that names the partition it wrote. */
write(hostId: ExecutionHostId, session: WorkspaceSessionState) {
partitions.set(hostId, session)
listeners.forEach((listener) => listener())
listeners.forEach((listener) => listener(hostId))
},
/** A save that names no partition, so every partition may have changed. */
writeUnnamed(hostId: ExecutionHostId, session: WorkspaceSessionState) {
partitions.set(hostId, session)
listeners.forEach((listener) => listener(null))
},
/** A removed host's partition, named by the save that drops it. */
removePartition(hostId: ExecutionHostId) {
partitions.delete(hostId)
listeners.forEach((listener) => listener(hostId))
}
}
}
/** Today's projection: every partition loaded with its save-and-diff report, then filtered by home. */
function fullProjection(
partitions: Map<ExecutionHostId, WorkspaceSessionState>,
homeHostId: (key: string) => ExecutionHostId | null
): [string, PublishedWorkspaceLayout][] {
const projected = new Map<string, PublishedWorkspaceLayout>()
for (const [hostId, session] of partitions) {
const { layout } = loadWorkspaceLayout(hostId, session, nameBasedLoadContext())
for (const [key, workspace] of Object.entries(layout.workspaces)) {
if (homeHostId(key) === hostId) {
projected.set(key, publishWorkspaceLayout(workspace, hostId))
}
}
}
return [...projected]
}
function setup() {
const partitions = new Map<ExecutionHostId, WorkspaceSessionState>([
[LOCAL_EXECUTION_HOST_ID, localDesktopSession()],
@@ -51,7 +89,7 @@ function setup() {
store: () => fake.store,
homeHostId: (key) => homes[key] ?? null
})
return { ...fake, stream, homes }
return { ...fake, stream, homes, partitions }
}
const flush = () => new Promise<void>((resolve) => queueMicrotask(resolve))
@@ -199,4 +237,86 @@ describe('WorkspaceLayoutStream', () => {
expect(JSON.stringify(later.tabs)).toBe(JSON.stringify(earlier.tabs.slice(1)))
}
})
it('re-projects only the partition a write names', async () => {
const { stream, store, write, writeUnnamed } = setup()
stream.subscribe(() => {})
store.getWorkspaceSession.mockClear()
write(SSH_HOST, relaySshSession('target-1'))
await flush()
expect(store.getWorkspaceSession.mock.calls).toEqual([[SSH_HOST]])
store.getWorkspaceSession.mockClear()
writeUnnamed(SSH_HOST, relaySshSession('target-1'))
await flush()
expect(store.getWorkspaceSession.mock.calls).toEqual([[LOCAL_EXECUTION_HOST_ID], [SSH_HOST]])
})
it('publishes what a full re-projection with the load report would, write after write', async () => {
const { stream, write, writeUnnamed, removePartition, homes, partitions } = setup()
const homeHostId = (key: string) => homes[key] ?? null
let cache = new Map<string, PublishedWorkspaceLayout>()
const { snapshot } = stream.subscribe((event) => {
if (event.type === 'removed') {
cache.delete(event.key)
} else {
cache.set(event.key, event.layout)
}
})
cache = new Map(snapshot.map((entry) => [entry.key, entry.layout]))
const expectFullProjection = async (): Promise<void> => {
await flush()
const expected = fullProjection(partitions, homeHostId)
expect(Object.fromEntries(cache)).toEqual(Object.fromEntries(expected))
// A late subscriber's snapshot keeps today's order too.
const late = stream.subscribe(() => {})
expect(late.snapshot.map((entry) => [entry.key, entry.layout])).toEqual(expected)
late.unsubscribe()
}
await expectFullProjection()
const local = localDesktopSession()
local.tabGroups![GIT_KEY]![0]!.tabOrder.reverse()
write(LOCAL_EXECUTION_HOST_ID, local)
await expectFullProjection()
// A named write and an unnamed one in the same task.
const ssh = relaySshSession('target-1')
delete ssh.tabsByWorktree[SSH_KEY]
delete ssh.unifiedTabs![SSH_KEY]
write(SSH_HOST, ssh)
writeUnnamed(LOCAL_EXECUTION_HOST_ID, localDesktopSession())
await expectFullProjection()
// A new partition, a home that moves between partitions, then the partition removed.
homes[SERVER_KEY] = SERVER_HOST
write(SERVER_HOST, serverRuntimeSession('env-1'))
await expectFullProjection()
homes[GIT_KEY] = SERVER_HOST
write(SSH_HOST, relaySshSession('target-1'))
await expectFullProjection()
homes[GIT_KEY] = LOCAL_EXECUTION_HOST_ID
removePartition(SERVER_HOST)
await expectFullProjection()
})
it('re-projects every partition after a failed projection', async () => {
const { stream, store, write } = setup()
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const events: WorkspaceLayoutEvent[] = []
stream.subscribe((event) => events.push(event))
store.getWorkspaceSession.mockImplementationOnce(() => {
throw new Error('read failed')
})
const local = localDesktopSession()
local.tabGroups![GIT_KEY]![0]!.tabOrder.reverse()
write(LOCAL_EXECUTION_HOST_ID, local)
await flush()
expect(events).toEqual([])
// The local edit the failed projection missed is published by a write elsewhere.
write(SSH_HOST, relaySshSession('target-1'))
await flush()
expect(events.map((event) => [event.type, event.key])).toEqual([['workspace', GIT_KEY]])
expect(warn).toHaveBeenCalledTimes(1)
warn.mockRestore()
})
})
+58 -28
View File
@@ -1,9 +1,10 @@
// The read-only layout stream behind `layout.subscribe`: each workspace's published layout,
// re-projected from the Store's sessions after every write while anyone subscribes. Nothing reads
// it until a client opts in; with no subscriber it does no work.
// re-projected from the Store's sessions after every write while anyone subscribes. A write that
// names its partition re-projects only that partition. Nothing reads it until a client opts in;
// with no subscriber it does no work.
import type { ExecutionHostId } from '../../shared/execution-host'
import { loadWorkspaceLayout } from '../../shared/workspace-layout/workspace-layout-load'
import { loadWorkspaceLayoutWithoutReport } from '../../shared/workspace-layout/workspace-layout-load'
import { stableJson } from '../../shared/workspace-layout/workspace-layout-load-report'
import {
publishWorkspaceLayout,
@@ -26,8 +27,11 @@ export class WorkspaceLayoutStream {
private readonly listeners = new Set<LayoutListener>()
/** Workspace key → its published layout, with its JSON for the by-value comparison. */
private published = new Map<string, Published>()
/** Partition → every workspace it holds, its own copy of a workspace it does not own included. */
private projectedPartitions = new Map<ExecutionHostId, Map<string, Published>>()
private stopWatching: (() => void) | null = null
private dirty = false
/** Partitions to re-project at the next flush; 'all' when a write did not name its partition. */
private dirty: Set<ExecutionHostId> | 'all' | null = null
private failureLogged = false
constructor(
@@ -45,9 +49,10 @@ export class WorkspaceLayoutStream {
} {
if (this.listeners.size === 0) {
this.stopWatching =
this.deps.store()?.onWorkspaceSessionWritten?.(() => this.markDirty()) ?? null
this.deps.store()?.onWorkspaceSessionWritten?.((hostId) => this.markDirty(hostId)) ?? null
this.published = new Map()
this.dirty = true
this.projectedPartitions = new Map()
this.dirty = 'all'
}
// A write not yet published reaches existing listeners first, so the snapshot is current.
this.flush()
@@ -62,28 +67,38 @@ export class WorkspaceLayoutStream {
this.stopWatching?.()
this.stopWatching = null
this.published.clear()
this.projectedPartitions.clear()
}
}
}
private markDirty(): void {
if (this.dirty) {
return
private markDirty(hostId: ExecutionHostId | null): void {
const scheduled = this.dirty !== null
if (hostId === null) {
this.dirty = 'all'
} else if (this.dirty === null) {
this.dirty = new Set([hostId])
} else if (this.dirty !== 'all') {
this.dirty.add(hostId)
}
if (!scheduled) {
// Coalesces a burst of writes in one task into one projection.
queueMicrotask(() => this.flush())
}
this.dirty = true
// Coalesces a burst of writes in one task into one projection.
queueMicrotask(() => this.flush())
}
private flush(): void {
if (!this.dirty) {
const dirty = this.dirty
if (dirty === null) {
return
}
this.dirty = false
this.dirty = null
// A failed projection is logged once; it never reaches the write that notified it.
try {
this.reconcile()
this.reconcile(dirty)
} catch (error) {
// The cache may be part-updated; the next write re-projects every partition.
this.projectedPartitions.clear()
this.logFailure('projection', error)
}
}
@@ -95,28 +110,43 @@ export class WorkspaceLayoutStream {
}
}
private project(): Map<string, Published> {
const store = this.deps.store()
private projectPartition(hostId: ExecutionHostId): Map<string, Published> {
const projected = new Map<string, Published>()
for (const hostId of store?.getWorkspaceSessionHostIds?.() ?? []) {
const session = store?.getWorkspaceSession?.(hostId)
if (!session) {
continue
}
const { layout } = loadWorkspaceLayout(hostId, session, nameBasedLoadContext())
for (const [key, workspace] of Object.entries(layout.workspaces)) {
const session = this.deps.store()?.getWorkspaceSession?.(hostId)
if (!session) {
return projected
}
const { layout } = loadWorkspaceLayoutWithoutReport(hostId, session, nameBasedLoadContext())
for (const [key, workspace] of Object.entries(layout.workspaces)) {
const published = publishWorkspaceLayout(workspace, hostId)
projected.set(key, { layout: published, json: stableJson(published) })
}
return projected
}
private project(dirty: Set<ExecutionHostId> | 'all'): Map<string, Published> {
const hostIds = this.deps.store()?.getWorkspaceSessionHostIds?.() ?? []
const partitions = new Map<ExecutionHostId, Map<string, Published>>()
for (const hostId of hostIds) {
const cached =
dirty === 'all' || dirty.has(hostId) ? undefined : this.projectedPartitions.get(hostId)
partitions.set(hostId, cached ?? this.projectPartition(hostId))
}
this.projectedPartitions = partitions
const projected = new Map<string, Published>()
for (const [hostId, workspaces] of partitions) {
for (const [key, workspace] of workspaces) {
// Only the owning partition's copy is published; a stray copy elsewhere is not shown.
if (this.deps.homeHostId(key) === hostId) {
const layout = publishWorkspaceLayout(workspace, hostId)
projected.set(key, { layout, json: stableJson(layout) })
projected.set(key, workspace)
}
}
}
return projected
}
private reconcile(): void {
const next = this.project()
private reconcile(dirty: Set<ExecutionHostId> | 'all'): void {
const next = this.project(dirty)
const events: WorkspaceLayoutEvent[] = []
for (const key of this.published.keys()) {
if (!next.has(key)) {
@@ -142,15 +142,6 @@ describe.each(targets)('runtime layout client over the %s transport', (_name, ta
return host.streams[count - 1]!
}
/** Whether a retry opens exactly `delayMs` after the previous loss. */
async function expectRetryAfter(delayMs: number): Promise<FakeStream> {
const count = host.streams.length
await vi.advanceTimersByTimeAsync(delayMs - 1)
expect(host.streams).toHaveLength(count)
await vi.advanceTimersByTimeAsync(1)
return opened(count + 1)
}
function snapshot(workspaces: { key: string; layout: PublishedWorkspaceLayout }[] = []) {
return { type: 'snapshot', subscriptionId: 'layout-1', workspaces }
}
@@ -186,7 +177,7 @@ describe.each(targets)('runtime layout client over the %s transport', (_name, ta
expect(stream.unsubscribed).toBe(false)
})
it('after a lost stream, resubscribes and the new snapshot replaces the cache', async () => {
it('after a lost stream, keeps the last state and never reopens it', async () => {
subscribe()
const first = await opened(1)
first.send(
@@ -197,36 +188,27 @@ describe.each(targets)('runtime layout client over the %s transport', (_name, ta
)
first.drop()
expect(first.unsubscribed).toBe(true)
// The last state stays until the runtime sends a new one; nothing is cleared or guessed.
expect(cache).toEqual({ a: layout('a', ['t1', 't2']), b: layout('b') })
const second = await expectRetryAfter(250)
// A late frame from the dropped stream changes nothing.
expect(status).toEqual({ state: 'lost' })
await vi.advanceTimersByTimeAsync(60_000)
expect(host.streams).toHaveLength(1)
// The last state stays; a late frame from the dropped stream changes nothing.
first.send({ type: 'removed', key: 'a' })
expect(cache).toHaveProperty('a')
second.send(snapshot([{ key: 'a', layout: layout('a', ['t2']) }]))
expect(cache).toEqual({ a: layout('a', ['t2']) })
expect(cache).toEqual({ a: layout('a', ['t1', 't2']), b: layout('b') })
})
it('backs off on failed subscribes and error frames, and a snapshot alone does not reset it', async () => {
host.rejectNextSubscribe()
subscribe()
await vi.advanceTimersByTimeAsync(0)
expect(host.streams).toHaveLength(0)
const first = await expectRetryAfter(250)
first.fail('stream failed')
expect(first.unsubscribed).toBe(true)
// A host that snapshots then drops at once keeps backing off instead of looping fast.
let stream = await expectRetryAfter(500)
for (const delay of [1000, 2000, 4000, 5000, 5000]) {
stream.send(snapshot())
stream.drop()
stream = await expectRetryAfter(delay)
}
// A change after the snapshot proves the stream stayed up; the next loss starts over.
stream.send(snapshot())
stream.send({ type: 'workspace', key: 'a', layout: layout('a') })
stream.drop()
await expectRetryAfter(250)
it.each([
['a failed subscribe', () => host.rejectNextSubscribe(), async () => {}],
['an error frame', () => {}, async () => (await opened(1)).fail('stream failed')]
])('stops after %s without retrying', async (_case, before, fail) => {
before()
const subscription = subscribe()
await fail()
await vi.advanceTimersByTimeAsync(60_000)
expect(status).toEqual({ state: 'lost' })
expect(host.streams.length).toBeLessThanOrEqual(1)
expect(host.streams.every((stream) => stream.unsubscribed)).toBe(true)
subscription.close()
expect(status).toEqual({ state: 'lost' })
})
it.each([
@@ -260,7 +242,7 @@ describe.each(targets)('runtime layout client over the %s transport', (_name, ta
expect(status).toMatchObject({ state: 'refused', code: 'method_not_found' })
})
it('close unsubscribes, stops retrying and ignores later frames', async () => {
it('close unsubscribes and ignores later frames', async () => {
const subscription = subscribe()
const stream = await opened(1)
stream.send(snapshot())
@@ -274,15 +256,6 @@ describe.each(targets)('runtime layout client over the %s transport', (_name, ta
expect(cache).toEqual({})
})
it('close while waiting to retry cancels the retry', async () => {
const subscription = subscribe()
;(await opened(1)).drop()
expect(status).toEqual({ state: 'retrying' })
subscription.close()
await vi.advanceTimersByTimeAsync(10_000)
expect(host.streams).toHaveLength(1)
})
it('close before the subscribe settles releases the stream it then gets', async () => {
const subscription = subscribe()
subscription.close()
@@ -11,20 +11,19 @@ import {
import type { RuntimeClientTarget } from '../../../shared/runtime-client-target'
import { subscribeRuntimeRpc } from './runtime-rpc-subscribe'
/** `retrying`: waiting to reopen a lost stream. `refused`: the host answered that it will never
* serve this stream, so the client stopped. */
/** `lost`: the stream ended without a refusal and the client stopped. `refused`: the host answered
* that it will never serve this stream, so the client stopped. */
export type RuntimeLayoutStreamStatus =
| { state: 'connecting' | 'live' | 'retrying' | 'closed' }
| { state: 'connecting' | 'live' | 'lost' | 'closed' }
| { state: 'refused'; code: string }
const RETRY_BASE_MS = 250
const RETRY_MAX_MS = 5000
// An older host without the method, or a caller scope denied it: a retry gets the same answer.
const REFUSAL_CODES = ['method_not_found', 'forbidden'] as const
/**
* Opens the stream and keeps it open: after a lost stream it resubscribes, and the new snapshot
* replaces the cache. `onFrame` gets a snapshot first, then that stream's changes.
* Opens the stream once; `onFrame` gets a snapshot first, then that stream's changes. A local
* stream ends only on close, document retirement or a refusal, so nothing reopens it. A remote
* stream that drops reports `lost`; reopening it belongs to the connection's reconnect.
*/
export function subscribeRuntimeLayout(
target: RuntimeClientTarget,
@@ -35,8 +34,6 @@ export function subscribeRuntimeLayout(
// Bumped whenever a stream is given up, so its late callbacks change nothing.
let generation = 0
let handle: { unsubscribe: () => void } | null = null
let retryTimer: ReturnType<typeof setTimeout> | null = null
let failures = 0
let state: RuntimeLayoutStreamStatus['state'] = 'connecting'
const setStatus = (status: RuntimeLayoutStreamStatus): void => {
@@ -52,10 +49,6 @@ export function subscribeRuntimeLayout(
const stop = (status: RuntimeLayoutStreamStatus): void => {
release()
if (retryTimer !== null) {
clearTimeout(retryTimer)
retryTimer = null
}
setStatus(status)
}
@@ -67,18 +60,7 @@ export function subscribeRuntimeLayout(
console.warn('[workspace-layout] layout stream lost:', error)
}
const refusal = REFUSAL_CODES.find((code) => hasRuntimeRpcErrorCode(error, code))
if (refusal) {
stop({ state: 'refused', code: refusal })
return
}
release()
setStatus({ state: 'retrying' })
const delay = Math.min(RETRY_BASE_MS * 2 ** failures, RETRY_MAX_MS)
failures += 1
retryTimer = setTimeout(() => {
retryTimer = null
void open()
}, delay)
stop(refusal ? { state: 'refused', code: refusal } : { state: 'lost' })
}
const receive = (streamGeneration: number, frame: WorkspaceLayoutStreamFrame | null): void => {
@@ -95,9 +77,6 @@ export function subscribeRuntimeLayout(
} else if (state !== 'live') {
// The host sends the snapshot first; a change before it has nothing to replace.
return
} else {
// A change after the snapshot proves the stream stays up, unlike a snapshot then a drop.
failures = 0
}
onFrame(frame)
}
@@ -124,7 +103,7 @@ export function subscribeRuntimeLayout(
void open()
return {
close: () => {
if (state !== 'closed' && state !== 'refused') {
if (state === 'connecting' || state === 'live') {
stop({ state: 'closed' })
}
}
@@ -22,6 +22,7 @@ import {
} from './workspace-layout-load-bindings'
import { diffStoredSession } from './workspace-layout-load-report'
import type {
LoadedWorkspaceLayout,
WorkspaceLayoutLoadContext,
WorkspaceLayoutLoadResult
} from './workspace-layout-load-types'
@@ -120,6 +121,16 @@ export function loadWorkspaceLayout(
stored: WorkspaceSessionState,
context: WorkspaceLayoutLoadContext
): WorkspaceLayoutLoadResult {
const loaded = loadWorkspaceLayoutWithoutReport(hostId, stored, context)
return { ...loaded, changes: diffStoredSession(stored, saveWorkspaceLayout(loaded)) }
}
/** The same load without the save and diff, for a reader that never writes the result back. */
export function loadWorkspaceLayoutWithoutReport(
hostId: ExecutionHostId,
stored: WorkspaceSessionState,
context: WorkspaceLayoutLoadContext
): LoadedWorkspaceLayout {
// The Loader edits its own copy; the Store's object stays untouched.
const session = structuredClone(stored)
const desktopView: DesktopLayoutView = {
@@ -174,6 +185,5 @@ export function loadWorkspaceLayout(
loadLeaves(layout, { session, layouts }, { view: desktopView, facts, carried }, context)
unbindDuplicateTerminals(layout)
layout = applyLegacySurfaceTombstones(layout, session, carried)
const loaded = { layout, desktopView, facts, carried }
return { ...loaded, changes: diffStoredSession(stored, saveWorkspaceLayout(loaded)) }
return { layout, desktopView, facts, carried }
}
+105 -25
View File
@@ -2,8 +2,9 @@
* Measures command → drawn update for layout commands, idle and with three panes flooding output
* (`yes`), one of them in a hidden worktree, and time to first drawn after relaunching a large
* profile. Measuring only: it records each number in the oracle report and never fails on one,
* so a later budget can gate on it. Runs with no layout subscriber, so main is measured as users
* run it. The committed main baseline is workspace-layout-oracle-latency-baseline.json.
* so a later budget can gate on it. The plain run has no layout subscriber, so main is measured as
* users run it; the subscribed run holds one `layout.subscribe` stream open in the window, as the
* layout client will. The committed main baseline is workspace-layout-oracle-latency-baseline.json.
*
* Each command is timed twice. The in-app clock (helpers/layout-latency-probe.ts) stops in the
* window at React's commit and at the next frame. The outer clock stops when the test sees the
@@ -12,7 +13,7 @@
import { mkdirSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import type { Page, TestInfo } from '@stablyai/playwright-test'
import type { ElectronApplication, Page, TestInfo } from '@stablyai/playwright-test'
import { expect, test } from './helpers/orca-app'
import { createRestartSession } from './helpers/orca-restart'
import { bootstrapFirstLaunch, seededRepoPathOrSkip } from './helpers/terminal-restart-persistence'
@@ -57,6 +58,53 @@ const LARGE_PROFILE = { hiddenWorktrees: 12, tabsPerWorktree: 3, activePanes: 4
type Condition = 'idle' | 'flood'
type Sample = { outerMs: number; inApp: InAppSample }
type Samples = Record<string, Sample[]>
/** Main's memory at a phase's start and end, in MB; no GC is forced, so read it as a trend. */
type MainMemory = { heapUsedMb: [number, number]; rssMb: [number, number] }
/** What the window's layout stream received during a phase. */
type StreamTraffic = { frames: number; kb: number }
declare global {
// oxlint-disable-next-line typescript-eslint/consistent-type-definitions -- declaration merging requires interface
interface Window {
__layoutStreamTraffic?: { frames: number; bytes: number; errors: number }
}
}
async function readMainMemory(
app: ElectronApplication
): Promise<{ heapUsed: number; rss: number }> {
return app.evaluate(() => {
const { heapUsed, rss } = process.memoryUsage()
return { heapUsed, rss }
})
}
/** Holds one `layout.subscribe` stream open in the window for the rest of the run. */
async function attachLayoutSubscriber(page: Page): Promise<void> {
await page.evaluate(async () => {
const traffic = { frames: 0, bytes: 0, errors: 0 }
window.__layoutStreamTraffic = traffic
await window.api.runtime.subscribe({ method: 'layout.subscribe', params: {} }, (response) => {
if (!response.ok) {
traffic.errors += 1
return
}
traffic.frames += 1
traffic.bytes += JSON.stringify(response.result).length
})
})
await expect
.poll(() => page.evaluate(() => window.__layoutStreamTraffic?.frames ?? 0))
.toBeGreaterThan(0)
}
async function readStreamTraffic(page: Page): Promise<{ frames: number; bytes: number }> {
const traffic = await page.evaluate(() => window.__layoutStreamTraffic ?? null)
if (traffic && traffic.errors > 0) {
throw new Error(`layout stream reported ${traffic.errors} error frame(s)`)
}
return { frames: traffic?.frames ?? 0, bytes: traffic?.bytes ?? 0 }
}
/** Reads can briefly fail while the window re-publishes its terminals; only reads are retried. */
async function read<T>(client: RuntimeClient, method: string, params: unknown): Promise<T> {
@@ -243,9 +291,11 @@ type Stats = ReturnType<typeof stats>
type StartupSample = { firstDrawnMs: number; eventLoopDelayMax: number }
type LatencyReport =
| {
scenario: 'latency'
scenario: 'latency' | 'latency-subscribed'
summary: Record<string, { n: number; inAppCommit: Stats; inAppDrawn: Stats; outer: Stats }>
eventLoopDelay: Record<string, EventLoopDelaySummary>
mainMemory: Record<Condition, MainMemory>
streamTraffic?: Record<Condition, StreamTraffic>
closeConfirms: Record<Condition, number>
samples: Samples
}
@@ -271,9 +321,7 @@ function writeLatencyReport(testInfo: TestInfo, { samples, ...summary }: Latency
)
}
// oxlint-disable-next-line no-empty-pattern -- The measurement owns its launch.
test('layout oracle: command to drawn latency', async ({}, testInfo) => {
test.setTimeout(90 * 60_000)
async function measureLatency(testInfo: TestInfo, subscribed: boolean): Promise<void> {
const repoPath = seededRepoPathOrSkip()
const session = createRestartSession(testInfo)
const { app, page } = await session.launch()
@@ -284,12 +332,22 @@ test('layout oracle: command to drawn latency', async ({}, testInfo) => {
const worktree = `id:${worktreeId}`
const client = new RuntimeClient(session.userDataDir, 30_000)
await waitForBoundPanes(page, 1)
const phases: Record<Condition, { from: number; to: number; confirms: number }> = {
idle: { from: Date.now(), to: 0, confirms: 0 },
flood: { from: 0, to: 0, confirms: 0 }
if (subscribed) {
await attachLayoutSubscriber(page)
}
phases.idle.confirms = await measureCondition(page, client, worktreeId, 'idle', samples)
phases.idle.to = Date.now()
type Mark = { at: number; heapUsed: number; rss: number; frames: number; bytes: number }
const mark = async (): Promise<Mark> => ({
at: Date.now(),
...(await readMainMemory(app)),
...(await readStreamTraffic(page))
})
type Phase = { from: Mark; to: Mark; confirms: number }
const measurePhase = async (condition: Condition): Promise<Phase> => {
const from = await mark()
const confirms = await measureCondition(page, client, worktreeId, condition, samples)
return { from, to: await mark(), confirms }
}
const idle = await measurePhase('idle')
// Two flooding tabs beside the measured one and one in a worktree that is never shown.
const list = await read<RuntimeTerminalListResult>(client, 'terminal.list', { worktree })
@@ -305,9 +363,7 @@ test('layout oracle: command to drawn latency', async ({}, testInfo) => {
}
await client.call('terminal.focus', { terminal: measured.handle, navigation: 'host' })
await waitForActiveTerminalManager(page)
phases.flood.from = Date.now()
phases.flood.confirms = await measureCondition(page, client, worktreeId, 'flood', samples)
phases.flood.to = Date.now()
const flood = await measurePhase('flood')
const summary = Object.fromEntries(
Object.entries(samples).map(([action, values]) => [
@@ -320,18 +376,30 @@ test('layout oracle: command to drawn latency', async ({}, testInfo) => {
}
])
)
const eventLoopDelay = Object.fromEntries(
Object.entries(phases).map(([condition, phase]) => [
condition,
summarizeEventLoopDelay(session.userDataDir, 'main', phase.from, phase.to)
])
)
const closeConfirms = { idle: phases.idle.confirms, flood: phases.flood.confirms }
const perCondition = <T>(read: (phase: Phase) => T): Record<Condition, T> => ({
idle: read(idle),
flood: read(flood)
})
const mb = (bytes: number): number => Math.round(bytes / 1024 / 102.4) / 10
writeLatencyReport(testInfo, {
scenario: 'latency',
scenario: subscribed ? 'latency-subscribed' : 'latency',
summary,
eventLoopDelay,
closeConfirms,
eventLoopDelay: perCondition(({ from, to }) =>
summarizeEventLoopDelay(session.userDataDir, 'main', from.at, to.at)
),
mainMemory: perCondition(({ from, to }) => ({
heapUsedMb: [mb(from.heapUsed), mb(to.heapUsed)],
rssMb: [mb(from.rss), mb(to.rss)]
})),
...(subscribed
? {
streamTraffic: perCondition(({ from, to }) => ({
frames: to.frames - from.frames,
kb: Math.round((to.bytes - from.bytes) / 1024)
}))
}
: {}),
closeConfirms: perCondition(({ confirms }) => confirms),
samples
})
} finally {
@@ -341,6 +409,18 @@ test('layout oracle: command to drawn latency', async ({}, testInfo) => {
await session.close(app)
await session.dispose()
}
}
// oxlint-disable-next-line no-empty-pattern -- The measurement owns its launch.
test('layout oracle: command to drawn latency', async ({}, testInfo) => {
test.setTimeout(90 * 60_000)
await measureLatency(testInfo, false)
})
// oxlint-disable-next-line no-empty-pattern -- The measurement owns its launch.
test('layout oracle: command to drawn latency with a layout subscriber', async ({}, testInfo) => {
test.setTimeout(90 * 60_000)
await measureLatency(testInfo, true)
})
// oxlint-disable-next-line no-empty-pattern -- The measurement owns its launches.