blob: cbb11ac47b4a6482e2c3df944494939778ccdfd9 [file] [edit]
#!/usr/bin/env fuchsia-vendored-python
# Copyright 2026 The Fuchsia Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
import contextlib
import io
import json
import signal
import subprocess
import tempfile
import unittest
from pathlib import Path
from unittest import mock
from build.scripts import build_timeout, graceful_timeout, managed_process
class BuildTimeoutTest(unittest.TestCase):
def test_default_build_timeout_schedule_valid(self) -> None:
schedule = build_timeout.DEFAULT_BUILD_TIMEOUT_SCHEDULE
self.assertGreater(len(schedule.steps), 0)
self.assertIsNotNone(schedule.final)
events = schedule.to_events(hard_timeout=3600.0)
self.assertGreater(len(events), 0)
def test_build_timeout_default_in_code_schedule(self) -> None:
mock_proc = mock.Mock(spec=managed_process.ManagedProcess)
mock_proc.poll.return_value = 0
mock_proc.returncode = 0
mock_proc.wait.return_value = 0
with mock.patch.object(
managed_process.ManagedProcess, "spawn", return_value=mock_proc
) as mock_spawn:
rc = build_timeout.main(["--timeout", "90m", "--", "echo", "hi"])
self.assertEqual(rc, 0)
mock_spawn.assert_called_once_with(["echo", "hi"], separate_pgrp=True)
def test_build_timeout_no_separate_pgrp(self) -> None:
mock_proc = mock.Mock(spec=managed_process.ManagedProcess)
mock_proc.poll.return_value = 0
mock_proc.returncode = 0
mock_proc.wait.return_value = 0
with mock.patch.object(
managed_process.ManagedProcess, "spawn", return_value=mock_proc
) as mock_spawn:
rc = build_timeout.main(
[
"--timeout",
"90m",
"--no-separate-pgrp",
"--",
"echo",
"hi",
]
)
self.assertEqual(rc, 0)
mock_spawn.assert_called_once_with(["echo", "hi"], separate_pgrp=False)
def test_build_timeout_grace_period_forwarded(self) -> None:
mock_proc = mock.Mock(spec=managed_process.ManagedProcess)
mock_proc.poll.return_value = 0
mock_proc.returncode = 0
mock_proc.wait.return_value = 0
with mock.patch.object(
managed_process.ManagedProcess, "spawn", return_value=mock_proc
):
rc = build_timeout.main(
[
"--timeout",
"90m",
"--grace-period",
"60s",
"--",
"echo",
"hi",
]
)
self.assertEqual(rc, 0)
def test_build_timeout_expired_presents_diagnostics(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
log_dir = tmp_path / "timeout_logs"
log_dir.mkdir(parents=True, exist_ok=True)
snapshot_data = {
"pid": 1234,
"ppid": 1,
"comm": "ninja",
"state": "S",
"cmdline": ["ninja", "-C", "out"],
"children": [
{
"pid": 5678,
"ppid": 1234,
"comm": "rustc",
"state": "R",
"cmdline": ["rustc", "main.rs"],
"children": [],
}
],
}
(log_dir / "pstree_stop.json").write_text(
json.dumps(snapshot_data), encoding="utf-8"
)
mock_proc = mock.Mock(spec=managed_process.ManagedProcess)
mock_proc.pid = 1234
mock_proc.returncode = None
mock_proc.poll.return_value = None
mock_proc.wait.side_effect = [
subprocess.TimeoutExpired(cmd="test", timeout=10.0),
0,
0,
]
timeout_res = graceful_timeout.TimeoutResult(
returncode=build_timeout.EXIT_TIMEOUT,
timed_out=True,
)
with (
mock.patch.object(
managed_process.ManagedProcess,
"spawn",
return_value=mock_proc,
),
mock.patch.object(
graceful_timeout.GracefulTimeoutRunner,
"_resolve_returncode",
return_value=timeout_res,
),
):
stderr_buf = io.StringIO()
with contextlib.redirect_stderr(stderr_buf):
rc = build_timeout.main(
[
"--timeout",
"60s",
"--log-dir",
str(log_dir),
"--",
"ninja",
]
)
self.assertEqual(rc, build_timeout.EXIT_TIMEOUT)
output = stderr_buf.getvalue()
self.assertIn("BUILD TIMED OUT (configured timeout: 60s)", output)
self.assertIn("=== Active Leaf Processes at Timeout [stop]", output)
self.assertIn("5678 rustc", output)
self.assertIn("--- Process Tree [stop] ---", output)
self.assertIn("1234 ninja", output)
def test_build_timeout_cancelled_banner_printed(self) -> None:
mock_proc = mock.Mock(spec=managed_process.ManagedProcess)
mock_proc.pid = 1234
mock_proc.returncode = None
mock_proc.poll.return_value = None
cancelled_res = graceful_timeout.TimeoutResult(
returncode=130,
timed_out=False,
interrupted_signal=signal.SIGINT,
)
with (
mock.patch.object(
managed_process.ManagedProcess,
"spawn",
return_value=mock_proc,
),
mock.patch.object(
graceful_timeout.GracefulTimeoutRunner,
"_resolve_returncode",
return_value=cancelled_res,
),
):
stderr_buf = io.StringIO()
with contextlib.redirect_stderr(stderr_buf):
rc = build_timeout.main(
[
"--timeout",
"60s",
"--",
"ninja",
]
)
self.assertEqual(rc, 130)
output = stderr_buf.getvalue()
self.assertIn("BUILD CANCELLED (interrupted by", output)
def test_format_timeout_diagnostics(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
log_dir = tmp_path / "timeout_logs"
log_dir.mkdir(parents=True, exist_ok=True)
snapshot_data = {
"pid": 1234,
"ppid": 1,
"comm": "ninja",
"state": "S",
"cmdline": ["ninja", "-C", "out"],
"children": [
{
"pid": 5678,
"ppid": 1234,
"comm": "rustc",
"state": "R",
"cmdline": ["rustc", "main.rs"],
"children": [],
}
],
}
(log_dir / "pstree_stop.json").write_text(
json.dumps(snapshot_data), encoding="utf-8"
)
lines = list(build_timeout.format_timeout_diagnostics(log_dir))
self.assertIn(
f"Process tree diagnostics saved to: {log_dir}", lines
)
self.assertIn(
"=== Active Leaf Processes at Timeout [stop] (1 active) ===",
lines,
)
self.assertIn("--- Process Tree [stop] ---", lines)
self.assertTrue(any("5678 rustc" in line for line in lines))
def test_build_timeout_exclude_process_from_summary(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
log_dir = tmp_path / "timeout_logs"
log_dir.mkdir(parents=True, exist_ok=True)
snapshot_data = {
"pid": 1234,
"ppid": 1,
"comm": "ninja",
"state": "S",
"cmdline": ["ninja"],
"children": [
{
"pid": 5678,
"ppid": 1234,
"comm": "fake_daemon",
"state": "S",
"cmdline": ["fake_daemon"],
"children": [],
},
{
"pid": 5679,
"ppid": 1234,
"comm": "rustc",
"state": "R",
"cmdline": ["rustc", "foo.rs"],
"children": [],
},
],
}
(log_dir / "pstree_stop.json").write_text(
json.dumps(snapshot_data), encoding="utf-8"
)
lines = list(
build_timeout.format_timeout_diagnostics(
log_dir,
filter_fn=lambda n: n.comm != "fake_daemon",
)
)
summary_section = "\n".join(lines)
self.assertIn(
"Active Leaf Processes at Timeout [stop] (1 active)",
summary_section,
)
self.assertIn("[CPU] rustc foo.rs (pid 5679)", summary_section)
# fake_daemon should NOT be in the active leaf section
# but MUST still be present in the process tree section
self.assertIn("--- Process Tree [stop] ---", summary_section)
self.assertIn("5678 fake_daemon", summary_section)
def test_present_timeout_diagnostics_banner(self) -> None:
result = graceful_timeout.TimeoutResult(returncode=124, timed_out=True)
stderr_buf = io.StringIO()
with contextlib.redirect_stderr(stderr_buf):
build_timeout.present_timeout_diagnostics(
None, result, timeout_str="45m"
)
output = stderr_buf.getvalue()
self.assertIn(
"[build_timeout.py] BUILD TIMED OUT (configured timeout: 45m)",
output,
)
def test_load_timeout_snapshots_handles_corrupt_files(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
log_dir = Path(tmpdir)
(log_dir / "pstree_bad_json.json").write_text(
"invalid json", encoding="utf-8"
)
(log_dir / "pstree_bad_schema.json").write_text(
json.dumps({"wrong": "schema"}), encoding="utf-8"
)
stderr_buf = io.StringIO()
with contextlib.redirect_stderr(stderr_buf):
snapshots = build_timeout.load_timeout_snapshots(log_dir)
self.assertEqual(snapshots, {})
output = stderr_buf.getvalue()
self.assertIn("Failed to parse snapshot JSON", output)
self.assertIn("Invalid process tree schema", output)
def test_timeout_metadata_serialization_roundtrip(self) -> None:
meta = build_timeout.TimeoutMetadata(
log_dir="/path/to/logs",
snapshots=["pstree_stop.json"],
active_processes=["[CPU] rustc (pid 1)"],
total_matching_processes=1,
total_leaf_processes=1,
)
data = meta.to_dict()
self.assertEqual(data["log_dir"], "/path/to/logs")
self.assertEqual(data["snapshots"], ["pstree_stop.json"])
self.assertEqual(data["active_processes"], ["[CPU] rustc (pid 1)"])
self.assertEqual(data["total_matching_processes"], 1)
self.assertEqual(data["total_leaf_processes"], 1)
meta2 = build_timeout.TimeoutMetadata.from_dict(data)
self.assertEqual(meta, meta2)
def test_timeout_metadata_collect(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
timeout_dir = Path(tmpdir)
snapshot_data = {
"pid": 100,
"ppid": 1,
"comm": "ninja",
"state": "S",
"cmdline": ["ninja"],
"children": [
{
"pid": 200,
"ppid": 100,
"comm": "rustc",
"state": "R",
"cmdline": ["rustc", "foo.rs"],
"children": [],
}
],
}
(timeout_dir / "pstree_stop.json").write_text(
json.dumps(snapshot_data), encoding="utf-8"
)
meta = build_timeout.TimeoutMetadata.collect(timeout_dir)
self.assertIsNotNone(meta)
assert meta is not None
self.assertEqual(meta.snapshots, ["pstree_stop.json"])
self.assertEqual(meta.total_matching_processes, 1)
self.assertEqual(meta.total_leaf_processes, 1)
self.assertEqual(len(meta.active_processes), 1)
self.assertIn(
"[CPU] rustc foo.rs (pid 200)", meta.active_processes[0]
)
if __name__ == "__main__":
unittest.main()