From 6dbc959b4fd8d7a25a3f839fbb1636a7ada09ba0 Mon Sep 17 00:00:00 2001 From: Emin Date: Tue, 11 Aug 2026 17:42:03 +0800 Subject: [PATCH 01/52] feat: isolate worker stdio and stream tool logs --- chipcompiler/engine/flow.py | 12 -- chipcompiler/engine/rerun.py | 19 -- chipcompiler/runtime/log_stream.py | 194 +++++++++++++++++++ chipcompiler/runtime/stdio_isolation.py | 57 ++++++ chipcompiler/runtime/worker.py | 196 ++++++++++++++++++++ chipcompiler/tools/ecc_dreamplace/module.py | 13 -- chipcompiler/tools/ecc_sizer/runner.py | 16 +- chipcompiler/tools/yosys/runner.py | 39 ++-- chipcompiler/tools/yosys/utility.py | 4 +- test/runtime/test_log_stream.py | 156 ++++++++++++++++ test/runtime/test_stdio_isolation.py | 84 +++++++++ test/runtime/test_worker.py | 156 ++++++++++++++++ test/tools/ecc_sizer/test_runner.py | 4 +- test/tools/yosys/test_runner.py | 15 +- test/tools/yosys/test_utility.py | 55 +++--- 15 files changed, 896 insertions(+), 124 deletions(-) create mode 100644 chipcompiler/runtime/log_stream.py create mode 100644 chipcompiler/runtime/stdio_isolation.py create mode 100644 chipcompiler/runtime/worker.py create mode 100644 test/runtime/test_log_stream.py create mode 100644 test/runtime/test_stdio_isolation.py create mode 100644 test/runtime/test_worker.py diff --git a/chipcompiler/engine/flow.py b/chipcompiler/engine/flow.py index 3a0175a2..1b59b6c6 100644 --- a/chipcompiler/engine/flow.py +++ b/chipcompiler/engine/flow.py @@ -13,7 +13,6 @@ SignoffPackageOptions, SignoffPackageResult, ) -from chipcompiler.utility.log import redirect_stdio_to_file logger = logging.getLogger(__name__) @@ -479,17 +478,6 @@ def run_step( self.set_state(name=workspace_step.name, tool=workspace_step.tool, state=StateEnum.Ongoing) _notify_flow_observer(observer, "on_step_started", workspace_step) - # run step - log_file = workspace_step.log.file or "" - if log_file: - log_file = os.path.abspath(log_file) - try: - os.makedirs(os.path.dirname(log_file) or ".", exist_ok=True) - redirect_stdio_to_file(log_file) - except Exception: - logger.exception("Failed to redirect stdio to log file: %s", log_file) - - step_tag = f"{workspace_step.name}({workspace_step.tool})" self.workspace.logger.info(f"[STEP] {step_tag} pid={os.getpid()} started") pid = os.getpid() diff --git a/chipcompiler/engine/rerun.py b/chipcompiler/engine/rerun.py index 0885fded..738051b8 100644 --- a/chipcompiler/engine/rerun.py +++ b/chipcompiler/engine/rerun.py @@ -7,21 +7,16 @@ from an existing workspace; execution itself stays in ``EngineFlow.run_step``. """ -import logging -import os import shutil from pathlib import Path from typing import TYPE_CHECKING, NamedTuple from chipcompiler.data import StateEnum, Workspace, WorkspaceStep, log_flow -from chipcompiler.utility.log import redirect_stdio_to_file from chipcompiler.utility.path import path_is_within if TYPE_CHECKING: from chipcompiler.engine.flow import EngineFlow -logger = logging.getLogger(__name__) - class StepRunResult(NamedTuple): ok: bool @@ -124,7 +119,6 @@ def _run_selected(flow: "EngineFlow", selected: list[tuple[WorkspaceStep, Path]] f"{workspace_step.tool} - begin step - {workspace_step.name}" ) _reset_output_dir(output_dir) - _redirect_to_step_log(workspace_step) flow.init_db_engine_for_step(workspace_step) state = flow.run_step(workspace_step, rerun=True) log_flow(workspace=flow.workspace) @@ -166,19 +160,6 @@ def _validated_output_dirs(workspace: Workspace, steps: list[WorkspaceStep]) -> return output_dirs -def _redirect_to_step_log(workspace_step: WorkspaceStep) -> None: - """Redirect stdio before DB init so its warnings land in the step log.""" - log_file = workspace_step.log.file or "" - if not log_file: - return - log_file = os.path.abspath(log_file) - try: - os.makedirs(os.path.dirname(log_file) or ".", exist_ok=True) - redirect_stdio_to_file(log_file) - except Exception: - logger.exception("Failed to redirect stdio to log file: %s", log_file) - - def _reset_output_dir(output_dir: Path) -> None: if output_dir.exists(): shutil.rmtree(output_dir) diff --git a/chipcompiler/runtime/log_stream.py b/chipcompiler/runtime/log_stream.py new file mode 100644 index 00000000..63dd1bc4 --- /dev/null +++ b/chipcompiler/runtime/log_stream.py @@ -0,0 +1,194 @@ +"""Step marker protocol and log stream archive. + +The worker emits step markers on stderr using a Record Separator prefix: + \\x1eECC-STEP {"event":"begin","step":"Synthesis","tool":"yosys"}\\n + \\x1eECC-STEP {"event":"end","step":"Synthesis","tool":"yosys"}\\n + +The client-side LogStreamReader drains worker stderr, parses markers to +switch between step log files, and archives raw tool bytes to the correct +step log path. +""" + +import json +import os +import threading +from collections.abc import Callable +from dataclasses import dataclass, field +from pathlib import Path +from typing import BinaryIO + +MARKER_PREFIX = b"\x1eECC-STEP " + + +@dataclass +class StepMarker: + event: str + step: str + tool: str + + +def emit_step_marker(event: str, step: str, tool: str) -> None: + """Write a step marker to stderr using a single os.write() call.""" + import sys + + sys.stdout.flush() + sys.stderr.flush() + payload = json.dumps({"event": event, "step": step, "tool": tool}, separators=(",", ":")) + line = b"\x1eECC-STEP " + payload.encode("utf-8") + b"\n" + os.write(2, line) + + +def parse_marker(line: bytes) -> StepMarker | None: + """Parse a complete line as a step marker, or return None if invalid.""" + if not line.startswith(MARKER_PREFIX): + return None + payload = line[len(MARKER_PREFIX) :] + if payload.endswith(b"\n"): + payload = payload[:-1] + try: + data = json.loads(payload) + except (json.JSONDecodeError, UnicodeDecodeError): + return None + event = data.get("event") + step = data.get("step") + tool = data.get("tool") + if not isinstance(event, str) or not isinstance(step, str) or not isinstance(tool, str): + return None + return StepMarker(event=event, step=step, tool=tool) + + +@dataclass +class LogStreamState: + """Mutable state maintained by the log stream reader.""" + + current_step: str | None = None + current_tool: str | None = None + tail_bytes: bytes = b"" + archive_file: BinaryIO | None = field(default=None, repr=False) + bytes_archived: int = 0 + steps_seen: list[str] = field(default_factory=list) + error: Exception | None = None + + +class LogStreamReader: + """Drains worker stderr, parses markers, and archives raw bytes to step logs. + + The reader runs in a dedicated thread. Marker lines are consumed (not archived). + Non-marker bytes are written to the current step's log file. A callback is + invoked for display purposes with the decoded text. + """ + + def __init__( + self, + stderr: BinaryIO, + *, + log_path_resolver: Callable[[str, str], Path | None] | None = None, + on_output: Callable[[bytes], None] | None = None, + tail_size: int = 4096, + ): + self._stderr = stderr + self._resolve_path = log_path_resolver + self._on_output = on_output + self._tail_size = tail_size + self._state = LogStreamState() + self._thread: threading.Thread | None = None + self._stop = threading.Event() + + @property + def state(self) -> LogStreamState: + return self._state + + def start(self) -> None: + self._thread = threading.Thread(target=self._drain_loop, name="ecc-log-reader", daemon=True) + self._thread.start() + + def join(self, timeout: float | None = None) -> None: + if self._thread is not None: + self._thread.join(timeout=timeout) + + def stop(self) -> None: + self._stop.set() + + def _drain_loop(self) -> None: + buf = b"" + try: + while not self._stop.is_set(): + chunk = self._stderr.read(8192) + if not chunk: + break + buf += chunk + buf = self._process_buffer(buf) + if buf: + self._emit_data(buf) + except Exception as exc: + self._state.error = exc + finally: + self._close_archive() + + def _process_buffer(self, buf: bytes) -> bytes: + while True: + nl = buf.find(b"\n") + if nl < 0: + if buf.startswith(MARKER_PREFIX[:1]) and len(buf) < 512: + return buf + if buf: + self._emit_data(buf) + return b"" + line = buf[: nl + 1] + buf = buf[nl + 1 :] + marker = parse_marker(line) + if marker is not None: + self._handle_marker(marker) + else: + self._emit_data(line) + return buf + + def _handle_marker(self, marker: StepMarker) -> None: + if marker.event == "begin": + self._close_archive() + self._state.current_step = marker.step + self._state.current_tool = marker.tool + self._state.steps_seen.append(marker.step) + self._open_archive(marker.step, marker.tool) + elif marker.event == "end": + self._close_archive() + self._state.current_step = None + self._state.current_tool = None + + def _emit_data(self, data: bytes) -> None: + if self._state.archive_file is not None: + try: + self._state.archive_file.write(data) + self._state.bytes_archived += len(data) + except OSError: + pass + self._update_tail(data) + if self._on_output is not None: + self._on_output(data) + + def _update_tail(self, data: bytes) -> None: + combined = self._state.tail_bytes + data + if len(combined) > self._tail_size: + combined = combined[-self._tail_size :] + self._state.tail_bytes = combined + + def _open_archive(self, step: str, tool: str) -> None: + if self._resolve_path is None: + return + path = self._resolve_path(step, tool) + if path is None: + return + try: + path.parent.mkdir(parents=True, exist_ok=True) + self._state.archive_file = path.open("wb") # noqa: SIM115 + except OSError: + self._state.archive_file = None + + def _close_archive(self) -> None: + if self._state.archive_file is not None: + try: + self._state.archive_file.flush() + self._state.archive_file.close() + except OSError: + pass + self._state.archive_file = None diff --git a/chipcompiler/runtime/stdio_isolation.py b/chipcompiler/runtime/stdio_isolation.py new file mode 100644 index 00000000..ad4a6a17 --- /dev/null +++ b/chipcompiler/runtime/stdio_isolation.py @@ -0,0 +1,57 @@ +"""Permanent stdio isolation for the worker process. + +At worker startup, the protocol output stream is duplicated to a safe fd, +then fd 1 is permanently redirected to fd 2. This ensures that any Python, +C/C++, glog, or subprocess output goes to stderr (the log stream), while +the protocol writer uses only the saved fd for RPC frames. +""" + +import os +import sys +from typing import BinaryIO + + +class StdioIsolation: + """Installs permanent fd-level stdio isolation for the worker process. + + After install(): + - protocol_stream: a binary stream on the original stdout fd (for RPC frames only) + - fd 1 and sys.stdout: permanently point to stderr (for tool/EDA output) + """ + + def __init__(self): + self._protocol_fd: int | None = None + self._protocol_stream: BinaryIO | None = None + self._installed = False + + @property + def protocol_stream(self) -> BinaryIO: + if self._protocol_stream is None: + raise RuntimeError("stdio isolation not installed") + return self._protocol_stream + + @property + def installed(self) -> bool: + return self._installed + + def install(self) -> BinaryIO: + """Install permanent stdio isolation. Must be called once at worker startup.""" + if self._installed: + return self._protocol_stream # type: ignore[return-value] + + sys.stdout.flush() + sys.stderr.flush() + + self._protocol_fd = os.dup(1) + os.dup2(2, 1) + sys.stdout = sys.stderr + + self._protocol_stream = os.fdopen(self._protocol_fd, "wb", buffering=0) + self._installed = True + return self._protocol_stream + + def close(self) -> None: + if self._protocol_stream is not None: + self._protocol_stream.close() + self._protocol_stream = None + self._protocol_fd = None diff --git a/chipcompiler/runtime/worker.py b/chipcompiler/runtime/worker.py new file mode 100644 index 00000000..493d79ec --- /dev/null +++ b/chipcompiler/runtime/worker.py @@ -0,0 +1,196 @@ +import json +import os +import signal +import subprocess +import threading +from contextlib import suppress +from dataclasses import dataclass +from pathlib import Path +from typing import BinaryIO + +from chipcompiler.runtime.transport import ( + ContentLengthDecoder, + TransportError, + encode_content_length_frame, +) +from chipcompiler.utility.json import json_read, json_write + + +@dataclass(frozen=True) +class WorkerResult: + success: bool + response: dict | None = None + exit_code: int | None = None + signal_number: int | None = None + error: str | None = None + + +class WorkerProcessError(Exception): + pass + + +_GRACEFUL_WAIT = 2.0 +_FORCEFUL_WAIT = 3.0 + + +class WorkerClient: + """Manages a worker subprocess running `ecc rpc serve --stdio`.""" + + def __init__(self, worker_argv: list[str]): + self._argv = worker_argv + self._process: subprocess.Popen | None = None + self._lock = threading.Lock() + self._decoder = ContentLengthDecoder() + + def start(self) -> subprocess.Popen: + self._process = subprocess.Popen( + self._argv, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + start_new_session=True, + ) + return self._process + + @property + def process(self) -> subprocess.Popen | None: + return self._process + + @property + def stderr(self) -> BinaryIO | None: + if self._process is None: + return None + return self._process.stderr + + def send_request(self, method: str, params: dict, request_id: int = 1) -> None: + if self._process is None or self._process.stdin is None: + raise WorkerProcessError("worker not started") + payload = json.dumps( + {"jsonrpc": "2.0", "method": method, "params": params, "id": request_id}, + separators=(",", ":"), + ) + frame = encode_content_length_frame(payload) + try: + self._process.stdin.write(frame) + self._process.stdin.flush() + except OSError as exc: + raise WorkerProcessError(f"failed to send request: {exc}") from exc + + def read_response(self) -> dict: + if self._process is None or self._process.stdout is None: + raise WorkerProcessError("worker not started") + while True: + read1 = getattr(self._process.stdout, "read1", None) + chunk = read1(8192) if read1 is not None else self._process.stdout.read(8192) + if not chunk: + raise WorkerProcessError("worker stdout closed before response") + try: + messages = self._decoder.feed(chunk) + except TransportError as exc: + raise WorkerProcessError(f"protocol error: {exc}") from exc + for msg in messages: + return json.loads(msg) + + def request(self, method: str, params: dict, request_id: int = 1) -> WorkerResult: + try: + self.send_request(method, params, request_id) + response = self.read_response() + except WorkerProcessError as exc: + return WorkerResult(success=False, error=str(exc)) + if "error" in response: + err_msg = response["error"].get("message", "rpc error") + return WorkerResult(success=False, response=response, error=err_msg) + return WorkerResult(success=True, response=response) + + def terminate(self) -> int | None: + proc = self._process + if proc is None: + return None + return _terminate_process_group(proc) + + def is_alive(self) -> bool: + if self._process is None: + return False + return self._process.poll() is None + + +def _terminate_process_group(proc: subprocess.Popen) -> int: + """Escalate signals to the worker process group.""" + pid = proc.pid + try: + pgid = os.getpgid(pid) + except OSError: + return proc.wait() + + if proc.poll() is not None: + return proc.returncode + + with suppress(OSError): + os.killpg(pgid, signal.SIGINT) + try: + proc.wait(timeout=_GRACEFUL_WAIT) + return proc.returncode + except subprocess.TimeoutExpired: + pass + + with suppress(OSError): + os.killpg(pgid, signal.SIGTERM) + try: + proc.wait(timeout=_FORCEFUL_WAIT) + return proc.returncode + except subprocess.TimeoutExpired: + pass + + with suppress(OSError): + os.killpg(pgid, signal.SIGKILL) + return proc.wait() + + +def classify_worker_exit(proc: subprocess.Popen) -> WorkerResult: + """Classify how the worker exited after it is no longer running.""" + code = proc.returncode + if code is None: + return WorkerResult(success=False, error="worker still running") + if code == 0: + return WorkerResult(success=True, exit_code=0) + if code < 0: + sig = -code + try: + sig_name = signal.Signals(sig).name + except ValueError: + sig_name = str(sig) + return WorkerResult( + success=False, + exit_code=code, + signal_number=sig, + error=f"worker killed by {sig_name}", + ) + return WorkerResult(success=False, exit_code=code, error=f"worker exited with code {code}") + + +def repair_flow_state(flow_json_path: str | Path) -> list[str]: + """Repair Ongoing steps left by a crashed worker, setting them to Incomplete. + + Returns the list of step names that were repaired. + """ + path = Path(flow_json_path) + data = json_read(path) + if not data: + return [] + + steps = data.get("steps") + if not isinstance(steps, list): + return [] + + repaired: list[str] = [] + for step in steps: + if not isinstance(step, dict): + continue + if step.get("state") == "Ongoing": + step["state"] = "Incomplete" + repaired.append(step.get("name", "")) + + if repaired: + json_write(path, data) + + return repaired diff --git a/chipcompiler/tools/ecc_dreamplace/module.py b/chipcompiler/tools/ecc_dreamplace/module.py index e35eefc9..f6027b8c 100644 --- a/chipcompiler/tools/ecc_dreamplace/module.py +++ b/chipcompiler/tools/ecc_dreamplace/module.py @@ -2,7 +2,6 @@ import json import logging -import os import sys from contextlib import contextmanager from pathlib import Path @@ -60,25 +59,15 @@ def _build_params(self, params_cls, *, legalize_only: bool): return params - def _log_path(self, *, legalize_only: bool) -> str: - log_name = "dreamplace_legalization.log" if legalize_only else "dreamplace_placement.log" - return os.path.join(self.result_dir, log_name) - @contextmanager def _configure_root_logging(self, *, legalize_only: bool): root_logger = logging.getLogger() original_handlers = root_logger.handlers[:] original_level = root_logger.level - log_file = self.step.log.file or self._log_path(legalize_only=legalize_only) - os.makedirs(os.path.dirname(log_file) or ".", exist_ok=True) - formatter = logging.Formatter("[%(levelname)-7s] %(message)s") - file_handler = logging.FileHandler(log_file, mode="w", encoding="utf-8") - file_handler.setFormatter(formatter) stdout_handler = logging.StreamHandler(sys.stdout) stdout_handler.setFormatter(formatter) - root_logger.addHandler(file_handler) root_logger.addHandler(stdout_handler) if original_level > logging.INFO: root_logger.setLevel(logging.INFO) @@ -86,9 +75,7 @@ def _configure_root_logging(self, *, legalize_only: bool): try: yield finally: - root_logger.removeHandler(file_handler) root_logger.removeHandler(stdout_handler) - file_handler.close() stdout_handler.close() root_logger.setLevel(original_level) for handler in original_handlers: diff --git a/chipcompiler/tools/ecc_sizer/runner.py b/chipcompiler/tools/ecc_sizer/runner.py index 046cdb2d..df413503 100644 --- a/chipcompiler/tools/ecc_sizer/runner.py +++ b/chipcompiler/tools/ecc_sizer/runner.py @@ -43,19 +43,15 @@ def run_step( output_dir = step.data.workdir_for(step.name) or "" os.makedirs(output_dir, exist_ok=True) - log_path = step.log.file or "" - os.makedirs(os.path.dirname(log_path), exist_ok=True) os.makedirs(os.path.dirname(step.output.def_ or ""), exist_ok=True) command = get_sizer_command() + ["-env", str(env_path), "-f", str(cmd_path)] - with open(log_path, "w", encoding="utf-8") as log_file: - result = subprocess.run( - command, - cwd=str(output_dir), - stdout=log_file, - stderr=subprocess.STDOUT, - check=False, - ) + result = subprocess.run( + command, + cwd=str(output_dir), + stderr=subprocess.STDOUT, + check=False, + ) if result.returncode == 0 and _has_required_outputs(step): sub_flow.update_step(step_name=run_sizer_step, state=StateEnum.Success) diff --git a/chipcompiler/tools/yosys/runner.py b/chipcompiler/tools/yosys/runner.py index 9024c8bb..f5267752 100644 --- a/chipcompiler/tools/yosys/runner.py +++ b/chipcompiler/tools/yosys/runner.py @@ -45,17 +45,11 @@ def run_step(workspace: Workspace, step: YosysStep, ecc_module=None) -> bool: True if synthesis succeeded, False otherwise """ sub_flow = YosysSubFlow(workspace=workspace, workspace_step=step) - log_path = step.log.file or "" yosys_cmd, yosys_env = get_yosys_runtime() if not yosys_cmd: sub_flow.update_step(step_name="run yosys", state=StateEnum.Invalid) error_msg = "Error: yosys is not available (bundled runtime or PATH)." - try: - with open(log_path, "w") as log_file: - log_file.write(error_msg + "\n") - except Exception: - pass logger.error(error_msg) return False @@ -76,24 +70,21 @@ def run_step(workspace: Workspace, step: YosysStep, ecc_module=None) -> bool: cmd = yosys_cmd + ["yosys_synthesis.tcl"] - with open(log_path, "w") as log_file: - step_data = getattr(step, "data", None) - if getattr(step_data, "requires_slang", True) and not check_slang_support( - yosys_cmd=yosys_cmd, - cwd_dir=cwd_dir, - yosys_env=yosys_env, - log_file=log_file, - ): - sub_flow.update_step(step_name="run yosys", state=StateEnum.Invalid) - return False - - result = subprocess.run( - cmd, - cwd=cwd_dir, - env=yosys_env, - stdout=log_file, - stderr=subprocess.STDOUT, - ) + step_data = getattr(step, "data", None) + if getattr(step_data, "requires_slang", True) and not check_slang_support( + yosys_cmd=yosys_cmd, + cwd_dir=cwd_dir, + yosys_env=yosys_env, + ): + sub_flow.update_step(step_name="run yosys", state=StateEnum.Invalid) + return False + + result = subprocess.run( + cmd, + cwd=cwd_dir, + env=yosys_env, + stderr=subprocess.STDOUT, + ) if os.path.exists(step.output.verilog or ""): sub_flow.update_step(step_name="run yosys", state=StateEnum.Success) diff --git a/chipcompiler/tools/yosys/utility.py b/chipcompiler/tools/yosys/utility.py index 9af1ced4..986c1e28 100644 --- a/chipcompiler/tools/yosys/utility.py +++ b/chipcompiler/tools/yosys/utility.py @@ -109,7 +109,7 @@ def get_yosys_runtime() -> tuple[list[str], dict[str, str]]: def check_slang_support( - yosys_cmd: list[str], cwd_dir: str, yosys_env: dict[str, str], log_file, timeout: int = 60 + yosys_cmd: list[str], cwd_dir: str, yosys_env: dict[str, str], timeout: int = 60 ) -> bool: """ Run a lightweight slang frontend availability check. @@ -134,7 +134,6 @@ def check_slang_support( yosys_cmd + ["-Q", "-T", "-p", "plugin -i slang"], cwd=cwd_dir, env=yosys_env, - stdout=log_file, stderr=subprocess.STDOUT, timeout=timeout, ) @@ -146,7 +145,6 @@ def check_slang_support( "Neither builtin read_slang nor a loadable slang plugin was found. " "Please use a yosys build with slang support." ) - log_file.write(error_msg + "\n") print(error_msg) return False diff --git a/test/runtime/test_log_stream.py b/test/runtime/test_log_stream.py new file mode 100644 index 00000000..9fc01f74 --- /dev/null +++ b/test/runtime/test_log_stream.py @@ -0,0 +1,156 @@ +"""Tests for chipcompiler.runtime.log_stream — marker parsing and archive.""" + +import io + +from chipcompiler.runtime.log_stream import ( + LogStreamReader, + StepMarker, + parse_marker, +) + + +class TestParseMarker: + def test_valid_begin(self): + line = b'\x1eECC-STEP {"event":"begin","step":"Synthesis","tool":"yosys"}\n' + m = parse_marker(line) + assert m == StepMarker(event="begin", step="Synthesis", tool="yosys") + + def test_valid_end(self): + line = b'\x1eECC-STEP {"event":"end","step":"Placement","tool":"ecc"}\n' + m = parse_marker(line) + assert m == StepMarker(event="end", step="Placement", tool="ecc") + + def test_no_prefix(self): + assert parse_marker(b"normal log line\n") is None + + def test_malformed_json(self): + assert parse_marker(b"\x1eECC-STEP {bad json}\n") is None + + def test_missing_fields(self): + line = b'\x1eECC-STEP {"event":"begin"}\n' + assert parse_marker(line) is None + + def test_wrong_field_types(self): + line = b'\x1eECC-STEP {"event":1,"step":"A","tool":"B"}\n' + assert parse_marker(line) is None + + def test_no_trailing_newline(self): + line = b'\x1eECC-STEP {"event":"begin","step":"S","tool":"T"}' + m = parse_marker(line) + assert m is not None + assert m.event == "begin" + + +class TestLogStreamReader: + def _make_stream(self, chunks: list[bytes]) -> io.BytesIO: + return io.BytesIO(b"".join(chunks)) + + def test_archives_to_step_file(self, tmp_path): + log_path = tmp_path / "synth.log" + + def resolver(step, tool): + return log_path + + stream_data = ( + b'\x1eECC-STEP {"event":"begin","step":"Synthesis","tool":"yosys"}\n' + b"yosys output line 1\n" + b"yosys output line 2\n" + b'\x1eECC-STEP {"event":"end","step":"Synthesis","tool":"yosys"}\n' + ) + stream = io.BytesIO(stream_data) + reader = LogStreamReader(stream, log_path_resolver=resolver) + reader.start() + reader.join(timeout=5) + + assert log_path.exists() + content = log_path.read_bytes() + assert b"yosys output line 1\n" in content + assert b"yosys output line 2\n" in content + assert b"ECC-STEP" not in content + + def test_markers_not_archived(self, tmp_path): + log_path = tmp_path / "step.log" + + def resolver(step, tool): + return log_path + + stream_data = ( + b'\x1eECC-STEP {"event":"begin","step":"S","tool":"T"}\n' + b"data\n" + b'\x1eECC-STEP {"event":"end","step":"S","tool":"T"}\n' + ) + reader = LogStreamReader(io.BytesIO(stream_data), log_path_resolver=resolver) + reader.start() + reader.join(timeout=5) + assert log_path.read_bytes() == b"data\n" + + def test_multiple_steps(self, tmp_path): + paths = {} + + def resolver(step, tool): + p = tmp_path / f"{step}.log" + paths[step] = p + return p + + stream_data = ( + b'\x1eECC-STEP {"event":"begin","step":"A","tool":"t"}\n' + b"output A\n" + b'\x1eECC-STEP {"event":"end","step":"A","tool":"t"}\n' + b'\x1eECC-STEP {"event":"begin","step":"B","tool":"t"}\n' + b"output B\n" + b'\x1eECC-STEP {"event":"end","step":"B","tool":"t"}\n' + ) + reader = LogStreamReader(io.BytesIO(stream_data), log_path_resolver=resolver) + reader.start() + reader.join(timeout=5) + assert paths["A"].read_bytes() == b"output A\n" + assert paths["B"].read_bytes() == b"output B\n" + assert reader.state.steps_seen == ["A", "B"] + + def test_non_utf8_bytes_preserved(self, tmp_path): + log_path = tmp_path / "step.log" + + def resolver(step, tool): + return log_path + + raw = b"\x80\x81\xff\xfe binary data\n" + stream_data = ( + b'\x1eECC-STEP {"event":"begin","step":"S","tool":"T"}\n' + + raw + + b'\x1eECC-STEP {"event":"end","step":"S","tool":"T"}\n' + ) + reader = LogStreamReader(io.BytesIO(stream_data), log_path_resolver=resolver) + reader.start() + reader.join(timeout=5) + assert log_path.read_bytes() == raw + + def test_malformed_marker_treated_as_data(self, tmp_path): + log_path = tmp_path / "step.log" + + def resolver(step, tool): + return log_path + + stream_data = ( + b'\x1eECC-STEP {"event":"begin","step":"S","tool":"T"}\n' + b"\x1eECC-STEP {bad json}\n" + b'\x1eECC-STEP {"event":"end","step":"S","tool":"T"}\n' + ) + reader = LogStreamReader(io.BytesIO(stream_data), log_path_resolver=resolver) + reader.start() + reader.join(timeout=5) + assert b"{bad json}" in log_path.read_bytes() + + def test_on_output_callback(self): + received = [] + stream_data = b"hello world\n" + reader = LogStreamReader(io.BytesIO(stream_data), on_output=received.append) + reader.start() + reader.join(timeout=5) + assert b"hello world\n" in b"".join(received) + + def test_tail_bytes_maintained(self): + data = b"x" * 8000 + b"\n" + reader = LogStreamReader(io.BytesIO(data), tail_size=100) + reader.start() + reader.join(timeout=5) + assert len(reader.state.tail_bytes) == 100 diff --git a/test/runtime/test_stdio_isolation.py b/test/runtime/test_stdio_isolation.py new file mode 100644 index 00000000..f5bb8ac4 --- /dev/null +++ b/test/runtime/test_stdio_isolation.py @@ -0,0 +1,84 @@ +"""Tests for chipcompiler.runtime.stdio_isolation.""" + +import os +import sys + +import pytest + +from chipcompiler.runtime.stdio_isolation import StdioIsolation + + +class TestStdioIsolation: + def test_not_installed_raises(self): + iso = StdioIsolation() + with pytest.raises(RuntimeError, match="not installed"): + _ = iso.protocol_stream + + def test_install_returns_writable_stream(self): + iso = StdioIsolation() + saved_stdout_fd = os.dup(1) + saved_stderr_fd = os.dup(2) + saved_stdout = sys.stdout + try: + r_fd, w_fd = os.pipe() + os.dup2(w_fd, 1) + os.close(w_fd) + + r2_fd, w2_fd = os.pipe() + os.dup2(w2_fd, 2) + os.close(w2_fd) + + stream = iso.install() + assert iso.installed + + stream.write(b"protocol data") + stream.flush() + + os.write(1, b"tool output on fd1") + + os.dup2(saved_stdout_fd, 1) + os.dup2(saved_stderr_fd, 2) + sys.stdout = saved_stdout + + protocol_data = os.read(r_fd, 4096) + stderr_data = os.read(r2_fd, 4096) + + assert protocol_data == b"protocol data" + assert b"tool output on fd1" in stderr_data + + iso.close() + os.close(r_fd) + os.close(r2_fd) + finally: + os.dup2(saved_stdout_fd, 1) + os.dup2(saved_stderr_fd, 2) + sys.stdout = saved_stdout + os.close(saved_stdout_fd) + os.close(saved_stderr_fd) + + def test_double_install_is_idempotent(self): + iso = StdioIsolation() + saved_stdout_fd = os.dup(1) + saved_stderr_fd = os.dup(2) + saved_stdout = sys.stdout + try: + r_fd, w_fd = os.pipe() + os.dup2(w_fd, 1) + os.close(w_fd) + r2_fd, w2_fd = os.pipe() + os.dup2(w2_fd, 2) + os.close(w2_fd) + + s1 = iso.install() + s2 = iso.install() + assert s1 is s2 + + iso.close() + os.close(r_fd) + os.close(r2_fd) + finally: + os.dup2(saved_stdout_fd, 1) + os.dup2(saved_stderr_fd, 2) + sys.stdout = saved_stdout + os.close(saved_stdout_fd) + os.close(saved_stderr_fd) diff --git a/test/runtime/test_worker.py b/test/runtime/test_worker.py new file mode 100644 index 00000000..6b07e72f --- /dev/null +++ b/test/runtime/test_worker.py @@ -0,0 +1,156 @@ +"""Tests for chipcompiler.runtime.worker — lifecycle, signal handling, and state repair.""" + +import json +import signal +import sys +import textwrap +from unittest.mock import MagicMock + +from chipcompiler.runtime.worker import ( + WorkerClient, + WorkerResult, + classify_worker_exit, + repair_flow_state, +) + + +class TestWorkerResult: + def test_success_result(self): + r = WorkerResult(success=True, response={"result": {"ok": True}}) + assert r.success is True + assert r.response == {"result": {"ok": True}} + + def test_failure_result(self): + r = WorkerResult(success=False, error="timeout", exit_code=1) + assert r.success is False + assert r.error == "timeout" + assert r.exit_code == 1 + + +class TestClassifyWorkerExit: + def test_normal_exit(self): + proc = MagicMock() + proc.returncode = 0 + result = classify_worker_exit(proc) + assert result.success is True + assert result.exit_code == 0 + + def test_nonzero_exit(self): + proc = MagicMock() + proc.returncode = 1 + result = classify_worker_exit(proc) + assert result.success is False + assert result.exit_code == 1 + + def test_signal_kill(self): + proc = MagicMock() + proc.returncode = -signal.SIGKILL + result = classify_worker_exit(proc) + assert result.success is False + assert result.signal_number == signal.SIGKILL + assert "SIGKILL" in result.error + + def test_signal_abort(self): + proc = MagicMock() + proc.returncode = -signal.SIGABRT + result = classify_worker_exit(proc) + assert result.success is False + assert result.signal_number == signal.SIGABRT + + def test_still_running(self): + proc = MagicMock() + proc.returncode = None + result = classify_worker_exit(proc) + assert result.success is False + assert "still running" in result.error + + +class TestRepairFlowState: + def test_repairs_ongoing_to_incomplete(self, tmp_path): + flow_json = tmp_path / "flow.json" + data = { + "steps": [ + {"name": "Synthesis", "tool": "yosys", "state": "Success"}, + {"name": "Placement", "tool": "ecc", "state": "Ongoing"}, + {"name": "Routing", "tool": "ecc", "state": "Unstart"}, + ] + } + flow_json.write_text(json.dumps(data)) + repaired = repair_flow_state(flow_json) + assert repaired == ["Placement"] + result = json.loads(flow_json.read_text()) + assert result["steps"][1]["state"] == "Incomplete" + assert result["steps"][0]["state"] == "Success" + assert result["steps"][2]["state"] == "Unstart" + + def test_no_ongoing_steps(self, tmp_path): + flow_json = tmp_path / "flow.json" + data = {"steps": [{"name": "Synthesis", "tool": "yosys", "state": "Success"}]} + flow_json.write_text(json.dumps(data)) + repaired = repair_flow_state(flow_json) + assert repaired == [] + + def test_missing_file(self, tmp_path): + flow_json = tmp_path / "nonexistent.json" + repaired = repair_flow_state(flow_json) + assert repaired == [] + + def test_empty_file(self, tmp_path): + flow_json = tmp_path / "flow.json" + flow_json.write_text("{}") + repaired = repair_flow_state(flow_json) + assert repaired == [] + + def test_multiple_ongoing(self, tmp_path): + flow_json = tmp_path / "flow.json" + data = { + "steps": [ + {"name": "A", "tool": "t1", "state": "Ongoing"}, + {"name": "B", "tool": "t2", "state": "Ongoing"}, + ] + } + flow_json.write_text(json.dumps(data)) + repaired = repair_flow_state(flow_json) + assert set(repaired) == {"A", "B"} + + +class TestWorkerClientSubprocess: + """Integration test using a real subprocess.""" + + def test_start_and_terminate(self): + client = WorkerClient([sys.executable, "-c", "import time; time.sleep(60)"]) + client.start() + assert client.is_alive() + client.terminate() + assert not client.is_alive() + + def test_rpc_round_trip(self): + script = textwrap.dedent("""\ + import sys, json + data = b"" + while True: + chunk = sys.stdin.buffer.read(1) + if not chunk: + break + data += chunk + if b"\\r\\n\\r\\n" in data: + header, _, body_start = data.partition(b"\\r\\n\\r\\n") + length = int(header.split(b":")[1]) + while len(body_start) < length: + body_start += sys.stdin.buffer.read(1) + request = json.loads(body_start[:length]) + resp = {"jsonrpc": "2.0", "result": {"echo": True}, "id": request["id"]} + response = json.dumps(resp) + frame = f"Content-Length: {len(response)}\\r\\n\\r\\n{response}" + sys.stdout.buffer.write(frame.encode()) + sys.stdout.buffer.flush() + break + """) + client = WorkerClient([sys.executable, "-c", script]) + client.start() + try: + result = client.request("test.echo", {}) + assert result.success is True + assert result.response["result"]["echo"] is True + finally: + client.terminate() diff --git a/test/tools/ecc_sizer/test_runner.py b/test/tools/ecc_sizer/test_runner.py index 1823b478..10b75f4e 100644 --- a/test/tools/ecc_sizer/test_runner.py +++ b/test/tools/ecc_sizer/test_runner.py @@ -28,7 +28,7 @@ def __getattribute__(self, name): calls = [] - def fake_run(command, cwd, stdout, stderr, check): + def fake_run(command, cwd, stderr, check): calls.append((command, cwd, stderr, check)) os.makedirs(os.path.dirname(str(step.output.def_)), exist_ok=True) with open(str(step.output.def_), "w", encoding="utf-8") as file: @@ -118,7 +118,7 @@ def test_sizer_runner_marks_subflow_incomplete_when_outputs_are_missing( monkeypatch.setattr( subprocess, "run", - lambda command, cwd, stdout, stderr, check: SimpleNamespace(returncode=0), + lambda command, cwd, stderr, check: SimpleNamespace(returncode=0), ) assert sizer_runner.run_step(workspace, step) == StateEnum.Imcomplete diff --git a/test/tools/yosys/test_runner.py b/test/tools/yosys/test_runner.py index 77e163e2..34078ae7 100644 --- a/test/tools/yosys/test_runner.py +++ b/test/tools/yosys/test_runner.py @@ -48,7 +48,7 @@ def __init__(self, workspace, workspace_step): def check(self): return None - def fake_check_slang_support(yosys_cmd, cwd_dir, yosys_env, log_file): + def fake_check_slang_support(yosys_cmd, cwd_dir, yosys_env): check_calls.append( { "yosys_cmd": list(yosys_cmd), @@ -58,7 +58,7 @@ def fake_check_slang_support(yosys_cmd, cwd_dir, yosys_env, log_file): ) return True - def fake_run(cmd, cwd, env, stdout, stderr): + def fake_run(cmd, cwd, env, stderr): run_calls.append( { "cmd": list(cmd), @@ -118,7 +118,7 @@ def __init__(self, workspace, workspace_step): def check(self): return None - def fake_run(cmd, cwd, env, stdout, stderr): + def fake_run(cmd, cwd, env, stderr): output_file.write_text("module top(); endmodule\n") return SimpleNamespace(returncode=0) @@ -147,11 +147,10 @@ def __init__(self, workspace, workspace_step): def update_step(self, step_name, state, runtime="", memory=0, info=None): updates.append((step_name, state)) - def fake_check_slang_support(yosys_cmd, cwd_dir, yosys_env, log_file): - log_file.write("Error: yosys slang frontend check failed.\n") + def fake_check_slang_support(yosys_cmd, cwd_dir, yosys_env): return False - def fake_run(cmd, cwd, env, stdout, stderr): + def fake_run(cmd, cwd, env, stderr): run_calls.append(list(cmd)) raise AssertionError("Synthesis should not run when slang check fails") @@ -166,7 +165,6 @@ def fake_run(cmd, cwd, env, stdout, stderr): assert result is False assert run_calls == [] assert ("run yosys", StateEnum.Invalid) in updates - assert "slang frontend check failed" in log_file.read_text() def test_run_step_skips_slang_check_for_native_verilog(tmp_path, monkeypatch): @@ -191,7 +189,7 @@ def check(self): def fail_slang_check(*args, **kwargs): raise AssertionError("native Verilog must not probe the Slang frontend") - def fake_run(cmd, cwd, env, stdout, stderr): + def fake_run(cmd, cwd, env, stderr): output_file.write_text("module top(); endmodule\n") return SimpleNamespace(returncode=0) @@ -225,4 +223,3 @@ def update_step(self, step_name, state, runtime="", memory=0, info=None): assert result is False assert ("run yosys", StateEnum.Invalid) in updates - assert "yosys is not available" in log_file.read_text() diff --git a/test/tools/yosys/test_utility.py b/test/tools/yosys/test_utility.py index 3ff7c327..240702e5 100644 --- a/test/tools/yosys/test_utility.py +++ b/test/tools/yosys/test_utility.py @@ -66,11 +66,10 @@ def test_get_yosys_runtime_builds_local_env_without_mutating_global_env(tmp_path assert before == after -def test_check_slang_support_accepts_builtin_frontend(tmp_path, monkeypatch): - log_path = tmp_path / "check.log" +def test_check_slang_support_accepts_builtin_frontend(monkeypatch): calls = [] - def fake_run(cmd, cwd, env, stdout, stderr, timeout): + def fake_run(cmd, cwd, env, stderr, timeout, stdout=None): calls.append( { "cmd": list(cmd), @@ -82,13 +81,11 @@ def fake_run(cmd, cwd, env, stdout, stderr, timeout): return SimpleNamespace(returncode=0, stdout=b"read_slang [options] [filename]") monkeypatch.setattr(utility.subprocess, "run", fake_run) - with open(log_path, "w") as log_file: - ok = utility.check_slang_support( - yosys_cmd=["yosys"], - cwd_dir="/tmp/test", - yosys_env={"PATH": "/tmp/bin"}, - log_file=log_file, - ) + ok = utility.check_slang_support( + yosys_cmd=["yosys"], + cwd_dir="/tmp/test", + yosys_env={"PATH": "/tmp/bin"}, + ) assert ok is True assert len(calls) == 1 @@ -98,24 +95,21 @@ def fake_run(cmd, cwd, env, stdout, stderr, timeout): assert calls[0]["timeout"] == 60 -def test_check_slang_support_falls_back_to_plugin(tmp_path, monkeypatch): - log_path = tmp_path / "check.log" +def test_check_slang_support_falls_back_to_plugin(monkeypatch): calls = [] - def fake_run(cmd, cwd, env, stdout, stderr, timeout): + def fake_run(cmd, cwd, env, stderr, timeout, stdout=None): calls.append(list(cmd)) if "help read_slang" in cmd: return SimpleNamespace(returncode=0, stdout=b"No such command or cell type: read_slang") return SimpleNamespace(returncode=0, stdout=b"") monkeypatch.setattr(utility.subprocess, "run", fake_run) - with open(log_path, "w") as log_file: - ok = utility.check_slang_support( - yosys_cmd=["yosys"], - cwd_dir="/tmp/test", - yosys_env={"PATH": "/tmp/bin"}, - log_file=log_file, - ) + ok = utility.check_slang_support( + yosys_cmd=["yosys"], + cwd_dir="/tmp/test", + yosys_env={"PATH": "/tmp/bin"}, + ) assert ok is True assert calls == [ @@ -124,22 +118,19 @@ def fake_run(cmd, cwd, env, stdout, stderr, timeout): ] -def test_check_slang_support_writes_error_on_failure(tmp_path, monkeypatch): - log_path = tmp_path / "check_fail.log" - - def fake_run(cmd, cwd, env, stdout, stderr, timeout): +def test_check_slang_support_prints_error_on_failure(monkeypatch, capsys): + def fake_run(cmd, cwd, env, stderr, timeout, stdout=None): if "help read_slang" in cmd: return SimpleNamespace(returncode=0, stdout=b"No such command or cell type: read_slang") return SimpleNamespace(returncode=1, stdout=b"") monkeypatch.setattr(utility.subprocess, "run", fake_run) - with open(log_path, "w") as log_file: - ok = utility.check_slang_support( - yosys_cmd=["yosys"], - cwd_dir="/tmp/test", - yosys_env={"PATH": "/tmp/bin"}, - log_file=log_file, - ) + ok = utility.check_slang_support( + yosys_cmd=["yosys"], + cwd_dir="/tmp/test", + yosys_env={"PATH": "/tmp/bin"}, + ) assert ok is False - assert "slang frontend check failed" in log_path.read_text() + assert "slang frontend check failed" in capsys.readouterr().out + From 598e4dd6adc35e18b60d060bc7969d3f080db856 Mon Sep 17 00:00:00 2001 From: Emin Date: Tue, 11 Aug 2026 18:10:57 +0800 Subject: [PATCH 02/52] fix: correct P0 defects in worker, stdio isolation, and log stream Worker response correlation now matches by request_id; notifications are queued separately. Process-group cleanup caches pgid at start and signals the group even after leader exits. Operation-scoped repair only touches the named step. Production stdio_server.main() installs StdioIsolation permanently; per-handler redirect_stdout_to_stderr removed from rpc_dispatch (now a no-op under permanent isolation). LogStreamReader archives unknown marker events as raw data instead of silently discarding them. Archive I/O errors are surfaced through state.error. --- chipcompiler/runtime/log_stream.py | 27 ++--- chipcompiler/runtime/rpc_dispatch.py | 5 +- chipcompiler/runtime/stdio_isolation.py | 6 +- chipcompiler/runtime/stdio_server.py | 17 ++- chipcompiler/runtime/worker.py | 106 +++++++++++------ chipcompiler/tools/ecc_dreamplace/module.py | 4 +- test/runtime/test_log_stream.py | 67 +++++++++++ test/runtime/test_stdio_server.py | 59 +++++----- test/runtime/test_worker.py | 122 ++++++++++++++++++++ 9 files changed, 314 insertions(+), 99 deletions(-) diff --git a/chipcompiler/runtime/log_stream.py b/chipcompiler/runtime/log_stream.py index 63dd1bc4..c6c43505 100644 --- a/chipcompiler/runtime/log_stream.py +++ b/chipcompiler/runtime/log_stream.py @@ -34,7 +34,7 @@ def emit_step_marker(event: str, step: str, tool: str) -> None: sys.stdout.flush() sys.stderr.flush() payload = json.dumps({"event": event, "step": step, "tool": tool}, separators=(",", ":")) - line = b"\x1eECC-STEP " + payload.encode("utf-8") + b"\n" + line = MARKER_PREFIX + payload.encode("utf-8") + b"\n" os.write(2, line) @@ -61,8 +61,6 @@ def parse_marker(line: bytes) -> StepMarker | None: class LogStreamState: """Mutable state maintained by the log stream reader.""" - current_step: str | None = None - current_tool: str | None = None tail_bytes: bytes = b"" archive_file: BinaryIO | None = field(default=None, repr=False) bytes_archived: int = 0 @@ -138,30 +136,28 @@ def _process_buffer(self, buf: bytes) -> bytes: buf = buf[nl + 1 :] marker = parse_marker(line) if marker is not None: - self._handle_marker(marker) + self._handle_marker(marker, line) else: self._emit_data(line) - return buf - def _handle_marker(self, marker: StepMarker) -> None: + def _handle_marker(self, marker: StepMarker, raw_line: bytes) -> None: if marker.event == "begin": self._close_archive() - self._state.current_step = marker.step - self._state.current_tool = marker.tool self._state.steps_seen.append(marker.step) self._open_archive(marker.step, marker.tool) elif marker.event == "end": self._close_archive() - self._state.current_step = None - self._state.current_tool = None + else: + self._emit_data(raw_line) def _emit_data(self, data: bytes) -> None: if self._state.archive_file is not None: try: self._state.archive_file.write(data) self._state.bytes_archived += len(data) - except OSError: - pass + except OSError as exc: + self._state.error = exc + self._state.archive_file = None self._update_tail(data) if self._on_output is not None: self._on_output(data) @@ -181,7 +177,8 @@ def _open_archive(self, step: str, tool: str) -> None: try: path.parent.mkdir(parents=True, exist_ok=True) self._state.archive_file = path.open("wb") # noqa: SIM115 - except OSError: + except OSError as exc: + self._state.error = exc self._state.archive_file = None def _close_archive(self) -> None: @@ -189,6 +186,6 @@ def _close_archive(self) -> None: try: self._state.archive_file.flush() self._state.archive_file.close() - except OSError: - pass + except OSError as exc: + self._state.error = exc self._state.archive_file = None diff --git a/chipcompiler/runtime/rpc_dispatch.py b/chipcompiler/runtime/rpc_dispatch.py index 89ad3f91..b2cae712 100644 --- a/chipcompiler/runtime/rpc_dispatch.py +++ b/chipcompiler/runtime/rpc_dispatch.py @@ -5,8 +5,6 @@ from jsonrpcserver import Success, dispatch from oslash.either import Left, Right -from chipcompiler.runtime.events import redirect_stdout_to_stderr - JsonRpcHandler = Callable[..., Any] @@ -31,8 +29,7 @@ def dispatch(self, payload: bytes | str) -> str: def _wrap_handler(self, handler: JsonRpcHandler) -> JsonRpcHandler: @wraps(handler) def wrapped(*args: Any, **kwargs: Any): - with redirect_stdout_to_stderr(): - result = handler(*args, **kwargs) + result = handler(*args, **kwargs) if isinstance(result, Left | Right): return result return Success(result) diff --git a/chipcompiler/runtime/stdio_isolation.py b/chipcompiler/runtime/stdio_isolation.py index ad4a6a17..baa5f889 100644 --- a/chipcompiler/runtime/stdio_isolation.py +++ b/chipcompiler/runtime/stdio_isolation.py @@ -20,7 +20,6 @@ class StdioIsolation: """ def __init__(self): - self._protocol_fd: int | None = None self._protocol_stream: BinaryIO | None = None self._installed = False @@ -42,11 +41,11 @@ def install(self) -> BinaryIO: sys.stdout.flush() sys.stderr.flush() - self._protocol_fd = os.dup(1) + protocol_fd = os.dup(1) os.dup2(2, 1) sys.stdout = sys.stderr - self._protocol_stream = os.fdopen(self._protocol_fd, "wb", buffering=0) + self._protocol_stream = os.fdopen(protocol_fd, "wb", buffering=0) self._installed = True return self._protocol_stream @@ -54,4 +53,3 @@ def close(self) -> None: if self._protocol_stream is not None: self._protocol_stream.close() self._protocol_stream = None - self._protocol_fd = None diff --git a/chipcompiler/runtime/stdio_server.py b/chipcompiler/runtime/stdio_server.py index de0f9ced..630140d7 100644 --- a/chipcompiler/runtime/stdio_server.py +++ b/chipcompiler/runtime/stdio_server.py @@ -113,8 +113,15 @@ def _read_chunk(input_stream: BinaryIO) -> bytes: def main(*, persistent_db_enabled: bool = False) -> int: - return run_stdio_server( - sys.stdin.buffer, - sys.stdout.buffer, - persistent_db_enabled=persistent_db_enabled, - ) + from chipcompiler.runtime.stdio_isolation import StdioIsolation + + isolation = StdioIsolation() + protocol_stream = isolation.install() + try: + return run_stdio_server( + sys.stdin.buffer, + protocol_stream, + persistent_db_enabled=persistent_db_enabled, + ) + finally: + isolation.close() diff --git a/chipcompiler/runtime/worker.py b/chipcompiler/runtime/worker.py index 493d79ec..8e9ef66d 100644 --- a/chipcompiler/runtime/worker.py +++ b/chipcompiler/runtime/worker.py @@ -2,7 +2,7 @@ import os import signal import subprocess -import threading +from collections import deque from contextlib import suppress from dataclasses import dataclass from pathlib import Path @@ -39,8 +39,9 @@ class WorkerClient: def __init__(self, worker_argv: list[str]): self._argv = worker_argv self._process: subprocess.Popen | None = None - self._lock = threading.Lock() + self._pgid: int | None = None self._decoder = ContentLengthDecoder() + self._notifications: deque[dict] = deque() def start(self) -> subprocess.Popen: self._process = subprocess.Popen( @@ -50,6 +51,7 @@ def start(self) -> subprocess.Popen: stderr=subprocess.PIPE, start_new_session=True, ) + self._pgid = self._process.pid return self._process @property @@ -76,7 +78,12 @@ def send_request(self, method: str, params: dict, request_id: int = 1) -> None: except OSError as exc: raise WorkerProcessError(f"failed to send request: {exc}") from exc - def read_response(self) -> dict: + def read_response(self, request_id: int = 1) -> dict: + """Read the next RPC response matching request_id. + + Notifications (messages without an 'id' field) are queued internally. + Malformed JSON raises WorkerProcessError. + """ if self._process is None or self._process.stdout is None: raise WorkerProcessError("worker not started") while True: @@ -88,13 +95,30 @@ def read_response(self) -> dict: messages = self._decoder.feed(chunk) except TransportError as exc: raise WorkerProcessError(f"protocol error: {exc}") from exc - for msg in messages: - return json.loads(msg) + for raw in messages: + try: + msg = json.loads(raw) + except (json.JSONDecodeError, ValueError) as exc: + raise WorkerProcessError(f"malformed JSON from worker: {exc}") from exc + if not isinstance(msg, dict): + raise WorkerProcessError("expected JSON object from worker") + if "id" not in msg: + self._notifications.append(msg) + continue + if msg["id"] != request_id: + self._notifications.append(msg) + continue + return msg + + def pop_notification(self) -> dict | None: + if self._notifications: + return self._notifications.popleft() + return None def request(self, method: str, params: dict, request_id: int = 1) -> WorkerResult: try: self.send_request(method, params, request_id) - response = self.read_response() + response = self.read_response(request_id) except WorkerProcessError as exc: return WorkerResult(success=False, error=str(exc)) if "error" in response: @@ -106,7 +130,7 @@ def terminate(self) -> int | None: proc = self._process if proc is None: return None - return _terminate_process_group(proc) + return _terminate_process_group(proc, self._pgid) def is_alive(self) -> bool: if self._process is None: @@ -114,36 +138,36 @@ def is_alive(self) -> bool: return self._process.poll() is None -def _terminate_process_group(proc: subprocess.Popen) -> int: - """Escalate signals to the worker process group.""" - pid = proc.pid - try: - pgid = os.getpgid(pid) - except OSError: - return proc.wait() +def _terminate_process_group(proc: subprocess.Popen, pgid: int | None = None) -> int: + """Escalate signals to the worker process group. - if proc.poll() is not None: - return proc.returncode + pgid is cached at start time (the worker pid, since start_new_session=True). + Signals the group even after the leader has already exited, because + descendants may still be running. + """ + if pgid is None: + pgid = proc.pid - with suppress(OSError): - os.killpg(pgid, signal.SIGINT) - try: - proc.wait(timeout=_GRACEFUL_WAIT) - return proc.returncode - except subprocess.TimeoutExpired: - pass + def _signal_group(sig: int) -> None: + with suppress(OSError): + os.killpg(pgid, sig) - with suppress(OSError): - os.killpg(pgid, signal.SIGTERM) - try: - proc.wait(timeout=_FORCEFUL_WAIT) - return proc.returncode - except subprocess.TimeoutExpired: - pass + if proc.poll() is None: + _signal_group(signal.SIGINT) + try: + proc.wait(timeout=_GRACEFUL_WAIT) + except subprocess.TimeoutExpired: + _signal_group(signal.SIGTERM) + try: + proc.wait(timeout=_FORCEFUL_WAIT) + except subprocess.TimeoutExpired: + _signal_group(signal.SIGKILL) + proc.wait() + else: + _signal_group(signal.SIGTERM) + _signal_group(signal.SIGKILL) - with suppress(OSError): - os.killpg(pgid, signal.SIGKILL) - return proc.wait() + return proc.returncode def classify_worker_exit(proc: subprocess.Popen) -> WorkerResult: @@ -168,10 +192,14 @@ def classify_worker_exit(proc: subprocess.Popen) -> WorkerResult: return WorkerResult(success=False, exit_code=code, error=f"worker exited with code {code}") -def repair_flow_state(flow_json_path: str | Path) -> list[str]: +def repair_flow_state(flow_json_path: str | Path, *, active_step: str | None = None) -> list[str]: """Repair Ongoing steps left by a crashed worker, setting them to Incomplete. + If active_step is provided, only that specific step is repaired (operation-scoped). + If active_step is None, all Ongoing steps are repaired (legacy fallback). + Returns the list of step names that were repaired. + Raises OSError if the repaired state cannot be persisted. """ path = Path(flow_json_path) data = json_read(path) @@ -186,9 +214,13 @@ def repair_flow_state(flow_json_path: str | Path) -> list[str]: for step in steps: if not isinstance(step, dict): continue - if step.get("state") == "Ongoing": - step["state"] = "Incomplete" - repaired.append(step.get("name", "")) + if step.get("state") != "Ongoing": + continue + step_name = step.get("name", "") + if active_step is not None and step_name != active_step: + continue + step["state"] = "Incomplete" + repaired.append(step_name) if repaired: json_write(path, data) diff --git a/chipcompiler/tools/ecc_dreamplace/module.py b/chipcompiler/tools/ecc_dreamplace/module.py index f6027b8c..757f71a7 100644 --- a/chipcompiler/tools/ecc_dreamplace/module.py +++ b/chipcompiler/tools/ecc_dreamplace/module.py @@ -60,7 +60,7 @@ def _build_params(self, params_cls, *, legalize_only: bool): return params @contextmanager - def _configure_root_logging(self, *, legalize_only: bool): + def _configure_root_logging(self): root_logger = logging.getLogger() original_handlers = root_logger.handlers[:] original_level = root_logger.level @@ -86,7 +86,7 @@ def _run(self, *, legalize_only: bool) -> bool: from dreamplace.Params import Params from dreamplace.Placer import PlacementEngine - with self._configure_root_logging(legalize_only=legalize_only): + with self._configure_root_logging(): params = self._build_params(Params, legalize_only=legalize_only) engine = PlacementEngine(params) diff --git a/test/runtime/test_log_stream.py b/test/runtime/test_log_stream.py index 9fc01f74..2720ec82 100644 --- a/test/runtime/test_log_stream.py +++ b/test/runtime/test_log_stream.py @@ -154,3 +154,70 @@ def test_tail_bytes_maintained(self): reader.start() reader.join(timeout=5) assert len(reader.state.tail_bytes) == 100 + + def test_unknown_marker_event_archived_as_data(self, tmp_path): + """A valid marker with an unrecognized event must be archived as raw data.""" + log_path = tmp_path / "step.log" + + def resolver(step, tool): + return log_path + + unknown_line = b'\x1eECC-STEP {"event":"pause","step":"S","tool":"T"}\n' + stream_data = ( + b'\x1eECC-STEP {"event":"begin","step":"S","tool":"T"}\n' + + unknown_line + + b'\x1eECC-STEP {"event":"end","step":"S","tool":"T"}\n' + ) + reader = LogStreamReader(io.BytesIO(stream_data), log_path_resolver=resolver) + reader.start() + reader.join(timeout=5) + assert log_path.read_bytes() == unknown_line + + def test_archive_write_error_surfaces_in_state(self, tmp_path): + """An OSError during archive write must be captured in state.error.""" + log_path = tmp_path / "step.log" + + def resolver(step, tool): + return log_path + + stream_data = ( + b'\x1eECC-STEP {"event":"begin","step":"S","tool":"T"}\n' + b"some output\n" + b'\x1eECC-STEP {"event":"end","step":"S","tool":"T"}\n' + ) + reader = LogStreamReader(io.BytesIO(stream_data), log_path_resolver=resolver) + reader.start() + reader.join(timeout=5) + + log_path.unlink() + log_path.mkdir() + + stream_data2 = ( + b'\x1eECC-STEP {"event":"begin","step":"S","tool":"T"}\n' + b"more output\n" + b'\x1eECC-STEP {"event":"end","step":"S","tool":"T"}\n' + ) + reader2 = LogStreamReader(io.BytesIO(stream_data2), log_path_resolver=resolver) + reader2.start() + reader2.join(timeout=5) + assert reader2.state.error is not None + assert isinstance(reader2.state.error, OSError) + + def test_archive_open_error_surfaces_in_state(self, tmp_path): + """An OSError when opening an archive must be captured in state.error.""" + + def resolver(step, tool): + return tmp_path / "nonexistent_dir" / "sub" / "step.log" + + (tmp_path / "nonexistent_dir").write_text("not a directory") + + stream_data = ( + b'\x1eECC-STEP {"event":"begin","step":"S","tool":"T"}\n' + b"output\n" + b'\x1eECC-STEP {"event":"end","step":"S","tool":"T"}\n' + ) + reader = LogStreamReader(io.BytesIO(stream_data), log_path_resolver=resolver) + reader.start() + reader.join(timeout=5) + assert reader.state.error is not None + assert isinstance(reader.state.error, OSError) diff --git a/test/runtime/test_stdio_server.py b/test/runtime/test_stdio_server.py index 4fb400f3..bd8b5a68 100644 --- a/test/runtime/test_stdio_server.py +++ b/test/runtime/test_stdio_server.py @@ -112,39 +112,34 @@ def test_stdio_server_stops_after_shutdown_notification_in_buffer(): assert stdout.getvalue() == b"" -def test_stdio_server_redirects_print_noise_away_from_protocol_stdout(capfd): - server = RuntimeServer() - server.dispatcher.add_method("test.noisyPrint", lambda: print("tool output") or {"ok": True}) - stdin = io.BytesIO(_request("test.noisyPrint", 1)) - stdout = io.BytesIO() - - rc = run_stdio_server(stdin, stdout, server=server) - - captured = capfd.readouterr() - assert rc == 0 - assert captured.out == "" - assert "tool output" in captured.err - assert _decode_output(stdout.getvalue())[0]["result"] == {"ok": True} - - -def test_stdio_server_redirects_fd_stdout_noise_away_from_protocol_stdout(capfd): - server = RuntimeServer() - - def noisy_fd(): - os.write(1, b"tool output\n") - return {"ok": True} - - server.dispatcher.add_method("test.noisyFd", noisy_fd) - stdin = io.BytesIO(_request("test.noisyFd", 1)) - stdout = io.BytesIO() - - rc = run_stdio_server(stdin, stdout, server=server) +def test_rpc_stdio_subprocess_print_noise_stays_on_stderr(): + """Production path (main) installs StdioIsolation; handler print() goes to stderr.""" + stdin_data = _request("test.noisy", 1) + _request("rpc.shutdown", 2) + completed = subprocess.run( + [ + sys.executable, + "-c", + "import sys;" + "from chipcompiler.runtime.stdio_isolation import StdioIsolation;" + "iso = StdioIsolation(); ps = iso.install();" + "from chipcompiler.runtime.server import RuntimeServer;" + "from chipcompiler.runtime.stdio_server import run_stdio_server;" + "s = RuntimeServer();" + "s.dispatcher.add_method('test.noisy', lambda: print('tool output') or {'ok': True});" + "rc = run_stdio_server(sys.stdin.buffer, ps, server=s);" + "iso.close(); sys.exit(rc)", + ], + input=stdin_data, + capture_output=True, + check=False, + ) - captured = capfd.readouterr() - assert rc == 0 - assert captured.out == "" - assert "tool output" in captured.err - assert _decode_output(stdout.getvalue())[0]["result"] == {"ok": True} + assert completed.returncode == 0 + responses = _decode_output(completed.stdout) + assert len(responses) == 2 + assert responses[0]["result"] == {"ok": True} + assert b"tool output" not in completed.stdout + assert b"tool output" in completed.stderr def test_rpc_stdio_subprocess_smoke(): diff --git a/test/runtime/test_worker.py b/test/runtime/test_worker.py index 6b07e72f..50bd8776 100644 --- a/test/runtime/test_worker.py +++ b/test/runtime/test_worker.py @@ -6,8 +6,11 @@ import textwrap from unittest.mock import MagicMock +import pytest + from chipcompiler.runtime.worker import ( WorkerClient, + WorkerProcessError, WorkerResult, classify_worker_exit, repair_flow_state, @@ -113,6 +116,32 @@ def test_multiple_ongoing(self, tmp_path): repaired = repair_flow_state(flow_json) assert set(repaired) == {"A", "B"} + def test_scoped_repair_only_active_step(self, tmp_path): + flow_json = tmp_path / "flow.json" + data = { + "steps": [ + {"name": "Synthesis", "tool": "yosys", "state": "Ongoing"}, + {"name": "Placement", "tool": "ecc", "state": "Ongoing"}, + ] + } + flow_json.write_text(json.dumps(data)) + repaired = repair_flow_state(flow_json, active_step="Placement") + assert repaired == ["Placement"] + result = json.loads(flow_json.read_text()) + assert result["steps"][0]["state"] == "Ongoing" + assert result["steps"][1]["state"] == "Incomplete" + + def test_scoped_repair_step_not_ongoing(self, tmp_path): + flow_json = tmp_path / "flow.json" + data = { + "steps": [ + {"name": "Synthesis", "tool": "yosys", "state": "Success"}, + ] + } + flow_json.write_text(json.dumps(data)) + repaired = repair_flow_state(flow_json, active_step="Synthesis") + assert repaired == [] + class TestWorkerClientSubprocess: """Integration test using a real subprocess.""" @@ -154,3 +183,96 @@ def test_rpc_round_trip(self): assert result.response["result"]["echo"] is True finally: client.terminate() + + def test_response_correlation_skips_notification(self): + """A notification before the response must not steal the response slot.""" + script = textwrap.dedent("""\ + import sys, json + data = b"" + while True: + chunk = sys.stdin.buffer.read(1) + if not chunk: + break + data += chunk + if b"\\r\\n\\r\\n" in data: + header, _, body_start = data.partition(b"\\r\\n\\r\\n") + length = int(header.split(b":")[1]) + while len(body_start) < length: + body_start += sys.stdin.buffer.read(1) + request = json.loads(body_start[:length]) + # Send a notification first (no "id" field) + notif = json.dumps( + {"jsonrpc": "2.0", "method": "progress", "params": {"pct": 50}} + ) + frame_n = f"Content-Length: {len(notif)}\\r\\n\\r\\n{notif}" + sys.stdout.buffer.write(frame_n.encode()) + # Then the actual response + resp = json.dumps( + {"jsonrpc": "2.0", "result": {"done": True}, "id": request["id"]} + ) + frame_r = f"Content-Length: {len(resp)}\\r\\n\\r\\n{resp}" + sys.stdout.buffer.write(frame_r.encode()) + sys.stdout.buffer.flush() + break + """) + client = WorkerClient([sys.executable, "-c", script]) + client.start() + try: + result = client.request("test.work", {}, request_id=7) + assert result.success is True + assert result.response["result"]["done"] is True + notif = client.pop_notification() + assert notif is not None + assert notif["method"] == "progress" + finally: + client.terminate() + + def test_malformed_json_raises_protocol_error(self): + script = textwrap.dedent("""\ + import sys + garbage = b"not json at all" + frame = f"Content-Length: {len(garbage)}\\r\\n\\r\\n".encode() + garbage + sys.stdout.buffer.write(frame) + sys.stdout.buffer.flush() + import time; time.sleep(1) + """) + client = WorkerClient([sys.executable, "-c", script]) + client.start() + try: + with pytest.raises(WorkerProcessError, match="malformed JSON"): + client.read_response(request_id=1) + finally: + client.terminate() + + def test_terminate_kills_orphaned_child(self): + """After the leader exits, terminate must still signal the process group.""" + script = textwrap.dedent("""\ + import os, sys, time + pid = os.fork() + if pid == 0: + # child: sleep indefinitely + time.sleep(60) + os._exit(0) + else: + # leader: print child pid and exit + sys.stdout.buffer.write(f"{pid}\\n".encode()) + sys.stdout.buffer.flush() + os._exit(0) + """) + client = WorkerClient([sys.executable, "-c", script]) + proc = client.start() + import time + + time.sleep(0.3) + child_pid_line = proc.stdout.readline() + child_pid = int(child_pid_line.strip()) + client.terminate() + time.sleep(0.2) + import os + + try: + os.kill(child_pid, 0) + alive = True + except OSError: + alive = False + assert not alive, "orphaned child should have been killed by process-group signal" From 217f9f2606b2831bf60efc7424188f0c674aade9 Mon Sep 17 00:00:00 2001 From: Emin Date: Tue, 11 Aug 2026 18:31:23 +0800 Subject: [PATCH 03/52] fix: correct remaining P0 defects in worker correlation, escalation, repair, and marker matching Response correlation: separate id-keyed pending-response store from notification queue. read_response checks pending store before reading stdout. All decoded messages in every batch are preserved. Process-group escalation: check group liveness (killpg signal 0) after each signal+wait. Continue SIGTERM/SIGKILL even when proc.wait() has already returned but the group still has live members. Flow repair: require active_step (no unscoped fallback). Check json_write return value and raise OSError on write failure. Log stream: track active step/tool in state. Only a matching end marker closes the archive. Mismatched end markers are archived as raw data without changing state. --- chipcompiler/runtime/log_stream.py | 11 ++- chipcompiler/runtime/worker.py | 64 ++++++++++------ test/runtime/test_log_stream.py | 37 +++++++++ test/runtime/test_worker.py | 117 ++++++++++++++++++++++++----- 4 files changed, 188 insertions(+), 41 deletions(-) diff --git a/chipcompiler/runtime/log_stream.py b/chipcompiler/runtime/log_stream.py index c6c43505..34c61d68 100644 --- a/chipcompiler/runtime/log_stream.py +++ b/chipcompiler/runtime/log_stream.py @@ -63,6 +63,8 @@ class LogStreamState: tail_bytes: bytes = b"" archive_file: BinaryIO | None = field(default=None, repr=False) + active_step: str | None = None + active_tool: str | None = None bytes_archived: int = 0 steps_seen: list[str] = field(default_factory=list) error: Exception | None = None @@ -143,10 +145,17 @@ def _process_buffer(self, buf: bytes) -> bytes: def _handle_marker(self, marker: StepMarker, raw_line: bytes) -> None: if marker.event == "begin": self._close_archive() + self._state.active_step = marker.step + self._state.active_tool = marker.tool self._state.steps_seen.append(marker.step) self._open_archive(marker.step, marker.tool) elif marker.event == "end": - self._close_archive() + if marker.step == self._state.active_step and marker.tool == self._state.active_tool: + self._close_archive() + self._state.active_step = None + self._state.active_tool = None + else: + self._emit_data(raw_line) else: self._emit_data(raw_line) diff --git a/chipcompiler/runtime/worker.py b/chipcompiler/runtime/worker.py index 8e9ef66d..5f39db95 100644 --- a/chipcompiler/runtime/worker.py +++ b/chipcompiler/runtime/worker.py @@ -42,6 +42,7 @@ def __init__(self, worker_argv: list[str]): self._pgid: int | None = None self._decoder = ContentLengthDecoder() self._notifications: deque[dict] = deque() + self._pending_responses: dict[int, dict] = {} def start(self) -> subprocess.Popen: self._process = subprocess.Popen( @@ -81,9 +82,13 @@ def send_request(self, method: str, params: dict, request_id: int = 1) -> None: def read_response(self, request_id: int = 1) -> dict: """Read the next RPC response matching request_id. - Notifications (messages without an 'id' field) are queued internally. + Checks the pending-response buffer first (for out-of-order delivery). + Non-matching responses are stored by id for later retrieval. + Notifications (no 'id') are queued separately. Malformed JSON raises WorkerProcessError. """ + if request_id in self._pending_responses: + return self._pending_responses.pop(request_id) if self._process is None or self._process.stdout is None: raise WorkerProcessError("worker not started") while True: @@ -95,6 +100,7 @@ def read_response(self, request_id: int = 1) -> dict: messages = self._decoder.feed(chunk) except TransportError as exc: raise WorkerProcessError(f"protocol error: {exc}") from exc + found: dict | None = None for raw in messages: try: msg = json.loads(raw) @@ -104,11 +110,16 @@ def read_response(self, request_id: int = 1) -> dict: raise WorkerProcessError("expected JSON object from worker") if "id" not in msg: self._notifications.append(msg) - continue - if msg["id"] != request_id: - self._notifications.append(msg) - continue - return msg + elif msg["id"] == request_id and found is None: + found = msg + else: + msg_id = msg.get("id") + if msg_id is not None: + self._pending_responses[msg_id] = msg + else: + self._notifications.append(msg) + if found is not None: + return found def pop_notification(self) -> dict | None: if self._notifications: @@ -142,30 +153,41 @@ def _terminate_process_group(proc: subprocess.Popen, pgid: int | None = None) -> """Escalate signals to the worker process group. pgid is cached at start time (the worker pid, since start_new_session=True). - Signals the group even after the leader has already exited, because - descendants may still be running. + After each signal, checks whether the process group still has live members + and continues escalation until the group is gone. """ if pgid is None: pgid = proc.pid + def _group_alive() -> bool: + try: + os.killpg(pgid, 0) + return True + except OSError: + return False + def _signal_group(sig: int) -> None: with suppress(OSError): os.killpg(pgid, sig) if proc.poll() is None: _signal_group(signal.SIGINT) - try: + with suppress(subprocess.TimeoutExpired): proc.wait(timeout=_GRACEFUL_WAIT) - except subprocess.TimeoutExpired: + if _group_alive(): _signal_group(signal.SIGTERM) - try: + with suppress(subprocess.TimeoutExpired): proc.wait(timeout=_FORCEFUL_WAIT) - except subprocess.TimeoutExpired: + if _group_alive(): _signal_group(signal.SIGKILL) proc.wait() else: - _signal_group(signal.SIGTERM) - _signal_group(signal.SIGKILL) + if _group_alive(): + _signal_group(signal.SIGTERM) + _signal_group(signal.SIGKILL) + + if proc.poll() is None: + proc.wait() return proc.returncode @@ -192,11 +214,11 @@ def classify_worker_exit(proc: subprocess.Popen) -> WorkerResult: return WorkerResult(success=False, exit_code=code, error=f"worker exited with code {code}") -def repair_flow_state(flow_json_path: str | Path, *, active_step: str | None = None) -> list[str]: - """Repair Ongoing steps left by a crashed worker, setting them to Incomplete. +def repair_flow_state(flow_json_path: str | Path, *, active_step: str) -> list[str]: + """Repair the named Ongoing step left by a crashed worker, setting it to Incomplete. - If active_step is provided, only that specific step is repaired (operation-scoped). - If active_step is None, all Ongoing steps are repaired (legacy fallback). + Operation-scoped: only the active_step is repaired. The caller must identify + which step was owned by the crashed operation. Returns the list of step names that were repaired. Raises OSError if the repaired state cannot be persisted. @@ -217,12 +239,12 @@ def repair_flow_state(flow_json_path: str | Path, *, active_step: str | None = N if step.get("state") != "Ongoing": continue step_name = step.get("name", "") - if active_step is not None and step_name != active_step: + if step_name != active_step: continue step["state"] = "Incomplete" repaired.append(step_name) - if repaired: - json_write(path, data) + if repaired and not json_write(path, data): + raise OSError(f"failed to persist repaired flow state: {path}") return repaired diff --git a/test/runtime/test_log_stream.py b/test/runtime/test_log_stream.py index 2720ec82..b2edb1db 100644 --- a/test/runtime/test_log_stream.py +++ b/test/runtime/test_log_stream.py @@ -221,3 +221,40 @@ def resolver(step, tool): reader.join(timeout=5) assert reader.state.error is not None assert isinstance(reader.state.error, OSError) + + def test_mismatched_end_marker_does_not_close_archive(self, tmp_path): + """begin A -> end B -> data -> end A: data must be in A's archive.""" + log_path = tmp_path / "step.log" + + def resolver(step, tool): + return log_path + + mismatched_end = b'\x1eECC-STEP {"event":"end","step":"B","tool":"T"}\n' + stream_data = ( + b'\x1eECC-STEP {"event":"begin","step":"A","tool":"T"}\n' + b"before\n" + + mismatched_end + + b"after\n" + + b'\x1eECC-STEP {"event":"end","step":"A","tool":"T"}\n' + ) + reader = LogStreamReader(io.BytesIO(stream_data), log_path_resolver=resolver) + reader.start() + reader.join(timeout=5) + content = log_path.read_bytes() + assert b"before\n" in content + assert mismatched_end in content + assert b"after\n" in content + + def test_active_step_tracked_in_state(self, tmp_path): + """State tracks the active step/tool during archiving.""" + log_path = tmp_path / "step.log" + + def resolver(step, tool): + return log_path + + stream_data = b'\x1eECC-STEP {"event":"begin","step":"Synthesis","tool":"yosys"}\ndata\n' + reader = LogStreamReader(io.BytesIO(stream_data), log_path_resolver=resolver) + reader.start() + reader.join(timeout=5) + assert reader.state.active_step == "Synthesis" + assert reader.state.active_tool == "yosys" diff --git a/test/runtime/test_worker.py b/test/runtime/test_worker.py index 50bd8776..bbe3f1c1 100644 --- a/test/runtime/test_worker.py +++ b/test/runtime/test_worker.py @@ -79,7 +79,7 @@ def test_repairs_ongoing_to_incomplete(self, tmp_path): ] } flow_json.write_text(json.dumps(data)) - repaired = repair_flow_state(flow_json) + repaired = repair_flow_state(flow_json, active_step="Placement") assert repaired == ["Placement"] result = json.loads(flow_json.read_text()) assert result["steps"][1]["state"] == "Incomplete" @@ -90,21 +90,21 @@ def test_no_ongoing_steps(self, tmp_path): flow_json = tmp_path / "flow.json" data = {"steps": [{"name": "Synthesis", "tool": "yosys", "state": "Success"}]} flow_json.write_text(json.dumps(data)) - repaired = repair_flow_state(flow_json) + repaired = repair_flow_state(flow_json, active_step="Synthesis") assert repaired == [] def test_missing_file(self, tmp_path): flow_json = tmp_path / "nonexistent.json" - repaired = repair_flow_state(flow_json) + repaired = repair_flow_state(flow_json, active_step="X") assert repaired == [] def test_empty_file(self, tmp_path): flow_json = tmp_path / "flow.json" flow_json.write_text("{}") - repaired = repair_flow_state(flow_json) + repaired = repair_flow_state(flow_json, active_step="X") assert repaired == [] - def test_multiple_ongoing(self, tmp_path): + def test_scoped_repair_only_active_step(self, tmp_path): flow_json = tmp_path / "flow.json" data = { "steps": [ @@ -113,20 +113,8 @@ def test_multiple_ongoing(self, tmp_path): ] } flow_json.write_text(json.dumps(data)) - repaired = repair_flow_state(flow_json) - assert set(repaired) == {"A", "B"} - - def test_scoped_repair_only_active_step(self, tmp_path): - flow_json = tmp_path / "flow.json" - data = { - "steps": [ - {"name": "Synthesis", "tool": "yosys", "state": "Ongoing"}, - {"name": "Placement", "tool": "ecc", "state": "Ongoing"}, - ] - } - flow_json.write_text(json.dumps(data)) - repaired = repair_flow_state(flow_json, active_step="Placement") - assert repaired == ["Placement"] + repaired = repair_flow_state(flow_json, active_step="B") + assert repaired == ["B"] result = json.loads(flow_json.read_text()) assert result["steps"][0]["state"] == "Ongoing" assert result["steps"][1]["state"] == "Incomplete" @@ -142,6 +130,23 @@ def test_scoped_repair_step_not_ongoing(self, tmp_path): repaired = repair_flow_state(flow_json, active_step="Synthesis") assert repaired == [] + def test_write_failure_raises_oserror(self, tmp_path): + flow_json = tmp_path / "flow.json" + data = { + "steps": [ + {"name": "A", "tool": "t", "state": "Ongoing"}, + ] + } + flow_json.write_text(json.dumps(data)) + flow_json.chmod(0o444) + tmp_path.chmod(0o555) + try: + with pytest.raises(OSError, match="failed to persist"): + repair_flow_state(flow_json, active_step="A") + finally: + tmp_path.chmod(0o755) + flow_json.chmod(0o644) + class TestWorkerClientSubprocess: """Integration test using a real subprocess.""" @@ -276,3 +281,77 @@ def test_terminate_kills_orphaned_child(self): except OSError: alive = False assert not alive, "orphaned child should have been killed by process-group signal" + + def test_out_of_order_responses_preserved(self): + """Responses arriving in reverse order must all be retrievable.""" + script = textwrap.dedent("""\ + import sys, json + data = b"" + while True: + chunk = sys.stdin.buffer.read(1) + if not chunk: + break + data += chunk + if b"\\r\\n\\r\\n" in data: + header, _, body_start = data.partition(b"\\r\\n\\r\\n") + length = int(header.split(b":")[1]) + while len(body_start) < length: + body_start += sys.stdin.buffer.read(1) + json.loads(body_start[:length]) + # Send responses in reverse order: id=2 then id=1 + r2 = json.dumps( + {"jsonrpc": "2.0", "result": {"v": 2}, "id": 2} + ) + r1 = json.dumps( + {"jsonrpc": "2.0", "result": {"v": 1}, "id": 1} + ) + for r in [r2, r1]: + frame = f"Content-Length: {len(r)}\\r\\n\\r\\n{r}" + sys.stdout.buffer.write(frame.encode()) + sys.stdout.buffer.flush() + break + """) + client = WorkerClient([sys.executable, "-c", script]) + client.start() + try: + client.send_request("test", {}, request_id=1) + resp1 = client.read_response(request_id=1) + assert resp1["result"]["v"] == 1 + resp2 = client.read_response(request_id=2) + assert resp2["result"]["v"] == 2 + finally: + client.terminate() + + def test_leader_exits_during_sigint_descendant_killed(self): + """Leader exits on SIGINT but descendant ignores it; must still be killed.""" + script = textwrap.dedent("""\ + import os, sys, signal, time + pid = os.fork() + if pid == 0: + signal.signal(signal.SIGINT, signal.SIG_IGN) + time.sleep(60) + os._exit(0) + else: + sys.stdout.buffer.write(f"{pid}\\n".encode()) + sys.stdout.buffer.flush() + # Leader exits immediately on SIGINT + signal.signal(signal.SIGINT, lambda *a: os._exit(0)) + time.sleep(60) + """) + client = WorkerClient([sys.executable, "-c", script]) + proc = client.start() + import time + + time.sleep(0.3) + child_pid_line = proc.stdout.readline() + child_pid = int(child_pid_line.strip()) + client.terminate() + time.sleep(0.5) + import os + + try: + os.kill(child_pid, 0) + alive = True + except OSError: + alive = False + assert not alive, "descendant ignoring SIGINT should still be killed by SIGTERM/SIGKILL" From f8c76c3e7c9d722505b531576b427beedff5222a Mon Sep 17 00:00:00 2001 From: Emin Date: Tue, 11 Aug 2026 19:02:18 +0800 Subject: [PATCH 04/52] fix: validate response envelopes, add grace-period escalation, state-machine markers, and emit from EngineFlow Response envelope validation: require jsonrpc=="2.0" and exactly one of result or error (with code+message) before storing or returning a response. Invalid envelopes raise WorkerProcessError. Process-group escalation: replace proc.wait()-based escalation with group-liveness polling via os.killpg(pgid, 0) with deadlines. After each signal, wait for the group to exit before escalating. Descendants that handle SIGTERM gracefully are not SIGKILL'd. Log stream state machine: only accept begin markers while inactive (no active step). A begin while active is archived as raw data without state change. Close archive handle properly on write failure. Step marker emission: EngineFlow.run_step() emits begin/end markers on stderr around tool execution. Begin after Ongoing persistence, end in finally path. --- chipcompiler/engine/flow.py | 5 ++ chipcompiler/runtime/log_stream.py | 18 ++++-- chipcompiler/runtime/worker.py | 72 +++++++++++++++------- test/runtime/test_log_stream.py | 25 ++++++++ test/runtime/test_worker.py | 99 ++++++++++++++++++++++++++++++ 5 files changed, 192 insertions(+), 27 deletions(-) diff --git a/chipcompiler/engine/flow.py b/chipcompiler/engine/flow.py index 1b59b6c6..192d78f2 100644 --- a/chipcompiler/engine/flow.py +++ b/chipcompiler/engine/flow.py @@ -480,6 +480,10 @@ def run_step( self.workspace.logger.info(f"[STEP] {step_tag} pid={os.getpid()} started") + from chipcompiler.runtime.log_stream import emit_step_marker + + emit_step_marker("begin", step=workspace_step.name, tool=workspace_step.tool) + pid = os.getpid() start_memory_mb = get_process_rss_mb(pid) peak_memory = [start_memory_mb] @@ -513,6 +517,7 @@ def run_step( delattr(self.workspace, "_runtime_flow_observer") else: self.workspace._runtime_flow_observer = previous_observer + emit_step_marker("end", step=workspace_step.name, tool=workspace_step.tool) # compute metrics peak_memory_mb = peak_memory[0] - start_memory_mb diff --git a/chipcompiler/runtime/log_stream.py b/chipcompiler/runtime/log_stream.py index 34c61d68..92baeb63 100644 --- a/chipcompiler/runtime/log_stream.py +++ b/chipcompiler/runtime/log_stream.py @@ -13,6 +13,7 @@ import os import threading from collections.abc import Callable +from contextlib import suppress from dataclasses import dataclass, field from pathlib import Path from typing import BinaryIO @@ -144,11 +145,13 @@ def _process_buffer(self, buf: bytes) -> bytes: def _handle_marker(self, marker: StepMarker, raw_line: bytes) -> None: if marker.event == "begin": - self._close_archive() - self._state.active_step = marker.step - self._state.active_tool = marker.tool - self._state.steps_seen.append(marker.step) - self._open_archive(marker.step, marker.tool) + if self._state.active_step is None: + self._state.active_step = marker.step + self._state.active_tool = marker.tool + self._state.steps_seen.append(marker.step) + self._open_archive(marker.step, marker.tool) + else: + self._emit_data(raw_line) elif marker.event == "end": if marker.step == self._state.active_step and marker.tool == self._state.active_tool: self._close_archive() @@ -165,7 +168,10 @@ def _emit_data(self, data: bytes) -> None: self._state.archive_file.write(data) self._state.bytes_archived += len(data) except OSError as exc: - self._state.error = exc + if self._state.error is None: + self._state.error = exc + with suppress(OSError): + self._state.archive_file.close() self._state.archive_file = None self._update_tail(data) if self._on_output is not None: diff --git a/chipcompiler/runtime/worker.py b/chipcompiler/runtime/worker.py index 5f39db95..71e8feb9 100644 --- a/chipcompiler/runtime/worker.py +++ b/chipcompiler/runtime/worker.py @@ -85,7 +85,7 @@ def read_response(self, request_id: int = 1) -> dict: Checks the pending-response buffer first (for out-of-order delivery). Non-matching responses are stored by id for later retrieval. Notifications (no 'id') are queued separately. - Malformed JSON raises WorkerProcessError. + Invalid envelopes raise WorkerProcessError. """ if request_id in self._pending_responses: return self._pending_responses.pop(request_id) @@ -110,14 +110,12 @@ def read_response(self, request_id: int = 1) -> dict: raise WorkerProcessError("expected JSON object from worker") if "id" not in msg: self._notifications.append(msg) - elif msg["id"] == request_id and found is None: - found = msg else: - msg_id = msg.get("id") - if msg_id is not None: - self._pending_responses[msg_id] = msg + _validate_response_envelope(msg) + if msg["id"] == request_id and found is None: + found = msg else: - self._notifications.append(msg) + self._pending_responses[msg["id"]] = msg if found is not None: return found @@ -153,8 +151,8 @@ def _terminate_process_group(proc: subprocess.Popen, pgid: int | None = None) -> """Escalate signals to the worker process group. pgid is cached at start time (the worker pid, since start_new_session=True). - After each signal, checks whether the process group still has live members - and continues escalation until the group is gone. + After each signal, waits for the process group to exit within a deadline + before escalating to the next signal. """ if pgid is None: pgid = proc.pid @@ -170,21 +168,24 @@ def _signal_group(sig: int) -> None: with suppress(OSError): os.killpg(pgid, sig) - if proc.poll() is None: + def _wait_group_exit(timeout: float) -> bool: + """Poll group liveness until dead or timeout. Returns True if dead.""" + import time + + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if not _group_alive(): + return True + time.sleep(0.05) + return not _group_alive() + + if proc.poll() is None or _group_alive(): _signal_group(signal.SIGINT) - with suppress(subprocess.TimeoutExpired): - proc.wait(timeout=_GRACEFUL_WAIT) - if _group_alive(): + if not _wait_group_exit(_GRACEFUL_WAIT): _signal_group(signal.SIGTERM) - with suppress(subprocess.TimeoutExpired): - proc.wait(timeout=_FORCEFUL_WAIT) - if _group_alive(): + if not _wait_group_exit(_FORCEFUL_WAIT): _signal_group(signal.SIGKILL) - proc.wait() - else: - if _group_alive(): - _signal_group(signal.SIGTERM) - _signal_group(signal.SIGKILL) + _wait_group_exit(_FORCEFUL_WAIT) if proc.poll() is None: proc.wait() @@ -192,6 +193,35 @@ def _signal_group(sig: int) -> None: return proc.returncode +def _validate_response_envelope(msg: dict) -> None: + """Validate a JSON-RPC 2.0 response envelope. + + Requires jsonrpc=="2.0" and exactly one of "result" or "error". + Error objects must have "code" (int) and "message" (str). + Raises WorkerProcessError on invalid envelopes. + """ + if msg.get("jsonrpc") != "2.0": + raise WorkerProcessError( + f"invalid JSON-RPC response: missing or wrong 'jsonrpc' version: {msg!r}" + ) + has_result = "result" in msg + has_error = "error" in msg + if has_result == has_error: + raise WorkerProcessError( + f"invalid JSON-RPC response: must have exactly one of 'result' or 'error': {msg!r}" + ) + if has_error: + err = msg["error"] + if ( + not isinstance(err, dict) + or not isinstance(err.get("code"), int) + or not isinstance(err.get("message"), str) + ): + raise WorkerProcessError( + f"invalid JSON-RPC error object: requires 'code' (int) and 'message' (str): {msg!r}" + ) + + def classify_worker_exit(proc: subprocess.Popen) -> WorkerResult: """Classify how the worker exited after it is no longer running.""" code = proc.returncode diff --git a/test/runtime/test_log_stream.py b/test/runtime/test_log_stream.py index b2edb1db..62da9a7e 100644 --- a/test/runtime/test_log_stream.py +++ b/test/runtime/test_log_stream.py @@ -258,3 +258,28 @@ def resolver(step, tool): reader.join(timeout=5) assert reader.state.active_step == "Synthesis" assert reader.state.active_tool == "yosys" + + def test_duplicate_begin_does_not_switch_archive(self, tmp_path): + """begin A -> data -> begin B -> data -> end A: all data stays in A's archive.""" + log_path = tmp_path / "step.log" + + def resolver(step, tool): + return log_path + + begin_b = b'\x1eECC-STEP {"event":"begin","step":"B","tool":"T"}\n' + stream_data = ( + b'\x1eECC-STEP {"event":"begin","step":"A","tool":"T"}\n' + b"before\n" + + begin_b + + b"after\n" + + b'\x1eECC-STEP {"event":"end","step":"A","tool":"T"}\n' + ) + reader = LogStreamReader(io.BytesIO(stream_data), log_path_resolver=resolver) + reader.start() + reader.join(timeout=5) + content = log_path.read_bytes() + assert b"before\n" in content + assert begin_b in content + assert b"after\n" in content + assert reader.state.active_step is None + assert reader.state.steps_seen == ["A"] diff --git a/test/runtime/test_worker.py b/test/runtime/test_worker.py index bbe3f1c1..a34cd2ac 100644 --- a/test/runtime/test_worker.py +++ b/test/runtime/test_worker.py @@ -249,6 +249,63 @@ def test_malformed_json_raises_protocol_error(self): finally: client.terminate() + def test_invalid_envelope_missing_jsonrpc_raises(self): + """A response with no 'jsonrpc' field must be rejected.""" + script = textwrap.dedent("""\ + import sys, json + resp = json.dumps({"id": 1, "result": {}}).encode() + frame = f"Content-Length: {len(resp)}\\r\\n\\r\\n".encode() + resp + sys.stdout.buffer.write(frame) + sys.stdout.buffer.flush() + import time; time.sleep(1) + """) + client = WorkerClient([sys.executable, "-c", script]) + client.start() + try: + with pytest.raises(WorkerProcessError, match="jsonrpc"): + client.read_response(request_id=1) + finally: + client.terminate() + + def test_invalid_envelope_both_result_and_error_raises(self): + """A response with both 'result' and 'error' must be rejected.""" + script = textwrap.dedent("""\ + import sys, json + resp = json.dumps({ + "jsonrpc": "2.0", "id": 1, + "result": {}, "error": {"code": -1, "message": "x"} + }).encode() + frame = f"Content-Length: {len(resp)}\\r\\n\\r\\n".encode() + resp + sys.stdout.buffer.write(frame) + sys.stdout.buffer.flush() + import time; time.sleep(1) + """) + client = WorkerClient([sys.executable, "-c", script]) + client.start() + try: + with pytest.raises(WorkerProcessError, match="exactly one"): + client.read_response(request_id=1) + finally: + client.terminate() + + def test_invalid_envelope_neither_result_nor_error_raises(self): + """A response with neither 'result' nor 'error' must be rejected.""" + script = textwrap.dedent("""\ + import sys, json + resp = json.dumps({"jsonrpc": "2.0", "id": 1}).encode() + frame = f"Content-Length: {len(resp)}\\r\\n\\r\\n".encode() + resp + sys.stdout.buffer.write(frame) + sys.stdout.buffer.flush() + import time; time.sleep(1) + """) + client = WorkerClient([sys.executable, "-c", script]) + client.start() + try: + with pytest.raises(WorkerProcessError, match="exactly one"): + client.read_response(request_id=1) + finally: + client.terminate() + def test_terminate_kills_orphaned_child(self): """After the leader exits, terminate must still signal the process group.""" script = textwrap.dedent("""\ @@ -355,3 +412,45 @@ def test_leader_exits_during_sigint_descendant_killed(self): except OSError: alive = False assert not alive, "descendant ignoring SIGINT should still be killed by SIGTERM/SIGKILL" + + def test_descendant_graceful_sigterm_exit(self): + """Descendant handles SIGTERM and exits within grace window — no SIGKILL needed.""" + script = textwrap.dedent("""\ + import os, sys, signal, time + pid = os.fork() + if pid == 0: + # Child: ignore SIGINT, handle SIGTERM with brief cleanup + signal.signal(signal.SIGINT, signal.SIG_IGN) + marker = f"/tmp/ecc-test-grace-{os.getpid()}" + def handle_term(*a): + open(marker, "w").close() + os._exit(0) + signal.signal(signal.SIGTERM, handle_term) + time.sleep(60) + os._exit(0) + else: + sys.stdout.buffer.write(f"{pid}\\n".encode()) + sys.stdout.buffer.flush() + signal.signal(signal.SIGINT, lambda *a: os._exit(0)) + time.sleep(60) + """) + client = WorkerClient([sys.executable, "-c", script]) + proc = client.start() + import time + + time.sleep(0.3) + child_pid_line = proc.stdout.readline() + child_pid = int(child_pid_line.strip()) + client.terminate() + time.sleep(0.5) + import os + + marker = f"/tmp/ecc-test-grace-{child_pid}" + try: + os.kill(child_pid, 0) + alive = True + except OSError: + alive = False + assert not alive, "descendant should have exited on SIGTERM" + assert os.path.exists(marker), "descendant SIGTERM handler should have run (not SIGKILL'd)" + os.unlink(marker) From 148957048b11d75c86d70dc34325204239a83431 Mon Sep 17 00:00:00 2001 From: Emin Date: Tue, 11 Aug 2026 19:32:50 +0800 Subject: [PATCH 05/52] feat(runtime): fix P0 primitives and add operation orchestrator Worker: - Reap leader during group-liveness waits (zombie no longer keeps group visible; graceful shutdown completes before forceful deadline) - Validate response id as int|str|None; reject booleans and non-scalars Flow: - Move marker end after ALL step finalization (metrics, state persist, layout, DB cleanup, observer) via outer try/finally LogStream: - Archive close exception safety: close in finally regardless of flush New: - worker_operation.py: typed RunOperation orchestrator integrating WorkerClient + LogStreamReader + repair into OperationResult - Crash path: terminate group, drain reader, repair flow.json, return typed failure Tests: - Elapsed-time regression proving graceful terminate < 5s - Response id=true and id=[1] rejection - Operation orchestrator: success, RPC error, crash+repair, archive --- chipcompiler/runtime/log_stream.py | 12 +- chipcompiler/runtime/worker.py | 16 +- chipcompiler/runtime/worker_operation.py | 180 +++++++++++++++++++++++ test/runtime/test_worker.py | 67 +++++++++ test/runtime/test_worker_operation.py | 169 +++++++++++++++++++++ 5 files changed, 437 insertions(+), 7 deletions(-) create mode 100644 chipcompiler/runtime/worker_operation.py create mode 100644 test/runtime/test_worker_operation.py diff --git a/chipcompiler/runtime/log_stream.py b/chipcompiler/runtime/log_stream.py index 92baeb63..1d94e178 100644 --- a/chipcompiler/runtime/log_stream.py +++ b/chipcompiler/runtime/log_stream.py @@ -200,7 +200,13 @@ def _close_archive(self) -> None: if self._state.archive_file is not None: try: self._state.archive_file.flush() - self._state.archive_file.close() except OSError as exc: - self._state.error = exc - self._state.archive_file = None + if self._state.error is None: + self._state.error = exc + finally: + try: + self._state.archive_file.close() + except OSError as exc: + if self._state.error is None: + self._state.error = exc + self._state.archive_file = None diff --git a/chipcompiler/runtime/worker.py b/chipcompiler/runtime/worker.py index 71e8feb9..5d55cc80 100644 --- a/chipcompiler/runtime/worker.py +++ b/chipcompiler/runtime/worker.py @@ -152,12 +152,16 @@ def _terminate_process_group(proc: subprocess.Popen, pgid: int | None = None) -> pgid is cached at start time (the worker pid, since start_new_session=True). After each signal, waits for the process group to exit within a deadline - before escalating to the next signal. + before escalating to the next signal. The leader is reaped each iteration + so its zombie does not keep the group visible to killpg. """ + import time + if pgid is None: pgid = proc.pid def _group_alive() -> bool: + proc.poll() try: os.killpg(pgid, 0) return True @@ -170,8 +174,6 @@ def _signal_group(sig: int) -> None: def _wait_group_exit(timeout: float) -> bool: """Poll group liveness until dead or timeout. Returns True if dead.""" - import time - deadline = time.monotonic() + timeout while time.monotonic() < deadline: if not _group_alive(): @@ -196,7 +198,8 @@ def _wait_group_exit(timeout: float) -> bool: def _validate_response_envelope(msg: dict) -> None: """Validate a JSON-RPC 2.0 response envelope. - Requires jsonrpc=="2.0" and exactly one of "result" or "error". + Requires jsonrpc=="2.0", exactly one of "result" or "error", + and id must be int|str|None (no booleans, lists, or dicts). Error objects must have "code" (int) and "message" (str). Raises WorkerProcessError on invalid envelopes. """ @@ -204,6 +207,11 @@ def _validate_response_envelope(msg: dict) -> None: raise WorkerProcessError( f"invalid JSON-RPC response: missing or wrong 'jsonrpc' version: {msg!r}" ) + msg_id = msg.get("id") + if isinstance(msg_id, bool) or not isinstance(msg_id, (int, str, type(None))): + raise WorkerProcessError( + f"invalid JSON-RPC response: 'id' must be int, str, or null: {msg!r}" + ) has_result = "result" in msg has_error = "error" in msg if has_result == has_error: diff --git a/chipcompiler/runtime/worker_operation.py b/chipcompiler/runtime/worker_operation.py new file mode 100644 index 00000000..4d827591 --- /dev/null +++ b/chipcompiler/runtime/worker_operation.py @@ -0,0 +1,180 @@ +"""Typed operation orchestrator for CLI/headless EDA execution via worker. + +Integrates WorkerClient, LogStreamReader, process-group cleanup, and +flow-state repair into one typed OperationResult boundary. All EDA +execution from CLI entry points should go through RunOperation. +""" + +import sys +from collections.abc import Callable +from contextlib import suppress +from dataclasses import dataclass, field +from pathlib import Path + +from chipcompiler.runtime.log_stream import LogStreamReader, LogStreamState +from chipcompiler.runtime.worker import ( + WorkerClient, + WorkerResult, + classify_worker_exit, + repair_flow_state, +) + + +@dataclass(frozen=True) +class OperationResult: + """Typed outcome of a single worker operation (run or run_step).""" + + success: bool + rpc_result: dict | None = None + exit_code: int | None = None + signal_number: int | None = None + error: str | None = None + repaired_steps: list[str] = field(default_factory=list) + archive_error: Exception | None = field(default=None, repr=False) + log_state: LogStreamState | None = field(default=None, repr=False) + + +class RunOperation: + """Orchestrates a single EDA flow execution through an isolated worker. + + Usage: + op = RunOperation( + workspace_dir=Path("/path/to/workspace"), + flow_json_path=Path("/path/to/flow.json"), + log_path_resolver=my_resolver, + ) + result = op.run(method="flow.run", params={...}) + """ + + def __init__( + self, + *, + workspace_dir: Path, + flow_json_path: Path, + worker_argv: list[str] | None = None, + log_path_resolver: Callable[[str, str], Path | None] | None = None, + on_output: Callable[[bytes], None] | None = None, + ): + self._workspace_dir = workspace_dir + self._flow_json_path = flow_json_path + self._worker_argv = worker_argv or [ + sys.executable, + "-m", + "chipcompiler.runtime.stdio_server", + "--workspace", + str(workspace_dir), + ] + self._log_path_resolver = log_path_resolver + self._on_output = on_output + + def run(self, method: str, params: dict, *, request_id: int = 1) -> OperationResult: + """Execute one RPC method against the worker and return a typed result.""" + client = WorkerClient(self._worker_argv) + active_step: str | None = None + + try: + proc = client.start() + + reader = LogStreamReader( + proc.stderr, + log_path_resolver=self._log_path_resolver, + on_output=self._on_output, + ) + reader.start() + + try: + rpc_result = client.request(method, params, request_id) + except KeyboardInterrupt: + return self._handle_crash(client, reader, active_step, "operation interrupted") + + reader.stop() + reader.join(timeout=5.0) + + log_state = reader.state + archive_error = log_state.error + active_step = log_state.active_step + + if not rpc_result.success: + if not client.is_alive(): + return self._handle_crash( + client, reader, active_step, rpc_result.error or "worker crashed" + ) + exit_result = self._shutdown(client) + return OperationResult( + success=False, + rpc_result=rpc_result.response, + exit_code=exit_result.exit_code, + signal_number=exit_result.signal_number, + error=rpc_result.error, + archive_error=archive_error, + log_state=log_state, + ) + + exit_result = self._shutdown(client) + + return OperationResult( + success=exit_result.exit_code == 0 or exit_result.exit_code is None, + rpc_result=rpc_result.response, + exit_code=exit_result.exit_code, + signal_number=exit_result.signal_number, + error=exit_result.error if exit_result.exit_code not in (0, None) else None, + archive_error=archive_error, + log_state=log_state, + ) + + except Exception as exc: + return self._handle_crash(client, None, active_step, str(exc)) + + def _shutdown(self, client: WorkerClient) -> WorkerResult: + """Clean shutdown: terminate process group and classify exit.""" + client.terminate() + proc = client.process + if proc is None: + return WorkerResult(success=True, exit_code=0) + return classify_worker_exit(proc) + + def _handle_crash( + self, + client: WorkerClient, + reader: LogStreamReader | None, + active_step: str | None, + error: str, + ) -> OperationResult: + """Crash recovery: terminate, drain, repair, return failure.""" + exit_code: int | None = None + signal_number: int | None = None + repaired: list[str] = [] + log_state: LogStreamState | None = None + archive_error: Exception | None = None + + try: + client.terminate() + proc = client.process + if proc is not None: + exit_result = classify_worker_exit(proc) + exit_code = exit_result.exit_code + signal_number = exit_result.signal_number + except Exception: + pass + + if reader is not None: + reader.join(timeout=2.0) + reader.stop() + log_state = reader.state + archive_error = log_state.error + if active_step is None: + active_step = log_state.active_step + + if active_step is not None and self._flow_json_path.exists(): + with suppress(OSError): + repaired = repair_flow_state(self._flow_json_path, active_step=active_step) + + return OperationResult( + success=False, + exit_code=exit_code, + signal_number=signal_number, + error=error, + repaired_steps=repaired, + archive_error=archive_error, + log_state=log_state, + ) diff --git a/test/runtime/test_worker.py b/test/runtime/test_worker.py index a34cd2ac..de87669a 100644 --- a/test/runtime/test_worker.py +++ b/test/runtime/test_worker.py @@ -306,6 +306,42 @@ def test_invalid_envelope_neither_result_nor_error_raises(self): finally: client.terminate() + def test_invalid_response_id_boolean_raises(self): + """A response with id=true (boolean) must be rejected, not confused with int 1.""" + script = textwrap.dedent("""\ + import sys, json + resp = json.dumps({"jsonrpc": "2.0", "id": True, "result": {}}).encode() + frame = f"Content-Length: {len(resp)}\\r\\n\\r\\n".encode() + resp + sys.stdout.buffer.write(frame) + sys.stdout.buffer.flush() + import time; time.sleep(1) + """) + client = WorkerClient([sys.executable, "-c", script]) + client.start() + try: + with pytest.raises(WorkerProcessError, match="'id' must be int, str, or null"): + client.read_response(request_id=1) + finally: + client.terminate() + + def test_invalid_response_id_array_raises(self): + """A response with id=[1] (array) must be rejected.""" + script = textwrap.dedent("""\ + import sys, json + resp = json.dumps({"jsonrpc": "2.0", "id": [1], "result": {}}).encode() + frame = f"Content-Length: {len(resp)}\\r\\n\\r\\n".encode() + resp + sys.stdout.buffer.write(frame) + sys.stdout.buffer.flush() + import time; time.sleep(1) + """) + client = WorkerClient([sys.executable, "-c", script]) + client.start() + try: + with pytest.raises(WorkerProcessError, match="'id' must be int, str, or null"): + client.read_response(request_id=1) + finally: + client.terminate() + def test_terminate_kills_orphaned_child(self): """After the leader exits, terminate must still signal the process group.""" script = textwrap.dedent("""\ @@ -454,3 +490,34 @@ def handle_term(*a): assert not alive, "descendant should have exited on SIGTERM" assert os.path.exists(marker), "descendant SIGTERM handler should have run (not SIGKILL'd)" os.unlink(marker) + + def test_graceful_terminate_completes_before_forceful_deadline(self): + """Graceful shutdown must complete well before the SIGKILL deadline (< 5s total).""" + script = textwrap.dedent("""\ + import os, sys, signal, time + pid = os.fork() + if pid == 0: + signal.signal(signal.SIGINT, signal.SIG_IGN) + def handle_term(*a): + os._exit(0) + signal.signal(signal.SIGTERM, handle_term) + time.sleep(60) + os._exit(0) + else: + sys.stdout.buffer.write(f"{pid}\\n".encode()) + sys.stdout.buffer.flush() + signal.signal(signal.SIGINT, lambda *a: os._exit(0)) + time.sleep(60) + """) + client = WorkerClient([sys.executable, "-c", script]) + proc = client.start() + import time + + time.sleep(0.3) + proc.stdout.readline() + t0 = time.monotonic() + client.terminate() + elapsed = time.monotonic() - t0 + assert elapsed < 5.0, ( + f"graceful terminate took {elapsed:.2f}s — should complete before forceful deadline" + ) diff --git a/test/runtime/test_worker_operation.py b/test/runtime/test_worker_operation.py new file mode 100644 index 00000000..2bc82a67 --- /dev/null +++ b/test/runtime/test_worker_operation.py @@ -0,0 +1,169 @@ +"""Tests for chipcompiler.runtime.worker_operation — typed operation orchestrator.""" + +import json +import sys +import textwrap + +from chipcompiler.runtime.worker_operation import RunOperation + + +class TestRunOperationSuccess: + def test_successful_rpc_returns_result(self, tmp_path): + script = textwrap.dedent("""\ + import sys, json + data = b"" + while True: + chunk = sys.stdin.buffer.read(1) + if not chunk: + break + data += chunk + if b"\\r\\n\\r\\n" in data: + header, _, body_start = data.partition(b"\\r\\n\\r\\n") + length = int(header.split(b":")[1]) + while len(body_start) < length: + body_start += sys.stdin.buffer.read(1) + request = json.loads(body_start[:length]) + resp = {"jsonrpc": "2.0", "result": {"steps": ["syn"]}, "id": request["id"]} + response = json.dumps(resp) + frame = f"Content-Length: {len(response)}\\r\\n\\r\\n{response}" + sys.stdout.buffer.write(frame.encode()) + sys.stdout.buffer.flush() + break + """) + flow_json = tmp_path / "flow.json" + flow_json.write_text("{}") + op = RunOperation( + workspace_dir=tmp_path, + flow_json_path=flow_json, + worker_argv=[sys.executable, "-c", script], + ) + result = op.run("flow.run", {"workspace_id": "test"}) + assert result.success is True + assert result.rpc_result["result"] == {"steps": ["syn"]} + assert result.archive_error is None + + def test_rpc_error_returns_failure(self, tmp_path): + script = textwrap.dedent("""\ + import sys, json + data = b"" + while True: + chunk = sys.stdin.buffer.read(1) + if not chunk: + break + data += chunk + if b"\\r\\n\\r\\n" in data: + header, _, body_start = data.partition(b"\\r\\n\\r\\n") + length = int(header.split(b":")[1]) + while len(body_start) < length: + body_start += sys.stdin.buffer.read(1) + request = json.loads(body_start[:length]) + resp = { + "jsonrpc": "2.0", + "error": {"code": -1, "message": "step failed"}, + "id": request["id"], + } + response = json.dumps(resp) + frame = f"Content-Length: {len(response)}\\r\\n\\r\\n{response}" + sys.stdout.buffer.write(frame.encode()) + sys.stdout.buffer.flush() + break + """) + flow_json = tmp_path / "flow.json" + flow_json.write_text("{}") + op = RunOperation( + workspace_dir=tmp_path, + flow_json_path=flow_json, + worker_argv=[sys.executable, "-c", script], + ) + result = op.run("flow.run", {"workspace_id": "test"}) + assert result.success is False + assert "step failed" in result.error + + +class TestRunOperationCrash: + def test_worker_crash_triggers_repair(self, tmp_path): + script_file = tmp_path / "crash_worker.py" + script_file.write_text( + "import sys, os, json\n" + "marker = chr(0x1e).encode() + b'ECC-STEP ' + " + "json.dumps({'event':'begin','step':'Synthesis','tool':'yosys'}).encode()" + " + b'\\n'\n" + "os.write(2, marker)\n" + "os._exit(1)\n" + ) + flow_json = tmp_path / "flow.json" + data = {"steps": [{"name": "Synthesis", "tool": "yosys", "state": "Ongoing"}]} + flow_json.write_text(json.dumps(data)) + op = RunOperation( + workspace_dir=tmp_path, + flow_json_path=flow_json, + worker_argv=[sys.executable, str(script_file)], + ) + result = op.run("flow.run", {"workspace_id": "test"}) + assert result.success is False + assert result.repaired_steps == ["Synthesis"] + repaired_data = json.loads(flow_json.read_text()) + assert repaired_data["steps"][0]["state"] == "Incomplete" + + def test_worker_crash_no_flow_json_no_repair(self, tmp_path): + script = "import sys; sys.exit(1)" + flow_json = tmp_path / "flow.json" + op = RunOperation( + workspace_dir=tmp_path, + flow_json_path=flow_json, + worker_argv=[sys.executable, "-c", script], + ) + result = op.run("flow.run", {"workspace_id": "test"}) + assert result.success is False + assert result.repaired_steps == [] + + +class TestRunOperationStderrArchive: + def test_stderr_archived_to_step_log(self, tmp_path): + script_file = tmp_path / "archive_worker.py" + script_file.write_text( + "import sys, os, json\n" + "def marker(event, step, tool):\n" + " payload = json.dumps({'event':event,'step':step,'tool':tool})\n" + " return chr(0x1e).encode() + b'ECC-STEP ' + " + "payload.encode() + b'\\n'\n" + "os.write(2, marker('begin', 'Synthesis', 'yosys'))\n" + "os.write(2, b'Synthesizing module top...\\n')\n" + "os.write(2, marker('end', 'Synthesis', 'yosys'))\n" + "data = b''\n" + "while True:\n" + " chunk = sys.stdin.buffer.read(1)\n" + " if not chunk:\n" + " break\n" + " data += chunk\n" + " if b'\\r\\n\\r\\n' in data:\n" + " header, _, body_start = data.partition(b'\\r\\n\\r\\n')\n" + " length = int(header.split(b':')[1])\n" + " while len(body_start) < length:\n" + " body_start += sys.stdin.buffer.read(1)\n" + " request = json.loads(body_start[:length])\n" + " resp = {'jsonrpc': '2.0', 'result': {}, 'id': request['id']}\n" + " response = json.dumps(resp)\n" + " frame = f'Content-Length: {len(response)}\\r\\n\\r\\n{response}'\n" + " sys.stdout.buffer.write(frame.encode())\n" + " sys.stdout.buffer.flush()\n" + " break\n" + ) + logs_dir = tmp_path / "logs" + + def resolver(step: str, tool: str): + return logs_dir / f"{step}.log" + + flow_json = tmp_path / "flow.json" + flow_json.write_text("{}") + op = RunOperation( + workspace_dir=tmp_path, + flow_json_path=flow_json, + worker_argv=[sys.executable, str(script_file)], + log_path_resolver=resolver, + ) + result = op.run("flow.run", {"workspace_id": "test"}) + assert result.success is True + log_file = logs_dir / "Synthesis.log" + assert log_file.exists() + assert b"Synthesizing module top..." in log_file.read_bytes() From a2f0bca8c5c4fad0eb170dbd04bbf5cc24b57b3e Mon Sep 17 00:00:00 2001 From: Emin Date: Tue, 11 Aug 2026 20:02:26 +0800 Subject: [PATCH 06/52] feat(runtime): implement real worker lifecycle in RunOperation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the fake one-RPC wrapper with the canonical session sequence: rpc.hello → workspace.open → flow.run → rpc.shutdown → EOF wait. Key changes: - Default argv now launches `ecc rpc serve --stdio --persistent-db` - Graceful shutdown via rpc.shutdown RPC, not signals - Archive completion is a required condition for success (archive error, reader timeout, or unmatched begin marker all force failure) - Protocol failures (dead worker, invalid envelope, EOF) route through crash recovery with flow.json repair - Non-object JSON marker payloads no longer crash parse_marker - LogStreamReader gains a `completed` property for checked drain Tests rewritten to exercise the full multi-request lifecycle with hello/open/run/shutdown, plus crash repair, protocol failure recovery, archive error detection, and non-object marker resilience. --- chipcompiler/runtime/log_stream.py | 9 + chipcompiler/runtime/worker_operation.py | 162 +++++++++---- test/runtime/test_worker_operation.py | 277 +++++++++++++++-------- 3 files changed, 311 insertions(+), 137 deletions(-) diff --git a/chipcompiler/runtime/log_stream.py b/chipcompiler/runtime/log_stream.py index 1d94e178..ce531439 100644 --- a/chipcompiler/runtime/log_stream.py +++ b/chipcompiler/runtime/log_stream.py @@ -50,6 +50,8 @@ def parse_marker(line: bytes) -> StepMarker | None: data = json.loads(payload) except (json.JSONDecodeError, UnicodeDecodeError): return None + if not isinstance(data, dict): + return None event = data.get("event") step = data.get("step") tool = data.get("tool") @@ -107,6 +109,13 @@ def join(self, timeout: float | None = None) -> None: if self._thread is not None: self._thread.join(timeout=timeout) + @property + def completed(self) -> bool: + """True if the drain thread has finished (or was never started).""" + if self._thread is None: + return True + return not self._thread.is_alive() + def stop(self) -> None: self._stop.set() diff --git a/chipcompiler/runtime/worker_operation.py b/chipcompiler/runtime/worker_operation.py index 4d827591..5c9e526a 100644 --- a/chipcompiler/runtime/worker_operation.py +++ b/chipcompiler/runtime/worker_operation.py @@ -5,6 +5,8 @@ execution from CLI entry points should go through RunOperation. """ +import os +import subprocess import sys from collections.abc import Callable from contextlib import suppress @@ -14,11 +16,19 @@ from chipcompiler.runtime.log_stream import LogStreamReader, LogStreamState from chipcompiler.runtime.worker import ( WorkerClient, + WorkerProcessError, WorkerResult, classify_worker_exit, repair_flow_state, ) +PROTOCOL_VERSION = 1 + + +def _default_worker_argv() -> list[str]: + ecc_bin = os.path.join(os.path.dirname(sys.executable), "ecc") + return [ecc_bin, "rpc", "serve", "--stdio", "--persistent-db"] + @dataclass(frozen=True) class OperationResult: @@ -37,11 +47,13 @@ class OperationResult: class RunOperation: """Orchestrates a single EDA flow execution through an isolated worker. + Implements the real session lifecycle: + rpc.hello → workspace.open → caller's method → rpc.shutdown → EOF wait. + Usage: op = RunOperation( workspace_dir=Path("/path/to/workspace"), flow_json_path=Path("/path/to/flow.json"), - log_path_resolver=my_resolver, ) result = op.run(method="flow.run", params={...}) """ @@ -57,20 +69,17 @@ def __init__( ): self._workspace_dir = workspace_dir self._flow_json_path = flow_json_path - self._worker_argv = worker_argv or [ - sys.executable, - "-m", - "chipcompiler.runtime.stdio_server", - "--workspace", - str(workspace_dir), - ] + self._worker_argv = worker_argv or _default_worker_argv() self._log_path_resolver = log_path_resolver self._on_output = on_output def run(self, method: str, params: dict, *, request_id: int = 1) -> OperationResult: - """Execute one RPC method against the worker and return a typed result.""" + """Execute one RPC method against the worker and return a typed result. + + Session sequence: hello → workspace.open → method → rpc.shutdown → EOF. + """ client = WorkerClient(self._worker_argv) - active_step: str | None = None + reader: LogStreamReader | None = None try: proc = client.start() @@ -82,62 +91,127 @@ def run(self, method: str, params: dict, *, request_id: int = 1) -> OperationRes ) reader.start() - try: - rpc_result = client.request(method, params, request_id) - except KeyboardInterrupt: - return self._handle_crash(client, reader, active_step, "operation interrupted") + hello_result = client.request("rpc.hello", {"version": PROTOCOL_VERSION}, request_id=0) + if not hello_result.success: + return self._handle_protocol_or_crash(client, reader, hello_result) - reader.stop() - reader.join(timeout=5.0) + open_result = client.request( + "workspace.open", + {"path": str(self._workspace_dir)}, + request_id=0, + ) + if not open_result.success: + return self._handle_protocol_or_crash(client, reader, open_result) - log_state = reader.state - archive_error = log_state.error - active_step = log_state.active_step + rpc_result = client.request(method, params, request_id) if not rpc_result.success: - if not client.is_alive(): - return self._handle_crash( - client, reader, active_step, rpc_result.error or "worker crashed" - ) - exit_result = self._shutdown(client) + if rpc_result.response is None or not client.is_alive(): + error = rpc_result.error or "protocol failure" + return self._handle_crash(client, reader, error) + # Live-worker RPC error: graceful shutdown then drain + self._graceful_shutdown(client) + reader.join(timeout=5.0) + reader.stop() + log_state = reader.state return OperationResult( success=False, rpc_result=rpc_result.response, - exit_code=exit_result.exit_code, - signal_number=exit_result.signal_number, + exit_code=client.process.returncode if client.process else None, error=rpc_result.error, - archive_error=archive_error, + archive_error=log_state.error, log_state=log_state, ) - exit_result = self._shutdown(client) + shutdown_ok = self._graceful_shutdown(client) + + reader.join(timeout=5.0) + reader.stop() + + log_state = reader.state + + error_parts: list[str] = [] + if log_state.error is not None: + error_parts.append(f"archive error: {log_state.error}") + if not reader.completed: + error_parts.append("log reader did not complete") + if log_state.active_step is not None: + error_parts.append(f"unmatched begin marker for step: {log_state.active_step}") + if not shutdown_ok: + error_parts.append("worker did not exit cleanly after shutdown") + + if error_parts: + return OperationResult( + success=False, + rpc_result=rpc_result.response, + exit_code=client.process.returncode if client.process else None, + error="; ".join(error_parts), + archive_error=log_state.error, + log_state=log_state, + ) return OperationResult( - success=exit_result.exit_code == 0 or exit_result.exit_code is None, + success=True, rpc_result=rpc_result.response, - exit_code=exit_result.exit_code, - signal_number=exit_result.signal_number, - error=exit_result.error if exit_result.exit_code not in (0, None) else None, - archive_error=archive_error, + exit_code=0, log_state=log_state, ) + except KeyboardInterrupt: + return self._handle_crash(client, reader, "operation interrupted") except Exception as exc: - return self._handle_crash(client, None, active_step, str(exc)) + return self._handle_crash(client, reader, str(exc)) + + def _handle_protocol_or_crash( + self, + client: WorkerClient, + reader: LogStreamReader | None, + result: WorkerResult, + ) -> OperationResult: + """Route a failed WorkerResult to crash recovery or RPC error.""" + if result.response is None or not client.is_alive(): + error = result.error or "protocol failure" + return self._handle_crash(client, reader, error) + + self._graceful_shutdown(client) + if reader is not None: + reader.join(timeout=2.0) + reader.stop() + + log_state = reader.state if reader else None + return OperationResult( + success=False, + rpc_result=result.response, + exit_code=client.process.returncode if client.process else None, + error=result.error, + archive_error=log_state.error if log_state else None, + log_state=log_state, + ) + + def _graceful_shutdown(self, client: WorkerClient) -> bool: + """Send rpc.shutdown and wait for graceful EOF + zero exit.""" + try: + client.send_request("rpc.shutdown", {}, request_id=0) + except WorkerProcessError: + client.terminate() + return False - def _shutdown(self, client: WorkerClient) -> WorkerResult: - """Clean shutdown: terminate process group and classify exit.""" - client.terminate() proc = client.process if proc is None: - return WorkerResult(success=True, exit_code=0) - return classify_worker_exit(proc) + return True + + try: + proc.wait(timeout=10.0) + except subprocess.TimeoutExpired: + client.terminate() + return False + + return proc.returncode == 0 def _handle_crash( self, client: WorkerClient, reader: LogStreamReader | None, - active_step: str | None, error: str, ) -> OperationResult: """Crash recovery: terminate, drain, repair, return failure.""" @@ -145,7 +219,7 @@ def _handle_crash( signal_number: int | None = None repaired: list[str] = [] log_state: LogStreamState | None = None - archive_error: Exception | None = None + active_step: str | None = None try: client.terminate() @@ -161,9 +235,7 @@ def _handle_crash( reader.join(timeout=2.0) reader.stop() log_state = reader.state - archive_error = log_state.error - if active_step is None: - active_step = log_state.active_step + active_step = log_state.active_step if active_step is not None and self._flow_json_path.exists(): with suppress(OSError): @@ -175,6 +247,6 @@ def _handle_crash( signal_number=signal_number, error=error, repaired_steps=repaired, - archive_error=archive_error, + archive_error=log_state.error if log_state else None, log_state=log_state, ) diff --git a/test/runtime/test_worker_operation.py b/test/runtime/test_worker_operation.py index 2bc82a67..66b75ca8 100644 --- a/test/runtime/test_worker_operation.py +++ b/test/runtime/test_worker_operation.py @@ -6,90 +6,106 @@ from chipcompiler.runtime.worker_operation import RunOperation +# Common RPC helpers injected into subprocess scripts. +_RPC_HELPERS = textwrap.dedent("""\ + import sys, os, json -class TestRunOperationSuccess: - def test_successful_rpc_returns_result(self, tmp_path): - script = textwrap.dedent("""\ - import sys, json - data = b"" - while True: - chunk = sys.stdin.buffer.read(1) - if not chunk: - break - data += chunk - if b"\\r\\n\\r\\n" in data: - header, _, body_start = data.partition(b"\\r\\n\\r\\n") - length = int(header.split(b":")[1]) - while len(body_start) < length: - body_start += sys.stdin.buffer.read(1) - request = json.loads(body_start[:length]) - resp = {"jsonrpc": "2.0", "result": {"steps": ["syn"]}, "id": request["id"]} - response = json.dumps(resp) - frame = f"Content-Length: {len(response)}\\r\\n\\r\\n{response}" - sys.stdout.buffer.write(frame.encode()) - sys.stdout.buffer.flush() - break - """) + def read_request(): + data = b"" + while True: + chunk = sys.stdin.buffer.read(1) + if not chunk: + return None + data += chunk + if b"\\r\\n\\r\\n" in data: + header, _, body_start = data.partition(b"\\r\\n\\r\\n") + length = int(header.split(b":")[1]) + while len(body_start) < length: + body_start += sys.stdin.buffer.read(1) + return json.loads(body_start[:length]) + + def send_response(resp): + payload = json.dumps(resp) + frame = f"Content-Length: {len(payload)}\\r\\n\\r\\n{payload}" + sys.stdout.buffer.write(frame.encode()) + sys.stdout.buffer.flush() + + def make_marker(event, step, tool): + p = json.dumps({"event": event, "step": step, "tool": tool}) + return chr(0x1e).encode() + b"ECC-STEP " + p.encode() + b"\\n" +""") + +LIFECYCLE_SERVER = _RPC_HELPERS + textwrap.dedent("""\ + while True: + req = read_request() + if req is None: + break + method = req.get("method", "") + req_id = req.get("id") + + if method == "rpc.hello": + resp = {"jsonrpc": "2.0", "result": {"version": 1, "capabilities": []}, "id": req_id} + send_response(resp) + elif method == "workspace.open": + send_response({"jsonrpc": "2.0", "result": {"workspace_id": "test"}, "id": req_id}) + elif method == "rpc.shutdown": + send_response({"jsonrpc": "2.0", "result": {"ok": True}, "id": req_id}) + break + elif method == "flow.run": + send_response({"jsonrpc": "2.0", "result": {"steps": ["syn"]}, "id": req_id}) + else: + err = {"code": -32601, "message": "unknown method"} + resp = {"jsonrpc": "2.0", "error": err, "id": req_id} + send_response(resp) +""") + + +class TestRunOperationLifecycle: + def test_successful_lifecycle(self, tmp_path): + script = tmp_path / "server.py" + script.write_text(LIFECYCLE_SERVER) flow_json = tmp_path / "flow.json" flow_json.write_text("{}") op = RunOperation( workspace_dir=tmp_path, flow_json_path=flow_json, - worker_argv=[sys.executable, "-c", script], + worker_argv=[sys.executable, str(script)], ) result = op.run("flow.run", {"workspace_id": "test"}) assert result.success is True assert result.rpc_result["result"] == {"steps": ["syn"]} + assert result.exit_code == 0 assert result.archive_error is None def test_rpc_error_returns_failure(self, tmp_path): - script = textwrap.dedent("""\ - import sys, json - data = b"" - while True: - chunk = sys.stdin.buffer.read(1) - if not chunk: - break - data += chunk - if b"\\r\\n\\r\\n" in data: - header, _, body_start = data.partition(b"\\r\\n\\r\\n") - length = int(header.split(b":")[1]) - while len(body_start) < length: - body_start += sys.stdin.buffer.read(1) - request = json.loads(body_start[:length]) - resp = { - "jsonrpc": "2.0", - "error": {"code": -1, "message": "step failed"}, - "id": request["id"], - } - response = json.dumps(resp) - frame = f"Content-Length: {len(response)}\\r\\n\\r\\n{response}" - sys.stdout.buffer.write(frame.encode()) - sys.stdout.buffer.flush() - break - """) + script = tmp_path / "server.py" + script.write_text(LIFECYCLE_SERVER) flow_json = tmp_path / "flow.json" flow_json.write_text("{}") op = RunOperation( workspace_dir=tmp_path, flow_json_path=flow_json, - worker_argv=[sys.executable, "-c", script], + worker_argv=[sys.executable, str(script)], ) - result = op.run("flow.run", {"workspace_id": "test"}) + result = op.run("unknown.method", {"workspace_id": "test"}) assert result.success is False - assert "step failed" in result.error + assert "unknown method" in result.error class TestRunOperationCrash: def test_worker_crash_triggers_repair(self, tmp_path): - script_file = tmp_path / "crash_worker.py" - script_file.write_text( - "import sys, os, json\n" - "marker = chr(0x1e).encode() + b'ECC-STEP ' + " - "json.dumps({'event':'begin','step':'Synthesis','tool':'yosys'}).encode()" - " + b'\\n'\n" - "os.write(2, marker)\n" - "os._exit(1)\n" + crash_script = tmp_path / "crash_after_open.py" + crash_script.write_text( + _RPC_HELPERS + + textwrap.dedent("""\ + req = read_request() # hello + send_response({"jsonrpc": "2.0", "result": {"version": 1}, "id": req["id"]}) + req = read_request() # workspace.open + send_response({"jsonrpc": "2.0", "result": {"workspace_id": "x"}, "id": req["id"]}) + # Emit begin marker on stderr, then crash + os.write(2, make_marker("begin", "Synthesis", "yosys")) + os._exit(1) + """) ) flow_json = tmp_path / "flow.json" data = {"steps": [{"name": "Synthesis", "tool": "yosys", "state": "Ongoing"}]} @@ -97,7 +113,7 @@ def test_worker_crash_triggers_repair(self, tmp_path): op = RunOperation( workspace_dir=tmp_path, flow_json_path=flow_json, - worker_argv=[sys.executable, str(script_file)], + worker_argv=[sys.executable, str(crash_script)], ) result = op.run("flow.run", {"workspace_id": "test"}) assert result.success is False @@ -106,7 +122,13 @@ def test_worker_crash_triggers_repair(self, tmp_path): assert repaired_data["steps"][0]["state"] == "Incomplete" def test_worker_crash_no_flow_json_no_repair(self, tmp_path): - script = "import sys; sys.exit(1)" + script = _RPC_HELPERS + textwrap.dedent("""\ + req = read_request() # hello + send_response({"jsonrpc": "2.0", "result": {"version": 1}, "id": req["id"]}) + req = read_request() # workspace.open + send_response({"jsonrpc": "2.0", "result": {}, "id": req["id"]}) + sys.exit(1) + """) flow_json = tmp_path / "flow.json" op = RunOperation( workspace_dir=tmp_path, @@ -117,37 +139,96 @@ def test_worker_crash_no_flow_json_no_repair(self, tmp_path): assert result.success is False assert result.repaired_steps == [] + def test_protocol_failure_routes_to_recovery(self, tmp_path): + """A worker that returns invalid JSON triggers crash recovery.""" + script = _RPC_HELPERS + textwrap.dedent("""\ + req = read_request() # hello + send_response({"jsonrpc": "2.0", "result": {"version": 1}, "id": req["id"]}) + req = read_request() # workspace.open + send_response({"jsonrpc": "2.0", "result": {}, "id": req["id"]}) + # Emit begin marker, then send garbage on stdout + os.write(2, make_marker("begin", "Place", "ecc")) + req = read_request() # flow.run + # Send invalid frame (bad Content-Length header) + sys.stdout.buffer.write(b"Content-Length: 5\\r\\n\\r\\n{}") + sys.stdout.buffer.flush() + sys.stdout.buffer.close() + os._exit(1) + """) + flow_json = tmp_path / "flow.json" + data = {"steps": [{"name": "Place", "tool": "ecc", "state": "Ongoing"}]} + flow_json.write_text(json.dumps(data)) + op = RunOperation( + workspace_dir=tmp_path, + flow_json_path=flow_json, + worker_argv=[sys.executable, "-c", script], + ) + result = op.run("flow.run", {"workspace_id": "test"}) + assert result.success is False + assert result.repaired_steps == ["Place"] + + +class TestRunOperationArchive: + def test_archive_error_forces_failure(self, tmp_path): + """Success is False when archive path cannot be opened.""" + script = tmp_path / "server_with_markers.py" + script.write_text( + _RPC_HELPERS + + textwrap.dedent("""\ + req = read_request() # hello + send_response({"jsonrpc": "2.0", "result": {"version": 1}, "id": req["id"]}) + req = read_request() # workspace.open + send_response({"jsonrpc": "2.0", "result": {}, "id": req["id"]}) + os.write(2, make_marker("begin", "Synthesis", "yosys")) + os.write(2, b'Synthesizing...\\n') + os.write(2, make_marker("end", "Synthesis", "yosys")) + req = read_request() # flow.run + send_response({"jsonrpc": "2.0", "result": {"steps": ["syn"]}, "id": req["id"]}) + req = read_request() # rpc.shutdown + send_response({"jsonrpc": "2.0", "result": {"ok": True}, "id": req["id"]}) + """) + ) + + readonly_dir = tmp_path / "readonly_logs" + readonly_dir.mkdir() + readonly_dir.chmod(0o444) + + def bad_resolver(step: str, tool: str): + return readonly_dir / "sub" / f"{step}.log" + + flow_json = tmp_path / "flow.json" + flow_json.write_text("{}") + op = RunOperation( + workspace_dir=tmp_path, + flow_json_path=flow_json, + worker_argv=[sys.executable, str(script)], + log_path_resolver=bad_resolver, + ) + try: + result = op.run("flow.run", {"workspace_id": "test"}) + assert result.success is False + assert result.archive_error is not None + assert "archive error" in result.error + finally: + readonly_dir.chmod(0o755) -class TestRunOperationStderrArchive: def test_stderr_archived_to_step_log(self, tmp_path): - script_file = tmp_path / "archive_worker.py" - script_file.write_text( - "import sys, os, json\n" - "def marker(event, step, tool):\n" - " payload = json.dumps({'event':event,'step':step,'tool':tool})\n" - " return chr(0x1e).encode() + b'ECC-STEP ' + " - "payload.encode() + b'\\n'\n" - "os.write(2, marker('begin', 'Synthesis', 'yosys'))\n" - "os.write(2, b'Synthesizing module top...\\n')\n" - "os.write(2, marker('end', 'Synthesis', 'yosys'))\n" - "data = b''\n" - "while True:\n" - " chunk = sys.stdin.buffer.read(1)\n" - " if not chunk:\n" - " break\n" - " data += chunk\n" - " if b'\\r\\n\\r\\n' in data:\n" - " header, _, body_start = data.partition(b'\\r\\n\\r\\n')\n" - " length = int(header.split(b':')[1])\n" - " while len(body_start) < length:\n" - " body_start += sys.stdin.buffer.read(1)\n" - " request = json.loads(body_start[:length])\n" - " resp = {'jsonrpc': '2.0', 'result': {}, 'id': request['id']}\n" - " response = json.dumps(resp)\n" - " frame = f'Content-Length: {len(response)}\\r\\n\\r\\n{response}'\n" - " sys.stdout.buffer.write(frame.encode())\n" - " sys.stdout.buffer.flush()\n" - " break\n" + script = tmp_path / "server_with_markers.py" + script.write_text( + _RPC_HELPERS + + textwrap.dedent("""\ + req = read_request() # hello + send_response({"jsonrpc": "2.0", "result": {"version": 1}, "id": req["id"]}) + req = read_request() # workspace.open + send_response({"jsonrpc": "2.0", "result": {}, "id": req["id"]}) + os.write(2, make_marker("begin", "Synthesis", "yosys")) + os.write(2, b'Synthesizing module top...\\n') + os.write(2, make_marker("end", "Synthesis", "yosys")) + req = read_request() # flow.run + send_response({"jsonrpc": "2.0", "result": {}, "id": req["id"]}) + req = read_request() # rpc.shutdown + send_response({"jsonrpc": "2.0", "result": {"ok": True}, "id": req["id"]}) + """) ) logs_dir = tmp_path / "logs" @@ -159,7 +240,7 @@ def resolver(step: str, tool: str): op = RunOperation( workspace_dir=tmp_path, flow_json_path=flow_json, - worker_argv=[sys.executable, str(script_file)], + worker_argv=[sys.executable, str(script)], log_path_resolver=resolver, ) result = op.run("flow.run", {"workspace_id": "test"}) @@ -167,3 +248,15 @@ def resolver(step: str, tool: str): log_file = logs_dir / "Synthesis.log" assert log_file.exists() assert b"Synthesizing module top..." in log_file.read_bytes() + + +class TestParseMarkerNonObject: + def test_non_object_json_treated_as_raw(self): + """Non-dict JSON markers don't crash the reader.""" + from chipcompiler.runtime.log_stream import parse_marker + + assert parse_marker(b"\x1eECC-STEP []\n") is None + assert parse_marker(b"\x1eECC-STEP 42\n") is None + assert parse_marker(b'\x1eECC-STEP "hello"\n') is None + assert parse_marker(b"\x1eECC-STEP true\n") is None + assert parse_marker(b"\x1eECC-STEP null\n") is None From ded0b872b82c104503a33f2fc53e61986916acc5 Mon Sep 17 00:00:00 2001 From: Emin Date: Tue, 11 Aug 2026 20:30:02 +0800 Subject: [PATCH 07/52] fix: correct workspace.open contract, bind workspaceId, validate shutdown - Send `directory` (not `path`) in workspace.open params to match WorkspaceOpenRequest schema - Extract workspaceId from open response and inject it into subsequent flow request params as workspace_id - Validate rpc.shutdown response (require result.ok is True) before waiting for process exit - Add real-server lifecycle tests proving hello/open/shutdown through the installed ecc rpc serve --stdio --persistent-db binary - Add contract tests asserting directory field and workspace_id injection --- chipcompiler/runtime/worker_operation.py | 14 ++- test/runtime/test_worker_operation.py | 146 ++++++++++++++++++++++- 2 files changed, 150 insertions(+), 10 deletions(-) diff --git a/chipcompiler/runtime/worker_operation.py b/chipcompiler/runtime/worker_operation.py index 5c9e526a..03ed52e6 100644 --- a/chipcompiler/runtime/worker_operation.py +++ b/chipcompiler/runtime/worker_operation.py @@ -16,7 +16,6 @@ from chipcompiler.runtime.log_stream import LogStreamReader, LogStreamState from chipcompiler.runtime.worker import ( WorkerClient, - WorkerProcessError, WorkerResult, classify_worker_exit, repair_flow_state, @@ -97,12 +96,15 @@ def run(self, method: str, params: dict, *, request_id: int = 1) -> OperationRes open_result = client.request( "workspace.open", - {"path": str(self._workspace_dir)}, + {"directory": str(self._workspace_dir)}, request_id=0, ) if not open_result.success: return self._handle_protocol_or_crash(client, reader, open_result) + workspace_id = open_result.response["result"]["workspaceId"] + params = {**params, "workspace_id": workspace_id} + rpc_result = client.request(method, params, request_id) if not rpc_result.success: @@ -191,8 +193,12 @@ def _handle_protocol_or_crash( def _graceful_shutdown(self, client: WorkerClient) -> bool: """Send rpc.shutdown and wait for graceful EOF + zero exit.""" try: - client.send_request("rpc.shutdown", {}, request_id=0) - except WorkerProcessError: + result = client.request("rpc.shutdown", {}, request_id=0) + except Exception: + client.terminate() + return False + + if not result.success or not (result.response or {}).get("result", {}).get("ok"): client.terminate() return False diff --git a/test/runtime/test_worker_operation.py b/test/runtime/test_worker_operation.py index 66b75ca8..eb5b3f15 100644 --- a/test/runtime/test_worker_operation.py +++ b/test/runtime/test_worker_operation.py @@ -1,9 +1,13 @@ """Tests for chipcompiler.runtime.worker_operation — typed operation orchestrator.""" import json +import os import sys import textwrap +import pytest + +from chipcompiler.runtime.worker import WorkerClient from chipcompiler.runtime.worker_operation import RunOperation # Common RPC helpers injected into subprocess scripts. @@ -47,7 +51,7 @@ def make_marker(event, step, tool): resp = {"jsonrpc": "2.0", "result": {"version": 1, "capabilities": []}, "id": req_id} send_response(resp) elif method == "workspace.open": - send_response({"jsonrpc": "2.0", "result": {"workspace_id": "test"}, "id": req_id}) + send_response({"jsonrpc": "2.0", "result": {"workspaceId": "test"}, "id": req_id}) elif method == "rpc.shutdown": send_response({"jsonrpc": "2.0", "result": {"ok": True}, "id": req_id}) break @@ -101,7 +105,7 @@ def test_worker_crash_triggers_repair(self, tmp_path): req = read_request() # hello send_response({"jsonrpc": "2.0", "result": {"version": 1}, "id": req["id"]}) req = read_request() # workspace.open - send_response({"jsonrpc": "2.0", "result": {"workspace_id": "x"}, "id": req["id"]}) + send_response({"jsonrpc": "2.0", "result": {"workspaceId": "x"}, "id": req["id"]}) # Emit begin marker on stderr, then crash os.write(2, make_marker("begin", "Synthesis", "yosys")) os._exit(1) @@ -126,7 +130,7 @@ def test_worker_crash_no_flow_json_no_repair(self, tmp_path): req = read_request() # hello send_response({"jsonrpc": "2.0", "result": {"version": 1}, "id": req["id"]}) req = read_request() # workspace.open - send_response({"jsonrpc": "2.0", "result": {}, "id": req["id"]}) + send_response({"jsonrpc": "2.0", "result": {"workspaceId": "x"}, "id": req["id"]}) sys.exit(1) """) flow_json = tmp_path / "flow.json" @@ -145,7 +149,7 @@ def test_protocol_failure_routes_to_recovery(self, tmp_path): req = read_request() # hello send_response({"jsonrpc": "2.0", "result": {"version": 1}, "id": req["id"]}) req = read_request() # workspace.open - send_response({"jsonrpc": "2.0", "result": {}, "id": req["id"]}) + send_response({"jsonrpc": "2.0", "result": {"workspaceId": "x"}, "id": req["id"]}) # Emit begin marker, then send garbage on stdout os.write(2, make_marker("begin", "Place", "ecc")) req = read_request() # flow.run @@ -178,7 +182,7 @@ def test_archive_error_forces_failure(self, tmp_path): req = read_request() # hello send_response({"jsonrpc": "2.0", "result": {"version": 1}, "id": req["id"]}) req = read_request() # workspace.open - send_response({"jsonrpc": "2.0", "result": {}, "id": req["id"]}) + send_response({"jsonrpc": "2.0", "result": {"workspaceId": "ws1"}, "id": req["id"]}) os.write(2, make_marker("begin", "Synthesis", "yosys")) os.write(2, b'Synthesizing...\\n') os.write(2, make_marker("end", "Synthesis", "yosys")) @@ -220,7 +224,7 @@ def test_stderr_archived_to_step_log(self, tmp_path): req = read_request() # hello send_response({"jsonrpc": "2.0", "result": {"version": 1}, "id": req["id"]}) req = read_request() # workspace.open - send_response({"jsonrpc": "2.0", "result": {}, "id": req["id"]}) + send_response({"jsonrpc": "2.0", "result": {"workspaceId": "ws1"}, "id": req["id"]}) os.write(2, make_marker("begin", "Synthesis", "yosys")) os.write(2, b'Synthesizing module top...\\n') os.write(2, make_marker("end", "Synthesis", "yosys")) @@ -250,6 +254,66 @@ def resolver(step: str, tool: str): assert b"Synthesizing module top..." in log_file.read_bytes() +class TestWorkspaceIdInjection: + def test_workspace_id_injected_into_flow_params(self, tmp_path): + """workspace_id from workspace.open is injected into the flow request params.""" + script = tmp_path / "echo_server.py" + script.write_text( + _RPC_HELPERS + + textwrap.dedent("""\ + req = read_request() # hello + send_response({"jsonrpc": "2.0", "result": {"version": 1}, "id": req["id"]}) + req = read_request() # workspace.open + ws_resp = {"workspaceId": "injected-ws"} + send_response({"jsonrpc": "2.0", "result": ws_resp, "id": req["id"]}) + req = read_request() # flow.run — echo params back + send_response({"jsonrpc": "2.0", "result": req.get("params", {}), "id": req["id"]}) + req = read_request() # rpc.shutdown + send_response({"jsonrpc": "2.0", "result": {"ok": True}, "id": req["id"]}) + """) + ) + flow_json = tmp_path / "flow.json" + flow_json.write_text("{}") + op = RunOperation( + workspace_dir=tmp_path, + flow_json_path=flow_json, + worker_argv=[sys.executable, str(script)], + ) + result = op.run("flow.run", {"rerun": True}) + assert result.success is True + assert result.rpc_result["result"]["workspace_id"] == "injected-ws" + assert result.rpc_result["result"]["rerun"] is True + + def test_workspace_open_sends_directory_field(self, tmp_path): + """workspace.open sends 'directory' not 'path'.""" + script = tmp_path / "check_open_server.py" + script.write_text( + _RPC_HELPERS + + textwrap.dedent("""\ + req = read_request() # hello + send_response({"jsonrpc": "2.0", "result": {"version": 1}, "id": req["id"]}) + req = read_request() # workspace.open + params = req.get("params", {}) + assert "directory" in params, f"expected 'directory' in params, got {params}" + assert "path" not in params, f"unexpected 'path' in params: {params}" + send_response({"jsonrpc": "2.0", "result": {"workspaceId": "ok"}, "id": req["id"]}) + req = read_request() # flow.run + send_response({"jsonrpc": "2.0", "result": {}, "id": req["id"]}) + req = read_request() # rpc.shutdown + send_response({"jsonrpc": "2.0", "result": {"ok": True}, "id": req["id"]}) + """) + ) + flow_json = tmp_path / "flow.json" + flow_json.write_text("{}") + op = RunOperation( + workspace_dir=tmp_path, + flow_json_path=flow_json, + worker_argv=[sys.executable, str(script)], + ) + result = op.run("flow.run", {}) + assert result.success is True + + class TestParseMarkerNonObject: def test_non_object_json_treated_as_raw(self): """Non-dict JSON markers don't crash the reader.""" @@ -260,3 +324,73 @@ def test_non_object_json_treated_as_raw(self): assert parse_marker(b'\x1eECC-STEP "hello"\n') is None assert parse_marker(b"\x1eECC-STEP true\n") is None assert parse_marker(b"\x1eECC-STEP null\n") is None + + +_ECC_BIN = os.path.join(os.path.dirname(sys.executable), "ecc") + + +@pytest.mark.skipif(not os.path.isfile(_ECC_BIN), reason="ecc binary not installed") +class TestRealServerLifecycle: + """Prove the RPC protocol works against the real ecc rpc serve --stdio --persistent-db.""" + + def test_hello_and_shutdown(self): + """rpc.hello + rpc.shutdown succeed against the real server.""" + client = WorkerClient([_ECC_BIN, "rpc", "serve", "--stdio", "--persistent-db"]) + proc = client.start() + try: + hello = client.request("rpc.hello", {"version": 1}, request_id=0) + assert hello.success is True + assert hello.response["result"]["version"] == 1 + + shutdown = client.request("rpc.shutdown", {}, request_id=0) + assert shutdown.success is True + assert shutdown.response["result"]["ok"] is True + + proc.wait(timeout=5.0) + assert proc.returncode == 0 + finally: + if proc.poll() is None: + client.terminate() + + def test_workspace_open_with_real_workspace(self, tmp_path): + """workspace.open succeeds with a minimal valid workspace fixture.""" + home = tmp_path / "home" + home.mkdir() + (home / "parameters.json").write_text( + json.dumps( + { + "design_name": "test_design", + "origin_verilog": "", + "origin_def": "", + } + ) + ) + (home / "home.json").write_text( + json.dumps( + { + "path": str(tmp_path), + "name": "test_design", + "pdk": "", + "runs": [], + } + ) + ) + + client = WorkerClient([_ECC_BIN, "rpc", "serve", "--stdio", "--persistent-db"]) + proc = client.start() + try: + hello = client.request("rpc.hello", {"version": 1}, request_id=0) + assert hello.success is True + + open_result = client.request( + "workspace.open", {"directory": str(tmp_path)}, request_id=1 + ) + if open_result.success: + assert "workspaceId" in open_result.response["result"] + + shutdown = client.request("rpc.shutdown", {}, request_id=0) + assert shutdown.success is True + proc.wait(timeout=5.0) + finally: + if proc.poll() is None: + client.terminate() From 550b7abbce299a0e90bdcd8f8202a4ba166869bb Mon Sep 17 00:00:00 2001 From: Emin Date: Tue, 11 Aug 2026 20:56:47 +0800 Subject: [PATCH 08/52] feat(cli): route non-TTY flow execution through RunOperation worker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the non-interactive `ecc run` path through the isolated worker process (RunOperation → flow.run RPC) instead of calling EngineFlow.run_steps() directly in-process. Falls back to direct execution if the worker binary is unavailable. Also fixes strict shutdown validation (ok is True, not truthiness) and replaces the false-positive real workspace test with the canonical minimal_ics55_pdk_factory fixture that requires success. --- chipcompiler/cli/command_handlers/project.py | 26 ++- chipcompiler/runtime/worker_operation.py | 7 +- test/cli/commands/conftest.py | 9 ++ test/cli/conftest.py | 13 ++ test/runtime/test_worker_operation.py | 162 ++++++++++++++----- 5 files changed, 175 insertions(+), 42 deletions(-) diff --git a/chipcompiler/cli/command_handlers/project.py b/chipcompiler/cli/command_handlers/project.py index 020bf3ae..c78b74d2 100644 --- a/chipcompiler/cli/command_handlers/project.py +++ b/chipcompiler/cli/command_handlers/project.py @@ -187,6 +187,29 @@ def _canonically_inside(path: str, anchor: str) -> bool: return real == real_base or real.startswith(real_base.rstrip(os.sep) + os.sep) +def _run_flow_via_worker(workspace_dir: str) -> bool: + """Execute flow.run through an isolated worker process. + + Falls back to None if the worker binary is unavailable, signaling the caller + to use direct in-process execution instead. + """ + from pathlib import Path + + from chipcompiler.runtime.worker_operation import RunOperation, _default_worker_argv + + argv = _default_worker_argv() + if not os.path.isfile(argv[0]): + return None + + flow_json_path = Path(workspace_dir) / "home" / "flow.json" + op = RunOperation( + workspace_dir=Path(workspace_dir), + flow_json_path=flow_json_path, + ) + result = op.run("flow.run", {"rerun": False}) + return result.success + + def run(command_input: RunInput, ctx: CommandContext) -> CommandResult: if command_input.workspace is not None: return _run_workspace(command_input, ctx) @@ -427,7 +450,8 @@ def run(command_input: RunInput, ctx: CommandContext) -> CommandResult: if should_enable_run_progress(ctx, sys.stderr): flow_ok = run_flow_with_progress(engine_flow, ctx, project, sys.stderr) else: - flow_ok = engine_flow.run_steps() + worker_result = _run_flow_via_worker(run_dir) + flow_ok = worker_result if worker_result is not None else engine_flow.run_steps() if not flow_ok: return CommandResult.err( diff --git a/chipcompiler/runtime/worker_operation.py b/chipcompiler/runtime/worker_operation.py index 03ed52e6..0b873883 100644 --- a/chipcompiler/runtime/worker_operation.py +++ b/chipcompiler/runtime/worker_operation.py @@ -198,7 +198,12 @@ def _graceful_shutdown(self, client: WorkerClient) -> bool: client.terminate() return False - if not result.success or not (result.response or {}).get("result", {}).get("ok"): + if not result.success: + client.terminate() + return False + + ok = (result.response or {}).get("result", {}).get("ok") + if ok is not True: client.terminate() return False diff --git a/test/cli/commands/conftest.py b/test/cli/commands/conftest.py index 93cb7cd5..65f1708f 100644 --- a/test/cli/commands/conftest.py +++ b/test/cli/commands/conftest.py @@ -56,6 +56,11 @@ def fake_create_workspace(**kwargs): capture["create_kwargs"] = kwargs return workspace_obj + def fake_run_flow_via_worker(workspace_dir): + for inst in DummyFlow.instances: + inst.run_called = True + return DummyFlow.run_steps_value + monkeypatch.setattr("chipcompiler.data.create_workspace", fake_create_workspace) monkeypatch.setattr("chipcompiler.engine.EngineFlow", DummyFlow) monkeypatch.setattr( @@ -66,5 +71,9 @@ def fake_create_workspace(**kwargs): "chipcompiler.cli.project.config._validate_pdk_contents", lambda name, root, overrides=None: None, ) + monkeypatch.setattr( + "chipcompiler.cli.command_handlers.project._run_flow_via_worker", + fake_run_flow_via_worker, + ) return SimpleNamespace(capture=capture, flow=DummyFlow) diff --git a/test/cli/conftest.py b/test/cli/conftest.py index 5855c8f0..d6ff25ed 100644 --- a/test/cli/conftest.py +++ b/test/cli/conftest.py @@ -196,3 +196,16 @@ def factory(): mock_pdk_validation(monkeypatch) return factory + + +@pytest.fixture(autouse=True) +def _disable_worker_routing(monkeypatch): + """Disable worker routing in CLI tests by default. + + Tests that need to exercise the worker path should override this by + patching _run_flow_via_worker themselves (e.g. via flow_mocks fixture). + """ + monkeypatch.setattr( + "chipcompiler.cli.command_handlers.project._run_flow_via_worker", + lambda workspace_dir: None, + ) diff --git a/test/runtime/test_worker_operation.py b/test/runtime/test_worker_operation.py index eb5b3f15..390d1bb7 100644 --- a/test/runtime/test_worker_operation.py +++ b/test/runtime/test_worker_operation.py @@ -314,6 +314,98 @@ def test_workspace_open_sends_directory_field(self, tmp_path): assert result.success is True +class TestShutdownValidation: + """Strict ok is True validation in _graceful_shutdown.""" + + @pytest.mark.parametrize( + "ok_value", + [1, "yes", "true", [], {}], + ids=["int", "string", "string-true", "list", "dict"], + ) + def test_truthy_non_boolean_ok_fails_shutdown(self, tmp_path, ok_value): + """Non-True truthy values must not be accepted as successful shutdown.""" + script = tmp_path / "bad_shutdown.py" + script.write_text( + _RPC_HELPERS + + textwrap.dedent(f"""\ + req = read_request() # hello + send_response({{"jsonrpc": "2.0", "result": {{"version": 1}}, "id": req["id"]}}) + req = read_request() # workspace.open + send_response({{"jsonrpc": "2.0", "result": {{"workspaceId": "w"}}, "id": req["id"]}}) + req = read_request() # flow.run + send_response({{"jsonrpc": "2.0", "result": {{}}, "id": req["id"]}}) + req = read_request() # rpc.shutdown + ok_val = {repr(ok_value)} + resp = {{"jsonrpc": "2.0", "result": {{"ok": ok_val}}, "id": req["id"]}} + send_response(resp) + """) + ) + flow_json = tmp_path / "flow.json" + flow_json.write_text("{}") + op = RunOperation( + workspace_dir=tmp_path, + flow_json_path=flow_json, + worker_argv=[sys.executable, str(script)], + ) + result = op.run("flow.run", {}) + assert result.success is False + assert "worker did not exit cleanly" in result.error + + def test_false_ok_fails_shutdown(self, tmp_path): + """ok: false must not be accepted.""" + script = tmp_path / "false_shutdown.py" + script.write_text( + _RPC_HELPERS + + textwrap.dedent("""\ + req = read_request() # hello + send_response({"jsonrpc": "2.0", "result": {"version": 1}, "id": req["id"]}) + req = read_request() # workspace.open + send_response({"jsonrpc": "2.0", "result": {"workspaceId": "w"}, "id": req["id"]}) + req = read_request() # flow.run + send_response({"jsonrpc": "2.0", "result": {}, "id": req["id"]}) + req = read_request() # rpc.shutdown + send_response({"jsonrpc": "2.0", "result": {"ok": False}, "id": req["id"]}) + """) + ) + flow_json = tmp_path / "flow.json" + flow_json.write_text("{}") + op = RunOperation( + workspace_dir=tmp_path, + flow_json_path=flow_json, + worker_argv=[sys.executable, str(script)], + ) + result = op.run("flow.run", {}) + assert result.success is False + assert "worker did not exit cleanly" in result.error + + def test_missing_ok_fails_shutdown(self, tmp_path): + """Missing ok field must not be accepted.""" + script = tmp_path / "no_ok_shutdown.py" + script.write_text( + _RPC_HELPERS + + textwrap.dedent("""\ + req = read_request() # hello + send_response({"jsonrpc": "2.0", "result": {"version": 1}, "id": req["id"]}) + req = read_request() # workspace.open + send_response({"jsonrpc": "2.0", "result": {"workspaceId": "w"}, "id": req["id"]}) + req = read_request() # flow.run + send_response({"jsonrpc": "2.0", "result": {}, "id": req["id"]}) + req = read_request() # rpc.shutdown + send_response({"jsonrpc": "2.0", "result": {}, "id": req["id"]}) + """) + ) + flow_json = tmp_path / "flow.json" + flow_json.write_text("{}") + op = RunOperation( + workspace_dir=tmp_path, + flow_json_path=flow_json, + worker_argv=[sys.executable, str(script)], + ) + result = op.run("flow.run", {}) + assert result.success is False + assert "worker did not exit cleanly" in result.error + + class TestParseMarkerNonObject: def test_non_object_json_treated_as_raw(self): """Non-dict JSON markers don't crash the reader.""" @@ -352,45 +444,35 @@ def test_hello_and_shutdown(self): if proc.poll() is None: client.terminate() - def test_workspace_open_with_real_workspace(self, tmp_path): - """workspace.open succeeds with a minimal valid workspace fixture.""" - home = tmp_path / "home" - home.mkdir() - (home / "parameters.json").write_text( - json.dumps( - { - "design_name": "test_design", - "origin_verilog": "", - "origin_def": "", - } - ) + def test_run_operation_with_real_workspace(self, tmp_path, minimal_ics55_pdk_factory): + """RunOperation lifecycle succeeds against the real server with a valid workspace.""" + from chipcompiler.data import create_workspace + + pdk_root = minimal_ics55_pdk_factory(tmp_path / "ics55") + rtl_path = tmp_path / "gcd.v" + rtl_path.write_text("module gcd(input clk, output y); assign y = clk; endmodule\n") + workspace_dir = tmp_path / "workspace" + create_workspace( + directory=workspace_dir, + origin_def="", + origin_verilog=rtl_path, + pdk="ics55", + pdk_root=pdk_root, + parameters={ + "PDK": "ics55", + "Design": "gcd", + "Top module": "gcd", + "Clock": "clk", + "Frequency max [MHz]": 100, + }, ) - (home / "home.json").write_text( - json.dumps( - { - "path": str(tmp_path), - "name": "test_design", - "pdk": "", - "runs": [], - } - ) + flow_json = workspace_dir / "home" / "flow.json" + op = RunOperation( + workspace_dir=workspace_dir, + flow_json_path=flow_json, + worker_argv=[_ECC_BIN, "rpc", "serve", "--stdio", "--persistent-db"], ) - - client = WorkerClient([_ECC_BIN, "rpc", "serve", "--stdio", "--persistent-db"]) - proc = client.start() - try: - hello = client.request("rpc.hello", {"version": 1}, request_id=0) - assert hello.success is True - - open_result = client.request( - "workspace.open", {"directory": str(tmp_path)}, request_id=1 - ) - if open_result.success: - assert "workspaceId" in open_result.response["result"] - - shutdown = client.request("rpc.shutdown", {}, request_id=0) - assert shutdown.success is True - proc.wait(timeout=5.0) - finally: - if proc.poll() is None: - client.terminate() + result = op.run("workspace.home", {}) + assert result.success is True + assert result.exit_code == 0 + assert "path" in result.rpc_result["result"] From 0f8dc3b08ee43d93b29b31f677358d45f5f75711 Mon Sep 17 00:00:00 2001 From: Emin Date: Tue, 11 Aug 2026 21:22:19 +0800 Subject: [PATCH 09/52] feat(cli): add step-log archive resolver and remove direct-execution fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire a canonical workspace step-log resolver into the production worker route so that RunOperation archives EDA output to the correct step log paths (/_/log/.log). Remove the binary-missing fallback to engine_flow.run_steps() — a missing worker binary now returns a structured OperationResult with error detail instead of silently falling back to in-process execution. Propagate OperationResult failure fields (error, exit_code, repaired_steps) into the CLI CommandResult error records for richer failure diagnostics. --- chipcompiler/cli/command_handlers/project.py | 63 +++++++++++++------- test/cli/commands/conftest.py | 4 +- test/cli/commands/test_run.py | 51 ++++++++++++++++ test/cli/conftest.py | 6 +- 4 files changed, 101 insertions(+), 23 deletions(-) diff --git a/chipcompiler/cli/command_handlers/project.py b/chipcompiler/cli/command_handlers/project.py index c78b74d2..3aefd1e2 100644 --- a/chipcompiler/cli/command_handlers/project.py +++ b/chipcompiler/cli/command_handlers/project.py @@ -187,27 +187,45 @@ def _canonically_inside(path: str, anchor: str) -> bool: return real == real_base or real.startswith(real_base.rstrip(os.sep) + os.sep) -def _run_flow_via_worker(workspace_dir: str) -> bool: +def _workspace_step_log_resolver(workspace_dir: str): + """Return a (step, tool) -> Path resolver for workspace step logs.""" + from pathlib import Path + + base = Path(workspace_dir) + + def resolve(step: str, tool: str) -> Path: + return base / f"{step}_{tool}" / "log" / f"{step}.log" + + return resolve + + +def _run_flow_via_worker(workspace_dir: str): """Execute flow.run through an isolated worker process. - Falls back to None if the worker binary is unavailable, signaling the caller - to use direct in-process execution instead. + Returns an OperationResult. A missing worker binary is a structured failure. """ from pathlib import Path - from chipcompiler.runtime.worker_operation import RunOperation, _default_worker_argv + from chipcompiler.runtime.worker_operation import ( + OperationResult, + RunOperation, + _default_worker_argv, + ) argv = _default_worker_argv() if not os.path.isfile(argv[0]): - return None + return OperationResult( + success=False, + error=f"worker binary not found: {argv[0]}", + ) flow_json_path = Path(workspace_dir) / "home" / "flow.json" op = RunOperation( workspace_dir=Path(workspace_dir), flow_json_path=flow_json_path, + log_path_resolver=_workspace_step_log_resolver(workspace_dir), ) - result = op.run("flow.run", {"rerun": False}) - return result.success + return op.run("flow.run", {"rerun": False}) def run(command_input: RunInput, ctx: CommandContext) -> CommandResult: @@ -449,22 +467,27 @@ def run(command_input: RunInput, ctx: CommandContext) -> CommandResult: if should_enable_run_progress(ctx, sys.stderr): flow_ok = run_flow_with_progress(engine_flow, ctx, project, sys.stderr) + op_result = None else: - worker_result = _run_flow_via_worker(run_dir) - flow_ok = worker_result if worker_result is not None else engine_flow.run_steps() + op_result = _run_flow_via_worker(run_dir) + flow_ok = op_result.success if not flow_ok: - return CommandResult.err( - [ - { - "run": run_name, - "status": "failed", - "workspace": run_dir, - "inspect_cmd": disclosure_cmd("ecc status", project, ctx.run_id), - "log": disclosure_cmd("ecc log", project, ctx.run_id), - } - ] - ) + error_record = { + "run": run_name, + "status": "failed", + "workspace": run_dir, + "inspect_cmd": disclosure_cmd("ecc status", project, ctx.run_id), + "log": disclosure_cmd("ecc log", project, ctx.run_id), + } + if op_result is not None and not op_result.success: + if op_result.error: + error_record["error"] = op_result.error + if op_result.exit_code is not None: + error_record["exit_code"] = op_result.exit_code + if op_result.repaired_steps: + error_record["repaired_steps"] = op_result.repaired_steps + return CommandResult.err([error_record]) except Exception as exc: return CommandResult.err( [ diff --git a/test/cli/commands/conftest.py b/test/cli/commands/conftest.py index 65f1708f..d4677395 100644 --- a/test/cli/commands/conftest.py +++ b/test/cli/commands/conftest.py @@ -57,9 +57,11 @@ def fake_create_workspace(**kwargs): return workspace_obj def fake_run_flow_via_worker(workspace_dir): + from chipcompiler.runtime.worker_operation import OperationResult + for inst in DummyFlow.instances: inst.run_called = True - return DummyFlow.run_steps_value + return OperationResult(success=DummyFlow.run_steps_value, exit_code=0) monkeypatch.setattr("chipcompiler.data.create_workspace", fake_create_workspace) monkeypatch.setattr("chipcompiler.engine.EngineFlow", DummyFlow) diff --git a/test/cli/commands/test_run.py b/test/cli/commands/test_run.py index 2c716870..aa84420c 100644 --- a/test/cli/commands/test_run.py +++ b/test/cli/commands/test_run.py @@ -426,3 +426,54 @@ def test_option_conflicts(self, argv, error, capsys, monkeypatch): record = json.loads(capsys.readouterr().out)["records"][0] assert rc == 1 assert record["error"] == error + + +class TestWorkspaceStepLogResolver: + def test_resolver_produces_canonical_step_log_path(self, tmp_path): + from chipcompiler.cli.command_handlers.project import _workspace_step_log_resolver + + resolver = _workspace_step_log_resolver(str(tmp_path)) + path = resolver("Synthesis", "yosys") + assert path == tmp_path / "Synthesis_yosys" / "log" / "Synthesis.log" + + def test_resolver_produces_correct_paths_for_multiple_tools(self, tmp_path): + from chipcompiler.cli.command_handlers.project import _workspace_step_log_resolver + + resolver = _workspace_step_log_resolver(str(tmp_path)) + assert resolver("Floorplan", "ecc") == tmp_path / "Floorplan_ecc" / "log" / "Floorplan.log" + assert resolver("CTS", "ecc") == tmp_path / "CTS_ecc" / "log" / "CTS.log" + assert resolver("Place", "ecc") == tmp_path / "Place_ecc" / "log" / "Place.log" + + +class TestRunFlowViaWorkerFailure: + def test_missing_binary_returns_structured_failure(self, tmp_path, monkeypatch): + """The binary check in _run_flow_via_worker returns a typed error.""" + monkeypatch.setattr( + "chipcompiler.runtime.worker_operation._default_worker_argv", + lambda: [str(tmp_path / "nonexistent_ecc"), "rpc", "serve", "--stdio"], + ) + # Also restore the real function past the autouse fixture + from chipcompiler.cli.command_handlers import project as proj_module + from chipcompiler.runtime.worker_operation import ( + OperationResult, + RunOperation, + _default_worker_argv, + ) + + def real_run_flow_via_worker(workspace_dir): + from pathlib import Path + + argv = _default_worker_argv() + if not os.path.isfile(argv[0]): + return OperationResult(success=False, error=f"worker binary not found: {argv[0]}") + flow_json_path = Path(workspace_dir) / "home" / "flow.json" + op = RunOperation( + workspace_dir=Path(workspace_dir), + flow_json_path=flow_json_path, + log_path_resolver=proj_module._workspace_step_log_resolver(workspace_dir), + ) + return op.run("flow.run", {"rerun": False}) + + result = real_run_flow_via_worker(str(tmp_path)) + assert result.success is False + assert "not found" in result.error diff --git a/test/cli/conftest.py b/test/cli/conftest.py index d6ff25ed..bd58dc90 100644 --- a/test/cli/conftest.py +++ b/test/cli/conftest.py @@ -200,12 +200,14 @@ def factory(): @pytest.fixture(autouse=True) def _disable_worker_routing(monkeypatch): - """Disable worker routing in CLI tests by default. + """Simulate successful worker execution in CLI tests by default. Tests that need to exercise the worker path should override this by patching _run_flow_via_worker themselves (e.g. via flow_mocks fixture). """ + from chipcompiler.runtime.worker_operation import OperationResult + monkeypatch.setattr( "chipcompiler.cli.command_handlers.project._run_flow_via_worker", - lambda workspace_dir: None, + lambda workspace_dir: OperationResult(success=True, exit_code=0), ) From f50baf45bc7cba7f53e8e665b573cbcc5ae784b9 Mon Sep 17 00:00:00 2001 From: Emin Date: Tue, 11 Aug 2026 21:43:26 +0800 Subject: [PATCH 10/52] feat(runtime): harden log archive with step allowlist, path containment, and resilient drain LogStreamReader now accepts a valid_steps allowlist built from flow.json. Markers with (step, tool) pairs not in the set are treated as ordinary stderr data, preventing untrusted marker strings from switching archive ownership. After resolving a path, enforce that it resolves under workspace_dir before opening the archive file. This prevents path traversal via crafted marker step names like "../../escape". Resolver and on_output callback exceptions are now isolated: first error is recorded, the failed sink is disabled, and draining continues to EOF so pipe backpressure cannot deadlock the worker. The production CLI route reads flow.json to build the allowlist before starting RunOperation. --- chipcompiler/cli/command_handlers/project.py | 15 ++ chipcompiler/runtime/log_stream.py | 44 ++++- chipcompiler/runtime/worker_operation.py | 4 + test/runtime/test_log_stream.py | 177 +++++++++++++++++++ 4 files changed, 237 insertions(+), 3 deletions(-) diff --git a/chipcompiler/cli/command_handlers/project.py b/chipcompiler/cli/command_handlers/project.py index 3aefd1e2..dbe0507b 100644 --- a/chipcompiler/cli/command_handlers/project.py +++ b/chipcompiler/cli/command_handlers/project.py @@ -204,6 +204,7 @@ def _run_flow_via_worker(workspace_dir: str): Returns an OperationResult. A missing worker binary is a structured failure. """ + import json as json_mod from pathlib import Path from chipcompiler.runtime.worker_operation import ( @@ -220,10 +221,24 @@ def _run_flow_via_worker(workspace_dir: str): ) flow_json_path = Path(workspace_dir) / "home" / "flow.json" + + valid_steps: set[tuple[str, str]] | None = None + try: + with open(flow_json_path) as f: + flow_data = json_mod.load(f) + valid_steps = { + (s["name"], s["tool"]) + for s in flow_data.get("steps", []) + if isinstance(s, dict) and "name" in s and "tool" in s + } + except (OSError, json_mod.JSONDecodeError, KeyError): + pass + op = RunOperation( workspace_dir=Path(workspace_dir), flow_json_path=flow_json_path, log_path_resolver=_workspace_step_log_resolver(workspace_dir), + valid_steps=valid_steps, ) return op.run("flow.run", {"rerun": False}) diff --git a/chipcompiler/runtime/log_stream.py b/chipcompiler/runtime/log_stream.py index ce531439..5f71df08 100644 --- a/chipcompiler/runtime/log_stream.py +++ b/chipcompiler/runtime/log_stream.py @@ -88,11 +88,16 @@ def __init__( log_path_resolver: Callable[[str, str], Path | None] | None = None, on_output: Callable[[bytes], None] | None = None, tail_size: int = 4096, + valid_steps: set[tuple[str, str]] | None = None, + workspace_dir: Path | None = None, ): self._stderr = stderr self._resolve_path = log_path_resolver self._on_output = on_output + self._on_output_disabled = False self._tail_size = tail_size + self._valid_steps = valid_steps + self._workspace_dir = workspace_dir self._state = LogStreamState() self._thread: threading.Thread | None = None self._stop = threading.Event() @@ -152,8 +157,16 @@ def _process_buffer(self, buf: bytes) -> bytes: else: self._emit_data(line) + def _is_allowed_step(self, step: str, tool: str) -> bool: + if self._valid_steps is None: + return True + return (step, tool) in self._valid_steps + def _handle_marker(self, marker: StepMarker, raw_line: bytes) -> None: if marker.event == "begin": + if not self._is_allowed_step(marker.step, marker.tool): + self._emit_data(raw_line) + return if self._state.active_step is None: self._state.active_step = marker.step self._state.active_tool = marker.tool @@ -183,8 +196,13 @@ def _emit_data(self, data: bytes) -> None: self._state.archive_file.close() self._state.archive_file = None self._update_tail(data) - if self._on_output is not None: - self._on_output(data) + if self._on_output is not None and not self._on_output_disabled: + try: + self._on_output(data) + except Exception as exc: + if self._state.error is None: + self._state.error = exc + self._on_output_disabled = True def _update_tail(self, data: bytes) -> None: combined = self._state.tail_bytes + data @@ -195,9 +213,29 @@ def _update_tail(self, data: bytes) -> None: def _open_archive(self, step: str, tool: str) -> None: if self._resolve_path is None: return - path = self._resolve_path(step, tool) + try: + path = self._resolve_path(step, tool) + except Exception as exc: + if self._state.error is None: + self._state.error = exc + return if path is None: return + if self._workspace_dir is not None: + try: + resolved = path.resolve() + workspace_resolved = self._workspace_dir.resolve() + if not ( + resolved == workspace_resolved + or str(resolved).startswith(str(workspace_resolved) + os.sep) + ): + if self._state.error is None: + self._state.error = ValueError(f"archive path escapes workspace: {path}") + return + except (OSError, ValueError) as exc: + if self._state.error is None: + self._state.error = exc + return try: path.parent.mkdir(parents=True, exist_ok=True) self._state.archive_file = path.open("wb") # noqa: SIM115 diff --git a/chipcompiler/runtime/worker_operation.py b/chipcompiler/runtime/worker_operation.py index 0b873883..5979e62d 100644 --- a/chipcompiler/runtime/worker_operation.py +++ b/chipcompiler/runtime/worker_operation.py @@ -65,12 +65,14 @@ def __init__( worker_argv: list[str] | None = None, log_path_resolver: Callable[[str, str], Path | None] | None = None, on_output: Callable[[bytes], None] | None = None, + valid_steps: set[tuple[str, str]] | None = None, ): self._workspace_dir = workspace_dir self._flow_json_path = flow_json_path self._worker_argv = worker_argv or _default_worker_argv() self._log_path_resolver = log_path_resolver self._on_output = on_output + self._valid_steps = valid_steps def run(self, method: str, params: dict, *, request_id: int = 1) -> OperationResult: """Execute one RPC method against the worker and return a typed result. @@ -87,6 +89,8 @@ def run(self, method: str, params: dict, *, request_id: int = 1) -> OperationRes proc.stderr, log_path_resolver=self._log_path_resolver, on_output=self._on_output, + valid_steps=self._valid_steps, + workspace_dir=self._workspace_dir, ) reader.start() diff --git a/test/runtime/test_log_stream.py b/test/runtime/test_log_stream.py index 62da9a7e..d7477e6b 100644 --- a/test/runtime/test_log_stream.py +++ b/test/runtime/test_log_stream.py @@ -283,3 +283,180 @@ def resolver(step, tool): assert b"after\n" in content assert reader.state.active_step is None assert reader.state.steps_seen == ["A"] + + +class TestLogStreamAllowlist: + def test_unknown_step_marker_treated_as_data(self, tmp_path): + """A begin marker for a pair not in valid_steps is archived as data.""" + log_path = tmp_path / "step.log" + + def resolver(step, tool): + return log_path + + valid = {("Synthesis", "yosys")} + unknown_begin = b'\x1eECC-STEP {"event":"begin","step":"../../escape","tool":"evil"}\n' + stream_data = ( + b'\x1eECC-STEP {"event":"begin","step":"Synthesis","tool":"yosys"}\n' + + unknown_begin + + b"normal data\n" + + b'\x1eECC-STEP {"event":"end","step":"Synthesis","tool":"yosys"}\n' + ) + reader = LogStreamReader( + io.BytesIO(stream_data), log_path_resolver=resolver, valid_steps=valid + ) + reader.start() + reader.join(timeout=5) + content = log_path.read_bytes() + assert unknown_begin in content + assert b"normal data\n" in content + assert reader.state.steps_seen == ["Synthesis"] + + def test_unknown_step_before_any_active_is_data(self): + """An unknown begin marker with no active step is sent to callback as data.""" + received = [] + valid = {("Place", "ecc")} + stream_data = b'\x1eECC-STEP {"event":"begin","step":"Bogus","tool":"fake"}\ntrailing\n' + reader = LogStreamReader( + io.BytesIO(stream_data), on_output=received.append, valid_steps=valid + ) + reader.start() + reader.join(timeout=5) + combined = b"".join(received) + assert b"Bogus" in combined + assert b"trailing\n" in combined + assert reader.state.active_step is None + + def test_path_escape_does_not_open_archive(self, tmp_path): + """A resolved path outside workspace_dir must not open an archive file.""" + escape_target = tmp_path / "outside.log" + + def resolver(step, tool): + return escape_target + + workspace = tmp_path / "workspace" + workspace.mkdir() + valid = {("Escape", "evil")} + stream_data = ( + b'\x1eECC-STEP {"event":"begin","step":"Escape","tool":"evil"}\n' + b"should not be written\n" + b'\x1eECC-STEP {"event":"end","step":"Escape","tool":"evil"}\n' + ) + reader = LogStreamReader( + io.BytesIO(stream_data), + log_path_resolver=resolver, + valid_steps=valid, + workspace_dir=workspace, + ) + reader.start() + reader.join(timeout=5) + assert not escape_target.exists() + assert reader.state.error is not None + assert "escapes workspace" in str(reader.state.error) + + def test_contained_path_opens_normally(self, tmp_path): + """A path that resolves inside workspace_dir opens and archives.""" + workspace = tmp_path / "workspace" + workspace.mkdir() + log_path = workspace / "Synthesis_yosys" / "log" / "Synthesis.log" + + def resolver(step, tool): + return log_path + + valid = {("Synthesis", "yosys")} + stream_data = ( + b'\x1eECC-STEP {"event":"begin","step":"Synthesis","tool":"yosys"}\n' + b"tool output\n" + b'\x1eECC-STEP {"event":"end","step":"Synthesis","tool":"yosys"}\n' + ) + reader = LogStreamReader( + io.BytesIO(stream_data), + log_path_resolver=resolver, + valid_steps=valid, + workspace_dir=workspace, + ) + reader.start() + reader.join(timeout=5) + assert log_path.read_bytes() == b"tool output\n" + assert reader.state.error is None + + +class TestLogStreamResilience: + def test_resolver_exception_disables_archive_continues_drain(self): + """A resolver that raises must not kill the drain thread.""" + received = [] + call_count = [0] + + def failing_resolver(step, tool): + call_count[0] += 1 + raise RuntimeError("resolver failed") + + stream_data = ( + b'\x1eECC-STEP {"event":"begin","step":"S","tool":"T"}\n' + b"output after failed resolver\n" + b'\x1eECC-STEP {"event":"end","step":"S","tool":"T"}\n' + b"trailing data\n" + ) + reader = LogStreamReader( + io.BytesIO(stream_data), + log_path_resolver=failing_resolver, + on_output=received.append, + ) + reader.start() + reader.join(timeout=5) + assert reader.completed + assert isinstance(reader.state.error, RuntimeError) + assert "resolver failed" in str(reader.state.error) + combined = b"".join(received) + assert b"output after failed resolver\n" in combined + assert b"trailing data\n" in combined + + def test_callback_exception_disables_callback_continues_drain(self, tmp_path): + """An on_output callback that raises must not kill archiving.""" + log_path = tmp_path / "step.log" + call_count = [0] + + def failing_callback(data): + call_count[0] += 1 + if call_count[0] == 1: + raise ValueError("callback exploded") + + def resolver(step, tool): + return log_path + + stream_data = ( + b'\x1eECC-STEP {"event":"begin","step":"S","tool":"T"}\n' + b"line 1\n" + b"line 2\n" + b'\x1eECC-STEP {"event":"end","step":"S","tool":"T"}\n' + ) + reader = LogStreamReader( + io.BytesIO(stream_data), + log_path_resolver=resolver, + on_output=failing_callback, + ) + reader.start() + reader.join(timeout=5) + assert reader.completed + assert isinstance(reader.state.error, ValueError) + content = log_path.read_bytes() + assert b"line 1\n" in content + assert b"line 2\n" in content + + def test_drain_completes_after_callback_disabled(self): + """After callback is disabled, remaining data is still drained.""" + call_count = [0] + + def failing_callback(data): + call_count[0] += 1 + raise RuntimeError("always fails") + + stream_data = b"line 1\nline 2\nline 3\n" + reader = LogStreamReader( + io.BytesIO(stream_data), + on_output=failing_callback, + ) + reader.start() + reader.join(timeout=5) + assert reader.completed + assert call_count[0] == 1 + assert b"line 3\n" in reader.state.tail_bytes From ebdb49291b152712fdd2c95f4be1d1ed0b557fbe Mon Sep 17 00:00:00 2001 From: Emin Date: Wed, 12 Aug 2026 10:13:08 +0800 Subject: [PATCH 11/52] chore: drop dead redirect_stdout_to_stderr orphaned by stdio isolation --- chipcompiler/runtime/events.py | 51 ---------------------------------- chipcompiler/runtime/worker.py | 7 ----- test/runtime/test_events.py | 35 ----------------------- 3 files changed, 93 deletions(-) delete mode 100644 chipcompiler/runtime/events.py delete mode 100644 test/runtime/test_events.py diff --git a/chipcompiler/runtime/events.py b/chipcompiler/runtime/events.py deleted file mode 100644 index de7aaff9..00000000 --- a/chipcompiler/runtime/events.py +++ /dev/null @@ -1,51 +0,0 @@ -import os -import sys -from contextlib import contextmanager, suppress - - -@contextmanager -def redirect_stdout_to_stderr(): - saved_stdout = sys.stdout - saved_stderr = sys.stderr - saved_stdout_fd = None - saved_stderr_fd = None - - with suppress(Exception): - sys.stdout.flush() - with suppress(Exception): - sys.stderr.flush() - - try: - saved_stdout_fd = os.dup(1) - saved_stderr_fd = os.dup(2) - os.dup2(2, 1) - sys.stdout = sys.stderr - except OSError: - with suppress(Exception): - if saved_stdout_fd is not None: - os.close(saved_stdout_fd) - if saved_stderr_fd is not None: - os.close(saved_stderr_fd) - sys.stdout = sys.stderr - try: - yield - finally: - sys.stdout = saved_stdout - sys.stderr = saved_stderr - return - - try: - yield - finally: - with suppress(Exception): - sys.stdout.flush() - with suppress(Exception): - sys.stderr.flush() - try: - os.dup2(saved_stdout_fd, 1) - os.dup2(saved_stderr_fd, 2) - finally: - os.close(saved_stdout_fd) - os.close(saved_stderr_fd) - sys.stdout = saved_stdout - sys.stderr = saved_stderr diff --git a/chipcompiler/runtime/worker.py b/chipcompiler/runtime/worker.py index 5d55cc80..3acbd376 100644 --- a/chipcompiler/runtime/worker.py +++ b/chipcompiler/runtime/worker.py @@ -6,7 +6,6 @@ from contextlib import suppress from dataclasses import dataclass from pathlib import Path -from typing import BinaryIO from chipcompiler.runtime.transport import ( ContentLengthDecoder, @@ -59,12 +58,6 @@ def start(self) -> subprocess.Popen: def process(self) -> subprocess.Popen | None: return self._process - @property - def stderr(self) -> BinaryIO | None: - if self._process is None: - return None - return self._process.stderr - def send_request(self, method: str, params: dict, request_id: int = 1) -> None: if self._process is None or self._process.stdin is None: raise WorkerProcessError("worker not started") diff --git a/test/runtime/test_events.py b/test/runtime/test_events.py deleted file mode 100644 index 971ee5f3..00000000 --- a/test/runtime/test_events.py +++ /dev/null @@ -1,35 +0,0 @@ -import os -import sys - -from chipcompiler.runtime.events import redirect_stdout_to_stderr - - -def test_redirect_stdout_to_stderr_restores_fd_1_and_fd_2(tmp_path): - stdout_target = tmp_path / "stdout.txt" - stderr_target = tmp_path / "stderr.txt" - redirected_target = tmp_path / "redirected.txt" - - saved_stdout_fd = os.dup(1) - saved_stderr_fd = os.dup(2) - with open(stdout_target, "wb") as stdout_file, open(stderr_target, "wb") as stderr_file: - os.dup2(stdout_file.fileno(), 1) - os.dup2(stderr_file.fileno(), 2) - try: - with redirect_stdout_to_stderr(), open(redirected_target, "wb") as redirected_file: - os.dup2(redirected_file.fileno(), 2) - os.write(1, b"captured stdout\n") - os.write(2, b"captured stderr\n") - - os.write(1, b"restored stdout\n") - os.write(2, b"restored stderr\n") - finally: - os.dup2(saved_stdout_fd, 1) - os.dup2(saved_stderr_fd, 2) - os.close(saved_stdout_fd) - os.close(saved_stderr_fd) - sys.stdout = sys.__stdout__ - sys.stderr = sys.__stderr__ - - assert stdout_target.read_text() == "restored stdout\n" - assert stderr_target.read_text() == "captured stdout\nrestored stderr\n" - assert redirected_target.read_text() == "captured stderr\n" From 6a116f350d6ff529065cb4b035ab60d10e428f66 Mon Sep 17 00:00:00 2001 From: Emin Date: Wed, 12 Aug 2026 10:22:44 +0800 Subject: [PATCH 12/52] chore: drop unused log helpers from utility.log --- chipcompiler/utility/__init__.py | 6 ---- chipcompiler/utility/log.py | 59 -------------------------------- 2 files changed, 65 deletions(-) diff --git a/chipcompiler/utility/__init__.py b/chipcompiler/utility/__init__.py index abf98fb8..e51567aa 100644 --- a/chipcompiler/utility/__init__.py +++ b/chipcompiler/utility/__init__.py @@ -10,11 +10,8 @@ from .json import JsonReadError, dict_to_str, json_read, json_read_strict, json_write from .log import ( Logger, - build_timestamped_log_file, create_logger, - init_api_runtime_log, redirect_stdio_to_file, - rotate_log_on_start, ) from .plot import plot_bar_chart, plot_csv_bar_chart, plot_csv_map, plot_csv_table, plot_metrics from .util import track_process_memory @@ -28,10 +25,7 @@ "dict_to_str", "Logger", "create_logger", - "build_timestamped_log_file", - "rotate_log_on_start", "redirect_stdio_to_file", - "init_api_runtime_log", "track_process_memory", "plot_csv_map", "plot_metrics", diff --git a/chipcompiler/utility/log.py b/chipcompiler/utility/log.py index 12578add..0c434389 100644 --- a/chipcompiler/utility/log.py +++ b/chipcompiler/utility/log.py @@ -6,57 +6,11 @@ import sys import time from contextlib import suppress -from datetime import datetime from logging.handlers import RotatingFileHandler from typing import TextIO # TODO: Move some functions to Logger Module -def build_timestamped_log_file(log_file: str, pid: int | None = None) -> str: - """ - Build a timestamped log file path from a base path. - Example: - /tmp/chipcompiler-api-server.log - -> /tmp/chipcompiler-api-server-20260211-090428-12345.log - """ - resolved_path = os.path.abspath(os.path.expanduser(log_file)) - base_dir = os.path.dirname(resolved_path) or "." - base_name = os.path.basename(resolved_path) - stem, ext = os.path.splitext(base_name) - - timestamp = datetime.now().strftime("%Y%m%d-%H%M%S") - pid_value = os.getpid() if pid is None else pid - - file_name = f"{stem}-{timestamp}-{pid_value}{ext}" if ext else f"{stem}-{timestamp}-{pid_value}" - - return os.path.join(base_dir, file_name) - - -def rotate_log_on_start(log_file: str, max_bytes: int, backup_count: int) -> None: - """Rotate log file at startup if it exceeds max_bytes.""" - if max_bytes <= 0 or not os.path.exists(log_file): - return - try: - if os.path.getsize(log_file) < max_bytes: - return - except OSError: - return - - if backup_count <= 0: - os.remove(log_file) - return - - # Shift existing backups: .5 -> delete, .4 -> .5, ... .1 -> .2 - oldest = f"{log_file}.{backup_count}" - if os.path.exists(oldest): - os.remove(oldest) - for i in range(backup_count - 1, 0, -1): - src, dst = f"{log_file}.{i}", f"{log_file}.{i + 1}" - if os.path.exists(src): - os.replace(src, dst) - os.replace(log_file, f"{log_file}.1") - - def flush_cstdio() -> None: """Flush C stdio buffers (printf/std::cout/glog) that Python-level flushes miss. @@ -85,19 +39,6 @@ def redirect_stdio_to_file(log_file: str) -> TextIO: return log_stream -def init_api_runtime_log( - log_file: str, - max_bytes: int = 20 * 1024 * 1024, - backup_count: int = 5, -) -> str: - """Initialize API runtime logging: rotate if needed, redirect stdio.""" - resolved = os.path.abspath(os.path.expanduser(log_file)) - os.makedirs(os.path.dirname(resolved) or ".", exist_ok=True) - rotate_log_on_start(resolved, max_bytes, backup_count) - redirect_stdio_to_file(resolved) - return resolved - - class Logger: def __init__( self, From a1e53f120049b088c0a923354f6511735484d36f Mon Sep 17 00:00:00 2001 From: Emin Date: Wed, 12 Aug 2026 10:51:29 +0800 Subject: [PATCH 13/52] fix(test): make worker tests robust under root and non-reaping init --- test/runtime/test_worker.py | 76 +++++++++++++-------------- test/runtime/test_worker_operation.py | 20 ++++--- 2 files changed, 47 insertions(+), 49 deletions(-) diff --git a/test/runtime/test_worker.py b/test/runtime/test_worker.py index de87669a..dcdbb408 100644 --- a/test/runtime/test_worker.py +++ b/test/runtime/test_worker.py @@ -1,9 +1,12 @@ """Tests for chipcompiler.runtime.worker — lifecycle, signal handling, and state repair.""" import json +import os import signal import sys import textwrap +import time +from pathlib import Path from unittest.mock import MagicMock import pytest @@ -17,6 +20,20 @@ ) +def _pid_alive(pid: int) -> bool: + try: + os.kill(pid, 0) + except OSError: + return False + # A container PID 1 that does not reap leaves killed children as zombies, + # and os.kill(pid, 0) still succeeds for zombies. + try: + stat = Path(f"/proc/{pid}/stat").read_text() + except OSError: + return False + return stat.rpartition(")")[2].split()[0] != "Z" + + class TestWorkerResult: def test_success_result(self): r = WorkerResult(success=True, response={"result": {"ok": True}}) @@ -130,7 +147,7 @@ def test_scoped_repair_step_not_ongoing(self, tmp_path): repaired = repair_flow_state(flow_json, active_step="Synthesis") assert repaired == [] - def test_write_failure_raises_oserror(self, tmp_path): + def test_write_failure_raises_oserror(self, tmp_path, monkeypatch): flow_json = tmp_path / "flow.json" data = { "steps": [ @@ -138,14 +155,11 @@ def test_write_failure_raises_oserror(self, tmp_path): ] } flow_json.write_text(json.dumps(data)) - flow_json.chmod(0o444) - tmp_path.chmod(0o555) - try: - with pytest.raises(OSError, match="failed to persist"): - repair_flow_state(flow_json, active_step="A") - finally: - tmp_path.chmod(0o755) - flow_json.chmod(0o644) + # chmod-based read-only setups are bypassed when tests run as root, + # so simulate the persist failure directly. + monkeypatch.setattr("chipcompiler.runtime.worker.json_write", lambda *a, **k: False) + with pytest.raises(OSError, match="failed to persist"): + repair_flow_state(flow_json, active_step="A") class TestWorkerClientSubprocess: @@ -359,21 +373,16 @@ def test_terminate_kills_orphaned_child(self): """) client = WorkerClient([sys.executable, "-c", script]) proc = client.start() - import time time.sleep(0.3) child_pid_line = proc.stdout.readline() child_pid = int(child_pid_line.strip()) client.terminate() time.sleep(0.2) - import os - try: - os.kill(child_pid, 0) - alive = True - except OSError: - alive = False - assert not alive, "orphaned child should have been killed by process-group signal" + assert not _pid_alive(child_pid), ( + "orphaned child should have been killed by process-group signal" + ) def test_out_of_order_responses_preserved(self): """Responses arriving in reverse order must all be retrievable.""" @@ -433,21 +442,16 @@ def test_leader_exits_during_sigint_descendant_killed(self): """) client = WorkerClient([sys.executable, "-c", script]) proc = client.start() - import time time.sleep(0.3) child_pid_line = proc.stdout.readline() child_pid = int(child_pid_line.strip()) client.terminate() time.sleep(0.5) - import os - try: - os.kill(child_pid, 0) - alive = True - except OSError: - alive = False - assert not alive, "descendant ignoring SIGINT should still be killed by SIGTERM/SIGKILL" + assert not _pid_alive(child_pid), ( + "descendant ignoring SIGINT should still be killed by SIGTERM/SIGKILL" + ) def test_descendant_graceful_sigterm_exit(self): """Descendant handles SIGTERM and exits within grace window — no SIGKILL needed.""" @@ -472,22 +476,15 @@ def handle_term(*a): """) client = WorkerClient([sys.executable, "-c", script]) proc = client.start() - import time time.sleep(0.3) child_pid_line = proc.stdout.readline() child_pid = int(child_pid_line.strip()) client.terminate() time.sleep(0.5) - import os marker = f"/tmp/ecc-test-grace-{child_pid}" - try: - os.kill(child_pid, 0) - alive = True - except OSError: - alive = False - assert not alive, "descendant should have exited on SIGTERM" + assert not _pid_alive(child_pid), "descendant should have exited on SIGTERM" assert os.path.exists(marker), "descendant SIGTERM handler should have run (not SIGKILL'd)" os.unlink(marker) @@ -498,20 +495,23 @@ def test_graceful_terminate_completes_before_forceful_deadline(self): pid = os.fork() if pid == 0: signal.signal(signal.SIGINT, signal.SIG_IGN) - def handle_term(*a): - os._exit(0) - signal.signal(signal.SIGTERM, handle_term) + signal.signal(signal.SIGTERM, lambda *a: os._exit(0)) time.sleep(60) os._exit(0) else: sys.stdout.buffer.write(f"{pid}\\n".encode()) sys.stdout.buffer.flush() - signal.signal(signal.SIGINT, lambda *a: os._exit(0)) + signal.signal(signal.SIGINT, signal.SIG_IGN) + # Reap the child before exiting so no zombie keeps the process + # group visible to killpg under a non-reaping PID 1. + def handle_term(*a): + os.waitpid(pid, 0) + os._exit(0) + signal.signal(signal.SIGTERM, handle_term) time.sleep(60) """) client = WorkerClient([sys.executable, "-c", script]) proc = client.start() - import time time.sleep(0.3) proc.stdout.readline() diff --git a/test/runtime/test_worker_operation.py b/test/runtime/test_worker_operation.py index 390d1bb7..3d73ee6c 100644 --- a/test/runtime/test_worker_operation.py +++ b/test/runtime/test_worker_operation.py @@ -193,12 +193,13 @@ def test_archive_error_forces_failure(self, tmp_path): """) ) - readonly_dir = tmp_path / "readonly_logs" - readonly_dir.mkdir() - readonly_dir.chmod(0o444) + # A regular file as path component makes mkdir fail with ENOTDIR even + # when tests run as root (chmod-based read-only setups are bypassed). + blocker = tmp_path / "not_a_dir" + blocker.write_text("regular file") def bad_resolver(step: str, tool: str): - return readonly_dir / "sub" / f"{step}.log" + return blocker / "sub" / f"{step}.log" flow_json = tmp_path / "flow.json" flow_json.write_text("{}") @@ -208,13 +209,10 @@ def bad_resolver(step: str, tool: str): worker_argv=[sys.executable, str(script)], log_path_resolver=bad_resolver, ) - try: - result = op.run("flow.run", {"workspace_id": "test"}) - assert result.success is False - assert result.archive_error is not None - assert "archive error" in result.error - finally: - readonly_dir.chmod(0o755) + result = op.run("flow.run", {"workspace_id": "test"}) + assert result.success is False + assert result.archive_error is not None + assert "archive error" in result.error def test_stderr_archived_to_step_log(self, tmp_path): script = tmp_path / "server_with_markers.py" From 978379813fd6e166a5120e3399bd5856dfddd2f9 Mon Sep 17 00:00:00 2001 From: Emin Date: Tue, 18 Aug 2026 16:50:59 +0800 Subject: [PATCH 14/52] feat(runtime): version step markers and emit end after step-scoped writes Add a protocol version field (v: 1) to step marker payloads; parsers reject frames with a missing or unsupported version as ordinary bytes. Relocate the end marker in EngineFlow.run_step so it fires after all step-scoped writes (final state persistence, [RESULT], QOR, layout snapshot, db cleanup) and before the completion observer notification, so a consumer that has read the end marker has seen every byte of the step. --- chipcompiler/engine/flow.py | 5 +- chipcompiler/runtime/log_stream.py | 12 ++- test/runtime/test_log_stream.py | 150 ++++++++++++++++++-------- test/runtime/test_worker_operation.py | 2 +- test/test_engine_flow.py | 57 ++++++++++ 5 files changed, 176 insertions(+), 50 deletions(-) diff --git a/chipcompiler/engine/flow.py b/chipcompiler/engine/flow.py index 192d78f2..baf857ae 100644 --- a/chipcompiler/engine/flow.py +++ b/chipcompiler/engine/flow.py @@ -517,7 +517,6 @@ def run_step( delattr(self.workspace, "_runtime_flow_observer") else: self.workspace._runtime_flow_observer = previous_observer - emit_step_marker("end", step=workspace_step.name, tool=workspace_step.tool) # compute metrics peak_memory_mb = peak_memory[0] - start_memory_mb @@ -583,6 +582,10 @@ def run_step( save_layout_image(workspace=self.workspace, step=workspace_step) self.clear_db_engine_after_step(workspace_step, state) + # The end marker closes the step's byte stream only after every + # step-scoped write (state persistence, [RESULT], QOR, layout, db + # cleanup) has flushed, and always before the completion notification. + emit_step_marker("end", step=workspace_step.name, tool=workspace_step.tool) _notify_flow_observer(observer, "on_step_completed", workspace_step, state) if state == StateEnum.Success and not _wait_for_step_rendered( observer, diff --git a/chipcompiler/runtime/log_stream.py b/chipcompiler/runtime/log_stream.py index 5f71df08..e0f4486e 100644 --- a/chipcompiler/runtime/log_stream.py +++ b/chipcompiler/runtime/log_stream.py @@ -1,8 +1,8 @@ """Step marker protocol and log stream archive. The worker emits step markers on stderr using a Record Separator prefix: - \\x1eECC-STEP {"event":"begin","step":"Synthesis","tool":"yosys"}\\n - \\x1eECC-STEP {"event":"end","step":"Synthesis","tool":"yosys"}\\n + \\x1eECC-STEP {"v":1,"event":"begin","step":"Synthesis","tool":"yosys"}\\n + \\x1eECC-STEP {"v":1,"event":"end","step":"Synthesis","tool":"yosys"}\\n The client-side LogStreamReader drains worker stderr, parses markers to switch between step log files, and archives raw tool bytes to the correct @@ -19,6 +19,7 @@ from typing import BinaryIO MARKER_PREFIX = b"\x1eECC-STEP " +MARKER_VERSION = 1 @dataclass @@ -34,7 +35,10 @@ def emit_step_marker(event: str, step: str, tool: str) -> None: sys.stdout.flush() sys.stderr.flush() - payload = json.dumps({"event": event, "step": step, "tool": tool}, separators=(",", ":")) + payload = json.dumps( + {"v": MARKER_VERSION, "event": event, "step": step, "tool": tool}, + separators=(",", ":"), + ) line = MARKER_PREFIX + payload.encode("utf-8") + b"\n" os.write(2, line) @@ -52,6 +56,8 @@ def parse_marker(line: bytes) -> StepMarker | None: return None if not isinstance(data, dict): return None + if data.get("v") != MARKER_VERSION: + return None event = data.get("event") step = data.get("step") tool = data.get("tool") diff --git a/test/runtime/test_log_stream.py b/test/runtime/test_log_stream.py index d7477e6b..03f23403 100644 --- a/test/runtime/test_log_stream.py +++ b/test/runtime/test_log_stream.py @@ -1,22 +1,25 @@ """Tests for chipcompiler.runtime.log_stream — marker parsing and archive.""" import io +import os from chipcompiler.runtime.log_stream import ( + MARKER_PREFIX, LogStreamReader, StepMarker, + emit_step_marker, parse_marker, ) class TestParseMarker: def test_valid_begin(self): - line = b'\x1eECC-STEP {"event":"begin","step":"Synthesis","tool":"yosys"}\n' + line = b'\x1eECC-STEP {"v":1,"event":"begin","step":"Synthesis","tool":"yosys"}\n' m = parse_marker(line) assert m == StepMarker(event="begin", step="Synthesis", tool="yosys") def test_valid_end(self): - line = b'\x1eECC-STEP {"event":"end","step":"Placement","tool":"ecc"}\n' + line = b'\x1eECC-STEP {"v":1,"event":"end","step":"Placement","tool":"ecc"}\n' m = parse_marker(line) assert m == StepMarker(event="end", step="Placement", tool="ecc") @@ -27,19 +30,51 @@ def test_malformed_json(self): assert parse_marker(b"\x1eECC-STEP {bad json}\n") is None def test_missing_fields(self): - line = b'\x1eECC-STEP {"event":"begin"}\n' + line = b'\x1eECC-STEP {"v":1,"event":"begin"}\n' assert parse_marker(line) is None def test_wrong_field_types(self): - line = b'\x1eECC-STEP {"event":1,"step":"A","tool":"B"}\n' + line = b'\x1eECC-STEP {"v":1,"event":1,"step":"A","tool":"B"}\n' assert parse_marker(line) is None def test_no_trailing_newline(self): - line = b'\x1eECC-STEP {"event":"begin","step":"S","tool":"T"}' + line = b'\x1eECC-STEP {"v":1,"event":"begin","step":"S","tool":"T"}' m = parse_marker(line) assert m is not None assert m.event == "begin" + def test_missing_version_rejected(self): + line = b'\x1eECC-STEP {"event":"begin","step":"S","tool":"T"}\n' + assert parse_marker(line) is None + + def test_unsupported_version_rejected(self): + line = b'\x1eECC-STEP {"v":2,"event":"begin","step":"S","tool":"T"}\n' + assert parse_marker(line) is None + + def test_string_version_rejected(self): + line = b'\x1eECC-STEP {"v":"1","event":"begin","step":"S","tool":"T"}\n' + assert parse_marker(line) is None + + +class TestEmitStepMarker: + def test_payload_carries_version_and_round_trips(self, monkeypatch): + written = [] + real_write = os.write + + def fake_write(fd, data): + if fd == 2: + written.append(data) + return len(data) + return real_write(fd, data) + + monkeypatch.setattr(os, "write", fake_write) + emit_step_marker("begin", step="Synthesis", tool="yosys") + + assert written == [ + MARKER_PREFIX + b'{"v":1,"event":"begin","step":"Synthesis","tool":"yosys"}\n' + ] + assert parse_marker(written[0]) == StepMarker(event="begin", step="Synthesis", tool="yosys") + class TestLogStreamReader: def _make_stream(self, chunks: list[bytes]) -> io.BytesIO: @@ -52,10 +87,10 @@ def resolver(step, tool): return log_path stream_data = ( - b'\x1eECC-STEP {"event":"begin","step":"Synthesis","tool":"yosys"}\n' + b'\x1eECC-STEP {"v":1,"event":"begin","step":"Synthesis","tool":"yosys"}\n' b"yosys output line 1\n" b"yosys output line 2\n" - b'\x1eECC-STEP {"event":"end","step":"Synthesis","tool":"yosys"}\n' + b'\x1eECC-STEP {"v":1,"event":"end","step":"Synthesis","tool":"yosys"}\n' ) stream = io.BytesIO(stream_data) reader = LogStreamReader(stream, log_path_resolver=resolver) @@ -75,9 +110,9 @@ def resolver(step, tool): return log_path stream_data = ( - b'\x1eECC-STEP {"event":"begin","step":"S","tool":"T"}\n' + b'\x1eECC-STEP {"v":1,"event":"begin","step":"S","tool":"T"}\n' b"data\n" - b'\x1eECC-STEP {"event":"end","step":"S","tool":"T"}\n' + b'\x1eECC-STEP {"v":1,"event":"end","step":"S","tool":"T"}\n' ) reader = LogStreamReader(io.BytesIO(stream_data), log_path_resolver=resolver) reader.start() @@ -93,12 +128,12 @@ def resolver(step, tool): return p stream_data = ( - b'\x1eECC-STEP {"event":"begin","step":"A","tool":"t"}\n' + b'\x1eECC-STEP {"v":1,"event":"begin","step":"A","tool":"t"}\n' b"output A\n" - b'\x1eECC-STEP {"event":"end","step":"A","tool":"t"}\n' - b'\x1eECC-STEP {"event":"begin","step":"B","tool":"t"}\n' + b'\x1eECC-STEP {"v":1,"event":"end","step":"A","tool":"t"}\n' + b'\x1eECC-STEP {"v":1,"event":"begin","step":"B","tool":"t"}\n' b"output B\n" - b'\x1eECC-STEP {"event":"end","step":"B","tool":"t"}\n' + b'\x1eECC-STEP {"v":1,"event":"end","step":"B","tool":"t"}\n' ) reader = LogStreamReader(io.BytesIO(stream_data), log_path_resolver=resolver) reader.start() @@ -115,9 +150,9 @@ def resolver(step, tool): raw = b"\x80\x81\xff\xfe binary data\n" stream_data = ( - b'\x1eECC-STEP {"event":"begin","step":"S","tool":"T"}\n' + b'\x1eECC-STEP {"v":1,"event":"begin","step":"S","tool":"T"}\n' + raw - + b'\x1eECC-STEP {"event":"end","step":"S","tool":"T"}\n' + + b'\x1eECC-STEP {"v":1,"event":"end","step":"S","tool":"T"}\n' ) reader = LogStreamReader(io.BytesIO(stream_data), log_path_resolver=resolver) reader.start() @@ -131,9 +166,9 @@ def resolver(step, tool): return log_path stream_data = ( - b'\x1eECC-STEP {"event":"begin","step":"S","tool":"T"}\n' + b'\x1eECC-STEP {"v":1,"event":"begin","step":"S","tool":"T"}\n' b"\x1eECC-STEP {bad json}\n" - b'\x1eECC-STEP {"event":"end","step":"S","tool":"T"}\n' + b'\x1eECC-STEP {"v":1,"event":"end","step":"S","tool":"T"}\n' ) reader = LogStreamReader(io.BytesIO(stream_data), log_path_resolver=resolver) reader.start() @@ -162,17 +197,36 @@ def test_unknown_marker_event_archived_as_data(self, tmp_path): def resolver(step, tool): return log_path - unknown_line = b'\x1eECC-STEP {"event":"pause","step":"S","tool":"T"}\n' + unknown_line = b'\x1eECC-STEP {"v":1,"event":"pause","step":"S","tool":"T"}\n' stream_data = ( - b'\x1eECC-STEP {"event":"begin","step":"S","tool":"T"}\n' + b'\x1eECC-STEP {"v":1,"event":"begin","step":"S","tool":"T"}\n' + unknown_line - + b'\x1eECC-STEP {"event":"end","step":"S","tool":"T"}\n' + + b'\x1eECC-STEP {"v":1,"event":"end","step":"S","tool":"T"}\n' ) reader = LogStreamReader(io.BytesIO(stream_data), log_path_resolver=resolver) reader.start() reader.join(timeout=5) assert log_path.read_bytes() == unknown_line + def test_unversioned_marker_archived_as_data(self, tmp_path): + """A marker frame without a supported version is archived as raw data.""" + log_path = tmp_path / "step.log" + + def resolver(step, tool): + return log_path + + unversioned_begin = b'\x1eECC-STEP {"event":"begin","step":"S","tool":"T"}\n' + stream_data = ( + b'\x1eECC-STEP {"v":1,"event":"begin","step":"S","tool":"T"}\n' + + unversioned_begin + + b'\x1eECC-STEP {"v":1,"event":"end","step":"S","tool":"T"}\n' + ) + reader = LogStreamReader(io.BytesIO(stream_data), log_path_resolver=resolver) + reader.start() + reader.join(timeout=5) + assert log_path.read_bytes() == unversioned_begin + assert reader.state.steps_seen == ["S"] + def test_archive_write_error_surfaces_in_state(self, tmp_path): """An OSError during archive write must be captured in state.error.""" log_path = tmp_path / "step.log" @@ -181,9 +235,9 @@ def resolver(step, tool): return log_path stream_data = ( - b'\x1eECC-STEP {"event":"begin","step":"S","tool":"T"}\n' + b'\x1eECC-STEP {"v":1,"event":"begin","step":"S","tool":"T"}\n' b"some output\n" - b'\x1eECC-STEP {"event":"end","step":"S","tool":"T"}\n' + b'\x1eECC-STEP {"v":1,"event":"end","step":"S","tool":"T"}\n' ) reader = LogStreamReader(io.BytesIO(stream_data), log_path_resolver=resolver) reader.start() @@ -193,9 +247,9 @@ def resolver(step, tool): log_path.mkdir() stream_data2 = ( - b'\x1eECC-STEP {"event":"begin","step":"S","tool":"T"}\n' + b'\x1eECC-STEP {"v":1,"event":"begin","step":"S","tool":"T"}\n' b"more output\n" - b'\x1eECC-STEP {"event":"end","step":"S","tool":"T"}\n' + b'\x1eECC-STEP {"v":1,"event":"end","step":"S","tool":"T"}\n' ) reader2 = LogStreamReader(io.BytesIO(stream_data2), log_path_resolver=resolver) reader2.start() @@ -212,9 +266,9 @@ def resolver(step, tool): (tmp_path / "nonexistent_dir").write_text("not a directory") stream_data = ( - b'\x1eECC-STEP {"event":"begin","step":"S","tool":"T"}\n' + b'\x1eECC-STEP {"v":1,"event":"begin","step":"S","tool":"T"}\n' b"output\n" - b'\x1eECC-STEP {"event":"end","step":"S","tool":"T"}\n' + b'\x1eECC-STEP {"v":1,"event":"end","step":"S","tool":"T"}\n' ) reader = LogStreamReader(io.BytesIO(stream_data), log_path_resolver=resolver) reader.start() @@ -229,13 +283,13 @@ def test_mismatched_end_marker_does_not_close_archive(self, tmp_path): def resolver(step, tool): return log_path - mismatched_end = b'\x1eECC-STEP {"event":"end","step":"B","tool":"T"}\n' + mismatched_end = b'\x1eECC-STEP {"v":1,"event":"end","step":"B","tool":"T"}\n' stream_data = ( - b'\x1eECC-STEP {"event":"begin","step":"A","tool":"T"}\n' + b'\x1eECC-STEP {"v":1,"event":"begin","step":"A","tool":"T"}\n' b"before\n" + mismatched_end + b"after\n" - + b'\x1eECC-STEP {"event":"end","step":"A","tool":"T"}\n' + + b'\x1eECC-STEP {"v":1,"event":"end","step":"A","tool":"T"}\n' ) reader = LogStreamReader(io.BytesIO(stream_data), log_path_resolver=resolver) reader.start() @@ -252,7 +306,9 @@ def test_active_step_tracked_in_state(self, tmp_path): def resolver(step, tool): return log_path - stream_data = b'\x1eECC-STEP {"event":"begin","step":"Synthesis","tool":"yosys"}\ndata\n' + stream_data = ( + b'\x1eECC-STEP {"v":1,"event":"begin","step":"Synthesis","tool":"yosys"}\ndata\n' + ) reader = LogStreamReader(io.BytesIO(stream_data), log_path_resolver=resolver) reader.start() reader.join(timeout=5) @@ -266,13 +322,13 @@ def test_duplicate_begin_does_not_switch_archive(self, tmp_path): def resolver(step, tool): return log_path - begin_b = b'\x1eECC-STEP {"event":"begin","step":"B","tool":"T"}\n' + begin_b = b'\x1eECC-STEP {"v":1,"event":"begin","step":"B","tool":"T"}\n' stream_data = ( - b'\x1eECC-STEP {"event":"begin","step":"A","tool":"T"}\n' + b'\x1eECC-STEP {"v":1,"event":"begin","step":"A","tool":"T"}\n' b"before\n" + begin_b + b"after\n" - + b'\x1eECC-STEP {"event":"end","step":"A","tool":"T"}\n' + + b'\x1eECC-STEP {"v":1,"event":"end","step":"A","tool":"T"}\n' ) reader = LogStreamReader(io.BytesIO(stream_data), log_path_resolver=resolver) reader.start() @@ -294,12 +350,14 @@ def resolver(step, tool): return log_path valid = {("Synthesis", "yosys")} - unknown_begin = b'\x1eECC-STEP {"event":"begin","step":"../../escape","tool":"evil"}\n' + unknown_begin = ( + b'\x1eECC-STEP {"v":1,"event":"begin","step":"../../escape","tool":"evil"}\n' + ) stream_data = ( - b'\x1eECC-STEP {"event":"begin","step":"Synthesis","tool":"yosys"}\n' + b'\x1eECC-STEP {"v":1,"event":"begin","step":"Synthesis","tool":"yosys"}\n' + unknown_begin + b"normal data\n" - + b'\x1eECC-STEP {"event":"end","step":"Synthesis","tool":"yosys"}\n' + + b'\x1eECC-STEP {"v":1,"event":"end","step":"Synthesis","tool":"yosys"}\n' ) reader = LogStreamReader( io.BytesIO(stream_data), log_path_resolver=resolver, valid_steps=valid @@ -315,7 +373,9 @@ def test_unknown_step_before_any_active_is_data(self): """An unknown begin marker with no active step is sent to callback as data.""" received = [] valid = {("Place", "ecc")} - stream_data = b'\x1eECC-STEP {"event":"begin","step":"Bogus","tool":"fake"}\ntrailing\n' + stream_data = ( + b'\x1eECC-STEP {"v":1,"event":"begin","step":"Bogus","tool":"fake"}\ntrailing\n' + ) reader = LogStreamReader( io.BytesIO(stream_data), on_output=received.append, valid_steps=valid ) @@ -337,9 +397,9 @@ def resolver(step, tool): workspace.mkdir() valid = {("Escape", "evil")} stream_data = ( - b'\x1eECC-STEP {"event":"begin","step":"Escape","tool":"evil"}\n' + b'\x1eECC-STEP {"v":1,"event":"begin","step":"Escape","tool":"evil"}\n' b"should not be written\n" - b'\x1eECC-STEP {"event":"end","step":"Escape","tool":"evil"}\n' + b'\x1eECC-STEP {"v":1,"event":"end","step":"Escape","tool":"evil"}\n' ) reader = LogStreamReader( io.BytesIO(stream_data), @@ -364,9 +424,9 @@ def resolver(step, tool): valid = {("Synthesis", "yosys")} stream_data = ( - b'\x1eECC-STEP {"event":"begin","step":"Synthesis","tool":"yosys"}\n' + b'\x1eECC-STEP {"v":1,"event":"begin","step":"Synthesis","tool":"yosys"}\n' b"tool output\n" - b'\x1eECC-STEP {"event":"end","step":"Synthesis","tool":"yosys"}\n' + b'\x1eECC-STEP {"v":1,"event":"end","step":"Synthesis","tool":"yosys"}\n' ) reader = LogStreamReader( io.BytesIO(stream_data), @@ -391,9 +451,9 @@ def failing_resolver(step, tool): raise RuntimeError("resolver failed") stream_data = ( - b'\x1eECC-STEP {"event":"begin","step":"S","tool":"T"}\n' + b'\x1eECC-STEP {"v":1,"event":"begin","step":"S","tool":"T"}\n' b"output after failed resolver\n" - b'\x1eECC-STEP {"event":"end","step":"S","tool":"T"}\n' + b'\x1eECC-STEP {"v":1,"event":"end","step":"S","tool":"T"}\n' b"trailing data\n" ) reader = LogStreamReader( @@ -424,10 +484,10 @@ def resolver(step, tool): return log_path stream_data = ( - b'\x1eECC-STEP {"event":"begin","step":"S","tool":"T"}\n' + b'\x1eECC-STEP {"v":1,"event":"begin","step":"S","tool":"T"}\n' b"line 1\n" b"line 2\n" - b'\x1eECC-STEP {"event":"end","step":"S","tool":"T"}\n' + b'\x1eECC-STEP {"v":1,"event":"end","step":"S","tool":"T"}\n' ) reader = LogStreamReader( io.BytesIO(stream_data), diff --git a/test/runtime/test_worker_operation.py b/test/runtime/test_worker_operation.py index 3d73ee6c..4f259e1a 100644 --- a/test/runtime/test_worker_operation.py +++ b/test/runtime/test_worker_operation.py @@ -35,7 +35,7 @@ def send_response(resp): sys.stdout.buffer.flush() def make_marker(event, step, tool): - p = json.dumps({"event": event, "step": step, "tool": tool}) + p = json.dumps({"v": 1, "event": event, "step": step, "tool": tool}) return chr(0x1e).encode() + b"ECC-STEP " + p.encode() + b"\\n" """) diff --git a/test/test_engine_flow.py b/test/test_engine_flow.py index 3300738f..8c1003cf 100644 --- a/test/test_engine_flow.py +++ b/test/test_engine_flow.py @@ -96,6 +96,63 @@ def test_engine_flow_does_not_delay_short_step_before_return(monkeypatch, tmp_pa assert sleep_calls == [] +def test_end_marker_follows_step_writes_and_precedes_completion(monkeypatch, tmp_path): + """The end marker fires after all step-scoped writes and before completion notify.""" + import chipcompiler.runtime.log_stream as log_stream_module + + workspace = Workspace() + workspace.flow.data = { + "steps": [{"name": "route", "tool": "ecc", "state": "Unstart"}], + } + workspace_step = EccStep(name="route", directory=tmp_path, tool="ecc") + engine_flow = EngineFlow(workspace) + engine_flow.workspace_steps = [workspace_step] + engine_flow.engine_db = SimpleNamespace(engine=None) + + events: list[tuple[str, object]] = [] + + monkeypatch.setattr(tools, "run_step", lambda **_kwargs: True) + monkeypatch.setattr(engine_flow, "check_step_result", lambda **_kwargs: True) + monkeypatch.setattr( + tools, + "save_layout_image", + lambda **_kwargs: events.append(("layout", None)), + ) + + original_set_state = engine_flow.set_state + + def recording_set_state(**kwargs): + events.append(("set_state", kwargs.get("state"))) + return original_set_state(**kwargs) + + monkeypatch.setattr(engine_flow, "set_state", recording_set_state) + monkeypatch.setattr( + engine_flow, + "clear_db_engine_after_step", + lambda step, state: events.append(("db_cleanup", state)), + ) + monkeypatch.setattr( + log_stream_module, + "emit_step_marker", + lambda event, *, step, tool: events.append(("marker", event)), + ) + + class CompletionObserver: + def on_step_completed(self, step, state): + # The end marker must already have fired when completion is notified. + assert ("marker", "end") in events + events.append(("observer", "completed")) + + result = engine_flow.run_step(workspace_step, observer=CompletionObserver()) + + assert result == StateEnum.Success + end_index = events.index(("marker", "end")) + assert events.index(("set_state", StateEnum.Success)) < end_index + assert events.index(("layout", None)) < end_index + assert events.index(("db_cleanup", StateEnum.Success)) < end_index + assert end_index < events.index(("observer", "completed")) + + def test_check_step_result_synthesis_uses_common_verilog(tmp_path): verilog = tmp_path / "gcd.v" verilog.write_text("module gcd; endmodule\n") From 0d9f7e1a4527f7985ba53951d655871472281777 Mon Sep 17 00:00:00 2001 From: Emin Date: Tue, 18 Aug 2026 16:52:35 +0800 Subject: [PATCH 15/52] docs(specification): add step marker protocol v1 Normative specification of the step marker byte-stream protocol: frame format and v1 payload, consumer/producer semantics, the ordering guarantee (end after all step-scoped writes, before completion notify), the single-producer invariant, the archive path layout, and the DEC-1 protocol change making the GUI the only live-log consumer. --- docs/specification/marker-protocol.md | 143 ++++++++++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 docs/specification/marker-protocol.md diff --git a/docs/specification/marker-protocol.md b/docs/specification/marker-protocol.md new file mode 100644 index 00000000..b4000452 --- /dev/null +++ b/docs/specification/marker-protocol.md @@ -0,0 +1,143 @@ +# Step Marker Protocol + +This document is the normative specification of the step marker protocol: the +byte-stream convention an executor process (CLI worker or GUI sidecar) uses to +delimit per-step tool output on its stderr stream, and the rules clients +follow to archive that output into per-step log files. + +Status: v1, normative. + +## Design Invariant + +An executor process never opens, writes, or tails step log files. It writes +bytes to fd 1/2 and emits step markers on fd 2. Workspace state files +(flow.json, home.json) and the workspace logger (`log/.log`) are +exempt from this rule; it applies to step tool logs only. + +Each client (CLI, GUI) archives the byte stream into per-step log files as a +client-side result. Files are archival results: any tool that later reads a +step log (GUI history view, `ecc log`, failure-context extraction) is a +read-only consumer and does not care which client wrote it. + +Markers are private to the byte-stream layer. A marker is only ever meaningful +between the producing executor and the consuming archiver in its client. +Matched markers are consumed by the archiver and are never archived, forwarded +to any user-visible surface, or shown to users. + +## Frame Format + +A marker frame is exactly one line on fd 2: + +``` +\x1e ECC-STEP \n +``` + +- `\x1e` is the ASCII Record Separator control character. +- The literal prefix `ECC-STEP ` (with one trailing space) follows. +- The payload is a single JSON object serialized without insignificant + whitespace. +- The frame is terminated by a single `\n`. + +Payload (version 1): + +```json +{"v":1,"event":"begin","step":"Synthesis","tool":"yosys"} +{"v":1,"event":"end","step":"Synthesis","tool":"yosys"} +``` + +Fields: + +- `v` (number, required): protocol version. This document defines version `1`. +- `event` (string, required): `begin` or `end`. +- `step` (string, required): the flow step name (e.g. `Synthesis`). +- `tool` (string, required): the tool identifier (e.g. `yosys`, `ecc`). + +## Semantics + +Producer rules (executor): + +- `begin` is emitted when a step starts executing, before any of the step's + tool output. +- `end` is emitted according to the ordering guarantee below. +- Emission is unconditional: every execution path has a consumer, so no mode + flag exists. + +Consumer rules (client archiver): + +- A `begin` frame while no step is active: open/activate archival for + `(step, tool)`. An `end` frame matching the active `(step, tool)`: close + archival. Both frames are consumed (not archived). +- Any of the following is treated as ordinary stream bytes, never as a + marker: a `begin` while a step is active, an `end` that does not match the + active `(step, tool)`, an unknown `event`, a missing or unsupported `v`, or + a malformed frame (bad JSON, non-dict payload, missing/wrong-typed fields). +- Bytes received while no step is active are *unscoped*: they are not + attributed to any step and must not be written to any step log archive. +- Consumers must drain the stream continuously with a bounded buffer, and must + bound every wait they place on marker arrival (an executor crash may mean an + `end` frame never arrives). + +## Ordering Guarantee + +Within a step, the executor writes the `end` marker: + +- **after** all step-scoped writes have flushed — final state persistence, + `[RESULT]` logging, QOR/metrics refresh, layout snapshot, and db cleanup — + so that a consumer that has read the `end` marker has seen every byte of + that step; and +- **before** the step completion notification (`step.completed`) is published. + +Because the previous step's final state is persisted before its `end` marker, +and the `end` marker precedes the next step's `begin` marker, a consumer may +refresh per-step final states from flow.json on each `begin` marker and once +more when the operation ends. + +If a step's execution or post-processing raises, no `end` marker is emitted +for that step; consumers treat the missing `end` as a crash signal and must +not wait indefinitely for it. + +## Single-Producer Invariant + +At most one flow executes per executor process at a time: + +- GUI: one sidecar per workspace, with `RuntimeOperationConflict` enforced per + workspace. +- CLI: one worker per run operation. + +Frames from different steps therefore never interleave on the stream; a +`begin` while a step is active indicates a protocol violation or foreign +output and is handled as ordinary bytes (see Semantics). + +## Archive Path Layout + +Clients archive step output to: + +``` +/_/log/.log +``` + +Consumers must sanitize `step`/`tool` (reject path separators, `..`, and +empty names) and must verify the resolved path stays inside the workspace +directory before opening it. A frame that fails sanitization or containment +is degraded to ordinary bytes; its bytes are never written outside the +workspace. + +The archive is opened with truncation on each accepted `begin`, so a rerun +starts a fresh byte stream with cursor 0. + +## Protocol Change: Live-Log Events Are Client-Synthesized + +As of this protocol version, ecc no longer emits `step.log` notifications or +attaches `finalLog` to `step.completed` on the runtime event channel. Live +step-log events are synthesized by the client that archives the stream: + +- the CLI archives silently (its terminal UI renders from the same stream); +- the GUI's Electron process synthesizes `step.log` / `finalLog` events for + its renderer from the archived bytes. + +Consequence (DEC-1, accepted 2026-08-18): the GUI is the only supported +consumer of operation live logs. The event shapes the GUI renderer consumes +(`step.log` payload `{chunk, cursor, step, tool}`; `finalLog` on +`step.completed`) are unchanged — only their producer moved, from ecc +file-tailing to client-side synthesis. Versions are pinned via the +superproject's ecc submodule, so producer and consumer always match. From 7a3d6ae0f82dba1202612675a6c479b2d85219c0 Mon Sep 17 00:00:00 2001 From: Emin Date: Tue, 18 Aug 2026 17:08:02 +0800 Subject: [PATCH 16/52] feat(runtime): add reset_dependents to flow.run_step and sequential calls to RunOperation FlowRunStepRequest gains an optional reset_dependents field (additive, mirroring operation.start_step), and the flow.run_step handler forwards it so direct step reruns can invalidate the downstream suffix. RunOperation.run_sequence executes an ordered list of RPC calls in one worker session, stopping at the first failed RPC: remaining calls are skipped, the session is still shut down gracefully and drained, and the returned OperationResult describes the failing call. --- chipcompiler/runtime/requests.py | 1 + chipcompiler/runtime/worker_operation.py | 32 +++++++-- chipcompiler/runtime/workspace_api.py | 2 +- test/runtime/test_requests.py | 30 ++++++-- test/runtime/test_worker_operation.py | 89 ++++++++++++++++++++++++ test/runtime/test_workspace_api.py | 56 +++++++++++++++ 6 files changed, 197 insertions(+), 13 deletions(-) diff --git a/chipcompiler/runtime/requests.py b/chipcompiler/runtime/requests.py index d8a602c2..3e7d2367 100644 --- a/chipcompiler/runtime/requests.py +++ b/chipcompiler/runtime/requests.py @@ -67,6 +67,7 @@ class FlowRunStepRequest: workspace_id: str step: str rerun: bool = False + reset_dependents: bool = False @dataclass(frozen=True) diff --git a/chipcompiler/runtime/worker_operation.py b/chipcompiler/runtime/worker_operation.py index 5979e62d..9d63ea21 100644 --- a/chipcompiler/runtime/worker_operation.py +++ b/chipcompiler/runtime/worker_operation.py @@ -79,6 +79,24 @@ def run(self, method: str, params: dict, *, request_id: int = 1) -> OperationRes Session sequence: hello → workspace.open → method → rpc.shutdown → EOF. """ + return self.run_sequence([(method, params)], request_id=request_id) + + def run_sequence( + self, + calls: list[tuple[str, dict]], + *, + request_id: int = 1, + ) -> OperationResult: + """Execute an ordered list of (method, params) calls in one session. + + The calls share a single worker session: + hello → workspace.open → call 1 → ... → call N → rpc.shutdown → EOF. + + Execution stops at the first failed RPC: remaining calls are skipped, + the session is still shut down gracefully and drained, and the + returned OperationResult describes the failing call. On success the + result describes the last call. + """ client = WorkerClient(self._worker_argv) reader: LogStreamReader | None = None @@ -107,11 +125,15 @@ def run(self, method: str, params: dict, *, request_id: int = 1) -> OperationRes return self._handle_protocol_or_crash(client, reader, open_result) workspace_id = open_result.response["result"]["workspaceId"] - params = {**params, "workspace_id": workspace_id} - rpc_result = client.request(method, params, request_id) + rpc_result: WorkerResult | None = None + for index, (method, params) in enumerate(calls): + full_params = {**params, "workspace_id": workspace_id} + rpc_result = client.request(method, full_params, request_id + index) + if not rpc_result.success: + break - if not rpc_result.success: + if rpc_result is not None and not rpc_result.success: if rpc_result.response is None or not client.is_alive(): error = rpc_result.error or "protocol failure" return self._handle_crash(client, reader, error) @@ -149,7 +171,7 @@ def run(self, method: str, params: dict, *, request_id: int = 1) -> OperationRes if error_parts: return OperationResult( success=False, - rpc_result=rpc_result.response, + rpc_result=rpc_result.response if rpc_result else None, exit_code=client.process.returncode if client.process else None, error="; ".join(error_parts), archive_error=log_state.error, @@ -158,7 +180,7 @@ def run(self, method: str, params: dict, *, request_id: int = 1) -> OperationRes return OperationResult( success=True, - rpc_result=rpc_result.response, + rpc_result=rpc_result.response if rpc_result else None, exit_code=0, log_state=log_state, ) diff --git a/chipcompiler/runtime/workspace_api.py b/chipcompiler/runtime/workspace_api.py index 38525718..ea75ed39 100644 --- a/chipcompiler/runtime/workspace_api.py +++ b/chipcompiler/runtime/workspace_api.py @@ -287,7 +287,7 @@ def run(session: WorkspaceSession) -> dict: return self._with_session_mutation_lock(request.workspace_id, run) def flow_run_step(self, request: FlowRunStepRequest) -> dict: - return self._flow_run_step(request) + return self._flow_run_step(request, reset_dependents=request.reset_dependents) def _flow_run_step( self, diff --git a/test/runtime/test_requests.py b/test/runtime/test_requests.py index 16d237dd..ab6b5fac 100644 --- a/test/runtime/test_requests.py +++ b/test/runtime/test_requests.py @@ -307,6 +307,10 @@ def test_rerun_must_be_boolean(method, params): "operation.start_step", {"workspaceId": "ws-1", "step": "Synthesis", "resetDependents": 1}, ), + ( + "flow.run_step", + {"workspaceId": "ws-1", "step": "Synthesis", "resetDependents": 1}, + ), ], ) def test_reset_dependents_must_be_boolean(method, params): @@ -316,14 +320,26 @@ def test_reset_dependents_must_be_boolean(method, params): assert exc_info.value.reason == "reset_dependents must be a boolean" -def test_direct_flow_run_step_rejects_gui_only_reset_dependents_field(): - with pytest.raises(RequestValidationError) as exc_info: - _parse_runtime_request( - "flow.run_step", - {"workspaceId": "ws-1", "step": "Synthesis", "resetDependents": True}, - ) +def test_flow_run_step_parses_reset_dependents(): + request = _parse_runtime_request( + "flow.run_step", + {"workspaceId": "ws-1", "step": "Synthesis", "resetDependents": True}, + ) + + assert request == FlowRunStepRequest( + workspace_id="ws-1", + step="Synthesis", + reset_dependents=True, + ) + + +def test_flow_run_step_reset_dependents_defaults_to_false(): + request = _parse_runtime_request( + "flow.run_step", + {"workspaceId": "ws-1", "step": "Synthesis"}, + ) - assert exc_info.value.reason == "unknown field: reset_dependents" + assert request == FlowRunStepRequest(workspace_id="ws-1", step="Synthesis") def test_unknown_runtime_method_has_no_request_model(): diff --git a/test/runtime/test_worker_operation.py b/test/runtime/test_worker_operation.py index 4f259e1a..4db4c021 100644 --- a/test/runtime/test_worker_operation.py +++ b/test/runtime/test_worker_operation.py @@ -96,6 +96,95 @@ def test_rpc_error_returns_failure(self, tmp_path): assert "unknown method" in result.error +class TestRunOperationSequence: + @staticmethod + def _write_server(tmp_path): + script = tmp_path / "sequence_server.py" + script.write_text( + _RPC_HELPERS + + textwrap.dedent("""\ + received = [] + fail_first = os.environ.get("SEQ_FAIL_FIRST") == "1" + while True: + req = read_request() + if req is None: + break + method = req.get("method", "") + req_id = req.get("id") + received.append(method) + if method == "rpc.hello": + send_response({"jsonrpc": "2.0", "result": {"version": 1}, "id": req_id}) + elif method == "workspace.open": + result = {"workspaceId": "test"} + send_response({"jsonrpc": "2.0", "result": result, "id": req_id}) + elif method == "flow.run_step": + if fail_first: + err = {"code": -32000, "message": "step failed"} + send_response({"jsonrpc": "2.0", "error": err, "id": req_id}) + else: + result = {"step": "ok"} + send_response({"jsonrpc": "2.0", "result": result, "id": req_id}) + elif method == "flow.run": + send_response({"jsonrpc": "2.0", "result": {"ran": True}, "id": req_id}) + elif method == "rpc.shutdown": + with open(os.environ["SEQ_LOG"], "w") as fh: + fh.write("\\n".join(received)) + send_response({"jsonrpc": "2.0", "result": {"ok": True}, "id": req_id}) + break + else: + err = {"code": -32601, "message": "unknown method"} + send_response({"jsonrpc": "2.0", "error": err, "id": req_id}) + """) + ) + return script + + def _make_operation(self, tmp_path, monkeypatch, script, *, fail_first): + log_file = tmp_path / "received.txt" + monkeypatch.setenv("SEQ_LOG", str(log_file)) + if fail_first: + monkeypatch.setenv("SEQ_FAIL_FIRST", "1") + else: + monkeypatch.delenv("SEQ_FAIL_FIRST", raising=False) + flow_json = tmp_path / "flow.json" + flow_json.write_text("{}") + op = RunOperation( + workspace_dir=tmp_path, + flow_json_path=flow_json, + worker_argv=[sys.executable, str(script)], + ) + return op, log_file + + CALLS = [ + ("flow.run_step", {"step": "Synthesis", "rerun": True, "reset_dependents": True}), + ("flow.run", {"rerun": False}), + ] + + def test_successful_sequence_returns_last_call_result(self, tmp_path, monkeypatch): + script = self._write_server(tmp_path) + op, log_file = self._make_operation(tmp_path, monkeypatch, script, fail_first=False) + result = op.run_sequence(self.CALLS) + assert result.success is True + assert result.rpc_result["result"] == {"ran": True} + received = log_file.read_text().splitlines() + assert received == [ + "rpc.hello", + "workspace.open", + "flow.run_step", + "flow.run", + "rpc.shutdown", + ] + + def test_first_failure_skips_follow_up_and_still_shuts_down(self, tmp_path, monkeypatch): + script = self._write_server(tmp_path) + op, log_file = self._make_operation(tmp_path, monkeypatch, script, fail_first=True) + result = op.run_sequence(self.CALLS) + assert result.success is False + assert "step failed" in result.error + received = log_file.read_text().splitlines() + assert "flow.run" not in received + assert received[-1] == "rpc.shutdown" + + class TestRunOperationCrash: def test_worker_crash_triggers_repair(self, tmp_path): crash_script = tmp_path / "crash_after_open.py" diff --git a/test/runtime/test_workspace_api.py b/test/runtime/test_workspace_api.py index 53960de0..a55be043 100644 --- a/test/runtime/test_workspace_api.py +++ b/test/runtime/test_workspace_api.py @@ -1397,6 +1397,62 @@ def step_spec(name, tool): } +def test_flow_run_step_direct_rerun_applies_reset_dependents_from_request(monkeypatch, tmp_path): + _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) + + def step_spec(name, tool): + step_dir = ws / f"{name}_{tool}" + artifact_dir = step_dir / "output" + artifact_dir.mkdir(parents=True) + (artifact_dir / "stale").write_text(name) + subflow_path = step_dir / "subflow.json" + subflow_path.write_text(json.dumps({"path": str(subflow_path), "steps": []})) + checklist_path = step_dir / "checklist.json" + checklist_path.write_text(json.dumps({"checklist": []})) + return { + "name": name, + "tool": tool, + "output": {"dir": artifact_dir}, + "subflow": SimpleNamespace(path=subflow_path, steps=[]), + "checklist": SimpleNamespace(path=checklist_path, checklist=[]), + } + + synthesis = step_spec("Synthesis", "yosys") + floorplan = step_spec("Floorplan", "ecc") + route = step_spec("route", "ecc") + DummyFlow.workspace_step_specs = (synthesis, floorplan, route) + api = WorkspaceRuntimeApi() + workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"] + session = api.sessions.get_session(workspace_id) + session.workspace.flow.data = { + "steps": [ + {"name": spec["name"], "tool": spec["tool"], "state": "Success"} + for spec in (synthesis, floorplan, route) + ] + } + + result = api.flow_run_step( + FlowRunStepRequest( + workspace_id=workspace_id, + step="Floorplan", + rerun=True, + reset_dependents=True, + ) + ) + + assert result == {"step": "Floorplan", "state": "Success"} + assert (synthesis["output"]["dir"] / "stale").read_text() == "Synthesis" + for spec in (floorplan, route): + assert list(spec["output"]["dir"].iterdir()) == [] + records = {record["name"]: record for record in session.workspace.flow.data["steps"]} + assert any( + record["name"] == "Synthesis" and record["state"] == "Success" + for record in session.workspace.flow.data["steps"] + ) + assert records["Floorplan"]["state"] == "Unstart" + assert records["route"]["state"] == "Unstart" + + def test_flow_run_step_rerun_rejects_an_open_layout_edit(monkeypatch, tmp_path): _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) api = WorkspaceRuntimeApi() From fa1db8c8c6f4829f1b5bf55c4e857a5649a38a6d Mon Sep 17 00:00:00 2001 From: Emin Date: Tue, 18 Aug 2026 17:38:36 +0800 Subject: [PATCH 17/52] feat(cli): route every ecc run variant through the worker All execution paths now go through RunOperation; the TTY distinction only selects UI rendering. - run --workspace maps --resume/--from onto flow.run_step with reset_dependents plus a follow-up flow.run in one worker session, and --only/--force onto a single flow.run_step; the no-op cases (already successful selections) keep their current records. - run_flow_with_progress is rewritten around the reader callbacks: begin markers drive step transitions, on_output drives the throttled live line, and per-step final states refresh from flow.json on each begin marker and once at operation end. - LogStreamReader gains an on_step_event callback fired on matched begin/end markers; RunOperation forwards it. - Delete preserve_cli_stdio, the log monitor, the incremental log tail, redirect_stdio_to_file, and the in-process rerun execution. --- chipcompiler/cli/command_handlers/project.py | 203 +++- chipcompiler/cli/rendering/progress.py | 388 ++---- chipcompiler/runtime/log_stream.py | 25 + chipcompiler/runtime/worker_operation.py | 3 + chipcompiler/utility/__init__.py | 2 - chipcompiler/utility/log.py | 18 - test/cli/commands/test_run.py | 212 ++-- test/cli/rendering/test_progress.py | 1113 ++++++------------ test/runtime/test_log_stream.py | 93 ++ 9 files changed, 848 insertions(+), 1209 deletions(-) diff --git a/chipcompiler/cli/command_handlers/project.py b/chipcompiler/cli/command_handlers/project.py index dbe0507b..ee4376b2 100644 --- a/chipcompiler/cli/command_handlers/project.py +++ b/chipcompiler/cli/command_handlers/project.py @@ -189,57 +189,64 @@ def _canonically_inside(path: str, anchor: str) -> bool: def _workspace_step_log_resolver(workspace_dir: str): """Return a (step, tool) -> Path resolver for workspace step logs.""" - from pathlib import Path - - base = Path(workspace_dir) + from chipcompiler.runtime.log_stream import step_log_archive_resolver - def resolve(step: str, tool: str) -> Path: - return base / f"{step}_{tool}" / "log" / f"{step}.log" + return step_log_archive_resolver(workspace_dir) - return resolve - -def _run_flow_via_worker(workspace_dir: str): - """Execute flow.run through an isolated worker process. - - Returns an OperationResult. A missing worker binary is a structured failure. - """ - import json as json_mod - from pathlib import Path - - from chipcompiler.runtime.worker_operation import ( - OperationResult, - RunOperation, - _default_worker_argv, - ) +def _worker_binary_missing_error() -> str | None: + from chipcompiler.runtime.worker_operation import _default_worker_argv argv = _default_worker_argv() if not os.path.isfile(argv[0]): - return OperationResult( - success=False, - error=f"worker binary not found: {argv[0]}", - ) + return f"worker binary not found: {argv[0]}" + return None - flow_json_path = Path(workspace_dir) / "home" / "flow.json" - valid_steps: set[tuple[str, str]] | None = None +def _load_valid_steps(flow_json_path) -> set[tuple[str, str]] | None: + import json as json_mod + try: with open(flow_json_path) as f: flow_data = json_mod.load(f) - valid_steps = { + return { (s["name"], s["tool"]) for s in flow_data.get("steps", []) if isinstance(s, dict) and "name" in s and "tool" in s } except (OSError, json_mod.JSONDecodeError, KeyError): - pass + return None + + +def _make_run_operation(workspace_dir: str, *, on_output=None, on_step_event=None): + """Build a RunOperation for a workspace with step-log archiving wired in.""" + from pathlib import Path - op = RunOperation( + from chipcompiler.runtime.worker_operation import RunOperation + + flow_json_path = Path(workspace_dir) / "home" / "flow.json" + return RunOperation( workspace_dir=Path(workspace_dir), flow_json_path=flow_json_path, log_path_resolver=_workspace_step_log_resolver(workspace_dir), - valid_steps=valid_steps, + on_output=on_output, + on_step_event=on_step_event, + valid_steps=_load_valid_steps(flow_json_path), ) + + +def _run_flow_via_worker(workspace_dir: str, *, on_output=None, on_step_event=None): + """Execute flow.run through an isolated worker process. + + Returns an OperationResult. A missing worker binary is a structured failure. + """ + from chipcompiler.runtime.worker_operation import OperationResult + + missing = _worker_binary_missing_error() + if missing is not None: + return OperationResult(success=False, error=missing) + + op = _make_run_operation(workspace_dir, on_output=on_output, on_step_event=on_step_event) return op.run("flow.run", {"rerun": False}) @@ -481,11 +488,16 @@ def run(command_input: RunInput, ctx: CommandContext) -> CommandResult: ) if should_enable_run_progress(ctx, sys.stderr): - flow_ok = run_flow_with_progress(engine_flow, ctx, project, sys.stderr) - op_result = None + op_result = run_flow_with_progress( + run_dir, + ctx, + project, + sys.stderr, + run_operation=lambda **callbacks: _run_flow_via_worker(run_dir, **callbacks), + ) else: op_result = _run_flow_via_worker(run_dir) - flow_ok = op_result.success + flow_ok = op_result.success if not flow_ok: error_record = { @@ -495,13 +507,12 @@ def run(command_input: RunInput, ctx: CommandContext) -> CommandResult: "inspect_cmd": disclosure_cmd("ecc status", project, ctx.run_id), "log": disclosure_cmd("ecc log", project, ctx.run_id), } - if op_result is not None and not op_result.success: - if op_result.error: - error_record["error"] = op_result.error - if op_result.exit_code is not None: - error_record["exit_code"] = op_result.exit_code - if op_result.repaired_steps: - error_record["repaired_steps"] = op_result.repaired_steps + if op_result.error: + error_record["error"] = op_result.error + if op_result.exit_code is not None: + error_record["exit_code"] = op_result.exit_code + if op_result.repaired_steps: + error_record["repaired_steps"] = op_result.repaired_steps return CommandResult.err([error_record]) except Exception as exc: return CommandResult.err( @@ -579,32 +590,100 @@ def error(kind: str, **fields) -> CommandResult: except ValueError as exc: return error("unknown_step", workspace=workspace_path, reason=str(exc)) - from chipcompiler.cli.rendering.progress import preserve_cli_stdio + def no_op_result() -> CommandResult: + return CommandResult.ok( + [ + { + "run": "workspace", + "status": "success", + "workspace": workspace_path, + "executed_steps": [], + "no_op": True, + } + ] + ) - try: - with preserve_cli_stdio(): - if selected: - engine_flow.create_step_workspaces(executable_steps=set(selected)) - if command_input.only is not None: - result = rerun.run_only(engine_flow, command_input.only, force=command_input.force) - elif command_input.from_step is not None: - result = rerun.run_from(engine_flow, command_input.from_step) - else: - result = rerun.run_resume(engine_flow) - except ValueError as exc: - return error("step_unavailable", workspace=workspace_path, reason=str(exc)) - except Exception as exc: - return error("flow_failed", workspace=workspace_path, reason=str(exc)) + if not selected: + # --only on an already-successful step without --force, or --resume + # with every step successful: nothing to execute. + return no_op_result() + + target = selected[0] + if command_input.only is not None: + calls = [("flow.run_step", {"step": target, "rerun": bool(command_input.force)})] + else: + calls = [ + ("flow.run_step", {"step": target, "rerun": True, "reset_dependents": True}), + ("flow.run", {"rerun": False}), + ] + + from chipcompiler.runtime.worker_operation import OperationResult + + missing = _worker_binary_missing_error() + if missing is not None: + op_result = OperationResult(success=False, error=missing) + else: + op = _make_run_operation(workspace_path) + op_result = op.run_sequence(calls) + + if op_result.success: + return CommandResult.ok( + [ + { + "run": "workspace", + "status": "success", + "workspace": workspace_path, + "executed_steps": list(selected), + "no_op": False, + } + ] + ) + executed, failed_step = _workspace_run_outcome(workspace_path, selected) record = { "run": "workspace", - "status": "success" if result.ok else "failed", + "status": "failed", "workspace": workspace_path, - "executed_steps": list(result.executed), - "no_op": result.ok and not result.executed, + "executed_steps": executed, + "no_op": False, + "resume_cmd": f"ecc run --workspace {shlex.quote(workspace_path)} --resume", } - if result.ok: - return CommandResult.ok([record]) - record["failed_step"] = result.failed - record["resume_cmd"] = f"ecc run --workspace {shlex.quote(workspace_path)} --resume" + if failed_step is not None: + record["failed_step"] = failed_step + if op_result.error: + record["error"] = op_result.error + if op_result.exit_code is not None: + record["exit_code"] = op_result.exit_code + if op_result.repaired_steps: + record["repaired_steps"] = op_result.repaired_steps return CommandResult.err([record]) + + +def _workspace_run_outcome( + workspace_path: str, selected: list[str] +) -> tuple[list[str], str | None]: + """Derive executed steps and the failed step from post-run flow.json. + + After a stopped sequence the selected suffix reads as: a Success prefix + that did execute, the step that failed, and an Unstart remainder that was + invalidated but never ran. + """ + import json as json_mod + + try: + with open(os.path.join(workspace_path, "home", "flow.json")) as f: + flow_data = json_mod.load(f) + except (OSError, json_mod.JSONDecodeError): + return [], None + + states = { + record["name"]: record.get("state") + for record in flow_data.get("steps", []) + if isinstance(record, dict) and "name" in record + } + executed = [] + for name in selected: + if states.get(name) != "Success": + return executed, name + executed.append(name) + return executed, None diff --git a/chipcompiler/cli/rendering/progress.py b/chipcompiler/cli/rendering/progress.py index 5241de37..23e293d5 100644 --- a/chipcompiler/cli/rendering/progress.py +++ b/chipcompiler/cli/rendering/progress.py @@ -1,10 +1,7 @@ -import contextlib -import multiprocessing +import json import os import re import shutil -import sys -import threading import time from chipcompiler.cli.core.output import disclosure_cmd, normalize_state, normalize_step_name @@ -17,8 +14,6 @@ ) from chipcompiler.cli.rendering.pretty import BOLD, CYAN, DIM, GREEN, RED, RESET from chipcompiler.cli.rendering.pretty import style as _style -from chipcompiler.data import StateEnum, log_flow -from chipcompiler.utility.log import flush_cstdio, redirect_stdio_to_file def supports_color(stream, mode, env=None): @@ -43,8 +38,7 @@ def should_enable_run_progress(ctx, stderr): _CONTROL_RE = re.compile(r"[\r\n\t]+") _C0_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]") _MULTI_SPACE_RE = re.compile(r" {2,}") -_LOG_POLL_INTERVAL = 0.5 -_LOG_STALE_AFTER = 10.0 +_LIVE_LINE_MIN_INTERVAL = 0.1 def sanitize_log_line(line): @@ -108,132 +102,6 @@ def format_error_context(log_path, context_lines, log_cmd, *, color=True): return "\n".join(lines) + "\n" -def latest_log_line(path): - if not path or not os.path.isfile(path): - return None - try: - with open(path, errors="replace") as f: - lines = f.readlines() - except OSError: - return None - for line in reversed(lines): - sanitized = sanitize_log_line(line) - if sanitized: - return sanitized - return None - - -class _IncrementalLogTail: - def __init__(self, path, step_name, stale_after=10.0): - self.path = path - self.step_name = step_name - self.stale_after = stale_after - self.file_id = None - self.change_id = None - self.fingerprint = None - self.head = None - self.offset = 0 - self.partial = "" - self.started_at = None - self.last_line = None - self.last_update_at = None - - def poll(self, now=None): - now = time.monotonic() if now is None else now - if self.started_at is None: - self.started_at = now - - for line in self._read_new_lines(): - sanitized = sanitize_log_line(line) - if sanitized: - self.last_line = sanitized - self.last_update_at = now - - if self.last_line is None: - elapsed = max(0, now - self.started_at) - return f"running {self.step_name}, waiting for step log {int(elapsed)}s..." - - elapsed = max(0, now - self.last_update_at) - if elapsed >= self.stale_after: - return f"running {self.step_name}, last log {int(elapsed)}s ago: {self.last_line}" - - return self.last_line - - def _read_new_lines(self): - if not self.path or not os.path.isfile(self.path): - return [] - - try: - stat = os.stat(self.path) - except OSError: - return [] - - file_id = (stat.st_dev, stat.st_ino) - change_id = (stat.st_mtime_ns, stat.st_ctime_ns) - replaced = False - if self.file_id == file_id and self.offset > 0: - head = self._head() - if stat.st_size < self.offset: - replaced = True - elif stat.st_size == self.offset: - fingerprint = self._fingerprint(stat.st_size) - replaced = fingerprint != self.fingerprint - elif self.head is not None: - compare_len = min(len(self.head), len(head), self.offset) - replaced = head[:compare_len] != self.head[:compare_len] - if self.file_id != file_id or replaced: - self.file_id = file_id - self.offset = 0 - self.partial = "" - self.change_id = change_id - - try: - with open(self.path, encoding="utf-8", errors="replace") as f: - f.seek(self.offset) - chunk = f.read() - self.offset = f.tell() - except OSError: - return [] - - self.fingerprint = self._fingerprint(stat.st_size) - self.head = self._head() - if not chunk: - return [] - - text = self.partial + chunk - lines = text.splitlines(keepends=True) - if lines and not lines[-1].endswith(("\n", "\r")): - self.partial = lines.pop() - else: - self.partial = "" - - return lines - - def _head(self): - if not self.path or not os.path.isfile(self.path): - return None - try: - with open(self.path, "rb") as f: - return f.read(4096) - except OSError: - return None - - def _fingerprint(self, size): - if not self.path or not os.path.isfile(self.path): - return None - try: - with open(self.path, "rb") as f: - head = f.read(4096) - if size > 4096: - f.seek(max(0, size - 4096)) - tail = f.read(4096) - else: - tail = b"" - except OSError: - return None - return size, head, tail - - def terminal_width(fallback=80): cols, _ = shutil.get_terminal_size(fallback=(fallback, 24)) return max(cols, 1) @@ -255,50 +123,6 @@ def _stable_stream_from(stream): return os.fdopen(dup_fd, "w", encoding=encoding, errors=errors, buffering=1, closefd=True) -@contextlib.contextmanager -def preserve_cli_stdio(): - saved_stdout = sys.stdout - saved_stderr = sys.stderr - saved_stdout_fd = None - saved_stderr_fd = None - - for stream in (sys.stdout, sys.stderr): - with contextlib.suppress(Exception): - stream.flush() - - try: - saved_stdout_fd = os.dup(1) - saved_stderr_fd = os.dup(2) - except OSError: - if saved_stdout_fd is not None: - os.close(saved_stdout_fd) - try: - yield - finally: - sys.stdout = saved_stdout - sys.stderr = saved_stderr - return - - try: - yield - finally: - for stream in (sys.stdout, sys.stderr): - with contextlib.suppress(Exception): - stream.flush() - # Drain C/C++ buffered output while fds still point at the step log, - # or it leaks to the terminal on the next flush after restore. - flush_cstdio() - - try: - os.dup2(saved_stdout_fd, 1) - os.dup2(saved_stderr_fd, 2) - finally: - os.close(saved_stdout_fd) - os.close(saved_stderr_fd) - sys.stdout = saved_stdout - sys.stderr = saved_stderr - - class RunProgressRenderer: def __init__(self, stream, width_fn=None, *, color=False): self._stream = stream @@ -358,151 +182,91 @@ def render_failure_context(self, block): self._stream.flush() -def _monitor_log_progress( - renderer, - log_path, - step_name, - stop_event, - interval=_LOG_POLL_INTERVAL, - stale_after=_LOG_STALE_AFTER, -): - tail = _IncrementalLogTail(log_path, step_name, stale_after=stale_after) - while not stop_event.is_set(): - renderer.running(tail.poll()) - stop_event.wait(interval) - - -def _poll_log(renderer, log_path, stop_event, interval=_LOG_POLL_INTERVAL): - _monitor_log_progress(renderer, log_path, "step", stop_event, interval=interval) - - -def _start_log_monitor( - renderer, - log_path, - step_name, - *, - isolated=False, - interval=_LOG_POLL_INTERVAL, - stale_after=_LOG_STALE_AFTER, -): - if isolated: - ctx = multiprocessing.get_context("fork") - stop_event = ctx.Event() - monitor = ctx.Process( - target=_monitor_log_progress, - args=(renderer, log_path, step_name, stop_event), - kwargs={"interval": interval, "stale_after": stale_after}, - daemon=True, - ) - else: - stop_event = threading.Event() - monitor = threading.Thread( - target=_monitor_log_progress, - args=(renderer, log_path, step_name, stop_event), - kwargs={"interval": interval, "stale_after": stale_after}, - daemon=True, - ) - monitor.start() - return stop_event, monitor - - -def _stop_log_monitor(stop_event, monitor, timeout=2.0): - stop_event.set() - monitor.join(timeout=timeout) - if monitor.is_alive() and hasattr(monitor, "terminate"): - monitor.terminate() - monitor.join(timeout=timeout) - - -def run_flow_with_progress(engine_flow, ctx, project, stderr): +def run_flow_with_progress(workspace_dir, ctx, project, stderr, run_operation): + """Render TTY progress for a worker-driven flow execution. + + run_operation receives the reader callbacks (on_output, on_step_event) + and returns an OperationResult. Step transitions and the live log line + are driven by those callbacks; per-step final states are refreshed from + flow.json on each begin marker and once more when the operation ends. + """ color = supports_color(stderr, ctx.output_mode) progress_stream = _stable_stream_from(stderr) try: renderer = RunProgressRenderer(progress_stream, color=color) - engine_flow.workspace.home.reset() - - run_dir = engine_flow.workspace.directory run_name = ctx.run_id or "default" - renderer.start_run(run_name, run_dir) - - for workspace_step in engine_flow.workspace_steps: - step_token = normalize_step_name(workspace_step.name) - tool = workspace_step.tool - log_path = workspace_step.log.file or "" - - engine_flow.workspace.logger.log_section( - f"{workspace_step.tool} - begin step - {workspace_step.name}" - ) - - renderer.start_step(step_token, tool) - renderer.running("starting step...") - - stop_event, monitor = _start_log_monitor( - renderer, - log_path, - step_token, - isolated=progress_stream is not stderr, - ) - - start = time.time() - + renderer.start_run(run_name, workspace_dir) + + from chipcompiler.runtime.log_stream import step_log_archive_resolver + + flow_json_path = os.path.join(workspace_dir, "home", "flow.json") + resolve_log = step_log_archive_resolver(workspace_dir) + rendered = set() + live = {"written_at": 0.0} + + def on_output(data: bytes) -> None: + text = sanitize_log_line(data.decode("utf-8", errors="replace")) + if not text: + return + now = time.monotonic() + if now - live["written_at"] < _LIVE_LINE_MIN_INTERVAL: + return + live["written_at"] = now + renderer.running(text) + + def refresh_final_states() -> None: try: - with preserve_cli_stdio(): - if not engine_flow.check_state( - name=workspace_step.name, - tool=workspace_step.tool, - state=StateEnum.Success, - ): - init_log_stream = None - if log_path: - try: - abs_log_path = os.path.abspath(log_path) - os.makedirs(os.path.dirname(abs_log_path) or ".", exist_ok=True) - init_log_stream = redirect_stdio_to_file(abs_log_path) - except OSError: - init_log_stream = None - try: - engine_flow.init_db_engine() - finally: - if init_log_stream is not None: - init_log_stream.close() - state = engine_flow.run_step(workspace_step) - finally: - _stop_log_monitor(stop_event, monitor) - renderer.clear() - - log_flow(workspace=engine_flow.workspace) - engine_flow.workspace.logger.log_section( - f"{workspace_step.tool} - end step - {workspace_step.name}" - ) - - elapsed = time.time() - start - hours = int(elapsed // 3600) - minutes = int((elapsed % 3600) // 60) - seconds = int(elapsed % 60) - runtime = f"{hours}:{minutes:02d}:{seconds:02d}" - - status = normalize_state(state.value) - - rel_log = "" - if log_path: + with open(flow_json_path) as handle: + flow_data = json.load(handle) + except (OSError, json.JSONDecodeError): + return + for record in flow_data.get("steps", []): + if not isinstance(record, dict): + continue + name = record.get("name") + tool = record.get("tool") or "" + state = record.get("state") + if not name or (name, tool) in rendered: + continue + if state not in ("Success", "Imcomplete", "Incomplete", "Invalid"): + continue + rendered.add((name, tool)) + runtime = record.get("runtime") or "0:00:00" + step_token = normalize_step_name(name) + log_path = str(resolve_log(name, tool)) try: - rel_log = os.path.relpath(log_path, engine_flow.workspace.directory) + rel_log = os.path.relpath(log_path, workspace_dir) except ValueError: rel_log = log_path - - inspect = disclosure_cmd(f"ecc log {step_token}", project, ctx.run_id) - - is_success = state == StateEnum.Success - renderer.finish_step(step_token, tool, status, runtime, rel_log, inspect, is_success) - - if not is_success: - _maybe_render_failure_context( - renderer, log_path, rel_log, step_token, project, ctx.run_id, color + inspect = disclosure_cmd(f"ecc log {step_token}", project, ctx.run_id) + success = state == "Success" + renderer.finish_step( + step_token, + tool, + normalize_state(state), + runtime, + rel_log, + inspect, + success, ) - return False + if not success: + _maybe_render_failure_context( + renderer, log_path, rel_log, step_token, project, ctx.run_id, color + ) + + def on_step_event(event: str, step: str, tool: str) -> None: + if event != "begin": + return + # The previous step's final state is persisted before this begin + # marker is written, so flow.json already reflects it here. + refresh_final_states() + renderer.start_step(normalize_step_name(step), tool) + renderer.running("starting step...") - return True + result = run_operation(on_output=on_output, on_step_event=on_step_event) + refresh_final_states() + renderer.clear() + return result finally: if progress_stream is not stderr: progress_stream.close() diff --git a/chipcompiler/runtime/log_stream.py b/chipcompiler/runtime/log_stream.py index e0f4486e..6dc641da 100644 --- a/chipcompiler/runtime/log_stream.py +++ b/chipcompiler/runtime/log_stream.py @@ -43,6 +43,16 @@ def emit_step_marker(event: str, step: str, tool: str) -> None: os.write(2, line) +def step_log_archive_resolver(workspace_dir) -> Callable[[str, str], Path]: + """Resolve the archive path for a step's tool log inside a workspace.""" + base = Path(workspace_dir) + + def resolve(step: str, tool: str) -> Path: + return base / f"{step}_{tool}" / "log" / f"{step}.log" + + return resolve + + def parse_marker(line: bytes) -> StepMarker | None: """Parse a complete line as a step marker, or return None if invalid.""" if not line.startswith(MARKER_PREFIX): @@ -93,6 +103,7 @@ def __init__( *, log_path_resolver: Callable[[str, str], Path | None] | None = None, on_output: Callable[[bytes], None] | None = None, + on_step_event: Callable[[str, str, str], None] | None = None, tail_size: int = 4096, valid_steps: set[tuple[str, str]] | None = None, workspace_dir: Path | None = None, @@ -101,6 +112,8 @@ def __init__( self._resolve_path = log_path_resolver self._on_output = on_output self._on_output_disabled = False + self._on_step_event = on_step_event + self._on_step_event_disabled = False self._tail_size = tail_size self._valid_steps = valid_steps self._workspace_dir = workspace_dir @@ -178,6 +191,7 @@ def _handle_marker(self, marker: StepMarker, raw_line: bytes) -> None: self._state.active_tool = marker.tool self._state.steps_seen.append(marker.step) self._open_archive(marker.step, marker.tool) + self._emit_step_event("begin", marker.step, marker.tool) else: self._emit_data(raw_line) elif marker.event == "end": @@ -185,11 +199,22 @@ def _handle_marker(self, marker: StepMarker, raw_line: bytes) -> None: self._close_archive() self._state.active_step = None self._state.active_tool = None + self._emit_step_event("end", marker.step, marker.tool) else: self._emit_data(raw_line) else: self._emit_data(raw_line) + def _emit_step_event(self, event: str, step: str, tool: str) -> None: + if self._on_step_event is None or self._on_step_event_disabled: + return + try: + self._on_step_event(event, step, tool) + except Exception as exc: + if self._state.error is None: + self._state.error = exc + self._on_step_event_disabled = True + def _emit_data(self, data: bytes) -> None: if self._state.archive_file is not None: try: diff --git a/chipcompiler/runtime/worker_operation.py b/chipcompiler/runtime/worker_operation.py index 9d63ea21..ffc33d88 100644 --- a/chipcompiler/runtime/worker_operation.py +++ b/chipcompiler/runtime/worker_operation.py @@ -65,6 +65,7 @@ def __init__( worker_argv: list[str] | None = None, log_path_resolver: Callable[[str, str], Path | None] | None = None, on_output: Callable[[bytes], None] | None = None, + on_step_event: Callable[[str, str, str], None] | None = None, valid_steps: set[tuple[str, str]] | None = None, ): self._workspace_dir = workspace_dir @@ -72,6 +73,7 @@ def __init__( self._worker_argv = worker_argv or _default_worker_argv() self._log_path_resolver = log_path_resolver self._on_output = on_output + self._on_step_event = on_step_event self._valid_steps = valid_steps def run(self, method: str, params: dict, *, request_id: int = 1) -> OperationResult: @@ -107,6 +109,7 @@ def run_sequence( proc.stderr, log_path_resolver=self._log_path_resolver, on_output=self._on_output, + on_step_event=self._on_step_event, valid_steps=self._valid_steps, workspace_dir=self._workspace_dir, ) diff --git a/chipcompiler/utility/__init__.py b/chipcompiler/utility/__init__.py index e51567aa..7c45c1d2 100644 --- a/chipcompiler/utility/__init__.py +++ b/chipcompiler/utility/__init__.py @@ -11,7 +11,6 @@ from .log import ( Logger, create_logger, - redirect_stdio_to_file, ) from .plot import plot_bar_chart, plot_csv_bar_chart, plot_csv_map, plot_csv_table, plot_metrics from .util import track_process_memory @@ -25,7 +24,6 @@ "dict_to_str", "Logger", "create_logger", - "redirect_stdio_to_file", "track_process_memory", "plot_csv_map", "plot_metrics", diff --git a/chipcompiler/utility/log.py b/chipcompiler/utility/log.py index 0c434389..ec0d6b51 100644 --- a/chipcompiler/utility/log.py +++ b/chipcompiler/utility/log.py @@ -7,7 +7,6 @@ import time from contextlib import suppress from logging.handlers import RotatingFileHandler -from typing import TextIO # TODO: Move some functions to Logger Module @@ -22,23 +21,6 @@ def flush_cstdio() -> None: ctypes.CDLL(None).fflush(None) -def redirect_stdio_to_file(log_file: str) -> TextIO: - """Redirect process stdout/stderr to log_file at file-descriptor level.""" - # The stream intentionally stays open: its fd is dup2'd onto stdout/stderr below. - log_stream = open(log_file, "a", encoding="utf-8", buffering=1) # noqa: SIM115 - - for stream in (sys.stdout, sys.stderr): - with suppress(Exception): - stream.flush() - flush_cstdio() - - os.dup2(log_stream.fileno(), 1) - os.dup2(log_stream.fileno(), 2) - sys.stdout = os.fdopen(1, "w", encoding="utf-8", buffering=1, closefd=False) - sys.stderr = os.fdopen(2, "w", encoding="utf-8", buffering=1, closefd=False) - return log_stream - - class Logger: def __init__( self, diff --git a/test/cli/commands/test_run.py b/test/cli/commands/test_run.py index aa84420c..e705663a 100644 --- a/test/cli/commands/test_run.py +++ b/test/cli/commands/test_run.py @@ -5,7 +5,6 @@ import pytest from chipcompiler.cli import main as cli_main -from chipcompiler.engine import StepRunResult def _set_flow_preset(project_dir, preset): @@ -240,18 +239,20 @@ def test_run_forwards_absolute_paths_with_relative_env_pdk_root( class TestWorkspaceRun: @pytest.fixture - def workspace_mocks(self, monkeypatch): + def workspace_mocks(self, monkeypatch, tmp_path): + from chipcompiler.runtime.worker_operation import OperationResult + seen = SimpleNamespace( load_path=None, has_init=True, - selected_error=None, - selected=None, - create_calls=0, - executable=None, - only=None, - from_step=None, - resume=False, - result=StepRunResult(ok=True, executed=("place",)), + calls=None, + result=OperationResult(success=True, exit_code=0), + binary_error=None, + steps=[ + {"name": "Synthesis", "tool": "yosys", "state": "Success"}, + {"name": "place", "tool": "ecc", "state": "Imcomplete"}, + {"name": "CTS", "tool": "ecc", "state": "Unstart"}, + ], ) class Flow: @@ -261,48 +262,40 @@ def __init__(self, workspace): def has_init(self): return seen.has_init - def create_step_workspaces(self, *, executable_steps=None): - seen.create_calls += 1 - seen.executable = executable_steps - - def selected_step_names(flow, *, from_step=None, only=None, force=False): - if seen.selected_error is not None: - raise seen.selected_error - seen.selected = {"from_step": from_step, "only": only, "force": force} - if only is not None: - return [] if not force else [only] - if from_step is not None: - return [from_step, "CTS"] - return ["place", "CTS"] - - def run_only(flow, name, *, force=False): - seen.only = (name, force) - return seen.result - - def run_from(flow, name): - seen.from_step = name - return seen.result - - def run_resume(flow): - seen.resume = True - return seen.result - def fake_load_workspace(path): seen.load_path = path - return SimpleNamespace(name="workspace") + return SimpleNamespace( + name="workspace", + flow=SimpleNamespace(data={"steps": [dict(step) for step in seen.steps]}), + ) + + class FakeOperation: + def run_sequence(self, calls): + seen.calls = calls + return seen.result monkeypatch.setattr("chipcompiler.data.load_workspace", fake_load_workspace) monkeypatch.setattr("chipcompiler.engine.EngineFlow", Flow) - monkeypatch.setattr("chipcompiler.engine.rerun.selected_step_names", selected_step_names) - monkeypatch.setattr("chipcompiler.engine.rerun.run_only", run_only) - monkeypatch.setattr("chipcompiler.engine.rerun.run_from", run_from) - monkeypatch.setattr("chipcompiler.engine.rerun.run_resume", run_resume) + monkeypatch.setattr( + "chipcompiler.cli.command_handlers.project._make_run_operation", + lambda workspace_path, **kwargs: FakeOperation(), + ) + monkeypatch.setattr( + "chipcompiler.cli.command_handlers.project._worker_binary_missing_error", + lambda: seen.binary_error, + ) monkeypatch.setattr( "chipcompiler.data.create_workspace", lambda **_kwargs: pytest.fail("workspace mode must not create a workspace"), ) return seen + def _write_post_run_flow(self, workspace, steps): + home = os.path.join(workspace, "home") + os.makedirs(home, exist_ok=True) + with open(os.path.join(home, "flow.json"), "w") as f: + json.dump({"steps": steps}, f) + def test_only_force_wiring(self, workspace_mocks, tmp_path, capsys): workspace = str(tmp_path / "workspace") @@ -311,40 +304,33 @@ def test_only_force_wiring(self, workspace_mocks, tmp_path, capsys): record = json.loads(capsys.readouterr().out)["records"][0] assert rc == 0 assert workspace_mocks.load_path == workspace - assert workspace_mocks.selected == {"from_step": None, "only": "place", "force": True} - assert workspace_mocks.executable == {"place"} - assert workspace_mocks.only == ("place", True) + assert workspace_mocks.calls == [("flow.run_step", {"step": "place", "rerun": True})] assert record["run"] == "workspace" assert record["status"] == "success" assert record["workspace"] == workspace assert record["executed_steps"] == ["place"] assert record["no_op"] is False - def test_default_selector_is_resume(self, workspace_mocks, tmp_path): - rc = cli_main.run(["run", "--workspace", str(tmp_path / "workspace"), "--plain"]) - - assert rc == 0 - assert workspace_mocks.selected == {"from_step": None, "only": None, "force": False} - assert workspace_mocks.executable == {"place", "CTS"} - assert workspace_mocks.resume is True + def test_only_without_force_runs_step(self, workspace_mocks, tmp_path): + workspace = str(tmp_path / "workspace") - def test_from_step_wiring(self, workspace_mocks, tmp_path): - rc = cli_main.run(["run", "--workspace", str(tmp_path / "workspace"), "--from", "CTS"]) + rc = cli_main.run(["run", "--workspace", workspace, "--only", "place", "--json"]) assert rc == 0 - assert workspace_mocks.from_step == "CTS" - assert workspace_mocks.executable == {"CTS"} + assert workspace_mocks.calls == [("flow.run_step", {"step": "place", "rerun": False})] - def test_noop_selection_skips_workspace_rebuild(self, workspace_mocks, tmp_path, capsys): - workspace_mocks.result = StepRunResult(ok=True, executed=()) + def test_only_success_step_without_force_is_noop(self, workspace_mocks, tmp_path, capsys): + workspace_mocks.steps = [ + {"name": "Synthesis", "tool": "yosys", "state": "Success"}, + {"name": "place", "tool": "ecc", "state": "Success"}, + ] workspace = str(tmp_path / "workspace") rc = cli_main.run(["run", "--workspace", workspace, "--only", "place", "--json"]) record = json.loads(capsys.readouterr().out)["records"][0] assert rc == 0 - assert workspace_mocks.create_calls == 0 - assert workspace_mocks.only == ("place", False) + assert workspace_mocks.calls is None assert record == { "run": "workspace", "status": "success", @@ -353,23 +339,107 @@ def test_noop_selection_skips_workspace_rebuild(self, workspace_mocks, tmp_path, "no_op": True, } + def test_default_selector_is_resume(self, workspace_mocks, tmp_path, capsys): + workspace = str(tmp_path / "workspace") + + rc = cli_main.run(["run", "--workspace", workspace, "--json"]) + + record = json.loads(capsys.readouterr().out)["records"][0] + assert rc == 0 + assert workspace_mocks.calls == [ + ("flow.run_step", {"step": "place", "rerun": True, "reset_dependents": True}), + ("flow.run", {"rerun": False}), + ] + assert record["executed_steps"] == ["place", "CTS"] + + def test_from_step_wiring(self, workspace_mocks, tmp_path, capsys): + workspace = str(tmp_path / "workspace") + + rc = cli_main.run(["run", "--workspace", workspace, "--from", "CTS", "--json"]) + + record = json.loads(capsys.readouterr().out)["records"][0] + assert rc == 0 + assert workspace_mocks.calls == [ + ("flow.run_step", {"step": "CTS", "rerun": True, "reset_dependents": True}), + ("flow.run", {"rerun": False}), + ] + assert record["executed_steps"] == ["CTS"] + + def test_resume_all_success_is_noop(self, workspace_mocks, tmp_path, capsys): + workspace_mocks.steps = [ + {"name": "Synthesis", "tool": "yosys", "state": "Success"}, + {"name": "place", "tool": "ecc", "state": "Success"}, + ] + workspace = str(tmp_path / "workspace") + + rc = cli_main.run(["run", "--workspace", workspace, "--resume", "--json"]) + + record = json.loads(capsys.readouterr().out)["records"][0] + assert rc == 0 + assert workspace_mocks.calls is None + assert record["no_op"] is True + assert record["executed_steps"] == [] + def test_failed_run_reports_failed_step_and_resume(self, workspace_mocks, tmp_path, capsys): - workspace_mocks.result = StepRunResult(ok=False, executed=(), failed="place") + from chipcompiler.runtime.worker_operation import OperationResult + + workspace_mocks.result = OperationResult( + success=False, error="run step place failed with state Imcomplete" + ) workspace = str(tmp_path / "workspace") + self._write_post_run_flow( + workspace, + [ + {"name": "Synthesis", "tool": "yosys", "state": "Success"}, + {"name": "place", "tool": "ecc", "state": "Imcomplete"}, + {"name": "CTS", "tool": "ecc", "state": "Unstart"}, + ], + ) - rc = cli_main.run(["run", "--workspace", workspace, "--only", "place", "--json"]) + rc = cli_main.run(["run", "--workspace", workspace, "--only", "place", "--force", "--json"]) record = json.loads(capsys.readouterr().out)["records"][0] assert rc == 1 - assert record == { - "run": "workspace", - "status": "failed", - "workspace": workspace, - "executed_steps": [], - "no_op": False, - "failed_step": "place", - "resume_cmd": f"ecc run --workspace {workspace} --resume", - } + assert record["status"] == "failed" + assert record["executed_steps"] == [] + assert record["failed_step"] == "place" + assert record["resume_cmd"] == f"ecc run --workspace {workspace} --resume" + assert "place" in record["error"] + + def test_failed_suffix_run_reports_executed_prefix(self, workspace_mocks, tmp_path, capsys): + from chipcompiler.runtime.worker_operation import OperationResult + + workspace_mocks.result = OperationResult(success=False, error="run flow failed") + workspace = str(tmp_path / "workspace") + self._write_post_run_flow( + workspace, + [ + {"name": "Synthesis", "tool": "yosys", "state": "Success"}, + {"name": "place", "tool": "ecc", "state": "Success"}, + {"name": "CTS", "tool": "ecc", "state": "Imcomplete"}, + ], + ) + + rc = cli_main.run(["run", "--workspace", workspace, "--resume", "--json"]) + + record = json.loads(capsys.readouterr().out)["records"][0] + assert rc == 1 + assert record["executed_steps"] == ["place"] + assert record["failed_step"] == "CTS" + + def test_missing_worker_binary_returns_structured_failure( + self, workspace_mocks, tmp_path, capsys + ): + workspace_mocks.binary_error = "worker binary not found: /missing/ecc" + workspace = str(tmp_path / "workspace") + + rc = cli_main.run(["run", "--workspace", workspace, "--json"]) + + record = json.loads(capsys.readouterr().out)["records"][0] + assert rc == 1 + assert record["status"] == "failed" + assert "not found" in record["error"] + assert workspace_mocks.calls is None def test_invalid_workspace(self, tmp_path, capsys): rc = cli_main.run(["run", "--workspace", str(tmp_path / "missing"), "--json"]) @@ -388,8 +458,6 @@ def test_missing_flow(self, workspace_mocks, tmp_path, capsys): assert record["error"] == "missing_flow" def test_unknown_step(self, workspace_mocks, tmp_path, capsys): - workspace_mocks.selected_error = ValueError("unknown step 'bogus'") - rc = cli_main.run( ["run", "--workspace", str(tmp_path / "workspace"), "--only", "bogus", "--json"] ) diff --git a/test/cli/rendering/test_progress.py b/test/cli/rendering/test_progress.py index 442049e8..bc154a8c 100644 --- a/test/cli/rendering/test_progress.py +++ b/test/cli/rendering/test_progress.py @@ -1,4 +1,5 @@ import io +import json import os import re import sys @@ -6,8 +7,6 @@ import time from pathlib import Path -import pytest - import chipcompiler.cli.rendering.progress as progress from chipcompiler.cli.core.types import CommandContext, OutputMode from chipcompiler.cli.inspection.log_view import LineKind, LogLine @@ -15,7 +14,6 @@ from chipcompiler.cli.rendering.progress import ( RunProgressRenderer, format_error_context, - latest_log_line, run_flow_with_progress, sanitize_log_line, should_enable_run_progress, @@ -23,8 +21,7 @@ supports_color, truncate_to_width, ) -from chipcompiler.data import LogPaths, StateEnum -from chipcompiler.utility.log import redirect_stdio_to_file +from chipcompiler.runtime.worker_operation import OperationResult _ANSI_RE = re.compile(r"\x1b\[[0-9;]*[a-zA-Z]") @@ -202,178 +199,6 @@ def test_small_width(self): assert truncate_to_width("hello", 2) == "he" -# -- latest_log_line -- - - -class TestLatestLogLine: - def test_returns_last_nonempty_line(self, tmp_path): - log = tmp_path / "test.log" - log.write_text("line one\nline two\n\n") - assert latest_log_line(str(log)) == "line two" - - def test_returns_none_for_missing_file(self): - assert latest_log_line("/nonexistent/file.log") is None - - def test_returns_none_for_empty_file(self, tmp_path): - log = tmp_path / "empty.log" - log.write_text("") - assert latest_log_line(str(log)) is None - - def test_returns_none_for_none_path(self): - assert latest_log_line(None) is None - - def test_sanitizes_ansi_in_line(self, tmp_path): - log = tmp_path / "ansi.log" - log.write_text("\x1b[32mprogress\x1b[0m\n") - assert latest_log_line(str(log)) == "progress" - - def test_trailing_newlines_only(self, tmp_path): - log = tmp_path / "nl.log" - log.write_text("\n\n\n") - assert latest_log_line(str(log)) is None - - -# -- incremental log tail -- - - -class TestIncrementalLogTail: - def test_reads_only_appended_complete_lines(self, tmp_path): - log = tmp_path / "step.log" - log.write_text("first\n") - tail = progress._IncrementalLogTail(str(log), "floorplan", stale_after=10.0) - - assert tail.poll(now=0.0) == "first" - - log.write_text("first\nsecond\n") - - assert tail.poll(now=1.0) == "second" - - def test_carries_partial_line_until_newline_arrives(self, tmp_path): - log = tmp_path / "step.log" - log.write_text("partial") - tail = progress._IncrementalLogTail(str(log), "floorplan", stale_after=10.0) - - assert tail.poll(now=0.0) == "running floorplan, waiting for step log 0s..." - - log.write_text("partial line\n") - - assert tail.poll(now=1.0) == "partial line" - - def test_ignores_empty_or_pure_control_lines(self, tmp_path): - log = tmp_path / "step.log" - log.write_text("\x1b[31m\x1b[0m\n\nreal\n") - tail = progress._IncrementalLogTail(str(log), "floorplan", stale_after=10.0) - - assert tail.poll(now=0.0) == "real" - - def test_restarts_when_file_is_truncated(self, tmp_path): - log = tmp_path / "step.log" - log.write_text("old\n") - tail = progress._IncrementalLogTail(str(log), "floorplan", stale_after=10.0) - assert tail.poll(now=0.0) == "old" - - log.write_text("new\n") - - assert tail.poll(now=1.0) == "new" - - def test_restarts_when_replaced_file_grows_beyond_previous_offset(self, tmp_path): - log = tmp_path / "step.log" - log.write_text("old\n") - tail = progress._IncrementalLogTail(str(log), "floorplan", stale_after=10.0) - assert tail.poll(now=0.0) == "old" - - log.write_text("replacement line\n") - - assert tail.poll(now=1.0) == "replacement line" - - def test_reports_stale_status_without_losing_last_line(self, tmp_path): - log = tmp_path / "step.log" - log.write_text("StaDataPropagation.cc:710] data bwd propagation start\n") - tail = progress._IncrementalLogTail(str(log), "fixfanout", stale_after=5.0) - assert tail.poll(now=10.0) == "StaDataPropagation.cc:710] data bwd propagation start" - - assert ( - tail.poll(now=16.0) == "running fixfanout, last log 6s ago: " - "StaDataPropagation.cc:710] data bwd propagation start" - ) - assert tail.last_line == "StaDataPropagation.cc:710] data bwd propagation start" - - -class TestMonitorLogProgress: - def test_late_created_log_updates_after_initial_waiting_status(self, tmp_path): - log = tmp_path / "late.log" - renderer = RecordingRenderer() - stop_event = threading.Event() - monitor = threading.Thread( - target=progress._monitor_log_progress, - args=(renderer, str(log), "floorplan", stop_event), - kwargs={"interval": 0.01, "stale_after": 10.0}, - daemon=True, - ) - - monitor.start() - try: - assert _wait_until( - lambda: renderer.has_line_containing("waiting for step log"), timeout=1.0 - ) - log.write_text("first appended line\n") - assert _wait_until( - lambda: renderer.has_line_containing("first appended line"), timeout=1.0 - ) - finally: - stop_event.set() - monitor.join(timeout=1.0) - - def test_silent_log_switches_from_banner_to_stale_status(self, tmp_path): - log = tmp_path / "silent.log" - log.write_text("|_| |_/_/\\_\\ |_|\n") - renderer = RecordingRenderer() - stop_event = threading.Event() - monitor = threading.Thread( - target=progress._monitor_log_progress, - args=(renderer, str(log), "fixfanout", stop_event), - kwargs={"interval": 0.01, "stale_after": 0.03}, - daemon=True, - ) - - monitor.start() - try: - assert _wait_until(lambda: renderer.has_line_containing("|_| |_"), timeout=1.0) - assert _wait_until(lambda: renderer.has_line_containing("last log"), timeout=1.0) - assert renderer.has_line_containing("running fixfanout") - finally: - stop_event.set() - monitor.join(timeout=1.0) - - def test_isolated_monitor_renders_stale_status_while_main_thread_is_busy(self, tmp_path): - log = tmp_path / "silent.log" - output = tmp_path / "progress.txt" - log.write_text("|_| |_/_/\\_\\ |_|\n") - - with open(output, "w", encoding="utf-8", buffering=1) as stream: - renderer = RunProgressRenderer(stream, color=False) - stop_event, monitor = progress._start_log_monitor( - renderer, - str(log), - "fixfanout", - isolated=True, - interval=0.01, - stale_after=0.03, - ) - previous_interval = sys.getswitchinterval() - try: - sys.setswitchinterval(0.5) - deadline = time.time() + 0.15 - while time.time() < deadline: - pass - finally: - sys.setswitchinterval(previous_interval) - stop_event.set() - monitor.join(timeout=1.0) - - assert "running fixfanout, last log" in output.read_text() - - # -- RunProgressRenderer -- @@ -579,270 +404,337 @@ def test_preserves_fd_stream_error_handler(self, tmp_path): assert "\\u2713" in path.read_text() -class TestPreserveCliStdio: - def test_restores_fd_stdout_stderr_after_redirect(self, tmp_path, capfd): - log_file = tmp_path / "step.log" +# --------------------------------------------------------------------------- +# Failure context block formatting (AC-5) +# --------------------------------------------------------------------------- - with progress.preserve_cli_stdio(): - redirected = redirect_stdio_to_file(str(log_file)) - print("inside stdout") - sys.stderr.write("inside stderr\n") - redirected.flush() - print("after stdout") - sys.stderr.write("after stderr\n") +class TestFormatErrorContext: + def test_first_line_is_error_log_path(self): + ctx_lines = [LogLine(10, LineKind.ERROR, "Error: something")] + out = format_error_context("log/synthesis.log", ctx_lines, "ecc log synthesis", color=False) + assert out.startswith("error: log/synthesis.log") - captured = capfd.readouterr() - assert "after stdout" in captured.out - assert "after stderr" in captured.err - assert "after stdout" not in log_file.read_text() - assert "after stderr" not in log_file.read_text() + def test_includes_numbered_context_lines(self): + ctx_lines = [ + LogLine(8, LineKind.INFO, "INFO: before"), + LogLine(9, LineKind.WARNING, "Warning: careful"), + LogLine(10, LineKind.ERROR, "Error: failed"), + ] + out = format_error_context("log/synthesis.log", ctx_lines, "ecc log synthesis", color=False) + for ll in ctx_lines: + assert str(ll.line_no) in out + assert ll.text in out - def test_restores_fd_stdout_stderr_after_exception(self, tmp_path, capfd): - log_file = tmp_path / "step.log" + def test_compact_kind_labels(self): + ctx_lines = [ + LogLine(5, LineKind.ERROR, "bad"), + LogLine(6, LineKind.WARNING, "meh"), + LogLine(7, LineKind.TRACEBACK, " File ..."), + LogLine(8, LineKind.INFO, "ok"), + ] + out = format_error_context("log/p.log", ctx_lines, "ecc log step", color=False) + assert "ERROR" in out + assert "WARN" in out + assert "TRACE" in out + assert "INFO" in out - with pytest.raises(RuntimeError, match="boom"), progress.preserve_cli_stdio(): - redirect_stdio_to_file(str(log_file)) - raise RuntimeError("boom") + def test_footer_includes_for_more_log_info(self): + ctx_lines = [LogLine(1, LineKind.ERROR, "failed")] + out = format_error_context( + "log/p.log", ctx_lines, "ecc log synthesis --project myproj", color=False + ) + assert "For more log info:" in out + assert "ecc log synthesis --project myproj" in out - print("after stdout") - sys.stderr.write("after stderr\n") + def test_footer_includes_command_grep_field(self): + ctx_lines = [LogLine(1, LineKind.ERROR, "failed")] + log_cmd = "ecc log synthesis --project myproj --run-id abc123" + out = format_error_context("log/p.log", ctx_lines, log_cmd, color=False) + assert 'command="ecc log synthesis --project myproj --run-id abc123"' in out - captured = capfd.readouterr() - assert "after stdout" in captured.out - assert "after stderr" in captured.err - assert "after stdout" not in log_file.read_text() - assert "after stderr" not in log_file.read_text() - - -# -- run_flow_with_progress -- - - -def _make_ws(directory="/tmp", log_section_fn=None): - section_fn = log_section_fn or (lambda self, msg: None) - return type( - "WS", - (), - { - "home": type("Home", (), {"reset": lambda self: None})(), - "logger": type( - "L", - (), - { - "info": lambda *a, **k: None, - "log_section": section_fn, - "log_separator": lambda *a, **k: None, - }, - )(), - "flow": type("F", (), {"data": {"steps": []}, "path": ""})(), - "directory": directory, - }, - )() - - -def _make_step(name, tool, log_file=""): - log = LogPaths(file=Path(log_file)) if log_file else LogPaths() - return type("WSS", (), {"name": name, "tool": tool, "log": log})() - - -def _make_flow(ws, steps, run_step_fn, init_db_engine_fn=None, check_state_fn=None): - if init_db_engine_fn is None: - - def init_db_engine_fn(self): - return None - - if check_state_fn is None: - - def check_state_fn(self, name, tool, state): - return False - - return type( - "EF", - (), - { - "workspace": ws, - "workspace_steps": steps, - "init_db_engine": init_db_engine_fn, - "run_step": run_step_fn, - "check_state": check_state_fn, - }, - )() + def test_project_and_run_id_preserved_in_footer(self): + ctx_lines = [LogLine(1, LineKind.ERROR, "failed")] + log_cmd = "ecc log synthesis --project /path/to/proj --run-id run42" + out = format_error_context("log/p.log", ctx_lines, log_cmd, color=False) + assert "--project /path/to/proj" in out + assert "--run-id run42" in out + + def test_color_gating_no_ansi_when_disabled(self): + ctx_lines = [LogLine(10, LineKind.ERROR, "Error: bad")] + out = format_error_context("log/p.log", ctx_lines, "ecc log step", color=False) + assert "\x1b[" not in out + + def test_color_gating_ansi_when_enabled(self): + ctx_lines = [LogLine(10, LineKind.ERROR, "Error: bad")] + out = format_error_context("log/p.log", ctx_lines, "ecc log step", color=True) + assert "\x1b[" in out + + def test_line_number_padding_consistent(self): + ctx_lines = [ + LogLine(1, LineKind.PLAIN, "first"), + LogLine(10, LineKind.ERROR, "error"), + LogLine(100, LineKind.PLAIN, "hundred"), + ] + out = format_error_context("log/p.log", ctx_lines, "ecc log step", color=False) + lines = out.strip().split("\n") + context_lines = [ + line + for line in lines + if line.strip() + and not line.startswith("error:") + and not line.startswith("For") + and not line.startswith("command=") + ] + for line in context_lines: + assert line.startswith(" ") + + def test_empty_context(self): + out = format_error_context("log/p.log", [], "ecc log step", color=False) + assert "error: log/p.log" in out + assert "For more log info:" in out + + +# --------------------------------------------------------------------------- +# run_flow_with_progress (worker-driven) +# --------------------------------------------------------------------------- + + +def _write_flow_json(workspace_dir, steps): + home = Path(workspace_dir) / "home" + home.mkdir(parents=True, exist_ok=True) + (home / "flow.json").write_text(json.dumps({"steps": steps})) + + +def _step_record(name, tool, state, runtime="0:00:01"): + return {"name": name, "tool": tool, "state": state, "runtime": runtime} + + +def _write_archived_log(workspace_dir, step, tool, content): + log_path = Path(workspace_dir) / f"{step}_{tool}" / "log" / f"{step}.log" + log_path.parent.mkdir(parents=True, exist_ok=True) + log_path.write_text(content) + return log_path + + +def _make_operation(workspace_dir, events, result): + """Replay reader-callback events against a flow.json progression.""" + + def run_operation(on_output, on_step_event): + for event in events: + kind = event[0] + if kind == "begin": + on_step_event("begin", event[1], event[2]) + elif kind == "output": + on_output(event[1]) + elif kind == "states": + _write_flow_json(workspace_dir, event[1]) + return result + + return run_operation class TestRunFlowWithProgress: def test_success_summary_format(self, tmp_path): - flow = _make_flow( - _make_ws(str(tmp_path)), - [_make_step("Synthesis", "yosys", str(tmp_path / "synth.log"))], - lambda self, s: StateEnum.Success, + workspace = tmp_path / "workspace" + _write_flow_json(workspace, [_step_record("Synthesis", "yosys", "Unstart")]) + operation = _make_operation( + workspace, + [ + ("begin", "Synthesis", "yosys"), + ("states", [_step_record("Synthesis", "yosys", "Success")]), + ], + OperationResult(success=True, exit_code=0), ) buf = FakeTTYStderr(isatty_value=True) - result = run_flow_with_progress(flow, _make_ctx(), None, buf) - assert result is True + result = run_flow_with_progress(str(workspace), _make_ctx(), None, buf, operation) + + assert result.success is True output = "".join(buf.written) assert "✓ synthesis (yosys)" in output - assert "status=success" not in output - def test_stops_on_failure(self): - call_count = [0] + def test_result_passed_through(self, tmp_path): + workspace = tmp_path / "workspace" + _write_flow_json(workspace, []) + failure = OperationResult(success=False, error="worker exploded") + operation = _make_operation(workspace, [], failure) - def fake_run_step(self, s): - call_count[0] += 1 - if s.name == "Synthesis": - return StateEnum.Success - return StateEnum.Imcomplete + buf = FakeTTYStderr(isatty_value=True) + result = run_flow_with_progress(str(workspace), _make_ctx(), None, buf, operation) - flow = _make_flow( - _make_ws(), - [_make_step("Synthesis", "yosys"), _make_step("Floorplan", "ecc")], - fake_run_step, - ) + assert result is failure - buf = FakeTTYStderr(isatty_value=True) - result = run_flow_with_progress(flow, _make_ctx(), None, buf) - assert result is False - assert call_count[0] == 2 - - def test_summary_includes_inspect_detail_line(self): - flow = _make_flow( - _make_ws(), - [_make_step("Synthesis", "yosys", "/tmp/synth.log")], - lambda self, s: StateEnum.Success, + def test_step_headers_emitted(self, tmp_path): + workspace = tmp_path / "workspace" + _write_flow_json(workspace, []) + operation = _make_operation( + workspace, + [ + ("begin", "Synthesis", "yosys"), + ("begin", "Floorplan", "ecc"), + ], + OperationResult(success=True, exit_code=0), ) buf = FakeTTYStderr(isatty_value=True) - run_flow_with_progress(flow, _make_ctx(), "myproject", buf) + run_flow_with_progress(str(workspace), _make_ctx(), None, buf, operation) plain = _strip_ansi("".join(buf.written)) - assert " inspect: ecc log synthesis --project myproject\n" in plain - - def test_summary_includes_log_detail_line(self, tmp_path): - log_file = tmp_path / "synth.log" - log_file.write_text("content\n") + assert "> synthesis (yosys)\n" in plain + assert "> floorplan (ecc)\n" in plain - flow = _make_flow( - _make_ws(str(tmp_path)), - [_make_step("Synthesis", "yosys", str(log_file))], - lambda self, s: StateEnum.Success, - ) + def test_run_header_emitted(self, tmp_path): + workspace = tmp_path / "workspace" + _write_flow_json(workspace, []) + operation = _make_operation(workspace, [], OperationResult(success=True, exit_code=0)) buf = FakeTTYStderr(isatty_value=True) - run_flow_with_progress(flow, _make_ctx(), None, buf) + run_flow_with_progress(str(workspace), _make_ctx(), None, buf, operation) output = "".join(buf.written) - assert " log:" in output + assert "[run]" in output + assert "workspace=" in output def test_run_label_uses_ctx_run_id(self, tmp_path): - run_dir = tmp_path / "sweeps" / "s1" / "r4" - flow = _make_flow( - _make_ws(str(run_dir)), - [_make_step("Synthesis", "yosys", str(tmp_path / "synth.log"))], - lambda self, s: StateEnum.Success, - ) + workspace = tmp_path / "workspace" + _write_flow_json(workspace, []) + operation = _make_operation(workspace, [], OperationResult(success=True, exit_code=0)) buf = FakeTTYStderr(isatty_value=True) - result = run_flow_with_progress(flow, _make_ctx(run_id="sweeps/s1/r4"), None, buf) + run_flow_with_progress( + str(workspace), _make_ctx(run_id="sweeps/s1/r4"), None, buf, operation + ) + plain = _strip_ansi("".join(buf.written)) + assert f"[run] sweeps/s1/r4 workspace={workspace}\n" in plain + + def test_summary_includes_inspect_detail_line(self, tmp_path): + workspace = tmp_path / "workspace" + _write_flow_json(workspace, [_step_record("Synthesis", "yosys", "Unstart")]) + operation = _make_operation( + workspace, + [ + ("begin", "Synthesis", "yosys"), + ("states", [_step_record("Synthesis", "yosys", "Success")]), + ], + OperationResult(success=True, exit_code=0), + ) - assert result is True + buf = FakeTTYStderr(isatty_value=True) + run_flow_with_progress(str(workspace), _make_ctx(), "myproject", buf, operation) plain = _strip_ansi("".join(buf.written)) - assert f"[run] sweeps/s1/r4 workspace={run_dir}\n" in plain + assert " inspect: ecc log synthesis --project myproject\n" in plain - def test_inspect_detail_carries_run_id(self): - flow = _make_flow( - _make_ws(), - [_make_step("Synthesis", "yosys", "/tmp/synth.log")], - lambda self, s: StateEnum.Success, + def test_inspect_detail_carries_run_id(self, tmp_path): + workspace = tmp_path / "workspace" + _write_flow_json(workspace, [_step_record("Synthesis", "yosys", "Unstart")]) + operation = _make_operation( + workspace, + [("states", [_step_record("Synthesis", "yosys", "Success")])], + OperationResult(success=True, exit_code=0), ) buf = FakeTTYStderr(isatty_value=True) - run_flow_with_progress(flow, _make_ctx(run_id="exp1"), "myproject", buf) + run_flow_with_progress( + str(workspace), _make_ctx(run_id="exp1"), "myproject", buf, operation + ) plain = _strip_ansi("".join(buf.written)) assert " inspect: ecc log synthesis --project myproject --run-id exp1\n" in plain - def test_step_headers_emitted(self): - flow = _make_flow( - _make_ws(), + def test_summary_includes_log_detail_line(self, tmp_path): + workspace = tmp_path / "workspace" + _write_flow_json(workspace, [_step_record("Synthesis", "yosys", "Unstart")]) + operation = _make_operation( + workspace, [ - _make_step("Synthesis", "yosys"), - _make_step("Floorplan", "ecc"), + ("begin", "Synthesis", "yosys"), + ("states", [_step_record("Synthesis", "yosys", "Success")]), ], - lambda self, s: StateEnum.Success, + OperationResult(success=True, exit_code=0), ) buf = FakeTTYStderr(isatty_value=True) - result = run_flow_with_progress(flow, _make_ctx(), None, buf) - assert result is True + run_flow_with_progress(str(workspace), _make_ctx(), None, buf, operation) plain = _strip_ansi("".join(buf.written)) - assert "> synthesis (yosys)\n" in plain - assert "> floorplan (ecc)\n" in plain + assert " log: Synthesis_yosys/log/Synthesis.log" in plain - def test_run_header_emitted(self, tmp_path): - flow = _make_flow( - _make_ws(str(tmp_path)), - [_make_step("Synthesis", "yosys")], - lambda self, s: StateEnum.Success, + def test_block_separator_between_steps(self, tmp_path): + workspace = tmp_path / "workspace" + _write_flow_json(workspace, []) + operation = _make_operation( + workspace, + [ + ("begin", "Synthesis", "yosys"), + ("states", [_step_record("Synthesis", "yosys", "Success")]), + ("begin", "Floorplan", "ecc"), + ], + OperationResult(success=True, exit_code=0), ) buf = FakeTTYStderr(isatty_value=True) - run_flow_with_progress(flow, _make_ctx(), None, buf) + run_flow_with_progress(str(workspace), _make_ctx(), None, buf, operation) output = "".join(buf.written) - assert "[run]" in output - assert "workspace=" in output - - def test_block_separator_between_steps(self): - flow = _make_flow( - _make_ws(), + synth_summary = output.find("✓ synthesis") + fp_header = output.find("> floorplan") + assert synth_summary >= 0 + assert fp_header >= 0 + assert "\n\n" in output[synth_summary:fp_header] + + def test_previous_step_rendered_before_next_header(self, tmp_path): + workspace = tmp_path / "workspace" + _write_flow_json(workspace, []) + operation = _make_operation( + workspace, [ - _make_step("Synthesis", "yosys"), - _make_step("Floorplan", "ecc"), + ("begin", "Synthesis", "yosys"), + ("states", [_step_record("Synthesis", "yosys", "Success")]), + ("begin", "Floorplan", "ecc"), ], - lambda self, s: StateEnum.Success, + OperationResult(success=True, exit_code=0), ) buf = FakeTTYStderr(isatty_value=True) - result = run_flow_with_progress(flow, _make_ctx(), None, buf) - assert result is True + run_flow_with_progress(str(workspace), _make_ctx(), None, buf, operation) output = "".join(buf.written) synth_summary = output.find("✓ synthesis") fp_header = output.find("> floorplan") - between = output[synth_summary:fp_header] - assert "\n\n" in between - - def test_failure_summary_includes_status(self): - def fake_run_step(self, s): - if s.name == "Synthesis": - return StateEnum.Success - return StateEnum.Imcomplete - - flow = _make_flow( - _make_ws(), - [_make_step("Synthesis", "yosys"), _make_step("Floorplan", "ecc")], - fake_run_step, + assert synth_summary >= 0 + assert fp_header > synth_summary + + def test_failure_summary_includes_status(self, tmp_path): + workspace = tmp_path / "workspace" + _write_flow_json(workspace, []) + operation = _make_operation( + workspace, + [ + ("begin", "Synthesis", "yosys"), + ("states", [_step_record("Synthesis", "yosys", "Success")]), + ("begin", "Floorplan", "ecc"), + ("states", [_step_record("Floorplan", "ecc", "Imcomplete", "0:00:02")]), + ], + OperationResult(success=False, error="run flow failed"), ) buf = FakeTTYStderr(isatty_value=True) - result = run_flow_with_progress(flow, _make_ctx(), None, buf) - assert result is False + result = run_flow_with_progress(str(workspace), _make_ctx(), None, buf, operation) + assert result.success is False plain = _strip_ansi("".join(buf.written)) assert "✗ floorplan (ecc)" in plain - assert "incomplete" in plain + assert "imcomplete" in plain def test_transient_line_shows_log_content(self, tmp_path): - log_file = tmp_path / "synth.log" - - def fake_run_step(self, s): - log_file.write_text("Synthesizing module top\n") - time.sleep(1.0) - return StateEnum.Success - - flow = _make_flow( - _make_ws(str(tmp_path)), - [_make_step("Synthesis", "yosys", str(log_file))], - fake_run_step, + workspace = tmp_path / "workspace" + _write_flow_json(workspace, [_step_record("Synthesis", "yosys", "Unstart")]) + operation = _make_operation( + workspace, + [ + ("begin", "Synthesis", "yosys"), + ("output", b"Synthesizing module top\n"), + ("states", [_step_record("Synthesis", "yosys", "Success")]), + ], + OperationResult(success=True, exit_code=0), ) buf = FakeTTYStderr(isatty_value=True) - result = run_flow_with_progress(flow, _make_ctx(), None, buf) - assert result is True + run_flow_with_progress(str(workspace), _make_ctx(), None, buf, operation) plain = _strip_ansi("".join(buf.written)) assert "Synthesizing module top" in plain @@ -852,402 +744,135 @@ def fake_run_step(self, s): assert summary_pos >= 0 assert log_pos < summary_pos - def test_transient_shows_waiting_when_no_log(self): - def fake_run_step(self, s): - time.sleep(1.0) - return StateEnum.Success - - flow = _make_flow( - _make_ws(), - [_make_step("Synthesis", "yosys", "/tmp/nonexistent_synth.log")], - fake_run_step, + def test_output_burst_is_throttled(self, tmp_path): + workspace = tmp_path / "workspace" + _write_flow_json(workspace, [_step_record("Synthesis", "yosys", "Unstart")]) + operation = _make_operation( + workspace, + [ + ("begin", "Synthesis", "yosys"), + ("output", b"line one\n"), + ("output", b"line two\n"), + ], + OperationResult(success=True, exit_code=0), ) buf = FakeTTYStderr(isatty_value=True) - result = run_flow_with_progress(flow, _make_ctx(), None, buf) - assert result is True + run_flow_with_progress(str(workspace), _make_ctx(), None, buf, operation) plain = _strip_ansi("".join(buf.written)) - assert " log: running synthesis, waiting for step log" in plain - - def test_log_section_markers_emitted(self, tmp_path): - sections = [] - flow = _make_flow( - _make_ws(str(tmp_path), log_section_fn=lambda self, msg: sections.append(msg)), - [_make_step("Synthesis", "yosys")], - lambda self, s: StateEnum.Success, - ) - - buf = FakeTTYStderr(isatty_value=True) - run_flow_with_progress(flow, _make_ctx(), None, buf) - - assert "yosys - begin step - Synthesis" in sections - assert "yosys - end step - Synthesis" in sections - assert sections.index("yosys - begin step - Synthesis") < sections.index( - "yosys - end step - Synthesis" - ) - - def test_log_section_markers_around_run_step(self, tmp_path): - call_order = [] - - def fake_run_step(self, s): - call_order.append(("run_step", s.name)) - return StateEnum.Success - - flow = _make_flow( - _make_ws( - str(tmp_path), log_section_fn=lambda self, msg: call_order.append(("section", msg)) - ), - [_make_step("Floorplan", "ecc")], - fake_run_step, - ) - - buf = FakeTTYStderr(isatty_value=True) - run_flow_with_progress(flow, _make_ctx(), None, buf) - - begin_idx = call_order.index(("section", "ecc - begin step - Floorplan")) - run_idx = call_order.index(("run_step", "Floorplan")) - end_idx = call_order.index(("section", "ecc - end step - Floorplan")) - assert begin_idx < run_idx < end_idx - - def test_init_db_engine_called_before_run_step(self, tmp_path): - call_order = [] - - def fake_init_db_engine(self): - call_order.append(("init_db_engine",)) - - def fake_run_step(self, s): - call_order.append(("run_step", s.name)) - return StateEnum.Success - - flow = _make_flow( - _make_ws( - str(tmp_path), log_section_fn=lambda self, msg: call_order.append(("section", msg)) - ), - [_make_step("Synthesis", "yosys")], - fake_run_step, - init_db_engine_fn=fake_init_db_engine, - ) - - buf = FakeTTYStderr(isatty_value=True) - run_flow_with_progress(flow, _make_ctx(), None, buf) - - begin_idx = call_order.index(("section", "yosys - begin step - Synthesis")) - init_idx = call_order.index(("init_db_engine",)) - run_idx = call_order.index(("run_step", "Synthesis")) - end_idx = call_order.index(("section", "yosys - end step - Synthesis")) - assert begin_idx < init_idx < run_idx < end_idx - - def test_restores_progress_output_after_run_step_redirects_stdio(self, tmp_path, capfd): - log_file = tmp_path / "place.log" - call_order = [] - - def fake_init_db_engine(self): - call_order.append(("init_db_engine",)) - - def fake_run_step(self, s): - call_order.append(("run_step", s.name)) - redirected = redirect_stdio_to_file(str(log_file)) - print("raw tool stdout") - sys.stderr.write("Plotting array maps: 57%\n") - redirected.flush() - time.sleep(1.0) - return StateEnum.Success - - flow = _make_flow( - _make_ws( - str(tmp_path), log_section_fn=lambda self, msg: call_order.append(("section", msg)) - ), - [_make_step("placement", "dreamplace", str(log_file))], - fake_run_step, - init_db_engine_fn=fake_init_db_engine, - ) - - result = run_flow_with_progress(flow, _make_ctx(), "myproj", sys.stderr) - print("after progress stdout") - sys.stderr.write("after progress stderr\n") - - captured = capfd.readouterr() - terminal = _strip_ansi(captured.err) - step_log = log_file.read_text() - - assert result is True - assert "> placement (dreamplace)\n" in terminal - assert "Plotting array maps: 57%" in terminal - assert "✓ placement (dreamplace)" in terminal - assert "after progress stdout" in captured.out - assert "after progress stderr" in captured.err - - assert "raw tool stdout" in step_log - assert "Plotting array maps: 57%" in step_log - assert "> placement (dreamplace)" not in step_log - assert "log: waiting for log..." not in step_log - assert "✓ placement (dreamplace)" not in step_log - assert "after progress stdout" not in step_log - assert "after progress stderr" not in step_log - - begin_idx = call_order.index(("section", "dreamplace - begin step - placement")) - init_idx = call_order.index(("init_db_engine",)) - run_idx = call_order.index(("run_step", "placement")) - end_idx = call_order.index(("section", "dreamplace - end step - placement")) - assert begin_idx < init_idx < run_idx < end_idx - - def test_captures_init_db_engine_output_in_step_log(self, tmp_path, capfd): - log_file = tmp_path / "floorplan.log" - - def fake_init_db_engine(self): - print("raw init stdout") - sys.stderr.write("raw init stderr\n") - sys.stderr.flush() - - def fake_run_step(self, s): - time.sleep(1.0) - return StateEnum.Success - - flow = _make_flow( - _make_ws(str(tmp_path)), - [_make_step("Floorplan", "ecc", str(log_file))], - fake_run_step, - init_db_engine_fn=fake_init_db_engine, - ) - - result = run_flow_with_progress(flow, _make_ctx(), "myproj", sys.stderr) - - captured = capfd.readouterr() - terminal = _strip_ansi(captured.err) - step_log = log_file.read_text() - - assert result is True - assert "raw init stdout" in step_log - assert "raw init stderr" in step_log - assert "raw init stdout" not in captured.out - assert "log: raw init stderr" in terminal - assert "\nraw init stderr\n" not in terminal - - def test_does_not_initialize_db_for_skipped_progress_step(self, tmp_path): - synth_log = tmp_path / "synth.log" - floorplan_log = tmp_path / "floorplan.log" - init_calls = [] - - def fake_check_state(self, name, tool, state): - return name == "Synthesis" and state == StateEnum.Success - - def fake_init_db_engine(self): - init_calls.append("init_db_engine") - print("init for step") - - def fake_run_step(self, s): - return StateEnum.Success - - flow = _make_flow( - _make_ws(str(tmp_path)), + assert "line one" in plain + assert "line two" not in plain + + def test_marker_text_never_rendered(self, tmp_path): + workspace = tmp_path / "workspace" + _write_flow_json(workspace, [_step_record("Synthesis", "yosys", "Unstart")]) + operation = _make_operation( + workspace, [ - _make_step("Synthesis", "yosys", str(synth_log)), - _make_step("Floorplan", "ecc", str(floorplan_log)), + ("begin", "Synthesis", "yosys"), + ("states", [_step_record("Synthesis", "yosys", "Success")]), ], - fake_run_step, - init_db_engine_fn=fake_init_db_engine, - check_state_fn=fake_check_state, + OperationResult(success=True, exit_code=0), ) buf = FakeTTYStderr(isatty_value=True) - result = run_flow_with_progress(flow, _make_ctx(), "myproj", buf) - - assert result is True - assert init_calls == ["init_db_engine"] - assert not synth_log.exists() - assert "init for step" in floorplan_log.read_text() - - def test_monitor_cleanup_on_run_step_exception(self, tmp_path): - def raising_run_step(self, s): - raise RuntimeError("tool crashed") - - flow = _make_flow( - _make_ws(str(tmp_path)), - [_make_step("Synthesis", "yosys")], - raising_run_step, - ) - - buf = FakeTTYStderr(isatty_value=True) - with pytest.raises(RuntimeError, match="tool crashed"): - run_flow_with_progress(flow, _make_ctx(), None, buf) - - output = "".join(buf.written) - assert "\r\x1b[K" in output + run_flow_with_progress(str(workspace), _make_ctx(), None, buf, operation) + plain = _strip_ansi("".join(buf.written)) + assert "ECC-STEP" not in plain - def test_color_enabled_for_tty_text(self, monkeypatch): + def test_color_enabled_for_tty_text(self, tmp_path, monkeypatch): monkeypatch.delenv("NO_COLOR", raising=False) monkeypatch.setenv("TERM", "xterm-256color") - - flow = _make_flow( - _make_ws(), - [_make_step("Synthesis", "yosys")], - lambda self, s: StateEnum.Success, + workspace = tmp_path / "workspace" + _write_flow_json(workspace, []) + operation = _make_operation( + workspace, + [("begin", "Synthesis", "yosys")], + OperationResult(success=True, exit_code=0), ) buf = FakeTTYStderr(isatty_value=True) - run_flow_with_progress(flow, _make_ctx(), None, buf) + run_flow_with_progress(str(workspace), _make_ctx(), None, buf, operation) output = "".join(buf.written) assert "\x1b[36m" in output # cyan for step header - def test_color_disabled_for_non_tty(self): - flow = _make_flow( - _make_ws(), - [_make_step("Synthesis", "yosys")], - lambda self, s: StateEnum.Success, + def test_color_disabled_for_non_tty(self, tmp_path): + workspace = tmp_path / "workspace" + _write_flow_json(workspace, []) + operation = _make_operation( + workspace, + [("begin", "Synthesis", "yosys")], + OperationResult(success=True, exit_code=0), ) buf = FakeTTYStderr(isatty_value=False) - run_flow_with_progress(flow, _make_ctx(), None, buf) + run_flow_with_progress(str(workspace), _make_ctx(), None, buf, operation) output = "".join(buf.written) for code in (BOLD, CYAN, GREEN, RED, DIM): assert code not in output # --------------------------------------------------------------------------- -# Failure context block formatting (AC-5) -# --------------------------------------------------------------------------- - - -class TestFormatErrorContext: - def test_first_line_is_error_log_path(self): - ctx_lines = [LogLine(10, LineKind.ERROR, "Error: something")] - out = format_error_context("log/synthesis.log", ctx_lines, "ecc log synthesis", color=False) - assert out.startswith("error: log/synthesis.log") - - def test_includes_numbered_context_lines(self): - ctx_lines = [ - LogLine(8, LineKind.INFO, "INFO: before"), - LogLine(9, LineKind.WARNING, "Warning: careful"), - LogLine(10, LineKind.ERROR, "Error: failed"), - ] - out = format_error_context("log/synthesis.log", ctx_lines, "ecc log synthesis", color=False) - for ll in ctx_lines: - assert str(ll.line_no) in out - assert ll.text in out - - def test_compact_kind_labels(self): - ctx_lines = [ - LogLine(5, LineKind.ERROR, "bad"), - LogLine(6, LineKind.WARNING, "meh"), - LogLine(7, LineKind.TRACEBACK, " File ..."), - LogLine(8, LineKind.INFO, "ok"), - ] - out = format_error_context("log/p.log", ctx_lines, "ecc log step", color=False) - assert "ERROR" in out - assert "WARN" in out - assert "TRACE" in out - assert "INFO" in out - - def test_footer_includes_for_more_log_info(self): - ctx_lines = [LogLine(1, LineKind.ERROR, "failed")] - out = format_error_context( - "log/p.log", ctx_lines, "ecc log synthesis --project myproj", color=False - ) - assert "For more log info:" in out - assert "ecc log synthesis --project myproj" in out - - def test_footer_includes_command_grep_field(self): - ctx_lines = [LogLine(1, LineKind.ERROR, "failed")] - log_cmd = "ecc log synthesis --project myproj --run-id abc123" - out = format_error_context("log/p.log", ctx_lines, log_cmd, color=False) - assert 'command="ecc log synthesis --project myproj --run-id abc123"' in out - - def test_project_and_run_id_preserved_in_footer(self): - ctx_lines = [LogLine(1, LineKind.ERROR, "failed")] - log_cmd = "ecc log synthesis --project /path/to/proj --run-id run42" - out = format_error_context("log/p.log", ctx_lines, log_cmd, color=False) - assert "--project /path/to/proj" in out - assert "--run-id run42" in out - - def test_color_gating_no_ansi_when_disabled(self): - ctx_lines = [LogLine(10, LineKind.ERROR, "Error: bad")] - out = format_error_context("log/p.log", ctx_lines, "ecc log step", color=False) - assert "\x1b[" not in out - - def test_color_gating_ansi_when_enabled(self): - ctx_lines = [LogLine(10, LineKind.ERROR, "Error: bad")] - out = format_error_context("log/p.log", ctx_lines, "ecc log step", color=True) - assert "\x1b[" in out - - def test_line_number_padding_consistent(self): - ctx_lines = [ - LogLine(1, LineKind.PLAIN, "first"), - LogLine(10, LineKind.ERROR, "error"), - LogLine(100, LineKind.PLAIN, "hundred"), - ] - out = format_error_context("log/p.log", ctx_lines, "ecc log step", color=False) - lines = out.strip().split("\n") - context_lines = [ - line - for line in lines - if line.strip() - and not line.startswith("error:") - and not line.startswith("For") - and not line.startswith("command=") - ] - for line in context_lines: - assert line.startswith(" ") - - def test_empty_context(self): - out = format_error_context("log/p.log", [], "ecc log step", color=False) - assert "error: log/p.log" in out - assert "For more log info:" in out - - -# --------------------------------------------------------------------------- -# Failure context progress integration (AC-6) +# Failure context progress integration # --------------------------------------------------------------------------- class TestFailureContextIntegration: def test_failed_step_prints_context_block(self, tmp_path): - log_file = tmp_path / "synth.log" - log_file.write_text("line 1\nline 2\nError: something failed\nline 4\n") - - def fail_step(self, s): - return StateEnum.Imcomplete - - flow = _make_flow( - _make_ws(str(tmp_path)), - [_make_step("Synthesis", "yosys", str(log_file))], - fail_step, + workspace = tmp_path / "workspace" + _write_flow_json(workspace, [_step_record("Synthesis", "yosys", "Unstart")]) + _write_archived_log( + workspace, "Synthesis", "yosys", "line 1\nline 2\nError: something failed\nline 4\n" + ) + operation = _make_operation( + workspace, + [ + ("begin", "Synthesis", "yosys"), + ("states", [_step_record("Synthesis", "yosys", "Imcomplete")]), + ], + OperationResult(success=False, error="run flow failed"), ) buf = FakeTTYStderr(isatty_value=True) - result = run_flow_with_progress(flow, _make_ctx(), "myproj", buf) - assert result is False + result = run_flow_with_progress(str(workspace), _make_ctx(), "myproj", buf, operation) + assert result.success is False plain = _strip_ansi("".join(buf.written)) assert "error:" in plain assert "For more log info:" in plain assert 'command="' in plain def test_successful_step_no_context_block(self, tmp_path): - log_file = tmp_path / "synth.log" - log_file.write_text("line 1\nline 2\nall good\n") - - flow = _make_flow( - _make_ws(str(tmp_path)), - [_make_step("Synthesis", "yosys", str(log_file))], - lambda self, s: StateEnum.Success, + workspace = tmp_path / "workspace" + _write_flow_json(workspace, [_step_record("Synthesis", "yosys", "Unstart")]) + _write_archived_log(workspace, "Synthesis", "yosys", "line 1\nline 2\nall good\n") + operation = _make_operation( + workspace, + [ + ("begin", "Synthesis", "yosys"), + ("states", [_step_record("Synthesis", "yosys", "Success")]), + ], + OperationResult(success=True, exit_code=0), ) buf = FakeTTYStderr(isatty_value=True) - result = run_flow_with_progress(flow, _make_ctx(), "myproj", buf) - assert result is True + result = run_flow_with_progress(str(workspace), _make_ctx(), "myproj", buf, operation) + assert result.success is True plain = _strip_ansi("".join(buf.written)) assert "error:" not in plain assert "For more log info:" not in plain - def test_missing_log_no_context_block(self): - flow = _make_flow( - _make_ws(), - [_make_step("Synthesis", "yosys", "/nonexistent/synth.log")], - lambda self, s: StateEnum.Imcomplete, + def test_missing_log_no_context_block(self, tmp_path): + workspace = tmp_path / "workspace" + _write_flow_json(workspace, [_step_record("Synthesis", "yosys", "Unstart")]) + operation = _make_operation( + workspace, + [("states", [_step_record("Synthesis", "yosys", "Imcomplete")])], + OperationResult(success=False, error="run flow failed"), ) buf = FakeTTYStderr(isatty_value=True) - result = run_flow_with_progress(flow, _make_ctx(), "myproj", buf) - assert result is False + result = run_flow_with_progress(str(workspace), _make_ctx(), "myproj", buf, operation) + assert result.success is False plain = _strip_ansi("".join(buf.written)) assert "error:" not in plain assert "For more log info:" not in plain @@ -1255,51 +880,53 @@ def test_missing_log_no_context_block(self): assert "inspect:" in plain def test_empty_log_no_context_block(self, tmp_path): - log_file = tmp_path / "empty.log" - log_file.write_text("") - - flow = _make_flow( - _make_ws(str(tmp_path)), - [_make_step("Synthesis", "yosys", str(log_file))], - lambda self, s: StateEnum.Imcomplete, + workspace = tmp_path / "workspace" + _write_flow_json(workspace, [_step_record("Synthesis", "yosys", "Unstart")]) + _write_archived_log(workspace, "Synthesis", "yosys", "") + operation = _make_operation( + workspace, + [("states", [_step_record("Synthesis", "yosys", "Imcomplete")])], + OperationResult(success=False, error="run flow failed"), ) buf = FakeTTYStderr(isatty_value=True) - result = run_flow_with_progress(flow, _make_ctx(), "myproj", buf) - assert result is False + result = run_flow_with_progress(str(workspace), _make_ctx(), "myproj", buf, operation) + assert result.success is False plain = _strip_ansi("".join(buf.written)) assert "For more log info:" not in plain def test_existing_log_and_inspect_lines_remain(self, tmp_path): - log_file = tmp_path / "synth.log" - log_file.write_text("line 1\nError: fail\nline 3\n") - - flow = _make_flow( - _make_ws(str(tmp_path)), - [_make_step("Synthesis", "yosys", str(log_file))], - lambda self, s: StateEnum.Imcomplete, + workspace = tmp_path / "workspace" + _write_flow_json(workspace, [_step_record("Synthesis", "yosys", "Unstart")]) + _write_archived_log(workspace, "Synthesis", "yosys", "line 1\nError: fail\nline 3\n") + operation = _make_operation( + workspace, + [("states", [_step_record("Synthesis", "yosys", "Imcomplete")])], + OperationResult(success=False, error="run flow failed"), ) buf = FakeTTYStderr(isatty_value=True) - result = run_flow_with_progress(flow, _make_ctx(), "myproj", buf) - assert result is False + result = run_flow_with_progress(str(workspace), _make_ctx(), "myproj", buf, operation) + assert result.success is False plain = _strip_ansi("".join(buf.written)) assert "log:" in plain assert "inspect:" in plain def test_context_block_no_blank_lines_between_rows(self, tmp_path): - log_file = tmp_path / "synth.log" - log_file.write_text("line one\nline two\nError: boom\nline four\n") - - flow = _make_flow( - _make_ws(str(tmp_path)), - [_make_step("Synthesis", "yosys", str(log_file))], - lambda self, s: StateEnum.Imcomplete, + workspace = tmp_path / "workspace" + _write_flow_json(workspace, [_step_record("Synthesis", "yosys", "Unstart")]) + _write_archived_log( + workspace, "Synthesis", "yosys", "line one\nline two\nError: boom\nline four\n" + ) + operation = _make_operation( + workspace, + [("states", [_step_record("Synthesis", "yosys", "Imcomplete")])], + OperationResult(success=False, error="run flow failed"), ) buf = FakeTTYStderr(isatty_value=True) - result = run_flow_with_progress(flow, _make_ctx(), "myproj", buf) - assert result is False + result = run_flow_with_progress(str(workspace), _make_ctx(), "myproj", buf, operation) + assert result.success is False raw = "".join(buf.written) header_pos = raw.find("error:") diff --git a/test/runtime/test_log_stream.py b/test/runtime/test_log_stream.py index 03f23403..5dc147bb 100644 --- a/test/runtime/test_log_stream.py +++ b/test/runtime/test_log_stream.py @@ -440,6 +440,99 @@ def resolver(step, tool): assert reader.state.error is None +class TestStepLogArchiveResolver: + def test_resolver_produces_canonical_step_log_path(self, tmp_path): + from chipcompiler.runtime.log_stream import step_log_archive_resolver + + resolver = step_log_archive_resolver(tmp_path) + assert resolver("Synthesis", "yosys") == ( + tmp_path / "Synthesis_yosys" / "log" / "Synthesis.log" + ) + assert resolver("Floorplan", "ecc") == ( + tmp_path / "Floorplan_ecc" / "log" / "Floorplan.log" + ) + + +class TestOnStepEvent: + def test_fires_on_matched_begin_and_end(self, tmp_path): + events = [] + log_path = tmp_path / "step.log" + stream_data = ( + b'\x1eECC-STEP {"v":1,"event":"begin","step":"S","tool":"T"}\n' + b"data\n" + b'\x1eECC-STEP {"v":1,"event":"end","step":"S","tool":"T"}\n' + ) + reader = LogStreamReader( + io.BytesIO(stream_data), + log_path_resolver=lambda step, tool: log_path, + on_step_event=lambda event, step, tool: events.append((event, step, tool)), + ) + reader.start() + reader.join(timeout=5) + assert events == [("begin", "S", "T"), ("end", "S", "T")] + + def test_does_not_fire_on_unmatched_markers(self, tmp_path): + events = [] + log_path = tmp_path / "step.log" + nested_begin = b'\x1eECC-STEP {"v":1,"event":"begin","step":"B","tool":"T"}\n' + mismatched_end = b'\x1eECC-STEP {"v":1,"event":"end","step":"X","tool":"T"}\n' + stream_data = ( + b'\x1eECC-STEP {"v":1,"event":"begin","step":"A","tool":"T"}\n' + + nested_begin + + mismatched_end + + b'\x1eECC-STEP {"v":1,"event":"end","step":"A","tool":"T"}\n' + ) + reader = LogStreamReader( + io.BytesIO(stream_data), + log_path_resolver=lambda step, tool: log_path, + on_step_event=lambda event, step, tool: events.append((event, step, tool)), + ) + reader.start() + reader.join(timeout=5) + assert events == [("begin", "A", "T"), ("end", "A", "T")] + + def test_does_not_fire_on_disallowed_marker(self, tmp_path): + events = [] + stream_data = b'\x1eECC-STEP {"v":1,"event":"begin","step":"Bogus","tool":"fake"}\n' + reader = LogStreamReader( + io.BytesIO(stream_data), + on_step_event=lambda event, step, tool: events.append((event, step, tool)), + valid_steps={("Real", "tool")}, + ) + reader.start() + reader.join(timeout=5) + assert events == [] + + def test_callback_exception_disables_callback_continues_drain(self, tmp_path): + log_path = tmp_path / "step.log" + calls = [0] + + def failing_callback(event, step, tool): + calls[0] += 1 + raise RuntimeError("step event exploded") + + stream_data = ( + b'\x1eECC-STEP {"v":1,"event":"begin","step":"S","tool":"T"}\n' + b"line 1\n" + b'\x1eECC-STEP {"v":1,"event":"end","step":"S","tool":"T"}\n' + b'\x1eECC-STEP {"v":1,"event":"begin","step":"S2","tool":"T"}\n' + b"line 2\n" + ) + reader = LogStreamReader( + io.BytesIO(stream_data), + log_path_resolver=lambda step, tool: log_path, + on_step_event=failing_callback, + ) + reader.start() + reader.join(timeout=5) + assert reader.completed + assert calls[0] == 1 + assert isinstance(reader.state.error, RuntimeError) + # The second begin re-opened (truncated) the shared log path, so its + # content proves archiving continued after the callback was disabled. + assert log_path.read_bytes() == b"line 2\n" + + class TestLogStreamResilience: def test_resolver_exception_disables_archive_continues_drain(self): """A resolver that raises must not kill the drain thread.""" From 862c00f3592e40f26310db0986dbc206c64c4688 Mon Sep 17 00:00:00 2001 From: Emin Date: Tue, 18 Aug 2026 17:43:32 +0800 Subject: [PATCH 18/52] feat(runtime): drop server-side step log tailing Executors never write step log files, so there is nothing for the runtime server to tail: remove the step log tail thread, the delta publisher, the final log reader, and their call sites. step.completed keeps step, tool, state, stepCommitId, workspaceRevision, and the render gate; live step.log events and finalLog are now synthesized by the archiving client instead of the executor. --- chipcompiler/runtime/operations.py | 129 ----------------------------- test/runtime/test_operations.py | 34 ++++---- 2 files changed, 17 insertions(+), 146 deletions(-) diff --git a/chipcompiler/runtime/operations.py b/chipcompiler/runtime/operations.py index ddba330c..86d5d0ee 100644 --- a/chipcompiler/runtime/operations.py +++ b/chipcompiler/runtime/operations.py @@ -4,31 +4,14 @@ import time from collections.abc import Callable from dataclasses import dataclass, field -from pathlib import Path from typing import Any from uuid import uuid4 -_LOG_POLL_INTERVAL_SECONDS = 0.25 -_MAX_LOG_CHUNK_BYTES = 16 * 1024 -_MAX_FINAL_LOG_BYTES = 64 * 1024 _RENDER_ACK_RETRY_SECONDS = 5.0 _RENDER_ACK_PAUSE_SECONDS = 30.0 _RENDER_ACK_ABORT_SECONDS = 300.0 -@dataclass -class _StepLogTail: - """A bounded worker-side reader for one active step log.""" - - operation_id: str - path: Path - step: str - tool: str - cursor: int - stopped: threading.Event = field(default_factory=threading.Event) - thread: threading.Thread | None = None - - class RuntimeOperationConflict(RuntimeError): """A workspace already owns a non-terminal runtime operation.""" @@ -80,7 +63,6 @@ def __init__(self, publisher: Callable[[dict[str, Any]], None] | None = None): self._operations: dict[str, RuntimeOperation] = {} self._active_by_workspace: dict[str, str] = {} self._idempotency: dict[tuple[str, str], str] = {} - self._step_log_tails: dict[str, _StepLogTail] = {} self._runtime_instance_id = uuid4().hex self._workspace_sequences: dict[str, int] = {} @@ -304,12 +286,10 @@ def _run( operation.updated_at = time.time() event = self._new_event_locked(operation, event_type, {"error": operation.error}) self._publish(event) - self._stop_step_log_tail(operation_id) with self._lock: self._active_by_workspace.pop(self._operations[operation_id].workspace_id, None) def step_started(self, operation_id: str, workspace_step: Any) -> None: - self._stop_step_log_tail(operation_id) with self._lock: operation = self._operations[operation_id] operation.current_step = str(getattr(workspace_step, "name", "")) @@ -324,24 +304,7 @@ def step_started(self, operation_id: str, workspace_step: Any) -> None: "state": "Ongoing", }, ) - log_tail = _step_log_tail_for( - operation_id, - getattr(workspace_step, "log", None), - operation.current_step, - operation.current_tool, - ) - if log_tail is not None: - self._step_log_tails[operation_id] = log_tail self._publish(event) - if log_tail is not None: - thread = threading.Thread( - target=self._tail_step_log, - args=(log_tail,), - name=f"ecc-runtime-log-{operation_id}", - daemon=True, - ) - log_tail.thread = thread - thread.start() def rerun_prepared( self, @@ -367,16 +330,13 @@ def rerun_prepared( self._publish(event) def step_completed(self, operation_id: str, workspace_step: Any, state: Any) -> None: - self._stop_step_log_tail(operation_id) state_value = str(getattr(state, "value", state)) - final_log = _read_final_log(getattr(workspace_step, "log", None)) with self._render_gate: operation = self._operations[operation_id] operation.current_step = str(getattr(workspace_step, "name", "")) operation.current_tool = str(getattr(workspace_step, "tool", "")) operation.updated_at = time.time() payload: dict[str, Any] = { - "finalLog": final_log, "step": operation.current_step, "tool": operation.current_tool, "state": state_value, @@ -423,7 +383,6 @@ def subflow_stage( self._publish(event) def step_skipped(self, operation_id: str, workspace_step: Any) -> None: - self._stop_step_log_tail(operation_id) with self._lock: operation = self._operations[operation_id] operation.current_step = str(getattr(workspace_step, "name", "")) @@ -517,57 +476,6 @@ def wait_for_step_rendered(self, operation_id: str) -> bool: if replay_event is not None: self._publish(replay_event) - def _tail_step_log(self, log_tail: _StepLogTail) -> None: - while not log_tail.stopped.is_set(): - self._publish_step_log_delta(log_tail) - log_tail.stopped.wait(_LOG_POLL_INTERVAL_SECONDS) - - def _publish_step_log_delta(self, log_tail: _StepLogTail) -> None: - try: - size = log_tail.path.stat().st_size - if size < log_tail.cursor: - # A rerun may truncate or replace a log file. The renderer treats - # this as a new bounded stream for the same step attempt. - log_tail.cursor = 0 - if size <= log_tail.cursor: - return - with log_tail.path.open("rb") as log_file: - log_file.seek(log_tail.cursor) - chunk = log_file.read(_MAX_LOG_CHUNK_BYTES) - except OSError: - return - - if not chunk: - return - log_tail.cursor += len(chunk) - text = chunk.decode("utf-8", errors="replace") - with self._lock: - if self._step_log_tails.get(log_tail.operation_id) is not log_tail: - return - operation = self._operations.get(log_tail.operation_id) - if operation is None: - return - event = self._new_event_locked( - operation, - "step.log", - { - "chunk": text, - "cursor": log_tail.cursor, - "step": log_tail.step, - "tool": log_tail.tool, - }, - ) - self._publish(event) - - def _stop_step_log_tail(self, operation_id: str) -> None: - with self._lock: - log_tail = self._step_log_tails.pop(operation_id, None) - if log_tail is None: - return - log_tail.stopped.set() - if log_tail.thread is not None and log_tail.thread is not threading.current_thread(): - log_tail.thread.join(timeout=_LOG_POLL_INTERVAL_SECONDS + 0.25) - def _new_event_locked( self, operation: RuntimeOperation, @@ -661,40 +569,3 @@ def on_step_skipped(self, workspace_step: Any) -> None: def wait_for_step_rendered(self, _workspace_step: Any, _state: Any) -> bool: return self._manager.wait_for_step_rendered(self._operation_id) - - -def _read_final_log(log: Any) -> str: - path = getattr(log, "file", None) - if not path: - return "" - try: - with Path(path).open("rb") as log_file: - log_file.seek(0, 2) - size = log_file.tell() - log_file.seek(max(0, size - _MAX_FINAL_LOG_BYTES)) - return log_file.read().decode("utf-8", errors="replace") - except OSError: - return "" - - -def _step_log_tail_for( - operation_id: str, - log: Any, - step: str, - tool: str, -) -> _StepLogTail | None: - path = getattr(log, "file", None) - if not path: - return None - log_path = Path(path) - try: - cursor = log_path.stat().st_size - except OSError: - cursor = 0 - return _StepLogTail( - operation_id=operation_id, - path=log_path, - step=step, - tool=tool, - cursor=cursor, - ) diff --git a/test/runtime/test_operations.py b/test/runtime/test_operations.py index 02e28ace..0935608b 100644 --- a/test/runtime/test_operations.py +++ b/test/runtime/test_operations.py @@ -364,10 +364,8 @@ def runner(observer): assert _wait_for_terminal(manager, started["operationId"])["state"] == "cancelled" -def test_step_log_events_stream_only_new_log_bytes_and_keep_final_tail(tmp_path): +def test_server_never_emits_step_log_and_step_completed_has_no_final_log(tmp_path): events = [] - step_started = threading.Event() - complete_step = threading.Event() manager = RuntimeOperationManager(events.append) log_file = tmp_path / "Synthesis.log" log_file.write_text("previous run\n", encoding="utf-8") @@ -379,8 +377,8 @@ def test_step_log_events_stream_only_new_log_bytes_and_keep_final_tail(tmp_path) def runner(observer): observer.on_step_started(step) - step_started.set() - assert complete_step.wait(timeout=2) + with log_file.open("a", encoding="utf-8") as handle: + handle.write("live line one\nlive line two\n") observer.on_step_completed(step, StateEnum.Success) assert observer.wait_for_step_rendered(step, StateEnum.Success) return {"rerun": False} @@ -394,21 +392,23 @@ def runner(observer): idempotency_key="request-log-stream", runner=runner, ) - assert step_started.wait(timeout=1) - with log_file.open("a", encoding="utf-8") as handle: - handle.write("live line one\nlive line two\n") - - step_log = _wait_for_event(events, "step.log") - assert step_log["payload"]["chunk"] == "live line one\nlive line two\n" - assert step_log["payload"]["cursor"] == log_file.stat().st_size - - complete_step.set() - step_complete = _wait_for_event(events, "step.completed") - assert step_complete["payload"]["finalLog"] == ("previous run\nlive line one\nlive line two\n") - assert manager.acknowledge_step_rendered(started["operationId"], step_complete["eventId"])[ + + step_completed = _wait_for_event(events, "step.completed") + # Outlast the historical tail poll interval so a stray publisher would fire. + threading.Event().wait(0.3) + assert not any(event["type"] == "step.log" for event in events) + assert "finalLog" not in step_completed["payload"] + assert step_completed["payload"]["step"] == "Synthesis" + assert step_completed["payload"]["tool"] == "yosys" + assert step_completed["payload"]["state"] == "Success" + assert step_completed["payload"]["stepCommitId"] + assert step_completed["payload"]["workspaceRevision"] == 1 + + assert manager.acknowledge_step_rendered(started["operationId"], step_completed["eventId"])[ "accepted" ] assert _wait_for_terminal(manager, started["operationId"])["state"] == "succeeded" + assert not any(event["type"] == "step.log" for event in events) def _wait_for_event(events: list[dict], event_type: str) -> dict: From bda13d72504d56b71a86b256069004f88edbfcf5 Mon Sep 17 00:00:00 2001 From: Emin Date: Tue, 18 Aug 2026 18:07:42 +0800 Subject: [PATCH 19/52] fix(runtime,cli): drain C stdio before markers and harden archive target checks Review follow-ups for the worker-streamed logs delta: - emit_step_marker now drains C/C++ stdio buffers (flush_cstdio) before writing, so native output can never land on the wrong side of a step boundary. - parse_marker rejects JSON boolean versions (true == 1 in Python). - LogStreamReader validates step/tool names (no separators, dot segments, or empty values) and containment before activating a step; violating begin frames degrade to ordinary bytes instead of being consumed. - ecc run --workspace --only maps executed steps to flow.run_step with rerun: true, restoring the previous clean-artifact contract; --force remains the gate for re-executing a successful step. - Add CLI-level tests that execute against a real worker subprocess: --resume suffix execution with archiving, --only single-step execution, and non-TTY flow.run archival with no marker leakage. --- chipcompiler/cli/command_handlers/project.py | 4 +- chipcompiler/runtime/log_stream.py | 64 +++++- test/cli/commands/test_run.py | 2 +- test/cli/commands/test_run_worker.py | 230 +++++++++++++++++++ test/cli/conftest.py | 2 +- test/runtime/test_log_stream.py | 104 +++++++++ 6 files changed, 395 insertions(+), 11 deletions(-) create mode 100644 test/cli/commands/test_run_worker.py diff --git a/chipcompiler/cli/command_handlers/project.py b/chipcompiler/cli/command_handlers/project.py index ee4376b2..85b8dd13 100644 --- a/chipcompiler/cli/command_handlers/project.py +++ b/chipcompiler/cli/command_handlers/project.py @@ -610,7 +610,9 @@ def no_op_result() -> CommandResult: target = selected[0] if command_input.only is not None: - calls = [("flow.run_step", {"step": target, "rerun": bool(command_input.force)})] + # An executed --only step always reruns with clean artifacts; the + # --force distinction only gates whether a successful step qualifies. + calls = [("flow.run_step", {"step": target, "rerun": True})] else: calls = [ ("flow.run_step", {"step": target, "rerun": True, "reset_dependents": True}), diff --git a/chipcompiler/runtime/log_stream.py b/chipcompiler/runtime/log_stream.py index 6dc641da..c9e4d42b 100644 --- a/chipcompiler/runtime/log_stream.py +++ b/chipcompiler/runtime/log_stream.py @@ -33,8 +33,13 @@ def emit_step_marker(event: str, step: str, tool: str) -> None: """Write a step marker to stderr using a single os.write() call.""" import sys + from chipcompiler.utility.log import flush_cstdio + sys.stdout.flush() sys.stderr.flush() + # C/C++ buffers must drain before the marker, or pending native output + # lands on the wrong side of the step boundary. + flush_cstdio() payload = json.dumps( {"v": MARKER_VERSION, "event": event, "step": step, "tool": tool}, separators=(",", ":"), @@ -66,7 +71,9 @@ def parse_marker(line: bytes) -> StepMarker | None: return None if not isinstance(data, dict): return None - if data.get("v") != MARKER_VERSION: + version = data.get("v") + # JSON true/false are bool in Python; bool == 1 is True, so exclude it. + if isinstance(version, bool) or version != MARKER_VERSION: return None event = data.get("event") step = data.get("step") @@ -181,19 +188,60 @@ def _is_allowed_step(self, step: str, tool: str) -> bool: return True return (step, tool) in self._valid_steps + def _archive_target_ok(self, step: str, tool: str) -> bool: + """Validate sanitization and containment before activating a step. + + A marker whose archive target is unsafe or unresolvable is degraded to + ordinary bytes instead of activating archival. + """ + if self._resolve_path is None: + return True + for value in (step, tool): + if not value or "/" in value or "\\" in value or ".." in value: + if self._state.error is None: + self._state.error = ValueError(f"unsafe step marker name: {value!r}") + return False + try: + path = self._resolve_path(step, tool) + except Exception as exc: + if self._state.error is None: + self._state.error = exc + return False + if path is None: + return False + if self._workspace_dir is not None: + try: + resolved = path.resolve() + workspace_resolved = self._workspace_dir.resolve() + if not ( + resolved == workspace_resolved + or str(resolved).startswith(str(workspace_resolved) + os.sep) + ): + if self._state.error is None: + self._state.error = ValueError(f"archive path escapes workspace: {path}") + return False + except (OSError, ValueError) as exc: + if self._state.error is None: + self._state.error = exc + return False + return True + def _handle_marker(self, marker: StepMarker, raw_line: bytes) -> None: if marker.event == "begin": if not self._is_allowed_step(marker.step, marker.tool): self._emit_data(raw_line) return - if self._state.active_step is None: - self._state.active_step = marker.step - self._state.active_tool = marker.tool - self._state.steps_seen.append(marker.step) - self._open_archive(marker.step, marker.tool) - self._emit_step_event("begin", marker.step, marker.tool) - else: + if self._state.active_step is not None: self._emit_data(raw_line) + return + if not self._archive_target_ok(marker.step, marker.tool): + self._emit_data(raw_line) + return + self._state.active_step = marker.step + self._state.active_tool = marker.tool + self._state.steps_seen.append(marker.step) + self._open_archive(marker.step, marker.tool) + self._emit_step_event("begin", marker.step, marker.tool) elif marker.event == "end": if marker.step == self._state.active_step and marker.tool == self._state.active_tool: self._close_archive() diff --git a/test/cli/commands/test_run.py b/test/cli/commands/test_run.py index e705663a..77a77a7c 100644 --- a/test/cli/commands/test_run.py +++ b/test/cli/commands/test_run.py @@ -317,7 +317,7 @@ def test_only_without_force_runs_step(self, workspace_mocks, tmp_path): rc = cli_main.run(["run", "--workspace", workspace, "--only", "place", "--json"]) assert rc == 0 - assert workspace_mocks.calls == [("flow.run_step", {"step": "place", "rerun": False})] + assert workspace_mocks.calls == [("flow.run_step", {"step": "place", "rerun": True})] def test_only_success_step_without_force_is_noop(self, workspace_mocks, tmp_path, capsys): workspace_mocks.steps = [ diff --git a/test/cli/commands/test_run_worker.py b/test/cli/commands/test_run_worker.py new file mode 100644 index 00000000..f0346378 --- /dev/null +++ b/test/cli/commands/test_run_worker.py @@ -0,0 +1,230 @@ +"""CLI-level run tests that execute against a real worker subprocess.""" + +import json +import os +import sys +import textwrap +from types import SimpleNamespace + +import pytest + +from chipcompiler.cli import main as cli_main +from chipcompiler.cli.command_handlers import project as project_module + +# Captured at import time, before the autouse fixture replaces the module attr. +_REAL_RUN_FLOW_VIA_WORKER = project_module._run_flow_via_worker + +_FAKE_WORKER = textwrap.dedent("""\ + import sys, os, json + + def read_request(): + data = b"" + while True: + chunk = sys.stdin.buffer.read(1) + if not chunk: + return None + data += chunk + if b"\\r\\n\\r\\n" in data: + header, _, body_start = data.partition(b"\\r\\n\\r\\n") + length = int(header.split(b":")[1]) + while len(body_start) < length: + body_start += sys.stdin.buffer.read(1) + return json.loads(body_start[:length]) + + def send_response(resp): + payload = json.dumps(resp) + frame = f"Content-Length: {len(payload)}\\r\\n\\r\\n{payload}" + sys.stdout.buffer.write(frame.encode()) + sys.stdout.buffer.flush() + + def make_marker(event, step, tool): + payload = json.dumps({"v": 1, "event": event, "step": step, "tool": tool}) + return chr(0x1e).encode() + b"ECC-STEP " + payload.encode() + b"\\n" + + def flow_json_path(ws_dir): + return os.path.join(ws_dir, "home", "flow.json") + + def run_one_step(ws_dir, name, tool): + os.write(2, make_marker("begin", name, tool)) + os.write(2, ("output of " + name + "\\n").encode()) + os.write(2, make_marker("end", name, tool)) + path = flow_json_path(ws_dir) + with open(path) as handle: + data = json.load(handle) + for record in data["steps"]: + if record["name"] == name: + record["state"] = "Success" + record["runtime"] = "0:00:01" + with open(path, "w") as handle: + json.dump(data, handle) + + def run_pending(ws_dir): + path = flow_json_path(ws_dir) + with open(path) as handle: + data = json.load(handle) + for record in data["steps"]: + if record.get("state") != "Success": + run_one_step(ws_dir, record["name"], record.get("tool", "ecc")) + + ws_dir = "" + while True: + req = read_request() + if req is None: + break + method = req.get("method", "") + req_id = req.get("id") + if method == "rpc.hello": + send_response({"jsonrpc": "2.0", "result": {"version": 1}, "id": req_id}) + elif method == "workspace.open": + ws_dir = req["params"]["directory"] + result = {"workspaceId": "fake-worker"} + send_response({"jsonrpc": "2.0", "result": result, "id": req_id}) + elif method == "flow.run_step": + step = req["params"]["step"] + tool = "ecc" + with open(flow_json_path(ws_dir)) as handle: + for record in json.load(handle)["steps"]: + if record["name"] == step: + tool = record.get("tool", "ecc") + run_one_step(ws_dir, step, tool) + result = {"step": step, "state": "Success"} + send_response({"jsonrpc": "2.0", "result": result, "id": req_id}) + elif method == "flow.run": + run_pending(ws_dir) + send_response({"jsonrpc": "2.0", "result": {"rerun": False}, "id": req_id}) + elif method == "rpc.shutdown": + send_response({"jsonrpc": "2.0", "result": {"ok": True}, "id": req_id}) + break + else: + err = {"code": -32601, "message": "unknown method"} + send_response({"jsonrpc": "2.0", "error": err, "id": req_id}) +""") + + +def _write_flow_json(workspace_dir, steps): + home = os.path.join(workspace_dir, "home") + os.makedirs(home, exist_ok=True) + with open(os.path.join(home, "flow.json"), "w") as handle: + json.dump({"steps": steps}, handle) + + +def _read_flow_states(workspace_dir): + with open(os.path.join(workspace_dir, "home", "flow.json")) as handle: + return {record["name"]: record["state"] for record in json.load(handle)["steps"]} + + +@pytest.fixture +def fake_worker(tmp_path, monkeypatch): + script = tmp_path / "fake_worker.py" + script.write_text(_FAKE_WORKER) + monkeypatch.setattr( + "chipcompiler.runtime.worker_operation._default_worker_argv", + lambda: [sys.executable, str(script)], + ) + return script + + +class TestWorkspaceRunWithRealWorker: + @pytest.fixture + def validation_mocks(self, monkeypatch): + class Flow: + def __init__(self, workspace): + self.workspace = workspace + + def has_init(self): + return True + + def fake_load_workspace(path): + return SimpleNamespace( + name="workspace", + flow=SimpleNamespace( + data={ + "steps": [ + {"name": "Synthesis", "tool": "yosys", "state": "Success"}, + {"name": "place", "tool": "ecc", "state": "Imcomplete"}, + {"name": "CTS", "tool": "ecc", "state": "Unstart"}, + ] + } + ), + ) + + monkeypatch.setattr("chipcompiler.data.load_workspace", fake_load_workspace) + monkeypatch.setattr("chipcompiler.engine.EngineFlow", Flow) + + def test_resume_runs_suffix_through_worker_and_archives( + self, fake_worker, validation_mocks, tmp_path, capsys + ): + workspace = str(tmp_path / "workspace") + _write_flow_json( + workspace, + [ + {"name": "Synthesis", "tool": "yosys", "state": "Success"}, + {"name": "place", "tool": "ecc", "state": "Imcomplete"}, + {"name": "CTS", "tool": "ecc", "state": "Unstart"}, + ], + ) + + rc = cli_main.run(["run", "--workspace", workspace, "--resume", "--json"]) + + record = json.loads(capsys.readouterr().out)["records"][0] + assert rc == 0 + assert record["status"] == "success" + assert record["executed_steps"] == ["place", "CTS"] + assert _read_flow_states(workspace) == { + "Synthesis": "Success", + "place": "Success", + "CTS": "Success", + } + for step, tool in (("place", "ecc"), ("CTS", "ecc")): + log_path = os.path.join(workspace, f"{step}_{tool}", "log", f"{step}.log") + with open(log_path, "rb") as handle: + content = handle.read() + assert f"output of {step}\n".encode() in content + assert b"ECC-STEP" not in content + + def test_only_executes_single_step(self, fake_worker, validation_mocks, tmp_path, capsys): + workspace = str(tmp_path / "workspace") + _write_flow_json( + workspace, + [ + {"name": "Synthesis", "tool": "yosys", "state": "Success"}, + {"name": "place", "tool": "ecc", "state": "Imcomplete"}, + {"name": "CTS", "tool": "ecc", "state": "Unstart"}, + ], + ) + + rc = cli_main.run(["run", "--workspace", workspace, "--only", "place", "--json"]) + + record = json.loads(capsys.readouterr().out)["records"][0] + assert rc == 0 + assert record["executed_steps"] == ["place"] + assert _read_flow_states(workspace) == { + "Synthesis": "Success", + "place": "Success", + "CTS": "Unstart", + } + assert not os.path.exists(os.path.join(workspace, "CTS_ecc")) + + +class TestFlowRunViaWorkerArchival: + def test_non_tty_run_archives_step_logs_without_markers( + self, fake_worker, tmp_path, monkeypatch + ): + workspace = str(tmp_path / "workspace") + _write_flow_json( + workspace, + [ + {"name": "Synthesis", "tool": "yosys", "state": "Unstart"}, + {"name": "place", "tool": "ecc", "state": "Unstart"}, + ], + ) + + result = _REAL_RUN_FLOW_VIA_WORKER(workspace) + + assert result.success is True + for step, tool in (("Synthesis", "yosys"), ("place", "ecc")): + log_path = os.path.join(workspace, f"{step}_{tool}", "log", f"{step}.log") + with open(log_path, "rb") as handle: + content = handle.read() + assert f"output of {step}\n".encode() in content + assert b"ECC-STEP" not in content diff --git a/test/cli/conftest.py b/test/cli/conftest.py index bd58dc90..4d301bbf 100644 --- a/test/cli/conftest.py +++ b/test/cli/conftest.py @@ -209,5 +209,5 @@ def _disable_worker_routing(monkeypatch): monkeypatch.setattr( "chipcompiler.cli.command_handlers.project._run_flow_via_worker", - lambda workspace_dir: OperationResult(success=True, exit_code=0), + lambda workspace_dir, **_kwargs: OperationResult(success=True, exit_code=0), ) diff --git a/test/runtime/test_log_stream.py b/test/runtime/test_log_stream.py index 5dc147bb..a933c3db 100644 --- a/test/runtime/test_log_stream.py +++ b/test/runtime/test_log_stream.py @@ -55,6 +55,10 @@ def test_string_version_rejected(self): line = b'\x1eECC-STEP {"v":"1","event":"begin","step":"S","tool":"T"}\n' assert parse_marker(line) is None + def test_boolean_version_rejected(self): + line = b'\x1eECC-STEP {"v":true,"event":"begin","step":"S","tool":"T"}\n' + assert parse_marker(line) is None + class TestEmitStepMarker: def test_payload_carries_version_and_round_trips(self, monkeypatch): @@ -75,6 +79,33 @@ def fake_write(fd, data): ] assert parse_marker(written[0]) == StepMarker(event="begin", step="Synthesis", tool="yosys") + def test_c_stdio_buffer_drains_before_marker(self, tmp_path): + """Native buffered output must reach fd 2 ahead of the marker bytes.""" + import ctypes + + libc = ctypes.CDLL(None) + libc.fputs.argtypes = [ctypes.c_char_p, ctypes.c_void_p] + stderr_file = ctypes.c_void_p.in_dll(libc, "stderr") + + sink = tmp_path / "fd2.bin" + saved_fd = os.dup(2) + try: + with sink.open("wb") as handle: + os.dup2(handle.fileno(), 2) + libc.fputs(b"native-before-end\n", stderr_file) + # No fflush here: emit_step_marker must drain the C buffer first. + emit_step_marker("end", step="S", tool="T") + finally: + os.dup2(saved_fd, 2) + os.close(saved_fd) + + content = sink.read_bytes() + assert content == ( + b"native-before-end\n" + + MARKER_PREFIX + + b'{"v":1,"event":"end","step":"S","tool":"T"}\n' + ) + class TestLogStreamReader: def _make_stream(self, chunks: list[bytes]) -> io.BytesIO: @@ -440,6 +471,79 @@ def resolver(step, tool): assert reader.state.error is None +class TestArchiveTargetSanitization: + def test_separator_in_name_degrades_to_data(self, tmp_path): + """An allowlisted begin with a path separator is ordinary bytes, not a marker.""" + workspace = tmp_path / "workspace" + workspace.mkdir() + received = [] + + def resolver(step, tool): + base = workspace + return base / f"{step}_{tool}" / "log" / f"{step}.log" + + unsafe_begin = b'\x1eECC-STEP {"v":1,"event":"begin","step":"foo/bar","tool":"ecc"}\n' + stream_data = unsafe_begin + b"body bytes\n" + reader = LogStreamReader( + io.BytesIO(stream_data), + log_path_resolver=resolver, + valid_steps={("foo/bar", "ecc")}, + workspace_dir=workspace, + on_output=received.append, + ) + reader.start() + reader.join(timeout=5) + combined = b"".join(received) + assert unsafe_begin in combined + assert b"body bytes\n" in combined + assert reader.state.active_step is None + assert reader.state.steps_seen == [] + assert isinstance(reader.state.error, ValueError) + assert not (workspace / "foo").exists() + + def test_dotdot_in_name_degrades_to_data(self, tmp_path): + workspace = tmp_path / "workspace" + workspace.mkdir() + received = [] + unsafe_begin = b'\x1eECC-STEP {"v":1,"event":"begin","step":"..","tool":".."}\n' + reader = LogStreamReader( + io.BytesIO(unsafe_begin), + log_path_resolver=lambda step, tool: workspace / "x" / "x.log", + valid_steps={("..", "..")}, + workspace_dir=workspace, + on_output=received.append, + ) + reader.start() + reader.join(timeout=5) + assert b"".join(received) == unsafe_begin + assert reader.state.active_step is None + assert isinstance(reader.state.error, ValueError) + + def test_containment_violation_degrades_begin_to_data(self, tmp_path): + """A begin whose archive escapes the workspace is forwarded as data.""" + workspace = tmp_path / "workspace" + workspace.mkdir() + escape_target = tmp_path / "outside.log" + received = [] + unsafe_begin = b'\x1eECC-STEP {"v":1,"event":"begin","step":"Escape","tool":"evil"}\n' + stream_data = unsafe_begin + b"not archived\n" + reader = LogStreamReader( + io.BytesIO(stream_data), + log_path_resolver=lambda step, tool: escape_target, + valid_steps={("Escape", "evil")}, + workspace_dir=workspace, + on_output=received.append, + ) + reader.start() + reader.join(timeout=5) + combined = b"".join(received) + assert unsafe_begin in combined + assert b"not archived\n" in combined + assert reader.state.active_step is None + assert not escape_target.exists() + assert "escapes workspace" in str(reader.state.error) + + class TestStepLogArchiveResolver: def test_resolver_produces_canonical_step_log_path(self, tmp_path): from chipcompiler.runtime.log_stream import step_log_archive_resolver From 8fee40bd11e7acbff26105abd220a4b88e0dc2a9 Mon Sep 17 00:00:00 2001 From: Emin Date: Tue, 18 Aug 2026 18:24:45 +0800 Subject: [PATCH 20/52] feat(runtime): add invalidate_dependents for faithful --only reruns The documented workspace-mode contract requires a re-executed step to replace its own artifacts and mark downstream steps Unstart while keeping their outputs. reset_dependents cannot express this because it also clears downstream artifacts, so FlowRunStepRequest gains an additive invalidate_dependents field: the server prepares only the target step and marks the downstream suffix Unstart in flow.json. Also resolve the remaining review findings: the log reader now resolves and validates an archive target exactly once and opens that validated path, and the C stdio ordering test uses an explicitly buffered FILE* so it fails without the flush. --- chipcompiler/cli/command_handlers/project.py | 3 +- chipcompiler/runtime/log_stream.py | 56 +++++---------- chipcompiler/runtime/requests.py | 5 +- chipcompiler/runtime/workspace_api.py | 26 ++++++- test/cli/commands/test_run.py | 8 ++- test/cli/commands/test_run_worker.py | 72 +++++++++++++++++++- test/runtime/test_log_stream.py | 15 +++- test/runtime/test_requests.py | 19 +++++- test/runtime/test_workspace_api.py | 57 ++++++++++++++++ 9 files changed, 209 insertions(+), 52 deletions(-) diff --git a/chipcompiler/cli/command_handlers/project.py b/chipcompiler/cli/command_handlers/project.py index 85b8dd13..128ce66d 100644 --- a/chipcompiler/cli/command_handlers/project.py +++ b/chipcompiler/cli/command_handlers/project.py @@ -612,7 +612,8 @@ def no_op_result() -> CommandResult: if command_input.only is not None: # An executed --only step always reruns with clean artifacts; the # --force distinction only gates whether a successful step qualifies. - calls = [("flow.run_step", {"step": target, "rerun": True})] + # Downstream steps keep their outputs but are marked Unstart. + calls = [("flow.run_step", {"step": target, "rerun": True, "invalidate_dependents": True})] else: calls = [ ("flow.run_step", {"step": target, "rerun": True, "reset_dependents": True}), diff --git a/chipcompiler/runtime/log_stream.py b/chipcompiler/runtime/log_stream.py index c9e4d42b..86368e77 100644 --- a/chipcompiler/runtime/log_stream.py +++ b/chipcompiler/runtime/log_stream.py @@ -188,27 +188,28 @@ def _is_allowed_step(self, step: str, tool: str) -> bool: return True return (step, tool) in self._valid_steps - def _archive_target_ok(self, step: str, tool: str) -> bool: - """Validate sanitization and containment before activating a step. + def _validated_archive_path(self, step: str, tool: str) -> Path | None: + """Resolve and validate the archive path for a begin marker. - A marker whose archive target is unsafe or unresolvable is degraded to - ordinary bytes instead of activating archival. + Returns the validated path, or None when archiving is not configured + (resolver absent) or when the marker must degrade to ordinary bytes + (unsafe names, resolver failure, containment violation). """ if self._resolve_path is None: - return True + return None for value in (step, tool): if not value or "/" in value or "\\" in value or ".." in value: if self._state.error is None: self._state.error = ValueError(f"unsafe step marker name: {value!r}") - return False + return None try: path = self._resolve_path(step, tool) except Exception as exc: if self._state.error is None: self._state.error = exc - return False + return None if path is None: - return False + return None if self._workspace_dir is not None: try: resolved = path.resolve() @@ -219,12 +220,12 @@ def _archive_target_ok(self, step: str, tool: str) -> bool: ): if self._state.error is None: self._state.error = ValueError(f"archive path escapes workspace: {path}") - return False + return None except (OSError, ValueError) as exc: if self._state.error is None: self._state.error = exc - return False - return True + return None + return path def _handle_marker(self, marker: StepMarker, raw_line: bytes) -> None: if marker.event == "begin": @@ -234,13 +235,15 @@ def _handle_marker(self, marker: StepMarker, raw_line: bytes) -> None: if self._state.active_step is not None: self._emit_data(raw_line) return - if not self._archive_target_ok(marker.step, marker.tool): + archive_path = self._validated_archive_path(marker.step, marker.tool) + if self._resolve_path is not None and archive_path is None: self._emit_data(raw_line) return self._state.active_step = marker.step self._state.active_tool = marker.tool self._state.steps_seen.append(marker.step) - self._open_archive(marker.step, marker.tool) + if archive_path is not None: + self._open_archive(archive_path) self._emit_step_event("begin", marker.step, marker.tool) elif marker.event == "end": if marker.step == self._state.active_step and marker.tool == self._state.active_tool: @@ -289,32 +292,7 @@ def _update_tail(self, data: bytes) -> None: combined = combined[-self._tail_size :] self._state.tail_bytes = combined - def _open_archive(self, step: str, tool: str) -> None: - if self._resolve_path is None: - return - try: - path = self._resolve_path(step, tool) - except Exception as exc: - if self._state.error is None: - self._state.error = exc - return - if path is None: - return - if self._workspace_dir is not None: - try: - resolved = path.resolve() - workspace_resolved = self._workspace_dir.resolve() - if not ( - resolved == workspace_resolved - or str(resolved).startswith(str(workspace_resolved) + os.sep) - ): - if self._state.error is None: - self._state.error = ValueError(f"archive path escapes workspace: {path}") - return - except (OSError, ValueError) as exc: - if self._state.error is None: - self._state.error = exc - return + def _open_archive(self, path: Path) -> None: try: path.parent.mkdir(parents=True, exist_ok=True) self._state.archive_file = path.open("wb") # noqa: SIM115 diff --git a/chipcompiler/runtime/requests.py b/chipcompiler/runtime/requests.py index 3e7d2367..05bceb65 100644 --- a/chipcompiler/runtime/requests.py +++ b/chipcompiler/runtime/requests.py @@ -68,6 +68,7 @@ class FlowRunStepRequest: step: str rerun: bool = False reset_dependents: bool = False + invalidate_dependents: bool = False @dataclass(frozen=True) @@ -178,6 +179,7 @@ def __init__(self, reason: str): "workspaceRevision": "workspace_revision", "idempotencyKey": "idempotency_key", "resetDependents": "reset_dependents", + "invalidateDependents": "invalidate_dependents", "configPath": "config_path", "outputPath": "output_path", "infoId": "info_id", @@ -214,7 +216,8 @@ def parse_request_model(model: type, params: object): if required and _is_missing(values[field.name]): raise RequestValidationError(f"missing required field: {field.name}") - if field.name in {"rerun", "reset_dependents"} and not isinstance(values[field.name], bool): + bool_fields = {"rerun", "reset_dependents", "invalidate_dependents"} + if field.name in bool_fields and not isinstance(values[field.name], bool): raise RequestValidationError(f"{field.name} must be a boolean") return model(**values) diff --git a/chipcompiler/runtime/workspace_api.py b/chipcompiler/runtime/workspace_api.py index ea75ed39..fcbec884 100644 --- a/chipcompiler/runtime/workspace_api.py +++ b/chipcompiler/runtime/workspace_api.py @@ -322,13 +322,22 @@ def run_step(session: WorkspaceSession) -> dict: affected_steps = self._rerun_affected_steps( engine_flow, workspace_step, - reset_dependents=reset_dependents, + reset_dependents=reset_dependents or request.invalidate_dependents, ) + if reset_dependents: + prepare_steps, invalidate_steps = affected_steps, [] + elif request.invalidate_dependents: + # Clear only the target's artifacts; downstream steps keep + # their outputs but are marked Unstart for a later resume. + prepare_steps, invalidate_steps = affected_steps[:1], affected_steps[1:] + else: + prepare_steps, invalidate_steps = affected_steps, [] self._prepare_steps_for_rerun( session.workspace, engine_flow, - affected_steps, + prepare_steps, ) + self._invalidate_step_records(engine_flow, invalidate_steps) self._notify_rerun_prepared( observer, affected_steps, @@ -903,6 +912,19 @@ def _rerun_affected_steps(engine_flow, workspace_step, *, reset_dependents: bool return [workspace_step] return workspace_steps[start_index:] + @staticmethod + def _invalidate_step_records(engine_flow, workspace_steps) -> None: + """Mark steps Unstart in flow.json without touching their artifacts.""" + updated = False + for workspace_step in workspace_steps: + record = engine_flow.get_step(workspace_step.name, workspace_step.tool) + if record is None: + continue + record.update({"state": "Unstart", "runtime": "", "peak memory (mb)": 0}) + updated = True + if updated: + engine_flow.save() + @staticmethod def _notify_rerun_prepared( observer, diff --git a/test/cli/commands/test_run.py b/test/cli/commands/test_run.py index 77a77a7c..cac0ce86 100644 --- a/test/cli/commands/test_run.py +++ b/test/cli/commands/test_run.py @@ -304,7 +304,9 @@ def test_only_force_wiring(self, workspace_mocks, tmp_path, capsys): record = json.loads(capsys.readouterr().out)["records"][0] assert rc == 0 assert workspace_mocks.load_path == workspace - assert workspace_mocks.calls == [("flow.run_step", {"step": "place", "rerun": True})] + assert workspace_mocks.calls == [ + ("flow.run_step", {"step": "place", "rerun": True, "invalidate_dependents": True}) + ] assert record["run"] == "workspace" assert record["status"] == "success" assert record["workspace"] == workspace @@ -317,7 +319,9 @@ def test_only_without_force_runs_step(self, workspace_mocks, tmp_path): rc = cli_main.run(["run", "--workspace", workspace, "--only", "place", "--json"]) assert rc == 0 - assert workspace_mocks.calls == [("flow.run_step", {"step": "place", "rerun": True})] + assert workspace_mocks.calls == [ + ("flow.run_step", {"step": "place", "rerun": True, "invalidate_dependents": True}) + ] def test_only_success_step_without_force_is_noop(self, workspace_mocks, tmp_path, capsys): workspace_mocks.steps = [ diff --git a/test/cli/commands/test_run_worker.py b/test/cli/commands/test_run_worker.py index f0346378..b68f7386 100644 --- a/test/cli/commands/test_run_worker.py +++ b/test/cli/commands/test_run_worker.py @@ -80,12 +80,24 @@ def run_pending(ws_dir): result = {"workspaceId": "fake-worker"} send_response({"jsonrpc": "2.0", "result": result, "id": req_id}) elif method == "flow.run_step": - step = req["params"]["step"] + params = req["params"] + step = params["step"] tool = "ecc" with open(flow_json_path(ws_dir)) as handle: - for record in json.load(handle)["steps"]: + flow_data = json.load(handle) + for record in flow_data["steps"]: + if record["name"] == step: + tool = record.get("tool", "ecc") + if params.get("invalidate_dependents"): + seen_target = False + for record in flow_data["steps"]: if record["name"] == step: - tool = record.get("tool", "ecc") + seen_target = True + continue + if seen_target: + record["state"] = "Unstart" + with open(flow_json_path(ws_dir), "w") as handle: + json.dump(flow_data, handle) run_one_step(ws_dir, step, tool) result = {"step": step, "state": "Success"} send_response({"jsonrpc": "2.0", "result": result, "id": req_id}) @@ -205,6 +217,60 @@ def test_only_executes_single_step(self, fake_worker, validation_mocks, tmp_path } assert not os.path.exists(os.path.join(workspace, "CTS_ecc")) + def test_only_force_marks_downstream_unstart_but_keeps_outputs( + self, fake_worker, tmp_path, capsys, monkeypatch + ): + workspace = str(tmp_path / "workspace") + _write_flow_json( + workspace, + [ + {"name": "Synthesis", "tool": "yosys", "state": "Success"}, + {"name": "place", "tool": "ecc", "state": "Success"}, + {"name": "CTS", "tool": "ecc", "state": "Success"}, + ], + ) + cts_output = os.path.join(workspace, "CTS_ecc", "output") + os.makedirs(cts_output) + with open(os.path.join(cts_output, "result.def"), "w") as handle: + handle.write("old") + + class Flow: + def __init__(self, workspace): + self.workspace = workspace + + def has_init(self): + return True + + def fake_load_workspace(path): + return SimpleNamespace( + name="workspace", + flow=SimpleNamespace( + data={ + "steps": [ + {"name": "Synthesis", "tool": "yosys", "state": "Success"}, + {"name": "place", "tool": "ecc", "state": "Success"}, + {"name": "CTS", "tool": "ecc", "state": "Success"}, + ] + } + ), + ) + + monkeypatch.setattr("chipcompiler.data.load_workspace", fake_load_workspace) + monkeypatch.setattr("chipcompiler.engine.EngineFlow", Flow) + + rc = cli_main.run(["run", "--workspace", workspace, "--only", "place", "--force", "--json"]) + + record = json.loads(capsys.readouterr().out)["records"][0] + assert rc == 0 + assert record["executed_steps"] == ["place"] + assert _read_flow_states(workspace) == { + "Synthesis": "Success", + "place": "Success", + "CTS": "Unstart", + } + with open(os.path.join(cts_output, "result.def")) as handle: + assert handle.read() == "old" + class TestFlowRunViaWorkerArchival: def test_non_tty_run_archives_step_logs_without_markers( diff --git a/test/runtime/test_log_stream.py b/test/runtime/test_log_stream.py index a933c3db..b1b20b85 100644 --- a/test/runtime/test_log_stream.py +++ b/test/runtime/test_log_stream.py @@ -84,17 +84,26 @@ def test_c_stdio_buffer_drains_before_marker(self, tmp_path): import ctypes libc = ctypes.CDLL(None) + libc.fdopen.restype = ctypes.c_void_p + libc.fdopen.argtypes = [ctypes.c_int, ctypes.c_char_p] + libc.setvbuf.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_int, ctypes.c_size_t] libc.fputs.argtypes = [ctypes.c_char_p, ctypes.c_void_p] - stderr_file = ctypes.c_void_p.in_dll(libc, "stderr") + libc.fclose.argtypes = [ctypes.c_void_p] + _IOFBF = 0 sink = tmp_path / "fd2.bin" saved_fd = os.dup(2) try: with sink.open("wb") as handle: os.dup2(handle.fileno(), 2) - libc.fputs(b"native-before-end\n", stderr_file) - # No fflush here: emit_step_marker must drain the C buffer first. + # A fully buffered FILE* targeting fd 2: fputs bytes stay in the C + # buffer until something flushes the process streams. + stream = libc.fdopen(os.dup(2), b"w") + assert stream + assert libc.setvbuf(stream, None, _IOFBF, 4096) == 0 + libc.fputs(b"native-before-end\n", stream) emit_step_marker("end", step="S", tool="T") + libc.fclose(stream) finally: os.dup2(saved_fd, 2) os.close(saved_fd) diff --git a/test/runtime/test_requests.py b/test/runtime/test_requests.py index ab6b5fac..2a56057a 100644 --- a/test/runtime/test_requests.py +++ b/test/runtime/test_requests.py @@ -311,13 +311,17 @@ def test_rerun_must_be_boolean(method, params): "flow.run_step", {"workspaceId": "ws-1", "step": "Synthesis", "resetDependents": 1}, ), + ( + "flow.run_step", + {"workspaceId": "ws-1", "step": "Synthesis", "invalidateDependents": 1}, + ), ], ) def test_reset_dependents_must_be_boolean(method, params): with pytest.raises(RequestValidationError) as exc_info: _parse_runtime_request(method, params) - assert exc_info.value.reason == "reset_dependents must be a boolean" + assert "must be a boolean" in exc_info.value.reason def test_flow_run_step_parses_reset_dependents(): @@ -333,6 +337,19 @@ def test_flow_run_step_parses_reset_dependents(): ) +def test_flow_run_step_parses_invalidate_dependents(): + request = _parse_runtime_request( + "flow.run_step", + {"workspaceId": "ws-1", "step": "Synthesis", "invalidateDependents": True}, + ) + + assert request == FlowRunStepRequest( + workspace_id="ws-1", + step="Synthesis", + invalidate_dependents=True, + ) + + def test_flow_run_step_reset_dependents_defaults_to_false(): request = _parse_runtime_request( "flow.run_step", diff --git a/test/runtime/test_workspace_api.py b/test/runtime/test_workspace_api.py index a55be043..d6024042 100644 --- a/test/runtime/test_workspace_api.py +++ b/test/runtime/test_workspace_api.py @@ -1453,6 +1453,63 @@ def step_spec(name, tool): assert records["route"]["state"] == "Unstart" +def test_flow_run_step_direct_rerun_invalidate_dependents_keeps_downstream_outputs( + monkeypatch, tmp_path +): + _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) + + def step_spec(name, tool): + step_dir = ws / f"{name}_{tool}" + artifact_dir = step_dir / "output" + artifact_dir.mkdir(parents=True) + (artifact_dir / "stale").write_text(name) + subflow_path = step_dir / "subflow.json" + subflow_path.write_text(json.dumps({"path": str(subflow_path), "steps": []})) + checklist_path = step_dir / "checklist.json" + checklist_path.write_text(json.dumps({"checklist": []})) + return { + "name": name, + "tool": tool, + "output": {"dir": artifact_dir}, + "subflow": SimpleNamespace(path=subflow_path, steps=[]), + "checklist": SimpleNamespace(path=checklist_path, checklist=[]), + } + + synthesis = step_spec("Synthesis", "yosys") + floorplan = step_spec("Floorplan", "ecc") + route = step_spec("route", "ecc") + DummyFlow.workspace_step_specs = (synthesis, floorplan, route) + api = WorkspaceRuntimeApi() + workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"] + session = api.sessions.get_session(workspace_id) + session.workspace.flow.data = { + "steps": [ + {"name": spec["name"], "tool": spec["tool"], "state": "Success"} + for spec in (synthesis, floorplan, route) + ] + } + + result = api.flow_run_step( + FlowRunStepRequest( + workspace_id=workspace_id, + step="Floorplan", + rerun=True, + invalidate_dependents=True, + ) + ) + + assert result == {"step": "Floorplan", "state": "Success"} + assert list(floorplan["output"]["dir"].iterdir()) == [] + assert (route["output"]["dir"] / "stale").read_text() == "route" + records = {record["name"]: record for record in session.workspace.flow.data["steps"]} + assert any( + record["name"] == "Synthesis" and record["state"] == "Success" + for record in session.workspace.flow.data["steps"] + ) + assert records["Floorplan"]["state"] == "Unstart" + assert records["route"]["state"] == "Unstart" + + def test_flow_run_step_rerun_rejects_an_open_layout_edit(monkeypatch, tmp_path): _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) api = WorkspaceRuntimeApi() From 19f3354fe53d1508301a8d6dd212b11241bbd86f Mon Sep 17 00:00:00 2001 From: Emin Date: Tue, 18 Aug 2026 18:33:47 +0800 Subject: [PATCH 21/52] style: drop trailing blank line at end of yosys utility tests --- test/tools/yosys/test_utility.py | 1 - 1 file changed, 1 deletion(-) diff --git a/test/tools/yosys/test_utility.py b/test/tools/yosys/test_utility.py index 240702e5..d5c119ad 100644 --- a/test/tools/yosys/test_utility.py +++ b/test/tools/yosys/test_utility.py @@ -133,4 +133,3 @@ def fake_run(cmd, cwd, env, stderr, timeout, stdout=None): assert ok is False assert "slang frontend check failed" in capsys.readouterr().out - From e70a47ea6484c6d580f70f7679666a663fb9915a Mon Sep 17 00:00:00 2001 From: Emin Date: Tue, 18 Aug 2026 20:39:21 +0800 Subject: [PATCH 22/52] refactor(runtime,cli): reuse shared helpers and trim hot-path copies - The log reader processes lines with a single split per chunk, trims its tail only past twice the cap, shares path_is_within for containment, and records first-error-wins through one helper. - RunOperation's failed-RPC branch reuses _handle_protocol_or_crash. - The CLI shares one worker-call entry point and one tolerant flow.json reader (cli.inspection.discovery.read_flow_json); the TTY live line throttles before decoding and sanitizing. - The rerun preparation collapses the duplicate prepare branches. - The marker protocol spec now documents the allowlist policy and each consumer's failure default. --- chipcompiler/cli/command_handlers/project.py | 80 ++++++++++---------- chipcompiler/cli/rendering/progress.py | 16 ++-- chipcompiler/runtime/log_stream.py | 74 ++++++++---------- chipcompiler/runtime/worker_operation.py | 17 +---- chipcompiler/runtime/workspace_api.py | 4 +- docs/specification/marker-protocol.md | 22 ++++++ test/cli/commands/test_run.py | 44 ++--------- 7 files changed, 105 insertions(+), 152 deletions(-) diff --git a/chipcompiler/cli/command_handlers/project.py b/chipcompiler/cli/command_handlers/project.py index 128ce66d..080a8e55 100644 --- a/chipcompiler/cli/command_handlers/project.py +++ b/chipcompiler/cli/command_handlers/project.py @@ -187,13 +187,6 @@ def _canonically_inside(path: str, anchor: str) -> bool: return real == real_base or real.startswith(real_base.rstrip(os.sep) + os.sep) -def _workspace_step_log_resolver(workspace_dir: str): - """Return a (step, tool) -> Path resolver for workspace step logs.""" - from chipcompiler.runtime.log_stream import step_log_archive_resolver - - return step_log_archive_resolver(workspace_dir) - - def _worker_binary_missing_error() -> str | None: from chipcompiler.runtime.worker_operation import _default_worker_argv @@ -203,42 +196,48 @@ def _worker_binary_missing_error() -> str | None: return None -def _load_valid_steps(flow_json_path) -> set[tuple[str, str]] | None: - import json as json_mod +def _read_flow_data(workspace_dir: str) -> dict | None: + """Read home/flow.json, tolerating a missing or corrupt file.""" + from chipcompiler.cli.inspection.discovery import CORRUPT_FLOW_JSON, read_flow_json - try: - with open(flow_json_path) as f: - flow_data = json_mod.load(f) - return { - (s["name"], s["tool"]) - for s in flow_data.get("steps", []) - if isinstance(s, dict) and "name" in s and "tool" in s - } - except (OSError, json_mod.JSONDecodeError, KeyError): + flow_data = read_flow_json(workspace_dir) + if flow_data is None or flow_data is CORRUPT_FLOW_JSON: return None + return flow_data + + +def _load_valid_steps(workspace_dir: str) -> set[tuple[str, str]] | None: + flow_data = _read_flow_data(workspace_dir) + if flow_data is None: + return None + return { + (s["name"], s["tool"]) + for s in flow_data.get("steps", []) + if isinstance(s, dict) and "name" in s and "tool" in s + } def _make_run_operation(workspace_dir: str, *, on_output=None, on_step_event=None): """Build a RunOperation for a workspace with step-log archiving wired in.""" from pathlib import Path + from chipcompiler.runtime.log_stream import step_log_archive_resolver from chipcompiler.runtime.worker_operation import RunOperation - flow_json_path = Path(workspace_dir) / "home" / "flow.json" return RunOperation( workspace_dir=Path(workspace_dir), - flow_json_path=flow_json_path, - log_path_resolver=_workspace_step_log_resolver(workspace_dir), + flow_json_path=Path(workspace_dir) / "home" / "flow.json", + log_path_resolver=step_log_archive_resolver(workspace_dir), on_output=on_output, on_step_event=on_step_event, - valid_steps=_load_valid_steps(flow_json_path), + valid_steps=_load_valid_steps(workspace_dir), ) -def _run_flow_via_worker(workspace_dir: str, *, on_output=None, on_step_event=None): - """Execute flow.run through an isolated worker process. +def _run_worker_calls(workspace_dir: str, calls: list[tuple[str, dict]], **callbacks): + """Execute an ordered RPC sequence through the workspace's worker. - Returns an OperationResult. A missing worker binary is a structured failure. + A missing worker binary is a structured failure, never a crash. """ from chipcompiler.runtime.worker_operation import OperationResult @@ -246,8 +245,18 @@ def _run_flow_via_worker(workspace_dir: str, *, on_output=None, on_step_event=No if missing is not None: return OperationResult(success=False, error=missing) - op = _make_run_operation(workspace_dir, on_output=on_output, on_step_event=on_step_event) - return op.run("flow.run", {"rerun": False}) + op = _make_run_operation(workspace_dir, **callbacks) + return op.run_sequence(calls) + + +def _run_flow_via_worker(workspace_dir: str, *, on_output=None, on_step_event=None): + """Execute flow.run through an isolated worker process.""" + return _run_worker_calls( + workspace_dir, + [("flow.run", {"rerun": False})], + on_output=on_output, + on_step_event=on_step_event, + ) def run(command_input: RunInput, ctx: CommandContext) -> CommandResult: @@ -620,14 +629,7 @@ def no_op_result() -> CommandResult: ("flow.run", {"rerun": False}), ] - from chipcompiler.runtime.worker_operation import OperationResult - - missing = _worker_binary_missing_error() - if missing is not None: - op_result = OperationResult(success=False, error=missing) - else: - op = _make_run_operation(workspace_path) - op_result = op.run_sequence(calls) + op_result = _run_worker_calls(workspace_path, calls) if op_result.success: return CommandResult.ok( @@ -671,12 +673,8 @@ def _workspace_run_outcome( that did execute, the step that failed, and an Unstart remainder that was invalidated but never ran. """ - import json as json_mod - - try: - with open(os.path.join(workspace_path, "home", "flow.json")) as f: - flow_data = json_mod.load(f) - except (OSError, json_mod.JSONDecodeError): + flow_data = _read_flow_data(workspace_path) + if flow_data is None: return [], None states = { diff --git a/chipcompiler/cli/rendering/progress.py b/chipcompiler/cli/rendering/progress.py index 23e293d5..b2b2e476 100644 --- a/chipcompiler/cli/rendering/progress.py +++ b/chipcompiler/cli/rendering/progress.py @@ -1,4 +1,3 @@ -import json import os import re import shutil @@ -199,26 +198,25 @@ def run_flow_with_progress(workspace_dir, ctx, project, stderr, run_operation): from chipcompiler.runtime.log_stream import step_log_archive_resolver - flow_json_path = os.path.join(workspace_dir, "home", "flow.json") resolve_log = step_log_archive_resolver(workspace_dir) rendered = set() live = {"written_at": 0.0} def on_output(data: bytes) -> None: - text = sanitize_log_line(data.decode("utf-8", errors="replace")) - if not text: - return now = time.monotonic() if now - live["written_at"] < _LIVE_LINE_MIN_INTERVAL: return + text = sanitize_log_line(data.decode("utf-8", errors="replace")) + if not text: + return live["written_at"] = now renderer.running(text) def refresh_final_states() -> None: - try: - with open(flow_json_path) as handle: - flow_data = json.load(handle) - except (OSError, json.JSONDecodeError): + from chipcompiler.cli.inspection.discovery import CORRUPT_FLOW_JSON, read_flow_json + + flow_data = read_flow_json(workspace_dir) + if flow_data is None or flow_data is CORRUPT_FLOW_JSON: return for record in flow_data.get("steps", []): if not isinstance(record, dict): diff --git a/chipcompiler/runtime/log_stream.py b/chipcompiler/runtime/log_stream.py index 86368e77..62b3e986 100644 --- a/chipcompiler/runtime/log_stream.py +++ b/chipcompiler/runtime/log_stream.py @@ -18,6 +18,8 @@ from pathlib import Path from typing import BinaryIO +from chipcompiler.utility.path import path_is_within + MARKER_PREFIX = b"\x1eECC-STEP " MARKER_VERSION = 1 @@ -132,6 +134,10 @@ def __init__( def state(self) -> LogStreamState: return self._state + def _record_error(self, exc: Exception) -> None: + if self._state.error is None: + self._state.error = exc + def start(self) -> None: self._thread = threading.Thread(target=self._drain_loop, name="ecc-log-reader", daemon=True) self._thread.start() @@ -167,21 +173,20 @@ def _drain_loop(self) -> None: self._close_archive() def _process_buffer(self, buf: bytes) -> bytes: - while True: - nl = buf.find(b"\n") - if nl < 0: - if buf.startswith(MARKER_PREFIX[:1]) and len(buf) < 512: - return buf - if buf: - self._emit_data(buf) - return b"" - line = buf[: nl + 1] - buf = buf[nl + 1 :] - marker = parse_marker(line) + lines = buf.split(b"\n") + for line in lines[:-1]: + frame = line + b"\n" + marker = parse_marker(frame) if marker is not None: - self._handle_marker(marker, line) + self._handle_marker(marker, frame) else: - self._emit_data(line) + self._emit_data(frame) + remainder = lines[-1] + if remainder.startswith(MARKER_PREFIX[:1]) and len(remainder) < 512: + return remainder + if remainder: + self._emit_data(remainder) + return b"" def _is_allowed_step(self, step: str, tool: str) -> bool: if self._valid_steps is None: @@ -199,32 +204,18 @@ def _validated_archive_path(self, step: str, tool: str) -> Path | None: return None for value in (step, tool): if not value or "/" in value or "\\" in value or ".." in value: - if self._state.error is None: - self._state.error = ValueError(f"unsafe step marker name: {value!r}") + self._record_error(ValueError(f"unsafe step marker name: {value!r}")) return None try: path = self._resolve_path(step, tool) except Exception as exc: - if self._state.error is None: - self._state.error = exc + self._record_error(exc) return None if path is None: return None - if self._workspace_dir is not None: - try: - resolved = path.resolve() - workspace_resolved = self._workspace_dir.resolve() - if not ( - resolved == workspace_resolved - or str(resolved).startswith(str(workspace_resolved) + os.sep) - ): - if self._state.error is None: - self._state.error = ValueError(f"archive path escapes workspace: {path}") - return None - except (OSError, ValueError) as exc: - if self._state.error is None: - self._state.error = exc - return None + if self._workspace_dir is not None and not path_is_within(path, self._workspace_dir): + self._record_error(ValueError(f"archive path escapes workspace: {path}")) + return None return path def _handle_marker(self, marker: StepMarker, raw_line: bytes) -> None: @@ -262,8 +253,7 @@ def _emit_step_event(self, event: str, step: str, tool: str) -> None: try: self._on_step_event(event, step, tool) except Exception as exc: - if self._state.error is None: - self._state.error = exc + self._record_error(exc) self._on_step_event_disabled = True def _emit_data(self, data: bytes) -> None: @@ -272,8 +262,7 @@ def _emit_data(self, data: bytes) -> None: self._state.archive_file.write(data) self._state.bytes_archived += len(data) except OSError as exc: - if self._state.error is None: - self._state.error = exc + self._record_error(exc) with suppress(OSError): self._state.archive_file.close() self._state.archive_file = None @@ -282,13 +271,12 @@ def _emit_data(self, data: bytes) -> None: try: self._on_output(data) except Exception as exc: - if self._state.error is None: - self._state.error = exc + self._record_error(exc) self._on_output_disabled = True def _update_tail(self, data: bytes) -> None: combined = self._state.tail_bytes + data - if len(combined) > self._tail_size: + if len(combined) > 2 * self._tail_size: combined = combined[-self._tail_size :] self._state.tail_bytes = combined @@ -297,7 +285,7 @@ def _open_archive(self, path: Path) -> None: path.parent.mkdir(parents=True, exist_ok=True) self._state.archive_file = path.open("wb") # noqa: SIM115 except OSError as exc: - self._state.error = exc + self._record_error(exc) self._state.archive_file = None def _close_archive(self) -> None: @@ -305,12 +293,10 @@ def _close_archive(self) -> None: try: self._state.archive_file.flush() except OSError as exc: - if self._state.error is None: - self._state.error = exc + self._record_error(exc) finally: try: self._state.archive_file.close() except OSError as exc: - if self._state.error is None: - self._state.error = exc + self._record_error(exc) self._state.archive_file = None diff --git a/chipcompiler/runtime/worker_operation.py b/chipcompiler/runtime/worker_operation.py index ffc33d88..90714ab5 100644 --- a/chipcompiler/runtime/worker_operation.py +++ b/chipcompiler/runtime/worker_operation.py @@ -137,22 +137,7 @@ def run_sequence( break if rpc_result is not None and not rpc_result.success: - if rpc_result.response is None or not client.is_alive(): - error = rpc_result.error or "protocol failure" - return self._handle_crash(client, reader, error) - # Live-worker RPC error: graceful shutdown then drain - self._graceful_shutdown(client) - reader.join(timeout=5.0) - reader.stop() - log_state = reader.state - return OperationResult( - success=False, - rpc_result=rpc_result.response, - exit_code=client.process.returncode if client.process else None, - error=rpc_result.error, - archive_error=log_state.error, - log_state=log_state, - ) + return self._handle_protocol_or_crash(client, reader, rpc_result) shutdown_ok = self._graceful_shutdown(client) diff --git a/chipcompiler/runtime/workspace_api.py b/chipcompiler/runtime/workspace_api.py index fcbec884..547eee45 100644 --- a/chipcompiler/runtime/workspace_api.py +++ b/chipcompiler/runtime/workspace_api.py @@ -324,9 +324,7 @@ def run_step(session: WorkspaceSession) -> dict: workspace_step, reset_dependents=reset_dependents or request.invalidate_dependents, ) - if reset_dependents: - prepare_steps, invalidate_steps = affected_steps, [] - elif request.invalidate_dependents: + if request.invalidate_dependents and not reset_dependents: # Clear only the target's artifacts; downstream steps keep # their outputs but are marked Unstart for a later resume. prepare_steps, invalidate_steps = affected_steps[:1], affected_steps[1:] diff --git a/docs/specification/marker-protocol.md b/docs/specification/marker-protocol.md index b4000452..24949df2 100644 --- a/docs/specification/marker-protocol.md +++ b/docs/specification/marker-protocol.md @@ -125,6 +125,28 @@ workspace. The archive is opened with truncation on each accepted `begin`, so a rerun starts a fresh byte stream with cursor 0. +## Allowlist + +On top of sanitization and containment, consumers should restrict archival +to `(step, tool)` pairs read from the workspace's flow.json: a begin marker +whose pair is not in the allowlist degrades to ordinary bytes. The allowlist +is loaded at workspace open and refreshed when an operation starts and when +a rerun is prepared. + +When flow.json cannot be read, each consumer picks its failure default and +documents it here: + +- the **GUI** archiver fails closed (empty allowlist): all markers degrade + to unscoped bytes, which still land in the sidecar log file, so no output + is lost; +- the **CLI** reader fails open (no allowlist): markers are honored after + sanitization and containment only, because the archived step logs are the + CLI's only record of the run. + +Both defaults are safe: containment bounds where bytes can be written, and +the bytes always land in a visible place. + + ## Protocol Change: Live-Log Events Are Client-Synthesized As of this protocol version, ecc no longer emits `step.log` notifications or diff --git a/test/cli/commands/test_run.py b/test/cli/commands/test_run.py index cac0ce86..5786a916 100644 --- a/test/cli/commands/test_run.py +++ b/test/cli/commands/test_run.py @@ -5,6 +5,9 @@ import pytest from chipcompiler.cli import main as cli_main +from chipcompiler.cli.command_handlers import project as project_module + +_REAL_RUN_FLOW_VIA_WORKER = project_module._run_flow_via_worker def _set_flow_preset(project_dir, preset): @@ -500,52 +503,15 @@ def test_option_conflicts(self, argv, error, capsys, monkeypatch): assert record["error"] == error -class TestWorkspaceStepLogResolver: - def test_resolver_produces_canonical_step_log_path(self, tmp_path): - from chipcompiler.cli.command_handlers.project import _workspace_step_log_resolver - - resolver = _workspace_step_log_resolver(str(tmp_path)) - path = resolver("Synthesis", "yosys") - assert path == tmp_path / "Synthesis_yosys" / "log" / "Synthesis.log" - - def test_resolver_produces_correct_paths_for_multiple_tools(self, tmp_path): - from chipcompiler.cli.command_handlers.project import _workspace_step_log_resolver - - resolver = _workspace_step_log_resolver(str(tmp_path)) - assert resolver("Floorplan", "ecc") == tmp_path / "Floorplan_ecc" / "log" / "Floorplan.log" - assert resolver("CTS", "ecc") == tmp_path / "CTS_ecc" / "log" / "CTS.log" - assert resolver("Place", "ecc") == tmp_path / "Place_ecc" / "log" / "Place.log" - - class TestRunFlowViaWorkerFailure: def test_missing_binary_returns_structured_failure(self, tmp_path, monkeypatch): - """The binary check in _run_flow_via_worker returns a typed error.""" + """A missing worker binary is a typed failure, not a crash.""" monkeypatch.setattr( "chipcompiler.runtime.worker_operation._default_worker_argv", lambda: [str(tmp_path / "nonexistent_ecc"), "rpc", "serve", "--stdio"], ) - # Also restore the real function past the autouse fixture - from chipcompiler.cli.command_handlers import project as proj_module - from chipcompiler.runtime.worker_operation import ( - OperationResult, - RunOperation, - _default_worker_argv, - ) - def real_run_flow_via_worker(workspace_dir): - from pathlib import Path - - argv = _default_worker_argv() - if not os.path.isfile(argv[0]): - return OperationResult(success=False, error=f"worker binary not found: {argv[0]}") - flow_json_path = Path(workspace_dir) / "home" / "flow.json" - op = RunOperation( - workspace_dir=Path(workspace_dir), - flow_json_path=flow_json_path, - log_path_resolver=proj_module._workspace_step_log_resolver(workspace_dir), - ) - return op.run("flow.run", {"rerun": False}) + result = _REAL_RUN_FLOW_VIA_WORKER(str(tmp_path)) - result = real_run_flow_via_worker(str(tmp_path)) assert result.success is False assert "not found" in result.error From bcfed8fb5506901fa72a885f323d699066d41f8f Mon Sep 17 00:00:00 2001 From: Emin Date: Tue, 18 Aug 2026 21:52:16 +0800 Subject: [PATCH 23/52] fix(runtime): scan for step markers at arbitrary byte boundaries The producer never inserts a newline before a marker frame, so a frame can immediately follow tool output that lacks a trailing newline. The reader now scans for the reserved prefix anywhere in the stream: bytes before a candidate are data, a trailing partial prefix is held back, incomplete candidates are bounded, and only valid newline-terminated v1 frames are consumed. The specification documents the scanning rule and now shows the exact wire spelling of the prefix. --- chipcompiler/runtime/log_stream.py | 37 +++++++++--- docs/specification/marker-protocol.md | 16 ++++- test/runtime/test_log_stream.py | 86 +++++++++++++++++++++++++++ 3 files changed, 127 insertions(+), 12 deletions(-) diff --git a/chipcompiler/runtime/log_stream.py b/chipcompiler/runtime/log_stream.py index 62b3e986..27adc13c 100644 --- a/chipcompiler/runtime/log_stream.py +++ b/chipcompiler/runtime/log_stream.py @@ -173,20 +173,39 @@ def _drain_loop(self) -> None: self._close_archive() def _process_buffer(self, buf: bytes) -> bytes: - lines = buf.split(b"\n") - for line in lines[:-1]: - frame = line + b"\n" + while True: + idx = buf.find(MARKER_PREFIX) + if idx < 0: + # No candidate frame: hold back only a trailing partial prefix. + tail = buf.rfind(MARKER_PREFIX[:1]) + if tail >= 0 and MARKER_PREFIX.startswith(buf[tail:]): + if tail: + self._emit_data(buf[:tail]) + return buf[tail:] + if buf: + self._emit_data(buf) + return b"" + if idx > 0: + # Bytes before a marker candidate are ordinary stream data. + self._emit_data(buf[:idx]) + buf = buf[idx:] + continue + nl = buf.find(b"\n") + if nl < 0: + if len(buf) < 512: + return buf + # An overlong candidate without a newline is not a marker: + # emit the prefix's first byte and rescan the remainder. + self._emit_data(buf[:1]) + buf = buf[1:] + continue + frame = buf[: nl + 1] marker = parse_marker(frame) if marker is not None: self._handle_marker(marker, frame) else: self._emit_data(frame) - remainder = lines[-1] - if remainder.startswith(MARKER_PREFIX[:1]) and len(remainder) < 512: - return remainder - if remainder: - self._emit_data(remainder) - return b"" + buf = buf[nl + 1 :] def _is_allowed_step(self, step: str, tool: str) -> bool: if self._valid_steps is None: diff --git a/docs/specification/marker-protocol.md b/docs/specification/marker-protocol.md index 24949df2..14594cf6 100644 --- a/docs/specification/marker-protocol.md +++ b/docs/specification/marker-protocol.md @@ -29,11 +29,12 @@ to any user-visible surface, or shown to users. A marker frame is exactly one line on fd 2: ``` -\x1e ECC-STEP \n +\x1eECC-STEP {"v":1,"event":"begin","step":"Synthesis","tool":"yosys"}\n ``` -- `\x1e` is the ASCII Record Separator control character. -- The literal prefix `ECC-STEP ` (with one trailing space) follows. +- `\x1e` is the ASCII Record Separator control character (one byte, `0x1e`). +- The literal text `ECC-STEP ` (with one trailing space) follows immediately, + with no space between `\x1e` and `ECC-STEP`. - The payload is a single JSON object serialized without insignificant whitespace. - The frame is terminated by a single `\n`. @@ -64,6 +65,15 @@ Producer rules (executor): Consumer rules (client archiver): +- Consumers scan for the reserved prefix `\x1eECC-STEP ` at **arbitrary byte + boundaries**, not only at line starts: the producer does not insert a + newline before a frame, so a frame may immediately follow output that lacks + a trailing newline. Bytes preceding a candidate frame are ordinary stream + data. +- A candidate is only consumed when it is a complete, newline-terminated, + valid v1 frame. An incomplete candidate at the end of the buffered stream + is held back (bounded: any candidate longer than 512 bytes without a + newline is not a frame; its bytes are data). - A `begin` frame while no step is active: open/activate archival for `(step, tool)`. An `end` frame matching the active `(step, tool)`: close archival. Both frames are consumed (not archived). diff --git a/test/runtime/test_log_stream.py b/test/runtime/test_log_stream.py index b1b20b85..d687792c 100644 --- a/test/runtime/test_log_stream.py +++ b/test/runtime/test_log_stream.py @@ -12,6 +12,23 @@ ) +class _ChunkedStream: + """A binary stream that returns fixed-size chunks regardless of read size.""" + + def __init__(self, data: bytes, chunk_size: int): + self._data = data + self._chunk_size = chunk_size + self._pos = 0 + + def read(self, n: int = -1) -> bytes: + if self._pos >= len(self._data): + return b"" + size = self._chunk_size if n < 0 else min(n, self._chunk_size) + part = self._data[self._pos : self._pos + size] + self._pos += size + return part + + class TestParseMarker: def test_valid_begin(self): line = b'\x1eECC-STEP {"v":1,"event":"begin","step":"Synthesis","tool":"yosys"}\n' @@ -646,6 +663,75 @@ def failing_callback(event, step, tool): assert log_path.read_bytes() == b"line 2\n" +class TestMarkerBoundaryScanning: + """Markers are recognized wherever the reserved prefix appears.""" + + def test_end_marker_after_unterminated_output(self, tmp_path): + log_path = tmp_path / "step.log" + stream_data = ( + b'\x1eECC-STEP {"v":1,"event":"begin","step":"S","tool":"T"}\n' + b"last line without newline" + b'\x1eECC-STEP {"v":1,"event":"end","step":"S","tool":"T"}\n' + ) + reader = LogStreamReader( + io.BytesIO(stream_data), log_path_resolver=lambda step, tool: log_path + ) + reader.start() + reader.join(timeout=5) + assert log_path.read_bytes() == b"last line without newline" + assert reader.state.active_step is None + assert reader.state.steps_seen == ["S"] + + def test_end_marker_split_after_unterminated_output(self, tmp_path): + log_path = tmp_path / "step.log" + end_frame = b'\x1eECC-STEP {"v":1,"event":"end","step":"S","tool":"T"}\n' + stream_data = ( + b'\x1eECC-STEP {"v":1,"event":"begin","step":"S","tool":"T"}\nunterminated' + end_frame + ) + reader = LogStreamReader( + _ChunkedStream(stream_data, 7), log_path_resolver=lambda step, tool: log_path + ) + reader.start() + reader.join(timeout=5) + assert log_path.read_bytes() == b"unterminated" + assert reader.state.active_step is None + + def test_invalid_marker_mid_line_is_data(self, tmp_path): + log_path = tmp_path / "step.log" + stream_data = ( + b'\x1eECC-STEP {"v":1,"event":"begin","step":"S","tool":"T"}\n' + b"glued text \x1eECC-STEP {bad json}\n" + b'\x1eECC-STEP {"v":1,"event":"end","step":"S","tool":"T"}\n' + ) + reader = LogStreamReader( + io.BytesIO(stream_data), log_path_resolver=lambda step, tool: log_path + ) + reader.start() + reader.join(timeout=5) + content = log_path.read_bytes() + assert b"glued text " in content + assert b"{bad json}" in content + assert reader.state.active_step is None + + def test_overlong_candidate_recovers_following_marker(self, tmp_path): + log_path = tmp_path / "step.log" + stream_data = ( + b'\x1eECC-STEP {"v":1,"event":"begin","step":"S","tool":"T"}\n' + b"\x1eECC-STEP " + + b"a" * 600 + + b'\x1eECC-STEP {"v":1,"event":"end","step":"S","tool":"T"}\n' + ) + reader = LogStreamReader( + _ChunkedStream(stream_data, 100), log_path_resolver=lambda step, tool: log_path + ) + reader.start() + reader.join(timeout=5) + content = log_path.read_bytes() + assert b"a" * 600 in content + assert b'"event":"end"' not in content + assert reader.state.active_step is None + + class TestLogStreamResilience: def test_resolver_exception_disables_archive_continues_drain(self): """A resolver that raises must not kill the drain thread.""" From 03921a07aeb93c482989663e19d99d846d8294bb Mon Sep 17 00:00:00 2001 From: Emin Date: Tue, 18 Aug 2026 22:02:18 +0800 Subject: [PATCH 24/52] fix(runtime): repair unmatched step state on live-worker RPC errors A flow that raises after the begin marker returns a structured RPC error from a still-alive worker, leaving flow.json with a stale Ongoing record. The RPC-error path now performs the same repair as crash recovery after the reader drains: the active step is marked Incomplete and the result carries repaired_steps alongside the failing RPC response. --- chipcompiler/runtime/worker_operation.py | 9 +++++ test/runtime/test_worker_operation.py | 47 ++++++++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/chipcompiler/runtime/worker_operation.py b/chipcompiler/runtime/worker_operation.py index 90714ab5..9e43e9bb 100644 --- a/chipcompiler/runtime/worker_operation.py +++ b/chipcompiler/runtime/worker_operation.py @@ -195,11 +195,20 @@ def _handle_protocol_or_crash( reader.stop() log_state = reader.state if reader else None + # A live-worker RPC error can still leave a step unmatched: the flow + # raised after the begin marker, so flow.json may hold a stale Ongoing + # record. Repair it exactly as crash recovery does. + repaired: list[str] = [] + active_step = log_state.active_step if log_state else None + if active_step is not None and self._flow_json_path.exists(): + with suppress(OSError): + repaired = repair_flow_state(self._flow_json_path, active_step=active_step) return OperationResult( success=False, rpc_result=result.response, exit_code=client.process.returncode if client.process else None, error=result.error, + repaired_steps=repaired, archive_error=log_state.error if log_state else None, log_state=log_state, ) diff --git a/test/runtime/test_worker_operation.py b/test/runtime/test_worker_operation.py index 4db4c021..fd591434 100644 --- a/test/runtime/test_worker_operation.py +++ b/test/runtime/test_worker_operation.py @@ -185,6 +185,53 @@ def test_first_failure_skips_follow_up_and_still_shuts_down(self, tmp_path, monk assert received[-1] == "rpc.shutdown" +class TestRunOperationRpcErrorRepair: + def test_rpc_error_with_unmatched_step_repairs_flow_state(self, tmp_path): + """A live worker's RPC error still repairs a step left Ongoing.""" + script = tmp_path / "error_after_begin.py" + script.write_text( + _RPC_HELPERS + + textwrap.dedent("""\ + while True: + req = read_request() + if req is None: + break + method = req.get("method", "") + req_id = req.get("id") + if method == "rpc.hello": + send_response({"jsonrpc": "2.0", "result": {"version": 1}, "id": req_id}) + elif method == "workspace.open": + result = {"workspaceId": "x"} + send_response({"jsonrpc": "2.0", "result": result, "id": req_id}) + elif method == "flow.run_step": + os.write(2, make_marker("begin", "Synthesis", "yosys")) + os.write(2, b"partial output\\n") + err = {"code": -32000, "message": "run step Synthesis failed"} + send_response({"jsonrpc": "2.0", "error": err, "id": req_id}) + elif method == "rpc.shutdown": + send_response({"jsonrpc": "2.0", "result": {"ok": True}, "id": req_id}) + break + else: + err = {"code": -32601, "message": "unknown method"} + send_response({"jsonrpc": "2.0", "error": err, "id": req_id}) + """) + ) + flow_json = tmp_path / "flow.json" + data = {"steps": [{"name": "Synthesis", "tool": "yosys", "state": "Ongoing"}]} + flow_json.write_text(json.dumps(data)) + op = RunOperation( + workspace_dir=tmp_path, + flow_json_path=flow_json, + worker_argv=[sys.executable, str(script)], + ) + result = op.run_sequence([("flow.run_step", {"step": "Synthesis", "rerun": True})]) + assert result.success is False + assert "run step Synthesis failed" in result.error + assert result.repaired_steps == ["Synthesis"] + repaired_data = json.loads(flow_json.read_text()) + assert repaired_data["steps"][0]["state"] == "Incomplete" + + class TestRunOperationCrash: def test_worker_crash_triggers_repair(self, tmp_path): crash_script = tmp_path / "crash_after_open.py" From 7291400af7a64f4b56c2f38e2ae7eedf5716c4b1 Mon Sep 17 00:00:00 2001 From: Emin Date: Wed, 19 Aug 2026 07:56:25 +0800 Subject: [PATCH 25/52] test(runtime): split the log stream suite into coherent modules test_log_stream.py crossed the repository's 700-line review bar at 814 lines. The coverage now lives in three focused modules, every assertion preserved: test_log_stream.py keeps the reader archiving and resilience cases, test_log_stream_markers.py holds the parse/emit/boundary-scanning suite (including the chunked-stream helper), and test_log_stream_targets.py covers allowlist, sanitization, containment, resolver, and step-event behavior. --- test/runtime/test_log_stream.py | 468 +----------------------- test/runtime/test_log_stream_markers.py | 202 ++++++++++ test/runtime/test_log_stream_targets.py | 270 ++++++++++++++ 3 files changed, 475 insertions(+), 465 deletions(-) create mode 100644 test/runtime/test_log_stream_markers.py create mode 100644 test/runtime/test_log_stream_targets.py diff --git a/test/runtime/test_log_stream.py b/test/runtime/test_log_stream.py index d687792c..576361af 100644 --- a/test/runtime/test_log_stream.py +++ b/test/runtime/test_log_stream.py @@ -1,136 +1,8 @@ -"""Tests for chipcompiler.runtime.log_stream — marker parsing and archive.""" +"""Tests for chipcompiler.runtime.log_stream — reader archiving and resilience.""" import io -import os - -from chipcompiler.runtime.log_stream import ( - MARKER_PREFIX, - LogStreamReader, - StepMarker, - emit_step_marker, - parse_marker, -) - - -class _ChunkedStream: - """A binary stream that returns fixed-size chunks regardless of read size.""" - - def __init__(self, data: bytes, chunk_size: int): - self._data = data - self._chunk_size = chunk_size - self._pos = 0 - - def read(self, n: int = -1) -> bytes: - if self._pos >= len(self._data): - return b"" - size = self._chunk_size if n < 0 else min(n, self._chunk_size) - part = self._data[self._pos : self._pos + size] - self._pos += size - return part - - -class TestParseMarker: - def test_valid_begin(self): - line = b'\x1eECC-STEP {"v":1,"event":"begin","step":"Synthesis","tool":"yosys"}\n' - m = parse_marker(line) - assert m == StepMarker(event="begin", step="Synthesis", tool="yosys") - - def test_valid_end(self): - line = b'\x1eECC-STEP {"v":1,"event":"end","step":"Placement","tool":"ecc"}\n' - m = parse_marker(line) - assert m == StepMarker(event="end", step="Placement", tool="ecc") - - def test_no_prefix(self): - assert parse_marker(b"normal log line\n") is None - - def test_malformed_json(self): - assert parse_marker(b"\x1eECC-STEP {bad json}\n") is None - - def test_missing_fields(self): - line = b'\x1eECC-STEP {"v":1,"event":"begin"}\n' - assert parse_marker(line) is None - - def test_wrong_field_types(self): - line = b'\x1eECC-STEP {"v":1,"event":1,"step":"A","tool":"B"}\n' - assert parse_marker(line) is None - - def test_no_trailing_newline(self): - line = b'\x1eECC-STEP {"v":1,"event":"begin","step":"S","tool":"T"}' - m = parse_marker(line) - assert m is not None - assert m.event == "begin" - - def test_missing_version_rejected(self): - line = b'\x1eECC-STEP {"event":"begin","step":"S","tool":"T"}\n' - assert parse_marker(line) is None - - def test_unsupported_version_rejected(self): - line = b'\x1eECC-STEP {"v":2,"event":"begin","step":"S","tool":"T"}\n' - assert parse_marker(line) is None - - def test_string_version_rejected(self): - line = b'\x1eECC-STEP {"v":"1","event":"begin","step":"S","tool":"T"}\n' - assert parse_marker(line) is None - - def test_boolean_version_rejected(self): - line = b'\x1eECC-STEP {"v":true,"event":"begin","step":"S","tool":"T"}\n' - assert parse_marker(line) is None - - -class TestEmitStepMarker: - def test_payload_carries_version_and_round_trips(self, monkeypatch): - written = [] - real_write = os.write - - def fake_write(fd, data): - if fd == 2: - written.append(data) - return len(data) - return real_write(fd, data) - - monkeypatch.setattr(os, "write", fake_write) - emit_step_marker("begin", step="Synthesis", tool="yosys") - - assert written == [ - MARKER_PREFIX + b'{"v":1,"event":"begin","step":"Synthesis","tool":"yosys"}\n' - ] - assert parse_marker(written[0]) == StepMarker(event="begin", step="Synthesis", tool="yosys") - - def test_c_stdio_buffer_drains_before_marker(self, tmp_path): - """Native buffered output must reach fd 2 ahead of the marker bytes.""" - import ctypes - - libc = ctypes.CDLL(None) - libc.fdopen.restype = ctypes.c_void_p - libc.fdopen.argtypes = [ctypes.c_int, ctypes.c_char_p] - libc.setvbuf.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_int, ctypes.c_size_t] - libc.fputs.argtypes = [ctypes.c_char_p, ctypes.c_void_p] - libc.fclose.argtypes = [ctypes.c_void_p] - _IOFBF = 0 - - sink = tmp_path / "fd2.bin" - saved_fd = os.dup(2) - try: - with sink.open("wb") as handle: - os.dup2(handle.fileno(), 2) - # A fully buffered FILE* targeting fd 2: fputs bytes stay in the C - # buffer until something flushes the process streams. - stream = libc.fdopen(os.dup(2), b"w") - assert stream - assert libc.setvbuf(stream, None, _IOFBF, 4096) == 0 - libc.fputs(b"native-before-end\n", stream) - emit_step_marker("end", step="S", tool="T") - libc.fclose(stream) - finally: - os.dup2(saved_fd, 2) - os.close(saved_fd) - - content = sink.read_bytes() - assert content == ( - b"native-before-end\n" - + MARKER_PREFIX - + b'{"v":1,"event":"end","step":"S","tool":"T"}\n' - ) + +from chipcompiler.runtime.log_stream import LogStreamReader class TestLogStreamReader: @@ -398,340 +270,6 @@ def resolver(step, tool): assert reader.state.steps_seen == ["A"] -class TestLogStreamAllowlist: - def test_unknown_step_marker_treated_as_data(self, tmp_path): - """A begin marker for a pair not in valid_steps is archived as data.""" - log_path = tmp_path / "step.log" - - def resolver(step, tool): - return log_path - - valid = {("Synthesis", "yosys")} - unknown_begin = ( - b'\x1eECC-STEP {"v":1,"event":"begin","step":"../../escape","tool":"evil"}\n' - ) - stream_data = ( - b'\x1eECC-STEP {"v":1,"event":"begin","step":"Synthesis","tool":"yosys"}\n' - + unknown_begin - + b"normal data\n" - + b'\x1eECC-STEP {"v":1,"event":"end","step":"Synthesis","tool":"yosys"}\n' - ) - reader = LogStreamReader( - io.BytesIO(stream_data), log_path_resolver=resolver, valid_steps=valid - ) - reader.start() - reader.join(timeout=5) - content = log_path.read_bytes() - assert unknown_begin in content - assert b"normal data\n" in content - assert reader.state.steps_seen == ["Synthesis"] - - def test_unknown_step_before_any_active_is_data(self): - """An unknown begin marker with no active step is sent to callback as data.""" - received = [] - valid = {("Place", "ecc")} - stream_data = ( - b'\x1eECC-STEP {"v":1,"event":"begin","step":"Bogus","tool":"fake"}\ntrailing\n' - ) - reader = LogStreamReader( - io.BytesIO(stream_data), on_output=received.append, valid_steps=valid - ) - reader.start() - reader.join(timeout=5) - combined = b"".join(received) - assert b"Bogus" in combined - assert b"trailing\n" in combined - assert reader.state.active_step is None - - def test_path_escape_does_not_open_archive(self, tmp_path): - """A resolved path outside workspace_dir must not open an archive file.""" - escape_target = tmp_path / "outside.log" - - def resolver(step, tool): - return escape_target - - workspace = tmp_path / "workspace" - workspace.mkdir() - valid = {("Escape", "evil")} - stream_data = ( - b'\x1eECC-STEP {"v":1,"event":"begin","step":"Escape","tool":"evil"}\n' - b"should not be written\n" - b'\x1eECC-STEP {"v":1,"event":"end","step":"Escape","tool":"evil"}\n' - ) - reader = LogStreamReader( - io.BytesIO(stream_data), - log_path_resolver=resolver, - valid_steps=valid, - workspace_dir=workspace, - ) - reader.start() - reader.join(timeout=5) - assert not escape_target.exists() - assert reader.state.error is not None - assert "escapes workspace" in str(reader.state.error) - - def test_contained_path_opens_normally(self, tmp_path): - """A path that resolves inside workspace_dir opens and archives.""" - workspace = tmp_path / "workspace" - workspace.mkdir() - log_path = workspace / "Synthesis_yosys" / "log" / "Synthesis.log" - - def resolver(step, tool): - return log_path - - valid = {("Synthesis", "yosys")} - stream_data = ( - b'\x1eECC-STEP {"v":1,"event":"begin","step":"Synthesis","tool":"yosys"}\n' - b"tool output\n" - b'\x1eECC-STEP {"v":1,"event":"end","step":"Synthesis","tool":"yosys"}\n' - ) - reader = LogStreamReader( - io.BytesIO(stream_data), - log_path_resolver=resolver, - valid_steps=valid, - workspace_dir=workspace, - ) - reader.start() - reader.join(timeout=5) - assert log_path.read_bytes() == b"tool output\n" - assert reader.state.error is None - - -class TestArchiveTargetSanitization: - def test_separator_in_name_degrades_to_data(self, tmp_path): - """An allowlisted begin with a path separator is ordinary bytes, not a marker.""" - workspace = tmp_path / "workspace" - workspace.mkdir() - received = [] - - def resolver(step, tool): - base = workspace - return base / f"{step}_{tool}" / "log" / f"{step}.log" - - unsafe_begin = b'\x1eECC-STEP {"v":1,"event":"begin","step":"foo/bar","tool":"ecc"}\n' - stream_data = unsafe_begin + b"body bytes\n" - reader = LogStreamReader( - io.BytesIO(stream_data), - log_path_resolver=resolver, - valid_steps={("foo/bar", "ecc")}, - workspace_dir=workspace, - on_output=received.append, - ) - reader.start() - reader.join(timeout=5) - combined = b"".join(received) - assert unsafe_begin in combined - assert b"body bytes\n" in combined - assert reader.state.active_step is None - assert reader.state.steps_seen == [] - assert isinstance(reader.state.error, ValueError) - assert not (workspace / "foo").exists() - - def test_dotdot_in_name_degrades_to_data(self, tmp_path): - workspace = tmp_path / "workspace" - workspace.mkdir() - received = [] - unsafe_begin = b'\x1eECC-STEP {"v":1,"event":"begin","step":"..","tool":".."}\n' - reader = LogStreamReader( - io.BytesIO(unsafe_begin), - log_path_resolver=lambda step, tool: workspace / "x" / "x.log", - valid_steps={("..", "..")}, - workspace_dir=workspace, - on_output=received.append, - ) - reader.start() - reader.join(timeout=5) - assert b"".join(received) == unsafe_begin - assert reader.state.active_step is None - assert isinstance(reader.state.error, ValueError) - - def test_containment_violation_degrades_begin_to_data(self, tmp_path): - """A begin whose archive escapes the workspace is forwarded as data.""" - workspace = tmp_path / "workspace" - workspace.mkdir() - escape_target = tmp_path / "outside.log" - received = [] - unsafe_begin = b'\x1eECC-STEP {"v":1,"event":"begin","step":"Escape","tool":"evil"}\n' - stream_data = unsafe_begin + b"not archived\n" - reader = LogStreamReader( - io.BytesIO(stream_data), - log_path_resolver=lambda step, tool: escape_target, - valid_steps={("Escape", "evil")}, - workspace_dir=workspace, - on_output=received.append, - ) - reader.start() - reader.join(timeout=5) - combined = b"".join(received) - assert unsafe_begin in combined - assert b"not archived\n" in combined - assert reader.state.active_step is None - assert not escape_target.exists() - assert "escapes workspace" in str(reader.state.error) - - -class TestStepLogArchiveResolver: - def test_resolver_produces_canonical_step_log_path(self, tmp_path): - from chipcompiler.runtime.log_stream import step_log_archive_resolver - - resolver = step_log_archive_resolver(tmp_path) - assert resolver("Synthesis", "yosys") == ( - tmp_path / "Synthesis_yosys" / "log" / "Synthesis.log" - ) - assert resolver("Floorplan", "ecc") == ( - tmp_path / "Floorplan_ecc" / "log" / "Floorplan.log" - ) - - -class TestOnStepEvent: - def test_fires_on_matched_begin_and_end(self, tmp_path): - events = [] - log_path = tmp_path / "step.log" - stream_data = ( - b'\x1eECC-STEP {"v":1,"event":"begin","step":"S","tool":"T"}\n' - b"data\n" - b'\x1eECC-STEP {"v":1,"event":"end","step":"S","tool":"T"}\n' - ) - reader = LogStreamReader( - io.BytesIO(stream_data), - log_path_resolver=lambda step, tool: log_path, - on_step_event=lambda event, step, tool: events.append((event, step, tool)), - ) - reader.start() - reader.join(timeout=5) - assert events == [("begin", "S", "T"), ("end", "S", "T")] - - def test_does_not_fire_on_unmatched_markers(self, tmp_path): - events = [] - log_path = tmp_path / "step.log" - nested_begin = b'\x1eECC-STEP {"v":1,"event":"begin","step":"B","tool":"T"}\n' - mismatched_end = b'\x1eECC-STEP {"v":1,"event":"end","step":"X","tool":"T"}\n' - stream_data = ( - b'\x1eECC-STEP {"v":1,"event":"begin","step":"A","tool":"T"}\n' - + nested_begin - + mismatched_end - + b'\x1eECC-STEP {"v":1,"event":"end","step":"A","tool":"T"}\n' - ) - reader = LogStreamReader( - io.BytesIO(stream_data), - log_path_resolver=lambda step, tool: log_path, - on_step_event=lambda event, step, tool: events.append((event, step, tool)), - ) - reader.start() - reader.join(timeout=5) - assert events == [("begin", "A", "T"), ("end", "A", "T")] - - def test_does_not_fire_on_disallowed_marker(self, tmp_path): - events = [] - stream_data = b'\x1eECC-STEP {"v":1,"event":"begin","step":"Bogus","tool":"fake"}\n' - reader = LogStreamReader( - io.BytesIO(stream_data), - on_step_event=lambda event, step, tool: events.append((event, step, tool)), - valid_steps={("Real", "tool")}, - ) - reader.start() - reader.join(timeout=5) - assert events == [] - - def test_callback_exception_disables_callback_continues_drain(self, tmp_path): - log_path = tmp_path / "step.log" - calls = [0] - - def failing_callback(event, step, tool): - calls[0] += 1 - raise RuntimeError("step event exploded") - - stream_data = ( - b'\x1eECC-STEP {"v":1,"event":"begin","step":"S","tool":"T"}\n' - b"line 1\n" - b'\x1eECC-STEP {"v":1,"event":"end","step":"S","tool":"T"}\n' - b'\x1eECC-STEP {"v":1,"event":"begin","step":"S2","tool":"T"}\n' - b"line 2\n" - ) - reader = LogStreamReader( - io.BytesIO(stream_data), - log_path_resolver=lambda step, tool: log_path, - on_step_event=failing_callback, - ) - reader.start() - reader.join(timeout=5) - assert reader.completed - assert calls[0] == 1 - assert isinstance(reader.state.error, RuntimeError) - # The second begin re-opened (truncated) the shared log path, so its - # content proves archiving continued after the callback was disabled. - assert log_path.read_bytes() == b"line 2\n" - - -class TestMarkerBoundaryScanning: - """Markers are recognized wherever the reserved prefix appears.""" - - def test_end_marker_after_unterminated_output(self, tmp_path): - log_path = tmp_path / "step.log" - stream_data = ( - b'\x1eECC-STEP {"v":1,"event":"begin","step":"S","tool":"T"}\n' - b"last line without newline" - b'\x1eECC-STEP {"v":1,"event":"end","step":"S","tool":"T"}\n' - ) - reader = LogStreamReader( - io.BytesIO(stream_data), log_path_resolver=lambda step, tool: log_path - ) - reader.start() - reader.join(timeout=5) - assert log_path.read_bytes() == b"last line without newline" - assert reader.state.active_step is None - assert reader.state.steps_seen == ["S"] - - def test_end_marker_split_after_unterminated_output(self, tmp_path): - log_path = tmp_path / "step.log" - end_frame = b'\x1eECC-STEP {"v":1,"event":"end","step":"S","tool":"T"}\n' - stream_data = ( - b'\x1eECC-STEP {"v":1,"event":"begin","step":"S","tool":"T"}\nunterminated' + end_frame - ) - reader = LogStreamReader( - _ChunkedStream(stream_data, 7), log_path_resolver=lambda step, tool: log_path - ) - reader.start() - reader.join(timeout=5) - assert log_path.read_bytes() == b"unterminated" - assert reader.state.active_step is None - - def test_invalid_marker_mid_line_is_data(self, tmp_path): - log_path = tmp_path / "step.log" - stream_data = ( - b'\x1eECC-STEP {"v":1,"event":"begin","step":"S","tool":"T"}\n' - b"glued text \x1eECC-STEP {bad json}\n" - b'\x1eECC-STEP {"v":1,"event":"end","step":"S","tool":"T"}\n' - ) - reader = LogStreamReader( - io.BytesIO(stream_data), log_path_resolver=lambda step, tool: log_path - ) - reader.start() - reader.join(timeout=5) - content = log_path.read_bytes() - assert b"glued text " in content - assert b"{bad json}" in content - assert reader.state.active_step is None - - def test_overlong_candidate_recovers_following_marker(self, tmp_path): - log_path = tmp_path / "step.log" - stream_data = ( - b'\x1eECC-STEP {"v":1,"event":"begin","step":"S","tool":"T"}\n' - b"\x1eECC-STEP " - + b"a" * 600 - + b'\x1eECC-STEP {"v":1,"event":"end","step":"S","tool":"T"}\n' - ) - reader = LogStreamReader( - _ChunkedStream(stream_data, 100), log_path_resolver=lambda step, tool: log_path - ) - reader.start() - reader.join(timeout=5) - content = log_path.read_bytes() - assert b"a" * 600 in content - assert b'"event":"end"' not in content - assert reader.state.active_step is None - - class TestLogStreamResilience: def test_resolver_exception_disables_archive_continues_drain(self): """A resolver that raises must not kill the drain thread.""" diff --git a/test/runtime/test_log_stream_markers.py b/test/runtime/test_log_stream_markers.py new file mode 100644 index 00000000..3280fbac --- /dev/null +++ b/test/runtime/test_log_stream_markers.py @@ -0,0 +1,202 @@ +"""Tests for chipcompiler.runtime.log_stream — marker protocol parsing and emission.""" + +import io +import os + +from chipcompiler.runtime.log_stream import ( + MARKER_PREFIX, + LogStreamReader, + StepMarker, + emit_step_marker, + parse_marker, +) + + +class _ChunkedStream: + """A binary stream that returns fixed-size chunks regardless of read size.""" + + def __init__(self, data: bytes, chunk_size: int): + self._data = data + self._chunk_size = chunk_size + self._pos = 0 + + def read(self, n: int = -1) -> bytes: + if self._pos >= len(self._data): + return b"" + size = self._chunk_size if n < 0 else min(n, self._chunk_size) + part = self._data[self._pos : self._pos + size] + self._pos += size + return part + + +class TestParseMarker: + def test_valid_begin(self): + line = b'\x1eECC-STEP {"v":1,"event":"begin","step":"Synthesis","tool":"yosys"}\n' + m = parse_marker(line) + assert m == StepMarker(event="begin", step="Synthesis", tool="yosys") + + def test_valid_end(self): + line = b'\x1eECC-STEP {"v":1,"event":"end","step":"Placement","tool":"ecc"}\n' + m = parse_marker(line) + assert m == StepMarker(event="end", step="Placement", tool="ecc") + + def test_no_prefix(self): + assert parse_marker(b"normal log line\n") is None + + def test_malformed_json(self): + assert parse_marker(b"\x1eECC-STEP {bad json}\n") is None + + def test_missing_fields(self): + line = b'\x1eECC-STEP {"v":1,"event":"begin"}\n' + assert parse_marker(line) is None + + def test_wrong_field_types(self): + line = b'\x1eECC-STEP {"v":1,"event":1,"step":"A","tool":"B"}\n' + assert parse_marker(line) is None + + def test_no_trailing_newline(self): + line = b'\x1eECC-STEP {"v":1,"event":"begin","step":"S","tool":"T"}' + m = parse_marker(line) + assert m is not None + assert m.event == "begin" + + def test_missing_version_rejected(self): + line = b'\x1eECC-STEP {"event":"begin","step":"S","tool":"T"}\n' + assert parse_marker(line) is None + + def test_unsupported_version_rejected(self): + line = b'\x1eECC-STEP {"v":2,"event":"begin","step":"S","tool":"T"}\n' + assert parse_marker(line) is None + + def test_string_version_rejected(self): + line = b'\x1eECC-STEP {"v":"1","event":"begin","step":"S","tool":"T"}\n' + assert parse_marker(line) is None + + def test_boolean_version_rejected(self): + line = b'\x1eECC-STEP {"v":true,"event":"begin","step":"S","tool":"T"}\n' + assert parse_marker(line) is None + + +class TestEmitStepMarker: + def test_payload_carries_version_and_round_trips(self, monkeypatch): + written = [] + real_write = os.write + + def fake_write(fd, data): + if fd == 2: + written.append(data) + return len(data) + return real_write(fd, data) + + monkeypatch.setattr(os, "write", fake_write) + emit_step_marker("begin", step="Synthesis", tool="yosys") + + assert written == [ + MARKER_PREFIX + b'{"v":1,"event":"begin","step":"Synthesis","tool":"yosys"}\n' + ] + assert parse_marker(written[0]) == StepMarker(event="begin", step="Synthesis", tool="yosys") + + def test_c_stdio_buffer_drains_before_marker(self, tmp_path): + """Native buffered output must reach fd 2 ahead of the marker bytes.""" + import ctypes + + libc = ctypes.CDLL(None) + libc.fdopen.restype = ctypes.c_void_p + libc.fdopen.argtypes = [ctypes.c_int, ctypes.c_char_p] + libc.setvbuf.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_int, ctypes.c_size_t] + libc.fputs.argtypes = [ctypes.c_char_p, ctypes.c_void_p] + libc.fclose.argtypes = [ctypes.c_void_p] + _IOFBF = 0 + + sink = tmp_path / "fd2.bin" + saved_fd = os.dup(2) + try: + with sink.open("wb") as handle: + os.dup2(handle.fileno(), 2) + # A fully buffered FILE* targeting fd 2: fputs bytes stay in the C + # buffer until something flushes the process streams. + stream = libc.fdopen(os.dup(2), b"w") + assert stream + assert libc.setvbuf(stream, None, _IOFBF, 4096) == 0 + libc.fputs(b"native-before-end\n", stream) + emit_step_marker("end", step="S", tool="T") + libc.fclose(stream) + finally: + os.dup2(saved_fd, 2) + os.close(saved_fd) + + content = sink.read_bytes() + assert content == ( + b"native-before-end\n" + + MARKER_PREFIX + + b'{"v":1,"event":"end","step":"S","tool":"T"}\n' + ) + + +class TestMarkerBoundaryScanning: + """Markers are recognized wherever the reserved prefix appears.""" + + def test_end_marker_after_unterminated_output(self, tmp_path): + log_path = tmp_path / "step.log" + stream_data = ( + b'\x1eECC-STEP {"v":1,"event":"begin","step":"S","tool":"T"}\n' + b"last line without newline" + b'\x1eECC-STEP {"v":1,"event":"end","step":"S","tool":"T"}\n' + ) + reader = LogStreamReader( + io.BytesIO(stream_data), log_path_resolver=lambda step, tool: log_path + ) + reader.start() + reader.join(timeout=5) + assert log_path.read_bytes() == b"last line without newline" + assert reader.state.active_step is None + assert reader.state.steps_seen == ["S"] + + def test_end_marker_split_after_unterminated_output(self, tmp_path): + log_path = tmp_path / "step.log" + end_frame = b'\x1eECC-STEP {"v":1,"event":"end","step":"S","tool":"T"}\n' + stream_data = ( + b'\x1eECC-STEP {"v":1,"event":"begin","step":"S","tool":"T"}\nunterminated' + end_frame + ) + reader = LogStreamReader( + _ChunkedStream(stream_data, 7), log_path_resolver=lambda step, tool: log_path + ) + reader.start() + reader.join(timeout=5) + assert log_path.read_bytes() == b"unterminated" + assert reader.state.active_step is None + + def test_invalid_marker_mid_line_is_data(self, tmp_path): + log_path = tmp_path / "step.log" + stream_data = ( + b'\x1eECC-STEP {"v":1,"event":"begin","step":"S","tool":"T"}\n' + b"glued text \x1eECC-STEP {bad json}\n" + b'\x1eECC-STEP {"v":1,"event":"end","step":"S","tool":"T"}\n' + ) + reader = LogStreamReader( + io.BytesIO(stream_data), log_path_resolver=lambda step, tool: log_path + ) + reader.start() + reader.join(timeout=5) + content = log_path.read_bytes() + assert b"glued text " in content + assert b"{bad json}" in content + assert reader.state.active_step is None + + def test_overlong_candidate_recovers_following_marker(self, tmp_path): + log_path = tmp_path / "step.log" + stream_data = ( + b'\x1eECC-STEP {"v":1,"event":"begin","step":"S","tool":"T"}\n' + b"\x1eECC-STEP " + + b"a" * 600 + + b'\x1eECC-STEP {"v":1,"event":"end","step":"S","tool":"T"}\n' + ) + reader = LogStreamReader( + _ChunkedStream(stream_data, 100), log_path_resolver=lambda step, tool: log_path + ) + reader.start() + reader.join(timeout=5) + content = log_path.read_bytes() + assert b"a" * 600 in content + assert b'"event":"end"' not in content + assert reader.state.active_step is None diff --git a/test/runtime/test_log_stream_targets.py b/test/runtime/test_log_stream_targets.py new file mode 100644 index 00000000..0cabca4f --- /dev/null +++ b/test/runtime/test_log_stream_targets.py @@ -0,0 +1,270 @@ +"""Tests for chipcompiler.runtime.log_stream — archive targets and step events.""" + +import io + +from chipcompiler.runtime.log_stream import LogStreamReader + + +class TestLogStreamAllowlist: + def test_unknown_step_marker_treated_as_data(self, tmp_path): + """A begin marker for a pair not in valid_steps is archived as data.""" + log_path = tmp_path / "step.log" + + def resolver(step, tool): + return log_path + + valid = {("Synthesis", "yosys")} + unknown_begin = ( + b'\x1eECC-STEP {"v":1,"event":"begin","step":"../../escape","tool":"evil"}\n' + ) + stream_data = ( + b'\x1eECC-STEP {"v":1,"event":"begin","step":"Synthesis","tool":"yosys"}\n' + + unknown_begin + + b"normal data\n" + + b'\x1eECC-STEP {"v":1,"event":"end","step":"Synthesis","tool":"yosys"}\n' + ) + reader = LogStreamReader( + io.BytesIO(stream_data), log_path_resolver=resolver, valid_steps=valid + ) + reader.start() + reader.join(timeout=5) + content = log_path.read_bytes() + assert unknown_begin in content + assert b"normal data\n" in content + assert reader.state.steps_seen == ["Synthesis"] + + def test_unknown_step_before_any_active_is_data(self): + """An unknown begin marker with no active step is sent to callback as data.""" + received = [] + valid = {("Place", "ecc")} + stream_data = ( + b'\x1eECC-STEP {"v":1,"event":"begin","step":"Bogus","tool":"fake"}\ntrailing\n' + ) + reader = LogStreamReader( + io.BytesIO(stream_data), on_output=received.append, valid_steps=valid + ) + reader.start() + reader.join(timeout=5) + combined = b"".join(received) + assert b"Bogus" in combined + assert b"trailing\n" in combined + assert reader.state.active_step is None + + def test_path_escape_does_not_open_archive(self, tmp_path): + """A resolved path outside workspace_dir must not open an archive file.""" + escape_target = tmp_path / "outside.log" + + def resolver(step, tool): + return escape_target + + workspace = tmp_path / "workspace" + workspace.mkdir() + valid = {("Escape", "evil")} + stream_data = ( + b'\x1eECC-STEP {"v":1,"event":"begin","step":"Escape","tool":"evil"}\n' + b"should not be written\n" + b'\x1eECC-STEP {"v":1,"event":"end","step":"Escape","tool":"evil"}\n' + ) + reader = LogStreamReader( + io.BytesIO(stream_data), + log_path_resolver=resolver, + valid_steps=valid, + workspace_dir=workspace, + ) + reader.start() + reader.join(timeout=5) + assert not escape_target.exists() + assert reader.state.error is not None + assert "escapes workspace" in str(reader.state.error) + + def test_contained_path_opens_normally(self, tmp_path): + """A path that resolves inside workspace_dir opens and archives.""" + workspace = tmp_path / "workspace" + workspace.mkdir() + log_path = workspace / "Synthesis_yosys" / "log" / "Synthesis.log" + + def resolver(step, tool): + return log_path + + valid = {("Synthesis", "yosys")} + stream_data = ( + b'\x1eECC-STEP {"v":1,"event":"begin","step":"Synthesis","tool":"yosys"}\n' + b"tool output\n" + b'\x1eECC-STEP {"v":1,"event":"end","step":"Synthesis","tool":"yosys"}\n' + ) + reader = LogStreamReader( + io.BytesIO(stream_data), + log_path_resolver=resolver, + valid_steps=valid, + workspace_dir=workspace, + ) + reader.start() + reader.join(timeout=5) + assert log_path.read_bytes() == b"tool output\n" + assert reader.state.error is None + + +class TestArchiveTargetSanitization: + def test_separator_in_name_degrades_to_data(self, tmp_path): + """An allowlisted begin with a path separator is ordinary bytes, not a marker.""" + workspace = tmp_path / "workspace" + workspace.mkdir() + received = [] + + def resolver(step, tool): + base = workspace + return base / f"{step}_{tool}" / "log" / f"{step}.log" + + unsafe_begin = b'\x1eECC-STEP {"v":1,"event":"begin","step":"foo/bar","tool":"ecc"}\n' + stream_data = unsafe_begin + b"body bytes\n" + reader = LogStreamReader( + io.BytesIO(stream_data), + log_path_resolver=resolver, + valid_steps={("foo/bar", "ecc")}, + workspace_dir=workspace, + on_output=received.append, + ) + reader.start() + reader.join(timeout=5) + combined = b"".join(received) + assert unsafe_begin in combined + assert b"body bytes\n" in combined + assert reader.state.active_step is None + assert reader.state.steps_seen == [] + assert isinstance(reader.state.error, ValueError) + assert not (workspace / "foo").exists() + + def test_dotdot_in_name_degrades_to_data(self, tmp_path): + workspace = tmp_path / "workspace" + workspace.mkdir() + received = [] + unsafe_begin = b'\x1eECC-STEP {"v":1,"event":"begin","step":"..","tool":".."}\n' + reader = LogStreamReader( + io.BytesIO(unsafe_begin), + log_path_resolver=lambda step, tool: workspace / "x" / "x.log", + valid_steps={("..", "..")}, + workspace_dir=workspace, + on_output=received.append, + ) + reader.start() + reader.join(timeout=5) + assert b"".join(received) == unsafe_begin + assert reader.state.active_step is None + assert isinstance(reader.state.error, ValueError) + + def test_containment_violation_degrades_begin_to_data(self, tmp_path): + """A begin whose archive escapes the workspace is forwarded as data.""" + workspace = tmp_path / "workspace" + workspace.mkdir() + escape_target = tmp_path / "outside.log" + received = [] + unsafe_begin = b'\x1eECC-STEP {"v":1,"event":"begin","step":"Escape","tool":"evil"}\n' + stream_data = unsafe_begin + b"not archived\n" + reader = LogStreamReader( + io.BytesIO(stream_data), + log_path_resolver=lambda step, tool: escape_target, + valid_steps={("Escape", "evil")}, + workspace_dir=workspace, + on_output=received.append, + ) + reader.start() + reader.join(timeout=5) + combined = b"".join(received) + assert unsafe_begin in combined + assert b"not archived\n" in combined + assert reader.state.active_step is None + assert not escape_target.exists() + assert "escapes workspace" in str(reader.state.error) + + +class TestStepLogArchiveResolver: + def test_resolver_produces_canonical_step_log_path(self, tmp_path): + from chipcompiler.runtime.log_stream import step_log_archive_resolver + + resolver = step_log_archive_resolver(tmp_path) + assert resolver("Synthesis", "yosys") == ( + tmp_path / "Synthesis_yosys" / "log" / "Synthesis.log" + ) + assert resolver("Floorplan", "ecc") == ( + tmp_path / "Floorplan_ecc" / "log" / "Floorplan.log" + ) + + +class TestOnStepEvent: + def test_fires_on_matched_begin_and_end(self, tmp_path): + events = [] + log_path = tmp_path / "step.log" + stream_data = ( + b'\x1eECC-STEP {"v":1,"event":"begin","step":"S","tool":"T"}\n' + b"data\n" + b'\x1eECC-STEP {"v":1,"event":"end","step":"S","tool":"T"}\n' + ) + reader = LogStreamReader( + io.BytesIO(stream_data), + log_path_resolver=lambda step, tool: log_path, + on_step_event=lambda event, step, tool: events.append((event, step, tool)), + ) + reader.start() + reader.join(timeout=5) + assert events == [("begin", "S", "T"), ("end", "S", "T")] + + def test_does_not_fire_on_unmatched_markers(self, tmp_path): + events = [] + log_path = tmp_path / "step.log" + nested_begin = b'\x1eECC-STEP {"v":1,"event":"begin","step":"B","tool":"T"}\n' + mismatched_end = b'\x1eECC-STEP {"v":1,"event":"end","step":"X","tool":"T"}\n' + stream_data = ( + b'\x1eECC-STEP {"v":1,"event":"begin","step":"A","tool":"T"}\n' + + nested_begin + + mismatched_end + + b'\x1eECC-STEP {"v":1,"event":"end","step":"A","tool":"T"}\n' + ) + reader = LogStreamReader( + io.BytesIO(stream_data), + log_path_resolver=lambda step, tool: log_path, + on_step_event=lambda event, step, tool: events.append((event, step, tool)), + ) + reader.start() + reader.join(timeout=5) + assert events == [("begin", "A", "T"), ("end", "A", "T")] + + def test_does_not_fire_on_disallowed_marker(self, tmp_path): + events = [] + stream_data = b'\x1eECC-STEP {"v":1,"event":"begin","step":"Bogus","tool":"fake"}\n' + reader = LogStreamReader( + io.BytesIO(stream_data), + on_step_event=lambda event, step, tool: events.append((event, step, tool)), + valid_steps={("Real", "tool")}, + ) + reader.start() + reader.join(timeout=5) + assert events == [] + + def test_callback_exception_disables_callback_continues_drain(self, tmp_path): + log_path = tmp_path / "step.log" + calls = [0] + + def failing_callback(event, step, tool): + calls[0] += 1 + raise RuntimeError("step event exploded") + + stream_data = ( + b'\x1eECC-STEP {"v":1,"event":"begin","step":"S","tool":"T"}\n' + b"line 1\n" + b'\x1eECC-STEP {"v":1,"event":"end","step":"S","tool":"T"}\n' + b'\x1eECC-STEP {"v":1,"event":"begin","step":"S2","tool":"T"}\n' + b"line 2\n" + ) + reader = LogStreamReader( + io.BytesIO(stream_data), + log_path_resolver=lambda step, tool: log_path, + on_step_event=failing_callback, + ) + reader.start() + reader.join(timeout=5) + assert reader.completed + assert calls[0] == 1 + assert isinstance(reader.state.error, RuntimeError) + # The second begin re-opened (truncated) the shared log path, so its + # content proves archiving continued after the callback was disabled. + assert log_path.read_bytes() == b"line 2\n" From e20d4c39e47772d26d12ad25fbe6ee3119c4349d Mon Sep 17 00:00:00 2001 From: Emin Date: Wed, 19 Aug 2026 08:44:31 +0800 Subject: [PATCH 26/52] fix(runtime,engine): close the post-processing trust gaps - run_step no longer emits the end marker when the final state could not be persisted; the step downgrades to Imcomplete so consumers see a crashed step rather than a stale flow.json claiming success. - repair_flow_state now also repairs a Success record for the step that was active at crash time: a Success without a completed end marker means the crash interrupted post-processing, so the persisted result is not trustworthy. - Rerun invalidation is atomic again: the invalidation is persisted before any artifact directory is deleted, and a failed save refuses to modify outputs (matching the previous CLI contract). - The worker's flow build verifies tool dependencies only for steps that will actually execute, so resume/from/only runs reuse successful predecessor outputs when an unselected tool is absent. - The 512-byte marker holdback is now inclusive per the specification; exactly-512-byte incomplete candidates are held and consumed. --- chipcompiler/engine/flow.py | 19 ++++++- chipcompiler/runtime/log_stream.py | 2 +- chipcompiler/runtime/worker.py | 9 ++-- chipcompiler/runtime/workspace_api.py | 57 +++++++++++++++----- test/runtime/test_log_stream_markers.py | 29 +++++++++++ test/runtime/test_worker.py | 22 ++++++-- test/runtime/test_workspace_api.py | 69 ++++++++++++++++++++++++- test/test_engine_flow.py | 63 ++++++++++++++++++---- test/tools/ecc_sizer/test_runner.py | 29 ++++++----- 9 files changed, 248 insertions(+), 51 deletions(-) diff --git a/chipcompiler/engine/flow.py b/chipcompiler/engine/flow.py index baf857ae..d1f69ca7 100644 --- a/chipcompiler/engine/flow.py +++ b/chipcompiler/engine/flow.py @@ -534,13 +534,25 @@ def run_step( else StateEnum.Imcomplete ) - self.set_state( + persisted = self.set_state( name=workspace_step.name, tool=workspace_step.tool, state=state, runtime=runtime, peak_memory=peak_memory_mb, ) + if persisted and not self.save(): + persisted = False + if not persisted: + # The marker protocol guarantees the final state is persisted + # before the end marker; a failed save makes the run's result + # untrustworthy, so the step is reported incomplete and no end + # marker is emitted for it. + state = StateEnum.Imcomplete + self.workspace.logger.error( + "[RESULT] %s final state could not be persisted; marking step Imcomplete", + step_tag, + ) self.workspace.logger.info( "[RESULT] %s state=%s runtime=%s mem=%sMB exitcode=%s", step_tag, @@ -585,7 +597,10 @@ def run_step( # The end marker closes the step's byte stream only after every # step-scoped write (state persistence, [RESULT], QOR, layout, db # cleanup) has flushed, and always before the completion notification. - emit_step_marker("end", step=workspace_step.name, tool=workspace_step.tool) + # When the final state could not be persisted, the marker stays + # unwritten: consumers treat the step as crashed and repair its state. + if persisted: + emit_step_marker("end", step=workspace_step.name, tool=workspace_step.tool) _notify_flow_observer(observer, "on_step_completed", workspace_step, state) if state == StateEnum.Success and not _wait_for_step_rendered( observer, diff --git a/chipcompiler/runtime/log_stream.py b/chipcompiler/runtime/log_stream.py index 27adc13c..86d92acd 100644 --- a/chipcompiler/runtime/log_stream.py +++ b/chipcompiler/runtime/log_stream.py @@ -192,7 +192,7 @@ def _process_buffer(self, buf: bytes) -> bytes: continue nl = buf.find(b"\n") if nl < 0: - if len(buf) < 512: + if len(buf) <= 512: return buf # An overlong candidate without a newline is not a marker: # emit the prefix's first byte and rescan the remainder. diff --git a/chipcompiler/runtime/worker.py b/chipcompiler/runtime/worker.py index 3acbd376..baff1961 100644 --- a/chipcompiler/runtime/worker.py +++ b/chipcompiler/runtime/worker.py @@ -246,10 +246,13 @@ def classify_worker_exit(proc: subprocess.Popen) -> WorkerResult: def repair_flow_state(flow_json_path: str | Path, *, active_step: str) -> list[str]: - """Repair the named Ongoing step left by a crashed worker, setting it to Incomplete. + """Repair the step left unfinished by a crashed worker, setting it to Incomplete. Operation-scoped: only the active_step is repaired. The caller must identify - which step was owned by the crashed operation. + which step was owned by the crashed operation. Both Ongoing and Success + records are repaired: a Success without a completed end marker means the + crash interrupted the step's post-processing, so its persisted result is + not trustworthy. Returns the list of step names that were repaired. Raises OSError if the repaired state cannot be persisted. @@ -267,7 +270,7 @@ def repair_flow_state(flow_json_path: str | Path, *, active_step: str) -> list[s for step in steps: if not isinstance(step, dict): continue - if step.get("state") != "Ongoing": + if step.get("state") not in ("Ongoing", "Success"): continue step_name = step.get("name", "") if step_name != active_step: diff --git a/chipcompiler/runtime/workspace_api.py b/chipcompiler/runtime/workspace_api.py index 547eee45..e450c07e 100644 --- a/chipcompiler/runtime/workspace_api.py +++ b/chipcompiler/runtime/workspace_api.py @@ -249,9 +249,22 @@ def run(session: WorkspaceSession) -> dict: self._release_session_db(session) previous_db = None + if request.rerun or not session.workspace.flow.data.get("steps"): + # A full rerun executes every step; a fresh workspace has no + # persisted states yet, so every step is verified. + executable_steps = None + else: + executable_steps = { + record["name"] + for record in session.workspace.flow.data.get("steps", []) + if isinstance(record, dict) + and "name" in record + and record.get("state") != "Success" + } engine_flow = self._build_flow_for_session( session, attach_session_db=should_capture and not request.rerun, + executable_steps=executable_steps, ) if request.rerun: affected_steps = list(getattr(engine_flow, "workspace_steps", [])) @@ -306,6 +319,7 @@ def run_step(session: WorkspaceSession) -> dict: engine_flow = self._build_flow_for_session( session, attach_session_db=should_capture and not request.rerun, + executable_steps={request.step}, ) if request.rerun: if session.layout_edit_session is not None: @@ -843,8 +857,9 @@ def _build_flow_for_session( session: WorkspaceSession, *, attach_session_db: bool, + executable_steps: set[str] | None = None, ): - engine_flow = build_flow_for_workspace(session.workspace) + engine_flow = build_flow_for_workspace(session.workspace, executable_steps=executable_steps) if attach_session_db: engine_flow.engine_db = session.db_handle return engine_flow @@ -920,8 +935,11 @@ def _invalidate_step_records(engine_flow, workspace_steps) -> None: continue record.update({"state": "Unstart", "runtime": "", "peak memory (mb)": 0}) updated = True - if updated: - engine_flow.save() + if updated and not engine_flow.save(): + raise RuntimeApiError( + "command_failed", + "failed to persist step invalidation; refusing to modify outputs", + ) @staticmethod def _notify_rerun_prepared( @@ -977,13 +995,9 @@ def _prepare_steps_for_rerun(workspace, engine_flow, workspace_steps) -> None: known_directories.add(resolved) artifact_directories.append((workspace_step.name, directory)) - for step_name, directory in artifact_directories: - WorkspaceRuntimeApi._clear_step_artifact_dir( - workspace_root, - directory, - step_name, - ) - + # Persist the invalidation before any output is deleted: a failed + # save must leave the workspace untouched rather than clearing + # artifacts while the recorded states stay stale. updated_record = False for workspace_step in unique_steps: record = engine_flow.get_step(workspace_step.name, workspace_step.tool) @@ -998,8 +1012,18 @@ def _prepare_steps_for_rerun(workspace, engine_flow, workspace_steps) -> None: } ) updated_record = True - if updated_record: - engine_flow.save() + if updated_record and not engine_flow.save(): + raise RuntimeApiError( + "command_failed", + "failed to persist step invalidation; refusing to modify outputs", + ) + + for step_name, directory in artifact_directories: + WorkspaceRuntimeApi._clear_step_artifact_dir( + workspace_root, + directory, + step_name, + ) for workspace_step in unique_steps: WorkspaceRuntimeApi._reset_step_subflow(workspace_step) @@ -2033,7 +2057,12 @@ def _artifact_fingerprint(paths: tuple[Path, ...]) -> str: return digest.hexdigest() -def build_flow_for_workspace(workspace, *, create_step_workspaces: bool = True): +def build_flow_for_workspace( + workspace, + *, + create_step_workspaces: bool = True, + executable_steps: set[str] | None = None, +): import chipcompiler.engine as engine_api import chipcompiler.rtl2gds as rtl2gds_api @@ -2043,7 +2072,7 @@ def build_flow_for_workspace(workspace, *, create_step_workspaces: bool = True): engine_flow.add_step(step=step, tool=tool, state=state) if create_step_workspaces: - engine_flow.create_step_workspaces() + engine_flow.create_step_workspaces(executable_steps=executable_steps) return engine_flow diff --git a/test/runtime/test_log_stream_markers.py b/test/runtime/test_log_stream_markers.py index 3280fbac..e425b169 100644 --- a/test/runtime/test_log_stream_markers.py +++ b/test/runtime/test_log_stream_markers.py @@ -200,3 +200,32 @@ def test_overlong_candidate_recovers_following_marker(self, tmp_path): assert b"a" * 600 in content assert b'"event":"end"' not in content assert reader.state.active_step is None + + def test_candidate_at_exactly_512_bytes_is_held(self, tmp_path): + """A 512-byte candidate without its newline is held, then consumed.""" + log_path = tmp_path / "step.log" + wrapper = b'{"v":1,"event":"begin","step":"%s","tool":"T"}' + pad = 512 - len(b"\x1eECC-STEP ") - (len(wrapper) - 2) + payload = wrapper % (b"S" * pad) + frame_head = b"\x1eECC-STEP " + payload + assert len(frame_head) == 512 + reader = LogStreamReader( + _ChunkedStream( + frame_head + b"\nbody\n" + frame_head.replace(b"begin", b"end") + b"\n", 512 + ), + log_path_resolver=lambda step, tool: log_path, + ) + reader.start() + reader.join(timeout=5) + assert log_path.read_bytes() == b"body\n" + assert reader.state.active_step is None + + def test_candidate_beyond_512_bytes_degrades(self, tmp_path): + received = [] + overlong = b"\x1eECC-STEP " + b"a" * 503 + assert len(overlong) > 512 + reader = LogStreamReader(_ChunkedStream(overlong, 64), on_output=received.append) + reader.start() + reader.join(timeout=5) + assert b"".join(received) == overlong + assert reader.state.active_step is None diff --git a/test/runtime/test_worker.py b/test/runtime/test_worker.py index dcdbb408..08765c27 100644 --- a/test/runtime/test_worker.py +++ b/test/runtime/test_worker.py @@ -103,12 +103,22 @@ def test_repairs_ongoing_to_incomplete(self, tmp_path): assert result["steps"][0]["state"] == "Success" assert result["steps"][2]["state"] == "Unstart" - def test_no_ongoing_steps(self, tmp_path): + def test_repairs_active_success_interrupted_after_persisting(self, tmp_path): + """A Success without a completed end marker crashed in post-processing.""" flow_json = tmp_path / "flow.json" - data = {"steps": [{"name": "Synthesis", "tool": "yosys", "state": "Success"}]} + data = { + "steps": [ + {"name": "Synthesis", "tool": "yosys", "state": "Success"}, + {"name": "Routing", "tool": "ecc", "state": "Success"}, + ] + } flow_json.write_text(json.dumps(data)) repaired = repair_flow_state(flow_json, active_step="Synthesis") - assert repaired == [] + assert repaired == ["Synthesis"] + result = json.loads(flow_json.read_text()) + assert result["steps"][0]["state"] == "Incomplete" + # A step that was not active when the worker died keeps its state. + assert result["steps"][1]["state"] == "Success" def test_missing_file(self, tmp_path): flow_json = tmp_path / "nonexistent.json" @@ -136,7 +146,7 @@ def test_scoped_repair_only_active_step(self, tmp_path): assert result["steps"][0]["state"] == "Ongoing" assert result["steps"][1]["state"] == "Incomplete" - def test_scoped_repair_step_not_ongoing(self, tmp_path): + def test_scoped_repair_ignores_terminal_steps_not_active(self, tmp_path): flow_json = tmp_path / "flow.json" data = { "steps": [ @@ -144,8 +154,10 @@ def test_scoped_repair_step_not_ongoing(self, tmp_path): ] } flow_json.write_text(json.dumps(data)) - repaired = repair_flow_state(flow_json, active_step="Synthesis") + repaired = repair_flow_state(flow_json, active_step="Other") assert repaired == [] + result = json.loads(flow_json.read_text()) + assert result["steps"][0]["state"] == "Success" def test_write_failure_raises_oserror(self, tmp_path, monkeypatch): flow_json = tmp_path / "flow.json" diff --git a/test/runtime/test_workspace_api.py b/test/runtime/test_workspace_api.py index d6024042..f2356f3c 100644 --- a/test/runtime/test_workspace_api.py +++ b/test/runtime/test_workspace_api.py @@ -89,8 +89,9 @@ def add_step(self, step, tool, state): {"name": step, "tool": tool, "state": state} ) - def create_step_workspaces(self): + def create_step_workspaces(self, *, executable_steps=None): self.created = True + self.executable_steps = executable_steps def run_steps(self, *, rerun=False): self.run_steps_calls.append(rerun) @@ -1510,6 +1511,72 @@ def step_spec(name, tool): assert records["route"]["state"] == "Unstart" +def test_flow_run_verifies_only_steps_that_will_execute(monkeypatch, tmp_path): + _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) + api = WorkspaceRuntimeApi() + workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"] + session = api.sessions.get_session(workspace_id) + session.workspace.flow.data = { + "steps": [ + {"name": "Synthesis", "tool": "yosys", "state": "Success"}, + {"name": "Floorplan", "tool": "ecc", "state": "Unstart"}, + ] + } + + api.flow_run(FlowRunRequest(workspace_id=workspace_id)) + + assert DummyFlow.instances[-1].executable_steps == {"Floorplan"} + + +def test_flow_run_step_verifies_only_the_target_step(monkeypatch, tmp_path): + _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) + api = WorkspaceRuntimeApi() + workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"] + + api.flow_run_step(FlowRunStepRequest(workspace_id=workspace_id, step="Floorplan", rerun=True)) + + assert DummyFlow.instances[-1].executable_steps == {"Floorplan"} + + +def test_rerun_prepare_refuses_to_modify_outputs_when_invalidation_save_fails( + monkeypatch, tmp_path +): + _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) + + step_dir = ws / "Floorplan_ecc" + artifact_dir = step_dir / "output" + artifact_dir.mkdir(parents=True) + (artifact_dir / "keep.txt").write_text("keep") + subflow_path = step_dir / "subflow.json" + subflow_path.write_text(json.dumps({"path": str(subflow_path), "steps": []})) + checklist_path = step_dir / "checklist.json" + checklist_path.write_text(json.dumps({"checklist": []})) + DummyFlow.workspace_step_specs = ( + { + "name": "Floorplan", + "tool": "ecc", + "output": {"dir": artifact_dir}, + "subflow": SimpleNamespace(path=subflow_path, steps=[]), + "checklist": SimpleNamespace(path=checklist_path, checklist=[]), + }, + ) + api = WorkspaceRuntimeApi() + workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"] + session = api.sessions.get_session(workspace_id) + session.workspace.flow.data = { + "steps": [{"name": "Floorplan", "tool": "ecc", "state": "Success"}] + } + monkeypatch.setattr(DummyFlow, "save", lambda self: False) + + with pytest.raises(RuntimeApiError, match="failed to persist step invalidation"): + api.flow_run_step( + FlowRunStepRequest(workspace_id=workspace_id, step="Floorplan", rerun=True) + ) + + # The artifact directory survived because the invalidation never persisted. + assert (artifact_dir / "keep.txt").read_text() == "keep" + + def test_flow_run_step_rerun_rejects_an_open_layout_edit(monkeypatch, tmp_path): _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) api = WorkspaceRuntimeApi() diff --git a/test/test_engine_flow.py b/test/test_engine_flow.py index 8c1003cf..92f8b7d8 100644 --- a/test/test_engine_flow.py +++ b/test/test_engine_flow.py @@ -17,6 +17,7 @@ YosysOutput, YosysStep, ) +from chipcompiler.data.workspace import Flow from chipcompiler.engine.flow import EngineFlow @@ -30,10 +31,8 @@ def test_engine_flow_persists_run_facts_before_refreshing_qor_analysis( monkeypatch, tmp_path, ): - workspace = Workspace() - workspace.flow.data = { - "steps": [{"name": "route", "tool": "ecc", "state": "Unstart"}], - } + (tmp_path / "home").mkdir(exist_ok=True) + workspace = Workspace(directory=tmp_path, flow=Flow(path=tmp_path / "home" / "flow.json")) step_feature = tmp_path / "feature" / "route.step.json" sdc_path = tmp_path / "gcd.sdc" sdc_contents = "create_clock -name clk -period 2 [get_ports clk]\n" @@ -48,6 +47,9 @@ def test_engine_flow_persists_run_facts_before_refreshing_qor_analysis( feature=EccFeature(step=step_feature), ) engine_flow = EngineFlow(workspace) + engine_flow.workspace.flow.data = { + "steps": [{"name": "route", "tool": "ecc", "state": "Unstart"}], + } engine_flow.workspace_steps = [workspace_step] engine_flow.engine_db = SimpleNamespace(engine=None) refreshed = [] @@ -100,12 +102,13 @@ def test_end_marker_follows_step_writes_and_precedes_completion(monkeypatch, tmp """The end marker fires after all step-scoped writes and before completion notify.""" import chipcompiler.runtime.log_stream as log_stream_module - workspace = Workspace() - workspace.flow.data = { - "steps": [{"name": "route", "tool": "ecc", "state": "Unstart"}], - } + (tmp_path / "home").mkdir(exist_ok=True) + workspace = Workspace(directory=tmp_path, flow=Flow(path=tmp_path / "home" / "flow.json")) workspace_step = EccStep(name="route", directory=tmp_path, tool="ecc") engine_flow = EngineFlow(workspace) + engine_flow.workspace.flow.data = { + "steps": [{"name": "route", "tool": "ecc", "state": "Unstart"}], + } engine_flow.workspace_steps = [workspace_step] engine_flow.engine_db = SimpleNamespace(engine=None) @@ -153,6 +156,43 @@ def on_step_completed(self, step, state): assert end_index < events.index(("observer", "completed")) +def test_end_marker_suppressed_when_final_state_persistence_fails(monkeypatch, tmp_path): + """A failed final save downgrades the step and suppresses the end marker.""" + import chipcompiler.runtime.log_stream as log_stream_module + + workspace = Workspace() + workspace.flow.data = { + "steps": [{"name": "route", "tool": "ecc", "state": "Unstart"}], + } + workspace_step = EccStep(name="route", directory=tmp_path, tool="ecc") + engine_flow = EngineFlow(workspace) + engine_flow.workspace_steps = [workspace_step] + engine_flow.engine_db = SimpleNamespace(engine=None) + + events = [] + monkeypatch.setattr(tools, "run_step", lambda **_kwargs: True) + monkeypatch.setattr(engine_flow, "check_step_result", lambda **_kwargs: True) + monkeypatch.setattr(engine_flow, "save", lambda: False) + monkeypatch.setattr( + log_stream_module, + "emit_step_marker", + lambda event, *, step, tool: events.append(("marker", event)), + ) + + completed_states = [] + + class CompletionObserver: + def on_step_completed(self, step, state): + completed_states.append(state) + + result = engine_flow.run_step(workspace_step, observer=CompletionObserver()) + + assert result == StateEnum.Imcomplete + assert ("marker", "begin") in events + assert ("marker", "end") not in events + assert completed_states == [StateEnum.Imcomplete] + + def test_check_step_result_synthesis_uses_common_verilog(tmp_path): verilog = tmp_path / "gcd.v" verilog.write_text("module gcd; endmodule\n") @@ -306,11 +346,12 @@ def raise_on_run(**_kwargs): assert state == StateEnum.Imcomplete def test_no_exception_uses_file_check(self, monkeypatch, tmp_path): - workspace = Workspace() - workspace.flow.data = { + (tmp_path / "home").mkdir(exist_ok=True) + workspace = Workspace(directory=tmp_path, flow=Flow(path=tmp_path / "home" / "flow.json")) + engine_flow = EngineFlow(workspace) + engine_flow.workspace.flow.data = { "steps": [{"name": "route", "tool": "ecc", "state": "Unstart"}], } - engine_flow = EngineFlow(workspace) workspace_step = EccStep(name="route", directory=tmp_path, tool="ecc") engine_flow.workspace_steps = [workspace_step] engine_flow.engine_db = SimpleNamespace(engine=None) diff --git a/test/tools/ecc_sizer/test_runner.py b/test/tools/ecc_sizer/test_runner.py index 10b75f4e..7fbffa2e 100644 --- a/test/tools/ecc_sizer/test_runner.py +++ b/test/tools/ecc_sizer/test_runner.py @@ -202,20 +202,6 @@ def test_engine_flow_clears_cached_db_after_successful_sizer_step(tmp_path, monk workspace = _workspace(tmp_path) workspace.flow.path = tmp_path / "flow.json" - workspace.flow.data = { - "steps": [ - { - "name": StepEnum.TIMING_OPT.value, - "tool": "sizer", - "state": StateEnum.Unstart.value, - }, - { - "name": StepEnum.LEGALIZATION.value, - "tool": "ecc", - "state": StateEnum.Unstart.value, - }, - ] - } sizer_step = EccStep( name=StepEnum.TIMING_OPT.value, @@ -246,6 +232,21 @@ def close(self): pre_sizer_db_closed.append(True) engine_flow = EngineFlow(workspace) + engine_flow.workspace.flow.data = { + "steps": [ + { + "name": StepEnum.TIMING_OPT.value, + "tool": "sizer", + "state": StateEnum.Unstart.value, + }, + { + "name": StepEnum.LEGALIZATION.value, + "tool": "ecc", + "state": StateEnum.Unstart.value, + }, + ] + } + engine_flow.save() engine_flow.workspace_steps = [sizer_step, post_sizer_step] monkeypatch.setattr(engine_flow, "engine_db", CloseableDb()) From 0dd3ea5fe1a3d58a27268c02c059c4a1c6dd8a4b Mon Sep 17 00:00:00 2001 From: Emin Date: Wed, 19 Aug 2026 09:48:26 +0800 Subject: [PATCH 27/52] test(runtime): reject non-UTF-8 marker payloads in the normative suite parse_marker already catches UnicodeDecodeError, but the rejection was not pinned by a test. Add the normative case so the Python reader and the TS archiver (fatal TextDecoder) are held to the same rule: a frame whose payload is not valid UTF-8 is ordinary stream bytes, never a marker. --- test/runtime/test_log_stream_markers.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/runtime/test_log_stream_markers.py b/test/runtime/test_log_stream_markers.py index e425b169..0e3e4974 100644 --- a/test/runtime/test_log_stream_markers.py +++ b/test/runtime/test_log_stream_markers.py @@ -76,6 +76,10 @@ def test_boolean_version_rejected(self): line = b'\x1eECC-STEP {"v":true,"event":"begin","step":"S","tool":"T"}\n' assert parse_marker(line) is None + def test_non_utf8_payload_rejected(self): + line = b'\x1eECC-STEP {"v":1,"event":"begin","step":"S\xff","tool":"T"}\n' + assert parse_marker(line) is None + class TestEmitStepMarker: def test_payload_carries_version_and_round_trips(self, monkeypatch): From 197dbfa907a5820b78d26f02d794f71197d39ee2 Mon Sep 17 00:00:00 2001 From: Emin Date: Wed, 19 Aug 2026 09:48:33 +0800 Subject: [PATCH 28/52] test(engine): pin executable-steps chaining from a Success predecessor Cover the resume/suffix dependency path the filter exists for: with executable_steps active, a non-executing Success predecessor is built without a dependency check, so its outputs still chain into the executing successor's inputs and its missing tool marks nothing Incomplete. --- test/test_engine_flow.py | 53 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/test/test_engine_flow.py b/test/test_engine_flow.py index 92f8b7d8..1d835b58 100644 --- a/test/test_engine_flow.py +++ b/test/test_engine_flow.py @@ -290,6 +290,59 @@ def fake_create_step(workspace, step, eda, **kwargs): assert sta_step.output.spef is rcx_output.spef # same object, per legacy contract +def test_executable_steps_filter_chains_success_predecessor(monkeypatch, tmp_path): + # create_step_workspaces(executable_steps=...) builds non-executing steps + # without a dependency check, so a Success predecessor whose tool is missing + # still chains its outputs to the executing successor and marks nothing + # Incomplete. + workspace = Workspace( + directory=tmp_path, + flow=Flow(path=tmp_path / "home" / "flow.json"), + ) + flow = EngineFlow(workspace) + # EngineFlow construction loads (and resets) flow.data; set steps after. + flow.workspace.flow.data = { + "steps": [ + {"name": "syn", "tool": "missing-tool", "state": StateEnum.Success.value}, + {"name": "floorplan", "tool": "ecc", "state": StateEnum.Unstart.value}, + ] + } + + predecessor_output = EccOutput( + def_=tmp_path / "syn.def", + verilog=tmp_path / "syn.v", + db=tmp_path / "syn.db", + ) + prebuilt = { + "syn": EccStep(name="syn", tool="missing-tool", output=predecessor_output), + "floorplan": EccStep(name="floorplan", tool="ecc"), + } + calls = [] + + def fake_create_step(workspace, step, eda, *, check_dependency, **kwargs): + calls.append({"step": step, "check_dependency": check_dependency, "inputs": kwargs}) + # Mirror the load_eda_module contract: a missing tool fails the build + # only when the dependency check actually runs. + if check_dependency and eda == "missing-tool": + return None + return prebuilt[step] + + monkeypatch.setattr(tools, "create_step", fake_create_step) + + flow.create_step_workspaces(executable_steps={"floorplan"}) + + assert [call["check_dependency"] for call in calls] == [False, True] + successor_inputs = calls[1]["inputs"] + assert successor_inputs["input_def"] == predecessor_output.def_ + assert successor_inputs["input_verilog"] == predecessor_output.verilog + assert successor_inputs["input_db"] == predecessor_output.db + assert [step.name for step in flow.workspace_steps] == ["syn", "floorplan"] + assert all( + step.get("state") != StateEnum.Imcomplete.value + for step in flow.workspace.flow.data["steps"] + ) + + # --- Phase 2: Silent failure regression tests --- From fbf19ca7f665d4cf250174f40b6f6524ee8c80ad Mon Sep 17 00:00:00 2001 From: Emin Date: Wed, 19 Aug 2026 10:21:40 +0800 Subject: [PATCH 29/52] test(runtime): cover non-object marker payloads and QOR ordering Round-5 re-review P3 follow-ups: pin rejection of non-object JSON payloads ([], null, 42, "hello", true) in the normative parse matrix, and give the end-marker ordering step a feature path so the QOR/metrics refresh is explicitly ordered before the end marker. --- test/runtime/test_log_stream_markers.py | 7 +++++++ test/test_engine_flow.py | 13 ++++++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/test/runtime/test_log_stream_markers.py b/test/runtime/test_log_stream_markers.py index 0e3e4974..bca98bf3 100644 --- a/test/runtime/test_log_stream_markers.py +++ b/test/runtime/test_log_stream_markers.py @@ -3,6 +3,8 @@ import io import os +import pytest + from chipcompiler.runtime.log_stream import ( MARKER_PREFIX, LogStreamReader, @@ -80,6 +82,11 @@ def test_non_utf8_payload_rejected(self): line = b'\x1eECC-STEP {"v":1,"event":"begin","step":"S\xff","tool":"T"}\n' assert parse_marker(line) is None + @pytest.mark.parametrize("payload", ["[]", "null", "42", '"hello"', "true"]) + def test_non_object_payload_rejected(self, payload): + line = f"\x1eECC-STEP {payload}\n".encode() + assert parse_marker(line) is None + class TestEmitStepMarker: def test_payload_carries_version_and_round_trips(self, monkeypatch): diff --git a/test/test_engine_flow.py b/test/test_engine_flow.py index 1d835b58..971fb641 100644 --- a/test/test_engine_flow.py +++ b/test/test_engine_flow.py @@ -104,7 +104,12 @@ def test_end_marker_follows_step_writes_and_precedes_completion(monkeypatch, tmp (tmp_path / "home").mkdir(exist_ok=True) workspace = Workspace(directory=tmp_path, flow=Flow(path=tmp_path / "home" / "flow.json")) - workspace_step = EccStep(name="route", directory=tmp_path, tool="ecc") + workspace_step = EccStep( + name="route", + directory=tmp_path, + tool="ecc", + feature=EccFeature(step=tmp_path / "route.feature.json"), + ) engine_flow = EngineFlow(workspace) engine_flow.workspace.flow.data = { "steps": [{"name": "route", "tool": "ecc", "state": "Unstart"}], @@ -116,6 +121,11 @@ def test_end_marker_follows_step_writes_and_precedes_completion(monkeypatch, tmp monkeypatch.setattr(tools, "run_step", lambda **_kwargs: True) monkeypatch.setattr(engine_flow, "check_step_result", lambda **_kwargs: True) + monkeypatch.setattr( + tools, + "build_step_metrics", + lambda **_kwargs: events.append(("qor", None)) or {}, + ) monkeypatch.setattr( tools, "save_layout_image", @@ -151,6 +161,7 @@ def on_step_completed(self, step, state): assert result == StateEnum.Success end_index = events.index(("marker", "end")) assert events.index(("set_state", StateEnum.Success)) < end_index + assert events.index(("qor", None)) < end_index assert events.index(("layout", None)) < end_index assert events.index(("db_cleanup", StateEnum.Success)) < end_index assert end_index < events.index(("observer", "completed")) From 2ddd8838d36ebae317e0fe5b94de1747d463802e Mon Sep 17 00:00:00 2001 From: KoEkko <2251930460@qq.com> Date: Wed, 19 Aug 2026 10:49:51 +0800 Subject: [PATCH 30/52] fix(runtime): save LVS geometry snapshots --- chipcompiler/engine/flow.py | 1 + chipcompiler/tools/ecc/runner.py | 1 + test/tools/ecc/test_runner.py | 20 +++++++++++++++----- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/chipcompiler/engine/flow.py b/chipcompiler/engine/flow.py index 3a0175a2..27873e0b 100644 --- a/chipcompiler/engine/flow.py +++ b/chipcompiler/engine/flow.py @@ -28,6 +28,7 @@ StepEnum.LEGALIZATION.value, StepEnum.ROUTING.value, StepEnum.DRC.value, + StepEnum.LVS.value, StepEnum.FILLER.value, } ) diff --git a/chipcompiler/tools/ecc/runner.py b/chipcompiler/tools/ecc/runner.py index 561f0c91..e9dc9828 100644 --- a/chipcompiler/tools/ecc/runner.py +++ b/chipcompiler/tools/ecc/runner.py @@ -38,6 +38,7 @@ StepEnum.LEGALIZATION.value, StepEnum.ROUTING.value, StepEnum.DRC.value, + StepEnum.LVS.value, StepEnum.FILLER.value, StepEnum.RCX.value, StepEnum.STA.value, diff --git a/test/tools/ecc/test_runner.py b/test/tools/ecc/test_runner.py index 3e399a0c..6e002396 100644 --- a/test/tools/ecc/test_runner.py +++ b/test/tools/ecc/test_runner.py @@ -1,6 +1,8 @@ import json from pathlib import Path +import pytest + from chipcompiler.data import ( PDK, EccData, @@ -566,9 +568,10 @@ def test_rcx_checklist_uses_top_module_for_spef_design_token(tmp_path): assert checklist.check_spef_file(str(spef)) is True -def test_save_data_writes_geometry_snapshot_for_physical_step(tmp_path): +@pytest.mark.parametrize("step_name", (StepEnum.ROUTING.value, StepEnum.LVS.value)) +def test_save_data_writes_geometry_snapshot_for_physical_step(tmp_path, step_name): workspace = Workspace(directory=tmp_path, design=OriginDesign(name="gcd", top_module="gcd")) - step = build_step(workspace, StepEnum.ROUTING.value, None, None) + step = build_step(workspace, step_name, None, None) module = SnapshotSaveEccModule(write_snapshot=True) assert ecc_runner.save_data(workspace, step, module, feature_step=False) is True @@ -626,11 +629,18 @@ def test_save_data_fails_when_geometry_snapshot_cannot_be_written(tmp_path): ) -def test_engine_flow_requires_geometry_manifest_for_physical_steps(tmp_path): +@pytest.mark.parametrize("step_name", (StepEnum.ROUTING.value, StepEnum.LVS.value)) +def test_engine_flow_requires_geometry_manifest_for_physical_steps(tmp_path, step_name): workspace = Workspace(directory=tmp_path, design=OriginDesign(name="gcd", top_module="gcd")) - step = build_step(workspace, StepEnum.ROUTING.value, None, None) + step = build_step(workspace, step_name, None, None) build_step_space(step) - for output_path in (step.output.def_, step.output.verilog, step.output.gds): + for output_path in ( + step.output.def_, + step.output.verilog, + step.output.gds, + step.report.step, + step.feature.step, + ): assert output_path is not None output_path.write_text("", encoding="utf-8") assert step.output.geometry_manifest is not None From c23244f60c8d00b30e41b652f6859ce18ff66da6 Mon Sep 17 00:00:00 2001 From: Emin Date: Wed, 19 Aug 2026 11:19:57 +0800 Subject: [PATCH 31/52] fix(engine): make the final state save authoritative for the step record MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit set_state returned True even when its save failed, and run_step's redundant second save only flipped a local variable — a failed final save left the canonical record (and possibly flow.json) at Success while reporting Imcomplete. set_state now returns the real save result, run_step performs one final save, and on failure the canonical record is downgraded in memory, the end marker suppressed, and Imcomplete reported. The regression uses a real Flow fixture so the failing save is the one persisting the record: exact save count, no end marker, Imcomplete return/observer/record, Ongoing on disk. --- chipcompiler/engine/flow.py | 11 +++++++---- test/test_engine_flow.py | 38 ++++++++++++++++++++++++++++++------- test/utility/test_json.py | 5 +++-- 3 files changed, 41 insertions(+), 13 deletions(-) diff --git a/chipcompiler/engine/flow.py b/chipcompiler/engine/flow.py index d1f69ca7..99fe17cb 100644 --- a/chipcompiler/engine/flow.py +++ b/chipcompiler/engine/flow.py @@ -171,6 +171,7 @@ def set_state( tool, state_value, ) + return False return True return False @@ -541,14 +542,16 @@ def run_step( runtime=runtime, peak_memory=peak_memory_mb, ) - if persisted and not self.save(): - persisted = False if not persisted: # The marker protocol guarantees the final state is persisted # before the end marker; a failed save makes the run's result - # untrustworthy, so the step is reported incomplete and no end - # marker is emitted for it. + # untrustworthy. Downgrade the canonical in-memory record (the + # downgrade itself is not persisted — the save just failed), + # suppress the end marker, and report the step incomplete. state = StateEnum.Imcomplete + record = self.get_step(workspace_step.name, workspace_step.tool) + if record is not None: + record["state"] = StateEnum.Imcomplete.value self.workspace.logger.error( "[RESULT] %s final state could not be persisted; marking step Imcomplete", step_tag, diff --git a/test/test_engine_flow.py b/test/test_engine_flow.py index 971fb641..977e57b6 100644 --- a/test/test_engine_flow.py +++ b/test/test_engine_flow.py @@ -168,28 +168,46 @@ def on_step_completed(self, step, state): def test_end_marker_suppressed_when_final_state_persistence_fails(monkeypatch, tmp_path): - """A failed final save downgrades the step and suppresses the end marker.""" + """A failed final save downgrades the step and suppresses the end marker. + + Uses a real Flow so the failing save is the one that would have persisted + the record: the Ongoing save succeeds, the one final save fails, and the + canonical record must end Imcomplete in memory and non-Success on disk. + """ import chipcompiler.runtime.log_stream as log_stream_module + from chipcompiler.utility import json_read - workspace = Workspace() - workspace.flow.data = { - "steps": [{"name": "route", "tool": "ecc", "state": "Unstart"}], - } + (tmp_path / "home").mkdir(exist_ok=True) + flow_path = tmp_path / "home" / "flow.json" + workspace = Workspace(directory=tmp_path, flow=Flow(path=flow_path)) workspace_step = EccStep(name="route", directory=tmp_path, tool="ecc") engine_flow = EngineFlow(workspace) + engine_flow.workspace.flow.data = { + "steps": [{"name": "route", "tool": "ecc", "state": "Unstart"}], + } engine_flow.workspace_steps = [workspace_step] engine_flow.engine_db = SimpleNamespace(engine=None) events = [] monkeypatch.setattr(tools, "run_step", lambda **_kwargs: True) monkeypatch.setattr(engine_flow, "check_step_result", lambda **_kwargs: True) - monkeypatch.setattr(engine_flow, "save", lambda: False) monkeypatch.setattr( log_stream_module, "emit_step_marker", lambda event, *, step, tool: events.append(("marker", event)), ) + real_save = engine_flow.save + save_calls = [] + + def save_failing_on_final(): + save_calls.append(len(save_calls) + 1) + if len(save_calls) == 1: + return real_save() # the Ongoing save persists + return False # the one final save fails + + monkeypatch.setattr(engine_flow, "save", save_failing_on_final) + completed_states = [] class CompletionObserver: @@ -198,10 +216,16 @@ def on_step_completed(self, step, state): result = engine_flow.run_step(workspace_step, observer=CompletionObserver()) - assert result == StateEnum.Imcomplete + assert save_calls == [1, 2] assert ("marker", "begin") in events assert ("marker", "end") not in events + assert result == StateEnum.Imcomplete assert completed_states == [StateEnum.Imcomplete] + # The canonical record is downgraded in memory and never reached disk as + # Success: the only persisted state is the Ongoing from the first save. + record = engine_flow.get_step("route", "ecc") + assert record["state"] == StateEnum.Imcomplete.value + assert json_read(flow_path)["steps"][0]["state"] == StateEnum.Ongoing.value def test_check_step_result_synthesis_uses_common_verilog(tmp_path): diff --git a/test/utility/test_json.py b/test/utility/test_json.py index f79cf73c..26455ce0 100644 --- a/test/utility/test_json.py +++ b/test/utility/test_json.py @@ -191,8 +191,9 @@ def test_set_state_updates_in_memory_when_save_fails(self, tmp_path, monkeypatch monkeypatch.setattr("chipcompiler.utility.json_write", lambda *a, **kw: False) result = flow.set_state("SYNTHESIS", "yosys", StateEnum.Success) - assert result is True - # In-memory state is updated + # The record is mutated in memory, but the result reports the actual + # persistence status so callers can distrust an unpersisted state. + assert result is False assert workspace.flow.data["steps"][0]["state"] == StateEnum.Success.value def test_stale_file_causes_rerun_on_resume(self, tmp_path, monkeypatch): From 9df817f6b9dc676946432a6e8029f764cd57222c Mon Sep 17 00:00:00 2001 From: Emin Date: Wed, 19 Aug 2026 11:20:13 +0800 Subject: [PATCH 32/52] fix(runtime): make --only invalidation one all-or-nothing save MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The invalidate_dependents path persisted and deleted the target through _prepare_steps_for_rerun, then saved downstream records separately — a second-save failure left target artifacts deleted and the session records half-mutated. _prepare_steps_for_rerun now validates paths, snapshots every affected record, applies target reset plus downstream state invalidation, persists once, and restores the snapshots before raising on failure; artifacts are cleared only after the save succeeds. The now-unused _invalidate_step_records is removed. Regressions: a three-step save failure pins restored records, untouched artifacts, and a single save attempt; an in-process flow_run_step run with a real EngineFlow proves a failed final save leaves no Success record and the next non-rerun call re-executes the step. --- chipcompiler/runtime/workspace_api.py | 51 ++++----- test/runtime/test_workspace_api.py | 147 ++++++++++++++++++++++++++ 2 files changed, 174 insertions(+), 24 deletions(-) diff --git a/chipcompiler/runtime/workspace_api.py b/chipcompiler/runtime/workspace_api.py index e450c07e..06cc50d3 100644 --- a/chipcompiler/runtime/workspace_api.py +++ b/chipcompiler/runtime/workspace_api.py @@ -348,8 +348,8 @@ def run_step(session: WorkspaceSession) -> dict: session.workspace, engine_flow, prepare_steps, + invalidate_only_steps=invalidate_steps, ) - self._invalidate_step_records(engine_flow, invalidate_steps) self._notify_rerun_prepared( observer, affected_steps, @@ -925,22 +925,6 @@ def _rerun_affected_steps(engine_flow, workspace_step, *, reset_dependents: bool return [workspace_step] return workspace_steps[start_index:] - @staticmethod - def _invalidate_step_records(engine_flow, workspace_steps) -> None: - """Mark steps Unstart in flow.json without touching their artifacts.""" - updated = False - for workspace_step in workspace_steps: - record = engine_flow.get_step(workspace_step.name, workspace_step.tool) - if record is None: - continue - record.update({"state": "Unstart", "runtime": "", "peak memory (mb)": 0}) - updated = True - if updated and not engine_flow.save(): - raise RuntimeApiError( - "command_failed", - "failed to persist step invalidation; refusing to modify outputs", - ) - @staticmethod def _notify_rerun_prepared( observer, @@ -967,7 +951,13 @@ def _prepare_step_for_rerun(workspace, engine_flow, workspace_step) -> None: ) @staticmethod - def _prepare_steps_for_rerun(workspace, engine_flow, workspace_steps) -> None: + def _prepare_steps_for_rerun( + workspace, + engine_flow, + workspace_steps, + *, + invalidate_only_steps=(), + ) -> None: workspace_root = Path(workspace.directory).resolve() unique_steps = [] known_step_keys = set() @@ -995,10 +985,16 @@ def _prepare_steps_for_rerun(workspace, engine_flow, workspace_steps) -> None: known_directories.add(resolved) artifact_directories.append((workspace_step.name, directory)) - # Persist the invalidation before any output is deleted: a failed - # save must leave the workspace untouched rather than clearing - # artifacts while the recorded states stay stale. - updated_record = False + # One all-or-nothing state transition: apply the full reset for the + # rerun targets and the state-only invalidation for their dependents, + # then persist once. A failed save restores the record snapshots so + # neither disk nor the live session is left half-mutated, and no + # artifact is deleted before the new states are durable. + snapshots = [] + for workspace_step in (*unique_steps, *invalidate_only_steps): + record = engine_flow.get_step(workspace_step.name, workspace_step.tool) + if record is not None: + snapshots.append((record, dict(record))) for workspace_step in unique_steps: record = engine_flow.get_step(workspace_step.name, workspace_step.tool) if record is None: @@ -1011,8 +1007,15 @@ def _prepare_steps_for_rerun(workspace, engine_flow, workspace_steps) -> None: "info": {}, } ) - updated_record = True - if updated_record and not engine_flow.save(): + for workspace_step in invalidate_only_steps: + record = engine_flow.get_step(workspace_step.name, workspace_step.tool) + if record is None: + continue + record.update({"state": "Unstart", "runtime": "", "peak memory (mb)": 0}) + if snapshots and not engine_flow.save(): + for record, snapshot in snapshots: + record.clear() + record.update(snapshot) raise RuntimeApiError( "command_failed", "failed to persist step invalidation; refusing to modify outputs", diff --git a/test/runtime/test_workspace_api.py b/test/runtime/test_workspace_api.py index f2356f3c..8b45a944 100644 --- a/test/runtime/test_workspace_api.py +++ b/test/runtime/test_workspace_api.py @@ -1577,6 +1577,153 @@ def test_rerun_prepare_refuses_to_modify_outputs_when_invalidation_save_fails( assert (artifact_dir / "keep.txt").read_text() == "keep" +def test_rerun_invalidate_dependents_save_failure_restores_all_records(monkeypatch, tmp_path): + """The combined target+downstream invalidation is one state transition: + when its single save fails, every record is restored and no artifact, + subflow, or checklist is touched.""" + _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) + + def step_spec(name, tool): + step_dir = ws / f"{name}_{tool}" + artifact_dir = step_dir / "output" + artifact_dir.mkdir(parents=True) + (artifact_dir / "stale").write_text(name) + subflow_path = step_dir / "subflow.json" + subflow_path.write_text(json.dumps({"path": str(subflow_path), "steps": []})) + checklist_path = step_dir / "checklist.json" + checklist_path.write_text(json.dumps({"checklist": []})) + return { + "name": name, + "tool": tool, + "output": {"dir": artifact_dir}, + "subflow": SimpleNamespace(path=subflow_path, steps=[]), + "checklist": SimpleNamespace(path=checklist_path, checklist=[]), + } + + synthesis = step_spec("Synthesis", "yosys") + floorplan = step_spec("Floorplan", "ecc") + route = step_spec("route", "ecc") + DummyFlow.workspace_step_specs = (synthesis, floorplan, route) + api = WorkspaceRuntimeApi() + workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"] + session = api.sessions.get_session(workspace_id) + session.workspace.flow.data = { + "steps": [ + {"name": spec["name"], "tool": spec["tool"], "state": "Success"} + for spec in (synthesis, floorplan, route) + ] + } + + save_calls = [] + + def failing_save(self): + save_calls.append(self) + return False + + monkeypatch.setattr(DummyFlow, "save", failing_save) + + with pytest.raises(RuntimeApiError, match="failed to persist step invalidation"): + api.flow_run_step( + FlowRunStepRequest( + workspace_id=workspace_id, + step="Floorplan", + rerun=True, + invalidate_dependents=True, + ) + ) + + # Exactly one save attempted for the combined invalidation; its failure + # left both memory and disk untouched, and no output was deleted. (The + # session flow build may append a fresh Synthesis entry after the three + # original records; the originals are what the invalidation touches.) + assert len(save_calls) == 1 + steps = session.workspace.flow.data["steps"] + assert [step["state"] for step in steps[:3]] == ["Success", "Success", "Success"] + assert (ws / "home" / "flow.json").read_text() == json.dumps({"steps": []}) + assert (floorplan["output"]["dir"] / "stale").read_text() == "Floorplan" + assert (route["output"]["dir"] / "stale").read_text() == "route" + assert json.loads(floorplan["subflow"].path.read_text()) == { + "path": str(floorplan["subflow"].path), + "steps": [], + } + + +def test_flow_run_step_final_save_failure_leaves_no_success_record(monkeypatch, tmp_path): + """In-process execution path: a failed final save must not leave the + session record at Success, and the next non-rerun call must not skip the + step.""" + from chipcompiler.data import EccStep + from chipcompiler.engine.flow import EngineFlow + from chipcompiler.utility import json_read + + _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) + + class FinalSaveFailsOnceFlow(EngineFlow): + # Class-level so the flow rebuilt for the next call shares the budget. + failures_left = 1 + + def __init__(self, workspace, engine_db=None): + super().__init__(workspace, engine_db) + self.engine_db = SimpleNamespace(has_init=lambda: True, engine=None) + + def save(self): + if type(self).failures_left > 0 and any( + step.get("state") == "Success" for step in self.workspace.flow.data.get("steps", []) + ): + type(self).failures_left -= 1 + return False + return super().save() + + FinalSaveFailsOnceFlow.failures_left = 1 + monkeypatch.setattr("chipcompiler.engine.EngineFlow", FinalSaveFailsOnceFlow) + monkeypatch.setattr( + "chipcompiler.tools.create_step", + lambda workspace, step, eda, **kwargs: EccStep(name=step, tool=eda), + ) + run_calls = [] + monkeypatch.setattr( + "chipcompiler.tools.run_step", lambda **kwargs: run_calls.append(kwargs) or True + ) + monkeypatch.setattr("chipcompiler.tools.save_layout_image", lambda **kwargs: True) + monkeypatch.setattr(EngineFlow, "check_step_result", lambda self, workspace_step: True) + markers = [] + monkeypatch.setattr( + "chipcompiler.runtime.log_stream.emit_step_marker", + lambda event, *, step, tool: markers.append(event), + ) + + api = WorkspaceRuntimeApi() + workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"] + session = api.sessions.get_session(workspace_id) + session.workspace.logger = SimpleNamespace( + info=lambda *a, **k: None, + error=lambda *a, **k: None, + warning=lambda *a, **k: None, + exception=lambda *a, **k: None, + log_section=lambda *a, **k: None, + ) + session.workspace.pdk = SimpleNamespace(sdc=None) + + with pytest.raises(RuntimeApiError, match="failed with state Incomplete"): + api.flow_run_step(FlowRunStepRequest(workspace_id=workspace_id, step="Synthesis")) + + # The failed final save downgraded the live record; disk only ever saw + # the Ongoing state, and no end marker fired. + record = session.workspace.flow.data["steps"][0] + assert record["state"] == StateEnum.Imcomplete.value + flow_path = session.workspace.flow.path + assert json_read(flow_path)["steps"][0]["state"] == "Ongoing" + assert markers == ["begin"] + + # The next non-rerun call does not skip the step: it executes again and + # persists Success once the save works. + result = api.flow_run_step(FlowRunStepRequest(workspace_id=workspace_id, step="Synthesis")) + + assert result == {"step": "Synthesis", "state": "Success"} + assert len(run_calls) == 2 + assert json_read(flow_path)["steps"][0]["state"] == "Success" + + def test_flow_run_step_rerun_rejects_an_open_layout_edit(monkeypatch, tmp_path): _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) api = WorkspaceRuntimeApi() From 4dfc797a21b223c33396d81f63f0f45054ed5fe6 Mon Sep 17 00:00:00 2001 From: Emin Date: Wed, 19 Aug 2026 11:48:45 +0800 Subject: [PATCH 33/52] fix(agent): migrate the agent engine to marker-driven execution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deleting redirect_stdio_to_file broke agent/engine.py at import time (main pytest collects only test/, so the break was invisible), and its run_step still redirected executor stdio into step log files with no markers — against the protocol. The agent now emits begin/end markers exactly like EngineFlow.run_step, never touches step log files, and its _finish_step returns the authoritative save result: a failed final save downgrades the canonical record and suppresses the end marker. New tests pin the marker ordering around step writes and the failed-save suppression. The three-step invalidation regression now also seeds non-default record metadata and asserts identity-preserving rollback of the complete records. --- agent/engine.py | 44 ++++++++++++-------- agent/test/test_engine.py | 66 ++++++++++++++++++++++++++++++ test/runtime/test_workspace_api.py | 16 +++++++- 3 files changed, 108 insertions(+), 18 deletions(-) diff --git a/agent/engine.py b/agent/engine.py index 2f9948f9..70e63ab0 100644 --- a/agent/engine.py +++ b/agent/engine.py @@ -5,7 +5,6 @@ from chipcompiler.data import StateEnum, WorkspaceStep from chipcompiler.engine.flow import EngineFlow, get_process_rss_mb, track_current_process_memory -from chipcompiler.utility.log import redirect_stdio_to_file from .tools import run_step as run_agent_step @@ -36,7 +35,12 @@ def run_step(self, workspace_step: WorkspaceStep | str, *, rerun: bool = False) start_time = time.time() timing_constraints = self.timing_constraint_facts() self.set_state(name=workspace_step.name, tool=workspace_step.tool, state=StateEnum.Ongoing) - self._redirect_step_stdio(workspace_step) + # The agent is an executor: it never writes step log files. Its bytes + # stay on fd 1/2 and versioned step markers frame the step's stream + # for the client-side archiver, exactly like EngineFlow.run_step. + from chipcompiler.runtime.log_stream import emit_step_marker + + emit_step_marker("begin", step=workspace_step.name, tool=workspace_step.tool) start_memory, peak_memory, stop_monitor, monitor = self._start_memory_monitor() result = False try: @@ -52,26 +56,20 @@ def run_step(self, workspace_step: WorkspaceStep | str, *, rerun: bool = False) elapsed = time.time() - start_time state = self._step_state(workspace_step, result) - self._finish_step( + state, persisted = self._finish_step( workspace_step, state, elapsed, timing_constraints, max(0, round(peak_memory[0] - start_memory, 3)), ) + # The end marker closes the step's byte stream only after every + # step-scoped write has persisted; a failed final save reads as a + # crash to consumers, so the marker stays unwritten. + if persisted: + emit_step_marker("end", step=workspace_step.name, tool=workspace_step.tool) return state - def _redirect_step_stdio(self, workspace_step: WorkspaceStep) -> None: - log_file = workspace_step.log.file or "" - if not log_file: - return - try: - log_file = os.path.abspath(log_file) - os.makedirs(os.path.dirname(log_file) or ".", exist_ok=True) - redirect_stdio_to_file(log_file) - except Exception: - traceback.print_exc() - def _start_memory_monitor(self) -> tuple[float, list[float], Event, Thread]: start_memory = get_process_rss_mb(os.getpid()) peak_memory = [start_memory] @@ -107,15 +105,28 @@ def _finish_step( elapsed: float, timing_constraints: dict, peak_memory_mb: float, - ) -> None: + ) -> tuple[StateEnum, bool]: runtime = f"{int(elapsed // 3600)}:{int((elapsed % 3600) // 60)}:{int(elapsed % 60)}" - self.set_state( + persisted = self.set_state( name=workspace_step.name, tool=workspace_step.tool, state=state, runtime=runtime, peak_memory=peak_memory_mb, ) + if not persisted: + # The final state did not reach disk: the recorded result is not + # trustworthy. Downgrade the canonical record in memory (the + # downgrade itself is not persisted — the save just failed). + state = StateEnum.Imcomplete + record = self.get_step(workspace_step.name, workspace_step.tool) + if record is not None: + record["state"] = StateEnum.Imcomplete.value + self.workspace.logger.error( + "[RESULT] %s(%s) final state could not be persisted; marking step Imcomplete", + workspace_step.name, + workspace_step.tool, + ) if state == StateEnum.Success: self._save_agent_step_facts( workspace_step, @@ -125,6 +136,7 @@ def _finish_step( timing_constraints, ) self.clear_db_engine_after_step(workspace_step, state) + return state, persisted def _save_agent_step_facts( self, diff --git a/agent/test/test_engine.py b/agent/test/test_engine.py index 2e80a2de..a40e6b76 100644 --- a/agent/test/test_engine.py +++ b/agent/test/test_engine.py @@ -38,3 +38,69 @@ def run_step(**_kwargs): assert flow.run_step(step) is expected_state assert flow.check_state("route", "ecc", expected_state) + + +def _marker_workspace(tmp_path): + workspace = Workspace(directory=tmp_path, flow=Flow(path=tmp_path / "flow.json")) + flow = AgentEngineFlow(workspace) + workspace.flow.data = {"steps": [{"name": "route", "tool": "ecc", "state": "Unstart"}]} + step = EccStep(name="route", directory=tmp_path, tool="ecc") + flow.workspace_steps = [step] + flow.engine_db = SimpleNamespace(engine=None) + return flow, step + + +def test_agent_engine_emits_markers_around_step_writes(monkeypatch, tmp_path): + """The agent executor frames the step stream with markers and never + redirects stdio into a step log file.""" + flow, step = _marker_workspace(tmp_path) + events = [] + monkeypatch.setattr(flow, "check_step_result", lambda **_kwargs: True) + monkeypatch.setattr( + "agent.engine.run_agent_step", + lambda **kwargs: events.append(("tool", None)) or True, + ) + monkeypatch.setattr(flow, "save_step_flow_facts", lambda **_kwargs: False) + monkeypatch.setattr( + "chipcompiler.tools.save_layout_image", + lambda **_kwargs: events.append(("layout", None)), + ) + monkeypatch.setattr( + "chipcompiler.runtime.log_stream.emit_step_marker", + lambda event, *, step, tool: events.append(("marker", event)), + ) + + assert flow.run_step(step) is StateEnum.Success + end_index = events.index(("marker", "end")) + assert events.index(("marker", "begin")) < events.index(("tool", None)) + assert events.index(("layout", None)) < end_index + + +def test_agent_engine_suppresses_end_marker_when_final_save_fails(monkeypatch, tmp_path): + """A failed final save downgrades the record and suppresses the end + marker, matching the base engine's authoritative-save contract.""" + flow, step = _marker_workspace(tmp_path) + markers = [] + monkeypatch.setattr(flow, "check_step_result", lambda **_kwargs: True) + monkeypatch.setattr("agent.engine.run_agent_step", lambda **kwargs: True) + monkeypatch.setattr( + "chipcompiler.runtime.log_stream.emit_step_marker", + lambda event, *, step, tool: markers.append(event), + ) + + real_save = flow.save + save_calls = [] + + def save_failing_on_final(): + save_calls.append(len(save_calls) + 1) + if len(save_calls) == 1: + return real_save() # the Ongoing save persists + return False # the one final save fails + + monkeypatch.setattr(flow, "save", save_failing_on_final) + + assert flow.run_step(step) is StateEnum.Imcomplete + assert save_calls == [1, 2] + assert markers == ["begin"] + record = flow.get_step("route", "ecc") + assert record["state"] == StateEnum.Imcomplete.value diff --git a/test/runtime/test_workspace_api.py b/test/runtime/test_workspace_api.py index 8b45a944..b3c1a25e 100644 --- a/test/runtime/test_workspace_api.py +++ b/test/runtime/test_workspace_api.py @@ -1609,10 +1609,21 @@ def step_spec(name, tool): session = api.sessions.get_session(workspace_id) session.workspace.flow.data = { "steps": [ - {"name": spec["name"], "tool": spec["tool"], "state": "Success"} + { + "name": spec["name"], + "tool": spec["tool"], + "state": "Success", + "runtime": "0:01:00", + "peak memory (mb)": 123.0, + "info": {"cached": spec["name"]}, + } for spec in (synthesis, floorplan, route) ] } + # Capture the original record objects and their full contents: rollback + # must restore the same dicts in place, not substitute new ones. + original_records = list(session.workspace.flow.data["steps"]) + original_snapshots = [dict(record) for record in original_records] save_calls = [] @@ -1638,7 +1649,8 @@ def failing_save(self): # original records; the originals are what the invalidation touches.) assert len(save_calls) == 1 steps = session.workspace.flow.data["steps"] - assert [step["state"] for step in steps[:3]] == ["Success", "Success", "Success"] + assert steps[:3] == original_snapshots + assert all(step is original for step, original in zip(steps[:3], original_records, strict=True)) assert (ws / "home" / "flow.json").read_text() == json.dumps({"steps": []}) assert (floorplan["output"]["dir"] / "stale").read_text() == "Floorplan" assert (route["output"]["dir"] / "stale").read_text() == "route" From b36767e824b5efe4598d22ded6e63bfe67be1059 Mon Sep 17 00:00:00 2001 From: Emin Date: Wed, 19 Aug 2026 15:03:28 +0800 Subject: [PATCH 34/52] refactor(engine): own the executor lifecycle once in EngineFlow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AgentEngineFlow duplicated the full step lifecycle — selection, Ongoing transition, marker emission, memory tracking, tool invocation, final save, downgrade, post-processing, db cleanup — which is how the redirect_stdio_to_file deletion left it broken and protocol-inconsistent for four rounds. EngineFlow.run_step now invokes the tool through _invoke_step_tool and derives the state through _derive_step_state; the base hooks preserve existing behavior exactly. AgentEngineFlow keeps only its DRC insertion and two hook overrides (run_agent_step, with False -> Imcomplete, Invalid passthrough, True|Success -> artifact check). New agent regression proves the inherited lifecycle drives the agent hook and opens no step log file even when one is declared. --- agent/engine.py | 148 +++--------------------------------- agent/test/test_engine.py | 29 +++++++ chipcompiler/engine/flow.py | 37 +++++---- 3 files changed, 65 insertions(+), 149 deletions(-) diff --git a/agent/engine.py b/agent/engine.py index 70e63ab0..393c1d58 100644 --- a/agent/engine.py +++ b/agent/engine.py @@ -1,15 +1,13 @@ -import os -import time -import traceback -from threading import Event, Thread - from chipcompiler.data import StateEnum, WorkspaceStep -from chipcompiler.engine.flow import EngineFlow, get_process_rss_mb, track_current_process_memory +from chipcompiler.engine.flow import EngineFlow from .tools import run_step as run_agent_step class AgentEngineFlow(EngineFlow): + """Flow Agent engine: the canonical step lifecycle lives in EngineFlow; + only the tool runner and the agent's result vocabulary differ.""" + def build_default_steps(self): super().build_default_steps() steps = self.workspace.flow.data["steps"] @@ -19,75 +17,16 @@ def build_default_steps(self): steps.insert(filler_index, self.init_flow_step("DRC", "ecc", StateEnum.Unstart)) self.save() - def run_step(self, workspace_step: WorkspaceStep | str, *, rerun: bool = False) -> StateEnum: - if isinstance(workspace_step, str): - workspace_step = self.get_workspace_step(workspace_step) - if workspace_step is None: - return StateEnum.Invalid - step_tag = f"{workspace_step.name}({workspace_step.tool})" - if not rerun and self.check_state( - name=workspace_step.name, tool=workspace_step.tool, state=StateEnum.Success - ): - self.workspace.logger.info("[SKIP] %s already succeeded", step_tag) - self.clear_db_engine_after_step(workspace_step, StateEnum.Success) - return StateEnum.Success - - start_time = time.time() - timing_constraints = self.timing_constraint_facts() - self.set_state(name=workspace_step.name, tool=workspace_step.tool, state=StateEnum.Ongoing) - # The agent is an executor: it never writes step log files. Its bytes - # stay on fd 1/2 and versioned step markers frame the step's stream - # for the client-side archiver, exactly like EngineFlow.run_step. - from chipcompiler.runtime.log_stream import emit_step_marker - - emit_step_marker("begin", step=workspace_step.name, tool=workspace_step.tool) - start_memory, peak_memory, stop_monitor, monitor = self._start_memory_monitor() - result = False - try: - result = run_agent_step( - workspace=self.workspace, step=workspace_step, ecc_module=self.engine_db.engine - ) - self.workspace.logger.info("[STEP] %s finished result=%s", step_tag, result) - except Exception: - self.workspace.logger.error("[STEP] %s failed with exception", step_tag) - traceback.print_exc() - finally: - self._stop_memory_monitor(stop_monitor, monitor) - - elapsed = time.time() - start_time - state = self._step_state(workspace_step, result) - state, persisted = self._finish_step( - workspace_step, - state, - elapsed, - timing_constraints, - max(0, round(peak_memory[0] - start_memory, 3)), - ) - # The end marker closes the step's byte stream only after every - # step-scoped write has persisted; a failed final save reads as a - # crash to consumers, so the marker stays unwritten. - if persisted: - emit_step_marker("end", step=workspace_step.name, tool=workspace_step.tool) - return state - - def _start_memory_monitor(self) -> tuple[float, list[float], Event, Thread]: - start_memory = get_process_rss_mb(os.getpid()) - peak_memory = [start_memory] - stop_monitor = Event() - monitor = Thread( - target=track_current_process_memory, - args=(os.getpid(), stop_monitor, peak_memory), - daemon=True, + def _invoke_step_tool(self, workspace_step: WorkspaceStep): + return run_agent_step( + workspace=self.workspace, step=workspace_step, ecc_module=self.engine_db.engine ) - monitor.start() - return start_memory, peak_memory, stop_monitor, monitor - - @staticmethod - def _stop_memory_monitor(stop_monitor: Event, monitor: Thread) -> None: - stop_monitor.set() - monitor.join() - def _step_state(self, workspace_step: WorkspaceStep, result: object) -> StateEnum: + def _derive_step_state( + self, workspace_step: WorkspaceStep, result, *, raised: bool + ) -> StateEnum: + if raised: + return StateEnum.Imcomplete if result is StateEnum.Invalid: return StateEnum.Invalid if result is True or result is StateEnum.Success: @@ -97,66 +36,3 @@ def _step_state(self, workspace_step: WorkspaceStep, result: object) -> StateEnu else StateEnum.Imcomplete ) return StateEnum.Imcomplete - - def _finish_step( - self, - workspace_step: WorkspaceStep, - state: StateEnum, - elapsed: float, - timing_constraints: dict, - peak_memory_mb: float, - ) -> tuple[StateEnum, bool]: - runtime = f"{int(elapsed // 3600)}:{int((elapsed % 3600) // 60)}:{int(elapsed % 60)}" - persisted = self.set_state( - name=workspace_step.name, - tool=workspace_step.tool, - state=state, - runtime=runtime, - peak_memory=peak_memory_mb, - ) - if not persisted: - # The final state did not reach disk: the recorded result is not - # trustworthy. Downgrade the canonical record in memory (the - # downgrade itself is not persisted — the save just failed). - state = StateEnum.Imcomplete - record = self.get_step(workspace_step.name, workspace_step.tool) - if record is not None: - record["state"] = StateEnum.Imcomplete.value - self.workspace.logger.error( - "[RESULT] %s(%s) final state could not be persisted; marking step Imcomplete", - workspace_step.name, - workspace_step.tool, - ) - if state == StateEnum.Success: - self._save_agent_step_facts( - workspace_step, - state, - elapsed, - peak_memory_mb, - timing_constraints, - ) - self.clear_db_engine_after_step(workspace_step, state) - return state, persisted - - def _save_agent_step_facts( - self, - workspace_step: WorkspaceStep, - state: StateEnum, - elapsed: float, - peak_memory: float, - timing_constraints: dict, - ) -> None: - from chipcompiler.tools import build_step_metrics, save_layout_image - - if self.save_step_flow_facts( - workspace_step=workspace_step, - state=state, - runtime_seconds=elapsed, - peak_memory_mb=peak_memory, - timing_constraints=timing_constraints, - ): - try: - build_step_metrics(workspace=self.workspace, step=workspace_step) - except Exception: - self.workspace.logger.exception("[QOR] failed to refresh analysis") - save_layout_image(workspace=self.workspace, step=workspace_step) diff --git a/agent/test/test_engine.py b/agent/test/test_engine.py index a40e6b76..7b44d834 100644 --- a/agent/test/test_engine.py +++ b/agent/test/test_engine.py @@ -104,3 +104,32 @@ def save_failing_on_final(): assert markers == ["begin"] record = flow.get_step("route", "ecc") assert record["state"] == StateEnum.Imcomplete.value + + +def test_agent_engine_inherits_lifecycle_and_never_opens_step_logs(monkeypatch, tmp_path): + """The inherited base lifecycle drives the agent tool hook end to end, + and no step log file is opened even when the step declares a log path.""" + flow, step = _marker_workspace(tmp_path) + declared_log = tmp_path / "route_ecc" / "log" / "route.log" + step.log.file = declared_log + tool_calls = [] + monkeypatch.setattr(flow, "check_step_result", lambda **_kwargs: True) + monkeypatch.setattr( + "agent.engine.run_agent_step", + lambda **kwargs: tool_calls.append(kwargs) or True, + ) + monkeypatch.setattr(flow, "save_step_flow_facts", lambda **_kwargs: False) + monkeypatch.setattr("chipcompiler.tools.save_layout_image", lambda **_kwargs: True) + markers = [] + monkeypatch.setattr( + "chipcompiler.runtime.log_stream.emit_step_marker", + lambda event, *, step, tool: markers.append(event), + ) + + assert flow.run_step(step) is StateEnum.Success + # The agent runner hook was invoked through the base lifecycle. + assert len(tool_calls) == 1 + assert tool_calls[0]["step"] is step + assert markers == ["begin", "end"] + assert not declared_log.exists() + assert flow.check_state("route", "ecc", StateEnum.Success) diff --git a/chipcompiler/engine/flow.py b/chipcompiler/engine/flow.py index 99fe17cb..58a33bb7 100644 --- a/chipcompiler/engine/flow.py +++ b/chipcompiler/engine/flow.py @@ -499,12 +499,9 @@ def run_step( if observer is not None: self.workspace._runtime_flow_observer = observer step_raised_exception = False + result = None try: - from chipcompiler.tools import run_step as run_tool_step - - result = run_tool_step( - workspace=self.workspace, step=workspace_step, ecc_module=self.engine_db.engine - ) + result = self._invoke_step_tool(workspace_step) self.workspace.logger.info(f"[STEP] {step_tag} finished result={result}") except Exception: step_raised_exception = True @@ -526,14 +523,7 @@ def run_step( runtime = f"{int(elapsed // 3600)}:{int((elapsed % 3600) // 60)}:{int(elapsed % 60)}" # determine and save state - if step_raised_exception: - state = StateEnum.Imcomplete - else: - state = ( - StateEnum.Success - if self.check_step_result(workspace_step=workspace_step) - else StateEnum.Imcomplete - ) + state = self._derive_step_state(workspace_step, result, raised=step_raised_exception) persisted = self.set_state( name=workspace_step.name, @@ -623,6 +613,27 @@ def init_db_engine_for_step(self, workspace_step: WorkspaceStep) -> bool: return self.engine_db.create_db_engine(step=workspace_step) + def _invoke_step_tool(self, workspace_step: WorkspaceStep): + """Run the step's tool. Subclasses redirect to their own runner.""" + from chipcompiler.tools import run_step as run_tool_step + + return run_tool_step( + workspace=self.workspace, step=workspace_step, ecc_module=self.engine_db.engine + ) + + def _derive_step_state( + self, workspace_step: WorkspaceStep, result, *, raised: bool + ) -> StateEnum: + """Map the tool result to the step state. Subclasses keep their own + result vocabulary; the base engine trusts the artifact check.""" + if raised: + return StateEnum.Imcomplete + return ( + StateEnum.Success + if self.check_step_result(workspace_step=workspace_step) + else StateEnum.Imcomplete + ) + def _notify_flow_observer(observer, method_name: str, *args) -> None: """Keep optional GUI observers outside the flow engine's failure domain.""" From 4f097bee09a7d1d1eebf9b3d0a35c924a647b038 Mon Sep 17 00:00:00 2001 From: Emin Date: Wed, 19 Aug 2026 21:45:26 +0800 Subject: [PATCH 35/52] fix(cli): drive the --from/--resume suffix as explicit step calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The run_step+flow.run sequence let the trailing unscoped flow.run resume from the first non-success step — with a failed step before the --from boundary, it executed and mutated steps outside the requested suffix. The suffix is now driven as explicit per-step run_step calls (boundary step with reset_dependents, then each persisted successor), preserving rerun.run_from's exact scoping and stop-on-first-failure ordering. Wiring tests updated to the stepwise contract, plus a regression with a failed step before the --from boundary proving it is never called. --- chipcompiler/cli/command_handlers/project.py | 10 ++++--- test/cli/commands/test_run.py | 29 ++++++++++++++++++-- 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/chipcompiler/cli/command_handlers/project.py b/chipcompiler/cli/command_handlers/project.py index 080a8e55..b4d5ef70 100644 --- a/chipcompiler/cli/command_handlers/project.py +++ b/chipcompiler/cli/command_handlers/project.py @@ -624,10 +624,12 @@ def no_op_result() -> CommandResult: # Downstream steps keep their outputs but are marked Unstart. calls = [("flow.run_step", {"step": target, "rerun": True, "invalidate_dependents": True})] else: - calls = [ - ("flow.run_step", {"step": target, "rerun": True, "reset_dependents": True}), - ("flow.run", {"rerun": False}), - ] + # --resume/--from run exactly the selected suffix, step by step. A + # trailing unscoped flow.run would resume from the FIRST non-success + # step — possibly before the --from boundary — so the suffix is + # driven as explicit run_step calls instead. + calls = [("flow.run_step", {"step": target, "rerun": True, "reset_dependents": True})] + calls += [("flow.run_step", {"step": name, "rerun": True}) for name in selected[1:]] op_result = _run_worker_calls(workspace_path, calls) diff --git a/test/cli/commands/test_run.py b/test/cli/commands/test_run.py index 5786a916..1c025689 100644 --- a/test/cli/commands/test_run.py +++ b/test/cli/commands/test_run.py @@ -353,9 +353,12 @@ def test_default_selector_is_resume(self, workspace_mocks, tmp_path, capsys): record = json.loads(capsys.readouterr().out)["records"][0] assert rc == 0 + # The persisted suffix is driven step by step: an unscoped flow.run + # would resume from the first non-success step, which may sit before + # the selected boundary. assert workspace_mocks.calls == [ ("flow.run_step", {"step": "place", "rerun": True, "reset_dependents": True}), - ("flow.run", {"rerun": False}), + ("flow.run_step", {"step": "CTS", "rerun": True}), ] assert record["executed_steps"] == ["place", "CTS"] @@ -368,10 +371,32 @@ def test_from_step_wiring(self, workspace_mocks, tmp_path, capsys): assert rc == 0 assert workspace_mocks.calls == [ ("flow.run_step", {"step": "CTS", "rerun": True, "reset_dependents": True}), - ("flow.run", {"rerun": False}), ] assert record["executed_steps"] == ["CTS"] + def test_from_step_never_runs_steps_before_the_boundary( + self, workspace_mocks, tmp_path, capsys + ): + # Regression: with a failed step BEFORE the --from boundary, the + # previous run_step+flow.run sequence let flow.run resume from that + # earlier step, executing outside the requested suffix. + workspace_mocks.steps = [ + {"name": "Synthesis", "tool": "yosys", "state": "Imcomplete"}, + {"name": "place", "tool": "ecc", "state": "Imcomplete"}, + {"name": "CTS", "tool": "ecc", "state": "Unstart"}, + ] + workspace = str(tmp_path / "workspace") + + rc = cli_main.run(["run", "--workspace", workspace, "--from", "place", "--json"]) + + record = json.loads(capsys.readouterr().out)["records"][0] + assert rc == 0 + assert workspace_mocks.calls == [ + ("flow.run_step", {"step": "place", "rerun": True, "reset_dependents": True}), + ("flow.run_step", {"step": "CTS", "rerun": True}), + ] + assert record["executed_steps"] == ["place", "CTS"] + def test_resume_all_success_is_noop(self, workspace_mocks, tmp_path, capsys): workspace_mocks.steps = [ {"name": "Synthesis", "tool": "yosys", "state": "Success"}, From ab8ede7bcbcc3df5f5900d2f6f46f990778047d5 Mon Sep 17 00:00:00 2001 From: Emin Date: Wed, 19 Aug 2026 21:45:50 +0800 Subject: [PATCH 36/52] feat(runtime): self-archive step logs for in-process executor runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Direct EngineFlow execution (agent candidate reruns, documented examples) emitted markers to fd 2 with no client to consume them: raw frames leaked to the caller's stderr and no per-step archive was written. The new archive_own_step_logs context redirects the process's own fd 2 through a pipe so a LogStreamReader archives step bytes and consumes markers while echoing everything to the original stderr — the executor still never opens a log file; the client role just runs in-process. The agent's candidate rerun path uses it, and the teardown closes the saved stderr only after the reader drains, pinned by a capfd regression. --- agent/workspace_api.py | 8 +++- chipcompiler/runtime/log_stream.py | 61 +++++++++++++++++++++++++++++- test/runtime/test_log_stream.py | 29 ++++++++++++++ 3 files changed, 96 insertions(+), 2 deletions(-) diff --git a/agent/workspace_api.py b/agent/workspace_api.py index 9c310a17..63df9baf 100644 --- a/agent/workspace_api.py +++ b/agent/workspace_api.py @@ -262,8 +262,14 @@ def _clear_candidate_artifact_dir(workspace_root: Path, directory: Path, step_na def _run_candidate_step(flow, step) -> None: + from chipcompiler.runtime.log_stream import archive_own_step_logs + _init_db_engine_for_workspace_step(flow, step) - state = flow.run_step(step, rerun=True) + # In-process execution is still executor+client in one process: route the + # own fd-2 stream through the reader so markers are consumed and the + # step's bytes land in its archive (echoed to the real stderr). + with archive_own_step_logs(flow.workspace.directory): + state = flow.run_step(step, rerun=True) if _state_value(state) != "Success": raise RuntimeApiError( "command_failed", diff --git a/chipcompiler/runtime/log_stream.py b/chipcompiler/runtime/log_stream.py index 86d92acd..3860663a 100644 --- a/chipcompiler/runtime/log_stream.py +++ b/chipcompiler/runtime/log_stream.py @@ -13,7 +13,7 @@ import os import threading from collections.abc import Callable -from contextlib import suppress +from contextlib import contextmanager, suppress from dataclasses import dataclass, field from pathlib import Path from typing import BinaryIO @@ -319,3 +319,62 @@ def _close_archive(self) -> None: except OSError as exc: self._record_error(exc) self._state.archive_file = None + + +@contextmanager +def archive_own_step_logs(workspace_dir, *, echo: bool = True): + """Archive this process's own fd-2 stream into per-step log files. + + In-process executor runs (no separate client process, e.g. agent + candidate reruns or the documented direct EngineFlow examples) still + must not write step log files from executor code. This context redirects + fd 2 through a pipe so a LogStreamReader — the client role — archives + step-scoped bytes and consumes markers, while echoing all bytes to the + original stderr. Yields the reader so callers can inspect + ``reader.state`` after the block. + """ + import sys + + from chipcompiler.utility.json import json_read + from chipcompiler.utility.log import flush_cstdio + + workspace_dir = Path(workspace_dir) + flow_data = json_read(workspace_dir / "home" / "flow.json") + valid_steps = { + (step["name"], step["tool"]) + for step in flow_data.get("steps", []) + if isinstance(step, dict) and "name" in step and "tool" in step + } + + sys.stdout.flush() + sys.stderr.flush() + flush_cstdio() + real_stderr = os.dup(2) + read_fd, write_fd = os.pipe() + os.dup2(write_fd, 2) + os.close(write_fd) + + def _echo(data: bytes) -> None: + os.write(real_stderr, data) + + reader = LogStreamReader( + os.fdopen(read_fd, "rb"), + log_path_resolver=step_log_archive_resolver(workspace_dir), + on_output=_echo if echo else None, + valid_steps=valid_steps or None, + workspace_dir=workspace_dir, + ) + reader.start() + try: + yield reader + finally: + # Flush everything, restore fd 2 so the pipe sees EOF, and only then + # wait for the reader to drain the tail — the echo callback writes to + # real_stderr, so it must stay open until the drain finishes. + sys.stdout.flush() + sys.stderr.flush() + flush_cstdio() + os.dup2(real_stderr, 2) + reader.join(timeout=5.0) + reader.stop() + os.close(real_stderr) diff --git a/test/runtime/test_log_stream.py b/test/runtime/test_log_stream.py index 576361af..d6500daf 100644 --- a/test/runtime/test_log_stream.py +++ b/test/runtime/test_log_stream.py @@ -270,6 +270,35 @@ def resolver(step, tool): assert reader.state.steps_seen == ["A"] +class TestArchiveOwnStepLogs: + """In-process executor runs self-archive through the fd-2 pipe.""" + + def test_archives_step_bytes_and_echoes_without_markers(self, tmp_path, capfd): + import json + import os + + from chipcompiler.runtime.log_stream import archive_own_step_logs, emit_step_marker + + workspace = tmp_path / "ws" + (workspace / "home").mkdir(parents=True) + (workspace / "home" / "flow.json").write_text( + json.dumps({"steps": [{"name": "S", "tool": "T", "state": "Ongoing"}]}) + ) + + with archive_own_step_logs(workspace) as reader: + emit_step_marker("begin", step="S", tool="T") + os.write(2, b"tool output\n") + emit_step_marker("end", step="S", tool="T") + os.write(2, b"unscoped tail\n") + + assert reader.state.error is None + assert (workspace / "S_T" / "log" / "S.log").read_bytes() == b"tool output\n" + echoed = capfd.readouterr().err + assert "tool output" in echoed + assert "unscoped tail" in echoed + assert "ECC-STEP" not in echoed + + class TestLogStreamResilience: def test_resolver_exception_disables_archive_continues_drain(self): """A resolver that raises must not kill the drain thread.""" From 7d5b8bbd1a6b09248ddffd7bbb9039b2df8450bd Mon Sep 17 00:00:00 2001 From: Emin Date: Wed, 19 Aug 2026 21:46:47 +0800 Subject: [PATCH 37/52] docs(examples): wrap the direct-run example in archive_own_step_logs --- docs/examples/gcd/README.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/examples/gcd/README.md b/docs/examples/gcd/README.md index e73a4672..6c29a111 100644 --- a/docs/examples/gcd/README.md +++ b/docs/examples/gcd/README.md @@ -114,7 +114,13 @@ if not engine_flow.has_init(): # Create step workspaces and run engine_flow.create_step_workspaces() -engine_flow.run_steps() +# In-process runs still archive per-step logs client-side: the engine emits +# step markers on fd 2, and this context routes the process's own stream +# through the archiver (markers never reach the terminal). +from chipcompiler.runtime.log_stream import archive_own_step_logs + +with archive_own_step_logs(workspace.directory): + engine_flow.run_steps() ``` The flow we defined is: From 5fd32e33e6a47818ce1e6c82e97217424641b27e Mon Sep 17 00:00:00 2001 From: Emin Date: Wed, 19 Aug 2026 22:13:53 +0800 Subject: [PATCH 38/52] fix(runtime): capture fd 1 in the in-process self-archive pipe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit archive_own_step_logs redirected only fd 2, but tool subprocess stdout and native tool logging write fd 1 — in-process runs (agent candidate reruns, direct EngineFlow examples) lost those bytes from the step archives. Both descriptors now share the pipe, matching the merged stream the CLI worker's stdio isolation produces; markers stay on fd 2 and every byte echoes to the original stderr. The regression now also pins fd 1 bytes landing in the archive in write order. --- chipcompiler/runtime/log_stream.py | 20 +++++++++++++------- test/runtime/test_log_stream.py | 5 ++++- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/chipcompiler/runtime/log_stream.py b/chipcompiler/runtime/log_stream.py index 3860663a..059563c9 100644 --- a/chipcompiler/runtime/log_stream.py +++ b/chipcompiler/runtime/log_stream.py @@ -323,14 +323,15 @@ def _close_archive(self) -> None: @contextmanager def archive_own_step_logs(workspace_dir, *, echo: bool = True): - """Archive this process's own fd-2 stream into per-step log files. + """Archive this process's own fd 1+2 streams into per-step log files. In-process executor runs (no separate client process, e.g. agent candidate reruns or the documented direct EngineFlow examples) still must not write step log files from executor code. This context redirects - fd 2 through a pipe so a LogStreamReader — the client role — archives - step-scoped bytes and consumes markers, while echoing all bytes to the - original stderr. Yields the reader so callers can inspect + fd 1 and fd 2 through one pipe — the same merged stream the CLI worker's + stdio isolation produces — so a LogStreamReader (the client role) + archives step-scoped bytes and consumes markers, while echoing all bytes + to the original stderr. Yields the reader so callers can inspect ``reader.state`` after the block. """ import sys @@ -349,8 +350,10 @@ def archive_own_step_logs(workspace_dir, *, echo: bool = True): sys.stdout.flush() sys.stderr.flush() flush_cstdio() + real_stdout = os.dup(1) real_stderr = os.dup(2) read_fd, write_fd = os.pipe() + os.dup2(write_fd, 1) os.dup2(write_fd, 2) os.close(write_fd) @@ -368,13 +371,16 @@ def _echo(data: bytes) -> None: try: yield reader finally: - # Flush everything, restore fd 2 so the pipe sees EOF, and only then - # wait for the reader to drain the tail — the echo callback writes to - # real_stderr, so it must stay open until the drain finishes. + # Flush everything, restore both descriptors so the pipe sees EOF, + # and only then wait for the reader to drain the tail — the echo + # callback writes to real_stderr, so it must stay open until the + # drain finishes. sys.stdout.flush() sys.stderr.flush() flush_cstdio() + os.dup2(real_stdout, 1) os.dup2(real_stderr, 2) reader.join(timeout=5.0) reader.stop() + os.close(real_stdout) os.close(real_stderr) diff --git a/test/runtime/test_log_stream.py b/test/runtime/test_log_stream.py index d6500daf..41df44dc 100644 --- a/test/runtime/test_log_stream.py +++ b/test/runtime/test_log_stream.py @@ -288,13 +288,16 @@ def test_archives_step_bytes_and_echoes_without_markers(self, tmp_path, capfd): with archive_own_step_logs(workspace) as reader: emit_step_marker("begin", step="S", tool="T") os.write(2, b"tool output\n") + os.write(1, b"stdout line\n") emit_step_marker("end", step="S", tool="T") os.write(2, b"unscoped tail\n") assert reader.state.error is None - assert (workspace / "S_T" / "log" / "S.log").read_bytes() == b"tool output\n" + # fd 1 and fd 2 bytes both land in the archive, markers never do. + assert (workspace / "S_T" / "log" / "S.log").read_bytes() == (b"tool output\nstdout line\n") echoed = capfd.readouterr().err assert "tool output" in echoed + assert "stdout line" in echoed assert "unscoped tail" in echoed assert "ECC-STEP" not in echoed From de882056f113cd01acc90a725ff3408065e17f79 Mon Sep 17 00:00:00 2001 From: Emin Date: Wed, 19 Aug 2026 23:22:20 +0800 Subject: [PATCH 39/52] fix(engine): self-archive step logs on direct rerun paths The public rerun helpers (run_from/run_only/run_resume) execute steps in-process with no client to consume the marker stream: step logs stayed empty and raw ECC-STEP frames leaked to the caller's terminal. _run_selected now wraps execution in archive_own_step_logs so direct callers get the same client-side archival as worker runs. A regression drives real marker+byte writes through run_from and asserts the archive contents and a marker-free terminal. --- chipcompiler/engine/rerun.py | 34 ++++++++++++++++++++-------------- test/test_engine_rerun.py | 27 +++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 14 deletions(-) diff --git a/chipcompiler/engine/rerun.py b/chipcompiler/engine/rerun.py index 738051b8..f3827638 100644 --- a/chipcompiler/engine/rerun.py +++ b/chipcompiler/engine/rerun.py @@ -113,21 +113,27 @@ def _run_selected(flow: "EngineFlow", selected: list[tuple[WorkspaceStep, Path]] if flow.engine_db is not None: flow.engine_db.close() + # Direct in-process runs are executor and client in one process: route + # the own fd 1/2 stream through the archiver so step bytes land in + # per-step logs and markers never reach the caller's terminal. + from chipcompiler.runtime.log_stream import archive_own_step_logs + executed = [] - for workspace_step, output_dir in selected: - flow.workspace.logger.log_section( - f"{workspace_step.tool} - begin step - {workspace_step.name}" - ) - _reset_output_dir(output_dir) - flow.init_db_engine_for_step(workspace_step) - state = flow.run_step(workspace_step, rerun=True) - log_flow(workspace=flow.workspace) - flow.workspace.logger.log_section( - f"{workspace_step.tool} - end step - {workspace_step.name}" - ) - if state != StateEnum.Success: - return StepRunResult(ok=False, executed=tuple(executed), failed=workspace_step.name) - executed.append(workspace_step.name) + with archive_own_step_logs(flow.workspace.directory): + for workspace_step, output_dir in selected: + flow.workspace.logger.log_section( + f"{workspace_step.tool} - begin step - {workspace_step.name}" + ) + _reset_output_dir(output_dir) + flow.init_db_engine_for_step(workspace_step) + state = flow.run_step(workspace_step, rerun=True) + log_flow(workspace=flow.workspace) + flow.workspace.logger.log_section( + f"{workspace_step.tool} - end step - {workspace_step.name}" + ) + if state != StateEnum.Success: + return StepRunResult(ok=False, executed=tuple(executed), failed=workspace_step.name) + executed.append(workspace_step.name) return StepRunResult(ok=True, executed=tuple(executed)) diff --git a/test/test_engine_rerun.py b/test/test_engine_rerun.py index bf78c4e7..cb8c0059 100644 --- a/test/test_engine_rerun.py +++ b/test/test_engine_rerun.py @@ -128,6 +128,33 @@ def test_reexecutes_suffix_and_clears_only_executed_outputs(self, monkeypatch, t assert not stale_place.exists() assert not stale_cts.exists() + def test_direct_run_self_archives_step_bytes(self, monkeypatch, tmp_path, capfd): + """In-process rerun routes fd 1/2 through the archiver: step bytes land + in the step log and markers never reach the caller's terminal.""" + import os + + from chipcompiler.runtime.log_stream import emit_step_marker + + flow = _make_run_flow(tmp_path, [("place", "Success"), ("CTS", "Unstart")]) + _write_output(flow, "place") + + def run_step_with_bytes(workspace_step, *, rerun=False): + emit_step_marker("begin", step=workspace_step.name, tool=workspace_step.tool) + os.write(2, f"{workspace_step.name} bytes\n".encode()) + emit_step_marker("end", step=workspace_step.name, tool=workspace_step.tool) + flow.set_state(workspace_step.name, workspace_step.tool, StateEnum.Success) + return StateEnum.Success + + monkeypatch.setattr(flow, "run_step", run_step_with_bytes) + monkeypatch.setattr(flow, "init_db_engine_for_step", lambda step: True) + + result = rerun.run_from(flow, "place") + + assert result.ok + archive = tmp_path / "place_ecc" / "log" / "place.log" + assert archive.read_bytes() == b"place bytes\n" + assert "ECC-STEP" not in capfd.readouterr().err + def test_failure_stops_suffix_and_keeps_downstream_output(self, monkeypatch, tmp_path): flow = _make_run_flow( tmp_path, From 2bff0c171b669f68ef6860a39f0fbdc17138e091 Mon Sep 17 00:00:00 2001 From: Emin Date: Wed, 19 Aug 2026 23:23:01 +0800 Subject: [PATCH 40/52] fix(runtime): reconcile flow state on archive failure and report repair failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two worker failure paths left persisted state inconsistent with the reported result. When the worker completed but the reader archived with errors (or an unmatched begin), the step's Success record survived while the CLI reported failure — resume would skip the step and never recreate the missing log; the error branch now repairs the affected record to Incomplete. And a crash-repair that cannot persist (disk full, permissions) was silently suppressed; both error paths now append the repair failure to the result error instead. Regressions pin the Success downgrade on archive failure and the surfaced repair failure on crash. --- chipcompiler/runtime/worker_operation.py | 29 +++++++-- test/runtime/test_worker_operation.py | 81 ++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 4 deletions(-) diff --git a/chipcompiler/runtime/worker_operation.py b/chipcompiler/runtime/worker_operation.py index 9e43e9bb..6dbd39bb 100644 --- a/chipcompiler/runtime/worker_operation.py +++ b/chipcompiler/runtime/worker_operation.py @@ -9,7 +9,6 @@ import subprocess import sys from collections.abc import Callable -from contextlib import suppress from dataclasses import dataclass, field from pathlib import Path @@ -157,11 +156,24 @@ def run_sequence( error_parts.append("worker did not exit cleanly after shutdown") if error_parts: + # The reported failure must match the persisted state: a step + # left Success in flow.json would be skipped by a later resume + # while its archive is missing or incomplete. + repair_step = log_state.active_step + if repair_step is None and log_state.error is not None and log_state.steps_seen: + repair_step = log_state.steps_seen[-1] + repaired: list[str] = [] + if repair_step is not None and self._flow_json_path.exists(): + try: + repaired = repair_flow_state(self._flow_json_path, active_step=repair_step) + except OSError as exc: + error_parts.append(f"state repair failed: {exc}") return OperationResult( success=False, rpc_result=rpc_result.response if rpc_result else None, exit_code=client.process.returncode if client.process else None, error="; ".join(error_parts), + repaired_steps=repaired, archive_error=log_state.error, log_state=log_state, ) @@ -199,15 +211,20 @@ def _handle_protocol_or_crash( # raised after the begin marker, so flow.json may hold a stale Ongoing # record. Repair it exactly as crash recovery does. repaired: list[str] = [] + error = result.error active_step = log_state.active_step if log_state else None if active_step is not None and self._flow_json_path.exists(): - with suppress(OSError): + try: repaired = repair_flow_state(self._flow_json_path, active_step=active_step) + except OSError as exc: + # A repair that cannot persist must be visible: swallowing it + # would report recovery while the record stays Ongoing. + error = f"{error}; state repair failed: {exc}" return OperationResult( success=False, rpc_result=result.response, exit_code=client.process.returncode if client.process else None, - error=result.error, + error=error, repaired_steps=repaired, archive_error=log_state.error if log_state else None, log_state=log_state, @@ -272,8 +289,12 @@ def _handle_crash( active_step = log_state.active_step if active_step is not None and self._flow_json_path.exists(): - with suppress(OSError): + try: repaired = repair_flow_state(self._flow_json_path, active_step=active_step) + except OSError as exc: + # A repair that cannot persist must be visible: swallowing it + # would report recovery while the record stays Ongoing. + error = f"{error}; state repair failed: {exc}" return OperationResult( success=False, diff --git a/test/runtime/test_worker_operation.py b/test/runtime/test_worker_operation.py index fd591434..75efb5fd 100644 --- a/test/runtime/test_worker_operation.py +++ b/test/runtime/test_worker_operation.py @@ -261,6 +261,42 @@ def test_worker_crash_triggers_repair(self, tmp_path): repaired_data = json.loads(flow_json.read_text()) assert repaired_data["steps"][0]["state"] == "Incomplete" + def test_crash_repair_failure_is_reported(self, tmp_path, monkeypatch): + """A repair that cannot persist must surface in the result error.""" + crash_script = tmp_path / "crash_after_open.py" + crash_script.write_text( + _RPC_HELPERS + + textwrap.dedent("""\ + req = read_request() # hello + send_response({"jsonrpc": "2.0", "result": {"version": 1}, "id": req["id"]}) + req = read_request() # workspace.open + send_response({"jsonrpc": "2.0", "result": {"workspaceId": "x"}, "id": req["id"]}) + os.write(2, make_marker("begin", "Synthesis", "yosys")) + os._exit(1) + """) + ) + flow_json = tmp_path / "flow.json" + data = {"steps": [{"name": "Synthesis", "tool": "yosys", "state": "Ongoing"}]} + flow_json.write_text(json.dumps(data)) + + def failing_repair(*args, **kwargs): + raise OSError("disk full") + + monkeypatch.setattr( + "chipcompiler.runtime.worker_operation.repair_flow_state", failing_repair + ) + op = RunOperation( + workspace_dir=tmp_path, + flow_json_path=flow_json, + worker_argv=[sys.executable, str(crash_script)], + ) + result = op.run("flow.run", {"workspace_id": "test"}) + assert result.success is False + assert result.repaired_steps == [] + assert "state repair failed" in result.error + # The record is left as it was — the failure is reported, not hidden. + assert json.loads(flow_json.read_text())["steps"][0]["state"] == "Ongoing" + def test_worker_crash_no_flow_json_no_repair(self, tmp_path): script = _RPC_HELPERS + textwrap.dedent("""\ req = read_request() # hello @@ -350,6 +386,51 @@ def bad_resolver(step: str, tool: str): assert result.archive_error is not None assert "archive error" in result.error + def test_archive_error_reconciles_the_success_record(self, tmp_path): + """An archive failure must not leave flow.json claiming Success.""" + script = tmp_path / "server_with_markers.py" + script.write_text( + _RPC_HELPERS + + textwrap.dedent("""\ + req = read_request() # hello + send_response({"jsonrpc": "2.0", "result": {"version": 1}, "id": req["id"]}) + req = read_request() # workspace.open + send_response({"jsonrpc": "2.0", "result": {"workspaceId": "ws1"}, "id": req["id"]}) + os.write(2, make_marker("begin", "Synthesis", "yosys")) + os.write(2, b'Synthesizing...\\n') + os.write(2, make_marker("end", "Synthesis", "yosys")) + req = read_request() # flow.run + send_response({"jsonrpc": "2.0", "result": {"steps": ["syn"]}, "id": req["id"]}) + req = read_request() # rpc.shutdown + send_response({"jsonrpc": "2.0", "result": {"ok": True}, "id": req["id"]}) + """) + ) + + blocker = tmp_path / "not_a_dir" + blocker.write_text("regular file") + + def bad_resolver(step: str, tool: str): + return blocker / "sub" / f"{step}.log" + + flow_json = tmp_path / "flow.json" + flow_json.write_text( + json.dumps({"steps": [{"name": "Synthesis", "tool": "yosys", "state": "Success"}]}) + ) + op = RunOperation( + workspace_dir=tmp_path, + flow_json_path=flow_json, + worker_argv=[sys.executable, str(script)], + log_path_resolver=bad_resolver, + ) + result = op.run("flow.run", {"workspace_id": "test"}) + assert result.success is False + assert result.archive_error is not None + # The persisted record is downgraded so a later resume reruns the step + # and recreates the missing log instead of skipping it. + assert result.repaired_steps == ["Synthesis"] + repaired = json.loads(flow_json.read_text()) + assert repaired["steps"][0]["state"] == "Incomplete" + def test_stderr_archived_to_step_log(self, tmp_path): script = tmp_path / "server_with_markers.py" script.write_text( From 599a975cac64ea38d8471497734cbade0e1f4b9c Mon Sep 17 00:00:00 2001 From: Emin Date: Thu, 20 Aug 2026 00:31:03 +0800 Subject: [PATCH 41/52] fix(runtime): align archive layout with builders and propagate archive failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - step_log_archive_resolver mirrors the sizer builder's sanitized directory (Timing optimization -> timing_optimization_sizer), so worker success no longer writes to a directory the built step never owned. - Every remaining direct EngineFlow entry point (integration conftest, both gcd examples) now runs inside archive_own_step_logs; the conftest's obsolete fd save/restore workaround is removed. - The reader records error_step at the first archive error, and all three worker failure paths (clean-shutdown errors, RPC error, crash) reconcile through one _reconcile_step_state helper — a Success record never survives a missing or incomplete archive. - In-process rerun paths (engine.rerun helpers, agent candidate reruns) now fail and downgrade the record when archival fails or a begin is unmatched, instead of reporting success over a missing log. - Rerun preparation moves to runtime/rerun_prepare.py, keeping workspace_api.py an adapter per the module-size rule. --- agent/workspace_api.py | 15 +- chipcompiler/engine/rerun.py | 25 ++- chipcompiler/runtime/log_stream.py | 16 +- chipcompiler/runtime/rerun_prepare.py | 194 +++++++++++++++++++ chipcompiler/runtime/worker_operation.py | 67 ++++--- chipcompiler/runtime/workspace_api.py | 194 +------------------ docs/examples/gcd/README.cn.md | 7 +- docs/examples/gcd/ics55flow_with_filelist.py | 6 +- test/integration/conftest.py | 27 +-- test/runtime/test_log_stream_targets.py | 10 + test/test_engine_rerun.py | 31 +++ 11 files changed, 345 insertions(+), 247 deletions(-) create mode 100644 chipcompiler/runtime/rerun_prepare.py diff --git a/agent/workspace_api.py b/agent/workspace_api.py index 63df9baf..84a38e46 100644 --- a/agent/workspace_api.py +++ b/agent/workspace_api.py @@ -3,6 +3,7 @@ from hashlib import sha256 from pathlib import Path +from chipcompiler.data import StateEnum from chipcompiler.runtime.requests import WorkspaceIdRequest from chipcompiler.runtime.workspace_api import ( RuntimeApiError, @@ -268,8 +269,20 @@ def _run_candidate_step(flow, step) -> None: # In-process execution is still executor+client in one process: route the # own fd-2 stream through the reader so markers are consumed and the # step's bytes land in its archive (echoed to the real stderr). - with archive_own_step_logs(flow.workspace.directory): + with archive_own_step_logs(flow.workspace.directory) as reader: state = flow.run_step(step, rerun=True) + # An archive failure or unmatched begin must not report success while the + # step's log is missing; downgrade so a later rerun rebuilds it. + if reader.state.error is not None or reader.state.active_step is not None: + record = flow.get_step(step.name, step.tool) + if record is not None: + record["state"] = StateEnum.Imcomplete.value + flow.save() + raise RuntimeApiError( + "command_failed", + f"candidate rerun step {step.name} log archival failed: " + f"{reader.state.error or 'unmatched begin marker'}", + ) if _state_value(state) != "Success": raise RuntimeApiError( "command_failed", diff --git a/chipcompiler/engine/rerun.py b/chipcompiler/engine/rerun.py index f3827638..8088a959 100644 --- a/chipcompiler/engine/rerun.py +++ b/chipcompiler/engine/rerun.py @@ -119,7 +119,8 @@ def _run_selected(flow: "EngineFlow", selected: list[tuple[WorkspaceStep, Path]] from chipcompiler.runtime.log_stream import archive_own_step_logs executed = [] - with archive_own_step_logs(flow.workspace.directory): + failed = None + with archive_own_step_logs(flow.workspace.directory) as reader: for workspace_step, output_dir in selected: flow.workspace.logger.log_section( f"{workspace_step.tool} - begin step - {workspace_step.name}" @@ -132,8 +133,28 @@ def _run_selected(flow: "EngineFlow", selected: list[tuple[WorkspaceStep, Path]] f"{workspace_step.tool} - end step - {workspace_step.name}" ) if state != StateEnum.Success: - return StepRunResult(ok=False, executed=tuple(executed), failed=workspace_step.name) + failed = workspace_step.name + break executed.append(workspace_step.name) + + # The reader drained at context exit. An archive failure or an unmatched + # begin must not leave a Success record whose log is missing: downgrade + # the affected step so a later resume reruns it and rebuilds the archive. + archive_error = reader.state.error + unmatched = reader.state.active_step + if archive_error is not None or unmatched is not None: + target = reader.state.error_step or unmatched + if target is None and executed: + target = executed[-1] + if target is not None: + for record in flow.workspace.flow.data.get("steps", []): + if record.get("name") == target: + record["state"] = StateEnum.Imcomplete.value + flow.save() + break + return StepRunResult(ok=False, executed=tuple(executed), failed=failed or target) + if failed is not None: + return StepRunResult(ok=False, executed=tuple(executed), failed=failed) return StepRunResult(ok=True, executed=tuple(executed)) diff --git a/chipcompiler/runtime/log_stream.py b/chipcompiler/runtime/log_stream.py index 059563c9..a794b466 100644 --- a/chipcompiler/runtime/log_stream.py +++ b/chipcompiler/runtime/log_stream.py @@ -55,7 +55,15 @@ def step_log_archive_resolver(workspace_dir) -> Callable[[str, str], Path]: base = Path(workspace_dir) def resolve(step: str, tool: str) -> Path: - return base / f"{step}_{tool}" / "log" / f"{step}.log" + # Mirror the step-directory layout the builders create: the sizer + # builder sanitizes its directory name (whitespace runs become + # underscores, lowercased) while the other builders use the raw + # "_" form. + if tool == "sizer": + directory = f"{'_'.join(step.split()).lower()}_sizer" + else: + directory = f"{step}_{tool}" + return base / directory / "log" / f"{step}.log" return resolve @@ -96,6 +104,9 @@ class LogStreamState: bytes_archived: int = 0 steps_seen: list[str] = field(default_factory=list) error: Exception | None = None + # The step being archived when the first error was recorded, so failure + # paths can reconcile exactly that record even after its end marker. + error_step: str | None = None class LogStreamReader: @@ -137,6 +148,7 @@ def state(self) -> LogStreamState: def _record_error(self, exc: Exception) -> None: if self._state.error is None: self._state.error = exc + self._state.error_step = self._state.active_step def start(self) -> None: self._thread = threading.Thread(target=self._drain_loop, name="ecc-log-reader", daemon=True) @@ -168,7 +180,7 @@ def _drain_loop(self) -> None: if buf: self._emit_data(buf) except Exception as exc: - self._state.error = exc + self._record_error(exc) finally: self._close_archive() diff --git a/chipcompiler/runtime/rerun_prepare.py b/chipcompiler/runtime/rerun_prepare.py new file mode 100644 index 00000000..decabca8 --- /dev/null +++ b/chipcompiler/runtime/rerun_prepare.py @@ -0,0 +1,194 @@ +"""Rerun preparation: atomic record invalidation plus artifact cleanup. + +Extracted from workspace_api.py so the state-transition machinery lives in a +focused module and the API class stays an adapter. `prepare_steps_for_rerun` +is one all-or-nothing transition: validate artifact directories, snapshot +affected flow records, apply the target reset plus dependent state +invalidation, persist once, and only then delete the target's artifacts. +""" + +import shutil +from pathlib import Path + +from chipcompiler.utility.path import path_is_within + + +def _runtime_api_error(message: str): + # Local import: workspace_api imports this module, so the shared error + # type is resolved lazily to keep the dependency one-directional. + from chipcompiler.runtime.workspace_api import RuntimeApiError + + return RuntimeApiError("command_failed", message) + + +def rerun_affected_steps(engine_flow, workspace_step, *, reset_dependents: bool): + if not reset_dependents: + return [workspace_step] + workspace_steps = list(getattr(engine_flow, "workspace_steps", [])) + try: + start_index = workspace_steps.index(workspace_step) + except ValueError: + return [workspace_step] + return workspace_steps[start_index:] + + +def prepare_step_for_rerun(workspace, engine_flow, workspace_step) -> None: + prepare_steps_for_rerun(workspace, engine_flow, [workspace_step]) + + +def prepare_steps_for_rerun( + workspace, + engine_flow, + workspace_steps, + *, + invalidate_only_steps=(), +) -> None: + workspace_root = Path(workspace.directory).resolve() + unique_steps = [] + known_step_keys = set() + for workspace_step in workspace_steps: + key = ( + str(getattr(workspace_step, "name", "")), + str(getattr(workspace_step, "tool", "")), + ) + if key in known_step_keys: + continue + known_step_keys.add(key) + unique_steps.append(workspace_step) + + artifact_directories = [] + known_directories = set() + for workspace_step in unique_steps: + for directory in _step_artifact_dirs(workspace_step): + resolved = _validate_step_artifact_dir( + workspace_root, + directory, + workspace_step.name, + ) + if resolved in known_directories: + continue + known_directories.add(resolved) + artifact_directories.append((workspace_step.name, directory)) + + # One all-or-nothing state transition: apply the full reset for the + # rerun targets and the state-only invalidation for their dependents, + # then persist once. A failed save restores the record snapshots so + # neither disk nor the live session is left half-mutated, and no + # artifact is deleted before the new states are durable. + snapshots = [] + for workspace_step in (*unique_steps, *invalidate_only_steps): + record = engine_flow.get_step(workspace_step.name, workspace_step.tool) + if record is not None: + snapshots.append((record, dict(record))) + for workspace_step in unique_steps: + record = engine_flow.get_step(workspace_step.name, workspace_step.tool) + if record is None: + continue + record.update( + { + "state": "Unstart", + "runtime": "", + "peak memory (mb)": 0, + "info": {}, + } + ) + for workspace_step in invalidate_only_steps: + record = engine_flow.get_step(workspace_step.name, workspace_step.tool) + if record is None: + continue + record.update({"state": "Unstart", "runtime": "", "peak memory (mb)": 0}) + if snapshots and not engine_flow.save(): + for record, snapshot in snapshots: + record.clear() + record.update(snapshot) + raise _runtime_api_error("failed to persist step invalidation; refusing to modify outputs") + + for step_name, directory in artifact_directories: + _clear_step_artifact_dir( + workspace_root, + directory, + step_name, + ) + + for workspace_step in unique_steps: + _reset_step_subflow(workspace_step) + _reset_step_checklist(workspace_step) + + +def _reset_step_subflow(workspace_step) -> None: + from chipcompiler.utility import json_read, json_write + + subflow = getattr(workspace_step, "subflow", None) + path = getattr(subflow, "path", None) + if not path: + return + subflow_path = Path(path) + data = json_read(subflow_path) + steps = data.get("steps", []) if isinstance(data, dict) else [] + if not isinstance(steps, list): + return + for step in steps: + if not isinstance(step, dict): + continue + step.update( + { + "state": "Unstart", + "runtime": "", + "peak memory (mb)": 0, + "info": {}, + } + ) + json_write(subflow_path, {"path": str(subflow_path), "steps": steps}) + subflow.steps = steps + + +def _reset_step_checklist(workspace_step) -> None: + from chipcompiler.data import Checklist + + checklist = getattr(workspace_step, "checklist", None) + path = getattr(checklist, "path", None) + if not path: + return + checklist_path = Path(path) + Checklist(checklist_path).replace([]) + checklist.checklist = [] + + +def _step_artifact_dirs(step) -> tuple[Path, ...]: + directories: list[Path] = [] + for field in ("output", "data", "feature", "analysis", "report", "log"): + value = getattr(step, field, {}) + directory = value.get("dir") if isinstance(value, dict) else getattr(value, "dir", None) + if directory: + directories.append(Path(directory)) + return tuple(dict.fromkeys(directories)) + + +def _clear_step_artifact_dir( + workspace_root: Path, + directory: Path, + step_name: str, +) -> None: + _validate_step_artifact_dir(workspace_root, directory, step_name) + if directory.exists(): + if not directory.is_dir(): + raise _runtime_api_error(f"step artifact is not a directory: {step_name}") + shutil.rmtree(directory) + directory.mkdir(parents=True, exist_ok=True) + + +def _validate_step_artifact_dir( + workspace_root: Path, + directory: Path, + step_name: str, +) -> Path: + resolved = directory.resolve() + if ( + resolved == workspace_root + or not path_is_within(resolved, workspace_root) + or directory.is_symlink() + ): + raise _runtime_api_error(f"step artifact escapes workspace: {step_name}") + if directory.exists() and not directory.is_dir(): + raise _runtime_api_error(f"step artifact is not a directory: {step_name}") + return resolved diff --git a/chipcompiler/runtime/worker_operation.py b/chipcompiler/runtime/worker_operation.py index 6dbd39bb..a63e83cd 100644 --- a/chipcompiler/runtime/worker_operation.py +++ b/chipcompiler/runtime/worker_operation.py @@ -159,15 +159,9 @@ def run_sequence( # The reported failure must match the persisted state: a step # left Success in flow.json would be skipped by a later resume # while its archive is missing or incomplete. - repair_step = log_state.active_step - if repair_step is None and log_state.error is not None and log_state.steps_seen: - repair_step = log_state.steps_seen[-1] - repaired: list[str] = [] - if repair_step is not None and self._flow_json_path.exists(): - try: - repaired = repair_flow_state(self._flow_json_path, active_step=repair_step) - except OSError as exc: - error_parts.append(f"state repair failed: {exc}") + repaired, repair_error = self._reconcile_step_state(log_state) + if repair_error is not None: + error_parts.append(repair_error) return OperationResult( success=False, rpc_result=rpc_result.response if rpc_result else None, @@ -210,16 +204,13 @@ def _handle_protocol_or_crash( # A live-worker RPC error can still leave a step unmatched: the flow # raised after the begin marker, so flow.json may hold a stale Ongoing # record. Repair it exactly as crash recovery does. - repaired: list[str] = [] + # A live-worker RPC error can leave an unmatched Ongoing step; an + # archive failure on an already-ended step leaves a stale Success. + # Both reconcile through the same repair path. + repaired, repair_error = self._reconcile_step_state(log_state) error = result.error - active_step = log_state.active_step if log_state else None - if active_step is not None and self._flow_json_path.exists(): - try: - repaired = repair_flow_state(self._flow_json_path, active_step=active_step) - except OSError as exc: - # A repair that cannot persist must be visible: swallowing it - # would report recovery while the record stays Ongoing. - error = f"{error}; state repair failed: {exc}" + if repair_error is not None: + error = f"{error}; {repair_error}" return OperationResult( success=False, rpc_result=result.response, @@ -259,6 +250,29 @@ def _graceful_shutdown(self, client: WorkerClient) -> bool: return proc.returncode == 0 + def _reconcile_step_state( + self, log_state: LogStreamState | None + ) -> tuple[list[str], str | None]: + """Repair the step whose marker/archive evidence failed, if any. + + Picks the unmatched active step first, then the step being archived + when the first reader error fired, then the last seen step on an + archive error. Returns (repaired_steps, error_text): reconciling to + Incomplete keeps a later resume from trusting a stale Success whose + log is missing or incomplete. + """ + if log_state is None: + return [], None + step = log_state.active_step or log_state.error_step + if step is None and log_state.error is not None and log_state.steps_seen: + step = log_state.steps_seen[-1] + if step is None or not self._flow_json_path.exists(): + return [], None + try: + return repair_flow_state(self._flow_json_path, active_step=step), None + except OSError as exc: + return [], f"state repair failed: {exc}" + def _handle_crash( self, client: WorkerClient, @@ -268,9 +282,7 @@ def _handle_crash( """Crash recovery: terminate, drain, repair, return failure.""" exit_code: int | None = None signal_number: int | None = None - repaired: list[str] = [] log_state: LogStreamState | None = None - active_step: str | None = None try: client.terminate() @@ -286,15 +298,12 @@ def _handle_crash( reader.join(timeout=2.0) reader.stop() log_state = reader.state - active_step = log_state.active_step - - if active_step is not None and self._flow_json_path.exists(): - try: - repaired = repair_flow_state(self._flow_json_path, active_step=active_step) - except OSError as exc: - # A repair that cannot persist must be visible: swallowing it - # would report recovery while the record stays Ongoing. - error = f"{error}; state repair failed: {exc}" + + # The crashed step (still active) or a step whose archive failed + # before the crash both reconcile to Incomplete through one path. + repaired, repair_error = self._reconcile_step_state(log_state) + if repair_error is not None: + error = f"{error}; {repair_error}" return OperationResult( success=False, diff --git a/chipcompiler/runtime/workspace_api.py b/chipcompiler/runtime/workspace_api.py index 06cc50d3..e8a306fd 100644 --- a/chipcompiler/runtime/workspace_api.py +++ b/chipcompiler/runtime/workspace_api.py @@ -39,6 +39,7 @@ WorkspaceOpenRequest, WorkspaceSyncConfigRequest, ) +from chipcompiler.runtime.rerun_prepare import prepare_steps_for_rerun, rerun_affected_steps from chipcompiler.runtime.sessions import ( LayoutEditSession, WorkspaceSession, @@ -333,7 +334,7 @@ def run_step(session: WorkspaceSession) -> dict: if workspace_step is None: raise RuntimeApiError("command_failed", f"step not found: {request.step}") if request.rerun: - affected_steps = self._rerun_affected_steps( + affected_steps = rerun_affected_steps( engine_flow, workspace_step, reset_dependents=reset_dependents or request.invalidate_dependents, @@ -344,7 +345,7 @@ def run_step(session: WorkspaceSession) -> dict: prepare_steps, invalidate_steps = affected_steps[:1], affected_steps[1:] else: prepare_steps, invalidate_steps = affected_steps, [] - self._prepare_steps_for_rerun( + prepare_steps_for_rerun( session.workspace, engine_flow, prepare_steps, @@ -914,17 +915,6 @@ def _prepare_workspace_for_rerun( preserve_user_inputs=preserve_user_inputs, ) - @staticmethod - def _rerun_affected_steps(engine_flow, workspace_step, *, reset_dependents: bool): - if not reset_dependents: - return [workspace_step] - workspace_steps = list(getattr(engine_flow, "workspace_steps", [])) - try: - start_index = workspace_steps.index(workspace_step) - except ValueError: - return [workspace_step] - return workspace_steps[start_index:] - @staticmethod def _notify_rerun_prepared( observer, @@ -942,184 +932,6 @@ def _notify_rerun_prepared( target_step=target_step, ) - @staticmethod - def _prepare_step_for_rerun(workspace, engine_flow, workspace_step) -> None: - WorkspaceRuntimeApi._prepare_steps_for_rerun( - workspace, - engine_flow, - [workspace_step], - ) - - @staticmethod - def _prepare_steps_for_rerun( - workspace, - engine_flow, - workspace_steps, - *, - invalidate_only_steps=(), - ) -> None: - workspace_root = Path(workspace.directory).resolve() - unique_steps = [] - known_step_keys = set() - for workspace_step in workspace_steps: - key = ( - str(getattr(workspace_step, "name", "")), - str(getattr(workspace_step, "tool", "")), - ) - if key in known_step_keys: - continue - known_step_keys.add(key) - unique_steps.append(workspace_step) - - artifact_directories = [] - known_directories = set() - for workspace_step in unique_steps: - for directory in WorkspaceRuntimeApi._step_artifact_dirs(workspace_step): - resolved = WorkspaceRuntimeApi._validate_step_artifact_dir( - workspace_root, - directory, - workspace_step.name, - ) - if resolved in known_directories: - continue - known_directories.add(resolved) - artifact_directories.append((workspace_step.name, directory)) - - # One all-or-nothing state transition: apply the full reset for the - # rerun targets and the state-only invalidation for their dependents, - # then persist once. A failed save restores the record snapshots so - # neither disk nor the live session is left half-mutated, and no - # artifact is deleted before the new states are durable. - snapshots = [] - for workspace_step in (*unique_steps, *invalidate_only_steps): - record = engine_flow.get_step(workspace_step.name, workspace_step.tool) - if record is not None: - snapshots.append((record, dict(record))) - for workspace_step in unique_steps: - record = engine_flow.get_step(workspace_step.name, workspace_step.tool) - if record is None: - continue - record.update( - { - "state": "Unstart", - "runtime": "", - "peak memory (mb)": 0, - "info": {}, - } - ) - for workspace_step in invalidate_only_steps: - record = engine_flow.get_step(workspace_step.name, workspace_step.tool) - if record is None: - continue - record.update({"state": "Unstart", "runtime": "", "peak memory (mb)": 0}) - if snapshots and not engine_flow.save(): - for record, snapshot in snapshots: - record.clear() - record.update(snapshot) - raise RuntimeApiError( - "command_failed", - "failed to persist step invalidation; refusing to modify outputs", - ) - - for step_name, directory in artifact_directories: - WorkspaceRuntimeApi._clear_step_artifact_dir( - workspace_root, - directory, - step_name, - ) - - for workspace_step in unique_steps: - WorkspaceRuntimeApi._reset_step_subflow(workspace_step) - WorkspaceRuntimeApi._reset_step_checklist(workspace_step) - - @staticmethod - def _reset_step_subflow(workspace_step) -> None: - from chipcompiler.utility import json_read, json_write - - subflow = getattr(workspace_step, "subflow", None) - path = getattr(subflow, "path", None) - if not path: - return - subflow_path = Path(path) - data = json_read(subflow_path) - steps = data.get("steps", []) if isinstance(data, dict) else [] - if not isinstance(steps, list): - return - for step in steps: - if not isinstance(step, dict): - continue - step.update( - { - "state": "Unstart", - "runtime": "", - "peak memory (mb)": 0, - "info": {}, - } - ) - json_write(subflow_path, {"path": str(subflow_path), "steps": steps}) - subflow.steps = steps - - @staticmethod - def _reset_step_checklist(workspace_step) -> None: - from chipcompiler.data import Checklist - - checklist = getattr(workspace_step, "checklist", None) - path = getattr(checklist, "path", None) - if not path: - return - checklist_path = Path(path) - Checklist(checklist_path).replace([]) - checklist.checklist = [] - - @staticmethod - def _step_artifact_dirs(step) -> tuple[Path, ...]: - directories: list[Path] = [] - for field in ("output", "data", "feature", "analysis", "report", "log"): - value = getattr(step, field, {}) - directory = value.get("dir") if isinstance(value, dict) else getattr(value, "dir", None) - if directory: - directories.append(Path(directory)) - return tuple(dict.fromkeys(directories)) - - @staticmethod - def _clear_step_artifact_dir( - workspace_root: Path, - directory: Path, - step_name: str, - ) -> None: - WorkspaceRuntimeApi._validate_step_artifact_dir(workspace_root, directory, step_name) - if directory.exists(): - if not directory.is_dir(): - raise RuntimeApiError( - "command_failed", - f"step artifact is not a directory: {step_name}", - ) - shutil.rmtree(directory) - directory.mkdir(parents=True, exist_ok=True) - - @staticmethod - def _validate_step_artifact_dir( - workspace_root: Path, - directory: Path, - step_name: str, - ) -> Path: - resolved = directory.resolve() - if ( - resolved == workspace_root - or not path_is_within(resolved, workspace_root) - or directory.is_symlink() - ): - raise RuntimeApiError( - "command_failed", - f"step artifact escapes workspace: {step_name}", - ) - if directory.exists() and not directory.is_dir(): - raise RuntimeApiError( - "command_failed", - f"step artifact is not a directory: {step_name}", - ) - return resolved - def _layout_edit_begin_result(edit_session: LayoutEditSession, *, reused: bool) -> dict: return { diff --git a/docs/examples/gcd/README.cn.md b/docs/examples/gcd/README.cn.md index 8ed8f221..2db9b47d 100644 --- a/docs/examples/gcd/README.cn.md +++ b/docs/examples/gcd/README.cn.md @@ -114,7 +114,12 @@ if not engine_flow.has_init(): # 创建步骤工作空间并运行 engine_flow.create_step_workspaces() -engine_flow.run_steps() +# 进程内直接运行时,用 archive_own_step_logs 把本进程的 fd 1/2 交给客户端归档器, +# 每个步骤的日志文件照常生成,marker 不会泄漏到终端。 +from chipcompiler.runtime.log_stream import archive_own_step_logs + +with archive_own_step_logs(workspace.directory): + engine_flow.run_steps() ``` 定义的流程如下: diff --git a/docs/examples/gcd/ics55flow_with_filelist.py b/docs/examples/gcd/ics55flow_with_filelist.py index b7a505b6..d661bba6 100644 --- a/docs/examples/gcd/ics55flow_with_filelist.py +++ b/docs/examples/gcd/ics55flow_with_filelist.py @@ -21,6 +21,7 @@ get_pdk, ) from chipcompiler.engine import EngineFlow +from chipcompiler.runtime.log_stream import archive_own_step_logs # Setup paths workspace_dir = "./gcd_workspace_with_filelist" @@ -158,7 +159,10 @@ # - Runs remaining steps via subprocess for isolation # - Updates state and runtime after each step # - Stops if any step fails (state = Incomplete) -engine_flow.run_steps() +# archive_own_step_logs routes this process's fd 1/2 through the client-side +# archiver so each step's log file is written and markers stay off the terminal. +with archive_own_step_logs(workspace_dir): + engine_flow.run_steps() print("\nFlow completed successfully!") print(f"Check logs and outputs in: {workspace_dir}") diff --git a/test/integration/conftest.py b/test/integration/conftest.py index a5fda155..98c3db27 100644 --- a/test/integration/conftest.py +++ b/test/integration/conftest.py @@ -1,13 +1,9 @@ -import os -import sys -from contextlib import suppress from pathlib import Path import pytest from chipcompiler.data import create_workspace, get_design_parameters, get_pdk from chipcompiler.engine import EngineDB, EngineFlow -from chipcompiler.utility.log import flush_cstdio REPO_ROOT = Path(__file__).resolve().parents[2] @@ -52,23 +48,14 @@ def run_workspace_flow( engine_flow.create_step_workspaces() - # EngineFlow.run_step dup2's fd 1/2 into each step's log file and never - # restores them. Save/restore around the flow so pytest's own reporting - # is not swallowed by the last step's log. - saved_fds = (os.dup(1), os.dup(2)) - saved_streams = (sys.stdout, sys.stderr) - try: + # The engine emits step markers on fd 1/2 instead of writing step logs; + # route the process's own stream through the client-side archiver so the + # integration run still produces per-step logs without leaking markers + # into pytest's own output. + from chipcompiler.runtime.log_stream import archive_own_step_logs + + with archive_own_step_logs(workspace.directory): return engine_flow.run_steps() - finally: - with suppress(Exception): - sys.stdout.flush() - sys.stderr.flush() - flush_cstdio() - os.dup2(saved_fds[0], 1) - os.dup2(saved_fds[1], 2) - os.close(saved_fds[0]) - os.close(saved_fds[1]) - sys.stdout, sys.stderr = saved_streams @pytest.fixture diff --git a/test/runtime/test_log_stream_targets.py b/test/runtime/test_log_stream_targets.py index 0cabca4f..4dee57a2 100644 --- a/test/runtime/test_log_stream_targets.py +++ b/test/runtime/test_log_stream_targets.py @@ -189,6 +189,16 @@ def test_resolver_produces_canonical_step_log_path(self, tmp_path): tmp_path / "Floorplan_ecc" / "log" / "Floorplan.log" ) + def test_resolver_mirrors_the_sizer_builder_layout(self, tmp_path): + """The sizer builder sanitizes its step directory; the archive must + land in the same directory the built step owns.""" + from chipcompiler.runtime.log_stream import step_log_archive_resolver + + resolver = step_log_archive_resolver(tmp_path) + assert resolver("Timing optimization", "sizer") == ( + tmp_path / "timing_optimization_sizer" / "log" / "Timing optimization.log" + ) + class TestOnStepEvent: def test_fires_on_matched_begin_and_end(self, tmp_path): diff --git a/test/test_engine_rerun.py b/test/test_engine_rerun.py index cb8c0059..195b092f 100644 --- a/test/test_engine_rerun.py +++ b/test/test_engine_rerun.py @@ -155,6 +155,37 @@ def run_step_with_bytes(workspace_step, *, rerun=False): assert archive.read_bytes() == b"place bytes\n" assert "ECC-STEP" not in capfd.readouterr().err + def test_archive_failure_fails_and_downgrades_the_record(self, monkeypatch, tmp_path, capfd): + """An in-process archive failure must not leave ok=True over a Success + record with a missing log.""" + import os + + from chipcompiler.runtime.log_stream import emit_step_marker + + flow = _make_run_flow(tmp_path, [("place", "Success")]) + _write_output(flow, "place") + + def run_step_with_markers(workspace_step, *, rerun=False): + emit_step_marker("begin", step=workspace_step.name, tool=workspace_step.tool) + os.write(2, b"bytes\n") + emit_step_marker("end", step=workspace_step.name, tool=workspace_step.tool) + flow.set_state(workspace_step.name, workspace_step.tool, StateEnum.Success) + return StateEnum.Success + + monkeypatch.setattr(flow, "run_step", run_step_with_markers) + monkeypatch.setattr(flow, "init_db_engine_for_step", lambda step: True) + + # Make the archive path unopenable: a regular file where the step's + # log directory must be created (the output dir stays intact). + (tmp_path / "place_ecc" / "log").write_text("regular file") + + result = rerun.run_from(flow, "place") + + assert result.ok is False + assert result.failed == "place" + assert _flow_states(flow) == [StateEnum.Imcomplete.value] + assert "ECC-STEP" not in capfd.readouterr().err + def test_failure_stops_suffix_and_keeps_downstream_output(self, monkeypatch, tmp_path): flow = _make_run_flow( tmp_path, From 8d30d295dcf14a2a2ea5c494effa3c6b421844de Mon Sep 17 00:00:00 2001 From: Emin Date: Thu, 20 Aug 2026 00:59:59 +0800 Subject: [PATCH 42/52] fix(runtime): stream live bytes with read1 and cover the last bare example - LogStreamReader drained pipes with read(8192), which blocks until the buffer fills or EOF: steps emitting less than 8 KiB showed no live progress until they exited. read1 delivers whatever the pipe currently holds (with a plain-read fallback); a pipe-based regression proves a short line is delivered while the writer stays open. - docs/examples/gcd/ics55flow.py was the last shipped entry point calling run_steps() bare; it now runs inside archive_own_step_logs like the other direct-run examples. --- chipcompiler/runtime/log_stream.py | 6 +++++- docs/examples/gcd/ics55flow.py | 6 +++++- test/runtime/test_log_stream.py | 23 +++++++++++++++++++++++ 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/chipcompiler/runtime/log_stream.py b/chipcompiler/runtime/log_stream.py index a794b466..5a2f94e6 100644 --- a/chipcompiler/runtime/log_stream.py +++ b/chipcompiler/runtime/log_stream.py @@ -169,10 +169,14 @@ def stop(self) -> None: self._stop.set() def _drain_loop(self) -> None: + # read1 returns whatever the pipe currently holds; read(8192) would + # block until the buffer fills or EOF, stalling live progress for + # steps that emit less than 8 KiB while still running. + read_chunk = getattr(self._stderr, "read1", None) or self._stderr.read buf = b"" try: while not self._stop.is_set(): - chunk = self._stderr.read(8192) + chunk = read_chunk(8192) if not chunk: break buf += chunk diff --git a/docs/examples/gcd/ics55flow.py b/docs/examples/gcd/ics55flow.py index 5acfa46b..1ab8deaf 100644 --- a/docs/examples/gcd/ics55flow.py +++ b/docs/examples/gcd/ics55flow.py @@ -6,6 +6,7 @@ get_pdk, ) from chipcompiler.engine import EngineFlow +from chipcompiler.runtime.log_stream import archive_own_step_logs # Setup paths workspace_dir = "./gcd_workspace" @@ -85,4 +86,7 @@ # Create step workspaces and run engine_flow.create_step_workspaces() -engine_flow.run_steps() +# archive_own_step_logs routes this process's fd 1/2 through the client-side +# archiver so each step's log file is written and markers stay off the terminal. +with archive_own_step_logs(workspace_dir): + engine_flow.run_steps() diff --git a/test/runtime/test_log_stream.py b/test/runtime/test_log_stream.py index 41df44dc..65bd4b12 100644 --- a/test/runtime/test_log_stream.py +++ b/test/runtime/test_log_stream.py @@ -270,6 +270,29 @@ def resolver(step, tool): assert reader.state.steps_seen == ["A"] +class TestLiveStreaming: + def test_short_output_flows_without_waiting_for_8kib(self): + """read1 delivers available bytes immediately; read(8192) would block + until the buffer fills or EOF and stall live progress.""" + import os + import time + + read_fd, write_fd = os.pipe() + received = [] + reader = LogStreamReader(os.fdopen(read_fd, "rb"), on_output=received.append) + reader.start() + try: + os.write(write_fd, b"short line\n") + deadline = time.monotonic() + 2.0 + while not received and time.monotonic() < deadline: + time.sleep(0.01) + assert received == [b"short line\n"] + finally: + os.close(write_fd) + reader.join(timeout=5) + reader.stop() + + class TestArchiveOwnStepLogs: """In-process executor runs self-archive through the fd-2 pipe.""" From 0e51b0f40854c0c7bfbf822ffe95348e6ee9b4ff Mon Sep 17 00:00:00 2001 From: Emin Date: Thu, 20 Aug 2026 01:31:18 +0800 Subject: [PATCH 43/52] fix(runtime): reconcile on exceptions, check repair saves, close the pipe - _run_selected reconciles the reader state in an exception path too: a step raising after its begin marker no longer skips the archive downgrade before the exception propagates. - Both downgrade sites now go through set_state, so the repair persists through the authoritative save and a failed save is surfaced (logged) instead of leaving flow.json at Success over a missing archive. - archive_own_step_logs closes the pipe read stream at teardown; long runs of in-process reruns no longer accumulate pipe fds toward EMFILE. A regression proves a post-begin exception still downgrades the record. --- agent/workspace_api.py | 6 +-- chipcompiler/engine/rerun.py | 75 +++++++++++++++++++----------- chipcompiler/runtime/log_stream.py | 8 +++- test/test_engine_rerun.py | 28 +++++++++++ 4 files changed, 83 insertions(+), 34 deletions(-) diff --git a/agent/workspace_api.py b/agent/workspace_api.py index 84a38e46..59a0d520 100644 --- a/agent/workspace_api.py +++ b/agent/workspace_api.py @@ -274,10 +274,8 @@ def _run_candidate_step(flow, step) -> None: # An archive failure or unmatched begin must not report success while the # step's log is missing; downgrade so a later rerun rebuilds it. if reader.state.error is not None or reader.state.active_step is not None: - record = flow.get_step(step.name, step.tool) - if record is not None: - record["state"] = StateEnum.Imcomplete.value - flow.save() + # set_state owns the authoritative save; a failed save is logged there. + flow.set_state(step.name, step.tool, StateEnum.Imcomplete) raise RuntimeApiError( "command_failed", f"candidate rerun step {step.name} log archival failed: " diff --git a/chipcompiler/engine/rerun.py b/chipcompiler/engine/rerun.py index 8088a959..58afc3ad 100644 --- a/chipcompiler/engine/rerun.py +++ b/chipcompiler/engine/rerun.py @@ -120,44 +120,63 @@ def _run_selected(flow: "EngineFlow", selected: list[tuple[WorkspaceStep, Path]] executed = [] failed = None - with archive_own_step_logs(flow.workspace.directory) as reader: - for workspace_step, output_dir in selected: - flow.workspace.logger.log_section( - f"{workspace_step.tool} - begin step - {workspace_step.name}" - ) - _reset_output_dir(output_dir) - flow.init_db_engine_for_step(workspace_step) - state = flow.run_step(workspace_step, rerun=True) - log_flow(workspace=flow.workspace) - flow.workspace.logger.log_section( - f"{workspace_step.tool} - end step - {workspace_step.name}" - ) - if state != StateEnum.Success: - failed = workspace_step.name - break - executed.append(workspace_step.name) + reader = None + try: + with archive_own_step_logs(flow.workspace.directory) as active_reader: + reader = active_reader + for workspace_step, output_dir in selected: + flow.workspace.logger.log_section( + f"{workspace_step.tool} - begin step - {workspace_step.name}" + ) + _reset_output_dir(output_dir) + flow.init_db_engine_for_step(workspace_step) + state = flow.run_step(workspace_step, rerun=True) + log_flow(workspace=flow.workspace) + flow.workspace.logger.log_section( + f"{workspace_step.tool} - end step - {workspace_step.name}" + ) + if state != StateEnum.Success: + failed = workspace_step.name + break + executed.append(workspace_step.name) + except BaseException: + # A step that raised after its begin marker (post-processing, marker + # write) still needs archive reconciliation before propagating. + if reader is not None: + _downgrade_unarchived_step(flow, reader, executed) + raise # The reader drained at context exit. An archive failure or an unmatched # begin must not leave a Success record whose log is missing: downgrade # the affected step so a later resume reruns it and rebuilds the archive. - archive_error = reader.state.error - unmatched = reader.state.active_step - if archive_error is not None or unmatched is not None: - target = reader.state.error_step or unmatched - if target is None and executed: - target = executed[-1] - if target is not None: - for record in flow.workspace.flow.data.get("steps", []): - if record.get("name") == target: - record["state"] = StateEnum.Imcomplete.value - flow.save() - break + if reader is not None and ( + reader.state.error is not None or reader.state.active_step is not None + ): + target = _downgrade_unarchived_step(flow, reader, executed) return StepRunResult(ok=False, executed=tuple(executed), failed=failed or target) if failed is not None: return StepRunResult(ok=False, executed=tuple(executed), failed=failed) return StepRunResult(ok=True, executed=tuple(executed)) +def _downgrade_unarchived_step(flow: "EngineFlow", reader, executed: list[str]) -> str | None: + """Downgrade the step whose archive failed or whose end marker never came. + + Uses set_state so the downgrade persists through the single authoritative + save; a failed save is logged by set_state itself. + """ + target = reader.state.error_step or reader.state.active_step + if target is None and executed: + target = executed[-1] + if target is None: + return None + for record in flow.workspace.flow.data.get("steps", []): + if record.get("name") == target: + flow.set_state(target, record.get("tool", ""), StateEnum.Imcomplete) + break + return target + + def _validated_output_dirs(workspace: Workspace, steps: list[WorkspaceStep]) -> list[Path]: """Validate that each step output is its canonical ``/output`` dir. diff --git a/chipcompiler/runtime/log_stream.py b/chipcompiler/runtime/log_stream.py index 5a2f94e6..090f7533 100644 --- a/chipcompiler/runtime/log_stream.py +++ b/chipcompiler/runtime/log_stream.py @@ -376,8 +376,9 @@ def archive_own_step_logs(workspace_dir, *, echo: bool = True): def _echo(data: bytes) -> None: os.write(real_stderr, data) + stream = os.fdopen(read_fd, "rb") reader = LogStreamReader( - os.fdopen(read_fd, "rb"), + stream, log_path_resolver=step_log_archive_resolver(workspace_dir), on_output=_echo if echo else None, valid_steps=valid_steps or None, @@ -390,7 +391,8 @@ def _echo(data: bytes) -> None: # Flush everything, restore both descriptors so the pipe sees EOF, # and only then wait for the reader to drain the tail — the echo # callback writes to real_stderr, so it must stay open until the - # drain finishes. + # drain finishes. The pipe's read stream closes too: nothing else + # owns it, and leaked pipes accumulate into EMFILE over many reruns. sys.stdout.flush() sys.stderr.flush() flush_cstdio() @@ -398,5 +400,7 @@ def _echo(data: bytes) -> None: os.dup2(real_stderr, 2) reader.join(timeout=5.0) reader.stop() + with suppress(OSError): + stream.close() os.close(real_stdout) os.close(real_stderr) diff --git a/test/test_engine_rerun.py b/test/test_engine_rerun.py index 195b092f..30e70737 100644 --- a/test/test_engine_rerun.py +++ b/test/test_engine_rerun.py @@ -186,6 +186,34 @@ def run_step_with_markers(workspace_step, *, rerun=False): assert _flow_states(flow) == [StateEnum.Imcomplete.value] assert "ECC-STEP" not in capfd.readouterr().err + def test_step_exception_reconciles_archive_before_propagating( + self, monkeypatch, tmp_path, capfd + ): + """A run_step that raises after its begin marker still reconciles the + reader state before the exception propagates.""" + import os + + from chipcompiler.runtime.log_stream import emit_step_marker + + flow = _make_run_flow(tmp_path, [("place", "Success")]) + _write_output(flow, "place") + + def raising_run_step(workspace_step, *, rerun=False): + emit_step_marker("begin", step=workspace_step.name, tool=workspace_step.tool) + os.write(2, b"partial output\n") + raise RuntimeError("post-processing blew up") + + monkeypatch.setattr(flow, "run_step", raising_run_step) + monkeypatch.setattr(flow, "init_db_engine_for_step", lambda step: True) + + with pytest.raises(RuntimeError, match="post-processing"): + rerun.run_from(flow, "place") + + # The unmatched begin downgraded the record instead of leaving a + # stale Success over a partial archive. + assert _flow_states(flow) == [StateEnum.Imcomplete.value] + assert "ECC-STEP" not in capfd.readouterr().err + def test_failure_stops_suffix_and_keeps_downstream_output(self, monkeypatch, tmp_path): flow = _make_run_flow( tmp_path, From a57111e85c444d4e81582130fd87851d6fc86134 Mon Sep 17 00:00:00 2001 From: Emin Date: Thu, 20 Aug 2026 02:29:44 +0800 Subject: [PATCH 44/52] fix(runtime): close the remaining failure-path state gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - prepare_steps_for_rerun restores the persisted records when post-save cleanup (artifact delete, subflow/checklist reset) fails midway, so the workspace is never left with Unstart states over half-deleted outputs. - EngineFlow.run_step downgrades the persisted Ongoing when the begin marker cannot reach fd 2 — no reader ever saw the step, so worker recovery could never identify it. - Runtime flow_run_step appends the global flow/status log after each executed step, restoring the log_flow side effect the in-process rerun helpers used to provide for --workspace/--from/--resume runs. Regressions pin the cleanup rollback and the begin-marker downgrade. --- chipcompiler/engine/flow.py | 12 +++++- chipcompiler/runtime/rerun_prepare.py | 28 ++++++++----- chipcompiler/runtime/workspace_api.py | 6 +++ test/runtime/test_workspace_api.py | 57 +++++++++++++++++++++++++++ test/test_engine_flow.py | 29 ++++++++++++++ 5 files changed, 122 insertions(+), 10 deletions(-) diff --git a/chipcompiler/engine/flow.py b/chipcompiler/engine/flow.py index 2eab577b..84b8c61c 100644 --- a/chipcompiler/engine/flow.py +++ b/chipcompiler/engine/flow.py @@ -484,7 +484,17 @@ def run_step( from chipcompiler.runtime.log_stream import emit_step_marker - emit_step_marker("begin", step=workspace_step.name, tool=workspace_step.tool) + try: + emit_step_marker("begin", step=workspace_step.name, tool=workspace_step.tool) + except OSError: + # fd 2 is closed or the reader pipe is broken: the marker never + # reached any client, so recovery could never identify this step + # from the stream. Downgrade the persisted Ongoing now instead of + # leaving a permanent Ongoing no repair pass can find. + self.set_state( + name=workspace_step.name, tool=workspace_step.tool, state=StateEnum.Imcomplete + ) + raise pid = os.getpid() start_memory_mb = get_process_rss_mb(pid) diff --git a/chipcompiler/runtime/rerun_prepare.py b/chipcompiler/runtime/rerun_prepare.py index decabca8..55b11259 100644 --- a/chipcompiler/runtime/rerun_prepare.py +++ b/chipcompiler/runtime/rerun_prepare.py @@ -103,16 +103,26 @@ def prepare_steps_for_rerun( record.update(snapshot) raise _runtime_api_error("failed to persist step invalidation; refusing to modify outputs") - for step_name, directory in artifact_directories: - _clear_step_artifact_dir( - workspace_root, - directory, - step_name, - ) + # Post-save cleanup can still fail midway (artifact delete, subflow or + # checklist reset): restore the persisted records so the workspace is + # not left with Unstart states over half-deleted outputs. + try: + for step_name, directory in artifact_directories: + _clear_step_artifact_dir( + workspace_root, + directory, + step_name, + ) - for workspace_step in unique_steps: - _reset_step_subflow(workspace_step) - _reset_step_checklist(workspace_step) + for workspace_step in unique_steps: + _reset_step_subflow(workspace_step) + _reset_step_checklist(workspace_step) + except Exception: + for record, snapshot in snapshots: + record.clear() + record.update(snapshot) + engine_flow.save() + raise def _reset_step_subflow(workspace_step) -> None: diff --git a/chipcompiler/runtime/workspace_api.py b/chipcompiler/runtime/workspace_api.py index e8a306fd..cb797da3 100644 --- a/chipcompiler/runtime/workspace_api.py +++ b/chipcompiler/runtime/workspace_api.py @@ -372,6 +372,12 @@ def run_step(session: WorkspaceSession) -> dict: rerun=request.rerun, observer=observer, ) + # Keep the global flow/status log entries that the in-process + # rerun helpers used to append after every selected step. + if getattr(session.workspace, "logger", None) is not None: + from chipcompiler.data import log_flow + + log_flow(workspace=session.workspace) finally: if should_capture: self._capture_flow_db( diff --git a/test/runtime/test_workspace_api.py b/test/runtime/test_workspace_api.py index b3c1a25e..fa96706a 100644 --- a/test/runtime/test_workspace_api.py +++ b/test/runtime/test_workspace_api.py @@ -1660,6 +1660,63 @@ def failing_save(self): } +def test_rerun_cleanup_failure_restores_persisted_records(monkeypatch, tmp_path): + """If post-save cleanup (artifact/subflow/checklist) fails midway, the + persisted Unstart records are restored so the workspace is not left + half-prepared.""" + _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) + + def step_spec(name, tool): + step_dir = ws / f"{name}_{tool}" + artifact_dir = step_dir / "output" + artifact_dir.mkdir(parents=True) + (artifact_dir / "stale").write_text(name) + subflow_path = step_dir / "subflow.json" + subflow_path.write_text(json.dumps({"path": str(subflow_path), "steps": []})) + checklist_path = step_dir / "checklist.json" + checklist_path.write_text(json.dumps({"checklist": []})) + return { + "name": name, + "tool": tool, + "output": {"dir": artifact_dir}, + "subflow": SimpleNamespace(path=subflow_path, steps=[]), + "checklist": SimpleNamespace(path=checklist_path, checklist=[]), + } + + synthesis = step_spec("Synthesis", "yosys") + floorplan = step_spec("Floorplan", "ecc") + route = step_spec("route", "ecc") + DummyFlow.workspace_step_specs = (synthesis, floorplan, route) + api = WorkspaceRuntimeApi() + workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"] + session = api.sessions.get_session(workspace_id) + session.workspace.flow.data = { + "steps": [ + {"name": spec["name"], "tool": spec["tool"], "state": "Success"} + for spec in (synthesis, floorplan, route) + ] + } + original_snapshots = [dict(r) for r in session.workspace.flow.data["steps"]] + + def exploding_reset(workspace_step): + raise OSError("checklist write failed") + + monkeypatch.setattr("chipcompiler.runtime.rerun_prepare._reset_step_checklist", exploding_reset) + + with pytest.raises(OSError, match="checklist write failed"): + api.flow_run_step( + FlowRunStepRequest( + workspace_id=workspace_id, + step="Floorplan", + rerun=True, + invalidate_dependents=True, + ) + ) + + steps = session.workspace.flow.data["steps"] + assert steps[:3] == original_snapshots + + def test_flow_run_step_final_save_failure_leaves_no_success_record(monkeypatch, tmp_path): """In-process execution path: a failed final save must not leave the session record at Success, and the next non-rerun call must not skip the diff --git a/test/test_engine_flow.py b/test/test_engine_flow.py index 977e57b6..0f86d5ee 100644 --- a/test/test_engine_flow.py +++ b/test/test_engine_flow.py @@ -228,6 +228,35 @@ def on_step_completed(self, step, state): assert json_read(flow_path)["steps"][0]["state"] == StateEnum.Ongoing.value +def test_begin_marker_failure_downgrades_ongoing(monkeypatch, tmp_path): + """If the begin marker cannot reach fd 2, no reader ever sees the step: + downgrade the persisted Ongoing instead of leaving an unfindable record.""" + import chipcompiler.runtime.log_stream as log_stream_module + + (tmp_path / "home").mkdir(exist_ok=True) + workspace = Workspace(directory=tmp_path, flow=Flow(path=tmp_path / "home" / "flow.json")) + engine_flow = EngineFlow(workspace) + engine_flow.workspace.flow.data = { + "steps": [{"name": "route", "tool": "ecc", "state": "Unstart"}] + } + workspace_step = EccStep(name="route", directory=tmp_path, tool="ecc") + engine_flow.workspace_steps = [workspace_step] + engine_flow.engine_db = SimpleNamespace(engine=None) + + def broken_emit(event, *, step, tool): + raise OSError("fd 2 closed") + + monkeypatch.setattr(log_stream_module, "emit_step_marker", broken_emit) + + import pytest as _pytest + + with _pytest.raises(OSError, match="fd 2 closed"): + engine_flow.run_step(workspace_step) + + record = engine_flow.get_step("route", "ecc") + assert record["state"] == StateEnum.Imcomplete.value + + def test_check_step_result_synthesis_uses_common_verilog(tmp_path): verilog = tmp_path / "gcd.v" verilog.write_text("module gcd; endmodule\n") From b7719c6c751ff62cc436a96a714c07e38f683174 Mon Sep 17 00:00:00 2001 From: Emin Date: Thu, 20 Aug 2026 03:04:35 +0800 Subject: [PATCH 45/52] feat(runtime): self-archive direct run_steps at the public boundary Review rounds kept finding the same gap: direct EngineFlow execution is a documented Python API, but per-callsite wrapping cannot hold. run_steps now wraps itself in archive_own_step_logs, so bare documented scripts archive step logs and keep markers off the terminal. The context passes through untouched when an outer client owns the stream (the stdio server entry marks itself via mark_external_log_client) or when nested inside another archive context, preserving the single-producer invariant for worker, sidecar, agent-candidate, and rerun-helper paths. The integration conftest drops its now-redundant explicit wrapper. Regressions pin the nested and external-client passthroughs. --- agent/workspace_api.py | 8 +++- chipcompiler/engine/flow.py | 62 +++++++++++++++------------- chipcompiler/runtime/log_stream.py | 23 +++++++++++ chipcompiler/runtime/stdio_server.py | 3 ++ test/integration/conftest.py | 12 ++---- test/runtime/test_log_stream.py | 35 ++++++++++++++++ 6 files changed, 105 insertions(+), 38 deletions(-) diff --git a/agent/workspace_api.py b/agent/workspace_api.py index 59a0d520..b382d623 100644 --- a/agent/workspace_api.py +++ b/agent/workspace_api.py @@ -272,8 +272,12 @@ def _run_candidate_step(flow, step) -> None: with archive_own_step_logs(flow.workspace.directory) as reader: state = flow.run_step(step, rerun=True) # An archive failure or unmatched begin must not report success while the - # step's log is missing; downgrade so a later rerun rebuilds it. - if reader.state.error is not None or reader.state.active_step is not None: + # step's log is missing; downgrade so a later rerun rebuilds it. A None + # reader means an outer client owns the stream (passthrough) — nothing to + # reconcile here. + if reader is not None and ( + reader.state.error is not None or reader.state.active_step is not None + ): # set_state owns the authoritative save; a failed save is logged there. flow.set_state(step.name, step.tool, StateEnum.Imcomplete) raise RuntimeApiError( diff --git a/chipcompiler/engine/flow.py b/chipcompiler/engine/flow.py index 84b8c61c..bcc8a372 100644 --- a/chipcompiler/engine/flow.py +++ b/chipcompiler/engine/flow.py @@ -407,36 +407,42 @@ def run_steps(self, *, rerun: bool = False, observer=None) -> bool: """ run all flow steps """ + from chipcompiler.runtime.log_stream import archive_own_step_logs + + # Direct in-process runs (documented Python API) self-archive so step + # logs exist and markers stay off the caller's terminal; inside a + # worker/sidecar process the outer client owns the stream and this + # context passes through. + with archive_own_step_logs(self.workspace.directory): + for workspace_step in self.workspace_steps: + self.workspace.logger.log_section( + f"{workspace_step.tool} - begin step - {workspace_step.name}" + ) + self.init_db_engine() + state = ( + self.run_step(workspace_step, rerun=rerun) + if observer is None + else self.run_step(workspace_step, rerun=rerun, observer=observer) + ) - for workspace_step in self.workspace_steps: - self.workspace.logger.log_section( - f"{workspace_step.tool} - begin step - {workspace_step.name}" - ) - self.init_db_engine() - state = ( - self.run_step(workspace_step, rerun=rerun) - if observer is None - else self.run_step(workspace_step, rerun=rerun, observer=observer) - ) - - log_flow(workspace=self.workspace) - self.workspace.logger.log_section( - f"{workspace_step.tool} - end step - {workspace_step.name}" - ) + log_flow(workspace=self.workspace) + self.workspace.logger.log_section( + f"{workspace_step.tool} - end step - {workspace_step.name}" + ) - match state: - case StateEnum.Success: - continue - case StateEnum.Invalid: - return False - case StateEnum.Unstart: - return False - case StateEnum.Imcomplete: - return False - case StateEnum.Pending: - return False - case StateEnum.Ongoing: - return False + match state: + case StateEnum.Success: + continue + case StateEnum.Invalid: + return False + case StateEnum.Unstart: + return False + case StateEnum.Imcomplete: + return False + case StateEnum.Pending: + return False + case StateEnum.Ongoing: + return False total_steps = len(self.workspace.flow.data.get("steps", [])) if len(self.workspace_steps) < total_steps: diff --git a/chipcompiler/runtime/log_stream.py b/chipcompiler/runtime/log_stream.py index 090f7533..0b7ffd20 100644 --- a/chipcompiler/runtime/log_stream.py +++ b/chipcompiler/runtime/log_stream.py @@ -337,6 +337,19 @@ def _close_archive(self) -> None: self._state.archive_file = None +# Set by the stdio/RPC server entry point: in that process an outer client +# (the CLI parent or the Electron archiver) owns the fd stream, so in-process +# self-archiving must stay off to keep the single-producer invariant. +_EXTERNAL_LOG_CLIENT = False +_SELF_ARCHIVE_ACTIVE = False + + +def mark_external_log_client() -> None: + """Mark that this process's log stream is owned by an outer client.""" + global _EXTERNAL_LOG_CLIENT + _EXTERNAL_LOG_CLIENT = True + + @contextmanager def archive_own_step_logs(workspace_dir, *, echo: bool = True): """Archive this process's own fd 1+2 streams into per-step log files. @@ -355,6 +368,15 @@ def archive_own_step_logs(workspace_dir, *, echo: bool = True): from chipcompiler.utility.json import json_read from chipcompiler.utility.log import flush_cstdio + global _SELF_ARCHIVE_ACTIVE + if _EXTERNAL_LOG_CLIENT or _SELF_ARCHIVE_ACTIVE: + # An outer client owns this stream (worker/sidecar process), or an + # outer archive_own_step_logs is already active (e.g. run_steps + # auto-archives around an explicit caller): pass through untouched. + yield None + return + _SELF_ARCHIVE_ACTIVE = True + workspace_dir = Path(workspace_dir) flow_data = json_read(workspace_dir / "home" / "flow.json") valid_steps = { @@ -404,3 +426,4 @@ def _echo(data: bytes) -> None: stream.close() os.close(real_stdout) os.close(real_stderr) + _SELF_ARCHIVE_ACTIVE = False diff --git a/chipcompiler/runtime/stdio_server.py b/chipcompiler/runtime/stdio_server.py index 630140d7..5035f39b 100644 --- a/chipcompiler/runtime/stdio_server.py +++ b/chipcompiler/runtime/stdio_server.py @@ -113,8 +113,11 @@ def _read_chunk(input_stream: BinaryIO) -> bytes: def main(*, persistent_db_enabled: bool = False) -> int: + from chipcompiler.runtime.log_stream import mark_external_log_client from chipcompiler.runtime.stdio_isolation import StdioIsolation + # This process's fd stream belongs to the parent client's archiver. + mark_external_log_client() isolation = StdioIsolation() protocol_stream = isolation.install() try: diff --git a/test/integration/conftest.py b/test/integration/conftest.py index 98c3db27..d98c07b9 100644 --- a/test/integration/conftest.py +++ b/test/integration/conftest.py @@ -48,14 +48,10 @@ def run_workspace_flow( engine_flow.create_step_workspaces() - # The engine emits step markers on fd 1/2 instead of writing step logs; - # route the process's own stream through the client-side archiver so the - # integration run still produces per-step logs without leaking markers - # into pytest's own output. - from chipcompiler.runtime.log_stream import archive_own_step_logs - - with archive_own_step_logs(workspace.directory): - return engine_flow.run_steps() + # run_steps self-archives: the engine emits step markers on fd 1/2, and + # the built-in client-side archiver writes per-step logs without leaking + # markers into pytest's own output. + return engine_flow.run_steps() @pytest.fixture diff --git a/test/runtime/test_log_stream.py b/test/runtime/test_log_stream.py index 65bd4b12..a991fefe 100644 --- a/test/runtime/test_log_stream.py +++ b/test/runtime/test_log_stream.py @@ -325,6 +325,41 @@ def test_archives_step_bytes_and_echoes_without_markers(self, tmp_path, capfd): assert "ECC-STEP" not in echoed +class TestArchiveOwnStepLogsPassthrough: + def test_external_client_or_nesting_passes_through(self, tmp_path, capfd): + """A worker/sidecar process (marked) or an already-active outer context + must not double-wrap: markers keep flowing to the outer client.""" + import os + + import chipcompiler.runtime.log_stream as log_stream_module + from chipcompiler.runtime.log_stream import archive_own_step_logs, emit_step_marker + + workspace = tmp_path / "ws" + (workspace / "home").mkdir(parents=True) + + # Nested: the outer context archives; the inner one passes through. + with ( + archive_own_step_logs(workspace), + archive_own_step_logs(workspace) as inner, + ): + assert inner is None + emit_step_marker("begin", step="S", tool="T") + os.write(2, b"bytes\n") + emit_step_marker("end", step="S", tool="T") + assert (workspace / "S_T" / "log" / "S.log").read_bytes() == b"bytes\n" + assert "ECC-STEP" not in capfd.readouterr().err + + # Marked external client: no self-archive at all, bytes pass through. + log_stream_module.mark_external_log_client() + try: + with archive_own_step_logs(workspace) as reader: + assert reader is None + os.write(2, b"raw\n") + finally: + log_stream_module._EXTERNAL_LOG_CLIENT = False + assert "raw" in capfd.readouterr().err + + class TestLogStreamResilience: def test_resolver_exception_disables_archive_continues_drain(self): """A resolver that raises must not kill the drain thread.""" From c48da7ed80f4e8ec76234d178ca387e252da8687 Mon Sep 17 00:00:00 2001 From: Emin Date: Thu, 20 Aug 2026 03:32:00 +0800 Subject: [PATCH 46/52] fix(runtime): irreversible cleanup keeps Unstart, reconcile run_steps and pre-marker crashes - prepare_steps_for_rerun cleanup is per-step: a mid-cleanup failure keeps the persisted Unstart on steps whose artifacts are already gone and rolls back only untouched steps, so resume never trusts Success over deleted outputs. - run_steps reconciles the self-archive reader after the loop (and before exception propagation): archive failures or unmatched begins downgrade the record and return False instead of reporting success over a missing log. - Worker crash recovery falls back to the persisted Ongoing record when no stream evidence exists (kill between the Ongoing save and the begin marker). Regressions pin all three paths. --- chipcompiler/engine/flow.py | 69 ++++++++++++++---------- chipcompiler/engine/rerun.py | 6 +-- chipcompiler/runtime/rerun_prepare.py | 28 ++++++---- chipcompiler/runtime/worker_operation.py | 19 +++++-- test/runtime/test_worker_operation.py | 31 +++++++++++ test/runtime/test_workspace_api.py | 6 ++- test/test_engine_flow.py | 39 ++++++++++++++ 7 files changed, 154 insertions(+), 44 deletions(-) diff --git a/chipcompiler/engine/flow.py b/chipcompiler/engine/flow.py index bcc8a372..d41a0136 100644 --- a/chipcompiler/engine/flow.py +++ b/chipcompiler/engine/flow.py @@ -409,40 +409,53 @@ def run_steps(self, *, rerun: bool = False, observer=None) -> bool: """ from chipcompiler.runtime.log_stream import archive_own_step_logs + from .rerun import downgrade_unarchived_step + # Direct in-process runs (documented Python API) self-archive so step # logs exist and markers stay off the caller's terminal; inside a # worker/sidecar process the outer client owns the stream and this # context passes through. - with archive_own_step_logs(self.workspace.directory): - for workspace_step in self.workspace_steps: - self.workspace.logger.log_section( - f"{workspace_step.tool} - begin step - {workspace_step.name}" - ) - self.init_db_engine() - state = ( - self.run_step(workspace_step, rerun=rerun) - if observer is None - else self.run_step(workspace_step, rerun=rerun, observer=observer) - ) + succeeded = True + with archive_own_step_logs(self.workspace.directory) as reader: + try: + for workspace_step in self.workspace_steps: + self.workspace.logger.log_section( + f"{workspace_step.tool} - begin step - {workspace_step.name}" + ) + self.init_db_engine() + state = ( + self.run_step(workspace_step, rerun=rerun) + if observer is None + else self.run_step(workspace_step, rerun=rerun, observer=observer) + ) - log_flow(workspace=self.workspace) - self.workspace.logger.log_section( - f"{workspace_step.tool} - end step - {workspace_step.name}" - ) + log_flow(workspace=self.workspace) + self.workspace.logger.log_section( + f"{workspace_step.tool} - end step - {workspace_step.name}" + ) - match state: - case StateEnum.Success: - continue - case StateEnum.Invalid: - return False - case StateEnum.Unstart: - return False - case StateEnum.Imcomplete: - return False - case StateEnum.Pending: - return False - case StateEnum.Ongoing: - return False + match state: + case StateEnum.Success: + continue + case _: + succeeded = False + break + except BaseException: + # Reconcile archive evidence before the exception propagates. + if reader is not None: + downgrade_unarchived_step(self, reader, []) + raise + + # An archive failure or unmatched begin must not report success over a + # missing step log; reconcile after the reader drains. (reader is None + # when an outer client owns the stream — nothing to reconcile here.) + if reader is not None and ( + reader.state.error is not None or reader.state.active_step is not None + ): + downgrade_unarchived_step(self, reader, []) + succeeded = False + if not succeeded: + return False total_steps = len(self.workspace.flow.data.get("steps", [])) if len(self.workspace_steps) < total_steps: diff --git a/chipcompiler/engine/rerun.py b/chipcompiler/engine/rerun.py index 58afc3ad..22a558e9 100644 --- a/chipcompiler/engine/rerun.py +++ b/chipcompiler/engine/rerun.py @@ -143,7 +143,7 @@ def _run_selected(flow: "EngineFlow", selected: list[tuple[WorkspaceStep, Path]] # A step that raised after its begin marker (post-processing, marker # write) still needs archive reconciliation before propagating. if reader is not None: - _downgrade_unarchived_step(flow, reader, executed) + downgrade_unarchived_step(flow, reader, executed) raise # The reader drained at context exit. An archive failure or an unmatched @@ -152,14 +152,14 @@ def _run_selected(flow: "EngineFlow", selected: list[tuple[WorkspaceStep, Path]] if reader is not None and ( reader.state.error is not None or reader.state.active_step is not None ): - target = _downgrade_unarchived_step(flow, reader, executed) + target = downgrade_unarchived_step(flow, reader, executed) return StepRunResult(ok=False, executed=tuple(executed), failed=failed or target) if failed is not None: return StepRunResult(ok=False, executed=tuple(executed), failed=failed) return StepRunResult(ok=True, executed=tuple(executed)) -def _downgrade_unarchived_step(flow: "EngineFlow", reader, executed: list[str]) -> str | None: +def downgrade_unarchived_step(flow: "EngineFlow", reader, executed: list[str]) -> str | None: """Downgrade the step whose archive failed or whose end marker never came. Uses set_state so the downgrade persists through the single authoritative diff --git a/chipcompiler/runtime/rerun_prepare.py b/chipcompiler/runtime/rerun_prepare.py index 55b11259..b932bec6 100644 --- a/chipcompiler/runtime/rerun_prepare.py +++ b/chipcompiler/runtime/rerun_prepare.py @@ -104,21 +104,31 @@ def prepare_steps_for_rerun( raise _runtime_api_error("failed to persist step invalidation; refusing to modify outputs") # Post-save cleanup can still fail midway (artifact delete, subflow or - # checklist reset): restore the persisted records so the workspace is - # not left with Unstart states over half-deleted outputs. + # checklist reset). Cleanup is irreversible for steps already cleared, + # so those steps keep their persisted Unstart records (their outputs are + # gone); only steps not yet touched roll back to their snapshots. try: - for step_name, directory in artifact_directories: - _clear_step_artifact_dir( - workspace_root, - directory, - step_name, - ) - for workspace_step in unique_steps: + for step_name, directory in artifact_directories: + if step_name != workspace_step.name: + continue + _clear_step_artifact_dir( + workspace_root, + directory, + step_name, + ) _reset_step_subflow(workspace_step) _reset_step_checklist(workspace_step) except Exception: + # Steps up to and including the one mid-cleanup have lost artifacts; + # their persisted Unstart must stay. Later steps roll back intact. + cleaned = { + str(getattr(step, "name", "")) + for step in unique_steps[: unique_steps.index(workspace_step) + 1] + } for record, snapshot in snapshots: + if record.get("name") in cleaned: + continue record.clear() record.update(snapshot) engine_flow.save() diff --git a/chipcompiler/runtime/worker_operation.py b/chipcompiler/runtime/worker_operation.py index a63e83cd..083eeef1 100644 --- a/chipcompiler/runtime/worker_operation.py +++ b/chipcompiler/runtime/worker_operation.py @@ -257,15 +257,28 @@ def _reconcile_step_state( Picks the unmatched active step first, then the step being archived when the first reader error fired, then the last seen step on an - archive error. Returns (repaired_steps, error_text): reconciling to - Incomplete keeps a later resume from trusting a stale Success whose - log is missing or incomplete. + archive error. With no stream evidence at all (a crash between the + Ongoing save and the begin marker), falls back to the persisted + Ongoing record — a worker session runs at most one step at a time. + Returns (repaired_steps, error_text): reconciling to Incomplete keeps + a later resume from trusting a stale record whose run never finished. """ if log_state is None: return [], None step = log_state.active_step or log_state.error_step if step is None and log_state.error is not None and log_state.steps_seen: step = log_state.steps_seen[-1] + if step is None and self._flow_json_path.exists(): + from chipcompiler.utility import json_read + + data = json_read(self._flow_json_path) + ongoing = [ + record + for record in data.get("steps", []) + if isinstance(record, dict) and record.get("state") == "Ongoing" + ] + if len(ongoing) == 1: + step = ongoing[0].get("name") if step is None or not self._flow_json_path.exists(): return [], None try: diff --git a/test/runtime/test_worker_operation.py b/test/runtime/test_worker_operation.py index 75efb5fd..d333d455 100644 --- a/test/runtime/test_worker_operation.py +++ b/test/runtime/test_worker_operation.py @@ -297,6 +297,37 @@ def failing_repair(*args, **kwargs): # The record is left as it was — the failure is reported, not hidden. assert json.loads(flow_json.read_text())["steps"][0]["state"] == "Ongoing" + def test_crash_before_any_marker_repairs_persisted_ongoing(self, tmp_path): + """Killed between the Ongoing save and the begin marker: no stream + evidence exists, so recovery falls back to the persisted Ongoing.""" + crash_script = tmp_path / "crash_before_marker.py" + crash_script.write_text( + _RPC_HELPERS + + textwrap.dedent("""\ + req = read_request() # hello + send_response({"jsonrpc": "2.0", "result": {"version": 1}, "id": req["id"]}) + req = read_request() # workspace.open + send_response({"jsonrpc": "2.0", "result": {"workspaceId": "x"}, "id": req["id"]}) + # Die before any marker — as if killed between the Ongoing save + # and the begin write. + os._exit(1) + """) + ) + flow_json = tmp_path / "flow.json" + flow_json.write_text( + json.dumps({"steps": [{"name": "Synthesis", "tool": "yosys", "state": "Ongoing"}]}) + ) + op = RunOperation( + workspace_dir=tmp_path, + flow_json_path=flow_json, + worker_argv=[sys.executable, str(crash_script)], + ) + result = op.run("flow.run", {"workspace_id": "test"}) + assert result.success is False + assert result.repaired_steps == ["Synthesis"] + repaired = json.loads(flow_json.read_text()) + assert repaired["steps"][0]["state"] == "Incomplete" + def test_worker_crash_no_flow_json_no_repair(self, tmp_path): script = _RPC_HELPERS + textwrap.dedent("""\ req = read_request() # hello diff --git a/test/runtime/test_workspace_api.py b/test/runtime/test_workspace_api.py index fa96706a..7efb1b7b 100644 --- a/test/runtime/test_workspace_api.py +++ b/test/runtime/test_workspace_api.py @@ -1713,8 +1713,12 @@ def exploding_reset(workspace_step): ) ) + # The failing step was already cleaned (its artifacts are gone), so it + # keeps the persisted Unstart; the untouched steps roll back. steps = session.workspace.flow.data["steps"] - assert steps[:3] == original_snapshots + assert steps[0] == original_snapshots[0] # Synthesis untouched + assert steps[1]["state"] == "Unstart" # Floorplan cleaned before the failure + assert steps[2] == original_snapshots[2] # route rolled back def test_flow_run_step_final_save_failure_leaves_no_success_record(monkeypatch, tmp_path): diff --git a/test/test_engine_flow.py b/test/test_engine_flow.py index 0f86d5ee..547b5eae 100644 --- a/test/test_engine_flow.py +++ b/test/test_engine_flow.py @@ -228,6 +228,45 @@ def on_step_completed(self, step, state): assert json_read(flow_path)["steps"][0]["state"] == StateEnum.Ongoing.value +def test_run_steps_archive_failure_returns_false_and_downgrades(monkeypatch, tmp_path): + """A direct run_steps with a broken archive path must report failure and + downgrade the record instead of returning True over a missing log.""" + import os + + from chipcompiler.runtime.log_stream import emit_step_marker + + workspace = Workspace( + directory=tmp_path, + flow=Flow(path=tmp_path / "home" / "flow.json"), + ) + engine_flow = EngineFlow(workspace) + engine_flow.workspace.flow.data = { + "steps": [{"name": "route", "tool": "ecc", "state": "Unstart"}] + } + step_dir = tmp_path / "route_ecc" + workspace_step = EccStep(name="route", directory=step_dir, tool="ecc") + engine_flow.workspace_steps = [workspace_step] + engine_flow.engine_db = SimpleNamespace(engine=None) + + def run_step_with_markers(ws_step, *, rerun=False, observer=None): + emit_step_marker("begin", step=ws_step.name, tool=ws_step.tool) + os.write(2, b"bytes\n") + emit_step_marker("end", step=ws_step.name, tool=ws_step.tool) + engine_flow.set_state(ws_step.name, ws_step.tool, StateEnum.Success) + return StateEnum.Success + + monkeypatch.setattr(engine_flow, "run_step", run_step_with_markers) + monkeypatch.setattr(engine_flow, "init_db_engine", lambda: True) + + # The step's log directory is a regular file: the archive cannot open. + step_dir.mkdir(parents=True) + (step_dir / "log").write_text("regular file") + + assert engine_flow.run_steps() is False + record = engine_flow.get_step("route", "ecc") + assert record["state"] == StateEnum.Imcomplete.value + + def test_begin_marker_failure_downgrades_ongoing(monkeypatch, tmp_path): """If the begin marker cannot reach fd 2, no reader ever sees the step: downgrade the persisted Ongoing instead of leaving an unfindable record.""" From c48b00ed05a3e22077d3d51ed6d9ad80990fc845 Mon Sep 17 00:00:00 2001 From: Emin Date: Thu, 20 Aug 2026 04:02:43 +0800 Subject: [PATCH 47/52] fix(runtime): keep display failures out of archive error state - on_output/on_step_event callback exceptions now land in LogStreamState.display_error instead of error: a broken renderer no longer reads as an archive failure and can no longer downgrade a step whose archive is complete. - downgrade_unarchived_step surfaces an unpersistable downgrade with an explicit error log naming the stale Success record, instead of reporting a repair that never reached disk. Regressions pin both: a crashed renderer leaves the operation successful with the record intact, and a failed downgrade save keeps the disk record honestly at Success while the operation fails. --- chipcompiler/engine/rerun.py | 10 ++++-- chipcompiler/runtime/log_stream.py | 9 +++-- test/runtime/test_worker_operation.py | 47 +++++++++++++++++++++++++++ test/test_engine_rerun.py | 43 ++++++++++++++++++++++++ 4 files changed, 105 insertions(+), 4 deletions(-) diff --git a/chipcompiler/engine/rerun.py b/chipcompiler/engine/rerun.py index 22a558e9..60dd0e1c 100644 --- a/chipcompiler/engine/rerun.py +++ b/chipcompiler/engine/rerun.py @@ -163,7 +163,8 @@ def downgrade_unarchived_step(flow: "EngineFlow", reader, executed: list[str]) - """Downgrade the step whose archive failed or whose end marker never came. Uses set_state so the downgrade persists through the single authoritative - save; a failed save is logged by set_state itself. + save. If that save fails, the disk record still claims Success — log the + incomplete repair explicitly so the stale record is not silently trusted. """ target = reader.state.error_step or reader.state.active_step if target is None and executed: @@ -172,7 +173,12 @@ def downgrade_unarchived_step(flow: "EngineFlow", reader, executed: list[str]) - return None for record in flow.workspace.flow.data.get("steps", []): if record.get("name") == target: - flow.set_state(target, record.get("tool", ""), StateEnum.Imcomplete) + if not flow.set_state(target, record.get("tool", ""), StateEnum.Imcomplete): + flow.workspace.logger.error( + "archive downgrade for step %s could not be persisted; " + "flow.json may still claim Success — repair it before resuming", + target, + ) break return target diff --git a/chipcompiler/runtime/log_stream.py b/chipcompiler/runtime/log_stream.py index 0b7ffd20..fb989fd5 100644 --- a/chipcompiler/runtime/log_stream.py +++ b/chipcompiler/runtime/log_stream.py @@ -107,6 +107,9 @@ class LogStreamState: # The step being archived when the first error was recorded, so failure # paths can reconcile exactly that record even after its end marker. error_step: str | None = None + # Display-callback failures live apart from archival failures: a broken + # renderer must never read as a broken archive. + display_error: Exception | None = None class LogStreamReader: @@ -288,7 +291,8 @@ def _emit_step_event(self, event: str, step: str, tool: str) -> None: try: self._on_step_event(event, step, tool) except Exception as exc: - self._record_error(exc) + if self._state.display_error is None: + self._state.display_error = exc self._on_step_event_disabled = True def _emit_data(self, data: bytes) -> None: @@ -306,7 +310,8 @@ def _emit_data(self, data: bytes) -> None: try: self._on_output(data) except Exception as exc: - self._record_error(exc) + if self._state.display_error is None: + self._state.display_error = exc self._on_output_disabled = True def _update_tail(self, data: bytes) -> None: diff --git a/test/runtime/test_worker_operation.py b/test/runtime/test_worker_operation.py index d333d455..50c5f758 100644 --- a/test/runtime/test_worker_operation.py +++ b/test/runtime/test_worker_operation.py @@ -417,6 +417,53 @@ def bad_resolver(step: str, tool: str): assert result.archive_error is not None assert "archive error" in result.error + def test_display_callback_failure_is_not_an_archive_failure(self, tmp_path): + """A broken on_output renderer must not downgrade the step or fail + the operation when the archive itself is fine.""" + script = tmp_path / "server_with_markers.py" + script.write_text( + _RPC_HELPERS + + textwrap.dedent("""\ + req = read_request() # hello + send_response({"jsonrpc": "2.0", "result": {"version": 1}, "id": req["id"]}) + req = read_request() # workspace.open + send_response({"jsonrpc": "2.0", "result": {"workspaceId": "ws1"}, "id": req["id"]}) + os.write(2, make_marker("begin", "Synthesis", "yosys")) + os.write(2, b'Synthesizing...\\n') + os.write(2, make_marker("end", "Synthesis", "yosys")) + req = read_request() # flow.run + send_response({"jsonrpc": "2.0", "result": {"steps": ["syn"]}, "id": req["id"]}) + req = read_request() # rpc.shutdown + send_response({"jsonrpc": "2.0", "result": {"ok": True}, "id": req["id"]}) + """) + ) + logs_dir = tmp_path / "logs" + + def resolver(step: str, tool: str): + return logs_dir / f"{step}.log" + + def broken_display(data: bytes): + raise RuntimeError("renderer blew up") + + flow_json = tmp_path / "flow.json" + flow_json.write_text( + json.dumps({"steps": [{"name": "Synthesis", "tool": "yosys", "state": "Success"}]}) + ) + op = RunOperation( + workspace_dir=tmp_path, + flow_json_path=flow_json, + worker_argv=[sys.executable, str(script)], + log_path_resolver=resolver, + on_output=broken_display, + ) + result = op.run("flow.run", {"workspace_id": "test"}) + assert result.success is True + assert result.repaired_steps == [] + assert result.log_state is not None + assert result.log_state.display_error is not None + assert result.log_state.error is None + assert json.loads(flow_json.read_text())["steps"][0]["state"] == "Success" + def test_archive_error_reconciles_the_success_record(self, tmp_path): """An archive failure must not leave flow.json claiming Success.""" script = tmp_path / "server_with_markers.py" diff --git a/test/test_engine_rerun.py b/test/test_engine_rerun.py index 30e70737..298bc2ee 100644 --- a/test/test_engine_rerun.py +++ b/test/test_engine_rerun.py @@ -186,6 +186,49 @@ def run_step_with_markers(workspace_step, *, rerun=False): assert _flow_states(flow) == [StateEnum.Imcomplete.value] assert "ECC-STEP" not in capfd.readouterr().err + def test_archive_downgrade_save_failure_does_not_pretend(self, monkeypatch, tmp_path, capfd): + """When the downgrade cannot persist, the disk record honestly keeps + Success while the operation reports failure — no fake repair.""" + import os + + from chipcompiler.runtime.log_stream import emit_step_marker + + flow = _make_run_flow(tmp_path, [("place", "Success")]) + _write_output(flow, "place") + + def run_step_with_markers(workspace_step, *, rerun=False): + emit_step_marker("begin", step=workspace_step.name, tool=workspace_step.tool) + os.write(2, b"bytes\n") + emit_step_marker("end", step=workspace_step.name, tool=workspace_step.tool) + flow.set_state(workspace_step.name, workspace_step.tool, StateEnum.Success) + return StateEnum.Success + + monkeypatch.setattr(flow, "run_step", run_step_with_markers) + monkeypatch.setattr(flow, "init_db_engine_for_step", lambda step: True) + + # The archive cannot open (regular file at the log path), and the + # downgrade save fails too — but the invalidation save must succeed. + (tmp_path / "place_ecc" / "log").write_text("regular file") + real_save = flow.save + + def save_fails_on_downgrade(): + states = [s.get("state") for s in flow.workspace.flow.data.get("steps", [])] + if StateEnum.Imcomplete.value in states: + return False + return real_save() + + monkeypatch.setattr(flow, "save", save_fails_on_downgrade) + + result = rerun.run_from(flow, "place") + + assert result.ok is False + assert result.failed == "place" + # In memory the downgrade happened; on disk the record honestly keeps + # Success (the failed save is logged, not hidden). + assert _flow_states(flow) == [StateEnum.Imcomplete.value] + persisted = json.loads((tmp_path / "home" / "flow.json").read_text()) + assert persisted["steps"][0]["state"] == "Success" + def test_step_exception_reconciles_archive_before_propagating( self, monkeypatch, tmp_path, capfd ): From db70ec6c3dec6c40dd80fc367152d95c7f20b88c Mon Sep 17 00:00:00 2001 From: Emin Date: Thu, 20 Aug 2026 04:09:46 +0800 Subject: [PATCH 48/52] test(runtime): pin display-error separation and honest downgrade persistence The two callback-resilience contract tests now assert display_error (display-only failures), and the rerun suite gains a regression proving an unpersistable downgrade keeps the disk record honestly at Success while the operation reports failure. --- test/runtime/test_log_stream.py | 2 +- test/runtime/test_log_stream_targets.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/test/runtime/test_log_stream.py b/test/runtime/test_log_stream.py index a991fefe..463c1722 100644 --- a/test/runtime/test_log_stream.py +++ b/test/runtime/test_log_stream.py @@ -417,7 +417,7 @@ def resolver(step, tool): reader.start() reader.join(timeout=5) assert reader.completed - assert isinstance(reader.state.error, ValueError) + assert isinstance(reader.state.display_error, ValueError) content = log_path.read_bytes() assert b"line 1\n" in content assert b"line 2\n" in content diff --git a/test/runtime/test_log_stream_targets.py b/test/runtime/test_log_stream_targets.py index 4dee57a2..2f316bc5 100644 --- a/test/runtime/test_log_stream_targets.py +++ b/test/runtime/test_log_stream_targets.py @@ -274,7 +274,7 @@ def failing_callback(event, step, tool): reader.join(timeout=5) assert reader.completed assert calls[0] == 1 - assert isinstance(reader.state.error, RuntimeError) + assert isinstance(reader.state.display_error, RuntimeError) # The second begin re-opened (truncated) the shared log path, so its # content proves archiving continued after the callback was disabled. assert log_path.read_bytes() == b"line 2\n" From c3829a1523354666e3b5a7ec954f650e7c050dbb Mon Sep 17 00:00:00 2001 From: Emin Date: Thu, 20 Aug 2026 05:32:02 +0800 Subject: [PATCH 49/52] fix(runtime): reconcile candidate exceptions, reap workers on SIGTERM, reset the self-archive guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The agent candidate path reconciles reader evidence before propagating a step exception, matching rerun/run_steps: no persisted Success over a partial archive. - RunOperation converts parent SIGTERM into KeyboardInterrupt on the main thread (restored on exit), routing through crash recovery so the worker's process group and EDA descendants are reaped instead of mutating the workspace after the CLI dies. - archive_own_step_logs setup failure now restores any redirected fds, closes opened descriptors, and resets the guard — a failed setup no longer poisons later in-process runs. Regressions pin all three paths. --- agent/test/test_workspace_api.py | 59 ++++++++++++++++++++++++ agent/workspace_api.py | 16 ++++++- chipcompiler/runtime/log_stream.py | 58 ++++++++++++++++------- chipcompiler/runtime/worker_operation.py | 20 ++++++++ test/runtime/test_log_stream.py | 38 +++++++++++++++ test/runtime/test_worker_operation.py | 37 +++++++++++++++ 6 files changed, 209 insertions(+), 19 deletions(-) diff --git a/agent/test/test_workspace_api.py b/agent/test/test_workspace_api.py index a400f3f7..7b623e5b 100644 --- a/agent/test/test_workspace_api.py +++ b/agent/test/test_workspace_api.py @@ -1,6 +1,10 @@ +import json +import os from pathlib import Path from types import SimpleNamespace +import pytest + from agent.requests import CandidateRerunRequest from agent.workspace_api import FlowAgentRuntimeApi, _candidate_step_artifact_dirs from chipcompiler.data import StateEnum @@ -112,6 +116,61 @@ def test_candidate_rerun_uses_the_agent_flow_and_replays_its_receipts(monkeypatc assert not list(cts_output.iterdir()) +def test_candidate_step_exception_reconciles_the_record(monkeypatch, tmp_path, capfd): + """A run_step raising after its begin marker must downgrade the record + before the exception propagates — not leave Success over a partial log.""" + (tmp_path / "home").mkdir() + steps = [{"name": "place", "tool": "dreamplace", "state": "Success"}] + (tmp_path / "home" / "flow.json").write_text(json.dumps({"steps": steps})) + workspace = SimpleNamespace( + directory=tmp_path, + flow=SimpleNamespace(data={"steps": [dict(s) for s in steps]}), + ) + place_output = tmp_path / "place_dreamplace" / "output" + place_output.mkdir(parents=True) + step = SimpleNamespace( + name="place", + tool="dreamplace", + output=EccOutput(dir=place_output), + analysis={}, + ) + flow = _Flow(workspace, (step,)) + + from chipcompiler.runtime.log_stream import emit_step_marker + + def raising_run_step(step, *, rerun): + flow.run_calls.append((step.name, rerun)) + emit_step_marker("begin", step=step.name, tool=step.tool) + os.write(2, b"partial\n") + raise RuntimeError("layout save blew up") + + flow.run_step = raising_run_step + flow.set_state = lambda name, tool, state: ( + workspace.flow.data["steps"][0].update({"state": state.value}) + ) + + api = FlowAgentRuntimeApi(_EccApi(workspace)) + monkeypatch.setattr("agent.workspace_api.build_agent_flow_for_workspace", lambda _ws: flow) + monkeypatch.setattr( + "agent.workspace_api._init_db_engine_for_workspace_step", lambda _flow, _step: None + ) + + with pytest.raises(RuntimeError, match="layout save blew up"): + api.candidate_rerun( + CandidateRerunRequest( + workspace_id="workspace-1", + target_step="place", + end_step="place", + candidate_id=None, + patch=None, + execution_scope="single_step", + ) + ) + + assert workspace.flow.data["steps"][0]["state"] == StateEnum.Imcomplete.value + assert "ECC-STEP" not in capfd.readouterr().err + + class _EccApi: def __init__(self, workspace): self.session = SimpleNamespace(workspace=workspace, db_handle=None) diff --git a/agent/workspace_api.py b/agent/workspace_api.py index b382d623..c8a2b893 100644 --- a/agent/workspace_api.py +++ b/agent/workspace_api.py @@ -269,8 +269,20 @@ def _run_candidate_step(flow, step) -> None: # In-process execution is still executor+client in one process: route the # own fd-2 stream through the reader so markers are consumed and the # step's bytes land in its archive (echoed to the real stderr). - with archive_own_step_logs(flow.workspace.directory) as reader: - state = flow.run_step(step, rerun=True) + reader = None + try: + with archive_own_step_logs(flow.workspace.directory) as active_reader: + reader = active_reader + state = flow.run_step(step, rerun=True) + except BaseException: + # A step raising after its begin marker (post-processing, the end + # write) leaves the reader holding an active step while flow.json may + # already say Success; reconcile before propagating. + if reader is not None and ( + reader.state.error is not None or reader.state.active_step is not None + ): + flow.set_state(step.name, step.tool, StateEnum.Imcomplete) + raise # An archive failure or unmatched begin must not report success while the # step's log is missing; downgrade so a later rerun rebuilds it. A None # reader means an outer client owns the stream (passthrough) — nothing to diff --git a/chipcompiler/runtime/log_stream.py b/chipcompiler/runtime/log_stream.py index fb989fd5..36fc55f2 100644 --- a/chipcompiler/runtime/log_stream.py +++ b/chipcompiler/runtime/log_stream.py @@ -395,23 +395,47 @@ def archive_own_step_logs(workspace_dir, *, echo: bool = True): flush_cstdio() real_stdout = os.dup(1) real_stderr = os.dup(2) - read_fd, write_fd = os.pipe() - os.dup2(write_fd, 1) - os.dup2(write_fd, 2) - os.close(write_fd) - - def _echo(data: bytes) -> None: - os.write(real_stderr, data) - - stream = os.fdopen(read_fd, "rb") - reader = LogStreamReader( - stream, - log_path_resolver=step_log_archive_resolver(workspace_dir), - on_output=_echo if echo else None, - valid_steps=valid_steps or None, - workspace_dir=workspace_dir, - ) - reader.start() + read_fd = -1 + write_fd = -1 + stream = None + try: + read_fd, write_fd = os.pipe() + os.dup2(write_fd, 1) + os.dup2(write_fd, 2) + os.close(write_fd) + write_fd = -1 + stream = os.fdopen(read_fd, "rb") + read_fd = -1 # the stream owns it now + + def _echo(data: bytes) -> None: + os.write(real_stderr, data) + + reader = LogStreamReader( + stream, + log_path_resolver=step_log_archive_resolver(workspace_dir), + on_output=_echo if echo else None, + valid_steps=valid_steps or None, + workspace_dir=workspace_dir, + ) + reader.start() + except BaseException: + # A setup failure must not poison later runs: restore any redirected + # descriptors, close what was opened, and release the guard. + os.dup2(real_stdout, 1) + os.dup2(real_stderr, 2) + if write_fd >= 0: + with suppress(OSError): + os.close(write_fd) + if stream is not None: + with suppress(OSError): + stream.close() + elif read_fd >= 0: + with suppress(OSError): + os.close(read_fd) + os.close(real_stdout) + os.close(real_stderr) + _SELF_ARCHIVE_ACTIVE = False + raise try: yield reader finally: diff --git a/chipcompiler/runtime/worker_operation.py b/chipcompiler/runtime/worker_operation.py index 083eeef1..18ff50f3 100644 --- a/chipcompiler/runtime/worker_operation.py +++ b/chipcompiler/runtime/worker_operation.py @@ -6,9 +6,11 @@ """ import os +import signal import subprocess import sys from collections.abc import Callable +from contextlib import suppress from dataclasses import dataclass, field from pathlib import Path @@ -101,6 +103,20 @@ def run_sequence( client = WorkerClient(self._worker_argv) reader: LogStreamReader | None = None + # The worker runs in its own process group (start_new_session), so a + # parent SIGTERM would otherwise leave it and its EDA descendants + # mutating the workspace. Convert SIGTERM into a KeyboardInterrupt on + # the main thread, which routes through crash recovery and + # terminates the worker group. (Main thread only; harmless no-op + # elsewhere.) + previous_sigterm = None + try: + previous_sigterm = signal.signal( + signal.SIGTERM, lambda _sig, _frame: (_ for _ in ()).throw(KeyboardInterrupt()) + ) + except (ValueError, OSError): + previous_sigterm = None + try: proc = client.start() @@ -183,6 +199,10 @@ def run_sequence( return self._handle_crash(client, reader, "operation interrupted") except Exception as exc: return self._handle_crash(client, reader, str(exc)) + finally: + if previous_sigterm is not None: + with suppress(OSError, ValueError): + signal.signal(signal.SIGTERM, previous_sigterm) def _handle_protocol_or_crash( self, diff --git a/test/runtime/test_log_stream.py b/test/runtime/test_log_stream.py index 463c1722..595c15a0 100644 --- a/test/runtime/test_log_stream.py +++ b/test/runtime/test_log_stream.py @@ -2,6 +2,8 @@ import io +import pytest + from chipcompiler.runtime.log_stream import LogStreamReader @@ -360,6 +362,42 @@ def test_external_client_or_nesting_passes_through(self, tmp_path, capfd): assert "raw" in capfd.readouterr().err +class TestArchiveSetupFailure: + def test_setup_failure_restores_fds_and_releases_the_guard(self, tmp_path, monkeypatch, capfd): + """A failure during setup (pipe/dup/reader start) must restore fd 1/2 + and release the guard so later runs still archive.""" + import os + + import chipcompiler.runtime.log_stream as log_stream_module + from chipcompiler.runtime.log_stream import archive_own_step_logs, emit_step_marker + + workspace = tmp_path / "ws" + (workspace / "home").mkdir(parents=True) + (workspace / "home" / "flow.json").write_text( + '{"steps": [{"name": "S", "tool": "T", "state": "Ongoing"}]}' + ) + + monkeypatch.setattr( + log_stream_module.os, "pipe", lambda: (_ for _ in ()).throw(OSError("no fds")) + ) + with ( + pytest.raises(OSError, match="no fds"), + archive_own_step_logs(workspace), + ): + pass + assert log_stream_module._SELF_ARCHIVE_ACTIVE is False + # fd 2 still reaches the terminal after the failed setup. + os.write(2, b"still alive\n") + assert "still alive" in capfd.readouterr().err + + monkeypatch.undo() + with archive_own_step_logs(workspace): + emit_step_marker("begin", step="S", tool="T") + os.write(2, b"bytes\n") + emit_step_marker("end", step="S", tool="T") + assert (workspace / "S_T" / "log" / "S.log").read_bytes() == b"bytes\n" + + class TestLogStreamResilience: def test_resolver_exception_disables_archive_continues_drain(self): """A resolver that raises must not kill the drain thread.""" diff --git a/test/runtime/test_worker_operation.py b/test/runtime/test_worker_operation.py index 50c5f758..fb5d77ce 100644 --- a/test/runtime/test_worker_operation.py +++ b/test/runtime/test_worker_operation.py @@ -297,6 +297,43 @@ def failing_repair(*args, **kwargs): # The record is left as it was — the failure is reported, not hidden. assert json.loads(flow_json.read_text())["steps"][0]["state"] == "Ongoing" + def test_parent_sigterm_terminates_the_worker_group(self, tmp_path): + """SIGTERM to the CLI must route through crash recovery and reap the + worker process group instead of leaving EDA descendants running.""" + import signal as signal_module + import threading + import time + + script = _RPC_HELPERS + textwrap.dedent("""\ + req = read_request() # hello + send_response({"jsonrpc": "2.0", "result": {"version": 1}, "id": req["id"]}) + req = read_request() # workspace.open + send_response({"jsonrpc": "2.0", "result": {"workspaceId": "x"}, "id": req["id"]}) + # Hang forever, simulating a long EDA step. + while True: + time.sleep(1) + """) + script = "import time\n" + script + flow_json = tmp_path / "flow.json" + flow_json.write_text(json.dumps({"steps": []})) + op = RunOperation( + workspace_dir=tmp_path, + flow_json_path=flow_json, + worker_argv=[sys.executable, "-c", script], + ) + + def send_sigterm(): + time.sleep(0.5) + os.kill(os.getpid(), signal_module.SIGTERM) + + killer = threading.Thread(target=send_sigterm, daemon=True) + killer.start() + result = op.run("flow.run", {"workspace_id": "test"}) + + assert result.success is False + assert "interrupted" in (result.error or "") + assert result.signal_number == -signal_module.SIGKILL or result.exit_code is not None + def test_crash_before_any_marker_repairs_persisted_ongoing(self, tmp_path): """Killed between the Ongoing save and the begin marker: no stream evidence exists, so recovery falls back to the persisted Ongoing.""" From 8ab07858bd0cfe3d49bd15c1c2264f69dd164f5b Mon Sep 17 00:00:00 2001 From: Emin Date: Thu, 20 Aug 2026 06:07:37 +0800 Subject: [PATCH 50/52] fix(runtime): archive direct run_step, pair-match repairs, preflight suffix tools - EngineFlow.run_step now also self-archives (passthrough inside worker/ sidecar processes and nested contexts), so no direct execution path leaks markers or leaves step logs unwritten. - repair_flow_state and downgrade_unarchived_step match on (name, tool): a duplicate step name under another tool is no longer downgraded by an unrelated archive failure; the reader carries error_tool for the evidence pair. - The CLI preflights every selected step's tool before the first worker call, so an unavailable tool fails the run before reset_dependents invalidates and clears the suffix. Regressions pin the pair matching, the preflight, and the direct-run archival. --- chipcompiler/cli/command_handlers/project.py | 23 +++++++++++++++++++ chipcompiler/engine/flow.py | 20 +++++++++++++++- chipcompiler/engine/rerun.py | 23 ++++++++++++------- chipcompiler/runtime/log_stream.py | 2 ++ chipcompiler/runtime/worker.py | 11 ++++++--- chipcompiler/runtime/worker_operation.py | 4 +++- test/cli/commands/test_run.py | 24 ++++++++++++++++++++ test/runtime/test_worker.py | 17 ++++++++++++++ 8 files changed, 111 insertions(+), 13 deletions(-) diff --git a/chipcompiler/cli/command_handlers/project.py b/chipcompiler/cli/command_handlers/project.py index b4d5ef70..fd541d58 100644 --- a/chipcompiler/cli/command_handlers/project.py +++ b/chipcompiler/cli/command_handlers/project.py @@ -217,6 +217,22 @@ def _load_valid_steps(workspace_dir: str) -> set[tuple[str, str]] | None: } +def _preflight_selected_tools(engine_flow, selected: list[str]) -> str | None: + """Fail before any mutation if a selected step's tool is unavailable.""" + from chipcompiler.tools.eda import load_eda_module + + tools = { + step["name"]: step.get("tool") + for step in engine_flow.workspace.flow.data.get("steps", []) + if isinstance(step, dict) and "name" in step + } + for name in selected: + tool = tools.get(name) + if tool and load_eda_module(tool, check_dependency=True) is None: + return f"tool unavailable for step {name}: {tool}" + return None + + def _make_run_operation(workspace_dir: str, *, on_output=None, on_step_event=None): """Build a RunOperation for a workspace with step-log archiving wired in.""" from pathlib import Path @@ -617,6 +633,13 @@ def no_op_result() -> CommandResult: # with every step successful: nothing to execute. return no_op_result() + # Preflight the whole selected suffix before anything is invalidated: + # the first worker call resets and clears the suffix, so discovering an + # unavailable tool mid-sequence would leave the workspace mutated. + tool_error = _preflight_selected_tools(engine_flow, selected) + if tool_error is not None: + return error("config_error", workspace=workspace_path, reason=tool_error) + target = selected[0] if command_input.only is not None: # An executed --only step always reruns with clean artifacts; the diff --git a/chipcompiler/engine/flow.py b/chipcompiler/engine/flow.py index d41a0136..355273da 100644 --- a/chipcompiler/engine/flow.py +++ b/chipcompiler/engine/flow.py @@ -4,6 +4,7 @@ import logging import os import time +from contextlib import nullcontext from threading import Event, Thread from chipcompiler.data import EccOutput, StateEnum, StepEnum, Workspace, WorkspaceStep, log_flow @@ -416,7 +417,8 @@ def run_steps(self, *, rerun: bool = False, observer=None) -> bool: # worker/sidecar process the outer client owns the stream and this # context passes through. succeeded = True - with archive_own_step_logs(self.workspace.directory) as reader: + directory = self.workspace.directory + with archive_own_step_logs(directory) if directory is not None else nullcontext() as reader: try: for workspace_step in self.workspace_steps: self.workspace.logger.log_section( @@ -483,6 +485,22 @@ def run_step( if workspace_step is None: return StateEnum.Invalid + from chipcompiler.runtime.log_stream import archive_own_step_logs + + # Direct callers get client-side archival too; inside a worker/sidecar + # process or an explicit archive context this passes through. + if self.workspace.directory is None: + return self._run_step_body(workspace_step, rerun=rerun, observer=observer) + with archive_own_step_logs(self.workspace.directory): + return self._run_step_body(workspace_step, rerun=rerun, observer=observer) + + def _run_step_body( + self, + workspace_step: WorkspaceStep, + *, + rerun: bool = False, + observer=None, + ) -> StateEnum: step_tag = f"{workspace_step.name}({workspace_step.tool})" if not rerun and self.check_state( diff --git a/chipcompiler/engine/rerun.py b/chipcompiler/engine/rerun.py index 60dd0e1c..1add3d8f 100644 --- a/chipcompiler/engine/rerun.py +++ b/chipcompiler/engine/rerun.py @@ -167,19 +167,26 @@ def downgrade_unarchived_step(flow: "EngineFlow", reader, executed: list[str]) - incomplete repair explicitly so the stale record is not silently trusted. """ target = reader.state.error_step or reader.state.active_step + target_tool = reader.state.error_tool or reader.state.active_tool if target is None and executed: target = executed[-1] + target_tool = None if target is None: return None for record in flow.workspace.flow.data.get("steps", []): - if record.get("name") == target: - if not flow.set_state(target, record.get("tool", ""), StateEnum.Imcomplete): - flow.workspace.logger.error( - "archive downgrade for step %s could not be persisted; " - "flow.json may still claim Success — repair it before resuming", - target, - ) - break + if record.get("name") != target: + continue + # A flow may carry the same step name under two tools; only the + # evidenced (name, tool) pair may be downgraded. + if target_tool is not None and record.get("tool") != target_tool: + continue + if not flow.set_state(target, record.get("tool", ""), StateEnum.Imcomplete): + flow.workspace.logger.error( + "archive downgrade for step %s could not be persisted; " + "flow.json may still claim Success — repair it before resuming", + target, + ) + break return target diff --git a/chipcompiler/runtime/log_stream.py b/chipcompiler/runtime/log_stream.py index 36fc55f2..e460bbde 100644 --- a/chipcompiler/runtime/log_stream.py +++ b/chipcompiler/runtime/log_stream.py @@ -110,6 +110,7 @@ class LogStreamState: # Display-callback failures live apart from archival failures: a broken # renderer must never read as a broken archive. display_error: Exception | None = None + error_tool: str | None = None class LogStreamReader: @@ -152,6 +153,7 @@ def _record_error(self, exc: Exception) -> None: if self._state.error is None: self._state.error = exc self._state.error_step = self._state.active_step + self._state.error_tool = self._state.active_tool def start(self) -> None: self._thread = threading.Thread(target=self._drain_loop, name="ecc-log-reader", daemon=True) diff --git a/chipcompiler/runtime/worker.py b/chipcompiler/runtime/worker.py index baff1961..3f9fb421 100644 --- a/chipcompiler/runtime/worker.py +++ b/chipcompiler/runtime/worker.py @@ -245,14 +245,17 @@ def classify_worker_exit(proc: subprocess.Popen) -> WorkerResult: return WorkerResult(success=False, exit_code=code, error=f"worker exited with code {code}") -def repair_flow_state(flow_json_path: str | Path, *, active_step: str) -> list[str]: +def repair_flow_state( + flow_json_path: str | Path, *, active_step: str, active_tool: str | None = None +) -> list[str]: """Repair the step left unfinished by a crashed worker, setting it to Incomplete. - Operation-scoped: only the active_step is repaired. The caller must identify + Operation-scoped: only the active step is repaired. The caller must identify which step was owned by the crashed operation. Both Ongoing and Success records are repaired: a Success without a completed end marker means the crash interrupted the step's post-processing, so its persisted result is - not trustworthy. + not trustworthy. When active_tool is given, only the matching (name, tool) + record is touched — a flow may carry the same step name under two tools. Returns the list of step names that were repaired. Raises OSError if the repaired state cannot be persisted. @@ -275,6 +278,8 @@ def repair_flow_state(flow_json_path: str | Path, *, active_step: str) -> list[s step_name = step.get("name", "") if step_name != active_step: continue + if active_tool is not None and step.get("tool") != active_tool: + continue step["state"] = "Incomplete" repaired.append(step_name) diff --git a/chipcompiler/runtime/worker_operation.py b/chipcompiler/runtime/worker_operation.py index 18ff50f3..b4060969 100644 --- a/chipcompiler/runtime/worker_operation.py +++ b/chipcompiler/runtime/worker_operation.py @@ -286,6 +286,7 @@ def _reconcile_step_state( if log_state is None: return [], None step = log_state.active_step or log_state.error_step + tool = log_state.active_tool or log_state.error_tool if step is None and log_state.error is not None and log_state.steps_seen: step = log_state.steps_seen[-1] if step is None and self._flow_json_path.exists(): @@ -299,10 +300,11 @@ def _reconcile_step_state( ] if len(ongoing) == 1: step = ongoing[0].get("name") + tool = ongoing[0].get("tool") if step is None or not self._flow_json_path.exists(): return [], None try: - return repair_flow_state(self._flow_json_path, active_step=step), None + return repair_flow_state(self._flow_json_path, active_step=step, active_tool=tool), None except OSError as exc: return [], f"state repair failed: {exc}" diff --git a/test/cli/commands/test_run.py b/test/cli/commands/test_run.py index 1c025689..a8c1fb7b 100644 --- a/test/cli/commands/test_run.py +++ b/test/cli/commands/test_run.py @@ -374,6 +374,30 @@ def test_from_step_wiring(self, workspace_mocks, tmp_path, capsys): ] assert record["executed_steps"] == ["CTS"] + def test_preflight_rejects_an_unavailable_tool_before_any_mutation( + self, workspace_mocks, tmp_path, capsys, monkeypatch + ): + """A later step with an unavailable tool fails before the first + worker call — nothing is invalidated or deleted.""" + import chipcompiler.tools.eda as eda_module + + real_load = eda_module.load_eda_module + + def fake_load(tool, *, check_dependency=True): + if tool == "ecc" and check_dependency: + return None # CTS's tool is unavailable + return real_load(tool, check_dependency=check_dependency) + + monkeypatch.setattr("chipcompiler.tools.eda.load_eda_module", fake_load) + workspace = str(tmp_path / "workspace") + + rc = cli_main.run(["run", "--workspace", workspace, "--from", "place", "--json"]) + + record = json.loads(capsys.readouterr().out)["records"][0] + assert rc != 0 + assert record["error"] == "config_error" + assert workspace_mocks.calls is None or workspace_mocks.calls == [] + def test_from_step_never_runs_steps_before_the_boundary( self, workspace_mocks, tmp_path, capsys ): diff --git a/test/runtime/test_worker.py b/test/runtime/test_worker.py index 08765c27..57811348 100644 --- a/test/runtime/test_worker.py +++ b/test/runtime/test_worker.py @@ -103,6 +103,23 @@ def test_repairs_ongoing_to_incomplete(self, tmp_path): assert result["steps"][0]["state"] == "Success" assert result["steps"][2]["state"] == "Unstart" + def test_repairs_only_the_matching_tool_for_duplicate_names(self, tmp_path): + """A flow may carry the same step name under two tools; repair must + match the (name, tool) pair and never touch the other record.""" + flow_json = tmp_path / "flow.json" + data = { + "steps": [ + {"name": "place", "tool": "yosys", "state": "Success"}, + {"name": "place", "tool": "ecc", "state": "Ongoing"}, + ] + } + flow_json.write_text(json.dumps(data)) + repaired = repair_flow_state(flow_json, active_step="place", active_tool="ecc") + assert repaired == ["place"] + result = json.loads(flow_json.read_text()) + assert result["steps"][0]["state"] == "Success" + assert result["steps"][1]["state"] == "Incomplete" + def test_repairs_active_success_interrupted_after_persisting(self, tmp_path): """A Success without a completed end marker crashed in post-processing.""" flow_json = tmp_path / "flow.json" From 014ce681d425dc61250ce3f7e67229e9a9024464 Mon Sep 17 00:00:00 2001 From: Emin Date: Thu, 20 Aug 2026 06:53:08 +0800 Subject: [PATCH 51/52] refactor(engine,cli): decompose runner and workspace-run modules past the 700-line bar - engine/runner.py: EngineFlowRunner mixin owns the step/flow execution lifecycle (markers, memory tracking, authoritative final save, post-processing, db cleanup, observer/render gate); flow.py keeps the data/state/build spine at 321 lines. run_step now reconciles the self-archive reader on both success and exception paths. - cli/command_handlers/workspace_run.py: worker-call construction, suffix preflight, and outcome reconciliation move out of project.py (499 lines). The preflight now catches dependency-check exceptions and covers the sizer runtime sentinel (src/sizer_os.tcl), and archive_own_step_logs guards descriptor-duplication failures so a broken setup releases the guard and restores fds. Regressions pin direct run_step downgrade, preflight rejection without mutation, and setup-failure recovery. --- chipcompiler/cli/command_handlers/project.py | 240 +--------- .../cli/command_handlers/workspace_run.py | 265 +++++++++++ chipcompiler/engine/flow.py | 397 +--------------- chipcompiler/engine/runner.py | 426 ++++++++++++++++++ chipcompiler/runtime/log_stream.py | 22 +- test/cli/commands/test_run.py | 4 +- test/test_engine_flow.py | 32 +- test/tools/ecc_sizer/test_runner.py | 2 +- 8 files changed, 753 insertions(+), 635 deletions(-) create mode 100644 chipcompiler/cli/command_handlers/workspace_run.py create mode 100644 chipcompiler/engine/runner.py diff --git a/chipcompiler/cli/command_handlers/project.py b/chipcompiler/cli/command_handlers/project.py index fd541d58..3ba31286 100644 --- a/chipcompiler/cli/command_handlers/project.py +++ b/chipcompiler/cli/command_handlers/project.py @@ -1,9 +1,18 @@ import contextlib import os -import shlex import shutil import sys +from chipcompiler.cli.command_handlers.workspace_run import ( # noqa: F401 + _load_valid_steps, + _make_run_operation, + _preflight_selected_tools, + _read_flow_data, + _run_flow_via_worker, + _run_worker_calls, + _run_workspace, + _workspace_run_outcome, +) from chipcompiler.cli.core.inputs import CheckInput, InitInput, RunInput from chipcompiler.cli.core.output import disclosure_cmd from chipcompiler.cli.core.records import error_record @@ -196,85 +205,6 @@ def _worker_binary_missing_error() -> str | None: return None -def _read_flow_data(workspace_dir: str) -> dict | None: - """Read home/flow.json, tolerating a missing or corrupt file.""" - from chipcompiler.cli.inspection.discovery import CORRUPT_FLOW_JSON, read_flow_json - - flow_data = read_flow_json(workspace_dir) - if flow_data is None or flow_data is CORRUPT_FLOW_JSON: - return None - return flow_data - - -def _load_valid_steps(workspace_dir: str) -> set[tuple[str, str]] | None: - flow_data = _read_flow_data(workspace_dir) - if flow_data is None: - return None - return { - (s["name"], s["tool"]) - for s in flow_data.get("steps", []) - if isinstance(s, dict) and "name" in s and "tool" in s - } - - -def _preflight_selected_tools(engine_flow, selected: list[str]) -> str | None: - """Fail before any mutation if a selected step's tool is unavailable.""" - from chipcompiler.tools.eda import load_eda_module - - tools = { - step["name"]: step.get("tool") - for step in engine_flow.workspace.flow.data.get("steps", []) - if isinstance(step, dict) and "name" in step - } - for name in selected: - tool = tools.get(name) - if tool and load_eda_module(tool, check_dependency=True) is None: - return f"tool unavailable for step {name}: {tool}" - return None - - -def _make_run_operation(workspace_dir: str, *, on_output=None, on_step_event=None): - """Build a RunOperation for a workspace with step-log archiving wired in.""" - from pathlib import Path - - from chipcompiler.runtime.log_stream import step_log_archive_resolver - from chipcompiler.runtime.worker_operation import RunOperation - - return RunOperation( - workspace_dir=Path(workspace_dir), - flow_json_path=Path(workspace_dir) / "home" / "flow.json", - log_path_resolver=step_log_archive_resolver(workspace_dir), - on_output=on_output, - on_step_event=on_step_event, - valid_steps=_load_valid_steps(workspace_dir), - ) - - -def _run_worker_calls(workspace_dir: str, calls: list[tuple[str, dict]], **callbacks): - """Execute an ordered RPC sequence through the workspace's worker. - - A missing worker binary is a structured failure, never a crash. - """ - from chipcompiler.runtime.worker_operation import OperationResult - - missing = _worker_binary_missing_error() - if missing is not None: - return OperationResult(success=False, error=missing) - - op = _make_run_operation(workspace_dir, **callbacks) - return op.run_sequence(calls) - - -def _run_flow_via_worker(workspace_dir: str, *, on_output=None, on_step_event=None): - """Execute flow.run through an isolated worker process.""" - return _run_worker_calls( - workspace_dir, - [("flow.run", {"rerun": False})], - on_output=on_output, - on_step_event=on_step_event, - ) - - def run(command_input: RunInput, ctx: CommandContext) -> CommandResult: if command_input.workspace is not None: return _run_workspace(command_input, ctx) @@ -563,153 +493,3 @@ def run(command_input: RunInput, ctx: CommandContext) -> CommandResult: } ] ) - - -def _run_workspace(command_input: RunInput, ctx: CommandContext) -> CommandResult: - def error(kind: str, **fields) -> CommandResult: - return CommandResult.err([{"kind": "error", "error": kind, **fields}]) - - if ctx.project is not None or command_input.project.run_id is not None: - return error("project_workspace_conflict") - if command_input.overwrite: - return error("overwrite_requires_project") - if command_input.param_set: - return error("set_requires_project") - selectors = sum( - ( - command_input.resume, - command_input.from_step is not None, - command_input.only is not None, - ) - ) - if selectors > 1: - return error("selector_conflict") - if command_input.force and command_input.only is None: - return error("force_requires_only") - - from chipcompiler.data import load_workspace - from chipcompiler.engine import EngineFlow, rerun - - workspace_path = os.path.abspath(os.path.expanduser(command_input.workspace)) - try: - workspace = load_workspace(workspace_path) - except Exception as exc: - return error("invalid_workspace", workspace=workspace_path, reason=str(exc)) - if workspace is None: - return error("invalid_workspace", workspace=workspace_path) - - try: - engine_flow = EngineFlow(workspace=workspace) - except Exception as exc: - return error("invalid_workspace", workspace=workspace_path, reason=str(exc)) - if not engine_flow.has_init(): - return error("missing_flow", workspace=workspace_path) - - try: - selected = rerun.selected_step_names( - engine_flow, - from_step=command_input.from_step, - only=command_input.only, - force=command_input.force, - ) - except ValueError as exc: - return error("unknown_step", workspace=workspace_path, reason=str(exc)) - - def no_op_result() -> CommandResult: - return CommandResult.ok( - [ - { - "run": "workspace", - "status": "success", - "workspace": workspace_path, - "executed_steps": [], - "no_op": True, - } - ] - ) - - if not selected: - # --only on an already-successful step without --force, or --resume - # with every step successful: nothing to execute. - return no_op_result() - - # Preflight the whole selected suffix before anything is invalidated: - # the first worker call resets and clears the suffix, so discovering an - # unavailable tool mid-sequence would leave the workspace mutated. - tool_error = _preflight_selected_tools(engine_flow, selected) - if tool_error is not None: - return error("config_error", workspace=workspace_path, reason=tool_error) - - target = selected[0] - if command_input.only is not None: - # An executed --only step always reruns with clean artifacts; the - # --force distinction only gates whether a successful step qualifies. - # Downstream steps keep their outputs but are marked Unstart. - calls = [("flow.run_step", {"step": target, "rerun": True, "invalidate_dependents": True})] - else: - # --resume/--from run exactly the selected suffix, step by step. A - # trailing unscoped flow.run would resume from the FIRST non-success - # step — possibly before the --from boundary — so the suffix is - # driven as explicit run_step calls instead. - calls = [("flow.run_step", {"step": target, "rerun": True, "reset_dependents": True})] - calls += [("flow.run_step", {"step": name, "rerun": True}) for name in selected[1:]] - - op_result = _run_worker_calls(workspace_path, calls) - - if op_result.success: - return CommandResult.ok( - [ - { - "run": "workspace", - "status": "success", - "workspace": workspace_path, - "executed_steps": list(selected), - "no_op": False, - } - ] - ) - - executed, failed_step = _workspace_run_outcome(workspace_path, selected) - record = { - "run": "workspace", - "status": "failed", - "workspace": workspace_path, - "executed_steps": executed, - "no_op": False, - "resume_cmd": f"ecc run --workspace {shlex.quote(workspace_path)} --resume", - } - if failed_step is not None: - record["failed_step"] = failed_step - if op_result.error: - record["error"] = op_result.error - if op_result.exit_code is not None: - record["exit_code"] = op_result.exit_code - if op_result.repaired_steps: - record["repaired_steps"] = op_result.repaired_steps - return CommandResult.err([record]) - - -def _workspace_run_outcome( - workspace_path: str, selected: list[str] -) -> tuple[list[str], str | None]: - """Derive executed steps and the failed step from post-run flow.json. - - After a stopped sequence the selected suffix reads as: a Success prefix - that did execute, the step that failed, and an Unstart remainder that was - invalidated but never ran. - """ - flow_data = _read_flow_data(workspace_path) - if flow_data is None: - return [], None - - states = { - record["name"]: record.get("state") - for record in flow_data.get("steps", []) - if isinstance(record, dict) and "name" in record - } - executed = [] - for name in selected: - if states.get(name) != "Success": - return executed, name - executed.append(name) - return executed, None diff --git a/chipcompiler/cli/command_handlers/workspace_run.py b/chipcompiler/cli/command_handlers/workspace_run.py new file mode 100644 index 00000000..1ce9fad0 --- /dev/null +++ b/chipcompiler/cli/command_handlers/workspace_run.py @@ -0,0 +1,265 @@ +"""Workspace-run orchestration behind `ecc run --workspace`. + +Owns the worker-call construction, tool preflight, and outcome +reconciliation for `--resume`/`--from`/`--only` selections. The CLI handler +in project.py stays a thin dispatcher. +""" + +import os +import shlex + +from chipcompiler.cli.core.inputs import RunInput +from chipcompiler.cli.core.types import CommandContext, CommandResult + + +def _worker_binary_missing_error() -> str | None: + from chipcompiler.runtime.worker_operation import _default_worker_argv + + argv = _default_worker_argv() + if not os.path.isfile(argv[0]): + return f"worker binary not found: {argv[0]}" + return None + + +def _read_flow_data(workspace_dir: str) -> dict | None: + """Read home/flow.json, tolerating a missing or corrupt file.""" + from chipcompiler.cli.inspection.discovery import CORRUPT_FLOW_JSON, read_flow_json + + flow_data = read_flow_json(workspace_dir) + if flow_data is None or flow_data is CORRUPT_FLOW_JSON: + return None + return flow_data + + +def _load_valid_steps(workspace_dir: str) -> set[tuple[str, str]] | None: + flow_data = _read_flow_data(workspace_dir) + if flow_data is None: + return None + return { + (s["name"], s["tool"]) + for s in flow_data.get("steps", []) + if isinstance(s, dict) and "name" in s and "tool" in s + } + + +def _preflight_selected_tools(engine_flow, selected: list[str]) -> str | None: + """Fail before any mutation if a selected step's tool is unavailable.""" + from chipcompiler.tools.eda import load_eda_module + + tools = { + step["name"]: step.get("tool") + for step in engine_flow.workspace.flow.data.get("steps", []) + if isinstance(step, dict) and "name" in step + } + for name in selected: + tool = tools.get(name) + if not tool: + continue + try: + if load_eda_module(tool, check_dependency=True) is None: + return f"tool unavailable for step {name}: {tool}" + except Exception as exc: + # Dependency checks raise (e.g. yosys is_eda_exist) rather than + # returning None; both shapes mean unavailable. + return f"tool unavailable for step {name}: {tool} ({exc})" + if tool == "sizer": + from chipcompiler.tools.ecc_sizer import is_sizer_runtime_exist + + try: + if not is_sizer_runtime_exist(): + return f"sizer runtime incomplete for step {name}: missing src/sizer_os.tcl" + except Exception as exc: + return f"sizer runtime check failed for step {name}: {exc}" + return None + + +def _make_run_operation(workspace_dir: str, *, on_output=None, on_step_event=None): + """Build a RunOperation for a workspace with step-log archiving wired in.""" + from pathlib import Path + + from chipcompiler.runtime.log_stream import step_log_archive_resolver + from chipcompiler.runtime.worker_operation import RunOperation + + return RunOperation( + workspace_dir=Path(workspace_dir), + flow_json_path=Path(workspace_dir) / "home" / "flow.json", + log_path_resolver=step_log_archive_resolver(workspace_dir), + on_output=on_output, + on_step_event=on_step_event, + valid_steps=_load_valid_steps(workspace_dir), + ) + + +def _run_worker_calls(workspace_dir: str, calls: list[tuple[str, dict]], **callbacks): + """Execute an ordered RPC sequence through the workspace's worker. + + A missing worker binary is a structured failure, never a crash. + """ + from chipcompiler.runtime.worker_operation import OperationResult + + missing = _worker_binary_missing_error() + if missing is not None: + return OperationResult(success=False, error=missing) + + op = _make_run_operation(workspace_dir, **callbacks) + return op.run_sequence(calls) + + +def _run_flow_via_worker(workspace_dir: str, *, on_output=None, on_step_event=None): + """Execute flow.run through an isolated worker process.""" + return _run_worker_calls( + workspace_dir, + [("flow.run", {"rerun": False})], + on_output=on_output, + on_step_event=on_step_event, + ) + + +def _run_workspace(command_input: RunInput, ctx: CommandContext) -> CommandResult: + def error(kind: str, **fields) -> CommandResult: + return CommandResult.err([{"kind": "error", "error": kind, **fields}]) + + if ctx.project is not None or command_input.project.run_id is not None: + return error("project_workspace_conflict") + if command_input.overwrite: + return error("overwrite_requires_project") + if command_input.param_set: + return error("set_requires_project") + selectors = sum( + ( + command_input.resume, + command_input.from_step is not None, + command_input.only is not None, + ) + ) + if selectors > 1: + return error("selector_conflict") + if command_input.force and command_input.only is None: + return error("force_requires_only") + + from chipcompiler.data import load_workspace + from chipcompiler.engine import EngineFlow, rerun + + workspace_path = os.path.abspath(os.path.expanduser(command_input.workspace)) + try: + workspace = load_workspace(workspace_path) + except Exception as exc: + return error("invalid_workspace", workspace=workspace_path, reason=str(exc)) + if workspace is None: + return error("invalid_workspace", workspace=workspace_path) + + try: + engine_flow = EngineFlow(workspace=workspace) + except Exception as exc: + return error("invalid_workspace", workspace=workspace_path, reason=str(exc)) + if not engine_flow.has_init(): + return error("missing_flow", workspace=workspace_path) + + try: + selected = rerun.selected_step_names( + engine_flow, + from_step=command_input.from_step, + only=command_input.only, + force=command_input.force, + ) + except ValueError as exc: + return error("unknown_step", workspace=workspace_path, reason=str(exc)) + + def no_op_result() -> CommandResult: + return CommandResult.ok( + [ + { + "run": "workspace", + "status": "success", + "workspace": workspace_path, + "executed_steps": [], + "no_op": True, + } + ] + ) + + if not selected: + # --only on an already-successful step without --force, or --resume + # with every step successful: nothing to execute. + return no_op_result() + + # Preflight the whole selected suffix before anything is invalidated: + # the first worker call resets and clears the suffix, so discovering an + # unavailable tool mid-sequence would leave the workspace mutated. + tool_error = _preflight_selected_tools(engine_flow, selected) + if tool_error is not None: + return error("config_error", workspace=workspace_path, reason=tool_error) + + target = selected[0] + if command_input.only is not None: + # An executed --only step always reruns with clean artifacts; the + # --force distinction only gates whether a successful step qualifies. + # Downstream steps keep their outputs but are marked Unstart. + calls = [("flow.run_step", {"step": target, "rerun": True, "invalidate_dependents": True})] + else: + # --resume/--from run exactly the selected suffix, step by step. A + # trailing unscoped flow.run would resume from the FIRST non-success + # step — possibly before the --from boundary — so the suffix is + # driven as explicit run_step calls instead. + calls = [("flow.run_step", {"step": target, "rerun": True, "reset_dependents": True})] + calls += [("flow.run_step", {"step": name, "rerun": True}) for name in selected[1:]] + + op_result = _run_worker_calls(workspace_path, calls) + + if op_result.success: + return CommandResult.ok( + [ + { + "run": "workspace", + "status": "success", + "workspace": workspace_path, + "executed_steps": list(selected), + "no_op": False, + } + ] + ) + + executed, failed_step = _workspace_run_outcome(workspace_path, selected) + record = { + "run": "workspace", + "status": "failed", + "workspace": workspace_path, + "executed_steps": executed, + "no_op": False, + "resume_cmd": f"ecc run --workspace {shlex.quote(workspace_path)} --resume", + } + if failed_step is not None: + record["failed_step"] = failed_step + if op_result.error: + record["error"] = op_result.error + if op_result.exit_code is not None: + record["exit_code"] = op_result.exit_code + if op_result.repaired_steps: + record["repaired_steps"] = op_result.repaired_steps + return CommandResult.err([record]) + + +def _workspace_run_outcome( + workspace_path: str, selected: list[str] +) -> tuple[list[str], str | None]: + """Derive executed steps and the failed step from post-run flow.json. + + After a stopped sequence the selected suffix reads as: a Success prefix + that did execute, the step that failed, and an Unstart remainder that was + invalidated but never ran. + """ + flow_data = _read_flow_data(workspace_path) + if flow_data is None: + return [], None + + states = { + record["name"]: record.get("state") + for record in flow_data.get("steps", []) + if isinstance(record, dict) and "name" in record + } + executed = [] + for name in selected: + if states.get(name) != "Success": + return executed, name + executed.append(name) + return executed, None diff --git a/chipcompiler/engine/flow.py b/chipcompiler/engine/flow.py index 355273da..b241a9c5 100644 --- a/chipcompiler/engine/flow.py +++ b/chipcompiler/engine/flow.py @@ -1,14 +1,11 @@ #!/usr/bin/env python -import hashlib import logging import os -import time -from contextlib import nullcontext -from threading import Event, Thread -from chipcompiler.data import EccOutput, StateEnum, StepEnum, Workspace, WorkspaceStep, log_flow +from chipcompiler.data import EccOutput, StateEnum, StepEnum, Workspace, WorkspaceStep from chipcompiler.engine import EngineDB +from chipcompiler.engine.runner import EngineFlowRunner from chipcompiler.engine.signoff import ( SignoffPackageCollector, SignoffPackageOptions, @@ -34,28 +31,7 @@ ) -def get_process_rss_mb(pid: int) -> float: - peak_memory = 0 - try: - with open(f"/proc/{pid}/status") as f: - for line in f: - if line.startswith("VmRSS:"): - rss_kb = int(line.split()[1]) - peak_memory = rss_kb / 1024 - break - except (OSError, ValueError): - pass - return peak_memory - - -def track_current_process_memory(pid: int, stop_event: Event, peak_memory: list[float]): - while not stop_event.is_set(): - peak_memory[0] = max(peak_memory[0], get_process_rss_mb(pid)) - stop_event.wait(0.1) - peak_memory[0] = max(peak_memory[0], get_process_rss_mb(pid)) - - -class EngineFlow: +class EngineFlow(EngineFlowRunner): def __init__(self, workspace: Workspace, engine_db: EngineDB = None): self.workspace = workspace self.workspace_steps = [] @@ -204,7 +180,6 @@ def check_step_result(self, workspace_step: WorkspaceStep): """ check step output exist """ - import os success = False output = workspace_step.output @@ -344,369 +319,3 @@ def init_db_engine(self) -> bool: break return self.engine_db.create_db_engine(step=workspace_step) - - def clear_db_engine_after_step(self, workspace_step: WorkspaceStep, state: StateEnum) -> None: - if workspace_step.tool == "sizer" and state == StateEnum.Success: - engine_db = self.engine_db - self.engine_db = None - if engine_db is not None: - close = getattr(engine_db, "close", None) - if callable(close): - close() - - def timing_constraint_facts(self) -> dict: - sdc_path = self.workspace.pdk.sdc - if sdc_path is None: - return {"availability": "missing_source"} - - try: - path = os.fspath(sdc_path) - size_bytes = os.path.getsize(path) - digest = hashlib.sha256() - with open(path, "rb") as sdc_file: - for chunk in iter(lambda: sdc_file.read(1024 * 1024), b""): - digest.update(chunk) - except OSError: - return {"availability": "unreadable"} - - return { - "availability": "available", - "sha256": digest.hexdigest(), - "size_bytes": size_bytes, - } - - def save_step_flow_facts( - self, - workspace_step: WorkspaceStep, - state: StateEnum, - runtime_seconds: float, - peak_memory_mb: float, - timing_constraints: dict, - ) -> bool: - feature_path = getattr(workspace_step.feature, "step", None) - if feature_path is None or feature_path == "": - return False - - from chipcompiler.utility import JsonReadError, json_read_strict, json_write - - try: - existing = json_read_strict(feature_path) - except (FileNotFoundError, JsonReadError): - existing = {} - payload = existing if isinstance(existing, dict) else {} - payload["run"] = { - "state": state.value, - "runtime_seconds": round(runtime_seconds, 3), - "peak_memory_mb": round(peak_memory_mb, 3), - } - payload["constraints"] = {"sdc": timing_constraints} - return json_write(file_path=feature_path, data=payload) - - return True - - def run_steps(self, *, rerun: bool = False, observer=None) -> bool: - """ - run all flow steps - """ - from chipcompiler.runtime.log_stream import archive_own_step_logs - - from .rerun import downgrade_unarchived_step - - # Direct in-process runs (documented Python API) self-archive so step - # logs exist and markers stay off the caller's terminal; inside a - # worker/sidecar process the outer client owns the stream and this - # context passes through. - succeeded = True - directory = self.workspace.directory - with archive_own_step_logs(directory) if directory is not None else nullcontext() as reader: - try: - for workspace_step in self.workspace_steps: - self.workspace.logger.log_section( - f"{workspace_step.tool} - begin step - {workspace_step.name}" - ) - self.init_db_engine() - state = ( - self.run_step(workspace_step, rerun=rerun) - if observer is None - else self.run_step(workspace_step, rerun=rerun, observer=observer) - ) - - log_flow(workspace=self.workspace) - self.workspace.logger.log_section( - f"{workspace_step.tool} - end step - {workspace_step.name}" - ) - - match state: - case StateEnum.Success: - continue - case _: - succeeded = False - break - except BaseException: - # Reconcile archive evidence before the exception propagates. - if reader is not None: - downgrade_unarchived_step(self, reader, []) - raise - - # An archive failure or unmatched begin must not report success over a - # missing step log; reconcile after the reader drains. (reader is None - # when an outer client owns the stream — nothing to reconcile here.) - if reader is not None and ( - reader.state.error is not None or reader.state.active_step is not None - ): - downgrade_unarchived_step(self, reader, []) - succeeded = False - if not succeeded: - return False - - total_steps = len(self.workspace.flow.data.get("steps", [])) - if len(self.workspace_steps) < total_steps: - self.workspace.logger.error( - "Flow incomplete: %d of %d steps were created; remaining steps could not be set up", - len(self.workspace_steps), - total_steps, - ) - return False - - return True - - def run_step( - self, - workspace_step: WorkspaceStep | str, - *, - rerun: bool = False, - observer=None, - ) -> StateEnum: - """ - run single step - """ - if isinstance(workspace_step, str): - workspace_step = self.get_workspace_step(workspace_step) - if workspace_step is None: - return StateEnum.Invalid - - from chipcompiler.runtime.log_stream import archive_own_step_logs - - # Direct callers get client-side archival too; inside a worker/sidecar - # process or an explicit archive context this passes through. - if self.workspace.directory is None: - return self._run_step_body(workspace_step, rerun=rerun, observer=observer) - with archive_own_step_logs(self.workspace.directory): - return self._run_step_body(workspace_step, rerun=rerun, observer=observer) - - def _run_step_body( - self, - workspace_step: WorkspaceStep, - *, - rerun: bool = False, - observer=None, - ) -> StateEnum: - step_tag = f"{workspace_step.name}({workspace_step.tool})" - - if not rerun and self.check_state( - name=workspace_step.name, tool=workspace_step.tool, state=StateEnum.Success - ): - self.workspace.logger.info("[SKIP] %s already succeeded", step_tag) - self.clear_db_engine_after_step(workspace_step, StateEnum.Success) - _notify_flow_observer(observer, "on_step_skipped", workspace_step) - return StateEnum.Success - - # set state ongoing - start_time = time.time() - timing_constraints = self.timing_constraint_facts() - self.set_state(name=workspace_step.name, tool=workspace_step.tool, state=StateEnum.Ongoing) - _notify_flow_observer(observer, "on_step_started", workspace_step) - - self.workspace.logger.info(f"[STEP] {step_tag} pid={os.getpid()} started") - - from chipcompiler.runtime.log_stream import emit_step_marker - - try: - emit_step_marker("begin", step=workspace_step.name, tool=workspace_step.tool) - except OSError: - # fd 2 is closed or the reader pipe is broken: the marker never - # reached any client, so recovery could never identify this step - # from the stream. Downgrade the persisted Ongoing now instead of - # leaving a permanent Ongoing no repair pass can find. - self.set_state( - name=workspace_step.name, tool=workspace_step.tool, state=StateEnum.Imcomplete - ) - raise - - pid = os.getpid() - start_memory_mb = get_process_rss_mb(pid) - peak_memory = [start_memory_mb] - stop_memory_monitor = Event() - memory_monitor = Thread( - target=track_current_process_memory, - args=(pid, stop_memory_monitor, peak_memory), - daemon=True, - ) - memory_monitor.start() - previous_observer = getattr(self.workspace, "_runtime_flow_observer", None) - if observer is not None: - self.workspace._runtime_flow_observer = observer - step_raised_exception = False - result = None - try: - result = self._invoke_step_tool(workspace_step) - self.workspace.logger.info(f"[STEP] {step_tag} finished result={result}") - except Exception: - step_raised_exception = True - self.workspace.logger.error(f"[STEP] {step_tag} failed with exception") - self.workspace.logger.exception(f"[STEP] {step_tag} exception details") - finally: - stop_memory_monitor.set() - memory_monitor.join() - if observer is not None: - if previous_observer is None: - delattr(self.workspace, "_runtime_flow_observer") - else: - self.workspace._runtime_flow_observer = previous_observer - - # compute metrics - peak_memory_mb = peak_memory[0] - start_memory_mb - peak_memory_mb = 0 if peak_memory_mb < 0 else round(peak_memory_mb, 3) - elapsed = time.time() - start_time - runtime = f"{int(elapsed // 3600)}:{int((elapsed % 3600) // 60)}:{int(elapsed % 60)}" - - # determine and save state - state = self._derive_step_state(workspace_step, result, raised=step_raised_exception) - - persisted = self.set_state( - name=workspace_step.name, - tool=workspace_step.tool, - state=state, - runtime=runtime, - peak_memory=peak_memory_mb, - ) - if not persisted: - # The marker protocol guarantees the final state is persisted - # before the end marker; a failed save makes the run's result - # untrustworthy. Downgrade the canonical in-memory record (the - # downgrade itself is not persisted — the save just failed), - # suppress the end marker, and report the step incomplete. - state = StateEnum.Imcomplete - record = self.get_step(workspace_step.name, workspace_step.tool) - if record is not None: - record["state"] = StateEnum.Imcomplete.value - self.workspace.logger.error( - "[RESULT] %s final state could not be persisted; marking step Imcomplete", - step_tag, - ) - self.workspace.logger.info( - "[RESULT] %s state=%s runtime=%s mem=%sMB exitcode=%s", - step_tag, - state.value, - runtime, - peak_memory_mb, - 0, - ) - - # save layout snapshot on success - if state == StateEnum.Success: - if self.save_step_flow_facts( - workspace_step=workspace_step, - state=state, - runtime_seconds=elapsed, - peak_memory_mb=peak_memory_mb, - timing_constraints=timing_constraints, - ): - try: - from chipcompiler.tools import build_step_metrics - - if build_step_metrics(workspace=self.workspace, step=workspace_step) is None: - self.workspace.logger.warning( - "[QOR] %s run facts were saved but analysis refresh is unavailable", - step_tag, - ) - except Exception: - self.workspace.logger.exception( - "[QOR] %s failed to refresh analysis after saving run facts", - step_tag, - ) - else: - self.workspace.logger.warning( - "[QOR] %s has no step feature path; run facts were not saved", - step_tag, - ) - from chipcompiler.tools import save_layout_image - - save_layout_image(workspace=self.workspace, step=workspace_step) - - self.clear_db_engine_after_step(workspace_step, state) - # The end marker closes the step's byte stream only after every - # step-scoped write (state persistence, [RESULT], QOR, layout, db - # cleanup) has flushed, and always before the completion notification. - # When the final state could not be persisted, the marker stays - # unwritten: consumers treat the step as crashed and repair its state. - if persisted: - emit_step_marker("end", step=workspace_step.name, tool=workspace_step.tool) - _notify_flow_observer(observer, "on_step_completed", workspace_step, state) - if state == StateEnum.Success and not _wait_for_step_rendered( - observer, - workspace_step, - state, - ): - return StateEnum.Invalid - - return state - - def init_db_engine_for_step(self, workspace_step: WorkspaceStep) -> bool: - """Initialize the native DB engine from an explicitly selected step.""" - if self.engine_db is None: - self.engine_db = EngineDB(workspace=self.workspace) - elif self.engine_db.has_init(): - return True - - return self.engine_db.create_db_engine(step=workspace_step) - - def _invoke_step_tool(self, workspace_step: WorkspaceStep): - """Run the step's tool. Subclasses redirect to their own runner.""" - from chipcompiler.tools import run_step as run_tool_step - - return run_tool_step( - workspace=self.workspace, step=workspace_step, ecc_module=self.engine_db.engine - ) - - def _derive_step_state( - self, workspace_step: WorkspaceStep, result, *, raised: bool - ) -> StateEnum: - """Map the tool result to the step state. Subclasses keep their own - result vocabulary; the base engine trusts the artifact check.""" - if raised: - return StateEnum.Imcomplete - return ( - StateEnum.Success - if self.check_step_result(workspace_step=workspace_step) - else StateEnum.Imcomplete - ) - - -def _notify_flow_observer(observer, method_name: str, *args) -> None: - """Keep optional GUI observers outside the flow engine's failure domain.""" - if observer is None: - return - callback = getattr(observer, method_name, None) - if not callable(callback): - return - try: - callback(*args) - except Exception: - # Runtime observers must never turn a completed tool execution into a - # failed flow. The coordinator records transport failures separately. - logging.getLogger(__name__).exception("flow observer callback failed: %s", method_name) - - -def _wait_for_step_rendered(observer, workspace_step: WorkspaceStep, state: StateEnum) -> bool: - if observer is None or state != StateEnum.Success: - return True - callback = getattr(observer, "wait_for_step_rendered", None) - if not callable(callback): - return True - try: - return bool(callback(workspace_step, state)) - except Exception: - # Fail-open: observer bugs must not invalidate successful tool results. - logging.getLogger(__name__).exception("flow observer render gate failed") - return True diff --git a/chipcompiler/engine/runner.py b/chipcompiler/engine/runner.py new file mode 100644 index 00000000..b0c137ed --- /dev/null +++ b/chipcompiler/engine/runner.py @@ -0,0 +1,426 @@ +"""Step execution lifecycle for EngineFlow. + +Owns the protocol-critical ordering for a single step or a full flow: +markers, memory tracking, the authoritative final save, post-processing, +db cleanup, observer callbacks, and the render gate. EngineFlow inherits +this mixin; the data/state/build methods stay in flow.py. +""" + +import hashlib +import logging +import os +import time +from contextlib import nullcontext +from threading import Event, Thread + +from chipcompiler.data import StateEnum, WorkspaceStep, log_flow +from chipcompiler.engine import EngineDB + + +def get_process_rss_mb(pid: int) -> float: + peak_memory = 0 + try: + with open(f"/proc/{pid}/status") as f: + for line in f: + if line.startswith("VmRSS:"): + rss_kb = int(line.split()[1]) + peak_memory = rss_kb / 1024 + break + except (OSError, ValueError): + pass + return peak_memory + + +def track_current_process_memory(pid: int, stop_event: Event, peak_memory: list[float]): + while not stop_event.is_set(): + peak_memory[0] = max(peak_memory[0], get_process_rss_mb(pid)) + stop_event.wait(0.1) + peak_memory[0] = max(peak_memory[0], get_process_rss_mb(pid)) + + +class EngineFlowRunner: + """Mixin: step/flow execution lifecycle; requires the EngineFlow spine.""" + + def clear_db_engine_after_step(self, workspace_step: WorkspaceStep, state: StateEnum) -> None: + if workspace_step.tool == "sizer" and state == StateEnum.Success: + engine_db = self.engine_db + self.engine_db = None + if engine_db is not None: + close = getattr(engine_db, "close", None) + if callable(close): + close() + + def timing_constraint_facts(self) -> dict: + sdc_path = self.workspace.pdk.sdc + if sdc_path is None: + return {"availability": "missing_source"} + + try: + path = os.fspath(sdc_path) + size_bytes = os.path.getsize(path) + digest = hashlib.sha256() + with open(path, "rb") as sdc_file: + for chunk in iter(lambda: sdc_file.read(1024 * 1024), b""): + digest.update(chunk) + except OSError: + return {"availability": "unreadable"} + + return { + "availability": "available", + "sha256": digest.hexdigest(), + "size_bytes": size_bytes, + } + + def save_step_flow_facts( + self, + workspace_step: WorkspaceStep, + state: StateEnum, + runtime_seconds: float, + peak_memory_mb: float, + timing_constraints: dict, + ) -> bool: + feature_path = getattr(workspace_step.feature, "step", None) + if feature_path is None or feature_path == "": + return False + + from chipcompiler.utility import JsonReadError, json_read_strict, json_write + + try: + existing = json_read_strict(feature_path) + except (FileNotFoundError, JsonReadError): + existing = {} + payload = existing if isinstance(existing, dict) else {} + payload["run"] = { + "state": state.value, + "runtime_seconds": round(runtime_seconds, 3), + "peak_memory_mb": round(peak_memory_mb, 3), + } + payload["constraints"] = {"sdc": timing_constraints} + return json_write(file_path=feature_path, data=payload) + + return True + + def run_steps(self, *, rerun: bool = False, observer=None) -> bool: + """ + run all flow steps + """ + from chipcompiler.runtime.log_stream import archive_own_step_logs + + from .rerun import downgrade_unarchived_step + + # Direct in-process runs (documented Python API) self-archive so step + # logs exist and markers stay off the caller's terminal; inside a + # worker/sidecar process the outer client owns the stream and this + # context passes through. + succeeded = True + directory = self.workspace.directory + with archive_own_step_logs(directory) if directory is not None else nullcontext() as reader: + try: + for workspace_step in self.workspace_steps: + self.workspace.logger.log_section( + f"{workspace_step.tool} - begin step - {workspace_step.name}" + ) + self.init_db_engine() + state = ( + self.run_step(workspace_step, rerun=rerun) + if observer is None + else self.run_step(workspace_step, rerun=rerun, observer=observer) + ) + + log_flow(workspace=self.workspace) + self.workspace.logger.log_section( + f"{workspace_step.tool} - end step - {workspace_step.name}" + ) + + match state: + case StateEnum.Success: + continue + case _: + succeeded = False + break + except BaseException: + # Reconcile archive evidence before the exception propagates. + if reader is not None: + downgrade_unarchived_step(self, reader, []) + raise + + # An archive failure or unmatched begin must not report success over a + # missing step log; reconcile after the reader drains. (reader is None + # when an outer client owns the stream — nothing to reconcile here.) + if reader is not None and ( + reader.state.error is not None or reader.state.active_step is not None + ): + downgrade_unarchived_step(self, reader, []) + succeeded = False + if not succeeded: + return False + + total_steps = len(self.workspace.flow.data.get("steps", [])) + if len(self.workspace_steps) < total_steps: + self.workspace.logger.error( + "Flow incomplete: %d of %d steps were created; remaining steps could not be set up", + len(self.workspace_steps), + total_steps, + ) + return False + + return True + + def run_step( + self, + workspace_step: WorkspaceStep | str, + *, + rerun: bool = False, + observer=None, + ) -> StateEnum: + """ + run single step + """ + if isinstance(workspace_step, str): + workspace_step = self.get_workspace_step(workspace_step) + if workspace_step is None: + return StateEnum.Invalid + + from chipcompiler.runtime.log_stream import archive_own_step_logs + + from .rerun import downgrade_unarchived_step + + # Direct callers get client-side archival too; inside a worker/sidecar + # process or an explicit archive context this passes through. + if self.workspace.directory is None: + return self._run_step_body(workspace_step, rerun=rerun, observer=observer) + reader = None + try: + with archive_own_step_logs(self.workspace.directory) as active_reader: + reader = active_reader + state = self._run_step_body(workspace_step, rerun=rerun, observer=observer) + except BaseException: + # Reconcile archive evidence before the exception propagates. + if reader is not None: + downgrade_unarchived_step(self, reader, []) + raise + # An archive failure or unmatched begin must not report Success over a + # missing log; reconcile after the reader drains. + if reader is not None and ( + reader.state.error is not None or reader.state.active_step is not None + ): + downgrade_unarchived_step(self, reader, []) + return StateEnum.Imcomplete + return state + + def _run_step_body( + self, + workspace_step: WorkspaceStep, + *, + rerun: bool = False, + observer=None, + ) -> StateEnum: + step_tag = f"{workspace_step.name}({workspace_step.tool})" + + if not rerun and self.check_state( + name=workspace_step.name, tool=workspace_step.tool, state=StateEnum.Success + ): + self.workspace.logger.info("[SKIP] %s already succeeded", step_tag) + self.clear_db_engine_after_step(workspace_step, StateEnum.Success) + _notify_flow_observer(observer, "on_step_skipped", workspace_step) + return StateEnum.Success + + # set state ongoing + start_time = time.time() + timing_constraints = self.timing_constraint_facts() + self.set_state(name=workspace_step.name, tool=workspace_step.tool, state=StateEnum.Ongoing) + _notify_flow_observer(observer, "on_step_started", workspace_step) + + self.workspace.logger.info(f"[STEP] {step_tag} pid={os.getpid()} started") + + from chipcompiler.runtime.log_stream import emit_step_marker + + try: + emit_step_marker("begin", step=workspace_step.name, tool=workspace_step.tool) + except OSError: + # fd 2 is closed or the reader pipe is broken: the marker never + # reached any client, so recovery could never identify this step + # from the stream. Downgrade the persisted Ongoing now instead of + # leaving a permanent Ongoing no repair pass can find. + self.set_state( + name=workspace_step.name, tool=workspace_step.tool, state=StateEnum.Imcomplete + ) + raise + + pid = os.getpid() + start_memory_mb = get_process_rss_mb(pid) + peak_memory = [start_memory_mb] + stop_memory_monitor = Event() + memory_monitor = Thread( + target=track_current_process_memory, + args=(pid, stop_memory_monitor, peak_memory), + daemon=True, + ) + memory_monitor.start() + previous_observer = getattr(self.workspace, "_runtime_flow_observer", None) + if observer is not None: + self.workspace._runtime_flow_observer = observer + step_raised_exception = False + result = None + try: + result = self._invoke_step_tool(workspace_step) + self.workspace.logger.info(f"[STEP] {step_tag} finished result={result}") + except Exception: + step_raised_exception = True + self.workspace.logger.error(f"[STEP] {step_tag} failed with exception") + self.workspace.logger.exception(f"[STEP] {step_tag} exception details") + finally: + stop_memory_monitor.set() + memory_monitor.join() + if observer is not None: + if previous_observer is None: + delattr(self.workspace, "_runtime_flow_observer") + else: + self.workspace._runtime_flow_observer = previous_observer + + # compute metrics + peak_memory_mb = peak_memory[0] - start_memory_mb + peak_memory_mb = 0 if peak_memory_mb < 0 else round(peak_memory_mb, 3) + elapsed = time.time() - start_time + runtime = f"{int(elapsed // 3600)}:{int((elapsed % 3600) // 60)}:{int(elapsed % 60)}" + + # determine and save state + state = self._derive_step_state(workspace_step, result, raised=step_raised_exception) + + persisted = self.set_state( + name=workspace_step.name, + tool=workspace_step.tool, + state=state, + runtime=runtime, + peak_memory=peak_memory_mb, + ) + if not persisted: + # The marker protocol guarantees the final state is persisted + # before the end marker; a failed save makes the run's result + # untrustworthy. Downgrade the canonical in-memory record (the + # downgrade itself is not persisted — the save just failed), + # suppress the end marker, and report the step incomplete. + state = StateEnum.Imcomplete + record = self.get_step(workspace_step.name, workspace_step.tool) + if record is not None: + record["state"] = StateEnum.Imcomplete.value + self.workspace.logger.error( + "[RESULT] %s final state could not be persisted; marking step Imcomplete", + step_tag, + ) + self.workspace.logger.info( + "[RESULT] %s state=%s runtime=%s mem=%sMB exitcode=%s", + step_tag, + state.value, + runtime, + peak_memory_mb, + 0, + ) + + # save layout snapshot on success + if state == StateEnum.Success: + if self.save_step_flow_facts( + workspace_step=workspace_step, + state=state, + runtime_seconds=elapsed, + peak_memory_mb=peak_memory_mb, + timing_constraints=timing_constraints, + ): + try: + from chipcompiler.tools import build_step_metrics + + if build_step_metrics(workspace=self.workspace, step=workspace_step) is None: + self.workspace.logger.warning( + "[QOR] %s run facts were saved but analysis refresh is unavailable", + step_tag, + ) + except Exception: + self.workspace.logger.exception( + "[QOR] %s failed to refresh analysis after saving run facts", + step_tag, + ) + else: + self.workspace.logger.warning( + "[QOR] %s has no step feature path; run facts were not saved", + step_tag, + ) + from chipcompiler.tools import save_layout_image + + save_layout_image(workspace=self.workspace, step=workspace_step) + + self.clear_db_engine_after_step(workspace_step, state) + # The end marker closes the step's byte stream only after every + # step-scoped write (state persistence, [RESULT], QOR, layout, db + # cleanup) has flushed, and always before the completion notification. + # When the final state could not be persisted, the marker stays + # unwritten: consumers treat the step as crashed and repair its state. + if persisted: + emit_step_marker("end", step=workspace_step.name, tool=workspace_step.tool) + _notify_flow_observer(observer, "on_step_completed", workspace_step, state) + if state == StateEnum.Success and not _wait_for_step_rendered( + observer, + workspace_step, + state, + ): + return StateEnum.Invalid + + return state + + def init_db_engine_for_step(self, workspace_step: WorkspaceStep) -> bool: + """Initialize the native DB engine from an explicitly selected step.""" + if self.engine_db is None: + self.engine_db = EngineDB(workspace=self.workspace) + elif self.engine_db.has_init(): + return True + + return self.engine_db.create_db_engine(step=workspace_step) + + def _invoke_step_tool(self, workspace_step: WorkspaceStep): + """Run the step's tool. Subclasses redirect to their own runner.""" + from chipcompiler.tools import run_step as run_tool_step + + return run_tool_step( + workspace=self.workspace, step=workspace_step, ecc_module=self.engine_db.engine + ) + + def _derive_step_state( + self, workspace_step: WorkspaceStep, result, *, raised: bool + ) -> StateEnum: + """Map the tool result to the step state. Subclasses keep their own + result vocabulary; the base engine trusts the artifact check.""" + if raised: + return StateEnum.Imcomplete + return ( + StateEnum.Success + if self.check_step_result(workspace_step=workspace_step) + else StateEnum.Imcomplete + ) + + +def _notify_flow_observer(observer, method_name: str, *args) -> None: + """Keep optional GUI observers outside the flow engine's failure domain.""" + if observer is None: + return + callback = getattr(observer, method_name, None) + if not callable(callback): + return + try: + callback(*args) + except Exception: + # Runtime observers must never turn a completed tool execution into a + # failed flow. The coordinator records transport failures separately. + logging.getLogger(__name__).exception("flow observer callback failed: %s", method_name) + + +def _wait_for_step_rendered(observer, workspace_step: WorkspaceStep, state: StateEnum) -> bool: + if observer is None or state != StateEnum.Success: + return True + callback = getattr(observer, "wait_for_step_rendered", None) + if not callable(callback): + return True + try: + return bool(callback(workspace_step, state)) + except Exception: + # Fail-open: observer bugs must not invalidate successful tool results. + logging.getLogger(__name__).exception("flow observer render gate failed") + return True diff --git a/chipcompiler/runtime/log_stream.py b/chipcompiler/runtime/log_stream.py index e460bbde..c37cfd01 100644 --- a/chipcompiler/runtime/log_stream.py +++ b/chipcompiler/runtime/log_stream.py @@ -395,12 +395,14 @@ def archive_own_step_logs(workspace_dir, *, echo: bool = True): sys.stdout.flush() sys.stderr.flush() flush_cstdio() - real_stdout = os.dup(1) - real_stderr = os.dup(2) + real_stdout = -1 + real_stderr = -1 read_fd = -1 write_fd = -1 stream = None try: + real_stdout = os.dup(1) + real_stderr = os.dup(2) read_fd, write_fd = os.pipe() os.dup2(write_fd, 1) os.dup2(write_fd, 2) @@ -423,8 +425,12 @@ def _echo(data: bytes) -> None: except BaseException: # A setup failure must not poison later runs: restore any redirected # descriptors, close what was opened, and release the guard. - os.dup2(real_stdout, 1) - os.dup2(real_stderr, 2) + if real_stdout >= 0: + with suppress(OSError): + os.dup2(real_stdout, 1) + if real_stderr >= 0: + with suppress(OSError): + os.dup2(real_stderr, 2) if write_fd >= 0: with suppress(OSError): os.close(write_fd) @@ -434,8 +440,12 @@ def _echo(data: bytes) -> None: elif read_fd >= 0: with suppress(OSError): os.close(read_fd) - os.close(real_stdout) - os.close(real_stderr) + if real_stdout >= 0: + with suppress(OSError): + os.close(real_stdout) + if real_stderr >= 0: + with suppress(OSError): + os.close(real_stderr) _SELF_ARCHIVE_ACTIVE = False raise try: diff --git a/test/cli/commands/test_run.py b/test/cli/commands/test_run.py index a8c1fb7b..a6870cbb 100644 --- a/test/cli/commands/test_run.py +++ b/test/cli/commands/test_run.py @@ -280,11 +280,11 @@ def run_sequence(self, calls): monkeypatch.setattr("chipcompiler.data.load_workspace", fake_load_workspace) monkeypatch.setattr("chipcompiler.engine.EngineFlow", Flow) monkeypatch.setattr( - "chipcompiler.cli.command_handlers.project._make_run_operation", + "chipcompiler.cli.command_handlers.workspace_run._make_run_operation", lambda workspace_path, **kwargs: FakeOperation(), ) monkeypatch.setattr( - "chipcompiler.cli.command_handlers.project._worker_binary_missing_error", + "chipcompiler.cli.command_handlers.workspace_run._worker_binary_missing_error", lambda: seen.binary_error, ) monkeypatch.setattr( diff --git a/test/test_engine_flow.py b/test/test_engine_flow.py index 547b5eae..d816d35a 100644 --- a/test/test_engine_flow.py +++ b/test/test_engine_flow.py @@ -4,7 +4,7 @@ import pytest -import chipcompiler.engine.flow as flow_module +import chipcompiler.engine.runner as runner_module from chipcompiler import tools from chipcompiler.data import ( EccFeature, @@ -92,7 +92,7 @@ def test_engine_flow_does_not_delay_short_step_before_return(monkeypatch, tmp_pa sleep_calls = [] monkeypatch.setattr(tools, "run_step", lambda **_kwargs: False) - monkeypatch.setattr(flow_module.time, "sleep", sleep_calls.append) + monkeypatch.setattr(runner_module.time, "sleep", sleep_calls.append) assert engine_flow.run_step(workspace_step) is StateEnum.Imcomplete assert sleep_calls == [] @@ -228,6 +228,34 @@ def on_step_completed(self, step, state): assert json_read(flow_path)["steps"][0]["state"] == StateEnum.Ongoing.value +def test_direct_run_step_archive_failure_downgrades(monkeypatch, tmp_path): + """A bare run_step (no run_steps wrapper) with a broken archive path + returns Imcomplete and downgrades the record after the reader drains.""" + workspace = Workspace( + directory=tmp_path, + flow=Flow(path=tmp_path / "home" / "flow.json"), + ) + engine_flow = EngineFlow(workspace) + engine_flow.workspace.flow.data = { + "steps": [{"name": "route", "tool": "ecc", "state": "Unstart"}] + } + step_dir = tmp_path / "route_ecc" + step_dir.mkdir(parents=True) + (step_dir / "log").write_text("regular file") + workspace_step = EccStep(name="route", directory=step_dir, tool="ecc") + engine_flow.workspace_steps = [workspace_step] + engine_flow.engine_db = SimpleNamespace(engine=None) + + monkeypatch.setattr(tools, "run_step", lambda **_kwargs: True) + monkeypatch.setattr(engine_flow, "check_step_result", lambda **_kwargs: True) + + result = engine_flow.run_step(workspace_step) + + assert result == StateEnum.Imcomplete + record = engine_flow.get_step("route", "ecc") + assert record["state"] == StateEnum.Imcomplete.value + + def test_run_steps_archive_failure_returns_false_and_downgrades(monkeypatch, tmp_path): """A direct run_steps with a broken archive path must report failure and downgrade the record instead of returning True over a missing log.""" diff --git a/test/tools/ecc_sizer/test_runner.py b/test/tools/ecc_sizer/test_runner.py index 7fbffa2e..cea3a4e2 100644 --- a/test/tools/ecc_sizer/test_runner.py +++ b/test/tools/ecc_sizer/test_runner.py @@ -197,7 +197,7 @@ def test_timing_opt_step_result_does_not_require_gds(tmp_path): def test_engine_flow_clears_cached_db_after_successful_sizer_step(tmp_path, monkeypatch): import chipcompiler.tools as tools_api - from chipcompiler.engine import flow as flow_module + from chipcompiler.engine import runner as flow_module from chipcompiler.engine.flow import EngineFlow workspace = _workspace(tmp_path) From 3376eb490ab4cd73444effcf8fec829f9d8adf80 Mon Sep 17 00:00:00 2001 From: Emin Date: Thu, 20 Aug 2026 08:01:57 +0800 Subject: [PATCH 52/52] fix(runtime): preserve marker identity on validation failures and harden archive teardown - A begin rejected by name/containment validation now keeps the marker's (step, tool) identity on error_step/error_tool, so failure-path reconciliation can find and downgrade the step even though it never activated. - archive_own_step_logs moves the pre-setup flushes into the guarded try and releases _SELF_ARCHIVE_ACTIVE in an innermost finally with per-step suppression, so any teardown failure (broken descriptor, closed stream) still restores fd 1/2 and frees the guard. Regressions pin the identity preservation and teardown failure recovery. --- chipcompiler/runtime/log_stream.py | 45 ++++++++++++++++--------- test/runtime/test_log_stream.py | 39 +++++++++++++++++++++ test/runtime/test_log_stream_targets.py | 4 +++ 3 files changed, 73 insertions(+), 15 deletions(-) diff --git a/chipcompiler/runtime/log_stream.py b/chipcompiler/runtime/log_stream.py index c37cfd01..1fdc081a 100644 --- a/chipcompiler/runtime/log_stream.py +++ b/chipcompiler/runtime/log_stream.py @@ -268,6 +268,12 @@ def _handle_marker(self, marker: StepMarker, raw_line: bytes) -> None: return archive_path = self._validated_archive_path(marker.step, marker.tool) if self._resolve_path is not None and archive_path is None: + # Validation failed before activation: keep the marker's + # identity on the error so failure-path reconciliation can + # still find and downgrade this step. + if self._state.error_step is None: + self._state.error_step = marker.step + self._state.error_tool = marker.tool self._emit_data(raw_line) return self._state.active_step = marker.step @@ -392,15 +398,15 @@ def archive_own_step_logs(workspace_dir, *, echo: bool = True): if isinstance(step, dict) and "name" in step and "tool" in step } - sys.stdout.flush() - sys.stderr.flush() - flush_cstdio() real_stdout = -1 real_stderr = -1 read_fd = -1 write_fd = -1 stream = None try: + sys.stdout.flush() + sys.stderr.flush() + flush_cstdio() real_stdout = os.dup(1) real_stderr = os.dup(2) read_fd, write_fd = os.pipe() @@ -456,15 +462,24 @@ def _echo(data: bytes) -> None: # callback writes to real_stderr, so it must stay open until the # drain finishes. The pipe's read stream closes too: nothing else # owns it, and leaked pipes accumulate into EMFILE over many reruns. - sys.stdout.flush() - sys.stderr.flush() - flush_cstdio() - os.dup2(real_stdout, 1) - os.dup2(real_stderr, 2) - reader.join(timeout=5.0) - reader.stop() - with suppress(OSError): - stream.close() - os.close(real_stdout) - os.close(real_stderr) - _SELF_ARCHIVE_ACTIVE = False + # Every step here can fail on a broken descriptor, so the guard is + # released in the innermost finally regardless. + try: + with suppress(Exception): + sys.stdout.flush() + sys.stderr.flush() + flush_cstdio() + with suppress(OSError): + os.dup2(real_stdout, 1) + with suppress(OSError): + os.dup2(real_stderr, 2) + reader.join(timeout=5.0) + reader.stop() + with suppress(OSError): + stream.close() + with suppress(OSError): + os.close(real_stdout) + with suppress(OSError): + os.close(real_stderr) + finally: + _SELF_ARCHIVE_ACTIVE = False diff --git a/test/runtime/test_log_stream.py b/test/runtime/test_log_stream.py index 595c15a0..4a89d660 100644 --- a/test/runtime/test_log_stream.py +++ b/test/runtime/test_log_stream.py @@ -362,6 +362,45 @@ def test_external_client_or_nesting_passes_through(self, tmp_path, capfd): assert "raw" in capfd.readouterr().err +class TestArchiveTeardownFailure: + def test_teardown_flush_failure_still_releases_the_guard(self, tmp_path, monkeypatch, capfd): + """A teardown flush raising (closed stream) must not leave the guard + set or the fds attached to the pipe.""" + import os + + import chipcompiler.runtime.log_stream as log_stream_module + from chipcompiler.runtime.log_stream import archive_own_step_logs, emit_step_marker + + workspace = tmp_path / "ws" + (workspace / "home").mkdir(parents=True) + (workspace / "home" / "flow.json").write_text( + '{"steps": [{"name": "S", "tool": "T", "state": "Ongoing"}]}' + ) + + # Fail the C-stdio flush at teardown only: armed just before the + # context exits, so entry and marker flushes still succeed. + import chipcompiler.utility.log as utility_log + + armed = [] + + def flaky_c_flush(): + if armed: + raise OSError("broken stdio buffer") + + monkeypatch.setattr(utility_log, "flush_cstdio", flaky_c_flush) + with archive_own_step_logs(workspace): + emit_step_marker("begin", step="S", tool="T") + os.write(2, b"bytes\n") + emit_step_marker("end", step="S", tool="T") + armed.append(True) + + assert log_stream_module._SELF_ARCHIVE_ACTIVE is False + assert (workspace / "S_T" / "log" / "S.log").read_bytes() == b"bytes\n" + # fd 2 is restored to the terminal. + os.write(2, b"restored\n") + assert "restored" in capfd.readouterr().err + + class TestArchiveSetupFailure: def test_setup_failure_restores_fds_and_releases_the_guard(self, tmp_path, monkeypatch, capfd): """A failure during setup (pipe/dup/reader start) must restore fd 1/2 diff --git a/test/runtime/test_log_stream_targets.py b/test/runtime/test_log_stream_targets.py index 2f316bc5..50fa2741 100644 --- a/test/runtime/test_log_stream_targets.py +++ b/test/runtime/test_log_stream_targets.py @@ -175,6 +175,10 @@ def test_containment_violation_degrades_begin_to_data(self, tmp_path): assert reader.state.active_step is None assert not escape_target.exists() assert "escapes workspace" in str(reader.state.error) + # The marker's identity survives on the error so failure-path + # reconciliation can find and downgrade this step. + assert reader.state.error_step == "Escape" + assert reader.state.error_tool == "evil" class TestStepLogArchiveResolver: