fix(issue-17): review R1 - the finalizer ends with its wrapper

finalize-end ran the disk grace and the broker re-observation as a child
of the wrapper; TERM to the wrapper left it running, and at the deadline it
still closed the job and published the error (codex R1, reproduced with the
real registry). The bash loops it replaced ended with the wrapper.

The wrapper now passes its own pid (--watch-pid "$$", as for the stall
watcher). finalize-end checks it every 0.2 s through both waits and before
the close and the event, and leaves without either once it is gone.
A comparison test sends TERM to the old and the new wrapper inside each
grace: both exit at once, leave no process behind and make no call.

Also: no blank line at the end of the old-decisions fixture, and the #42
inventory follows main() one line down.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
GodopuandClaude Opus 5.5 committed 2026-10-06 07:02:26 +00:00
1 parent 66d1490230
commit c5cebe6c32
5 files changed
+137 -7

No files matched your search

+90
View File
@@ -343,6 +343,64 @@ def test_c_finalize_grace_sees_a_late_completion(tmp_path):
assert "FINAL=completed" in new[1] and new[3] == ""
def _gone(pgid: int) -> bool:
try:
os.killpg(pgid, 0)
except ProcessLookupError:
return True
except PermissionError:
return False
return False
@pytest.mark.parametrize("status,sub_rc,idle", [("broker_unavailable", 3, 0), ("timeout", 2, 10)])
def test_c_finalize_ends_with_its_wrapper(tmp_path, status, sub_rc, idle):
"""Review R1: TERM to the wrapper during either grace ends the grace too -
the wrapper exits at once, nothing it started outlives it for long, and no
close or event follows after the deadline (6 s)."""
results = []
for which in ("old", "new"):
work = tmp_path / which
reg = work / "reg"
reg.mkdir(parents=True)
record = json.dumps({"job_id": "j1", "status": "running"})
(reg / "j1.json").write_text(record)
log = work / "calls.log"
log.write_text("")
shim = _shim(work)
out = work / "out.txt"
script = (f'source "{WRAPPER}"\n' + (f'source "{OLD}"\n' if which == "old" else "")
+ f'REGISTRY_DIR="{reg}"; PY="{shim}"\n'
+ 'echo READY\n' + _finalize_body(status, sub_rc, "direct", start_offset=0, timeout=6, idle=idle))
with open(out, "w") as fh:
proc = subprocess.Popen(["bash", "-c", script], stdout=fh, stderr=subprocess.STDOUT, cwd=str(work),
env=_env(work, CALL_LOG=log, DELEGATE_JOB_PYTHON=shim), start_new_session=True)
try:
assert _until_true(lambda: "READY" in out.read_text(), 30), out.read_text()
time.sleep(2) # inside the grace
assert proc.poll() is None, out.read_text()
t0 = time.monotonic()
proc.terminate() # the wrapper only
proc.wait(timeout=10)
exit_s = time.monotonic() - t0
group_gone = _until_true(lambda: _gone(proc.pid), 6)
time.sleep(6) # past the deadline
results.append((exit_s < 2, group_gone, log.read_text(), (reg / "j1.json").read_text() == record))
finally:
if not _gone(proc.pid):
os.killpg(proc.pid, 9)
assert results[0] == results[1] == (True, True, "", True), results
def _until_true(pred, limit):
end = time.monotonic() + limit
while time.monotonic() < end:
if pred():
return True
time.sleep(0.1)
return pred()
@pytest.mark.parametrize("rc", [0, 1, 2, 3, 4, 5])
def test_c_sub_status_exit_code_and_subscriber_up(tmp_path, rc):
body = (f'st=$(_map_sub_rc {rc} j1 2>/dev/null || _decide sub-status {rc} --job j1 --registry-dir "$REGISTRY_DIR")\n'
@@ -422,3 +480,35 @@ def test_s_the_wrapper_carries_no_moved_decision():
for label in ("wall_clock_timeout", "deadline_timeout", "agent_blocked", "session_busy"):
assert label not in text, label
assert "BASH_REMATCH" not in LIB_INT.read_text()
def test_u_finalize_end_leaves_without_side_effects_once_its_wrapper_is_gone(tmp_path):
"""Review R1, in process: a dead --watch-pid stops the wait and every side effect."""
import argparse
dead = subprocess.Popen(["true"])
dead.wait()
(tmp_path / "j.json").write_text(json.dumps({"status": "running"}))
log = tmp_path / "calls.log"
a = argparse.Namespace(job="j", registry_dir=str(tmp_path), status="broker_unavailable", start_time=int(time.time()),
timeout=30, idle_timeout=0, sub_rc=3, phase="direct", type="direct", final="broker_unavailable",
evidence_until=0, dispatch_token="t", preflight_reason="", python=str(_shim(tmp_path)),
scripts_dir=str(tmp_path / "scripts"), watch_pid=dead.pid)
os.environ["CALL_LOG"] = str(log)
try:
t0 = time.monotonic()
with pytest.raises(decide.OwnerGone):
decide.finalize_end(a)
assert time.monotonic() - t0 < 2
a.status, a.start_time = "error", 0 # no wait at all: the close is still withheld
with pytest.raises(decide.OwnerGone):
decide.finalize_end(a)
finally:
del os.environ["CALL_LOG"]
assert not log.exists() or log.read_text() == ""
a.watch_pid = os.getpid() # alive: the close goes ahead
os.environ["CALL_LOG"] = str(log)
try:
assert decide.finalize_end(a) == "broker_unavailable"
finally:
del os.environ["CALL_LOG"]
assert "registry.py" in log.read_text()