mirror of
https://github.com/razzant/ouroboros.git
synced 2026-10-03 20:27:56 +00:00
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:
parent
5ae7f3577f
commit
abe9370237
3 changed files with 48 additions and 9 deletions
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue