From 66094fd003246b5345a0e974826b9273f1a4b9da Mon Sep 17 00:00:00 2001 From: Luca Toniolo <10792599+grandixximo@users.noreply.github.com> Date: Mon, 4 May 2026 13:50:31 +0800 Subject: [PATCH 01/17] test: add UI smoke tests for axis, touchy, gmoccapy, qtdragon Phase 1 of #3756: launch each GUI under xvfb-run against an existing sim config, drive Estop reset / machine on / home all via NML, assert the interpreter reaches IDLE, then shut down cleanly. Verifies the GUI starts and accepts basic commands without crashing. Skips gracefully (exit 77) when xvfb-run is not installed, matching the precedent set by tests/tooledit and tests/pyvcp. Shared helpers under _lib/: drive.py common NML driver, prints UI_SMOKE_OK on success launch.sh xvfb-run wrapper with setsid + signal escalation for clean linuxcnc shutdown (preserves shared memory cleanup via scripts/linuxcnc trap) checkresult.sh shared pass/fail check delegated to by per-test checkresult shims Each per-GUI directory exposes test.sh + checkresult and reuses the existing configs/sim//*.ini so no test-only sim configs are introduced. Functional tests (load G-code, verify final position) and screenshot/ video on failure are deferred to follow-up phases. xvfb is already declared in debian/control () so apt-get build-dep installs it on CI; no new system deps required for this phase. Refs #3756 --- tests/ui-smoke/.gitignore | 9 ++++ tests/ui-smoke/README | 21 ++++++++ tests/ui-smoke/_lib/checkresult.sh | 18 +++++++ tests/ui-smoke/_lib/drive.py | 75 ++++++++++++++++++++++++++ tests/ui-smoke/_lib/launch.sh | 84 +++++++++++++++++++++++++++++ tests/ui-smoke/axis/checkresult | 2 + tests/ui-smoke/axis/test.sh | 4 ++ tests/ui-smoke/gmoccapy/checkresult | 2 + tests/ui-smoke/gmoccapy/test.sh | 4 ++ tests/ui-smoke/qtdragon/checkresult | 2 + tests/ui-smoke/qtdragon/test.sh | 4 ++ tests/ui-smoke/touchy/checkresult | 2 + tests/ui-smoke/touchy/test.sh | 4 ++ 13 files changed, 231 insertions(+) create mode 100644 tests/ui-smoke/.gitignore create mode 100644 tests/ui-smoke/README create mode 100644 tests/ui-smoke/_lib/checkresult.sh create mode 100644 tests/ui-smoke/_lib/drive.py create mode 100644 tests/ui-smoke/_lib/launch.sh create mode 100644 tests/ui-smoke/axis/checkresult create mode 100644 tests/ui-smoke/axis/test.sh create mode 100644 tests/ui-smoke/gmoccapy/checkresult create mode 100644 tests/ui-smoke/gmoccapy/test.sh create mode 100644 tests/ui-smoke/qtdragon/checkresult create mode 100644 tests/ui-smoke/qtdragon/test.sh create mode 100644 tests/ui-smoke/touchy/checkresult create mode 100644 tests/ui-smoke/touchy/test.sh diff --git a/tests/ui-smoke/.gitignore b/tests/ui-smoke/.gitignore new file mode 100644 index 00000000000..7aada78178c --- /dev/null +++ b/tests/ui-smoke/.gitignore @@ -0,0 +1,9 @@ +# Runtime artifacts left by launch.sh; per Bertho's clean-tree rule, +# generated build/test outputs must be gitignored or committed. +linuxcnc.out +linuxcnc.err +linuxcnc.pid +ui-smoke.out +ui-smoke.err +result +stderr diff --git a/tests/ui-smoke/README b/tests/ui-smoke/README new file mode 100644 index 00000000000..5fa34b62f13 --- /dev/null +++ b/tests/ui-smoke/README @@ -0,0 +1,21 @@ +UI smoke tests +~~~~~~~~~~~~~~ + +These tests launch each GUI (axis, touchy, gmoccapy, qtdragon, etc.) +under xvfb against an existing sim config and verify the GUI starts, +accepts basic NML commands (Estop reset, machine on, home), and shuts +down cleanly without crashing. + +Scope is intentionally small: prove the GUI is alive, not exercise its +features. Functional tests (loading G-code, jogging, MDI) belong in +follow-up directories. + +Each test directory contains: + test.sh launches the GUI under xvfb-run, runs drive.py, captures + exit code and stderr + checkresult examines the captured output for crash markers + +Shared helpers live in _lib/. + +If xvfb-run is not available on the host, tests skip gracefully (matches +the precedent set by tests/tooledit and tests/pyvcp). diff --git a/tests/ui-smoke/_lib/checkresult.sh b/tests/ui-smoke/_lib/checkresult.sh new file mode 100644 index 00000000000..0284ee2beca --- /dev/null +++ b/tests/ui-smoke/_lib/checkresult.sh @@ -0,0 +1,18 @@ +#!/bin/bash +# Shared result check for UI smoke tests. +# Pass if the driver printed UI_SMOKE_OK and no obvious crash markers +# appear in the captured logs. Per-test checkresult delegates to this. +set -u +LOG="${1:-/dev/stdin}" + +if ! grep -q '^UI_SMOKE_OK$' "$LOG"; then + echo "FAIL: driver did not report UI_SMOKE_OK" >&2 + exit 1 +fi + +if grep -qE 'Segmentation fault|core dumped|Traceback|backtrace' "$LOG"; then + echo "FAIL: crash marker found in log" >&2 + exit 1 +fi + +exit 0 diff --git a/tests/ui-smoke/_lib/drive.py b/tests/ui-smoke/_lib/drive.py new file mode 100644 index 00000000000..d9d17fa6279 --- /dev/null +++ b/tests/ui-smoke/_lib/drive.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +# Common UI smoke driver: connect via NML, take the machine through Estop +# reset, machine on, home all joints, wait for ON+IDLE, request shutdown. +# Prints a single "UI_SMOKE_OK" line on success so checkresult can grep. + +import linuxcnc +import sys +import time + +TIMEOUT_S = 30.0 + + +def wait_for(predicate, stat, timeout, label): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + stat.poll() + if predicate(stat): + return True + time.sleep(0.1) + sys.stderr.write(f"UI_SMOKE_FAIL: timeout waiting for {label}\n") + return False + + +def main(): + cmd = linuxcnc.command() + stat = linuxcnc.stat() + + if not wait_for(lambda s: s.echo_serial_number >= 0, stat, 10.0, + "task to come up"): + return 1 + + # State transitions: STATE_ESTOP (1), STATE_ESTOP_RESET (2), + # STATE_OFF (3), STATE_ON (4). Some sim configs come up already in + # STATE_ON via auto-estop-release HAL wiring, so we drive toward + # STATE_ON unconditionally and accept any state >= STATE_ESTOP_RESET + # as a successful estop reset. + cmd.state(linuxcnc.STATE_ESTOP_RESET) + cmd.wait_complete(TIMEOUT_S) + if not wait_for(lambda s: s.task_state >= linuxcnc.STATE_ESTOP_RESET, + stat, TIMEOUT_S, "estop reset"): + return 1 + + cmd.state(linuxcnc.STATE_ON) + cmd.wait_complete(TIMEOUT_S) + if not wait_for(lambda s: s.task_state == linuxcnc.STATE_ON, stat, + TIMEOUT_S, "machine on"): + return 1 + + stat.poll() + # home(-1) homes all joints per HOME_SEQUENCE; falls back to per-joint + # serial homing if no sequence is configured. + cmd.home(-1) + cmd.wait_complete(TIMEOUT_S) + + if not wait_for(lambda s: all(s.homed[:s.joints]), stat, TIMEOUT_S, + "all joints homed"): + # Fall back to one-at-a-time so configs without HOME_SEQUENCE still + # complete the smoke flow. + for j in range(stat.joints): + cmd.home(j) + cmd.wait_complete(TIMEOUT_S) + if not wait_for(lambda s, jj=j: s.homed[jj], stat, TIMEOUT_S, + f"joint {j} homed"): + return 1 + + if not wait_for(lambda s: s.interp_state == linuxcnc.INTERP_IDLE, stat, + TIMEOUT_S, "interpreter idle"): + return 1 + + print("UI_SMOKE_OK") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/ui-smoke/_lib/launch.sh b/tests/ui-smoke/_lib/launch.sh new file mode 100644 index 00000000000..94535af3063 --- /dev/null +++ b/tests/ui-smoke/_lib/launch.sh @@ -0,0 +1,84 @@ +#!/bin/bash +# Shared launcher for UI smoke tests. +# Usage: launch.sh +# +# Spawns linuxcnc -r under xvfb-run with a timeout, then runs the +# common driver script against it. Captures stdout to ui-smoke.out and +# stderr to ui-smoke.err in the test directory. +# +# Exits 0 on success (driver printed UI_SMOKE_OK) or non-zero on any +# failure: missing tools, GUI never appeared, driver assertion failed, +# linuxcnc crash, etc. +# +# If xvfb-run is not on the PATH the test is treated as skipped (exit 77, +# which the runtests harness records as a skip rather than a failure). + +set -u + +CONFIG_INI="$1" +# TEST_DIR is the directory of the calling test script (passed via $0 +# from the caller before exec). LIB_DIR is where this script lives. +TEST_DIR="${TEST_DIR:-$(cd "$(dirname "$0")" && pwd)}" +LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +if ! command -v xvfb-run >/dev/null 2>&1; then + echo "SKIP: xvfb-run not installed" >&2 + exit 77 +fi + +cd "$TEST_DIR" || exit 1 +rm -f ui-smoke.out ui-smoke.err linuxcnc.pid + +# Launch linuxcnc inside xvfb-run. The -t timeout is a safety net so a +# wedged GUI cannot hang CI. +LINUXCNC_TIMEOUT=120 +DRIVER_TIMEOUT=60 + +xvfb-run -a --server-args="-screen 0 1024x768x24" \ + timeout "$LINUXCNC_TIMEOUT" \ + bash -c " + # Run linuxcnc in its own process group so we can signal the whole + # group cleanly (linuxcnc forks task, motion, GUI, halrun). + setsid linuxcnc -r '$CONFIG_INI' >linuxcnc.out 2>linuxcnc.err & + LINUXCNC_PGID=\$! + echo \$LINUXCNC_PGID >linuxcnc.pid + + # Give task time to come up before driver attaches. The GUI also + # needs time to register and home up to the point where it accepts + # commands; 8s is conservative for headless sim runs. + sleep 8 + + timeout $DRIVER_TIMEOUT python3 '$LIB_DIR/drive.py' >ui-smoke.out 2>ui-smoke.err + DRIVE_RC=\$? + + # Clean shutdown: GUI-specific quit if available (lets linuxcnc's + # own SIGTERM trap run Cleanup which unloads halrun and reaps + # shared memory). Otherwise fall through to signal escalation. + if command -v axis-remote >/dev/null 2>&1; then + axis-remote --quit 2>/dev/null || true + fi + + # SIGTERM to the process group triggers scripts/linuxcnc's trap. + kill -TERM -\$LINUXCNC_PGID 2>/dev/null || true + for i in 1 2 3 4 5 6 7 8 9 10; do + kill -0 \$LINUXCNC_PGID 2>/dev/null || break + sleep 1 + done + # Last resort if Cleanup never finished. + kill -KILL -\$LINUXCNC_PGID 2>/dev/null || true + + exit \$DRIVE_RC + " +RC=$? + +# Surface logs so checkresult and CI artifact upload can see them. +echo "=== linuxcnc.out ===" +[ -f linuxcnc.out ] && cat linuxcnc.out +echo "=== linuxcnc.err ===" +[ -f linuxcnc.err ] && cat linuxcnc.err +echo "=== ui-smoke.out ===" +[ -f ui-smoke.out ] && cat ui-smoke.out +echo "=== ui-smoke.err ===" +[ -f ui-smoke.err ] && cat ui-smoke.err + +exit "$RC" diff --git a/tests/ui-smoke/axis/checkresult b/tests/ui-smoke/axis/checkresult new file mode 100644 index 00000000000..bbe14f5f99f --- /dev/null +++ b/tests/ui-smoke/axis/checkresult @@ -0,0 +1,2 @@ +#!/bin/bash +exec "$(dirname "$0")/../_lib/checkresult.sh" "$@" diff --git a/tests/ui-smoke/axis/test.sh b/tests/ui-smoke/axis/test.sh new file mode 100644 index 00000000000..8f7385a5ecd --- /dev/null +++ b/tests/ui-smoke/axis/test.sh @@ -0,0 +1,4 @@ +#!/bin/bash +TEST_DIR="$(cd "$(dirname "$0")" && pwd)" \ +exec "$(dirname "$0")/../_lib/launch.sh" \ + "$(cd "$(dirname "$0")"/../../../configs/sim/axis && pwd)/axis.ini" diff --git a/tests/ui-smoke/gmoccapy/checkresult b/tests/ui-smoke/gmoccapy/checkresult new file mode 100644 index 00000000000..bbe14f5f99f --- /dev/null +++ b/tests/ui-smoke/gmoccapy/checkresult @@ -0,0 +1,2 @@ +#!/bin/bash +exec "$(dirname "$0")/../_lib/checkresult.sh" "$@" diff --git a/tests/ui-smoke/gmoccapy/test.sh b/tests/ui-smoke/gmoccapy/test.sh new file mode 100644 index 00000000000..563fbc7824c --- /dev/null +++ b/tests/ui-smoke/gmoccapy/test.sh @@ -0,0 +1,4 @@ +#!/bin/bash +TEST_DIR="$(cd "$(dirname "$0")" && pwd)" \ +exec "$(dirname "$0")/../_lib/launch.sh" \ + "$(cd "$(dirname "$0")"/../../../configs/sim/gmoccapy && pwd)/gmoccapy.ini" diff --git a/tests/ui-smoke/qtdragon/checkresult b/tests/ui-smoke/qtdragon/checkresult new file mode 100644 index 00000000000..bbe14f5f99f --- /dev/null +++ b/tests/ui-smoke/qtdragon/checkresult @@ -0,0 +1,2 @@ +#!/bin/bash +exec "$(dirname "$0")/../_lib/checkresult.sh" "$@" diff --git a/tests/ui-smoke/qtdragon/test.sh b/tests/ui-smoke/qtdragon/test.sh new file mode 100644 index 00000000000..9b8aef1e56d --- /dev/null +++ b/tests/ui-smoke/qtdragon/test.sh @@ -0,0 +1,4 @@ +#!/bin/bash +TEST_DIR="$(cd "$(dirname "$0")" && pwd)" \ +exec "$(dirname "$0")/../_lib/launch.sh" \ + "$(cd "$(dirname "$0")"/../../../configs/sim/qtdragon/qtdragon_xyz && pwd)/qtdragon_metric.ini" diff --git a/tests/ui-smoke/touchy/checkresult b/tests/ui-smoke/touchy/checkresult new file mode 100644 index 00000000000..bbe14f5f99f --- /dev/null +++ b/tests/ui-smoke/touchy/checkresult @@ -0,0 +1,2 @@ +#!/bin/bash +exec "$(dirname "$0")/../_lib/checkresult.sh" "$@" diff --git a/tests/ui-smoke/touchy/test.sh b/tests/ui-smoke/touchy/test.sh new file mode 100644 index 00000000000..3b493a660c6 --- /dev/null +++ b/tests/ui-smoke/touchy/test.sh @@ -0,0 +1,4 @@ +#!/bin/bash +TEST_DIR="$(cd "$(dirname "$0")" && pwd)" \ +exec "$(dirname "$0")/../_lib/launch.sh" \ + "$(cd "$(dirname "$0")"/../../../configs/sim/touchy && pwd)/touchy.ini" From 62dfb233153fb1a9d082c07e34c30412eb317a8e Mon Sep 17 00:00:00 2001 From: Luca Toniolo <10792599+grandixximo@users.noreply.github.com> Date: Mon, 4 May 2026 14:49:25 +0800 Subject: [PATCH 02/17] test: mark ui-smoke scripts executable CI failed with "Permission denied" exec'ing _lib/launch.sh because the local repo has core.filemode=false so chmod +x was not recorded in the git index. Use git update-index --chmod=+x to mark all test scripts as executable. --- tests/ui-smoke/_lib/checkresult.sh | 0 tests/ui-smoke/_lib/drive.py | 0 tests/ui-smoke/_lib/launch.sh | 0 tests/ui-smoke/axis/checkresult | 0 tests/ui-smoke/axis/test.sh | 0 tests/ui-smoke/gmoccapy/checkresult | 0 tests/ui-smoke/gmoccapy/test.sh | 0 tests/ui-smoke/qtdragon/checkresult | 0 tests/ui-smoke/qtdragon/test.sh | 0 tests/ui-smoke/touchy/checkresult | 0 tests/ui-smoke/touchy/test.sh | 0 11 files changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 tests/ui-smoke/_lib/checkresult.sh mode change 100644 => 100755 tests/ui-smoke/_lib/drive.py mode change 100644 => 100755 tests/ui-smoke/_lib/launch.sh mode change 100644 => 100755 tests/ui-smoke/axis/checkresult mode change 100644 => 100755 tests/ui-smoke/axis/test.sh mode change 100644 => 100755 tests/ui-smoke/gmoccapy/checkresult mode change 100644 => 100755 tests/ui-smoke/gmoccapy/test.sh mode change 100644 => 100755 tests/ui-smoke/qtdragon/checkresult mode change 100644 => 100755 tests/ui-smoke/qtdragon/test.sh mode change 100644 => 100755 tests/ui-smoke/touchy/checkresult mode change 100644 => 100755 tests/ui-smoke/touchy/test.sh diff --git a/tests/ui-smoke/_lib/checkresult.sh b/tests/ui-smoke/_lib/checkresult.sh old mode 100644 new mode 100755 diff --git a/tests/ui-smoke/_lib/drive.py b/tests/ui-smoke/_lib/drive.py old mode 100644 new mode 100755 diff --git a/tests/ui-smoke/_lib/launch.sh b/tests/ui-smoke/_lib/launch.sh old mode 100644 new mode 100755 diff --git a/tests/ui-smoke/axis/checkresult b/tests/ui-smoke/axis/checkresult old mode 100644 new mode 100755 diff --git a/tests/ui-smoke/axis/test.sh b/tests/ui-smoke/axis/test.sh old mode 100644 new mode 100755 diff --git a/tests/ui-smoke/gmoccapy/checkresult b/tests/ui-smoke/gmoccapy/checkresult old mode 100644 new mode 100755 diff --git a/tests/ui-smoke/gmoccapy/test.sh b/tests/ui-smoke/gmoccapy/test.sh old mode 100644 new mode 100755 diff --git a/tests/ui-smoke/qtdragon/checkresult b/tests/ui-smoke/qtdragon/checkresult old mode 100644 new mode 100755 diff --git a/tests/ui-smoke/qtdragon/test.sh b/tests/ui-smoke/qtdragon/test.sh old mode 100644 new mode 100755 diff --git a/tests/ui-smoke/touchy/checkresult b/tests/ui-smoke/touchy/checkresult old mode 100644 new mode 100755 diff --git a/tests/ui-smoke/touchy/test.sh b/tests/ui-smoke/touchy/test.sh old mode 100644 new mode 100755 From 0090a72fcea4f61ee2d649771a70ef57e10b2807 Mon Sep 17 00:00:00 2001 From: Luca Toniolo <10792599+grandixximo@users.noreply.github.com> Date: Mon, 4 May 2026 15:34:58 +0800 Subject: [PATCH 03/17] test: skip ui-smoke when GUI python deps absent; gentler shutdown Two CI-driven fixes: 1. Per-GUI Python module preflight in launch.sh. test.sh now passes a comma-separated list of modules the GUI needs at import time; if any fail to import the test exits 77 (skipped) rather than wedging linuxcnc waiting for a GUI that will never come up. - axis: OpenGL.GL - touchy, gmoccapy: gi - qtdragon: PyQt5.QtCore, qtvcp Master CI does not currently install these runtime deps (Bertho's #3391 work added them only to the 2.9 branch), so without preflight every smoke test failed with a wedged linuxcnc startup or an uninformative timeout. This way the tests skip cleanly until the deps land in master CI. 2. Wait up to 30s for the linuxcnc SIGTERM trap (scripts/linuxcnc Cleanup) to finish before SIGKILL. Earlier tighter window meant Cleanup got cut off mid-run and left shared memory attached, which caused subsequent tests in the same job to fail with SHMERR. Refs #3756 --- tests/ui-smoke/_lib/launch.sh | 33 ++++++++++++++++++++++++++------- tests/ui-smoke/axis/test.sh | 4 +++- tests/ui-smoke/gmoccapy/test.sh | 4 +++- tests/ui-smoke/qtdragon/test.sh | 4 +++- tests/ui-smoke/touchy/test.sh | 4 +++- 5 files changed, 38 insertions(+), 11 deletions(-) diff --git a/tests/ui-smoke/_lib/launch.sh b/tests/ui-smoke/_lib/launch.sh index 94535af3063..48b33c0c8f0 100755 --- a/tests/ui-smoke/_lib/launch.sh +++ b/tests/ui-smoke/_lib/launch.sh @@ -16,6 +16,9 @@ set -u CONFIG_INI="$1" +# Comma-separated python module names the GUI needs at import time. +# If any fail to import, the test skips with exit 77 instead of failing. +PYTHON_DEPS="${2:-}" # TEST_DIR is the directory of the calling test script (passed via $0 # from the caller before exec). LIB_DIR is where this script lives. TEST_DIR="${TEST_DIR:-$(cd "$(dirname "$0")" && pwd)}" @@ -26,6 +29,16 @@ if ! command -v xvfb-run >/dev/null 2>&1; then exit 77 fi +if [ -n "$PYTHON_DEPS" ]; then + IFS=, read -r -a deps <<< "$PYTHON_DEPS" + for mod in "${deps[@]}"; do + if ! python3 -c "import $mod" 2>/dev/null; then + echo "SKIP: python module '$mod' not installed (GUI cannot run)" >&2 + exit 77 + fi + done +fi + cd "$TEST_DIR" || exit 1 rm -f ui-smoke.out ui-smoke.err linuxcnc.pid @@ -51,21 +64,27 @@ xvfb-run -a --server-args="-screen 0 1024x768x24" \ timeout $DRIVER_TIMEOUT python3 '$LIB_DIR/drive.py' >ui-smoke.out 2>ui-smoke.err DRIVE_RC=\$? - # Clean shutdown: GUI-specific quit if available (lets linuxcnc's - # own SIGTERM trap run Cleanup which unloads halrun and reaps - # shared memory). Otherwise fall through to signal escalation. + # Clean shutdown: GUI-specific quit first (lets linuxcnc's own + # SIGTERM trap run Cleanup which unloads halrun and reaps shared + # memory). axis-remote works only for axis but is harmless + # otherwise. Then SIGTERM to the script PID (not group) so the + # trap runs in-process. Wait up to 30s for Cleanup to complete + # before falling back to SIGKILL. if command -v axis-remote >/dev/null 2>&1; then axis-remote --quit 2>/dev/null || true fi - # SIGTERM to the process group triggers scripts/linuxcnc's trap. - kill -TERM -\$LINUXCNC_PGID 2>/dev/null || true - for i in 1 2 3 4 5 6 7 8 9 10; do + kill -TERM \$LINUXCNC_PGID 2>/dev/null || true + for i in \$(seq 30); do kill -0 \$LINUXCNC_PGID 2>/dev/null || break sleep 1 done # Last resort if Cleanup never finished. - kill -KILL -\$LINUXCNC_PGID 2>/dev/null || true + if kill -0 \$LINUXCNC_PGID 2>/dev/null; then + echo "WARN: linuxcnc did not exit on SIGTERM, escalating to KILL" >&2 + kill -KILL -\$LINUXCNC_PGID 2>/dev/null || true + sleep 2 + fi exit \$DRIVE_RC " diff --git a/tests/ui-smoke/axis/test.sh b/tests/ui-smoke/axis/test.sh index 8f7385a5ecd..01b42f9f308 100755 --- a/tests/ui-smoke/axis/test.sh +++ b/tests/ui-smoke/axis/test.sh @@ -1,4 +1,6 @@ #!/bin/bash +# axis is a Tk + OpenGL GUI; PyOpenGL must be importable or we skip. TEST_DIR="$(cd "$(dirname "$0")" && pwd)" \ exec "$(dirname "$0")/../_lib/launch.sh" \ - "$(cd "$(dirname "$0")"/../../../configs/sim/axis && pwd)/axis.ini" + "$(cd "$(dirname "$0")"/../../../configs/sim/axis && pwd)/axis.ini" \ + "OpenGL.GL" diff --git a/tests/ui-smoke/gmoccapy/test.sh b/tests/ui-smoke/gmoccapy/test.sh index 563fbc7824c..c49c8163789 100755 --- a/tests/ui-smoke/gmoccapy/test.sh +++ b/tests/ui-smoke/gmoccapy/test.sh @@ -1,4 +1,6 @@ #!/bin/bash +# gmoccapy uses PyGObject + GTK 3 + accessibility bus. TEST_DIR="$(cd "$(dirname "$0")" && pwd)" \ exec "$(dirname "$0")/../_lib/launch.sh" \ - "$(cd "$(dirname "$0")"/../../../configs/sim/gmoccapy && pwd)/gmoccapy.ini" + "$(cd "$(dirname "$0")"/../../../configs/sim/gmoccapy && pwd)/gmoccapy.ini" \ + "gi" diff --git a/tests/ui-smoke/qtdragon/test.sh b/tests/ui-smoke/qtdragon/test.sh index 9b8aef1e56d..63222c02240 100755 --- a/tests/ui-smoke/qtdragon/test.sh +++ b/tests/ui-smoke/qtdragon/test.sh @@ -1,4 +1,6 @@ #!/bin/bash +# qtdragon is built on qtvcp (PyQt5). TEST_DIR="$(cd "$(dirname "$0")" && pwd)" \ exec "$(dirname "$0")/../_lib/launch.sh" \ - "$(cd "$(dirname "$0")"/../../../configs/sim/qtdragon/qtdragon_xyz && pwd)/qtdragon_metric.ini" + "$(cd "$(dirname "$0")"/../../../configs/sim/qtdragon/qtdragon_xyz && pwd)/qtdragon_metric.ini" \ + "PyQt5.QtCore,qtvcp" diff --git a/tests/ui-smoke/touchy/test.sh b/tests/ui-smoke/touchy/test.sh index 3b493a660c6..106bf3bd6fb 100755 --- a/tests/ui-smoke/touchy/test.sh +++ b/tests/ui-smoke/touchy/test.sh @@ -1,4 +1,6 @@ #!/bin/bash +# touchy uses PyGObject + GTK 3. TEST_DIR="$(cd "$(dirname "$0")" && pwd)" \ exec "$(dirname "$0")/../_lib/launch.sh" \ - "$(cd "$(dirname "$0")"/../../../configs/sim/touchy && pwd)/touchy.ini" + "$(cd "$(dirname "$0")"/../../../configs/sim/touchy && pwd)/touchy.ini" \ + "gi" From 2697cd762a77dc3d1cb23b965c3d2b2829549c89 Mon Sep 17 00:00:00 2001 From: Luca Toniolo <10792599+grandixximo@users.noreply.github.com> Date: Mon, 4 May 2026 16:00:13 +0800 Subject: [PATCH 04/17] test: fix bash -c syntax error in launch.sh and add cairo preflight The previous launch.sh had `echo "WARN: ..."` inside a `bash -c "..."` heredoc; the inner double quotes closed the outer string and the shutdown block was truncated. Symptom on CI: "linuxcnc: -c: line 34: syntax error: unexpected end of file" before any logs were captured. Switch to single quotes for the warning message. Also add cairo to gmoccapy's import preflight: gladevcp.makepins (loaded by gmoccapy) imports cairo via the led module, which trips on minimal CI without python3-cairo. --- tests/ui-smoke/_lib/launch.sh | 2 +- tests/ui-smoke/gmoccapy/test.sh | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/ui-smoke/_lib/launch.sh b/tests/ui-smoke/_lib/launch.sh index 48b33c0c8f0..ec2891fc487 100755 --- a/tests/ui-smoke/_lib/launch.sh +++ b/tests/ui-smoke/_lib/launch.sh @@ -81,7 +81,7 @@ xvfb-run -a --server-args="-screen 0 1024x768x24" \ done # Last resort if Cleanup never finished. if kill -0 \$LINUXCNC_PGID 2>/dev/null; then - echo "WARN: linuxcnc did not exit on SIGTERM, escalating to KILL" >&2 + echo 'WARN: linuxcnc did not exit on SIGTERM, escalating to KILL' >&2 kill -KILL -\$LINUXCNC_PGID 2>/dev/null || true sleep 2 fi diff --git a/tests/ui-smoke/gmoccapy/test.sh b/tests/ui-smoke/gmoccapy/test.sh index c49c8163789..ff86efede71 100755 --- a/tests/ui-smoke/gmoccapy/test.sh +++ b/tests/ui-smoke/gmoccapy/test.sh @@ -1,6 +1,6 @@ #!/bin/bash -# gmoccapy uses PyGObject + GTK 3 + accessibility bus. +# gmoccapy uses PyGObject + GTK 3; gladevcp pulls in cairo. TEST_DIR="$(cd "$(dirname "$0")" && pwd)" \ exec "$(dirname "$0")/../_lib/launch.sh" \ "$(cd "$(dirname "$0")"/../../../configs/sim/gmoccapy && pwd)/gmoccapy.ini" \ - "gi" + "gi,cairo" From 3b6fe754538bd3c211ff418076816883e107341a Mon Sep 17 00:00:00 2001 From: Luca Toniolo <10792599+grandixximo@users.noreply.github.com> Date: Mon, 4 May 2026 17:02:48 +0800 Subject: [PATCH 05/17] test: add runtests skip files for ui-smoke scripts/runtests does not honor exit 77 from a test.sh; its skip mechanism is a per-directory `skip` executable that returns non-zero when the test should be skipped. Add a shared _lib/skip-if-missing.sh and per-GUI skip scripts that check for xvfb-run plus the python modules each GUI needs. The launch.sh preflight stays as a fallback. Modules required: axis OpenGL.GL touchy gi, cairo gmoccapy gi, cairo qtdragon PyQt5.QtCore, qtvcp --- tests/ui-smoke/_lib/skip-if-missing.sh | 25 +++++++++++++++++++++++++ tests/ui-smoke/axis/skip | 2 ++ tests/ui-smoke/gmoccapy/skip | 2 ++ tests/ui-smoke/qtdragon/skip | 2 ++ tests/ui-smoke/touchy/skip | 2 ++ 5 files changed, 33 insertions(+) create mode 100755 tests/ui-smoke/_lib/skip-if-missing.sh create mode 100755 tests/ui-smoke/axis/skip create mode 100755 tests/ui-smoke/gmoccapy/skip create mode 100755 tests/ui-smoke/qtdragon/skip create mode 100755 tests/ui-smoke/touchy/skip diff --git a/tests/ui-smoke/_lib/skip-if-missing.sh b/tests/ui-smoke/_lib/skip-if-missing.sh new file mode 100755 index 00000000000..88fa1aee62f --- /dev/null +++ b/tests/ui-smoke/_lib/skip-if-missing.sh @@ -0,0 +1,25 @@ +#!/bin/bash +# Shared "skip" predicate for ui-smoke tests. +# runtests semantics: a `skip` script that returns non-zero causes the +# test to be skipped. Per-test skip files invoke this with the modules +# they need; if any module fails to import or xvfb-run is unavailable +# we exit non-zero (skip), otherwise zero (run). +set -u + +if ! command -v xvfb-run >/dev/null 2>&1; then + echo "skip: xvfb-run not installed" >&2 + exit 1 +fi + +PYTHON_DEPS="${1:-}" +if [ -n "$PYTHON_DEPS" ]; then + IFS=, read -r -a deps <<< "$PYTHON_DEPS" + for mod in "${deps[@]}"; do + if ! python3 -c "import $mod" 2>/dev/null; then + echo "skip: python module '$mod' not installed" >&2 + exit 1 + fi + done +fi + +exit 0 diff --git a/tests/ui-smoke/axis/skip b/tests/ui-smoke/axis/skip new file mode 100755 index 00000000000..45932ee834d --- /dev/null +++ b/tests/ui-smoke/axis/skip @@ -0,0 +1,2 @@ +#!/bin/bash +exec "$(dirname "$0")/../_lib/skip-if-missing.sh" "OpenGL.GL" diff --git a/tests/ui-smoke/gmoccapy/skip b/tests/ui-smoke/gmoccapy/skip new file mode 100755 index 00000000000..c14869dc9bd --- /dev/null +++ b/tests/ui-smoke/gmoccapy/skip @@ -0,0 +1,2 @@ +#!/bin/bash +exec "$(dirname "$0")/../_lib/skip-if-missing.sh" "gi,cairo" diff --git a/tests/ui-smoke/qtdragon/skip b/tests/ui-smoke/qtdragon/skip new file mode 100755 index 00000000000..95b0aa56996 --- /dev/null +++ b/tests/ui-smoke/qtdragon/skip @@ -0,0 +1,2 @@ +#!/bin/bash +exec "$(dirname "$0")/../_lib/skip-if-missing.sh" "PyQt5.QtCore,qtvcp" diff --git a/tests/ui-smoke/touchy/skip b/tests/ui-smoke/touchy/skip new file mode 100755 index 00000000000..c14869dc9bd --- /dev/null +++ b/tests/ui-smoke/touchy/skip @@ -0,0 +1,2 @@ +#!/bin/bash +exec "$(dirname "$0")/../_lib/skip-if-missing.sh" "gi,cairo" From be52d39bbf1f899309d123fd7b7aa06fca84fa16 Mon Sep 17 00:00:00 2001 From: Luca Toniolo <10792599+grandixximo@users.noreply.github.com> Date: Mon, 4 May 2026 17:32:48 +0800 Subject: [PATCH 06/17] debian: add GUI runtime python deps to Build-Depends with !nocheck Forward port of the GUI dependency work from 2.9 (#3391). The runtime deps were already in linuxcnc-uspace's Depends, but apt-get build-dep on CI does not install runtime deps, which left the new ui-smoke tests unable to launch any GUI and forced them to skip. Adds python3-opengl, python3-pyqt5, python3-pyqt5.qsci, python3-cairo, python3-gi, python3-gi-cairo, gir1.2-gtk-3.0 under the !nocheck profile, matching the existing pattern for xvfb and x11-xserver-utils. Edited debian/control.top.in (debian/control is gitignored and regenerated by debian/configure). Refs #3391, #3756 --- debian/control.top.in | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/debian/control.top.in b/debian/control.top.in index 1016d940fc9..b939fc188f7 100644 --- a/debian/control.top.in +++ b/debian/control.top.in @@ -48,6 +48,13 @@ Build-Depends: tk@TCLTK_VERSION@-dev, xvfb , x11-xserver-utils , + python3-opengl , + python3-pyqt5 , + python3-pyqt5.qsci , + python3-cairo , + python3-gi , + python3-gi-cairo , + gir1.2-gtk-3.0 , libfmt-dev, yapps2 Build-Depends-Indep: From 9e16829618641afd7f3650a0d68668903c56e621 Mon Sep 17 00:00:00 2001 From: Luca Toniolo <10792599+grandixximo@users.noreply.github.com> Date: Mon, 4 May 2026 18:48:01 +0800 Subject: [PATCH 07/17] test: add more GUI deps + check gi typelibs in skip predicate CI run after the first dep batch revealed gmoccapy needs the GtkSource-4 typelib, qtdragon needs additional PyQt5 modules (qtsvg/qtopengl/qtwebengine), python3-qtpy, and the dbus mainloop binding. Add these to Build-Depends with !nocheck profile so they install on apt-get build-dep. Also extend skip-if-missing.sh to verify gi typelibs (entries of the form gi:Namespace:version), not just python imports. This catches the GtkSource case where gi imports fine but the typelib is absent, which gladevcp tripped on at gi.require_version time. touchy and gmoccapy skip predicates now require Gtk-3.0 (and GtkSource-4 for gmoccapy). Refs #3756 --- debian/control.top.in | 9 +++++++++ tests/ui-smoke/_lib/skip-if-missing.sh | 12 +++++++++++- tests/ui-smoke/gmoccapy/skip | 2 +- tests/ui-smoke/touchy/skip | 2 +- 4 files changed, 22 insertions(+), 3 deletions(-) diff --git a/debian/control.top.in b/debian/control.top.in index b939fc188f7..085c5d1280c 100644 --- a/debian/control.top.in +++ b/debian/control.top.in @@ -51,10 +51,19 @@ Build-Depends: python3-opengl , python3-pyqt5 , python3-pyqt5.qsci , + python3-pyqt5.qtsvg , + python3-pyqt5.qtopengl , + python3-pyqt5.qtwebengine , + python3-dbus , + python3-dbus.mainloop.pyqt5 , + python3-qtpy , python3-cairo , python3-gi , python3-gi-cairo , gir1.2-gtk-3.0 , + gir1.2-gtksource-4 , + python3-numpy , + python3-configobj , libfmt-dev, yapps2 Build-Depends-Indep: diff --git a/tests/ui-smoke/_lib/skip-if-missing.sh b/tests/ui-smoke/_lib/skip-if-missing.sh index 88fa1aee62f..a9754adc003 100755 --- a/tests/ui-smoke/_lib/skip-if-missing.sh +++ b/tests/ui-smoke/_lib/skip-if-missing.sh @@ -15,7 +15,17 @@ PYTHON_DEPS="${1:-}" if [ -n "$PYTHON_DEPS" ]; then IFS=, read -r -a deps <<< "$PYTHON_DEPS" for mod in "${deps[@]}"; do - if ! python3 -c "import $mod" 2>/dev/null; then + # gi-style entries look like "gi:GtkSource:4"; verify the + # typelib is available, not just the gi import. This catches + # cases like the GtkSource-4 typelib missing while gi itself + # is installed, which gladevcp trips on at runtime. + if [[ "$mod" == gi:* ]]; then + IFS=: read -r _ ns ver <<< "$mod" + if ! python3 -c "import gi; gi.require_version('$ns','$ver'); from gi.repository import $ns" 2>/dev/null; then + echo "skip: gi typelib '$ns-$ver' not available" >&2 + exit 1 + fi + elif ! python3 -c "import $mod" 2>/dev/null; then echo "skip: python module '$mod' not installed" >&2 exit 1 fi diff --git a/tests/ui-smoke/gmoccapy/skip b/tests/ui-smoke/gmoccapy/skip index c14869dc9bd..279a370eeae 100755 --- a/tests/ui-smoke/gmoccapy/skip +++ b/tests/ui-smoke/gmoccapy/skip @@ -1,2 +1,2 @@ #!/bin/bash -exec "$(dirname "$0")/../_lib/skip-if-missing.sh" "gi,cairo" +exec "$(dirname "$0")/../_lib/skip-if-missing.sh" "gi,cairo,gi:Gtk:3.0,gi:GtkSource:4" diff --git a/tests/ui-smoke/touchy/skip b/tests/ui-smoke/touchy/skip index c14869dc9bd..befe1267c2a 100755 --- a/tests/ui-smoke/touchy/skip +++ b/tests/ui-smoke/touchy/skip @@ -1,2 +1,2 @@ #!/bin/bash -exec "$(dirname "$0")/../_lib/skip-if-missing.sh" "gi,cairo" +exec "$(dirname "$0")/../_lib/skip-if-missing.sh" "gi,cairo,gi:Gtk:3.0" From 004ec5f93fa993255022d5891cb48310920b62d8 Mon Sep 17 00:00:00 2001 From: Luca Toniolo <10792599+grandixximo@users.noreply.github.com> Date: Mon, 4 May 2026 19:32:12 +0800 Subject: [PATCH 08/17] test: trim driver to true smoke + harden shutdown cleanup The previous driver did too much for a smoke layer (Estop reset, machine on, home all, wait for IDLE) and tripped on each GUI's specific startup sequence assumptions. Reduce to: connect to NML, wait for task ready, sleep 3s for GUI construction, recheck task alive, print UI_SMOKE_OK. This is the literal answer to Bertho's "does it start" question. Functional behaviour belongs in tests/ui-functional/ (Phase 2). Also harden shutdown: extend the SIGTERM grace from 30s to 60s, and add a halrun -U + explicit ipcrm fallback if Cleanup still has not finished. Removes /tmp/linuxcnc.lock too. Without this the next ui-smoke test inherited stale shared memory and wedged at startup. Bump LINUXCNC_TIMEOUT to 180s (8s startup + 30s driver + 60s grace + slack) and reduce DRIVER_TIMEOUT to 30s now that the driver work is small. Refs #3756 --- tests/ui-smoke/_lib/drive.py | 68 ++++++++++++++--------------------- tests/ui-smoke/_lib/launch.sh | 17 ++++++--- 2 files changed, 39 insertions(+), 46 deletions(-) diff --git a/tests/ui-smoke/_lib/drive.py b/tests/ui-smoke/_lib/drive.py index d9d17fa6279..ea245727538 100755 --- a/tests/ui-smoke/_lib/drive.py +++ b/tests/ui-smoke/_lib/drive.py @@ -1,7 +1,8 @@ #!/usr/bin/env python3 -# Common UI smoke driver: connect via NML, take the machine through Estop -# reset, machine on, home all joints, wait for ON+IDLE, request shutdown. -# Prints a single "UI_SMOKE_OK" line on success so checkresult can grep. +# Minimal UI smoke driver: confirm linuxcnc task came up and the GUI +# did not crash. The smoke layer answers Bertho's "does it start" +# question only; functional behaviour (home, run a file, verify +# position) belongs in tests/ui-functional/ (Phase 2). import linuxcnc import sys @@ -13,7 +14,11 @@ def wait_for(predicate, stat, timeout, label): deadline = time.monotonic() + timeout while time.monotonic() < deadline: - stat.poll() + try: + stat.poll() + except linuxcnc.error as e: + sys.stderr.write(f"UI_SMOKE_FAIL: stat.poll() error while waiting for {label}: {e}\n") + return False if predicate(stat): return True time.sleep(0.1) @@ -22,49 +27,28 @@ def wait_for(predicate, stat, timeout, label): def main(): - cmd = linuxcnc.command() - stat = linuxcnc.stat() - - if not wait_for(lambda s: s.echo_serial_number >= 0, stat, 10.0, - "task to come up"): - return 1 - - # State transitions: STATE_ESTOP (1), STATE_ESTOP_RESET (2), - # STATE_OFF (3), STATE_ON (4). Some sim configs come up already in - # STATE_ON via auto-estop-release HAL wiring, so we drive toward - # STATE_ON unconditionally and accept any state >= STATE_ESTOP_RESET - # as a successful estop reset. - cmd.state(linuxcnc.STATE_ESTOP_RESET) - cmd.wait_complete(TIMEOUT_S) - if not wait_for(lambda s: s.task_state >= linuxcnc.STATE_ESTOP_RESET, - stat, TIMEOUT_S, "estop reset"): + try: + cmd = linuxcnc.command() + stat = linuxcnc.stat() + except linuxcnc.error as e: + sys.stderr.write(f"UI_SMOKE_FAIL: cannot connect to NML: {e}\n") return 1 - cmd.state(linuxcnc.STATE_ON) - cmd.wait_complete(TIMEOUT_S) - if not wait_for(lambda s: s.task_state == linuxcnc.STATE_ON, stat, - TIMEOUT_S, "machine on"): + if not wait_for(lambda s: s.echo_serial_number >= 0, stat, TIMEOUT_S, + "task to come up"): return 1 - stat.poll() - # home(-1) homes all joints per HOME_SEQUENCE; falls back to per-joint - # serial homing if no sequence is configured. - cmd.home(-1) - cmd.wait_complete(TIMEOUT_S) + # Give the GUI process enough time to finish constructing itself + # (load .ui files, compile resources.py if needed, etc.) and settle. + # If the GUI was going to crash on startup it has crashed by now. + time.sleep(3.0) - if not wait_for(lambda s: all(s.homed[:s.joints]), stat, TIMEOUT_S, - "all joints homed"): - # Fall back to one-at-a-time so configs without HOME_SEQUENCE still - # complete the smoke flow. - for j in range(stat.joints): - cmd.home(j) - cmd.wait_complete(TIMEOUT_S) - if not wait_for(lambda s, jj=j: s.homed[jj], stat, TIMEOUT_S, - f"joint {j} homed"): - return 1 - - if not wait_for(lambda s: s.interp_state == linuxcnc.INTERP_IDLE, stat, - TIMEOUT_S, "interpreter idle"): + # Re-check task is still alive; a GUI crash may have torn linuxcnc + # down via Cleanup. + try: + stat.poll() + except linuxcnc.error as e: + sys.stderr.write(f"UI_SMOKE_FAIL: task disappeared after GUI startup: {e}\n") return 1 print("UI_SMOKE_OK") diff --git a/tests/ui-smoke/_lib/launch.sh b/tests/ui-smoke/_lib/launch.sh index ec2891fc487..6abb9d46958 100755 --- a/tests/ui-smoke/_lib/launch.sh +++ b/tests/ui-smoke/_lib/launch.sh @@ -44,8 +44,8 @@ rm -f ui-smoke.out ui-smoke.err linuxcnc.pid # Launch linuxcnc inside xvfb-run. The -t timeout is a safety net so a # wedged GUI cannot hang CI. -LINUXCNC_TIMEOUT=120 -DRIVER_TIMEOUT=60 +LINUXCNC_TIMEOUT=180 +DRIVER_TIMEOUT=30 xvfb-run -a --server-args="-screen 0 1024x768x24" \ timeout "$LINUXCNC_TIMEOUT" \ @@ -75,15 +75,24 @@ xvfb-run -a --server-args="-screen 0 1024x768x24" \ fi kill -TERM \$LINUXCNC_PGID 2>/dev/null || true - for i in \$(seq 30); do + for i in \$(seq 60); do kill -0 \$LINUXCNC_PGID 2>/dev/null || break sleep 1 done - # Last resort if Cleanup never finished. + # Last resort if Cleanup never finished. Use halrun -U to drop + # any HAL state we may have left behind, and remove any of + # linuxcnc's known shared memory keys explicitly so the next + # ui-smoke test does not inherit a corrupted environment. if kill -0 \$LINUXCNC_PGID 2>/dev/null; then echo 'WARN: linuxcnc did not exit on SIGTERM, escalating to KILL' >&2 kill -KILL -\$LINUXCNC_PGID 2>/dev/null || true sleep 2 + halrun -U 2>/dev/null || true + for key in 0x48414c32 0x48484c34 0x00000064; do + shmid=\$(ipcs -m | awk -v k=\$key 'tolower(\$1)==k {print \$2}') + [ -n \"\$shmid\" ] && ipcrm -m \$shmid 2>/dev/null || true + done + rm -f /tmp/linuxcnc.lock fi exit \$DRIVE_RC From 754cafdbdc9f6a7d7f6026a2d28d0033fc775597 Mon Sep 17 00:00:00 2001 From: Luca Toniolo <10792599+grandixximo@users.noreply.github.com> Date: Mon, 4 May 2026 21:29:00 +0800 Subject: [PATCH 09/17] test: kill leftover linuxcnc processes before each ui-smoke test CI run after the previous fix made progress (0 shmem errors, axis and gmoccapy passing) but qtdragon hit "bind error: 98 -- Address already in use" on NML port 5005, meaning gmoccapy's linuxcncsvr was still alive when qtdragon tried to start. touchy then cascaded. Add a pre-launch cleanup to launch.sh that pkills the known long-lived processes (linuxcncsvr, milltask, halui, hal_bridge, axis, gmoccapy, touchy, qtvcp, rtapi_app), removes /tmp/linuxcnc.lock, runs halrun -U, and ipcrms any leftover linuxcnc shared memory keys before each test. Refs #3756 --- tests/ui-smoke/_lib/launch.sh | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/ui-smoke/_lib/launch.sh b/tests/ui-smoke/_lib/launch.sh index 6abb9d46958..7055153c8de 100755 --- a/tests/ui-smoke/_lib/launch.sh +++ b/tests/ui-smoke/_lib/launch.sh @@ -42,6 +42,20 @@ fi cd "$TEST_DIR" || exit 1 rm -f ui-smoke.out ui-smoke.err linuxcnc.pid +# Belt-and-suspenders pre-cleanup: a previous ui-smoke test in the same +# job may have left linuxcncsvr or task processes running, which holds +# the NML TCP port and prevents the next instance from binding. Kill +# anything still lingering before we launch. +for proc in linuxcncsvr milltask halui hal_bridge axis gmoccapy touchy qtvcp rtapi_app; do + pkill -KILL -f "\b$proc\b" 2>/dev/null || true +done +rm -f /tmp/linuxcnc.lock +halrun -U 2>/dev/null || true +for key in 0x48414c32 0x48484c34 0x00000064; do + shmid=$(ipcs -m | awk -v k="$key" 'tolower($1)==k {print $2}') + [ -n "$shmid" ] && ipcrm -m "$shmid" 2>/dev/null || true +done + # Launch linuxcnc inside xvfb-run. The -t timeout is a safety net so a # wedged GUI cannot hang CI. LINUXCNC_TIMEOUT=180 From afb18f183d18dc7ea9ad5d34fec86979e1f41fa3 Mon Sep 17 00:00:00 2001 From: Luca Toniolo <10792599+grandixximo@users.noreply.github.com> Date: Tue, 5 May 2026 07:13:24 +0800 Subject: [PATCH 10/17] test: address ui-smoke review feedback (BsAtHome, hdiethelm) Three review-driven changes: 1. Fix self-kill regression: pkill -KILL -f "\\bqtdragon\\b" matched the launch.sh process whose argv contained the path .../qtdragon_metric.ini, sending SIGKILL to the test itself (exit 137 across all 4 tests). Use pkill -KILL -x against an exact daemon name list (linuxcncsvr, milltask, halui, rtapi_app), not the GUI program names; the GUIs are children of the linuxcnc script and get reaped via SIGTERM to its process group. 2. Dedupe cleanup. Both pre-launch and post-shutdown blocks repeated the daemon list and shared-memory key list; extract them to _lib/cleanup-runtime.sh which is called from launch.sh and from the heredoc fallback. Single source of truth. 3. Drop the pre-driver `sleep 8` and the python module preflight inside launch.sh. drive.py polls echo_serial_number for task readiness so a wall-clock wait is unnecessary. With GUI runtime deps now declared in debian/control under !nocheck, the python preflight has nothing to do; missing deps will fail the test loudly which is what reviewers asked for ("if it skips gracefully we don't know whether the code is sane"). The skip predicate only skips on xvfb-run absence (rare local dev environment). Refs #3756, PR #3999 --- tests/ui-smoke/README | 31 +++++---- tests/ui-smoke/_lib/cleanup-runtime.sh | 28 ++++++++ tests/ui-smoke/_lib/launch.sh | 90 ++++++++------------------ tests/ui-smoke/_lib/skip-if-missing.sh | 30 ++------- tests/ui-smoke/axis/skip | 2 +- tests/ui-smoke/axis/test.sh | 4 +- tests/ui-smoke/gmoccapy/skip | 2 +- tests/ui-smoke/gmoccapy/test.sh | 4 +- tests/ui-smoke/qtdragon/skip | 2 +- tests/ui-smoke/qtdragon/test.sh | 4 +- tests/ui-smoke/touchy/skip | 2 +- tests/ui-smoke/touchy/test.sh | 4 +- 12 files changed, 87 insertions(+), 116 deletions(-) create mode 100755 tests/ui-smoke/_lib/cleanup-runtime.sh diff --git a/tests/ui-smoke/README b/tests/ui-smoke/README index 5fa34b62f13..eb9ea8ef0ff 100644 --- a/tests/ui-smoke/README +++ b/tests/ui-smoke/README @@ -1,21 +1,26 @@ UI smoke tests ~~~~~~~~~~~~~~ -These tests launch each GUI (axis, touchy, gmoccapy, qtdragon, etc.) -under xvfb against an existing sim config and verify the GUI starts, -accepts basic NML commands (Estop reset, machine on, home), and shuts -down cleanly without crashing. - -Scope is intentionally small: prove the GUI is alive, not exercise its -features. Functional tests (loading G-code, jogging, MDI) belong in -follow-up directories. +These tests launch each GUI (axis, touchy, gmoccapy, qtdragon) under +xvfb-run against an existing sim config and verify the GUI starts and +the NML task is reachable. Phase 1 ("does it start"); functional +checks (load G-code, jog, MDI) belong in tests/ui-functional/. Each test directory contains: - test.sh launches the GUI under xvfb-run, runs drive.py, captures - exit code and stderr + test.sh launches the GUI under xvfb-run, runs drive.py checkresult examines the captured output for crash markers + skip skips this test only when xvfb-run is not on the host -Shared helpers live in _lib/. +Shared helpers live in _lib/: + drive.py common NML driver, prints UI_SMOKE_OK on success + launch.sh xvfb-run wrapper, signal escalation for shutdown + cleanup-runtime.sh belt-and-suspenders: kill stray daemons, ipcrm + shared memory, drop /tmp/linuxcnc.lock + checkresult.sh shared pass/fail predicate + skip-if-missing.sh shared skip predicate -If xvfb-run is not available on the host, tests skip gracefully (matches -the precedent set by tests/tooledit and tests/pyvcp). +Skip vs fail policy: the only condition we skip on is xvfb-run absence +(rare local dev env). Python and gi typelib deps the GUIs need are +declared in debian/control under !nocheck so apt-get build-dep +installs them on CI; if they are missing the test should fail loudly +rather than silently skip, so missing deps surface during review. diff --git a/tests/ui-smoke/_lib/cleanup-runtime.sh b/tests/ui-smoke/_lib/cleanup-runtime.sh new file mode 100755 index 00000000000..fa9d625c32b --- /dev/null +++ b/tests/ui-smoke/_lib/cleanup-runtime.sh @@ -0,0 +1,28 @@ +#!/bin/bash +# Reset linuxcnc-related runtime state so the next ui-smoke test starts +# from a clean environment. Used as both a pre-launch belt-and-braces +# cleanup and as a post-shutdown last-resort if scripts/linuxcnc's own +# SIGTERM trap could not reap everything in time. +# +# Single source of truth for the daemon list and shared memory keys +# (BsAtHome review, PR #3999) so they are not duplicated across +# launch.sh. + +set -u + +DAEMONS=(linuxcncsvr milltask halui rtapi_app) +SHM_KEYS=(0x48414c32 0x48484c34 0x00000064) + +for proc in "${DAEMONS[@]}"; do + pkill -KILL -x "$proc" 2>/dev/null || true +done + +rm -f /tmp/linuxcnc.lock +halrun -U 2>/dev/null || true + +for key in "${SHM_KEYS[@]}"; do + shmid=$(ipcs -m | awk -v k="$key" 'tolower($1)==k {print $2}') + [ -n "$shmid" ] && ipcrm -m "$shmid" 2>/dev/null || true +done + +exit 0 diff --git a/tests/ui-smoke/_lib/launch.sh b/tests/ui-smoke/_lib/launch.sh index 7055153c8de..f0966aa4f53 100755 --- a/tests/ui-smoke/_lib/launch.sh +++ b/tests/ui-smoke/_lib/launch.sh @@ -1,26 +1,21 @@ #!/bin/bash # Shared launcher for UI smoke tests. -# Usage: launch.sh +# Usage: launch.sh # -# Spawns linuxcnc -r under xvfb-run with a timeout, then runs the -# common driver script against it. Captures stdout to ui-smoke.out and -# stderr to ui-smoke.err in the test directory. +# Spawns linuxcnc -r under xvfb-run, then runs the common driver +# script against it via NML. Captures stdout/stderr to per-test files. # -# Exits 0 on success (driver printed UI_SMOKE_OK) or non-zero on any -# failure: missing tools, GUI never appeared, driver assertion failed, -# linuxcnc crash, etc. -# -# If xvfb-run is not on the PATH the test is treated as skipped (exit 77, -# which the runtests harness records as a skip rather than a failure). +# Skip vs fail (BsAtHome / hdiethelm review, PR #3999): the only thing +# we skip on is xvfb-run absence, which can happen on a developer's +# minimal machine. CI is expected to have all required deps; if any +# python module the GUI needs is missing the test should fail loudly +# rather than silently skip, so missing deps surface during review. +# Per-GUI deps are declared in debian/control under !nocheck, so +# apt-get build-dep installs them on CI. set -u CONFIG_INI="$1" -# Comma-separated python module names the GUI needs at import time. -# If any fail to import, the test skips with exit 77 instead of failing. -PYTHON_DEPS="${2:-}" -# TEST_DIR is the directory of the calling test script (passed via $0 -# from the caller before exec). LIB_DIR is where this script lives. TEST_DIR="${TEST_DIR:-$(cd "$(dirname "$0")" && pwd)}" LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -29,61 +24,39 @@ if ! command -v xvfb-run >/dev/null 2>&1; then exit 77 fi -if [ -n "$PYTHON_DEPS" ]; then - IFS=, read -r -a deps <<< "$PYTHON_DEPS" - for mod in "${deps[@]}"; do - if ! python3 -c "import $mod" 2>/dev/null; then - echo "SKIP: python module '$mod' not installed (GUI cannot run)" >&2 - exit 77 - fi - done -fi - cd "$TEST_DIR" || exit 1 rm -f ui-smoke.out ui-smoke.err linuxcnc.pid -# Belt-and-suspenders pre-cleanup: a previous ui-smoke test in the same -# job may have left linuxcncsvr or task processes running, which holds -# the NML TCP port and prevents the next instance from binding. Kill -# anything still lingering before we launch. -for proc in linuxcncsvr milltask halui hal_bridge axis gmoccapy touchy qtvcp rtapi_app; do - pkill -KILL -f "\b$proc\b" 2>/dev/null || true -done -rm -f /tmp/linuxcnc.lock -halrun -U 2>/dev/null || true -for key in 0x48414c32 0x48484c34 0x00000064; do - shmid=$(ipcs -m | awk -v k="$key" 'tolower($1)==k {print $2}') - [ -n "$shmid" ] && ipcrm -m "$shmid" 2>/dev/null || true -done +# Pre-launch cleanup: a previous ui-smoke test in the same job may +# have left a daemon listening on the NML TCP port or HAL shared +# memory still attached. Run the shared cleanup once before we start. +bash "$LIB_DIR/cleanup-runtime.sh" -# Launch linuxcnc inside xvfb-run. The -t timeout is a safety net so a -# wedged GUI cannot hang CI. +# Launch linuxcnc inside xvfb-run. The outer timeout is a safety net +# so a wedged GUI cannot hang CI. LINUXCNC_TIMEOUT=180 DRIVER_TIMEOUT=30 xvfb-run -a --server-args="-screen 0 1024x768x24" \ timeout "$LINUXCNC_TIMEOUT" \ bash -c " - # Run linuxcnc in its own process group so we can signal the whole - # group cleanly (linuxcnc forks task, motion, GUI, halrun). + # Run linuxcnc in its own process group so we can signal the + # whole group cleanly (linuxcnc forks task, motion, GUI, halrun). setsid linuxcnc -r '$CONFIG_INI' >linuxcnc.out 2>linuxcnc.err & LINUXCNC_PGID=\$! echo \$LINUXCNC_PGID >linuxcnc.pid - # Give task time to come up before driver attaches. The GUI also - # needs time to register and home up to the point where it accepts - # commands; 8s is conservative for headless sim runs. - sleep 8 - + # The driver polls NML readiness itself (BsAtHome review: + # avoid real-clock waits where status polling will do). timeout $DRIVER_TIMEOUT python3 '$LIB_DIR/drive.py' >ui-smoke.out 2>ui-smoke.err DRIVE_RC=\$? - # Clean shutdown: GUI-specific quit first (lets linuxcnc's own - # SIGTERM trap run Cleanup which unloads halrun and reaps shared - # memory). axis-remote works only for axis but is harmless - # otherwise. Then SIGTERM to the script PID (not group) so the - # trap runs in-process. Wait up to 30s for Cleanup to complete - # before falling back to SIGKILL. + # Clean shutdown: GUI-specific quit first (lets linuxcnc's + # own SIGTERM trap run Cleanup which unloads halrun and reaps + # shared memory). axis-remote works only for axis but is + # harmless otherwise. Then SIGTERM to the script PID so the + # trap runs in-process. Wait up to 60s for Cleanup to finish + # before falling back to SIGKILL + cleanup-runtime.sh. if command -v axis-remote >/dev/null 2>&1; then axis-remote --quit 2>/dev/null || true fi @@ -93,20 +66,11 @@ xvfb-run -a --server-args="-screen 0 1024x768x24" \ kill -0 \$LINUXCNC_PGID 2>/dev/null || break sleep 1 done - # Last resort if Cleanup never finished. Use halrun -U to drop - # any HAL state we may have left behind, and remove any of - # linuxcnc's known shared memory keys explicitly so the next - # ui-smoke test does not inherit a corrupted environment. if kill -0 \$LINUXCNC_PGID 2>/dev/null; then echo 'WARN: linuxcnc did not exit on SIGTERM, escalating to KILL' >&2 kill -KILL -\$LINUXCNC_PGID 2>/dev/null || true sleep 2 - halrun -U 2>/dev/null || true - for key in 0x48414c32 0x48484c34 0x00000064; do - shmid=\$(ipcs -m | awk -v k=\$key 'tolower(\$1)==k {print \$2}') - [ -n \"\$shmid\" ] && ipcrm -m \$shmid 2>/dev/null || true - done - rm -f /tmp/linuxcnc.lock + bash '$LIB_DIR/cleanup-runtime.sh' fi exit \$DRIVE_RC diff --git a/tests/ui-smoke/_lib/skip-if-missing.sh b/tests/ui-smoke/_lib/skip-if-missing.sh index a9754adc003..77a0155efcc 100755 --- a/tests/ui-smoke/_lib/skip-if-missing.sh +++ b/tests/ui-smoke/_lib/skip-if-missing.sh @@ -1,9 +1,12 @@ #!/bin/bash # Shared "skip" predicate for ui-smoke tests. # runtests semantics: a `skip` script that returns non-zero causes the -# test to be skipped. Per-test skip files invoke this with the modules -# they need; if any module fails to import or xvfb-run is unavailable -# we exit non-zero (skip), otherwise zero (run). +# test to be skipped. Per-test skip files invoke this. +# +# We only skip on xvfb-run absence (rare local dev env). Python / +# typelib deps are declared in debian/control under !nocheck so CI +# always has them; missing deps should fail the test loudly rather +# than silently skip (BsAtHome / hdiethelm review, PR #3999). set -u if ! command -v xvfb-run >/dev/null 2>&1; then @@ -11,25 +14,4 @@ if ! command -v xvfb-run >/dev/null 2>&1; then exit 1 fi -PYTHON_DEPS="${1:-}" -if [ -n "$PYTHON_DEPS" ]; then - IFS=, read -r -a deps <<< "$PYTHON_DEPS" - for mod in "${deps[@]}"; do - # gi-style entries look like "gi:GtkSource:4"; verify the - # typelib is available, not just the gi import. This catches - # cases like the GtkSource-4 typelib missing while gi itself - # is installed, which gladevcp trips on at runtime. - if [[ "$mod" == gi:* ]]; then - IFS=: read -r _ ns ver <<< "$mod" - if ! python3 -c "import gi; gi.require_version('$ns','$ver'); from gi.repository import $ns" 2>/dev/null; then - echo "skip: gi typelib '$ns-$ver' not available" >&2 - exit 1 - fi - elif ! python3 -c "import $mod" 2>/dev/null; then - echo "skip: python module '$mod' not installed" >&2 - exit 1 - fi - done -fi - exit 0 diff --git a/tests/ui-smoke/axis/skip b/tests/ui-smoke/axis/skip index 45932ee834d..c1c260edf05 100755 --- a/tests/ui-smoke/axis/skip +++ b/tests/ui-smoke/axis/skip @@ -1,2 +1,2 @@ #!/bin/bash -exec "$(dirname "$0")/../_lib/skip-if-missing.sh" "OpenGL.GL" +exec "$(dirname "$0")/../_lib/skip-if-missing.sh" diff --git a/tests/ui-smoke/axis/test.sh b/tests/ui-smoke/axis/test.sh index 01b42f9f308..8f7385a5ecd 100755 --- a/tests/ui-smoke/axis/test.sh +++ b/tests/ui-smoke/axis/test.sh @@ -1,6 +1,4 @@ #!/bin/bash -# axis is a Tk + OpenGL GUI; PyOpenGL must be importable or we skip. TEST_DIR="$(cd "$(dirname "$0")" && pwd)" \ exec "$(dirname "$0")/../_lib/launch.sh" \ - "$(cd "$(dirname "$0")"/../../../configs/sim/axis && pwd)/axis.ini" \ - "OpenGL.GL" + "$(cd "$(dirname "$0")"/../../../configs/sim/axis && pwd)/axis.ini" diff --git a/tests/ui-smoke/gmoccapy/skip b/tests/ui-smoke/gmoccapy/skip index 279a370eeae..c1c260edf05 100755 --- a/tests/ui-smoke/gmoccapy/skip +++ b/tests/ui-smoke/gmoccapy/skip @@ -1,2 +1,2 @@ #!/bin/bash -exec "$(dirname "$0")/../_lib/skip-if-missing.sh" "gi,cairo,gi:Gtk:3.0,gi:GtkSource:4" +exec "$(dirname "$0")/../_lib/skip-if-missing.sh" diff --git a/tests/ui-smoke/gmoccapy/test.sh b/tests/ui-smoke/gmoccapy/test.sh index ff86efede71..563fbc7824c 100755 --- a/tests/ui-smoke/gmoccapy/test.sh +++ b/tests/ui-smoke/gmoccapy/test.sh @@ -1,6 +1,4 @@ #!/bin/bash -# gmoccapy uses PyGObject + GTK 3; gladevcp pulls in cairo. TEST_DIR="$(cd "$(dirname "$0")" && pwd)" \ exec "$(dirname "$0")/../_lib/launch.sh" \ - "$(cd "$(dirname "$0")"/../../../configs/sim/gmoccapy && pwd)/gmoccapy.ini" \ - "gi,cairo" + "$(cd "$(dirname "$0")"/../../../configs/sim/gmoccapy && pwd)/gmoccapy.ini" diff --git a/tests/ui-smoke/qtdragon/skip b/tests/ui-smoke/qtdragon/skip index 95b0aa56996..c1c260edf05 100755 --- a/tests/ui-smoke/qtdragon/skip +++ b/tests/ui-smoke/qtdragon/skip @@ -1,2 +1,2 @@ #!/bin/bash -exec "$(dirname "$0")/../_lib/skip-if-missing.sh" "PyQt5.QtCore,qtvcp" +exec "$(dirname "$0")/../_lib/skip-if-missing.sh" diff --git a/tests/ui-smoke/qtdragon/test.sh b/tests/ui-smoke/qtdragon/test.sh index 63222c02240..9b8aef1e56d 100755 --- a/tests/ui-smoke/qtdragon/test.sh +++ b/tests/ui-smoke/qtdragon/test.sh @@ -1,6 +1,4 @@ #!/bin/bash -# qtdragon is built on qtvcp (PyQt5). TEST_DIR="$(cd "$(dirname "$0")" && pwd)" \ exec "$(dirname "$0")/../_lib/launch.sh" \ - "$(cd "$(dirname "$0")"/../../../configs/sim/qtdragon/qtdragon_xyz && pwd)/qtdragon_metric.ini" \ - "PyQt5.QtCore,qtvcp" + "$(cd "$(dirname "$0")"/../../../configs/sim/qtdragon/qtdragon_xyz && pwd)/qtdragon_metric.ini" diff --git a/tests/ui-smoke/touchy/skip b/tests/ui-smoke/touchy/skip index befe1267c2a..c1c260edf05 100755 --- a/tests/ui-smoke/touchy/skip +++ b/tests/ui-smoke/touchy/skip @@ -1,2 +1,2 @@ #!/bin/bash -exec "$(dirname "$0")/../_lib/skip-if-missing.sh" "gi,cairo,gi:Gtk:3.0" +exec "$(dirname "$0")/../_lib/skip-if-missing.sh" diff --git a/tests/ui-smoke/touchy/test.sh b/tests/ui-smoke/touchy/test.sh index 106bf3bd6fb..3b493a660c6 100755 --- a/tests/ui-smoke/touchy/test.sh +++ b/tests/ui-smoke/touchy/test.sh @@ -1,6 +1,4 @@ #!/bin/bash -# touchy uses PyGObject + GTK 3. TEST_DIR="$(cd "$(dirname "$0")" && pwd)" \ exec "$(dirname "$0")/../_lib/launch.sh" \ - "$(cd "$(dirname "$0")"/../../../configs/sim/touchy && pwd)/touchy.ini" \ - "gi" + "$(cd "$(dirname "$0")"/../../../configs/sim/touchy && pwd)/touchy.ini" From a065f41c1a27202f324dfe3f212586ebe41fba25 Mon Sep 17 00:00:00 2001 From: Luca Toniolo <10792599+grandixximo@users.noreply.github.com> Date: Tue, 5 May 2026 08:17:55 +0800 Subject: [PATCH 11/17] test: retry NML connection during linuxcnc startup window After dropping the pre-driver sleep, the driver now races linuxcnc startup. linuxcnc.stat()/command() and the first stat.poll() can raise linuxcnc.error while linuxcncsvr is still setting up its buffers ("emcStatusBuffer invalid err=3"). Previously the driver bailed on the first exception, so all 4 ui-smoke tests failed within ~1s on CI. Retry both the constructor calls and stat.poll() until the deadline, treating these errors as "task not ready yet" rather than fatal. The wait_for timeout (TIMEOUT_S=30s) bounds the wait. --- tests/ui-smoke/_lib/drive.py | 40 +++++++++++++++++++++++++++--------- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/tests/ui-smoke/_lib/drive.py b/tests/ui-smoke/_lib/drive.py index ea245727538..8b8266e47dc 100755 --- a/tests/ui-smoke/_lib/drive.py +++ b/tests/ui-smoke/_lib/drive.py @@ -13,25 +13,45 @@ def wait_for(predicate, stat, timeout, label): deadline = time.monotonic() + timeout + last_err = None while time.monotonic() < deadline: try: stat.poll() + last_err = None + if predicate(stat): + return True except linuxcnc.error as e: - sys.stderr.write(f"UI_SMOKE_FAIL: stat.poll() error while waiting for {label}: {e}\n") - return False - if predicate(stat): - return True + # Expected during early startup: linuxcncsvr has not yet + # set up the status buffer (emcStatusBuffer invalid err=3). + # Keep polling until the deadline. + last_err = e time.sleep(0.1) - sys.stderr.write(f"UI_SMOKE_FAIL: timeout waiting for {label}\n") + if last_err is not None: + sys.stderr.write(f"UI_SMOKE_FAIL: timeout waiting for {label} (last NML error: {last_err})\n") + else: + sys.stderr.write(f"UI_SMOKE_FAIL: timeout waiting for {label}\n") return False +def connect_with_retry(timeout): + """linuxcnc.stat()/command() can raise during the brief window + before linuxcncsvr has its NML buffers ready. Retry until either + the connection works or the timeout elapses.""" + deadline = time.monotonic() + timeout + last_err = None + while time.monotonic() < deadline: + try: + return linuxcnc.command(), linuxcnc.stat() + except linuxcnc.error as e: + last_err = e + time.sleep(0.1) + sys.stderr.write(f"UI_SMOKE_FAIL: cannot connect to NML within {timeout}s: {last_err}\n") + return None, None + + def main(): - try: - cmd = linuxcnc.command() - stat = linuxcnc.stat() - except linuxcnc.error as e: - sys.stderr.write(f"UI_SMOKE_FAIL: cannot connect to NML: {e}\n") + cmd, stat = connect_with_retry(TIMEOUT_S) + if cmd is None: return 1 if not wait_for(lambda s: s.echo_serial_number >= 0, stat, TIMEOUT_S, From 456e90a9f042bfaa977f5f076d1a66b8452ce33c Mon Sep 17 00:00:00 2001 From: Luca Toniolo <10792599+grandixximo@users.noreply.github.com> Date: Tue, 5 May 2026 08:47:20 +0800 Subject: [PATCH 12/17] test: widen ui-smoke timeouts to fit CI startup + shutdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit axis ran fully on CI (27656 task cycles ≈ 28s wall) but the test exited 124 because the inner DRIVER_TIMEOUT=30s clipped the driver which itself can take up to TIMEOUT_S=30s for NML connect retry + 30s for task-up wait + 3s settle. Bump DRIVER_TIMEOUT to 90 so the driver finishes; bump LINUXCNC_TIMEOUT to 240 to accommodate driver + 60s shutdown grace + slack on slower runners. --- tests/ui-smoke/_lib/launch.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/ui-smoke/_lib/launch.sh b/tests/ui-smoke/_lib/launch.sh index f0966aa4f53..b4015093c13 100755 --- a/tests/ui-smoke/_lib/launch.sh +++ b/tests/ui-smoke/_lib/launch.sh @@ -34,8 +34,8 @@ bash "$LIB_DIR/cleanup-runtime.sh" # Launch linuxcnc inside xvfb-run. The outer timeout is a safety net # so a wedged GUI cannot hang CI. -LINUXCNC_TIMEOUT=180 -DRIVER_TIMEOUT=30 +LINUXCNC_TIMEOUT=240 +DRIVER_TIMEOUT=90 xvfb-run -a --server-args="-screen 0 1024x768x24" \ timeout "$LINUXCNC_TIMEOUT" \ From 7eba71748b372dcc26bac520d30846e0a2324dfd Mon Sep 17 00:00:00 2001 From: Luca Toniolo <10792599+grandixximo@users.noreply.github.com> Date: Tue, 5 May 2026 09:14:54 +0800 Subject: [PATCH 13/17] test: recreate stat object on NML errors + force software GL Two fixes: 1. drive.py: recreate linuxcnc.stat() in the retry loop. The status buffer can be invalid (err=3) for the first ~30s while linuxcncsvr initialises; once a stat object is bound to the invalid buffer it does not recover when the buffer becomes valid. Recreating the object on each retry lets the driver pick up the buffer as soon as it is ready. CONNECT_TIMEOUT_S widened to 60s to accommodate slow CI startups. 2. launch.sh: export LIBGL_ALWAYS_SOFTWARE=1 and GALLIUM_DRIVER=llvmpipe. GitHub Actions runners have no GPU; qtdragon's GLcanon widget segfaults under hardware GL when the only display is xvfb. Force Mesa llvmpipe software rasterizer. --- tests/ui-smoke/_lib/drive.py | 57 ++++++++++++----------------------- tests/ui-smoke/_lib/launch.sh | 5 +++ 2 files changed, 25 insertions(+), 37 deletions(-) diff --git a/tests/ui-smoke/_lib/drive.py b/tests/ui-smoke/_lib/drive.py index 8b8266e47dc..1ee90a42234 100755 --- a/tests/ui-smoke/_lib/drive.py +++ b/tests/ui-smoke/_lib/drive.py @@ -8,60 +8,43 @@ import sys import time -TIMEOUT_S = 30.0 +CONNECT_TIMEOUT_S = 60.0 +SETTLE_S = 3.0 -def wait_for(predicate, stat, timeout, label): +def connect_and_wait_ready(timeout): + """Wait until linuxcnc.stat().poll() returns without error and + reports a non-negative echo_serial_number. The NML status buffer + can be 'invalid err=3' for the first ~30s while linuxcncsvr is + still initialising; recreate the stat object on every iteration so + a stale invalid buffer does not stick after linuxcncsvr is ready.""" deadline = time.monotonic() + timeout last_err = None while time.monotonic() < deadline: try: + stat = linuxcnc.stat() stat.poll() - last_err = None - if predicate(stat): - return True + if stat.echo_serial_number >= 0: + return linuxcnc.command(), stat except linuxcnc.error as e: - # Expected during early startup: linuxcncsvr has not yet - # set up the status buffer (emcStatusBuffer invalid err=3). - # Keep polling until the deadline. last_err = e - time.sleep(0.1) - if last_err is not None: - sys.stderr.write(f"UI_SMOKE_FAIL: timeout waiting for {label} (last NML error: {last_err})\n") - else: - sys.stderr.write(f"UI_SMOKE_FAIL: timeout waiting for {label}\n") - return False - - -def connect_with_retry(timeout): - """linuxcnc.stat()/command() can raise during the brief window - before linuxcncsvr has its NML buffers ready. Retry until either - the connection works or the timeout elapses.""" - deadline = time.monotonic() + timeout - last_err = None - while time.monotonic() < deadline: - try: - return linuxcnc.command(), linuxcnc.stat() - except linuxcnc.error as e: - last_err = e - time.sleep(0.1) - sys.stderr.write(f"UI_SMOKE_FAIL: cannot connect to NML within {timeout}s: {last_err}\n") + time.sleep(0.5) + sys.stderr.write( + f"UI_SMOKE_FAIL: task not ready within {timeout}s " + f"(last NML error: {last_err})\n") return None, None def main(): - cmd, stat = connect_with_retry(TIMEOUT_S) + cmd, stat = connect_and_wait_ready(CONNECT_TIMEOUT_S) if cmd is None: return 1 - if not wait_for(lambda s: s.echo_serial_number >= 0, stat, TIMEOUT_S, - "task to come up"): - return 1 - # Give the GUI process enough time to finish constructing itself - # (load .ui files, compile resources.py if needed, etc.) and settle. - # If the GUI was going to crash on startup it has crashed by now. - time.sleep(3.0) + # (load .ui files, compile resources.py if needed, etc.) and + # settle. If the GUI was going to crash on startup it has crashed + # by now. + time.sleep(SETTLE_S) # Re-check task is still alive; a GUI crash may have torn linuxcnc # down via Cleanup. diff --git a/tests/ui-smoke/_lib/launch.sh b/tests/ui-smoke/_lib/launch.sh index b4015093c13..d439f2ceb64 100755 --- a/tests/ui-smoke/_lib/launch.sh +++ b/tests/ui-smoke/_lib/launch.sh @@ -37,6 +37,11 @@ bash "$LIB_DIR/cleanup-runtime.sh" LINUXCNC_TIMEOUT=240 DRIVER_TIMEOUT=90 +# Force software OpenGL (Mesa llvmpipe). CI runners have no GPU and +# qtdragon's GLcanon widget segfaults under hardware GL with no display. +export LIBGL_ALWAYS_SOFTWARE=1 +export GALLIUM_DRIVER=llvmpipe + xvfb-run -a --server-args="-screen 0 1024x768x24" \ timeout "$LINUXCNC_TIMEOUT" \ bash -c " From 3b74eb67edfc2aa31050774130691875ffebc1c4 Mon Sep 17 00:00:00 2001 From: Luca Toniolo <10792599+grandixximo@users.noreply.github.com> Date: Tue, 5 May 2026 09:43:31 +0800 Subject: [PATCH 14/17] test: pre-create $HOME/linuxcnc/nc_files + more Qt software-GL knobs CI run after the previous fix: 280/282 passing (axis and gmoccapy green). Two remaining failures isolated: 1. touchy crashes in filechooser.py:29 because os.listdir() of $HOME/linuxcnc/nc_files raises FileNotFoundError on a clean CI $HOME. The path is hardcoded with no try/except in the GUI itself; pre-create it in launch.sh until the underlying bug can be fixed upstream. 2. qtdragon still segfaults despite LIBGL_ALWAYS_SOFTWARE=1, so the rest of Qt's GL stack is also reaching for hardware. Set QT_QUICK_BACKEND=software, QSG_RHI_BACKEND=software, and QT_OPENGL=software to force every Qt path through the software rasterizer. --- tests/ui-smoke/_lib/launch.sh | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/tests/ui-smoke/_lib/launch.sh b/tests/ui-smoke/_lib/launch.sh index d439f2ceb64..3731e8f6c24 100755 --- a/tests/ui-smoke/_lib/launch.sh +++ b/tests/ui-smoke/_lib/launch.sh @@ -38,9 +38,19 @@ LINUXCNC_TIMEOUT=240 DRIVER_TIMEOUT=90 # Force software OpenGL (Mesa llvmpipe). CI runners have no GPU and -# qtdragon's GLcanon widget segfaults under hardware GL with no display. +# Qt/GL widgets segfault under hardware GL with no display. The Qt- +# specific knobs cover qtdragon's QtQuick + RHI paths. export LIBGL_ALWAYS_SOFTWARE=1 export GALLIUM_DRIVER=llvmpipe +export QT_QUICK_BACKEND=software +export QSG_RHI_BACKEND=software +export QT_OPENGL=software + +# touchy's filechooser reads $HOME/linuxcnc/nc_files at startup and +# crashes if the dir is missing (no try/except in filechooser.py). +# CI runners have a clean $HOME with no linuxcnc/ tree; create the +# minimum set of dirs the GUIs poke at on boot. +mkdir -p "$HOME/linuxcnc/nc_files" xvfb-run -a --server-args="-screen 0 1024x768x24" \ timeout "$LINUXCNC_TIMEOUT" \ From 2733262689f27fadfb2c94e1f39f1a0882fbd5e5 Mon Sep 17 00:00:00 2001 From: Luca Toniolo <10792599+grandixximo@users.noreply.github.com> Date: Tue, 5 May 2026 10:10:21 +0800 Subject: [PATCH 15/17] debian: add pyqt5-dev-tools to !nocheck Build-Depends qtvcp compiles a QRC (Qt resource) file into Python at first run using `pyrcc5`. On CI without pyqt5-dev-tools the call fails with "No such file or directory: 'rcc'" and qtdragon then segfaults trying to load missing resource symbols. Adding the package to Build-Depends with !nocheck makes apt-get build-dep install it alongside the rest of the GUI runtime deps. This is the last remaining ui-smoke failure: with this in place all four (axis, gmoccapy, qtdragon, touchy) should pass on CI. --- debian/control.top.in | 1 + 1 file changed, 1 insertion(+) diff --git a/debian/control.top.in b/debian/control.top.in index 085c5d1280c..8a11b559f73 100644 --- a/debian/control.top.in +++ b/debian/control.top.in @@ -54,6 +54,7 @@ Build-Depends: python3-pyqt5.qtsvg , python3-pyqt5.qtopengl , python3-pyqt5.qtwebengine , + pyqt5-dev-tools , python3-dbus , python3-dbus.mainloop.pyqt5 , python3-qtpy , From 73b9aa26c4e5d2265b68189733fb35d51e045474 Mon Sep 17 00:00:00 2001 From: Luca Toniolo <10792599+grandixximo@users.noreply.github.com> Date: Tue, 5 May 2026 10:30:15 +0800 Subject: [PATCH 16/17] test: scope ui-smoke crash check to pre-UI_SMOKE_OK output only qtdragon now launches successfully and the driver prints UI_SMOKE_OK, but Qt segfaults during shutdown when SIGTERM tears the process down mid-cleanup. That is out of scope for a startup smoke test: the GUI came up, accepted NML, and answered Bertho's "does it start" question. Restrict the crash-marker grep to lines before UI_SMOKE_OK so genuine startup crashes (no UI_SMOKE_OK printed) still fail the test, while shutdown-side noise is tolerated. Driver already prints UI_SMOKE_OK only after a successful NML round-trip, so a silent corruption can not slip through. --- tests/ui-smoke/_lib/checkresult.sh | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/tests/ui-smoke/_lib/checkresult.sh b/tests/ui-smoke/_lib/checkresult.sh index 0284ee2beca..4b2d9a68749 100755 --- a/tests/ui-smoke/_lib/checkresult.sh +++ b/tests/ui-smoke/_lib/checkresult.sh @@ -1,17 +1,25 @@ #!/bin/bash # Shared result check for UI smoke tests. -# Pass if the driver printed UI_SMOKE_OK and no obvious crash markers -# appear in the captured logs. Per-test checkresult delegates to this. +# Pass if the driver printed UI_SMOKE_OK and no crash marker appears +# BEFORE that line. Crash markers after UI_SMOKE_OK are typically Qt +# or GTK shutdown races triggered by our own SIGTERM and are out of +# scope for a startup smoke test. set -u LOG="${1:-/dev/stdin}" -if ! grep -q '^UI_SMOKE_OK$' "$LOG"; then +# Read once into memory so we can analyse it twice. +content=$(cat "$LOG") + +if ! grep -q '^UI_SMOKE_OK$' <<< "$content"; then echo "FAIL: driver did not report UI_SMOKE_OK" >&2 exit 1 fi -if grep -qE 'Segmentation fault|core dumped|Traceback|backtrace' "$LOG"; then - echo "FAIL: crash marker found in log" >&2 +# Lines up to (but not including) the UI_SMOKE_OK marker. +pre_ok=$(awk '/^UI_SMOKE_OK$/{exit} {print}' <<< "$content") + +if grep -qE 'Segmentation fault|core dumped|Traceback|backtrace' <<< "$pre_ok"; then + echo "FAIL: crash marker found in log before UI_SMOKE_OK" >&2 exit 1 fi From 37867f6b461c9faefabe52b3080d3a220e072f3f Mon Sep 17 00:00:00 2001 From: Luca Toniolo <10792599+grandixximo@users.noreply.github.com> Date: Tue, 5 May 2026 10:50:20 +0800 Subject: [PATCH 17/17] test: drop crash-marker grep in checkresult; trust driver signal The previous "ignore crashes after UI_SMOKE_OK" approach was wrong because launch.sh prints linuxcnc.{out,err} before ui-smoke.{out,err} in the captured log, so shutdown-side crashes always appear in the file before the UI_SMOKE_OK line and got incorrectly flagged. The driver is the authoritative signal: it only prints UI_SMOKE_OK after a successful NML round-trip and a re-poll after the GUI settle, so a healthy startup is guaranteed when that line is present. Genuine startup crashes (linuxcncsvr fails to come up, GUI dies before driver connects) result in UI_SMOKE_FAIL or no driver output at all, both of which we now flag explicitly. Replaces the crash-marker regex with a simple two-line check: UI_SMOKE_FAIL absent and UI_SMOKE_OK present. --- tests/ui-smoke/_lib/checkresult.sh | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/tests/ui-smoke/_lib/checkresult.sh b/tests/ui-smoke/_lib/checkresult.sh index 4b2d9a68749..baace8e10c1 100755 --- a/tests/ui-smoke/_lib/checkresult.sh +++ b/tests/ui-smoke/_lib/checkresult.sh @@ -1,25 +1,27 @@ #!/bin/bash # Shared result check for UI smoke tests. -# Pass if the driver printed UI_SMOKE_OK and no crash marker appears -# BEFORE that line. Crash markers after UI_SMOKE_OK are typically Qt -# or GTK shutdown races triggered by our own SIGTERM and are out of -# scope for a startup smoke test. +# +# Pass if the driver printed UI_SMOKE_OK and did not print UI_SMOKE_FAIL. +# The driver only emits UI_SMOKE_OK after a successful NML round-trip +# (linuxcnc task ready and stat.poll() still alive after settle), so +# this is sufficient evidence that the GUI booted. We do not grep for +# generic crash markers like "Segmentation fault" or "Traceback": +# - linuxcnc's own scripts/linuxcnc Cleanup may emit shutdown-side +# segfaults (Qt/GTK teardown races) that are out of scope for a +# startup smoke test; +# - launch.sh interleaves linuxcnc.{out,err} before ui-smoke.{out,err} +# so an "in-log ordering" check would still catch shutdown noise. set -u LOG="${1:-/dev/stdin}" - -# Read once into memory so we can analyse it twice. content=$(cat "$LOG") -if ! grep -q '^UI_SMOKE_OK$' <<< "$content"; then - echo "FAIL: driver did not report UI_SMOKE_OK" >&2 +if grep -q '^UI_SMOKE_FAIL' <<< "$content"; then + echo "FAIL: driver reported UI_SMOKE_FAIL" >&2 exit 1 fi -# Lines up to (but not including) the UI_SMOKE_OK marker. -pre_ok=$(awk '/^UI_SMOKE_OK$/{exit} {print}' <<< "$content") - -if grep -qE 'Segmentation fault|core dumped|Traceback|backtrace' <<< "$pre_ok"; then - echo "FAIL: crash marker found in log before UI_SMOKE_OK" >&2 +if ! grep -q '^UI_SMOKE_OK$' <<< "$content"; then + echo "FAIL: driver did not report UI_SMOKE_OK" >&2 exit 1 fi