Import LinuxCNC source tree

This commit is contained in:
Wang Dequan
2026-08-20 06:17:39 +08:00
commit 2c814634c3
9429 changed files with 10556013 additions and 0 deletions

View File

@@ -0,0 +1,10 @@
# Loaded via ALSA_CONFIG_PATH by ui-smoke launch.sh. Redirect the
# default PCM/CTL to alsa-lib's null devices to silence the tests.
<confdir:alsa.conf>
pcm.!default {
type null
}
ctl.!default {
type null
}

View File

@@ -0,0 +1,25 @@
#!/bin/bash
# Shared result check for UI smoke quit-path tests.
#
# Pass if the quit launcher printed UI_SMOKE_QUIT_OK (the GUI exited on
# its own SIGTERM within the grace) and did not print UI_SMOKE_QUIT_FAIL.
set -u
if [ $# -lt 1 ]; then
echo "FAIL: checkresult-quit requires the result-log path as argument" >&2
exit 1
fi
LOG="$1"
if grep -q '^UI_SMOKE_QUIT_FAIL' "$LOG"; then
echo "FAIL: $(grep -m1 '^UI_SMOKE_QUIT_FAIL' "$LOG")" >&2
exit 1
fi
if ! grep -q '^UI_SMOKE_QUIT_OK' "$LOG"; then
echo "FAIL: GUI did not report a clean SIGTERM exit (no UI_SMOKE_QUIT_OK)" >&2
exit 1
fi
exit 0

View File

@@ -0,0 +1,32 @@
#!/bin/bash
# Shared result check for UI smoke tests.
#
# 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, and the driver-signal approach catches the
# startup-time failures we actually care about.
set -u
if [ $# -lt 1 ]; then
echo "FAIL: checkresult requires the result-log path as argument" >&2
exit 1
fi
LOG="$1"
if grep -q '^UI_SMOKE_FAIL' "$LOG"; then
echo "FAIL: driver reported UI_SMOKE_FAIL" >&2
exit 1
fi
if ! grep -q '^UI_SMOKE_OK$' "$LOG"; then
echo "FAIL: driver did not report UI_SMOKE_OK" >&2
exit 1
fi
exit 0

View File

@@ -0,0 +1,30 @@
#!/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.
#
# SHM_KEYS mirrors SHMEM_KEY in scripts/runtests:157. If a ui-smoke
# crash leaks any of these, the next runtests invocation aborts in
# test_shmem(); we must clean the full set.
set -u
DAEMONS=(linuxcncsvr milltask halui rtapi_app)
SHM_KEYS=(0x00000064 0x48414c32 0x48484c34 0x90280a48 0x130cf406 0x434c522b)
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=$(LC_ALL=C ipcs -m | awk -v k="$key" 'tolower($1)==k {print $2}')
if [ -n "$shmid" ]; then
ipcrm -m "$shmid" 2>/dev/null || true
fi
done
exit 0

View File

@@ -0,0 +1,45 @@
#!/bin/bash
# Native crash capture for the UI smoke launchers. A GUI segfault is the
# failure these tests most need to explain, and it lands in C/C++ (Qt,
# dbus, GL) where PYTHONFAULTHANDLER stops at the event-loop frame. Arm a
# core dump before launch; after the run, if the GUI left a core, print a
# native backtrace into the log so CI shows the faulting frame directly.
# Source with LIB_DIR set; runs only on the failure path, so green runs
# pay nothing.
crashdump_arm() {
CORE_DIR="$(mktemp -d -t ui-smoke-cores.XXXXXX)"
export CORE_DIR
ulimit -c unlimited 2>/dev/null || true
# core_pattern is global; only set it if already root. Never sudo:
# the suite must run unattended. Non-root falls back to a cwd "core".
if [ "$(id -u)" = 0 ]; then
sysctl -w "kernel.core_pattern=$CORE_DIR/core.%e.%p" >/dev/null 2>&1 || true
fi
}
crashdump_report() {
[ -n "${CORE_DIR:-}" ] || return 0
local core
# shellcheck disable=SC2012 # mktemp dir, no odd filenames
core=$(ls -t "$CORE_DIR"/core* ./core* /tmp/core* 2>/dev/null | head -1)
if [ -n "$core" ]; then
echo "=== crash: native backtrace ($core) ==="
# gdb reads the core; pull it in if missing, only when root.
if ! command -v gdb >/dev/null 2>&1 && [ "$(id -u)" = 0 ]; then
apt-get install -y -q gdb >/dev/null 2>&1 || true
fi
if command -v gdb >/dev/null 2>&1; then
# "bt" first: gdb auto-selects the faulting thread on a SIGSEGV
# core. "thread apply all bt" after gives the rest.
gdb -batch -nx \
-ex "bt" \
-ex "echo \n=== all threads ===\n" \
-ex "thread apply all bt" \
"$(command -v python3)" "$core" 2>&1 | head -400
else
echo "(gdb unavailable; core left at $core)"
fi
fi
rm -rf "$CORE_DIR"
}

408
tests/ui-smoke/_lib/drive.py Executable file
View File

@@ -0,0 +1,408 @@
#!/usr/bin/env python3
# UI smoke driver.
#
# Default mode (Phase 1): confirm linuxcnc task came up and the GUI did
# not crash. The driver only proves the GUI started and NML is reachable.
#
# --run-program mode (Phase 2): also estop-reset, machine-on, home,
# program_open + auto(RUN), wait for sustained INTERP_IDLE, and assert
# (stat.position_after - stat.position_after_home) equals --expect-delta-mm
# converted to machine units via stat.linear_units. Snapshot-and-delta
# sidesteps per-sim HOME offsets; mm-input + linear_units conversion
# sidesteps per-sim LINEAR_UNITS (axis and touchy sims are inch).
import argparse
import linuxcnc
import os
import sys
import time
CONNECT_TIMEOUT_S = 60.0
SETTLE_S = 3.0
SETTLE_POLLS = 5
POLL_INTERVAL_S = 0.01
# Per-attempt wait timeout for ensure_state / ensure_mode. The state
# normally lands well under 1s; profiling showed nothing benefits from
# more than 3s here, and shorter timeouts trim wall time when a retry
# is needed (notably gmoccapy reverting task_mode AUTO -> MANUAL).
ENSURE_ATTEMPT_TIMEOUT_S = 3.0
# After the desired task_state / task_mode is reached, re-check after
# this long. Some GUIs (notably gmoccapy and qtdragon) run their own
# startup commands that can revert a state we just set; the post-reach
# stability check catches that.
STATE_STABILITY_S = 0.5
STATE_RETRY_BUDGET = 6
# linuxcnc launcher PID, written to linuxcnc.pid by the launcher and read
# once at startup. The driver watches it so a GUI crash, which tears
# linuxcnc down, fails the test in ~1s with a clear message instead of
# waiting out a long NML poll. A dead task keeps serving its last stat
# buffer, so process liveness is the only reliable crash signal.
_WATCH_PID = None
class LauncherGone(Exception):
"""linuxcnc process group exited (GUI crashed or task died)."""
def _read_pid(path):
try:
with open(path) as f:
return int(f.read().strip())
except (OSError, ValueError):
return None
# Crash markers faulthandler and scripts/linuxcnc write to linuxcnc.err
# the instant the GUI dies. The launcher PID can linger in Cleanup, so
# scanning these catches the crash sooner and regardless of which GUI.
_CRASH_MARKERS = ("Fatal Python error", "Segmentation fault", "Aborted")
def _crash_marker_seen():
try:
with open("linuxcnc.err") as f:
return any(m in f.read() for m in _CRASH_MARKERS)
except OSError:
return False
def _watchdog():
"""Raise LauncherGone if the GUI has crashed: either the launcher PID
is gone, or a crash marker appeared in linuxcnc.err. Unknown PID and
a missing log count as alive, so a not-yet-written file never
false-fails the test."""
if _WATCH_PID is not None:
try:
os.kill(_WATCH_PID, 0)
except ProcessLookupError:
raise LauncherGone()
except PermissionError:
pass
if _crash_marker_seen():
raise LauncherGone()
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.
Catch the full Exception hierarchy: in early startup stat.poll()
can raise SystemError ('error return without exception set') when
the underlying C function reports failure without setting a Python
exception. Treat that the same as linuxcnc.error and retry."""
deadline = time.monotonic() + timeout
last_err = None
while time.monotonic() < deadline:
_watchdog()
try:
stat = linuxcnc.stat()
stat.poll()
if stat.echo_serial_number >= 0:
return linuxcnc.command(), stat
except Exception as e:
last_err = e
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 wait_until_quiet(stat, predicate, timeout):
"""Poll stat until predicate(stat) is true. Returns True on success,
False on timeout. Never writes UI_SMOKE_FAIL: caller decides whether
a timeout here is fatal (and writes its own UI_SMOKE_FAIL line) or
is part of a retry that may still succeed. checkresult.sh greps for
any '^UI_SMOKE_FAIL' line, so spurious emissions during retries
must not happen."""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
_watchdog()
stat.poll()
if predicate(stat):
return True
time.sleep(POLL_INTERVAL_S)
return False
def wait_until(stat, predicate, timeout, label):
"""Like wait_until_quiet but emits UI_SMOKE_FAIL on timeout. Use
only when timeout is fatal at the call site (no retry above)."""
if wait_until_quiet(stat, predicate, timeout):
return True
sys.stderr.write(f"UI_SMOKE_FAIL: timeout waiting for {label} after {timeout}s\n")
return False
def home_all(cmd, stat, timeout):
"""Home every joint. Uses c.home(-1) which respects HOME_SEQUENCE
if configured. Caller must have already ensured task_state is ON
via ensure_state; otherwise the home command is rejected with
'cannot be executed until the machine is out of E-stop and turned
on'. Mode change uses ensure_mode so a GUI that reverts mode mid-
sequence (gmoccapy) is detected and retried."""
if not ensure_mode(cmd, stat, linuxcnc.MODE_MANUAL, "MODE_MANUAL"):
return False
cmd.teleop_enable(0)
cmd.wait_complete()
stat.poll()
njoints = stat.joints
cmd.home(-1)
if not wait_until(
stat,
lambda s: all(s.homed[i] for i in range(njoints)),
timeout, "all joints homed"):
return False
cmd.teleop_enable(1)
cmd.wait_complete()
return True
def wait_state(stat, target_state, timeout, label):
"""Poll until stat.task_state == target_state. wait_complete on a
state-change command only proves task ack'd the NML message, not
that the underlying state machine has transitioned. Polling
task_state is the only deterministic signal."""
return wait_until(
stat,
lambda s: s.task_state == target_state,
timeout, label)
def ensure_state(cmd, stat, target_state, label):
"""Issue c.state(target_state), wait for stat.task_state to reach
target_state, then verify it stays there across STATE_STABILITY_S.
If the GUI reverts (e.g. gmoccapy re-issues its own ESTOP on
startup), retry up to STATE_RETRY_BUDGET times. Returns True on
stable success, False on exhausted budget."""
for attempt in range(1, STATE_RETRY_BUDGET + 1):
cmd.state(target_state)
cmd.wait_complete()
if not wait_until_quiet(
stat, lambda s: s.task_state == target_state,
ENSURE_ATTEMPT_TIMEOUT_S):
sys.stderr.write(
f"WARN: {label} not reached on attempt {attempt}, retrying\n")
continue
time.sleep(STATE_STABILITY_S)
stat.poll()
if stat.task_state == target_state:
return True
sys.stderr.write(
f"WARN: {label} reverted to task_state={stat.task_state} "
f"after attempt {attempt}, retrying\n")
sys.stderr.write(
f"UI_SMOKE_FAIL: {label} did not hold stable across "
f"{STATE_RETRY_BUDGET} attempts\n")
return False
def ensure_mode(cmd, stat, target_mode, label):
"""Same retry+stability pattern as ensure_state, for task_mode."""
for attempt in range(1, STATE_RETRY_BUDGET + 1):
cmd.mode(target_mode)
cmd.wait_complete()
if not wait_until_quiet(
stat, lambda s: s.task_mode == target_mode,
ENSURE_ATTEMPT_TIMEOUT_S):
sys.stderr.write(
f"WARN: {label} not reached on attempt {attempt}, retrying\n")
continue
time.sleep(STATE_STABILITY_S)
stat.poll()
if stat.task_mode == target_mode:
return True
sys.stderr.write(
f"WARN: {label} reverted to task_mode={stat.task_mode} "
f"after attempt {attempt}, retrying\n")
sys.stderr.write(
f"UI_SMOKE_FAIL: {label} did not hold stable across "
f"{STATE_RETRY_BUDGET} attempts\n")
return False
PROGRAM_START_TIMEOUT_S = 5.0
def snapshot(stat):
"""Best-effort one-line summary of state fields relevant to Phase 2
debugging. Caller is expected to have just polled."""
return (
f"task_state={stat.task_state} task_mode={stat.task_mode} "
f"interp_state={stat.interp_state} exec_state={stat.exec_state} "
f"motion_type={stat.motion_type} queue={stat.queue} "
f"queued_mdi_commands={stat.queued_mdi_commands} "
f"file={stat.file!r}")
def wait_program_started(stat, timeout):
"""Wait until interp_state leaves INTERP_IDLE, i.e. the program
has actually begun executing. Without this guard, a short program
can finish before wait_program_idle gets its first poll, and the
settle-window then mistakes the pre-start IDLE for the post-end
IDLE; we then read stat.position at (0,0,0)."""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
_watchdog()
stat.poll()
if stat.interp_state != linuxcnc.INTERP_IDLE:
return True
time.sleep(POLL_INTERVAL_S)
stat.poll()
sys.stderr.write(
f"UI_SMOKE_FAIL: program did not start within {timeout}s "
f"(interp_state stayed INTERP_IDLE) state: {snapshot(stat)}\n")
return False
def wait_program_idle(stat, timeout):
"""Wait until interp_state returns to INTERP_IDLE and the motion
queue is drained for SETTLE_POLLS consecutive polls. Caller must
have already proven the program started via wait_program_started;
otherwise this returns immediately on the pre-start IDLE."""
deadline = time.monotonic() + timeout
consecutive = 0
while time.monotonic() < deadline:
_watchdog()
stat.poll()
idle = (
stat.interp_state == linuxcnc.INTERP_IDLE
and stat.queue == 0
)
if idle:
consecutive += 1
if consecutive >= SETTLE_POLLS:
return True
else:
consecutive = 0
time.sleep(POLL_INTERVAL_S)
sys.stderr.write(f"UI_SMOKE_FAIL: program did not reach idle within {timeout}s\n")
return False
def run_program(cmd, stat, ngc_path, expect_delta_mm, tol, run_timeout):
"""Estop reset, machine on, home, snapshot position, load + run ngc,
verify (final - start) delta matches expect_delta_mm converted to
machine units."""
if not ensure_state(cmd, stat, linuxcnc.STATE_ESTOP_RESET,
"STATE_ESTOP_RESET"):
return False
if not ensure_state(cmd, stat, linuxcnc.STATE_ON, "STATE_ON"):
return False
if not home_all(cmd, stat, timeout=60.0):
return False
if not ensure_mode(cmd, stat, linuxcnc.MODE_AUTO, "MODE_AUTO"):
return False
# Snapshot start position AFTER homing + AFTER mode transition. The
# GUI might re-issue mode commands during its own startup; doing the
# snapshot last means we record the position right before AUTO_RUN.
stat.poll()
start_pos = stat.position[:3]
cmd.program_open(ngc_path)
cmd.wait_complete()
# No wait_complete after auto(AUTO_RUN, 0): wait_complete blocks
# until the operation finishes, which for AUTO_RUN means the whole
# program completes. That would race wait_program_started; by the
# time we polled, interp would already be back at INTERP_IDLE.
cmd.auto(linuxcnc.AUTO_RUN, 0)
if not wait_program_started(stat, PROGRAM_START_TIMEOUT_S):
return False
if not wait_program_idle(stat, run_timeout):
return False
# stat.linear_units: machine units per mm. mm machine -> 1.0;
# inch machine -> 1/25.4 = 0.03937. Multiplying the expected mm
# delta by linear_units gives the expected delta in machine units,
# which is what stat.position reports.
units_per_mm = stat.linear_units
expect_machine = [d * units_per_mm for d in expect_delta_mm]
final_pos = stat.position[:3]
actual_delta = [final_pos[i] - start_pos[i] for i in range(3)]
err = [abs(actual_delta[i] - expect_machine[i]) for i in range(3)]
if any(e > tol for e in err):
sys.stderr.write(
f"UI_SMOKE_FAIL: delta mismatch "
f"expect_mm={expect_delta_mm} units_per_mm={units_per_mm} "
f"expect_machine={expect_machine} "
f"start={start_pos} final={final_pos} "
f"actual_delta={actual_delta} err={err} tol={tol}\n")
return False
return True
def parse_xyz(s):
parts = [float(p) for p in s.split(",")]
if len(parts) != 3:
raise argparse.ArgumentTypeError("expected x,y,z (three comma-separated floats)")
return parts
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--run-program", metavar="NGC",
help="g-code file to load and run (enables Phase 2 mode)")
ap.add_argument("--expect-delta-mm", type=parse_xyz, metavar="DX,DY,DZ",
help="expected XYZ delta in mm from post-home position "
"(required with --run-program). Driver converts to "
"machine units via stat.linear_units so the same "
"value works on inch and mm sims.")
ap.add_argument("--tol", type=float, default=1e-4,
help="position tolerance per axis in machine units "
"(default: 1e-4)")
ap.add_argument("--run-timeout", type=float, default=60.0,
help="program-completion timeout in seconds (default: 60)")
args = ap.parse_args()
if args.run_program and args.expect_delta_mm is None:
ap.error("--run-program requires --expect-delta-mm DX,DY,DZ")
global _WATCH_PID
_WATCH_PID = _read_pid("linuxcnc.pid")
try:
cmd, stat = connect_and_wait_ready(CONNECT_TIMEOUT_S)
if cmd is None:
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(SETTLE_S)
_watchdog()
# 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
if args.run_program:
if not run_program(cmd, stat,
args.run_program, args.expect_delta_mm,
args.tol, args.run_timeout):
return 1
except LauncherGone:
sys.stderr.write(
"UI_SMOKE_FAIL: linuxcnc exited before the driver finished; "
"the GUI crashed or task died. See linuxcnc.out / linuxcnc.err "
"above for the backtrace.\n")
return 1
print("UI_SMOKE_OK")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,31 @@
#!/bin/bash
# Shared headless environment for the UI smoke launchers. Sourced by
# launch.sh and quit-launch.sh so the two stay in lockstep; a knob added
# here reaches both. The caller must set LIB_DIR before sourcing (it
# locates asound.conf). This file only exports; it runs no commands.
# Force software OpenGL (Mesa llvmpipe). CI runners have no GPU and
# 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
# Dodge a long-known xcb_glx integration crash that hits QtWebEngine
# and related Qt5 widgets under xvfb (Launchpad #1761708, QTBUG-67537).
# Forces the egl path which is what software-GL stacks expect anyway.
export QT_XCB_GL_INTEGRATION=xcb_egl
# Silence audio: xvfb covers X but not sound. Demote every Gst
# Audio/Sink and disable canberra/SDL/pulse/ALSA-default paths.
export ALSA_CONFIG_PATH="$LIB_DIR/asound.conf"
export CANBERRA_DRIVER=null
export GST_PLUGIN_FEATURE_RANK="pulsesink:NONE,alsasink:NONE,osssink:NONE,oss4sink:NONE,jackaudiosink:NONE,pipewiresink:NONE,openalsink:NONE"
export PULSE_SERVER=/dev/null
export SDL_AUDIODRIVER=dummy
# Dump a Python traceback on a fatal signal. For a pure-Python crash this
# names the line; for a C/C++ crash (Qt, dbus, GL) it shows the Python
# frame that called in. The native side is captured by crashdump.sh.
export PYTHONFAULTHANDLER=1

112
tests/ui-smoke/_lib/launch.sh Executable file
View File

@@ -0,0 +1,112 @@
#!/bin/bash
# Shared launcher for UI smoke tests.
# Usage: launch.sh <sim-config-ini>
#
# Spawns linuxcnc -r <ini> under xvfb-run, then runs the common driver
# script against it via NML. Captures stdout/stderr to per-test files.
#
# Skip vs fail (BsAtHome / hdiethelm review, PR #3999): xvfb-run absence
# is handled by the per-test skip files (skip-if-missing.sh), which
# runtests gates on before invoking test.sh. 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. Per-GUI deps are
# declared in debian/control under !nocheck, so apt-get build-dep
# installs them on CI.
set -u
CONFIG_INI="$1"
shift
DRIVER_ARGS=("$@")
TEST_DIR="${TEST_DIR:-$(cd "$(dirname "$0")" && pwd)}"
LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$TEST_DIR" || exit 1
rm -f ui-smoke.out ui-smoke.err linuxcnc.pid
# 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 outer timeout is a safety net
# so a wedged GUI cannot hang CI. Driver timeout covers connect (60s)
# + GUI settle (3s) + optional Phase 2 run (estop/home/program ~90s).
LINUXCNC_TIMEOUT=300
DRIVER_TIMEOUT=180
# Shared headless environment (software GL + audio silencing), kept in
# launch-env.sh so launch.sh and quit-launch.sh cannot drift apart.
. "$LIB_DIR/launch-env.sh"
# Arm a core dump so a GUI segfault can be backtraced after the run.
. "$LIB_DIR/crashdump.sh"
crashdump_arm
# Export the per-invocation values so the inner bash -c receives them
# as proper env vars (avoids embedding paths into the inner script
# via quoting, which breaks on apostrophes / spaces).
export CONFIG_INI LIB_DIR DRIVER_TIMEOUT
# Single quotes around the inner script are intentional: CONFIG_INI,
# LIB_DIR and DRIVER_TIMEOUT are expanded by the inner bash (which sees
# them via the exported env), not by the outer shell.
# shellcheck disable=SC2016
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 makes the child a session leader, so its PID equals
# the PGID and we can group-signal via "kill -- -$PID".
setsid linuxcnc -r "$CONFIG_INI" >linuxcnc.out 2>linuxcnc.err &
LINUXCNC_PID=$!
echo "$LINUXCNC_PID" >linuxcnc.pid
# The driver polls NML readiness itself (BsAtHome review:
# avoid real-clock waits where status polling will do).
# Driver args (Phase 2: --run-program/--expect-pos) come through
# as positional $@ from the inner bash -c.
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 end
# its own SIGTERM trap run Cleanup which unloads halrun and
# reaps shared memory). axis-remote works only for axis but is
# harmless otherwise. Then group-SIGTERM 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
kill -TERM -- -"$LINUXCNC_PID" 2>/dev/null || true
for _ in $(seq 60); do
kill -0 "$LINUXCNC_PID" 2>/dev/null || break
sleep 1
done
if kill -0 "$LINUXCNC_PID" 2>/dev/null; then
echo "WARN: linuxcnc did not exit on SIGTERM, escalating to KILL" >&2
kill -KILL -- -"$LINUXCNC_PID" 2>/dev/null || true
sleep 2
bash "$LIB_DIR/cleanup-runtime.sh"
fi
exit "$DRIVE_RC"
' _launch "${DRIVER_ARGS[@]}"
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
# If the GUI dumped a core, print its native backtrace.
crashdump_report
exit "$RC"

View File

@@ -0,0 +1,79 @@
#!/bin/bash
# Sourced by the qtdragon ui-smoke tests (smoke and quit) to build a
# config qtvcp can actually run under CI. Sets QTDRAGON_INI to the
# patched ini path and exports the headless env; the caller then execs
# run-gui.sh or quit-launch.sh with "$QTDRAGON_INI". Must be sourced
# with LIB_DIR already set.
#
# qtdragon's qtvcp logger writes its log file (path from INI [DISPLAY]
# LOG_FILE) into the config directory. CI mounts the workspace read-
# only for the runtime user, so a relative LOG_FILE like 'qtdragon.log'
# resolves to a path qtvcp cannot create, hal_bridge then exits, and
# linuxcnc tears down before our driver can do anything. Mirror the
# config dir to a writable tmp location and patch LOG_FILE to be
# rooted at $HOME so the log lands in a directory we can write to.
#
# Force the Qt offscreen platform plugin. qtvcp under xvfb + xcb on
# Ubuntu 24.04 segfaults during widget construction (no backtrace);
# Debian containers in the same CI matrix do not. Offscreen renders
# entirely in memory, no X server needed (xvfb-run still wraps the
# call so the rest of scripts/linuxcnc's X-display assumptions hold).
# scripts/linuxcnc itself forces QT_QPA_PLATFORM=xcb unless
# LINUXCNC_OPENGL_PLATFORM is set to something other than glx, so we
# pin both env vars.
#
# qtdragon embeds a QWebEngineView (Chromium). Under offscreen + xvfb
# with no GPU and no user namespaces in the CI runner sandbox,
# QtWebEngine browser-process init segfaults even with --no-sandbox
# --single-process --disable-gpu (Chromium logs "Sandboxing disabled
# by user." then crashes inside the same qtvcp PID). Rather than keep
# tuning Chromium flags for a widget the smoke test never touches,
# we shim qtpy.QtWebEngineWidgets to raise ImportError; web_widget.py
# already has a fallback path that swaps the QWebEngineView for a
# plain QWidget when the import fails (its "fail safe - mostly for
# designer" branch). No Chromium spawn = no crash.
: "${LIB_DIR:?qtdragon-prepare.sh must be sourced with LIB_DIR set}"
SRC_DIR="$(cd "$LIB_DIR/../../../configs/sim/qtdragon/qtdragon_xyz" && pwd)"
WORK_DIR="$(mktemp -d -t ui-smoke-qtdragon.XXXXXX)"
trap 'rm -rf "$WORK_DIR"' EXIT
cp -r "$SRC_DIR/." "$WORK_DIR/"
sed -i 's|^LOG_FILE = qtdragon\.log$|LOG_FILE = ~/qtdragon.log|' \
"$WORK_DIR/qtdragon_metric.ini"
export LINUXCNC_OPENGL_PLATFORM=offscreen
export QT_QPA_PLATFORM=offscreen
# sitecustomize.py is auto-imported by Python from any sys.path entry
# at interpreter startup. Drop a meta_path finder that blocks the
# qtpy.QtWebEngineWidgets import so WebWidget falls back to QWidget.
SHIM_DIR="$WORK_DIR/_pyshim"
mkdir -p "$SHIM_DIR"
cat >"$SHIM_DIR/sitecustomize.py" <<'PY'
import sys
from importlib.abc import MetaPathFinder, Loader
from importlib.util import spec_from_loader
_BLOCK = {'qtpy.QtWebEngineWidgets', 'PyQt5.QtWebEngineWidgets'}
class _BlockLoader(Loader):
def create_module(self, spec):
raise ImportError('QtWebEngineWidgets blocked for ui-smoke CI')
def exec_module(self, module):
pass
class _BlockFinder(MetaPathFinder):
def find_spec(self, name, path, target=None):
if name in _BLOCK:
return spec_from_loader(name, _BlockLoader())
return None
sys.meta_path.insert(0, _BlockFinder())
PY
export PYTHONPATH="$SHIM_DIR${PYTHONPATH:+:$PYTHONPATH}"
# Consumed by the sourcing test.sh, which execs the launcher with it.
# shellcheck disable=SC2034
QTDRAGON_INI="$WORK_DIR/qtdragon_metric.ini"

View File

@@ -0,0 +1,129 @@
#!/bin/bash
# Quit-path launcher for UI smoke tests.
# Usage: quit-launch.sh <sim-config-ini> <gui-process-match>
#
# Boots linuxcnc + GUI under xvfb-run exactly like launch.sh, waits for
# the NML task to come up (via drive.py), then sends SIGTERM to the GUI
# process *alone* and asserts the GUI exits on its own within a short
# grace. This is the regression guard for the SIGTERM clean-shutdown
# handlers: a GUI that absorbs SIGTERM and has to be SIGKILLed fails.
#
# <gui-process-match> is a pgrep -f pattern identifying the GUI process
# (e.g. "bin/touchy", "bin/gmoccapy"). It must not match the linuxcnc
# launcher or task/motion helpers.
#
# Markers (consumed by checkresult-quit.sh):
# UI_SMOKE_QUIT_OK GUI exited on SIGTERM within QUIT_GRACE
# UI_SMOKE_QUIT_FAIL GUI never started, was not found, or ignored TERM
set -u
CONFIG_INI="$1"
GUI_MATCH="$2"
TEST_DIR="${TEST_DIR:-$(cd "$(dirname "$0")" && pwd)}"
LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$TEST_DIR" || exit 1
rm -f ui-smoke.out ui-smoke.err linuxcnc.pid
bash "$LIB_DIR/cleanup-runtime.sh"
LINUXCNC_TIMEOUT=240
DRIVER_TIMEOUT=90
# Seconds to wait for the GUI to exit after SIGTERM before declaring it
# stuck. A GUI honouring SIGTERM exits in well under a second; the
# margin covers Cleanup of task/motion on slow CI.
QUIT_GRACE=15
# Shared headless environment (software GL + audio silencing), kept in
# launch-env.sh so launch.sh and quit-launch.sh cannot drift apart.
. "$LIB_DIR/launch-env.sh"
# Arm a core dump so a GUI segfault can be backtraced after the run.
. "$LIB_DIR/crashdump.sh"
crashdump_arm
export CONFIG_INI LIB_DIR DRIVER_TIMEOUT GUI_MATCH QUIT_GRACE
# shellcheck disable=SC2016
xvfb-run -a --server-args="-screen 0 1024x768x24" \
timeout "$LINUXCNC_TIMEOUT" \
bash -c '
setsid linuxcnc -r "$CONFIG_INI" >linuxcnc.out 2>linuxcnc.err &
LINUXCNC_PID=$!
echo "$LINUXCNC_PID" >linuxcnc.pid
# Wait until the task is reachable (GUI has constructed and the
# NML round-trip works). Reuse the phase-1 driver for readiness.
timeout "$DRIVER_TIMEOUT" python3 "$LIB_DIR/drive.py" >ui-smoke.out 2>ui-smoke.err
if ! grep -q "^UI_SMOKE_OK$" ui-smoke.out; then
echo "UI_SMOKE_QUIT_FAIL: GUI did not come up; cannot test quit"
kill -KILL -- -"$LINUXCNC_PID" 2>/dev/null || true
bash "$LIB_DIR/cleanup-runtime.sh"
exit 1
fi
# Identify the GUI process. pgrep -f matches against the whole
# command line, so wrapper processes (the linuxcnc launcher, the
# xvfb-run shell, this bash -c) also match because the GUI name
# appears in the config path or the embedded script text. Every
# such wrapper has a shell or xvfb-run as argv[0]; the real GUI
# is a python interpreter. Pick the first match whose argv[0]
# basename is a python binary.
GUI_PID=""
for p in $(pgrep -f "$GUI_MATCH"); do
arg0=$(tr "\0" "\n" <"/proc/$p/cmdline" 2>/dev/null | head -1)
case "$(basename "$arg0" 2>/dev/null)" in
python*) GUI_PID="$p"; break ;;
esac
done
if [ -z "$GUI_PID" ]; then
echo "UI_SMOKE_QUIT_FAIL: GUI process matching \"$GUI_MATCH\" not found"
kill -KILL -- -"$LINUXCNC_PID" 2>/dev/null || true
bash "$LIB_DIR/cleanup-runtime.sh"
exit 1
fi
# Send SIGTERM to the GUI alone and time how long it takes to go.
kill -TERM "$GUI_PID" 2>/dev/null || true
waited=0
while [ "$waited" -lt "$QUIT_GRACE" ]; do
kill -0 "$GUI_PID" 2>/dev/null || break
sleep 1
waited=$((waited + 1))
done
if kill -0 "$GUI_PID" 2>/dev/null; then
echo "UI_SMOKE_QUIT_FAIL: GUI (pid $GUI_PID) still alive ${QUIT_GRACE}s after SIGTERM"
RC=1
else
echo "UI_SMOKE_QUIT_OK: GUI exited ${waited}s after SIGTERM"
RC=0
fi
# Tear down whatever is left (task/motion, or the GUI on failure).
kill -TERM -- -"$LINUXCNC_PID" 2>/dev/null || true
for _ in $(seq 30); do
kill -0 "$LINUXCNC_PID" 2>/dev/null || break
sleep 1
done
if kill -0 "$LINUXCNC_PID" 2>/dev/null; then
kill -KILL -- -"$LINUXCNC_PID" 2>/dev/null || true
sleep 2
bash "$LIB_DIR/cleanup-runtime.sh"
fi
exit "$RC"
'
RC=$?
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
# If the GUI dumped a core, print its native backtrace.
crashdump_report
exit "$RC"

28
tests/ui-smoke/_lib/run-gui.sh Executable file
View File

@@ -0,0 +1,28 @@
#!/bin/bash
# Dispatcher invoked from each per-GUI test.sh. Resolves an INI path
# under configs/sim/ and execs launch.sh in the caller's test dir.
# Usage: run-gui.sh <relpath-under-configs/sim> [driver-args...]
# e.g. run-gui.sh axis/axis.ini
# run-gui.sh qtdragon/qtdragon_xyz/qtdragon_metric.ini
# run-gui.sh axis/axis.ini --run-program /abs/smoke.ngc --expect-pos 10,10,5
set -u
LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TEST_DIR="${TEST_DIR:-$(cd "$(dirname "$0")" && pwd)}"
CONFIGS_DIR="$(cd "$LIB_DIR/../../../configs/sim" && pwd)"
INI_ARG="$1"
shift
# Accept either a relative path under configs/sim/ or an absolute path.
# Absolute paths are used by tests that need to point at a writable
# mirror of a shipped config (qtdragon writes a log file inside the
# config dir, which is read-only on CI).
case "$INI_ARG" in
/*) INI_PATH="$INI_ARG" ;;
*) INI_PATH="$CONFIGS_DIR/$INI_ARG" ;;
esac
export TEST_DIR
exec "$LIB_DIR/launch.sh" "$INI_PATH" "$@"

View File

@@ -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.
#
# We only skip on xvfb-run absence (rare local dev env) or when the
# GUI under test was not built (--disable-gui headless builds).
# 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).
#
# Usage: skip-if-missing.sh [gui-binary]
set -u
if ! command -v xvfb-run >/dev/null 2>&1; then
echo "skip: xvfb-run not installed" >&2
exit 1
fi
if [ $# -ge 1 ] && ! command -v "$1" >/dev/null 2>&1; then
echo "skip: $1 not built (headless build)" >&2
exit 1
fi
exit 0

View File

@@ -0,0 +1,13 @@
(Phase 2 UI smoke program. Force mm input units with G21 so the move)
(commanded here is the same physical distance regardless of the sim)
(config's LINEAR_UNITS. Use G91 relative so the move is independent of)
(each sim's HOME position, then return to G90 absolute and end with M2.)
(stat.position is still reported in the machine's LINEAR_UNITS; the)
(driver converts the expected mm delta to machine units via)
(stat.linear_units before comparing. Note: axis and touchy sims are)
(inch machines, so a 1mm move shows as ~0.03937 in stat.position.)
G21
G91
G0 X1 Y1
G90
M2