@@ -13,7 +13,6 @@
*/
import { spawn , type ChildProcessWithoutNullStreams } from 'node:child_process'
import { StringDecoder } from 'node:string_decoder'
import { accessSync , copyFileSync , constants as fsConstants , mkdtempSync , rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { delimiter , dirname , isAbsolute , join } from 'node:path'
@@ -313,6 +312,37 @@ const TRUNCATION_MARKER = '… [truncated]'
*/
const TRUNCATION_MARKER_BYTES = Buffer . byteLength ( TRUNCATION_MARKER , 'utf8' )
/**
* Serialized JSON-string cost of `text` (the two quotes plus each character's
* escaped byte width), measured WITHOUT materializing the escaped copy, and
* abandoned the instant it exceeds `maxBytes`. `JSON.stringify(text)` would
* allocate the whole escaped form first — up to sixfold a control-char-dense
* string — so a near-budget line under a large `maxLogBytes` could momentarily
* allocate over a gigabyte just to measure it. This walks code point by code
* point and stops at the cap, so the measurement allocates nothing.
* @param text - the candidate string.
* @param maxBytes - the largest serialized size the caller can admit.
* @returns the exact serialized byte cost, or `undefined` once it exceeds `maxBytes`.
*/
function jsonStringCostUpTo ( text : string , maxBytes : number ) : number | undefined {
let bytes = 2 // the enclosing quotes
for ( const character of text ) {
const code = character . codePointAt ( 0 ) as number
// Control characters below 0x20 escape to `\uXXXX` (6) except the five with
// short forms `\b \t \n \f \r` (2); `"` and `\` escape to 2; everything else
// rides at its raw UTF-8 width.
if ( code < 0x20 ) {
bytes += code === 0x08 || code === 0x09 || code === 0x0a || code === 0x0c || code === 0x0d ? 2 : 6
} else if ( code === 0x22 || code === 0x5c ) {
bytes += 2
} else {
bytes += Buffer . byteLength ( character , 'utf8' )
}
if ( bytes > maxBytes ) return undefined
}
return bytes
}
/**
* Cap a done-frame `error.message` to `maxValueBytes` host-side: a forged done
* frame can carry an arbitrarily long message, so truncate by RAW UTF-8 byte
@@ -729,26 +759,22 @@ export class PythonCodeRuntime extends CodeRuntime {
logs . push ( logTruncationMarker ( this . config . maxLogBytes ) )
return
}
// Past the lower bound the escape expands the st ring at most sixfold,
// so this copy is bounded by ~6x the remaining budget.
const cost = Buffer . byteLength ( JSON . stringify ( text ) , 'utf8' ) + 1
if ( cost > logBudget ) {
// Past the lower bound, measure the exact se rialized cost without
// allocating the escaped copy: `jsonStringCostUpTo` walks to the cap and
// stops, so even a near-budget control-char-dense line never materializes
// a sixfold-inflated `JSON.stringify` result. `+ 1` for the separator.
const measured = jsonStringCostUpTo ( text , logBudget - 1 )
if ( measured === undefined ) {
logsTruncated = true
logs . push ( logTruncationMarker ( this . config . maxLogBytes ) )
return
}
logBudget -= cost
logBudget -= measured + 1
logs . push ( text )
}
// Stray-byte capture: anything the child writes to its stdout/stderr
// (native prints, C-extension writes) still counts against the ledger.
// One STREAMING decoder per pipe: a multibyte UTF-8 sequence can span
// two chunks (native writes, os.write past the pipe buffer), and
// decoding each chunk independently would corrupt both halves into
// replacement characters. StringDecoder holds the partial sequence
// until its continuation bytes arrive; the pipes are separate byte
// streams, so they cannot share one decoder.
//
// Output is admitted per LINE, not per transport chunk. `logs` entries
// are joined with `\n` downstream (Code Mode), so each entry must be one
@@ -756,47 +782,67 @@ export class PythonCodeRuntime extends CodeRuntime {
// boundary into a model-visible newline, so a single 200 KiB native write
// split across pipe reads would read back with spurious line breaks. The
// child's own `log` frames are already line-granular; stray capture
// matches them by holding a per-stream residual and adm itting only on a
// real `\n`. A run of bytes carrying no newline accumulates in the
// residual; the ledger bo und s it — `admit` charges each completed line, so
// a newline-free flood is capped when the pending residual would cross the
// budget, and the trailing partial is flushed once on `end`.
const strayOut = { decoder : new StringDecoder ( 'utf8' ) , residual : '' }
const strayErr = { decoder : new StringDecoder ( 'utf8' ) , residual : '' }
const captureStray = ( stray : { decoder : StringDecoder ; residual : string } , chunk : Buffer ) : void = > {
// matches them by spl itting on `\n`.
//
// Buffered as raw `Buffer` ch unk s w ith a running byte counter, exactly
// like the fd-3 reader below and for the same reasons: a string `+=`
// accumulator re-copies the whole residual on every pipe chunk (quadratic
// on a large newline-free write), and scanning it from index 0 each chunk
// is a second quadratic. Appending a chunk is O(1); the split happens only
// when a `\n` actually arrived. A newline never appears inside a UTF-8
// multibyte sequence (continuation bytes are 0x80– 0xBF), so splitting on
// the raw 0x0a byte and decoding each complete line is safe without a
// streaming decoder — a line's bytes are whole by construction.
interface StrayBuffer { chunks : Buffer [ ] ; bytes : number }
const strayOut : StrayBuffer = { chunks : [ ] , bytes : 0 }
const strayErr : StrayBuffer = { chunks : [ ] , bytes : 0 }
const captureStray = ( stray : StrayBuffer , chunk : Buffer ) : void = > {
// Once the ledger has truncated, stop buffering: admit() is a no-op past
// that point, so continuing to grow the residual would retain host
// memory for output that can never be admitted.
// that point, so continuing to accumulate would retain host memory for
// output that can never be admitted.
if ( logsTruncated ) return
stray . residual += stray . decoder . write ( chunk )
let newline = stray . residual . indexOf ( '\n' )
while ( newline >= 0 ) {
admit ( stray . residual . slice ( 0 , newline ) )
stray . residual = stray . residual . slice ( newline + 1 )
newline = stray . residual . indexOf ( '\n' )
stray . chunks . push ( chunk )
stray . bytes + = chunk . length
if ( chunk . includes ( 0x0a ) ) {
let buffered = Buffer . concat ( stray . chunks )
let newline : number
while ( ( newline = buffered . indexOf ( 0x0a ) ) >= 0 ) {
admit ( buffered . subarray ( 0 , newline ) . toString ( 'utf8' ) )
buffered = buffered . subarray ( newline + 1 )
}
// Carry the residual as a fresh right-sized copy, not the subarray view
// (which would pin the whole concat allocation). See detachResidual.
stray . chunks = detachResidual ( buffered )
stray . bytes = buffered . length
}
// Newline-free residual is bounded by the ledger, not left to grow with
// the stream: an `os.write(1, b"A"*N)` flood carrying no newline would
// otherwise accumulate N bytes in host memory before `end`. When the
// pending residual would cross the budget, admit it now — admit()
// truncates and marks the ledger, and the truncation short-circuit above
// stops further buffering on the next chunk.
if ( stray . residual . length + 3 > logBudget ) {
admit ( stray . residual )
stray . residual = ''
// pending residual would cross the budget, admit it now — admit() charges
// its serialized cost, truncates, and marks the ledger, and the
// truncation short-circuit above stops buffering on the next chunk. The
// raw byte count is a safe lower bound on the serialized cost, so this
// fires no later than the budget is genuinely at risk.
if ( stray . bytes + 3 > logBudget ) {
flushStray ( stray )
}
}
// Flush a pipe's residual into `logs`. Called on the budget threshold
// above, on the pipe's `end` (normal drain), and — for the setsid-escapee
// path where destroy() forces settlement without an `end` — explicitly in
// the closeDeadline handler. Idempotent: it clears what it admits, so a
// later flush is a no-op. The `chunks.length` guard is the only emptiness
// check needed — `data` never emits a zero-length Buffer, so a non-empty
// chunk list always decodes to a non-empty tail.
function flushStray ( stray : StrayBuffer ) : void {
if ( stray . chunks . length === 0 ) return
const tail = Buffer . concat ( stray . chunks ) . toString ( 'utf8' )
stray . chunks = [ ]
stray . bytes = 0
admit ( tail )
}
child . stdout . on ( 'data' , ( chunk : Buffer ) = > { captureStray ( strayOut , chunk ) } )
child . stderr . on ( 'data' , ( chunk : Buffer ) = > { captureStray ( strayErr , chunk ) } )
// Flush each pipe's residual and decoder when it ends: a final line with
// no trailing newline, plus output that STOPS mid-sequence (native code
// killed between bytes) leaves a partial character in the decoder, which
// end() renders as U+FFFD rather than dropping the evidence. `end` fires
// before `close` settles the run, so the flush is admitted into `logs`.
const flushStray = ( stray : { decoder : StringDecoder ; residual : string } ) : void = > {
const tail = stray . residual + stray . decoder . end ( )
if ( tail . length > 0 ) admit ( tail )
}
child . stdout . on ( 'end' , ( ) = > { flushStray ( strayOut ) } )
child . stderr . on ( 'end' , ( ) = > { flushStray ( strayErr ) } )
@@ -829,11 +875,23 @@ export class PythonCodeRuntime extends CodeRuntime {
// ceiling this check exists to enforce. The counter is exact and free,
// and the retained chunks are released here so the rejected payload is
// not still held while the run settles.
// Reading the counter rather than the line length also charges the
// whole unframed buffer, which over-counts by at most the newline-
// bearing chunk's own length (one pipe read): the residual carried in
// is always a partial line, so nothing but the current line can be
// larger than that.
//
// The counter charges the whole unframed buffer, which over-counts by at
// most the newline- bearing chunk's own length (one pipe read): the
// residual carried in is always a partial line, so nothing but the
// current line can be larger than that. That over-count is deliberate and
// load-bounded on the OTHER side: the config cap is `ceiling - envelope`,
// and a legitimate near-cap frame plus a following chunk's leading bytes
// could in principle nudge the counter over the ceiling for one read
// window — but only when maxLogBytes/maxValueBytes is configured within
// one pipe read of the 256 MiB ceiling, orders of magnitude past the
// 32/64 KiB defaults. Enforcing the ceiling per-frame instead (splitting
// before the check) would require `Buffer.concat`-ing an over-ceiling
// single frame before rejecting it, reintroducing the peak-memory
// doubling this pre-concat check and its regression tests exist to
// prevent; the memory-safety bound against hostile input at any config
// takes precedence over a false-reject reachable only at a pathological
// near-ceiling config.
if ( pendingBytes > FRAME_CEILING_BYTES ) {
pendingChunks = [ ]
sealedBlocks = [ ]
@@ -1233,12 +1291,17 @@ export class PythonCodeRuntime extends CodeRuntime {
// `close` awaits every stdio stream draining, which a setsid-escaped
// orphan holding our inherited pipes can prevent forever. Bound that
// wait: after SIGKILL has had the grace window plus a margin to reap the
// child itself, force settlement on the decided result. Detaching the
// stream handles lets `close` land as a no-op if it ever arrives, and
// stops the orphan's stray output from being accounted against a run
// that already finished. `unref` so the deadline never keeps the host
// process alive on its own.
// child itself, force settlement on the decided result. Flush any
// newline-free stray residual FIRST — a leader that wrote a diagnostic
// with `os.write(1, ...)` and exited leaves it buffered, and destroying
// the stream below drops it before an `end`/`close` flush could run, so
// the diagnostic would be lost from `logs`. Detaching the stream handles
// then lets `close` land as a no-op if it ever arrives, and stops the
// orphan's stray output from being accounted against a run that already
// finished. `unref` so the deadline never keeps the host process alive.
closeDeadline = setTimeout ( ( ) = > {
flushStray ( strayOut )
flushStray ( strayErr )
proto . destroy ( )
child . stdout . destroy ( )
child . stderr . destroy ( )