Files
orca/src/shared/runtime-rpc-call-queue.test.ts
T
f231d1cde6 feat(files): play remote audio and video through one byte-range preview path (#35038)
* feat(media): read remote media ranges through the paired runtime

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(media): stream remote media through orca-media grants

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(editor): play video and audio from remote runtime workspaces

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(media): surface update-required for remote media and cancel in-flight chunks

Probe files.readChunk once per environment and pairing revision before minting a
grant so older servers show the update message. Closing a media stream now aborts
the in-flight remote chunk. Also validate the IPC address, fix extension-only
names, and tighten the preview return type.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(files): one classifier decides which viewer opens a file

Local reads, files.readPreview on hosts, git blob previews and the desktop's
remote binary handling each kept their own image/PDF extension table, and
media had another. file-preview-kind.ts is now the one table: image, PDF,
audio or video, with the MIME type. AVIF, which only local reads knew,
previews on hosts and in git diffs too.

* perf(runtime): give byte-range reads their own small call lane

files.readChunk (media streams, paged files, downloads) now runs in a
two-call lane per environment, so a playing video or a download never holds
the slots terminal input and workspace calls use.

* feat(files): stream previews from paired runtimes by byte range

Generalises the remote media grant into one byte-range preview path: an
orca-media:// grant keyed by environment, worktree, path and pairing
revision, streamed over files.stat/readChunk. Audio and video use it now;
the shared classifier decides which viewers do.

- One files.stat at mint replaces the readChunk probe: it proves the host
  serves range reads and that the path is a file, so an old host or a
  missing file fails in the tab, not as a silent player error.
- A burst of range requests shares one stat (reused for 1s), so seeking
  costs one round trip less on a slow link; later requests re-stat, so a
  growing recording keeps playing.
- Removing a paired runtime revokes its grants; a re-paired one answers
  410 and drops the grant.
- Only readers that render bytes mint a stream; baseline checks don't.

Co-authored-by: Sahil Sharma <128581847+SahilSharma0810@users.noreply.github.com>

* fix(files): prove chunk reads at mint, and let a reload report the new size

Hosts from v1.4.5 answer files.stat but only v1.4.111 and later answer
files.readChunk, and no capability names it, so minting reads one byte,
cached per pairing, and an older host fails in the tab with the update
message. A re-mint seeds the grant with its fresh stat, so a reload never
reuses an older size. A renderer gone mid-mint mints nothing. Preview
chunks time out after 20s so a stalled one frees its lane sooner.

Co-authored-by: Sahil Sharma <128581847+SahilSharma0810@users.noreply.github.com>

* fix(files): resolve preview hosts in the pairing registry's own directory

The remote preview reader looked environments up under the canonical
user-data path, while the pairing IPC writes the registry under
app.getPath('userData'), which can differ in casing on case-sensitive
installs. Both now use one getter. A chunk-read verdict is cached only
when a pairing revision pins it, so it can't outlive a re-pair.

Co-authored-by: Sahil Sharma <128581847+SahilSharma0810@users.noreply.github.com>

* fix(files): read the pairing registry from one directory everywhere

Doc previews resolved paired servers under the canonical user-data path,
the same casing mismatch the media reader had. They and every other main
caller that names the registry directory inline now use
getRuntimeEnvironmentRegistryRoot(), so it has one source.

Co-authored-by: Sahil Sharma <128581847+SahilSharma0810@users.noreply.github.com>

* fix(files): pin an unpinned preview grant to the pairing it was minted on

A renderer that hadn't loaded the environment's pairing revision yet sent
none, and every later chunk read went unguarded, so a re-pair under the
same environment id would let the old URL read from the new host. Main now
pins the registered revision at mint.

Co-authored-by: Sahil Sharma <128581847+SahilSharma0810@users.noreply.github.com>

* refactor(files): route the remaining pairing-registry readers through the one getter

AI-vault host scans, the settings runtime-environment preference, skill
capability checks and the mobile relay host list named the registry
directory inline. They now use getRuntimeEnvironmentRegistryRoot() too;
callers that also use the directory for other storage keep their own path.

Co-authored-by: Sahil Sharma <128581847+SahilSharma0810@users.noreply.github.com>

* refactor(files): write new pairings through the same registry root

The ephemeral VM pairing writer named the registry directory inline; it now
uses getRuntimeEnvironmentRegistryRoot() like every reader.

Co-authored-by: Sahil Sharma <128581847+SahilSharma0810@users.noreply.github.com>

---------

Co-authored-by: Sahil Sharma <128581847+SahilSharma0810@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 05:08:55 -07:00

248 lines
9.3 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest'
import { RuntimeRpcCallQueueOverloadError, RuntimeRpcCallQueuePool } from './runtime-rpc-call-queue'
describe('runtime RPC call queue', () => {
it('limits background calls while allowing foreground runtime work through', async () => {
const queue = new RuntimeRpcCallQueuePool(2, 1)
const started: string[] = []
const pending: ((value: string) => void)[] = []
const enqueuePending = (method: string, label: string): Promise<string> =>
queue.enqueue('web-runtime', method, async () => {
started.push(label)
return await new Promise<string>((resolve) => pending.push(resolve))
})
const background1 = enqueuePending('github.prForBranch', 'background-1')
const background2 = enqueuePending('hostedReview.forBranch', 'background-2')
await vi.waitFor(() => expect(started).toEqual(['background-1']))
const foreground = queue.enqueue('web-runtime', 'terminal.send', async () => {
started.push('foreground')
return 'foreground'
})
await expect(foreground).resolves.toBe('foreground')
expect(started).toEqual(['background-1', 'foreground'])
pending.shift()?.('background-1')
await expect(background1).resolves.toBe('background-1')
await vi.waitFor(() => expect(started).toEqual(['background-1', 'foreground', 'background-2']))
pending.shift()?.('background-2')
await expect(background2).resolves.toBe('background-2')
})
it('runs foreground calls while worktree deletes wait, still bounding the deletes', async () => {
const queue = new RuntimeRpcCallQueuePool(2, 1)
const started: string[] = []
const pending: (() => void)[] = []
const deletes = ['rm-1', 'rm-2', 'rm-3'].map((label) =>
queue.enqueue('web-runtime', 'worktree.rm', async () => {
started.push(label)
await new Promise<void>((resolve) => pending.push(resolve))
return label
})
)
await vi.waitFor(() => expect(started).toEqual(['rm-1', 'rm-2']))
const listing = queue.enqueue('web-runtime', 'worktree.list', async () => 'listed')
await expect(listing).resolves.toBe('listed')
expect(started).toEqual(['rm-1', 'rm-2'])
pending.shift()?.()
await expect(deletes[0]).resolves.toBe('rm-1')
await vi.waitFor(() => expect(started).toEqual(['rm-1', 'rm-2', 'rm-3']))
pending.shift()?.()
pending.shift()?.()
await expect(Promise.all(deletes)).resolves.toEqual(['rm-1', 'rm-2', 'rm-3'])
})
it('runs foreground calls while model catalog reads wait on a host listing', async () => {
const queue = new RuntimeRpcCallQueuePool(8, 2)
const release: (() => void)[] = []
const waits = Array.from({ length: 8 }, () =>
queue.enqueue('web-runtime', 'agentSession.modelCatalog', async () => {
await new Promise<void>((resolve) => release.push(resolve))
return 'listed'
})
)
await vi.waitFor(() => expect(release).toHaveLength(8))
const send = queue.enqueue('web-runtime', 'agentSession.send', async () => 'sent')
await expect(send).resolves.toBe('sent')
for (const resolve of release) {
resolve()
}
await expect(Promise.all(waits)).resolves.toEqual(Array(8).fill('listed'))
})
it('keeps terminal input moving while byte-range reads fill their own small lane', async () => {
const queue = new RuntimeRpcCallQueuePool(8, 2)
const release: (() => void)[] = []
const reads = Array.from({ length: 6 }, () =>
queue.enqueue('web-runtime', 'files.readChunk', async () => {
await new Promise<void>((resolve) => release.push(resolve))
return 'chunk'
})
)
// Two at a time, however many viewers ask.
await vi.waitFor(() => expect(release).toHaveLength(2))
const send = queue.enqueue('web-runtime', 'terminal.send', async () => 'sent')
await expect(send).resolves.toBe('sent')
expect(release).toHaveLength(2)
for (let finished = 0; finished < 6; finished += 1) {
await vi.waitFor(() => expect(release.length).toBeGreaterThan(0))
expect(release.length).toBeLessThanOrEqual(2)
release.shift()?.()
}
await expect(Promise.all(reads)).resolves.toEqual(Array(6).fill('chunk'))
})
it('frees the queue slot when a runtime call throws synchronously', async () => {
const queue = new RuntimeRpcCallQueuePool(1, 1)
const first = queue.enqueue('web-runtime', 'status.get', () => {
throw new Error('invalid stored runtime pairing')
})
await expect(first).rejects.toThrow('invalid stored runtime pairing')
const second = queue.enqueue('web-runtime', 'status.get', async () => 'second')
await expect(second).resolves.toBe('second')
})
it('removes an aborted call before it starts', async () => {
const queue = new RuntimeRpcCallQueuePool(1, 1)
let releaseFirst: () => void = () => {}
const first = queue.enqueue('runtime-a', 'status.get', async () => {
await new Promise<void>((resolve) => {
releaseFirst = resolve
})
})
const controller = new AbortController()
const run = vi.fn(async () => 'cancelled')
const cancelled = queue.enqueue('runtime-a', 'status.get', run, 1, controller.signal)
controller.abort()
await expect(cancelled).rejects.toMatchObject({ name: 'AbortError' })
expect(run).not.toHaveBeenCalled()
releaseFirst()
await expect(first).resolves.toBeUndefined()
await expect(
queue.enqueue('runtime-a', 'status.get', async () => 'recovered', 1)
).resolves.toBe('recovered')
})
it('preserves queued background ordering across large bursts', async () => {
const queue = new RuntimeRpcCallQueuePool(1, 1)
const started: number[] = []
let releaseFirst: () => void = () => {}
const first = queue.enqueue('web-runtime', 'github.prForBranch', async () => {
started.push(0)
await new Promise<void>((resolve) => {
releaseFirst = resolve
})
return 0
})
const rest = Array.from({ length: 70 }, (_, index) =>
queue.enqueue('web-runtime', 'github.prForBranch', async () => {
const value = index + 1
started.push(value)
return value
})
)
await vi.waitFor(() => expect(started).toEqual([0]))
releaseFirst()
await expect(Promise.all([first, ...rest])).resolves.toEqual(
Array.from({ length: 71 }, (_, index) => index)
)
expect(started).toEqual(Array.from({ length: 71 }, (_, index) => index))
})
it('rejects per-selector overload and accepts work after the queue drains', async () => {
const queue = new RuntimeRpcCallQueuePool(1, 1, 2, 10)
let releaseFirst: () => void = () => {}
const first = queue.enqueue('runtime-a', 'status.get', async () => {
await new Promise<void>((resolve) => {
releaseFirst = resolve
})
return 'first'
})
const second = queue.enqueue('runtime-a', 'status.get', async () => 'second')
const third = queue.enqueue('runtime-a', 'status.get', async () => 'third')
await expect(queue.enqueue('runtime-a', 'status.get', async () => 'overflow')).rejects.toEqual(
expect.objectContaining({
code: 'runtime_rpc_queue_overloaded',
scope: 'selector'
})
)
releaseFirst()
await expect(Promise.all([first, second, third])).resolves.toEqual(['first', 'second', 'third'])
await expect(queue.enqueue('runtime-a', 'status.get', async () => 'recovered')).resolves.toBe(
'recovered'
)
})
it('caps queued calls across selectors and recovers after draining', async () => {
const queue = new RuntimeRpcCallQueuePool(1, 1, 10, 2)
const releases: (() => void)[] = []
const blockers = ['runtime-a', 'runtime-b'].map((selector) =>
queue.enqueue(selector, 'status.get', async () => {
await new Promise<void>((resolve) => releases.push(resolve))
})
)
const queuedA = queue.enqueue('runtime-a', 'status.get', async () => 'queued-a')
const queuedB = queue.enqueue('runtime-b', 'status.get', async () => 'queued-b')
const overload = queue.enqueue('runtime-c', 'status.get', async () => 'overflow')
await expect(overload).rejects.toBeInstanceOf(RuntimeRpcCallQueueOverloadError)
await expect(overload).rejects.toMatchObject({ scope: 'global' })
releases.splice(0).forEach((release) => release())
await expect(Promise.all([...blockers, queuedA, queuedB])).resolves.toEqual([
undefined,
undefined,
'queued-a',
'queued-b'
])
await expect(queue.enqueue('runtime-c', 'status.get', async () => 'recovered')).resolves.toBe(
'recovered'
)
})
it('caps retained call bytes across active and queued work, then recovers', async () => {
const queue = new RuntimeRpcCallQueuePool(1, 1, 10, 10, 10)
let releaseFirst: () => void = () => {}
let firstStarted = false
const first = queue.enqueue(
'runtime-a',
'status.get',
async () => {
firstStarted = true
await new Promise<void>((resolve) => {
releaseFirst = resolve
})
return 'first'
},
10
)
await vi.waitFor(() => expect(firstStarted).toBe(true))
await expect(
queue.enqueue('runtime-b', 'status.get', async () => 'overflow', 1)
).rejects.toMatchObject({ scope: 'memory' })
releaseFirst()
await expect(first).resolves.toBe('first')
await expect(
queue.enqueue('runtime-b', 'status.get', async () => 'recovered', 10)
).resolves.toBe('recovered')
})
})