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:
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user