Skip to content

Commit 2d07ccc

Browse files
committed
fix(w2): clean up owned gates on acceptance cancellation
1 parent 65616a8 commit 2d07ccc

2 files changed

Lines changed: 218 additions & 21 deletions

File tree

‎scripts/run_w2_acceptance.py‎

Lines changed: 63 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import signal
2121
import subprocess
2222
import sys
23+
import threading
2324
import time
2425
import xml.etree.ElementTree as ET
2526

@@ -288,37 +289,58 @@ def run_gate(gate, sources, *, timeout, root=ROOT):
288289
"output": str(gate.output), "timeout_seconds": timeout}
289290
log = gate.output.parent / "logs" / (gate.name + ".log")
290291
row["log"] = str(log)
292+
process = stream = interruption = None
291293
try:
292294
require(not gate.output.exists(), "Gate output already exists; cannot reuse previous evidence")
293295
log.parent.mkdir(exist_ok=True)
294296
env = os.environ.copy()
295297
env.update(PYTHONHASHSEED="0", OMP_NUM_THREADS="1", OPENBLAS_NUM_THREADS="1", MKL_NUM_THREADS="1")
296298
options = {"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP} if os.name == "nt" else {"start_new_session": True}
297-
with log.open("xb") as stream:
298-
process = subprocess.Popen(gate.command, cwd=root, stdout=stream, stderr=subprocess.STDOUT,
299-
env=env, **options)
300-
try:
301-
row["returncode"] = process.wait(timeout=timeout)
302-
except subprocess.TimeoutExpired:
303-
row["error"] = f"Gate exceeded {timeout} seconds"
304-
try:
305-
terminate_process_tree(process)
306-
except (OSError, subprocess.SubprocessError) as exc:
307-
row["cleanup_error"] = f"{type(exc).__name__}: {exc}"
308-
raise
309-
if row["returncode"] != 0:
310-
# Save the primary failure before closing the log; close itself
311-
# can fail and must not replace a child model/tool error.
312-
row["error"] = child_failure(row, gate, log)
313-
raise ValueError(row["error"])
299+
stream = log.open("xb")
300+
process = subprocess.Popen(gate.command, cwd=root, stdout=stream, stderr=subprocess.STDOUT,
301+
env=env, **options)
302+
row["returncode"] = process.wait(timeout=timeout)
303+
if row["returncode"] != 0:
304+
row["error"] = child_failure(row, gate, log)
305+
raise ValueError(row["error"])
314306
row["validation"] = validate_gate_report(gate, sources, root)
315307
row.update(status="PASS", passed=True)
316-
except Exception as exc:
317-
row.setdefault("error", f"{type(exc).__name__}: {exc}")
318-
row["seconds"] = time.perf_counter() - started
308+
except BaseException as exc:
309+
row.update(status="FAIL", passed=False)
310+
row.setdefault("error", f"Gate exceeded {timeout} seconds" if isinstance(exc, subprocess.TimeoutExpired)
311+
else f"{type(exc).__name__}: {exc}")
312+
# Gates have their own process groups, so Ctrl-C sent to this supervisor
313+
# does not reach them. Unwind their nested compiler/QEMU sessions too.
314+
if process is not None and (process.poll() is None or not isinstance(exc, Exception)):
315+
try:
316+
terminate_process_tree(process)
317+
except (OSError, subprocess.SubprocessError) as cleanup:
318+
row["cleanup_error"] = f"{type(cleanup).__name__}: {cleanup}"
319+
row["returncode"] = process.returncode
320+
if not isinstance(exc, Exception):
321+
row["interrupted"] = True
322+
interruption = exc
323+
finally:
324+
# A log close/flush failure must not replace a cancellation or the
325+
# original model error. It must still prevent a successful report.
326+
if stream is not None:
327+
try:
328+
stream.close()
329+
except OSError as exc:
330+
row.update(status="FAIL", passed=False, log_error=f"{type(exc).__name__}: {exc}")
331+
row.setdefault("error", row["log_error"])
332+
row["seconds"] = time.perf_counter() - started
333+
if interruption is not None:
334+
interruption.w2_gate_result = row
335+
raise interruption
319336
return row
320337

321338

339+
def cancel_acceptance(signum, frame):
340+
"""Let the supervisor save failure evidence and stop its owned children."""
341+
raise SystemExit(128 + signum)
342+
343+
322344
def finalize_status(report):
323345
if report.get("error") or any(row["status"] in {"FAIL", "BLOCKED"} for row in report["gates"]):
324346
report.update(status="FAIL", passed=False)
@@ -411,6 +433,11 @@ def main(argv=None):
411433
"created_at": datetime.now(timezone.utc).isoformat(), "gates": [], "requested_gates": args.gates,
412434
"python": args.python, "checkout": checkout_evidence()}
413435
started = time.perf_counter()
436+
interrupted_exit = None
437+
previous_term = None
438+
if os.name != "nt" and threading.current_thread() is threading.main_thread():
439+
previous_term = signal.getsignal(signal.SIGTERM)
440+
signal.signal(signal.SIGTERM, cancel_acceptance)
414441
try:
415442
report["source_sha256"] = source_fingerprints()
416443
plan = {gate.name: gate for gate in build_plan(args)}
@@ -434,13 +461,28 @@ def main(argv=None):
434461
if final_sources != report["source_sha256"]:
435462
report["error"] = "Source files changed while acceptance was running; rerun after edits finish"
436463
report["final_source_sha256"] = final_sources
464+
except (KeyboardInterrupt, SystemExit) as exc:
465+
interrupted_exit = 130 if isinstance(exc, KeyboardInterrupt) else (
466+
exc.code if isinstance(exc.code, int) and exc.code != 0 else 1)
467+
report.update(interrupted=True, error=f"{type(exc).__name__}: W2 acceptance cancelled")
468+
current = getattr(exc, "w2_gate_result", None)
469+
if current is not None:
470+
report["gates"].append(current)
471+
completed = {row["name"] for row in report["gates"]}
472+
report["gates"].extend({"name": name, "status": "NOT_RUN", "passed": False,
473+
"error": "Acceptance cancelled before execution"}
474+
for name in GATES if name not in completed)
437475
except Exception as exc:
438476
report.setdefault("error", f"{type(exc).__name__}: {exc}")
477+
finally:
478+
if previous_term is not None:
479+
signal.signal(signal.SIGTERM, previous_term)
439480
report["seconds"] = time.perf_counter() - started
440481
finalize_status(report)
441482
write_reports(args.output_dir, report)
442483
print(f"[W2] {report['status']}: {args.output_dir / 'report.json'}", flush=True)
443-
return 0 if report["passed"] else 2 if report["status"] == "PARTIAL" else 1
484+
return interrupted_exit if interrupted_exit is not None else (
485+
0 if report["passed"] else 2 if report["status"] == "PARTIAL" else 1)
444486

