diff --git a/tests/tools/test_process_registry.py b/tests/tools/test_process_registry.py index 1b4fad8087..19292a1a4d 100644 --- a/tests/tools/test_process_registry.py +++ b/tests/tools/test_process_registry.py @@ -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): diff --git a/tests/tools/test_process_registry_list_exit.py b/tests/tools/test_process_registry_list_exit.py index 55f933d30e..26f604a742 100644 --- a/tests/tools/test_process_registry_list_exit.py +++ b/tests/tools/test_process_registry_list_exit.py @@ -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) diff --git a/tools/process_registry.py b/tools/process_registry.py index 2b417d618c..07599328ef 100644 --- a/tools/process_registry.py +++ b/tools/process_registry.py @@ -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