fix(runtime): isolate historical job lookup from PID reuse

This commit is contained in:
Alexandre Teixeira
2026-10-02 18:54:09 +01:00
parent db41d7e822
commit b648f9ddbe
3 changed files with 52 additions and 3 deletions
@@ -102,7 +102,11 @@ restoration during cancellation.
## Job history and continuations
`peek()` and resolution do not refresh or reap jobs. Output refresh reconciles
only the selected job, including its owned subprocess handle. Stop/output/ack
only the selected job. It polls a cached subprocess handle only while the
selected record is running and its frozen start token still verifies as owned;
historical or unverifiable identities cannot poll a replacement handle under
the same numeric PID. Global service refresh still reaps completed handles.
Stop/output/ack
require the caller's exact expected resource and revalidate linkage. Results
can update only an explicit result-field whitelist, never identity, owner,
generation, receipt, PID, command, path or authority fields.
@@ -198,6 +202,11 @@ the integrated gate spans the 145-file manifest. Validation used
`/tmp/odysseus-wave3-validation/bin/python` with functional bubblewrap.
Compileall, diff whitespace, conflict-marker and unmerged-index gates passed.
The post-commit integrated result is recorded in the final checkpoint report.
Final adversarial review found a numeric-PID-only cached-handle lookup in that
commit. A follow-up patch adds frozen-token validation and four PID-reuse/
unverifiable history regressions, plus a service-cleanup regression. The patched
focused gate passes 392 tests; the patched 145-file integrated gate passes 3369
tests, with the same 3 platform skips and 2 existing xfails. Static gates pass.
Platform skips remain
explicit: `/tmp` is not a symlink, RLIMIT_AS can be lowered on this host, and the
Windows-specific Ollama startup guard is not applicable on Linux. No missing
+10 -2
View File
@@ -231,8 +231,16 @@ def refresh(job_id=None) -> Dict[str, Dict[str, Any]]:
timeout). Idempotent — safe to call from a poll loop. Returns the store."""
jobs = _load()
for pid, proc in list(_LIVE_PROCS.items()):
if job_id is not None and pid != jobs.get(job_id, {}).get("pid"):
continue
if job_id is not None:
selected = jobs.get(job_id, {})
# Historical numeric PIDs can name a replacement child's cached
# handle. Targeted reads may poll only the frozen live incarnation;
# independent service maintenance may still reap completed handles.
if (selected.get("status") != "running"
or pid != selected.get("pid")
or process_ownership.verify(pid, selected.get("start_token"))
!= process_ownership.OWNED):
continue
if proc.poll() is not None:
_LIVE_PROCS.pop(pid, None)
changed = False
@@ -215,6 +215,38 @@ def test_target_lookup_does_not_wait_on_unrelated_live_handle(store, monkeypatch
bg_jobs.get("job", expected=resource)
@pytest.mark.parametrize("status", ["done", "running"])
@pytest.mark.parametrize("verdict", [process_ownership.FOREIGN, process_ownership.UNVERIFIABLE])
def test_historical_lookup_does_not_reap_reused_pid_handle(store, monkeypatch, status, verdict):
resource, rec = seed(store, status=status)
from pathlib import Path
Path(rec["exit_path"]).write_text("0")
Path(rec["result_path"]).write_text(json.dumps({
"resource_identity": resource.to_dict(),
"containment": {"id": resource.containment_id},
}))
monkeypatch.setattr(process_ownership, "verify", lambda *a: verdict)
class ReplacementProcess:
def poll(self):
pytest.fail("Historical lookup reaped the replacement incarnation")
replacement = ReplacementProcess()
monkeypatch.setattr(bg_jobs, "_LIVE_PROCS", {rec["pid"]: replacement})
assert bg_jobs.get("job", expected=resource)["status"] == "done"
assert bg_jobs._LIVE_PROCS[rec["pid"]] is replacement
def test_service_refresh_still_reaps_finished_handles(store, monkeypatch):
class FinishedProcess:
def poll(self):
return 0
monkeypatch.setattr(bg_jobs, "_LIVE_PROCS", {4321: FinishedProcess()})
bg_jobs.refresh()
assert bg_jobs._LIVE_PROCS == {}
def test_completed_result_outlives_lifecycle_receipt_without_signalling(store, monkeypatch):
resource, rec = seed(store, status="done")
from pathlib import Path