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.
This commit is contained in:
ScottW514
2026-10-17 18:53:44 -04:00
parent c978e76b4c
commit 84a231163a
+106
View File
@@ -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()