mirror of
https://github.com/stablyai/orca.git
synced 2026-10-29 00:03:37 +00:00
333 lines
12 KiB
TypeScript
333 lines
12 KiB
TypeScript
import type { Socket } from 'node:net'
|
|
import { encodeNdjson, NDJSON_MAX_LINE_BYTES } from './ndjson'
|
|
import { recordDaemonStreamBacklogEvent } from './daemon-stream-backlog-probe'
|
|
import {
|
|
clampToSafeSplitIndex,
|
|
encodeStreamDataEvent,
|
|
writeStreamDataEvents
|
|
} from './daemon-stream-data-split'
|
|
import type { PendingStreamDataBatch } from './daemon-stream-keep-tail-drop'
|
|
import type { DaemonEvent } from './types'
|
|
import { appendDaemonStreamData, type DaemonStreamEnqueueOptions } from './daemon-stream-data-entry'
|
|
import {
|
|
evaluateDroppableEnqueue,
|
|
refreshDroppableSessionMembership
|
|
} from './daemon-stream-droppable-membership'
|
|
|
|
type StreamDataClient = {
|
|
streamSocket: Socket | null
|
|
}
|
|
|
|
// 2ms: each chunk waits a half-window here AND again in main's PTY batch; a smaller interval still coalesces bursts while cutting the fixed latency tax (~8ms of the measured ~19ms DSR-under-load latency).
|
|
const STREAM_DATA_BATCH_INTERVAL_MS = 2
|
|
|
|
// Shallow socket: the stream is one FIFO, so a deep buffer buries a visible pane's echo behind other panes' bulk; bulk stops here and is HELD (flushSession can jump it), bounding echo latency.
|
|
// 128KB stays above the socket's ~16KB highWaterMark so a held state implies a false write() and thus a guaranteed 'drain' wake-up.
|
|
const SHALLOW_SOCKET_WRITE_GATE_BYTES =
|
|
process.env.ORCA_DAEMON_SHALLOW_SOCKET_GATE === '0' ? Number.POSITIVE_INFINITY : 128 * 1024
|
|
// Sliced writes: a coalesced entry can grow to megabytes; writing it whole would re-deepen the socket past the gate in one call.
|
|
const BULK_WRITE_SLICE_CHARS = 64 * 1024
|
|
// Safety valve: past this, write through — bounded daemon memory beats bounded echo latency in the extreme. Must sit FAR above the pacer's pause watermark + overshoot (~5MB) or an engaged valve buries interactive echo behind the whole backlog.
|
|
const HELD_WRITE_THROUGH_TOTAL_CHARS = 32 * 1024 * 1024
|
|
// Small-session bypass: a few-KB session (echo, redraws, query replies) is never the flood, so it must not wait FIFO behind others' megabytes; backstops the 100ms interactive fast-path, which misses under event-loop load.
|
|
const SMALL_SESSION_HOLD_BYPASS_CHARS = 4 * 1024
|
|
|
|
type DaemonStreamDataBatcherOptions = {
|
|
maxLineBytes?: number
|
|
/** Fires after each stream-socket write — the only place backlog grows, so the backlog pacer checks its watermark here. */
|
|
onAfterSocketWrite?: () => void
|
|
/** True for sessions whose queued output may be keep-tail dropped (main-marked background sessions). */
|
|
isSessionDroppable?: (sessionId: string) => boolean
|
|
/** Carve reply-eliciting query bytes (DSR/DA/DECRQM/OSC probes) out of dropped data — the hidden program blocks on the reply, so they must still be delivered even when their flood is not. */
|
|
salvageDroppedData?: (dropped: string) => string
|
|
}
|
|
|
|
export class DaemonStreamDataBatcher {
|
|
private pendingByClient = new Map<string, PendingStreamDataBatch>()
|
|
private getClient: (clientId: string) => StreamDataClient | undefined
|
|
private maxLineBytes: number
|
|
private onAfterSocketWrite: (() => void) | undefined
|
|
private isSessionDroppable: (sessionId: string) => boolean
|
|
private salvageDroppedData: (dropped: string) => string
|
|
|
|
constructor(
|
|
getClient: (clientId: string) => StreamDataClient | undefined,
|
|
options: DaemonStreamDataBatcherOptions = {}
|
|
) {
|
|
this.getClient = getClient
|
|
this.maxLineBytes = Math.max(1, options.maxLineBytes ?? NDJSON_MAX_LINE_BYTES)
|
|
this.onAfterSocketWrite = options.onAfterSocketWrite
|
|
this.isSessionDroppable = options.isSessionDroppable ?? (() => false)
|
|
this.salvageDroppedData = options.salvageDroppedData ?? (() => '')
|
|
}
|
|
|
|
enqueue(
|
|
clientId: string,
|
|
sessionId: string,
|
|
data: string,
|
|
options: DaemonStreamEnqueueOptions = {}
|
|
): void {
|
|
const client = this.getClient(clientId)
|
|
if (!client?.streamSocket || client.streamSocket.destroyed) {
|
|
return
|
|
}
|
|
|
|
const batch = this.getOrCreateBatch(clientId)
|
|
const queuedAfter = appendDaemonStreamData(batch, sessionId, data, options)
|
|
const queuedBefore = queuedAfter - data.length
|
|
evaluateDroppableEnqueue(
|
|
batch,
|
|
sessionId,
|
|
queuedBefore,
|
|
queuedAfter,
|
|
this.isSessionDroppable,
|
|
this.salvageDroppedData
|
|
)
|
|
|
|
if (
|
|
options.flushImmediately === true &&
|
|
this.queuedCharsForSession(batch, sessionId) <=
|
|
(options.flushMaxChars ?? Number.POSITIVE_INFINITY)
|
|
) {
|
|
this.flushSession(clientId, sessionId)
|
|
return
|
|
}
|
|
if (!batch.timer) {
|
|
batch.timer = setTimeout(() => this.flush(clientId), STREAM_DATA_BATCH_INTERVAL_MS)
|
|
}
|
|
}
|
|
|
|
/** Append a pre-shaped stream event at the current position in the session's byte order (scan handoff markers, gaps, transient facts). */
|
|
enqueueControlEvent(clientId: string, sessionId: string, control: DaemonEvent): void {
|
|
const client = this.getClient(clientId)
|
|
if (!client?.streamSocket || client.streamSocket.destroyed) {
|
|
return
|
|
}
|
|
const batch = this.getOrCreateBatch(clientId)
|
|
batch.queue.push({ sessionId, data: '', control })
|
|
if (!batch.timer) {
|
|
batch.timer = setTimeout(() => this.flush(clientId), STREAM_DATA_BATCH_INTERVAL_MS)
|
|
}
|
|
}
|
|
|
|
refreshSessionDroppability(sessionId: string): void {
|
|
const droppable = this.isSessionDroppable(sessionId)
|
|
refreshDroppableSessionMembership(this.pendingByClient.values(), sessionId, droppable)
|
|
}
|
|
|
|
private getOrCreateBatch(clientId: string): PendingStreamDataBatch {
|
|
let batch = this.pendingByClient.get(clientId)
|
|
if (!batch) {
|
|
batch = {
|
|
timer: null,
|
|
queue: [],
|
|
queuedChars: 0,
|
|
queuedCharsBySession: new Map(),
|
|
droppableQueuedSessionIds: new Set()
|
|
}
|
|
this.pendingByClient.set(clientId, batch)
|
|
}
|
|
return batch
|
|
}
|
|
|
|
queuedCharsForClient(clientId: string): number {
|
|
return this.pendingByClient.get(clientId)?.queuedChars ?? 0
|
|
}
|
|
|
|
flush(clientId: string): void {
|
|
const batch = this.pendingByClient.get(clientId)
|
|
if (!batch) {
|
|
return
|
|
}
|
|
|
|
if (batch.timer) {
|
|
clearTimeout(batch.timer)
|
|
batch.timer = null
|
|
}
|
|
|
|
const client = this.getClient(clientId)
|
|
if (!client?.streamSocket || client.streamSocket.destroyed) {
|
|
// A vanished stream socket drops the batch — the model owns the bytes and reconnect restores from a snapshot.
|
|
this.pendingByClient.delete(clientId)
|
|
return
|
|
}
|
|
|
|
const socket = client.streamSocket
|
|
// A session that held an entry must hold all its later entries this pass — writing around a held entry would reorder that session's bytes.
|
|
const heldSessions = new Set<string>()
|
|
const retained: PendingStreamDataBatch['queue'] = []
|
|
while (batch.queue.length > 0) {
|
|
const entry = batch.queue[0]
|
|
if (entry.control) {
|
|
// Control entries only respect the held-session order latch; at ~100B, writing them onto a deep socket is as harmless as the small-session bypass.
|
|
if (heldSessions.has(entry.sessionId)) {
|
|
retained.push(entry)
|
|
batch.queue.shift()
|
|
continue
|
|
}
|
|
batch.queue.shift()
|
|
socket.write(encodeNdjson(entry.control))
|
|
this.onAfterSocketWrite?.()
|
|
continue
|
|
}
|
|
const socketDeep = (socket.writableLength ?? 0) >= SHALLOW_SOCKET_WRITE_GATE_BYTES
|
|
if (socketDeep && batch.queuedChars <= HELD_WRITE_THROUGH_TOTAL_CHARS) {
|
|
const sessionHeld = batch.queuedCharsBySession.get(entry.sessionId) ?? 0
|
|
if (heldSessions.has(entry.sessionId) || sessionHeld > SMALL_SESSION_HOLD_BYPASS_CHARS) {
|
|
// Hold this flooding session's entry; small talkers keep flowing. No timer: a deep socket implies a prior false write(), so 'drain' (routed back to flush) is guaranteed to resume held bulk.
|
|
heldSessions.add(entry.sessionId)
|
|
retained.push(entry)
|
|
batch.queue.shift()
|
|
continue
|
|
}
|
|
} else if (socketDeep) {
|
|
// Valve engaged: held bulk exceeded the memory cap, so echo protection is off until it drains — rare enough to log every time.
|
|
recordDaemonStreamBacklogEvent('heldWriteThrough', {
|
|
heldChars: batch.queuedChars,
|
|
socketBufferedBytes: socket.writableLength ?? 0
|
|
})
|
|
}
|
|
const end =
|
|
entry.transformed || entry.data.length <= BULK_WRITE_SLICE_CHARS
|
|
? entry.data.length
|
|
: clampToSafeSplitIndex(entry.data, 0, BULK_WRITE_SLICE_CHARS)
|
|
const slice = entry.data.slice(0, end)
|
|
const entrySequenceChars = entry.sequenceChars ?? entry.data.length
|
|
const sliceSequenceChars = entry.transformed
|
|
? entrySequenceChars
|
|
: entrySequenceChars === 0
|
|
? 0
|
|
: slice.length
|
|
if (end >= entry.data.length) {
|
|
batch.queue.shift()
|
|
} else {
|
|
entry.data = entry.data.slice(end)
|
|
const remainingSequenceChars = entrySequenceChars - sliceSequenceChars
|
|
entry.sequenceChars =
|
|
remainingSequenceChars === entry.data.length ? undefined : remainingSequenceChars
|
|
}
|
|
batch.queuedChars -= slice.length
|
|
const sessionHeldAfter =
|
|
(batch.queuedCharsBySession.get(entry.sessionId) ?? slice.length) - slice.length
|
|
if (sessionHeldAfter <= 0) {
|
|
batch.queuedCharsBySession.delete(entry.sessionId)
|
|
batch.droppableQueuedSessionIds.delete(entry.sessionId)
|
|
} else {
|
|
batch.queuedCharsBySession.set(entry.sessionId, sessionHeldAfter)
|
|
}
|
|
writeStreamDataEvents(
|
|
socket,
|
|
entry.sessionId,
|
|
slice,
|
|
this.maxLineBytes,
|
|
sliceSequenceChars,
|
|
entry.seq,
|
|
entry.transformed
|
|
)
|
|
this.onAfterSocketWrite?.()
|
|
}
|
|
if (retained.length > 0) {
|
|
batch.queue = retained
|
|
// 'drain' only fires when the buffer fully empties (one gate-depth/turn = seconds for multi-MB backlogs); arm a no-op data event whose flush callback re-flushes while bytes are still in flight.
|
|
this.armHeldQueueRefill(socket, clientId, retained[0].sessionId)
|
|
return
|
|
}
|
|
this.pendingByClient.delete(clientId)
|
|
}
|
|
|
|
private refillArmedClients = new Set<string>()
|
|
|
|
private armHeldQueueRefill(socket: Socket, clientId: string, sessionId: string): void {
|
|
if (this.refillArmedClients.has(clientId) || socket.destroyed) {
|
|
return
|
|
}
|
|
this.refillArmedClients.add(clientId)
|
|
// Must be a real protocol no-op line, not an empty write: an empty write's callback fires immediately, defeating the in-flight re-flush.
|
|
socket.write(encodeStreamDataEvent(sessionId, ''), () => {
|
|
this.refillArmedClients.delete(clientId)
|
|
this.flush(clientId)
|
|
})
|
|
}
|
|
|
|
private queuedCharsForSession(batch: PendingStreamDataBatch, sessionId: string): number {
|
|
let chars = 0
|
|
for (const entry of batch.queue) {
|
|
if (entry.sessionId === sessionId) {
|
|
chars += entry.data.length
|
|
}
|
|
}
|
|
return chars
|
|
}
|
|
|
|
private flushSession(clientId: string, sessionId: string): void {
|
|
const batch = this.pendingByClient.get(clientId)
|
|
if (!batch) {
|
|
return
|
|
}
|
|
|
|
const flushed: PendingStreamDataBatch['queue'] = []
|
|
const retained: PendingStreamDataBatch['queue'] = []
|
|
let flushedChars = 0
|
|
for (const entry of batch.queue) {
|
|
if (entry.sessionId === sessionId) {
|
|
flushed.push(entry)
|
|
flushedChars += entry.data.length
|
|
} else {
|
|
retained.push(entry)
|
|
}
|
|
}
|
|
if (flushed.length === 0) {
|
|
return
|
|
}
|
|
|
|
batch.queue = retained
|
|
batch.queuedChars -= flushedChars
|
|
batch.queuedCharsBySession.delete(sessionId)
|
|
batch.droppableQueuedSessionIds.delete(sessionId)
|
|
if (batch.queue.length === 0) {
|
|
if (batch.timer) {
|
|
clearTimeout(batch.timer)
|
|
batch.timer = null
|
|
}
|
|
this.pendingByClient.delete(clientId)
|
|
}
|
|
|
|
const client = this.getClient(clientId)
|
|
if (!client?.streamSocket || client.streamSocket.destroyed) {
|
|
return
|
|
}
|
|
|
|
for (const entry of flushed) {
|
|
if (entry.control) {
|
|
client.streamSocket.write(encodeNdjson(entry.control))
|
|
this.onAfterSocketWrite?.()
|
|
} else {
|
|
writeStreamDataEvents(
|
|
client.streamSocket,
|
|
entry.sessionId,
|
|
entry.data,
|
|
this.maxLineBytes,
|
|
entry.sequenceChars ?? entry.data.length,
|
|
entry.seq,
|
|
entry.transformed
|
|
)
|
|
this.onAfterSocketWrite?.()
|
|
}
|
|
}
|
|
}
|
|
|
|
clear(clientId?: string): void {
|
|
const batches =
|
|
clientId === undefined
|
|
? Array.from(this.pendingByClient.entries())
|
|
: [[clientId, this.pendingByClient.get(clientId)] as const]
|
|
|
|
for (const [id, batch] of batches) {
|
|
if (batch?.timer) {
|
|
clearTimeout(batch.timer)
|
|
}
|
|
this.pendingByClient.delete(id)
|
|
}
|
|
}
|
|
}
|