testsAndMisc/python_pkg/brother_printer/_stuck_job.py
Krzysztof kuhy Rudnicki c4d5631054
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
Pre-commit checks / pre-commit (${{ matrix.group }}) (mypy, mypy) (push) Waiting to run
Pre-commit checks / pre-commit (${{ matrix.group }}) (pylint, pylint) (push) Waiting to run
Pre-commit checks / pre-commit (${{ matrix.group }}) (rest, ) (push) Waiting to run
Pre-commit checks / pre-commit (${{ matrix.group }}) (shellcheck, shellcheck) (push) Waiting to run
Python tests / test (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / lib-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
fix(brother_printer): truthful probe note, pyusb auto-install, stuck-job detection
The CUPS-fallback note blamed a missing pyusb when the probe was really
skipped because CUPS owned the printer. It now names the real reason.
run.sh installs python-pyusb for the system python3 that sudo runs.

A paper misfeed blocks the usb:// backend while CUPS keeps saying
"processing", and cancel leaves that backend hung so the queue never
recovers. The report now flags a job whose page counter is frozen for
15 s, and a backend of a cancelled job, with a y/N offer to SIGKILL it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AaBRWtXXWCs3K3yPHSruUC
2026-10-12 16:23:48 +02:00

175 lines
5.9 KiB
Python

"""Detect a print job that CUPS calls "processing" but that is going nowhere.
Two failure modes seen on the HL-1110 (2026-10-12), neither visible in
``printer-state-reasons``:
* **Stalled job** - the printer failed to pick up paper. It raises the
paper-empty bit, the ``usb://`` backend blocks, and CUPS keeps saying
"processing". Only the job's page counter freezes.
* **Orphaned backend** - ``cancel`` removes the job but the blocked backend
survives, so the queue stays "now printing <queue>-0" forever and even
``systemctl restart cups`` hangs. Killing the backend is the only cure.
``job-impressions-completed`` counts pages the filters have *rasterised*, not
sheets out of the printer: it runs ~5 pages ahead of the paper. That is fine
for spotting a freeze, useless as "pages printed".
"""
from __future__ import annotations
from dataclasses import dataclass
import os
from pathlib import Path
import re
import shutil
import signal
import time
from typing import TYPE_CHECKING
from python_pkg.brother_printer._query import run_command_text
from python_pkg.brother_printer.constants import (
DERIVED_BACKEND_ORPHANED,
DERIVED_JOB_STALLED,
DIM,
GREEN,
RED,
RESET,
_out,
_prompt,
)
if TYPE_CHECKING:
from python_pkg.brother_printer.data_classes import USBResult
# A healthy run on 2026-10-12 froze the counter for up to ~9 s between pages
# (sampled every 3 s), so 15 s leaves headroom before calling it stalled.
STALL_WINDOW_SEC = 15.0
POLL_INTERVAL_SEC = 1.0
PROC_DIR = Path("/proc")
_BACKEND_PREFIX = "usb://Brother"
_IMPRESSIONS_RE = re.compile(r"job-impressions-completed \(integer\) = (\d+)")
@dataclass
class StuckJob:
"""A job (or its leftover backend) that is not making progress."""
kind: str # "stalled" or "orphaned"
job_id: str
backend_pid: int = 0
def find_backend_pids() -> dict[str, int]:
"""Map CUPS job id -> PID of every running Brother ``usb://`` backend."""
backends: dict[str, int] = {}
for entry in PROC_DIR.iterdir():
if not entry.name.isdigit():
continue
try:
argv = (entry / "cmdline").read_bytes().split(b"\0")
except OSError:
continue # process exited mid-scan, or not ours to read
# cmdline is NUL-terminated, so argv[1] is b"" when there is no job id.
if (
len(argv) > 1
and argv[1]
and argv[0].decode(errors="replace").startswith(_BACKEND_PREFIX)
):
backends[argv[1].decode(errors="replace")] = int(entry.name)
return backends
def _job_impressions(job_id: str) -> int | None:
"""Read a job's rasterised-page counter, or None if CUPS will not say."""
ipptool_path = shutil.which("ipptool")
if not ipptool_path:
return None
uri = f"ipp://localhost/jobs/{job_id}"
output = run_command_text([ipptool_path, "-tv", uri, "get-job-attributes.test"])
match = _IMPRESSIONS_RE.search(output)
return int(match.group(1)) if match else None
def _job_is_stalled(job_id: str) -> bool:
"""Watch the page counter; True if it does not move within the window."""
start = _job_impressions(job_id)
if start is None:
return False
deadline = time.monotonic() + STALL_WINDOW_SEC
while time.monotonic() < deadline:
time.sleep(POLL_INTERVAL_SEC)
if _job_impressions(job_id) != start:
return False
return True
def detect_stuck_job(active_job_ids: list[str]) -> StuckJob | None:
"""Find an orphaned backend or a stalled active job.
Args:
active_job_ids: numeric ids of the jobs CUPS still lists for the queue.
"""
backends = find_backend_pids()
for job_id, pid in sorted(backends.items()):
if job_id not in active_job_ids:
return StuckJob("orphaned", job_id, pid)
if active_job_ids and _job_is_stalled(active_job_ids[0]):
job_id = active_job_ids[0]
return StuckJob("stalled", job_id, backends.get(job_id, 0))
return None
def kill_backend(pid: int) -> bool:
"""SIGKILL a hung backend; SIGTERM is what it already ignored."""
try:
os.kill(pid, signal.SIGKILL)
except OSError:
return False
return True
def offer_backend_kill(pid: int) -> None:
"""Ask before killing an orphaned backend; nothing else unblocks the queue."""
_out()
choice = _prompt(f" Kill hung backend PID {pid} to unblock the queue? [y/N]: ")
if choice.strip().lower() != "y":
_out(f" {DIM}No changes made.{RESET}")
elif kill_backend(pid):
_out(f" {GREEN}✓ Backend killed. CUPS will return to idle.{RESET}")
else:
_out(f" {RED}Could not kill PID {pid} (already gone, or not root).{RESET}")
def _active_job_ids(printer_name: str) -> list[str]:
"""Numeric ids of the jobs CUPS still lists for the queue."""
lpstat_path = shutil.which("lpstat")
if not lpstat_path:
return []
output = run_command_text([lpstat_path, "-o", printer_name])
prefix = f"{printer_name}-"
return [
line.split()[0].removeprefix(prefix)
for line in output.splitlines()
if line.startswith(prefix)
]
def flag_stuck_job(result: USBResult, printer_name: str) -> bool:
"""Replace a misleading "printing" status when the job is going nowhere."""
stuck = detect_stuck_job(_active_job_ids(printer_name))
if stuck is None:
return False
result.online = "TRUE"
if stuck.kind == "orphaned":
result.status_code = DERIVED_BACKEND_ORPHANED
result.display = (
f"backend PID {stuck.backend_pid} of cancelled job {stuck.job_id}"
' still running (a leftover "filter failed" message is just'
" the cancel killing Ghostscript)"
)
result.cups.orphaned_backend_pid = stuck.backend_pid
else:
result.status_code = DERIVED_JOB_STALLED
result.display = f"job {stuck.job_id} has not advanced a page in 15 s"
return True