diff --git a/docs/BRINGUP.md b/docs/BRINGUP.md index 37bae06..ed029ec 100644 --- a/docs/BRINGUP.md +++ b/docs/BRINGUP.md @@ -163,13 +163,17 @@ core mutex stands in for interrupt masking. `GFSINK` unset = null-sink mode currents around motion. Each motion run logs a producer-stats line (callbacks, µs/call, max-behind, clamped) - `clamped` should stay 0. The driver reports Idle when the stream is produced; the kernel plays it - a queue depth behind, and a jog sent at Idle while the kernel still drains - the last one adds up to a depth of pad slots ahead of its own bytes, so - the physical end runs further behind each time (measured 160, 290, 500 - and 540 ms after Idle across four chained 50 mm jogs). A `cnc/stop` at - Idle discards that tail; the position counters stay true to what was - played. Anything that must keep position waits for `cnc/state` to read - idle before it stops the controller. + a queue depth behind, so the machine still moves for about one depth after + Idle. The tail is flat, not cumulative: chaining jogs does not grow it + (measured 171, 175, 177 and 176 ms after Idle across four chained 50 mm + jogs, against `cnc/state`). A `cnc/stop` at Idle discards that tail; the + position counters stay true to what was played. Anything that must keep + position waits for `cnc/state` to read idle before it stops the + controller: forgectrl's `machine_is_idle()` reads that attribute, and the + mode switch, the cooling gate and the daemon shutdown all gate on it. + `POST /controller/stop` deliberately does not, because it is also the + emergency lever; it safes the machine with `cnc/stop` and the latch + before the signal instead. 4. Connect LightBurn/UGS to `:23`, or jog raw: `$J=G91X40F1200`. `^X` mid-motion aborts via kernel `cnc/stop` (controlled decel) and raises an alarm; TCP disconnects never kill the process (the dead-man fd stays held). @@ -1421,16 +1425,6 @@ feature requests, enhancements) will eventually be tracked as GitHub issues. far): re-measure the two heat coefficients and the machine's air-assist offset; and if a lit check still trips, the void-on-emission design with the tube as its own flow tracer. -8. **Idle before the kernel drains.** The driver reports Idle when the - stream is produced, up to about 550 ms before the pulse engine finishes - playing it after chained jogs (the fact under "Running the controller"). - A sender or a service that stops the controller at Idle loses that tail - silently. Decide whether Idle should hold until the kernel drains (the - stream engine has `gf_stream_kernel_idle`) or the continuation pads - should stop growing the lag; either is a driver change with a - `motion.*` catalog case. Until then every forgectrl path that stops - the controller after motion waits for `cnc/state` idle first (the - motion check does). **Deliberately not gated:** an armed GRBL job after an underrun cuts at the stale origin unless homing is required (GRBL mode permits unhomed cutting; the diff --git a/docs/CAMPAIGN-LOG.md b/docs/CAMPAIGN-LOG.md index 3118fa1..68fcd34 100644 --- a/docs/CAMPAIGN-LOG.md +++ b/docs/CAMPAIGN-LOG.md @@ -9286,6 +9286,56 @@ forgefirm push went red: a new `scripts/bench` python file must also be entered in the bench registry (`forgetest/forgetest/bench.py`), not only in that directory's README. +## 2026-09-07: Idle before the kernel drains, measured and closed + +The open item asked for a decision: hold Idle until the kernel drains, or +stop the continuation pads growing the lag. It quoted a tail of up to about +550 ms that grew with each chained jog (160, 290, 500, 540). The measurement +came first, and it changed the answer. + +On the machine, against `cnc/state` with a poll finer than the tail being +measured, four chained 50 mm jogs give 171, 175, 177 and 176 ms after Idle. +The tail is one queue depth and it is flat. The growth is gone. An earlier +attempt read 0 ms on every jog, which was the drill's own 250 ms status-poll +window standing in for the answer; a tail cannot be measured with a poll +coarser than itself. + +The host side agrees and says why. Stream bytes are the time axis, one per +machine tick, so a dumped stream's length is how long the machine plays it. +Chained jogs produce exactly 35755 bytes each, with no growth, and the churn +session (30 tiny moves, 20 ms gaps) holds at 64790 bytes at a producer lead +of 2 or 10 ms. Above that the picture changes: 15 ms inflates the same job to +5899 ms of playout and 50 ms to 8005 ms, all of it dark pad the machine still +has to move through. That is the mechanism the item described, and it lives +entirely above the shipped default. + +So neither driver change was made. Holding Idle has no supported lever: the +status letter comes straight from `state_get()`, and taking it over means a +third fork patch to the core state machine, against the standing goal of +repointing the submodule at upstream, for a signal whose authoritative form +already exists in `cnc/state`. A pacing change was written and measured +before being dropped: repaying the previous cycle's overshoot as pacing slack +cured a lead of 15 ms (5899 ms back down to 2496) but not 50 ms, and a +partial fix in the step generator's own loop, for a regime the shipped +configuration never enters, is not worth its risk. + +What shipped instead is the ceiling the source already described but did not +enforce: `GFSINK_LEAD_MS_MAX` was 200 while the comment beside it said 10 was +the limit, so an operator could set 50 and silently triple a job's playout. +It is now 10, refused with a message and a fall back to the default, and rule +17 of the laser stream harness holds the churn stream to a budget derived +from the job rather than a recorded number. The rule was checked both ways: +it passes at 2301 ms against a 3800 ms budget, and with the ceiling lifted it +fails at 5899 ms and 8005 ms. + +Two corrections to BRINGUP. The tail figure and its growth were stale. And +the claim that every forgectrl path stopping the controller after motion +waits for `cnc/state` idle was never true: `super.c` says in as many words +that `POST /controller/stop` is not idle-gated, because it is also the +emergency lever, and it safes the machine with `cnc/stop` and the latch +before the signal instead. The mode switch, the cooling gate and the daemon +shutdown do gate on `machine_is_idle()`, which reads `cnc/state`. + ## Reference notes ### Head-IRQ source validation — the beam-emission hypothesis diff --git a/scripts/bench/laser_stream_test.py b/scripts/bench/laser_stream_test.py index 3230722..c696438 100644 --- a/scripts/bench/laser_stream_test.py +++ b/scripts/bench/laser_stream_test.py @@ -132,6 +132,8 @@ def duty_for(s): # = 7.3 ms = ~206 ticks at 28160 Hz. 500 gives >2x margin while staying # far below any idle-gap pad run. FIRE_GAP_LIMIT_TICKS = 500 +# The machine tick: one stream byte per tick, so byte counts are durations. +MACHINE_TICK_HZ = 28160.0 WAIT_IDLE = ("wait_idle",) @@ -176,6 +178,13 @@ for _ in range(30): JOB_CHURN.append(("sleep", 0.02)) JOB_CHURN.insert(0, "M4 S0") JOB_CHURN.append("M5") +# Rule 17's budget, derived from the job rather than measured: 60 moves of +# 0.2 mm at F600 plus the scripted gaps. A stream longer than this is dark +# pad, and dark pad is time the machine keeps moving after the sender has +# been told the job is done. +CHURN_MOTION_S = 60 * (0.2 / (600.0 / 60.0)) +CHURN_GAPS_S = 60 * 0.02 +CHURN_BUDGET_S = (CHURN_MOTION_S + CHURN_GAPS_S) * 1.5 + 0.2 # Session D: a power ladder in the shape the bench threshold drill uses - # constant power (M3) so the commanded duty is the tested duty, rungs @@ -295,7 +304,7 @@ JOB_IDLE_S.append("M5") # same case one step further. M5_IDLE_MM = 5.0 M5_IDLE_FEED = 600 -M5_IDLE_TICKS = M5_IDLE_MM / (M5_IDLE_FEED / 60.0) * 28160 +M5_IDLE_TICKS = M5_IDLE_MM / (M5_IDLE_FEED / 60.0) * MACHINE_TICK_HZ JOB_M5_IDLE = [ "G91", "G21", "M3 S500", @@ -806,8 +815,22 @@ def main(): fail("[churn] no FIRE bits in the stream") check_termination("churn", data) gap_c = check_fire_gaps("churn", data) - print("PASS [churn]: %d bytes, %d fire ticks, max fire gap %d" - % (len(data), count_fire(data), gap_c)) + # Rule 17: the churn stream carries no runaway pad. Bytes are the time + # axis, one per machine tick, so the stream's length IS how long the + # machine plays it. A cycle that resumes while the kernel still drains + # re-bases production onto the wall cursor; if the producer's lead lets + # production stay ahead of that cursor across the gap, the re-base is + # skipped and the overshoot is inherited by every cycle after it. That + # is what GFSINK_LEAD_MS_MAX bounds, and this is what catches it. + churn_s = len(data) / MACHINE_TICK_HZ + if churn_s > CHURN_BUDGET_S: + fail("[churn] stream is %.0f ms of playout, over the %.0f ms budget: " + "the cycle re-base is leaving pad behind" + % (churn_s * 1e3, CHURN_BUDGET_S * 1e3)) + print("PASS [churn]: %d bytes, %d fire ticks, max fire gap %d, " + "%.0f ms of playout (budget %.0f)" + % (len(data), count_fire(data), gap_c, churn_s * 1e3, + CHURN_BUDGET_S * 1e3)) # --- session D: power ladder, rule 10 ------------------------------- data = run_session("ladder", JOB_LADDER, conf=ANALOG_CONF)