mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 16:02:50 +00:00
parseHandshakeMessage returned whatever JSON.parse produced, and the daemon interpolates the peer's version into a log line before any credential check. A version that is an object with a non-callable toString throws TypeError there, inside the frame-decoder callback. FrameDecoder.drainTurn wrapped its synchronous dispatch in try/finally with no catch, so the throw escaped feed(), escaped the socket data handler, and reached uncaughtException: the relay daemon exited and every PTY and agent session it held died with it. Two layers, because only the second closes the class: - parseHandshakeMessage now requires the string fields each arm carries (version; expected/got) and rejects a non-object payload. Both readers share the parser, so neither side can interpolate a non-string again. - FrameDecoder contains a frame owner that throws on the synchronous turn the same way it already contained one on a continuation turn: reset the residue and report one FrameDecoderContinuationError to onError. Every owner's onError already closes its own connection, so any future throw of this shape costs one connection instead of the process. The relay CLI channel gains an explicit onError so a malformed reply still ends that one-shot command instead of parking it.
275 lines
8.2 KiB
TypeScript
275 lines
8.2 KiB
TypeScript
import {
|
|
containFrameDecoderContinuation,
|
|
publishFrameDecoderError,
|
|
type DecodedFrame,
|
|
type FrameDecoderOptions
|
|
} from './relay-frame-decoder-contract'
|
|
import { RelayFrameBuffer } from './relay-frame-buffer'
|
|
export {
|
|
FrameDecoderContinuationError,
|
|
type DecodedFrame,
|
|
type FrameDecoderOptions
|
|
} from './relay-frame-decoder-contract'
|
|
|
|
export const HEADER_LENGTH = 13
|
|
export const MAX_MESSAGE_SIZE = 16 * 1024 * 1024
|
|
export const FRAME_DECODER_MAX_FRAMES_PER_TURN = 64
|
|
export const FRAME_DECODER_MAX_BYTES_PER_TURN = MAX_MESSAGE_SIZE + HEADER_LENGTH
|
|
export const FRAME_DECODER_MAX_TURN_MS = 4,
|
|
FRAME_DECODER_MAX_RETAINED_BYTES = MAX_MESSAGE_SIZE + HEADER_LENGTH + 1024 * 1024
|
|
|
|
export class FrameDecoder {
|
|
private readonly buffer = new RelayFrameBuffer()
|
|
private oversizedPayloadBytesRemaining = 0
|
|
private onFrame: (frame: DecodedFrame) => void
|
|
private onError: ((err: Error) => void) | null
|
|
private maxFramesPerTurn: number
|
|
private maxBytesPerTurn: number
|
|
private maxTurnMs: number
|
|
private now: () => number
|
|
private schedule: (callback: () => void) => unknown
|
|
private cancelScheduled: (handle: unknown) => void
|
|
private pause: (() => void) | null
|
|
private resume: (() => void) | null
|
|
private continuationHandle: unknown
|
|
private continuationHandleAssigned = false
|
|
private continuationScheduled = false
|
|
private paused = false
|
|
private draining = false
|
|
private generation = 0
|
|
|
|
constructor(
|
|
onFrame: (frame: DecodedFrame) => void,
|
|
onError?: (err: Error) => void,
|
|
options: FrameDecoderOptions = {}
|
|
) {
|
|
this.onFrame = onFrame
|
|
this.onError = onError ?? null
|
|
this.maxFramesPerTurn = positiveLimit(
|
|
options.maxFramesPerTurn,
|
|
FRAME_DECODER_MAX_FRAMES_PER_TURN
|
|
)
|
|
this.maxBytesPerTurn = positiveLimit(options.maxBytesPerTurn, FRAME_DECODER_MAX_BYTES_PER_TURN)
|
|
this.maxTurnMs = positiveLimit(options.maxTurnMs, FRAME_DECODER_MAX_TURN_MS)
|
|
this.now = options.now ?? Date.now
|
|
this.schedule = options.schedule ?? ((callback) => setImmediate(callback))
|
|
this.cancelScheduled =
|
|
options.cancelScheduled ?? ((handle) => clearImmediate(handle as NodeJS.Immediate))
|
|
this.pause = options.pause ?? null
|
|
this.resume = options.resume ?? null
|
|
}
|
|
|
|
feed(chunk: Buffer | Uint8Array): void {
|
|
const buf = Buffer.isBuffer(chunk)
|
|
? chunk
|
|
: Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength)
|
|
const retained = this.buffer.length + buf.length
|
|
if (retained > FRAME_DECODER_MAX_RETAINED_BYTES) {
|
|
this.reset()
|
|
publishFrameDecoderError(
|
|
this.onError,
|
|
new Error(`Frame decoder retained-input limit exceeded: ${retained}`)
|
|
)
|
|
return
|
|
}
|
|
if (buf.length > 0) {
|
|
this.buffer.append(buf)
|
|
}
|
|
if (!this.draining && !this.continuationScheduled) {
|
|
this.drainTurn()
|
|
}
|
|
}
|
|
|
|
reset(): void {
|
|
this.generation += 1
|
|
this.cancelContinuation()
|
|
this.buffer.clear()
|
|
this.oversizedPayloadBytesRemaining = 0
|
|
this.releasePause()
|
|
}
|
|
|
|
drain(): Buffer {
|
|
const out = this.buffer.drain()
|
|
this.reset()
|
|
return out
|
|
}
|
|
|
|
private drainTurn(): void {
|
|
if (this.draining) {
|
|
return
|
|
}
|
|
this.draining = true
|
|
const generation = this.generation
|
|
const startedAt = this.now()
|
|
let frames = 0
|
|
let bytes = 0
|
|
|
|
try {
|
|
while (generation === this.generation) {
|
|
if (
|
|
frames >= this.maxFramesPerTurn ||
|
|
bytes >= this.maxBytesPerTurn ||
|
|
(frames > 0 && this.now() - startedAt >= this.maxTurnMs)
|
|
) {
|
|
break
|
|
}
|
|
const discarded = this.discardOversizedPayload(bytes)
|
|
if (discarded > 0) {
|
|
bytes += discarded
|
|
continue
|
|
}
|
|
if (this.buffer.length < HEADER_LENGTH) {
|
|
break
|
|
}
|
|
const header = this.buffer.peek(HEADER_LENGTH)
|
|
const length = header.readUInt32BE(9)
|
|
if (length > MAX_MESSAGE_SIZE) {
|
|
this.buffer.discard(HEADER_LENGTH)
|
|
this.oversizedPayloadBytesRemaining = length
|
|
bytes += HEADER_LENGTH
|
|
publishFrameDecoderError(
|
|
this.onError,
|
|
new Error(`Frame payload too large: ${length} bytes — discarded`)
|
|
)
|
|
continue
|
|
}
|
|
const totalLength = HEADER_LENGTH + length
|
|
if (this.buffer.length < totalLength) {
|
|
break
|
|
}
|
|
if (frames > 0 && bytes + totalLength > this.maxBytesPerTurn) {
|
|
break
|
|
}
|
|
const framed = this.buffer.take(totalLength)
|
|
frames += 1
|
|
bytes += totalLength
|
|
// Why contain here and not in the caller: feed() runs straight from a socket 'data'
|
|
// handler, so a frame owner that throws on the first turn would escape as an
|
|
// uncaughtException and take the whole process — and every connection it serves — down.
|
|
// The continuation path already contains this; the synchronous path must match it, so
|
|
// one bad frame costs one connection (the owner's onError closes it), never the process.
|
|
try {
|
|
this.onFrame({
|
|
type: framed[0],
|
|
id: framed.readUInt32BE(1),
|
|
ack: framed.readUInt32BE(5),
|
|
payload: framed.subarray(HEADER_LENGTH, totalLength)
|
|
})
|
|
} catch (error) {
|
|
// reset() bumps the generation, which ends this turn and drops the residue.
|
|
containFrameDecoderContinuation(() => this.reset(), this.onError, error)
|
|
}
|
|
}
|
|
} finally {
|
|
this.draining = false
|
|
}
|
|
|
|
if (generation !== this.generation) {
|
|
return
|
|
}
|
|
if (this.hasRunnableWork()) {
|
|
this.scheduleContinuation()
|
|
} else {
|
|
this.releasePause()
|
|
}
|
|
}
|
|
|
|
private discardOversizedPayload(bytes: number): number {
|
|
if (this.oversizedPayloadBytesRemaining === 0 || this.buffer.length === 0) {
|
|
return 0
|
|
}
|
|
const discarded = Math.min(
|
|
this.oversizedPayloadBytesRemaining,
|
|
this.buffer.length,
|
|
Math.max(1, this.maxBytesPerTurn - bytes)
|
|
)
|
|
this.buffer.discard(discarded)
|
|
this.oversizedPayloadBytesRemaining -= discarded
|
|
return discarded
|
|
}
|
|
|
|
private hasRunnableWork(): boolean {
|
|
if (this.oversizedPayloadBytesRemaining > 0) {
|
|
return this.buffer.length > 0
|
|
}
|
|
if (this.buffer.length < HEADER_LENGTH) {
|
|
return false
|
|
}
|
|
const length = this.buffer.peek(HEADER_LENGTH).readUInt32BE(9)
|
|
return length > MAX_MESSAGE_SIZE || this.buffer.length >= HEADER_LENGTH + length
|
|
}
|
|
|
|
private scheduleContinuation(): void {
|
|
if (this.continuationScheduled) {
|
|
return
|
|
}
|
|
const generation = this.generation
|
|
this.continuationScheduled = true
|
|
try {
|
|
this.acquirePause()
|
|
} catch (error) {
|
|
this.continuationScheduled = false
|
|
throw error
|
|
}
|
|
if (generation !== this.generation) {
|
|
this.continuationScheduled = false
|
|
return
|
|
}
|
|
try {
|
|
this.continuationHandle = this.schedule(() => {
|
|
if (!this.continuationScheduled || generation !== this.generation) {
|
|
return
|
|
}
|
|
this.continuationScheduled = false
|
|
this.continuationHandleAssigned = false
|
|
this.continuationHandle = undefined
|
|
try {
|
|
this.drainTurn()
|
|
} catch (error) {
|
|
containFrameDecoderContinuation(() => this.reset(), this.onError, error)
|
|
}
|
|
})
|
|
this.continuationHandleAssigned = true
|
|
} catch (error) {
|
|
this.continuationScheduled = false
|
|
this.continuationHandle = undefined
|
|
this.releasePause()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
private cancelContinuation(): void {
|
|
if (!this.continuationScheduled) {
|
|
return
|
|
}
|
|
this.continuationScheduled = false
|
|
if (this.continuationHandleAssigned) {
|
|
this.cancelScheduled(this.continuationHandle)
|
|
}
|
|
this.continuationHandleAssigned = false
|
|
this.continuationHandle = undefined
|
|
}
|
|
|
|
private acquirePause(): void {
|
|
if (!this.paused) {
|
|
this.paused = true
|
|
try {
|
|
this.pause?.()
|
|
} catch (error) {
|
|
this.paused = false
|
|
throw error
|
|
}
|
|
}
|
|
}
|
|
|
|
private releasePause(): void {
|
|
if (this.paused) {
|
|
this.paused = false
|
|
this.resume?.()
|
|
}
|
|
}
|
|
}
|
|
|
|
const positiveLimit = (value: number | undefined, fallback: number): number =>
|
|
value !== undefined && Number.isFinite(value) && value > 0 ? value : fallback
|