fix: Wonder Ink prints without crashing, and prints worksheets at all
Little Waders, 0.2.2: SIGSEGV in QPrinter::resolution() every time a teacher confirmed a print job. QWebEngineView.print() keeps the raw QPrinter pointer and returns; Qt's didPrintPage() reads printer->resolution() once Chromium has rendered the page, and by then PyQt had deleted the printer, which print_view() held only as a local. Reproduced in the lab with the 0.2.2 code, same backtrace, no job. Keeping the printer alive fixed the crash and was measured, and Qt's path was still not a print path: a worksheet PDF tab printed as one blank page, a lesson went as 24.6 MB of 300 dpi pictures with no text, and a queue refusing jobs was reported as printed. Print is now: make a PDF (a worksheet already is one; a lesson page is printToPdf'd onto the printer's own paper; an inline PDF is downloaded and opened as a worksheet), show it in a preview with printer, pages, copies and colour, and hand it to CUPS with lp — Chrome's route in the same rooms. Nothing asynchronous holds a printer. Black and white uses the driver's ColorModel=Grayscale, because Elmer's generic PCL 6 driver ignores print-color-mode. An exception escaping any slot no longer aborts Wonder Ink: PyQt6's default is to kill the process, and it is now logged instead. tests/test-classroom-printing.sh: 50 checks. tools/lab/wonderink-print- check: 39 checks over 13 scenarios in the lab, against a queue built from Elmer's PPD, judged by what CUPS received. Not yet on paper. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
690ef95aff
commit
10433b850d
Executable
+152
@@ -0,0 +1,152 @@
|
||||
#!/bin/bash
|
||||
# Does Wonder Ink print — and does a job really reach CUPS?
|
||||
#
|
||||
# The lab's CUPS gets an "Elmer" built from Elmer's own PPD and options,
|
||||
# writing to a file instead of socket://10.10.0.155:9100, so every job
|
||||
# goes through the same filters the real printer's jobs do and none can
|
||||
# reach a printer. A second queue refuses jobs, to show a teacher is told.
|
||||
#
|
||||
# Each scenario runs the INSTALLED classroom-wonderink under a real
|
||||
# QtWebEngine (offscreen), presses the preview's buttons, and is then
|
||||
# judged by CUPS's own record of what it was given.
|
||||
#
|
||||
# tools/lab/wonderink-print-check [SCENARIO...]
|
||||
set -uo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
NAME="${LAB_NAME:-classroom-v2-lab}"
|
||||
COMMAND="${WONDERINK:-/usr/local/bin/classroom-wonderink}"
|
||||
|
||||
lab() { docker exec -i "$NAME" bash -c "$1"; }
|
||||
|
||||
docker cp "$ROOT/tools/lab/wonderink-print-rig.py" "$NAME:/tmp/wonderink-print-rig.py" >/dev/null
|
||||
lab 'chmod a+r /tmp/wonderink-print-rig.py'
|
||||
|
||||
# The church's printer network is fenced off inside the lab. Checked, not
|
||||
# assumed: a lab job must never be able to reach the real Elmer.
|
||||
lab 'ip route replace blackhole 10.10.0.0/16 2>/dev/null
|
||||
! timeout 3 bash -c "exec 3<>/dev/tcp/10.10.0.155/9100" 2>/dev/null' || {
|
||||
echo "REFUSING TO CONTINUE: the real Elmer is reachable from the lab" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
lab '
|
||||
set -e
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
command -v lpadmin >/dev/null && command -v pdftoppm >/dev/null || \
|
||||
apt-get install -y -qq cups cups-client poppler-utils >/dev/null 2>&1
|
||||
dpkg -s foomatic-db-compressed-ppds >/dev/null 2>&1 || \
|
||||
apt-get install -y -qq foomatic-db-compressed-ppds cups-filters ghostscript >/dev/null 2>&1
|
||||
grep -q "^FileDevice Yes" /etc/cups/cups-files.conf || echo "FileDevice Yes" >> /etc/cups/cups-files.conf
|
||||
pkill -x cupsd || true; sleep 1; /usr/sbin/cupsd; sleep 1
|
||||
cupsctl --debug-logging
|
||||
: > /var/log/cups/error_log
|
||||
mkdir -p /var/lab-print && chown lp /var/lab-print
|
||||
driver="foomatic-db-compressed-ppds:0/ppd/foomatic-ppd/Generic-PCL_6_PCL_XL_Printer-pxlcolor.ppd"
|
||||
for q in $(lpstat -e 2>/dev/null); do lpadmin -x "$q"; done
|
||||
lpadmin -p Elmer -E -v file:///var/lab-print/elmer.pxl -m "$driver" \
|
||||
-D "LAB STAND-IN for Elmer: its PPD, a file for a printer" \
|
||||
-o PrintoutMode=Normal -o ColorModel=Color 2>/dev/null
|
||||
lpadmin -d Elmer
|
||||
lpadmin -p Refusing -E -v file:///dev/null -m "$driver" -D "LAB: refuses jobs" 2>/dev/null
|
||||
cupsreject Refusing
|
||||
id teacher >/dev/null 2>&1 || useradd -m teacher
|
||||
install -d -o teacher -m 700 /tmp/teacher-rt
|
||||
# An inline PDF, served the way a site shows one rather than sends it.
|
||||
mkdir -p /tmp/www
|
||||
pgrep -f "http.server 8088" >/dev/null || (cd /tmp/www && setsid python3 -m http.server 8088 >/dev/null 2>&1 &)
|
||||
' || { echo "could not prepare the lab's CUPS" >&2; exit 1; }
|
||||
|
||||
passed=0
|
||||
failed=0
|
||||
|
||||
judge() {
|
||||
if eval "$2"; then
|
||||
printf ' \033[32mPASS\033[0m %s\n' "$1"
|
||||
passed=$((passed + 1))
|
||||
else
|
||||
printf ' \033[31mFAIL\033[0m %s\n' "$1"
|
||||
failed=$((failed + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
# Runs one scenario; leaves its output in $out and the jobs it caused in $jobs.
|
||||
run() {
|
||||
local before
|
||||
before=$(lab 'lpstat -W all -o 2>/dev/null | wc -l')
|
||||
out=$(lab "su teacher -c 'cd /tmp; XDG_RUNTIME_DIR=/tmp/teacher-rt QT_QPA_PLATFORM=offscreen \
|
||||
QTWEBENGINE_CHROMIUM_FLAGS=\"--no-sandbox --disable-gpu\" \
|
||||
timeout 120 python3 /tmp/wonderink-print-rig.py $COMMAND $1 2>&1'; echo \"exit=\$?\"")
|
||||
sleep 3
|
||||
jobs=$(lab "lpstat -W all -o | tail -n +$((before + 1)) | awk '{print \$1}' | tr '\n' ' '")
|
||||
job_options() { lab "grep -E '\\[Job ${1#*-}\\] argv\\[(4|5)\\]' /var/log/cups/error_log"; }
|
||||
job_device() { lab "grep -E '\\[Job ${1#*-}\\]' /var/log/cups/error_log | grep -oE 'sDEVICE=[a-z]+' | sort -u"; }
|
||||
}
|
||||
|
||||
count() { wc -w <<< "$jobs"; }
|
||||
said() { grep -q -- "$1" <<< "$out"; }
|
||||
exited_cleanly() { said 'exit=0' && ! said 'Traceback' && ! said 'Segmentation' && ! said 'Aborted'; }
|
||||
|
||||
scenarios=("$@")
|
||||
[[ ${#scenarios[@]} -gt 0 ]] || scenarios=(web pdf pdf-options pdf-viewer-button inline
|
||||
options bad-range broken cancel page-closes-itself close-window page-prints-again repeat)
|
||||
|
||||
# The inline scenario needs a PDF to serve; the worksheet scenario writes one.
|
||||
lab 'test -f /tmp/www/ark.pdf' || { run pdf >/dev/null; lab 'cp "/home/teacher/Downloads/Wonder Ink/Colour the Ark.pdf" /tmp/www/ark.pdf; chmod a+r /tmp/www/ark.pdf'; }
|
||||
|
||||
for scenario in "${scenarios[@]}"; do
|
||||
echo "$scenario"
|
||||
run "$scenario"
|
||||
|
||||
judge "Wonder Ink did not crash, and exited by itself" exited_cleanly
|
||||
|
||||
case "$scenario" in
|
||||
web)
|
||||
judge "the preview showed the lesson, 3 Letter pages, Elmer chosen" \
|
||||
"said \"pages=3 printers=\\['Elmer', 'Refusing'\\] paper=Letter\""
|
||||
judge "one job reached CUPS" '[[ $(count) -eq 1 ]]'
|
||||
judge "Elmer's filters printed it in colour" '[[ "$(job_device $jobs)" == *pxlcolor* ]]'
|
||||
judge "and nothing was left behind" "said 'scratch_left=\\[\\]'" ;;
|
||||
pdf|pdf-viewer-button)
|
||||
judge "the preview showed the worksheet's own 2 pages" "said 'pages=2'"
|
||||
judge "one job reached CUPS" '[[ $(count) -eq 1 ]]' ;;
|
||||
pdf-options)
|
||||
judge "one job" '[[ $(count) -eq 1 ]]'
|
||||
judge "page 2 only, three copies" \
|
||||
'[[ "$(job_options $jobs)" == *"argv[4]=\"3\""* && "$(job_options $jobs)" == *page-ranges=2* ]]'
|
||||
judge "in black and white, by the driver's own option" \
|
||||
'[[ "$(job_options $jobs)" == *ColorModel=Grayscale* && "$(job_device $jobs)" == *pxlmono* ]]' ;;
|
||||
inline)
|
||||
judge "the inline PDF was recognised" "said \"mime='application/pdf'\""
|
||||
judge "and opened as a worksheet tab" "said 'current_is_pdf=True'"
|
||||
judge "one job reached CUPS" '[[ $(count) -eq 1 ]]' ;;
|
||||
options)
|
||||
judge "pages 2-3, two copies, black and white" \
|
||||
'[[ "$(job_options $jobs)" == *page-ranges=2-3* && "$(job_options $jobs)" == *"argv[4]=\"2\""* && "$(job_device $jobs)" == *pxlmono* ]]' ;;
|
||||
bad-range)
|
||||
judge "a page that does not exist is explained, not printed" "said 'This document has 3 pages'"
|
||||
judge "and once corrected, one job" '[[ $(count) -eq 1 ]]' ;;
|
||||
broken)
|
||||
judge "a refusing printer is reported in CUPS's words" "said 'Refusing did not take the job'"
|
||||
judge "and no job is claimed" '[[ $(count) -eq 0 ]] && ! said "NOTIFY Sent"' ;;
|
||||
cancel)
|
||||
judge "Cancel sends nothing" '[[ $(count) -eq 0 ]]' ;;
|
||||
page-closes-itself)
|
||||
judge "the preview says the page closed, and cannot print" \
|
||||
"said \"status='The page was closed.' print_enabled=False\""
|
||||
judge "and sends nothing" '[[ $(count) -eq 0 ]]' ;;
|
||||
close-window)
|
||||
judge "closing Wonder Ink with the preview open sends nothing" '[[ $(count) -eq 0 ]]' ;;
|
||||
page-prints-again)
|
||||
judge "a second print request does not open a second preview" "said 'still one preview=True'"
|
||||
judge "one job" '[[ $(count) -eq 1 ]]' ;;
|
||||
repeat)
|
||||
judge "five prints in a row, five jobs" '[[ $(count) -eq 5 ]]' ;;
|
||||
esac
|
||||
|
||||
[[ "$failed" -eq 0 ]] || { echo "$out" | tail -15 | sed 's/^/ /'; }
|
||||
done
|
||||
|
||||
echo
|
||||
echo "$passed passed, $failed failed"
|
||||
[[ "$failed" -eq 0 ]]
|
||||
@@ -0,0 +1,141 @@
|
||||
"""Drive Wonder Ink's print preview through one scenario, headless.
|
||||
|
||||
usage: wonderink-print-rig.py WONDERINK_COMMAND SCENARIO
|
||||
|
||||
Run by tools/lab/wonderink-print-check inside the lab, against the
|
||||
command the package installed. It presses the preview's own buttons the
|
||||
way a teacher would, and prints what happened; the check around it asks
|
||||
CUPS what actually arrived.
|
||||
"""
|
||||
import importlib.machinery, importlib.util, sys, time, faulthandler, gc, os
|
||||
faulthandler.enable()
|
||||
path, scenario = sys.argv[1], sys.argv[2]
|
||||
loader = importlib.machinery.SourceFileLoader("wonderink", path)
|
||||
spec = importlib.util.spec_from_loader("wonderink", loader)
|
||||
wi = importlib.util.module_from_spec(spec); loader.exec_module(wi)
|
||||
|
||||
from pathlib import Path
|
||||
from PyQt6.QtCore import QTimer, QUrl
|
||||
from PyQt6.QtGui import QPdfWriter, QPainter, QFont, QPageSize
|
||||
from PyQt6.QtWidgets import QApplication
|
||||
from PyQt6.QtWebEngineCore import QWebEngineProfile, QWebEngineSettings
|
||||
|
||||
notes = []
|
||||
wi.notify = lambda s, b: (notes.append(s), print("NOTIFY", s, "|", b, flush=True))
|
||||
app = QApplication(sys.argv)
|
||||
profile = QWebEngineProfile("rig", app)
|
||||
for a in ("PdfViewerEnabled", "PluginsEnabled"):
|
||||
profile.settings().setAttribute(getattr(QWebEngineSettings.WebAttribute, a), True)
|
||||
window = wi.WonderInk(profile)
|
||||
profile.downloadRequested.connect(window.on_download)
|
||||
window.resize(1280, 800); window.show()
|
||||
t0 = time.monotonic()
|
||||
def stamp(m): print(f"[{time.monotonic()-t0:6.2f}s] {m}", flush=True)
|
||||
|
||||
def lesson(view, title="Noahs Ark lesson"):
|
||||
view.setHtml(f"<html><head><title>{title}</title></head><body>" + "".join(
|
||||
f"<h1>{title}, part {i}</h1><p>{'Noah built an ark. ' * 80}</p>" for i in range(5)
|
||||
) + "</body></html>", QUrl("https://app.wonderink.org/lesson"))
|
||||
|
||||
def worksheet(name="Colour the Ark"):
|
||||
d = wi.DOWNLOADS; d.mkdir(parents=True, exist_ok=True)
|
||||
p = d / f"{name}.pdf"
|
||||
w = QPdfWriter(str(p)); w.setPageSize(QPageSize(QPageSize.PageSizeId.Letter)); w.setResolution(300)
|
||||
pa = QPainter(w); pa.setFont(QFont("DejaVu Sans", 28))
|
||||
pa.drawText(200, 400, f"WORKSHEET {name} page one"); pa.drawRect(200, 600, 2000, 1500)
|
||||
w.newPage(); pa.drawText(200, 400, f"WORKSHEET {name} page two"); pa.end()
|
||||
return p
|
||||
|
||||
def when(cond, then, label, limit=30):
|
||||
start = time.monotonic()
|
||||
def poll():
|
||||
try: ok = cond()
|
||||
except Exception as e: ok = False
|
||||
if ok: return then()
|
||||
if time.monotonic() - start > limit:
|
||||
stamp(f"GAVE UP waiting for {label}"); app.exit(4); return
|
||||
QTimer.singleShot(100, poll)
|
||||
QTimer.singleShot(100, poll)
|
||||
|
||||
def preview(): return window._preview
|
||||
def ready(): return preview() is not None and preview()._pdf is not None
|
||||
def describe(d):
|
||||
return f"pages={d._pages} printers={[d._printer.itemText(i) for i in range(d._printer.count())]} paper={d.paper()} colour_offered={d._colour.isVisible()} status={d._status.text()!r}"
|
||||
|
||||
def finish(code=0):
|
||||
stamp(f"RESULT notes={notes} preview_open={preview() is not None} tabs={window._tabs.count()}")
|
||||
QTimer.singleShot(1500, lambda: app.exit(code))
|
||||
|
||||
def press_print(then=None):
|
||||
d = preview(); stamp("PREVIEW " + describe(d))
|
||||
d._print.click()
|
||||
stamp(f"after Print: open={preview() is not None} status={d._status.text() if preview() else ''!r}")
|
||||
(then or finish)()
|
||||
|
||||
actions = {
|
||||
"web": lambda view: when(ready, press_print, "preview"),
|
||||
"cancel": lambda view: when(ready, lambda: (stamp("PREVIEW " + describe(preview())), preview()._cancel.click(), finish()), "preview"),
|
||||
"options": lambda view: when(ready, lambda: (
|
||||
preview()._which.setCurrentIndex(1), preview()._range.setText("2-3"),
|
||||
preview()._copies.setValue(2), preview()._colour.setCurrentIndex(1), press_print()), "preview"),
|
||||
"bad-range": lambda view: when(ready, lambda: (
|
||||
preview()._which.setCurrentIndex(1), preview()._range.setText("7"), preview()._print.click(),
|
||||
stamp(f"bad range: open={preview() is not None} status={preview()._status.text()!r}"),
|
||||
preview()._range.setText("1"), press_print()), "preview"),
|
||||
"broken": lambda view: when(ready, lambda: (
|
||||
preview()._printer.setCurrentText("Refusing"),
|
||||
stamp(f"switched to Refusing: paper={preview().paper()} status={preview()._status.text()!r}"),
|
||||
when(ready, lambda: (
|
||||
stamp(f"re-rendered on {preview().paper()}: pages={preview()._pages}"),
|
||||
preview()._print.click(),
|
||||
stamp(f"broken: open={preview() is not None} status={preview()._status.text()!r}"),
|
||||
preview()._cancel.click(), finish()), "re-render")), "preview"),
|
||||
"page-closes-itself": lambda view: when(ready, lambda: (
|
||||
view.page().runJavaScript("window.close()"),
|
||||
when(lambda: preview() and "closed" in preview()._status.text(), lambda: (
|
||||
stamp(f"after page closed: status={preview()._status.text()!r} print_enabled={preview()._print.isEnabled()}"),
|
||||
preview()._cancel.click(), finish()), "closed message")), "preview"),
|
||||
"close-window": lambda view: when(ready, lambda: (stamp("closing window with preview open"), window.close()), "preview"),
|
||||
"page-prints-again": lambda view: when(ready, lambda: (
|
||||
view.page().runJavaScript("window.print()"),
|
||||
QTimer.singleShot(800, lambda: (stamp(f"second request while open: still one preview={preview() is not None}"), press_print()))), "preview"),
|
||||
}
|
||||
|
||||
if scenario == "repeat":
|
||||
view = window.open_tab(); lesson(view); n = [0]
|
||||
def once():
|
||||
if n[0] == 5: return finish()
|
||||
n[0] += 1; QTimer.singleShot(0, lambda: window.print_view(view))
|
||||
when(ready, lambda: press_print(then=lambda: QTimer.singleShot(200, once)), "preview")
|
||||
view.loadFinished.connect(lambda ok: once() if n[0] == 0 else None)
|
||||
elif scenario in ("pdf", "pdf-options"):
|
||||
window.open_pdf(worksheet()); view = window.current()
|
||||
if scenario == "pdf-options":
|
||||
actions["pdf-options"] = lambda view: when(ready, lambda: (
|
||||
preview()._which.setCurrentIndex(1), preview()._range.setText("2"),
|
||||
preview()._copies.setValue(3), preview()._colour.setCurrentIndex(1), press_print()), "preview")
|
||||
QTimer.singleShot(2500, lambda: (QTimer.singleShot(0, lambda: window.print_view(view)),
|
||||
actions[scenario if scenario == "pdf-options" else "web"](view)))
|
||||
elif scenario == "pdf-viewer-button":
|
||||
# The viewer's own print button reaches the page as a print request.
|
||||
window.open_pdf(worksheet()); view = window.current()
|
||||
QTimer.singleShot(2500, lambda: (actions["web"](view), view.page().printRequested.emit()))
|
||||
elif scenario == "inline":
|
||||
view = window.open_tab(); view.load(QUrl("http://127.0.0.1:8088/ark.pdf"))
|
||||
QTimer.singleShot(4000, lambda: (stamp(f"inline mime={window._mime.get(view)!r} shows_pdf={window._shows_pdf(view)}"),
|
||||
QTimer.singleShot(0, lambda: window.print_view(view)),
|
||||
when(ready, lambda: (stamp(f"worksheet tab opened: tabs={window._tabs.count()} current_is_pdf={window.current() in window._pdf_views}"), press_print()), "inline preview")))
|
||||
else:
|
||||
if scenario == "page-closes-itself":
|
||||
lesson(window.open_tab(), "The other lesson")
|
||||
view = window.open_tab(); lesson(view)
|
||||
fired = []
|
||||
def go(ok):
|
||||
if fired: return
|
||||
fired.append(1); QTimer.singleShot(0, lambda: window.print_view(view)); actions[scenario](view)
|
||||
view.loadFinished.connect(go)
|
||||
|
||||
QTimer.singleShot(90000, lambda: (stamp("TIMEOUT"), app.exit(3)))
|
||||
rc = app.exec()
|
||||
stamp(f"EXIT {rc} scratch_left={sorted(p.name for p in Path(os.environ.get('XDG_RUNTIME_DIR','/tmp')).glob('wonderink-print-*'))}")
|
||||
sys.exit(rc)
|
||||
Reference in New Issue
Block a user