blob: a7550f5d1220799a8cf14f9fa03e9ffc0fd9f3b0 [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.
"""Build timeout orchestrator.
Wraps build commands under progressive timeout schedules (graceful_timeout.py),
records structured process tree snapshots (pstree.py) at escalation milestones,
and presents human-readable diagnostics upon timeout.
"""
import argparse
import json
import signal
import sys
from collections.abc import Callable, Iterable, Sequence
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Self, TextIO
from build.python.modules.serialization import serialization
from build.scripts import graceful_timeout, managed_process, pstree
# JSON type aliases
_JSONPrimitive = str | int | float | bool | None
JSONValue = _JSONPrimitive | dict[str, Any] | list[Any]
JSONObject = dict[str, JSONValue]
JSONArray = list[JSONValue]
_SCRIPT: Path = Path(__file__)
def msg(text: str, file: TextIO | None = None) -> None:
"""Print a message prefixed with the script's basename."""
if file is None:
file = sys.stderr
print(f"[{_SCRIPT.name}] {text}", file=file)
EXIT_TIMEOUT: int = graceful_timeout.EXIT_TIMEOUT
PSTREE_SCRIPT: Path = Path(__file__).parent / "pstree.py"
MAX_TIMEOUT_LEAVES_DISPLAY: int = 10
MAX_TIMEOUT_TREE_LINES_DISPLAY: int = 50
DEFAULT_METADATA_MAX_LEAVES: int = 8
# Default build escalation schedule embedded directly in code.
DEFAULT_BUILD_TIMEOUT_SCHEDULE = graceful_timeout.Schedule(
steps=[
graceful_timeout.ScheduleStep(
signal=signal.SIGINT,
target=managed_process.ProcessTarget.LEADER,
scale=0.015,
min_duration="15s",
max_duration="45s",
label="stop",
description="Stop inflow: stops scheduling new tasks while allowing active in-flight subcommands to complete naturally.",
),
graceful_timeout.ScheduleStep(
signal=signal.SIGINT,
target=managed_process.ProcessTarget.LEADER,
scale=0.01,
min_duration="15s",
max_duration="45s",
label="cancel",
description="Cancel in-flight: interrupts active subcommands, allowing nested build layers to unwind and clean up temporary files.",
),
graceful_timeout.ScheduleStep(
signal=signal.SIGINT,
target=managed_process.ProcessTarget.LEADER,
scale=0.01,
min_duration="15s",
max_duration="45s",
label="shutdown",
description="Daemon shutdown: commands orchestrators and background daemons (RBE, ResultStore, profilers) to transmit final status and flush logs over the network.",
),
],
final=graceful_timeout.FinalStep(
signal=signal.SIGKILL,
target=managed_process.ProcessTarget.GROUP,
label="kill",
description="Terminal fallback: unconditional kernel SIGKILL of any surviving processes in the process group.",
),
)
@dataclass(frozen=True)
class TimeoutMetadata:
"""Structured metadata for build timeout diagnostics."""
log_dir: str
snapshots: list[str] = field(default_factory=list)
active_processes: list[str] = field(default_factory=list)
total_matching_processes: int = 0
total_leaf_processes: int = 0
def to_dict(self) -> JSONObject:
"""Serializes timeout metadata to a dictionary using serialization."""
return serialization.instance_to_dict(self)
@classmethod
def from_dict(cls, data: JSONObject) -> Self:
"""Deserializes a TimeoutMetadata instance from a dictionary using serialization."""
return serialization.instance_from_dict(cls, data)
@classmethod
def collect(
cls,
timeout_dir: Path,
filter_fn: Callable[[pstree.ProcessNode], bool] | None = None,
max_leaves: int = DEFAULT_METADATA_MAX_LEAVES,
) -> Self | None:
"""Discovers snapshots and extracts active leaf processes from a timeout directory."""
if not timeout_dir.exists():
return None
snapshots = sorted(f.name for f in timeout_dir.iterdir() if f.is_file())
if not snapshots:
return None
active_procs: list[str] = []
total_matching = 0
total_leaves = 0
stop_snapshot_path = timeout_dir / "pstree_stop.json"
if stop_snapshot_path.exists():
try:
content = stop_snapshot_path.read_text(encoding="utf-8")
data = json.loads(content)
except OSError as e:
msg(f"Failed to read {stop_snapshot_path.name}: {e}")
return cls(
log_dir=str(timeout_dir.resolve()), snapshots=snapshots
)
except json.JSONDecodeError as e:
msg(f"Failed to parse JSON from {stop_snapshot_path.name}: {e}")
return cls(
log_dir=str(timeout_dir.resolve()), snapshots=snapshots
)
if not isinstance(data, dict):
msg(
f"Expected JSON object in {stop_snapshot_path.name}, got {type(data).__name__}"
)
return cls(
log_dir=str(timeout_dir.resolve()), snapshots=snapshots
)
try:
root_node = pstree.ProcessNode.from_dict(data)
except (KeyError, TypeError, ValueError) as e:
msg(
f"Invalid process tree schema in {stop_snapshot_path.name}: {e}"
)
return cls(
log_dir=str(timeout_dir.resolve()), snapshots=snapshots
)
summary = root_node.summarize_active_leaves(
max_count=max_leaves, filter_fn=filter_fn
)
active_procs = summary.processes
total_matching = summary.total_matching_leaves
total_leaves = summary.total_leaves
return cls(
log_dir=str(timeout_dir.resolve()),
snapshots=snapshots,
active_processes=active_procs,
total_matching_processes=total_matching,
total_leaf_processes=total_leaves,
)
def load_timeout_snapshots(
timeout_dir: Path,
) -> dict[str, tuple[Path, pstree.ProcessNode]]:
"""Loads and deserializes all pstree JSON snapshots discovered in timeout_dir."""
loaded: dict[str, tuple[Path, pstree.ProcessNode]] = {}
for snapshot_file in sorted(timeout_dir.glob("pstree_*.json")):
label = snapshot_file.stem.removeprefix("pstree_")
try:
content = snapshot_file.read_text(encoding="utf-8")
data = json.loads(content)
node = pstree.ProcessNode.from_dict(data)
loaded[label] = (snapshot_file, node)
except OSError as e:
msg(f"Failed to read snapshot {snapshot_file.name}: {e}")
except json.JSONDecodeError as e:
msg(f"Failed to parse snapshot JSON {snapshot_file.name}: {e}")
except (KeyError, TypeError, ValueError) as e:
msg(f"Invalid process tree schema in {snapshot_file.name}: {e}")
return loaded
def format_leaf_processes(
header: str,
snapshot_file: Path,
root_node: pstree.ProcessNode,
max_leaves: int = MAX_TIMEOUT_LEAVES_DISPLAY,
filter_fn: Callable[[pstree.ProcessNode], bool] | None = None,
) -> Iterable[str]:
"""Yields formatted summary lines of active leaf processes in a snapshot."""
label = snapshot_file.stem.removeprefix("pstree_")
summary = root_node.summarize_active_leaves(
max_count=max_leaves,
filter_fn=filter_fn,
)
yield f"=== {header} [{label}] ({summary.total_matching_leaves} active) ==="
for line in summary.processes:
yield f" * {line}"
if summary.total_matching_leaves > len(summary.processes):
yield (
f" ... and {summary.total_matching_leaves - len(summary.processes)} "
f"more (see {snapshot_file.name})"
)
def format_process_tree(
label: str,
snapshot_file: Path,
root_node: pstree.ProcessNode,
max_tree_lines: int = MAX_TIMEOUT_TREE_LINES_DISPLAY,
) -> Iterable[str]:
"""Yields formatted ASCII process tree lines capped to max_tree_lines."""
yield f"--- Process Tree [{label}] ---"
tree_lines = list(root_node.format_tree())
for line in tree_lines[:max_tree_lines]:
yield f" {line}"
if len(tree_lines) > max_tree_lines:
yield (
f" ... and {len(tree_lines) - max_tree_lines} more lines (see {snapshot_file.name})"
)
def format_timeout_diagnostics(
timeout_dir: Path | None,
max_leaves: int = MAX_TIMEOUT_LEAVES_DISPLAY,
max_tree_lines: int = MAX_TIMEOUT_TREE_LINES_DISPLAY,
filter_fn: Callable[[pstree.ProcessNode], bool] | None = None,
) -> Iterable[str]:
"""Yields formatted timeout and cancellation diagnostic lines."""
if timeout_dir is None or not timeout_dir.exists():
return
snapshots = load_timeout_snapshots(timeout_dir)
if not snapshots:
return
yield f"Process tree diagnostics saved to: {timeout_dir}"
if "stop" in snapshots:
snap_file, node = snapshots["stop"]
yield from format_leaf_processes(
"Active Leaf Processes at Timeout",
snap_file,
node,
max_leaves=max_leaves,
filter_fn=filter_fn,
)
if "kill" in snapshots:
snap_file, node = snapshots["kill"]
yield from format_leaf_processes(
"Surviving Processes Before Final Kill",
snap_file,
node,
max_leaves=max_leaves,
filter_fn=filter_fn,
)
# Display process trees in chronological order
label_order = ["stop", "cancel", "shutdown", "kill"]
ordered_labels = [l for l in label_order if l in snapshots] + [
l for l in snapshots if l not in label_order
]
for label in ordered_labels:
snap_file, root_node = snapshots[label]
yield from format_process_tree(
label, snap_file, root_node, max_tree_lines=max_tree_lines
)
def present_timeout_diagnostics(
timeout_dir: Path | None,
result: graceful_timeout.TimeoutResult,
timeout_str: str | None = None,
max_leaves: int = MAX_TIMEOUT_LEAVES_DISPLAY,
max_tree_lines: int = MAX_TIMEOUT_TREE_LINES_DISPLAY,
filter_fn: Callable[[pstree.ProcessNode], bool] | None = None,
) -> None:
"""Presents process snapshots and diagnostics to stderr.
Emits a single prefixed title message via msg(), followed by formatted
diagnostic lines via print().
"""
if result.timed_out:
timeout_info = (
f" (configured timeout: {timeout_str})" if timeout_str else ""
)
msg(f"BUILD TIMED OUT{timeout_info}")
elif result.interrupted_signal is not None:
sig_name = (
signal.strsignal(result.interrupted_signal)
or f"signal {result.interrupted_signal}"
)
msg(f"BUILD CANCELLED (interrupted by {sig_name})")
for line in format_timeout_diagnostics(
timeout_dir,
max_leaves=max_leaves,
max_tree_lines=max_tree_lines,
filter_fn=filter_fn,
):
print(line, file=sys.stderr)
def main(argv: Sequence[str] | None = None) -> int:
"""CLI entrypoint."""
if argv is None:
argv = sys.argv[1:]
parser = argparse.ArgumentParser(
description="Wrap build commands under scheduled timeout and capture pstree diagnostics."
)
parser.add_argument(
"--timeout",
type=str,
default=None,
help="Command timeout budget (e.g. 90m, 5400s).",
)
parser.add_argument(
"--schedule-file",
type=Path,
default=None,
help="Path to JSON file defining the signal escalation schedule.",
)
parser.add_argument(
"--log-dir",
type=Path,
default=None,
help="Directory to save timeout process snapshots.",
)
parser.add_argument(
"--diag-margin",
type=str,
default="10s",
help="Maximum timeout for diagnostic snapshot commands (default: 10s).",
)
parser.add_argument(
"--verbose",
"-v",
action="store_true",
help="Enable verbose output.",
)
parser.add_argument(
"--preserve-status",
action="store_true",
help="Preserve child exit status even on timeout.",
)
parser.add_argument(
"--separate-pgrp",
action=argparse.BooleanOptionalAction,
default=True,
help="Spawn child in a separate process group (default: True).",
)
parser.add_argument(
"--max-leaves",
type=int,
default=MAX_TIMEOUT_LEAVES_DISPLAY,
help=f"Maximum active leaf processes to display in summary (default: {MAX_TIMEOUT_LEAVES_DISPLAY}).",
)
parser.add_argument(
"--max-tree-lines",
type=int,
default=MAX_TIMEOUT_TREE_LINES_DISPLAY,
help=f"Maximum ASCII process tree lines to display per snapshot (default: {MAX_TIMEOUT_TREE_LINES_DISPLAY}).",
)
parser.add_argument(
"--grace-period",
type=str,
default=None,
help="Explicit total grace period budget to allocate across escalation "
"steps (e.g. '60s', '60'). When omitted, the grace period is calculated "
"dynamically from the schedule steps.",
)
parser.add_argument(
"--exclude-process-from-summary",
action="append",
default=[],
help="Process name to exclude from active leaf summaries (can be repeated). "
"Does not affect full process tree snapshots.",
)
parser.add_argument(
"command",
nargs=argparse.REMAINDER,
help="Command to run.",
)
args = parser.parse_args(argv)
cmd = list(args.command)
if cmd and cmd[0] == "--":
cmd = cmd[1:]
if not cmd:
parser.error("No command specified to run.")
schedule = (
graceful_timeout.load_schedule_file(args.schedule_file)
if args.schedule_file
else DEFAULT_BUILD_TIMEOUT_SCHEDULE
)
hard_timeout = (
graceful_timeout.parse_duration_to_seconds(args.timeout)
if args.timeout
else None
)
grace_period = (
graceful_timeout.parse_duration_to_seconds(args.grace_period)
if args.grace_period
else None
)
# Validate schedule against hard_timeout early before spawning child process
if hard_timeout is not None:
schedule.to_events(
hard_timeout=hard_timeout,
grace_period=grace_period,
warn_stream=None,
)
diag_hook = None
if args.log_dir:
args.log_dir.mkdir(parents=True, exist_ok=True)
snapshot_template = args.log_dir / "pstree_{label}.json"
diag_hook = graceful_timeout.make_subprocess_hook(
[
sys.executable,
str(PSTREE_SCRIPT),
"{pid}",
"--format",
"json",
"--output",
str(snapshot_template),
],
diag_margin=graceful_timeout.parse_duration_to_seconds(
args.diag_margin
),
verbose=args.verbose,
)
proc = managed_process.ManagedProcess.spawn(
cmd, separate_pgrp=args.separate_pgrp
)
runner = graceful_timeout.GracefulTimeoutRunner(
proc,
schedule,
hard_timeout=hard_timeout,
grace_period=grace_period,
diagnostic_hook=diag_hook,
timeout_exit_code=EXIT_TIMEOUT,
preserve_status=args.preserve_status,
verbose=args.verbose,
)
result = runner.run()
if result.timed_out or result.interrupted_signal is not None:
exclude_set = set(args.exclude_process_from_summary)
filter_fn = (
(lambda n: n.comm not in exclude_set) if exclude_set else None
)
present_timeout_diagnostics(
args.log_dir,
result=result,
timeout_str=args.timeout,
max_leaves=args.max_leaves,
max_tree_lines=args.max_tree_lines,
filter_fn=filter_fn,
)
return result.returncode
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))