fix(code-runtime-python): clear stray buffers on truncation and drain the original std streams

Addresses the review's two carried warnings and the comment suggestion:
- Once the ledger truncates, every arm that marks it (admit()'s two ceilings and
  the child-marker frame arm) now clears both stray pipes' buffered output
  wholesale, so the end-path flushStray sees empty buffers instead of
  concat+decoding doomed data near a 256 MiB maxLogBytes; captureStray's newline
  loop re-checks the flag before re-retaining the residual.
- The child runs with -u (unbuffered), so sys.__stdout__/sys.__stderr__ writes
  are visible to stray capture immediately; the settlement flush still drains
  the original std streams before the done frame as a guard. A regression test
  writes through sys.__stdout__/sys.__stderr__ without an explicit flush and
  asserts both bytes land in logs. C-ext stdio remains an accepted residual,
  recorded in the README Known Limitations (en + zh).
- The ledger-comment arithmetic now states the exact boundary (serializes to
  exactly maxLogBytes; without the reserved byte it would be maxLogBytes + 1)
  in both host and child.
Note (en + zh) registers the stray-clear and -u/settlement-drain mechanisms and
the new test; pairings re-recorded; corpus passes 1029.
This commit is contained in:
Chinesezjc
2026-08-31 14:47:18 +08:00
committed by Tianyi Cui
parent 4c7811812d
commit 43a0879ad1
9 changed files with 90 additions and 12 deletions
@@ -4304,6 +4304,31 @@ describe('PythonCodeRuntime — hostile peer', () => {
expect(result.logs.join('')).toContain('stray stderr')
})
it('flushes bytes written through sys.__stdout__/sys.__stderr__ before the done frame', async () => {
// The bootstrap only replaces sys.stdout/sys.stderr with the _LogStream;
// sys.__stdout__/sys.__stderr__ are the original block-buffered wrappers
// over fd 1/2. A program that writes through them without an explicit flush
// would lose those bytes when the host SIGTERMs the child right after the
// done frame (the default SIGTERM disposition terminates without
// interpreter finalization). The settlement flush now drains the original
// std streams before sending the done frame, so the bytes land in the
// kernel pipe buffer and the host's stray capture records them.
const { runtime } = await setup()
const result = await runtime.run({
program: [
'import sys',
'sys.__stdout__.write("orig stdout\\n")',
'sys.__stderr__.write("orig stderr\\n")',
'return "done"',
].join('\n'),
bindings: [],
})
expect(result.error).toBeUndefined()
expect(result.value).toBe('done')
expect(result.logs.join('')).toContain('orig stdout')
expect(result.logs.join('')).toContain('orig stderr')
}, 15_000)
it('escalates to SIGKILL when the program traps SIGTERM and ignores the grace period', async () => {
// A program that traps SIGTERM should still die: the kill() escalation
// fires SIGKILL after graceMs. The full run reports either timeout (wall)