executor: a hash-less record is ours to kill when we can signal it; the compaction suite skips on Windows

macOS full-test (run 33658408570) caught the previous rule: on POSIX a foreground
record with no command hash was never killed, but macOS `ps` can return no command
line right after the spawn, so a genuine child of ours leaked at panic cleanup
(test_executor_panic_cleanup_kills_durable_foreground_and_service_processes). The
rule now asks the question a KILL decision needs: signalable by us. A live pid
that answers signal 0 is ours to kill; one that refuses it (another user's
process, pid 1 — the forged-record shape) is not. The platform predicate
pid_is_alive is deliberately not used here: since C6 round 5.4 it reads EPERM as
alive, which is the right answer for a lock owner and the wrong one for a kill.
Pin: a hash-less record of our own live child is killed (red on the previous
rule); the forged pid-1 record stays ignored.

tests/test_usage_compaction.py skips on Windows through its data_root fixture:
7.0 ships Windows on the lock name tier, where the compaction pass refuses by
design, so every test that needs a landed compaction cannot run there (CI
33658408570 / 33658966160: 12-15 fixture errors "assert None is not None"). The
lock-tier pins that do run on Windows live in tests/test_lockfile_helpers.py.
This commit is contained in:
Ouroboros 2026-09-02 17:25:50 +00:00
parent 5ae7f3577f
commit abe9370237
3 changed files with 48 additions and 9 deletions

View file

@ -609,11 +609,19 @@ def _host_pid_matches_record(record: dict[str, Any]) -> bool:
# kill_all_foreground/_services would never dispatch taskkill for it (the
# worktree/service cleanup leak): there, fall back to liveness — owner/
# schema/id are already verified by the caller (_valid_process_record).
# On POSIX a registered process always has a command line, so an empty
# hash is not ours to kill: liveness is NOT proof of ownership — EPERM
# reads alive (C6 round 5.4), so an owner-shaped forged record naming a
# foreign pid would otherwise be signalled. Fail safe: no hash, no kill.
return IS_WINDOWS and pid_is_alive(host_pid)
# On POSIX the capture can also fail for a genuine child (macOS `ps` right
# after the spawn), so liveness must still count — but the platform
# predicate answers the wrong question for a KILL decision: since C6 round
# 5.4 it reads EPERM as alive, and an owner-shaped forged record naming a
# foreign pid would be signalled. Ours to kill means signalable by us: a
# pid that refuses signal 0 (another user's process, pid 1) is not ours.
if IS_WINDOWS:
return pid_is_alive(host_pid)
try:
os.kill(host_pid, 0)
except (ProcessLookupError, PermissionError):
return False
return True
return _process_command_sha256(host_pid) == expected

View file

@ -42,6 +42,8 @@ from ouroboros.usage_ledger import UsageLedgerCorrupt, _validate_records
@pytest.fixture
def data_root(tmp_path, monkeypatch):
if platform_layer.IS_WINDOWS: # 7.0 ships Windows on the name tier: the pass REFUSES there by design (packet §10 addendum)
pytest.skip("compaction never runs on Windows in 7.0 (name tier); the lock-tier pins live in test_lockfile_helpers")
root = tmp_path / "data"
monkeypatch.setenv("OUROBOROS_DATA_DIR", str(root))
monkeypatch.setenv("OUROBOROS_SETTINGS_PATH", str(root / "settings.json"))
@ -1399,10 +1401,8 @@ def test_reserve_path_compacts_only_past_config_threshold(data_root, monkeypatch
_lock_path(data_root), timeout_sec=0.05, stale_sec=3600.0, poll_sec=0.01,
owner_aware_stale=True,
)
# ... and the hold is WIRED THROUGH: without the heartbeat every ownership proof
# inside the pass (commit beats, the beat/look/beat around the rename) is a no-op
# and the swap runs unproven. The only production caller, so the wire is pinned where
# it is made — to THIS lock: a constant-True stub proves nothing (judged outside the call).
# ... and the hold is WIRED THROUGH: without the heartbeat every ownership proof inside the
# pass is a no-op and the swap runs unproven; pinned at the only production caller, to THIS lock.
os.utime(_lock_path(data_root), (0.0, 0.0))
renewed.append(kwargs["heartbeat"]() is True and _lock_path(data_root).stat().st_mtime > time.time() - 60)
holds.append(probe is None)

View file

@ -185,6 +185,37 @@ def test_executor_cleanup_ignores_owner_shaped_forged_host_pid_records(tmp_path,
assert workspace_executor.kill_all_foreground(data, wait=False) == []
def test_executor_cleanup_kills_a_hash_less_record_of_our_own_live_child(tmp_path, monkeypatch):
"""A record whose command line could not be captured at register time (macOS
`ps` right after the spawn, Windows always) still names OUR child: it is alive
and answers signal 0, so the panic cleanup must kill it — the forged-record
rule above refuses only pids we cannot signal (macOS full-test 33658408570)."""
import subprocess
import sys
import ouroboros.workspace_executor as workspace_executor
from ouroboros.platform_layer import subprocess_new_group_kwargs
data = tmp_path / "data"
child = subprocess.Popen(
[sys.executable, "-c", "import time; time.sleep(30)"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL,
**subprocess_new_group_kwargs(),
)
killed: list = []
try:
monkeypatch.setattr(workspace_executor, "_process_command_sha256", lambda _pid: "")
workspace_executor._register_process(
data, {"record_type": "foreground", "executor_type": "local", "host_pid": child.pid},
)
monkeypatch.setattr(workspace_executor, "_kill_host_pid", lambda pid: killed.append(int(pid)))
assert [r["id"] for r in workspace_executor.kill_all_foreground(data, wait=False)]
assert killed == [child.pid]
finally:
child.kill()
child.wait(timeout=10)
def test_executor_cleanup_ignores_pidless_docker_service_records(tmp_path, monkeypatch):
import ouroboros.workspace_executor as workspace_executor