From 84a231163a5314056a09f6410b63a5ba35dc02c1 Mon Sep 17 00:00:00 2001 From: ScottW514 Date: Sat, 17 Oct 2026 18:53:44 -0400 Subject: [PATCH] laser_lifecycle_test: a ^X during the web-service homing keeps the lens hold Rule 18 (home-reset) sends a ^X during the web-service homing session and reads what the reset leaves. The session runs with homing_mode = gfcloud and a session command that sleeps (sleep 47.25, a command no other process on the host runs), so the device is the session's for as long as the rule wants it; Session drops GFSINK from the inherited environment, which is what lets gfcloud_home_cmd run. A reset during the session marks the position lost: the device is the session's, and nothing is read back. Left set, the core's re-init clears the homed mask, and the driver's Z-only hold with it, so a jog after $X would move an unreferenced lens. The rule reads: - before the session, a 1 mm Z jog is refused with error 15: the lens has no reference in this session, so it is held where it stands; - $H reports Home and the session's command is running; - the ^X 0.5 s later lands in Alarm with ALARM:6, the session's command is gone within 3 s, and the attribute log holds no new "cnc/stop 1": the device was the session's, so the stream owed the kernel no stop; - $X recovers to Idle; the Z jog is refused with error 15 again and MPos Z has not moved; - a program move G91 G1 Z1 F100 raises ALARM:2, the soft limit of the pinned lens; a ^X and $X then leave the controller Idle. The verdicts are the error code, the alarm codes, MPos Z, the attribute count and the process count; no log wording is judged. Session gets mpos_z(), the Z of a status report, beside mpos_x(). The soft-limit alarm asks for a reset before $X unlocks it, and the rule sends that ^X only once a status report has come from the alarm's wait: a ^X on the heels of the ALARM line lands while the soft limit's own reset is still pending and is folded into it. Measured on the null sink, a ^X 0 ms or 20 ms after the ALARM line is lost (the alarm's wait goes on, $X answers error 79) and one 100 ms after it is taken. The '?' polls that wait for the reset banner keep the controller's output flowing: in Alarm its protocol thread waits up to 10 s for traffic before it drains its output. Proof: python3 -m py_compile and pyflakes 4.0.3 are clean on scripts/bench/laser_lifecycle_test.py. Against the null-sink build of the grblHAL-glowforge default branch head, which carries "Stream engine: a reset whose position stays lost pins the lens before the core's re-init", configured as its CI configures it (-DGFMCODE_WAIT_S=5.0), rule 18 passes 10 runs in 10 and the harness passes whole, with no session command left running. Against the build of that change's parent ("Stream engine: the step a reset owes is taken only when the idle lands between interrupt callbacks") rule 18 fails 3 runs in 3, each at the Z jog after $X: the parent answers ok and the lens moves to Z 1.027. The harness runs in grblHAL-glowforge CI, step "Armed-window lifecycle" (python3 forgefirm/scripts/bench/laser_lifecycle_test.py build/grblHAL_glowforge), from the head of this repository's default branch: this commit is pushed before that driver change. Acceptance: none. scripts/bench/ is outside every covers map. --- scripts/bench/laser_lifecycle_test.py | 106 ++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) diff --git a/scripts/bench/laser_lifecycle_test.py b/scripts/bench/laser_lifecycle_test.py index e906135..e0591c5 100644 --- a/scripts/bench/laser_lifecycle_test.py +++ b/scripts/bench/laser_lifecycle_test.py @@ -73,6 +73,11 @@ reported messages: shipped stream (GFSINK_DUMP) moved, an unreferenced lens is still held after $X, and the head does not return to the job start; $X recovers to Idle and the next jog ships steps + 18. a ^X during the web-service homing session keeps the lens hold: the + session's command is ended, the stream owes no stop, the reset lands + in Alarm, and after $X a Z jog is still refused (error 15) with MPos + Z unmoved, while a program Z move hits the pinned lens's soft limit + (ALARM:2) The disarm grace is shortened via a temp config (GFHOME_CONF), the cooling verdict is published hermetically (GF_VERDICT_FILE), the same @@ -337,6 +342,12 @@ class Session: m = re.findall(r"MPos:(-?[\d.]+)", "".join(self.log[-4:])) return float(m[-1]) if m else None + def mpos_z(self): + self.sock.sendall(b"?") + read_avail(self.sock, self.log, 0.3) + m = re.findall(r"MPos:-?[\d.]+,-?[\d.]+,(-?[\d.]+)", "".join(self.log[-4:])) + return float(m[-1]) if m else None + def setting(self, n): """A $-setting's value, from a $$ dump.""" mark = len(self.log) @@ -993,6 +1004,100 @@ def test_cancel_and_abort(): s.close() +HOME_SESSION_CMD = "sleep 47.25" + + +def home_sessions(): + """How many processes of the web-service homing session's command are + running (the command is one no other process on the host uses).""" + r = subprocess.run(["pgrep", "-f", HOME_SESSION_CMD], capture_output=True, text=True) + return len(r.stdout.split()) + + +def test_homing_abort_keeps_z_hold(): + """Rule 18: a ^X during the web-service homing session keeps the lens + hold. The session owns the device while it runs, so the reset reads + nothing back and the position stays lost; the lens, unreferenced in + this session, must still be held where it stands once $X has cleared + the alarm. The session's command is ended by the reset, and the stream + owes the kernel no stop. A program Z move then hits the pinned lens's + soft limit.""" + s = Session("home-reset", disarm_s=60, attr_log=True, + conf_extra="homing_mode = gfcloud\ngfcloud_home_cmd = %s\n" % HOME_SESSION_CMD) + try: + if home_sessions(): + fail("[home-reset] a homing session's command is already running on the host") + mark = len(s.log) + if send_line(s.sock, "$J=G91Z1F100", s.log, tolerate_error=True) != "error" or \ + "error:15" not in "".join(s.log[mark:]): + fail("[home-reset] the lens is not held before the session: a Z jog was not refused") + send_line(s.sock, "", s.log) + z0 = s.mpos_z() + if z0 is None: + fail("[home-reset] no position in the status report") + stops = s.attr_count("cnc/stop 1") + s.send_raw("$H") + if not s.wait_state("Home", 5): + fail("[home-reset] $H never reported Home (state %s)" % s.state()) + time.sleep(0.5) + if not home_sessions(): + fail("[home-reset] the homing session's command is not running") + mark = len(s.log) + s.sock.sendall(b"\x18") + if not s.wait_state("Alarm", 8): + fail("[home-reset] ^X during the homing session did not land in Alarm (state %s)" + % s.state()) + read_avail(s.sock, s.log, 0.5) + if "ALARM:6" not in "".join(s.log[mark:]): + fail("[home-reset] the reset during homing did not raise ALARM:6") + end = time.time() + 3 + while home_sessions() and time.time() < end: + time.sleep(0.1) + if home_sessions(): + fail("[home-reset] the homing session's command outlived the reset") + if s.attr_count("cnc/stop 1") != stops: + fail("[home-reset] the reset during the session wrote cnc/stop: the stream owed no stop") + send_line(s.sock, "$X", s.log) + if not s.wait_state("Idle", 5): + fail("[home-reset] $X did not recover to Idle (state %s)" % s.state()) + mark = len(s.log) + if send_line(s.sock, "$J=G91Z1F100", s.log, tolerate_error=True) != "error" or \ + "error:15" not in "".join(s.log[mark:]): + fail("[home-reset] a Z jog after the reset was not refused: the lens hold was dropped") + time.sleep(0.6) + z1 = s.mpos_z() + if z1 is None or abs(z1 - z0) > 0.001: + fail("[home-reset] the lens moved after the reset (Z %s, was %.3f)" % (z1, z0)) + send_line(s.sock, "", s.log) + mark = len(s.log) + s.send_raw("G91 G1 Z1 F100") + if read_avail(s.sock, s.log, 5.0, until=("ALARM:", "ok")) != "ALARM:" or \ + "ALARM:2" not in "".join(s.log[mark:]): + fail("[home-reset] a program Z move did not hit the pinned lens's soft limit (ALARM:2)") + # The soft-limit alarm asks for a reset before $X unlocks it: one + # sent once a status report has come from the alarm's wait. A ^X + # on the heels of the ALARM line lands while the soft limit's own + # reset is still pending and is folded into it. The '?' polls keep + # the controller's output flowing while it waits for the banner. + if not s.wait_state("Alarm", 5): + fail("[home-reset] no status report from the soft-limit alarm (state %s)" % s.state()) + banners = s.count("GrblHAL") + s.sock.sendall(b"\x18") + end = time.time() + 5 + while s.count("GrblHAL") <= banners and time.time() < end: + s.state() + if s.count("GrblHAL") <= banners: + fail("[home-reset] no reset banner after the ^X that clears the soft limit") + send_line(s.sock, "$X", s.log) + if not s.wait_state("Idle", 5): + fail("[home-reset] $X after the soft limit did not recover to Idle (state %s)" % s.state()) + print("PASS [home-reset]: ^X during the homing session ended its command, wrote no " + "cnc/stop, landed in ALARM:6; after $X the Z jog was refused with Z at %.3f and " + "a program Z move raised ALARM:2" % z1) + finally: + s.close() + + def test_lid_cancel_from_hold(): """Rule 16: a job paused on the button is not resumable past a lid open. The lid cancels it from the hold as it does from Run - the @@ -1134,6 +1239,7 @@ def main(): test_lid_cancels_and_returns() test_pause_then_lid_cancel_returns_to_the_job_start() test_cancel_and_abort() + test_homing_abort_keeps_z_hold() test_lid_cancel_from_hold() test_lid_policy_hold() test_sigterm_mid_job()