From 9c222404804890e737a836a1d0f7d27e99d85c60 Mon Sep 17 00:00:00 2001 From: ScottW514 Date: Fri, 21 Aug 2026 13:00:56 -0400 Subject: [PATCH] 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. --- docs/CAMPAIGN-LOG.md | 33 ++++++++++++ scripts/bench/laser_lifecycle_test.py | 77 +++++++++++++++++++++++++++ 2 files changed, 110 insertions(+) diff --git a/docs/CAMPAIGN-LOG.md b/docs/CAMPAIGN-LOG.md index a181236..2a67b46 100644 --- a/docs/CAMPAIGN-LOG.md +++ b/docs/CAMPAIGN-LOG.md @@ -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 diff --git a/scripts/bench/laser_lifecycle_test.py b/scripts/bench/laser_lifecycle_test.py index f43a75c..0a4363f 100644 --- a/scripts/bench/laser_lifecycle_test.py +++ b/scripts/bench/laser_lifecycle_test.py @@ -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()