fix(process_registry): strip bash startup noise until real output, not only from the first read

A tty-less `bash -lic` writes "cannot set terminal process group" and
"no job control in this shell" as two separate write() calls. The reader
cleaned shell noise from the FIRST chunk only, so whenever it woke between
the two writes (loaded CI runners) the second line landed in
output_buffer as the process's only "output".

Symptoms on main: tests/hermes_cli/test_process_dock.py painted
"last: bash: no job control..." instead of "starting" (3 main reds,
Sep 21-22) and tests/tools/test_process_registry_list_exit.py's probe
woke on that noise before the writer had printed, so the completion
event lacked "owner-output" (2 main FLAKY frames). The same leak reaches
users through process.list output_preview and the live-work dock.

Keep stripping leading noise from every chunk until the process has
produced non-blank output; after that, matching text is the process's
own. The list_exit probe now waits for the writer's marker rather than
for any bytes.

Live repro (stand-in shell reproducing bash's two writes with a 50 ms
gap, real spawn_local + select/read1 reader): base 10/10 buffers carry
"bash: no job control in this shell\n"; fixed 0/10.
This commit is contained in:
teknium1
2026-09-22 09:22:24 -07:00
committed by Teknium
parent 33a30fdd81
commit 1393403dd7
3 changed files with 21 additions and 5 deletions

View File

@@ -408,6 +408,19 @@ def test_reader_loop_reassembles_multibyte_char_split_across_chunks(registry, mo
assert "\ufffd" not in session.output_buffer
def test_reader_loop_strips_shell_noise_split_across_reads(registry, monkeypatch):
"""``bash -lic`` without a tty writes its two startup warnings in two separate write() calls.
A reader that wakes between them (loaded CI) must still drop the second line: it leaked as the
process's only "output", so the dock painted ``last: bash: no job control...`` instead of
``starting`` and probes waiting on any output woke before the real writer had printed."""
session = _run_reader(registry, monkeypatch, [
b"bash: cannot set terminal process group (7): Inappropriate ioctl for device\n",
b"bash: no job control in this shell\n",
b"real output\n",
b"bash: no job control in this shell\n", # after real output it is the process's own text
])
assert session.output_buffer == "real output\nbash: no job control in this shell\n"
def test_reader_loop_flushes_truncated_multibyte_tail_at_eof(registry, monkeypatch):

View File

@@ -45,7 +45,7 @@ def _probe(root):
session.notify_on_complete = True
owner, sibling = sessions
deadline = time.monotonic() + 5
while not all(s.output_buffer for s in sessions):
while not all(name + "-output" in s.output_buffer for name, s in zip(("owner", "sibling"), sessions)):
assert time.monotonic() < deadline, "writers did not become ready"
time.sleep(0.01)
assert all(s.process.poll() is None for s in sessions)

View File

@@ -1328,7 +1328,10 @@ class ProcessRegistry(ProcessCheckpointMixin):
Windows pipes don't support select(); the blocking path is kept there and the lazy reconcile in
poll()/wait() remains the safety net. See #68915, #8340.
"""
first_chunk = True
# ``bash -lic`` without a tty writes its startup warnings one write() per line, so the
# reader can wake between them; strip leading noise from every chunk until the
# process has produced real output, not just from the first read.
head_noise = True
# A split multibyte UTF-8 char would become U+FFFD with stateless decoding; the
# incremental decoder holds the partial sequence until the rest arrives.
decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
@@ -1339,10 +1342,10 @@ class ProcessRegistry(ProcessCheckpointMixin):
# same treatment the foreground path already has in
# ``tools/environments/base.py::_wait_for_process``. (Ported from openclaw/openclaw#112325.)
def _append_chunk(chunk: str):
nonlocal first_chunk
if first_chunk:
nonlocal head_noise
if head_noise:
chunk = self._clean_shell_noise(chunk)
first_chunk = False
head_noise = not chunk.strip()
self._ingest_output(session, chunk)
try:
proc = session.process