Bound retained `stderr` to a tail + de-dup decode

`_relay_stream_lines()` kept ALL captured `stderr` for the
rc!=0 `CalledProcessError` note, so a long-lived `check=True`
child could buffer its entire stderr history in parent mem.
Add an `accum_cap` (the new `_STDERR_NOTE_TAIL_BYTES`=16KiB)
that trims the buffer to its TAIL — the bytes nearest the
failure, which are what the note wants anyway.

While here, factor the per-line decode/`rstrip` into a
`_decode_line()` helper so the in-loop + trailing-flush
emits no longer duplicate it.

Review: PR #465 (copilot-pull-request-reviewer)
https://github.com/goodboy/tractor/pull/465#pullrequestreview-4548191641

(this patch was generated in some part by [`claude-code`][claude-code-gh])
[claude-code-gh]: https://github.com/anthropics/claude-code
wkt/start_or_cancel_tests_474
Gud Boi 2026-06-24 19:08:19 -04:00
parent a3f8765264
commit 02cb789901
1 changed files with 29 additions and 8 deletions

View File

@ -42,6 +42,22 @@ log = get_logger()
_UNSET = object() _UNSET = object()
# cap the stderr bytes retained for a rc!=0 `CalledProcessError`
# note: a long-lived (`check=True`) child could otherwise buffer
# its ENTIRE stderr history in parent mem — keep only the TAIL
# (the bytes nearest the failure, which are what you want to see).
_STDERR_NOTE_TAIL_BYTES: int = 16 * 1024
def _decode_line(raw: bytes) -> str:
'''
Decode one relayed line: replace undecodable bytes and trim a
trailing carriage-return (so CRLF-term'd lines read clean).
'''
return raw.decode(errors='replace').rstrip('\r')
def _add_stderr_note( def _add_stderr_note(
cpe: subprocess.CalledProcessError, cpe: subprocess.CalledProcessError,
stderr_bytes: bytes, stderr_bytes: bytes,
@ -65,6 +81,7 @@ async def _relay_stream_lines(
emit: Callable[[str], None]|None = None, emit: Callable[[str], None]|None = None,
tag: str = '', tag: str = '',
accum: bytearray|None = None, accum: bytearray|None = None,
accum_cap: int|None = None,
) -> None: ) -> None:
''' '''
Concurrently drain a child subproc's `stdout`/`stderr` Concurrently drain a child subproc's `stdout`/`stderr`
@ -95,15 +112,21 @@ async def _relay_stream_lines(
async for chunk in stream: # ends at child-exit EOF async for chunk in stream: # ends at child-exit EOF
if accum is not None: if accum is not None:
accum += chunk accum += chunk
# bound retained bytes to the TAIL (see caller +
# `_STDERR_NOTE_TAIL_BYTES`) so a chatty long-lived
# child can't grow parent mem without limit.
if (
accum_cap is not None
and
len(accum) > accum_cap
):
del accum[:len(accum) - accum_cap]
if emit is None: if emit is None:
continue # drain(+accum)-only continue # drain(+accum)-only
buf: bytes = residual + chunk buf: bytes = residual + chunk
*lines, residual = buf.split(b'\n') *lines, residual = buf.split(b'\n')
for raw in lines: for raw in lines:
line: str = raw.decode( emit(f'[{tag}] {_decode_line(raw)}')
errors='replace',
).rstrip('\r')
emit(f'[{tag}] {line}')
# flush any trailing partial (un-newline-term'd) line @ EOF # flush any trailing partial (un-newline-term'd) line @ EOF
if ( if (
@ -111,10 +134,7 @@ async def _relay_stream_lines(
and and
residual residual
): ):
line: str = residual.decode( emit(f'[{tag}] {_decode_line(residual)}')
errors='replace',
).rstrip('\r')
emit(f'[{tag}] {line}')
async def supervise_run_process( async def supervise_run_process(
@ -293,6 +313,7 @@ async def supervise_run_process(
emit=emit if relay_stderr else None, emit=emit if relay_stderr else None,
tag=f'{tag}:err', tag=f'{tag}:err',
accum=stderr_accum, accum=stderr_accum,
accum_cap=_STDERR_NOTE_TAIL_BYTES,
) )
) )