[Perf-LH] Serialize relay JSON payloads once per publication (#13516)

* perf(relay): reuse serialized JSON payloads

* Defer bulk relay payload preparation until admission
This commit is contained in:
Neil
2026-08-10 01:17:14 -07:00
committed by GitHub
parent d305e48547
commit 131010277c
9 changed files with 775 additions and 98 deletions
@@ -0,0 +1,166 @@
import { performance } from 'node:perf_hooks'
import { platform, release } from 'node:os'
import {
HEADER_LENGTH,
MAX_MESSAGE_SIZE,
MessageType,
encodeFrame,
encodePreparedJsonRpcFrame,
prepareJsonRpcPayload,
type JsonRpcNotification
} from '../../src/relay/protocol'
type BenchmarkCase = {
name: string
message: JsonRpcNotification
targets: number
iterations: number
}
type BenchmarkResult = {
name: string
targets: number
payloadBytes: number
legacyMicros: number
preparedMicros: number
reductionPercent: number
speedup: number
}
const SAMPLES = 17
let checksum = 0
function encodeLegacyJsonRpcFrame(message: JsonRpcNotification, id: number, ack: number): Buffer {
const payload = Buffer.from(JSON.stringify(message), 'utf8')
if (payload.length > MAX_MESSAGE_SIZE) {
throw new Error(`Message too large: ${payload.length} bytes`)
}
return encodeFrame(MessageType.Regular, id, ack, payload)
}
function runLegacy(message: JsonRpcNotification, targets: number, iteration: number): void {
const estimated = encodeLegacyJsonRpcFrame(message, 0, 0).length
checksum = (checksum + estimated) >>> 0
for (let target = 0; target < targets; target++) {
consume(encodeLegacyJsonRpcFrame(message, iteration + target + 1, iteration))
}
}
function runPrepared(message: JsonRpcNotification, targets: number, iteration: number): void {
const payload = prepareJsonRpcPayload(message)
checksum = (checksum + HEADER_LENGTH + payload.byteLength) >>> 0
for (let target = 0; target < targets; target++) {
consume(encodePreparedJsonRpcFrame(payload, iteration + target + 1, iteration))
}
}
function consume(frame: Buffer): void {
checksum = (checksum + frame.length + frame[0] + frame.at(-1)!) >>> 0
}
function measure(operation: (iteration: number) => void, iterations: number): number {
const startedAt = performance.now()
for (let iteration = 0; iteration < iterations; iteration++) {
operation(iteration)
}
return ((performance.now() - startedAt) * 1000) / iterations
}
function median(values: readonly number[]): number {
const sorted = [...values].sort((left, right) => left - right)
return sorted[Math.floor(sorted.length / 2)]
}
function benchmarkCase(entry: BenchmarkCase): BenchmarkResult {
const legacy = (iteration: number): void => runLegacy(entry.message, entry.targets, iteration)
const prepared = (iteration: number): void => runPrepared(entry.message, entry.targets, iteration)
for (let warmup = 0; warmup < 3; warmup++) {
measure(legacy, Math.min(20, entry.iterations))
measure(prepared, Math.min(20, entry.iterations))
}
const legacySamples: number[] = []
const preparedSamples: number[] = []
for (let sample = 0; sample < SAMPLES; sample++) {
if (sample % 2 === 0) {
legacySamples.push(measure(legacy, entry.iterations))
preparedSamples.push(measure(prepared, entry.iterations))
} else {
preparedSamples.push(measure(prepared, entry.iterations))
legacySamples.push(measure(legacy, entry.iterations))
}
}
const legacyMicros = median(legacySamples)
const preparedMicros = median(preparedSamples)
return {
name: entry.name,
targets: entry.targets,
payloadBytes: Buffer.byteLength(JSON.stringify(entry.message)),
legacyMicros,
preparedMicros,
reductionPercent: ((legacyMicros - preparedMicros) / legacyMicros) * 100,
speedup: legacyMicros / preparedMicros
}
}
function terminalData(bytes: number): string {
const row = '\u001b[38;5;45mcompile src/renderer/pane.ts\u001b[0m\r\n'
return row.repeat(Math.ceil(bytes / row.length)).slice(0, bytes)
}
function watcherEvents(count: number): {
kind: string
absolutePath: string
isDirectory: boolean
}[] {
return Array.from({ length: count }, (_, index) => ({
kind: index % 7 === 0 ? 'create' : 'update',
absolutePath: `/repo/src/features/terminal/generated-${String(index).padStart(4, '0')}.ts`,
isDirectory: false
}))
}
const ptyMessage: JsonRpcNotification = {
jsonrpc: '2.0',
method: 'pty.data',
params: { id: 'pty-42', data: terminalData(16 * 1024), seq: 18_432, rawLength: 16 * 1024 }
}
const watcherMessage: JsonRpcNotification = {
jsonrpc: '2.0',
method: 'fs.changed',
params: { events: watcherEvents(256) }
}
const fileMessage: JsonRpcNotification = {
jsonrpc: '2.0',
method: 'fs.streamChunk',
params: {
streamId: 37,
seq: 11,
data: Buffer.alloc(256 * 1024, 0x61).toString('base64')
}
}
const cases: BenchmarkCase[] = [
{ name: 'PTY 16KiB', message: ptyMessage, targets: 1, iterations: 800 },
{ name: 'PTY 16KiB', message: ptyMessage, targets: 2, iterations: 600 },
{ name: 'watcher 256 events', message: watcherMessage, targets: 1, iterations: 400 },
{ name: 'watcher 256 events', message: watcherMessage, targets: 2, iterations: 300 },
{ name: 'file 256KiB raw', message: fileMessage, targets: 1, iterations: 50 },
{ name: 'file 256KiB raw', message: fileMessage, targets: 2, iterations: 35 }
]
const results = cases.map(benchmarkCase)
const lines = [
`relay JSON payload benchmark: Node ${process.version}, ${platform()} ${release()}, ${SAMPLES} interleaved samples`,
'case | targets | JSON bytes | legacy median us/op | prepared median us/op | reduction | speedup',
...results.map(
(result) =>
`${result.name} | ${result.targets} | ${result.payloadBytes} | ` +
`${result.legacyMicros.toFixed(2)} | ${result.preparedMicros.toFixed(2)} | ` +
`${result.reductionPercent.toFixed(1)}% | ${result.speedup.toFixed(2)}x`
),
`checksum=${checksum}`,
'Limitations: in-process framing only; excludes dispatcher scheduling, sink/network I/O, and tail-GC.',
'Legacy is the exact pre-change framing composition retained in this script, not a separate checkout.',
'The two-target rows model dispatcher fan-out; watcher batches and file streams are commonly single-target.'
]
process.stdout.write(`${lines.join('\n')}\n`)
+20 -1
View File
@@ -115,6 +115,13 @@ export type JsonRpcNotification = {
export type JsonRpcMessage = JsonRpcRequest | JsonRpcResponse | JsonRpcNotification
const JSON_RPC_PAYLOAD_BYTES = Symbol('jsonRpcPayloadBytes')
export type PreparedJsonRpcPayload = Readonly<{
byteLength: number
[JSON_RPC_PAYLOAD_BYTES]: Buffer
}>
// ── Framing: encode / decode ────────────────────────────────────────
/**
@@ -141,11 +148,23 @@ export function encodeFrame(
}
export function encodeJsonRpcFrame(msg: JsonRpcMessage, id: number, ack: number): Buffer {
return encodePreparedJsonRpcFrame(prepareJsonRpcPayload(msg), id, ack)
}
export function prepareJsonRpcPayload(msg: JsonRpcMessage): PreparedJsonRpcPayload {
const payload = Buffer.from(JSON.stringify(msg), 'utf-8')
if (payload.length > MAX_MESSAGE_SIZE) {
throw new Error(`Message too large: ${payload.length} bytes (max ${MAX_MESSAGE_SIZE})`)
}
return encodeFrame(MessageType.Regular, id, ack, payload)
return Object.freeze({ byteLength: payload.length, [JSON_RPC_PAYLOAD_BYTES]: payload })
}
export function encodePreparedJsonRpcFrame(
payload: PreparedJsonRpcPayload,
id: number,
ack: number
): Buffer {
return encodeFrame(MessageType.Regular, id, ack, payload[JSON_RPC_PAYLOAD_BYTES])
}
export function encodeKeepAliveFrame(id: number, ack: number): Buffer {
@@ -9,16 +9,15 @@ import { LEGACY_CLIENT_RETAINED_BYTES_LOW } from './legacy-relay-publication-led
import type * as ProtocolModule from './protocol'
import { encodeJsonRpcFrame, RelayErrorCode } from './protocol'
// Counts every frame encode (including the estimate-only ones) so a redundant re-encode is observable.
const encodeCalls = vi.hoisted(() => ({ count: 0 }))
const preparationCalls = vi.hoisted(() => ({ count: 0 }))
vi.mock('./protocol', async (importOriginal) => {
const actual = await importOriginal<typeof ProtocolModule>()
return {
...actual,
encodeJsonRpcFrame: (...args: Parameters<typeof actual.encodeJsonRpcFrame>) => {
encodeCalls.count++
return actual.encodeJsonRpcFrame(...args)
prepareJsonRpcPayload: (...args: Parameters<typeof actual.prepareJsonRpcPayload>) => {
preparationCalls.count++
return actual.prepareJsonRpcPayload(...args)
}
}
})
@@ -344,7 +343,7 @@ describe('RelayDispatcher bounded-capacity degradation', () => {
}
})
it('does not re-encode a dropped frame when its log line is suppressed', () => {
it('prepares a dropped frame once when its log line is suppressed', () => {
const primary = makeBoundedClient(16384)
const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true)
const bounded = new RelayDispatcher(primary.write, primary.options)
@@ -353,11 +352,10 @@ describe('RelayDispatcher bounded-capacity degradation', () => {
bounded.notify('fs.changed', flood)
expect(stderr).toHaveBeenCalledTimes(1)
// The suppressed drop must size the frame once, not once to publish and again to log.
encodeCalls.count = 0
preparationCalls.count = 0
bounded.notify('fs.changed', flood)
expect(stderr).toHaveBeenCalledTimes(1)
expect(encodeCalls.count).toBe(1)
expect(preparationCalls.count).toBe(1)
} finally {
stderr.mockRestore()
bounded.dispose()
+328
View File
@@ -0,0 +1,328 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type * as ProtocolModule from './protocol'
import {
RelayDispatcher,
type RelayClientSinkOptions,
type SinkWriteSettlement
} from './dispatcher'
import { encodeKeepAliveFrame, type JsonRpcNotification } from './protocol'
const protocolCalls = vi.hoisted(() => ({ preparations: 0, encodes: 0 }))
vi.mock('./protocol', async (importOriginal) => {
const actual = await importOriginal<typeof ProtocolModule>()
return {
...actual,
prepareJsonRpcPayload: (...args: Parameters<typeof actual.prepareJsonRpcPayload>) => {
protocolCalls.preparations++
return actual.prepareJsonRpcPayload(...args)
},
encodePreparedJsonRpcFrame: (...args: Parameters<typeof actual.encodePreparedJsonRpcFrame>) => {
protocolCalls.encodes++
return actual.encodePreparedJsonRpcFrame(...args)
}
}
})
type DecodedFrame = {
id: number
ack: number
message: JsonRpcNotification
payload: Buffer
}
function decodeFrame(frame: Buffer): DecodedFrame {
const length = frame.readUInt32BE(9)
const payload = frame.subarray(13, 13 + length)
return {
id: frame.readUInt32BE(1),
ack: frame.readUInt32BE(5),
message: JSON.parse(payload.toString('utf8')),
payload
}
}
class DrainSink {
readonly frames: Buffer[] = []
private readonly drainWaiters = new Set<() => void>()
private writableBytes = 0
private blocked = true
constructor(
highWaterMark = 4 * 1024 * 1024,
private readonly mutateFrames = false
) {
this.options = {
writableLength: () => this.writableBytes,
writableHighWaterMark: () => highWaterMark,
waitWriteDrain: (callback) => {
this.drainWaiters.add(callback)
return () => this.drainWaiters.delete(callback)
}
}
}
readonly options: RelayClientSinkOptions
write = (data: Buffer): boolean => {
this.frames.push(Buffer.from(data))
if (this.mutateFrames) {
data.fill(0x78, 13)
}
if (!this.blocked) {
return true
}
this.writableBytes = data.length
return false
}
drain(): void {
this.blocked = false
this.writableBytes = 0
for (const callback of Array.from(this.drainWaiters)) {
callback()
}
}
}
describe('RelayDispatcher prepared JSON payloads', () => {
afterEach(() => {
protocolCalls.preparations = 0
protocolCalls.encodes = 0
})
it('shares one payload while allocating independent drain-time sequence and ACK headers', () => {
const primary = new DrainSink(undefined, true)
const secondary = new DrainSink()
const dispatcher = new RelayDispatcher(primary.write, primary.options)
const secondaryId = dispatcher.attachClient(secondary.write, secondary.options)
try {
dispatcher.notify('test.blocker')
dispatcher.notifyClient(1, 'test.primary-only')
protocolCalls.preparations = 0
protocolCalls.encodes = 0
dispatcher.notify('workspace.changed', { revision: 7 })
expect(protocolCalls).toEqual({ preparations: 1, encodes: 0 })
dispatcher.feed(encodeKeepAliveFrame(41, 0))
dispatcher.feedClient(secondaryId, encodeKeepAliveFrame(73, 0))
primary.drain()
secondary.drain()
const primaryShared = primary.frames
.map(decodeFrame)
.find((frame) => frame.message.method === 'workspace.changed')
const secondaryShared = secondary.frames
.map(decodeFrame)
.find((frame) => frame.message.method === 'workspace.changed')
expect(primaryShared).toMatchObject({ id: 3, ack: 41 })
expect(secondaryShared).toMatchObject({ id: 2, ack: 73 })
expect(primaryShared?.payload.equals(secondaryShared!.payload)).toBe(true)
expect(protocolCalls).toEqual({ preparations: 1, encodes: 3 })
} finally {
dispatcher.dispose()
}
})
it('prepares a rejected producer frame once without allocating a header', () => {
const frames: Buffer[] = []
const dispatcher = new RelayDispatcher(
(frame) => {
frames.push(Buffer.from(frame))
return true
},
{ writableLength: () => 0, writableHighWaterMark: () => 1024 }
)
try {
expect(
dispatcher.publishProducerNotification(
1,
'fs.changed',
{ events: [{ path: '/repo/a' }] },
{
logDrop: false
}
)
).toBe(false)
expect(frames).toEqual([])
expect(protocolCalls).toEqual({ preparations: 1, encodes: 0 })
} finally {
dispatcher.dispose()
}
})
it('snapshots wire data and retains only PTY admission identity while queued', () => {
const sink = new DrainSink()
const dispatcher = new RelayDispatcher(sink.write, sink.options)
const params: Record<string, unknown> = {
id: 'pty-1',
data: 'before'.repeat(4096),
deliveryToken: 'token-before',
clientGeneration: 3,
ownerGeneration: 5,
ptyIncarnation: 'incarnation-before'
}
const internals = dispatcher as unknown as {
prepareFrame: (message: JsonRpcNotification) => {
ptyDataAdmissionParams: Readonly<Record<string, unknown>> | null
}
}
const prepared = internals.prepareFrame({ jsonrpc: '2.0', method: 'pty.data', params })
expect(prepared.ptyDataAdmissionParams).toEqual({
id: 'pty-1',
deliveryToken: 'token-before',
clientGeneration: 3,
ownerGeneration: 5,
ptyIncarnation: 'incarnation-before'
})
expect(prepared.ptyDataAdmissionParams).not.toHaveProperty('data')
const admissions: Readonly<Record<string, unknown>>[] = []
dispatcher.registerPtyDataPublicationAdmission((_clientId, admissionParams) => {
admissions.push(admissionParams)
return admissionParams.deliveryToken === 'token-before'
})
dispatcher.notify('test.blocker')
const settled = vi.fn<(result: SinkWriteSettlement) => void>()
expect(dispatcher.tryNotifyPtyDataToClient(1, params, settled)).toBe(true)
admissions.length = 0
params.data = 'after'
params.deliveryToken = 'token-after'
params.clientGeneration = 99
params.ownerGeneration = 100
params.ptyIncarnation = 'incarnation-after'
sink.drain()
const publication = sink.frames
.map(decodeFrame)
.find((frame) => frame.message.method === 'pty.data')
expect(publication?.message.params).toMatchObject({
data: 'before'.repeat(4096),
deliveryToken: 'token-before',
clientGeneration: 3,
ownerGeneration: 5,
ptyIncarnation: 'incarnation-before'
})
expect(admissions).toHaveLength(1)
expect(admissions[0]).not.toHaveProperty('data')
expect(admissions[0]).toMatchObject({ deliveryToken: 'token-before', clientGeneration: 3 })
expect(settled).toHaveBeenCalledExactlyOnceWith({ ok: true })
dispatcher.dispose()
})
it('retires queued PTY data without consuming sequence or losing settlement', () => {
const sink = new DrainSink()
const dispatcher = new RelayDispatcher(sink.write, sink.options)
let admitted = true
dispatcher.registerPtyDataPublicationAdmission(() => admitted)
try {
dispatcher.notify('test.blocker')
const retired = vi.fn<(result: SinkWriteSettlement) => void>()
expect(dispatcher.tryNotifyPtyDataToClient(1, { id: 'pty-1', data: 'retire' }, retired)).toBe(
true
)
admitted = false
sink.drain()
expect(retired).toHaveBeenCalledExactlyOnceWith({
ok: false,
error: expect.objectContaining({ message: 'PTY publication retired' })
})
admitted = true
expect(dispatcher.tryNotifyPtyDataToClient(1, { id: 'pty-1', data: 'next' }, vi.fn())).toBe(
true
)
const published = sink.frames
.map(decodeFrame)
.find((frame) => frame.message.method === 'pty.data')
expect(published).toMatchObject({ id: 2 })
expect(published?.message.params?.data).toBe('next')
} finally {
dispatcher.dispose()
}
})
it('reuses one snapshot when a 256 KiB file frame retries after producer drain', async () => {
const sink = new DrainSink()
const dispatcher = new RelayDispatcher(sink.write, sink.options)
try {
dispatcher.notify('test.blocker', { data: 'x'.repeat(16 * 1024) })
protocolCalls.preparations = 0
protocolCalls.encodes = 0
const originalData = Buffer.alloc(256 * 1024, 0x61).toString('base64')
const params = { streamId: 7, seq: 2, data: originalData }
const pending = dispatcher.notifyBulk('fs.streamChunk', params)
params.data = 'mutated'
await Promise.resolve()
expect(protocolCalls).toEqual({ preparations: 1, encodes: 0 })
sink.drain()
await pending
const chunk = sink.frames
.map(decodeFrame)
.find((frame) => frame.message.method === 'fs.streamChunk')
expect(chunk?.message.params).toEqual({ streamId: 7, seq: 2, data: originalData })
expect(protocolCalls).toEqual({ preparations: 1, encodes: 1 })
} finally {
dispatcher.dispose()
}
})
it('prepares queued 256 KiB bulk payloads only when their chain step becomes active', async () => {
const sink = new DrainSink()
const dispatcher = new RelayDispatcher(sink.write, sink.options)
try {
dispatcher.notify('test.blocker', { data: 'x'.repeat(16 * 1024) })
protocolCalls.preparations = 0
protocolCalls.encodes = 0
const originalData = Buffer.alloc(256 * 1024, 0x61).toString('base64')
const pending = Array.from({ length: 16 }, (_, index) => {
const params = { streamId: index + 1, seq: 1, data: originalData }
const publication = dispatcher.notifyBulk('fs.streamChunk', params)
params.data = 'mutated'
return publication
})
expect(protocolCalls).toEqual({ preparations: 0, encodes: 0 })
await Promise.resolve()
expect(protocolCalls).toEqual({ preparations: 1, encodes: 0 })
sink.drain()
await Promise.all(pending)
const chunks = sink.frames
.map(decodeFrame)
.filter((frame) => frame.message.method === 'fs.streamChunk')
expect(chunks).toHaveLength(16)
expect(chunks.every((frame) => frame.message.params?.data === originalData)).toBe(true)
expect(protocolCalls).toEqual({ preparations: 16, encodes: 16 })
} finally {
dispatcher.dispose()
}
})
it('lazily shares one bulk payload across broadcast clients', async () => {
const primary = new DrainSink()
const secondary = new DrainSink()
primary.drain()
secondary.drain()
const dispatcher = new RelayDispatcher(primary.write, primary.options)
dispatcher.attachClient(secondary.write, secondary.options)
try {
const params = { streamId: 4, seq: 8, data: 'before' }
const pending = dispatcher.notifyBulk('git.responseChunk', params)
params.data = 'after'
expect(protocolCalls).toEqual({ preparations: 0, encodes: 0 })
await pending
expect(decodeFrame(primary.frames[0]).message.params?.data).toBe('before')
expect(decodeFrame(secondary.frames[0]).message.params?.data).toBe('before')
expect(protocolCalls).toEqual({ preparations: 1, encodes: 2 })
} finally {
dispatcher.dispose()
}
})
})
+18 -16
View File
@@ -725,12 +725,18 @@ describe('RelayDispatcher', () => {
type DispatcherInternals = {
primaryClient: object
estimateFrameBytes: (msg: JsonRpcNotification) => number
prepareFrame: (msg: JsonRpcNotification) => object
enqueueFrame: (
client: object,
msg: JsonRpcNotification,
lane: string,
onSettled?: (result: SinkWriteSettlement) => void,
estimatedBytes?: number
onSettled?: (result: SinkWriteSettlement) => void
) => boolean
enqueuePreparedFrame: (
client: object,
frame: object,
lane: string,
onSettled?: (result: SinkWriteSettlement) => void
) => boolean
}
@@ -840,14 +846,14 @@ describe('RelayDispatcher', () => {
}
})
it('publishes PTY data with a single frame estimate', () => {
it('publishes PTY data with a single frame preparation', () => {
const frames: Buffer[] = []
const publisher = new RelayDispatcher((data) => {
frames.push(Buffer.from(data))
return true
})
try {
const spy = vi.spyOn(publisher as unknown as DispatcherInternals, 'estimateFrameBytes')
const spy = vi.spyOn(publisher as unknown as DispatcherInternals, 'prepareFrame')
expect(publisher.tryNotifyPtyData({ id: 'pty-1', data: 'hello' })).toBe(true)
expect(frames).toHaveLength(1)
expect(spy).toHaveBeenCalledTimes(1)
@@ -856,7 +862,7 @@ describe('RelayDispatcher', () => {
}
})
it('enqueueFrame with a caller-supplied estimate matches the computed default', () => {
it('a prepared enqueue matches the composition wrapper', () => {
const frames: Buffer[] = []
const publisher = new RelayDispatcher((data) => {
frames.push(Buffer.from(data))
@@ -871,12 +877,10 @@ describe('RelayDispatcher', () => {
}
expect(internals.enqueueFrame(internals.primaryClient, msg, 'ordinary')).toBe(true)
expect(
internals.enqueueFrame(
internals.enqueuePreparedFrame(
internals.primaryClient,
msg,
'ordinary',
undefined,
internals.estimateFrameBytes(msg)
internals.prepareFrame(msg),
'ordinary'
)
).toBe(true)
expect(frames).toHaveLength(2)
@@ -888,7 +892,7 @@ describe('RelayDispatcher', () => {
}
})
it('enqueueFrame rejects identically with and without a caller-supplied estimate', () => {
it('a prepared enqueue rejects identically to the composition wrapper', () => {
const { sized } = makeDispatcher([1030])
try {
const internals = sized as unknown as DispatcherInternals
@@ -899,12 +903,10 @@ describe('RelayDispatcher', () => {
}
expect(internals.enqueueFrame(internals.primaryClient, msg, 'ordinary')).toBe(false)
expect(
internals.enqueueFrame(
internals.enqueuePreparedFrame(
internals.primaryClient,
msg,
'ordinary',
undefined,
internals.estimateFrameBytes(msg)
internals.prepareFrame(msg),
'ordinary'
)
).toBe(false)
} finally {
+152 -69
View File
@@ -1,16 +1,19 @@
/* eslint-disable max-lines -- dispatcher keeps client routing, cancellation, and framing state together */
import {
FrameDecoder,
HEADER_LENGTH,
MessageType,
encodeJsonRpcFrame,
encodePreparedJsonRpcFrame,
encodeKeepAliveFrame,
parseJsonRpcMessage,
prepareJsonRpcPayload,
KEEPALIVE_SEND_MS,
RelayErrorCode,
type DecodedFrame,
type JsonRpcRequest,
type JsonRpcNotification,
type JsonRpcResponse
type JsonRpcResponse,
type PreparedJsonRpcPayload
} from './protocol'
import { ClientRequestAborts } from './client-request-aborts'
import { MAX_TIMER_DELAY_MS, isSafeTimerDelayMs } from '../shared/timer-delay'
@@ -80,6 +83,14 @@ type RelayClient = {
sessionIdentity: RelayClientSessionIdentity
}
type OutgoingJsonRpcMessage = JsonRpcRequest | JsonRpcResponse | JsonRpcNotification
type PreparedRelayFrame = Readonly<{
payload: PreparedJsonRpcPayload
frameBytes: number
ptyDataAdmissionParams: Readonly<Record<string, unknown>> | null
}>
// Why: the log key set is rebuilt per generation, but a producer minting synthetic method names would still
// grow it inside one generation — cap it well above the fixed relay method vocabulary.
const DROPPED_NOTIFICATION_LOG_KEY_LIMIT = 64
@@ -571,7 +582,8 @@ export class RelayDispatcher {
method,
...(params !== undefined ? { params } : {})
}
const frameBytes = this.estimateFrameBytes(msg)
const frame = this.prepareFrame(msg)
const frameBytes = frame.frameBytes
this.runPublicationTransaction(() => {
for (const client of this.clients.values()) {
if (client.closed) {
@@ -583,12 +595,12 @@ export class RelayDispatcher {
if (method === 'pty.replay') {
// Why: replay is never re-sent, so it takes the control lane where overflow is fatal — the
// writer closes the client and reconnect reloads history rather than stranding a short buffer.
this.enqueueFrame(client, msg, 'control', undefined, frameBytes)
this.enqueuePreparedFrame(client, frame, 'control')
continue
}
// Why: closing can never make an oversized frame sendable — the producer regenerates it after
// reattach and re-kills the link, turning a recoverable drop into an endless reconnect loop.
if (!this.publishToClient(client, msg, 'ordinary', undefined, frameBytes)) {
if (!this.publishPreparedToClient(client, frame, 'ordinary')) {
this.logDroppedProducerNotification(client, method, frameBytes)
}
}
@@ -620,13 +632,13 @@ export class RelayDispatcher {
if (method === 'pty.data' && !this.admitsPtyDataPublication(client.id, params ?? {})) {
return false
}
const frameBytes = this.estimateFrameBytes(msg)
if (this.publishToClient(client, msg, 'ordinary', undefined, frameBytes)) {
const frame = this.prepareFrame(msg)
if (this.publishPreparedToClient(client, frame, 'ordinary')) {
return true
}
// Why: same diagnostics as notify() — a producer that drops here must not do so silently.
if (options?.logDrop !== false) {
this.logDroppedProducerNotification(client, method, frameBytes)
this.logDroppedProducerNotification(client, method, frame.frameBytes)
}
return false
}
@@ -664,7 +676,6 @@ export class RelayDispatcher {
onSettled: (result: SinkWriteSettlement) => void = () => {},
options: {
controlOverflow?: 'close-client' | 'reject'
estimatedBytes?: number
} = {}
): boolean {
if (this.disposed) {
@@ -685,7 +696,6 @@ export class RelayDispatcher {
},
'control',
onSettled,
options.estimatedBytes,
options.controlOverflow
)
}
@@ -699,8 +709,13 @@ export class RelayDispatcher {
method,
...(params !== undefined ? { params } : {})
}
for (const client of this.activeClients()) {
if (!this.enqueueFrame(client, msg, 'control')) {
const clients = this.activeClients()
if (clients.length === 0) {
return
}
const frame = this.prepareFrame(msg)
for (const client of clients) {
if (!this.enqueuePreparedFrame(client, frame, 'control')) {
this.closeClient(
client,
new Error('Relay control publication capacity exceeded'),
@@ -724,27 +739,48 @@ export class RelayDispatcher {
if (this.disposed) {
return Promise.resolve()
}
const msg: JsonRpcNotification = {
jsonrpc: '2.0',
method,
...(params !== undefined ? { params } : {})
}
const targets =
opts?.clientId !== undefined
? [this.clients.get(opts.clientId)].filter((c): c is RelayClient => c !== undefined)
: Array.from(this.clients.values())
const waits: Promise<void>[] = []
for (const client of targets) {
if (client.closed) {
continue
const activeTargets = targets.filter((client) => !client.closed)
if (activeTargets.length === 0) {
return Promise.resolve()
}
const msg: JsonRpcNotification = {
jsonrpc: '2.0',
method,
...(params !== undefined ? { params: { ...params } } : {})
}
let prepared:
| { ok: true; frame: PreparedRelayFrame }
| { ok: false; error: unknown }
| undefined
const prepareOnce = (): PreparedRelayFrame => {
if (!prepared) {
try {
prepared = { ok: true, frame: this.prepareFrame(msg) }
} catch (error) {
prepared = { ok: false, error }
}
}
const step = client.bulkChain.then(() => this.publishBulkWhenAvailable(client, msg))
if (!prepared.ok) {
throw prepared.error
}
return prepared.frame
}
const lane = method === 'fs.streamChunk' ? 'fixed-bulk' : 'bulk'
const waits: Promise<void>[] = []
for (const client of activeTargets) {
const step = client.bulkChain.then(() => {
if (this.disposed || client.closed) {
return
}
return this.publishBulkWhenAvailable(client, prepareOnce(), lane)
})
client.bulkChain = step.catch(() => {})
waits.push(step)
}
if (waits.length === 0) {
return Promise.resolve()
}
return Promise.all(waits).then(() => {})
}
@@ -801,7 +837,7 @@ export class RelayDispatcher {
reject(new Error(`Request "${method}" timed out after ${timeoutMs}ms`))
}, timeoutMs)
this.pendingRelayRequests.set(id, { resolve, reject, timer })
if (!this.enqueueFrame(client, msg, 'control', () => {}, undefined, 'reject')) {
if (!this.enqueueFrame(client, msg, 'control', () => {}, 'reject')) {
clearTimeout(timer)
this.pendingRelayRequests.delete(id)
reject(new Error(`Request "${method}" exceeded the relay control transport capacity`))
@@ -1048,24 +1084,25 @@ export class RelayDispatcher {
id,
...(error ? { error } : { result: result ?? null })
}
const estimatedBytes = this.estimateFrameBytes(msg)
const lane = estimatedBytes > DISPATCHER_CONTROL_QUEUE_MAX_BYTES ? 'legacy-response' : 'control'
const accepted = this.enqueueFrame(client, msg, lane, onSettled)
const frame = this.prepareFrame(msg)
const lane =
frame.frameBytes > DISPATCHER_CONTROL_QUEUE_MAX_BYTES ? 'legacy-response' : 'control'
const accepted = this.enqueuePreparedFrame(client, frame, lane, onSettled)
if (accepted) {
return true
}
// Why: an oversized response must fail its own request; closing would kill every pane on the host.
// A rejected first enqueue either left onSettled untouched or closed the client, so exactly one settlement happens.
return this.enqueueFrame(
return this.enqueuePreparedFrame(
client,
{
this.prepareFrame({
jsonrpc: '2.0',
id,
error: {
code: RelayErrorCode.ResponseOverCapacity,
message: RESPONSE_OVER_CAPACITY_MESSAGE
}
},
}),
'control',
// Why: writing the substitute is not delivering the result — a settlement fence must never read
// the capacity error's successful write as "the peer received your result".
@@ -1080,29 +1117,45 @@ export class RelayDispatcher {
private enqueueFrame(
client: RelayClient,
msg: JsonRpcRequest | JsonRpcResponse | JsonRpcNotification,
msg: OutgoingJsonRpcMessage,
lane: DispatcherWriterLane,
onSettled: (result: SinkWriteSettlement) => void = () => {},
controlOverflow: 'close-client' | 'reject' = 'close-client'
): boolean {
if (this.disposed || client.closed) {
return false
}
return this.enqueuePreparedFrame(
client,
this.prepareFrame(msg),
lane,
onSettled,
controlOverflow
)
}
private enqueuePreparedFrame(
client: RelayClient,
frame: PreparedRelayFrame,
lane: DispatcherWriterLane,
onSettled: (result: SinkWriteSettlement) => void = () => {},
// Why: publish paths already sized the frame; avoid a redundant encode.
estimatedBytes?: number,
controlOverflow: 'close-client' | 'reject' = 'close-client'
): boolean {
if (this.disposed || client.closed) {
return false
}
const frameBytes = estimatedBytes ?? this.estimateFrameBytes(msg)
const encode = (): Buffer => {
const seq = client.nextOutgoingSeq++
return encodeJsonRpcFrame(msg, seq, client.highestReceivedSeq)
return encodePreparedJsonRpcFrame(frame.payload, seq, client.highestReceivedSeq)
}
const isStillAdmitted =
'method' in msg && msg.method === 'pty.data'
? () => this.admitsPtyDataPublication(client.id, msg.params ?? {})
: undefined
const admissionParams = frame.ptyDataAdmissionParams
const isStillAdmitted = admissionParams
? () => this.admitsPtyDataPublication(client.id, admissionParams)
: undefined
return client.writer.enqueue(
lane,
encode,
frameBytes,
frame.frameBytes,
onSettled,
lane === 'control' && controlOverflow === 'reject',
isStillAdmitted
@@ -1151,8 +1204,27 @@ export class RelayDispatcher {
return `${client.id}:${client.generation}`
}
private estimateFrameBytes(msg: JsonRpcRequest | JsonRpcResponse | JsonRpcNotification): number {
return encodeJsonRpcFrame(msg, 0, 0).length
private prepareFrame(msg: OutgoingJsonRpcMessage): PreparedRelayFrame {
const payload = prepareJsonRpcPayload(msg)
const params = 'method' in msg && msg.method === 'pty.data' ? (msg.params ?? {}) : null
return Object.freeze({
payload,
frameBytes: HEADER_LENGTH + payload.byteLength,
ptyDataAdmissionParams:
params === null
? null
: Object.freeze({
id: params.id,
deliveryToken: params.deliveryToken,
clientGeneration: params.clientGeneration,
ownerGeneration: params.ownerGeneration,
ptyIncarnation: params.ptyIncarnation
})
})
}
private estimateFrameBytes(msg: OutgoingJsonRpcMessage): number {
return HEADER_LENGTH + prepareJsonRpcPayload(msg).byteLength
}
private tryPublishToClients(
@@ -1164,7 +1236,8 @@ export class RelayDispatcher {
if (clients.length === 0) {
return true
}
const bytes = this.estimateFrameBytes(msg)
const frame = this.prepareFrame(msg)
const bytes = frame.frameBytes
if (clients.some((client) => !client.writer.canEnqueueProducer(bytes))) {
return false
}
@@ -1175,7 +1248,7 @@ export class RelayDispatcher {
return false
}
for (let index = 0; index < clients.length; index++) {
if (!this.enqueueLeasedFrame(clients[index], msg, lane, leases[index], bytes)) {
if (!this.enqueueLeasedFrame(clients[index], frame, lane, leases[index])) {
if (this.disposed || clients[index].closed) {
continue
}
@@ -1195,8 +1268,12 @@ export class RelayDispatcher {
lane: 'interactive' | 'ordinary'
): boolean {
return this.runPublicationTransaction(() => {
if (clients.length === 0) {
return true
}
const frame = this.prepareFrame(msg)
for (const client of clients) {
if (client.closed || this.publishToClient(client, msg, lane)) {
if (client.closed || this.publishPreparedToClient(client, frame, lane)) {
continue
}
this.closeClient(
@@ -1213,11 +1290,21 @@ export class RelayDispatcher {
client: RelayClient,
msg: JsonRpcNotification,
lane: 'interactive' | 'ordinary' | 'fixed-bulk' | 'bulk',
onSettled: (result: SinkWriteSettlement) => void = () => {},
// Why: broadcast callers size the frame once for every client; avoid a redundant encode.
estimatedBytes?: number
onSettled: (result: SinkWriteSettlement) => void = () => {}
): boolean {
const bytes = estimatedBytes ?? this.estimateFrameBytes(msg)
if (this.disposed || client.closed) {
return false
}
return this.publishPreparedToClient(client, this.prepareFrame(msg), lane, onSettled)
}
private publishPreparedToClient(
client: RelayClient,
frame: PreparedRelayFrame,
lane: 'interactive' | 'ordinary' | 'fixed-bulk' | 'bulk',
onSettled: (result: SinkWriteSettlement) => void = () => {}
): boolean {
const bytes = frame.frameBytes
const fixedBlocked =
lane === 'fixed-bulk' &&
(client.writer.retainedProducerBytes > 0 || bytes > client.writer.fixedFrameCapacity)
@@ -1228,12 +1315,15 @@ export class RelayDispatcher {
if (!leases) {
return false
}
return this.enqueueLeasedFrame(client, msg, lane, leases[0], bytes, onSettled)
return this.enqueueLeasedFrame(client, frame, lane, leases[0], onSettled)
}
private publishBulkWhenAvailable(client: RelayClient, msg: JsonRpcNotification): Promise<void> {
const bytes = this.estimateFrameBytes(msg)
const lane = msg.method === 'fs.streamChunk' ? 'fixed-bulk' : 'bulk'
private publishBulkWhenAvailable(
client: RelayClient,
frame: PreparedRelayFrame,
lane: 'fixed-bulk' | 'bulk'
): Promise<void> {
const bytes = frame.frameBytes
if (bytes > DEFAULT_PRODUCER_QUEUE_MAX_BYTES) {
return Promise.reject(new Error('Relay bulk frame exceeds sink producer capacity'))
}
@@ -1253,7 +1343,7 @@ export class RelayDispatcher {
return
}
if (
this.publishToClient(client, msg, lane, (result) => {
this.publishPreparedToClient(client, frame, lane, (result) => {
finish()
if (result.ok || this.disposed || client.closed) {
resolve()
@@ -1274,23 +1364,16 @@ export class RelayDispatcher {
private enqueueLeasedFrame(
client: RelayClient,
msg: JsonRpcNotification,
frame: PreparedRelayFrame,
lane: 'interactive' | 'ordinary' | 'fixed-bulk' | 'bulk',
lease: LegacyPublicationLease,
estimatedBytes: number,
onSettled: (result: SinkWriteSettlement) => void = () => {}
): boolean {
const accepted = this.enqueueFrame(
client,
msg,
lane,
(result) => {
lease.release()
onSettled(result)
this.notifyLegacyCapacityIfLow()
},
estimatedBytes
)
const accepted = this.enqueuePreparedFrame(client, frame, lane, (result) => {
lease.release()
onSettled(result)
this.notifyLegacyCapacityIfLow()
})
if (!accepted) {
lease.release()
this.notifyLegacyCapacityIfLow()
+62
View File
@@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest'
import * as mainProtocol from '../main/ssh/relay-protocol'
import {
HEADER_LENGTH,
MAX_MESSAGE_SIZE,
MessageType,
encodeFrame,
encodeJsonRpcFrame,
encodePreparedJsonRpcFrame,
prepareJsonRpcPayload,
type JsonRpcNotification
} from './protocol'
describe('prepared relay JSON payload framing', () => {
it('is byte-equivalent to direct composition for every header field', () => {
const message: JsonRpcNotification = {
jsonrpc: '2.0',
method: 'pty.data',
params: { id: 'pty-1', data: 'héllo "𝄞"\\\n\uD800', seq: 42 }
}
const payload = Buffer.from(JSON.stringify(message), 'utf8')
const relayPrepared = prepareJsonRpcPayload(message)
const mainPrepared = mainProtocol.prepareJsonRpcPayload(message)
for (const [id, ack] of [
[0, 0],
[19, 7],
[0xffffffff, 0xfffffffe]
]) {
const reference = encodeFrame(MessageType.Regular, id, ack, payload)
expect(encodePreparedJsonRpcFrame(relayPrepared, id, ack).equals(reference)).toBe(true)
expect(encodeJsonRpcFrame(message, id, ack).equals(reference)).toBe(true)
expect(mainProtocol.encodePreparedJsonRpcFrame(mainPrepared, id, ack).equals(reference)).toBe(
true
)
expect(mainProtocol.encodeJsonRpcFrame(message, id, ack).equals(reference)).toBe(true)
}
})
it('accepts the exact payload maximum and rejects one byte more', () => {
const base: JsonRpcNotification = {
jsonrpc: '2.0',
method: 'x',
params: { data: '' }
}
const overhead = Buffer.byteLength(JSON.stringify(base))
const exact = { ...base, params: { data: 'a'.repeat(MAX_MESSAGE_SIZE - overhead) } }
const oversized = { ...base, params: { data: 'a'.repeat(MAX_MESSAGE_SIZE - overhead + 1) } }
const relayPrepared = prepareJsonRpcPayload(exact)
const mainPrepared = mainProtocol.prepareJsonRpcPayload(exact)
expect(relayPrepared.byteLength).toBe(MAX_MESSAGE_SIZE)
expect(mainPrepared.byteLength).toBe(MAX_MESSAGE_SIZE)
expect(encodePreparedJsonRpcFrame(relayPrepared, 1, 0)).toHaveLength(
HEADER_LENGTH + MAX_MESSAGE_SIZE
)
expect(() => prepareJsonRpcPayload(oversized)).toThrow('Message too large')
expect(() => mainProtocol.prepareJsonRpcPayload(oversized)).toThrow('Message too large')
expect(() => encodeJsonRpcFrame(oversized, 1, 0)).toThrow('Message too large')
expect(() => mainProtocol.encodeJsonRpcFrame(oversized, 1, 0)).toThrow('Message too large')
})
})
+20 -1
View File
@@ -130,6 +130,13 @@ export type JsonRpcNotification = {
export type JsonRpcMessage = JsonRpcRequest | JsonRpcResponse | JsonRpcNotification
const JSON_RPC_PAYLOAD_BYTES = Symbol('jsonRpcPayloadBytes')
export type PreparedJsonRpcPayload = Readonly<{
byteLength: number
[JSON_RPC_PAYLOAD_BYTES]: Buffer
}>
export function encodeFrame(
type: number,
id: number,
@@ -145,11 +152,23 @@ export function encodeFrame(
}
export function encodeJsonRpcFrame(msg: JsonRpcMessage, id: number, ack: number): Buffer {
return encodePreparedJsonRpcFrame(prepareJsonRpcPayload(msg), id, ack)
}
export function prepareJsonRpcPayload(msg: JsonRpcMessage): PreparedJsonRpcPayload {
const payload = Buffer.from(JSON.stringify(msg), 'utf-8')
if (payload.length > MAX_MESSAGE_SIZE) {
throw new Error(`Message too large: ${payload.length} bytes`)
}
return encodeFrame(MessageType.Regular, id, ack, payload)
return Object.freeze({ byteLength: payload.length, [JSON_RPC_PAYLOAD_BYTES]: payload })
}
export function encodePreparedJsonRpcFrame(
payload: PreparedJsonRpcPayload,
id: number,
ack: number
): Buffer {
return encodeFrame(MessageType.Regular, id, ack, payload[JSON_RPC_PAYLOAD_BYTES])
}
export function encodeKeepAliveFrame(id: number, ack: number): Buffer {
+2 -2
View File
@@ -104,7 +104,7 @@ function publishWatcherBatchToClient(
const publish = (events: readonly MappedWatcherEvent[]): boolean =>
dispatcher.publishProducerNotification(clientId, 'fs.changed', { events })
// Fast path: publish the whole batch first — two encodes, the same cost as an unchunked emit.
// Fast path: publish the whole batch before paying to group or size individual events.
// logDrop:false because rejection here is a measurement, not an outcome: the batch is re-sent in
// chunks below, so logging it would report a drop for events that all arrive.
if (
@@ -244,7 +244,7 @@ function publishOverflowMarker(
// it through onClientDetached, which fires after this settlement.
retainOverflowMarker(dispatcher, state, clientId, rootPath, frameBytes)
},
{ controlOverflow: 'reject', estimatedBytes: frameBytes }
{ controlOverflow: 'reject' }
)
if (accepted || settled) {
return