mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 21:07:03 +01:00
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
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
175 lines
5.9 KiB
Python
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
|