mirror of
https://github.com/razzant/ouroboros.git
synced 2026-10-02 19:58:46 +00:00
Module side. 34 D11 owners classified: 13 byte-identical across tip/merge-base/reference (client_surface, gateway __init__/files/logs/mcp/ onboarding_host/schedules/task_events/task_hurry/ui_preferences, server_auth, server_entrypoint, server_web); 17 pure upstream drift - tip bytes stand (gateway _helpers/claudexor_accounts/contracts/control/extensions/history/ host_service/marketplace/models/presence_settings/projects/router/ skill_publish/state/tasks/ws, server_runtime); 3 carry only the D03 settings-seam / D04 retired-knob reference deltas - HOT-DEFERRED with that seam (gateway owner_settings/onboarding/settings, D12/D17 precedent); server.py split. The split: 6 leaves, 43 moved spans (rows 1034-1078 + 3948-3949), every span transplant-tool proof-green against git show HEAD:server.py (ast=tokens= byte-roundtrip on every symbol, leaf_invariants=[], zero declared names - the reference design homes shared rebindable state in server_process, so all six are projection-only leaves, no handles). server.py 3191->1640: server_process (drive root, logger, restart signals), server_routing_context (13 owner-turn projections), server_owner_routing (attachment staging, mailbox delivery, routing receipt, owner-message dispatch, /evolve off), server_liveness (WS3 wedge watchdog), server_maintenance (startup sweeps + periodic cadences), server_restart (live census, teardown args, update guards, bus shutdown). Facade = tip parent - moved spans + reference-style top import block (module- level PORT_FILE/logging reads force top imports), facade audit green: every kept span byte-identical to tip, every moved name re-exported by identity. Drift-probe first per leaf: reference leaves byte-true except 10 spans falsified by upstream drift (attachment-report train, OB-03 monotonic clock, child-ref promotion, planned-handoff train) - re-emitted from tip bytes, no oracle semantics replayed over drift. HOT-DEFERRED with evidence: rows 1070/1072/1073/1074 (_pending_restart, _handle_restart_in_supervisor, _check_pending_restart_drain, _perform_supervisor_restart) - the upstream delegation train coupled the restart performer to main() through the written module global _planned_delegate_restart_transaction_id; byte-preserving relocation would fork that state (D09-class second answer about ownership); inventory pinned in test_server_extraction._SERVER_OWNED. Rows 1080-1081 (lifespan, D03 settings-seam server half) - reader halves 913-917 are hot-deferred by D12/D17 (settings_integrity rewrite); landing the boot half alone would leave normalization neither persisted nor re-derived. Gateway ABI/alias rows = F3; web/ untouched. LIVE delta landed: the same-qualname FUNCTION_DEBT relocation rule (row 1033, delta id D11) replayed byte-identical from the reference into the tip-shaped validate_manifest_transition (ouroboros/review.py is NOT a protected file; the reference-only MODULE_DEBT_1500 layer NOT replayed - Q11=B). Pin renamed per the row with reference bytes. This unblocks the D08 lane's row 2016 deferral (FUNCTION_DEBT relocation of _handle_schedule_task). Test side: pin suite test_server_extraction (6, reference-adapted: deferred restart rows moved to the _SERVER_OWNED work order, prune-sweep rows 3948-3949 added to _MOVED_OWNERS, facade bound 1700 while the restart organ is deferred); rows 1192/1259 landed as the D11 slice of the reconciliation theme split (the two server_maintenance-bound tests re-homed - the byte-debt ratchet refused the +40-byte in-place retarget, giant 320340->318310); owner retargets mirrored path-keyed (run_isolation DATA_DIR, phase3c maintenance owner, project_routing_v664 routing_context, client_surface joined-text pin, ws3 fake clock -> server_liveness, panic-sweep leaf floor 5->11); patches of facade-resident readers deliberately NOT retargeted (they ride the deferral). All touched test files lossless (the one rename is ledger row 1033); no new ast-identical dup bodies. size-ratchet manifest regenerated with the official tool (one byte-debt shrink); ratchet lane 4 passed + 1 pre-existing base red reproduced bit-for- bit on pristinea56bb76a(parent-pair manifest/tree mismatch at7d2dca49, documented in LEDGER_CORRECTIONS 9) - the (a56bb76a-> this commit) pair is consistent. ruff check . --select F clean. CI-shape battery on this tree: parallel 11983 passed rc=0, serial 609 passed rc=0. Import smoke green (identity re-exports, shared Events single home). docs/v7next/LEDGER_CORRECTIONS.md: D11 lane section (10 entries). (cherry picked from commit 849f90be0c3b554753b2c580d14ac70450b2c58c)
234 lines
9.6 KiB
Python
234 lines
9.6 KiB
Python
"""Which ports a panic stop sweeps, and in what order it hard-exits.
|
|
|
|
Characterization of the Emergency Stop contract that must survive any change to
|
|
how ``server_control.execute_panic_stop`` learns the port the server bound: the
|
|
sweep targets the ACTUALLY bound main port (a custom-port install must not
|
|
panic-kill an unrelated listener on 8765), the host-service port follows it, and
|
|
nothing — including a failing sweep — may delay or reorder the hard exit.
|
|
|
|
Every destructive operation is neutralized here: no real process, port, daemon
|
|
or interpreter teardown runs.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
|
|
class _ExitCalled(RuntimeError):
|
|
pass
|
|
|
|
|
|
def _harness(monkeypatch, tmp_path, *, port_kill=None):
|
|
"""Neutralize every destructive teardown op and record the panic timeline."""
|
|
from ouroboros import server_control
|
|
|
|
events: list = []
|
|
|
|
def _record(name, value=None):
|
|
events.append((name, value) if value is not None else (name,))
|
|
|
|
def _kill_port(port):
|
|
_record("kill_process_on_port", port)
|
|
if port_kill is not None:
|
|
port_kill(port)
|
|
|
|
monkeypatch.setattr("supervisor.state.load_state", lambda: {})
|
|
monkeypatch.setattr("supervisor.state.save_state", lambda _state: None)
|
|
monkeypatch.setattr(
|
|
"supervisor.evolution_lifecycle.complete_evolution_campaign", lambda *a, **k: {}
|
|
)
|
|
monkeypatch.setattr("ouroboros.post_task_evolution.drop_pending_request", lambda *a, **k: None)
|
|
monkeypatch.setattr(
|
|
"ouroboros.local_model.get_manager",
|
|
lambda: SimpleNamespace(stop_server=lambda: _record("local_model_stop")),
|
|
)
|
|
monkeypatch.setattr(
|
|
"ouroboros.claudexor_daemon.get_owned_daemon",
|
|
lambda: SimpleNamespace(stop=lambda: _record("owned_daemon_stop")),
|
|
)
|
|
monkeypatch.setattr(
|
|
"ouroboros.tools.shell.kill_all_tracked_subprocesses", lambda: _record("kill_shells")
|
|
)
|
|
monkeypatch.setattr(
|
|
"ouroboros.workspace_executor.kill_all_foreground",
|
|
lambda *a, **k: _record("kill_foreground"),
|
|
)
|
|
monkeypatch.setattr(
|
|
"ouroboros.tools.services.kill_all_services", lambda *a, **k: _record("kill_services")
|
|
)
|
|
monkeypatch.setattr(
|
|
"ouroboros.extension_companion.panic_kill_all", lambda: _record("panic_kill_companions")
|
|
)
|
|
monkeypatch.setattr("multiprocessing.active_children", lambda: [])
|
|
monkeypatch.setattr("ouroboros.platform_layer.force_kill_pid", lambda *a, **k: None)
|
|
monkeypatch.setattr("ouroboros.platform_layer.kill_process_on_port", _kill_port)
|
|
monkeypatch.setattr("ouroboros.gateway.host_service.host_service_port", lambda: 8767)
|
|
monkeypatch.setattr(
|
|
server_control.os, "_exit", lambda code: (_ for _ in ()).throw(_ExitCalled(code))
|
|
)
|
|
return events, lambda **kw: _record("kill_workers", kw)
|
|
|
|
|
|
def _swept_ports(events: list) -> list:
|
|
return [value for name, value in (e for e in events if len(e) == 2) if name == "kill_process_on_port"]
|
|
|
|
|
|
def test_panic_through_the_server_sweeps_the_actually_bound_port(monkeypatch, tmp_path):
|
|
"""A custom-port install must panic-kill ITS listener, never a stranger on 8765."""
|
|
import server
|
|
|
|
events, kill_workers = _harness(monkeypatch, tmp_path)
|
|
monkeypatch.setattr(server, "DATA_DIR", tmp_path)
|
|
monkeypatch.setattr(server, "_ACTUAL_BOUND_PORT", 9123)
|
|
|
|
with pytest.raises(_ExitCalled) as exit_info:
|
|
server._execute_panic_stop(SimpleNamespace(stop=lambda: None), kill_workers)
|
|
|
|
assert _swept_ports(events) == [9123, 8767]
|
|
assert exit_info.value.args[0] == server.PANIC_EXIT_CODE
|
|
|
|
|
|
def test_panic_with_no_known_bound_port_sweeps_the_default_install_port(monkeypatch, tmp_path):
|
|
"""Nothing told this panic which port was bound, so the default install port
|
|
is the last resort — the panic never skips the sweep."""
|
|
from ouroboros import server_control
|
|
|
|
events, kill_workers = _harness(monkeypatch, tmp_path)
|
|
|
|
with pytest.raises(_ExitCalled):
|
|
server_control.execute_panic_stop(
|
|
consciousness=SimpleNamespace(stop=lambda: None),
|
|
kill_workers_fn=kill_workers,
|
|
data_dir=tmp_path,
|
|
panic_exit_code=120,
|
|
log=SimpleNamespace(critical=lambda *a, **k: None),
|
|
)
|
|
|
|
assert _swept_ports(events) == [8765, 8767]
|
|
|
|
|
|
def test_a_failing_main_port_sweep_still_sweeps_the_default_and_the_host_service(
|
|
monkeypatch, tmp_path,
|
|
):
|
|
"""The main-port sweep is fail-soft: a raising kill must not cost the panic the
|
|
default-port fallback or the host-service sweep that follows it."""
|
|
import server
|
|
|
|
def _boom(port):
|
|
if port == 9123:
|
|
raise OSError("port sweep failed")
|
|
|
|
events, kill_workers = _harness(monkeypatch, tmp_path, port_kill=_boom)
|
|
monkeypatch.setattr(server, "DATA_DIR", tmp_path)
|
|
monkeypatch.setattr(server, "_ACTUAL_BOUND_PORT", 9123)
|
|
|
|
with pytest.raises(_ExitCalled):
|
|
server._execute_panic_stop(SimpleNamespace(stop=lambda: None), kill_workers)
|
|
|
|
assert _swept_ports(events) == [9123, 8765, 8767]
|
|
|
|
|
|
def test_panic_teardown_order_ends_with_the_port_sweep_then_the_hard_exit(monkeypatch, tmp_path):
|
|
"""Cleanup, then the fail-soft child/port sweep, then os._exit — and the
|
|
durable panic flag is written before any of the killing starts."""
|
|
import server
|
|
|
|
events, kill_workers = _harness(monkeypatch, tmp_path)
|
|
monkeypatch.setattr(server, "DATA_DIR", tmp_path)
|
|
monkeypatch.setattr(server, "_ACTUAL_BOUND_PORT", 9123)
|
|
|
|
with pytest.raises(_ExitCalled):
|
|
server._execute_panic_stop(SimpleNamespace(stop=lambda: None), kill_workers)
|
|
|
|
assert [event[0] for event in events] == [
|
|
"local_model_stop",
|
|
"owned_daemon_stop",
|
|
"kill_shells",
|
|
"kill_foreground",
|
|
"kill_services",
|
|
"panic_kill_companions",
|
|
"kill_workers",
|
|
"kill_process_on_port",
|
|
"kill_process_on_port",
|
|
]
|
|
# Upstream bytes (dc4c0204) added reconcile_delegate_custody=False to the
|
|
# panic's kill_workers call; the pinned contract (kill everything, then
|
|
# sweep ports, then hard-exit) is unchanged.
|
|
assert events[6][1] == {
|
|
"force": True, "archive_service_logs": False, "reconcile_delegate_custody": False,
|
|
}
|
|
assert (tmp_path / "state" / "panic_stop.flag").read_text(encoding="utf-8") == "panic"
|
|
|
|
|
|
def test_the_server_passes_its_bound_port_instead_of_the_leaf_reaching_back(monkeypatch):
|
|
"""Emergency Stop 2A: the composition root owns the bound-port fact and hands
|
|
it down as a keyword-only argument with a default, so the panic leaf never has
|
|
to reach back into the server module for it."""
|
|
import inspect
|
|
|
|
import server
|
|
from ouroboros import server_control
|
|
|
|
parameter = inspect.signature(server_control.execute_panic_stop).parameters["bound_port"]
|
|
assert parameter.kind is inspect.Parameter.KEYWORD_ONLY
|
|
assert parameter.default is None
|
|
|
|
captured: dict = {}
|
|
monkeypatch.setattr(server, "_execute_panic_stop_impl", lambda *a, **kw: captured.update(kw))
|
|
monkeypatch.setattr(server, "_ACTUAL_BOUND_PORT", 9123)
|
|
|
|
server._execute_panic_stop(SimpleNamespace(stop=lambda: None), lambda **kw: None)
|
|
|
|
assert captured["bound_port"] == 9123
|
|
|
|
|
|
def test_no_server_host_leaf_imports_the_composition_root():
|
|
"""The lazy `import server` inside the panic port sweep was the last back-edge
|
|
from a host leaf to the composition root. Scanned as a class, at any depth, so
|
|
a future lazy import inside a function cannot quietly restore it."""
|
|
import ast
|
|
import pathlib
|
|
|
|
import server
|
|
|
|
leaves = sorted((pathlib.Path(server.__file__).parent / "ouroboros").glob("server_*.py"))
|
|
# 5 pre-split host leaves + the 6 D11 server-split leaves (liveness,
|
|
# maintenance, owner_routing, process, restart, routing_context). The floor
|
|
# guards against the glob silently matching nothing.
|
|
assert len(leaves) >= 11
|
|
for leaf in leaves:
|
|
for node in ast.walk(ast.parse(leaf.read_text(encoding="utf-8"))):
|
|
if isinstance(node, ast.Import):
|
|
assert not any(
|
|
alias.name == "server" or alias.name.startswith("server.")
|
|
for alias in node.names
|
|
), leaf.name
|
|
if isinstance(node, ast.ImportFrom):
|
|
assert node.module != "server", leaf.name
|
|
|
|
|
|
def test_emergency_process_cleanup_stays_a_separate_path_from_panic(monkeypatch, tmp_path):
|
|
"""The uvicorn-hang cleanup is NOT the panic: it finalizes running tasks with an
|
|
honest interrupted reason and returns, where panic hard-exits. Keeping them
|
|
separate is an explicit design decision, not an oversight."""
|
|
import server
|
|
|
|
worker_calls = []
|
|
monkeypatch.setattr(server, "DATA_DIR", tmp_path)
|
|
monkeypatch.setattr("ouroboros.tools.shell.kill_all_tracked_subprocesses", lambda: None)
|
|
monkeypatch.setattr("ouroboros.workspace_executor.kill_all_foreground", lambda *a, **k: None)
|
|
monkeypatch.setattr("ouroboros.tools.services.kill_all_services", lambda *a, **k: None)
|
|
monkeypatch.setattr("supervisor.workers.kill_workers", lambda **kw: worker_calls.append(kw))
|
|
monkeypatch.setattr("multiprocessing.active_children", lambda: [])
|
|
monkeypatch.setattr("ouroboros.platform_layer.force_kill_pid", lambda *a, **k: None)
|
|
monkeypatch.setattr("ouroboros.platform_layer.kill_process_on_port", lambda _port: None)
|
|
monkeypatch.setattr("ouroboros.extension_companion.panic_kill_all", lambda: None)
|
|
monkeypatch.setattr("ouroboros.gateway.host_service.host_service_port", lambda: 8767)
|
|
|
|
# Returns normally: no os._exit patch is needed, which is the whole point.
|
|
server._emergency_process_cleanup(port_sweep=False)
|
|
|
|
assert worker_calls and worker_calls[0]["force"] is True
|