Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 21 additions & 8 deletions launch/core/platforms/linux.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
52 changes: 52 additions & 0 deletions tests/linux_output_drain_test.py
Original file line number Diff line number Diff line change
@@ -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()