fix(downloads): don't let the supervisor kill a finishing child (review round 2)
- worker.py: the watchdog loop now treats only 'paused'/'cancelled' (or a vanished row) as a stop; a terminal status (done/error, or a re-queued contended asset) means the child already wrote its outcome and is exiting, so keep polling instead of killing it. Previously a proc.wait timeout landing between the child's terminal commit and its exit was misread as a route-cancel and SIGKILLed a successful download (dropping its completion notification). The stall/wall-clock/shutdown checks are now gated on status=='running' (#1, #5 test). - worker.py: guard the crash-path _job_liveness probe so a DB blip there can't propagate (the child is already dead; boot recovery is the backstop) (#2). - worker.py: drop _stop_child's redundant poll guard — escalate_kill no-ops on an exited proc and getpgid yields None for a reaped pid (#3). - worker.py: note the extraction heartbeat is per-attempt (a single over-timeout attempt is still killed, bounded by socket_timeout) (#4).
This commit is contained in:
+34
-24
@@ -279,9 +279,8 @@ def _signal_group(proc: subprocess.Popen, pgid: int | None, sig: int) -> None:
|
||||
def _stop_child(proc: subprocess.Popen, grace_s: float) -> None:
|
||||
"""SIGTERM the child's process GROUP, then SIGKILL if it outlives the grace period. Shares the
|
||||
terminate→wait→kill escalation with run_ffmpeg (`editmod.escalate_kill`); the only difference is
|
||||
that we signal the whole group so ffmpeg grandchildren die too."""
|
||||
if proc.poll() is not None:
|
||||
return
|
||||
that we signal the whole group so ffmpeg grandchildren die too. `escalate_kill` no-ops if the
|
||||
child already exited, and `getpgid` yields None for a reaped pid, so no separate poll guard."""
|
||||
try:
|
||||
pgid: int | None = os.getpgid(proc.pid)
|
||||
except OSError:
|
||||
@@ -326,18 +325,23 @@ def _run_supervised(job_id: int, asset_id: int) -> None:
|
||||
except subprocess.TimeoutExpired:
|
||||
pass
|
||||
status, stale = _job_liveness(job_id)
|
||||
if status is None or status != "running":
|
||||
reason = "cancelled" # a route paused/cancelled it (or the row vanished)
|
||||
break
|
||||
if stale is not None and stale > stall_s:
|
||||
reason = "stalled"
|
||||
break
|
||||
if time.monotonic() - started > wall_s:
|
||||
reason = "timeout"
|
||||
break
|
||||
if _stop.is_set():
|
||||
reason = "shutdown"
|
||||
if status == "running":
|
||||
# Only a genuinely-running child is subject to the stall / wall-clock / shutdown kill.
|
||||
if stale is not None and stale > stall_s:
|
||||
reason = "stalled"
|
||||
break
|
||||
if time.monotonic() - started > wall_s:
|
||||
reason = "timeout"
|
||||
break
|
||||
if _stop.is_set():
|
||||
reason = "shutdown"
|
||||
break
|
||||
elif status is None or status in ("paused", "cancelled"):
|
||||
reason = "cancelled" # a route stopped it (or the row vanished)
|
||||
break
|
||||
# else: a terminal status (done / error, or a re-queued contended asset) means the child
|
||||
# already wrote its outcome and is exiting on its own — keep polling; the next proc.wait
|
||||
# catches the exit. NEVER kill a finishing child (that could drop its completion notice).
|
||||
except Exception: # noqa: BLE001 — a watchdog error must NEVER leave the child running unsupervised
|
||||
log.exception("supervisor loop failed for job %s; stopping the child", job_id)
|
||||
_stop_child(proc, grace_s)
|
||||
@@ -351,17 +355,21 @@ def _run_supervised(job_id: int, asset_id: int) -> None:
|
||||
|
||||
if reason is None:
|
||||
# Child exited. If it died WITHOUT setting a terminal state (non-zero exit / crash / OOM),
|
||||
# the job is still 'running' — fail it here so nothing is left stuck.
|
||||
# the job is still 'running' — fail it here so nothing is left stuck. Guarded: the child is
|
||||
# already dead (no orphan risk), so a DB blip in this probe must not propagate.
|
||||
if proc.returncode != _CHILD_OK:
|
||||
status, _ = _job_liveness(job_id)
|
||||
if status == "running":
|
||||
_reset_asset_pending(asset_id)
|
||||
_cleanup_staging(asset_id)
|
||||
_set_job(
|
||||
job_id, status="error", error_code="unknown", phase=None,
|
||||
error=f"The download process exited unexpectedly (code {proc.returncode}).",
|
||||
)
|
||||
log.warning("job %s: child exited %s without finishing", job_id, proc.returncode)
|
||||
try:
|
||||
status, _ = _job_liveness(job_id)
|
||||
if status == "running":
|
||||
_reset_asset_pending(asset_id)
|
||||
_cleanup_staging(asset_id)
|
||||
_set_job(
|
||||
job_id, status="error", error_code="unknown", phase=None,
|
||||
error=f"The download process exited unexpectedly (code {proc.returncode}).",
|
||||
)
|
||||
log.warning("job %s: child exited %s without finishing", job_id, proc.returncode)
|
||||
except Exception: # noqa: BLE001 — boot recovery is the backstop for a stuck 'running' job
|
||||
log.exception("post-exit cleanup failed for job %s", job_id)
|
||||
return
|
||||
|
||||
_stop_child(proc, grace_s)
|
||||
@@ -712,6 +720,8 @@ def _extract_with_fallback(job_id: int, asset_id: int, url: str, spec: dict, sta
|
||||
opts["postprocessor_hooks"] = [_make_pp_hook(job_id)]
|
||||
_touch_job(job_id) # stall-watchdog heartbeat: extraction writes no progress, so a slow /
|
||||
# multi-client-set fallback would otherwise cross the stall timeout with a stale updated_at.
|
||||
# (Per-ATTEMPT only; a single extract_info that itself runs past the stall timeout is still
|
||||
# killed — acceptable, as socket_timeout + extractor_retries bound one attempt well under it.)
|
||||
try:
|
||||
with yt_dlp.YoutubeDL(opts) as ydl:
|
||||
return ydl.extract_info(url, download=True)
|
||||
|
||||
Reference in New Issue
Block a user