From 5f27a9cb30569ef6e7a5995f799e836c109bd3ba Mon Sep 17 00:00:00 2001 From: Luca Toniolo <10792599+grandixximo@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:38:39 +1100 Subject: [PATCH 01/15] touchy sim: wait for the test panel before the postgui file touchy_postgui.hal links touchy_test pins, but the panel was loaded without -W. Under load the pins did not exist yet and touchy exited. Part of #4669. --- configs/sim/touchy/touchy.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/configs/sim/touchy/touchy.ini b/configs/sim/touchy/touchy.ini index 369d601b336..6ab65406719 100644 --- a/configs/sim/touchy/touchy.ini +++ b/configs/sim/touchy/touchy.ini @@ -50,7 +50,7 @@ SERVO_PERIOD = 1000000 HALFILE = core_sim.hal # add a test panel so people can actually test the screen -HALCMD = loadusr gladevcp --always_above -c touchy_test touchy_test_panel.glade +HALCMD = loadusr -Wn touchy_test gladevcp --always_above -c touchy_test touchy_test_panel.glade POSTGUI_HALFILE = touchy_postgui.hal [TRAJ] From 32c929f3d55d0d2b51fd67578083000dec1a4fdb Mon Sep 17 00:00:00 2001 From: Luca Toniolo <10792599+grandixximo@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:38:39 +1100 Subject: [PATCH 02/15] tests/ui-smoke: measure the touchy window only once it is mapped xwininfo -tree also lists unmapped windows, so under load window-fit measured touchy at its creation size. Poll for a viewable window of stable size. Part of #4669. --- tests/ui-smoke/_lib/window-fit.sh | 34 ++++++++++++++++++++++++------- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/tests/ui-smoke/_lib/window-fit.sh b/tests/ui-smoke/_lib/window-fit.sh index 46717c762c6..f332e06b903 100644 --- a/tests/ui-smoke/_lib/window-fit.sh +++ b/tests/ui-smoke/_lib/window-fit.sh @@ -10,8 +10,9 @@ # # window_fit_check # is matched literally against the xwininfo tree -# line, e.g. '("touchy" "Touchy")'. The first matching window is checked -# against the root window (the screen). Needs xwininfo (x11-utils). +# line, e.g. '("touchy" "Touchy")'. The first viewable matching window +# is checked against the root window (the screen). Needs xwininfo +# (x11-utils). window_fit_check() { pattern="$1" @@ -22,11 +23,30 @@ window_fit_check() { root=$(xwininfo -root 2>/dev/null) screen_w=$(echo "$root" | awk '/Width:/{print $2; exit}') screen_h=$(echo "$root" | awk '/Height:/{print $2; exit}') - geom=$(xwininfo -root -tree 2>/dev/null | grep -F "$pattern" \ - | grep -oE '[0-9]+x[0-9]+\+[0-9]+\+[0-9]+' | head -1) - win_w=${geom%%x*} - rest=${geom#*x} - win_h=${rest%%+*} + # drive.py returns once task is up, which under load can be before + # the GUI maps its window. The tree also lists unmapped windows, at + # the size they were created with, so measure only a viewable one, + # and poll until its size reads the same twice in a row. + win_w= + win_h= + prev= + for _ in $(seq "${UI_SMOKE_FIT_TIMEOUT:-60}"); do + size= + for w in $(xwininfo -root -tree 2>/dev/null | grep -F "$pattern" \ + | grep -oE '^ +0x[0-9a-f]+'); do + info=$(xwininfo -id "$w" 2>/dev/null) + echo "$info" | grep -q "Map State: IsViewable" || continue + size=$(echo "$info" | awk '/^ *Width:/{w=$2} /^ *Height:/{h=$2} END{print w "x" h}') + break + done + if [ -n "$size" ] && [ "$size" = "$prev" ]; then + win_w=${size%x*} + win_h=${size#*x} + break + fi + prev=$size + sleep 1 + done if [ -z "$win_w" ] || [ -z "$win_h" ]; then echo "UI_SMOKE_FAIL: window-fit: no window matching $pattern found" return 1 From 79975369da2cbebe8dbe2937a8c3d23911f2afea Mon Sep 17 00:00:00 2001 From: Luca Toniolo <10792599+grandixximo@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:38:40 +1100 Subject: [PATCH 03/15] tests/motion-logger: let task issue its startup queue before the UI acts Whether the canon commands queued by interpreter init and the startup code reach motion depended on how fast the UI sent its first command. Wait for RCS_DONE (the dwell in startup-gcode-abort) and expect them. Part of #4669. --- .../expected.motion-logger | 1 + .../12-M99-endless-main-program/test-ui.py | 16 ++++++++++++++++ .../basic/expected.builtin-startup.in | 1 + tests/motion-logger/basic/test-ui.py | 16 ++++++++++++++++ .../mountaindew/expected.motion-logger | 1 + tests/motion-logger/mountaindew/test-ui.py | 16 ++++++++++++++++ .../expected.motion-logger.in | 9 ++++++--- .../motion-logger/startup-gcode-abort/test-ui.py | 9 +++++++++ 8 files changed, 66 insertions(+), 3 deletions(-) diff --git a/tests/interp/m98m99/12-M99-endless-main-program/expected.motion-logger b/tests/interp/m98m99/12-M99-endless-main-program/expected.motion-logger index aebc5e1e183..3f1d81c8228 100644 --- a/tests/interp/m98m99/12-M99-endless-main-program/expected.motion-logger +++ b/tests/interp/m98m99/12-M99-endless-main-program/expected.motion-logger @@ -176,6 +176,7 @@ SPINDLE_OFF DISABLE JOINT_UNHOME joint=-2 FREE +SET_TERM_COND termCond=2, tolerance=0.001 JOG_ABORT joint=0 JOG_ABORT joint=1 JOG_ABORT joint=2 diff --git a/tests/interp/m98m99/12-M99-endless-main-program/test-ui.py b/tests/interp/m98m99/12-M99-endless-main-program/test-ui.py index 0813006d4eb..108274aea10 100755 --- a/tests/interp/m98m99/12-M99-endless-main-program/test-ui.py +++ b/tests/interp/m98m99/12-M99-endless-main-program/test-ui.py @@ -16,6 +16,22 @@ e = linuxcnc.error_channel() +# +# Task queues the canon commands of interpreter init before it reads its +# first command. Let it issue them, or an early ESTOP_RESET wipes them +# and the log depends on how fast this script starts. +# + +deadline = time.monotonic() + 60 +while True: + s.poll() + if s.state == linuxcnc.RCS_DONE: break + if time.monotonic() > deadline: + print("task did not finish startup") + sys.exit(1) + time.sleep(0.01) + + # # Come out of E-stop, turn the machine on, home, and switch to Auto mode. # diff --git a/tests/motion-logger/basic/expected.builtin-startup.in b/tests/motion-logger/basic/expected.builtin-startup.in index 2296018b31c..fce76f310a5 100644 --- a/tests/motion-logger/basic/expected.builtin-startup.in +++ b/tests/motion-logger/basic/expected.builtin-startup.in @@ -176,6 +176,7 @@ SPINDLE_OFF DISABLE JOINT_UNHOME joint=-2 FREE +SET_TERM_COND termCond=2, tolerance=0.001 JOG_ABORT joint=0 JOG_ABORT joint=1 JOG_ABORT joint=2 diff --git a/tests/motion-logger/basic/test-ui.py b/tests/motion-logger/basic/test-ui.py index 21da4c2bd47..edbefe1a447 100755 --- a/tests/motion-logger/basic/test-ui.py +++ b/tests/motion-logger/basic/test-ui.py @@ -46,6 +46,22 @@ def end_log(logfile_name): e = linuxcnc.error_channel() +# +# Task queues the canon commands of interpreter init before it reads its +# first command. Let it issue them, or an early ESTOP_RESET wipes them +# and the log depends on how fast this script starts. +# + +deadline = time.monotonic() + 60 +while True: + s.poll() + if s.state == linuxcnc.RCS_DONE: break + if time.monotonic() > deadline: + print("task did not finish startup") + sys.exit(1) + time.sleep(0.01) + + # # Come out of E-stop, turn the machine on, and switch to Auto mode. # diff --git a/tests/motion-logger/mountaindew/expected.motion-logger b/tests/motion-logger/mountaindew/expected.motion-logger index c52ea9598ed..928320a5a4e 100644 --- a/tests/motion-logger/mountaindew/expected.motion-logger +++ b/tests/motion-logger/mountaindew/expected.motion-logger @@ -80,6 +80,7 @@ SPINDLE_OFF DISABLE JOINT_UNHOME joint=-2 FREE +SET_TERM_COND termCond=2, tolerance=0.001 JOG_ABORT joint=0 JOG_ABORT joint=1 JOG_ABORT joint=2 diff --git a/tests/motion-logger/mountaindew/test-ui.py b/tests/motion-logger/mountaindew/test-ui.py index e4310ff6a86..89bc7f93df6 100755 --- a/tests/motion-logger/mountaindew/test-ui.py +++ b/tests/motion-logger/mountaindew/test-ui.py @@ -16,6 +16,22 @@ e = linuxcnc.error_channel() +# +# Task queues the canon commands of interpreter init before it reads its +# first command. Let it issue them, or an early ESTOP_RESET wipes them +# and the log depends on how fast this script starts. +# + +deadline = time.monotonic() + 60 +while True: + s.poll() + if s.state == linuxcnc.RCS_DONE: break + if time.monotonic() > deadline: + print("task did not finish startup") + sys.exit(1) + time.sleep(0.01) + + # # Come out of E-stop, turn the machine on, home, and switch to Auto mode. # diff --git a/tests/motion-logger/startup-gcode-abort/expected.motion-logger.in b/tests/motion-logger/startup-gcode-abort/expected.motion-logger.in index 8bef6b8b1a0..8bc77b66e94 100644 --- a/tests/motion-logger/startup-gcode-abort/expected.motion-logger.in +++ b/tests/motion-logger/startup-gcode-abort/expected.motion-logger.in @@ -114,12 +114,15 @@ JOINT_UNHOME joint=-2 FREE # -# Task is now got the 'motion.in-position' pin visible, and is -# accepting abort calls from the UI. +# Task now issues what interp_list holds: the interpreter's init and the +# first move of the startup code, then waits in its dwell. # +SET_TERM_COND termCond=2, tolerance=0.001 +SET_LINE x=-1, y=-2, z=-3, a=0, b=0, c=0, u=0, v=0, w=0, id=2, motion_type=1, vel=4.98888, ini_maxvel=4.98888, acc=1247.22, turn=-1 + # -# The test UI immediately sent an Abort. +# The test UI sent an Abort during the dwell. # JOG_ABORT joint=0 diff --git a/tests/motion-logger/startup-gcode-abort/test-ui.py b/tests/motion-logger/startup-gcode-abort/test-ui.py index 601664c4261..0735b97326d 100755 --- a/tests/motion-logger/startup-gcode-abort/test-ui.py +++ b/tests/motion-logger/startup-gcode-abort/test-ui.py @@ -31,6 +31,15 @@ time.sleep(0.1) waitlimit -= 1 +# Abort in the dwell of the startup code, after its first move went out. +waitlimit = 600 +while 0 < waitlimit: + s.poll() + if s.exec_state == linuxcnc.EXEC_WAITING_FOR_DELAY: + break + time.sleep(0.1) + waitlimit -= 1 + print("UI abort") sys.stdout.flush() From 45c4568c16b0e723c26eead87cd728268316f4f8 Mon Sep 17 00:00:00 2001 From: Luca Toniolo <10792599+grandixximo@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:38:40 +1100 Subject: [PATCH 04/15] tests/hal-show: ignore the component id column halcmd's hal_init() after fork races the loaded module for the next uspace id, so the owner column changed under load. Mask it as the mb2hal tests do. Part of #4669. --- tests/hal-show/checkresult | 12 ++++++++++++ tests/hal-show/expected | 24 ++++++++++++------------ 2 files changed, 24 insertions(+), 12 deletions(-) create mode 100755 tests/hal-show/checkresult diff --git a/tests/hal-show/checkresult b/tests/hal-show/checkresult new file mode 100755 index 00000000000..23489ab0eb2 --- /dev/null +++ b/tests/hal-show/checkresult @@ -0,0 +1,12 @@ +#!/bin/bash +set -e + +TESTDIR=$(dirname "$1") +cd "${TESTDIR}" + +# The owner column is the component ID, which depends on the order in which +# halcmd and rtapi_app take IDs during each loadrt. That order changes with +# CPU load, so mask it. +sed --in-place --regexp-extended --expression='s/^( *)[0-9]+( +[a-z0-9]+ +(IN|OUT|I\/O) .*)$/\1(ignored comp id)\2/' result + +diff -u expected result diff --git a/tests/hal-show/expected b/tests/hal-show/expected index dd41b0cf1e4..427f5242aa4 100644 --- a/tests/hal-show/expected +++ b/tests/hal-show/expected @@ -1,11 +1,11 @@ Component Pins: Owner Type Dir Value Name - 10 bool IN FALSE conv-bool-uint.0.in <== net-conv-bool-uint.0.in - 16 real IN 0 conv-real-sint.0.in <== net-conv-real-sint.0.in - 19 sint IN 0 conv-sint-real.0.in <== net-conv-sint-real.0.in - 22 sint IN 0 conv-sint-uint.0.in <== net-conv-sint-uint.0.in - 13 uint IN 0x0000000000000000 conv-uint-bool.0.in <== net-conv-uint-bool.0.in - 25 uint IN 0x0000000000000000 conv-uint-sint.0.in <== net-conv-uint-sint.0.in + (ignored comp id) bool IN FALSE conv-bool-uint.0.in <== net-conv-bool-uint.0.in + (ignored comp id) real IN 0 conv-real-sint.0.in <== net-conv-real-sint.0.in + (ignored comp id) sint IN 0 conv-sint-real.0.in <== net-conv-sint-real.0.in + (ignored comp id) sint IN 0 conv-sint-uint.0.in <== net-conv-sint-uint.0.in + (ignored comp id) uint IN 0x0000000000000000 conv-uint-bool.0.in <== net-conv-uint-bool.0.in + (ignored comp id) uint IN 0x0000000000000000 conv-uint-sint.0.in <== net-conv-uint-sint.0.in Signals: Type Value Name (linked to) @@ -36,12 +36,12 @@ sint 0 net-conv-uint-sint.0.out Component Pins: Owner Type Dir Value Name - 10 bool IN TRUE conv-bool-uint.0.in <== net-conv-bool-uint.0.in - 16 real IN 2.147484e+09 conv-real-sint.0.in <== net-conv-real-sint.0.in - 19 sint IN -2147483648 conv-sint-real.0.in <== net-conv-sint-real.0.in - 22 sint IN -9223372036854775808 conv-sint-uint.0.in <== net-conv-sint-uint.0.in - 13 uint IN 0x00000000FFFFFFFF conv-uint-bool.0.in <== net-conv-uint-bool.0.in - 25 uint IN 0xFFFFFFFFFFFFFFFF conv-uint-sint.0.in <== net-conv-uint-sint.0.in + (ignored comp id) bool IN TRUE conv-bool-uint.0.in <== net-conv-bool-uint.0.in + (ignored comp id) real IN 2.147484e+09 conv-real-sint.0.in <== net-conv-real-sint.0.in + (ignored comp id) sint IN -2147483648 conv-sint-real.0.in <== net-conv-sint-real.0.in + (ignored comp id) sint IN -9223372036854775808 conv-sint-uint.0.in <== net-conv-sint-uint.0.in + (ignored comp id) uint IN 0x00000000FFFFFFFF conv-uint-bool.0.in <== net-conv-uint-bool.0.in + (ignored comp id) uint IN 0xFFFFFFFFFFFFFFFF conv-uint-sint.0.in <== net-conv-uint-sint.0.in Signals: Type Value Name (linked to) From f3c816f20e1ddbfe80940d08a5b54cf5e684534a Mon Sep 17 00:00:00 2001 From: Luca Toniolo <10792599+grandixximo@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:38:40 +1100 Subject: [PATCH 05/15] tests/ui-smoke: skip ESTOP_RESET when the homing retry finds the machine on ESTOP_RESET leaves traj enabled, so task keeps reporting ON and the retry after a slow first homing attempt could never succeed. Part of #4669. --- tests/ui-smoke/_lib/drive.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/ui-smoke/_lib/drive.py b/tests/ui-smoke/_lib/drive.py index 796d681e0ca..473d292b46c 100755 --- a/tests/ui-smoke/_lib/drive.py +++ b/tests/ui-smoke/_lib/drive.py @@ -159,8 +159,13 @@ def home_all(cmd, stat, timeout): (see HOME_RETRY_BUDGET).""" attempt_timeout = min(timeout, HOME_ATTEMPT_TIMEOUT_S) for attempt in range(1, HOME_RETRY_BUDGET + 1): - if not ensure_state(cmd, stat, linuxcnc.STATE_ESTOP_RESET, - "STATE_ESTOP_RESET"): + # Task reports ESTOP_RESET only while the machine is off, and an + # ESTOP_RESET command does not turn it off, so requesting it on a + # retry after the machine came on would never be met. + stat.poll() + if (stat.task_state != linuxcnc.STATE_ON and + 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 From 42170d180e9c2fd3021d5c60af24d0abf6352f05 Mon Sep 17 00:00:00 2001 From: Luca Toniolo <10792599+grandixximo@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:40:16 +1100 Subject: [PATCH 06/15] tests/hal-show: mask the id together with its padding The owner column is right-aligned, so an id below 10 gets one more leading space and still failed the diff. Reported by Hannes Diethelm. Part of #4669. --- tests/hal-show/checkresult | 2 +- tests/hal-show/expected | 24 ++++++++++++------------ 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/tests/hal-show/checkresult b/tests/hal-show/checkresult index 23489ab0eb2..11774b2335e 100755 --- a/tests/hal-show/checkresult +++ b/tests/hal-show/checkresult @@ -7,6 +7,6 @@ cd "${TESTDIR}" # The owner column is the component ID, which depends on the order in which # halcmd and rtapi_app take IDs during each loadrt. That order changes with # CPU load, so mask it. -sed --in-place --regexp-extended --expression='s/^( *)[0-9]+( +[a-z0-9]+ +(IN|OUT|I\/O) .*)$/\1(ignored comp id)\2/' result +sed --in-place --regexp-extended --expression='s/^ *[0-9]+( +[a-z0-9]+ +(IN|OUT|I\/O) .*)$/(ignored comp id)\1/' result diff -u expected result diff --git a/tests/hal-show/expected b/tests/hal-show/expected index 427f5242aa4..27325b418c1 100644 --- a/tests/hal-show/expected +++ b/tests/hal-show/expected @@ -1,11 +1,11 @@ Component Pins: Owner Type Dir Value Name - (ignored comp id) bool IN FALSE conv-bool-uint.0.in <== net-conv-bool-uint.0.in - (ignored comp id) real IN 0 conv-real-sint.0.in <== net-conv-real-sint.0.in - (ignored comp id) sint IN 0 conv-sint-real.0.in <== net-conv-sint-real.0.in - (ignored comp id) sint IN 0 conv-sint-uint.0.in <== net-conv-sint-uint.0.in - (ignored comp id) uint IN 0x0000000000000000 conv-uint-bool.0.in <== net-conv-uint-bool.0.in - (ignored comp id) uint IN 0x0000000000000000 conv-uint-sint.0.in <== net-conv-uint-sint.0.in +(ignored comp id) bool IN FALSE conv-bool-uint.0.in <== net-conv-bool-uint.0.in +(ignored comp id) real IN 0 conv-real-sint.0.in <== net-conv-real-sint.0.in +(ignored comp id) sint IN 0 conv-sint-real.0.in <== net-conv-sint-real.0.in +(ignored comp id) sint IN 0 conv-sint-uint.0.in <== net-conv-sint-uint.0.in +(ignored comp id) uint IN 0x0000000000000000 conv-uint-bool.0.in <== net-conv-uint-bool.0.in +(ignored comp id) uint IN 0x0000000000000000 conv-uint-sint.0.in <== net-conv-uint-sint.0.in Signals: Type Value Name (linked to) @@ -36,12 +36,12 @@ sint 0 net-conv-uint-sint.0.out Component Pins: Owner Type Dir Value Name - (ignored comp id) bool IN TRUE conv-bool-uint.0.in <== net-conv-bool-uint.0.in - (ignored comp id) real IN 2.147484e+09 conv-real-sint.0.in <== net-conv-real-sint.0.in - (ignored comp id) sint IN -2147483648 conv-sint-real.0.in <== net-conv-sint-real.0.in - (ignored comp id) sint IN -9223372036854775808 conv-sint-uint.0.in <== net-conv-sint-uint.0.in - (ignored comp id) uint IN 0x00000000FFFFFFFF conv-uint-bool.0.in <== net-conv-uint-bool.0.in - (ignored comp id) uint IN 0xFFFFFFFFFFFFFFFF conv-uint-sint.0.in <== net-conv-uint-sint.0.in +(ignored comp id) bool IN TRUE conv-bool-uint.0.in <== net-conv-bool-uint.0.in +(ignored comp id) real IN 2.147484e+09 conv-real-sint.0.in <== net-conv-real-sint.0.in +(ignored comp id) sint IN -2147483648 conv-sint-real.0.in <== net-conv-sint-real.0.in +(ignored comp id) sint IN -9223372036854775808 conv-sint-uint.0.in <== net-conv-sint-uint.0.in +(ignored comp id) uint IN 0x00000000FFFFFFFF conv-uint-bool.0.in <== net-conv-uint-bool.0.in +(ignored comp id) uint IN 0xFFFFFFFFFFFFFFFF conv-uint-sint.0.in <== net-conv-uint-sint.0.in Signals: Type Value Name (linked to) From 5832f8781a9f9dff58973a6e5d3cc3a7ac6fb18c Mon Sep 17 00:00:00 2001 From: Luca Toniolo <10792599+grandixximo@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:52:07 +1100 Subject: [PATCH 07/15] task: report a probe move done only once motion has judged it Motion flags a G38.2/G38.4 move that ended without contact one servo cycle after the planner goes idle. Task could read that cycle as DONE, finish the MDI command and miss the error, which linuxcncrsh then returned for the next command. Wait for emcmotStatus.probing to clear. Part of #4669. --- src/emc/task/taskintf.cc | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/emc/task/taskintf.cc b/src/emc/task/taskintf.cc index 049ae4d26d4..84a41c9e038 100644 --- a/src/emc/task/taskintf.cc +++ b/src/emc/task/taskintf.cc @@ -1704,7 +1704,9 @@ int emcTrajUpdate(EMC_TRAJ_STAT * stat) if (emcmotStatus.motionFlag & EMCMOT_MOTION_ERROR_BIT) { stat->status = RCS_STATUS::ERROR; - } else if (stat->inpos && (stat->queue == 0)) { + } else if (stat->inpos && (stat->queue == 0) && !emcmotStatus.probing) { + // a probe move that ends without contact is reported a servo + // cycle after the planner goes idle, so wait for the verdict stat->status = RCS_STATUS::DONE; } else { stat->status = RCS_STATUS::EXEC; From ff27e51187fc5608d38ea4f753646936134a7ede Mon Sep 17 00:00:00 2001 From: Luca Toniolo <10792599+grandixximo@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:52:07 +1100 Subject: [PATCH 08/15] tests/multiclick, tests/mux: load the whole input before starting A 100 deep fifo holds 99 samples, and the 100 line input was preloaded with a fixed 0.5s sleep, so a late halstreamer left the streamer empty and shifted the end of the output. Use deeper fifos and loadusr -w. Part of #4669. --- tests/multiclick/test.hal | 10 +++++----- tests/mux/test.hal | 9 +++++---- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/tests/multiclick/test.hal b/tests/multiclick/test.hal index 9e4d8558fc9..035f6eee00a 100644 --- a/tests/multiclick/test.hal +++ b/tests/multiclick/test.hal @@ -2,8 +2,8 @@ loadrt threads name1=millisecond period1=1000000 loadrt multiclick count=2 -loadrt sampler depth=100 cfg=bbbbbbbbbsbbbbbbbbbs -loadrt streamer depth=100 cfg=bb +loadrt sampler depth=128 cfg=bbbbbbbbbsbbbbbbbbbs +loadrt streamer depth=128 cfg=bb net in0 streamer.0.pin.0 => multiclick.0.in sampler.0.pin.0 @@ -50,9 +50,9 @@ setp multiclick.1.output-hold-ns 1500000 setp multiclick.1.invert-input True -# run halstreamer and let it pre-load the fifo -loadusr halstreamer input-signals -loadusr -w sleep 0.5 +# pre-load the whole input before the thread starts; halstreamer exits +# once the last line is in the fifo +loadusr -w halstreamer input-signals start loadusr -w halsampler -n 105 diff --git a/tests/mux/test.hal b/tests/mux/test.hal index 5cd72d1d369..f74fcc819d3 100644 --- a/tests/mux/test.hal +++ b/tests/mux/test.hal @@ -1,8 +1,8 @@ setexact_for_test_suite_only loadrt threads name1=slow period1=1000000 loadrt mux_generic config="b2b,b2f,b2s,b2u,f2b,f2f,f2s,f2u,s2b,s2f,s2s,s2u,u2b,u2f,u2s,u2u,b2b,f5f" -loadrt streamer depth=100 cfg=bu -loadrt sampler depth=100 cfg=bfsubfsubfsubfsubfbu +loadrt streamer depth=128 cfg=bu +loadrt sampler depth=128 cfg=bfsubfsubfsubfsubfbu addf streamer.0 slow addf mux-gen.00 slow @@ -80,8 +80,9 @@ net o15 mux-gen.15.out-uint sampler.0.pin.15 net o16 mux-gen.16.out-bool sampler.0.pin.16 net o17 mux-gen.17.out-real sampler.0.pin.17 -loadusr halstreamer input-signals -loadusr -w sleep 0.5 +# pre-load the whole input before the thread starts; halstreamer exits +# once the last line is in the fifo +loadusr -w halstreamer input-signals start loadusr -w halsampler -n 105 From ed1af00c36b909f65de1ca7f40febbfceab25ffc Mon Sep 17 00:00:00 2001 From: Luca Toniolo <10792599+grandixximo@users.noreply.github.com> Date: Wed, 14 Oct 2026 18:46:45 +1100 Subject: [PATCH 09/15] tests/halmodule/stream: print the result before signalling ready halrun ends the test once the reader is ready, so a reader descheduled between ready() and print() was interrupted before it printed "pass". Part of #4669. --- tests/halmodule/stream/stream_reader.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/halmodule/stream/stream_reader.py b/tests/halmodule/stream/stream_reader.py index 0aaa20965e8..bcb98c89be2 100755 --- a/tests/halmodule/stream/stream_reader.py +++ b/tests/halmodule/stream/stream_reader.py @@ -10,8 +10,9 @@ assert reader.sampleno == i+1 assert reader.read() is None assert reader.num_underruns == 1 +# report before ready(): halrun ends the run once it sees ready +print("pass", flush=True) c.ready() -print("pass") try: while 1: time.sleep(1) From 6df2b6dfc506d0a2ceb0698b661c534ef985204f Mon Sep 17 00:00:00 2001 From: Luca Toniolo <10792599+grandixximo@users.noreply.github.com> Date: Wed, 14 Oct 2026 18:50:24 +1100 Subject: [PATCH 10/15] tests/ui-smoke: let a slow homing sequence finish instead of homing again When homing outlasted the 15s attempt, home_all() issued home(-1) again. In the gmoccapy sim X and Z share one home switch, and X homes to 10, past its switch, so Z then started on an active switch, backed off without clearing it and aborted ("j2 end of move in home state 5"), on every further attempt. Keep waiting while any joint is homing. Part of #4669. --- tests/ui-smoke/_lib/drive.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/tests/ui-smoke/_lib/drive.py b/tests/ui-smoke/_lib/drive.py index 473d292b46c..3de99ebf67f 100755 --- a/tests/ui-smoke/_lib/drive.py +++ b/tests/ui-smoke/_lib/drive.py @@ -158,6 +158,7 @@ def home_all(cmd, stat, timeout): Ensures ESTOP_RESET + ON + MANUAL and retries the whole sequence (see HOME_RETRY_BUDGET).""" attempt_timeout = min(timeout, HOME_ATTEMPT_TIMEOUT_S) + deadline = time.monotonic() + timeout for attempt in range(1, HOME_RETRY_BUDGET + 1): # Task reports ESTOP_RESET only while the machine is off, and an # ESTOP_RESET command does not turn it off, so requesting it on a @@ -176,10 +177,19 @@ def home_all(cmd, stat, timeout): stat.poll() njoints = stat.joints cmd.home(-1) - if wait_until_quiet( + all_homed = lambda s: all(s.homed[i] for i in range(njoints)) + done = wait_until_quiet(stat, all_homed, attempt_timeout) + # On a slow machine homing can still be running here. Homing + # again would restart joints whose home switch another, already + # homed joint now holds active (the gmoccapy sim shares one switch + # between X and Z), so let a running sequence finish instead. + if not done and any(stat.joint[i]["homing"] for i in range(njoints)): + done = wait_until_quiet( stat, - lambda s: all(s.homed[i] for i in range(njoints)), - attempt_timeout): + lambda s: all_homed(s) or not any( + s.joint[i]["homing"] for i in range(njoints)), + max(0.0, deadline - time.monotonic())) and all_homed(stat) + if done: cmd.teleop_enable(1) cmd.wait_complete() return True From e80d4036012d1ac2964351218d375d1cfde02acf Mon Sep 17 00:00:00 2001 From: Luca Toniolo <10792599+grandixximo@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:29:09 +1100 Subject: [PATCH 11/15] gmoccapy sim: give X and Z their own home switches The gmoccapy sim joined the X and Z switches through or2. X homes to 10, past its switch, so after a home all the shared input stayed active and a second home all always failed: Z started on the switch, backed off without clearing it and aborted ("j2 end of move in home state 5"). Under load any interrupted first home led the ui-smoke test into this. Wire each switch to its own joint, as lib/hallib/simulated_home.hal does. Part of #4669. --- configs/sim/gmoccapy/simulated_home.hal | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/configs/sim/gmoccapy/simulated_home.hal b/configs/sim/gmoccapy/simulated_home.hal index 3422569833e..dfa0cf700cf 100644 --- a/configs/sim/gmoccapy/simulated_home.hal +++ b/configs/sim/gmoccapy/simulated_home.hal @@ -1,4 +1,3 @@ -loadrt or2 names=or2_0 loadrt comp names=comp_x,comp_y,comp_z net Xhomeswpos => comp_x.in0 @@ -17,16 +16,10 @@ setp comp_x.hyst .02 setp comp_y.hyst .02 setp comp_z.hyst .02 -net Xhomesw <= comp_x.out +net Xhomesw <= comp_x.out => joint.0.home-sw-in net Yhomesw <= comp_y.out => joint.1.home-sw-in -net Zhomesw <= comp_z.out - -net Xhomesw => or2_0.in0 -net Zhomesw => or2_0.in1 -net XZhomesw or2_0.out => joint.0.home-sw-in joint.2.home-sw-in +net Zhomesw <= comp_z.out => joint.2.home-sw-in addf comp_x servo-thread addf comp_y servo-thread addf comp_z servo-thread - -addf or2_0 servo-thread From 3928a06922d95095730e98a3dee58b5367cc3a7e Mon Sep 17 00:00:00 2001 From: Luca Toniolo <10792599+grandixximo@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:42:56 +1100 Subject: [PATCH 12/15] tests/remap/remap-io: read the pre-test status before setting the input The test set the input pin and then polled status for the "pre" line, expecting the old value. Motion copies the pin within a servo cycle, so under load the poll saw the new one. Poll first. Part of #4669. --- tests/remap/remap-io/test-ui.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/remap/remap-io/test-ui.py b/tests/remap/remap-io/test-ui.py index c96923b1124..6e68dccd600 100755 --- a/tests/remap/remap-io/test-ui.py +++ b/tests/remap/remap-io/test-ui.py @@ -76,6 +76,10 @@ def do_in(inp, d_in=True, wait_mode=None, timeout=None): timeout = (' Q%d' % timeout if timeout is not None else ''), ) + # Read the pre-test values before setting the input pin; motion copies + # the pin into the status within a servo cycle + s.poll() # Update status channel + # Set input pin if d_in: h['d_in'] = inp @@ -83,7 +87,6 @@ def do_in(inp, d_in=True, wait_mode=None, timeout=None): h['a_in'] = inp # Print pre-test values - s.poll() # Update status channel c.mdi('(print,cmd: "%s"; input: %.4f)' % (cmd, inp)) c.mdi('(print, M66 remapping pre: 5399=#5399; 100=#100; ' 'ain0=%.2f; din0=%d)' % (s.ain[0], s.din[0])) From 2d8c5d72bd9522a60af741c9fffafd1942aa6fc6 Mon Sep 17 00:00:00 2001 From: Luca Toniolo <10792599+grandixximo@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:42:56 +1100 Subject: [PATCH 13/15] tests/abort/stop-button-crazy-move: wait up to 60s instead of 10s Each wait ends as soon as its condition holds, so the limit only matters on a slow machine. Under heavy parallel load the queue had reached 948 of the 1000 segments after 10s. Part of #4669. --- tests/abort/stop-button-crazy-move/test-ui.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/abort/stop-button-crazy-move/test-ui.py b/tests/abort/stop-button-crazy-move/test-ui.py index 839940e1e26..a27168259b3 100755 --- a/tests/abort/stop-button-crazy-move/test-ui.py +++ b/tests/abort/stop-button-crazy-move/test-ui.py @@ -10,7 +10,7 @@ # Time increment and timeout, seconds TIME_INCR = 0.1 -TIMEOUT = 10.0 +TIMEOUT = 60.0 # # connect to LinuxCNC From 51f56f28a603bb852ce43479ebcb7986a4e6691d Mon Sep 17 00:00:00 2001 From: Luca Toniolo <10792599+grandixximo@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:19:07 +1100 Subject: [PATCH 14/15] tests: home one joint at a time Motion refuses to home a joint while another one is homing ("Homing not possible until current homing process is finished."). Ten tests sent back to back home commands for their joints and then waited for all of them, which works only while each home finishes before the next command reaches motion. Under load it does not: abort/stop-button-crazy-move timed out with joint 2 unhomed (#4669). linuxcnc_util gains home_joints(), which homes the joints one after the other and waits until each is homed and no joint is homing any more; the homing flags stay set until motion accepts the next home. With joint 0 homing through a 2 s final move, the old sequence fails stop-button-crazy-move on every run and home_joints() passes. --- lib/python/linuxcnc_util.py | 28 +++++++++++++++++++ .../on_abort_command-crazy-move/test-ui.py | 5 +--- tests/abort/stop-button-crazy-move/test-ui.py | 5 +--- .../g28.2/immediate-mode-guard/test-ui.py | 7 ++--- tests/interp/g28.2/invalid-pword/test-ui.py | 17 ++--------- tests/interp/g28.2/position-model/test-ui.py | 16 ++--------- tests/interp/g28.2/sequencing/test-ui.py | 17 ++--------- tests/interp/mdi-oword-m66/test-ui.py | 6 +--- tests/interp/subroutine-return/test-ui.py | 5 +--- tests/maxkins/test-ui.py | 5 ++-- tests/mqtt/display.py | 8 ++---- 11 files changed, 44 insertions(+), 75 deletions(-) diff --git a/lib/python/linuxcnc_util.py b/lib/python/linuxcnc_util.py index 3b215bf78a3..6ae8ff9e6f5 100644 --- a/lib/python/linuxcnc_util.py +++ b/lib/python/linuxcnc_util.py @@ -102,6 +102,34 @@ def wait_for_home(self, joints, timeout=10.0): raise LinuxCNC_Exception("timeout waiting for homing to complete:\nstatus.homed:" + str(self.status.homed) + "\nstatus.position:" + str(self.status.position)) + def home_joints(self, joints, timeout=10.0): + """Arguments: + + 'joints' is a list of unhomed joint numbers. + + 'timeout' is a float, the number of seconds to wait for + each joint to home before giving up. + + Homes the joints one after the other: motion refuses to home a + joint while another one is homing. A joint counts as done once + it is homed and no joint is homing any more; motion keeps the + homing flag set until it accepts the next home command. + + Returns once all the joints are homed, raises + LinuxCNC_Exception if one did not home before the timeout.""" + + for joint in joints: + self.command.home(joint) + start_time = time.time() + while True: + self.status.poll() + if self.status.homed[joint] and not any(j["homing"] for j in self.status.joint): + break + if (time.time() - start_time) > timeout: + raise LinuxCNC_Exception("timeout waiting for joint %d to home:\nstatus.homed:" % joint + str(self.status.homed) + "\nstatus.position:" + str(self.status.position)) + time.sleep(0.01) + + def wait_for_axis_to_stop(self, axis_letter, timeout=10.0): """Arguments: 'axis_letter' is a single character from the set [xyzabcuvw] diff --git a/tests/abort/on_abort_command-crazy-move/test-ui.py b/tests/abort/on_abort_command-crazy-move/test-ui.py index 0fd8c1dc559..1de81bc80b9 100755 --- a/tests/abort/on_abort_command-crazy-move/test-ui.py +++ b/tests/abort/on_abort_command-crazy-move/test-ui.py @@ -38,10 +38,7 @@ c.state(linuxcnc.STATE_ESTOP_RESET) c.state(linuxcnc.STATE_ON) -c.home(0) -c.home(1) -c.home(2) -l.wait_for_home([1, 1, 1, 0, 0, 0, 0, 0, 0]) +l.home_joints([0, 1, 2]) # # Print status function diff --git a/tests/abort/stop-button-crazy-move/test-ui.py b/tests/abort/stop-button-crazy-move/test-ui.py index a27168259b3..e20888a9489 100755 --- a/tests/abort/stop-button-crazy-move/test-ui.py +++ b/tests/abort/stop-button-crazy-move/test-ui.py @@ -37,10 +37,7 @@ c.state(linuxcnc.STATE_ESTOP_RESET) c.state(linuxcnc.STATE_ON) -c.home(0) -c.home(1) -c.home(2) -l.wait_for_home([1, 1, 1, 0, 0, 0, 0, 0, 0]) +l.home_joints([0, 1, 2]) # # Print status function diff --git a/tests/interp/g28.2/immediate-mode-guard/test-ui.py b/tests/interp/g28.2/immediate-mode-guard/test-ui.py index 6a45e0b7c86..008b8bc6e28 100755 --- a/tests/interp/g28.2/immediate-mode-guard/test-ui.py +++ b/tests/interp/g28.2/immediate-mode-guard/test-ui.py @@ -30,6 +30,7 @@ """ import linuxcnc +import linuxcnc_util import hal import sys @@ -74,11 +75,7 @@ def drain_errors(): c.state(linuxcnc.STATE_ESTOP_RESET) c.state(linuxcnc.STATE_ON) c.mode(linuxcnc.MODE_MANUAL) -c.home(0) -c.home(1) -c.home(2) -if not wait_homed([1, 1, 1]): - fail("initial home-all did not home all joints: {}".format(list(s.homed[:3]))) +linuxcnc_util.LinuxCNC(command=c, status=s, error=e).home_joints([0, 1, 2]) # Get into a coordinated (teleop) mode -- what a stray FREE dip would destroy. c.teleop_enable(1) diff --git a/tests/interp/g28.2/invalid-pword/test-ui.py b/tests/interp/g28.2/invalid-pword/test-ui.py index 4237a215b8c..19effdd827e 100755 --- a/tests/interp/g28.2/invalid-pword/test-ui.py +++ b/tests/interp/g28.2/invalid-pword/test-ui.py @@ -31,6 +31,7 @@ """ import linuxcnc +import linuxcnc_util import hal import os @@ -68,16 +69,6 @@ def wait_idle(timeout=10.0): return False -def wait_homed(expected, timeout=10.0): - t0 = time.time() - while time.time() - t0 < timeout: - poll() - if list(s.homed[:3]) == expected: - return True - time.sleep(0.01) - return False - - def drain_errors(): msgs = [] while True: @@ -95,11 +86,7 @@ def mode_name(m): c.state(linuxcnc.STATE_ESTOP_RESET) c.state(linuxcnc.STATE_ON) -c.home(0) -c.home(1) -c.home(2) -if not wait_homed([1, 1, 1]): - fail("initial home-all did not home all joints: {}".format(list(s.homed[:3]))) +linuxcnc_util.LinuxCNC(command=c, status=s, error=e).home_joints([0, 1, 2]) # Establish a coordinated mode worth preserving. c.mode(linuxcnc.MODE_MDI) diff --git a/tests/interp/g28.2/position-model/test-ui.py b/tests/interp/g28.2/position-model/test-ui.py index dd1e78295e0..108f8b9e009 100755 --- a/tests/interp/g28.2/position-model/test-ui.py +++ b/tests/interp/g28.2/position-model/test-ui.py @@ -20,6 +20,7 @@ """ import linuxcnc +import linuxcnc_util import hal import sys @@ -46,16 +47,6 @@ def wait_idle(timeout=10.0): return False -def wait_homed(expected, timeout=10.0): - t0 = time.time() - while time.time() - t0 < timeout: - poll() - if list(s.homed[:len(expected)]) == expected: - return True - time.sleep(0.01) - return False - - def fail(msg): print("FAIL: " + msg) sys.exit(1) @@ -76,10 +67,7 @@ def mdi(cmd): c.state(linuxcnc.STATE_ESTOP_RESET) c.state(linuxcnc.STATE_ON) -for j in range(4): - c.home(j) -if not wait_homed([1, 1, 1, 1]): - fail("initial home-all did not home all joints: {}".format(list(s.homed[:4]))) +linuxcnc_util.LinuxCNC(command=c, status=s).home_joints([0, 1, 2, 3]) c.mode(linuxcnc.MODE_MDI) time.sleep(0.2) diff --git a/tests/interp/g28.2/sequencing/test-ui.py b/tests/interp/g28.2/sequencing/test-ui.py index 87c68638802..49cd512bed1 100755 --- a/tests/interp/g28.2/sequencing/test-ui.py +++ b/tests/interp/g28.2/sequencing/test-ui.py @@ -13,6 +13,7 @@ """ import linuxcnc +import linuxcnc_util import hal import sys @@ -53,16 +54,6 @@ def wait_x(expected, timeout=5.0): return False -def wait_homed(expected, timeout=5.0): - t0 = time.time() - while time.time() - t0 < timeout: - poll() - if list(s.homed[:3]) == expected: - return True - time.sleep(0.01) - return False - - def fail(msg): print("FAIL: " + msg) sys.exit(1) @@ -74,11 +65,7 @@ def near(a, b, tol=0.001): c.state(linuxcnc.STATE_ESTOP_RESET) c.state(linuxcnc.STATE_ON) -c.home(0) -c.home(1) -c.home(2) -if not wait_homed([1, 1, 1]): - fail("initial home-all did not home all joints: {}".format(list(s.homed[:3]))) +linuxcnc_util.LinuxCNC(command=c, status=s).home_joints([0, 1, 2]) c.mode(linuxcnc.MODE_MDI) time.sleep(0.2) diff --git a/tests/interp/mdi-oword-m66/test-ui.py b/tests/interp/mdi-oword-m66/test-ui.py index f923af3d7a9..f14aa9cdff6 100755 --- a/tests/interp/mdi-oword-m66/test-ui.py +++ b/tests/interp/mdi-oword-m66/test-ui.py @@ -34,11 +34,7 @@ c.state(linuxcnc.STATE_ESTOP_RESET) c.state(linuxcnc.STATE_ON) -c.home(0) -c.home(1) -c.home(2) - -l.wait_for_home([1, 1, 1, 0, 0, 0, 0, 0, 0]) +l.home_joints([0, 1, 2]) c.mode(linuxcnc.MODE_MDI) diff --git a/tests/interp/subroutine-return/test-ui.py b/tests/interp/subroutine-return/test-ui.py index df8769d3b9a..98bd7f968ff 100755 --- a/tests/interp/subroutine-return/test-ui.py +++ b/tests/interp/subroutine-return/test-ui.py @@ -34,10 +34,7 @@ c.state(linuxcnc.STATE_ESTOP_RESET) c.state(linuxcnc.STATE_ON) -c.home(0) -c.home(1) -c.home(2) -l.wait_for_home([1, 1, 1, 0, 0, 0, 0, 0, 0]) +l.home_joints([0, 1, 2]) c.mode(linuxcnc.MODE_AUTO) diff --git a/tests/maxkins/test-ui.py b/tests/maxkins/test-ui.py index d65e561e516..de629c82fc0 100755 --- a/tests/maxkins/test-ui.py +++ b/tests/maxkins/test-ui.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 import linuxcnc +import linuxcnc_util import hal import time @@ -49,9 +50,7 @@ def wait_for_linuxcnc_startup(status, timeout=10.0): # HOME_ABSOLUTE_ENCODER = 1 is used in the ini file. c.state(linuxcnc.STATE_ESTOP_RESET) c.state(linuxcnc.STATE_ON) -for joint in range(9): - c.home(joint) -c.wait_complete() +linuxcnc_util.LinuxCNC(command=c, status=s, error=e).home_joints(range(9)) LETTERS = 'XYZABCUVW' diff --git a/tests/mqtt/display.py b/tests/mqtt/display.py index 3132c836434..c2e9a3faf95 100755 --- a/tests/mqtt/display.py +++ b/tests/mqtt/display.py @@ -20,12 +20,8 @@ c.state(linuxcnc.STATE_ON) c.wait_complete() -c.home(0) -c.home(1) -c.home(2) - -print("info: Waiting for homing to complete") -l.wait_for_home([1, 1, 1, 0, 0, 0, 0, 0, 0]) +print("info: Homing") +l.home_joints([0, 1, 2]) c.mode(linuxcnc.MODE_MDI) From ef51ef92316a3468baddb814650ef967b57ef880 Mon Sep 17 00:00:00 2001 From: Luca Toniolo <10792599+grandixximo@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:19:07 +1100 Subject: [PATCH 15/15] tests/ui-smoke: no import in the qtdragon shim's SIGUSR1 handler The launcher sends SIGUSR1 every second from qtvcp's interpreter startup on, until the shim's handler has saved a screenshot. The handler imported qtpy.QtCore. A signal that arrived while qtvcp's own first import of PyQt5.QtCore was loading PyQt5.sip ran that import a second time inside the first, and sip refused the outer one: "the sip module has already registered a module called PyQt5.QtCore", and qtdragon-quit found no window (#4669). The handler now does nothing until qtpy.QtCore has its QTimer; the launcher asks again. Raising the signal at that point of the import gives the error with the old handler every time, the new one imports. --- tests/ui-smoke/_lib/qtdragon-prepare.sh | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/tests/ui-smoke/_lib/qtdragon-prepare.sh b/tests/ui-smoke/_lib/qtdragon-prepare.sh index 19404a998db..5227966b99c 100644 --- a/tests/ui-smoke/_lib/qtdragon-prepare.sh +++ b/tests/ui-smoke/_lib/qtdragon-prepare.sh @@ -128,9 +128,16 @@ def _ui_smoke_grab(): pass def _ui_smoke_on_sigusr1(signum, frame): + # The launcher signals from interpreter startup on, so this can run + # in the middle of an import, qtvcp's first PyQt5 import included. + # An import from here then loads PyQt5.QtCore a second time and sip + # refuses it, killing qtvcp. Do nothing until qtpy.QtCore is in; the + # launcher asks again. + timer = getattr(sys.modules.get('qtpy.QtCore'), 'QTimer', None) + if timer is None: + return try: - from qtpy.QtCore import QTimer - QTimer.singleShot(0, _ui_smoke_grab) + timer.singleShot(0, _ui_smoke_grab) except Exception: pass