Files
orca/src/relay/git-response-stream.ts
T
Neil f737f3499f fix(relay): stream an oversized fs.listFiles reply instead of refusing it (#17954)
Opening Orca's own checkout over SSH cannot list its files in one response frame.
22,617 tracked paths average 58 characters, so the 20,001-row page the client asks
for serializes to 1,223,415 bytes — past `DISPATCHER_CONTROL_QUEUE_MAX_BYTES`, so
`sendResponse` demotes it to the `legacy-response` lane, where an unrelated
producer backlog can refuse it as an opaque `ResponseOverCapacity`. Break-even is
around 49 characters of average path; any `packages/<name>/src/...` monorepo is
over the line.

Picking a ceiling to refuse at does not fix that, it just moves where it shows up
and refuses listings that would have been delivered. `__streamResponse` already
exists for exactly this on the git methods, and it is its own negotiation in both
directions: an old client never sends it and gets the plain array on the
legacy-response lane as before, and an old relay ignores it and answers plainly,
which the client detects by the sentinel marker being absent. So fs.listFiles opts
into it — no new method, no new opcode, nothing to advertise — and the size of a
listing stops being a correctness question.

The response-stream registry becomes one per relay, shared by FsHandler and
GitHandler. A second registry is not an option and the header of
git-response-stream.ts says why: a client keys reassembly on `streamId` alone, so
two would hand out the same id and cross-feed chunks, and only the handler that
registers `git.responseAck` can credit the window a pump parks on.

Also declares `maxResults` on the runtime-RPC `files.listAll` and forwards it.
The mechanism "the client names its cap, so a full page reads as truncation" was
wired only on the Electron IPC hop; web and mobile were saved incidentally by
`remoteFileContentBudget` defaulting the cap inside `listRuntimeFiles`. A new
optional field is additive in both directions (wire rule 1).

The new Docker-gated spec is claimed by run-ssh-docker-e2e.mjs. The sharded e2e
lanes set no ORCA_E2E_SSH_DOCKER, so a Docker-gated spec that no runner names
self-skips everywhere and still reports green — pr-e2e-gate-contract enforces that.

Closes #12547
2026-09-02 05:36:54 -07:00

260 lines
8.2 KiB
TypeScript

// Streams large RPC responses onto the bulk lane in chunks instead of one
// JSON-RPC frame, so a big reply cannot head-of-line-block interactive pty.data
// echo on the shared SSH channel. Mirrors the fs read-stream credit-window
// pattern (see fs-handler-file-read.ts) but the payload is an in-memory
// serialized string rather than a file handle.
//
// ONE REGISTRY PER RELAY. The `git.*` method names below are the shipped wire
// spelling and are permanent, the way an opcode number is, so a second handler
// that needs streaming (`fs.listFiles` is the first) shares this instance rather
// than minting its own. A second registry is not an option: a client keys
// reassembly on `streamId` alone, so two would hand out the same id and
// cross-feed each other's chunks, and only the handler that registers
// `git.responseAck` can credit the ack window a pump parks on — the other's
// streams would stall at STREAM_ACK_WINDOW_CHUNKS forever. See
// `relay-runtime-services.ts` for the wiring.
import type { RelayDispatcher, RequestContext } from './dispatcher'
import {
GIT_RESPONSE_CHUNK_SIZE,
GIT_RESPONSE_STREAM_THRESHOLD,
STREAM_ACK_WINDOW_CHUNKS,
STREAM_ACK_STALL_RECHECK_MS,
type GitResponseStreamMarker
} from './protocol'
type GitResponseStreamEntry = {
ownerClientId: number
aborted: boolean
/** Highest chunk seq the client acknowledged (in-order; -1 = none yet). */
ackedThroughSeq: number
ackWaiters: Set<() => void>
}
/** Serialized git responses are chunked as base64 so multi-byte UTF-8
* sequences never split across a chunk boundary (the client concatenates the
* decoded bytes and parses once). */
function encodeChunks(payload: Buffer, chunkBytes = GIT_RESPONSE_CHUNK_SIZE): string[] {
const chunks: string[] = []
for (let offset = 0; offset < payload.length; offset += chunkBytes) {
chunks.push(payload.subarray(offset, offset + chunkBytes).toString('base64'))
}
return chunks
}
export class GitResponseStreamRegistry {
private streams = new Map<number, GitResponseStreamEntry>()
private nextId = 1
private register(ownerClientId: number): number {
const streamId = this.nextId++
this.streams.set(streamId, {
ownerClientId,
aborted: false,
ackedThroughSeq: -1,
ackWaiters: new Set()
})
return streamId
}
recordAck(streamId: number, seq: number, clientId: number): void {
const entry = this.streams.get(streamId)
if (
!entry ||
entry.ownerClientId !== clientId ||
typeof seq !== 'number' ||
!Number.isFinite(seq)
) {
return
}
if (seq > entry.ackedThroughSeq) {
entry.ackedThroughSeq = seq
}
this.wake(entry)
}
abort(streamId: number, clientId: number): void {
const entry = this.streams.get(streamId)
if (entry?.ownerClientId === clientId) {
entry.aborted = true
this.wake(entry)
}
}
/** Wake every parked pump so it re-checks staleness — used when a client
* detaches and its acks will never arrive. */
wakeAll(): void {
for (const entry of this.streams.values()) {
this.wake(entry)
}
}
private wake(entry: GitResponseStreamEntry): void {
for (const waiter of Array.from(entry.ackWaiters)) {
waiter()
}
}
private waitForAck(streamId: number): Promise<void> {
const entry = this.streams.get(streamId)
if (!entry || entry.aborted) {
return Promise.resolve()
}
return new Promise<void>((resolve) => {
let settled = false
const finish = (): void => {
if (settled) {
return
}
settled = true
clearTimeout(timer)
entry.ackWaiters.delete(finish)
resolve()
}
const timer = setTimeout(finish, STREAM_ACK_STALL_RECHECK_MS)
timer.unref?.()
entry.ackWaiters.add(finish)
})
}
/**
* Register a stream for `payload`, kick off the bulk-lane pump on a later
* task (so the sentinel response reaches the client first), and return the
* sentinel marker to send as the RPC result.
*/
startStream(
payload: Buffer,
dispatcher: RelayDispatcher,
context: RequestContext
): GitResponseStreamMarker {
const streamId = this.register(context.clientId)
const base64Budget =
dispatcher.producerDataBudget?.(
'git.responseChunk',
{ streamId, seq: payload.length },
context.clientId
) ?? Number.MAX_SAFE_INTEGER
const sinkChunkBytes = Math.floor(Math.max(0, base64Budget) / 4) * 3
if (sinkChunkBytes === 0) {
this.streams.delete(streamId)
throw new Error('Git response stream has no encoded producer capacity')
}
const chunks = encodeChunks(payload, Math.min(GIT_RESPONSE_CHUNK_SIZE, sinkChunkBytes))
// Why: kick the pump off the response task so the client sees the sentinel
// (and can subscribe/reassemble) before the first chunk frame arrives.
setImmediate(() => {
void this.pump(streamId, chunks, dispatcher, context)
})
return {
__orcaGitResponseStream: { streamId, totalBytes: payload.length, chunkCount: chunks.length }
}
}
private async pump(
streamId: number,
chunks: string[],
dispatcher: RelayDispatcher,
context: RequestContext
): Promise<void> {
const entry = this.streams.get(streamId)
if (!entry) {
return
}
const clientId = context.clientId
let seq = 0
let endReason: 'end' | 'aborted' | 'stale' = 'end'
try {
for (seq = 0; seq < chunks.length; seq += 1) {
if (context.isStale()) {
endReason = 'stale'
break
}
if (entry.aborted) {
endReason = 'aborted'
break
}
// Why: credit window — the client acks each chunk, bounding how many
// bulk bytes a keystroke echo can queue behind on the shared channel.
while (
seq - entry.ackedThroughSeq > STREAM_ACK_WINDOW_CHUNKS &&
!context.isStale() &&
!entry.aborted
) {
await this.waitForAck(streamId)
}
if (context.isStale()) {
endReason = 'stale'
break
}
if (entry.aborted) {
endReason = 'aborted'
break
}
// Why: notifyBulk waits out sink saturation so chunk frames never pile
// up in the outbound pipe ahead of interactive pty.data frames.
await dispatcher.notifyBulk(
'git.responseChunk',
{ streamId, seq, data: chunks[seq] },
{
clientId
}
)
}
if (endReason === 'end') {
await dispatcher.notifyBulk('git.responseEnd', { streamId }, { clientId })
}
} catch (err) {
if (!context.isStale() && !entry.aborted) {
try {
await dispatcher.notifyBulk(
'git.responseError',
{
streamId,
message: err instanceof Error ? err.message : String(err)
},
{ clientId }
)
} catch {
// Why: the original failure may be the owning channel closing; a
// second send failure must not escape this detached pump.
}
}
} finally {
this.streams.delete(streamId)
}
}
disposeAll(): void {
for (const entry of this.streams.values()) {
entry.aborted = true
this.wake(entry)
}
this.streams.clear()
}
}
/**
* Opt-in response streaming, shared by every handler that can answer with a
* payload too large for one control-lane frame.
*
* `__streamResponse` is its own negotiation in both directions: an old client
* never sends it and gets the plain result, and an old relay ignores it and
* answers plainly, which the client detects by the sentinel marker being absent.
* So there is no new method and no capability to advertise.
*/
export function maybeStreamRpcResponse(
result: unknown,
params: Record<string, unknown>,
context: RequestContext | undefined,
registry: GitResponseStreamRegistry,
dispatcher: RelayDispatcher
): unknown {
if (params.__streamResponse !== true || !context) {
return result
}
const payload = Buffer.from(JSON.stringify(result ?? null), 'utf-8')
if (payload.length <= GIT_RESPONSE_STREAM_THRESHOLD) {
return result
}
return registry.startStream(payload, dispatcher, context)
}