Files
linuxcncAllWasm/tests/ui-smoke/_lib/drive.py
2026-08-20 06:17:39 +08:00

409 lines
15 KiB
Python
Executable File

#!/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())