diff --git a/metagpt/tools/libs/terminal.py b/metagpt/tools/libs/terminal.py index 6f869d53fe..c5e2779f45 100644 --- a/metagpt/tools/libs/terminal.py +++ b/metagpt/tools/libs/terminal.py @@ -152,12 +152,39 @@ async def _read_and_process_output(self, cmd, daemon=False) -> str: # Read the output until the unique marker is found. # We read bytes directly from stdout instead of text because when reading text, # '\r' is changed to '\n', resulting in excessive output. + # Keep an incomplete trailing fragment in `tmp` until we see a + # newline (or EOF). Do NOT use `*lines, tmp = splitlines(True)` — + # when the buffer is a single newline-terminated line, that unpack + # moves the only line into `tmp` and never yields it, so the + # end-of-command marker is never observed and run_command hangs + # forever on Linux/WSL2 (#2110). tmp = b"" while True: - output = tmp + await self.process.stdout.read(1) - if not output: - continue - *lines, tmp = output.splitlines(True) + chunk = await self.process.stdout.read(1) + if not chunk: + # EOF: process any residual bytes still in the buffer. + if not tmp: + continue + lines, tmp = [tmp], b"" + else: + output = tmp + chunk + if output.endswith(b" +") or output.endswith(b" "): + lines, tmp = [output], b"" + else: + # Incomplete line — wait for more bytes. + # If the buffer contains multiple lines, flush complete ones. + parts = output.splitlines(True) + if len(parts) > 1: + *lines, tmp = parts + else: + # Single incomplete fragment (no trailing newline yet). + if parts and (parts[0].endswith(b" +") or parts[0].endswith(b" ")): + lines, tmp = parts, b"" + else: + tmp = output + continue for line in lines: line = line.decode(errors="ignore") ix = line.rfind(END_MARKER_VALUE) diff --git a/tests/metagpt/tools/libs/test_terminal.py b/tests/metagpt/tools/libs/test_terminal.py index 9c64009aea..b83e4b7ba6 100644 --- a/tests/metagpt/tools/libs/test_terminal.py +++ b/tests/metagpt/tools/libs/test_terminal.py @@ -20,3 +20,28 @@ async def test_terminal(): if __name__ == "__main__": pytest.main([__file__, "-s"]) + + +def test_splitlines_unpack_does_not_hide_end_marker(): + """Regression for #2110: a single newline-terminated buffer must be yielded. + + The old `*lines, tmp = buf.splitlines(True)` idiom moved a lone complete + line into `tmp`, so the end-of-command marker was never observed. + """ + from metagpt.utils.report import END_MARKER_VALUE + + # Simulate the buffer state when the marker arrives as the only line. + buf = END_MARKER_VALUE.encode() # already ends with \n + parts = buf.splitlines(True) + assert len(parts) == 1 + # Correct handling: treat a trailing-newline buffer as a complete line. + if parts[0].endswith(b"\n") or parts[0].endswith(b"\r"): + lines, tmp = parts, b"" + else: + *lines, tmp = parts if len(parts) > 1 else ([], parts[0] if parts else b"") + assert lines and END_MARKER_VALUE.encode() in lines[0] or END_MARKER_VALUE in lines[0].decode(errors="ignore") + assert tmp == b"" + # Old broken behavior would leave lines empty: + *broken_lines, broken_tmp = parts + assert broken_lines == [] + assert broken_tmp == parts[0]