diff --git a/launch/core/platforms/linux.py b/launch/core/platforms/linux.py index bd87786..2b77864 100644 --- a/launch/core/platforms/linux.py +++ b/launch/core/platforms/linux.py @@ -95,22 +95,35 @@ def _read_raw_output(self, timeout:int=30) -> tuple[str, Optional[CmdOutputMetad ''' timeout: in seconds ''' - - accumulated_output = "" + + # Do not repeatedly normalize and regex-scan the complete PTY transcript + # after every chunk. Large build/test commands can emit tens of megabytes; + # rescanning the growing string turns one command read into quadratic host + # work after the command itself has already completed. + output_chunks: list[str] = [] + prompt_probe = "" + prompt_end = CMD_OUTPUT_PS1_END.strip() start_time = time.time() while time.time() - start_time < timeout: try: chunk = self.output_queue.get(timeout=0.1) - accumulated_output += chunk.decode("utf-8", errors="ignore") - # PSReadLine injects ANSI + cursor control; normalize before matching - accumulated_clean = ANSI_ESCAPE.sub("", accumulated_output).replace("\r", "") - ps1_matches = CmdOutputMetadata.matches_ps1_metadata(accumulated_clean) - if ps1_matches: + normalized_chunk = ANSI_ESCAPE.sub( + "", chunk.decode("utf-8", errors="ignore") + ).replace("\r", "") + output_chunks.append(normalized_chunk) + + # The delimiter can straddle two socket reads, so retain only the + # short suffix needed to recognize it incrementally. Parse the + # complete metadata exactly once after the delimiter is observed. + prompt_probe += normalized_chunk + if prompt_end in prompt_probe: break + prompt_probe = prompt_probe[-(len(prompt_end) - 1):] except queue.Empty: continue - accumulated_output = ANSI_ESCAPE.sub("", accumulated_output).replace("\r", "") + + accumulated_output = "".join(output_chunks) ps1_matches = CmdOutputMetadata.matches_ps1_metadata(accumulated_output) metadata = ( CmdOutputMetadata.from_ps1_match(ps1_matches[-1]) if ps1_matches else None diff --git a/tests/linux_output_drain_test.py b/tests/linux_output_drain_test.py new file mode 100644 index 0000000..f405789 --- /dev/null +++ b/tests/linux_output_drain_test.py @@ -0,0 +1,52 @@ +import queue +import sys +import unittest +from pathlib import Path +from unittest.mock import patch + + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from launch.core.platforms.base import CmdOutputMetadata +from launch.core.platforms.linux import LinuxRuntime + + +class LinuxOutputDrainTests(unittest.TestCase): + def test_large_output_scans_for_prompt_once_and_accepts_split_delimiter(self): + runtime = object.__new__(LinuxRuntime) + runtime.stopped = True + runtime.output_queue = queue.Queue() + for _ in range(256): + runtime.output_queue.put(b"x" * 4096) + runtime.output_queue.put(b'\n###PS1JSON###\n{"exit_code": "0", "username": "root"') + runtime.output_queue.put( + b', "hostname": "test", "working_dir": "/testbed", ' + b'"py_interpreter_path": ""}\n###PS1END###\n' + ) + + original_matches = CmdOutputMetadata.matches_ps1_metadata + calls = 0 + + def counted_matches(output_text): + nonlocal calls + calls += 1 + return original_matches(output_text) + + with patch.object( + CmdOutputMetadata, + "matches_ps1_metadata", + new=counted_matches, + ): + output, metadata = runtime._read_raw_output(timeout=1) + + self.assertEqual(calls, 1) + self.assertIsNotNone(metadata) + self.assertEqual(metadata.exit_code, 0) + self.assertEqual(metadata.working_dir, "/testbed") + self.assertTrue(output.startswith("x" * 4096)) + self.assertEqual(output.count("x"), 256 * 4096) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file