Hold the job start across a pause in the lifecycle harness

laser.pause-resume-lid-cancel failed on the bench with the head 14.925 mm
along the first leg after the lid cancel: the driver took every resume
from a feed hold as a new job start (the core restarts a held cycle
through Idle), so the cancel returned the head to the pause point and
said "returned to the job start" about it. The driver fix is in
grblHAL-glowforge; this is its proof and the record.

The null-sink lifecycle harness gains rule 9: a job paused and resumed by
the button, then cancelled by the lid, returns to where the job began
(reproduces the bench failure to the millimeter on the old driver,
X=13.088 instead of 0); and a job abandoned in a hold and soft-reset is
over, so the next job from that spot returns to that spot. The campaign
log carries the dated account.

No catalog consequence: the acceptance test measured exactly this and
stays as it is; the harness is the CI side of the same contract.
This commit is contained in:
ScottW514
2026-08-21 13:00:56 -04:00
parent 54e1689889
commit 9c22240480
2 changed files with 110 additions and 0 deletions
+33
View File
@@ -3167,6 +3167,39 @@ that only showed up when a real 53 MB job was replayed through a fake ring
rather than a synthetic one. The job that hung the bench is kept as the
regression fixture.
## 2026-08-21: a lid cancel that went back to the wrong place
`laser.pause-resume-lid-cancel` failed its first run on the dev image of
2026-08-21 with "head not back at the job start (drift 14.925 mm)", and at the
bench the job had looked right: the button paused the cut, the button resumed
it, the lid stopped it, and the head came back. It came back to the wrong
place. The kernel counters agreed with the controller's own position report:
Y exactly where the job began, X 14.925 mm along the first leg, which at F200
is about four and a half seconds of cutting, the moment the pause landed.
**The controller had told the truth by its own bookkeeping.** The driver takes
the job start as the machine position at the Idle to Cycle transition, and the
grblHAL core restarts a held cycle by passing through Idle: `state_await_resume`
sets Idle and then Cycle back to back, so every resume from a feed hold was
recorded as a new job beginning where the hold had stopped. The lid cancel then
returned the head to the pause point and reported "returned to the job start",
which was exactly what it had written down.
**The fix is a definition.** A job is under way from that first transition
until the core is Idle with the planner empty (the program ran out, a stop, a
reset) or in an alarm. A resume passes through Idle with the planner still
loaded, so it is the same job and keeps its start; a job abandoned in a hold
and reset is over, and the next one starts where it starts. Both sides are
held by the null-sink lifecycle harness now, which reproduced the bench
failure to the millimeter (returned to X=13.088, the pause point) before the
fix and returns to X=0.000 after it.
**Why the acceptance test caught it and the eye did not:** the test measures
the return against the position it recorded before the job, not against the
controller's message. An operator watching the head come back has no such
reference, and fifteen millimeters on a forty millimeter square reads as
"back". The test stays as it is.
## Superseded status notes
### Shared machine services — remaining polish, as listed 2026-08-13
+77
View File
@@ -28,6 +28,11 @@ reported messages:
reset with the position kept (no alarm), the head returns to the
job start on its own; with lid_policy = hold the stock door hold and
cycle-start resume apply
9. the job start survives a pause: a job paused and resumed by the
button, then cancelled by the lid, returns to where the job began,
not to where it was paused (the core restarts a held cycle through
Idle); a job abandoned in a hold and reset ends there, so the next
job's start is captured afresh where it begins
The disarm grace is shortened via a temp config (GFHOME_CONF), the
cooling verdict is published hermetically (GF_VERDICT_FILE), the same
@@ -546,6 +551,77 @@ def test_lid_cancels_and_returns():
s.close()
def test_pause_then_lid_cancel_returns_to_the_job_start():
"""Rule 9: the button pause + resume does not move the job start. The
lid cancel after a resume returns the head to where the job began;
the pause point is not a job start. Then the other side: a job left
in a hold and soft-reset is over, and the next job from that spot
returns to that spot, not to the earlier job's start."""
s = Session("pause-lid-cancel", disarm_s=60, switches=SW_CLOSED)
try:
start_armed_move(s, "pause-lid-cancel", gcode="G1 X30 F600")
time.sleep(0.6)
s.press_button()
if not s.wait_state("Hold", 3):
fail("[pause-lid-cancel] the press did not feed-hold (state %s)" % s.state())
time.sleep(0.5)
x_hold = s.mpos_x()
if x_hold is None or x_hold < 1.0:
fail("[pause-lid-cancel] the hold landed too close to the start to tell (X=%s)" % x_hold)
s.press_button()
if not s.wait_state("Run", 3):
fail("[pause-lid-cancel] the second press did not resume (state %s)" % s.state())
time.sleep(0.4)
s.set_switches(SW_LID_OPEN)
if not wait_for(s.log, "lid opened - job cancelled", 5, s.sock):
fail("[pause-lid-cancel] the lid open did not cancel the job")
if not wait_for(s.log, "returned to the job start", 15, s.sock):
fail("[pause-lid-cancel] the head did not report returning to the job start")
if not s.wait_state("Idle", 5):
fail("[pause-lid-cancel] not Idle after the return (state %s)" % s.state())
x_end = s.mpos_x()
if x_end is None or abs(x_end) > 0.05:
fail("[pause-lid-cancel] the head returned to X=%s, not to the job start X=0 "
"(paused at X=%.3f: the resume was taken as a new job start)" % (x_end, x_hold))
print("PASS [pause-lid-cancel]: paused at X=%.3f, resumed, lid cancel returned to X=%.3f"
% (x_hold, x_end))
s.close()
# A job abandoned in a hold and reset is over: the next job starts
# where it starts.
s = Session("hold-reset-restart", disarm_s=60, switches=SW_CLOSED)
start_armed_move(s, "hold-reset-restart", gcode="G1 X30 F600")
time.sleep(0.6)
s.press_button()
if not s.wait_state("Hold", 3):
fail("[hold-reset-restart] the press did not feed-hold (state %s)" % s.state())
time.sleep(0.5)
s.sock.sendall(b"\x18")
if not wait_for(s.log, "for help]", 5, s.sock):
fail("[hold-reset-restart] no reset banner")
if not s.wait_state("Idle", 5):
fail("[hold-reset-restart] not Idle after the reset (state %s)" % s.state())
x_new_start = s.mpos_x()
if x_new_start is None or x_new_start < 1.0:
fail("[hold-reset-restart] the reset did not keep the position (X=%s)" % x_new_start)
read_avail(s.sock, s.log, 0.3)
start_armed_move(s, "hold-reset-restart", gcode="G1 X60 F600") # absolute: past the old start
time.sleep(0.5)
s.set_switches(SW_LID_OPEN)
if not wait_for(s.log, "returned to the job start", 15, s.sock):
fail("[hold-reset-restart] no return to the job start after the lid cancel")
if not s.wait_state("Idle", 5):
fail("[hold-reset-restart] not Idle after the return (state %s)" % s.state())
x_end = s.mpos_x()
if x_end is None or abs(x_end - x_new_start) > 0.05:
fail("[hold-reset-restart] the head returned to X=%s, not to this job's start X=%.3f "
"(the earlier job's start survived the reset)" % (x_end, x_new_start))
print("PASS [hold-reset-restart]: a job reset from a hold ended there; the next job "
"returned to its own start X=%.3f" % x_end)
finally:
s.close()
def test_lid_policy_hold():
"""lid_policy = hold keeps stock grblHAL behavior: the lid parks the job
in Door and a cycle start resumes it once closed."""
@@ -580,6 +656,7 @@ def main():
test_lid_open_in_wait()
test_button_pause_resume()
test_lid_cancels_and_returns()
test_pause_then_lid_cancel_returns_to_the_job_start()
test_lid_policy_hold()
test_verdict_blocks_arm()
test_sigterm_mid_job()