mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
* 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>
167 lines
7.4 KiB
TypeScript
167 lines
7.4 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest'
|
||
import type { OrcaRuntimeService } from '../../orca-runtime'
|
||
import type { WorkspaceLayoutStreamEvent } from '../../../../shared/workspace-layout/workspace-layout-stream-frames'
|
||
import { eraseRpcMethods, isStreamingMethod, type RpcContext } from '../core'
|
||
import { ALL_RPC_METHODS } from './index'
|
||
import { RUNTIME_CAPABILITIES } from '../../../../shared/protocol-version'
|
||
import { MOBILE_RPC_METHOD_ALLOWLIST } from '../../runtime-rpc/runtime-rpc-mobile-method-allowlist'
|
||
|
||
const methods = eraseRpcMethods(ALL_RPC_METHODS)
|
||
const subscribe = methods.find((method) => method.name === 'layout.subscribe')
|
||
const unsubscribeMethod = methods.find((method) => method.name === 'layout.unsubscribe')
|
||
const getRemoteSnapshots = methods.find((method) => method.name === 'layout.getRemoteSnapshots')
|
||
|
||
function harness(epoch?: string) {
|
||
const cleanups = new Map<string, () => void>()
|
||
let publish: (event: WorkspaceLayoutStreamEvent) => void = () => {}
|
||
const unsubscribe = vi.fn()
|
||
const fake = {
|
||
subscribeWorkspaceLayouts: (listener: (event: WorkspaceLayoutStreamEvent) => void) => {
|
||
publish = listener
|
||
return {
|
||
snapshot: [
|
||
{ key: 'a', layout: { worktreeId: 'a' } },
|
||
{ key: 'b', layout: { worktreeId: 'b' } }
|
||
],
|
||
seq: 4,
|
||
...(epoch ? { epoch } : {}),
|
||
unsubscribe
|
||
}
|
||
},
|
||
registerSubscriptionCleanup: (id: string, cleanup: () => void) => cleanups.set(id, cleanup),
|
||
cleanupSubscription: (id: string) => cleanups.get(id)?.()
|
||
}
|
||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handler calls only the three runtime methods this fake implements.
|
||
const runtime = fake as unknown as OrcaRuntimeService
|
||
return {
|
||
runtime,
|
||
cleanups,
|
||
unsubscribe,
|
||
publish: (event: WorkspaceLayoutStreamEvent) => publish(event)
|
||
}
|
||
}
|
||
|
||
describe('layout.subscribe', () => {
|
||
it('is served but neither advertised nor open to phones (inert)', () => {
|
||
expect(subscribe && isStreamingMethod(subscribe)).toBe(true)
|
||
expect(RUNTIME_CAPABILITIES.some((capability) => capability.startsWith('layout'))).toBe(false)
|
||
expect(MOBILE_RPC_METHOD_ALLOWLIST.has('layout.subscribe')).toBe(false)
|
||
})
|
||
|
||
it('sends the snapshot, then the named workspaces’ changes, and ends on unsubscribe', async () => {
|
||
const { runtime, cleanups, unsubscribe, publish } = harness()
|
||
const emitted: unknown[] = []
|
||
if (!subscribe || !isStreamingMethod(subscribe)) {
|
||
throw new Error('layout.subscribe is not a streaming method')
|
||
}
|
||
const done = subscribe.handler(
|
||
{ workspaces: ['b'] },
|
||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handler reads only runtime and connectionId from its context.
|
||
{ runtime, connectionId: 'conn-1' } as RpcContext,
|
||
(event) => emitted.push(event)
|
||
)
|
||
const [cleanupKey] = [...cleanups.keys()]
|
||
expect(emitted).toEqual([
|
||
{
|
||
type: 'snapshot',
|
||
subscriptionId: expect.any(String),
|
||
workspaces: [{ key: 'b', layout: { worktreeId: 'b' } }],
|
||
seq: 4
|
||
}
|
||
])
|
||
publish({ type: 'removed', key: 'a', seq: 5 })
|
||
publish({ type: 'removed', key: 'b', seq: 6 })
|
||
runtime.cleanupSubscription(cleanupKey!)
|
||
await done
|
||
expect(emitted.slice(1)).toEqual([{ type: 'removed', key: 'b', seq: 6 }, { type: 'end' }])
|
||
expect(unsubscribe).toHaveBeenCalledTimes(1)
|
||
})
|
||
|
||
it('sends each records frame with only the named workspaces, an empty one too', async () => {
|
||
const { runtime, cleanups, publish } = harness('epoch-1')
|
||
const emitted: unknown[] = []
|
||
if (!subscribe || !isStreamingMethod(subscribe)) {
|
||
throw new Error('layout.subscribe is not a streaming method')
|
||
}
|
||
const done = subscribe.handler(
|
||
{ workspaces: ['b'] },
|
||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handler reads only runtime and connectionId from its context.
|
||
{ runtime, connectionId: 'conn-1' } as RpcContext,
|
||
(event) => emitted.push(event)
|
||
)
|
||
const removed = (key: string) => ({ key, change: 'removed' as const })
|
||
publish({ type: 'records', epoch: 'epoch-1', seq: 5, workspaces: [removed('a'), removed('b')] })
|
||
// Sent though it names none of them, so the subscriber's positions stay gapless.
|
||
publish({ type: 'records', epoch: 'epoch-1', seq: 6, workspaces: [removed('a')] })
|
||
runtime.cleanupSubscription([...cleanups.keys()][0]!)
|
||
await done
|
||
expect(emitted).toEqual([
|
||
expect.objectContaining({ type: 'snapshot', seq: 4, epoch: 'epoch-1' }),
|
||
{ type: 'records', epoch: 'epoch-1', seq: 5, workspaces: [removed('b')] },
|
||
{ type: 'records', epoch: 'epoch-1', seq: 6, workspaces: [] },
|
||
{ type: 'end' }
|
||
])
|
||
})
|
||
|
||
it('ends a subscription only for the connection that opened it', async () => {
|
||
const { runtime, unsubscribe } = harness()
|
||
const emitted: unknown[] = []
|
||
if (!subscribe || !isStreamingMethod(subscribe) || !unsubscribeMethod) {
|
||
throw new Error('layout methods are missing')
|
||
}
|
||
if (isStreamingMethod(unsubscribeMethod)) {
|
||
throw new Error('layout.unsubscribe is not a request method')
|
||
}
|
||
const done = subscribe.handler(
|
||
undefined,
|
||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handler reads only runtime and connectionId from its context.
|
||
{ runtime, connectionId: 'conn-1' } as RpcContext,
|
||
(event) => emitted.push(event)
|
||
)
|
||
const [snapshot] = emitted
|
||
const subscriptionId =
|
||
snapshot && typeof snapshot === 'object' && 'subscriptionId' in snapshot
|
||
? snapshot.subscriptionId
|
||
: undefined
|
||
expect(subscriptionId).toEqual(expect.any(String))
|
||
|
||
await unsubscribeMethod.handler(
|
||
{ subscriptionId },
|
||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handler reads only runtime and connectionId from its context.
|
||
{ runtime, connectionId: 'conn-2' } as RpcContext
|
||
)
|
||
expect(unsubscribe).not.toHaveBeenCalled()
|
||
|
||
await unsubscribeMethod.handler(
|
||
{ subscriptionId },
|
||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handler reads only runtime and connectionId from its context.
|
||
{ runtime, connectionId: 'conn-1' } as RpcContext
|
||
)
|
||
await done
|
||
expect(unsubscribe).toHaveBeenCalledTimes(1)
|
||
expect(emitted.at(-1)).toEqual({ type: 'end' })
|
||
})
|
||
})
|
||
|
||
describe('layout.getRemoteSnapshots', () => {
|
||
it('is served but neither advertised nor open to phones (inert)', () => {
|
||
expect(getRemoteSnapshots?.permission).toBe('workspace')
|
||
expect(MOBILE_RPC_METHOD_ALLOWLIST.has('layout.getRemoteSnapshots')).toBe(false)
|
||
})
|
||
|
||
it('returns the stored snapshot of each known server', async () => {
|
||
const snapshots = {
|
||
one: { workspaces: { a: { worktree: 'a', tabs: [] } } },
|
||
two: { workspaces: {} }
|
||
}
|
||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handler calls only getRemoteLayoutSnapshots.
|
||
const runtime = { getRemoteLayoutSnapshots: () => snapshots } as unknown as OrcaRuntimeService
|
||
if (!getRemoteSnapshots || isStreamingMethod(getRemoteSnapshots)) {
|
||
throw new Error('layout.getRemoteSnapshots is not a request method')
|
||
}
|
||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handler reads only runtime from its context.
|
||
const result = await getRemoteSnapshots.handler(undefined, { runtime } as RpcContext)
|
||
expect(result).toEqual({ snapshots })
|
||
})
|
||
})
|