mirror of
https://github.com/openglow-org/forgefirm.git
synced 2026-09-27 08:41:13 -07:00
The tail after Idle is one queue depth, and it does not grow
The open item "Idle before the kernel drains" asked to decide between holding Idle until the kernel drains and stopping the continuation pads growing the lag. Measured first: on the machine, four chained 50 mm jogs against cnc/state give 171, 175, 177 and 176 ms after Idle. One queue depth, flat. The growth the item described is gone, so neither driver change was made and the item is closed. Host side agrees and says where the mechanism lives. Stream bytes are the time axis, so a dumped stream's length is how long the machine plays it: chained jogs produce 35755 bytes each with no growth, and the churn session holds at 64790 bytes at a producer lead of 2 or 10 ms. Only above the lead ceiling does it inflate. Rule 17 now holds that stream to a budget derived from the job rather than to a recorded number, so the inflation regime cannot return unnoticed; it fails at 5899 and 8005 ms with the ceiling lifted and passes at 2301 against 3800. BRINGUP carried a second error. It said every forgectrl path that stops the controller after motion waits for cnc/state idle. super.c says outright that POST /controller/stop is not idle-gated, because it is also the emergency lever, and 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(). Both the tail figure and the gating claim are corrected, and CAMPAIGN-LOG carries the measurements and the decision not to hold Idle. Acceptance: rule 17 is a host harness rule in the grblHAL repo's CI, not a catalog case. The catalog is unchanged because no machine behavior is: the driver change is a bound on an out-of-range knob.
This commit is contained in:
+11
-17
@@ -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 `<machine-ip>: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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user