fix(relay): stream large git responses on the bulk lane

Large git diff/exec responses (up to MAX_GIT_BUFFER=10MB) were sent as one
JSON-RPC frame on the shared SSH channel. encodeJsonRpcFrame's synchronous
JSON.stringify + Buffer.from of the whole payload, plus the multi-MB write,
head-of-line-blocked interactive pty.data echo: opening a big diff (e.g. a
lockfile) over SSH while typing in another pane stalled the echo for seconds.

The 2026-07 fs.readFileStream fix added a per-client serialized bulk lane
(RelayDispatcher.notifyBulk) honoring write()===false + waitWriteDrain, plus an
ack credit window — but only fs streams used it; git responses never did.

Stream the diff family (git.diff/branchDiff/commitDiff) and git.exec through the
bulk lane in chunks with the same ack-window backpressure. The relay returns a
small sentinel result and pumps git.responseChunk frames; the client reassembles
and JSON.parses. Small responses stay single-frame.

Cross-version safe via an opt-in capability gate (no handshake change):
- client sends __streamResponse:true; old relays ignore it → single-frame.
- new relays only stream when the flag is present → old clients get single-frame.
- new client checks for the stream sentinel; a plain result (old relay) is
  returned directly.

STREAM_CHUNK_SIZE is untouched; git chunking uses its own GIT_RESPONSE_CHUNK_SIZE
and the client reassembles by concatenation, so chunk size is not baked into any
cross-version offset math.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-07-10 05:57:05 -04:00
co-authored by Orca
parent c17d7cdae8
commit 4546181b10
9 changed files with 855 additions and 23 deletions
+16 -4
View File
@@ -7,6 +7,7 @@ type MockMultiplexer = {
notify: ReturnType<typeof vi.fn>
onNotification: ReturnType<typeof vi.fn>
onNotificationByMethod: ReturnType<typeof vi.fn>
onDispose: ReturnType<typeof vi.fn>
dispose: ReturnType<typeof vi.fn>
isDisposed: ReturnType<typeof vi.fn>
}
@@ -17,6 +18,9 @@ function createMockMux(): MockMultiplexer {
notify: vi.fn(),
onNotification: vi.fn(),
onNotificationByMethod: vi.fn().mockReturnValue(vi.fn()),
// Why: requestGitStreamable subscribes to onDispose before awaiting the
// response so it can reject in-flight reassembly if the link drops.
onDispose: vi.fn().mockReturnValue(vi.fn()),
dispose: vi.fn(),
isDisposed: vi.fn().mockReturnValue(false)
}
@@ -320,7 +324,10 @@ describe('SshGitProvider', () => {
'git.exec',
{
args: ['clone', '--progress', '--', 'git@example.com:repo.git', 'repo'],
cwd: '/home/user'
cwd: '/home/user',
// Why: exec opts into response streaming so a large stdout is chunked
// onto the bulk lane; old relays ignore the flag.
__streamResponse: true
},
{
signal: controller.signal,
@@ -353,7 +360,8 @@ describe('SshGitProvider', () => {
})
expect(mux.request).toHaveBeenCalledWith('git.exec', {
args: ['diff', '--cached', '--patch', '--minimal', '--no-color', '--no-ext-diff'],
cwd: '/home/user/repo'
cwd: '/home/user/repo',
__streamResponse: true
})
})
@@ -636,7 +644,10 @@ describe('SshGitProvider', () => {
expect(mux.request).toHaveBeenCalledWith('git.diff', {
worktreePath: '/home/user/repo',
filePath: 'src/index.ts',
staged: true
staged: true,
// Why: opts into response streaming; a small result still comes back as a
// single frame (relay decides), and old relays ignore the flag.
__streamResponse: true
})
expect(result).toEqual(diffResult)
})
@@ -880,7 +891,8 @@ describe('SshGitProvider', () => {
const result = await provider.getBranchDiff('/home/user/repo', 'main')
expect(mux.request).toHaveBeenCalledWith('git.branchDiff', {
worktreePath: '/home/user/repo',
baseRef: 'main'
baseRef: 'main',
__streamResponse: true
})
expect(result).toEqual(diffs)
})
+6 -5
View File
@@ -21,6 +21,7 @@ import type {
import type { GitHistoryOptions, GitHistoryResult } from '../../shared/git-history'
import { buildHostedRemoteCommitUrl, buildHostedRemoteFileUrl } from '../git/hosted-remote-url'
import { JsonRpcErrorCode } from '../ssh/relay-protocol'
import { requestGitStreamable } from '../ssh/ssh-git-response-stream-reader'
import type { CommitMessageDraftContext } from '../../shared/commit-message-generation'
import type { CommitMessagePlan } from '../../shared/commit-message-plan'
import type { RemoteCommitMessageExecResult } from '../text-generation/commit-message-text-generation'
@@ -373,7 +374,7 @@ export class SshGitProvider implements IGitProvider {
return this.gitDiffReadDedupe.run(
stableInFlightKey(['diff', worktreePath, filePath, staged, compareAgainstHead]),
async () =>
(await this.mux.request('git.diff', {
(await requestGitStreamable(this.mux, 'git.diff', {
worktreePath,
filePath,
staged,
@@ -597,7 +598,7 @@ export class SshGitProvider implements IGitProvider {
keyOptions.oldPath ?? null
]),
async () =>
(await this.mux.request('git.branchDiff', {
(await requestGitStreamable(this.mux, 'git.branchDiff', {
worktreePath,
baseRef,
...options
@@ -619,7 +620,7 @@ export class SshGitProvider implements IGitProvider {
args.oldPath ?? null
]),
async () =>
(await this.mux.request('git.commitDiff', {
(await requestGitStreamable(this.mux, 'git.commitDiff', {
worktreePath,
...args
})) as GitDiffResult
@@ -761,8 +762,8 @@ export class SshGitProvider implements IGitProvider {
options?: { signal?: AbortSignal; timeoutMs?: number }
): Promise<{ stdout: string; stderr: string }> {
const result = options
? await this.mux.request('git.exec', { args, cwd }, options)
: await this.mux.request('git.exec', { args, cwd })
? await requestGitStreamable(this.mux, 'git.exec', { args, cwd }, options)
: await requestGitStreamable(this.mux, 'git.exec', { args, cwd })
return result as {
stdout: string
stderr: string
+29
View File
@@ -55,6 +55,35 @@ export const STREAM_CHUNK_SIZE = 256 * 1024
* client. */
export const MAX_CONCURRENT_STREAMS = 16
// ── Git response streaming (see docs/relay-git-response-stream-design.md) ──
/** Serialized-JSON size above which the relay chunks a streamable git response
* (diff family + exec) onto the bulk lane instead of one JSON-RPC frame. Mirror
* of the relay-side constant; the client only opts in — the relay owns the
* decision — so this is documentation of the shared contract. */
export const GIT_RESPONSE_STREAM_THRESHOLD = 256 * 1024
/** Per-chunk size (serialized-result UTF-8 bytes) for git response streaming.
* The client reassembles by concatenation and does not depend on this value,
* so it stays cross-version safe. */
export const GIT_RESPONSE_CHUNK_SIZE = 128 * 1024
/** Sentinel the relay returns as the RPC result when the real payload streams
* as git.responseChunk frames. Absent from old relays, so a new client falls
* back to the plain result they return. */
export type GitResponseStreamMarker = {
__orcaGitResponseStream: { streamId: number; totalBytes: number; chunkCount: number }
}
export function isGitResponseStreamMarker(value: unknown): value is GitResponseStreamMarker {
return (
typeof value === 'object' &&
value !== null &&
typeof (value as GitResponseStreamMarker).__orcaGitResponseStream === 'object' &&
(value as GitResponseStreamMarker).__orcaGitResponseStream !== null
)
}
// ── JSON-RPC types ──────────────────────────────────────────────────
export type JsonRpcRequest = {
@@ -0,0 +1,240 @@
import type { SshChannelMultiplexer } from './ssh-channel-multiplexer'
import { RelayErrorCode, isGitResponseStreamMarker } from './relay-protocol'
const SENTINEL_STREAM_ID = -1
export class GitResponseStreamError extends Error {
readonly code = RelayErrorCode.StreamProtocolError
constructor(message: string) {
super(message)
}
}
type PendingFrame =
| { kind: 'chunk'; params: Record<string, unknown> }
| { kind: 'end'; params: Record<string, unknown> }
| { kind: 'error'; params: Record<string, unknown> }
/**
* Request a git method that may return a large payload, opting into response
* streaming so a big diff/exec response is chunked onto the relay's bulk lane
* instead of one JSON-RPC frame (which would head-of-line-block pty.data echo
* on the shared SSH channel).
*
* Cross-version behavior:
* - New relay + big result → returns the stream sentinel; we reassemble chunks.
* - New relay + small result, or old client → plain single-frame result.
* - Old relay (ignores `__streamResponse`) → returns the plain result; the
* marker check fails and we return it directly, i.e. today's behavior.
*/
export function requestGitStreamable(
mux: SshChannelMultiplexer,
method: string,
params: Record<string, unknown>,
options?: { signal?: AbortSignal }
): Promise<unknown> {
// Why: subscribe to chunk/end/error BEFORE awaiting the sentinel response so a
// chunk that lands in the same dispatch tick as the response is not dropped
// (mirrors readFileViaStream). streamIdRef stays SENTINEL until the sentinel
// resolves; frames are queued until then and drained.
const streamIdRef = { current: SENTINEL_STREAM_ID }
const unsubscribers: (() => void)[] = []
const cleanup = (): void => {
while (unsubscribers.length > 0) {
try {
unsubscribers.pop()?.()
} catch {
// best-effort
}
}
}
return new Promise<unknown>((resolve, reject) => {
const parts: Buffer[] = []
let expectedSeq = 0
let receivedBytes = 0
let totalBytes = 0
let chunkCount = 0
let settled = false
let metadataReady = false
const pending: PendingFrame[] = []
const cancel = (): void => {
if (streamIdRef.current !== SENTINEL_STREAM_ID && !mux.isDisposed()) {
try {
mux.notify('git.cancelResponseStream', { streamId: streamIdRef.current })
} catch {
// best-effort
}
}
}
const fail = (err: Error): void => {
if (settled) {
return
}
settled = true
cancel()
cleanup()
reject(err)
}
const succeed = (value: unknown): void => {
if (settled) {
return
}
settled = true
cleanup()
resolve(value)
}
const handleChunk = (p: Record<string, unknown>): void => {
if (settled || p.streamId !== streamIdRef.current) {
return
}
const seq = p.seq as number
const data = p.data as string
if (typeof seq !== 'number' || typeof data !== 'string') {
fail(new GitResponseStreamError(`Malformed chunk for git stream ${streamIdRef.current}`))
return
}
if (seq !== expectedSeq) {
fail(
new GitResponseStreamError(
`Out-of-order chunk for git stream ${streamIdRef.current}: expected ${expectedSeq}, got ${seq}`
)
)
return
}
const decoded = Buffer.from(data, 'base64')
parts.push(decoded)
receivedBytes += decoded.length
expectedSeq += 1
// Why: credit-based flow control — the relay caps unacked chunks so a big
// response cannot queue unbounded ahead of interactive pty.data frames.
mux.notify('git.responseAck', { streamId: streamIdRef.current, seq })
}
const handleEnd = (p: Record<string, unknown>): void => {
if (settled || p.streamId !== streamIdRef.current) {
return
}
if (expectedSeq !== chunkCount || receivedBytes !== totalBytes) {
fail(
new GitResponseStreamError(
`Git stream ${streamIdRef.current} incomplete: chunks ${expectedSeq}/${chunkCount}, bytes ${receivedBytes}/${totalBytes}`
)
)
return
}
try {
succeed(JSON.parse(Buffer.concat(parts).toString('utf-8')))
} catch (err) {
fail(
new GitResponseStreamError(
`Git stream ${streamIdRef.current} JSON parse failed: ${String(err)}`
)
)
}
}
const handleStreamError = (p: Record<string, unknown>): void => {
if (settled || p.streamId !== streamIdRef.current) {
return
}
fail(new Error((p.message as string | undefined) ?? 'git response stream error'))
}
const drainPending = (): void => {
while (!settled && pending.length > 0) {
const frame = pending.shift()!
if (frame.kind === 'chunk') {
handleChunk(frame.params)
} else if (frame.kind === 'end') {
handleEnd(frame.params)
} else {
handleStreamError(frame.params)
}
}
}
unsubscribers.push(
mux.onNotificationByMethod('git.responseChunk', (p) => {
if (!metadataReady) {
pending.push({ kind: 'chunk', params: p })
return
}
handleChunk(p)
})
)
unsubscribers.push(
mux.onNotificationByMethod('git.responseEnd', (p) => {
if (!metadataReady) {
pending.push({ kind: 'end', params: p })
return
}
handleEnd(p)
})
)
unsubscribers.push(
mux.onNotificationByMethod('git.responseError', (p) => {
if (!metadataReady) {
pending.push({ kind: 'error', params: p })
return
}
handleStreamError(p)
})
)
unsubscribers.push(
mux.onDispose((reason) => {
const err = new Error(
reason === 'connection_lost'
? 'SSH connection lost, reconnecting...'
: 'Multiplexer disposed'
) as Error & { code: string }
err.code = reason === 'connection_lost' ? 'CONNECTION_LOST' : 'DISPOSED'
fail(err)
})
)
if (options?.signal) {
const signal = options.signal
if (signal.aborted) {
const err = new Error('Request was cancelled') as Error & { name: string }
err.name = 'AbortError'
fail(err)
return
}
const onAbort = (): void => {
const err = new Error('Request was cancelled') as Error & { name: string }
err.name = 'AbortError'
fail(err)
}
signal.addEventListener('abort', onAbort, { once: true })
unsubscribers.push(() => signal.removeEventListener('abort', onAbort))
}
// Why: forward options only when present so callers that previously issued a
// 2-arg mux.request keep the same call shape (and their tests).
const streamParams = { ...params, __streamResponse: true }
const requestPromise = options
? mux.request(method, streamParams, options)
: mux.request(method, streamParams)
void requestPromise
.then((result) => {
if (settled) {
return
}
// Old relay / small result: plain single-frame value, no stream follows.
if (!isGitResponseStreamMarker(result)) {
succeed(result)
return
}
const marker = result.__orcaGitResponseStream
totalBytes = marker.totalBytes
chunkCount = marker.chunkCount
streamIdRef.current = marker.streamId
metadataReady = true
drainPending()
})
.catch((err) => fail(err as Error))
})
}
+62 -10
View File
@@ -61,6 +61,8 @@ import { getGitCloneFailureMessage } from '../shared/git-clone-failure-message'
import { syncForkDefaultBranch, validateGitForkSyncExpectedUpstream } from '../shared/git-fork-sync'
import { InFlightPromiseDedupe, stableInFlightKey } from '../shared/in-flight-promise-dedupe'
import { GIT_FETCH_SKIP_AUTO_MAINTENANCE_CONFIG_ARGS } from '../shared/git-fetch-auto-maintenance'
import { GitResponseStreamRegistry } from './git-response-stream'
import { GIT_RESPONSE_STREAM_THRESHOLD } from './protocol'
const execFileAsync = promisify(execFile)
const MAX_GIT_BUFFER = 10 * 1024 * 1024
@@ -171,6 +173,9 @@ function execFileWithStdin(
export class GitHandler {
private dispatcher: RelayDispatcher
private readonly gitDiffReadDedupe = new InFlightPromiseDedupe<unknown>()
// Why: large diff/exec responses are chunked onto the bulk lane so they do
// not head-of-line-block interactive pty.data echo on the shared SSH channel.
private readonly responseStreams = new GitResponseStreamRegistry()
// Why: configured submodule paths change rarely; an instance-level TTL cache
// avoids re-reading `.gitmodules` on every diff click over SSH, and being
@@ -182,6 +187,13 @@ export class GitHandler {
constructor(dispatcher: RelayDispatcher, _context: RelayContext) {
this.dispatcher = dispatcher
this.registerHandlers()
// Why: a detached client's git.responseAck frames will never arrive; wake
// any pump parked on the ack window so it re-checks staleness and exits.
this.dispatcher.onClientDetached?.(() => this.responseStreams.wakeAll())
}
dispose(): void {
this.responseStreams.disposeAll()
}
private registerHandlers(): void {
@@ -190,7 +202,7 @@ export class GitHandler {
this.dispatcher.onRequest('git.checkIgnored', (p) => this.checkIgnored(p))
this.dispatcher.onRequest('git.history', (p) => this.history(p))
this.dispatcher.onRequest('git.commit', (p) => this.commit(p))
this.dispatcher.onRequest('git.diff', (p) => this.getDiff(p))
this.dispatcher.onRequest('git.diff', (p, context) => this.getDiff(p, context))
this.dispatcher.onRequest('git.stage', (p) => this.stage(p))
this.dispatcher.onRequest('git.unstage', (p) => this.unstage(p))
this.dispatcher.onRequest('git.bulkStage', (p) => this.bulkStage(p))
@@ -215,8 +227,8 @@ export class GitHandler {
this.dispatcher.onRequest('git.pull', (p) => this.pull(p))
this.dispatcher.onRequest('git.fastForward', (p) => this.fastForward(p))
this.dispatcher.onRequest('git.rebaseFromBase', (p) => this.rebaseFromBase(p))
this.dispatcher.onRequest('git.branchDiff', (p) => this.branchDiff(p))
this.dispatcher.onRequest('git.commitDiff', (p) => this.commitDiff(p))
this.dispatcher.onRequest('git.branchDiff', (p, context) => this.branchDiff(p, context))
this.dispatcher.onRequest('git.commitDiff', (p, context) => this.commitDiff(p, context))
this.dispatcher.onRequest('git.listWorktrees', (p, context) => this.listWorktrees(p, context))
this.dispatcher.onRequest('git.addWorktree', (p) => this.addWorktree(p))
this.dispatcher.onRequest('git.removeWorktree', (p) => this.removeWorktree(p))
@@ -231,6 +243,43 @@ export class GitHandler {
this.dispatcher.onRequest('git.exec', (p, context) => this.exec(p, context))
this.dispatcher.onRequest('git.clone', (p, context) => this.clone(p, context))
this.dispatcher.onRequest('git.isGitRepo', (p) => this.isGitRepo(p))
this.dispatcher.onNotification('git.responseAck', (p) => this.responseAck(p))
this.dispatcher.onNotification('git.cancelResponseStream', (p) => this.cancelResponseStream(p))
}
private responseAck(params: Record<string, unknown>): void {
const streamId = params.streamId
const seq = params.seq
if (typeof streamId === 'number' && typeof seq === 'number') {
this.responseStreams.recordAck(streamId, seq)
}
}
private cancelResponseStream(params: Record<string, unknown>): void {
const streamId = params.streamId
if (typeof streamId === 'number') {
this.responseStreams.abort(streamId)
}
}
// Why: when the client opted into response streaming and the serialized result
// exceeds the threshold, chunk it onto the bulk lane and return a small
// sentinel as the RPC result. Old clients omit the flag (single-frame, as
// today); old relays never call this, so a new client falls back to the plain
// result they return.
private maybeStreamResponse(
result: unknown,
params: Record<string, unknown>,
context: RequestContext | undefined
): 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 this.responseStreams.startStream(payload, this.dispatcher, context)
}
private async runWithDiffDedupeClear<T>(run: () => Promise<T>): Promise<T> {
@@ -352,7 +401,7 @@ export class GitHandler {
})
}
private async getDiff(params: Record<string, unknown>) {
private async getDiff(params: Record<string, unknown>, context?: RequestContext) {
const worktreePath = params.worktreePath as string
const filePath = params.filePath as string
// Why: filePath is relative to worktreePath and used in readWorkingFile via
@@ -366,7 +415,7 @@ export class GitHandler {
const compareAgainstHead = params.compareAgainstHead as boolean | undefined
// Why: register the in-flight dedupe synchronously (before any await) so
// concurrent identical reads coalesce; submodule routing happens inside.
return this.gitDiffReadDedupe.run(
const result = await this.gitDiffReadDedupe.run(
stableInFlightKey(['diff', worktreePath, filePath, staged, compareAgainstHead]),
async () => {
// Why: gitlink paths can't be read as blobs and submodule working dirs
@@ -431,6 +480,7 @@ export class GitHandler {
)
}
)
return this.maybeStreamResponse(result, params, context)
}
private async stage(params: Record<string, unknown>) {
@@ -1027,7 +1077,7 @@ export class GitHandler {
}
}
private async branchDiff(params: Record<string, unknown>) {
private async branchDiff(params: Record<string, unknown>, context?: RequestContext) {
const worktreePath = params.worktreePath as string
const baseRef = params.baseRef as string
if (baseRef.startsWith('-')) {
@@ -1038,7 +1088,7 @@ export class GitHandler {
filePath: params.filePath as string | undefined,
oldPath: params.oldPath as string | undefined
}
return this.gitDiffReadDedupe.run(
const result = await this.gitDiffReadDedupe.run(
stableInFlightKey([
'branchDiff',
worktreePath,
@@ -1056,9 +1106,10 @@ export class GitHandler {
options
)
)
return this.maybeStreamResponse(result, params, context)
}
private async commitDiff(params: Record<string, unknown>) {
private async commitDiff(params: Record<string, unknown>, context?: RequestContext) {
const worktreePath = params.worktreePath as string
const args = {
commitOid: params.commitOid as string,
@@ -1066,7 +1117,7 @@ export class GitHandler {
filePath: params.filePath as string,
oldPath: params.oldPath as string | undefined
}
return this.gitDiffReadDedupe.run(
const result = await this.gitDiffReadDedupe.run(
stableInFlightKey([
'commitDiff',
worktreePath,
@@ -1077,6 +1128,7 @@ export class GitHandler {
]),
() => commitDiffEntry(this.gitBuffer.bind(this), worktreePath, args)
)
return this.maybeStreamResponse(result, params, context)
}
private async exec(params: Record<string, unknown>, context?: RequestContext) {
@@ -1085,7 +1137,7 @@ export class GitHandler {
validateGitExecArgs(args)
const { stdout, stderr } = await this.git(args, cwd, { signal: context?.signal })
return { stdout, stderr }
return this.maybeStreamResponse({ stdout, stderr }, params, context)
}
private async clone(params: Record<string, unknown>, context?: RequestContext) {
@@ -0,0 +1,289 @@
/**
* Regression: SSH typing latency under large git responses.
*
* The relay and the client share ONE ordered SSH channel. A large git diff/exec
* response sent as a single JSON-RPC frame queues megabytes into the outbound
* pipe at once; an interactive pty.data echo emitted mid-response then sits
* behind all of it and typing feels seconds-slow.
*
* These tests model the SSH channel as a congestible in-memory pipe and assert
* deterministic byte bounds instead of wall-clock latency:
* - WITHOUT the fix (client does not opt in), the whole response queues ahead
* of a pty echo;
* - WITH the fix (client opts in), the response streams on the bulk lane and
* at most ~1 chunk frame sits ahead of the echo;
* - both paths reassemble the identical git result.
*/
import { describe, expect, it, beforeEach, afterEach } from 'vitest'
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'
import { rm } from 'node:fs/promises'
import { execFileSync } from 'node:child_process'
import * as path from 'node:path'
import { tmpdir } from 'node:os'
import {
SshChannelMultiplexer,
type MultiplexerTransport
} from '../main/ssh/ssh-channel-multiplexer'
import { requestGitStreamable } from '../main/ssh/ssh-git-response-stream-reader'
import { RelayDispatcher } from './dispatcher'
import { RelayContext } from './context'
import { GitHandler } from './git-handler'
import { GIT_RESPONSE_CHUNK_SIZE } from './protocol'
// One framed git.responseChunk: base64 (4/3) + JSON envelope + header slack.
const FRAMED_CHUNK_BYTES = Math.ceil((GIT_RESPONSE_CHUNK_SIZE * 4) / 3) + 512
const SINK_HIGH_WATER_MARK = 64 * 1024
async function waitUntil(
predicate: () => boolean,
what: string,
timeoutMs = 10_000
): Promise<void> {
const deadline = Date.now() + timeoutMs
while (!predicate()) {
if (Date.now() > deadline) {
throw new Error(`waitUntil timed out: ${what}`)
}
await new Promise((r) => setImmediate(r))
}
}
async function waitUntilSettled(read: () => number, stableTurns = 25): Promise<void> {
let last = read()
let stable = 0
while (stable < stableTurns) {
await new Promise((r) => setImmediate(r))
const current = read()
if (current === last) {
stable += 1
} else {
stable = 0
last = current
}
}
}
type Harness = {
mux: SshChannelMultiplexer
dispatcher: RelayDispatcher
gitHandler: GitHandler
queuedBytes: () => number
deliverAll: () => void
startAutoDeliver: () => void
dispose: () => void
}
function createHarness(opts: { congested: boolean }): Harness {
let relayFeed: ((data: Buffer) => void) | null = null
const clientDataCallbacks: ((data: Buffer) => void)[] = []
const clientTransport: MultiplexerTransport = {
write: (data: Buffer) => {
// Client → relay (keystrokes, acks) is the opposite duplex direction and
// is not blocked by relay→client congestion.
setImmediate(() => relayFeed?.(data))
},
onData: (cb) => {
clientDataCallbacks.push(cb)
},
onClose: () => {}
}
const outQueue: Buffer[] = []
let queuedBytes = 0
const drainWaiters = new Set<() => void>()
const fireDrainIfIdle = (): void => {
if (queuedBytes > 0) {
return
}
for (const cb of Array.from(drainWaiters)) {
drainWaiters.delete(cb)
cb()
}
}
const dispatcher = new RelayDispatcher(
(data: Buffer) => {
outQueue.push(data)
queuedBytes += data.length
if (!opts.congested) {
return true
}
return queuedBytes < SINK_HIGH_WATER_MARK
},
{
waitWriteDrain: (cb: () => void) => {
drainWaiters.add(cb)
fireDrainIfIdle()
}
}
)
relayFeed = (data: Buffer) => dispatcher.feed(data)
const deliverAll = (): void => {
while (outQueue.length > 0) {
const buf = outQueue.shift()!
queuedBytes -= buf.length
for (const cb of clientDataCallbacks) {
cb(buf)
}
}
fireDrainIfIdle()
}
let autoDeliverTimer: ReturnType<typeof setInterval> | null = null
const startAutoDeliver = (): void => {
if (autoDeliverTimer) {
return
}
autoDeliverTimer = setInterval(deliverAll, 1)
}
const context = new RelayContext()
const gitHandler = new GitHandler(dispatcher, context)
const mux = new SshChannelMultiplexer(clientTransport)
return {
mux,
dispatcher,
gitHandler,
queuedBytes: () => queuedBytes,
deliverAll,
startAutoDeliver,
dispose: () => {
if (autoDeliverTimer) {
clearInterval(autoDeliverTimer)
}
mux.dispose()
dispatcher.dispose()
gitHandler.dispose()
}
}
}
// Build a repo whose staged diff for `big.txt` is several MB so git.diff
// returns a payload well over the stream threshold.
function makeRepoWithLargeStagedDiff(dir: string): void {
const env = { ...process.env }
const run = (args: string[]): void => {
execFileSync('git', args, { cwd: dir, stdio: 'pipe', env })
}
run(['init'])
run(['config', 'user.email', 'test@test.com'])
run(['config', 'user.name', 'Test'])
// Distinct non-repeating lines keep the diff from compressing to nothing.
// Stay under the render limits (120k lines / 6M chars) so the diff result
// carries the full ~4MB content and exceeds the stream threshold.
const lines: string[] = []
for (let i = 0; i < 12_000; i += 1) {
lines.push(`line ${i} ${'x'.repeat(100)}`)
}
writeFileSync(path.join(dir, 'big.txt'), lines.join('\n'))
run(['add', 'big.txt'])
}
describe('large git response vs pty.data echo head-of-line blocking', () => {
let tmpDir: string
let repoDir: string
beforeEach(() => {
tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-git-hol-'))
repoDir = path.join(tmpDir, 'repo')
mkdirSync(repoDir, { recursive: true })
makeRepoWithLargeStagedDiff(repoDir)
})
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true })
})
it('WITHOUT opt-in: the whole diff queues ahead of a pty echo (single-frame HOL)', async () => {
const harness = createHarness({ congested: true })
try {
let queuedBytesAheadOfEcho = -1
harness.dispatcher.onNotification('pty.data', (params) => {
queuedBytesAheadOfEcho = harness.queuedBytes()
harness.dispatcher.notify('pty.data', { id: params.id, data: params.data })
})
// Plain request WITHOUT __streamResponse: single-frame response, as today.
const diffPromise = harness.mux.request('git.diff', {
worktreePath: repoDir,
filePath: 'big.txt',
staged: true
})
// Let the whole single JSON-RPC frame land in the congested pipe.
await waitUntil(() => harness.queuedBytes() > SINK_HIGH_WATER_MARK, 'diff frame queued')
await waitUntilSettled(() => harness.queuedBytes())
harness.mux.notify('pty.data', { id: 'pty-1', data: 'x' })
await waitUntil(() => queuedBytesAheadOfEcho >= 0, 'echo emitted by relay')
// The echo sits behind the entire (multi-hundred-KB) diff frame — far
// more than a single bulk chunk would be.
expect(queuedBytesAheadOfEcho).toBeGreaterThan(512 * 1024)
harness.startAutoDeliver()
const result = (await diffPromise) as { diff?: string }
expect(typeof result).toBe('object')
} finally {
harness.dispose()
}
}, 30_000)
it('WITH opt-in: the diff streams on the bulk lane and the echo stays bounded', async () => {
const harness = createHarness({ congested: true })
try {
let queuedBytesAheadOfEcho = -1
harness.dispatcher.onNotification('pty.data', (params) => {
queuedBytesAheadOfEcho = harness.queuedBytes()
harness.dispatcher.notify('pty.data', { id: params.id, data: params.data })
})
const diffPromise = requestGitStreamable(harness.mux, 'git.diff', {
worktreePath: repoDir,
filePath: 'big.txt',
staged: true
})
// Deliver the sentinel response, then let the pump run into congestion.
await waitUntil(() => harness.queuedBytes() > 0, 'sentinel queued')
harness.deliverAll()
await waitUntil(() => harness.queuedBytes() > 0, 'first chunk queued')
await waitUntilSettled(() => harness.queuedBytes())
harness.mux.notify('pty.data', { id: 'pty-1', data: 'x' })
await waitUntil(() => queuedBytesAheadOfEcho >= 0, 'echo emitted by relay')
// At most one in-flight bulk frame (the write that saturated the sink)
// plus slack sits ahead of the echo.
expect(queuedBytesAheadOfEcho).toBeLessThan(2 * FRAMED_CHUNK_BYTES)
harness.startAutoDeliver()
const streamed = (await diffPromise) as Record<string, unknown>
expect(typeof streamed).toBe('object')
expect(streamed).not.toHaveProperty('__orcaGitResponseStream')
} finally {
harness.dispose()
}
}, 30_000)
it('streamed result equals the single-frame result', async () => {
const harness = createHarness({ congested: false })
try {
harness.startAutoDeliver()
const params = { worktreePath: repoDir, filePath: 'big.txt', staged: true }
// Why: request both concurrently so the shared 1ms delivery pump drives
// the plain frame and the streamed chunk/ack round-trips without either
// starving the other on a slow CI event loop.
const [plain, streamed] = await Promise.all([
harness.mux.request('git.diff', params),
requestGitStreamable(harness.mux, 'git.diff', params)
])
expect(streamed).toEqual(plain)
} finally {
harness.dispose()
}
}, 30_000)
})
+190
View File
@@ -0,0 +1,190 @@
// Streams large git RPC responses (diff family + exec) onto the bulk lane in
// chunks instead of one JSON-RPC frame, so a big diff 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.
import type { RelayDispatcher, RequestContext } from './dispatcher'
import {
GIT_RESPONSE_CHUNK_SIZE,
STREAM_ACK_WINDOW_CHUNKS,
STREAM_ACK_STALL_RECHECK_MS,
type GitResponseStreamMarker
} from './protocol'
type GitResponseStreamEntry = {
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): string[] {
const chunks: string[] = []
for (let offset = 0; offset < payload.length; offset += GIT_RESPONSE_CHUNK_SIZE) {
chunks.push(payload.subarray(offset, offset + GIT_RESPONSE_CHUNK_SIZE).toString('base64'))
}
return chunks
}
export class GitResponseStreamRegistry {
private streams = new Map<number, GitResponseStreamEntry>()
private nextId = 1
private register(): number {
const streamId = this.nextId++
this.streams.set(streamId, { aborted: false, ackedThroughSeq: -1, ackWaiters: new Set() })
return streamId
}
recordAck(streamId: number, seq: number): void {
const entry = this.streams.get(streamId)
if (!entry || typeof seq !== 'number' || !Number.isFinite(seq)) {
return
}
if (seq > entry.ackedThroughSeq) {
entry.ackedThroughSeq = seq
}
this.wake(entry)
}
abort(streamId: number): void {
const entry = this.streams.get(streamId)
if (entry) {
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()
const chunks = encodeChunks(payload)
// 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') {
dispatcher.notify('git.responseEnd', { streamId })
}
} catch (err) {
if (!context.isStale() && !entry.aborted) {
dispatcher.notify('git.responseError', {
streamId,
message: err instanceof Error ? err.message : String(err)
})
}
} finally {
this.streams.delete(streamId)
}
}
disposeAll(): void {
for (const id of Array.from(this.streams.keys())) {
this.abort(id)
}
this.streams.clear()
}
}
+21
View File
@@ -59,6 +59,27 @@ export const STREAM_ACK_WINDOW_CHUNKS = 4
* (and its open file handle) forever. */
export const STREAM_ACK_STALL_RECHECK_MS = 1_000
// ── Git response streaming (see docs/relay-git-response-stream-design.md) ──
/** Serialized-JSON size above which a streamable git response (diff family +
* exec) is chunked onto the bulk lane instead of one JSON-RPC frame, so a large
* diff cannot head-of-line-block interactive pty.data echo on the shared SSH
* channel. Below this, single-frame is cheaper and avoids stream overhead. */
export const GIT_RESPONSE_STREAM_THRESHOLD = 256 * 1024
/** Per-chunk size (UTF-8 bytes of the serialized result) for git response
* streaming. Independent from STREAM_CHUNK_SIZE — this offset math is not
* shared with fs streams, so tuning it here is cross-version safe as long as
* the client reassembles by concatenation (it does not depend on chunk size). */
export const GIT_RESPONSE_CHUNK_SIZE = 128 * 1024
/** Sentinel result returned in place of a large git response: the real payload
* follows as git.responseChunk frames on the bulk lane. Old relays never emit
* this, so a new client falls back to the plain result they return. */
export type GitResponseStreamMarker = {
__orcaGitResponseStream: { streamId: number; totalBytes: number; chunkCount: number }
}
export const RelayErrorCode = {
TooManyStreams: -33006,
StreamProtocolError: -33007
+2 -4
View File
@@ -454,10 +454,7 @@ async function main(): Promise<void> {
const ptyHandler = new PtyHandler(dispatcher, graceTimeMs)
const fsHandler = new FsHandler(dispatcher, context)
// Why: GitHandler registers its own request handlers on construction,
// so we hold the reference only for potential future disposal.
const _gitHandler = new GitHandler(dispatcher, context)
void _gitHandler
const gitHandler = new GitHandler(dispatcher, context)
const _preflightHandler = new PreflightHandler(dispatcher)
const _externalAutomationsHandler = new ExternalAutomationsHandler(dispatcher)
@@ -1013,6 +1010,7 @@ async function main(): Promise<void> {
dispatcher.dispose()
ptyHandler.dispose()
fsHandler.dispose()
gitHandler.dispose()
hookServer.stop()
// Why: Node's Unix server.close() can unlink the listen path. If the path
// was externally removed and rebound by a newer relay, closing this older