445487

446488
if __name__ == "__main__":

‎tests/test_w2_acceptance.py‎

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import json
55
import os
66
from pathlib import Path
7+
import signal
78
import subprocess
89
import sys
910
import time
@@ -183,6 +184,160 @@ def test_timeout_is_failure(tmp_path):
183184
assert result["status"] == "FAIL" and "exceeded" in result["error"]
184185

185186

187+
@pytest.mark.parametrize("failure", [KeyboardInterrupt("cancelled"), SystemExit(143)])
188+
def test_cancellation_reclaims_live_gate_and_preserves_exception(tmp_path, monkeypatch, failure):
189+
real_popen = subprocess.Popen
190+
children = []
191+
192+
def launch(*args, **kwargs):
193+
process = real_popen(*args, **kwargs)
194+
if args[0][0] != sys.executable:
195+
return process
196+
children.append(process)
197+
original_wait = process.wait
198+
199+
def interrupt(*args, **kwargs):
200+
process.wait = original_wait
201+
raise failure
202+
203+
process.wait = interrupt
204+
return process
205+
206+
monkeypatch.setattr(runner.subprocess, "Popen", launch)
207+
try:
208+
with pytest.raises(type(failure)) as caught:
209+
runner.run_gate(runner.Gate("frontend", (sys.executable, "-c", "import time;time.sleep(60)"),
210+
tmp_path / "frontend"), {}, timeout=90)
211+
assert caught.value is failure
212+
row = caught.value.w2_gate_result
213+
assert row["status"] == "FAIL" and row["interrupted"] is True
214+
assert row["seconds"] >= 0
215+
assert type(failure).__name__ in row["error"]
216+
assert children[0].poll() is not None
217+
finally:
218+
for process in children:
219+
if process.poll() is None:
220+
process.kill()
221+
process.wait(timeout=5)
222+
223+
224+
def test_cancellation_cleanup_and_log_errors_preserve_original(tmp_path, monkeypatch):
225+
failure = KeyboardInterrupt("primary interruption")
226+
227+
class Process:
228+
returncode = None
229+
230+
def poll(self):
231+
return None
232+
233+
def wait(self, **kwargs):
234+
raise failure
235+
236+
class Log:
237+
def close(self):
238+
raise OSError("secondary close error")
239+
240+
monkeypatch.setattr(Path, "open", lambda *args, **kwargs: Log())
241+
monkeypatch.setattr(runner.subprocess, "Popen", lambda *args, **kwargs: Process())
242+
monkeypatch.setattr(runner, "terminate_process_tree", lambda process: (_ for _ in ()).throw(OSError("cleanup failed")))
243+
with pytest.raises(KeyboardInterrupt) as caught:
244+
runner.run_gate(runner.Gate("frontend", ("unused",), tmp_path / "frontend"), {}, timeout=10)
245+
assert caught.value is failure
246+
row = failure.w2_gate_result
247+
assert "primary interruption" in row["error"]
248+
assert "cleanup failed" in row["cleanup_error"]
249+
assert "secondary close error" in row["log_error"]
250+
assert row["status"] == "FAIL" and row["passed"] is False
251+
252+
253+
@pytest.mark.parametrize("failure, code", [(KeyboardInterrupt(), 130), (SystemExit(143), 143)])
254+
def test_cancelled_main_saves_failure_report_without_running_next_gate(tmp_path, monkeypatch, failure, code):
255+
monkeypatch.setattr(runner, "checkout_evidence", lambda: {"head": None})
256+
monkeypatch.setattr(runner, "source_fingerprints", lambda: {})
257+
launched = []
258+
259+
def cancel(gate, *args, **kwargs):
260+
launched.append(gate.name)
261+
failure.w2_gate_result = {"name": gate.name, "status": "FAIL", "passed": False,
262+
"interrupted": True, "error": "original cancellation"}
263+
raise failure
264+
265+
monkeypatch.setattr(runner, "run_gate", cancel)
266+
previous_term = signal.getsignal(signal.SIGTERM)
267+
out = tmp_path / "cancelled"
268+
assert runner.main(["--output-dir", str(out)]) == code
269+
assert signal.getsignal(signal.SIGTERM) == previous_term
270+
report = json.loads((out / "report.json").read_text())
271+
assert report["status"] == "FAIL" and report["interrupted"] is True
272+
assert launched == ["frontend"]
273+
assert report["gates"][0]["error"] == "original cancellation"
274+
assert all(row["status"] == "NOT_RUN" for row in report["gates"][1:])
275+
assert (out / "report.md").is_file() and (out / "report.html").is_file()
276+
277+
278+
@pytest.mark.skipif(sys.platform != "linux", reason="Linux supervisor signal and nested-session integration")
279+
@pytest.mark.parametrize("cancel_signal", [signal.SIGINT, signal.SIGTERM])
280+
def test_real_acceptance_supervisor_cancellation_cleans_nested_worker(tmp_path, cancel_signal):
281+
pidfile = tmp_path / "worker.json"
282+
out = tmp_path / "acceptance"
283+
worker = ("import os,time,json;from pathlib import Path;"
284+
"pid=os.getpid();stat=Path(f'/proc/{pid}/stat').read_text();"
285+
f"Path({str(pidfile)!r}).write_text(json.dumps([pid,stat.rsplit(')',1)[1].split()[19]]));"
286+
"time.sleep(60)")
287+
probe = ("import sys;from scratchv.runtime.riscv_tensor import _run_process;"
288+
f"_run_process([sys.executable,'-B','-c',{worker!r}],cwd=None,timeout=50)")
289+
supervisor = (
290+
"import sys;from scripts import run_w2_acceptance as r;"
291+
"r.source_fingerprints=lambda:{};r.checkout_evidence=lambda:{'head':None};"
292+
f"r.build_plan=lambda a:[r.Gate('frontend',(sys.executable,'-B','-c',{probe!r}),a.output_dir/'frontend')];"
293+
f"raise SystemExit(r.main(['--output-dir',{str(out)!r},'--gates','frontend']))")
294+
unrelated = subprocess.Popen([sys.executable, "-c", "import time;time.sleep(60)"], start_new_session=True)
295+
process = subprocess.Popen([sys.executable, "-B", "-c", supervisor], cwd=runner.ROOT,
296+
stdout=subprocess.PIPE, stderr=subprocess.PIPE, start_new_session=True)
297+
pid = None
298+
try:
299+
deadline = time.monotonic() + 15
300+
while True:
301+
assert process.poll() is None, "Supervisor exited before worker readiness"
302+
assert time.monotonic() < deadline, "Worker did not become ready"
303+
try:
304+
pid, started = json.loads(pidfile.read_text())
305+
break
306+
except (FileNotFoundError, ValueError):
307+
time.sleep(0.01)
308+
process.send_signal(cancel_signal)
309+
stdout, stderr = process.communicate(timeout=25)
310+
assert process.returncode == 128 + cancel_signal, (stdout, stderr)
311+
report = json.loads((out / "report.json").read_text())
312+
assert report["status"] == "FAIL" and report["interrupted"] is True
313+
assert report["gates"][0]["interrupted"] is True
314+
assert report["gates"][0]["status"] == "FAIL"
315+
assert all(row["status"] == "NOT_RUN" for row in report["gates"][1:])
316+
assert unrelated.poll() is None
317+
deadline = time.monotonic() + 5
318+
while True:
319+
try:
320+
fields = Path(f"/proc/{pid}/stat").read_text().rsplit(")", 1)[1].split()
321+
except FileNotFoundError:
322+
break
323+
if fields[0] in {"Z", "X"} or fields[19] != started:
324+
break
325+
assert time.monotonic() < deadline, "Detached worker survived supervisor cancellation"
326+
time.sleep(0.01)
327+
finally:
328+
for child in (process, unrelated):
329+
if child.poll() is None:
330+
os.killpg(child.pid, signal.SIGKILL)
331+
child.wait(timeout=5)
332+
if pid is not None:
333+
try:
334+
fields = Path(f"/proc/{pid}/stat").read_text().rsplit(")", 1)[1].split()
335+
if fields[19] == started and fields[0] not in {"Z", "X"}:
336+
os.kill(pid, signal.SIGKILL)
337+
except FileNotFoundError:
338+
pass
339+
340+
186341
@pytest.mark.skipif(sys.platform != "linux", reason="Linux nested-session SIGTERM integration")
187342
def test_supervisor_cancels_nested_worker_session(tmp_path):
188343
"""Real probe -> _run_process -> different session, not a mocked killpg."""

0 commit comments

Comments
 (0)