mirror of
https://github.com/stablyai/orca.git
synced 2026-10-27 00:02:48 +00:00
* fix(mobile): keep a terminal lease alive when the handle lookup fails `waitForTerminal(..., 'exit')` rejects with `terminal_handle_stale` whenever the handle cannot be resolved right now. The loudest source is `record.rendererGraphEpoch !== this.rendererGraphEpoch` — every renderer graph reload invalidates handles issued before it, on panes whose PTY is untouched. All three terminal.subscribe branches wired that rejection straight to teardown (`.catch(() => registration.releaseIfCurrent())`), so the host retired a live lease and emitted `end`. Mobile reads `end` as "PTY gone" and rearms; after MAX_REARM_ATTEMPTS = 3 it stops and leaves the composer on "Waiting for terminal…", recovering only when a replacement handle arrives. A live PTY keeps its handle, so no replacement ever comes: the pane is dead until the app is backgrounded and reopened. Demand proof instead. Retire on abort (this socket is going away) or when isLeafPtyProvenAbsent says the process is gone; a real exit still resolves and retires through the .then leg. Absence must be proven, never inferred from a lookup that failed. Reproduced live: a paired 0.0.44 client over LAN, fault injected on the handle lookup, composer locked on "Waiting for terminal…" and STILL locked 97s after the fault was lifted with the PTY alive and the handle unchanged. Narrows one behavior #14992 pinned: a stale handle alone no longer retires the owner. The ownership property is unchanged — once absence is proven, the owning registration is still what retires. That test is updated, not deleted. * fix(mobile): observe PTY exit independent of stale handles * fix(mobile): stop subscribe setup when an exited pty releases synchronously The runtime doubles for terminal.subscribe never stubbed subscribeToPtyExit, so the new lifetime watcher threw and killed every subscription under test. Also return early when the watcher releases synchronously: cleanup had already run, so the stream handlers, view subscribers, and mobile presence registered afterwards were never torn down.
341 lines
11 KiB
TypeScript
341 lines
11 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest'
|
|
import { z } from 'zod'
|
|
import { RpcDispatcher } from './dispatcher'
|
|
import { defineMethod, defineStreamingMethod, type RpcRequest } from './core'
|
|
import type { OrcaRuntimeService } from '../orca-runtime'
|
|
import { TERMINAL_METHODS } from './methods/terminal'
|
|
import { createSubscriptionRegistryDouble } from './subscription-registry-test-double'
|
|
|
|
function stubRuntime(overrides: Partial<OrcaRuntimeService> = {}): OrcaRuntimeService {
|
|
return {
|
|
getRuntimeId: () => 'test-runtime',
|
|
// Why: subscribe streams register as remote view subscribers for Phase-5
|
|
// query-authority suppression (terminal-query-authority.md).
|
|
registerRemoteTerminalViewSubscriber: () => () => {},
|
|
...overrides
|
|
} as OrcaRuntimeService
|
|
}
|
|
|
|
function makeRequest(method: string, params?: unknown): RpcRequest {
|
|
return { id: 'req-1', authToken: 'tok', method, params }
|
|
}
|
|
|
|
describe('RpcDispatcher streaming', () => {
|
|
it('passes pairing authority to streaming handlers', async () => {
|
|
const pairing = {
|
|
getEndpoints: vi.fn(),
|
|
provisionRelay: vi.fn()
|
|
}
|
|
let receivedPairing: unknown
|
|
const dispatcher = new RpcDispatcher({
|
|
runtime: stubRuntime(),
|
|
methods: [
|
|
defineStreamingMethod({
|
|
name: 'test.pairing-stream',
|
|
params: null,
|
|
handler: async (_params, ctx) => {
|
|
receivedPairing = ctx.pairing
|
|
}
|
|
})
|
|
]
|
|
})
|
|
|
|
await dispatcher.dispatchStreaming(makeRequest('test.pairing-stream'), () => {}, { pairing })
|
|
|
|
expect(receivedPairing).toBe(pairing)
|
|
})
|
|
|
|
it('sends initial scrollback via emit', async () => {
|
|
const messages: string[] = []
|
|
const dispatcher = new RpcDispatcher({
|
|
runtime: stubRuntime({
|
|
readTerminal: vi.fn().mockResolvedValue({ tail: 'hello\nworld\n', truncated: false }),
|
|
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: null })
|
|
}),
|
|
methods: [
|
|
defineStreamingMethod({
|
|
name: 'terminal.subscribe',
|
|
params: z.object({ terminal: z.string() }),
|
|
handler: async (params, { runtime }, emit) => {
|
|
const read = await (runtime as OrcaRuntimeService).readTerminal(params.terminal)
|
|
emit({ type: 'scrollback', lines: read.tail, truncated: read.truncated })
|
|
|
|
const leaf = (runtime as OrcaRuntimeService).resolveLeafForHandle(params.terminal)
|
|
if (!leaf?.ptyId) {
|
|
emit({ type: 'end' })
|
|
}
|
|
}
|
|
})
|
|
]
|
|
})
|
|
|
|
await dispatcher.dispatchStreaming(
|
|
makeRequest('terminal.subscribe', { terminal: 'h-1' }),
|
|
(msg) => messages.push(msg)
|
|
)
|
|
|
|
expect(messages).toHaveLength(2)
|
|
const scrollback = JSON.parse(messages[0]!)
|
|
expect(scrollback).toMatchObject({
|
|
ok: true,
|
|
streaming: true,
|
|
result: { type: 'scrollback', lines: 'hello\nworld\n', truncated: false }
|
|
})
|
|
const end = JSON.parse(messages[1]!)
|
|
expect(end).toMatchObject({
|
|
ok: true,
|
|
streaming: true,
|
|
result: { type: 'end' }
|
|
})
|
|
})
|
|
|
|
it('streams live data chunks via emit', async () => {
|
|
const messages: string[] = []
|
|
let emitFn: ((result: unknown) => void) | null = null
|
|
let resolveHandler: (() => void) | null = null
|
|
|
|
const dispatcher = new RpcDispatcher({
|
|
runtime: stubRuntime(),
|
|
methods: [
|
|
defineStreamingMethod({
|
|
name: 'test.stream',
|
|
params: null,
|
|
handler: async (_params, _ctx, emit) => {
|
|
emitFn = emit
|
|
await new Promise<void>((resolve) => {
|
|
resolveHandler = resolve
|
|
})
|
|
}
|
|
})
|
|
]
|
|
})
|
|
|
|
const dispatchPromise = dispatcher.dispatchStreaming(makeRequest('test.stream'), (msg) =>
|
|
messages.push(msg)
|
|
)
|
|
|
|
// Wait for handler to capture emit
|
|
await vi.waitFor(() => expect(emitFn).not.toBeNull())
|
|
|
|
emitFn!({ type: 'data', chunk: 'line 1\n' })
|
|
emitFn!({ type: 'data', chunk: 'line 2\n' })
|
|
emitFn!({ type: 'end' })
|
|
resolveHandler!()
|
|
|
|
await dispatchPromise
|
|
|
|
expect(messages).toHaveLength(3)
|
|
expect(JSON.parse(messages[0]!)).toMatchObject({
|
|
streaming: true,
|
|
result: { type: 'data', chunk: 'line 1\n' }
|
|
})
|
|
expect(JSON.parse(messages[1]!)).toMatchObject({
|
|
streaming: true,
|
|
result: { type: 'data', chunk: 'line 2\n' }
|
|
})
|
|
expect(JSON.parse(messages[2]!)).toMatchObject({
|
|
streaming: true,
|
|
result: { type: 'end' }
|
|
})
|
|
})
|
|
|
|
it('unsubscribe stops further streaming', async () => {
|
|
const messages: string[] = []
|
|
let cleanup: (() => void) | null = null
|
|
|
|
const dispatcher = new RpcDispatcher({
|
|
runtime: stubRuntime({
|
|
registerSubscriptionCleanup: vi.fn().mockImplementation((_id: string, fn: () => void) => {
|
|
cleanup = fn
|
|
}),
|
|
cleanupSubscription: vi.fn().mockImplementation(() => {
|
|
cleanup?.()
|
|
})
|
|
}),
|
|
methods: [
|
|
defineStreamingMethod({
|
|
name: 'test.subscribe',
|
|
params: null,
|
|
handler: async (_params, { runtime }, emit) => {
|
|
emit({ type: 'scrollback', lines: '' })
|
|
|
|
await new Promise<void>((resolve) => {
|
|
;(runtime as OrcaRuntimeService).registerSubscriptionCleanup('sub-1', () => {
|
|
emit({ type: 'end' })
|
|
resolve()
|
|
})
|
|
})
|
|
}
|
|
}),
|
|
defineMethod({
|
|
name: 'test.unsubscribe',
|
|
params: z.object({ subscriptionId: z.string() }),
|
|
handler: async (params, { runtime }) => {
|
|
;(runtime as OrcaRuntimeService).cleanupSubscription(params.subscriptionId)
|
|
return { unsubscribed: true }
|
|
}
|
|
})
|
|
]
|
|
})
|
|
|
|
const subPromise = dispatcher.dispatchStreaming(makeRequest('test.subscribe'), (msg) =>
|
|
messages.push(msg)
|
|
)
|
|
|
|
await vi.waitFor(() => expect(cleanup).not.toBeNull())
|
|
|
|
const unsubMessages: string[] = []
|
|
await dispatcher.dispatchStreaming(
|
|
{
|
|
id: 'req-unsub',
|
|
authToken: 'tok',
|
|
method: 'test.unsubscribe',
|
|
params: { subscriptionId: 'sub-1' }
|
|
},
|
|
(msg) => unsubMessages.push(msg)
|
|
)
|
|
|
|
await subPromise
|
|
|
|
expect(unsubMessages).toHaveLength(1)
|
|
expect(JSON.parse(unsubMessages[0]!)).toMatchObject({
|
|
ok: true,
|
|
result: { unsubscribed: true }
|
|
})
|
|
|
|
const streamMessages = messages.map((m) => JSON.parse(m))
|
|
expect(streamMessages).toContainEqual(
|
|
expect.objectContaining({ result: { type: 'scrollback', lines: '' } })
|
|
)
|
|
expect(streamMessages).toContainEqual(expect.objectContaining({ result: { type: 'end' } }))
|
|
})
|
|
|
|
it('falls back to one-shot dispatch for non-streaming methods via dispatchStreaming', async () => {
|
|
const messages: string[] = []
|
|
const dispatcher = new RpcDispatcher({
|
|
runtime: stubRuntime(),
|
|
methods: [
|
|
defineMethod({
|
|
name: 'status.get',
|
|
params: null,
|
|
handler: async () => ({ status: 'ok' })
|
|
})
|
|
]
|
|
})
|
|
|
|
await dispatcher.dispatchStreaming(makeRequest('status.get'), (msg) => messages.push(msg))
|
|
|
|
expect(messages).toHaveLength(1)
|
|
const response = JSON.parse(messages[0]!)
|
|
expect(response).toMatchObject({ ok: true, result: { status: 'ok' } })
|
|
expect(response.streaming).toBeUndefined()
|
|
})
|
|
|
|
it('returns error for unknown method via dispatchStreaming', async () => {
|
|
const messages: string[] = []
|
|
const dispatcher = new RpcDispatcher({
|
|
runtime: stubRuntime(),
|
|
methods: []
|
|
})
|
|
|
|
await dispatcher.dispatchStreaming(makeRequest('nonexistent.method'), (msg) =>
|
|
messages.push(msg)
|
|
)
|
|
|
|
expect(messages).toHaveLength(1)
|
|
expect(JSON.parse(messages[0]!)).toMatchObject({
|
|
ok: false,
|
|
error: { code: 'method_not_found' }
|
|
})
|
|
})
|
|
|
|
it('returns error when streaming method is called via one-shot dispatch', async () => {
|
|
const dispatcher = new RpcDispatcher({
|
|
runtime: stubRuntime(),
|
|
methods: [
|
|
defineStreamingMethod({
|
|
name: 'test.stream',
|
|
params: null,
|
|
handler: async () => {}
|
|
})
|
|
]
|
|
})
|
|
|
|
const response = await dispatcher.dispatch(makeRequest('test.stream'))
|
|
|
|
expect(response).toMatchObject({
|
|
ok: false,
|
|
error: { code: 'method_not_supported' }
|
|
})
|
|
})
|
|
|
|
it('captures handler errors in streaming dispatch', async () => {
|
|
const messages: string[] = []
|
|
const dispatcher = new RpcDispatcher({
|
|
runtime: stubRuntime(),
|
|
methods: [
|
|
defineStreamingMethod({
|
|
name: 'test.explode',
|
|
params: null,
|
|
handler: async () => {
|
|
throw new Error('boom')
|
|
}
|
|
})
|
|
]
|
|
})
|
|
|
|
await dispatcher.dispatchStreaming(makeRequest('test.explode'), (msg) => messages.push(msg))
|
|
|
|
expect(messages).toHaveLength(1)
|
|
expect(JSON.parse(messages[0]!)).toMatchObject({
|
|
ok: false,
|
|
error: { code: 'runtime_error' }
|
|
})
|
|
})
|
|
|
|
it('ends terminal.subscribe when the backing terminal exits', async () => {
|
|
const messages: string[] = []
|
|
let resolveExit!: () => void
|
|
const registry = createSubscriptionRegistryDouble()
|
|
const runtime = stubRuntime({
|
|
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
|
|
readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }),
|
|
serializeTerminalBuffer: vi.fn().mockResolvedValue(null),
|
|
getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }),
|
|
getMobileDisplayMode: vi.fn().mockReturnValue('auto'),
|
|
getLayout: vi.fn().mockReturnValue({ seq: 1 }),
|
|
subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()),
|
|
subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()),
|
|
registerSubscriptionCleanup: vi.fn(registry.registerSubscriptionCleanup),
|
|
registerOwnedSubscriptionCleanup: vi.fn(registry.registerOwnedSubscriptionCleanup),
|
|
cleanupSubscription: vi.fn(registry.cleanupSubscription),
|
|
cleanupSubscriptionIfOwnedByConnection: vi.fn(
|
|
registry.cleanupSubscriptionIfOwnedByConnection
|
|
),
|
|
subscribeToPtyExit: vi.fn((_ptyId: string, listener: () => void) => {
|
|
resolveExit = listener
|
|
return vi.fn()
|
|
})
|
|
})
|
|
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
|
|
|
|
const dispatchPromise = dispatcher.dispatchStreaming(
|
|
makeRequest('terminal.subscribe', {
|
|
terminal: 'terminal-1',
|
|
client: { id: 'desktop-1', type: 'desktop' }
|
|
}),
|
|
(msg) => messages.push(msg)
|
|
)
|
|
|
|
await vi.waitFor(() => expect(registry.peekCleanup('terminal-1:desktop-1')).toBeDefined())
|
|
// Cleanup now registers before snapshot work so a disconnect cannot orphan
|
|
// a desktop width floor; wait for the actual exit waiter before resolving it.
|
|
await vi.waitFor(() => expect(runtime.subscribeToPtyExit).toHaveBeenCalled())
|
|
resolveExit()
|
|
await dispatchPromise
|
|
|
|
expect(messages.some((msg) => JSON.parse(msg).result?.type === 'end')).toBe(true)
|
|
expect(registry.peekCleanup('terminal-1:desktop-1')).toBeUndefined()
|
|
})
|
|
})
|