diff --git a/lib/python/common/hal_glib.py b/lib/python/common/hal_glib.py index 6227a3dae3b..3e6820a9959 100644 --- a/lib/python/common/hal_glib.py +++ b/lib/python/common/hal_glib.py @@ -615,8 +615,9 @@ def update(self): state_new = self.old['state'] if state_new != state_old: - # set machine estop/clear - if state_old == linuxcnc.STATE_ESTOP and state_new == linuxcnc.STATE_ESTOP_RESET: + # set machine estop/clear; a reset and machine on between two + # polls go from ESTOP straight to ON and still clear the estop + if state_old == linuxcnc.STATE_ESTOP: self.emit('state-estop-reset') self.emit('interp-idle') elif state_new == linuxcnc.STATE_ESTOP: diff --git a/scripts/linuxcnc.in b/scripts/linuxcnc.in index d0dbbe87bae..913c6ec5ba8 100644 --- a/scripts/linuxcnc.in +++ b/scripts/linuxcnc.in @@ -694,9 +694,21 @@ function Cleanup() { # Unused variable 'i', just a counter # shellcheck disable=SC2034 for i in $(seq 10); do - # (the one component is the halcmd itself; a thread is listed as - # a __ pseudo component, which stays until realtime stops) - if [ "$($HALCMD list comp | tr -s ' ' '\n' | grep -c -v -e '^__' -e '^$')" = 1 ]; then break; fi + # Wait only for components that can still leave: a thread is + # listed as a __ pseudo component, which stays until + # realtime stops, and a user component whose process is gone + # (a GUI that exited without hal_exit) never leaves. The halcmd + # that listed them has exited by the time its pid is checked. + COMPS=$($HALCMD -s show comp) + BUSY=0 + while read -r _ TYPE NAME PID _; do + case "$TYPE:$NAME" in + RT:__*) ;; + RT:*) BUSY=1 ;; + User:*) if "$KILL" -0 "$PID" 2>/dev/null; then BUSY=1; fi ;; + esac + done <<< "$COMPS" + if [ "$BUSY" = 0 ]; then break; fi sleep .2 done diff --git a/src/emc/usr_intf/gmoccapy/gmoccapy.py b/src/emc/usr_intf/gmoccapy/gmoccapy.py index cf17081c331..feacd444974 100644 --- a/src/emc/usr_intf/gmoccapy/gmoccapy.py +++ b/src/emc/usr_intf/gmoccapy/gmoccapy.py @@ -3100,16 +3100,24 @@ def on_hal_status_tool_in_spindle_changed(self, object, new_tool_no): self.prefs.putpref("tool_in_spindle", new_tool_no, int) self._update_toolinfo(new_tool_no) + # Show a state change on a toggle button without its toggled handler, + # which would send the state again: when this runs late, that is an old + # state, and an ESTOP_RESET to a machine already on aborts its motion. + def _show_toggle(self, widget, active): + handler = icon_theme_helper.find_handler_id_by_signal(widget, "toggled") + with widget.handler_block(handler): + widget.set_active(active) + def on_hal_status_state_estop(self, widget=None): - self.widgets.tbtn_estop.set_active(True) + self._show_toggle(self.widgets.tbtn_estop, True) self.widgets.tbtn_estop.set_image(self.widgets.img_emergency) self.widgets.tbtn_on.set_image(self.widgets.img_machine_off) self.widgets.tbtn_on.set_sensitive(False) - self.widgets.tbtn_on.set_active(False) + self._show_toggle(self.widgets.tbtn_on, False) self.command.mode(linuxcnc.MODE_MANUAL) def on_hal_status_state_estop_reset(self, widget=None): - self.widgets.tbtn_estop.set_active(False) + self._show_toggle(self.widgets.tbtn_estop, False) self.widgets.tbtn_estop.set_image(self.widgets.img_emergency_off) self.widgets.tbtn_on.set_image(self.widgets.img_machine_off) self.widgets.tbtn_on.set_sensitive(True) @@ -3132,8 +3140,7 @@ def on_hal_status_state_off(self, widget): "btn_tool_touchoff_x", "btn_tool_touchoff_z" ] self._sensitize_widgets(widgetlist, False) - if self.widgets.tbtn_on.get_active(): - self.widgets.tbtn_on.set_active(False) + self._show_toggle(self.widgets.tbtn_on, False) self.widgets.tbtn_on.set_image(self.widgets.img_machine_off) self.widgets.btn_exit.set_sensitive(True) self.widgets.ntb_main.set_current_page(0) @@ -3148,8 +3155,8 @@ def on_hal_status_state_on(self, widget): "btn_spindle_100", "spc_rapid", "spc_spindle" ] self._sensitize_widgets(widgetlist, True) - if not self.widgets.tbtn_on.get_active(): - self.widgets.tbtn_on.set_active(True) + self._show_toggle(self.widgets.tbtn_on, True) + self._update_widgets(True) self.widgets.tbtn_on.set_image(self.widgets.img_machine_on) self.widgets.btn_exit.set_sensitive(False) if self.widgets.ntb_main.get_current_page() != 0: diff --git a/src/hal/user_comps/gladevcp.py b/src/hal/user_comps/gladevcp.py index a3dd5ddce73..135d0f1b5ef 100644 --- a/src/hal/user_comps/gladevcp.py +++ b/src/hal/user_comps/gladevcp.py @@ -391,6 +391,11 @@ def main(): LOG.debug("Register callback '%s' for SIGINT and SIGTERM" %(signal_func)) signal.signal(signal.SIGTERM, handlers[signal_func]) signal.signal(signal.SIGINT, handlers[signal_func]) + else: + # halcmd unload sends SIGTERM. The KeyboardInterrupt hal makes of + # it is lost when it lands in a GLib callback (the status polls), + # so quit from the main loop instead. + GLib.unix_signal_add(GLib.PRIORITY_HIGH, signal.SIGTERM, Gtk.main_quit) try: Gtk.main() diff --git a/tests/ui-smoke/_lib/checkresult.sh b/tests/ui-smoke/_lib/checkresult.sh index b8760481ab6..122ea2b0739 100755 --- a/tests/ui-smoke/_lib/checkresult.sh +++ b/tests/ui-smoke/_lib/checkresult.sh @@ -3,7 +3,7 @@ # # 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 +# (linuxcnc task ready and stat.poll() still alive once the GUI is up), 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 diff --git a/tests/ui-smoke/_lib/drive.py b/tests/ui-smoke/_lib/drive.py index 3de99ebf67f..037f1c4bc9b 100755 --- a/tests/ui-smoke/_lib/drive.py +++ b/tests/ui-smoke/_lib/drive.py @@ -2,7 +2,9 @@ # 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. +# not crash. The driver only proves the GUI started and NML is reachable; +# with --gui-comp and --gui-window it also waits for the GUI's HAL +# component to be ready and its window to be up. # # --run-program mode (Phase 2): also estop-reset, machine-on, home, # program_open + auto(RUN), wait for sustained INTERP_IDLE, and assert @@ -14,11 +16,12 @@ import argparse import linuxcnc import os +import subprocess import sys import time CONNECT_TIMEOUT_S = 60.0 -SETTLE_S = 3.0 +GUI_READY_TIMEOUT_S = 30.0 SETTLE_POLLS = 5 POLL_INTERVAL_S = 0.01 # Per-attempt wait timeout for ensure_state / ensure_mode. The state @@ -26,21 +29,7 @@ # 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 early so the screenshots stay clean. -# Nothing load-bearing hangs off it: a revert landing after the window -# is caught by the home_all / run_program retry loops instead. -STATE_STABILITY_S = 0.5 STATE_RETRY_BUDGET = 6 -# Pause after homing before requesting AUTO. gmoccapy only enables AUTO -# once it has processed the all-homed signal in its own event loop (and -# re-asserts MANUAL itself on that signal). Requesting AUTO before then is -# rejected: it bounces back to MANUAL with an "It is not possible to -# change to Auto Mode" warning. ensure_mode would retry and win, but the -# warning lingers on screen; this settle lets the GUI catch up first. -POST_HOME_SETTLE_S = 2.0 # 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 @@ -136,6 +125,59 @@ def wait_until_quiet(stat, predicate, timeout): return False +def wait_gui_ready(comp, timeout): + """Wait until the GUI's HAL component reports ready. Each GUI marks + it ready once it has built its widgets and pins, so the driver does + not command the machine while the GUI is still constructing itself.""" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + _watchdog() + try: + out = subprocess.run(["halcmd", "-s", "show", "comp", comp], + capture_output=True, text=True, + timeout=10).stdout + except (OSError, subprocess.SubprocessError): + out = "" + for line in out.splitlines(): + f = line.split() + if len(f) >= 4 and f[2] == comp and f[-1] == "ready": + return True + time.sleep(0.1) + sys.stderr.write( + f"UI_SMOKE_FAIL: GUI component {comp} not ready within {timeout}s\n") + return False + + +def wait_gui_window(pattern, timeout): + """Wait until a top-level window whose xwininfo tree line holds + pattern (its WM_CLASS) is viewable. A GUI maps its window once it + has built itself and runs its main loop; gmoccapy reports its HAL + component ready well before, while it still sends its own startup + commands (a jog stop per axis among them), which abort a homing + started meanwhile.""" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + _watchdog() + try: + tree = subprocess.run(["xwininfo", "-root", "-tree"], + capture_output=True, text=True, + timeout=10).stdout + for line in tree.splitlines(): + if pattern not in line: + continue + info = subprocess.run(["xwininfo", "-id", line.split()[0]], + capture_output=True, text=True, + timeout=10).stdout + if "Map State: IsViewable" in info: + return True + except (OSError, subprocess.SubprocessError, IndexError): + pass + time.sleep(0.1) + sys.stderr.write( + f"UI_SMOKE_FAIL: no viewable window {pattern} within {timeout}s\n") + 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).""" @@ -145,9 +187,10 @@ def wait_until(stat, predicate, timeout, label): return False -# gmoccapy issues its own ESTOP during startup; on a slow runner it can -# land after the state checks passed, rejecting or aborting homing. -HOME_RETRY_BUDGET = 3 +# A GUI acts on the state it last polled, so under load it can undo a +# state the driver just set: gmoccapy has turned the machine off in the +# middle of homing. Such an attempt is retried. +HOME_RETRY_BUDGET = 6 # The sims home in a second or two; cap the per-attempt wait so a # reverted attempt does not sit out the full caller timeout. HOME_ATTEMPT_TIMEOUT_S = 15.0 @@ -164,21 +207,35 @@ def home_all(cmd, stat, timeout): # 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 + if stat.task_state != linuxcnc.STATE_ON: + if not (ensure_state(cmd, stat, linuxcnc.STATE_ESTOP_RESET, + "STATE_ESTOP_RESET") and + ensure_state(cmd, stat, linuxcnc.STATE_ON, "STATE_ON")): + continue if not ensure_mode(cmd, stat, linuxcnc.MODE_MANUAL, "MODE_MANUAL"): - return False + continue cmd.teleop_enable(0) cmd.wait_complete() stat.poll() njoints = stat.joints cmd.home(-1) all_homed = lambda s: all(s.homed[i] for i in range(njoints)) - done = wait_until_quiet(stat, all_homed, attempt_timeout) + # Homing has stopped for good once the machine is off, or once it + # was seen running and no joint is homing or homed any more: an + # abort clears both on every joint, while between two steps of a + # HOME_SEQUENCE some joint is already homed. gmoccapy's late + # reactions to the machine coming on abort homing that way. + seen = [] + def stopped(s): + homing = any(s.joint[i]["homing"] for i in range(njoints)) + if homing: + seen.append(True) + return (s.task_state != linuxcnc.STATE_ON or + (seen and not homing and + not any(s.homed[i] for i in range(njoints)))) + done = wait_until_quiet( + stat, lambda s: all_homed(s) or stopped(s), + attempt_timeout) and all_homed(stat) # 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 @@ -213,54 +270,47 @@ def wait_state(stat, 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.""" + """Issue c.state(target_state) and wait for stat.task_state to reach + target_state, up to STATE_RETRY_BUDGET times. A GUI can still undo + it later; the home_all and run_program retry loops catch that. + Returns False (with a WARN line) on exhausted budget, so the caller + can start over. ON cannot be reached from ESTOP, so a GUI's ESTOP + ends the wait for ON at once.""" + give_up = lambda s: (target_state == linuxcnc.STATE_ON and + s.task_state == linuxcnc.STATE_ESTOP) 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: + if wait_until_quiet( + stat, lambda s: s.task_state == target_state or give_up(s), + ENSURE_ATTEMPT_TIMEOUT_S) and not give_up(stat): return True + if give_up(stat): + sys.stderr.write(f"WARN: {label} undone by ESTOP, starting over\n") + return False 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") + f"WARN: {label} not reached on attempt {attempt}, retrying\n") return False def ensure_mode(cmd, stat, target_mode, label): - """Same retry+stability pattern as ensure_state, for task_mode.""" + """Same retry pattern as ensure_state, for task_mode. A GUI can + switch the mode back right after the driver's request (qtdragon and + gmoccapy switch to MANUAL once they see all-homed); a command newer + than the driver's with the mode not as requested shows that, so ask + again at once.""" 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: + serial = cmd.serial + overridden = lambda s: (s.echo_serial_number > serial and + s.task_mode != target_mode) + if wait_until_quiet( + stat, lambda s: s.task_mode == target_mode or overridden(s), + ENSURE_ATTEMPT_TIMEOUT_S) and not overridden(stat): 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") + f"WARN: {label} not reached on attempt {attempt}, retrying\n") return False @@ -284,13 +334,18 @@ def wait_program_started(stat, timeout): 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). Quiet on timeout so - run_program can retry; the caller decides when it is fatal.""" + run_program can retry; the caller decides when it is fatal. Gives + up at once when a GUI has switched out of AUTO or turned the machine + off, either of which rejects the run.""" deadline = time.monotonic() + timeout while time.monotonic() < deadline: _watchdog() stat.poll() if stat.interp_state != linuxcnc.INTERP_IDLE: return True + if (stat.task_mode != linuxcnc.MODE_AUTO or + stat.task_state != linuxcnc.STATE_ON): + return False time.sleep(POLL_INTERVAL_S) return False @@ -320,27 +375,56 @@ def wait_program_idle(stat, timeout): return False -# Retry budget for the program-start sequence. Same late-GUI-revert -# hole as homing (see HOME_RETRY_BUDGET): a revert to MANUAL landing -# after ensure_mode's stability check leaves AUTO_RUN rejected and the -# program never starts, so retry from the mode change. +# Retry budget for the program run. Same GUI-revert hole as homing +# (see HOME_RETRY_BUDGET): a revert to MANUAL after ensure_mode leaves +# AUTO_RUN rejected or aborts the run, so retry from the mode change. RUN_RETRY_BUDGET = 3 -def run_program(cmd, stat, ngc_path, expect_delta_mm, tol, run_timeout): - """Home, snapshot position, load + run ngc, verify (final - start) - delta matches expect_delta_mm converted to machine units.""" - if not home_all(cmd, stat, timeout=60.0): +def load_and_settle(cmd, ngc_path, post_home_settle): + """Open the program after homing: by then every GUI has opened its + own startup file (axis, touchy), which an earlier open collides + with. Then pause post_home_settle before AUTO. + + gmoccapy makes AUTO selectable only once its own status poll has + processed all-homed, and answers an earlier AUTO with an abort and a + warning that stays on screen. It shows nothing the driver could wait + on (no pin; under load stale reactions send commands too), so give + it this long; a rejection that still happens is retried. The same + pause lets its status poll see the new file while the interp is + IDLE; seen only at AUTO_RUN, the 'file-loaded' emit waits for the + program end and gmoccapy shows a bogus progress percentage.""" + cmd.program_open(ngc_path) + cmd.wait_complete() + time.sleep(post_home_settle) + + +def run_program(cmd, stat, ngc_path, expect_delta_mm, tol, run_timeout, + post_home_settle): + """Home, load ngc, snapshot position, run ngc, verify (final - + start) delta matches expect_delta_mm converted to machine units.""" + if not home_all(cmd, stat, 60.0): return False - - # Let the GUI react to the all-homed transition before requesting AUTO, - # so it does not reject the mode change (see POST_HOME_SETTLE_S). - time.sleep(POST_HOME_SETTLE_S) - stat.poll() + load_and_settle(cmd, ngc_path, post_home_settle) for attempt in range(1, RUN_RETRY_BUDGET + 1): + # A GUI can turn the machine off after homing (see + # HOME_RETRY_BUDGET). The joints stay homed, so unhome them and + # home again: gmoccapy offers AUTO only after it sees all-homed. + stat.poll() + if stat.task_state != linuxcnc.STATE_ON: + sys.stderr.write("WARN: machine went off after homing, homing again\n") + cmd.unhome(-1) + cmd.wait_complete() + if not (wait_until(stat, + lambda s: not any(s.homed[:s.joints]), + ENSURE_ATTEMPT_TIMEOUT_S, "unhomed") and + home_all(cmd, stat, 60.0)): + return False + load_and_settle(cmd, ngc_path, post_home_settle) + if not ensure_mode(cmd, stat, linuxcnc.MODE_AUTO, "MODE_AUTO"): - return False + continue # Snapshot start position AFTER homing + AFTER mode transition. # The GUI might re-issue mode commands during its own startup; @@ -349,25 +433,40 @@ def run_program(cmd, stat, ngc_path, expect_delta_mm, tol, run_timeout): stat.poll() start_pos = stat.position[:3] - cmd.program_open(ngc_path) - cmd.wait_complete() - # Let the GUI's GSTAT poll (~100 ms cycle) see the new file while - # the interp is still IDLE. Without the pause the 'file-loaded' - # emit is deferred until the interp returns to IDLE at program - # end, and gmoccapy loads the file with program.length still 0, - # showing a bogus progress percentage in the confirm shot. - time.sleep(0.4) # 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 IDLE. cmd.auto(linuxcnc.AUTO_RUN, 0) - if wait_program_started(stat, PROGRAM_START_TIMEOUT_S): - break - if attempt < RUN_RETRY_BUDGET: + if not wait_program_started(stat, PROGRAM_START_TIMEOUT_S): + if attempt < RUN_RETRY_BUDGET: + sys.stderr.write( + f"WARN: program did not start on attempt {attempt}, retrying\n") + continue + + 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)] + # A GUI that rejects AUTO after the run began aborts it and goes + # back to MANUAL (gmoccapy); run it again from where it stopped. + if (any(e > tol for e in err) and + stat.task_mode != linuxcnc.MODE_AUTO and + attempt < RUN_RETRY_BUDGET): sys.stderr.write( - f"WARN: program did not start on attempt {attempt}, retrying\n") + f"WARN: run aborted by a mode change on attempt {attempt}, " + f"retrying\n") + continue + break else: stat.poll() sys.stderr.write( @@ -375,18 +474,6 @@ def run_program(cmd, stat, ngc_path, expect_delta_mm, tol, run_timeout): f"{RUN_RETRY_BUDGET} attempts state: {snapshot(stat)}\n") 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 " @@ -419,6 +506,16 @@ def main(): "(default: 1e-4)") ap.add_argument("--run-timeout", type=float, default=60.0, help="program-completion timeout in seconds (default: 60)") + ap.add_argument("--gui-comp", metavar="NAME", + help="HAL component of the GUI; wait for it to be ready " + "before driving the machine") + ap.add_argument("--gui-window", metavar="WM_CLASS", + help="WM_CLASS of the GUI's main window as xwininfo shows " + "it; wait for the window to be viewable") + ap.add_argument("--post-home-settle", type=float, default=0.0, + metavar="SECONDS", + help="pause after homing for a GUI that offers AUTO only " + "once it has seen all joints homed (gmoccapy)") args = ap.parse_args() if args.run_program and args.expect_delta_mm is None: @@ -432,12 +529,12 @@ def main(): 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() + if args.gui_comp and not wait_gui_ready(args.gui_comp, + GUI_READY_TIMEOUT_S): + return 1 + if args.gui_window and not wait_gui_window(args.gui_window, + GUI_READY_TIMEOUT_S): + return 1 # Re-check task is still alive; a GUI crash may have torn linuxcnc # down via Cleanup. @@ -450,7 +547,8 @@ def main(): if args.run_program: if not run_program(cmd, stat, args.run_program, args.expect_delta_mm, - args.tol, args.run_timeout): + args.tol, args.run_timeout, + args.post_home_settle): return 1 except LauncherGone: sys.stderr.write( diff --git a/tests/ui-smoke/_lib/launch.sh b/tests/ui-smoke/_lib/launch.sh index fafefbb30ca..76e1f379168 100755 --- a/tests/ui-smoke/_lib/launch.sh +++ b/tests/ui-smoke/_lib/launch.sh @@ -31,7 +31,7 @@ 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). +# + GUI ready (30s) + optional Phase 2 run (estop/home/program ~90s). LINUXCNC_TIMEOUT=300 DRIVER_TIMEOUT=180 @@ -99,7 +99,9 @@ xvfb-run -a --server-args="-screen 0 $UI_SMOKE_XVFB_SCREEN" \ case " $* " in *" --run-program "*) # Wait for the UI to stop changing before keeping the - # shot, so a slow startup is not captured half-built. + # shot, so a slow startup is not captured half-built, + # and for the splash to close. + screenshot_wait_splash_gone screenshot_grab_settled confirm.png # Compare the confirm shot to the committed known-good # reference and write the diffs. Never fails the test. @@ -120,9 +122,9 @@ xvfb-run -a --server-args="-screen 0 $UI_SMOKE_XVFB_SCREEN" \ fi kill -TERM -- -"$LINUXCNC_PID" 2>/dev/null || true - for _ in $(seq 60); do + for _ in $(seq 600); do kill -0 "$LINUXCNC_PID" 2>/dev/null || break - sleep 1 + sleep .1 done if kill -0 "$LINUXCNC_PID" 2>/dev/null; then echo "WARN: linuxcnc did not exit on SIGTERM, escalating to KILL" >&2 diff --git a/tests/ui-smoke/_lib/screenshot.sh b/tests/ui-smoke/_lib/screenshot.sh index 45cc70b1ba3..06767994d08 100644 --- a/tests/ui-smoke/_lib/screenshot.sh +++ b/tests/ui-smoke/_lib/screenshot.sh @@ -85,12 +85,37 @@ screenshot_grab_qt() { # Differing-pixel count between two PNGs via ImageMagick; empty if neither # entry point is present (the settle loop then just keeps the last grab). +# Newer ImageMagick prints "362.205 (0.000174675)", older a bare count; +# keep the whole pixels of the first number. _screenshot_ae() { if command -v magick >/dev/null 2>&1; then - magick compare -metric AE "$1" "$2" null: 2>&1 + ae="$(magick compare -metric AE "$1" "$2" null: 2>&1)" elif command -v compare >/dev/null 2>&1; then - compare -metric AE "$1" "$2" null: 2>&1 + ae="$(compare -metric AE "$1" "$2" null: 2>&1)" + else + return 0 fi + ae="${ae%% *}" + echo "${ae%%.*}" +} + +# Wait for the LinuxCNC splash (popimage), which closes itself 5 s after +# startup; a fast run reaches the confirm shot before it goes, and it +# would cover the GUI. Gives up after 20 s; never fails a test. +screenshot_wait_splash_gone() { + for _ in $(seq 100); do + splash_up= + for w in $(xwininfo -root -tree 2>/dev/null \ + | grep -F '("popimage" "Popimage")' \ + | grep -oE '^ +0x[0-9a-f]+'); do + xwininfo -id "$w" 2>/dev/null | grep -q "Map State: IsViewable" \ + && splash_up=1 + done + [ -z "$splash_up" ] && return 0 + sleep 0.2 + done + echo "screenshot: splash still up after 20 s" + return 0 } # Confirm-shot grab that waits for the UI to stop changing before keeping it. diff --git a/tests/ui-smoke/axis/frame24.png b/tests/ui-smoke/axis/frame24.png new file mode 100644 index 00000000000..d466366512f Binary files /dev/null and b/tests/ui-smoke/axis/frame24.png differ diff --git a/tests/ui-smoke/axis/frame25.png b/tests/ui-smoke/axis/frame25.png new file mode 100644 index 00000000000..d466366512f Binary files /dev/null and b/tests/ui-smoke/axis/frame25.png differ diff --git a/tests/ui-smoke/axis/test.sh b/tests/ui-smoke/axis/test.sh index ba4fddfc6d0..bf2680a8710 100755 --- a/tests/ui-smoke/axis/test.sh +++ b/tests/ui-smoke/axis/test.sh @@ -1,4 +1,5 @@ #!/bin/bash LIB_DIR="$(cd "$(dirname "$0")/../_lib" && pwd)" exec "$LIB_DIR/run-gui.sh" axis/axis.ini \ + --gui-comp axisui --gui-window '("axis" "Axis")' \ --run-program "$LIB_DIR/smoke.ngc" --expect-delta-mm 1,1,0 diff --git a/tests/ui-smoke/gmoccapy/frame19.png b/tests/ui-smoke/gmoccapy/frame19.png new file mode 100644 index 00000000000..05e3e1fb424 Binary files /dev/null and b/tests/ui-smoke/gmoccapy/frame19.png differ diff --git a/tests/ui-smoke/gmoccapy/frame20.png b/tests/ui-smoke/gmoccapy/frame20.png new file mode 100644 index 00000000000..8a89d113d8e Binary files /dev/null and b/tests/ui-smoke/gmoccapy/frame20.png differ diff --git a/tests/ui-smoke/gmoccapy/frame21.png b/tests/ui-smoke/gmoccapy/frame21.png new file mode 100644 index 00000000000..552afbea800 Binary files /dev/null and b/tests/ui-smoke/gmoccapy/frame21.png differ diff --git a/tests/ui-smoke/gmoccapy/frame22.png b/tests/ui-smoke/gmoccapy/frame22.png new file mode 100644 index 00000000000..b75c9ce02a0 Binary files /dev/null and b/tests/ui-smoke/gmoccapy/frame22.png differ diff --git a/tests/ui-smoke/gmoccapy/frame23.png b/tests/ui-smoke/gmoccapy/frame23.png new file mode 100644 index 00000000000..f492e4ae75f Binary files /dev/null and b/tests/ui-smoke/gmoccapy/frame23.png differ diff --git a/tests/ui-smoke/gmoccapy/frame24.png b/tests/ui-smoke/gmoccapy/frame24.png new file mode 100644 index 00000000000..1683d0b8ea1 Binary files /dev/null and b/tests/ui-smoke/gmoccapy/frame24.png differ diff --git a/tests/ui-smoke/gmoccapy/frame25.png b/tests/ui-smoke/gmoccapy/frame25.png new file mode 100644 index 00000000000..a6093572645 Binary files /dev/null and b/tests/ui-smoke/gmoccapy/frame25.png differ diff --git a/tests/ui-smoke/gmoccapy/test.sh b/tests/ui-smoke/gmoccapy/test.sh index 63dbfee3abe..deddaa763a6 100755 --- a/tests/ui-smoke/gmoccapy/test.sh +++ b/tests/ui-smoke/gmoccapy/test.sh @@ -2,4 +2,6 @@ LIB_DIR="$(cd "$(dirname "$0")/../_lib" && pwd)" . "$LIB_DIR/gmoccapy-prepare.sh" exec "$LIB_DIR/run-gui.sh" "$GMOCCAPY_INI" \ + --gui-comp gmoccapy --gui-window '("gmoccapy" "Gmoccapy")' \ + --post-home-settle 2 \ --run-program "$LIB_DIR/smoke.ngc" --expect-delta-mm 1,1,0 diff --git a/tests/ui-smoke/qtdragon/test.sh b/tests/ui-smoke/qtdragon/test.sh index 812e99c7e89..af41f0d3f53 100755 --- a/tests/ui-smoke/qtdragon/test.sh +++ b/tests/ui-smoke/qtdragon/test.sh @@ -5,4 +5,5 @@ LIB_DIR="$(cd "$(dirname "$0")/../_lib" && pwd)" . "$LIB_DIR/qtdragon-prepare.sh" exec "$LIB_DIR/run-gui.sh" "$QTDRAGON_INI" \ + --gui-comp qtdragon \ --run-program "$LIB_DIR/smoke.ngc" --expect-delta-mm 1,1,0 diff --git a/tests/ui-smoke/touchy/frame24.png b/tests/ui-smoke/touchy/frame24.png new file mode 100644 index 00000000000..5f5bb67f96d Binary files /dev/null and b/tests/ui-smoke/touchy/frame24.png differ diff --git a/tests/ui-smoke/touchy/frame25.png b/tests/ui-smoke/touchy/frame25.png new file mode 100644 index 00000000000..5f5bb67f96d Binary files /dev/null and b/tests/ui-smoke/touchy/frame25.png differ diff --git a/tests/ui-smoke/touchy/test.sh b/tests/ui-smoke/touchy/test.sh index 831fe81b346..96f37cb744b 100755 --- a/tests/ui-smoke/touchy/test.sh +++ b/tests/ui-smoke/touchy/test.sh @@ -1,4 +1,5 @@ #!/bin/bash LIB_DIR="$(cd "$(dirname "$0")/../_lib" && pwd)" exec "$LIB_DIR/run-gui.sh" touchy/touchy.ini \ + --gui-comp touchy --gui-window '("touchy" "Touchy")' \ --run-program "$LIB_DIR/smoke.ngc" --expect-delta-mm 1,1,0 diff --git a/tests/workplane-retain/expected b/tests/workplane-retain/clear/expected similarity index 50% rename from tests/workplane-retain/expected rename to tests/workplane-retain/clear/expected index ca9601e7802..b5baf9e0ca3 100644 --- a/tests/workplane-retain/expected +++ b/tests/workplane-retain/clear/expected @@ -1,10 +1,3 @@ -RETAIN_WORK_PLANE = 1 -G55 in the plane: plane kept, G55, at 15 20 5 -M2: plane kept, G54, at 10 20 5 -abort, read ahead in the next plane: plane kept, G54, at 10 20 5 -abort, read ahead past G55: plane kept, G54, at 10 20 5 -abort, read ahead in a plane on G55: plane cancelled, G54, at 0 0 0 -abort, G52 in the plane: plane kept, G54, at 10 20 5 RETAIN_WORK_PLANE = 0 G55 in the plane: plane kept, G55, at 15 20 5 M2: plane cancelled, G54, at 0 0 0 diff --git a/tests/workplane-retain/clear/test.ini b/tests/workplane-retain/clear/test.ini new file mode 100644 index 00000000000..356e3ccf331 --- /dev/null +++ b/tests/workplane-retain/clear/test.ini @@ -0,0 +1,5 @@ +#INCLUDE ../workplane.inc + +[RS274NGC] +RS274NGC_STARTUP_CODE = G17 G20 G40 G49 G54 G64P0.005 G80 G90 G94 G97 M5 M9 +PARAMETER_FILE = test.var diff --git a/tests/workplane-retain/test.sh b/tests/workplane-retain/clear/test.sh similarity index 81% rename from tests/workplane-retain/test.sh rename to tests/workplane-retain/clear/test.sh index d7a8f21483e..0317460472d 100755 --- a/tests/workplane-retain/test.sh +++ b/tests/workplane-retain/clear/test.sh @@ -1,5 +1,4 @@ #!/bin/bash -e rm -f result test.var test.var.bak linuxcnc -r test.ini -linuxcnc -r clear.ini rm -f test.var test.var.bak diff --git a/tests/workplane-retain/retain/expected b/tests/workplane-retain/retain/expected new file mode 100644 index 00000000000..207e5ef33b2 --- /dev/null +++ b/tests/workplane-retain/retain/expected @@ -0,0 +1,7 @@ +RETAIN_WORK_PLANE = 1 +G55 in the plane: plane kept, G55, at 15 20 5 +M2: plane kept, G54, at 10 20 5 +abort, read ahead in the next plane: plane kept, G54, at 10 20 5 +abort, read ahead past G55: plane kept, G54, at 10 20 5 +abort, read ahead in a plane on G55: plane cancelled, G54, at 0 0 0 +abort, G52 in the plane: plane kept, G54, at 10 20 5 diff --git a/tests/workplane-retain/retain/test.ini b/tests/workplane-retain/retain/test.ini new file mode 100644 index 00000000000..cacd99a370a --- /dev/null +++ b/tests/workplane-retain/retain/test.ini @@ -0,0 +1,6 @@ +#INCLUDE ../workplane.inc + +[RS274NGC] +RS274NGC_STARTUP_CODE = G17 G20 G40 G49 G54 G64P0.005 G80 G90 G94 G97 M5 M9 +PARAMETER_FILE = test.var +RETAIN_WORK_PLANE = 1 diff --git a/tests/workplane-retain/retain/test.sh b/tests/workplane-retain/retain/test.sh new file mode 100644 index 00000000000..0317460472d --- /dev/null +++ b/tests/workplane-retain/retain/test.sh @@ -0,0 +1,4 @@ +#!/bin/bash -e +rm -f result test.var test.var.bak +linuxcnc -r test.ini +rm -f test.var test.var.bak diff --git a/tests/workplane-retain/test-ui.py b/tests/workplane-retain/test-ui.py index 93f3c418bd8..d85f7bc8926 100755 --- a/tests/workplane-retain/test-ui.py +++ b/tests/workplane-retain/test-ui.py @@ -3,7 +3,8 @@ # After an abort the plane is the one the machine was in, not the one the # read ahead had reached; each case checks it by moving to the plane's # origin, which the interpreter only finds if it agrees with the machine. -# Run once with the setting and once without. +# retain/ runs it with the setting and clear/ without, each a test of its +# own that includes the shared settings in workplane.inc. import os, sys, time import linuxcnc, linuxcnc_util @@ -53,6 +54,8 @@ def wait_for(done, timeout=30): time.sleep(0.05) def run(prog, abort=False): + # the programs sit next to this script, above the test directory + prog = os.path.join(os.path.dirname(os.path.abspath(__file__)), prog) c.mode(linuxcnc.MODE_AUTO) c.wait_complete(30) c.program_open(prog) diff --git a/tests/workplane-retain/test.ini b/tests/workplane-retain/test.ini deleted file mode 100644 index 2fd2850e903..00000000000 --- a/tests/workplane-retain/test.ini +++ /dev/null @@ -1,106 +0,0 @@ -[EMC] -VERSION = 1.1 -MACHINE = workplane_retain - DEBUG = 0 - -[DISPLAY] -DISPLAY = ./test-ui.py - -[FILTER] - -[TASK] -TASK = milltask -CYCLE_TIME = 0.001 - -[RS274NGC] -RS274NGC_STARTUP_CODE = G17 G20 G40 G49 G54 G64P0.005 G80 G90 G94 G97 M5 M9 -PARAMETER_FILE = test.var -RETAIN_WORK_PLANE = 1 - -[PYTHON] - -[EMCMOT] -EMCMOT = motmod -COMM_TIMEOUT = 1.0 -BASE_PERIOD = 600000 -SERVO_PERIOD = 1000000 - -[EMCIO] -TOOL_TABLE = tool.tbl -TOOL_CHANGE_POSITION = 0 0 2 - -[HAL] -HALUI = halui -HALFILE = LIB:core_sim.hal -HALFILE = LIB:simulated_home.hal -HALFILE = LIB:lathe.hal - -[TRAJ] -COORDINATES = X Y Z A -LINEAR_UNITS = inch -ANGULAR_UNITS = degree -DEFAULT_LINEAR_VELOCITY = 50.0 -DEFAULT_LINEAR_ACCEL = 500.0 -DEFAULT_ANGULAR_VELOCITY = 45.0 -MAX_LINEAR_VELOCITY = 200.0 -MAX_LINEAR_ACCEL = 250.0 -MAX_ANGULAR_VELOCITY = 90.0 - -[KINS] -KINEMATICS = trivkins coordinates=XYZA -JOINTS = 4 - -[AXIS_X] -MIN_LIMIT = -250.0 -MAX_LIMIT = 250.0 -MAX_VELOCITY = 100 -MAX_ACCELERATION = 1250 - -[AXIS_Y] -MIN_LIMIT = -250.0 -MAX_LIMIT = 250.0 -MAX_VELOCITY = 100 -MAX_ACCELERATION = 1250 - -[AXIS_Z] -MIN_LIMIT = -50.0 -MAX_LIMIT = 100.0 -MAX_VELOCITY = 100 -MAX_ACCELERATION = 1250 - -[AXIS_A] -MIN_LIMIT = -360.0 -MAX_LIMIT = 360.0 -MAX_VELOCITY = 90.0 -MAX_ACCELERATION = 1200.0 - - -[JOINT_0] -TYPE = LINEAR -MAX_VELOCITY = 100 -MAX_ACCELERATION = 1250 -MIN_LIMIT = -250.0 -MAX_LIMIT = 250.0 -HOME_SEQUENCE = 0 - -[JOINT_1] -TYPE = LINEAR -MAX_VELOCITY = 100 -MAX_ACCELERATION = 1250 -MIN_LIMIT = -250.0 -MAX_LIMIT = 250.0 -HOME_SEQUENCE = 0 - -[JOINT_2] -TYPE = LINEAR -MAX_VELOCITY = 100 -MAX_ACCELERATION = 1250 -MIN_LIMIT = -50.0 -MAX_LIMIT = 100.0 -HOME_SEQUENCE = 0 - -[JOINT_3] -TYPE = ANGULAR -MAX_VELOCITY = 90.0 -MAX_ACCELERATION = 1200.0 -HOME_SEQUENCE = 0 diff --git a/tests/workplane-retain/clear.ini b/tests/workplane-retain/workplane.inc similarity index 90% rename from tests/workplane-retain/clear.ini rename to tests/workplane-retain/workplane.inc index 891b931cef0..560f061c49b 100644 --- a/tests/workplane-retain/clear.ini +++ b/tests/workplane-retain/workplane.inc @@ -1,10 +1,10 @@ [EMC] VERSION = 1.1 -MACHINE = workplane_clear +MACHINE = workplane DEBUG = 0 [DISPLAY] -DISPLAY = ./test-ui.py +DISPLAY = ../test-ui.py [FILTER] @@ -12,10 +12,6 @@ DISPLAY = ./test-ui.py TASK = milltask CYCLE_TIME = 0.001 -[RS274NGC] -RS274NGC_STARTUP_CODE = G17 G20 G40 G49 G54 G64P0.005 G80 G90 G94 G97 M5 M9 -PARAMETER_FILE = test.var - [PYTHON] [EMCMOT]