feat(1.2): prefork supervisor primitive + tests (C, CoW gate passed)

CoW measurement on CPython 3.14: forked idle child keeps ~71MB shared,
dirties ~1MB private; working child ~26MB. PEP 683 immortal objects keep
code/module pages clean so gc.freeze() is unnecessary (freeze==nofreeze).

prefork.run_fleet: master imports the base floor once, forks one child
per worker (own process/GIL, CoW-shared floor), reaps + restarts with
backoff, graceful SIGTERM->SIGKILL shutdown. Not yet wired to a command
(that lands when 1.2 picks the target worker set).
This commit is contained in:
2026-06-18 18:36:38 -04:00
parent af615f8d44
commit 74096b6df0
3 changed files with 235 additions and 0 deletions

140
decnet/prefork.py Normal file
View File

@@ -0,0 +1,140 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Prefork supervisor — import the base floor ONCE in a master, then fork one
child process per worker. Children share the ~70 MB import floor via
copy-on-write.
Measured on CPython 3.14 (development/cow_probe.py): an idle forked child keeps
~71 MB shared and dirties only ~1 MB private; a working child dirties ~26 MB
(its own heap, not the floor). PEP 683 immortal objects keep module/code pages
clean, so the classic refcount-dirties-CoW problem does not bite and gc.freeze()
is unnecessary on 3.14.
Contrast with :mod:`decnet.supervisor` (asyncio tasks in ONE process, shared
GIL): use that for cheap co-resident IO workers. Use prefork for workers that
must keep their OWN process / GIL — CPU-heavy or isolation-critical — but
shouldn't each re-import the world.
Each worker spec is a zero-arg callable that BLOCKS running the worker (e.g.
``lambda: asyncio.run(profiler_worker(repo))``). It executes in the forked
child; the master only forks, reaps, and restarts.
"""
from __future__ import annotations
import logging
import os
import signal
import time
from collections.abc import Callable
log = logging.getLogger("decnet.prefork")
WorkerEntry = Callable[[], None]
def run_fleet(
specs: dict[str, WorkerEntry],
*,
max_backoff: float = 30.0,
poll_interval: float = 0.2,
stop_after: float | None = None,
) -> None:
"""Fork one child per worker and supervise them until SIGTERM/SIGINT.
A dead child is re-forked after exponential backoff (in-process
``Restart=on-failure``). Backoff is tracked per worker and scheduled
non-blockingly, so one worker's restart delay never stalls reaping of
another. On shutdown, children get SIGTERM, then SIGKILL after a grace
period.
``stop_after`` (seconds) is a test hook: cleanly shut the fleet down after
that long instead of waiting for a signal.
"""
if not specs:
return
children: dict[int, str] = {} # pid -> name
backoff: dict[str, float] = {n: 1.0 for n in specs}
due: dict[str, float] = {} # name -> earliest restart time
stopping = {"flag": False}
def _request_stop(_signum: int, _frame: object) -> None:
stopping["flag"] = True
signal.signal(signal.SIGTERM, _request_stop)
signal.signal(signal.SIGINT, _request_stop)
def spawn(name: str) -> None:
pid = os.fork()
if pid == 0: # ---- child ----
# Restore default signal handling so the worker's own asyncio
# handlers (or KeyboardInterrupt) work as if launched standalone.
signal.signal(signal.SIGTERM, signal.SIG_DFL)
signal.signal(signal.SIGINT, signal.SIG_DFL)
try:
specs[name]()
except KeyboardInterrupt:
pass
except BaseException: # noqa: BLE001 — last-resort child logging
log.exception("prefork: worker %s raised", name)
os._exit(1)
os._exit(0)
children[pid] = name # ---- parent ----
log.info("prefork: spawned %s pid=%d", name, pid)
log.info("prefork: master pid=%d forking %d workers: %s",
os.getpid(), len(specs), ", ".join(specs))
for name in specs:
spawn(name)
deadline = (time.monotonic() + stop_after) if stop_after is not None else None
while not stopping["flag"]:
if deadline is not None and time.monotonic() >= deadline:
break
now = time.monotonic()
# Restart any workers whose backoff has elapsed.
for name in [n for n, t in due.items() if now >= t]:
del due[name]
spawn(name)
# Reap without blocking so concurrent crashes are all handled.
try:
pid, status = os.waitpid(-1, os.WNOHANG)
except ChildProcessError:
pid = 0
if pid == 0:
time.sleep(poll_interval)
continue
name = children.pop(pid, None)
if name is None:
continue
code = os.waitstatus_to_exitcode(status)
log.warning("prefork: %s (pid=%d) exited code=%d; restart in %.0fs",
name, pid, code, backoff[name])
due[name] = time.monotonic() + backoff[name]
backoff[name] = min(backoff[name] * 2.0, max_backoff)
_shutdown(children)
def _shutdown(children: dict[int, str], *, grace: float = 15.0) -> None:
"""SIGTERM all children, reap within ``grace``, SIGKILL stragglers."""
for pid in list(children):
try:
os.kill(pid, signal.SIGTERM)
except ProcessLookupError:
children.pop(pid, None)
deadline = time.monotonic() + grace
while children and time.monotonic() < deadline:
try:
pid, _ = os.waitpid(-1, os.WNOHANG)
except ChildProcessError:
break
if pid:
children.pop(pid, None)
else:
time.sleep(0.1)
for pid in list(children):
try:
os.kill(pid, signal.SIGKILL)
except ProcessLookupError:
pass
log.info("prefork: fleet shut down")

55
tests/prefork_driver.py Normal file
View File

@@ -0,0 +1,55 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Standalone driver for the prefork supervisor — runnable directly OR via
tests/test_prefork.py (which execs it in a subprocess so no fork happens inside
the pytest/xdist worker).
python tests/prefork_driver.py <out_dir>
Forks two fake workers under decnet.prefork.run_fleet:
* "tick" — append a line every 0.2s forever (proves a worker runs & stays up)
* "crasher" — write a marker then exit(1) (proves restart-on-crash)
Runs for ~2s via stop_after, then shuts the fleet down. Writes results into
<out_dir>; the caller asserts on them.
"""
from __future__ import annotations
import os
import sys
import time
# Running this file as a script puts its own dir (tests/) on sys.path[0], which
# shadows the stdlib `logging` via tests/logging/. Drop it before importing
# decnet (still importable — it's installed in the venv).
if sys.path and os.path.basename(sys.path[0]) == "tests":
sys.path.pop(0)
from decnet.prefork import run_fleet # noqa: E402
def main(out: str) -> None:
tick_log = os.path.join(out, "tick.log")
crash_log = os.path.join(out, "crash.log")
def tick() -> None:
while True:
with open(tick_log, "a") as f:
f.write("t\n")
time.sleep(0.2)
def crasher() -> None:
with open(crash_log, "a") as f:
f.write("c\n")
time.sleep(0.15)
os._exit(1)
# Fast backoff so we observe multiple restarts inside the short window.
run_fleet(
{"tick": tick, "crasher": crasher},
max_backoff=0.2,
poll_interval=0.05,
stop_after=2.0,
)
if __name__ == "__main__":
main(sys.argv[1] if len(sys.argv) > 1 else ".")

40
tests/test_prefork.py Normal file
View File

@@ -0,0 +1,40 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Prefork supervisor behaviour, exercised via a subprocess driver so no fork
happens inside the pytest/xdist worker (which would be unsafe).
Proves: workers fork and run, a crashing worker is restarted with backoff, and
the fleet shuts down cleanly (stop_after returns, no orphaned children).
"""
from __future__ import annotations
import pathlib
import subprocess
import sys
def test_prefork_runs_and_restarts(tmp_path: pathlib.Path):
driver = pathlib.Path(__file__).parent / "prefork_driver.py"
proc = subprocess.run(
[sys.executable, str(driver), str(tmp_path)],
capture_output=True, text=True, timeout=30,
)
assert proc.returncode == 0, f"driver failed:\n{proc.stderr}"
tick = (tmp_path / "tick.log").read_text().splitlines()
crash = (tmp_path / "crash.log").read_text().splitlines()
# tick ran continuously for ~2s at 0.2s cadence → several lines.
assert len(tick) >= 5, f"tick worker did not stay up: {len(tick)} lines"
# crasher died fast and was restarted repeatedly → many markers.
assert len(crash) >= 3, f"crasher was not restarted: {len(crash)} markers"
def test_empty_fleet_returns(tmp_path: pathlib.Path):
# run_fleet([]) must be a no-op, not hang.
code = (
"from decnet.prefork import run_fleet; run_fleet({}, stop_after=5)"
)
proc = subprocess.run(
[sys.executable, "-c", code], capture_output=True, text=True, timeout=15
)
assert proc.returncode == 0, proc.stderr