fix(flow): retain terminal evidence across interrupted runs

Record parent and child process IDs for diagnostics, persist interruption
before cleanup, and ignore repeated termination signals during cleanup.
Close unexpected failures with a terminal result and retain the child's
exit status without treating namespace-local PID absence as completion.

Require a successful terminal run_flow result when checking simulation
logs with an associated result file. Cover repeated cancellation and
reject running, failed or inconsistent terminal records despite PASS text.

This supports the TINY-R2-P7 campaign. Interrupted and timed-out runs remain
non-acceptance evidence; UART output alone cannot qualify a Tiny simulation.
This commit is contained in:
maksyuki committed 2026-10-14 16:55:12 +08:00
1 parent a14fe12724
commit 6fbc6ec521
3 files changed
+108 -16

No files matched your search

+15
View File
@@ -39,6 +39,19 @@ def main() -> int:
if default_failure:
rejected.append(default_failure.group(0))
flow_result_path = args.result.with_name("result-sim.json")
flow_status = None
if flow_result_path.is_file():
try:
flow_result = json.loads(flow_result_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
flow_result = {}
flow_status = flow_result.get("status", "invalid")
# PIDs are namespace-local and can be reused. A durable terminal
# verdict, not a liveness guess from this process, establishes success.
if flow_status != "passed" or flow_result.get("exit_code") != 0:
missing.append("successful terminal run_flow result")
passed = not missing and not rejected
report = {
"schema_version": 1,
@@ -48,6 +61,8 @@ def main() -> int:
"missing_markers": missing,
"rejected_matches": sorted(set(rejected)),
}
if flow_status is not None:
report["flow_status"] = flow_status
atomic_write(args.result, json.dumps(report, indent=2, sort_keys=True) + "\n")
print(f"simulation check: {report['status']} ({args.result.resolve()})")
return int(not passed)
+35 -16
View File
@@ -124,24 +124,22 @@ def main() -> int:
args.result.parent.mkdir(parents=True, exist_ok=True)
started = datetime.now(timezone.utc)
start_clock = time.monotonic()
atomic_write(
args.result,
json.dumps(
{
"schema_version": 1,
"tool": args.tool,
"command": command,
"started_at": started.isoformat(),
"status": "running",
"log": str(args.log.resolve()),
},
indent=2,
sort_keys=True,
)
+ "\n",
)
running_result = {
"schema_version": 1,
"tool": args.tool,
"command": command,
"started_at": started.isoformat(),
"status": "running",
"parent_pid": os.getpid(),
"log": str(args.log.resolve()),
}
atomic_write(args.result, json.dumps(running_result, indent=2, sort_keys=True) + "\n")
def interrupt_flow(_signum: int, _frame: object) -> None:
# A launcher can deliver SIGINT followed by SIGTERM. Do not let a
# second signal interrupt process-group cleanup or result persistence.
signal.signal(signal.SIGINT, signal.SIG_IGN)
signal.signal(signal.SIGTERM, signal.SIG_IGN)
raise KeyboardInterrupt
signal.signal(signal.SIGINT, interrupt_flow)
@@ -169,6 +167,8 @@ def main() -> int:
pass_fds=jobserver_fds(),
start_new_session=True,
)
running_result["child_pid"] = process.pid
atomic_write(args.result, json.dumps(running_result, indent=2, sort_keys=True) + "\n")
assert process.stdout is not None
if args.stream_bytes:
while chunk := os.read(process.stdout.fileno(), 4096):
@@ -193,11 +193,25 @@ def main() -> int:
returncode = process.wait()
except KeyboardInterrupt:
error_message = "interrupted"
# Persist cancellation before the bounded cleanup wait. If the outer
# executor subsequently kills this wrapper, the file must not imply
# that the job is still running or that its child finished normally.
cancelled = dict(running_result, status="interrupted", exit_code=130,
interrupted_at=datetime.now(timezone.utc).isoformat(),
child_cleanup_pending=process is not None)
atomic_write(args.result, json.dumps(cancelled, indent=2, sort_keys=True) + "\n")
if process is not None:
terminate_process_group(process)
returncode = 130
except (OSError, subprocess.SubprocessError) as error:
error_message = str(error)
if process is not None:
terminate_process_group(process)
except BaseException as error:
error_message = f"unexpected {type(error).__name__}: {error}"
if process is not None:
terminate_process_group(process)
returncode = 1
finished = datetime.now(timezone.utc)
result = {
@@ -211,8 +225,13 @@ def main() -> int:
"peak_rss_kib": resource.getrusage(resource.RUSAGE_CHILDREN).ru_maxrss,
"exit_code": returncode,
"status": "interrupted" if returncode == 130 else ("passed" if returncode == 0 else "failed"),
"parent_pid": os.getpid(),
"log": str(args.log.resolve()),
}
if process is not None:
result["child_pid"] = process.pid
result["child_exit_code"] = process.returncode
result["child_cleanup_pending"] = process.returncode is None
if error_message:
result["error"] = error_message
if args.success_marker: