Mechanical, whole-tree `ruff format` pass — no behaviour change. Recorded in .git-blame-ignore-revs (next commit) so git blame skips it.
240 lines
9.9 KiB
Python
240 lines
9.9 KiB
Python
"""The download supervisor/watchdog: `_run_supervised` decides when to hard-kill a child, and
|
|
`_stop_child` escalates SIGTERM→SIGKILL. No real subprocess or DB here — a fake proc stands in and
|
|
the DB-liveness lookup + side effects are monkeypatched, so the decision logic is exercised on any
|
|
machine. These are regression tests for the class of bug that motivated the whole change: a wedged
|
|
download that a thread could neither kill nor cancel.
|
|
"""
|
|
|
|
import signal
|
|
import subprocess
|
|
|
|
import pytest
|
|
|
|
from app import worker
|
|
|
|
|
|
class _FakeProc:
|
|
"""Stands in for the child Popen. `waits` scripts each wait() call: "timeout" = still alive,
|
|
("exit", rc) = the child finished with that code."""
|
|
|
|
def __init__(self, waits, pid=4242):
|
|
self._waits = list(waits)
|
|
self.pid = pid
|
|
self.returncode = None
|
|
self.signals = []
|
|
|
|
def wait(self, timeout=None):
|
|
step = self._waits.pop(0) if self._waits else "timeout"
|
|
if step == "timeout":
|
|
raise subprocess.TimeoutExpired("child", timeout or 0)
|
|
self.returncode = step[1]
|
|
return step[1]
|
|
|
|
def poll(self):
|
|
return self.returncode
|
|
|
|
def send_signal(self, sig):
|
|
self.signals.append(sig)
|
|
|
|
|
|
@pytest.fixture
|
|
def rec(monkeypatch):
|
|
"""Record supervisor side effects and stub out the real spawn/kill/liveness/staging so a test
|
|
drives them. `calls` collects (name, kwargs) so assertions read the decisions, not the plumbing."""
|
|
calls = []
|
|
|
|
def record(name):
|
|
def _f(*args, **kwargs):
|
|
calls.append((name, args, kwargs))
|
|
|
|
return _f
|
|
|
|
monkeypatch.setattr(worker, "_reset_asset_pending", record("reset_asset"))
|
|
monkeypatch.setattr(worker, "_requeue_if_running", record("requeue"))
|
|
monkeypatch.setattr(worker, "_set_job", record("set_job"))
|
|
monkeypatch.setattr(worker, "_cleanup_staging", record("cleanup"))
|
|
monkeypatch.setattr(worker, "_stop_child", record("stop_child"))
|
|
monkeypatch.setattr(worker._stop, "is_set", lambda: False)
|
|
return calls
|
|
|
|
|
|
def _names(calls):
|
|
return [c[0] for c in calls]
|
|
|
|
|
|
def _spawn(monkeypatch, proc):
|
|
monkeypatch.setattr(worker, "_spawn_download_child", lambda job_id: proc)
|
|
|
|
|
|
def _liveness(monkeypatch, value):
|
|
monkeypatch.setattr(worker, "_job_liveness", lambda job_id: value)
|
|
|
|
|
|
def test_clean_child_exit_leaves_the_outcome_alone(monkeypatch, rec):
|
|
# Child finished on its own (exit 0) — it wrote the terminal state. The supervisor only reconciles
|
|
# the asset (a no-op when the child finalized it) and must not kill or re-fail anything.
|
|
_spawn(monkeypatch, _FakeProc([("exit", 0)]))
|
|
worker._run_supervised(1, 99)
|
|
assert _names(rec) == ["reset_asset"]
|
|
|
|
|
|
def test_no_progress_stall_kills_and_fails_the_job(monkeypatch, rec):
|
|
_spawn(monkeypatch, _FakeProc(["timeout"]))
|
|
_liveness(monkeypatch, ("running", 9999.0)) # far past the stall timeout
|
|
worker._run_supervised(1, 99)
|
|
names = _names(rec)
|
|
assert "stop_child" in names and "reset_asset" in names
|
|
set_job = next(c for c in rec if c[0] == "set_job")
|
|
assert set_job[2]["status"] == "error" and set_job[2]["error_code"] == "network"
|
|
assert "stalled" in set_job[2]["error"].lower()
|
|
|
|
|
|
def test_route_pause_kills_but_keeps_the_route_status(monkeypatch, rec):
|
|
# A route flipped the job to 'paused'. Kill + free the asset, but do NOT overwrite the
|
|
# status with an error and do NOT requeue.
|
|
_spawn(monkeypatch, _FakeProc(["timeout"]))
|
|
_liveness(monkeypatch, ("paused", 1.0))
|
|
worker._run_supervised(1, 99)
|
|
names = _names(rec)
|
|
assert "stop_child" in names and "reset_asset" in names
|
|
assert "set_job" not in names and "requeue" not in names
|
|
|
|
|
|
def test_route_cancel_kills_but_keeps_the_route_status(monkeypatch, rec):
|
|
# B-sub-1 regression: the cancel ROUTE writes the literal 'canceled' (models.py), but the supervisor
|
|
# used to compare against 'cancelled' — so this branch was DEAD and a canceled job's child ran on to
|
|
# completion, overwriting 'canceled' with 'done' and double-releasing its asset. The supervisor MUST
|
|
# treat the route's actual literal as a stop: kill + free the asset, no error write, no requeue.
|
|
_spawn(monkeypatch, _FakeProc(["timeout"]))
|
|
_liveness(monkeypatch, ("canceled", 1.0))
|
|
worker._run_supervised(1, 99)
|
|
names = _names(rec)
|
|
assert "stop_child" in names and "reset_asset" in names
|
|
assert "set_job" not in names and "requeue" not in names
|
|
|
|
|
|
def test_wall_clock_cap_kills_even_while_progressing(monkeypatch, rec):
|
|
monkeypatch.setattr(worker.settings, "download_wall_clock_max_seconds", 0)
|
|
_spawn(monkeypatch, _FakeProc(["timeout"]))
|
|
_liveness(monkeypatch, ("running", 1.0)) # fresh progress, but over the absolute cap
|
|
worker._run_supervised(1, 99)
|
|
set_job = next(c for c in rec if c[0] == "set_job")
|
|
assert set_job[2]["status"] == "error"
|
|
assert "time limit" in set_job[2]["error"].lower()
|
|
|
|
|
|
def test_child_crash_without_finishing_is_failed(monkeypatch, rec):
|
|
# Non-zero exit and the job is still 'running' → the child died (crash/OOM) before writing state.
|
|
_spawn(monkeypatch, _FakeProc([("exit", 1)]))
|
|
_liveness(monkeypatch, ("running", 1.0))
|
|
worker._run_supervised(1, 99)
|
|
names = _names(rec)
|
|
assert "reset_asset" in names
|
|
set_job = next(c for c in rec if c[0] == "set_job")
|
|
assert set_job[2]["status"] == "error" and "unexpectedly" in set_job[2]["error"].lower()
|
|
|
|
|
|
def test_child_nonzero_exit_that_already_set_state_is_left_alone(monkeypatch, rec):
|
|
# Child exited non-zero but the job is no longer 'running' (it recorded its own error) → no double.
|
|
_spawn(monkeypatch, _FakeProc([("exit", 1)]))
|
|
_liveness(monkeypatch, ("error", None))
|
|
worker._run_supervised(1, 99)
|
|
assert "set_job" not in _names(rec)
|
|
|
|
|
|
def test_spawn_failure_frees_the_asset_and_requeues(monkeypatch, rec):
|
|
def boom(job_id):
|
|
raise OSError("no fork")
|
|
|
|
monkeypatch.setattr(worker, "_spawn_download_child", boom)
|
|
worker._run_supervised(1, 99)
|
|
names = _names(rec)
|
|
assert "reset_asset" in names and "requeue" in names
|
|
|
|
|
|
def test_shutdown_kills_and_requeues_for_next_boot(monkeypatch, rec):
|
|
monkeypatch.setattr(worker._stop, "is_set", lambda: True)
|
|
_spawn(monkeypatch, _FakeProc(["timeout"]))
|
|
_liveness(monkeypatch, ("running", 1.0))
|
|
worker._run_supervised(1, 99)
|
|
names = _names(rec)
|
|
assert "stop_child" in names and "requeue" in names and "set_job" not in names
|
|
|
|
|
|
def test_a_long_download_survives_while_progress_stays_fresh(monkeypatch, rec):
|
|
# A download that outlives the stall timeout must NOT be killed as long as it keeps writing
|
|
# progress (updated_at stays fresh via the model's onupdate). Model: alive across several polls,
|
|
# liveness always fresh, then a clean exit — nothing killed, nothing failed.
|
|
monkeypatch.setattr(worker.settings, "download_stall_timeout_seconds", 10)
|
|
_spawn(monkeypatch, _FakeProc(["timeout", "timeout", "timeout", ("exit", 0)]))
|
|
_liveness(monkeypatch, ("running", 2.0)) # always well under the 10s stall
|
|
worker._run_supervised(1, 99)
|
|
assert _names(rec) == ["reset_asset"] # only the clean-exit reconciliation (a no-op), no kill
|
|
|
|
|
|
def test_a_finishing_child_is_not_mistaken_for_a_cancel(monkeypatch, rec):
|
|
# The child committed its terminal state (done/error) but hasn't exited yet (e.g. slow staging
|
|
# cleanup outlasting a poll). A non-'running' status must NOT be treated as a route-cancel and
|
|
# kill the finisher (which could drop its completion notification) — let it exit on its own.
|
|
_spawn(monkeypatch, _FakeProc(["timeout", ("exit", 0)])) # alive one poll, then a clean exit
|
|
_liveness(monkeypatch, ("done", 1.0))
|
|
worker._run_supervised(1, 99)
|
|
assert _names(rec) == ["reset_asset"] # not killed; only the clean-exit reconciliation (no-op)
|
|
|
|
|
|
def test_a_wedged_finished_child_is_killed_by_the_wall_clock(monkeypatch, rec):
|
|
# A child that reached a terminal state (done) but never exits (e.g. wedged in teardown on a stuck
|
|
# mount) must still be killed by the absolute wall-clock cap to free the worker slot — WITHOUT
|
|
# overwriting its DB status (it already succeeded).
|
|
monkeypatch.setattr(worker.settings, "download_wall_clock_max_seconds", 0)
|
|
_spawn(monkeypatch, _FakeProc(["timeout"])) # never exits within the test
|
|
_liveness(monkeypatch, ("done", 1.0)) # finished, but the process lingers
|
|
worker._run_supervised(1, 99)
|
|
names = _names(rec)
|
|
assert "stop_child" in names and "set_job" not in names # killed to free the slot, status kept
|
|
|
|
|
|
def test_a_watchdog_error_still_stops_the_child(monkeypatch, rec):
|
|
# If the liveness probe raises (e.g. a transient DB error), the child must NOT be left running
|
|
# unsupervised — the feature's whole point. It is killed and the job is failed.
|
|
_spawn(monkeypatch, _FakeProc(["timeout"]))
|
|
|
|
def boom(job_id):
|
|
raise RuntimeError("db down")
|
|
|
|
monkeypatch.setattr(worker, "_job_liveness", boom)
|
|
worker._run_supervised(1, 99)
|
|
names = _names(rec)
|
|
assert "stop_child" in names and "reset_asset" in names
|
|
assert next(c for c in rec if c[0] == "set_job")[2]["status"] == "error"
|
|
|
|
|
|
# --- _stop_child escalation -----------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture
|
|
def killpg_rec(monkeypatch):
|
|
sent = []
|
|
monkeypatch.setattr(worker.os, "getpgid", lambda pid: pid)
|
|
monkeypatch.setattr(worker.os, "killpg", lambda pgid, sig: sent.append(sig))
|
|
return sent
|
|
|
|
|
|
def test_stop_child_sigterm_is_enough(killpg_rec):
|
|
proc = _FakeProc([("exit", 0)]) # dies on the SIGTERM wait
|
|
worker._stop_child(proc, grace_s=0.01)
|
|
assert killpg_rec == [signal.SIGTERM]
|
|
|
|
|
|
def test_stop_child_escalates_to_sigkill(killpg_rec):
|
|
proc = _FakeProc(["timeout", ("exit", -9)]) # survives SIGTERM, dies on SIGKILL
|
|
worker._stop_child(proc, grace_s=0.01)
|
|
assert killpg_rec == [signal.SIGTERM, signal.SIGKILL]
|
|
|
|
|
|
def test_stop_child_noop_if_already_exited(killpg_rec):
|
|
proc = _FakeProc([])
|
|
proc.returncode = 0 # already gone
|
|
worker._stop_child(proc, grace_s=0.01)
|
|
assert killpg_rec == []
|