61 Commits
Author SHA1 Message Date
ScottW514 f9190ffddc Release v0.0.9
FORGEFIRM_RELEASE is 0.0.9, and releases/v0.0.9 carries the release notes
and the acceptance artifact that forgetest exported on the bench reference.

The artifact is from campaign c-20261018224638-4ca0 on the pins-only dev
image 20261018222431: authorized, 43 of 43 tests satisfied, all 43 run on
that image and none inherited. scripts/acceptance-gate.py against that
build's release manifest gives AUTHORIZED, with inputs identical to the
bench image.
2026-10-18 19:45:26 -04:00
ScottW514 5036517e22 Pin grblHAL 0.1.22, forgeext 0.1.5, forgectrl 0.1.34 and forgefirm-app 0.1.33 on the pushed heads, and meta-openglow in the kas lock
- grblhal-glowforge badc832 -> e159720 (0.1.21 -> 0.1.22): a soft reset
  during motion gets the kernel's ramp and the position is read back
  from the kernel's step counters, a reset that lands after the core's
  own idle still gets the stop, the owed step is taken only between
  callbacks, a position that stays lost pins the lens and tells the
  sender, the homing cycle masks are pinned so a write cannot drop the
  lens hold, the test hooks (GF_SWITCH_DEV included) are honored only in
  a null-sink run through one gate, the underrun fail-safe log line
  names its entry, and the latch_test and serial_test cases that hold
  each.
- forgeext bb75b23 -> ebd9592 (0.1.4 -> 0.1.5): the storage quota counts
  entries (one per 4 KiB) and the unlinked files a service holds open or
  mapped, bounded, every 10 s; a quarantine the host could not write is
  kept under the run directory, taken up at the next start or the first
  turn the state reads, and dropped by the write or the lift; the root's
  prepare turns current back to the state's version, drops only a
  versioned tree, and leaves a package it cannot turn refused while the
  host runs the others; a manifest that cannot be read holds its package
  out by name; the required-holds marker directory is one literal.
- forgectrl 6c2bb38 -> b235be3 (0.1.33 -> 0.1.34): a diagnostic start
  the cooling engine refuses goes once more a second later, canceled by
  a later press or the Abort, which clears its text; the sysfs root, the
  switch device and the run directory are honored only in a host-test
  run through one gate (src/testhook.h, with its HOST_TESTED row here);
  test_routes holds the panel's retry text to the daemon's and the
  environment scan reads secure_getenv; the operator's texts say Grbl
  sender; holds_wire_test holds the required-holds directory to
  forgeext's and a marker alone holds.
- forgefirm-app 0f59c54 -> 6fb146a (0.1.32 -> 0.1.33): the shared
  AGENTS.md block names a Grbl sender; nothing it installs changes.
- kas lock: meta-openglow b7626be -> c542ce6, the head that pins the
  kernel module dab378d, python3-gfhardware 6fb146a and gfutilities
  b9e74b8.

Proof: the four repositories' CI runs on the pushed heads are green
(grblHAL-glowforge 39170662884, forgeext 39170677776, forgectrl
39168946865 on its second attempt, python3-gfhardware 39168949407), and forgefirm's own
forgetest-ci on 0de9051 (39168937632). bitbake -c fetch of each pinned
recipe from these pins succeeds. The tree manifest from the pins holds
forgectrl at b235be3 (232 files), forgeext at ebd9592 (90), grblhal at
e159720 (178) and the app at 6fb146a (46); the coverage lint over it
finds no uncovered path and no row problem (43 tests), with the row for
forgectrl src/testhook.h selecting host_start.sh in build.yml;
check-ui-vendor finds the shared UI files identical; test_tree_manifest
passes (7); the gate self-check refuses the empty artifact (rc 1).

Acceptance: the pins move the fingerprints of every test whose covers
name the four components, and the whole catalog runs on the next image.
2026-10-18 18:20:47 -04:00
ScottW514 0de9051820 forgetest: a pull_request narrowed by types: is a filter too
The coverage lint counts a HOST_TESTED row's workflow on a push or a
pull_request with no filter under it, and it read a `types:` under
the pull_request as nothing. A pull_request under `types:` runs on
the activity types it names and no other, so a workflow on
`pull_request: types: [closed]` never runs on a pull request's open
or its new commits, and a row on it counted as running on every
change, with the host test behind it keeping its paths covered. No
workflow a row names writes a `types:`.

workflow_filters reads a `types:` key at the pull_request's own level
as a filter, by name, as it reads a `paths:`: the lint reads no type,
so any `types:` is one, fail closed. A `types:` is read under the
pull_request alone: a push has none, and the lint counts no other
event. host_row_problems lists a workflow whose pull_request carries
one and whose push carries a filter, or is absent, as "every push or
pull_request in its own on: carries a filter (pull_request: types)";
a push with no filter beside it keeps the workflow counting. The
HOST_TESTED comment and the docstrings name the types filter.

Proof: forgetest-ci "Unit tests" (`python -B -m unittest discover -s
tests -v` in forgetest): test_manifest.py runs 41 tests, OK, on
Ubuntu 24.04 with python 3.12, in a private PID namespace.
test_a_push_or_pull_request_under_a_filter_runs_on_not_every_change
gains: workflow_filters reads a `types:` under the pull_request with
a plain push beside it as the pull_request's filter alone; a
`types:` block list with a `paths-ignore:` after it as both; and a
`types:` under a push as none. Through host_row_problems, a plain
push with a pull_request under `types: [closed]` passes, and the
pull_request under `types: [closed]` alone is listed with
"pull_request: types". Control: the parent, 9e7b065, with the new
test file fails the case (failures=1) at the first `types:`, and
against the parent's manifest.py, workflow_filters reads both
`types:` fixtures as no filter and host_row_problems passes both
rows. The coverage lint on the tree manifest from the recipe pins,
with meta-openglow at 1e163bd on scarthgap, reports 0 uncovered
paths, 0 empty entries, 0 host-tested row problems, 43 tests.
pyflakes reports nothing.

Acceptance: none. forgetest is dev-only and in no fingerprint; the
lint runs in CI, not in a campaign, and tests/ are in no fingerprint.
2026-10-18 14:34:10 -04:00
ScottW514 9e7b065343 forgetest: the lint reads a needs: list at the key's indentation and across lines, and an empty one fails closed
The coverage lint counts a step of a HOST_TESTED row's workflow only
in a job whose `needs:` are met on every run, and _needs read two
valid forms of the key as an empty list, which _needs_run takes as
needs met. A block list whose items sit at the key's own indentation
(`needs:` over `- build`) ended at its first item, which the loop took
for the next key. A flow list continued on the lines under the key
(`needs: [` over `build` over `]`) ended at the key's line, with no
item, and one split after a comma (`needs: [lint,` over `build]`)
kept the first line's names and dropped the rest. So a job that
needed a conditional job, written either way, counted as running on
every run, and a host test behind a conditional build kept its row
green and its paths covered. No workflow a row names writes its
`needs:` in these forms.

_needs now takes a `- ` item at the key's own indentation as part of
the block list, and ends the list at a key at the key's indentation
or less; items at two indentations are a form it does not read. A
flow list whose `]` is not on the key's line is joined with the lines
under it up to the `]`: a key, or a line at the key's indentation
that is not the `]`, before it ends the join as a form not read.
Every form not read gives None, a name no job has, so the need is
never met: fail closed. An empty `needs:` (`[]`, or nothing under the
key) gives None too: GitHub refuses an empty needs, and a job that
names one does not run. The docstring names every form that fails
closed, so its claim is true of each.

Proof: forgetest-ci "Unit tests" (`python -B -m unittest discover -s
tests -v` in forgetest): test_manifest.py runs 41 tests, OK, on
Ubuntu 24.04 with python 3.12, in a private PID namespace.
test_a_job_runs_only_when_every_job_it_needs_runs gains the block
list at the key's indentation and the flow list across three lines
among the forms that run the test with the needed job unconditional
and do not with it under `if: false` or `if: github.event_name ==
'push'`; a flow list split after its comma in the chain of three,
which runs the test, and does not with the second-line job under
`if: false`; and, each failing closed with the needed job
unconditional, `needs: []`, a bare `needs:`, a flow list a key
interrupts before its `]`, and items at two indentations. Through
host_row_problems, a workflow on `[push]` whose test job needs, in a
block list at the key's indentation, a build job under `if: false`
is listed as "does not run it". Control: the parent, 8fc411f, with
the new test file fails the case (failures=1) at the block list at
the key's indentation, and run one by one against the parent's
manifest.py, each of the seven new forms reads as run and the row
passes. The coverage lint on the tree manifest from the recipe pins,
with meta-openglow at 1e163bd on scarthgap, reports 0 uncovered
paths, 0 empty entries, 0 host-tested row problems, 43 tests.
pyflakes reports nothing.

Acceptance: none. forgetest is dev-only and in no fingerprint; the
lint runs in CI, not in a campaign, and tests/ are in no fingerprint.
2026-10-18 14:31:59 -04:00
ScottW514 8fc411f50f AGENTS.md: the shared rules name the Grbl sender, not one product
The "Project-wide rules" block is the same text in every repository
of the project, and this copy's "What ForgeFIRM is" said "controller
for LightBurn and other senders on TCP port 23", naming one sender as
the controller's counterpart. The project's term is "a Grbl sender",
the generic client of the controller on port 23, and no sender is
named as a synonym for it anywhere. The sentence now reads
"controller for a Grbl sender on TCP port 23", at the same wrap,
and nothing else in the block changes.

Proof: the block from "## Project-wide rules" to the end of the file
diffs empty against forgectrl's AGENTS.md; before the change the two
differed in that one line. pyflakes, the unit tests and the coverage
lint do not read AGENTS.md.

Acceptance: none. AGENTS.md is in no fingerprint and no catalog
test's covers; a text that guides contributors changes no behavior.
2026-10-18 13:45:31 -04:00
ScottW514 4fa03f7e2b forgetest: the lint counts a push or pull request only when no filter is under it
A HOST_TESTED row's workflow has to run on push or pull_request, and
the lint read the events of its `on:` and nothing under them. A
`paths:` or `paths-ignore:` under the event keeps the workflow from
the pushes that change the other files, a `branches:` or
`branches-ignore:` from the pushes to the other branches, and a push
with only a `tags:` or `tags-ignore:` runs on no branch push at all.
So a workflow whose push and pull_request each carried such a filter
counted as running on every change, and a host test behind one kept
its row green and its paths covered.

workflow_filters reads what is under each event of the `on:` block
mapping: each `branches:`, `branches-ignore:`, `tags:`, `tags-ignore:`,
`paths:` or `paths-ignore:` key at the event's own level, by name; a
value on the event's line (`push: {branches: [main]}`); and a key
with one stray quote under the event. Each is a filter the lint does
not read: the simple rule, fail closed. The manifest carries no
branch (a component's record is its srcrev, source, files and
recipes), so a `branches:` naming the default branch is read as any
other filter, and a `paths:` is not matched against the row's covered
paths. host_row_problems counts a push or a pull_request only with no
filter under it, and lists a workflow whose every push and
pull_request carries one as "every push or pull_request in its own
on: carries a filter (push: paths-ignore)", with each event's filters
named. One push or pull_request with no filter is enough: the
workflow then runs on every change, through that event. "Runs" for
a row's workflow now means: on a push or a pull_request with no
filter under it, in a step on every run of the workflow, in a job
whose needs are met on every run, on a line of run text whose
failure ends the step. The HOST_TESTED comment and the docstrings
say so. A key deeper than the event's own level (a dispatch's
inputs) is no filter, and an `on:` list or scalar carries none.

The workflows the rows name, forgectrl's and forgeext's build.yml,
run on `push: branches: [main]` with a plain `pull_request:`, as
grblhal-glowforge's does: the pull request keeps each row counting,
and the push to main is the push that moves a pin.

Proof: forgetest-ci "Unit tests" (`python -B -m unittest discover -s
tests -v` in forgetest): test_manifest.py runs 41 tests, OK, on
Ubuntu 24.04 with python 3.12, in a private PID namespace. The new
case test_a_push_or_pull_request_under_a_filter_runs_on_not_every_change:
workflow_filters reads a `branches:` on the push with a plain
pull_request beside it; a `branches-ignore:` and a `paths-ignore:` on
the push with a `paths:` on the pull_request; `tags:`; `tags-ignore:`;
a value on the event's line and a stray-quoted key under the event as
filters the lint does not read; a dispatch's `inputs: paths:` as
none; and an `on:` flow list, scalar and block list as none. Through
host_row_problems, a push to main with every pull request passes, a
push with `paths-ignore:` alone is listed, and a push under
`branches:` with a pull_request under `paths:` is listed, each with
its filters named. The CI_YML fixture of the two older cases moves
its `paths:` from the push to the pull_request, so the workflow
still runs on every push and still names the second harness in a
filter alone. Controls: the parent, 9c97f05, with the new test file
errors the new case at workflow_filters (errors=1); host_row_problems
on the parent, and on e1d1c1b, passes a fixture whose push carries
`paths-ignore:` alone, which the change lists with "push:
paths-ignore". The coverage lint on the tree manifest from the recipe
pins, with meta-openglow at 1e163bd on scarthgap, reports 0 uncovered
paths, 0 empty entries, 0 host-tested row problems, 43 tests.
pyflakes reports no undefined name.

Acceptance: none. forgetest is dev-only and in no fingerprint; the
lint runs in CI, not in a campaign, and tests/ are in no fingerprint.
2026-10-18 13:44:59 -04:00
ScottW514 9c97f05db9 forgetest: the lint counts a step as run only when every job its job needs runs
The coverage lint judges a HOST_TESTED row by a step of its workflow
that runs on every run of the workflow. It read the `if:` and the
`continue-on-error:` of a step and of its job, and not the job's
`needs:`. GitHub skips a job whose needed job did not run (an `if:`
that held it back, or a failure), unless the job says `always()`, so
a test step in a job that needs a conditional job runs on not every
push, and counted all the same: a host test behind a conditional
build keeps its row green and its paths covered. No workflow a row
names uses `needs:`.

workflow_steps now reads each job's `needs:` (_needs: a flow list,
one scalar, or a block list), keeps the jobs by name, and resolves
them when the workflow is read (_needs_run): a job's needs are met
on every run when every job it names runs on every run, that is,
carries no `if:` other than the literal true and has its own needs
met, transitively. A job the workflow does not have, a cycle, and a
`needs:` in a form the lint does not read each fail closed. A needed
job's `continue-on-error:` does not bear: its failure fails neither
the run nor the jobs after it. The job's dict carries the result as
"needs_run", and _on_every_run counts no step of a job whose needs
are not met, so workflow_runs and checkout_path refuse it as they
refuse a conditional step. The reason stays "does not run it". The
lint evaluates no condition, so a dependent job under `always()`
counts as no job, as a step under `always()` counts as no step. The
HOST_TESTED comment and the docstrings say what the lint reads.

Proof: forgetest-ci "Unit tests" (`python -B -m unittest discover -s
tests -v` in forgetest): test_manifest.py runs 40 tests, OK, on
Ubuntu 24.04 with python 3.12, in a private PID namespace. The new
case test_a_job_runs_only_when_every_job_it_needs_runs: a job that
needs an unconditional job runs its test, with the `needs:` as a
scalar, a flow list, a quoted flow list and a block list; the same
four with the needed job under `if: false` or `if: github.event_name
== 'push'` do not; a needed job under `continue-on-error: true`
runs it; a chain of three runs it, and does not with the first job
under `if: false`; a needed job the workflow does not have, a cycle
of two, a self-need and a `needs:` holding a mapping each do not;
`if: always()` on the dependent job, and on the middle job of the
chain, does not. test_the_workflow_has_to_run_the_host_test gains a
workflow whose test job needs a job under `if: false`, listed as
"does not run it". Control: the parent, e1d1c1b, with the new test
file fails both cases (failures=2). The coverage lint on the tree
manifest from the recipe pins, with meta-openglow at 1e163bd on
scarthgap, reports 0 uncovered paths, 0 empty entries, 0 host-tested
row problems, 43 tests. pyflakes reports no undefined name.

Acceptance: none. forgetest is dev-only and in no fingerprint; the
lint runs in CI, not in a campaign, and tests/ are in no fingerprint.
2026-10-18 13:40:54 -04:00
ScottW514 e1d1c1b84e forgetest: motion.jog-roundtrip's ramp comment follows the kernel's stepped ramp
The soft-reset check's comment described the kernel's ramp with the
continuous formula: (f0^2 - 900^2) / (2 a) ticks, 12687 over 223 ms
at the x32 tick, and a part-way run-out at f0 * sqrt(1 - 2 (a / f0)
Q). The kernel's ramp is stepped (cnc.c, ramp_update_tasklet_fn): the
tick holds for the first 10 ms after the stop, then every 10 ms drops
by ramp_rate / 100 until it reaches the 900 Hz floor, which plays
10 ms before the stop. At 112640 Hz and 500000 Hz/s that is 5000 Hz
off every 10 ms: 24 intervals, 240 ms, about 13270 ticks, 118 ms of
the tick. The comment now says so, with the figures that follow: the
rate at a run-out is 24% of the tick with 110 ms of queue left, 38%
with 100 ms, 51% with 90 ms; the tail's mean over the 25 to 30 ms the
poll samples is 32%, 45% and 56% (61% over the 50 ms window bound),
not 21% to 56%; the 70% bound is 14 points above the slowest ramped
tail and 30 below a run-out; the time to leave running is the 100 ms
hold plus the run's end on the ramp, about 240 ms with 100 ms left,
270 ms with 110 ms, 340 ms at the floor. The evidence's ramp_ticks and
ramp_s stay the continuous figures, and the comment says they are,
about 4% and 7% under the stepped ramp's. No judge changes: the 70%
bound, the 25 ms window and the 50 ms gap stay.

The Alarm wait's failure text names a sampling stall: when the 5 ms
sampling loop runs past 2 s after the ^X, the wait has no time to
read a state, and the failure read "not in Alarm within 2 s (state
None)". It now says the state is unread because the sampling loop ran
past the 2 s. The condition is the same; the text is the change.

Proof: a compile and pyflakes 4.0.3 give no message on
forgetest/suite/motion.py, with no undefined name in forgetest or
tests. forgetest unit tests, python3 -B -m unittest discover -s tests
-p test_<name>.py, in a private PID namespace: test_manifest.py 39
OK, test_queue.py 15 OK, test_campaign.py 13 OK. Coverage lint on the
tree manifest from scripts/manifest-from-tree.py (python3 -B -m
forgetest.coverage --manifest ../tree-manifest.json --enforce):
0 uncovered, 0 empty, 0 host-tested row problems, 43 tests. Host dry
runs of the whole test against the null-sink build of the
grblHAL-glowforge default branch head ("serial_test: the scan's exempt
arguments each match once in the sources"), with the machine side
stood in for as before: with a model kernel that ramps from the
controller's cnc/stop write, the test passes, MPos X 3502.07 steps
against 3502, tail 22% of the tick; with a model kernel that ramps as
cnc.c does, 5000 Hz off every 10 ms from 110 ms of queue, the test
passes, MPos X 3496.95 steps against 3497, tail 33% of the tick, the
figure the comment gives for a run-out from 110 ms of queue.

Acceptance: the comment and the message text are inside the test's
own function, so the shared text of suite/motion.py does not change.
The fingerprint of motion.jog-roundtrip moves, and no other test's:
of the implementation hashes of the 43 catalog tests, before and
after, only that one differs.
2026-10-17 19:10:40 -04:00
ScottW514 b4493f5052 forgetest: motion.jog-roundtrip's covers name the forgectrl route its homed-mask judge reads
The soft-reset check's reference verdict is the homed mask, read before
and after the reset through forgectrl's GET /motion/state
(cb_motion_state in src/main.c, through motion_port to the controller
port's state record), because $# prints the mask only with $22 on,
which the driver leaves off. A test that depends on a forgectrl route
names ("forgectrl", "src/main.c") in its covers, as microstep_modes
and deadman do, so that a change to the route invalidates the test's
inherited PASS. motion.jog-roundtrip's covers are now _MOTION_COVERS
plus that entry.

Proof: a compile and pyflakes 4.0.3 give no message on
forgetest/suite/motion.py, with no undefined name in forgetest or
tests. forgetest unit tests, python3 -B -m unittest discover -s tests
-p test_<name>.py, in a private PID namespace: test_manifest.py 39
OK, test_queue.py 15 OK, test_campaign.py 13 OK. Coverage lint on the
tree manifest from scripts/manifest-from-tree.py (python3 -B -m
forgetest.coverage --manifest ../tree-manifest.json --enforce):
0 uncovered, 0 empty, 0 host-tested row problems, 43 tests; the
catalog loads motion.jog-roundtrip with the five covers entries. A
host dry run of the whole test against the null-sink build of the
grblHAL-glowforge default branch head ("serial_test: the scan's exempt
arguments each match once in the sources"), with the machine side
stood in for as before and a model kernel that ramps from the
controller's cnc/stop write, passes: MPos X 3534.93 steps against
3535, tail 23% of the tick, mask 4 kept, drift 0.000 mm.

Acceptance: the covers are part of the test's own text, so the
fingerprint of motion.jog-roundtrip moves, and no other test's: of
the implementation hashes of the 43 catalog tests, before and after,
only that one differs. From here a change under forgectrl's
src/main.c invalidates motion.jog-roundtrip's inherited PASS as well.
2026-10-17 19:08:02 -04:00
ScottW514 a3211ba1ea forgetest: motion.jog-roundtrip's read-back judge allows half a step, so a one-step miss always fails
The read-back judge of the soft-reset check compared MPos X in steps
with the kernel's X count to one step, and the Y and Z judges allowed
one step of MPos motion. One step is the smallest miss the read-back
can make: the core's kill step for the pulse start that never came,
which a position kept as the core counted it carries. MPos is
reported to 0.001 mm, so the difference of two readings carries up to
0.21 step at x32 (0.001 mm at $100 = 213.333; 0.05 step at x8), and
a one-step miss lands between 0.79 and 1.21 steps: on either side of
a one-step bound, at random. The bound is now half a step for X, Y
and Z: a correct read-back lands within 0.21 step, a one-step miss at
0.79 or more. The comment and the description say so.

With the committed judges the parent of the read-back work fails the
check on four counts, not two: the kernel is still running 1.0 s after
the ^X (the parent writes no cnc/stop), the tail rate is 100% of the
tick (the run ends at speed), the position is not read back (MPos X
one step past the kernel), and the homed mask goes 4 -> 0.

Proof: a compile and pyflakes 4.0.3 give no message on
forgetest/suite/motion.py, with no undefined name in forgetest or
tests. forgetest unit tests, python3 -B -m unittest discover -s tests
-p test_<name>.py, in a private PID namespace: test_manifest.py 39
OK, test_queue.py 15 OK, test_campaign.py 13 OK. Coverage lint on the
tree manifest from scripts/manifest-from-tree.py (python3 -B -m
forgetest.coverage --manifest ../tree-manifest.json --enforce):
0 uncovered, 0 empty, 0 host-tested row problems, 43 tests. Host dry
runs of the whole test against the null-sink build of the
grblHAL-glowforge default branch head ("serial_test: the scan's exempt
arguments each match once in the sources"), configured as its CI
configures it (-DGFMCODE_WAIT_S=5.0), with the head accelerometer,
the kernel's state and bytes-played counter, and forgectrl stood in
for and the step counters taken from the shipped stream: with a model
kernel that ramps from the controller's cnc/stop write, the test
passes, MPos X 3489.06 steps against 3489, tail 23% of the tick, mask
4 kept, drift 0.000 mm; with a model kernel that ramps as cnc.c does
(the tick held 10 ms, then 5000 Hz off every 10 ms to the 900 Hz
floor), the test passes, MPos X 3509.97 steps against 3510, tail 32%.
Against the null-sink build of the parent of "Stream engine: a soft
reset during motion gets the kernel's ramp, and the position is read
back from the kernel's counters" (the commit "Test hooks: honored only
in a null-sink run, through one gate"), under the ramp model, the
read-back judge fails 5 runs in 5, MPos X one step past the kernel
each time (1.06, 1.06, 1.09, 0.99 and 1.10 steps); with the one-step
judge the same parent passed the read-back 3 runs in 5 (misses of
1.09, 0.95, 0.90, 0.98 and 1.09 steps). Every parent run also fails
on the kernel still running 1.0 s after the ^X, the tail at 100%, and
the mask 4 -> 0. The real counters and the machine's timing are read
on the machine only.

Acceptance: the change is inside the test's own function and its
decorator, so the shared text of suite/motion.py does not change. The
fingerprint of motion.jog-roundtrip moves, and no other test's: of
the implementation hashes of the 43 catalog tests, before and after,
only that one differs. The check runs on the bench in the next
campaign as part of motion.jog-roundtrip.
2026-10-17 19:06:48 -04:00
ScottW514 84a231163a 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.
2026-10-17 18:53:44 -04:00
ScottW514 c978e76b4c laser_lifecycle_test: rule 17's abort lands in a move above the step-smoothing cutoff
Rule 17's ^X mid-move goes out during G1 X120 F3000 instead of
G1 X30 F600, and the plausibility bound on the distance the abort
kept widens to the new move's length. Every other line of the rule
stays.

At $100 = 213.333 the F600 move cruises at about 2133 steps/s. The
core's step smoothing puts a rate between 2000 and 4000 steps/s at
level 2: four interrupt callbacks per step period, and only one in
four carries an X step. The core counts a step one interrupt callback
ahead of the pulse start that outputs it, so a reset leaves a pending
step only when its idle lands on a callback that carries one, one run
in four at F600. On the null sink the shipped backlog is empty by the
time the reset runs and the residue is zero, so that pending step is
all the position check can see, and the check is the harness's only
proof of the driver's hand-over of the step at the idle and of the
read-back correction. At F3000 the move cruises at 10667 steps/s,
above the 8000 steps/s cutoff of level 0, where every callback
carries a step and every reset owes one. The ^X goes out 0.5 s after
the move reports Run and lands near 56 mm of the 120, inside the
move.

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, configured as its CI
configures it (-DGFMCODE_WAIT_S=5.0), rule 17 passes 10 runs in 10
and the harness passes whole. Two scratch mutants of that tree's
driver.c, each built the same way: (a) readback_position keeps
sys.position as the core counted it, with the position_lost handling
kept; (b) stepperGoIdle hands the engine no pending step, the step
and direction bits passed to gf_stream_go_idle forced to 0. With this
change each fails rule 17 in 5 runs of 5, every time on the position
check with MPos one step past the stream (17104.0 steps against
17103 for (a), 17197.0 against 17196 for (b), in their first runs).
With the parent's F600 the same mutants pass rule 17 15 runs in 20
(a) and 14 runs in 20 (b). 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.

Acceptance: none. scripts/bench/ is outside every covers map.
2026-10-17 18:51:58 -04:00
ScottW514 18cba1daa2 forgetest: motion.jog-roundtrip sends a soft reset during a move and judges the kernel's stop and the position read back
motion.jog-roundtrip gets a check after its hold/resume segment, with
the head at the start and the machine at rest: a 60 mm +X G1 at F3000,
and a ^X 0.3 s after the kernel starts to play it. At a soft reset
during motion the grblHAL driver stops the kernel with its ramp, reads
the position back from the kernel's step counters, and keeps the core's
reference. No other catalog test sends a ^X while the head moves, and
the null-sink harness cannot read real counters: its residue is zero by
construction.

The check fails when:
- the controller is not in Alarm within 2 s of the ^X, or is Idle with
  no alarm (the move had ended before the ^X);
- cnc/state does not leave running within 1.0 s, or leaves it for a
  state other than idle, or cnc/underruns or cnc/faults change across
  the check;
- the kernel did not decelerate: over the run's last 25 to 50 ms, the
  bytes played (the position record, polled at 5 ms) are more than 70%
  of cnc/step_freq;
- the ring holds bytes after the reset;
- after $X, MPos X in steps ($100) and the kernel's X count differ by
  more than one step, or Y or Z moved;
- the homed mask changes, or the sender gets a [MSG:] line that the
  position cannot be read back;
- the jog back by the MPos distance leaves the kernel's X counter more
  than one step from its start.
The head is jogged back before these judgments, so a failure of one of
them leaves the head at the start.

The ramp witness is the tail rate because of the arithmetic. At the
stop the ring holds about 110 ms of ticks: the 200 ms queue and the
10 ms lead, less the 100 ms that the core's alarm hold plays. The ramp
to the kernel's 900 Hz floor uses 112.6 ms of ticks, in every mode,
because the ramp scales with the tick. So the run ends at the floor or
runs out of data part of the way down the ramp, and the steps that
never played can be zero. A minimum of unplayed steps in the driver's
read-back log line, the bytes played after the ^X, and the time to
leave running cannot tell the ramp from a run-out with a 20% margin.
The log line is also an INFO line, which the log level setting can
remove. The tail rate is 21% to 56% of the tick for 90 to 110 ms of
queue at the stop, and 100% for a run-out. The other readings and the
log line go to the evidence.

The homed mask comes from the controller port's state record (forgectrl
/motion/state): $# prints [HOME:...:mask] only with $22 on, and the
driver's default is off. Before the reset the mask must hold the Z bit
(the lens hold), because a mask without it cannot show a dropped
reference.

The test also requires kernel.k1-k2, the kernel's controlled stop on
the SoC. The title and the description say what the test proves now.
est_min stays 3.

Proof: compile and pyflakes 4.0.3 give no message on suite/motion.py
and no undefined name in forgetest or tests. forgetest unit tests,
python3 -B -m unittest discover -s tests -p test_<name>.py, in a
private PID namespace: test_manifest.py 39 OK, test_queue.py 15 OK,
test_campaign.py 13 OK. Coverage lint on the tree manifest from
scripts/manifest-from-tree.py (python3 -B -m forgetest.coverage
--manifest ../tree-manifest.json --enforce): 0 uncovered, 0 empty,
0 host-tested row problems, 43 tests. A host dry run of the whole test
against the null-sink build of grblHAL-glowforge f3158d3: the head
accelerometer, the kernel's state and bytes-played counter, and
forgectrl are stand-ins; the step counters are the steps of the shipped
stream; /motion/state is the controller's own control port. With a
model kernel that ramps from the controller's cnc/stop write, the test
passes: tail 23% of the tick, MPos X 3496.1 steps against 3496, mask 4
kept, the read-back log line found, the X counter back at its start,
drift 0.000 mm. With a model kernel that plays its queue to the end at
the tick, the ramp check fails at 100%. Against the null-sink build of
the parent (eb8eb61), the check fails on the mask (4 -> 0) and on the
read-back (MPos X 3483.1 steps against 3482). The real counters, the
ramp, and the machine's timing are read on the machine only.

Acceptance: the change is inside the test's own function, so the
shared text of suite/motion.py does not change. The fingerprint of
motion.jog-roundtrip moves, and no other test's: of the implementation
hashes of the 43 catalog tests, before and after, only that one
differs. The new check runs on the bench in the next campaign as part
of motion.jog-roundtrip.
2026-10-17 17:21:30 -04:00
ScottW514 9c768619f8 laser_lifecycle_test: a ^X during a move asks for the kernel's stop, and keeps the position and the lens hold
Rule 17 (cancel-abort) reads three more witnesses of a soft reset
during motion. Its session runs with GFSINK_ATTR_LOG, the sysfs writes
that the hardware would get, beside GFSINK_DUMP.

- After the ^X, the attribute log holds one more "cnc/stop 1" than
  before it: the controller asked the kernel to stop with its ramp.
- MPos X, in steps at $100, is the net X of the shipped stream within
  half a step: the position kept is where the stream left the head, so
  a step that the controller counted and never shipped fails the rule.
- After $X, 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, and the
  abort keeps that hold.

The three new checks read no log wording: the attribute write, the
stream's bytes, MPos, and the jog's error code. The header's rule 17 says the same.
Session gets an attr_log option (GFSINK_ATTR_LOG, taken out of the
inherited environment otherwise) and three readers: setting(n) from a
$$ dump, attr_count(write) from the attribute log, and
shipped_x_steps() from the dump.

Proof: python3 -m py_compile and pyflakes 3.4.0 are clean on
scripts/bench/laser_lifecycle_test.py. The harness passes whole, on its
first attempt, against the null-sink build of grblHAL-glowforge with
the change that stops the kernel at a soft reset during motion and
reads the position back ("Stream engine: a soft reset during motion
gets the kernel's ramp, and the position is read back from the
kernel's counters", on top of eb8eb61), configured as its CI configures
it (-DGFMCODE_WAIT_S=5.0), and rule 17's session passes 10 runs in 10
there. Against the build of eb8eb61 the harness fails at rule 17, on
each of three attempts: "^X mid-move did not ask the kernel to stop
with its ramp (no cnc/stop write)". That build also accepts the Z jog
after the abort: a probe of it answered ok and then reported Z 1.027.
The position check, on scratch copies of the new driver: with the
core's pending step left out of the read-back, rule 17 fails 2 runs in
10, and with the read-back unapplied 3 runs in 10, each time with MPos
one step past the stream. The backlog is empty by the time the reset
runs, so a pending step is all that is left to miss, and a reset
leaves one only now and then. The harness runs in grblHAL-glowforge CI
(python3 forgefirm/scripts/bench/laser_lifecycle_test.py
build/grblHAL_glowforge) from the head of this repository's default
branch: push this commit first, then the driver change, as that
repository's AGENTS.md asks.

Acceptance: none. scripts/bench/ is outside every covers map.
2026-10-17 16:44:26 -04:00
ScottW514 79796b305b forgetest: the workflow_steps docstring says which list items the stray-quote rule reads
The docstring said a key with one stray quote as the first key of any
list item makes every step count as no step. The parser reads a step's
first key on its dash line and a list item outside a step, such as a
matrix entry, and does not read a list inside a step's body, so a
stray quote there leaves the step counted. The docstring now says so,
as the Testing page does.

Proof: forgetest-ci "Unit tests" step, test_manifest.py: 39 tests OK
on the committed tree (python -B -m unittest discover -s tests -p
test_manifest.py). pyflakes: clean. Coverage lint on the tree manifest:
0 uncovered, 0 empty, 0 host-tested row problems, 43 tests. The change
is a docstring; no case changes.

Acceptance: none; forgetest is dev-only and manifest.py is in no
catalog fingerprint.
2026-10-17 09:27:43 -04:00
ScottW514 7a71dd80b5 forgetest: the implementation hash walks a sibling imported as from . import x too
_imported walked a relative import only when it names a module (`from
.x import y`): a level-1 ImportFrom with no module, `from . import x`,
was passed over, so the suite module x joined no hash through it. The
guard on the suite's package imports reads levels 2 and 0 alone, so it
passed that spelling, and its own message sends an absolute import
there: "import it relatively (from .. or from .)". So the guard and the
walk did not read the same spellings: on a scratch copy of the catalog,
forgectrl.auth's import of setup.py written `from . import setup`
passed the guard, and an edit to setup.py's decode(), which the test
calls, left forgectrl.auth's implementation hash where it was.
homeoff.py imports cloud.py so (`from . import cloud as logs`); its
`from .cloud import log_lines_since, log_size` on the next line keeps
cloud.py in the walk, so no hash misses a module.

_imported now walks every level-1 relative import: one with no module
walks each name it imports as <suite>/<name>.py, the name and not its
alias (a name that is no module of the suite is passed over, as a
module was). The docstrings of _imported and sibling_imports, and the
comment of the guard's case, name both spellings.

Proof: forgetest-ci "Unit tests" (`python -B -m unittest discover -s
tests -v` in forgetest): test_manifest.py runs 39 tests, OK, on Windows
with python 3.14 and on Ubuntu 24.04 with python 3.12.
test_a_sibling_imported_inside_a_function_moves_the_tests_that_run_it
gains a leg in which m.two imports judge.py as `from . import judge as
j`: an edit to judge.py moves m.two and not m.one. Negative controls on
scratch copies: catalog.py as it was fails that leg (the two hashes are
equal), and so does the walk that reads the alias (j) in place of the
name. On a scratch copy of the catalog with forgectrl.auth's import
written `from . import setup`, an edit to setup.py's decode() now moves
forgectrl.auth's hash. The coverage lint on the tree manifest from the
recipe pins reports 0 uncovered paths, 0 host-tested row problems, 43
tests. pyflakes reports no undefined name.

Acceptance: none. The source_sha of 43 of 43 tests is unchanged: the
one module-less sibling import outside the registry (suite/__init__.py,
which has no tests) is homeoff.py's, and cloud.py is in its walk
already. catalog.py is the registry and no shared judge, forgetest is
in no fingerprint, and tests/ are in no fingerprint.
2026-10-17 07:17:14 -04:00
ScottW514 614efb23e4 forgetest: the lint counts a host test only on a line of run text whose failure ends the step
workflow_runs counted any simple command in a step's run text as
running the test: _SPLIT_RX split the text on &&, ||, ;, |, newlines,
then, do and else, and every part was matched. So a step that hid the
test's failure, or never ran it, kept a HOST_TESTED row green, against
the HOST_TESTED comment's "a step ... whose failure fails it":
`./build/tray_test || true`, `| tee log`, `&`, `true ||
./build/tray_test`, an `exit 0` or a `set +e` line before the test,
`if false; then ./build/tray_test; fi`, a here-document's lines, a
folded block (`run: >`) whose next line is `|| true`, and a step
under `shell: bash {0}` or a `defaults: run: shell:` of pwsh each
counted. The workflow_runs docstring said "Only what a step's run
commands execute counts", which a command the shell skips, or whose
status it drops, does not meet. No workflow of the tree uses these
forms at a row's test.

workflow_runs now reads the run text line by line as bash -e or sh -e
runs it (_run_commands), a backslash at the end of a line joining the
next. _shell_words reads a line's words and operators, with quotes and
expansions ($(...), ${...}, `...`) inside the words. A command counts
on a line that holds it alone, with its redirections, or in a pipeline
under pipefail (shell: bash, or `set -o pipefail` on an earlier line),
and only while errexit holds: a `set` of errexit or pipefail is
followed. A pipeline with no pipefail does not count. The lint stops at
a line with any other operator (`||`, `&&`, `;`, `&`, a subshell, a
here-document), at a command that opens a compound command or negates a
status (if, case, while, until, a group, a function, `!`), at a command
after which it cannot tell what a later line runs (exit, exec, trap,
eval, source, alias, shopt, hash and the like), and at a `set` of an
option it does not follow (noexec): nothing on that line or after it
counts. A for loop counts in one form: `for <name> in <words>; do` on
its own line, body lines that count (a pipeline under pipefail only),
and `done` on its own line. A step counts only when its own shell:,
and the `defaults: run: shell:` of its job and of the workflow, are
each bash, sh or absent: workflow_steps now reads those defaults, and
reads a folded block, or a scalar continued on the lines under its key,
as one line, as YAML folds it. A prefix is taken off only with the
flags with which it runs its command (`sudo -E`, `env -i`), so `command
-v ./build/tray_test`, which prints the path, does not count; exec is a
stop, no longer a prefix. The HOST_TESTED comment and the docstrings of
workflow_steps and workflow_runs say what the lint reads.

The lint does not read, and counts as no step: `./build/x; true`
(bash -e stops at the test, and the lint refuses the `;` all the
same), `exec ./build/x`, a loop on one line or with `do` on a line of
its own, a pipeline with the test last and no pipefail, a test under
`set +e` whose status a later line checks, a test after a guard line
with `||` or `&&`, and a `set` of any other option (`set -E`). It
counts without reading: what a loop's body does with the loop's name
(its list counts), a `cd` before the test, a `defaults: run:
working-directory:`, what PATH or BASH_ENV change, and the runner's
operating system (it does not read runs-on; a Windows runner's default
shell is pwsh).

Proof: forgetest-ci "Unit tests" (`python -B -m unittest discover -s
tests -v` in forgetest): test_manifest.py runs 39 tests, OK, on Windows
with python 3.14 and on Ubuntu 24.04 with python 3.12.
test_a_step_counts_only_when_every_run_of_the_workflow_runs_it_and_its_failure_fails_the_run
gains these legs, none of which runs the test: `|| true`, `true ||`,
`; true`, `&` and a pipe with no pipefail on the test's line; an `exit
0`, `set +e`, `set -n` or here-document line before it; a `{` group
that `} || true` ends; a loop whose body is `python $t || true`, or a
pipe with no pipefail; a folded block and a continued scalar with `||
true` under the test; a step under `shell: bash {0}`; a job's and a
workflow's `defaults: run: shell: pwsh`. A pipe under `set -o
pipefail` and under shell: bash, and a step under shell: sh, run it.
test_what_running_a_host_test_looks_like gains `command -v
./build/tray_test`, which does not run it; its loop case is the loop as
python3-gfhardware's CI writes it (an assignment from `$(basename "$t"
.py)` in the body), and its glob case is a loop on lines of its own.
Negative controls on scratch copies: manifest.py as it was fails the
first new leg (True is not false), and each new leg reads as running
the test there; 19 mutations of the new reading each fail a case: exit
no stop, errexit not followed, an unknown `set` option followed, a
here-document read as a redirection, no reserved words, pipefail always
on, shell: bash with no pipefail, `set -o pipefail` not followed, the
loop's body not read, a body pipeline with no pipefail read, a folded
block read line by line, a continued scalar's lines dropped, the job's
defaults not read, the workflow's defaults not read, any shell read,
sh not read, `$(` read as an operator, every flag of a prefix taken
off, and `sudo -E` kept. Under bash -e with a failing stand-in test,
the step exits 0 for `|| true`, `true ||`, `&`, a pipe with no
pipefail, an earlier `exit 0`, `set +e` or `set -n`, the `{ } || true`
group, both loop bodies and the folded block; `; true` exits 1. A
here-document's lines are its command's input (`cat <<'EOF'` exits 0),
and `bash {0}` runs with no errexit, so a line after the test decides
the step (exit 0). The 17 workflows of the tree's repositories (211
steps) read to the same events and steps; of their 161 run steps, 20
count fewer commands and 4 the same commands with a redirection taken
off or an expansion kept as written, none of them a step that runs a
row's test. Each
of the 46 HOST_TESTED rows runs its test under the new reading, in the
workflows at the recipe pins and at the heads of forgectrl and
forgeext. The coverage lint on the tree manifest from the recipe pins
reports 0 uncovered paths, 0 host-tested row problems, 43 tests.
pyflakes reports no undefined name.

Acceptance: none. forgetest is dev-only and in no fingerprint (the
source_sha of 43 of 43 tests is unchanged); the lint runs in CI, not in
a campaign, and tests/ are in no fingerprint.
2026-10-17 07:14:07 -04:00
ScottW514 3aecf800a5 forgetest: the lint's stray-quote rule reads a step's first key, on its dash line, too
The rule that a key with one stray quote makes every step of the
workflow count as no step never saw a step's first key, the one on its
dash line. _STEP_RX reads no key with a stray quote (`- run": make`,
`- name": Build`), and _stray_key cannot match a line that starts with
`- `. So such a step dropped out with no "unread" set, and the
workflow's other steps still counted. YAML reads the key with its quote
(PyYAML gives {'name"': 'Build', 'run': 'make'}), or refuses the file
(`- "run: make`), and GitHub refuses a workflow with a step key outside
the step schema: a HOST_TESTED row passed the lint for a workflow that
runs nothing. The HOST_TESTED comment, the workflow_steps docstring and
the Test page state the rule for a step's key, and every step's first
key is on its dash line. No workflow of the tree has such a key.

workflow_steps now reads the first key of every list item as it reads a
step's other keys: the dash and the spaces after it become spaces, and
a key with one stray quote there makes every step of the workflow carry
"unread", which _on_every_run refuses. The rule also takes in a list
item outside the steps (a matrix include entry): that only lists more.
The docstring says so.

Proof: forgetest-ci "Unit tests" (`python -B -m unittest discover -s
tests -v` in forgetest): test_manifest.py runs 39 tests, OK, on Windows
with python 3.14 and on Ubuntu 24.04 with python 3.12.
test_a_step_counts_only_when_every_run_of_the_workflow_runs_it_and_its_failure_fails_the_run
gains a step `- run": make` before a step that runs the test: no step
counts. Negative control on a scratch copy: manifest.py as it was fails
that case (True is not false). A probe of workflow_runs: `- name":
Build` with `run: make` under it, and `- "run: make`, each before a
step that runs the test, count as no step, as a stray quote on a
step's second key already did. The 17 workflows of the tree's
repositories (211 steps) read to the same events and steps before and
after the change. The coverage lint on the tree manifest from the
recipe pins reports 0 uncovered paths, 0 host-tested row problems, 43
tests. pyflakes reports no undefined name.

Acceptance: none. forgetest is dev-only and in no fingerprint (the
source_sha of 43 of 43 tests is unchanged); the lint runs in CI, not in
a campaign, and tests/ are in no fingerprint.
2026-10-17 06:59:13 -04:00
ScottW514 609ae561bf forgetest: forgectrl's test scratch trees leave the repository, and the two still written are ignored
forgetest/cool-flow-test/, forgetest/super-test/ and
forgetest/cool-fanwrite-test/ (46 files) are the scratch trees that
forgectrl's cool_flow_test and super_test, and the fan-write test that
cool_flow_test took in, write in the directory they run from: fake
sysfs attributes, a cooling.state verdict, an empty dev/glowforge.
447f902 committed them beside a change to suite/exthost.py that needs
none of them. Nothing reads them: forgetest.bb fetches forgetest/LICENSE,
forgetest/forgetest/, forgetest/forgetest.init and scripts/bench/, no
manifest component holds them, and no other file of the repository
names them.

The three trees go. forgetest/.gitignore ignores the two that the
forgectrl tests still write, so a run from this directory cannot stage
them again.

Proof: git grep finds the three directory names nowhere else in the
repository. forgetest-ci "Unit tests" (`python -B -m unittest discover
-s tests`) on this tree with meta-openglow beside it, on Ubuntu 24.04
with python 3.12 in a private PID namespace: 357 tests OK, with
test_image.py's account-render case skipped there (it makes a namespace
through sudo -n); test_image.py on its own outside the namespace: 29
tests OK. pyflakes reports no undefined name. The coverage lint on the
tree manifest from the recipe pins reports 0 uncovered paths, 0
host-tested row problems, 43 tests.

Acceptance: none. forgetest is dev-only, and the trees are outside
forgetest.bb's sources, every manifest component and every layer
content hash.
2026-10-16 23:56:28 -04:00
ScottW514 c849603942 bench README: the lifecycle harness row links the Test page plainly, as the bench page does
The row said that what laser_lifecycle_test.py walks is on the site,
Developers, "Testing". The Test page lists four of the fifteen sessions
main() runs, by choice: the harness header is the full list. And the
page is "Test" in the site's navigation, in its title and in the bench
page's own link; the site has no page called "Testing". The row now
says what the harness is and links the Test page by its title, as the
bench page of the site does.

Proof: README text only, no behavior change. The row names no session
and makes no claim about the page's list; its link text is the page's
title (mkdocs.yml: "Test: developers/testing.md"), and its URL does not
change.

Acceptance: none. **/*.md is outside every fingerprint
(NON_BEHAVIORAL in manifest.py).
2026-10-16 23:49:30 -04:00
ScottW514 6473e7d9ae forgetest-ci: the undefined-name step prints why it failed, and fails a run SIGINT stopped
The comment over the step said that a signal fails it. pyflakes ends a
SIGINT and a SIGPIPE itself, with status 1 ("... stopped" for SIGINT),
and status 1 goes on to the grep, so a run SIGINT stopped mid-tree
passed the step with nothing judged. And every failure the step gained
for a status other than 0 or 1 left no cause in the step log: the
group's 2>&1 also captures bash's own notice of a signal ("Killed"),
and nothing printed pyflakes.txt, so a usage error or a killed run
showed only the pyflakes version line before the failure.

The capture is printed when the pyflakes group fails, and the grep
also takes the "... stopped" line. The comment says what fails the
step: the grep's lines, a SIGINT stop, any status other than 0 or 1,
and a capture that cannot be written; a SIGPIPE ends with status 1 and
no line, and a write to the capture file raises none.

Proof: the step body, read with PyYAML from this workflow and, as the
control, from the workflow before this change, run under bash -e over
the forgetest tree on Ubuntu 24.04 with python 3.12 and pyflakes 4.0.3
(the install line a no-op: the interpreter on PATH is a shim to a
venv). The tree as it is exits 0 with both bodies, and an undefined
name planted exits 1 with its line in the log. A run that exits 2 and
a run killed by SIGKILL while it blocks on a FIFO exit 1 with both
bodies; the step log of the former body holds only the version line,
and this one prints "unrecognized arguments" and the "Killed" notice.
A run stopped by SIGINT exits 0 with the former body and 1 with this
one, its "... stopped" line in the log. A working directory that
cannot be written exits 1 with both, its "Permission denied" in the
log.

Acceptance: none; CI only. .github/ is outside meta-forgefirm and
every covers map.
2026-10-16 23:49:02 -04:00
ScottW514 fc6b7797af forgetest: the shared judges' comments say why puls.py is one
The comment over SHARED_JUDGES in catalog.py said that every test runs
through all four judges without importing them, puls.py among them, and
the comment of test_a_shared_judge_edit_moves_every_test said the same.
No test runs puls.py but the three that print the dark job
(cloud.lid-interlock-abort, cloud.oversize-stream and
exthost.armed-freeze, through offline_job), and suite/cloud.py imports
it with `from .. import puls`: it is a judge because the sibling walk
does not follow that import, as the Acceptance page of the site says.

Both comments now say that every test runs through runner.py, hw.py and
baseline.py with no import to follow, and that puls.py comes in by a
package import the walk does not follow. Comments only: SHARED_JUDGES
does not change.

Proof: forgetest-ci "Unit tests" (`python -B -m unittest discover -s
tests -v` in forgetest): test_manifest.py runs 39 tests, OK, on Windows
with python 3.14 and on Ubuntu 24.04 with python 3.12. The only import
of puls in the package is suite/cloud.py's (grep). pyflakes reports no
undefined name.

Acceptance: none. catalog.py is the registry and in no fingerprint, and
tests/ are in none (source_sha of 43 of 43 unchanged).
2026-10-16 23:46:48 -04:00
ScottW514 a10fd7782d forgetest: the hash cases hold what a test's own import imports in turn, and the guard refuses an absolute package import
No case held the step of implementation_sha that adds what a test's own
import imports in turn (`sibling_imports(sib, sibs)`), nor the
recursion in sibling_imports itself: the judge.py of
test_a_sibling_imported_inside_a_function_moves_the_tests_that_run_it
imports nothing. Either line deleted kept test_manifest.py green, while
on the real catalog the first drops motion.py's shared text from
cloud.mode-switch's hash and setup.py's from motion.release's. The guard
on the suite's package imports read the relative spelling alone: a
`from forgetest import server`, an `import forgetest.server`, or a
`from forgetest.suite.setup import ...` in a suite module passed it,
and the sibling walk does not follow any of them, so the module they
name joined no hash.

The case's judge.py now imports limit.py, which imports base.py: an
edit to either moves m.two and not m.one. The guard refuses any import
of the package by its absolute name in a suite module, at any depth.
No suite module imports the package that way. catalog.py does not change.

Proof: forgetest-ci "Unit tests" (`python -B -m unittest discover -s
tests -v` in forgetest): test_manifest.py runs 39 tests, OK, on Windows
with python 3.14 and on Ubuntu 24.04 with python 3.12. Negative
controls on scratch copies: catalog.py without `sibling_imports(sib,
sibs)` in implementation_sha fails the case at the limit.py edit; the
walk without its recursion fails it at the base.py edit; in
suite/forgectrl.py, a `from forgetest import server` and an `import
forgetest.server` inside a function, and forgectrl.auth's import of
setup.py spelled `from forgetest.suite.setup import request, decode`,
each fail the guard. pyflakes reports no undefined name.

Acceptance: none. tests/ are in no fingerprint (source_sha of 43 of 43
unchanged).
2026-10-16 23:45:25 -04:00
ScottW514 2af9b0277f forgetest: the lint's docstrings and its row reason say what it reads, not what a workflow runs on
host_row_problems' docstring gave the reason a row is listed as "a
dispatch, a call or a schedule runs on no change". A workflow on
workflow_call alone runs on every run of the workflow that calls it
(forgeext's package.yml is one); the lint lists it because it does not
follow a call to its caller. The reason it printed, "<ci> runs on no
push or pull request", was false the same way for such a workflow, and
for a flow mapping (`on: {push: ...}`) that names push. The docstrings
of _on_every_run and workflow_runs said that a step under an `if:`
other than true does not run on every run, where the lint evaluates no
condition: a step under `always()` or `success()` runs on every run and
still counts as no step. The Test page of the site says each of these
as the lint applies it.

The docstrings now say what the lint reads: a row is listed when the
workflow's own `on:` names neither push nor pull_request as the lint
reads it (a dispatch, a call or a schedule alone, a call not followed
to its caller, a flow mapping), and a step with a condition counts as
no step because the lint evaluates none. The row's reason reads "<ci>:
the lint reads no push or pull_request in its own on:". The comments
of the two cases that hold these rules say the same. No rule changes:
the same rows are listed, for the same workflows.

Proof: forgetest-ci "Unit tests" (`python -B -m unittest discover -s
tests -v` in forgetest): test_manifest.py runs 39 tests, OK, on Windows
with python 3.14 and on Ubuntu 24.04 with python 3.12, with
test_the_workflow_has_to_run_the_host_test asserting the new reason. A
probe of host_row_problems: a workflow on workflow_call alone and one
whose flow-mapping `on:` names push are each listed with the new
reason, and a plain `on: [push]` one is not. The coverage lint on the
tree manifest from the recipe pins reports 0 uncovered paths, 0
host-tested row problems, 43 tests. pyflakes reports no undefined name.

Acceptance: none. forgetest is dev-only and in no fingerprint (the
source_sha of 43 of 43 tests is unchanged); the lint runs in CI, not in
a campaign, and tests/ are in no fingerprint.
2026-10-16 23:43:23 -04:00
ScottW514 c717ecbcce forgetest: the lint reads a quoted key only in a matched pair, and a key with one stray quote runs nothing
_KEY_RX took an opening quote and a closing quote each on its own, so a
key with one stray quote read as the bare key: `push":` and
`pull_request':` counted as push and pull_request, where YAML reads the
keys `push"` and `pull_request'`, which are no events; a step's
`run": ...` counted as its run command, and `uses": actions/checkout@v4`
as a checkout. A HOST_TESTED row whose workflow was spelled so passed
the lint, while that workflow runs on no push and runs no such step.

_KEY_RX now reads a quote only as a matched pair. A key with one stray
quote is no key the lint reads, and where the lint reads keys it counts
as running nothing: an event key spelled so makes the `on:` name no
event, and one at the top level, at a job's name or key, or at a step's
key below its dash line makes every step of the workflow count as no
step (each carries "unread", which _on_every_run refuses). So a step's `if": false` still
keeps the step from counting, as it did under the loose reading, and
under a `jobs":` top level, where a job's `if: false` goes unread, no
step counts either. The HOST_TESTED comment says so. The lint only
lists more: a workflow with no such key reads as before.

Proof: forgetest-ci "Unit tests" (`python -B -m unittest discover -s
tests -v` in forgetest): test_manifest.py runs 39 tests, OK, on Windows
with python 3.14 and on Ubuntu 24.04 with python 3.12.
test_the_events_a_workflow_runs_on gains an `on:` with `push":`, and
one with `push:` and `'pull_request:`, each naming no event, and
test_a_step_counts_only_when_every_run_of_the_workflow_runs_it_and_its_failure_fails_the_run
gains a step with `if": false`, a step with `run": ./build/tray_test`,
a job with `if": false` and a `jobs":` top level, none of them running
the test. Negative controls on scratch copies: _KEY_RX as it was fails
the `run":` step and the `push":` event; the matched pairs with no
stray-quote rule fail the `if": false` step and the second `on:`; the
rule at a step's keys alone fails the job's `if": false`. The 17
workflows of the tree's repositories (211 steps) read to the same
events and steps before and after the change. The coverage lint on the
tree manifest from the recipe pins reports 0 uncovered paths, 0
host-tested row problems, 43 tests. pyflakes reports no undefined name.

Acceptance: none. forgetest is dev-only and in no fingerprint (the
source_sha of 43 of 43 tests is unchanged); the lint runs in CI, not in
a campaign, and tests/ are in no fingerprint.
2026-10-16 23:41:24 -04:00
ScottW514 49f087f376 forgetest: a failed press is recorded as the fixture's only when its request went out
_press_taken recorded the press as the fixture's for every FixtureError
that arrived with the button out. That included failures in which the
box made no press for the request: its refusal (a 401, or the 409 of a
jumper pulled after the probe), a failed mDNS lookup, a refused
connect. When the person at the machine pressed meanwhile, or the arm
wait ended for another reason, the evidence and the exported artifact
gave the box a press it never made, and press_now returned True.

FixtureError now carries `sent`: True when the failure came after the
request went out (a timeout or a reset once the request was sent,
which urllib raises bare; a failed connect or send is a URLError), so
the box may have pressed. The one retry after a fresh lookup keeps it,
also when the lookup itself fails. A refusal and a failed lookup leave
it False. With the button out, _press_taken still puts up no notice,
and it records the press as the fixture's only for a sent error;
otherwise the record stays the operator's, and press_now returns
whether the fixture pressed. No judge reads `by`, and no energy path
changes.

Proof: forgetest-ci "Unit tests" (`python -B -m unittest discover -s
tests -v` in forgetest): test_fixture.py runs 22 tests, OK on Ubuntu
24.04 with python 3.12, and on Windows with python 3.14 with
test_ready_reads_a_short_press_as_an_edge skipped (no os.mkfifo).
test_a_failure_after_the_request_went_out_says_so drives the real
client against the fake box: a press whose reply comes after the
client's timeout is sent, with one press, also when the refresh lookup
after it finds nothing; the jumper-out refusal, a failed lookup and a
refused connect are not sent, with no press.
test_the_arm_notice_comes_down_when_the_press_is_taken gains the leg
where the box refuses while the button goes out: press_now returns
False with no notice and the record the operator's, and arm_press
leaves the same. test_server.py (10), test_operator.py (17),
test_queue.py (15), test_mode.py (9), test_setup_dark.py (21) and
test_setup_burns.py (24) pass on both. Negative controls on scratch
copies: _press_taken giving every failure to the box fails the refusal
leg (press_now True); `sent` never set fails the first client leg;
`sent` set for a URLError too fails the refused-connect leg; the retry
that drops the flag fails the retry leg. pyflakes reports no undefined
name.

Acceptance: every catalog test's fingerprint moves: runner.py is a
shared judge, in every implementation hash (43 of 43 move); fixture.py
is in none. Against the campaign this series already needs, that adds
no run: 850498f moved all 43 once. tests/ are in none.
2026-10-16 23:36:59 -04:00
ScottW514 e046c534c2 setup.focus: the tube-current case plants a reading at HV_ON, so it holds the threshold
test_a_burn_whose_tube_current_the_test_never_saw_rise_fails planted
pic/hv_current at 0. That proved the check on the test's own reads
exists, but not its threshold: the check written as `> 0`, or as
`>= HV_ON`, passed every case. The case now plants HV_ON itself (30,
which is not over 30), so with the 431 the passing cases plant, the
threshold is held between 30 and 430. setup_burns.py does not change.

Proof: tests/test_setup_burns.py, the Unit tests step of forgetest-ci.yml
(python -B -m unittest discover -s tests), 24 tests OK on Windows with
python 3.14 and on Ubuntu 24.04 with python 3.12. Negative controls on
scratch copies: the check as `> 0`, as `>= HV_ON`, and as no check at
all each fail the case (Failed not raised); the case as it was (a
plant of 0) passes against the `> 0` check. pyflakes reports no
undefined name.

Acceptance: none. tests/ are in no fingerprint (source_sha of 43 of 43
unchanged).
2026-10-16 23:30:06 -04:00
ScottW514 36c1b75bb8 setup.focus: the head's place judged against the stops its manual home declared, read from the home anchor
head_at_home placed the gantry stops at minus manual_home_x and
manual_home_y as the settings read at the burn. A manual home reads
them once: it declares the head at the stops at minus the offsets, and
with an offset it moves the head to X0 Y0, that far in front of them. A
write of the offsets after the home restarts no controller and keeps
the X/Y reference. So a home made with 5 and 3, the offsets then
cleared, had the head at X0 Y0 judged home 5 and 3 mm in front of the
stops, and the burn started: the false PASS 2172ae4 set out to close.
The other way round, offsets set after a home made with none failed a
head that was at the stops. The home anchor holds the stops as the home
declared them: /status reports the anchor's X and Y plus the step
counters.

head_at_home now takes the stops from the X and Y of the anchor
(baseline.ANCHOR_PATH) when /status reports X and Y referenced by a
manual home, and judges the head against them within HOME_TOL_MM. An
anchor that is gone, or that is not a manual home of X and Y, fails the
test before any start. The settings are read for the remedy alone. With
an offset, the remedy names how the push ends: release the motors, push
the head against the stops, and end the release with Energize motors
($ME), not with Set home here, which moves the head off the stops to X0
Y0 again. The remedy before said only to release the motors and push
the head, and a release left standing makes the burn ask home-push,
which the test does not answer. Without an offset it stays "home it
there". The judge only refuses more: no energy or motion path changes.

Proof: tests/test_setup_burns.py, the Unit tests step of forgetest-ci.yml
(python -B -m unittest discover -s tests), 24 tests OK on Windows with
python 3.14 and on Ubuntu 24.04 with python 3.12. BurnRunTests plants
the anchor of a home with no offset beside the fake's /status, and
test_a_manual_home_offset_puts_the_stops_off_x0_y0 plants the anchor of
each home: a home made with 5 and 3 fails the head at X0 Y0 and passes
it at X-5 Y-3; the offsets cleared after that home still fail the head
at X0 Y0 before any start (no wizard start, no lever); the offsets set
after a home made with none pass the head at X0 Y0; an anchor that is
gone, or that reads as the startup's Z-only reference, fails before any
start. That last pair replaces the leg where an offset that did not
read as a number failed: the stops no longer come from the settings,
and the rule that leg held (no stops placed, nothing starts) holds on
the anchor. test_setup_dark.py (21) and test_manifest.py (39, with the
guard on the suite's package imports, which setup_burns.py's import of
baseline.py passes) pass. Negative controls on scratch copies:
head_at_home as it was fails the case at the offsets cleared after the
home (Failed not raised), and so do the stops read from the settings;
an anchor that cannot be read taken as X0 Y0 fails it at the anchor
that is gone; an anchor of any axes and any source fails it at the
startup anchor. A probe of the failure text: with the offsets 5 and 3
it names Energize motors ($ME), not Set home here; with none it says
"home it there". pyflakes reports no undefined name.

Acceptance: setup.focus's fingerprint moves (setup_burns.py's shared
text and the test's own description); the other 42 do not move
(source_sha compared before and after). baseline.py is a shared judge
already, in every hash. Against the campaign this series already
needs, that adds no run: 850498f moved all 43 once.
2026-10-16 23:28:00 -04:00
ScottW514 ce73dbb052 forgetest: a check run_check stops following at its timeout is aborted before the test fails
run_check judged its timeout with ctx.check, which raises, ahead of the
abort meant for that case, so the abort never ran: a check still
running at the end of its follow was left running behind the FAIL. For
setup.focus that is a live burn wizard. Its put-back waits 120 s for
the burn to end and then skips the controller restart, and forgectrl's
own bound on a live run (1500 s) outlasts the test's 900 s follow. The
same driver serves the setup.check-* tests,
setup.cloud-header-capture and motion.release.

The abort now goes out before the timeout is judged, and the
unreachable return after it is gone. The change only ends a check the
test started itself: it adds no energy and no motion.

Proof: tests/test_setup_dark.py, the Unit tests step of forgetest-ci.yml
(python -B -m unittest discover -s tests), 21 tests OK on Windows with
python 3.14 and on Ubuntu 24.04 with python 3.12:
test_a_check_that_does_not_end_in_time_is_aborted_before_the_test_fails
(a lens.travel check that never ends, followed for 0.2 s: Failed, and
/wiz/lens.travel/abort posted). test_setup_burns.py (24) and
test_setup_suite.py (15) pass. Negative control on a scratch copy: the
timeout judged before the abort, as it was, fails the case (only
/wiz/lens.travel/start posted). setup.focus run whole against
test_setup_burns.py's scripted daemon with a burn that never ends (the
follow cut to 0.5 s, the press prompt held open): before, Failed with no
abort, the wizard still running and the restart skipped ("the burn is
still ending"); after, Failed with laser.focus aborted, the wizard
stopped, and the controller stopped and started (200, 200). pyflakes
reports no undefined name.

Acceptance: the shared text of setup_dark.py moves the fingerprints of
its five tests (setup.check-switches, setup.check-airflow,
setup.check-motion, setup.check-lens, setup.cloud-header-capture), of
setup.focus (setup_burns.py imports it at its top level) and of
motion.release (its own function imports run_check); the other 36 do
not move (source_sha compared before and after). Against the campaign
this series already needs, that adds no run: 850498f moved all 43
once.
2026-10-16 23:00:54 -04:00
ScottW514 7b0f065a3f laser.emission-witness: the head is placed back only by a sample that read the window open
rapid_place searched the samples up to the first disarmed one, that one
included, and sample_to_disarm read each sample's X after its armed
flag. So the first sample to read the window closed could place the
head back with an X read after the close: a window that closed on the
return leg, up to about one sampling loop before the head was back,
placed the rapid inside the window with a disarm time of 0, and the
`0 <=` on that time could not fail.

rapid_place now searches the samples before the first disarmed one
alone, and sample_to_disarm reads the controller's report (its state
and X) before the sample, so a sample that reads the window open read
its X with the window open. The head stands back for 1 s before the M2,
not 0.5 s: the placement needs a sample whose X and armed flag both
come between the head's return and the close, and a slow sampling loop
needs that time. The judge only gets stricter. No energy or motion path
changes: the longer dwell runs dark after the M5 with the window open,
as the shorter one did.

Proof: tests/test_laser_dwell.py, the Unit tests step of forgetest-ci.yml
(python -B -m unittest discover -s tests), 13 tests OK on Windows with
python 3.14 and on Ubuntu 24.04 with python 3.12:
test_a_window_closed_before_the_head_is_back_does_not_place_the_rapid
gains the close at the first sample with the head back (2, None, 6),
and test_the_sampling_runs_on_past_the_close_and_ends_where_it_stopped
holds the order (each sample's X read before its armed flag).
test_cooling_suite.py (3) passes. Negative controls on scratch copies:
the search with the first disarmed sample included fails the first
case with (2, 6, 6); the report read after the sample fails the second.
emission_witness run whole on a fake clock with only its I/O stubbed
(the review's sweep: the armed flag served mid-GET, X read mid status
report, 40 phases of the rapid against sampling loops of 0.20 to
0.64 s): a window closing 0 to 2 s before the head is back fails the
placement at every phase and every loop, and the normal close passes
at 40 of 40 phases at every loop. The code before this change passed
early closes at some phases up to about one loop early (0.20 s at a
0.20 s loop, 0.5 s at a 0.64 s loop). With the new order and the 0.5 s
dwell, the normal close passed 39 of 40 at a 0.38 s loop and 12 of 40
at a 0.64 s loop. pyflakes reports no undefined name.

Acceptance: laser.emission-witness's fingerprint moves (its own text and
laser.py's shared text), laser.armed-kill and
laser.pause-resume-lid-cancel move through the shared text, and
cooling.flow-under-load and cooling.fail-tier-stop move because they
import from .laser in their own functions; the other 38 do not move
(source_sha compared before and after). Against the campaign this
series already needs, that adds no run: 850498f moved all 43 once.
laser.emission-witness is always run, and the next campaign judges the
placement on the bench.
2026-10-16 22:56:33 -04:00
ScottW514 d57421c71d bench README: the lifecycle harness row points at the Testing page
The row for laser_lifecycle_test.py in scripts/bench/README.md credited
the harness with M5/M3 persistence inside a job, the M2 close, and the
arm refusal under a blocking cooling verdict. The harness runs no
session for any of the three: its header gives them to laser_arm_test.c
case I, laser_stream_test session K, and laser_arm_test.c cases C, A2
and A3. The row also restated a list that the site keeps (Developers,
"Test"), and a README is an index card. The row now says what the
harness is and points at that page, as the bench page of the site does.

Proof: README text only, no behavior change. The three claims are gone
from the file, and each of the four sessions the Test page lists
is one main() runs.

Acceptance: none. **/*.md is outside every fingerprint
(NON_BEHAVIORAL in manifest.py).
2026-10-16 14:54:18 -04:00
ScottW514 50c56a863d laser_stream_test: rule 19 names every laser session that runs with the dose curve in force
Rule 19 of the header said that every session but the rolloff reference
run has laser_dose_curve = off. Beside that run, four laser sessions run
with the curve in force: rolloff-g1 and rolloff-g2 (rule 20) and
rolloff-two (rule 23) under DENSITY_CONF_CURVED, which carries no curve
key, and jog-dark (rule 22), which runs with no configuration at all,
so the controller's curve_default applies. Rule 20 itself says "under
M4 with the curve in force". Rule 19 now says that every laser session
but the rolloff ones (rules 20 and 23) and the jog run (rule 22) runs
with laser_dose_curve = off. The z session carries no laser key and
fires nothing, so "laser session" leaves it out.

Proof: header text only, no behavior change. python3 -B -m py_compile
and pyflakes are clean on scripts/bench/laser_stream_test.py, and the
harness passes whole, every session, against the native null-sink
build of the driver at badc832 (-DGFMCODE_WAIT_S=5.0). The harness runs
in grblHAL-glowforge CI (the step "Laser stream emission rules",
python3 forgefirm/scripts/bench/laser_stream_test.py
build/grblHAL_glowforge).

Acceptance: none; the header docstring of a harness outside every
covers map.
2026-10-16 14:53:14 -04:00
ScottW514 2172ae4101 setup.focus: the head's place judged against the gantry stops, at minus the manual home offsets when they are set
head_at_home judged a head referenced by a manual home at X0 Y0, on
the premise that a manual home declares X0 Y0 at the gantry stops. That
holds only while manual_home_x and manual_home_y are blank or 0. With an
offset, the manual provider declares the head at the stops at minus the
offsets and then jogs it to X0 Y0, that far in front of them
(glowforge_homing.c manual_home; the Homing pages of the docs), and
/status reads pos X0 Y0 there. The wizard draws every card from the
head's home against the stops (wizlive.c head_home; Setup, "The head at
home"), and the test answers HOME to that prompt. So with an offset the
judge recorded judged True and "the head is home on record" for a head
the offsets in front of the stops, the burn ran from there, and its box
landed that much farther from the stops. The failure message (home it
there) rebuilt the same state.

The sources put the home of the burn at the stops, and the stops at
minus the offsets in the coordinates /status reports, so the judge now
judges the head there. head_at_home reads the two offsets from
/settings (blank is 0; a value that is not a number of 0 or more fails
the test before any start), records the stops it judges against in
head_at_start, and judges pos against them within HOME_TOL_MM. With no
offset that is X0 Y0, the rule as it was. With an offset, a head that a
manual home left at X0 Y0 fails before any wizard start, with no
controller lever touched, and the message names the offsets, where
they put the stops, and the remedy: release the motors and push the
head against the stops (the head's place then rests on the operator's
word, as for any unreferenced head, once the release ends with
Energize motors). Judging at X0 Y0 under an offset
would affirm a place the wizard does not ask for, and recording the
read without judging it would let the test answer HOME with a read in
hand that puts the head away from the stops; neither is taken. The
offsets are read as they stand at the burn: /status does not report
the offsets the home used, and the home anchor, which holds the stops
that home declared, is not read. The description and the comments say
what is judged.

Proof: tests/test_setup_burns.py, the Unit tests step of
forgetest-ci.yml (python -B -m unittest discover -s tests), 24 tests OK
on Windows with python 3.14 and on Ubuntu 24.04 with python 3.12:
test_a_manual_home_offset_puts_the_stops_off_x0_y0 (manual_home_x 5 and
manual_home_y 3, homed_axes 7, home_source manual: at X0 Y0 the test
fails before any start, the stops on record at X-5 Y-3, no /controller/
lever posted, the settings as found; at X-5 Y-3 the burn runs, judged
True; manual_home_x "five" fails before any start), and the lit burn's
record carries the stops at X0 Y0. Negative controls on scratch copies:
setup_burns.py as it was, and the offsets taken as 0, each fail the new
case at its first run (the burn starts). pyflakes reports no undefined
name.

Acceptance: setup.focus's fingerprint moves (its own text and
setup_burns.py's shared text); no other test moves (source_sha compared
before and after). Against the campaign this series already needs, that
adds no run: 850498f moved all 43 once.
2026-10-16 14:48:32 -04:00
ScottW514 9451587728 setup.focus: the test's own sysfs reads go on record however the burn ends, and their tube-current check has a case
emission_seen, the two peaks the test reads from sysfs at every poll
and the count of polls that read both, was written inside emission_ok
after the three checks of the burn's result. A burn the daemon reports
dark fails at the first of them (Context.check raises), and a burn that
ends in a wizard error, a timeout or an abort never reaches emission_ok.
On each of those paths the record kept the daemon's word alone, where
the test's own reading is the one worth comparing with it. burn() now
records emission_seen, and logs it, in the finally of its run_check, so
it is on record however the burn ends. emission_ok keeps its checks, in
their order.

The check that the tube current read over HV_ON from here had no case
that could fail it: the one negative case reads both attributes at 0
and stops at the LASER_ON check, which comes first. A new case reads
the LASER_ON count nonzero and the tube current at 0, and fails on the
tube current.

Proof: tests/test_setup_burns.py, the Unit tests step of
forgetest-ci.yml (python -B -m unittest discover -s tests), 23 tests OK
on Windows with python 3.14 and on Ubuntu 24.04 with python 3.12, with
tests/test_setup_dark.py (20 OK):
test_a_burn_whose_tube_current_the_test_never_saw_rise_fails
(cnc/laser_on_sampled 118, pic/hv_current 0: Failed "the tube current
never read over 30 from here", the settings as found);
test_a_dark_burn_fails_and_still_restores finds emission_seen at 118
and 431 beside the dark result; the aborted burn's record carries
emission_seen too. run_test keeps the Run of a test that fails.
Negative controls on scratch copies: the tube-current check made
vacuous (True or ...) fails the new case (Failed not raised);
setup_burns.py as it was fails the dark case (no emission_seen) and the
aborted case. pyflakes reports no undefined name.

Acceptance: setup.focus's fingerprint moves (setup_burns.py's shared
text); no other test moves (source_sha compared before and after).
Against the campaign this series already needs, that adds no run:
850498f moved all 43 once.
2026-10-16 14:44:40 -04:00
ScottW514 bed46e961e laser.emission-witness: the read rule ends where the sampling stopped, and the rapid is placed inside the window
The dark stretch after the M5 runs on past the close, and three of its
judgments did not hold with it.

The read rule took the last sample served as the trail's last edge, and
sample() returns None for a request the daemon does not answer. So an
outage after the served close (one request that hangs to the client's
10 s timeout is enough) left a trail that ended at the close with no
gap, and the window that latches after the close was never read: the
test passed whole with a 30 ms fire at the end of the return rapid.
The sampling could also stop as early as about 1.8 s past the close,
and with a 0.9 s unread tail allowed, the last reading could come
before the latch of the window in flight: the review's tail model
(the kernel's ~1 s window latch, the sampler's GET timing, a fire just
before the close) finds 4 runs in 20000, 21 with a wider GET spread,
where that window goes unread and the judge passes.

The placement and the disarm timing searched the whole stretch. A
window that closed before the head was back (in the dwell at +20 mm
or on the return leg, up to about 2 s early) found the head back after
the close and passed "the rapid ran inside the dark stretch", with a
negative disarm time.

The rapid's sample count in the record ran past the close while its
span stopped at the close, so the record read about 8 Hz for a 5 Hz
loop.

sample_to_disarm moves from emission_witness to module level, ends two
windows and EMISSION_READ_GAP_S past the close, and returns the time
the sampling stopped beside the samples. read_gaps takes that time as
the trail's last edge (t_end), so a tail the daemon served none of is a
gap. rapid_place judges the placement over the armed part alone, up to
the first disarmed sample, and the disarm time must be 0 or more. The
rapid block counts its samples to the close and records how long the
sampling ran past it (sampled_past_close_s), and the log line says
both. The relit, HV and read judgments still cover the whole stretch.
The description says what is judged. tests/test_laser_dwell.py drops
relit(), a copy of the judge that no change to laser.py could fail.
Every change makes the judge stricter: no energy or motion path
changes.

Proof: tests/test_laser_dwell.py, the Unit tests step of
forgetest-ci.yml (python -B -m unittest discover -s tests), 13 tests OK
on Windows with python 3.14 and on Ubuntu 24.04 with python 3.12:
test_a_tail_the_daemon_never_served_fails (every sample served read,
then nothing for 2.9 s before the sampling stopped: gap 2.9 s, the rule
fails; served through, it holds),
test_the_sampling_runs_on_past_the_close_and_ends_where_it_stopped
(sample_to_disarm on a fake clock, the daemon serving the close and
nothing after it: the sampling ends at least 2.9 s past the close and
the rule fails), and
test_a_window_closed_before_the_head_is_back_does_not_place_the_rapid
(a close after the return places out, back and the close; a close in
the dwell at +20 mm leaves back None). test_cooling_suite.py (3) and
test_setup_burns.py (22) pass. Negative controls on scratch copies:
read_gaps without the end edge fails the first two cases; the end at
two windows fails the second; sample_to_disarm that returns the samples
alone errors in the second; rapid_place over the whole stretch fails
the third. emission_witness run whole on a fake clock with only its I/O
stubbed: a window closing 0.2, 1.0, 1.9 or 2.5 s before the head is
back fails the placement (the code before this change passed the first
three), and a normal close passes with 22 samples over 4.07 s to the
close in the record (32 before). Against a simulated daemon and kernel
latch with that 30 ms fire: an outage after the served close and one
request hung to the 10 s timeout fail the read rule (gaps 3.05 s and
10.32 s; both passed before); a tail served through with no fire
passes. In the review's tail model, the end edge alone and the longer
sampling alone each leave 0 of 20000 runs with the window in flight
unread and the judge passing, in both GET spreads. pyflakes reports no
undefined name.

Acceptance: laser.emission-witness's fingerprint moves (its own text
and laser.py's shared text), laser.armed-kill and
laser.pause-resume-lid-cancel move through the shared text, and
cooling.flow-under-load and cooling.fail-tier-stop move because they
import from .laser in their own functions; the other 38 do not move
(source_sha compared before and after). Against the campaign this
series already needs, that adds no run: 850498f moved all 43 once.
laser.emission-witness is always run, and the next campaign judges the
rapid with the end edge on the bench.
2026-10-16 14:40:53 -04:00
ScottW514 41ca7fab82 forgetest-ci: the coverage lint step fails when the lint fails, not only when tee does
The "Coverage lint (enforced)" step pipes the lint into tee for the
step summary. A step with no shell: runs under bash -e with no
pipefail, so the status of the step was tee's. An uncovered path, an
empty covers entry, a host-tested row problem (the lint exits 1) and a
manifest the lint cannot load (exit 2) each passed the job: the first
three after they printed the report, the last after one error line on
stderr and an empty step summary. The header of the workflow and the
Testing page say that an uncovered path fails the job, and this step
is the only place that runs the lint with --enforce.

The run block now starts with `set -o pipefail`, so the status of the
step is the lint's. The tee to the step summary stays. A comment above
the block says why.

Proof: the step body, read with PyYAML from this workflow and, as the
control, from the workflow before this change, run under bash -e from
forgefirm/forgetest of a copy of the tree on Ubuntu 24.04 with python
3.12, GITHUB_STEP_SUMMARY a file. The tree manifest from the recipe
pins: both bodies exit 0 and write the summary (0 uncovered paths, 0
host-tested row problems, 43 tests). The same manifest with one
uncovered forgectrl path planted: the lint reports 1 uncovered path,
the former body exits 0, this one exits 1, and both write the summary.
The manifest missing: the former body exits 0, this one exits 2.

Acceptance: none; CI only. .github/ is outside meta-forgefirm and
every covers map.
2026-10-16 14:27:06 -04:00
ScottW514 eb684d366e forgetest-ci: the undefined-name step fails on a pyflakes status other than 0 or 1
The step captured the pyflakes run with `|| true`, because pyflakes
exits 1 for any message, and judged the capture alone: "undefined
name" or "Traceback". A run that ended abnormally with no traceback
passed the step with nothing checked: a run killed by a signal, a usage
error (exit 2), or a working directory where the capture cannot be
written (grep then reads no file, and the `if` is false).

The capture is now `{ python -B -m pyflakes forgetest tests || test $?
-eq 1; } > pyflakes.txt 2>&1`. The two normal statuses of pyflakes, 0
and 1, go on to the grep; any other status fails the step under
bash -e, and so does a redirect that fails. The grep stays the gate
for an undefined name and for a Traceback. A comment above the run
block says so.

Proof: the step body, read from this workflow and, as the control,
from the workflow before this change, run under bash -e over the
forgetest tree on Ubuntu 24.04 with python 3.12 and pyflakes 4.0.3
(the install line left out: the interpreter on PATH is a shim or a
venv). Both bodies: the tree as it is exits 0; an undefined name
planted, pyflakes missing, and a pyflakes that raises each exit 1. A
pyflakes killed by SIGKILL, one ended by SIGTERM, one that exits 2,
and a working directory that cannot be written: the former body exits
0 each time, this one exits 1. PyYAML reads the run text of the step
as written.

Acceptance: none; CI only. .github/ is outside meta-forgefirm and
every covers map.
2026-10-16 14:24:54 -04:00
ScottW514 9ea9c5d10f forgefirm-image: the pack-licenses test says what tar does with a member it cannot read
The docstring of the case that holds the refusal said that the
manifest check alone passes a bundle truncated after the unreadable
member, and a comment on the member's directory said that it sorts
after license.manifest. Neither is the mechanism. tar reports an error
that it cannot open the member, skips it, writes every other member
and the end of the archive, and exits nonzero only at its end, a
status the former pipeline lost. Where the member sorts does not
matter. The docstring now says that tar skips the member and the
manifest check alone passes the bundle without it, and the comment is
gone.

Proof: forgetest-ci "Unit tests" (`python -B -m unittest discover -s
tests -v` in forgetest), on Ubuntu 24.04 with python 3.12 as an
unprivileged user: test_image.py's PackLicensesTests, 5 tests OK. With
the pack script as it was before 03146a1, the case fails with the
member under zlib/ and with it under aaa/ (exit status 0 both times);
with the script as it is, both placements pass. At the shell, on a
fixture tree with the unreadable member under aaa/ and then under
zlib/, the script as it was exits 0 both times after "Cannot open:
Permission denied", writes a bundle that holds every other member
(zzz/generic_Last, which sorts last, included), and removes the tree.
Text only, so no negative control applies to the change itself.
pyflakes reports no undefined name.

Acceptance: none. tests/ are in no fingerprint.
2026-10-16 14:23:02 -04:00
ScottW514 5d26818946 forgetest: a press the fixture made and reported failed leaves no notice standing
press_now and arm_press hand the arm press to the operator when the
bench actuator reports that the press failed. When the box makes its
200 ms press and its reply is lost, the client raises that failure at
its 3 s timeout or later (with ip set in the config it does not
retry), and by then the controller has taken the press and put the
button out. The fallback put the notice up all the same and started
the watch, which waits for the button to light before it waits for it
to go out. The button does not light again, so "Press it now" stood
through the job and asked for a second press, and a press during the
job pauses it.

Both fallbacks now record the fixture's error and then read the
button. Out: the press was taken, it is recorded as the fixture's, and
no notice goes up. Lit, or unreadable: the notice goes up with its
watch, as before. Nothing presses for the operator, and no judge or
energy path changes.

Proof: forgetest-ci "Unit tests" (`python -B -m unittest discover -s
tests -v` in forgetest), on Ubuntu 24.04 with python 3.12 and on
Windows with python 3.14: test_fixture.py's
test_the_arm_notice_comes_down_when_the_press_is_taken gains the leg
where the box puts the button out and then reports a failure: press_now
returns True with no notice and the press recorded as the fixture's,
and arm_press leaves no notice and the same record. The file's 21
tests OK on Ubuntu, and on Windows with
test_ready_reads_a_short_press_as_an_edge skipped (no os.mkfifo).
test_setup_burns.py (22), test_setup_dark.py (20), test_server.py
(10), test_operator.py (17), test_queue.py (15) and test_mode.py (9)
OK on Ubuntu. Negative controls on scratch copies: the leg
against the runner as it was fails at press_now (False, the notice
standing); with arm_press's fallback alone as it was, the leg fails at
its record ('operator', the notice up). pyflakes reports no undefined
name.

Acceptance: every catalog test's fingerprint moves: runner.py is a
shared judge, in every implementation hash (43 of 43 move). Against
the campaign this series already needs, that adds no run: 850498f moved
all 43 once. tests/ are in none.
2026-10-16 14:21:24 -04:00
ScottW514 8d16b93e86 forgetest: the shared judges and a test's own imports reach the hash the gate reads
The sibling walk read the top-level statements of a module alone. Five
tests import their judging helpers inside their own functions, so that
no other test of the module moves with them: cloud.mode-switch
(homeoff.py: judge_whole_motions, camera_home_return),
cooling.flow-under-load and cooling.fail-tier-stop (laser.py's sample
and run_and_sample, motion.py's log readers), forgectrl.auth (setup.py's
request and decode) and motion.release (setup_dark.py's run_check).
None of those helpers was in the hash of its test, and homeoff.py was
in no hash at all: an edit to judge_whole_motions moved 0 of 43 tests,
and an edit to laser.sample moved the three laser.* tests and neither
cooling test, so an inherited PASS survived a change to its own judge.
The guard that every package module a suite module imports is a listed
judge read the top level alone too, and no unit test held the
production path: Test.source_sha, which the fingerprint, the campaign
and the gate read, could drop the shared judges with CI green.

implementation_sha now adds to the hash of a test the suite modules its
own @test function imports, at any depth, and what they import in turn;
no other test of the module takes them. The shared text of a module
gives the modules it imports at any depth too: a helper that imports
inside its body brings that module into every test of the module, as
the helper itself does. _test_defs finds the @test functions of a
module for module_parts and for both walks. The guard walks the whole
tree of each suite module, so a package import inside a function or a
block is held to the list as well. test_a_shared_judge_edit_moves_every_test
holds the production path on a suite test: its source_sha equals
implementation_sha with SHARED_JUDGES and differs from it with none.

Proof: forgetest-ci "Unit tests" (`python -B -m unittest discover -s
tests -v` in forgetest): test_manifest.py runs 39 tests, OK, on Windows
with python 3.14 and on Ubuntu 24.04 with python 3.12, with
test_a_sibling_imported_inside_a_function_moves_the_tests_that_run_it
(a judge imported in the body of one test moves that test and not the
other; imported in the body of a helper, it moves both), the
source_sha assertions, and the guard on the whole tree; test_artifact.py,
test_campaign.py, test_redact.py and test_responsiveness.py pass.
Negative controls on scratch copies: implementation_sha's default
taking no judges fails the source_sha assertion, and so does
Test.source_sha passing judges=(); the new case fails against
catalog.py as it was, and against a shared-text walk of top-level
statements alone; a `from .. import server` inside a function of
suite/forgectrl.py, or in a try block at its top level, fails the
guard, and the function-local one passes the guard that reads the top
level alone. On the real catalog: an edit to judge_whole_motions moves
cloud.mode-switch alone (0 tests before); an edit to laser.sample moves
the three laser.* tests and both cooling tests (the three alone
before); an edit to run_check moves the six setup.* tests and
motion.release (the six alone before); an edit to setup.request moves
forgectrl.auth and motion.release (motion.release through setup_dark.py,
which imports setup.py) beside the 8 it moved before; an edit to
hw.sysfs_int
moves 43 of 43, as before. The coverage lint on the tree manifest
from the recipe pins reports 0 uncovered paths, 0 host-tested row
problems, 43 tests. pyflakes reports no undefined name.

Acceptance: cloud.mode-switch, cooling.flow-under-load,
cooling.fail-tier-stop, forgectrl.auth and motion.release: the
implementation hash of each moves once, its own imports joining it; the
other 38 do not move (source_sha compared before and after). Against
the campaign this series already needs, that adds no run: 850498f moved
all 43 once. tests/ are in no fingerprint.
2026-10-16 14:17:00 -04:00
ScottW514 5bcc536853 forgetest: the lint's step parser keeps a step's real key column and reads a quoted key
workflow_steps rebuilt the first line of a step with its first key two
columns past the dash, whatever followed the dash. A step written with
more than one space after its dash (`-   run: ./build/tray_test`, its
`if: false` at the column of the run key) then had its first key at one
column and the others at another: every key after the first was read as
a child of the first and dropped, `if:`, `continue-on-error:`,
`working-directory:` and `with:` among them. So such a step under
`if: false` counted as running its host test, and such a checkout under
a condition counted as a checkout. _KEY_RX did not read a quoted key,
so a step's `"if": false` was dropped the same way. Both spellings are
valid YAML that GitHub skips or tolerates.

The parser now replaces the dash with a space and realigns nothing, so
the key column of a step is the column its keys use. _KEY_RX reads a
key in single or double quotes as the same key. The HOST_TESTED comment
states the rule the lint applies: no `if:` other than `true` and no
`continue-on-error:` other than `false` on the step or its job.

Proof: forgetest-ci "Unit tests" (`python -B -m unittest discover -s
tests -v` in forgetest): test_manifest.py runs 38 tests, OK, on Windows
with python 3.14 and on Ubuntu 24.04 with python 3.12, with
test_a_step_counts_only_when_every_run_of_the_workflow_runs_it_and_its_failure_fails_the_run
extended by a step with three spaces after its dash under `if: false`
and a step under a quoted `"if": false`, neither running the test.
Negative controls on scratch copies: the first line rebuilt as before
fails the three-space case; _KEY_RX as before fails the quoted case.
The 17 workflows of the tree's repositories (205 steps) parse to the
same steps and events before and after the change. The coverage lint
on the tree manifest from the recipe pins reports 0 uncovered paths,
0 host-tested row problems, 43 tests. pyflakes reports no undefined
name.

Acceptance: none. forgetest is dev-only and in no fingerprint (the
source_sha of 43 of 43 tests is unchanged); the lint runs in CI, not in
a campaign, and tests/ are in no fingerprint.
2026-10-16 14:11:07 -04:00
ScottW514 c0c74d1658 laser_lifecycle_test: rule 3 names the holders of the sender-change reset
Rule 3 of the header says a sender change closes the window and a
reconnected client must re-arm. The harness does not walk the sequence
on from there, a new sender's soft reset of the held job, as a session
of its own (commit 09cf924 removed test_sender_change_then_reset), and
the header did not say where each assertion of that sequence lives.
The disarm and the latch relock at the change are laser_arm_test.c
case H (glowforge_laser.c: the client generation changing mid-cycle
enqueues the feed hold and disarms). The soft reset of the held job is
the core's reset from a completed hold, which session
hold-reset-restart walks under rule 10 with more asserted than the
removed case had: the reset banner, Idle with no alarm, and the
position kept. The hold's origin does not reach the reset path
(driver.c driverReset disarms and resets the stream whatever held the
job, and gflaser_disarm is idempotent), so the sender-change variant
is a permutation of that one branch, and this repository keeps one
proof per rule. Rule 3 now names both holders.

Proof: header text only, no behavior change. python3 -B -m py_compile
and pyflakes are clean on scripts/bench/laser_lifecycle_test.py, and
the harness passes whole against the native null-sink build of the
driver at badc832 (-DGFMCODE_WAIT_S=5.0), hold-reset-restart among
its sessions. The harness runs in grblHAL-glowforge CI (python3
forgefirm/scripts/bench/laser_lifecycle_test.py build/grblHAL_glowforge).

Acceptance: none; the header docstring of a harness outside every
covers map.
2026-10-15 22:42:49 -04:00
ScottW514 b2a011ac08 ctlport_test: the polling sender is a Grbl sender, not a product
The NewlinePoller docstring and poll_case's named one sender product
as the sender that polls '?' with an end of line behind it, and quoted
that product's poll rate beside the harness's own. The behavior under
test is the controller port's: an empty line is not the sender
speaking, a port jog is never refused for a poll, and every poll draws
its one ok. Which sender polls, and how often, is not the contract;
the harness's own rate (four times a second, period 0.25) is the fact
that matters. Both docstrings now say a Grbl sender that polls, and
the comparative rate is gone.

Proof: docstrings only, no behavior change. python3 -B -m py_compile
and pyflakes are clean on scripts/bench/ctlport_test.py, and the file
names no product. The harness runs in grblHAL-glowforge CI (python3
forgefirm/scripts/bench/ctlport_test.py build/grblHAL_glowforge).

Acceptance: none; docstrings of a harness outside every covers map.
2026-10-15 22:42:49 -04:00
ScottW514 68e9d3fb8e laser_stream_test: the rolloff reference pins the bench-default curve's rendering of S300 end to end
Rule 19 names laser_arm_test.c case P as the proof of the dose curve's
mapping. Case P calls curveComputeValue directly and reads its counts;
it does not reach the wiring from the S word to the stream:
spindleConfig installing the curve as the PWM compute hook, and the
level the hook returns riding the dither into FIRE density. A driver
that left the hook uninstalled passes case P and renders every cut
through the identity mapping, a different delivered dose at every S.

The rolloff reference session (rule 23) already runs one M4 line at
S300 under the shipped curve and reads its cruise density, to compare
against the two-cut run. It now also holds that density to the curve's
own point: 30 percent light through the bench-default curve lands
between its (45 percent density, 21 percent light) and (60, 37)
points, 53.44 percent density, level 68 of 127, density 0.535, held
to 0.02. The expectation is mirrored next to the curved configuration
the way the floor is, so a change to the curve changes it. The
header's rule 19 names the point. No session is added, and no session's
configuration changes.

Proof: the host test grblHAL-glowforge CI runs (the step "Laser stream
emission rules", python3 forgefirm/scripts/bench/laser_stream_test.py
build/grblHAL_glowforge) against the native null-sink build of the
driver at badc832 (-DGFMCODE_WAIT_S=5.0): the rolloff reference cruise
renders 0.5355 against the expected 0.5354, and the whole harness
passes, every session. Negative control, through a scratch copy of the
harness: the same cut with laser_dose_curve = off renders 0.3625 (the
identity mapping, level 46 through the 10 percent floor), 0.17 from
the point, and fails the assertion.

Acceptance: none; the harness is grblHAL's CI's, and src/** of
grblhal-glowforge keeps its covers.
2026-10-15 22:42:49 -04:00
ScottW514 f47373a20d laser_stream_test: check_density holds each lit span to the move, so a level applied late shows
check_density judges a density run by what its lit spans carry: every
power byte at full duty, each span's FIRE density at its level, no
burst past the base period. It did not read a span's length against
the move it rode. A level that reaches the stream late (the move opens
dark, FIRE starts where the level is applied) renders the right
density over a shorter span, and the density alone cannot see it:
with FIRE blanked in the first 40 percent of the S300 move of the
idle-s stream, the check passed. Rule 14 (a level set while idle
survives to the next run) and rule 12 (density rung by rung) proved
the density of whatever was lit, not that the whole move was lit;
rules 16 and 17 keep their span length through check_cut_spans.

check_density takes an optional move length in machine ticks and holds
each span to 0.9 to 1.1 of it. The three density call sites pass
theirs: the ladder sessions (density, floor-derived) LADDER_TICKS,
from the ladder's now explicit LADDER_FEED, and idle-s IDLE_S_TICKS.
Span and move are both in the mode's own ticks, so the bound holds at
every microstep mode. The bound is 0.9 to 1.1, not check_cut_spans'
0.8 to 1.25, because the real spread is about 1 percent (the faintest
rung, S20, opens on carried debt at 0.991 of the move; idle-s runs at
1.007) and the looser bound lets a move 15 percent dark pass. The
header's rules 12 and 14 say what they prove.

Proof: the host test grblHAL-glowforge CI runs (the step "Laser stream
emission rules", python3 forgefirm/scripts/bench/laser_stream_test.py
build/grblHAL_glowforge) against the native null-sink build of the
driver at badc832 (-DGFMCODE_WAIT_S=5.0): the density, idle-s and
floor-derived sessions pass the bound with span ratios 0.991 to 1.007,
and the whole harness passes, every session. Negative control on the
captured idle-s stream, through a scratch copy of the harness: FIRE
blanked in the first 40 percent of the S300 move (span ratio 0.604)
and in the first 15 percent (0.855) fail the bound, and the same
streams pass with the move length omitted; a 5 percent trim (0.956)
passes, so the bound is neither vacuous nor hair-trigger.

Acceptance: none; the harness is grblHAL's CI's, and src/** of
grblhal-glowforge keeps its covers.
2026-10-15 22:42:49 -04:00
ScottW514 199282c029 setup.focus: the head's place read from /status and judged at X0 Y0 before the burn starts
The focus burn answers the wizard's home prompt with HOME from a dict,
and the wizard takes the operator's word the same way: nothing reads
where the head stands. A read at that prompt places nothing either: the
wizard restarts the controller before it, and a fresh controller drops
the X/Y reference. The one read with meaning comes before POST
/wiz/laser.focus/start, under the campaign's own controller, when a
manual home references X and Y: with manual_home_x and manual_home_y
blank or 0, it is the one provider that declares X0 Y0 at the gantry
stops.

head_at_home(ctx), called after the ready gate and before anything
starts, waits up to 10 s for a /status that serves a position (a
transient miss is not a stop; ten seconds of none fails the test before
any energy), records the read as head_at_start (pos, homed_axes,
home_source, judged), and judges the head at X0 Y0 within HOME_TOL_MM
(0.05 mm) when homed_axes carries X and Y and home_source is manual:
off its home, the test fails before any wizard start, so no controller
lever is touched. Unreferenced, the head's place rests on the
operator's word at the ready gate, the log says so, and judged is
False. The HOME answer and the steps stay; the description names the
read. The strict form, a manual home before every focus burn, is a
procedure the operator must add to every campaign; this change records
and judges what it can read.

Proof: tests/test_setup_burns.py, the Unit tests step of
forgetest-ci.yml (python -B -m unittest discover -s tests), 22 tests OK
on Windows and on Linux, with tests/test_setup_dark.py (20 OK) and
tests/test_laser_dwell.py (10 OK). The scripted daemon's /status
carries homed_axes 7 and home_source manual: at X0 Y0 the burn runs and
the evidence carries the read with judged True; at X 12.5 the test
fails before any start, with no burn started, no /controller/ lever
posted, and the settings as found; unreferenced (homed_axes 4,
home_source startup) the burn runs with judged False. pyflakes reports
no undefined name. Negative control on a scratch copy: with
setup_burns.py as it was before this change (the HOME answer alone),
the X 12.5 case burns (Failed not raised) and the two evidence cases
find no head_at_start; the three tests catch it.

Acceptance: setup.focus's fingerprint moves (its own text and
setup_burns.py's shared text); no other suite module imports from
.setup_burns, so nothing else moves. The next campaign runs setup.focus
with the head's place on record.
2026-10-15 22:42:49 -04:00
ScottW514 6a4ece4b0d setup.focus: the test reads the LASER_ON count and the tube current from sysfs itself at every poll of the burn
The focus burn's emission was judged from the burn's result alone: the
daemon's own 25 Hz reads of cnc/laser_on_sampled and pic/hv_current,
reported as laser_on_samples and hv_max. The one live catalog test of
the wizard's burn took the daemon under test at its word for the
emission it reported, where the laser.* tests judge from the test's own
/status samples.

run_check takes on_poll(doc), called once per poll of /wiz/dark with
the status document, a caller's own witness read beside the check's;
the default keeps every positional caller as it is. Witnesses, in
setup_burns, reads the two attributes from sysfs at every poll and
keeps each at its peak and the count of polls that read both. burn()
passes it to run_check, and emission_ok(ctx, last, seen) keeps the
result's three checks and adds the test's own: at least one poll read
both witnesses, the LASER_ON count read nonzero from here, and the tube
current read over HV_ON (30, the daemon's own rule for a lit burn,
jobstream.h JOBSTREAM_HV_ON) from here. Once the result's three checks
pass, the evidence carries the two peaks and the read count as
emission_seen; the two readings are not compared with each other. The beam sensor's handling is unchanged, and
the description names both readings.

Proof: tests/test_setup_burns.py, the Unit tests step of
forgetest-ci.yml (python -B -m unittest discover -s tests), 20 tests
OK on Windows and on Linux, with tests/test_setup_dark.py (20 OK) for
the positional callers of run_check. BurnRunTests plants
cnc/laser_on_sampled and pic/hv_current under GF_SYSFS_ROOT (the shape
of a bench record), the lit burn's evidence carries the two peaks with
reads over zero, and a burn whose result reports a lit burn while the
two attributes read 0 from here fails on the LASER_ON count, with the
setting put back and the controller restarted on it. pyflakes reports
no undefined name. Negative control on a scratch copy: with
setup_burns.py and setup_dark.py as they were before this change, that
same case passes (Failed not raised) and the evidence has no
emission_seen; the two new assertions catch both.

Acceptance: setup.focus's fingerprint moves (its own text, and the
shared text of setup_burns.py and setup_dark.py), and every test of
setup_dark.py moves through run_check: setup.check-switches,
setup.check-airflow, setup.check-motion, setup.check-lens and
setup.cloud-header-capture. motion.release does not move (its run_check
import is inside the function body). The next campaign runs setup.focus
with both readings on the bench.
2026-10-15 22:42:49 -04:00
ScottW514 802cd3af8d laser.emission-witness: the LASER_ON count read through the rapid, and two windows past the close
The dark stretch after the M5 judged emission from the samples whose
/status carried a laser block. forgectrl leaves that block out of a
served /status when cnc/laser_on_sampled or cnc/laser_pgood_sampled
does not read, and sample() records the count as None, which the relit
judge (any sample with emission) and the HV judge (the maximum over the
readings) both pass: a stretch whose count went unread after the dark
point passed as dark. The kernel latches the count once per ~1 s window
and holds it through the next, so a window with no read in it is a
window in which a fire is not witnessed. The stretch also ended at the
first disarmed sample, before the count for the window in flight at the
close had latched, so the rapid's last second was never read.

read_gaps(samples, key) reports, over a trail, the samples that carry a
reading, the ones that do not, and the longest stretch of seconds
between two readings, with the trail's ends as edges: an unread lead or
tail is a gap, and a trail with no reading is one gap, its whole span.
The judge requires RAPID_MIN_READS (4) readings of the count with no
gap over EMISSION_READ_GAP_S (0.9 s), inside the EMISSION_WINDOW_S
(1.0 s) latch period, and the same floor of readings for the HV
current. Both checks come before the relit and HV-dark checks, so those
never judge an unread stretch. sample_to_disarm keeps sampling for two
windows after the first disarmed sample: the count for the window in
flight at the close latches up to one window later and is held through
the next, and the gap rule asks for a read of it in that tail,
measured to the last sample served. The timeout bounds the wait for
the close alone. The close is the first disarmed
sample, which the disarm timing and the span are measured to; the
evidence's rapid block carries emission_reads and hv_reads, the log
line says how many samples carried the count and the longest unread
stretch, and the description names the rule.

Proof: tests/test_laser_dwell.py RapidReadTests, the Unit tests step of
forgetest-ci.yml (python -B -m unittest discover -s tests), 10 tests OK
on Windows and on Linux: every sample read (gap 0.2 s), one miss
(0.4 s) and a 0.8 s hole are inside the rule; a 1.2 s hole, the block
gone after the dark point (1 of 25 read, gap 4.8 s), the block gone for
the last 1.6 s, and a one-sample stretch fail it. pyflakes reports no
undefined name. Negative control on a scratch copy: with the trail's tail dropped
from the edges, the last-window case and the block-gone case report no
gap and their two tests catch it; with the gap bound raised to 1.3 s,
the 1.2 s hole passes the rule and its test catches it.

Acceptance: laser.emission-witness's fingerprint moves (its own text
and laser.py's shared text), and laser.armed-kill and
laser.pause-resume-lid-cancel move through the shared text; no other
suite module imports .laser at module level. laser.emission-witness is
always run, and the next campaign judges the rapid stretch's read
cadence on the bench (about 5 Hz in the last campaign's record).
2026-10-15 22:42:49 -04:00
ScottW514 099399b58f forgetest-ci: the undefined-name step fails when pyflakes is missing or raises
The "No undefined names" step runs under bash -e with no pipefail, and
-e does not reach the condition of an `if`. The former body pipes the
pyflakes run into grep, so the step's status is grep's alone: with
pyflakes absent ("No module named pyflakes" on stderr, exit 1) or
crashing, grep sees no match, the `if` is false, and the step passes
with nothing checked. Its `pip install pyflakes` also names no
interpreter.

The step now installs with `python -m pip`, into the interpreter it
runs; runs `pyflakes --version`, which fails the step under -e when
the module is missing; captures the pyflakes run, stderr included, in
a file (its own exit status is 1 for any message, unused imports
included, so it cannot be the gate); and fails on "undefined name" or
on "Traceback", a crash. The grep is pinned to pyflakes' message text;
no pyflakes version is pinned.

Proof: the step body, read from this workflow, run under bash -e over
the forgetest tree on Ubuntu 24.04 with python 3.12, against the
former body as the control. pyflakes missing: the former body exits 0,
this one exits 1. pyflakes 4.0.3 present, the tree as committed: both
exit 0 (22 unused imports and 2 unused locals, no undefined name). An
undefined name planted in a scratch copy: both exit 1. A pyflakes that
raises (a shim, scratch only): the former body exits 0, this one exits
1. In CI the step's log shows the pyflakes version before the check.

Acceptance: none; CI only. .github/ is outside meta-forgefirm and
every covers map.
2026-10-15 21:13:05 -04:00
ScottW514 03146a1529 forgefirm-image: pack-licenses.sh refuses a license text tar cannot read
pack-licenses.sh packs the image's license tree into one tar.gz and
removes the tree. It piped tar into gzip under /bin/sh (dash, no
pipefail): the pipeline's status is gzip's, and tar's is lost. The
check that follows proves only that the bundle holds license.manifest.
So a member tar cannot read leaves a bundle without that text, the
build green, and the tree removed, the only copy of the text with it.

tar now runs the compressor itself (-I 'gzip -9 -n'), so its exit
status covers the read and the write. A failed pack removes the
partial bundle and the script refuses, with the tree left for the
build log; pack_licenses in forgefirm-image.bb fails do_rootfs on the
refusal. The bundle's bytes do not change: tar feeds the same stream
to the same gzip, and a tree packed by both forms gives one sha256.

Proof: forgetest-ci "Unit tests" (`python -B -m unittest discover -s
tests -v` in forgetest), on Ubuntu 24.04 with python 3.12 as an
unprivileged user: test_image.py's PackLicensesTests gain
test_a_license_text_tar_cannot_read_is_refused_and_the_tree_left (a
text under zlib/ with mode 0: a nonzero exit, a message on stderr, no
bundle, the manifest left); the class's 5 tests OK. Negative control
on a scratch copy: the new case against the script without this change
fails at assertRefused with an exit status of 0. At the shell, on a
fixture tree with the unreadable member, the script without this
change exits 0, leaves the bundle without the text and removes the
tree; with it, the script exits 1, leaves no bundle and leaves the
tree. On a readable tree both forms write byte-identical bundles (one
sha256 for both). pyflakes reports no undefined name.

Acceptance: every catalog test. pack-licenses.sh is under
meta-forgefirm, whose content hash is part of the platform in every
fingerprint, so the image that carries this change needs the whole
catalog on the bench.
2026-10-15 21:12:04 -04:00
ScottW514 852943e20c forgetest: the fixture's press docstring stops inviting a long press
Fixture._press said that a check that must reach the machine while the
button is down asks for the longest press the box can make, and the
unit test's comment repeated the invitation. That contradicts the
design: no press is ever held, every reader of the button catches the
default press (an edge, or a poll of 100 ms or less), and the box
clamps any request into 20 to 500 ms. The docstring now says what `ms`
is (the box's own 200 ms when it is not said, clamped by the box) and
that no check needs a longer press. The `ms` plumbing stays: it
mirrors the box's API field, and the box clamps regardless. The test's
pass-through assertion stays as the client's contract that a request
reaches the box as asked; the clamp is the box's, held by the fixture
firmware's policy test.

Proof: forgetest-ci "Unit tests" (`python -B -m unittest discover -s
tests -v` in forgetest), on Ubuntu 24.04 with python 3.12: the
ClientTests of test_fixture.py, 6 tests, OK. A grep for "longest
press" and "while the button is down" over fixture.py and
test_fixture.py prints nothing. Text only, so no negative control
applies. pyflakes reports no undefined name.

Acceptance: none. fixture.py is a non-suite module outside the shared
judges, and tests/ are in no fingerprint; no press length or path
changes.
2026-10-15 21:09:29 -04:00
ScottW514 a596767a9a forgetest: a press the fixture could not make leaves a notice that comes down at the press
press_now and arm_press hand the arm press to the operator when the
bench actuator refuses it (the box is off) or when the button never
lights for it within the arm wait, and put the notice up for a person.
Those three fallbacks raised the notice without the watch that takes
it down at the press, so the notice stood until the test's next step:
the second-press hazard the watch exists for, because a press during
the job pauses it. Each fallback now calls _clear_when_taken() after
its notice, so the notice comes down the moment the controller takes
the press, as the operator's own path does. No press, judge, or energy
path changes.

Proof: forgetest-ci "Unit tests" (`python -B -m unittest discover -s
tests -v` in forgetest), on Ubuntu 24.04 with python 3.12:
test_fixture.py's test_the_arm_notice_comes_down_when_the_press_is_taken
gains the leg where the fixture cannot press: press_now's notice for a
refused press, arm_press's notice for a refused press, and arm_press's
notice for a button that never lit each come down within 2 s of the
press. Three runs OK, the file's 21 tests OK. Negative control on a
scratch copy: the leg against the runner without the three calls fails
at the first press, the notice still standing. pyflakes reports no
undefined name.

Acceptance: every catalog test's fingerprint moves: runner.py is a
shared judge, in every implementation hash (43 of 43 move). Against
the campaign this series already needs, that adds no run: 850498f
moved all 43 once. tests/ are in none.
2026-10-15 18:06:36 -04:00
ScottW514 3d2f2d9ab4 forgetest: the arm-notice watch ends with its notice and the run
_clear_when_taken takes the operator's arm notice down the moment the
controller takes the press (the button lights for the arm wait and
goes out at the press). Its watch thread polled the button LEDs every
50 ms with the arm's timeouts as its only exits: 30 minutes for the
button to light, 90 minutes in all. Nothing in it read the run's end,
the abort event, or whether the notice it guarded still stood, so a
test that ended before the press (a FAIL on the way to the arm) left a
thread reading up to six sysfs files twenty times a second behind it.

The watch now lives only as long as the notice it guards and the run:
a notice cleared or replaced, a run that finished, or an abort ends
it. `standing()` reads both under the run's lock on every poll, and
the poll waits on the run's abort event instead of sleeping, so an
abort ends the wait at once. The cadence stays 50 ms: the notice still
drops within 50 ms of the press. The watch commands nothing; no press,
judge, or energy path changes.

Proof: forgetest-ci "Unit tests" (`python -B -m unittest discover -s
tests -v` in forgetest), on Ubuntu 24.04 with python 3.12:
test_fixture.py's test_the_arm_notice_comes_down_when_the_press_is_taken
gains the leg that a run finished before the press ends the watch
within 2 s and leaves the finished run's notice as it was; the file's
21 tests pass; test_setup_burns.py, whose burns reach the watch
through press_now, passes its 19 tests and leaves no
forgetest-arm-notice thread behind (10 before this change); and
test_server.py, test_operator.py and test_queue.py pass. Negative control on a scratch
copy: the new leg against the unchanged runner fails with the
forgetest-arm-notice thread still alive after the run finished.
pyflakes reports no undefined name.

Acceptance: every catalog test's fingerprint moves: runner.py is a
shared judge, in every implementation hash (43 of 43 move). Against
the campaign this series already needs, that adds no run: 850498f
moved all 43 once. tests/ are in none.
2026-10-15 18:01:31 -04:00
ScottW514 850498fb00 forgetest: the shared judges join every test's implementation hash
Every catalog test judges the machine through package modules it never
imports: the Context a test is handed and the takeover (runner.py), the
sysfs, LED, switch and HTTP readers behind it (hw.py), and the
fresh-boot idle state judged around every run (baseline.py). The cloud
and extension tests print the dark job of puls.py, which the suite
imports at level 2, and the sibling walk follows level-1 imports only.
So none of the four was in any fingerprint: a weakening of sysfs_int,
button_lit, Context.check, wait_for or position_quantized moved no
hash, and an inherited PASS of kernel.latch-locked-idle,
laser.armed-kill or any takeover test survived it unseen by the gate.

catalog.py names them in SHARED_JUDGES, with the criterion in the
comment above it: a module every test runs through without importing
it, or the dark job the cloud tests print. The rest of the package (the
server, the page, the campaign rules, the artifact, the manifest, the
registry, the bench actuator) judges nothing about the machine and
stays out. shared_judge_paths() resolves the four next to the module,
and implementation_sha folds their text after the module's shared text
and its imported siblings. A listed file that is missing raises, never
a hash computed without it. _direct_siblings stays level-1 only, and
the tool version does not move: the gate judges by source_sha and the
fingerprint.

Every catalog test's implementation hash moves once with this change,
so every recorded PASS is stale and the next campaign is a full one, 43
tests. The catalog hash does not move, so an open campaign stays open
with every test required. From here on, an edit to runner.py, hw.py,
baseline.py or puls.py moves every test.

Proof: forgetest-ci "Unit tests" (`python -B -m unittest discover -s
tests -v` in forgetest): test_manifest.py runs 38 tests, OK, with
test_a_shared_judge_edit_moves_every_test (an edit to a judge moves
both tests of a module, a package file outside the list moves neither)
and test_the_shared_judges_exist_and_take_every_package_import_of_the_suite
(the four files exist next to catalog.py, and every level-2 import of
a suite module is a listed judge or the registry). Negative controls
on scratch copies: `judged = []` in implementation_sha fails the first
case alone; "runner.py" dropped from SHARED_JUDGES fails the guard
alone, naming homeoff.py's import of runner. On the real catalog the
source_sha of 43 of 43 tests differs from HEAD's; with hw.sysfs_int
weakened to return the latch bit on an unparsable read, 0 of 43 move at
HEAD and 43 of 43 move with this change. The coverage lint on the tree
manifest from the recipe pins reports 0 uncovered paths, 0 host-tested
row problems, 43 tests. pyflakes reports no undefined name.

Acceptance: every catalog test. The implementation hash of all 43 moves
once, so the next campaign runs the whole catalog.
2026-10-15 17:54:06 -04:00
ScottW514 4d8f0d2e67 forgetest: the lint counts a host test as run only by a step every run of the workflow runs, on push or pull request
A HOST_TESTED row names a workflow and a host test, and the coverage
lint judges the row by what the workflow's steps run. The parser stored
a step's `if:` and `continue-on-error:` and read neither, read no job
key, and read no `on:`. So a step under `if: false`, a step whose
failure the run tolerates, a job under an `if:`, and a workflow on
`workflow_dispatch` alone all counted as running the test, and a
checkout under a condition counted as a checkout: a disabled or
tolerated host test keeps its row green and its paths covered.

The lint now reads the workflow's events and the conditions of a step
and of its job. `workflow_triggers` returns the events of the `on:`
mapping, list or scalar. What is under an event (its branches, its
paths, a dispatch's inputs) is not an event, and a flow mapping names
none. A row's workflow must run on push or pull_request, or the lint
reports that it runs on no push or pull request: a dispatch or a
schedule alone runs on no change, and a call alone is listed as well,
since the lint does not follow it to its caller. `workflow_steps`
tracks the job
each step is in and carries the job's `if:` and `continue-on-error:`
on the step as `job`. `_on_every_run` counts a step only when neither
it nor its job carries an `if:` other than the literal `true` or a
`continue-on-error:` other than the literal `false`. `workflow_runs`
and `checkout_path` use it, so a conditional step runs nothing and a
conditional checkout is no checkout. The reason for such a step stays
"does not run it". `needs:` is not read, nor are the `paths:` and
`branches:` filters of an event: the lint stays a floor there.

Proof: forgetest-ci "Unit tests" (`python -B -m unittest discover -s
tests -v` in forgetest): test_manifest.py runs 36 tests, OK, with the
two new cases, test_a_step_counts_only_when_every_run_of_the_workflow_runs_it_and_its_failure_fails_the_run
and test_the_events_a_workflow_runs_on, and with
test_the_workflow_has_to_run_the_host_test extended by a dispatch-only
workflow and a tolerated step. Negative controls on scratch copies:
`_on_every_run` forced to return True fails the step case and the
host-test case (failures=2); the trigger check deleted from
host_row_problems fails the host-test case (failures=1). The coverage
lint on the tree manifest from the recipe pins reports 0 uncovered
paths, 0 host-tested row problems, 43 tests. pyflakes reports no
undefined name.

Acceptance: none. forgetest is dev-only and in no fingerprint; the
lint runs in CI, not in a campaign.
2026-10-15 17:52:16 -04:00
ScottW514 f165713d05 forgetest: button.c and led.c leave the host-tested rows for the covers of the tests that drive them
HOST_TESTED carried forgectrl's src/button.* and src/led.* against
host_setup.sh, which presents the button as a held word in a fake
switches file and no LED at all. Both are driven on the machine by
catalog tests that still run: the fuse view forgectrl.auth reads is
two-factor, the token and the physical button (main.c cb_fuse_identity
through operator_present, button.c); the setup checks' press prompts
read the button and light it through led.c, and setup.check-switches
reads both back. manifest.py's own rule puts a path a catalog test
exercises on the hardware in that test's covers, not in a row: in a row
it is in no fingerprint, so a change to the button's poll, its debounce
or the LED release moved nothing and every PASS was inherited past it.

- manifest.py: the two rows go (src/hooks.h stays: main.c's hook
  declarations, proven through the setup routes on a host).
- suite/forgectrl.py: src/button.* joins _COVERS_AUTH.
- suite/setup_dark.py: src/button.* and src/led.* join DARK_COVERS, the
  covers of every setup check.

The laser tests judge the LED by the target the controller writes
(glowforge_laser.c button_led), so led.c is not theirs.

Proof: the coverage lint, enforced, on the tree manifest from the
current pins: 0 uncovered paths, 0 host-tested row problems, 43 tests.
tests/test_manifest.py (34) and the unit suite pass.

Acceptance: the fingerprints of forgectrl.auth and of every setup check
move; a release campaign runs them again.
2026-10-15 16:18:51 -04:00
ScottW514 80ddd53bcd laser_stream_test: rule 8 reads a FIRE tick's distance from a step, so a dwell burn shows under the dither
check_fire_gaps counted consecutive stepless ticks carrying FIRE and
reset on any dark tick: the analog rendering of a fire state leaked
into a cycle's idle pad (stepper_stream.c gf_stream_go_idle names it:
a stationary dwell burn at every planner starve). Under the density
model every tick goes through the dither mask, so the same leaked
state renders as bursts of at most one period (80 ticks at S800)
split by dark ticks, and the run never reaches the limit. With the
analog churn session gone, density-churn was the only session on rule
9, and it could not see the leak it was there for.

The check now reads rule 8 both ways: the run of lit stepless ticks as
before, and for every FIRE tick its distance from the nearest step on
either side, the one before it and the one after it. A cut's first
FIRE tick leads its first step by the accel-from-rest interval (412
ticks at x32) and its last trails its last step by less, both inside
the limit (1000); a FIRE tick with no step that near on either side is
in a gap, and lit, however the dither spaced it. The distance is read
from both sides because a stream opens dark on its lead-in pad and a
cycle opens lit before its first step: depth into a stepless run alone
flagged the m4 session's first lit tick, 22529 ticks of dark pad after
the stream start and a few hundred before the first step.

Proof: on synthetic streams, a legitimate 2253-tick dark gap between
two cuts passes both readings; a continuous analog leak across it
fails both; the same gap carrying a dithered leak at S800 (64 of 80
ticks lit) and at S200 (16 of 80) passed the old reading and fails the
new. The whole harness passes against the native null-sink build
(-DGFMCODE_WAIT_S=5.0), every session.

Acceptance: none; the harness is grblHAL's CI's, and src/** of
grblhal-glowforge keeps its covers.
2026-10-15 16:18:06 -04:00
ScottW514 c3da9fa1bd Catalog: setup.account-login, motion.cancel-abort, forgectrl.settings-bounds and exthost.service leave; the rootfs lint takes image.health's file checks
The catalog is 43. Each test leaves for a host proof that its
component's CI runs at the pins:

- setup.account-login: the login rules are host_routes.sh's (forgectrl
  6c2bb38); the render is test_image.py's new AccountRenderTests, which
  runs the layer's forgefirm-users.init with reload in a private mount
  namespace as root (a fresh /run, the scratch account files over /etc,
  the scratch data directory, root's, over /data): the data directory
  found 0700 is 0711 after the render, and as the account's own uid the
  home is entered and written while listing the data directory and
  reading the users record are EACCES. A control without the script's
  chmod go+x fails it (700 != 711). forgectrl.auth's cover widens to
  src/ui/login.*, since login.html had no other cover.
- motion.cancel-abort: laser_lifecycle_test.py's new rule 17 on the
  null-sink controller: 0x85 stops a jog short of its target, Idle; ^X
  mid-move lands in Alarm with the position kept and no return to the
  job start; $X recovers to Idle and the next jog ships steps (counted
  in GFSINK_DUMP). It passes on the driver at its pin, badc832.
  motion.deadman requires motion.pacing in its place.
- forgectrl.settings-bounds: host_routes.sh refuses the out-of-range and
  over-length values with the settings file byte for byte the same, and
  the lid lamp under GF_SYSFS_ROOT follows lid_lamp_idle (forgectrl
  6c2bb38).
- exthost.service: forgeext's host tests at the pinned bb75b23 -
  sandbox_test (the confinement from inside, the pulse device, the
  settings file and the setup record refused), run_test.py (the API,
  safe mode, the master switch, and the killed host under the init
  script's own wrapper), install_test.py and sdk_test.py.
  exthost.armed-freeze requires exthost.platform in its place.

HOST_TESTED rows for each path the lint named once exthost.service
left: forgectrl src/logs.* to logs_test.c and
docs/advisories/extensions.md to host_setup.sh; forgeext src/api.*,
caps.*, evfeed.*, holdkeep.*, httpreq.*, manifest.*, quota.*,
sandbox.*, settings.*, state.* to their unit tests, netrules.* to
netrules_test.py, call.* to sdk_test.py, install.*, pkg.* and index.* to
install_test.py, and main.c, fflog.* and CMakeLists.txt to run_test.py.
test_manifest's behavioral-path case is unaffected.

The rootfs lint takes image.health's file checks (rules init-order,
logging, watchdog, read-only, dhcpv6, and on a release image factory),
each proved in RootfsLintTests by a drift that fails it. image.health
keeps the live half: the rendered issue and motd against the running
manifest, one host and no pool process, watchdog0, no userspace
watchdog running, the mounts, the dev image's factory slots read-only,
no DHCPv6 client running, the DHCP client's -b and parent, ifup, the
getty, and IPv6. release.sh drops its PermitRootLogin and
PermitEmptyPasswords greps, its ROOTFS_READ_ONLY check and its /factory
fstab check, which the lint holds; it keeps the empty root shadow
field, / mounted ro, and the three HostKey lines under /data.

setup.py registers no test now: its helpers the deleted tests alone
called go (tls_base, tcp_open, mode, press_status, wiz_summary,
wait_settled, the login constants), and test_setup_suite.py keeps the
cases of what remains.

Fingerprints: image.health's moves (its text), every test in
exthost.py, forgectrl.py and motion.py moves with its module text, and
the layer content hash moves with rootfs-lint.sh: the next image
inherits no campaign.

Proof: the coverage lint over the tree manifest from the pins finds 0
uncovered paths and 0 host-tested row problems (43 tests); pyflakes on
the touched files is clean; forgetest's unit suite under Linux passes
(334), test_image.py's 28 among them. The lint passes image
20261015142442's release tree as release and its dev tree as dev, and
refuses the dev tree as release (sshd, factory).
laser_lifecycle_test.py passes whole on the null-sink build of badc832.
2026-10-15 14:02:20 -04:00
ScottW514 56aa164708 Pin forgeext 0.1.4 on the pushed head
- forgeext e09f7b8 -> bb75b23 (0.1.3 -> 0.1.4): sandbox_test opens the
  machine's own files from inside the sandbox - /dev/glowforge,
  /data/forgefirm/forgefirm.conf, /data/forgefirm/setup.json - and
  requires EACCES of each (ENOENT is no pass; a file the host lacks is
  made for the run, readable by every account, and removed after).
  Test-only; nothing the image installs changes.

Proof: forgeext's CI run on bb75b23 (38780750106) is green on a kernel
with Landlock ABI 7; its log shows the three files made for the run and
each open refused with EACCES (sandbox_test, 0 failures). bitbake -c
fetch forgeext from this pin succeeds (2 tasks).
2026-10-15 13:57:52 -04:00
ScottW514 df5ae151ab Pin forgeext 0.1.3 on the pushed head
- forgeext 31cdb40 -> e09f7b8 (0.1.2 -> 0.1.3): run_test's last phase
  runs the host under init/forgeext.init's own wrapper; a SIGKILL of the
  host takes its service with it at once, the wrapper starts the host
  again, and the service runs again under a new pid. Test-only; nothing
  the image installs changes.

Proof: forgeext's CI run on e09f7b8 (38777839870) is green on a kernel
with Landlock ABI 7, and the phase's six checks passed in it (run_test,
0 failures). bitbake -c fetch forgeext from this pin succeeds (2 tasks).
2026-10-15 13:37:42 -04:00
ScottW514 10ae7bf39c Pin forgectrl 0.1.33 on the pushed head
- forgectrl 22a2cfc -> 6c2bb38 (0.1.32 -> 0.1.33): cam.c resolves the
  lid and head lamps under GF_SYSFS_ROOT (unset: /sys/glowforge/, the
  paths as they were), and host_routes.sh proves the lid lamp follows
  lid_lamp_idle: a level is written, a refused value writes nothing, a
  cleared key writes the default 236.

Proof: forgectrl's CI run on 6c2bb38 (38769524512) is green, and
host_routes.sh ran in it. bitbake -c fetch forgectrl from this pin
succeeds (2 tasks). The tree manifest from the pins holds forgectrl at
6c2bb38 (229 files); the coverage lint finds no uncovered path and no
row problem (47 tests); check-ui-vendor finds the shared UI files
identical; test_tree_manifest passes; the gate self-check refuses the
empty artifact (rc 1).
2026-10-15 12:50:22 -04:00
84 changed files with 18680 additions and 1114 deletions
+16 -3
View File
@@ -143,9 +143,18 @@ jobs:
- name: No undefined names (the live drills never execute on the host)
working-directory: forgefirm/forgetest
# pyflakes exits 1 for any message, unused imports included, so the
# grep is the gate. A run stopped by SIGINT exits 1 too, and its
# "... stopped" line fails the grep. Any other status (a usage
# error, a signal pyflakes does not handle) fails the step and
# prints the capture, and a capture that cannot be written fails it
# too. pyflakes ends a SIGPIPE with status 1 and no line, and a
# write to the capture file raises none.
run: |
pip install pyflakes
if python -B -m pyflakes forgetest tests | grep -E "undefined name"; then exit 1; fi
python -m pip install pyflakes
python -B -m pyflakes --version
{ python -B -m pyflakes forgetest tests || test $? -eq 1; } > pyflakes.txt 2>&1 || { cat pyflakes.txt; exit 1; }
if grep -E "undefined name|Traceback|^\.\.\. stopped$" pyflakes.txt; then exit 1; fi
- name: Shared UI files match forgectrl at its pinned revision
working-directory: forgefirm
@@ -153,7 +162,11 @@ jobs:
- name: Coverage lint (enforced)
working-directory: forgefirm/forgetest
run: python -B -m forgetest.coverage --manifest ../tree-manifest.json --enforce | tee "$GITHUB_STEP_SUMMARY"
# The step runs under bash -e with no pipefail of its own: set here,
# the status of the step is the lint's, not tee's.
run: |
set -o pipefail
python -B -m forgetest.coverage --manifest ../tree-manifest.json --enforce | tee "$GITHUB_STEP_SUMMARY"
- name: Gate self-check on the tree manifest
working-directory: forgefirm
+1 -1
View File
@@ -133,7 +133,7 @@ ForgeFIRM is open firmware for stock Glowforge lasers (Basic, Plus, Pro). It
runs on the factory NXP i.MX6 control board with no hardware modification. It
replaces the cloud-dependent factory userspace with an open Linux image that
controls the machine locally. In GRBL mode the machine is a standard grblHAL
controller for LightBurn and other senders on TCP port 23. Cloud mode runs the
controller for a Grbl sender on TCP port 23. Cloud mode runs the
factory experience through the Glowforge web service, and it is a deliberate,
maintained feature. The stack is hardware validated: GRBL mode cuts real jobs,
and cloud mode runs end to end.
+4
View File
@@ -0,0 +1,4 @@
# The scratch trees forgectrl's cool_flow_test and super_test write in the
# directory they run from
/cool-flow-test/
/super-test/
@@ -1 +0,0 @@
{"seq":268,"ts_mono":1268.000,"fire_ok":true,"verdict":"OK","hold":false,"resume_ok":true,"armed":true,"reason":"","down_c":22.30,"up_c":22.30}
@@ -1 +0,0 @@
0
@@ -1 +0,0 @@
0
@@ -1 +0,0 @@
255
@@ -1 +0,0 @@
idle
@@ -1 +0,0 @@
1023
@@ -1 +0,0 @@
300
@@ -1 +0,0 @@
629
@@ -1 +0,0 @@
0
@@ -1 +0,0 @@
700
@@ -1 +0,0 @@
700
@@ -1 +0,0 @@
65535
@@ -1 +0,0 @@
26214
@@ -1 +0,0 @@
43278
@@ -1 +0,0 @@
300
@@ -1 +0,0 @@
300
@@ -1 +0,0 @@
300
@@ -1 +0,0 @@
{"seq":1517,"ts_mono":3127.000,"fire_ok":true,"verdict":"OK","hold":false,"resume_ok":true,"armed":false,"reason":"HEAD CRASH SIGNAL (axes x) - motion stopped, laser locked","down_c":22.30,"up_c":22.30}
-1
View File
@@ -1 +0,0 @@
0
@@ -1 +0,0 @@
1
@@ -1 +0,0 @@
0
@@ -1 +0,0 @@
0
-1
View File
@@ -1 +0,0 @@
idle
-1
View File
@@ -1 +0,0 @@
1
@@ -1 +0,0 @@
204
@@ -1 +0,0 @@
300
@@ -1 +0,0 @@
629
@@ -1 +0,0 @@
0
@@ -1 +0,0 @@
100
@@ -1 +0,0 @@
100
@@ -1 +0,0 @@
100
@@ -1 +0,0 @@
100
@@ -1 +0,0 @@
700
@@ -1 +0,0 @@
700
@@ -1 +0,0 @@
0
@@ -1 +0,0 @@
0
@@ -1 +0,0 @@
0
@@ -1 +0,0 @@
300
@@ -1 +0,0 @@
300
@@ -1 +0,0 @@
300
+97 -30
View File
@@ -69,11 +69,14 @@ class Test:
def source_sha(self):
"""The hash of the test's own implementation: its function (the
decorator included) together with the code its module shares
among its tests - everything outside the @test functions. Part
of the fingerprint: a change inside one test's body invalidates
that test's earlier passes and no other; a change to a helper
invalidates the tests of that module. A test the module does not
define in the @test form hashes its whole file."""
among its tests (everything outside the @test functions), the
shared code of every sibling module that either of the two
imports, and the shared judges (SHARED_JUDGES). Part of the
fingerprint: a change inside one test's body invalidates that
test's earlier passes and no other; a change to a helper
invalidates the tests of that module and the tests that import
it; a change to a shared judge invalidates every test. A test the
module does not define in the @test form hashes its whole file."""
if self._source_sha is None:
# One filler at a time: a second poll that arrives while the
# first computes waits for it rather than repeating the work.
@@ -144,6 +147,28 @@ def source_file_sha(path):
return hashlib.sha256(data).hexdigest()
# The package modules that judge the machine. Every test runs through
# the first three without importing them: the Context a test is handed
# and the takeover (runner.py), the sysfs, LED, switch and HTTP readers
# behind it (hw.py), and the fresh-boot idle state judged around every
# run (baseline.py). puls.py is the dark job the cloud and extension
# tests print: suite/cloud.py imports it from the package (`from .. import
# puls`), an import the sibling walk does not follow. Their text joins
# every implementation hash: a change to one of them moves every test.
# The rest of the package (the server, the page, the campaign rules, the
# artifact, the manifest, this registry, the bench actuator) judges
# nothing about the machine and stays out.
SHARED_JUDGES = ("baseline.py", "hw.py", "puls.py", "runner.py")
def shared_judge_paths():
"""The SHARED_JUDGES files, next to this module. Every one has to be
there: a judge missing from the package is an error, never a hash
computed without it."""
here = os.path.dirname(os.path.abspath(__file__))
return [os.path.join(here, name) for name in SHARED_JUDGES]
# The files never change under a run, so each is read and parsed once:
# path -> (text, tree or None), path -> (shared sha, {test id: own sha}),
# path -> the sibling modules it imports itself.
@@ -178,6 +203,20 @@ def _parsed(path):
return hit
def _test_defs(tree):
"""{test id: its function} for the @test("...") functions at the top
level of a module's tree."""
defs = {}
for node in (tree.body if tree is not None else []):
if not isinstance(node, ast.FunctionDef):
continue
for d in node.decorator_list:
if (isinstance(d, ast.Call) and isinstance(d.func, ast.Name) and d.func.id == "test"
and d.args and isinstance(d.args[0], ast.Constant) and isinstance(d.args[0].value, str)):
defs[d.args[0].value] = node
return defs
def module_parts(path):
"""(shared sha, {test id: own sha}) for a suite module: the test ids
are read from the @test("...") decorators on the module's top-level
@@ -191,14 +230,9 @@ def module_parts(path):
text, tree = _parsed(path)
lines = text.split("\n")
spans = {}
for node in (tree.body if tree is not None else []):
if not isinstance(node, ast.FunctionDef):
continue
for d in node.decorator_list:
if (isinstance(d, ast.Call) and isinstance(d.func, ast.Name) and d.func.id == "test"
and d.args and isinstance(d.args[0], ast.Constant) and isinstance(d.args[0].value, str)):
for tid, node in _test_defs(tree).items():
start = min([dd.lineno for dd in node.decorator_list] + [node.lineno])
spans[d.args[0].value] = (start, node.end_lineno)
spans[tid] = (start, node.end_lineno)
owned = set()
for a, b in spans.values():
owned.update(range(a, b + 1))
@@ -211,10 +245,10 @@ def module_parts(path):
def sibling_imports(path, seen=None):
"""The suite modules a module imports with `from .x import ...`, as
paths, transitively, in a stable order. A helper a test calls from
a sibling module is part of the judge, so its text belongs in the
test's fingerprint."""
"""The suite modules a module's shared text imports with `from .x
import ...` or `from . import x`, as paths, transitively, in a stable
order. A helper a test calls from a sibling module is part of the
judge, so its text belongs in the test's fingerprint."""
if seen is None:
seen = []
for sib in _direct_siblings(path):
@@ -224,32 +258,65 @@ def sibling_imports(path, seen=None):
return seen
def _imported(path, nodes):
"""The suite modules the statements `nodes` import with `from .x
import` or `from . import x` (each name a module of the suite), at any
depth (inside a function or a block too), in the order they are
written."""
here = os.path.dirname(path)
found = sorted((n for top in nodes for n in ast.walk(top)
if isinstance(n, ast.ImportFrom) and n.level == 1),
key=lambda n: (n.lineno, n.col_offset))
out = []
for node in found:
for name in ([node.module] if node.module else [a.name for a in node.names]):
sib = os.path.join(here, name.replace(".", os.sep) + ".py")
if os.path.isfile(sib) and sib not in out:
out.append(sib)
return out
def _direct_siblings(path):
"""The suite modules one module imports itself, in import order."""
"""The suite modules one module's shared text imports itself: at its
top level, and inside its helpers and blocks. What a @test function
imports is that test's own (_own_siblings)."""
with _PARTS_LOCK:
hit = _SIBLINGS.get(path)
if hit is None:
_text, tree = _parsed(path)
here = os.path.dirname(path)
hit = []
for node in (tree.body if tree is not None else []):
if isinstance(node, ast.ImportFrom) and node.level == 1 and node.module:
sib = os.path.join(here, node.module.replace(".", os.sep) + ".py")
if os.path.isfile(sib):
hit.append(sib)
_SIBLINGS[path] = hit
tests = list(_test_defs(tree).values())
shared = [n for n in (tree.body if tree is not None else []) if not any(n is t for t in tests)]
hit = _SIBLINGS[path] = _imported(path, shared)
return hit
def implementation_sha(path, test_id):
def _own_siblings(path, test_id):
"""The suite modules one test's own function imports: part of that
test's judge, and of no other test's."""
_text, tree = _parsed(path)
node = _test_defs(tree).get(test_id)
return _imported(path, [node]) if node is not None else []
def implementation_sha(path, test_id, judges=None):
"""The implementation hash of one test (see Test.source_sha): the
test's own text, the shared text of its module, and the shared text
of every sibling suite module the module imports."""
test's own text, the shared text of its module, the shared text of
every sibling suite module the module's shared text or the test's
own function imports (transitively), and the text of the shared
judges. `judges` names files to take as the judges in place of
SHARED_JUDGES (the unit tests edit their own)."""
shared, own = module_parts(path)
if test_id not in own:
return source_file_sha(path)
helpers = [module_parts(p)[0] for p in sorted(sibling_imports(path))]
return _manifest.sha256_text("%s:%s" % (":".join([shared] + helpers), own[test_id]))
sibs = sibling_imports(path)
for sib in _own_siblings(path, test_id):
if sib not in sibs and sib != path:
sibs.append(sib)
sibling_imports(sib, sibs)
helpers = [module_parts(p)[0] for p in sorted(sibs) if p != path]
paths = shared_judge_paths() if judges is None else list(judges)
judged = [module_parts(p)[0] for p in paths]
return _manifest.sha256_text("%s:%s" % (":".join([shared] + helpers + judged), own[test_id]))
def test(id, *, title, subsystem, kind="auto", hardware="api", mode=None, covers=(),
+25 -8
View File
@@ -43,7 +43,14 @@ PULSE_WAIT_S = 1.5 # a pulse in progress ends within the firmware's
class FixtureError(Exception):
pass
"""A request the fixture did not carry out as asked. `sent` is True
when the failure came after the request went out (no reply, or a
reply cut off), so the box may have acted on it; a refusal, a failed
lookup and a failed connect leave it False."""
def __init__(self, *args, sent=False):
super().__init__(*args)
self.sent = sent
# ---------------------------------------------------------------- mDNS
@@ -271,11 +278,20 @@ class Fixture:
body = {}
return e.code, body
except (urllib.error.URLError, socket.timeout, OSError) as e:
# urllib raises a failed connect or send as a URLError; a
# timeout or a reset after the request went out comes bare, and
# the box may have acted on the request
sent = not isinstance(e, urllib.error.URLError)
if retry and not self.ip_override:
# the address may have moved: one fresh lookup, one retry
# the address may have moved: one fresh lookup, one retry,
# and a failure of either still says the first one went out
try:
self.address(refresh=True)
return self._request(method, path, body, retry=False)
raise FixtureError("fixture %s: %s %s: %s" % (self.where(), method, path, e))
except FixtureError as again:
again.sent = again.sent or sent
raise
raise FixtureError("fixture %s: %s %s: %s" % (self.where(), method, path, e), sent=sent)
def status(self):
st, body = self._request("GET", "/")
@@ -314,11 +330,12 @@ class Fixture:
def _press(self, ms=None):
"""One press, spaced from the last one. `ms` is how long the box
holds it (the firmware clamps it into 20 to 500, and takes 200 when
it is not said): a check that must reach the machine *while* the
button is down asks for the longest press the box can make. The controller detects
the button's rising edge, so a press must follow a release it has
seen: the next press waits for the last pulse to end (the
holds it: the box's own 200 ms when it is not said, and the box
clamps a request into 20 to 500, so no press is ever held. Every
reader of the button catches the default press (an edge, or a poll
of 100 ms or less), so no check needs a longer one. The controller
detects the button's rising edge, so a press must follow a release
it has seen: the next press waits for the last pulse to end (the
fixture's own pulse_ms) plus BUTTON_GAP_S. A 409 for a pulse in
progress (a press this client did not time) is waited out once
against the fixture's state, then retried."""
+637 -59
View File
@@ -118,12 +118,22 @@ BEHAVIORAL = [
# the manifest (its tests/ and .github/ files are listed there), or
# "forgefirm" for this repository (the null-sink harnesses under
# scripts/bench/, which grblhal-glowforge's workflow runs). The workflow
# must run the host test: name it, or discover it (a glob, unittest
# discover, pytest on its directory). A host-tested path counts as covered
# in the coverage lint, and like every path no test covers it is in no
# fingerprint: a change there makes no acceptance run necessary. A path a
# catalog test exercises on the hardware belongs in that test's covers, not
# here.
# must run the host test: on a push or a pull_request with no branches,
# tags or paths filter under it, and no types under the pull_request
# (one of the two events is enough), in a step
# that runs on every run of the workflow and whose failure fails it (no
# `if:` other than `true` and no `continue-on-error:` other than `false`
# on the step or its job, every job the job's `needs:` names one that
# runs on every run, and no key with one stray quote where the lint
# reads the workflow's keys), under bash or sh, on a line of the step's
# run text whose failure ends the step (the command alone on its line,
# or in a pipeline under pipefail, with errexit set and no line before
# it that ends the step or opens a compound command: workflow_runs), and
# name it, or discover it (a glob, unittest discover, pytest on its
# directory). A host-tested path counts as covered in the coverage lint,
# and like every path no test covers it is in no fingerprint: a change
# there makes no acceptance run necessary. A path a catalog test
# exercises on the hardware belongs in that test's covers, not here.
HOST_TESTED = [
# An extension package's holds as the engine reads them (forgeext's
# holdkeep_test and run_test prove the writer's side).
@@ -179,19 +189,23 @@ HOST_TESTED = [
# The data and run directories from the environment: the SSH flag in the
# run directory, written and removed by POST /system/ssh.
("forgectrl", "src/paths.h", "forgectrl:tests/host_routes.sh", "forgectrl:.github/workflows/build.yml"),
# The one gate the test hooks are read through (the sysfs root, the switch
# device and the run directory, honored only in a host-test run): the
# daemon started with the hooks set and no gate ignores and names them.
("forgectrl", "src/testhook.h", "forgectrl:tests/host_start.sh", "forgectrl:.github/workflows/build.yml"),
# The first run in the page's order: the four documents at their hashes,
# the press step breathing teal, the press taken as the acceptance, the
# form steps through main.c's hooks (extensions.md stays with
# exthost.service, which covers it).
# form steps through main.c's hooks; and the on-demand Extensions
# advisory, accepted at its hash with the typed phrase.
("forgectrl", "src/advisories.*", "forgectrl:tests/host_setup.sh", "forgectrl:.github/workflows/build.yml"),
("forgectrl", "docs/advisories/extensions.md", "forgectrl:tests/host_setup.sh",
"forgectrl:.github/workflows/build.yml"),
("forgectrl", "docs/advisories/safety-and-risk.md", "forgectrl:tests/host_setup.sh",
"forgectrl:.github/workflows/build.yml"),
("forgectrl", "docs/advisories/licenses.md", "forgectrl:tests/host_setup.sh", "forgectrl:.github/workflows/build.yml"),
("forgectrl", "docs/advisories/privacy.md", "forgectrl:tests/host_setup.sh", "forgectrl:.github/workflows/build.yml"),
("forgectrl", "docs/advisories/cloud-service.md", "forgectrl:tests/host_setup.sh",
"forgectrl:.github/workflows/build.yml"),
("forgectrl", "src/button.*", "forgectrl:tests/host_setup.sh", "forgectrl:.github/workflows/build.yml"),
("forgectrl", "src/led.*", "forgectrl:tests/host_setup.sh", "forgectrl:.github/workflows/build.yml"),
("forgectrl", "src/hooks.h", "forgectrl:tests/host_setup.sh", "forgectrl:.github/workflows/build.yml"),
# The setup sheet id from the fuse serial, against golden ids.
("forgectrl", "src/sheetid.*", "forgectrl:tests/sheetid_test.c", "forgectrl:.github/workflows/build.yml"),
@@ -207,6 +221,34 @@ HOST_TESTED = [
# store that holds only hashes, and the extension host's credential
# (mode 0600, exactly the host's capabilities).
("forgectrl", "src/tokens.*", "forgectrl:tests/tokens_test.c", "forgectrl:.github/workflows/build.yml"),
# The logging tree's paths, the level and target validators, the
# configured levels.
("forgectrl", "src/logs.*", "forgectrl:tests/logs_test.c", "forgectrl:.github/workflows/build.yml"),
# The extension host, each part to the test that drives it (the
# freeze, the supervisor, the machine client and the groups stay with
# exthost.armed-freeze, which covers them).
("forgeext", "src/api.*", "forgeext:tests/api_test.c", "forgeext:.github/workflows/build.yml"),
("forgeext", "src/caps.*", "forgeext:tests/caps_test.c", "forgeext:.github/workflows/build.yml"),
("forgeext", "src/evfeed.*", "forgeext:tests/evfeed_test.c", "forgeext:.github/workflows/build.yml"),
("forgeext", "src/holdkeep.*", "forgeext:tests/holdkeep_test.c", "forgeext:.github/workflows/build.yml"),
("forgeext", "src/httpreq.*", "forgeext:tests/httpreq_test.c", "forgeext:.github/workflows/build.yml"),
("forgeext", "src/manifest.*", "forgeext:tests/manifest_test.c", "forgeext:.github/workflows/build.yml"),
("forgeext", "src/quota.*", "forgeext:tests/quota_test.c", "forgeext:.github/workflows/build.yml"),
("forgeext", "src/sandbox.*", "forgeext:tests/sandbox_test.c", "forgeext:.github/workflows/build.yml"),
("forgeext", "src/settings.*", "forgeext:tests/settings_test.c", "forgeext:.github/workflows/build.yml"),
("forgeext", "src/state.*", "forgeext:tests/state_test.c", "forgeext:.github/workflows/build.yml"),
("forgeext", "src/netrules.*", "forgeext:tests/netrules_test.py", "forgeext:.github/workflows/build.yml"),
# A package's page asking its own service (forgeext call).
("forgeext", "src/call.*", "forgeext:tests/sdk_test.py", "forgeext:.github/workflows/build.yml"),
# Archives in and refused, and the signed index.
("forgeext", "src/install.*", "forgeext:tests/install_test.py", "forgeext:.github/workflows/build.yml"),
("forgeext", "src/pkg.*", "forgeext:tests/install_test.py", "forgeext:.github/workflows/build.yml"),
("forgeext", "src/index.*", "forgeext:tests/install_test.py", "forgeext:.github/workflows/build.yml"),
# The daemon as built, end to end: its command line, its log, the
# killed host under the init script's wrapper.
("forgeext", "src/main.c", "forgeext:tests/run_test.py", "forgeext:.github/workflows/build.yml"),
("forgeext", "src/fflog.*", "forgeext:tests/run_test.py", "forgeext:.github/workflows/build.yml"),
("forgeext", "CMakeLists.txt", "forgeext:tests/run_test.py", "forgeext:.github/workflows/build.yml"),
]
# This repository's root, when the package runs from a checkout of it; a
@@ -388,33 +430,297 @@ def repo_name(manifest, repo):
_STEP_KEYS = ("name", "uses", "run", "id", "if", "with", "env", "working-directory", "shell",
"continue-on-error", "timeout-minutes")
_STEP_RX = re.compile(r"^(\s*)-\s+(%s)\s*:" % "|".join(re.escape(k) for k in _STEP_KEYS))
_KEY_RX = re.compile(r"^(\s*)([\w-]+)\s*:\s*(.*)$")
# A mapping key, plain or in a matched pair of quotes ("if": is the same
# key as if:).
_KEY_RX = re.compile(r"""^(\s*)(?=(?:"[\w-]+"|'[\w-]+'|[\w-]+)\s*:)["']?([\w-]+)["']?\s*:\s*(.*)$""")
# A key with one stray quote (if": or "if:), which _KEY_RX does not read:
# YAML reads a key with the quote in its name, which no workflow key and
# no event has, or refuses the file.
_STRAY_RX = re.compile(r"""^\s*["']?[\w-]+["']?\s*:""")
_ON_RX = re.compile(r"""^(?:on|"on"|'on')\s*:\s*(.*)$""")
# What a job or a step says about whether it runs on every run of the
# workflow, and whether its failure fails the run.
_CONDITION_KEYS = ("if", "continue-on-error")
def _indent(line):
return len(line) - len(line.lstrip(" "))
def workflow_steps(text):
"""A workflow's steps, each a dict of its keys: "uses", "run" (the
command text, a block's lines joined), "working-directory", and "with"
(a dict). YAML read by its indentation, as far as these workflows use
it; comments, whole lines and from ' #' to the end of a line, are not
read. Anything outside a step (a trigger's paths, a job's name) is not
a step."""
lines = []
def _stray_key(line):
"""Whether a line is a mapping key with one stray quote."""
return _KEY_RX.match(line) is None and _STRAY_RX.match(line) is not None
def _lines(text):
"""A workflow's lines with the comments dropped: whole comment lines,
and from ' #' to the end of a line."""
out = []
for ln in text.splitlines():
if ln.lstrip().startswith("#"):
continue
lines.append(re.sub(r"\s+#.*$", "", ln).rstrip())
out.append(re.sub(r"\s+#.*$", "", ln).rstrip())
return out
def _scalar(val):
"""A key's value, a quoted scalar without its quotes (a quote inside a
command stays)."""
val = val.strip()
if len(val) > 1 and val[0] == val[-1] and val[0] in "'\"":
return val[1:-1]
return val
def workflow_triggers(text):
"""The events a workflow runs on: the keys of its `on:` mapping, the
items of its list (`on: [push, pull_request]`, or one per line), or
its one scalar (`on: push`). What is under an event (its branches,
its paths, a dispatch's inputs) is not an event (workflow_filters
reads the filters). A flow mapping (`on: {push: ...}`) is not read,
and names no event. An event key with one stray quote (`push":`) is
no event, and the workflow then reads as one that runs on none."""
lines = _lines(text)
for i, ln in enumerate(lines):
m = _ON_RX.match(ln) if not _indent(ln) else None
if not m:
continue
val = m.group(1).strip()
if val.startswith("["):
return {_scalar(x) for x in val.strip("[]").split(",") if x.strip()}
if val:
return set() if val.startswith("{") else {_scalar(val)}
out, at = set(), None
for child in lines[i + 1:]:
if not child.strip():
continue
ind = _indent(child)
if ind == 0:
break
at = ind if at is None else at
if ind != at:
continue
body = child.strip()
if body.startswith("- "):
out.add(_scalar(body[2:]))
elif _stray_key(child):
return set()
else:
km = _KEY_RX.match(child)
if km:
out.add(km.group(2))
return out
return set()
# What under a push or a pull_request of `on:` keeps the workflow from a
# run: a ref or a path the event does not match, and under a pull_request
# alone, an activity type the pull request's event is not.
_FILTER_KEYS = ("branches", "branches-ignore", "tags", "tags-ignore", "paths", "paths-ignore")
_PULL_REQUEST_FILTER_KEYS = _FILTER_KEYS + ("types",)
def workflow_filters(text):
"""The events of a workflow's `on:` block mapping that carry a filter,
{event: [what]}: each `branches:`, `branches-ignore:`, `tags:`,
`tags-ignore:`, `paths:` or `paths-ignore:` key under the event, and
under a pull_request a `types:` key too, by name; a value on the
event's own line (`push: {branches: [main]}`), which the lint does
not read, as "a value the lint does not read"; and a key with one
stray quote under the event (`paths":`), as "a key the lint does not
read". Every filter counts: a push with only a branches or a tags
filter runs on no push of the other kind of ref, and a branch it
names is one the lint does not know, as the manifest carries no
branch. A pull_request under `types:` runs on the activity types it
names and no other, so one under `types: [closed]` runs on no change
of a pull request; the lint reads no type, so any `types:` is a
filter. A key deeper than the event's own level (a dispatch's
inputs) is no filter. An event of a list or a scalar `on:` carries
none."""
lines = _lines(text)
out = {}
for i, ln in enumerate(lines):
m = _ON_RX.match(ln) if not _indent(ln) else None
if not m:
continue
if m.group(1).strip():
return out
at, event, sub = None, None, None
for child in lines[i + 1:]:
if not child.strip():
continue
ind = _indent(child)
if ind == 0:
break
at = ind if at is None else at
km = _KEY_RX.match(child)
if ind == at:
event, sub = None, None
if km:
event = km.group(2)
if km.group(3).strip():
out.setdefault(event, []).append("a value the lint does not read")
elif event is not None and ind > at:
sub = ind if sub is None else sub
if ind != sub:
continue
if _stray_key(child):
out.setdefault(event, []).append("a key the lint does not read")
elif km and km.group(2) in (_PULL_REQUEST_FILTER_KEYS if event == "pull_request" else _FILTER_KEYS):
out.setdefault(event, []).append(km.group(2))
return out
return out
def _needs(lines, i):
"""The job names the `needs:` key at lines[i] names: a flow list
(`[build, lint]`, on the key's line or continued on the lines under
it up to its `]`), one scalar, or a block list on the lines under it
(its items deeper than the key, or at the key's own indentation).
None for every other form, which the lint does not read: a name no
job has, so the need is never met. That is an empty `needs:` (`[]`,
or nothing under the key), a flow list a key or a shallower line
interrupts before its `]`, a line under the key that is no list
item, and list items at two indentations."""
key_at = _indent(lines[i])
val = _KEY_RX.match(lines[i]).group(3).strip()
if val.startswith("["):
for ln in lines[i + 1:]:
if "]" in val:
break
if not ln.strip():
continue
ind, body = _indent(ln), ln.strip()
if ind < key_at or (ind == key_at and not body.startswith("]")):
return None
val += " " + body
if "]" not in val:
return None
return [_scalar(x) for x in val[1:val.index("]")].split(",") if x.strip()] or None
if val:
return [_scalar(val)]
out, at = [], None
for ln in lines[i + 1:]:
if not ln.strip():
continue
ind, body = _indent(ln), ln.strip()
if ind < key_at or (ind == key_at and not body.startswith("- ")):
break
at = ind if at is None else at
if not body.startswith("- ") or ind != at:
return None
out.append(_scalar(body[2:]))
return out or None
def _needs_run(jobs):
"""Whether each job's `needs:` are met on every run of the workflow,
as "needs_run" on the job's dict: True when every job it needs runs
on every run (no `if:` other than the literal true, and its own
needs met, transitively), False when one does not, names no job of
the workflow, or is in a cycle with it, or when the `needs:` is a
form the lint does not read. The lint evaluates no condition, so a
job under `always()` counts as no job, as its steps do. A needed
job's `continue-on-error:` does not bear: its failure fails neither
the run nor the jobs after it."""
state = {}
def runs(name, trail):
if name in state:
return state[name]
job = jobs.get(name)
if job is None or name in trail:
return False
needs = job.get("needs", [])
met = needs is not None and all(runs(n, trail | {name}) for n in needs)
job["needs_run"] = met
state[name] = met and job.get("if", "true").lower() == "true"
return state[name]
for name in jobs:
runs(name, frozenset())
def _defaults(lines, i):
"""{"shell": its value} for the `defaults:` key at lines[i] when it
gives the run steps a shell:, {} when it gives none. A flow mapping
(at the key or at its run:) and a key with one stray quote under it
give None: a shell the lint does not read."""
if _KEY_RX.match(lines[i]).group(3).strip():
return {"shell": None}
out = {}
for ln in lines[i + 1:]:
if not ln.strip():
continue
if _indent(ln) <= _indent(lines[i]):
break
km = _KEY_RX.match(ln)
if _stray_key(ln) or (km and km.group(2) == "run" and km.group(3).strip()):
return {"shell": None}
if km and km.group(2) == "shell":
out["shell"] = _scalar(km.group(3))
return out
def workflow_steps(text):
"""A workflow's steps, each a dict of its keys: "uses", "run" (the
command text: a literal block's lines one per line; a folded block,
or a scalar continued on the lines under its key, as one line),
"working-directory", "with" (a dict), "shell", "if" and
"continue-on-error" as written; "job": the "if" and
"continue-on-error" of the job the step is in, when it is in one,
its "needs" (_needs) and whether they are met on every run
("needs_run", _needs_run), and the "shell" its `defaults:` give; and
"workflow": the "shell" the
workflow's own `defaults:` give, when they give one (_defaults). YAML
read by its indentation, as far as these workflows use it; comments,
whole lines and from ' #' to the end of a line, are not read.
Anything outside a step (a trigger's paths, a job's name) is not a
step. A key with one stray quote (`if":`, `"run:`) at the top level,
at a job's name or a job's key, at a step's key, or as the first key of
a list item outside a step (a step's first key is on its dash line,
`- run": make`; a matrix entry is such an item; a list inside a step's
body is not read) is no key the lint reads: every step of the workflow
then carries "unread" and counts as no step."""
lines = _lines(text)
steps, i = [], 0
in_jobs, job_at, key_at, job, jobs = False, None, None, None, {}
stray, workflow = False, {}
while i < len(lines):
km = _KEY_RX.match(lines[i])
if km: # a mapping key: which job this is, and what the job itself says
ind, key = _indent(lines[i]), km.group(2)
if ind == 0:
in_jobs, job_at, key_at, job = key == "jobs", None, None, None
if key == "defaults":
workflow.update(_defaults(lines, i))
elif in_jobs and (job_at is None or ind == job_at):
job_at, key_at, job = ind, None, {}
jobs[key] = job
elif in_jobs and job_at is not None and ind > job_at and (key_at is None or ind == key_at):
key_at = ind
if key in _CONDITION_KEYS:
job[key] = _scalar(km.group(3))
elif key == "needs":
job["needs"] = _needs(lines, i)
elif key == "defaults":
job.update(_defaults(lines, i))
elif _stray_key(lines[i]):
ind = _indent(lines[i])
stray = stray or ind == 0 or (in_jobs and (job_at is None or ind == job_at or (
ind > job_at and (key_at is None or ind == key_at))))
# A list item's first key is on its dash line, where _STEP_RX reads no
# key with a stray quote: the dash and the spaces after it become spaces.
item = re.match(r"^\s*-\s+", lines[i])
if item and _stray_key(" " * item.end() + lines[i][item.end():]):
stray = True
m = _STEP_RX.match(lines[i])
if not m:
i += 1
continue
dash = len(m.group(1))
body = [" " * (dash + 2) + lines[i][dash + 1:].lstrip()]
# The dash becomes a space, nothing realigned: the first key stays at
# the column the step's other keys use, however many spaces follow it.
body = [" " * (dash + 1) + lines[i][dash + 1:]]
i += 1
while i < len(lines) and (not lines[i].strip() or _indent(lines[i]) > dash):
body.append(lines[i])
@@ -424,11 +730,10 @@ def workflow_steps(text):
while k < len(body):
km = _KEY_RX.match(body[k])
if not km or _indent(body[k]) != key_col:
stray = stray or (_indent(body[k]) == key_col and _stray_key(body[k]))
k += 1
continue
key, val = km.group(2), km.group(3).strip()
if len(val) > 1 and val[0] == val[-1] and val[0] in "'\"":
val = val[1:-1] # a quoted scalar, not a quote inside a command
key, val = km.group(2), _scalar(km.group(3))
k += 1
child = []
while k < len(body) and (not body[k].strip() or _indent(body[k]) > key_col):
@@ -438,20 +743,59 @@ def workflow_steps(text):
step["with"] = {c.group(2): c.group(3).strip().strip("'\"")
for c in (_KEY_RX.match(x) for x in child) if c}
elif key == "run":
step["run"] = "\n".join(x.strip() for x in child) if val[:1] in ("|", ">") else val
# YAML folds a folded block, and a plain or quoted scalar continued on the
# lines under its key, into one line where no blank or more indented line
# breaks it. Read as one line: where YAML keeps a break, the joined line
# counts no command that the shell's own lines would not.
text = [x.strip() for x in child if x.strip()]
if val[:1] == "|":
step["run"] = "\n".join(x.strip() for x in child)
elif val[:1] == ">":
step["run"] = " ".join(text)
else:
step["run"] = _scalar(" ".join([km.group(3).strip()] + text))
else:
step[key] = val
if job is not None:
step["job"] = job # the job's own dict: a job key written after its steps counts too
steps.append(step)
_needs_run(jobs)
for s in steps:
if stray:
s["unread"] = True
if workflow:
s["workflow"] = workflow
return steps
def _on_every_run(step):
"""Whether the lint counts a step as one that runs on every run of
its workflow and fails the run when it fails: neither the step nor
its job carries an `if:` other than the literal `true` or a
`continue-on-error:` other than the literal `false`, the job's
`needs:` are met on every run (_needs_run: GitHub skips a job whose
needed job did not run, unless the job says `always()`, which the
lint does not evaluate), and the workflow has no key the lint
cannot read ("unread"). The lint evaluates no condition, so a step
under `always()` or `success()` counts as no step too."""
if step.get("unread"):
return False
job = step.get("job") or {}
if not job.get("needs_run", True):
return False
for d in (step, job):
if d.get("if", "true").lower() != "true" or d.get("continue-on-error", "false").lower() != "false":
return False
return True
def checkout_path(steps, name, own):
"""Where a workflow checks the repository `name` out (its path, "" for
the workspace), or None when it does not: its own repository is the
checkout that names no repository, another one the checkout whose
repository is <owner>/<name>."""
for s in steps:
if not s.get("uses", "").startswith("actions/checkout"):
if not _on_every_run(s) or not s.get("uses", "").startswith("actions/checkout"):
continue
w = s.get("with", {})
repo = w.get("repository")
@@ -460,33 +804,224 @@ def checkout_path(steps, name, own):
return None
_SPLIT_RX = re.compile(r"&&|\|\||[;|\n]|(?:^|(?<=\s))(?:then|do|else)(?=\s|$)")
_PREFIXES = ("sudo", "env", "time", "exec", "command", "nice")
# What only starts the real command, and the flags with which each one
# runs it. Any other flag (`command -v` and `sudo -l` print the command)
# leaves the prefix as the command.
_PREFIXES = {"sudo": ("-E", "-H", "-n"), "env": ("-i", "-"), "time": ("-p",), "command": ("-p",), "nice": ()}
_INTERPRETER_RX = re.compile(r"^(?:python[0-9.]*|bash|sh)$")
# The shell's operators, the longest first. A redirection keeps a command
# one command, and a pipe joins commands into a pipeline; any other
# operator makes a list of commands, puts one in the background, or opens
# a subshell or a here-document.
_OPERATORS = ("&>>", "&&", "||", ";;", ";&", "|&", ">>", "<<", ">&", "<&", "<>", ">|", "&>",
"|", "&", ";", "(", ")", "<", ">")
_REDIRECTIONS = frozenset((">", ">>", "<", ">&", "<&", "<>", ">|", "&>", "&>>"))
# A command word that opens or closes a compound command (an if, a case,
# a loop, a group, a function) or negates a status.
_RESERVED = frozenset(("if", "then", "elif", "else", "fi", "case", "esac", "for", "select", "while",
"until", "do", "done", "function", "coproc", "{", "}", "[[", "]]", "!"))
# A command after which the lint cannot tell what a later line runs, or
# whether a failure still ends the step.
_STOPS = frozenset(("exit", "return", "exec", "trap", "eval", "source", ".", "alias", "shopt",
"enable", "builtin", "hash", "break", "continue", "logout"))
# The `set` options the lint follows (errexit, pipefail) or knows to
# change neither; a `set` of any other option ends what it reads.
_SET_FLAGS = {"e": "errexit", "u": "nounset", "x": "xtrace", "v": "verbose"}
_SET_NAMES = frozenset(("errexit", "pipefail", "nounset", "xtrace", "verbose"))
def _commands(run):
"""The simple commands of a step's run text, each as its words, with
what only starts the real command taken off: sudo and its flags, env,
time, exec, and variable assignments."""
import shlex
out = []
for part in _SPLIT_RX.split(run.replace("\\\n", " ")):
try:
words = shlex.split(part)
except ValueError:
words = part.split()
def _part_end(line, i):
"""Where the part of a shell word that starts at line[i] ends: a
quoted string, an escaped character, an expansion ($(...), ${...},
`...`), or one character; -1 when it does not close on the line."""
n, c = len(line), line[i]
if c == "\\":
return i + 2 if i + 1 < n else -1
if c == "'":
j = line.find("'", i + 1)
return j + 1 if j > 0 else -1
if c in "\"`":
j = i + 1
while j < n and line[j] != c:
j = _part_end(line, j) if line[j] in "\\$" or (c == '"' and line[j] == "`") else j + 1
if j < 0:
return -1
return j + 1 if j < n else -1
if c == "$" and line[i + 1:i + 2] in ("(", "{"):
opener, depth, j = line[i + 1], 0, i + 1
while j < n:
if line[j] == opener:
depth, j = depth + 1, j + 1
elif line[j] == (")" if opener == "(" else "}"):
depth, j = depth - 1, j + 1
if not depth:
return j
elif line[j] in "\\'\"`$":
j = _part_end(line, j)
if j < 0:
return -1
else:
j += 1
return -1
return i + 1
def _shell_words(line):
"""One line of shell as the lint reads it: [(True, word) or (False,
operator)], a word with its quotes taken off and its expansions kept
as written; None when a quote or an expansion does not close on the
line."""
out, word, i = [], None, 0
while i < len(line):
c = line[i]
if c in " \t|&;()<>":
if word is not None:
out.append((True, word))
word = None
if c in " \t":
i += 1
else:
op = next(o for o in _OPERATORS if line.startswith(o, i))
out.append((False, op))
i += len(op)
continue
j = _part_end(line, i)
if j < 0:
return None
part = line[i:j]
if part[0] in "'\"":
part = part[1:-1]
elif part[0] == "\\":
part = part[1:]
word = (word or "") + part
i = j
if word is not None:
out.append((True, word))
return out
def _command(words):
"""A simple command's words with what only starts the real command
taken off: sudo, env, time, command and nice with the flags with which
they run it (_PREFIXES), and variable assignments."""
while words:
if words[0] in _PREFIXES:
words = words[1:]
while words and words[0].startswith("-"):
words = words[1:]
rest = words[1:]
while rest and rest[0] in _PREFIXES[words[0]]:
rest = rest[1:]
if rest[:1] and rest[0].startswith("-"):
break
words = rest
elif re.match(r"^[A-Za-z_]\w*=", words[0]):
words = words[1:]
else:
break
if words:
out.append(words)
return words
def _line_commands(tokens):
"""The commands of a line of shell (_shell_words) that is one simple
command, or a pipeline of them, and nothing else, each as its words
after _command, with its redirections taken off. None for any other
line: one that does not close, holds another operator (a list, a
background job, a subshell, a here-document) or a pipe at its end, or
has a command that is a reserved word or one of _STOPS."""
if tokens is None:
return None
cmds, words, k = [], [], 0
while k < len(tokens):
is_word, text = tokens[k]
if is_word:
words.append(text)
elif text in _REDIRECTIONS and k + 1 < len(tokens) and tokens[k + 1][0]:
k += 1 # the redirection's target
elif text == "|" and words:
cmds.append(words)
words = []
else:
return None
k += 1
if cmds and not words:
return None
cmds = [_command(c) for c in cmds + [words]]
return None if any(c[:1] and (c[0] in _RESERVED or c[0] in _STOPS) for c in cmds) else cmds
def _for_header(tokens):
"""The words of a line `for <name> in <words>; do` without its `; do`,
None for any other line."""
if (tokens and len(tokens) >= 5 and tokens[0] == (True, "for") and tokens[2] == (True, "in")
and tokens[-2:] == [(False, ";"), (True, "do")] and all(w for w, _t in tokens[:-2])):
return [t for _w, t in tokens[:-2]]
return None
def _set(args, opts):
"""Follow in opts the errexit and pipefail that a `set` command's
arguments set or clear. False when they name an option the lint does
not follow (noexec, onecmd, the listing of `set -o`)."""
k = 0
while k < len(args):
a, k = args[k], k + 1
if a in ("-", "--") or len(a) < 2 or a[0] not in "-+":
return True # the positional parameters follow
for ch in a[1:]:
if ch == "o":
name, k = (args[k] if k < len(args) else ""), k + 1
else:
name = _SET_FLAGS.get(ch)
if name not in _SET_NAMES:
return False
if name in opts:
opts[name] = a[0] == "-"
return True
def _shell(step):
"""The shell a step's run text runs under: the step's own shell:, else
its job's `defaults: run: shell:`, else the workflow's; "" for none,
which is bash -e (sh -e with no bash) on a Linux or macOS runner (the
lint does not read runs-on). None when any one of them is other than
bash or sh: the lint reads the text of no other shell."""
given = [d["shell"] for d in (step, step.get("job") or {}, step.get("workflow") or {}) if "shell" in d]
if any(g not in ("bash", "sh") for g in given):
return None
return given[0] if given else ""
def _run_commands(run, shell):
"""The commands of a step's run text that the lint counts, each as its
words (_line_commands): see workflow_runs."""
opts = {"errexit": True, "pipefail": shell == "bash"}
lines = [ln.strip() for ln in run.replace("\\\n", " ").split("\n") if ln.strip()]
out, k = [], 0
while k < len(lines):
tokens = _shell_words(lines[k])
k += 1
header = _for_header(tokens)
if header is not None: # the loop is read whole: its header, its body, its done
groups = [[header]]
while k < len(lines) and lines[k] != "done":
cmds = _line_commands(_shell_words(lines[k]))
if (cmds is None or any(c[:1] == ["set"] for c in cmds)
or (len(cmds) > 1 and not opts["pipefail"])):
return out
groups.append(cmds)
k += 1
if k == len(lines):
return out
k += 1
else:
cmds = _line_commands(tokens)
if cmds is None:
return out
if any(c[:1] == ["set"] for c in cmds):
if len(cmds) > 1 or not _set(cmds[0][1:], opts):
return out
continue
groups = [cmds]
if opts["errexit"]:
out.extend(c for cmds in groups if len(cmds) == 1 or opts["pipefail"] for c in cmds if c)
return out
@@ -553,22 +1088,46 @@ def _pytest_runs(args, full, at):
def workflow_runs(steps, test_path):
"""Whether a step of the workflow runs the test at test_path (its path
in the workspace: the checkout's path joined to the test's path in its
repository). Only what a step's run commands execute counts, each path
resolved against the step's working-directory: the command itself
(a script run directly, or a C test's built binary, <dir>/<stem>);
the script an interpreter runs (python, bash, sh), a glob matching the
whole path; a for loop's list over the test; python -m unittest given
the test's file or a dotted name whose module is that file
(tests.test_x, tests.test_x.Case.test_one); unittest discover from the
test's own directory with a pattern its name matches; pytest on the
test or its own directory. An argument to anything else (echo, a build,
an install) does not run the test."""
repository) so that a failure of the test fails the step.
Only a step that _on_every_run accepts counts: the lint evaluates no
condition, so a step under an `if:` other than the literal true, or
with a `continue-on-error:` other than the literal false, on the step
or on its job, counts as no step. So does a step whose shell is other
than bash or sh (_shell: its own shell:, or a `defaults: run: shell:`
of its job or of the workflow).
The lint reads a step's run text line by line as bash -e or sh -e
runs it, a backslash at the end of a line joining the next one
(_run_commands). A command counts on a line that holds it alone, with
its redirections, or in a pipeline under pipefail (shell: bash, or a
`set -o pipefail` on an earlier line), and only while errexit holds
(`set +e` clears it). A pipeline with no pipefail does not count. The
lint stops at a line that holds any other operator (`||`, `&&`, `;`,
`&`, a subshell, a here-document), whose command opens a compound
command (if, case, while, until, a group, a function) or negates a
status (!), whose command is one of _STOPS (exit, exec, trap, eval,
source and the like), or that sets an option it does not follow: no
command on that line or after it counts. A for loop counts in one
form: `for <name> in <words>; do` on its own line, body lines that
count, and `done` on its own line.
Each path is resolved against the step's working-directory. A command
runs the test when it is the test (a script run directly, or a C
test's built binary, <dir>/<stem>); the script an interpreter runs
(python, bash, sh), a glob matching the whole path; a for loop's list
over the test; python -m unittest given the test's file or a dotted
name whose module is that file (tests.test_x,
tests.test_x.Case.test_one); unittest discover from the test's own
directory with a pattern its name matches; pytest on the test or its
own directory. An argument to anything else (echo, a build, an
install) does not run the test."""
full = posixpath.normpath(test_path)
stem, ext = posixpath.splitext(posixpath.basename(full))
compiled = ext in (".c", ".cc", ".cpp")
for s in steps:
run = s.get("run")
if not run:
run, shell = s.get("run"), _shell(s)
if not run or shell is None or not _on_every_run(s):
continue
wd = s.get("working-directory") or "."
@@ -577,7 +1136,7 @@ def workflow_runs(steps, test_path):
def names(p):
return "$" not in p and _segments_match(full, at(p))
for words in _commands(run):
for words in _run_commands(run, shell):
cmd, args = words[0], words[1:]
if cmd == "for" and "in" in args:
if any(names(p) for p in args[args.index("in") + 1:]):
@@ -608,8 +1167,18 @@ def host_row_problems(manifest, host=HOST_TESTED, root=REPO_ROOT, sources=SOURCE
"""HOST_TESTED rows that prove nothing: a glob that selects no
behavioral file of its component (the same defect as an empty covers
entry), a host test or a workflow that does not exist, a workflow that
cannot be read where it should be, one that does not check out the
test's repository, or one with no step that runs the test. Whether a
cannot be read where it should be, one whose own `on:` names neither
push nor pull_request as the lint reads it (workflow_triggers: a
dispatch, a call or a schedule alone, a call not followed to the
workflow that calls it, and a flow mapping, which names no event),
one whose every push and pull_request carries a filter
(workflow_filters: a branches, tags or paths filter, or a types
filter on the pull_request, which the lint does not read, so the
workflow runs on not every change of the covered files, or on not
every branch; one push or pull_request with no filter makes the
workflow run on every change),
one that does not check out the test's repository, or one with no
step that runs the test on every run (workflow_runs). Whether a
workflow runs its test is not checked where it cannot be read (see
read_source). Returns [(component, glob, host test, why)]."""
out = []
@@ -627,6 +1196,15 @@ def host_row_problems(manifest, host=HOST_TESTED, root=REPO_ROOT, sources=SOURCE
continue
if text is None:
continue
events = workflow_triggers(text) & {"push", "pull_request"}
if not events:
out.append((comp, pat, ref, "%s: the lint reads no push or pull_request in its own on:" % ci))
continue
filters = workflow_filters(text)
if all(e in filters for e in events):
out.append((comp, pat, ref, "%s: every push or pull_request in its own on: carries a filter (%s)" % (
ci, "; ".join("%s: %s" % (e, ", ".join(filters[e])) for e in sorted(events)))))
continue
steps = workflow_steps(text)
test_repo, _sep, test_path = ref.partition(":")
prefix = checkout_path(steps, repo_name(manifest, test_repo), test_repo == ci.partition(":")[0])
+57 -15
View File
@@ -464,8 +464,9 @@ class Context:
The fixture presses where the bench took this test's presses (or
opted in standing); otherwise the notice goes up for a person,
who is looking at the same prompt. Returns True when the fixture
pressed."""
who is looking at the same prompt. A press the fixture reports
failed while the button went out was taken all the same, and no
notice goes up for it. Returns True when the fixture pressed."""
fixture = getattr(self.runner, "fixture", None) if self.runner is not None else None
rec = {"channel": "button", "state": "arm", "by": "operator", "ts": now_ts()}
self.evidence.setdefault("actions", []).append(rec)
@@ -483,38 +484,73 @@ class Context:
fixture.act("button", "press")
except _fixture.FixtureError as e:
rec["fixture_error"] = str(e)
if self._press_taken(rec, e):
return rec["by"] == "fixture"
self.log("ARM press: fixture failed (%s) - asking the operator", e)
self.notice(text)
self._clear_when_taken()
return False
rec["by"] = "fixture"
self.log("ARM press by the fixture, at the machine's own press prompt")
return True
def _press_taken(self, rec, err):
"""After the box reported a failed press of a lit button: True when
the button is out, so the controller took a press. No notice goes
up then, because it would ask for a second press, and a press during
the job pauses it. The press is recorded as the box's only when its
request went out and the failure came after it (err.sent: the box
pressed and its reply was lost). After a refusal, a failed lookup
or a failed connect the box made no press, and the record stays as
it was: the press was a person's. A button still lit, or
unreadable, is False: the operator is asked."""
if hw.button_lit() is not False:
return False
if getattr(err, "sent", False):
rec["by"] = "fixture"
self.log("ARM press: the fixture reported a failure (%s), and the button is out: the press "
"was taken; no notice", err)
else:
self.log("ARM press: the fixture made no press (%s), and the button is out: a press was "
"taken all the same; no notice", err)
return True
def _clear_when_taken(self, lit_timeout=ARM_LIT_WAIT_S):
"""Take the arm notice down the moment the press is taken: the
controller lights the button white for its arm wait and puts it out
at the press. Without this the notice stood until the test's next
step, and an operator who still saw it pressed again, which pauses
the job. A notice replaced meanwhile is left alone."""
with self.run._lock:
nid = (self.run.notice or {}).get("id")
the job. A notice replaced meanwhile is left alone. The watch lives
only as long as the notice it guards and the run: a notice cleared
or replaced, a run that finished (passed, failed, or errored before
the press) or an abort ends it, so no thread is left polling the
LEDs behind a test."""
run = self.run
with run._lock:
nid = (run.notice or {}).get("id")
if nid is None:
return
def standing():
with run._lock:
return run.finished is None and (run.notice or {}).get("id") == nid
def watch():
t0 = time.time()
while hw.button_lit() is not True:
if time.time() - t0 > lit_timeout:
if not standing() or time.time() - t0 > lit_timeout:
return
if run.aborted.wait(0.05):
return
time.sleep(0.05)
while hw.button_lit() is not False:
if time.time() - t0 > lit_timeout + ARM_PRESS_WAIT_S:
if not standing() or time.time() - t0 > lit_timeout + ARM_PRESS_WAIT_S:
return
time.sleep(0.05)
with self.run._lock:
same = (self.run.notice or {}).get("id") == nid
if run.aborted.wait(0.05):
return
with run._lock:
same = (run.notice or {}).get("id") == nid
if same:
self.run.notice = None
run.notice = None
if same:
self.log("ARM: the button went out at the press; the notice is down")
@@ -529,8 +565,9 @@ class Context:
seconds for the button to light (the job may still be on its way
to the arm wait; a card that settles the coolant first takes
minutes) and presses once, recorded as the fixture's; if the
button never lights or the press fails, the notice goes up for a
person. Returns True when the fixture has been asked."""
button never lights, or the press fails with the button still lit,
the notice goes up for a person. Returns True when the fixture has
been asked."""
fixture = getattr(self.runner, "fixture", None) if self.runner is not None else None
rec = {"channel": "button", "state": "arm", "by": "operator", "ts": now_ts()}
self.evidence.setdefault("actions", []).append(rec)
@@ -555,13 +592,18 @@ class Context:
if lit is None:
self.log("ARM: the button never lit within %d s - asking the operator", lit_timeout)
self.notice(text)
self._clear_when_taken()
return
try:
fixture.act("button", "press")
except _fixture.FixtureError as e:
self.log("ARM press: fixture failed (%s) - asking the operator", e)
rec["fixture_error"] = str(e)
if self._press_taken(rec, e):
rec["took_s"] = round(lit, 2)
return
self.log("ARM press: fixture failed (%s) - asking the operator", e)
self.notice(text)
self._clear_when_taken()
return
rec["by"] = "fixture"
rec["took_s"] = round(lit, 2)
+3 -323
View File
@@ -8,7 +8,6 @@ import json
import os
import re
import signal
import stat
import subprocess
import sys
import time
@@ -488,8 +487,6 @@ FWUP = "/usr/bin/fwup"
EXT_ROOT = "/data/forgefirm/ext"
HOST_STATUS = "/run/forgefirm/ext/status.json"
SAFE_FILE = "/run/forgefirm/ext-safe"
HOST_LOG = "/data/log/forgefirm/forgeext/forgeext.log"
API_DIR = "/run/forgefirm/ext/api"
REF_ID = "org.forgetest.reference"
REF_KEY = "forgetest-reference"
REF_DEST = "192.0.2.1" # TEST-NET-1: declared so that port 443 is, and never dialed
@@ -841,323 +838,6 @@ def _as_found(ctx, fc, prior, raw, dir_mode, found_tree):
ctx.check(len(_host_pids()) == 1, "the extension host is not running at the end")
@test("exthost.service", title="A package's service runs confined under the extension host",
subsystem="exthost", kind="auto", hardware="takeover", est_min=5,
covers=[("forgeext", "**"), ("forgectrl", "src/main.c"), ("forgectrl", "src/logs.*"),
("forgectrl", "docs/advisories/extensions.md")],
requires=["exthost.platform"],
description="The extension host is one running process (/usr/bin/forgeext run) and the status "
"file is its word, and with extensions off it runs nothing and says why. The test builds a reference "
"package on the board, signs it with a key it makes there, and adds that key as an owner "
"key: an install without the community consent is refused, with it the package is "
"installed at the community tier. It turns extensions on over the advisory. The service "
"then runs as its pool account with no_new_privs and a seccomp filter, in its own group "
"under /sys/fs/cgroup/ffx with the limits of a service (25 percent of the core, 48 MiB, 32 "
"processes), with its chain in the rule table. From the inside it reads /etc and its "
"package, writes its data directory and nothing else, cannot read the settings file, the "
"setup record, or the pulse device, is refused by the machine on loopback and on its LAN "
"address even on the port it declared, cannot connect on a port it did not declare, and "
"cannot open a netlink socket. Its one way to the machine is its API socket (root's and its "
"account's, 0660, refused to another pool account): GET /v0/self names it and what it may use, "
"GET /v0/machine/mode is forgectrl's answer relayed, POST /v0/camera gives it a JPEG from the "
"lid camera it holds (with the camera's lamp at 0 and at 1023 two different pictures, the lit "
"one the larger) and refuses the head camera and a lamp past its range, a hold it was not granted is 403, a path "
"the API does not have 404, a path with .. 400; its output is in the forgeext log under its id. Safe mode "
"stops it and its end starts it again. A host killed outright takes its services with it "
"at once (the init wrapper), comes back, and starts the service again. ext_enabled=0 "
"stops it and leaves no group and no chain. The package, the key, the setting, and the "
"setup record are put back as found, the record under a forgectrl restart. The layer "
"content (the recipe, the init script's install, the image list) is in the platform "
"identity of every fingerprint.")
def service(ctx):
import shutil
import tempfile
fc = ctx.forgectrl
ev = ctx.evidence
ctx.check(os.path.isfile(FORGEEXT) and os.access("/etc/init.d/forgeext", os.X_OK),
"the image has no forgeext, or no init script for it")
hosts = _host_pids()
ev["host_pids"] = hosts
ctx.check(len(hosts) == 1, "the extension host is not one running process: %s", hosts)
ctx.check(_until(ctx, lambda: _host_status().get("pid") == hosts[0], 10),
"the status file %s is not the running host's: %s", HOST_STATUS, _host_status().get("pid"))
ctx.check(fc.wait_idle(timeout=30, abort=ctx.aborted), "machine not idle: settings are locked")
prior = fc.settings().get("ext_enabled") or ""
raw = read_file(record_path())
ctx.check(raw, "no setup record at %s", record_path())
ctx.check(not os.path.exists(SAFE_FILE), "%s exists: the machine is in safe mode", SAFE_FILE)
ctx.check(REF_ID not in [x.get("id") for x in _forgeext("list").get("packages", [])],
"%s is already installed", REF_ID)
found_tree = _tree(EXT_ROOT)
data_dir = os.path.dirname(EXT_ROOT)
dir_mode = os.stat(data_dir).st_mode & 0o7777
ev["data_dir_mode_found"] = "%04o" % dir_mode
if prior != "1":
st = _host_status()
ev["off"] = {k: st.get(k) for k in ("enabled", "off_reason")}
ctx.check(st.get("enabled") is False and "ext_enabled" in (st.get("off_reason") or "")
and not [x for x in st.get("services", []) if x.get("state") == "running"],
"with extensions off the host does not say so: %s", ev["off"])
st, body, hdrs = request(fc.base, "GET", "/advisories/extensions", headers={"Host": fc.host_header()})
etag = hdrs.get("etag")
ctx.check(st == 200 and etag, "GET /advisories/extensions -> %s", st)
work = tempfile.mkdtemp(prefix="forgetest-ffx.")
owner_key = os.path.join(EXT_ROOT, "keys", REF_KEY + ".pub")
uid = None
def cg(name):
return _read("%s/%s/%s" % (POOL_GROUP, REF_ID, name)).strip()
def chains():
return subprocess.run([NFT, "list", "table", "inet", "ffx"], capture_output=True, text=True, timeout=30).stdout
def running(other_than=0):
# The status file outlives a killed host: a pid that is the old one is no news.
x = _svc(REF_ID)
return x if x.get("state") == "running" and x.get("pid") and x["pid"] != other_than and os.path.exists("/proc/%d" % x["pid"]) else None
try:
lan = lan_ip()
archive, pub = _pack_reference(work, lan)
r = _forgeext("inspect", archive)
ev["inspect_without_the_key"] = r.get("tier")
ctx.check(r.get("ok") and r.get("tier") == "unverified", "before its key is the owner's the package reads %s", r)
shutil.copy(pub, owner_key)
os.chmod(owner_key, 0o644)
r = _forgeext("inspect", archive)
ctx.check(r.get("ok") and r.get("tier") == "community", "with the owner's key the package reads %s", r)
r = _forgeext("install", archive)
ev["install_without_consent"] = r.get("error")
ctx.log("install without the consent -> %s", r.get("error"))
ctx.check(r.get("ok") is False, "a community package was installed without the consent")
r = _forgeext("install", archive, "--consent-community")
ctx.check(r.get("ok") is True, "the install -> %s", r.get("error"))
ctx.check(_forgeext("check", REF_ID).get("ok") is True, "the installed tree fails its integrity check")
st, reply = fc.post("/settings", data={"ext_enabled": "1", "advisory": etag, "phrase": SAFETY_PHRASE})
ctx.check(st == 200, "ext_enabled=1 over the advisory -> %s %r", st, reply)
x = _until(ctx, running, 90, poll=0.5)
ev["status_at_start"] = _host_status()
ctx.check(x, "the service is not running: %s", {k: ev["status_at_start"].get(k) for k in ("enabled", "off_reason", "not_ready")}
if not _svc(REF_ID) else _svc(REF_ID))
pid = x["pid"]
uid = POOL_FIRST + int(x["account"][3:])
proc = _read("/proc/%d/status" % pid)
ev["service"] = {"pid": pid, "account": x["account"], "cgroup": _read("/proc/%d/cgroup" % pid).strip(),
"cpu.max": cg("cpu.max"), "memory.max": cg("memory.max"), "pids.max": cg("pids.max"),
"memory.current": cg("memory.current")}
ctx.log("the service: %s", ev["service"])
ctx.check(("Uid:\t%d\t%d\t%d\t%d" % ((uid,) * 4)) in proc and ("Gid:\t%d\t%d\t%d\t%d" % ((uid,) * 4)) in proc,
"the service does not run as %s alone", x["account"])
ctx.check("NoNewPrivs:\t1" in proc and "Seccomp:\t2" in proc, "no no_new_privs or no seccomp filter on the service")
ctx.check(ev["service"]["cgroup"] == "0::/ffx/" + REF_ID, "the service's group is %s", ev["service"]["cgroup"])
ctx.check(cg("cpu.max").split() == ["25000", "100000"] and cg("memory.max") == str(48 << 20) and cg("pids.max") == "32",
"the group's limits are not a service's: %s", ev["service"])
table = chains()
ctx.check(("chain u%d " % uid) in table and REF_DEST in table, "the service's chain is not in the rule table")
report_path = os.path.join(EXT_ROOT, "data", REF_ID, "report.json")
ctx.check(_until(ctx, lambda: os.path.isfile(report_path), 60, poll=0.5), "the service left no report")
rep = json.loads(_read(report_path))
ev["from_the_inside"] = rep
ctx.log("from the inside: %s", rep)
want = {"uid": uid, "gid": uid, "groups": [], "read_etc": "ok", "read_pkg": "ok", "write_data": "ok",
"read_settings": "EACCES", "read_setup": "EACCES", "write_pkg": "EACCES", "write_tmp": "EACCES",
"pulse_device": "EACCES", "loopback_declared_port": "ECONNREFUSED",
"loopback_undeclared_port": "EACCES", "unix_socket": "ok", "netlink_socket": "EPERM"}
if lan:
want["lan_declared_port"] = "ECONNREFUSED"
for k, v in want.items():
ctx.check(rep.get(k) == v, "from the inside, %s is %r, expected %r", k, rep.get(k), v)
# LC_CTYPE is the interpreter's own doing (it coerces the C locale at its start)
fixed = {"PATH", "LANG", "HOME", "TMPDIR", "FFX_ID", "FFX_PKG", "FFX_DATA", "FFX_API", "PYTHONDONTWRITEBYTECODE",
"PYTHONUNBUFFERED"}
ctx.check(rep.get("cwd") == os.path.join(EXT_ROOT, "data", REF_ID) and set(rep.get("env") or []) - {"LC_CTYPE"} == fixed,
"its working directory or its environment is not the fixed one: %s %s", rep.get("cwd"), rep.get("env"))
# its one way to the machine: the API socket, and the broker behind it
me = rep.get("api_self") or [0, {}]
ev["api"] = {k: rep.get(k) for k in ("api_self", "api_mode", "api_hold", "api_nowhere", "api_traversal")}
ctx.check(me[0] == 200 and me[1].get("id") == REF_ID and me[1].get("version") == "1.0.0"
and sorted(me[1].get("capabilities") or []) == sorted(["net.outbound:%s:443" % REF_DEST, "storage:1",
"machine.read", "settings.own",
"camera.lid"]),
"GET /v0/self from the inside: %s", me)
# a camera: the one it was granted, and not the one it was not
cam = rep.get("api_camera") or [0, "", False, False]
ev["api_camera"] = {"status": cam[0], "bytes": cam[1] if cam[0] == 200 else None,
"jpeg": cam[2], "image_type": cam[3],
"why": cam[1] if cam[0] != 200 else None}
ctx.log("the lid camera, from inside the sandbox: %s", ev["api_camera"])
ctx.check(cam[0] == 200 and cam[2] and cam[3] and isinstance(cam[1], int) and cam[1] > 4096,
"the granted camera did not come back as a JPEG: %s", ev["api_camera"])
head_ = rep.get("api_camera_head") or [0, ""]
ev["api_camera_head"] = head_[:2]
ctx.check(head_[0] == 403, "the head camera, which it was not granted -> %s", head_[:2])
badcam = rep.get("api_camera_bad") or [0, ""]
ev["api_camera_bad"] = badcam[:2]
ctx.check(badcam[0] == 400, "a camera there is none of -> %s", badcam[:2])
# The camera's own lamp for one frame: the machine lights it for
# the shot and puts its level back, so a frame with the lamp off
# and one with it full are two different pictures, the lit one the
# larger, and a level past the range is refused before the machine
# is asked.
dark = rep.get("api_camera_dark") or [0, "", False, False]
lit = rep.get("api_camera_lit") or [0, "", False, False]
ev["api_camera_lamp"] = {"dark": dark[:3], "lit": lit[:3]}
ctx.log("the lid camera with its lamp at 0 and at 1023: %s", ev["api_camera_lamp"])
ctx.check(dark[0] == 200 and dark[2] and lit[0] == 200 and lit[2],
"a frame with the lamp named did not come back as a JPEG: %s", ev["api_camera_lamp"])
ctx.check(isinstance(lit[1], int) and isinstance(dark[1], int) and lit[1] > dark[1],
"the lamp did not reach the camera: %d bytes at 1023, %d at 0", lit[1] or 0, dark[1] or 0)
lampbad = rep.get("api_camera_lamp_bad") or [0, ""]
ev["api_camera_lamp_bad"] = lampbad[:2]
ctx.check(lampbad[0] == 400 and "lamp" in str(lampbad[1]), "a lamp past its range -> %s", lampbad[:2])
# A package's capture yields to somebody watching a camera: it is
# never the person standing at the machine.
import urllib.request
shot_file = os.path.join(EXT_ROOT, "data", REF_ID, "shot")
if os.path.exists(shot_file):
os.remove(shot_file)
held = urllib.request.urlopen(
urllib.request.Request(fc.base + "/cam/stream", headers={"Host": fc.host_header()}), timeout=15)
try:
held.read(8192)
watching = _until(ctx, lambda: (fc.get("/cam/status")[1] or {}).get("clients"), 15, poll=0.5)
ctx.check(watching, "no viewer was counted while the stream was held")
_write(os.path.join(EXT_ROOT, "data", REF_ID, "shoot"), "now")
yielded = _until(ctx, lambda: json.loads(_read(shot_file)) if os.path.exists(shot_file) else None,
30, poll=0.5)
ev["camera_while_watched"] = yielded
ctx.log("the package's capture while a viewer watches: %s", yielded)
ctx.check(yielded and yielded[0] == 409,
"a package's capture did not yield to a viewer: %s", yielded)
ctx.check(yielded and "watching" in str(yielded[1]),
"it yielded without the machine's own words: %s", yielded)
finally:
held.close()
os.remove(shot_file)
_until(ctx, lambda: not (fc.get("/cam/status")[1] or {}).get("clients"), 15, poll=0.5)
_write(os.path.join(EXT_ROOT, "data", REF_ID, "shoot"), "now")
again = _until(ctx, lambda: json.loads(_read(shot_file)) if os.path.exists(shot_file) else None,
40, poll=0.5)
ev["camera_after_watching"] = [again[0], again[1]] if again else None
ctx.check(again and again[0] == 200 and again[2],
"the package's capture did not come back once the viewer stopped: %s", again)
# its own settings: read, set, and what the schema will not take
got = rep.get("api_settings") or [0, {}]
ev["api_settings"] = got
ctx.check(got[0] == 200 and (got[1] or {}).get("settings", {}).get("threshold") == 40
and (got[1] or {}).get("settings", {}).get("when") == "end"
and len((got[1] or {}).get("schema") or []) == 3,
"GET /v0/settings before anything is set, with its schema: %s", got)
set_ = rep.get("api_settings_set") or [0, {}]
ev["api_settings_set"] = set_
ctx.check(set_[0] == 200 and (set_[1] or {}).get("settings", {}).get("threshold") == 70
and (set_[1] or {}).get("settings", {}).get("note") == "set from inside"
and (set_[1] or {}).get("settings", {}).get("when") == "end",
"POST /v0/settings sets what it names and leaves the rest: %s", set_)
for key, why in (("api_settings_undeclared", "a setting the package does not declare"),
("api_settings_out_of_bounds", "a value outside its bounds")):
bad_ = rep.get(key) or [0, {}]
ev[key] = bad_
ctx.check(bad_[0] == 400 and (bad_[1] or {}).get("error"), "%s -> %s", why, bad_)
store = "%s/settings/%s.json" % (EXT_ROOT, REF_ID)
ev["settings_store"] = {"mode": "%04o" % (os.stat(store).st_mode & 0o7777) if os.path.exists(store) else None}
ctx.check(os.path.exists(store) and os.stat(store).st_mode & 0o7777 == 0o600,
"the settings store is not root's alone: %s", ev["settings_store"])
ctx.check(os.path.exists(store) and json.loads(_read(store)).get("threshold") == 70,
"the store does not hold what the package set")
mode_ = rep.get("api_mode") or [0, {}]
ctx.check(mode_[0] == 200 and mode_[1].get("mode") in ("grbl", "cloud") and "controller" in mode_[1],
"GET /v0/machine/mode from the inside is not forgectrl's answer: %s", mode_)
ctx.check((rep.get("api_hold") or [0])[0] == 403 and (rep.get("api_nowhere") or [0])[0] == 404
and (rep.get("api_traversal") or [0])[0] == 400,
"a hold it was not granted, a path the API does not have, a path with ..: %s %s %s",
rep.get("api_hold"), rep.get("api_nowhere"), rep.get("api_traversal"))
# the storage quota: the host measures what it holds and sets it aside
big = os.path.join(EXT_ROOT, "data", REF_ID, "toobig")
with open(big, "wb") as f:
f.write(b"x" * (2 << 20))
ctx.log("wrote 2 MiB into its data directory (it declares storage:1)")
def quarantined():
x = _svc(REF_ID)
return x if x.get("state") == "quarantined" else None
q = _until(ctx, quarantined, 90, poll=1.0)
ev["quota"] = {"state": (q or _svc(REF_ID)).get("state"), "reason": (q or _svc(REF_ID)).get("reason")}
ctx.log("over its quota: %s", ev["quota"])
ctx.check(q, "a service 2 MiB over its 1 MiB quota was not set aside: %s", _svc(REF_ID))
ctx.check("may hold" in ((q or {}).get("reason") or ""),
"it was set aside without the quota's reason: %s", (q or {}).get("reason"))
ctx.check(not os.path.exists("/proc/%d" % pid), "it is still running after being set aside")
# The operator's way out: clear the data, enable it again.
os.remove(big)
r = _forgeext("enable", REF_ID)
ctx.check(r.get("ok") is True, "enable after the quota -> %s", r.get("error"))
back = _until(ctx, lambda: _svc(REF_ID) if _svc(REF_ID).get("state") == "running" else None, 90, poll=0.5)
ctx.check(back, "it did not come back once its data was cleared and it was enabled: %s", _svc(REF_ID))
pid = (back or {}).get("pid") or pid
sock = "%s/%s.sock" % (API_DIR, REF_ID)
st_ = os.stat(sock)
ctx.check(stat.S_ISSOCK(st_.st_mode) and stat.S_IMODE(st_.st_mode) == 0o660 and (st_.st_uid, st_.st_gid) == (0, uid),
"its API socket is not root's and its account's at 0660: %o %d:%d", stat.S_IMODE(st_.st_mode), st_.st_uid, st_.st_gid)
other = POOL_FIRST + POOL_SIZE - 1 if uid != POOL_FIRST + POOL_SIZE - 1 else POOL_FIRST
rc, lines, errtext = _probe("-", other, "unix", sock)
ev["another_account_at_its_socket"] = lines
ctx.check(lines and lines[0].get("result") == "eacces", "another pool account at its socket: %s %s", lines, errtext)
ctx.check(_until(ctx, lambda: ("ext %s: reference service up" % REF_ID) in _read(HOST_LOG), 20, poll=1),
"the service's output is not in %s under its id", HOST_LOG)
_write(SAFE_FILE, "")
ctx.check(_until(ctx, lambda: not running() and "safe mode" in (_host_status().get("off_reason") or ""), 15),
"safe mode did not stop the service: %s", _host_status())
ctx.check(not os.path.exists("/proc/%d" % pid) and not os.path.isdir("%s/%s" % (POOL_GROUP, REF_ID)),
"safe mode left the process or its group")
os.remove(SAFE_FILE)
x = _until(ctx, lambda: running(pid), 60, poll=0.5)
ctx.check(x, "out of safe mode the service did not start again: %s", _svc(REF_ID))
pid = x["pid"]
ctx.log("safe mode stopped it and its end started it again (pid %d)", pid)
host = _host_pids()
ctx.check(len(host) == 1, "the extension host is not one process: %s", host)
# Only once the service is in its quiet loop: a service still on its way up ends by
# itself when its first line meets the dead host's pipe, and that would prove nothing.
beat_path = os.path.join(EXT_ROOT, "data", REF_ID, "beat")
seen = set()
ctx.check(_until(ctx, lambda: seen.add(_read(beat_path)) or len(seen) >= 3, 30, poll=0.2),
"the service's heartbeat does not advance")
t0 = time.time()
os.kill(host[0], signal.SIGKILL)
ctx.check(_until(ctx, lambda: not os.path.exists("/proc/%d" % pid), 2, poll=0.05),
"a killed host left its service running for 2 s: nobody would freeze it in an armed window")
ev["service_outlived_a_killed_host_s"] = round(time.time() - t0, 2)
x = _until(ctx, lambda: running(pid) if _host_status().get("pid") in _host_pids() else None, 60, poll=0.5)
ev["host_back_s"] = round(time.time() - t0, 1)
ctx.check(x, "the host did not come back and start the service again: %s", _host_status())
pid = x["pid"]
ctx.log("a killed host: its service gone in %.2f s, the host back and the service running after %.1f s",
ev["service_outlived_a_killed_host_s"], ev["host_back_s"])
st, reply = fc.post("/settings", data={"ext_enabled": "0"})
ctx.check(st == 200, "ext_enabled=0 -> %s %r", st, reply)
ctx.check(_until(ctx, lambda: not running() and not os.path.exists("/proc/%d" % pid), 15),
"ext_enabled=0 did not stop the service: %s", _svc(REF_ID))
ctx.check(not os.path.isdir("%s/%s" % (POOL_GROUP, REF_ID)) and ("chain u%d " % uid) not in chains(),
"ext_enabled=0 left the service's group or its chain")
ctx.check("ext_enabled" in (_host_status().get("off_reason") or ""), "the host does not say why nothing runs: %s",
_host_status().get("off_reason"))
finally:
_put_back(ctx, fc, work, prior, etag, raw, dir_mode)
_as_found(ctx, fc, prior, raw, dir_mode, found_tree)
# ------------------------------------------------- the armed window
def _frozen(id_):
@@ -1170,7 +850,7 @@ def _frozen(id_):
covers=[("forgeext", "src/super.*"), ("forgeext", "src/run.*"), ("forgeext", "src/machine.*"),
("forgeext", "src/cgroup.*"), ("forgectrl", "src/cool.*"), ("forgectrl", "src/cam.*"),
("forgectrl", "src/main.c")],
requires=["exthost.service"], actions=["button"],
requires=["exthost.platform"], actions=["button"],
steps=[OFFLINE_STEP,
"Bed clear (the job is dark: a 30 s square at S0, nothing fires). Press the button "
"when it lights."],
@@ -1186,8 +866,8 @@ def _frozen(id_):
"service is the same process throughout. A background capture is refused in words "
"while the window is open and served again once it closes, which is the other half "
"of keeping a package off the step stream: a capture costs kernel-side work that a "
"thread priority does not cover. Everything is put back as exthost.service puts it "
"back.")
"thread priority does not cover. The package, the key, the setting and the setup "
"record are put back as found, the record under a forgectrl restart.")
def armed_freeze(ctx):
import tempfile
import threading
+4 -106
View File
@@ -4,18 +4,17 @@
# SPDX-License-Identifier: MIT
"""forgectrl.* - the machine-services daemon's API, access control, and panel."""
import json
import socket
import time
from ..catalog import test
# The write guard reads the account and the session store (a machine with
# an account needs a session from the LAN; this host and the dev image
# write with the token), so both are the guard's domain.
# write with the token), so both are the guard's domain. The fuse view
# is two-factor: the token and the physical button, read by button.c.
_COVERS_AUTH = [("forgectrl", "src/auth.*"), ("forgectrl", "src/peer.*"), ("forgectrl", "src/main.c"),
("forgectrl", "src/session.*"), ("forgectrl", "src/users.*"),
("forgectrl", "src/ui/login.js"), ("forgectrl", "src/ui/wizard.js")]
("forgectrl", "src/session.*"), ("forgectrl", "src/users.*"), ("forgectrl", "src/button.*"),
("forgectrl", "src/ui/login.*"), ("forgectrl", "src/ui/wizard.js")]
def lan_ip():
@@ -210,104 +209,3 @@ def auth(ctx):
if st == 302:
ctx.check(loc == "/login?next=/setup%3Fstep%3Dlaser.focus",
"the login redirect does not carry the path: %r", loc)
@test("forgectrl.settings-bounds", title="Settings validation and restore", subsystem="forgectrl",
kind="auto", est_min=1,
covers=[("forgectrl", "src/settings.*"), ("forgectrl", "src/main.c"), ("forgectrl", "src/cam.c")],
description="An over-length value and an out-of-range value are refused (400) and leave the "
"settings byte-identical; an in-range value is accepted (200). The lid lamp "
"idles at lid_lamp_idle (unset = 236), an out-of-range level is refused, a new "
"level applies to the lamp at once, and clearing it returns the default.")
def settings_bounds(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
before = fc.settings()
ev["keys"] = len(before)
st, body = fc.post("/settings", data={"gf_serial": "X" * 300})
ev["overlong"] = st
ctx.log("POST /settings gf_serial=<300 chars> -> %s %s", st, body if isinstance(body, dict) else "")
ctx.check(st == 400, "over-length value -> %s, expected 400", st)
st, body = fc.post("/settings", data={"laser_disarm_s": "99999"})
ev["out_of_range"] = st
ctx.log("POST /settings laser_disarm_s=99999 -> %s", st)
ctx.check(st == 400, "out-of-range value -> %s, expected 400", st)
# The cloud download guard is bytes, so its range is far wider than the
# other numeric keys: check the far end is still a wall.
st, body = fc.post("/settings", data={"pulse_reject_threshold_bytes": "2000000000"})
ev["pulse_bytes_out_of_range"] = st
ctx.log("POST /settings pulse_reject_threshold_bytes=2000000000 -> %s", st)
ctx.check(st == 400, "out-of-range byte limit -> %s, expected 400", st)
st, body = fc.post("/settings", data={"no_such_key_forgetest": "1"})
ev["unknown_key"] = st
ctx.log("POST /settings no_such_key_forgetest=1 -> %s", st)
ctx.check(st in (400, 404), "unknown key -> %s, expected 400", st)
after = fc.settings()
ctx.check(json.dumps(after, sort_keys=True) == json.dumps(before, sort_keys=True),
"settings changed after refused writes")
ctx.log("settings unchanged after the refused writes")
# an accepted in-range write: rewrite a present key with its own value
key = None
for k in ("ui_units", "laser_disarm_s", "cool_flow_rise", "rail_settle_s"):
v = before.get(k)
if isinstance(v, str) and v != "":
key = k
break
if key is None:
key, val = "ui_units", "metric"
ctx.log("no settable key is present; writing %s=%s (recorded in evidence)", key, val)
else:
val = before[key]
st, body = fc.post("/settings", data={key: val})
ev["accepted"] = {"key": key, "value": val, "status": st}
ctx.log("POST /settings %s=%s -> %s", key, val, st)
ctx.check(st == 200, "in-range write -> %s, expected 200", st)
final = fc.settings()
others_before = {k: v for k, v in before.items() if k != key}
others_after = {k: v for k, v in final.items() if k != key}
ctx.check(others_before == others_after, "other settings changed by the write")
ctx.check(final.get(key) == val, "%s reads back %r, wrote %r", key, final.get(key), val)
# the lid lamp's idle level: resting at the setting, bounded, applied live
lamp_was = (before.get("lid_lamp_idle") or "").strip()
want = lamp_was or "236"
got = ctx.sysfs("pic/lid_led")
ev["lid_lamp"] = {"setting": lamp_was, "resting": got}
ctx.log("lid lamp: setting %r, pic/lid_led=%s (expected %s)", lamp_was, got, want)
ctx.check(got == want, "lid lamp rests at %s, lid_lamp_idle is %s", got, want)
for bad in ("256", "-1", "bright"):
st, body = fc.post("/settings", data={"lid_lamp_idle": bad})
ctx.check(st == 400, "lid_lamp_idle=%s -> %s, expected 400", bad, st)
ctx.log("lid_lamp_idle 256 / -1 / bright refused")
try_level = "100" if want != "100" else "120"
st, body = fc.post("/settings", data={"lid_lamp_idle": try_level})
ctx.check(st == 200, "lid_lamp_idle=%s -> %s, expected 200", try_level, st)
applied = None
t0 = time.time()
while time.time() - t0 < 5:
applied = ctx.sysfs("pic/lid_led")
if applied == try_level:
break
ctx.sleep(0.2)
ctx.log("lid_lamp_idle=%s -> pic/lid_led=%s after %.1f s", try_level, applied, time.time() - t0)
# an empty value clears the key: the query-string form carries it
st, body = (fc.post("/settings", params={"lid_lamp_idle": ""}) if not lamp_was
else fc.post("/settings", data={"lid_lamp_idle": lamp_was}))
ctx.check(st == 200, "restoring lid_lamp_idle=%r -> %s", lamp_was, st)
t0 = time.time()
back = None
while time.time() - t0 < 5:
back = ctx.sysfs("pic/lid_led")
if back == want:
break
ctx.sleep(0.2)
ev["lid_lamp"].update({"applied": applied, "restored": back})
ctx.check(applied == try_level, "lamp did not follow lid_lamp_idle=%s (reads %s)", try_level, applied)
ctx.check(back == want, "lamp did not return to %s after the restore (reads %s)", want, back)
ctx.log("lid lamp follows the setting live and returns to %s", want)
+14 -42
View File
@@ -4,11 +4,9 @@
# SPDX-License-Identifier: MIT
"""image.* - the post-flash health of the running image (always-required)."""
import glob
import gzip
import mmap
import os
import shutil
import struct
import re
import stat
@@ -123,15 +121,15 @@ def fds_of(pid):
"the daemon ownership, the board temperatures /status reports (the chassis and "
"the SoC die in a plausible range, the supply as a 10-bit count, the CPU "
"unthrottled), "
"the init ordering, the extension host as one process that starts after forgectrl and "
"stops before it on a machine with no package installed and nothing running under a "
"pool account, the data directory open for search on a machine with an account, the file modes the release depends on, and the mounts: the "
"the extension host as one process on a machine with no package installed and "
"nothing running under a pool account, no userspace watchdog running, "
"the data directory open for search on a machine with an account, the file modes the release depends on, and the mounts: the "
"rootfs read-only, /data writable, the account files and the banner rendered "
"into tmpfs, the sshd host keys on /data, the factory slots on the dev image only. "
"into tmpfs, the sshd host keys on /data, the dev image's factory slots read-only. "
"And the boot does not wait on the network: ifup has returned for wlan0 (it is "
"in the state file and no ifup is running) and the console login is up; each "
"DHCP client runs with -b and has left ifup (its parent is pid 1); no DHCPv6 "
"client is running or on the image, nor its hook script; and IPv6 is the "
"client is running; and IPv6 is the "
"kernel's own: enabled on wlan0, router advertisements accepted, a link-local "
"address up (a global address is logged, not checked).")
def image_health(ctx):
@@ -284,17 +282,8 @@ def image_health(ctx):
ctx.check(temps.get("soc_throttle") == 0,
"the CPU is throttled (or its cooling device is missing) at idle: %s", temps)
# 5. init ordering: controllers stop before the daemon
k = sorted(os.path.basename(p) for p in glob.glob("/etc/rc6.d/K*"))
ev["rc6_kill"] = k
kg = [x for x in k if "grblhal" in x]
kf = [x for x in k if x.endswith("forgectrl")]
ctx.log("rc6.d: %s", " ".join(k))
ctx.check(kg and kf, "rc6.d lacks the grblhal/forgectrl kill links")
ctx.check(kg[0] < kf[0], "controller kill link %s must sort before forgectrl's %s", kg[0], kf[0])
# 5b. the extension host: one process, up after forgectrl and down
# before it, and a campaign's machine is extension-free
# 5. the extension host: one process, and a campaign's machine is
# extension-free (its init order is the rootfs lint's)
host = []
for pid in hw.pidof("forgeext"):
argv = _read("/proc/%d/cmdline" % pid, "").split("\0")
@@ -302,13 +291,6 @@ def image_health(ctx):
host.append(pid)
ev["extension_host_pids"] = host
ctx.check(len(host) == 1, "the extension host is not one running process: %s", host)
s5 = sorted(os.path.basename(x) for x in glob.glob("/etc/rc5.d/S*"))
sx = [x for x in s5 if x.endswith("forgeext")]
sf = [x for x in s5 if x.endswith("forgectrl")]
kx = [x for x in k if x.endswith("forgeext")]
ev["forgeext_links"] = {"start": sx, "kill": kx}
ctx.check(sx and sf and sf[0] < sx[0], "the extension host must start after forgectrl: %s, %s", sf, sx)
ctx.check(kx and kx[0] < kf[0], "the extension host must stop before forgectrl: %s, %s", kx, kf)
ev["extension_packages"] = hw.ext_packages()
ev["pool_processes"] = len(hw.pool_pids())
ctx.log("extension host pid %s, packages %s, pool processes %d", host, ev["extension_packages"], ev["pool_processes"])
@@ -316,12 +298,9 @@ def image_health(ctx):
ev["extension_packages"])
ctx.check(not ev["pool_processes"], "%d process(es) run under extension pool accounts", ev["pool_processes"])
# 6. logging lever present, no userspace watchdog daemon
logging = [n for n in ("forgefirm-logging", "forgefirm-logrotate") if os.path.exists("/etc/init.d/" + n)]
ev["logging_init"] = logging
ctx.check(logging, "no ForgeFIRM logging/logrotate init script")
ctx.check(not os.path.exists("/etc/init.d/watchdog"), "a userspace watchdog init script is present")
# kernel threads (the imx2_wdt kthread is expected) have an empty cmdline
# 6. no userspace watchdog daemon running (the logging init and the
# absent watchdog init script are the rootfs lint's); kernel threads
# (the imx2_wdt kthread is expected) have an empty cmdline
user_wd = [p for p in hw.pidof("watchdog") if _read("/proc/%d/cmdline" % p, "")]
ev["userspace_watchdog_pids"] = user_wd
ctx.check(not user_wd, "a userspace watchdog daemon is running: %s", user_wd)
@@ -351,8 +330,9 @@ def image_health(ctx):
# 8. the mounts: the rootfs read-only, /data the writable partition,
# the state a read-only rootfs hands off (the read-only-rootfs image
# feature, forgefirm-users, forgefirm-hostname, forgefirm-banner, the
# sshd host keys). The
# dev image alone mounts the factory slots under /factory.
# sshd host keys). The dev image's factory slots under /factory are
# read-only; rcS's ROOTFS_READ_ONLY and the release fstab with no
# factory slot are the rootfs lint's.
mounts = {}
for line in (_read("/proc/mounts", "") or "").splitlines():
parts = line.split()
@@ -370,8 +350,6 @@ def image_health(ctx):
ctx.check("rw" in mount_opts("/data"), "/data is not mounted read-write: %s", mounts.get("/data"))
ctx.check("rw" in mount_opts("/var/lib"),
"/var/lib is not a writable copy (read-only-rootfs-hook.sh): %s", mounts.get("/var/lib"))
rcs = _read("/etc/default/rcS", "") or ""
ctx.check("ROOTFS_READ_ONLY=yes" in rcs.splitlines(), "/etc/default/rcS lacks ROOTFS_READ_ONLY=yes")
factory = sorted(p for p in mounts if p.startswith("/factory/"))
ev["factory_mounts"] = factory
dev_image = os.path.exists("/etc/forgefirm-dev")
@@ -382,8 +360,6 @@ def image_health(ctx):
if os.path.exists("/dev/mmcblk2p%d" % n):
p = "/factory/img%d" % n
ctx.check("ro" in mount_opts(p), "%s is not mounted read-only on the dev image: %s", p, mounts.get(p))
else:
ctx.check(not factory, "a release image mounts the factory slots: %s", factory)
if os.path.isfile("/data/forgefirm/users"):
names = [l.split(":")[0] for l in (_read("/data/forgefirm/users", "") or "").splitlines()
if l.strip() and not l.startswith("#")]
@@ -428,14 +404,10 @@ def image_health(ctx):
for pid, ppid, _comm, cmd in v4:
ctx.check("-b" in cmd.split(), "the DHCP client runs without -b: %s", cmd)
ctx.check(ppid == 1, "the DHCP client (pid %d) is still a child of pid %d", pid, ppid)
# no DHCPv6 client: not running, not on the image
# no DHCPv6 client running (none on the image is the rootfs lint's)
alive = [p[3] for p in procs if p[2] in DHCP6_CLIENTS]
ev["dhcp6_running"] = alive
ctx.check(not alive, "a DHCPv6 client is running: %s", alive)
for name in DHCP6_CLIENTS:
ctx.check(shutil.which(name) is None, "%s is on the image", name)
ctx.check(not os.path.exists("/usr/share/udhcpc/default6.script"),
"the DHCPv6 hook script is on the image")
# IPv6 is the kernel's own. A global address is evidence only: a
# network whose router advertisement offers no SLAAC prefix gives none.
conf = "/proc/sys/net/ipv6/conf/wlan0/"
+127 -46
View File
@@ -291,6 +291,77 @@ def dwell_gap(samples):
"hv_enable_dipped": dip is not None, "hv_enable_back_lit": back_lit}
def read_gaps(samples, key, t_end=None):
"""Over a sample trail: how many samples carry a reading for `key`,
how many do not, and the longest stretch of seconds between two
readings, the trail's ends counted as edges (an unread lead or tail
is a gap too; a trail with no reading is one gap, its whole span).
The trail ends when its sampling stopped (`t_end`), not at the last
sample served: a tail the daemon did not serve at all is a gap too.
A reading the daemon could not serve is a gap, never a dark sample:
/status leaves the laser block out when the kernel's count did not
read, and the count latches once per ~1 s window, so a window with
no read in it is a window in which a fire is not witnessed."""
if not samples:
return {"read": 0, "unread": 0, "gap_max_s": None}
ts = [s["t"] for s in samples if s.get(key) is not None]
last = samples[-1]["t"] if t_end is None else max(t_end, samples[-1]["t"])
edges = [samples[0]["t"]] + ts + [last]
gap = max(b - a for a, b in zip(edges, edges[1:]))
return {"read": len(ts), "unread": len(samples) - len(ts), "gap_max_s": round(gap, 3)}
def rapid_place(stretch, x0):
"""Where the head's reports place the rapid after the M5, judged over
the armed part of the dark stretch alone: its samples before the first
disarmed one. Returns (i_out, i_back, i_closed): the first sample with
the head at least 19.5 mm out, the first after it with the head back
at x0 (within 0.05 mm), and the first disarmed sample; each None when
there is none. A window that closes before the head is back leaves
i_back None: only a sample that read the window open places the head
back, and sample_to_disarm reads its X before its armed flag, so the
head was back with the window open. The first disarmed sample does
not tell whether the head came back before the close or after it."""
i_closed = next((i for i, s in enumerate(stretch) if not s["armed"]), None)
part = stretch if i_closed is None else stretch[:i_closed]
i_out = next((i for i, s in enumerate(part) if s.get("x") is not None and s["x"] - x0 >= 19.5), None)
i_back = (next((i for i in range(i_out + 1, len(part))
if part[i].get("x") is not None and abs(part[i]["x"] - x0) < 0.05), None)
if i_out is not None else None)
return i_out, i_back, i_closed
def sample_to_disarm(ctx, g, timeout=20.0, sample_hz=8):
"""Sample until the armed window closes, or the timeout, with the
controller's state and machine X beside each sample ("x"), read just
before the sample: a sample that reads the window open read its X
with the window open (rapid_place). A G4 dwell reports Idle, so Idle
cannot end this; the window closing at the program end does, after
two more kernel windows and the read gap allowed: the LASER_ON count
for the window in flight at the close latches up to one window later
and is held through the next, so the sampling runs on until that
count has been read. Returns the samples served and the time the
sampling stopped, the trail's end for the read rule."""
samples = []
period = 1.0 / sample_hz
t0 = time.time()
end = t0 + timeout
t_closed = None
while time.time() < end:
ctx.checkpoint()
rep = g.status_report()
smp = sample(ctx)
if smp:
smp["gstate"] = rep.get("state")
smp["x"] = (rep.get("MPos") or (None,))[0]
samples.append(smp)
if t_closed is None and not smp["armed"]:
t_closed = time.time()
end = t_closed + 2 * EMISSION_WINDOW_S + EMISSION_READ_GAP_S
time.sleep(period)
return samples, time.time()
def run_and_sample(ctx, g, job, sample_hz=8, overall_timeout=200):
"""Stream the job; sample forgectrl through arm -> fire -> disarm.
Completes on: emission seen then Idle > 3 s; or armed then disarmed
@@ -394,37 +465,22 @@ def wait_disarm(ctx, timeout):
"second press. The square ends at M5 with the window still open; once the "
"LASER_ON count reads 0 (the dark point), a rapid runs 20 mm out over the "
"first side and back, then the program ends. From the dark point until the "
"window closes, every sample reads no emission and the HV current at its idle "
"reading, and the head's reports place the rapid inside that stretch: LASER_ON "
"stays off with FIRE clear while the chain can fire. The M2 program end "
"disarms promptly (job-based, not the 60 s idle grace) and the button goes "
"window closes, and on until the count of the window in flight at the close is "
"read, every sample reads no emission and the HV current at its idle reading, "
"the kernel's LASER_ON count read in every ~1 s window of that stretch (a "
"reading the daemon could not serve is a gap, never a dark sample, and so is a "
"tail it served none of), and the head's reports place the rapid inside the "
"window, between the dark point and the close: LASER_ON stays off with FIRE "
"clear while the chain can fire. The M2 program end disarms promptly after the "
"head is back (job-based, not the 60 s idle grace) and the button goes "
"dark. The operator confirms all four sides.")
def emission_witness(ctx):
ev = ctx.evidence
# The rapid after the M5 (G91 is modal from the square): out over the
# first side and back, then the program end.
rapid_tail = ["G0 X20", "G4 P2.5", "G0 X-20", "G4 P0.5", "G90", "M2"]
def sample_to_disarm(g, timeout=20.0, sample_hz=8):
"""Sample until the armed window closes, or the timeout, with the
controller's state and machine X beside each sample ("x"). A G4
dwell reports Idle, so Idle cannot end this; the window closing at
the program end does."""
samples = []
period = 1.0 / sample_hz
t0 = time.time()
while time.time() - t0 < timeout:
ctx.checkpoint()
smp = sample(ctx)
rep = g.status_report()
if smp:
smp["gstate"] = rep.get("state")
smp["x"] = (rep.get("MPos") or (None,))[0]
samples.append(smp)
if not smp["armed"]:
break
time.sleep(period)
return samples
# first side and back, then the program end. The head stands back for
# 1 s before the M2, so that a sample of a slow sampling loop still
# reads it back with the window open (rapid_place).
rapid_tail = ["G0 X20", "G4 P2.5", "G0 X-20", "G4 P1", "G90", "M2"]
with ctx.grbl() as g, LiveJob(ctx, g):
prepare(ctx, g)
@@ -464,7 +520,7 @@ def emission_witness(ctx):
dark["x"] = (rep.get("MPos") or (None,))[0]
ctx.check(dark["x"] is not None, "the controller reports no machine position: %s", rep)
stream(g, rapid_tail)
tail = sample_to_disarm(g)
tail, t_end = sample_to_disarm(ctx, g)
finally:
ctx.clear_notice()
samples = cut + [dark] + tail
@@ -519,31 +575,40 @@ def emission_witness(ctx):
ctx.log("exhaust at the first emission sample: %s rpm (floor %.0f)",
ev["exhaust_at_first_fire"], floor)
# The rapid after the M5, with the window open and FIRE clear: every
# sample from the dark point until the window closes reads no emission
# and the HV current at its idle reading. The head's reports place the
# rapid inside that stretch: out to +20 mm, then back at the start.
# sample from the dark point until the sampling stops (two windows and
# the read gap allowed past the close) reads no emission and the HV
# current at its idle reading, and the kernel's LASER_ON count was read
# in every ~1 s window of that stretch, the one that latches after the
# close included: the end of the sampling is the trail's last edge. A
# reading the daemon could not serve is a gap, never a dark sample.
# The head's reports place the rapid inside the window, from the dark
# point to the close: out to +20 mm, then back at the start.
stretch = [dark] + tail
x0 = dark["x"]
i_out = next((i for i, s in enumerate(stretch) if s.get("x") is not None and s["x"] - x0 >= 19.5), None)
i_back = (next((i for i in range(i_out + 1, len(stretch))
if stretch[i].get("x") is not None and abs(stretch[i]["x"] - x0) < 0.05), None)
if i_out is not None else None)
closed = stretch[-1] if not stretch[-1]["armed"] else None
i_out, i_back, i_closed = rapid_place(stretch, x0)
closed = stretch[i_closed] if i_closed is not None else None
armed_part = stretch if i_closed is None else stretch[:i_closed + 1]
relit = [s for s in stretch if s["emission"]]
hv_dark = max((s["hv"] for s in stretch if s["hv"] is not None), default=0)
reads = read_gaps(stretch, "emission", t_end)
hv_reads = read_gaps(stretch, "hv", t_end)
dt = (closed["t"] - stretch[i_back]["t"]) if (closed and i_back is not None) else None
ev["rapid"] = {"samples": len(stretch), "span_s": round(stretch[-1]["t"] - dark["t"], 2),
"x_start": x0, "x_max": max((s["x"] for s in stretch if s.get("x") is not None), default=None),
ev["rapid"] = {"samples": len(armed_part), "span_s": round((closed or stretch[-1])["t"] - dark["t"], 2),
"x_start": x0, "x_max": max((s["x"] for s in armed_part if s.get("x") is not None), default=None),
"out_at_s": round(stretch[i_out]["t"] - dark["t"], 2) if i_out is not None else None,
"back_at_s": round(stretch[i_back]["t"] - dark["t"], 2) if i_back is not None else None,
"relit": [(round(s["t"] - dark["t"], 2), s["emission"]) for s in relit][:8],
"hv_max": hv_dark, "locked_at_dark": dark["locked"],
"locked_at_out": stretch[i_out]["locked"] if i_out is not None else None}
"locked_at_out": stretch[i_out]["locked"] if i_out is not None else None,
"sampled_past_close_s": round(t_end - closed["t"], 2) if closed else None,
"emission_reads": reads, "hv_reads": hv_reads}
ev["disarm_after_idle_s"] = round(dt, 1) if dt is not None else None
ctx.log("the rapid after the M5: %d samples over %.1f s from the dark point, out at +%s s, back at +%s s, "
"%d with emission, HV max %s; the window closed %s s after the head was back", len(stretch),
ev["rapid"]["span_s"], ev["rapid"]["out_at_s"], ev["rapid"]["back_at_s"], len(relit), hv_dark,
ev["disarm_after_idle_s"])
ctx.log("the rapid after the M5: %d samples over %.1f s from the dark point to the close, out at +%s s, "
"back at +%s s; sampled %s s past the close, %d of %d samples with emission, HV max %s, %d of %d "
"carried the LASER_ON count, longest unread stretch %s s; the window closed %s s after the head "
"was back", len(armed_part), ev["rapid"]["span_s"], ev["rapid"]["out_at_s"], ev["rapid"]["back_at_s"],
ev["rapid"]["sampled_past_close_s"], len(relit), len(stretch), hv_dark, reads["read"],
len(stretch), reads["gap_max_s"], ev["disarm_after_idle_s"])
ctx.check(armed_seen, "the armed window was never observed (arm refused, or no button press)")
ctx.check(peak > 0, "no emission witnessed on the square (emission_samples stayed 0)")
first_idle_fire = ev["idle_airflow_fire"][0] if idle_fire else (None, None, None)
@@ -558,16 +623,25 @@ def emission_witness(ctx):
ctx.check(hv and max(hv) > min(hv), "HV current did not rise during the burn (%s..%s)",
ev["hv_min"], ev["hv_max"])
ctx.check(i_out is not None and i_back is not None,
"the rapid did not run inside the dark stretch: out at %s, back at %s (X from %s, max %s)",
ev["rapid"]["out_at_s"], ev["rapid"]["back_at_s"], x0, ev["rapid"]["x_max"])
"the rapid did not run inside the dark stretch with the window open: out at %s, back at %s "
"(X from %s, max %s), the window closed at %s", ev["rapid"]["out_at_s"], ev["rapid"]["back_at_s"],
x0, ev["rapid"]["x_max"], round(closed["t"] - dark["t"], 2) if closed else None)
ctx.check(stretch[i_out]["locked"] is False, "the laser latch was locked with the head at +20 mm "
"(laser_locked %s): the rapid ran unarmed", stretch[i_out]["locked"])
ctx.check(reads["read"] >= RAPID_MIN_READS and reads["gap_max_s"] is not None
and reads["gap_max_s"] <= EMISSION_READ_GAP_S,
"the LASER_ON count was not read through the rapid: %d of %d samples carried it, the longest "
"unread stretch %s s (the kernel latches the count once per ~1 s window, so a fire in an "
"unread window is not witnessed)", reads["read"], len(stretch), reads["gap_max_s"])
ctx.check(not relit, "the laser emitted after the M5: %d samples, first at +%s s (emission_samples %s) "
"- a rapid after M5 fired", len(relit), relit[0]["t"] - dark["t"] if relit else None,
relit[0]["emission"] if relit else None)
ctx.check(hv_reads["read"] >= RAPID_MIN_READS,
"the HV current was readable on only %d of %d samples through the rapid (%d needed)",
hv_reads["read"], len(stretch), RAPID_MIN_READS)
ctx.check(hv_dark <= HV_DARK_MAX, "HV current %s after the M5 (idle reads ~0): the discharge ran "
"through the rapid", hv_dark)
ctx.check(dt is not None and dt < 10.0,
ctx.check(dt is not None and 0 <= dt < 10.0,
"the M2 job did not disarm promptly (%s s after the head was back; the idle grace is ~60 s)",
ev["disarm_after_idle_s"])
ctx.check(gap["button_latch_unlocked_samples"] and gap["button_latch_unlocked_max"] == 0,
@@ -586,6 +660,13 @@ def emission_witness(ctx):
HV_DARK_MAX = 20 # hv_current_raw reads 0 with the tube off; hundreds under fire
# The kernel latches cnc/laser_on_sampled once per ~1 s window (cnc.c
# LASER_SAMPLE_WINDOW) and holds the count through the next window, so a
# trail whose valid reads are never farther apart than EMISSION_READ_GAP_S
# has read every window's count at least once.
EMISSION_WINDOW_S = 1.0
EMISSION_READ_GAP_S = 0.9
RAPID_MIN_READS = 4 # the fewest samples with the count (and with the HV current) over the rapid
@test("laser.armed-kill", title="Armed kill mid-fire: the expected stop, then a SIGKILL",
+228 -75
View File
@@ -191,18 +191,239 @@ def pacing(ctx):
ctx.log("PASS: idle %.1f%%, moving %.1f%%, parked %.1f%%, hold/resume exact", idle, moving, parked)
@test("motion.jog-roundtrip", title="Motion quality: bounded jogs, max rate, diagonal, hold/resume",
@test("motion.jog-roundtrip", title="Motion quality: bounded jogs, max rate, diagonal, hold/resume, "
"a soft reset during a move",
subsystem="motion", kind="auto", mode="grbl", est_min=3,
covers=_MOTION_COVERS, requires=["kernel.latch-locked-idle"],
covers=_MOTION_COVERS + [("forgectrl", "src/main.c")],
requires=["kernel.latch-locked-idle", "kernel.k1-k2"],
steps=["Setup: the head parked with at least 60 mm of free +X and 40 mm of free +Y travel; "
"bed clear, lid closed."],
description="Sanity jogs (X, Y 40 mm out/back at F2400), max-rate X out/back (60 mm at "
"F12000), a diagonal out/back, then a G1 with a feed-hold/resume in the middle. "
"Then a soft reset (^X) during a 60 mm G1 at F3000: the kernel ramps the motion "
"down (its tick rate falls before the run ends) and leaves running with no "
"underrun and no fault, the ring is empty after the reset, MPos matches the "
"kernel's step counters within half a step after $X, the homed mask is kept, the sender "
"is not told the position is lost, and a jog back by the MPos distance returns "
"the kernel's X counter to its start within a step. "
"No jog refused, every move returns to Idle, position drift within 0.05 mm, and "
"the head accelerometer - the supervisor's own motion witness - saw the head move "
"on every jog it could sample; the counters alone are not proof of motion.")
def jog_roundtrip(ctx):
ev = ctx.evidence
def reset_during_move(g):
"""A soft reset during a move, from rest with the head at the start:
the kernel's ramp, the position read back from its step counters,
the reference kept, and the head jogged back. The readings go to the
evidence; the failures are returned, for the caller to judge once
the head is back."""
fc = ctx.forgectrl
machine_idle(ctx)
g.drain()
spm = [grbl_setting(g, k) for k in ("$100", "$101", "$102")]
ctx.check(all(spm), "the step scales did not read back ($100 %s, $101 %s, $102 %s)", *spm)
f0 = hw.sysfs_int("cnc/step_freq")
ramp = hw.sysfs_int("cnc/ramp_rate")
ctx.check(f0 and ramp, "cnc/step_freq %s, cnc/ramp_rate %s: the kernel's tick is unreadable",
f0, ramp)
under0, faults0 = hw.sysfs_int("cnc/underruns"), hw.sysfs_read("cnc/faults")
p0 = _kernel_position()
m0 = g.status_report().get("MPos")
h0 = _port_state(fc).get("homed")
ctx.check(m0, "no MPos in the status report before the reset")
# The mask comes from the controller port's state record: $# prints
# its [HOME:...:mask] line only with $22 on, which the driver
# leaves off. The driver always holds the Z bit (the lens hold);
# without it the mask could not tell a kept reference from a
# dropped one.
ctx.check(isinstance(h0, int) and h0 & 4,
"the homed mask reads %r before the reset: the lens hold (Z, 4) is not in it, so "
"the mask cannot witness the reset", h0)
log0 = _log_offset(GRBLHAL_LOG)
rec = {"step_freq": f0, "ramp_rate": ramp, "spm": spm, "kernel_before": list(p0),
"mpos_before": list(m0), "homed_before": h0}
r = g.command("G91G1X60F3000", timeout=0.5)
ctx.check(not any(x.startswith("error") for x in r), "the 60 mm G1 was refused: %s", r)
ctx.check(wait_state(ctx, g, "Run", 2.0), "the 60 mm G1 did not start (no Run within 2 s)")
# The kernel's run starts a queue depth behind the core's; the ^X
# lands 0.3 s into it, with the head at its cruise (the move takes
# 1.2 s, its acceleration 71 ms).
ok = ctx.wait_for(lambda: hw.sysfs_read("cnc/state") == "running", 1.0, poll=0.01)
ctx.check(ok is not None, "the kernel did not start the 60 mm G1 within 1 s of Run (cnc/state %s)",
hw.sysfs_read("cnc/state"))
time.sleep(0.3)
kx = _kernel_position()
kstate = hw.sysfs_read("cnc/state")
ctx.check(kstate == "running", "the kernel was %s at the ^X: the move had ended before it", kstate)
g.realtime(0x18)
t_x = time.time()
# The stop: the kernel leaves running, polled at 5 ms. Each sample
# is the time after the ^X, the state, the bytes played and the
# bytes handed (the position record).
samples = []
while True:
st = hw.sysfs_read("cnc/state")
pos = _kernel_position()
t = time.time() - t_x
samples.append((t, st, pos[3], pos[4]))
if st != "running" or t > 1.0:
break
time.sleep(0.005)
state = None
end = t_x + 2.0
while time.time() < end:
state = g.status_report()["state"]
if state.startswith("Alarm"):
break
time.sleep(0.05)
rec.update({"t_x": round(t_x, 3), "queue_at_x": kx[4] - kx[3], "kernel_end_state": samples[-1][1],
"left_running_s": round(samples[-1][0], 4), "samples": len(samples),
"state_after_x": state})
ev["reset"] = rec
if not (state or "").startswith("Alarm"):
if (state or "").startswith("Idle"):
ctx.fail("the move had ended before the ^X: the controller is Idle with no alarm")
ctx.fail("the controller was not in Alarm within 2 s of the ^X (state %s)",
state or "unread: the 5 ms sampling loop ran past the 2 s")
# The read-back ends inside a second of the reset, and the core's
# re-init flushes what it has read: the unlock goes after both.
ctx.sleep(max(0.0, t_x + 2.0 - time.time()))
text = g.drain()
r = g.command("$X")
text += "\n".join(r) + "\n"
rec["unlock"] = r
ctx.check(r and r[-1] == "ok", "$X after the reset answered %s", r)
ctx.check(wait_state(ctx, g, "Idle", 5.0), "the controller is not Idle after $X")
machine_idle(ctx)
text += g.drain()
m1 = g.status_report().get("MPos")
p1 = _kernel_position()
h1 = _port_state(fc).get("homed")
ctx.check(m1, "no MPos in the status report after $X")
lost = [ln.strip() for ln in text.splitlines()
if ln.strip().startswith("[MSG:") and "read back" in ln]
kd = [p1[a] - p0[a] for a in range(3)]
md = [(m1[a] - m0[a]) * spm[a] for a in range(3)]
rec.update({"kernel_after": list(p1), "mpos_after": list(m1), "homed_after": h1,
"kernel_delta_steps": kd, "mpos_delta_steps": [round(v, 2) for v in md],
"ring_after": p1[4] - p1[3], "lost_messages": lost,
"readback_log": _log_lines(GRBLHAL_LOG, log0,
"soft reset: the kernel ramped the motion down")[-1:]})
# Hand back: a jog by the MPos distance (a jog carries its own G91:
# the reset restored the parser's defaults).
dx = m1[0] - m0[0]
if abs(dx) >= 0.001:
r = g.command("$J=G91X%.3fF3000" % -dx)
ctx.check(not any(x.startswith("error") for x in r), "the jog back was refused: %s", r)
_, states, _ = wait_idle(ctx, g)
ctx.check("TIMEOUT" not in states, "the jog back did not return to Idle")
machine_idle(ctx)
p2 = _kernel_position()
under1, faults1 = hw.sysfs_int("cnc/underruns"), hw.sysfs_read("cnc/faults")
rec.update({"kernel_handed_back": list(p2), "underruns": [under0, under1], "faults": [faults0, faults1]})
# The ramp: the kernel decelerated. Nothing but its ramp lowers the
# tick rate once a run is up to speed, so the bytes it played over
# the run's last 25 to 50 ms, against the tick, say whether the run
# ended at speed or decelerated. The queue at cnc/stop is about
# 110 ms of ticks: the ring holds 200 ms and the producer leads by
# 10 ms, and the core's alarm hold plays 100 ms of it at the tick
# before the driver's stop. The kernel's ramp is stepped (cnc.c,
# ramp_update_tasklet_fn): the tick holds for the first 10 ms,
# then every 10 ms it drops by ramp_rate / 100 until it reaches
# the 900 Hz floor, which plays 10 ms before the stop. At the x32
# tick of 112640 Hz and its 500000 Hz/s ramp that is 5000 Hz off
# every 10 ms: 24 intervals, 240 ms, about 13270 ticks, 118 ms of
# the tick (the ramp scales with the tick, so every mode gives the
# same fractions). So the run ends at the floor, or out of data
# part way down the ramp, on the interval its queue runs out in:
# 24% of the tick with 110 ms left, 38% with 100 ms, 51% with
# 90 ms. The tail's mean over the 25 to 30 ms the poll samples is
# higher, since it spans the intervals before: 32% with 110 ms
# left, 45% with 100 ms, 56% with 90 ms (61% over the 50 ms window
# bound), and a few percent when the run reaches the floor. A run
# that ends at the tick (a run-out with no stop) reads 100%,
# within the sampling's few percent over 25 ms. The bound is 70%:
# 14 points above the slowest ramped tail and 30 below a run-out.
# The other readings are recorded, not judged: with the ramp about
# the size of the queue, the steps that never played can be none,
# the bytes played match a run-out within a few percent, and the
# time to leave running is the 100 ms hold plus the run's end on
# the ramp: about 240 ms with 100 ms left, 270 ms with 110 ms,
# 340 ms when the run reaches the floor. The evidence's ramp_ticks
# and ramp_s are the continuous figures, (f0^2 - 900^2) / (2 a)
# and (f0 - 900) / a: 12687 ticks and 223 ms at x32, about 4% and
# 7% under the stepped ramp's.
running = [s for s in samples if s[1] == "running"]
tail = None
if running:
last = running[-1]
rec["last_running_s"] = round(last[0], 4)
before = [s for s in running if last[0] - s[0] >= 0.025]
if before:
first = before[-1]
w = last[0] - first[0]
rate = ((last[2] - first[2]) & 0xffffffff) / w
tail = {"window_s": round(w, 4), "rate_hz": round(rate), "fraction": round(rate / f0, 3)}
rec.update({"tail": tail, "ramp_s": round((f0 - 900.0) / ramp, 4),
"ramp_ticks": int((f0 * f0 - 900.0 * 900.0) / (2.0 * ramp)),
"ring_at_idle": samples[-1][3] - samples[-1][2]})
ctx.log("soft reset during the move: kernel %s %.0f ms after the ^X (queue %d ticks at the ^X, "
"ramp %d ticks over %.0f ms), tail %s; kernel delta %s steps, MPos delta %s steps, "
"homed %s -> %s, ring %d after the reset, read-back %s",
rec["kernel_end_state"], 1e3 * rec["left_running_s"], rec["queue_at_x"], rec["ramp_ticks"],
1e3 * rec["ramp_s"], tail, kd, rec["mpos_delta_steps"], h0, h1, rec["ring_after"],
rec["readback_log"] or "(no line)")
fails = []
if samples[-1][1] == "running":
fails.append("the kernel was still running 1.0 s after the ^X")
elif samples[-1][1] != "idle":
fails.append("the kernel left running into %s, not idle" % samples[-1][1])
if under1 != under0 or faults1 != faults0:
fails.append("cnc/underruns %s -> %s, cnc/faults %s -> %s across the reset"
% (under0, under1, faults0, faults1))
if tail is None:
fails.append("the kernel left running %.0f ms after the ^X with no 25 ms of its run sampled "
"after it: a stop with no ramp" % (1e3 * rec["left_running_s"]))
elif tail["window_s"] > 0.050:
fails.append("the poll did not sample the run's tail (a %.0f ms gap)" % (1e3 * tail["window_s"]))
elif tail["fraction"] > 0.70:
fails.append("the kernel's tick ran at %.0f%% of %d Hz over the run's last %.0f ms: the run "
"ended at speed, not on the ramp" % (100 * tail["fraction"], f0, 1e3 * tail["window_s"]))
if rec["ring_after"]:
fails.append("the ring still holds %d bytes after the reset: the clear did not run" % rec["ring_after"])
# The read-back is exact, and the judge allows half a step. The
# smallest miss the read-back can make is one step: the core's
# kill step for the pulse start that never came, which a position
# kept as the core counted it carries. MPos is reported to
# 0.001 mm, so the difference of two readings carries up to 0.21
# step at x32 (0.05 at x8): a correct read-back lands within
# that, a one-step miss at 0.79 step or more, and a one-step
# tolerance would let the miss land on either side of it. The
# kernel counts X_DIR set as -X, so the sign of its X count is
# the one this +X move gave it.
if abs(kd[0]) < spm[0] or md[0] <= 0:
fails.append("the move did not reach the kernel or MPos: kernel X %+d steps, MPos X %+.1f steps"
% (kd[0], md[0]))
elif abs(abs(kd[0]) - md[0]) > 0.5:
fails.append("MPos X moved %.1f steps and the kernel played %d: the position was not read back "
"from the kernel's counters" % (md[0], abs(kd[0])))
for a, name in ((1, "Y"), (2, "Z")):
if kd[a] or abs(md[a]) > 0.5:
fails.append("%s moved under an X move: kernel %+d steps, MPos %+.1f steps" % (name, kd[a], md[a]))
# The reference is kept.
if h1 != h0:
fails.append("the homed mask went %s -> %s across the reset: the reference was dropped" % (h0, h1))
if lost:
fails.append("the sender was told the position is lost: %s" % lost[-1])
if abs(p2[0] - p0[0]) > 1:
fails.append("the jog back left the kernel's X counter %+d steps from its start" % (p2[0] - p0[0]))
return fails
accel = hw.AccelSampler()
ctx.check(accel.available, "no head accelerometer found (i2c %s): the motion witness is missing",
hw.HEAD_ACCEL_I2C)
@@ -269,6 +490,10 @@ def jog_roundtrip(ctx):
ctx.log("after ~: states %s", states)
g.command("G1X-30F2400", timeout=0.5)
wait_idle(ctx, g)
fails = reset_during_move(g)
for why in fails:
ctx.log("soft reset: %s", why)
ctx.check(not fails, "soft reset during the move: %s", "; ".join(fails))
g.command("G90")
final = g.status_report().get("MPos")
ev["final"] = final
@@ -559,78 +784,6 @@ def _wait_controller(ctx, fc, states, timeout, motion=None):
return m if isinstance(m, dict) else {}
# ---------------------------------------------------------------- cancel / abort
@test("motion.cancel-abort", title="Jog cancel and controlled abort recover cleanly", subsystem="motion",
kind="auto", mode="grbl", est_min=2,
covers=_MOTION_COVERS, requires=["motion.pacing"],
steps=["Bed clear; the head needs 40 mm of free +X travel."],
description="A jog-cancel (0x85) stops a jog short of its target and returns to Idle with "
"position preserved; a ^X abort mid-move (what the sender's Stop sends) "
"decelerates under control into Alarm with machine position retained, leaves the "
"head where it stopped - no return-to-start, that is the lid policy's move alone - "
"and $X recovers to Idle with a subsequent jog running (no driver wedge: the rail "
"never cycled).")
def cancel_abort(ctx):
ev = ctx.evidence
with ctx.grbl() as g:
clean_slate(ctx, g)
start = g.status_report()["MPos"]
# jog cancel
g.command("$J=G91X40F2400")
ctx.sleep(0.4)
g.realtime(0x85)
st = wait_state(ctx, g, "Idle", 5)
ctx.check(st is not None, "not Idle within 5 s of the jog cancel")
p1 = st["MPos"]
moved1 = p1[0] - start[0]
ev["cancel_moved_mm"] = round(moved1, 3)
ctx.log("jog cancel: moved %.3f mm of 40 (state %s)", moved1, st["state"])
ctx.check(0.5 < moved1 < 39.0, "jog cancel did not stop short of the target (%.3f mm)", moved1)
# ^X abort mid-move
g.command("G91")
g.command("G1X30F600", timeout=0.5)
ctx.sleep(1.0)
g.realtime(0x18)
st = wait_state(ctx, g, "Alarm", 5)
ctx.check(st is not None, "^X did not land in Alarm within 5 s")
p2 = st["MPos"]
moved2 = p2[0] - p1[0]
ev["abort_moved_mm"] = round(moved2, 3)
ctx.log("^X abort: state %s, moved %.3f mm of 30, position retained %s", st["state"], moved2, p2)
ctx.check(0.5 < moved2 < 29.5, "abort position not retained/plausible (%.3f mm)", moved2)
r = g.command("$X")
ctx.log("$X -> %s", r)
st = wait_state(ctx, g, "Idle", 5)
ctx.check(st is not None, "$X did not recover to Idle")
# A sender abort is not a lid cancel: it stops where it stopped and
# the head stays there. Only the lid/interlock policy returns to the
# job start, and an unasked-for return move would be a surprise to
# whoever pressed Stop.
text = drain_text(g, 2.0)
ev["abort_returned_home"] = "returned to the job start" in text
p3 = g.status_report()["MPos"]
ev["abort_drift_after_recovery_mm"] = round(abs(p3[0] - p2[0]), 3)
ctx.log("after $X: %s (moved %.3f mm since the abort)", p3, ev["abort_drift_after_recovery_mm"])
ctx.check(not ev["abort_returned_home"], "a sender abort triggered the return-to-start motion")
ctx.check(ev["abort_drift_after_recovery_mm"] <= 0.05,
"the head moved %.3f mm on its own after a sender abort",
ev["abort_drift_after_recovery_mm"])
# a jog after the abort proves the drivers are alive; return to start
back = -(p2[0] - start[0])
r = g.command("$J=G91X%.3fF2400" % back)
ctx.check(not any(x.startswith("error") for x in r), "return jog refused: %s", r)
peak, states, st = wait_idle(ctx, g, 30)
ctx.check("TIMEOUT" not in states, "return jog did not complete: %s", states)
final = st["MPos"]
drift = abs(final[0] - start[0])
ev["final_drift_mm"] = round(drift, 3)
ctx.log("returned: drift %.3f mm", drift)
ctx.check(drift <= 0.05, "position drift %.3f mm after cancel/abort/return", drift)
g.command("G90")
machine_idle(ctx)
# ---------------------------------------------------------------- dead-man
def _kernel_x_mm(ctx):
@@ -656,7 +809,7 @@ def _return_x(ctx, delta_mm):
"a kill during the web-service homing",
subsystem="motion", kind="auto", mode="grbl", est_min=6,
covers=_MOTION_COVERS + [("forgectrl", "src/main.c"), ("forgectrl", "init/**")],
requires=["motion.cancel-abort", "kernel.k1-k2"],
requires=["motion.pacing", "kernel.k1-k2"],
steps=["Bed clear; the head needs 40 mm of free +X travel and must not be at the left rail. "
"With cloud mode enabled the test also homes through the web service and kills the "
"controller mid-homing (about a minute); the head ends wherever the homing was."],
+4 -235
View File
@@ -3,10 +3,10 @@
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""setup.* - the first-run setup: the record and the
controller gate, the advisories, the account and its login, the HTTPS
boundary, SSH, the cloud switch, the machine's name, and the factory
return.
"""setup.* - what the setup tests share: the setup record and its
builders, the files of the data directory, a takeover that installs a
record, and the HTTPS client. The tests are setup_dark's and
setup_burns'; this module registers none.
The daemon reads the setup record (setup.json in the
data directory) and the account record (users) once, at its start, and
@@ -27,19 +27,15 @@ import posixpath
import http.client
import json
import os
import random
import socket
import ssl
import time
import urllib.parse
from ..catalog import test
from .. import hw
RECORD = "setup.json"
USERS = "users"
SSH_FLAG = "ssh-enabled"
TLS_BASE = "https://127.0.0.1" # FORGECTRL_TLS_URL overrides (host tests)
# The first run's form wizards, all at version 1 (wiz.c).
FORM_WIZARDS = ("advisories", "account", "preferences", "machine", "cloud")
@@ -52,8 +48,6 @@ REQUIRED_STEPS = REQUIRED_FORM_STEPS + ("cooling.flow",)
# The setting that counts the intake fans: the gate holds without it.
INTAKE_KEY = "cool_intake_fans"
SAFETY_PHRASE = "I UNDERSTAND"
LOGIN_FAILS = 5
LOGIN_LOCK_S = 30
# ----------------------------------------------------------------- files
@@ -284,19 +278,6 @@ def decode(content):
return text
def tls_base():
return (os.environ.get("FORGECTRL_TLS_URL") or TLS_BASE).rstrip("/")
def tcp_open(host, port, timeout=2.0):
try:
s = socket.create_connection((host, port), timeout=timeout)
s.close()
return True
except OSError:
return False
def session_from_cookie(value):
"""The ffsid session id out of a Set-Cookie value, or None."""
for part in (value or "").split(";"):
@@ -318,218 +299,6 @@ def wiz(fc):
return body
def mode(fc):
st, body = fc.get("/mode")
return body if (st == 200 and isinstance(body, dict)) else {}
def press_status(fc):
"""GET /wiz/advisories/press, the page's poll at the press step: the
daemon takes a pressed button and records the acceptance on this
read, so a press nobody polls for stays 'pressed' and unaccepted."""
st, body = fc.get("/wiz/advisories/press")
return body if (st == 200 and isinstance(body, dict)) else {}
def wiz_summary(w):
return {k: w.get(k) for k in ("first_run", "gate", "why", "recommended",
"advisories_complete", "acceptance_done", "account",
"fans_counted", "completed", "versions")}
def recommended(w):
"""The steps a GET /wiz body recommends: {id: reason}."""
return {x.get("id"): x.get("reason") or "" for x in w.get("recommended") or [] if isinstance(x, dict)}
def wait_settled(ctx):
"""The supervisor settled (the runner's own wait): the /mode body."""
return ctx.takeover().wait_settled()
# -------------------------------------------------------------- the login
@test("setup.account-login", title="The panel login over HTTPS", subsystem="setup",
kind="auto", est_min=2,
covers=[("forgectrl", "src/auth.*"), ("forgectrl", "src/session.*"), ("forgectrl", "src/users.*"),
("forgectrl", "src/tls.*"), ("forgectrl", "src/peer.*"), ("forgectrl", "src/main.c"),
("forgectrl", "src/ui/login.*")],
requires=["forgectrl.auth"], hardware="takeover",
description="The test makes its own account: the account record is moved aside under a "
"forgectrl restart, the account route creates a temporary one with a password "
"only the test knows, and the real record comes back under another restart at "
"the end, the system accounts replayed and the temporary home removed. The data "
"directory is set to 0700 first, as a strict umask leaves it: the render that makes "
"the account opens it for search and nothing else (0711), and tried as the account, "
"its home can be entered and written while the data directory cannot be listed and "
"the account record cannot be read. Over "
"HTTPS (self-signed, so unverified): GET /wiz reports no session and the "
"certificate fingerprint; POST /login with a wrong password is refused (401); "
"five failures lock the address (429, wait at most 30 s) and the right password "
"is refused inside the lock too; after the lock a wrong name is 401 and the "
"right pair sets the ffsid cookie (HttpOnly, Secure, SameSite=Strict); GET /wiz "
"with the cookie reports the session; POST /logout ends it.")
def account_login(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
name = "forgetest"
pw = "".join(random.choice("abcdefghjkmnpqrstuvwxyz23456789") for _ in range(16))
before = wiz(fc)
ev["account_before"] = (before.get("users") or {}).get("name")
homes = "/data/forgefirm/home"
dir_mode = os.stat(data_dir()).st_mode & 0o7777
ev["data_dir_mode_found"] = "%04o" % dir_mode
try:
with installed(ctx, {users_path(): None}):
w = wiz(fc)
ctx.check(not (w.get("users") or {}).get("exists"), "an account still exists: %s", w.get("users"))
# the data directory as a strict umask leaves it: the account's render has to open it
os.chmod(data_dir(), 0o700)
st, body = fc.post("/wiz/account", data={"name": name, "password": pw})
ctx.log("POST /wiz/account (temporary %r) -> %s %s", name, st, body if isinstance(body, dict) else "")
ctx.check(st == 200, "the temporary account was not created: %s %s", st, body)
w = wiz(fc)
ctx.check((w.get("users") or {}).get("name") == name, "the account reads %s", w.get("users"))
reach = home_reach(name)
ev["home_reach"] = reach
ev["data_dir_mode_after_render"] = "%04o" % (os.stat(data_dir()).st_mode & 0o7777)
ctx.log("as %s, from a data directory found 0700 (now %s): %s", name, ev["data_dir_mode_after_render"], reach)
ctx.check(reach.get("cd") == "ok" and reach.get("write") == "ok",
"the account cannot use its own home %s: %s", reach.get("home"), reach)
ctx.check(ev["data_dir_mode_after_render"] == "0711", "the render left the data directory at %s, expected "
"0711 (the search bit and nothing else)", ev["data_dir_mode_after_render"])
ctx.check(reach.get("list_data_dir") == "EACCES" and reach.get("read_record") == "EACCES",
"the account reads what is not its own: %s", reach)
login_checks(ctx, name, pw)
finally:
# as found; the replay below is the product's own say on the mode
os.chmod(data_dir(), dir_mode)
rc, out = hw.initd("forgefirm-users", "reload")
ev["users_reload_rc"] = rc
ctx.log("forgefirm-users reload -> rc %s %s", rc, out.strip()[:200])
if name != ev["account_before"]:
import shutil
shutil.rmtree(os.path.join(homes, name), ignore_errors=True)
after = wiz(fc)
ctx.check((after.get("users") or {}).get("name") == ev["account_before"],
"the account reads %r after the restore, was %r", (after.get("users") or {}).get("name"),
ev["account_before"])
# Run as root: becomes the account and tries its own home, and what is
# not its own. One JSON line.
HOME_REACH = r'''
import errno, json, os, pwd, sys
p = pwd.getpwnam(sys.argv[1])
os.setgroups([])
os.setgid(p.pw_gid)
os.setuid(p.pw_uid)
def tried(fn):
try:
fn()
return "ok"
except OSError as e:
return errno.errorcode.get(e.errno, str(e.errno))
def write():
path = os.path.join(p.pw_dir, ".forgetest-reach")
with open(path, "w") as f:
f.write("x")
os.remove(path)
print(json.dumps({"uid": os.getuid(), "home": p.pw_dir, "cd": tried(lambda: os.chdir(p.pw_dir)), "write": tried(write),
"list_data_dir": tried(lambda: os.listdir(sys.argv[2])),
"read_record": tried(lambda: open(os.path.join(sys.argv[2], "users")).read())}))
'''
def home_reach(name):
"""What the account can do with its own home and with the data
directory around it, tried as the account."""
import subprocess
import sys
p = subprocess.run([sys.executable, "-c", HOME_REACH, name, data_dir()], capture_output=True, text=True, timeout=30)
try:
return json.loads(p.stdout)
except ValueError:
return {"error": (p.stderr or p.stdout).strip()[-200:]}
def login_checks(ctx, name, pw):
"""The login rules against an account whose password the test knows."""
ev = ctx.evidence
base = tls_base()
st, body, hdrs = request(base, "GET", "/wiz")
w = decode(body)
ctx.log("GET %s/wiz -> %s", base, st)
ctx.check(st == 200 and isinstance(w, dict), "GET /wiz over HTTPS -> %s", st)
ctx.check(w.get("session") is False, "a session is reported without a cookie")
ev["tls_fingerprint"] = w.get("tls_fingerprint")
ctx.check(w.get("tls_fingerprint"), "GET /wiz carries no certificate fingerprint")
def attempt(n, p):
st, body, hdrs = request(base, "POST", "/login", data={"name": n, "password": p})
return st, decode(body), hdrs
# a lock left by an earlier run: wait it out first
st, body, hdrs = attempt(name, "not-" + pw)
if st == 429:
wait = int((body or {}).get("wait") or LOGIN_LOCK_S) if isinstance(body, dict) else LOGIN_LOCK_S
ctx.log("the address is locked from before (%s s): waiting", wait)
ctx.sleep(min(wait, LOGIN_LOCK_S) + 1)
st, body, hdrs = attempt(name, "not-" + pw)
codes = [st]
ctx.check(st == 401, "a wrong password -> %s, expected 401", st)
for i in range(LOGIN_FAILS + 1):
st, body, hdrs = attempt(name, "not-%s-%d" % (pw, i))
codes.append(st)
if st == 429:
break
ev["wrong_codes"] = codes
ctx.log("wrong password attempts -> %s", codes)
ctx.check(codes[-1] == 429 and all(c == 401 for c in codes[:-1]),
"the throttle did not lock after wrong attempts: %s", codes)
ctx.check(len(codes) - 1 <= LOGIN_FAILS, "the lock came after %d failures, the rule is %d",
len(codes) - 1, LOGIN_FAILS)
wait = body.get("wait") if isinstance(body, dict) else None
ev["wait"] = wait
ctx.check(isinstance(wait, int) and 0 < wait <= LOGIN_LOCK_S, "the lock reports wait=%r", wait)
st, body, hdrs = attempt(name, pw)
ev["right_inside_lock"] = st
ctx.check(st == 429, "the right password inside the lock -> %s, expected 429", st)
ctx.log("locked for %s s; the right password is refused inside the lock", wait)
ctx.sleep(wait + 1)
st, body, hdrs = attempt("not-" + name, pw)
ev["wrong_name"] = st
ctx.check(st == 401, "a wrong name with the right password -> %s, expected 401", st)
st, body, hdrs = attempt(name, pw)
cookie = hdrs.get("set-cookie") or ""
sid = session_from_cookie(cookie)
ev["login"] = st
ev["cookie_flags"] = sorted(cookie_flags(cookie))
ctx.log("POST /login (right) -> %s, cookie flags %s", st, ev["cookie_flags"])
ctx.check(st == 200, "the right name and password -> %s, expected 200", st)
ctx.check(sid and len(sid) == 64 and all(c in "0123456789abcdef" for c in sid),
"no ffsid session id in Set-Cookie: %r", cookie)
ctx.check({"httponly", "secure", "samesite"} <= cookie_flags(cookie),
"the cookie lacks HttpOnly, Secure, or SameSite: %r", cookie)
ctx.check("samesite=strict" in cookie.lower(), "the cookie is not SameSite=Strict: %r", cookie)
st, body, hdrs = request(base, "GET", "/wiz", headers={"Cookie": "ffsid=" + sid})
w = decode(body)
ctx.check(st == 200 and isinstance(w, dict) and w.get("session") is True,
"GET /wiz with the cookie does not report the session (%s %s)", st,
w.get("session") if isinstance(w, dict) else w)
st, body, hdrs = request(base, "POST", "/logout", headers={"Cookie": "ffsid=" + sid})
ev["logout"] = st
ctx.check(st == 200, "POST /logout -> %s", st)
ctx.check("max-age=0" in (hdrs.get("set-cookie") or "").lower(), "the logout did not clear the cookie")
st, body, hdrs = request(base, "GET", "/wiz", headers={"Cookie": "ffsid=" + sid})
w = decode(body)
ctx.check(isinstance(w, dict) and w.get("session") is False, "the session survives the logout")
ctx.log("session set by the login, seen by GET /wiz, ended by the logout")
+162 -13
View File
@@ -22,6 +22,7 @@ the real numbers go in.
import re
import time
from .. import baseline
from .. import hw
from ..catalog import test
from .setup_dark import run_check, Restore, FALLBACK_WINDOW, LENS_WINDOW_KEYS, dark
@@ -43,11 +44,135 @@ BURN_COVERS = [("forgectrl", "src/wizlive.*"), ("forgectrl", "src/wizrun.h"),
# Every burn asks first that the gantry stops are installed and the head
# is at its home against them; the bench's head stands there, and every
# burn's tail brings it back.
# burn's tail brings it back. head_at_home reads the head's place from
# /status before the wizard starts and judges it, within HOME_TOL_MM,
# against the stops as the manual home that references X and Y declared
# them (its home anchor): X0 Y0 for a home with no manual home offset,
# minus manual_home_x and manual_home_y for a home made with them.
HOME = "The head is home"
HOME_TOL_MM = 0.05
HOME_OFFSET_KEYS = ("manual_home_x", "manual_home_y")
EDGE_KEY = "lens_hall_edge_z_mm"
FOCUS_KEYS = ["thickness_mm", "z_passes", "ladder_z", "window", "emission", "pick", "picked_lines",
"pick_half_steps", "edge_z_mm", "steps_per_mm", "max_height_mm", "focus_range_mm", "summary"]
# pic/hv_current above this: the discharge is present (the daemon's own
# rule for a lit burn, jobstream.h JOBSTREAM_HV_ON).
HV_ON = 30
class Witnesses:
"""The two emission witnesses read from sysfs by the test itself at
every poll of the burn: the kernel's LASER_ON sample count
(cnc/laser_on_sampled) and the tube current (pic/hv_current), each
at its peak, and how many polls read both. The burn's result reports
the daemon's own reading of the same two; this one is taken past the
daemon."""
def __init__(self):
self.laser_on_max = None
self.hv_max = None
self.reads = 0
def __call__(self, _doc):
lon = hw.sysfs_int("cnc/laser_on_sampled")
hv = hw.sysfs_int("pic/hv_current")
if lon is not None:
self.laser_on_max = lon if self.laser_on_max is None else max(self.laser_on_max, lon)
if hv is not None:
self.hv_max = hv if self.hv_max is None else max(self.hv_max, hv)
if lon is not None and hv is not None:
self.reads += 1
def record(self):
return {"laser_on_max": self.laser_on_max, "hv_max": self.hv_max, "reads": self.reads}
def anchor_stops():
"""Where the manual home that references X and Y declared the gantry
stops, in /status coordinates: the X and Y of the controller's home
anchor (baseline.ANCHOR_PATH), to which /status adds the step
counters. None when the anchor does not read as a manual home of X
and Y. The settings do not place the stops: a manual home reads
manual_home_x and manual_home_y once, and a change to them after the
home leaves its reference as it is."""
try:
with open(baseline.ANCHOR_PATH, encoding="ascii", errors="replace") as f:
fields = f.read().split()
x, y, axes, source = float(fields[0]), float(fields[1]), int(fields[3]), fields[4]
except (OSError, ValueError, IndexError):
return None
if (axes & 3) != 3 or source != "manual":
return None
return x, y
def home_offset_remedy(ctx):
"""What brings the head to its home against the stops, from what the
next manual home reads: with no manual home offset, a home there;
with one, Set home here moves the head off the stops to X0 Y0, so the
head is pushed there with the motors released, and the release ends
with Energize motors."""
s = ctx.forgectrl.settings() or {}
raw = [str(s.get(key) or "").strip() for key in HOME_OFFSET_KEYS]
def no_offset(v):
try:
return v == "" or float(v) == 0
except ValueError:
return False
if no_offset(raw[0]) and no_offset(raw[1]):
return "home it there before the burn"
return ("with manual_home_x %s and manual_home_y %s, Set home here moves the head off the stops to X0 Y0; "
"release the motors, push the head against the stops, and end the release with Energize motors "
"($ME), not with Set home here, before the burn" % (raw[0] or "blank", raw[1] or "blank"))
def head_at_home(ctx):
"""The head's place on record before the wizard starts, read from
/status under the campaign's own controller: the wizard restarts the
controller before its home prompt, and a fresh controller drops the
X/Y reference, so a read at that prompt places nothing. When X and Y
are referenced by a manual home, the head is judged within
HOME_TOL_MM against the gantry stops, where the wizard draws from,
as that home declared them (anchor_stops): X0 Y0 for a home with no
offset, and minus manual_home_x and manual_home_y for a home made
with them, which moves the head to X0 Y0, that far in front of the
stops. Unreferenced, the head's place rests on the operator's word
at the ready gate, and the record says so."""
fc = ctx.forgectrl
read = {}
def served():
st = fc.status()
if not isinstance(st.get("pos"), dict):
return False
read.update(st)
return True
took = ctx.wait_for(served, 10)
ctx.check(took is not None, "/status served no head position within 10 s: the head's place cannot "
"go on record before the burn")
pos = read.get("pos") or {}
axes = read.get("homed_axes")
source = read.get("home_source")
referenced = isinstance(axes, int) and (axes & 3) == 3 and source == "manual"
rec = ctx.evidence["head_at_start"] = {"pos": pos, "homed_axes": axes, "home_source": source,
"judged": referenced}
if not referenced:
ctx.log("the head's place rests on the operator's word at the ready gate (X and Y unreferenced: "
"homed_axes %s, home_source %s, pos %s)", axes, source, pos)
return
found = anchor_stops()
ctx.check(found is not None, "the home anchor (%s) does not read as the manual home /status reports: the "
"gantry stops cannot be placed before the burn", baseline.ANCHOR_PATH)
stops = rec["stops"] = {"x": found[0], "y": found[1]}
x, y = pos.get("x"), pos.get("y")
at_home = (isinstance(x, (int, float)) and isinstance(y, (int, float))
and abs(x - stops["x"]) <= HOME_TOL_MM and abs(y - stops["y"]) <= HOME_TOL_MM)
ctx.check(at_home, "the head is not at its home against the gantry stops: /status reads pos %s with X "
"and Y referenced by a manual home that declared the stops at X%.2f Y%.2f; %s", pos, stops["x"],
stops["y"], "" if at_home else home_offset_remedy(ctx))
ctx.log("the head is home on record: pos %s, X and Y referenced by a manual home, the stops at X%.2f Y%.2f",
pos, stops["x"], stops["y"])
def preview_ok(ctx, wid):
@@ -91,17 +216,26 @@ def preview_ok(ctx, wid):
ctx.check(tail == want, "the %s program ends %s, not the tail %s", wid, tail, want)
def emission_ok(ctx, last):
"""The two witnesses of the burn, from the wizard's result. The head's
beam sensor is recorded, never required: the result reports its rise
and whether it read as the beam seen."""
def emission_ok(ctx, last, seen):
"""The two witnesses of the burn, from the wizard's result and from
the test's own reads of them at every poll (`seen`, a Witnesses).
The head's beam sensor is recorded, never required: the result
reports its rise and whether it read as the beam seen."""
e = (last.get("result") or {}).get("emission") or {}
ctx.evidence["emission"] = e
ctx.check(e.get("hv_max", 0) > 30, "the tube current never rose (%s)", e.get("hv_max"))
ctx.check(e.get("hv_max", 0) > HV_ON, "the tube current never rose (%s)", e.get("hv_max"))
ctx.check(e.get("laser_on_samples", 0) > 0, "the kernel never sampled LASER_ON")
ctx.check(isinstance(e.get("thermopile_delta"), int) and isinstance(e.get("beam_sensor_seen"), bool),
"the result does not record the beam sensor: %s", e)
ctx.log("the beam sensor rose %s (seen: %s)", e.get("thermopile_delta"), e.get("beam_sensor_seen"))
w = seen.record()
ctx.check(w["reads"] > 0, "the test could not read the witnesses from sysfs during the burn")
ctx.check((w["laser_on_max"] or 0) > 0,
"the kernel's LASER_ON count never read nonzero from here during the burn (%d reads); the "
"burn reports %s", w["reads"], e.get("laser_on_samples"))
ctx.check((w["hv_max"] or 0) > HV_ON,
"the tube current never read over %d from here during the burn (peak %s over %d reads); the "
"burn reports %s", HV_ON, w["hv_max"], w["reads"], e.get("hv_max"))
def thickness_answer(ctx):
@@ -116,7 +250,8 @@ def burn(ctx, wid, arm, pick, want):
"""One burn: the preview, then the burn after the press with every
prompt answered (the gantry stops installed, the head at home, the
scrap in the corner, its thickness in the machine's units, the arm,
and the pick in `pick`), the witnesses, and the keys of the result in
and the pick in `pick`), the witnesses (the burn's own, and the two
read from sysfs here at every poll), and the keys of the result in
`want`. run_check makes the arm press when the burn asks for it. A
prompt the test does not answer, or one asked again because its
answer was refused, ends the burn. Returns the result."""
@@ -127,6 +262,7 @@ def burn(ctx, wid, arm, pick, want):
answers.update(pick)
preview_ok(ctx, wid)
asked = set()
seen = Witnesses()
def on_prompt(p):
pid = p.get("id")
@@ -142,10 +278,16 @@ def burn(ctx, wid, arm, pick, want):
return answers[pid]
try:
last = run_check(ctx, wid, on_prompt, 900)
last = run_check(ctx, wid, on_prompt, 900, on_poll=seen)
finally:
ctx.clear_notice()
emission_ok(ctx, last)
# What the test read itself goes on record however the burn ends:
# reported dark, failed, timed out, or aborted.
w = seen.record()
ctx.evidence["emission_seen"] = w
ctx.log("seen from here: LASER_ON count peak %s, tube current peak %s over %d polls",
w["laser_on_max"], w["hv_max"], w["reads"])
emission_ok(ctx, last, seen)
r = last.get("result") or {}
missing = [k for k in want if k not in r]
ctx.check(not missing, "the result lacks %s: %s", missing, sorted(r))
@@ -223,9 +365,14 @@ def focus_window_ok(ctx, r, before):
"the button again at the arm, as the page asks.",
"Nothing else: the test answers every prompt itself and puts back the setting the "
"burn writes."],
description="The focus burn as a fresh head runs it: the edge setting is cleared first, so "
"the burn starts from the bench reference's edge, while the lens window stays "
"the one the Lens travel check wrote (or the fallback). POST "
description="The focus burn as a fresh head runs it. Before anything starts, the head's "
"place is read from /status and recorded: judged against the gantry stops when "
"a manual home references X and Y (where that home declared them: X0 Y0, or "
"minus the manual_home_x and manual_home_y it read), else left to the "
"operator's word at the ready gate. The "
"edge setting is cleared, so the burn starts from the bench reference's edge, "
"while the lens window stays the one the Lens travel check wrote (or the "
"fallback). POST "
"/wiz/laser.focus/start is answered that the gantry stops are installed, "
"references the lens, takes the controller in loopback posture, is answered that "
"the head is home and that the scrap is in the corner, and takes the thickness "
@@ -236,7 +383,8 @@ def focus_window_ok(ctx, r, before):
"inside the reach /status reports, and the program ending in that tail at the "
"edge /status reports, it burns twelve lines after the press, from the top of "
"the window to the bottom, seen by the two witnesses (the tube current and the "
"kernel's LASER_ON samples), with the beam sensor's rise recorded and not "
"kernel's LASER_ON samples) as the burn reports them and as the test reads them "
"from sysfs itself at every poll, with the beam sensor's rise recorded and not "
"required. The pick is line 11. The "
"result carries its keys; the ladder ran in the window the machine held, which "
"the burn leaves as found (it writes the edge alone, the one its result "
@@ -283,6 +431,7 @@ def focus(ctx):
ctx.log("the controller could not be restarted on the settings as found: %s", e)
ctx.ready("The focus burn: twelve lines on the scrap after a press. The bench presses when the "
"button lights.")
head_at_home(ctx)
try:
with Restore(ctx, [EDGE_KEY] + LENS_WINDOW_KEYS):
# A fresh head's focus: the edge unknown, so the burn runs from the
+13 -5
View File
@@ -26,9 +26,12 @@ PRESS_TEXT = "The button is lit white. Press it now: the laser fires after your
# check switches it back on. The bench reference draws its off current
# (74) for the first seconds and settles near 630.
PURGE_SPINUP_S = 30
# The checks' press prompts read the button (button.c) and light it
# (led.c) on the machine; the switches check reads both back.
DARK_COVERS = [("forgectrl", "src/wizdark.*"), ("forgectrl", "src/wizcalc.*"),
("forgectrl", "src/wiz.*"), ("forgectrl", "src/setup.*"),
("forgectrl", "src/main.c"), ("forgectrl", "src/ui/wizard.*")]
("forgectrl", "src/main.c"), ("forgectrl", "src/ui/wizard.*"),
("forgectrl", "src/button.*"), ("forgectrl", "src/led.*")]
# The lens window: the free half-steps below and above the hall edge the
# Lens travel check writes, and the fallback the lens frame holds while
# they are unset (lens.h).
@@ -79,11 +82,13 @@ def answer(fc, wid, prompt, value):
return fc.post("/wiz/%s/answer" % wid, data={"seq": str(prompt["seq"]), "value": value})
def run_check(ctx, wid, on_prompt, timeout_s):
def run_check(ctx, wid, on_prompt, timeout_s, on_poll=None):
"""Start `wid` and follow it to its end. on_prompt(prompt) is called
once per prompt (by sequence number) and returns the value to answer
with, or None when it handled the prompt another way (a fixture
action). Returns the final status document."""
action). on_poll(doc), when given, is called once per poll of
/wiz/dark with the status document: a caller's own witness read
beside the check's. Returns the final status document."""
fc = ctx.forgectrl
ev = ctx.evidence
start(ctx, fc, wid)
@@ -97,6 +102,8 @@ def run_check(ctx, wid, on_prompt, timeout_s):
d = dark(fc)
if d.get("id") != wid:
raise hw.HwError("another check took the slot: %s" % d.get("id"))
if on_poll is not None:
on_poll(d)
if d.get("phase") and (not phases or phases[-1] != d["phase"]):
phases.append(d["phase"])
ctx.log("%s: %s", wid, d["phase"])
@@ -125,10 +132,11 @@ def run_check(ctx, wid, on_prompt, timeout_s):
raise
ev["elapsed_s"] = round(time.time() - t0, 1)
ev["phases"] = phases[-12:]
ctx.check(last is not None, "the %s check did not end within %d s", wid, timeout_s)
if last is None:
# A check this test stops following is aborted before the test
# fails: no check runs on behind the FAIL.
fc.post("/wiz/%s/abort" % wid)
return {}
ctx.check(last is not None, "the %s check did not end within %d s", wid, timeout_s)
ev["log"] = last.get("log", [])[-12:]
ev["error"] = last.get("error")
ev["result"] = last.get("result")
View File
-1
View File
@@ -1 +0,0 @@
0
-1
View File
@@ -1 +0,0 @@
1
-1
View File
@@ -1 +0,0 @@
0
-1
View File
@@ -1 +0,0 @@
1
-1
View File
@@ -1 +0,0 @@
1
+135 -3
View File
@@ -40,6 +40,8 @@ class FakeFixture:
self.button_enabled = button_enabled
self.calls = []
self.presses = 0 # presses the device performed
self.reply_delay = 0.0 # seconds between a press and its reply
self.replied = 0 # delayed replies the device is done with (sent or lost)
srv = self
class H(BaseHTTPRequestHandler):
@@ -95,6 +97,14 @@ class FakeFixture:
threading.Timer(0.05, lambda: srv.state.__setitem__("button", "idle")).start()
s = self._state()
s["pulse_ms"] = 200
if srv.reply_delay:
time.sleep(srv.reply_delay) # the reply comes after the client gave up
try:
self._json(200, s)
except OSError:
pass
srv.replied += 1
return None
return self._json(200, s)
if self.path == "/release":
srv.state = {"lid": "closed", "interlock": "closed", "button": "idle"}
@@ -109,6 +119,11 @@ class FakeFixture:
self.httpd.shutdown()
self.httpd.server_close()
def wait_replied(self, n, timeout=5.0):
deadline = time.time() + timeout
while self.replied < n and time.time() < deadline:
time.sleep(0.02)
class ConfigTests(unittest.TestCase):
def setUp(self):
@@ -155,11 +170,11 @@ class ClientTests(unittest.TestCase):
self.resolved.append(hostname)
return "127.0.0.1"
def client(self, **over):
def client(self, timeout=3.0, resolver=None, **over):
cfg = {"hostname": "forgefixture", "key": KEY, "channels": ["lid", "interlock", "button"],
"port": self.dev.port}
cfg.update(over)
return fx.Fixture(cfg, resolver=self.resolver)
return fx.Fixture(cfg, timeout=timeout, resolver=resolver or self.resolver)
def test_status_covers_act_release(self):
f = self.client()
@@ -173,7 +188,7 @@ class ClientTests(unittest.TestCase):
f.act("button", "press")
self.assertEqual(self.dev.calls[-1], ("POST", "/button", {}))
time.sleep(0.15)
# a check that must reach the machine while the button is down asks for the longest press
# a pulse length asked for goes to the box as asked; the clamp is the box's
f.act("button", "press", ms=500)
self.assertEqual(self.dev.calls[-1], ("POST", "/button", {"ms": 500}))
time.sleep(0.15) # the fake's pulse ends
@@ -196,6 +211,35 @@ class ClientTests(unittest.TestCase):
f.act("button", "press")
self.assertIn("jumper", str(cm.exception))
def test_a_failure_after_the_request_went_out_says_so(self):
# The press made and its reply lost (it comes after the client's
# timeout): sent, one press. A refusal, a failed lookup and a
# refused connect: not sent, no press. The one retry keeps it.
self.dev.reply_delay = 0.6
with self.assertRaises(fx.FixtureError) as cm:
self.client(timeout=0.2, ip="127.0.0.1").act("button", "press")
self.assertTrue(cm.exception.sent)
self.dev.wait_replied(1)
self.assertEqual(self.dev.presses, 1)
answers = ["127.0.0.1"] # the refresh lookup after the timeout finds nothing
with self.assertRaises(fx.FixtureError) as cm:
self.client(timeout=0.2, resolver=lambda h: answers.pop(0) if answers else None).act("button", "press")
self.assertTrue(cm.exception.sent)
self.dev.wait_replied(2)
self.assertEqual(self.dev.presses, 2)
self.dev.reply_delay = 0.0
self.dev.button_enabled = False
with self.assertRaises(fx.FixtureError) as cm:
self.client(ip="127.0.0.1").act("button", "press")
self.assertFalse(cm.exception.sent)
with self.assertRaises(fx.FixtureError) as cm:
self.client(resolver=lambda h: None).act("button", "press")
self.assertFalse(cm.exception.sent)
with self.assertRaises(fx.FixtureError) as cm:
self.client(timeout=0.5, ip="127.0.0.1", port=1).act("button", "press")
self.assertFalse(cm.exception.sent)
self.assertEqual(self.dev.presses, 2)
def test_a_wrong_key_is_refused(self):
f = self.client(key="wrong")
with self.assertRaises(fx.FixtureError) as cm:
@@ -576,6 +620,94 @@ class RoutingTests(unittest.TestCase):
lit["v"] = False
time.sleep(0.3)
self.assertEqual((run.notice or {}).get("text"), "Something else.")
# The watch ends with the run: a test that ends before the press
# (a FAIL on the way to the arm, say) leaves no thread polling the
# LEDs for up to the arm's whole timeout.
def watching():
return [t for t in threading.enumerate() if t.name == "forgetest-arm-notice"]
deadline = time.time() + 2
while watching() and time.time() < deadline:
time.sleep(0.02) # the earlier watches are gone
self.assertFalse(watching())
lit["v"] = False
self.assertFalse(ctx.arm_press())
self.assertTrue(watching()) # up while the arm is still dark
run.finished = {"result": "FAIL", "message": "", "duration_s": 0}
deadline = time.time() + 2
while watching() and time.time() < deadline:
time.sleep(0.02)
self.assertFalse(watching())
self.assertIsNotNone(run.notice) # the finished run's notice is left as it was
# A press the fixture could not make is asked of the operator, and
# that notice comes down at the press like any other: press_now and
# arm_press with the box refusing, and arm_press with the button
# never lit for the box.
self.stub.arm_press = True
self.stub.fail = True
run = Run("test", "r.live", "r.live")
ctx = Context(run, self.runner, self.reg["r.live"])
def settles(up):
deadline = time.time() + 2
while (run.notice is not None) != up and time.time() < deadline:
time.sleep(0.02)
return (run.notice is not None) == up
lit["v"] = True # the machine waits for the press
self.assertFalse(ctx.press_now()) # the box is off: the operator is asked
self.assertIsNotNone(run.notice)
time.sleep(0.2) # lit: the arm waits for the press
lit["v"] = False # the operator's press is taken
self.assertTrue(settles(False))
lit["v"] = True
self.assertTrue(ctx.arm_press()) # the fixture is asked, and refuses
self.assertTrue(settles(True))
time.sleep(0.2)
lit["v"] = False
self.assertTrue(settles(False))
self.assertTrue(ctx.arm_press(lit_timeout=0)) # the button never lit for the fixture
self.assertTrue(settles(True))
lit["v"] = True
time.sleep(0.2)
lit["v"] = False
self.assertTrue(settles(False))
# A press the box made and reported failed (its reply lost after
# the press): the button is out when the error arrives, so the
# press was taken, and no notice asks for a second one.
def pressed_then_failed(channel, state, ms=None):
lit["v"] = False # the controller takes the press
raise fx.FixtureError("timed out", sent=True)
self.stub.act = pressed_then_failed
lit["v"] = True
self.assertTrue(ctx.press_now())
self.assertIsNone(run.notice)
self.assertEqual(ctx.evidence["actions"][-1]["by"], "fixture")
lit["v"] = True
self.assertTrue(ctx.arm_press())
deadline = time.time() + 2
while ctx.evidence["actions"][-1]["by"] != "fixture" and time.time() < deadline:
time.sleep(0.02)
time.sleep(0.1)
self.assertEqual(ctx.evidence["actions"][-1]["by"], "fixture")
self.assertIsNone(run.notice)
# A refusal (or a request that never reached the box) while the
# button went out: a press at the machine was taken, so no notice
# goes up either, and the record does not give it to the box.
def refused_while_pressed(channel, state, ms=None):
lit["v"] = False # a person's press is taken
raise fx.FixtureError("button press -> 409 button disabled: the enable jumper is out")
self.stub.act = refused_while_pressed
lit["v"] = True
self.assertFalse(ctx.press_now())
self.assertIsNone(run.notice)
self.assertEqual(ctx.evidence["actions"][-1]["by"], "operator")
lit["v"] = True
self.assertTrue(ctx.arm_press())
deadline = time.time() + 2
while "fixture_error" not in ctx.evidence["actions"][-1] and time.time() < deadline:
time.sleep(0.02)
time.sleep(0.1)
self.assertEqual(ctx.evidence["actions"][-1]["by"], "operator")
self.assertIsNone(run.notice)
finally:
runner_mod.hw.button_lit = saved
+135
View File
@@ -3,6 +3,7 @@
# https://community.openglow.org
# SPDX-License-Identifier: MIT
import json
import os
import shutil
import subprocess
@@ -120,6 +121,89 @@ class MachineNameTests(unittest.TestCase):
self.assertFalse(heading.startswith("#") or ".local" in issue, issue)
# forgefirm-users rendering a record, as the image runs it, in a private
# mount namespace as root: /run a fresh tmpfs, /etc read-only with the
# scratch tree's account files over the host's, and the scratch data
# directory over /data (made for the run where the host has none, as
# fflog_e2e.sh does, and removed after). The data directory is root's, as
# on the machine, whoever made the scratch tree. Then, as the account's
# own uid, what it can do with its home and with the data directory
# around it. Exit 97: the namespace could not be set up.
ACCOUNTS = r"""
T=$1; SCRIPT=$2
made=
[ -d /data ] || { mkdir /data && made=1; } || exit 97
chown -R 0:0 "$T/data" &&
mount --make-rprivate / && mount -t tmpfs tmpfs /run &&
mount -t overlay overlay -o "lowerdir=$T/etc:/etc" /etc &&
mount --bind "$T/data" /data || { [ -z "$made" ] || rmdir /data; exit 97; }
sh "$SCRIPT" reload
echo "rc=$?"
echo "mode=$(stat -c %a /data/forgefirm)"
python3 - <<'PY'
import errno, json, os, pwd
p = pwd.getpwnam("bench")
os.setgroups([])
os.setgid(p.pw_gid)
os.setuid(p.pw_uid)
def tried(fn):
try:
fn()
return "ok"
except OSError as e:
return errno.errorcode.get(e.errno, str(e.errno))
def write():
path = os.path.join(p.pw_dir, ".reach")
with open(path, "w") as f:
f.write("x")
os.remove(path)
print("reach=" + json.dumps({"uid": os.getuid(), "home": p.pw_dir, "cd": tried(lambda: os.chdir(p.pw_dir)),
"write": tried(write), "list_data_dir": tried(lambda: os.listdir("/data/forgefirm")),
"read_record": tried(lambda: open("/data/forgefirm/users").read())}))
PY
cd /
umount /data
[ -z "$made" ] || rmdir /data
rm -rf "$T/data"
"""
@unittest.skipUnless(os.path.isdir(LAYER) and shutil.which("unshare"), "needs Linux and a forgefirm checkout")
class AccountRenderTests(unittest.TestCase):
"""forgefirm-users, the layer's own init script, rendering the record."""
def test_the_render_opens_the_data_directory_to_its_account_and_no_further(self):
tree = tempfile.mkdtemp(prefix="forgetest-users.")
self.addCleanup(shutil.rmtree, tree, ignore_errors=True)
os.makedirs(os.path.join(tree, "etc"))
for name, text in (("passwd", "root:x:0:0:root:/root:/bin/sh\n"), ("shadow", "root::19000:0:99999:7:::\n"),
("group", "root:x:0:\n"), ("gshadow", "root:*::\n")):
with open(os.path.join(tree, "etc", name), "w") as f:
f.write(text)
data = os.path.join(tree, "data", "forgefirm")
os.makedirs(data)
os.chmod(data, 0o700) # as a strict umask leaves it
with open(os.path.join(data, "users"), "w") as f:
f.write("bench:$6$salt$hash:1000\n")
os.chmod(os.path.join(data, "users"), 0o600)
try:
p = subprocess.run(["sudo", "-n", "unshare", "-m", "sh", "-c", ACCOUNTS, "sh", tree,
os.path.join(LAYER, "forgefirm-users", "files", "forgefirm-users.init")],
capture_output=True, text=True, timeout=60)
except (OSError, subprocess.TimeoutExpired):
p = None
if p is None or "rc=" not in p.stdout:
if p is not None and p.returncode == 97:
raise AssertionError("the namespace was made but not set up: %s" % p.stderr.strip())
self.skipTest("no namespace can be made here (root through sudo -n)")
self.assertEqual(field(p.stdout, "rc"), "0", p.stdout + p.stderr)
self.assertEqual(field(p.stdout, "mode"), "711", p.stdout)
reach = json.loads(field(p.stdout, "reach"))
self.assertEqual((reach["uid"], reach["home"]), (1000, "/data/forgefirm/home/bench"), reach)
self.assertEqual((reach["cd"], reach["write"]), ("ok", "ok"), reach)
self.assertEqual((reach["list_data_dir"], reach["read_record"]), ("EACCES", "EACCES"), reach)
class NetworkBootTests(unittest.TestCase):
STANZA = ("auto wlan0\n"
"iface wlan0 inet dhcp\n"
@@ -232,6 +316,22 @@ class PackLicensesTests(unittest.TestCase):
self.assertIn("common-licenses/license.manifest", t.getnames())
self.assertFalse(os.path.exists(self.tree))
def test_a_license_text_tar_cannot_read_is_refused_and_the_tree_left(self):
"""A member tar cannot read fails the pack, bundle removed and tree
left: tar skips that member, and the manifest check alone passes
the bundle without it."""
if os.geteuid() == 0:
self.skipTest("root reads every file")
self.make_tree(self.RECIPES)
d = os.path.join(self.tree, "zlib")
os.makedirs(d)
locked = os.path.join(d, "generic_Zlib")
with open(locked, "w") as f:
f.write("zlib license\n")
os.chmod(locked, 0) # rmtree still unlinks it
self.assertRefused(self.pack())
self.assertTrue(os.path.isfile(self.manifest))
class RootfsLintTests(unittest.TestCase):
"""rootfs-lint.sh, the image's last rootfs step, on a fixture tree: a
@@ -255,6 +355,14 @@ class RootfsLintTests(unittest.TestCase):
self.put("etc/ssh/sshd_config_readonly", self.SSHD)
self.put("etc/network/interfaces", "auto lo\niface lo inet loopback\n" + NetworkBootTests.STANZA)
self.put("etc/nsswitch.conf", "hosts: files dns\n")
for name in ("forgectrl", "forgeext", "grblhal", "forgefirm-logging"):
self.put("etc/init.d/" + name, "#!/bin/sh\n", mode=0o755)
os.makedirs(self.path("etc/rc6.d"))
for link in ("rc5.d/S90forgectrl", "rc5.d/S91forgeext", "rc6.d/K09forgeext", "rc6.d/K80grblhal",
"rc6.d/K90forgectrl"):
os.symlink("../init.d/" + link.split("/")[1][3:], self.path("etc/" + link))
self.put("etc/default/rcS", "VERBOSE=no\nROOTFS_READ_ONLY=yes\n")
self.put("etc/fstab", "/dev/root / auto ro 1 0\n/dev/mmcblk2p3 /data auto defaults 0 0\n")
def path(self, rel):
return os.path.join(self.root, rel)
@@ -313,6 +421,33 @@ class RootfsLintTests(unittest.TestCase):
os.makedirs(self.path("etc/avahi"))
self.assertFails("mdns")
def test_the_extension_host_starting_before_forgectrl_fails(self):
os.rename(self.path("etc/rc5.d/S91forgeext"), self.path("etc/rc5.d/S89forgeext"))
self.assertFails("init-order")
def test_no_logging_init_script_fails(self):
os.remove(self.path("etc/init.d/forgefirm-logging"))
self.assertFails("logging")
def test_a_userspace_watchdog_init_script_fails(self):
self.put("etc/init.d/watchdog", "#!/bin/sh\n", mode=0o755)
self.assertFails("watchdog")
def test_rcs_without_the_read_only_rootfs_fails(self):
self.put("etc/default/rcS", "VERBOSE=no\n")
self.assertFails("read-only")
def test_a_dhcpv6_client_on_the_image_fails(self):
os.makedirs(self.path("sbin"))
os.symlink("/bin/busybox.nosuid", self.path("sbin/udhcpc6"))
self.assertFails("dhcpv6")
def test_a_factory_slot_in_fstab_fails_a_release_image_only(self):
self.put("etc/fstab", "/dev/root / auto ro 1 0\n/dev/mmcblk2p1 /factory/img1 auto ro,noatime 0 0\n")
self.assertFails("factory", "release")
p = self.lint("dev")
self.assertEqual(p.returncode, 0, p.stderr)
if __name__ == "__main__":
unittest.main()
+159 -1
View File
@@ -7,7 +7,13 @@
the button latch judged from the first emission through every sample
whose readback word shows the laser latch unlocked, both bits from that
word, so the relocked tail (where the relock itself sets the latch and
the emission counter still reads nonzero) is never judged."""
the emission counter still reads nonzero) is never judged. And its
read-gap verdict over the dark stretch after the M5: a sample whose
/status carried no laser block is a gap, never a dark sample, a tail the
daemon did not serve before the sampling stopped is a gap too, and a
stretch with a kernel window unread fails the rule; the sampling runs on
past the close; and the head's reports place the rapid inside the armed
window only."""
import unittest
from forgetest.suite import laser
@@ -47,5 +53,157 @@ class DwellGapTests(unittest.TestCase):
self.assertFalse(g["hv_enable_dipped"])
def rapid_trail(n, dt=0.2, holes=()):
"""A dark stretch of n samples dt seconds apart, the emission count
and the HV current read on every sample but the ones in `holes`: a
/status served without its laser block, which sample() records as
None."""
out = []
for i in range(n):
s = {"t": 100.0 + i * dt, "emission": 0, "hv": 0, "armed": True}
if i in holes:
s["emission"] = None
s["hv"] = None
out.append(s)
return out
def covered(r):
"""The rule emission_witness applies to read_gaps' report."""
return (r["read"] >= laser.RAPID_MIN_READS and r["gap_max_s"] is not None
and r["gap_max_s"] <= laser.EMISSION_READ_GAP_S)
class RapidReadTests(unittest.TestCase):
def test_every_sample_read_is_inside_the_rule(self):
r = laser.read_gaps(rapid_trail(25), "emission")
self.assertEqual((r["read"], r["unread"]), (25, 0))
self.assertAlmostEqual(r["gap_max_s"], 0.2)
self.assertTrue(covered(r))
def test_one_transient_miss_is_inside_the_rule(self):
r = laser.read_gaps(rapid_trail(25, holes=(12,)), "emission")
self.assertEqual((r["read"], r["unread"]), (24, 1))
self.assertAlmostEqual(r["gap_max_s"], 0.4)
self.assertTrue(covered(r))
def test_a_hole_shorter_than_a_window_is_inside_the_rule(self):
r = laser.read_gaps(rapid_trail(25, holes=(10, 11, 12)), "emission")
self.assertAlmostEqual(r["gap_max_s"], 0.8)
self.assertTrue(covered(r))
def test_a_hole_longer_than_a_window_fails(self):
r = laser.read_gaps(rapid_trail(25, holes=(10, 11, 12, 13, 14)), "emission")
self.assertAlmostEqual(r["gap_max_s"], 1.2)
self.assertFalse(covered(r))
def test_the_block_gone_after_the_dark_point_fails(self):
r = laser.read_gaps(rapid_trail(25, holes=tuple(range(1, 25))), "emission")
self.assertEqual((r["read"], r["unread"]), (1, 24))
self.assertAlmostEqual(r["gap_max_s"], 4.8)
self.assertFalse(covered(r))
def test_the_block_gone_for_the_last_window_and_a_half_fails(self):
# the tail is an edge: the window that latches after the close
# goes unread
r = laser.read_gaps(rapid_trail(25, holes=tuple(range(17, 25))), "emission")
self.assertAlmostEqual(r["gap_max_s"], 1.6)
self.assertFalse(covered(r))
def test_a_tail_the_daemon_never_served_fails(self):
# every sample served was read, and the served samples end at the
# close: the sampling ran on past it with nothing served, so the
# stretch from the last reading to where the sampling stopped is a gap
trail = rapid_trail(25)
tail = 2 * laser.EMISSION_WINDOW_S + laser.EMISSION_READ_GAP_S
r = laser.read_gaps(trail, "emission", trail[-1]["t"] + tail)
self.assertAlmostEqual(r["gap_max_s"], tail)
self.assertFalse(covered(r))
# served through: the sampling stops one loop after the last sample
self.assertTrue(covered(laser.read_gaps(trail, "emission", trail[-1]["t"] + 0.125)))
def test_a_stretch_of_one_sample_fails(self):
r = laser.read_gaps(rapid_trail(1), "emission")
self.assertEqual(r, {"read": 1, "unread": 0, "gap_max_s": 0.0})
self.assertFalse(covered(r))
class Clock:
"""time.time and time.sleep on a clock the test moves."""
def __init__(self, now=1000.0):
self.now = now
def time(self):
return self.now
def sleep(self, s):
self.now += s
class RapidTailTests(unittest.TestCase):
def test_the_sampling_runs_on_past_the_close_and_ends_where_it_stopped(self):
# The window closes 3 s in, and the daemon serves the closed
# sample and nothing after it: the sampling still runs two kernel
# windows and the read gap allowed past the close, and the time it
# stopped is the trail's end, so the tail it never served fails the
# read rule.
clock = Clock()
close = clock.now + 3.0
served = []
def sample(_ctx):
clock.now += 0.06 # the two GETs
if served and not served[-1]["armed"]:
return None # nothing served after the close
s = {"t": clock.now, "armed": clock.now < close, "emission": 0, "hv": 0}
served.append(s)
return s
class Ctx:
def checkpoint(self):
pass
class Grbl:
def status_report(self):
# the time of the read, as the head's X
return {"state": "Idle", "MPos": (clock.now, 0.0, 0.0)}
saved = laser.time, laser.sample
self.addCleanup(setattr, laser, "time", saved[0])
self.addCleanup(setattr, laser, "sample", saved[1])
laser.time, laser.sample = clock, sample
tail, t_end = laser.sample_to_disarm(Ctx(), Grbl())
closed = tail[-1]
self.assertFalse(closed["armed"])
self.assertTrue(all(s["armed"] for s in tail[:-1]))
self.assertGreaterEqual(t_end - closed["t"], 2 * laser.EMISSION_WINDOW_S + laser.EMISSION_READ_GAP_S)
self.assertFalse(covered(laser.read_gaps(tail, "emission", t_end)))
# each sample's X is read before its armed flag: an X that places
# the head was read with the window open
self.assertTrue(all(s["x"] < s["t"] for s in tail))
def place_trail(xs, closes_at):
"""A dark stretch with the head at machine X `xs` (one sample each,
0.2 s apart), the window closing at sample `closes_at`."""
return [{"t": 100.0 + i * 0.2, "x": float(x), "armed": i < closes_at} for i, x in enumerate(xs)]
class RapidPlaceTests(unittest.TestCase):
# out to +20 mm, the dwell there, back at the start, the dwell before the M2
XS = [0, 5, 20, 20, 20, 10, 0, 0, 0, 0]
def test_a_window_closed_before_the_head_is_back_does_not_place_the_rapid(self):
# the window closes after the head is back: out at 2, back at 6, closed at 8
self.assertEqual(laser.rapid_place(place_trail(self.XS, 8), 0.0), (2, 6, 8))
# the window closes in the dwell at +20 mm and the head comes back
# after the close: the samples past the close do not place it
self.assertEqual(laser.rapid_place(place_trail(self.XS, 4), 0.0), (2, None, 4))
# the window reads closed at the first sample with the head back:
# that sample does not tell whether the head was back before the
# close, so it does not place it
self.assertEqual(laser.rapid_place(place_trail(self.XS, 6), 0.0), (2, None, 6))
if __name__ == "__main__":
unittest.main()
+341 -7
View File
@@ -197,9 +197,10 @@ class CoverageReportTests(unittest.TestCase):
f.write(text)
return root
# A workflow of this repository as the scratch checkouts carry it: it
# A workflow of this repository as the scratch checkouts carry it: on
# every push, and on the pull requests that touch one bench tool, it
# checks itself out at the workspace root and runs the one harness.
CI_YML = ("on:\n push:\n paths:\n - 'scripts/bench/drill.py'\n"
CI_YML = ("on:\n push:\n pull_request:\n paths:\n - 'scripts/bench/drill.py'\n"
"jobs:\n test:\n steps:\n"
" - uses: actions/checkout@v4\n"
" # drill.py is a bench tool\n"
@@ -242,10 +243,36 @@ class CoverageReportTests(unittest.TestCase):
self.assertEqual(m.host_row_problems(man, [runs], root=root, sources=None), [])
self.assertEqual(m.host_row_problems(man, [idle], root=root, sources=None),
[(idle[0], idle[1], idle[2], ci + " does not run it")])
# a workflow whose own on: names neither push nor pull_request is listed, and a step whose
# failure the run tolerates does not run it
job = "jobs:\n test:\n steps:\n - uses: actions/checkout@v4\n - run: python3 scripts/bench/stream_test.py\n"
root = self._root({"scripts/bench/stream_test.py": "",
".github/workflows/manual.yml": "on:\n workflow_dispatch:\n" + job,
".github/workflows/soft.yml": "on: [push]\n" + job.replace(
" - run:", " - continue-on-error: true\n run:")})
manual = runs[:3] + ("forgefirm:.github/workflows/manual.yml",)
soft = runs[:3] + ("forgefirm:.github/workflows/soft.yml",)
self.assertEqual(m.host_row_problems(man, [manual, soft], root=root, sources=None),
[manual[:3] + (manual[3] + ": the lint reads no push or pull_request in its own on:",),
soft[:3] + (soft[3] + " does not run it",)])
# a step in a job that needs a conditional job does not run it (the checkout in a job of
# its own, as the lint reads a checkout for the whole workflow)
root = self._root({"scripts/bench/stream_test.py": "",
".github/workflows/needs.yml": "on: [push]\njobs:\n lint:\n steps:\n"
" - uses: actions/checkout@v4\n build:\n if: false\n steps:\n"
" - run: make\n test:\n needs: build\n steps:\n"
" - run: python3 scripts/bench/stream_test.py\n"})
needs = runs[:3] + ("forgefirm:.github/workflows/needs.yml",)
self.assertEqual(m.host_row_problems(man, [needs], root=root, sources=None),
[needs[:3] + (needs[3] + " does not run it",)])
def _runs(self, yml, path):
return m.workflow_runs(m.workflow_steps(yml), path)
def _block(self, *lines, keys=""):
"""A one-step workflow whose run text is these lines, the step's other keys before it."""
return "steps:\n - name: x\n%s run: |\n%s" % (keys, "".join(" %s\n" % ln for ln in lines))
def test_what_running_a_host_test_looks_like(self):
step = "steps:\n - name: x\n run: %s\n"
self.assertTrue(self._runs(step % "./build/tray_test", "tests/tray_test.c"))
@@ -254,9 +281,11 @@ class CoverageReportTests(unittest.TestCase):
self.assertTrue(self._runs(step % "python3 forgefirm/scripts/bench/ctlport_test.py build/x",
"forgefirm/scripts/bench/ctlport_test.py"))
self.assertTrue(self._runs(step % "python -m pytest -q tests", "tests/test_frame.py"))
# a block, with the step's working-directory: the words are its paths
# a block, with the step's working-directory: the words are its paths (the loop as
# python3-gfhardware's CI writes it, an expansion in an assignment)
block = ("steps:\n - name: every module\n working-directory: %s\n run: |\n"
" set -e\n for t in tests/test_*.py; do\n python -m unittest \"tests.$t\"\n"
" set -e\n for t in tests/test_*.py; do\n m=$(basename \"$t\" .py)\n"
" python -m unittest -v \"tests.$m\"\n"
" done\n - working-directory: forgefirm/forgetest\n"
" run: python -B -m unittest discover -s tests -v\n")
self.assertTrue(self._runs(block % "gfh", "gfh/tests/test_feeder.py"))
@@ -267,8 +296,8 @@ class CoverageReportTests(unittest.TestCase):
self.assertFalse(self._runs("on:\n push:\n paths:\n - 'kas/source-bundle.yml'\n"
+ step % "python3 x.py", "kas/source-bundle.yml"))
self.assertFalse(self._runs(step % "./build/lens_test # tray_test is manual", "tests/tray_test.c"))
self.assertFalse(self._runs(step % "for t in tests/test_*.py; do python $t; done",
"tools/tests/test_feeder.py"))
self.assertFalse(self._runs("steps:\n - run: |\n for t in tests/test_*.py; do\n python $t\n"
" done\n", "tools/tests/test_feeder.py"))
self.assertFalse(self._runs(step % "python -m unittest discover -s tests",
"scripts/bench/tests/foo_test.py"))
# a longer name, another file, a step's name, a comment line
@@ -281,6 +310,7 @@ class CoverageReportTests(unittest.TestCase):
# discover or pytest runs its own directory's matching files only
self.assertFalse(self._runs(step % "echo tray_test", "tests/tray_test.c"))
self.assertFalse(self._runs(step % "echo ./build/tray_test", "tests/tray_test.c"))
self.assertFalse(self._runs(step % "command -v ./build/tray_test", "tests/tray_test.c")) # prints it
self.assertFalse(self._runs(step % "cmake --build build --target tray_test", "tests/tray_test.c"))
self.assertFalse(self._runs(step % "cat tests/run_test.py", "tests/run_test.py"))
self.assertFalse(self._runs(step % "python -m unittest discover -s forgefirm",
@@ -305,6 +335,191 @@ class CoverageReportTests(unittest.TestCase):
self.assertFalse(self._runs(step % "python -m unittest -k test_feeder tests.test_lens",
"tests/test_feeder.py"))
def test_a_step_counts_only_when_every_run_of_the_workflow_runs_it_and_its_failure_fails_the_run(self):
step = "steps:\n - name: x\n%s run: ./build/tray_test\n"
test = "tests/tray_test.c"
# the lint evaluates no condition: an if: other than the literal true counts as no step,
# always() included; literally true is no condition
self.assertFalse(self._runs(step % " if: false\n", test))
self.assertFalse(self._runs(step % " if: github.event_name == 'push'\n", test))
self.assertFalse(self._runs(step % " if: ${{ always() }}\n", test))
self.assertTrue(self._runs(step % " if: true\n", test))
self.assertTrue(self._runs(step % " if: 'true'\n", test))
# however the step is spelled: more than one space after its dash, or a quoted key
self.assertFalse(self._runs("steps:\n - run: ./build/tray_test\n if: false\n", test))
self.assertFalse(self._runs(step % " \"if\": false\n", test))
# a key with one stray quote is no key the lint reads: at a step's key (its first one on
# its dash line too), a job's key or the top level, no step of the workflow counts
self.assertFalse(self._runs(step % " if\": false\n", test))
self.assertFalse(self._runs("steps:\n - name: x\n run\": ./build/tray_test\n", test))
self.assertFalse(self._runs("steps:\n - run\": make\n - run: ./build/tray_test\n", test))
self.assertFalse(self._runs("jobs:\n t:\n if\": false\n steps:\n - run: ./build/tray_test\n",
test))
self.assertFalse(self._runs("jobs\":\n t:\n if: false\n steps:\n - run: ./build/tray_test\n",
test))
# a step whose failure the run tolerates proves nothing; literally false is no tolerance
self.assertFalse(self._runs(step % " continue-on-error: true\n", test))
self.assertFalse(self._runs(step % " continue-on-error: ${{ inputs.soft }}\n", test))
self.assertTrue(self._runs(step % " continue-on-error: false\n", test))
# the job's own if: or continue-on-error: holds for each of its steps, wherever it is
# written, over a step's own, and not for another job's steps
job = "jobs:\n t:\n%s steps:\n - run: ./build/tray_test\n%s"
self.assertFalse(self._runs(job % (" if: github.ref == 'refs/heads/main'\n", ""), test))
self.assertFalse(self._runs(job % ("", " if: false\n"), test))
self.assertFalse(self._runs(job % (" continue-on-error: true\n", ""), test))
self.assertFalse(self._runs("jobs:\n t:\n continue-on-error: true\n steps:\n"
" - continue-on-error: false\n run: ./build/tray_test\n", test))
self.assertTrue(self._runs(job % (" if: true\n runs-on: ubuntu-latest\n", ""), test))
self.assertTrue(self._runs(job % (" permissions:\n contents: read\n", ""), test))
two = "jobs:\n a:\n if: false\n steps:\n - run: %s\n b:\n steps:\n - run: %s\n"
self.assertTrue(self._runs(two % ("make", "./build/tray_test"), test))
self.assertFalse(self._runs(two % ("./build/tray_test", "make"), test))
# the run text is read as bash -e or sh -e runs it: the test counts on a line that holds it
# alone, or in a pipeline under pipefail; any other operator on the line, or a pipe with no
# pipefail, keeps it from counting
for line in ("./build/tray_test || true", "true || ./build/tray_test", "./build/tray_test; true",
"./build/tray_test &", "./build/tray_test | tee log"):
self.assertFalse(self._runs(self._block(line), test), line)
self.assertTrue(self._runs(self._block("set -o pipefail", "./build/tray_test | tee log"), test))
self.assertTrue(self._runs(self._block("./build/tray_test | tee log", keys=" shell: bash\n"), test))
# no line counts after one that ends the step, turns errexit off, sets an option the lint
# does not follow (noexec), or opens a here-document or a compound command
for before in ("exit 0", "set +e", "set -n", "python3 - <<'EOF'"):
self.assertFalse(self._runs(self._block(before, "./build/tray_test"), test), before)
self.assertFalse(self._runs(self._block("{", "./build/tray_test", "} || true"), test))
# a loop counts only with a body whose lines count
for body in ("python $t || true", "python $t | tee log"):
self.assertFalse(self._runs(self._block("for t in tests/test_*.py; do", body, "done"), "tests/test_x.py"),
body)
# a folded block, and a scalar continued under its key, are one line to the shell
self.assertFalse(self._runs("steps:\n - run: >\n ./build/tray_test\n || true\n", test))
self.assertFalse(self._runs("steps:\n - run: ./build/tray_test\n || true\n", test))
# the lint reads the text of bash and sh alone: the step's shell, or its job's or its
# workflow's default
self.assertFalse(self._runs(self._block("./build/tray_test", keys=" shell: bash {0}\n"), test))
self.assertTrue(self._runs(self._block("./build/tray_test", keys=" shell: sh\n"), test))
self.assertFalse(self._runs("jobs:\n t:\n defaults:\n run:\n shell: pwsh\n steps:\n"
" - run: ./build/tray_test\n", test))
self.assertFalse(self._runs("defaults:\n run:\n shell: pwsh\njobs:\n t:\n steps:\n"
" - run: ./build/tray_test\n", test))
# a checkout under a condition is no checkout
yml = "steps:\n - uses: actions/checkout@v4\n%s with:\n path: own\n"
self.assertEqual(m.checkout_path(m.workflow_steps(yml % ""), "x", True), "own")
self.assertIsNone(m.checkout_path(m.workflow_steps(yml % " if: false\n"), "x", True))
def test_a_job_runs_only_when_every_job_it_needs_runs(self):
test = "tests/tray_test.c"
# GitHub skips a job whose needed job did not run: a job that needs a conditional job runs
# on not every run, however the needs: is written
yml = "jobs:\n build:\n%s steps:\n - run: make\n test:\n%s steps:\n - run: ./build/tray_test\n"
for needs in (" needs: build\n", " needs: [build]\n", " needs: ['build']\n",
" needs:\n - build\n", " needs:\n - build\n",
" needs: [\n build\n ]\n"):
self.assertTrue(self._runs(yml % ("", needs), test), needs)
self.assertFalse(self._runs(yml % (" if: false\n", needs), test), needs)
self.assertFalse(self._runs(yml % (" if: github.event_name == 'push'\n", needs), test), needs)
# the needed job's continue-on-error: does not bear: its failure fails neither the run nor
# the jobs after it
self.assertTrue(self._runs(yml % (" continue-on-error: true\n", " needs: build\n"), test))
# transitively; a job the workflow does not have, a cycle, and a needs: the lint does not
# read each fail closed
chain = "jobs:\n a:\n%s steps:\n - run: make\n b:\n needs: a\n steps:\n - run: make\n" \
" c:\n needs: [b]\n steps:\n - run: ./build/tray_test\n"
self.assertTrue(self._runs(chain % "", test))
self.assertFalse(self._runs(chain % " if: false\n", test))
self.assertFalse(self._runs(yml % ("", " needs: lint\n"), test))
self.assertFalse(self._runs(yml % (" needs: test\n", " needs: build\n"), test))
self.assertFalse(self._runs(yml % ("", " needs: test\n"), test))
self.assertFalse(self._runs(yml % ("", " needs:\n build: true\n"), test))
# a flow list split after a comma keeps the name on its second line: a conditional job
# there holds the dependent job back
split = (chain % "").replace(" needs: [b]\n", " needs: [a,\n b]\n")
self.assertTrue(self._runs(split, test))
self.assertFalse(self._runs(split.replace(" b:\n needs: a\n", " b:\n needs: a\n if: false\n"), test))
# an empty needs: (which GitHub refuses), a flow list a key interrupts before its ], and
# list items at two indentations each fail closed
for needs in (" needs: []\n", " needs:\n", " needs: [\n build\n",
" needs:\n - build\n - build\n"):
self.assertFalse(self._runs(yml % ("", needs), test), needs)
# through a row: the test job needs, in a block list at the key's own indentation, a
# build job under if: false (the checkout in a job of its own, as the lint reads a
# checkout for the whole workflow)
man = helpers.make_manifest()
root = self._root({"scripts/bench/stream_test.py": "",
".github/workflows/needs.yml": "on: [push]\njobs:\n lint:\n steps:\n"
" - uses: actions/checkout@v4\n build:\n if: false\n steps:\n"
" - run: make\n test:\n needs:\n - build\n steps:\n"
" - run: python3 scripts/bench/stream_test.py\n"})
row = ("grblhal-glowforge", "src/driver.c", "forgefirm:scripts/bench/stream_test.py",
"forgefirm:.github/workflows/needs.yml")
self.assertEqual(m.host_row_problems(man, [row], root=root, sources=None),
[row[:3] + (row[3] + " does not run it",)])
# a job under always() runs on every run, and the lint evaluates no condition: it counts as
# no job for its own steps and for the jobs after it
self.assertFalse(self._runs(yml % (" if: false\n", " needs: build\n if: always()\n"), test))
self.assertFalse(self._runs((chain % " if: false\n").replace(
" b:\n needs: a\n", " b:\n needs: a\n if: ${{ always() }}\n"), test))
def test_the_events_a_workflow_runs_on(self):
self.assertEqual(m.workflow_triggers("on:\n push:\n branches: [main]\n pull_request:\njobs:\n t:\n"),
{"push", "pull_request"})
self.assertEqual(m.workflow_triggers("on: [push, pull_request]\n"), {"push", "pull_request"})
self.assertEqual(m.workflow_triggers("on: push\n"), {"push"})
self.assertEqual(m.workflow_triggers("on:\n - push\n - workflow_dispatch\n"), {"push", "workflow_dispatch"})
self.assertEqual(m.workflow_triggers("\"on\":\n workflow_dispatch:\n inputs:\n push:\n"
" type: string\n"), {"workflow_dispatch"})
self.assertEqual(m.workflow_triggers("on:\n workflow_call:\n"), {"workflow_call"})
self.assertEqual(m.workflow_triggers("name: x\njobs:\n t:\n steps: []\n"), set())
# an event key with one stray quote is no event, and the workflow then names none
self.assertEqual(m.workflow_triggers("on:\n push\":\n"), set())
self.assertEqual(m.workflow_triggers("on:\n push:\n 'pull_request:\n"), set())
def test_a_push_or_pull_request_under_a_filter_runs_on_not_every_change(self):
f = m.workflow_filters
self.assertEqual(f("on:\n push:\n branches: [main]\n pull_request:\njobs:\n t:\n"), {"push": ["branches"]})
self.assertEqual(f("on:\n push:\n branches-ignore:\n - wip/*\n paths-ignore:\n - 'docs/**'\n"
" pull_request:\n paths:\n - 'src/**'\n"),
{"push": ["branches-ignore", "paths-ignore"], "pull_request": ["paths"]})
self.assertEqual(f("on:\n push:\n tags: ['v*']\n"), {"push": ["tags"]})
self.assertEqual(f("on:\n push:\n tags-ignore: ['v*']\n"), {"push": ["tags-ignore"]})
# a pull_request under types: runs on the activity types it names and no other; the lint
# reads no type, so any types: is a filter, on the pull_request alone
self.assertEqual(f("on:\n push:\n pull_request:\n types: [closed]\n"), {"pull_request": ["types"]})
self.assertEqual(f("on:\n pull_request:\n types:\n - opened\n - synchronize\n paths-ignore:\n"
" - '**.md'\n"), {"pull_request": ["types", "paths-ignore"]})
self.assertEqual(f("on:\n push:\n types: [x]\n"), {})
# a value on the event's line, and a key with one stray quote under it, are filters the
# lint does not read
self.assertEqual(f("on:\n push: {branches: [main]}\n"), {"push": ["a value the lint does not read"]})
self.assertEqual(f("on:\n push:\n paths\":\n - x\n"), {"push": ["a key the lint does not read"]})
# a filter is read at the event's own level: a dispatch's inputs are none, and an on: list
# or scalar carries none
self.assertEqual(f("on:\n workflow_dispatch:\n inputs:\n paths:\n type: string\n push:\n"),
{})
self.assertEqual(f("on: [push, pull_request]\n"), {})
self.assertEqual(f("on: push\n"), {})
self.assertEqual(f("on:\n - push\n"), {})
# a row's workflow counts when its push or its pull_request carries no filter: a push to
# the default branch alone with every pull request does, a push with paths-ignore alone
# does not, nor do a push and a pull request each under a filter; every push with a pull
# request under types: does, a pull request under types: alone does not
man = helpers.make_manifest()
job = "jobs:\n test:\n steps:\n - uses: actions/checkout@v4\n - run: python3 scripts/bench/stream_test.py\n"
root = self._root({"scripts/bench/stream_test.py": "",
".github/workflows/main.yml": "on:\n push:\n branches: [main]\n pull_request:\n" + job,
".github/workflows/docs.yml": "on:\n push:\n paths-ignore:\n - 'docs/**'\n" + job,
".github/workflows/both.yml": "on:\n push:\n branches: [main]\n pull_request:\n"
" paths:\n - 'src/**'\n" + job,
".github/workflows/push.yml": "on:\n push:\n pull_request:\n types: [closed]\n" + job,
".github/workflows/closed.yml": "on:\n pull_request:\n types: [closed]\n" + job})
rows = [("grblhal-glowforge", "src/driver.c", "forgefirm:scripts/bench/stream_test.py",
"forgefirm:.github/workflows/%s.yml" % w) for w in ("main", "docs", "both", "push", "closed")]
filtered = ": every push or pull_request in its own on: carries a filter (%s)"
self.assertEqual(m.host_row_problems(man, rows, root=root, sources=None),
[rows[1][:3] + (rows[1][3] + filtered % "push: paths-ignore",),
rows[2][:3] + (rows[2][3] + filtered % "pull_request: paths; push: branches",),
rows[4][:3] + (rows[4][3] + filtered % "pull_request: types",)])
def test_the_lint_says_when_it_could_not_read_a_workflow(self):
import contextlib
import io
@@ -366,7 +581,7 @@ class CoverageReportTests(unittest.TestCase):
def blob(text):
return subprocess.run(["git", "hash-object", "-w", "--stdin"], cwd=cache, input=text.encode(),
capture_output=True, check=True).stdout.decode().strip()
yml = "steps:\n - uses: actions/checkout@v4\n - run: ./build/tray_test\n"
yml = "on: [push, pull_request]\nsteps:\n - uses: actions/checkout@v4\n - run: ./build/tray_test\n"
comps = {"forgectrl": {"srcrev": "aaa", "source": url, "files": [
["src/tray.c", "1" * 40], ["tests/tray_test.c", "2" * 40], ["tests/lens_test.c", "3" * 40],
[".github/workflows/build.yml", blob(yml)], [".github/workflows/lost.yml", "4" * 40]]}}
@@ -425,6 +640,37 @@ class CatalogTests(unittest.TestCase):
for t in catalog.all_tests(reg):
self.assertEqual(len(t.source_sha), 64)
def test_the_shared_judges_exist_and_take_every_package_import_of_the_suite(self):
# The list is kept by hand: the Context reaches runner.py, hw.py
# and baseline.py with no import to find. So every package module
# a suite module does import, at its top level or inside a function
# or a block, has to be on it (or be the registry, which judges
# nothing), or that judge would be in no fingerprint. The sibling
# walk follows the relative spellings alone (`from .x import` and
# `from . import x`), so a suite module imports the package by no
# absolute name.
import ast
import glob
paths = catalog.shared_judge_paths()
self.assertEqual([os.path.basename(p) for p in paths], list(catalog.SHARED_JUDGES))
for p in paths:
self.assertTrue(os.path.isfile(p), p)
allowed = set(catalog.SHARED_JUDGES) | {"catalog.py"}
for mod in sorted(glob.glob(os.path.join(catalog.suite_dir(), "*.py"))):
with open(mod, encoding="utf-8") as f:
tree = ast.parse(f.read())
for node in ast.walk(tree):
if isinstance(node, ast.ImportFrom) and node.level == 2:
for name in ([node.module] if node.module else [a.name for a in node.names]):
self.assertIn(name + ".py", allowed,
"%s imports %s, which no fingerprint carries" % (os.path.basename(mod), name))
absolute = ([node.module or ""] if isinstance(node, ast.ImportFrom) and node.level == 0
else [a.name for a in node.names] if isinstance(node, ast.Import) else [])
for name in absolute:
self.assertFalse(name == "forgetest" or name.startswith("forgetest."),
"%s imports %s by its absolute name, which no hash follows: import it "
"relatively (from .. or from .)" % (os.path.basename(mod), name))
class ImplementationHashTests(unittest.TestCase):
"""A test's implementation hash is its own function plus the code its
@@ -511,6 +757,94 @@ def two(ctx):
f.write("def o():\n return 1\n")
self.assertEqual((a2, b2), self.shas())
def test_a_shared_judge_edit_moves_every_test(self):
# runner.py, hw.py and baseline.py are in every test's path with no
# import to follow (the Context, the baseline around each run), and
# puls.py comes in by a package import the walk does not follow, so
# their text joins every implementation hash.
judge = os.path.join(self.tmp, "hwlike.py")
def write_judge(text):
with open(judge, "w", newline="\n") as f:
f.write(text)
catalog.forget(judge)
def shas():
return tuple(catalog.implementation_sha(self.path, t, judges=[judge]) for t in ("m.one", "m.two"))
write_judge("def sysfs_int(attr, default=None):\n return default\n")
a1, b1 = shas()
write_judge("def sysfs_int(attr, default=None):\n return 8\n")
a2, b2 = shas()
self.assertNotEqual(a1, a2)
self.assertNotEqual(b1, b2)
# a package file outside the list changes nothing
other = os.path.join(self.tmp, "server.py")
with open(other, "w", newline="\n") as f:
f.write("def serve():\n return 1\n")
self.assertEqual((a2, b2), shas())
# Test.source_sha, which the fingerprint, the campaign and the gate
# read, takes the shared judges itself: a suite test's hash is the
# one with SHARED_JUDGES folded in, not the one without them.
import inspect
t = catalog.load_suite()["image.health"]
src = inspect.getsourcefile(t.fn)
self.assertEqual(t.source_sha, catalog.implementation_sha(src, t.id, judges=catalog.shared_judge_paths()))
self.assertNotEqual(t.source_sha, catalog.implementation_sha(src, t.id, judges=[]))
def test_a_sibling_imported_inside_a_function_moves_the_tests_that_run_it(self):
# A test that imports a judge inside its own function carries the
# judge in its hash, and what the judge imports in turn, at any
# depth, and no other test of its module does; a helper of the
# module that imports one inside its body carries it into every
# test of the module.
def put(name, text):
path = os.path.join(self.tmp, name)
with open(path, "w", newline="\n") as f:
f.write(text)
catalog.forget(path)
def write_judge(text):
put("judge.py", text)
write_judge("def judge(x):\n return x > 1\n")
self.write(self.MODULE.replace("def two(ctx):\n",
"def two(ctx):\n from .judge import judge\n judge(2)\n"))
a1, b1 = self.shas()
write_judge("def judge(x):\n return x > 2\n")
a2, b2 = self.shas()
self.assertEqual(a1, a2)
self.assertNotEqual(b1, b2)
# judge.py imports limit.py, which imports base.py: an edit to
# either moves m.two, and not m.one
put("base.py", "BASE = 1\n")
put("limit.py", "from .base import BASE\n\n\nLIMIT = BASE\n")
write_judge("from .limit import LIMIT\n\n\ndef judge(x):\n return x > LIMIT\n")
a5, b5 = self.shas()
put("limit.py", "from .base import BASE\n\n\nLIMIT = BASE + 1\n")
a6, b6 = self.shas()
self.assertEqual(a5, a6)
self.assertNotEqual(b5, b6)
put("base.py", "BASE = 2\n")
a7, b7 = self.shas()
self.assertEqual(a6, a7)
self.assertNotEqual(b6, b7)
self.write(self.MODULE.replace(" return x + 1\n",
" from .judge import judge\n return judge(x)\n"))
a3, b3 = self.shas()
write_judge("def judge(x):\n return x > 3\n")
a4, b4 = self.shas()
self.assertNotEqual(a3, a4)
self.assertNotEqual(b3, b4)
# the spelling with no module (`from . import x`, as homeoff.py imports cloud.py) is
# walked the same
self.write(self.MODULE.replace("def two(ctx):\n",
"def two(ctx):\n from . import judge as j\n j.judge(2)\n"))
a8, b8 = self.shas()
write_judge("def judge(x):\n return x > 4\n")
a9, b9 = self.shas()
self.assertEqual(a8, a9)
self.assertNotEqual(b8, b9)
def test_line_endings_do_not_count(self):
a1, b1 = self.shas()
catalog.forget(self.path)
+142 -4
View File
@@ -9,16 +9,19 @@ sender, the page, and the driver's laser module; and the burn against a
scripted daemon: the preview and the program read, the start, the
prompts answered in order (the gantry stops, the head at home, the scrap,
its thickness in the machine's units, the arm, the machine's own press
prompt, the pick), the witnesses judged, the record read at version 1,
the setting the burn wrote put back as found, and the controller started
prompt, the pick), the witnesses judged (the burn's own, and the two the
test reads from sysfs at every poll), the record read at version 1, the
setting the burn wrote put back as found, and the controller started
again on the edge and the window put back."""
import os
import shutil
import sys
import tempfile
import unittest
sys.path.insert(0, os.path.dirname(__file__))
import helpers # noqa: E402
from forgetest import catalog # noqa: E402
from forgetest import baseline, catalog # noqa: E402
from forgetest.runner import Aborted, Context, Failed, Run # noqa: E402
from forgetest.suite import setup_burns, setup_dark # noqa: E402
@@ -279,18 +282,50 @@ class ScriptedDaemon:
class BurnRunTests(unittest.TestCase):
def setUp(self):
# the two witnesses as sysfs serves them through a lit burn (the
# shape of a bench record: the LASER_ON count and the tube current)
self.sysfs = tempfile.mkdtemp(prefix="forgetest-burn-")
self.plant("cnc/laser_on_sampled", "118")
self.plant("pic/hv_current", "431")
self.saved_root = os.environ.get("GF_SYSFS_ROOT")
os.environ["GF_SYSFS_ROOT"] = self.sysfs + "/"
self.fake = helpers.FakeForgectrl().start()
# the head at its home against the gantry stops, X and Y referenced
# by a manual home with no offset (the fake's pos is X0 Y0 Z0), its
# anchor declaring the stops at X0 Y0
self.fake.state["status"].update({"homed": True, "homed_axes": 7, "home_source": "manual"})
self.saved_anchor = baseline.ANCHOR_PATH
baseline.ANCHOR_PATH = os.path.join(self.sysfs, "grblhal.homed")
self.anchor("0.000 0.000 0.000 7 manual")
self.daemon = ScriptedDaemon(self.fake)
self.poll = setup_dark.POLL_S
setup_dark.POLL_S = 0.01
def tearDown(self):
setup_dark.POLL_S = self.poll
baseline.ANCHOR_PATH = self.saved_anchor
self.fake.stop()
if self.saved_root is None:
os.environ.pop("GF_SYSFS_ROOT", None)
else:
os.environ["GF_SYSFS_ROOT"] = self.saved_root
shutil.rmtree(self.sysfs, ignore_errors=True)
def plant(self, attr, value):
path = os.path.join(self.sysfs, *attr.split("/"))
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w") as f:
f.write(value + "\n")
def anchor(self, line):
"""The controller's home anchor as a home writes it: X Y Z, the
axes it references, and what set it."""
with open(baseline.ANCHOR_PATH, "w") as f:
f.write(line + "\n")
def run_test(self, tid):
t = catalog.load_suite()[tid]
run = Run("test", t.id, t.title)
run = self.run = Run("test", t.id, t.title) # kept for a run that fails
run.unattended = True # the ready gate passes; the arm cue is a notice
t.fn(Context(run, None, t))
return run
@@ -312,6 +347,14 @@ class BurnRunTests(unittest.TestCase):
self.assertNotIn("lens_hall_edge_z_mm", start)
self.assertEqual((start["lens_stop_below_steps"], start["lens_stop_above_steps"]), ("14", "20"))
self.assertEqual(run.evidence["result"]["emission"]["hv_max"], 610)
# the head's place on record before the start, judged at the stops: X0 Y0 with no offset
self.assertEqual(run.evidence["head_at_start"],
{"pos": {"x": 0.0, "y": 0.0, "z": 0.0}, "homed_axes": 7, "home_source": "manual",
"judged": True, "stops": {"x": 0.0, "y": 0.0}})
# the witnesses read from here at every poll: each at its peak
seen = run.evidence["emission_seen"]
self.assertEqual((seen["laser_on_max"], seen["hv_max"]), (118, 431))
self.assertGreater(seen["reads"], 0)
fw = run.evidence["focus_window"]
self.assertEqual(fw["window"]["below"], 14)
self.assertEqual(fw["window"]["above"], 20)
@@ -402,6 +445,100 @@ class BurnRunTests(unittest.TestCase):
with self.assertRaises(Failed) as cm:
self.run_test("setup.focus")
self.assertIn("the tube current never rose", str(cm.exception))
# what the test read itself is on record beside the daemon's word
seen = self.run.evidence["emission_seen"]
self.assertEqual((seen["laser_on_max"], seen["hv_max"]), (118, 431))
self.assert_found()
def test_a_head_off_its_home_fails_before_any_start(self):
# X and Y referenced by a manual home, and the head 12.5 mm from
# the stops: nothing starts, no controller lever is touched.
self.fake.state["status"]["pos"] = {"x": 12.5, "y": 0.0, "z": 0.0}
with self.assertRaises(Failed) as cm:
self.run_test("setup.focus")
self.assertIn("not at its home", str(cm.exception))
self.assertEqual(self.daemon.order, [])
self.assertEqual(self.daemon.lever_posts(), [])
self.assert_found()
def test_a_manual_home_offset_puts_the_stops_off_x0_y0(self):
# A manual home made with manual_home_x 5 and manual_home_y 3
# declares the stops X-5 Y-3 (its anchor) and leaves the head at
# X0 Y0, in front of them, where the wizard does not draw from. X0
# Y0 fails before any start, with no controller lever touched; the
# head at the stops is home.
self.fake.state["settings"].update({"manual_home_x": "5", "manual_home_y": "3"})
self.anchor("-5.000 -3.000 0.000 7 manual")
with self.assertRaises(Failed) as cm:
self.run_test("setup.focus")
self.assertIn("not at its home", str(cm.exception))
self.assertEqual(self.run.evidence["head_at_start"]["stops"], {"x": -5.0, "y": -3.0})
self.assertEqual(self.daemon.order, [])
self.assertEqual(self.daemon.lever_posts(), [])
self.assert_found()
self.fake.state["status"]["pos"] = {"x": -5.0, "y": -3.0, "z": 0.0}
run = self.run_test("setup.focus")
self.assertTrue(run.evidence["head_at_start"]["judged"])
self.assertEqual(self.daemon.order, ["laser.focus"])
# The offsets cleared after that home leave its reference as it is:
# the stops are still X-5 Y-3, and the head at X0 Y0 still fails
# before any start.
for k in ("manual_home_x", "manual_home_y"):
self.fake.state["settings"].pop(k)
self.fake.state["status"]["pos"] = {"x": 0.0, "y": 0.0, "z": 0.0}
with self.assertRaises(Failed) as cm:
self.run_test("setup.focus")
self.assertIn("not at its home", str(cm.exception))
self.assertEqual(self.daemon.order, ["laser.focus"])
# The offsets set after a home made with none: the head at X0 Y0 is
# at the stops that home declared, and the burn runs.
self.fake.state["settings"].update({"manual_home_x": "5", "manual_home_y": "3"})
self.anchor("0.000 0.000 0.000 7 manual")
run = self.run_test("setup.focus")
self.assertEqual(run.evidence["head_at_start"]["stops"], {"x": 0.0, "y": 0.0})
self.assertEqual(self.daemon.order, ["laser.focus", "laser.focus"])
# An anchor that is gone, or that is not the manual home /status
# reports, places no stops: nothing starts.
os.unlink(baseline.ANCHOR_PATH)
with self.assertRaises(Failed) as cm:
self.run_test("setup.focus")
self.assertIn("home anchor", str(cm.exception))
self.anchor("0.000 0.000 0.000 4 startup")
with self.assertRaises(Failed) as cm:
self.run_test("setup.focus")
self.assertIn("home anchor", str(cm.exception))
self.assertEqual(self.daemon.order, ["laser.focus", "laser.focus"])
def test_an_unreferenced_head_runs_on_the_operators_word(self):
# A fresh controller's counters-only position places nothing: the
# read is recorded, not judged, and the burn runs.
self.fake.state["status"].update({"homed": False, "homed_axes": 4, "home_source": "startup",
"pos": {"x": 12.5, "y": 0.0, "z": 0.0}})
run = self.run_test("setup.focus")
self.assertEqual(run.evidence["head_at_start"]["judged"], False)
self.assertEqual(run.evidence["head_at_start"]["home_source"], "startup")
self.assertEqual(self.daemon.order, ["laser.focus"])
def test_a_burn_the_test_itself_never_saw_fails_and_still_restores(self):
# The burn's result reports a lit burn, and the test's own reads
# of the two witnesses never leave zero: the burn is judged on
# what was read from here, not on the daemon's word alone.
self.plant("cnc/laser_on_sampled", "0")
self.plant("pic/hv_current", "0")
with self.assertRaises(Failed) as cm:
self.run_test("setup.focus")
self.assertIn("LASER_ON count never read nonzero", str(cm.exception))
self.assert_found()
self.assert_restarted_on_found()
def test_a_burn_whose_tube_current_the_test_never_saw_rise_fails(self):
# The LASER_ON count read nonzero from here, and the tube current
# never read over HV_ON: the second witness is judged on its own
# reads, against the daemon's own threshold for a lit burn.
self.plant("pic/hv_current", str(setup_burns.HV_ON))
with self.assertRaises(Failed) as cm:
self.run_test("setup.focus")
self.assertIn("tube current never read over %d from here" % setup_burns.HV_ON, str(cm.exception))
self.assert_found()
def test_a_burn_with_a_silent_beam_sensor_passes(self):
@@ -446,6 +583,7 @@ class BurnRunTests(unittest.TestCase):
with self.assertRaises(Aborted):
t.fn(Context(run, None, t))
self.assertEqual(self.daemon.aborted, ["laser.focus"])
self.assertIn("emission_seen", run.evidence) # the test's own reads, on record all the same
self.assert_found()
self.assert_restarted_on_found()
+20
View File
@@ -408,6 +408,26 @@ class LensCheckTests(ScriptedCheck):
self.assertIn(("/wiz/lens.travel/abort", {}), self.fake.posts)
self.assert_put_back()
def test_a_check_that_does_not_end_in_time_is_aborted_before_the_test_fails(self):
# The run never ends: the test stops following it at its timeout,
# aborts it, and fails, so no check runs on behind the FAIL.
from forgetest.runner import Failed
from forgetest.suite import setup_dark
real = self.fake.on_get
def on_get(path, q):
if path == "/wiz/dark":
return 200, dict(self.dark) # running until aborted
return real(path, q)
self.fake.on_get = on_get
t = catalog.load_suite()["setup.check-lens"]
ctx = Context(Run("test", t.id, t.title), None, t)
with self.assertRaises(Failed) as cm:
setup_dark.run_check(ctx, self.WID, lambda p: None, 0.2)
self.assertIn("did not end within", str(cm.exception))
self.assertIn(("/wiz/lens.travel/abort", {}), self.fake.posts)
self.assertFalse(self.dark["running"])
def test_a_controller_that_does_not_restart_fails_the_hand_back(self):
# The start refused: the controller stays stopped on the stale
# window's heels, and the run says so rather than pass.
+3 -22
View File
@@ -3,9 +3,9 @@
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""The setup.* suite on the host: the registration (ids, kinds, the
takeover tests, the operator tests' hands), the record builders, the
cookie parsing, and the settle rule for a gated supervisor."""
"""The setup.* suite on the host: the registration (the operator tests'
hands, the intake tests' precheck), the record builders, the cookie
parsing, and the settle rule for a gated supervisor."""
import json
import os
import shutil
@@ -18,19 +18,11 @@ import helpers # noqa: E402
from forgetest import baseline, catalog # noqa: E402
from forgetest.suite import setup # noqa: E402
IDS = ("setup.account-login",)
class RegistrationTests(unittest.TestCase):
def setUp(self):
self.reg = catalog.load_suite()
def test_every_test_is_registered_with_its_kind(self):
kinds = {tid: self.reg[tid].kind for tid in IDS if tid in self.reg}
self.assertEqual(sorted(kinds), sorted(IDS))
for tid in IDS:
self.assertEqual(kinds[tid], "auto", tid)
def test_recommended_reads_the_status_document(self):
w = {"recommended": [{"id": "laser.floor", "reason": "the tube was replaced"},
{"id": "laser.focus", "reason": "the tray was replaced"},
@@ -59,17 +51,6 @@ class RegistrationTests(unittest.TestCase):
and t.kind != "auto" and not t.fixture_runnable(("button", "lid", "interlock")))
self.assertEqual(attended, ["setup.cloud-header-capture", "setup.focus"])
def test_the_login_test_makes_its_own_account(self):
# No bench credentials, no precheck: the test installs a temporary
# account under a takeover and restores the real one.
t = self.reg["setup.account-login"]
self.assertIsNone(getattr(t, "precheck", None))
self.assertEqual(t.hardware, "takeover")
with open(setup.__file__) as f:
src = f.read()
for k in ("FORGETEST_LOGIN_NAME", "FORGETEST_LOGIN_PASSWORD"):
self.assertNotIn(k, src)
class RecordTests(unittest.TestCase):
STATUS = {"sheet_id": "ABCDE-FGHIJ",
+1 -1
View File
@@ -11,4 +11,4 @@ overrides:
meta-freescale-distro:
commit: b9d6a5d9931922558046d230c1f5f4ef6ee72345
meta-openglow:
commit: b7626be45c7708a8e71bd03af61ca968796fc8f7
commit: c542ce6ec5799df9cf0f689eb883c790c71a414b
@@ -4,5 +4,5 @@
# only SRCREV and PV here - the image manifest leaves *-pin.inc out of the
# layer content hash because the component entry already identifies the
# pinned source (forgefirm-image-manifest.bbclass).
SRCREV = "22a2cfc04242ddc50ed0a54e967a59634f3f4d6d"
PV = "0.1.32"
SRCREV = "b235be3e8ac5fcd88080b00c9d441dbda9c0014c"
PV = "0.1.34"
@@ -4,5 +4,5 @@
# only SRCREV and PV here - the image manifest leaves *-pin.inc out of the
# layer content hash because the component entry already identifies the
# pinned source (forgefirm-image-manifest.bbclass).
SRCREV = "31cdb4005668286c9faa5b4b834f0cbc3fdbbe9b"
PV = "0.1.2"
SRCREV = "ebd959256d3da9f648d6975ba740bee86ad69c7b"
PV = "0.1.5"
@@ -6,9 +6,9 @@
# because the component entry already identifies the pinned source
# (forgefirm-image-manifest.bbclass). The python3-gfhardware recipe in
# meta-glowforge-bsp pins the same repository; move both together.
SRCREV = "0f59c547b6e8418712ab9e000c58eb1975c29d79"
SRCREV = "6fb146a13c51765eec492bf758946e573ae25297"
# Bump PV with every SRCREV move: the hash-derived package version is not
# monotonic on its own and buildhistory QA fails the build when it sorts
# backwards.
PV = "0.1.32+git"
PV = "0.1.33+git"
@@ -4,5 +4,5 @@
# changes; keep only SRCREV and PV here - the image manifest leaves
# *-pin.inc out of the layer content hash because the component entry
# already identifies the pinned source (forgefirm-image-manifest.bbclass).
SRCREV = "badc8323463c059e418bdf0e4a161ed73823bfc1"
PV = "0.1.21"
SRCREV = "e15972005c6e3e3b5bcb4d7408df0eaf83b5cc43"
PV = "0.1.22"
@@ -17,4 +17,4 @@
# it to equal the rootfs stamp, the .fw meta-version and the release tag,
# and the acceptance test image.health compares the stamp on the running
# machine with the manifest's.
FORGEFIRM_RELEASE ?= "0.0.8"
FORGEFIRM_RELEASE ?= "0.0.9"
@@ -31,8 +31,11 @@ done
base=$(basename "$dir")
mkdir -p "$(dirname "$out")" || fail "cannot make the directory of $out"
# tar runs the compressor itself, so its exit status covers both: a
# member it cannot read fails the pack, and a failed pack leaves no bundle.
tar -C "$(dirname "$dir")" --sort=name --mtime=@0 --owner=0 --group=0 \
--numeric-owner -cf - "$base" | gzip -9 -n > "$out" || fail "cannot write $out"
--numeric-owner -I 'gzip -9 -n' -cf "$out" "$base" \
|| { rm -f "$out"; fail "cannot pack $dir into $out"; }
# the tree goes only once the bundle holds its manifest
tar -tzf "$out" "$base/license.manifest" > /dev/null 2>&1 \
|| fail "$out holds no $base/license.manifest"
@@ -29,6 +29,18 @@
# mdns no mDNS responder: no /etc/avahi, no avahi-daemon, no
# libnss_mdns, and the hosts line of nsswitch.conf names no
# mdns source.
# init-order runlevel 5 starts forgectrl before forgeext, and runlevel 6
# stops grblhal before forgectrl and forgeext before
# forgectrl: the links sort in that order.
# logging a ForgeFIRM logging init script is on the image
# (forgefirm-logging or forgefirm-logrotate).
# watchdog no userspace watchdog init script (/etc/init.d/watchdog):
# the kernel feeds the hardware watchdog.
# read-only /etc/default/rcS sets ROOTFS_READ_ONLY=yes.
# dhcpv6 no DHCPv6 client (udhcpc6, dhcp6c, odhcp6c) in a bin
# directory, and no /usr/share/udhcpc/default6.script.
# factory on a release image, no /etc/fstab line mounts anything
# under /factory/ (the dev image mounts the factory slots).
root=${1:?rootfs}
kind=${2:?release or dev}
@@ -141,4 +153,52 @@ if active "$root/etc/nsswitch.conf" | grep -E '^[[:space:]]*hosts:' | grep -q 'm
fail mdns "nsswitch.conf resolves hosts through mdns"
fi
# ------------------------------------------------------------ init-order
# link <runlevel dir> <S|K> <name>: the first link of that kind whose name
# ends in name, or nothing
link() {
for l in "$root/etc/$1"/"$2"*"$3"; do
[ -L "$l" ] || [ -e "$l" ] || continue
echo "${l##*/}"
return
done
}
# before <what> <a> <b>: a and b are both there and a sorts first
before() {
if [ -z "$2" ] || [ -z "$3" ]; then
fail init-order "$1: a link is missing (${2:-none}, ${3:-none})"
elif [ "$(printf '%s\n%s\n' "$2" "$3" | LC_ALL=C sort | head -n 1)" != "$2" ] || [ "$2" = "$3" ]; then
fail init-order "$1: $2 does not sort before $3"
fi
}
before "rc5.d starts forgectrl before forgeext" "$(link rc5.d S forgectrl)" "$(link rc5.d S forgeext)"
before "rc6.d stops grblhal before forgectrl" "$(link rc6.d K grblhal)" "$(link rc6.d K forgectrl)"
before "rc6.d stops forgeext before forgectrl" "$(link rc6.d K forgeext)" "$(link rc6.d K forgectrl)"
# --------------------------------------------------------------- logging
[ -e "$root/etc/init.d/forgefirm-logging" ] || [ -e "$root/etc/init.d/forgefirm-logrotate" ] \
|| fail logging "no ForgeFIRM logging init script (forgefirm-logging, forgefirm-logrotate)"
# -------------------------------------------------------------- watchdog
[ -e "$root/etc/init.d/watchdog" ] && fail watchdog "/etc/init.d/watchdog is on the image"
# ------------------------------------------------------------- read-only
grep -qx 'ROOTFS_READ_ONLY=yes' "$root/etc/default/rcS" 2>/dev/null \
|| fail read-only "/etc/default/rcS does not set ROOTFS_READ_ONLY=yes"
# ---------------------------------------------------------------- dhcpv6
for d in bin sbin usr/bin usr/sbin; do
for p in udhcpc6 dhcp6c odhcp6c; do
if [ -e "$root/$d/$p" ] || [ -L "$root/$d/$p" ]; then
fail dhcpv6 "/$d/$p is on the image"
fi
done
done
[ -e "$root/usr/share/udhcpc/default6.script" ] && fail dhcpv6 "/usr/share/udhcpc/default6.script is on the image"
# --------------------------------------------------------------- factory
if [ "$kind" = release ] && active "$root/etc/fstab" | awk '$2 ~ /^\/factory\// { f = 1 } END { exit !f }'; then
fail factory "/etc/fstab mounts a factory slot under /factory/ on a release image"
fi
exit $failed
File diff suppressed because it is too large Load Diff
+57
View File
@@ -0,0 +1,57 @@
# ForgeFIRM acceptance - 20261018222431 (dev)
- Image: `20261018222431 (dev)` (forgefirm-image-dev)
- Manifest identity: `70ecd7867c2698d17f2f3c38db772868eda3364f541242c93fb5db4e1d593bcd`
- Catalog: `b38332a30994e16a499a9ded530062291bacabf873138461dd54421e2174c29b`
- Campaign: `c-20261018224638-4ca0` opened 2026-10-18T22:46:38Z
- Exported: 2026-10-18T23:33:36Z
- **Release authorized: YES**
- Tests: 43 total, 43 satisfied (0 inherited), 0 required
| Test | Kind | Result | Run at | Campaign | Inherited |
|---|---|---|---|---|---|
| `image.health` | auto, core | PASS | 2026-10-18T22:46:39Z | `c-20261018224638-4ca0` | no |
| `kernel.latch-locked-idle` | auto, core | PASS | 2026-10-18T22:46:39Z | `c-20261018224638-4ca0` | no |
| `kernel.k1-k2` | auto, core, takeover | PASS | 2026-10-18T22:47:02Z | `c-20261018224638-4ca0` | no |
| `kernel.deadman-close` | auto, core, takeover | PASS | 2026-10-18T22:47:12Z | `c-20261018224638-4ca0` | no |
| `kernel.backtrack-bounds` | auto, core, takeover | PASS | 2026-10-18T22:47:30Z | `c-20261018224638-4ca0` | no |
| `kernel.fire-line` | auto, core, takeover | PASS | 2026-10-18T22:48:14Z | `c-20261018224638-4ca0` | no |
| `kernel.resume-lead` | auto, takeover | PASS | 2026-10-18T22:48:37Z | `c-20261018224638-4ca0` | no |
| `kernel.pic-soc-load` | auto | PASS | 2026-10-18T22:48:41Z | `c-20261018224638-4ca0` | no |
| `forgectrl.auth` | auto | PASS | 2026-10-18T22:48:42Z | `c-20261018224638-4ca0` | no |
| `setup.check-switches` | operator | PASS | 2026-10-18T22:48:47Z | `c-20261018224638-4ca0` | no |
| `setup.check-airflow` | auto, takeover | PASS | 2026-10-18T22:49:27Z | `c-20261018224638-4ca0` | no |
| `setup.check-motion` | auto, takeover | PASS | 2026-10-18T22:50:03Z | `c-20261018224638-4ca0` | no |
| `setup.check-lens` | auto, takeover | PASS | 2026-10-18T22:50:24Z | `c-20261018224638-4ca0` | no |
| `setup.cloud-header-capture` | operator, takeover | PASS | 2026-10-18T23:08:40Z | `c-20261018224638-4ca0` | no |
| `setup.focus` | live, takeover | PASS | 2026-10-18T23:11:20Z | `c-20261018224638-4ca0` | no |
| `motion.pacing` | auto | PASS | 2026-10-18T22:49:43Z | `c-20261018224638-4ca0` | no |
| `motion.jog-roundtrip` | auto | PASS | 2026-10-18T22:50:42Z | `c-20261018224638-4ca0` | no |
| `motion.microstep-modes` | auto | PASS | 2026-10-18T22:51:07Z | `c-20261018224638-4ca0` | no |
| `motion.liveness-probe` | auto | PASS | 2026-10-18T22:51:16Z | `c-20261018224638-4ca0` | no |
| `motion.deadman` | auto | PASS | 2026-10-18T22:52:02Z | `c-20261018224638-4ca0` | no |
| `motion.step-timing-under-load` | auto | PASS | 2026-10-18T22:52:33Z | `c-20261018224638-4ca0` | no |
| `motion.release` | auto, takeover | PASS | 2026-10-18T22:52:59Z | `c-20261018224638-4ca0` | no |
| `cooling.aa-offset-calibrate` | auto | PASS | 2026-10-18T22:54:32Z | `c-20261018224638-4ca0` | no |
| `cooling.flow-verify` | auto | PASS | 2026-10-18T22:57:10Z | `c-20261018224638-4ca0` | no |
| `cooling.flow-under-load` | live | PASS | 2026-10-18T23:12:56Z | `c-20261018224638-4ca0` | no |
| `cooling.fail-tier-stop` | operator | PASS | 2026-10-18T22:57:30Z | `c-20261018224638-4ca0` | no |
| `laser.emission-witness` | live, core | PASS | 2026-10-18T23:09:51Z | `c-20261018224638-4ca0` | no |
| `laser.armed-kill` | live | PASS | 2026-10-18T23:13:40Z | `c-20261018224638-4ca0` | no |
| `laser.pause-resume-lid-cancel` | live | PASS | 2026-10-18T23:14:18Z | `c-20261018224638-4ca0` | no |
| `camera.snapshot` | auto | PASS | 2026-10-18T22:57:47Z | `c-20261018224638-4ca0` | no |
| `camera.sensor-profile` | auto | PASS | 2026-10-18T22:58:00Z | `c-20261018224638-4ca0` | no |
| `camera.h264-stream` | auto | PASS | 2026-10-18T22:58:14Z | `c-20261018224638-4ca0` | no |
| `camera.frame-health` | auto | PASS | 2026-10-18T22:58:32Z | `c-20261018224638-4ca0` | no |
| `camera.lid-privacy` | operator | PASS | 2026-10-18T22:58:48Z | `c-20261018224638-4ca0` | no |
| `update.slots-and-signature` | auto | PASS | 2026-10-18T22:58:50Z | `c-20261018224638-4ca0` | no |
| `cloud.mode-switch` | operator | PASS | 2026-10-18T23:00:59Z | `c-20261018224638-4ca0` | no |
| `cloud.service-protocol` | operator | PASS | 2026-10-18T23:15:30Z | `c-20261018224638-4ca0` | no |
| `cloud.lid-interlock-abort` | live | PASS | 2026-10-18T23:17:46Z | `c-20261018224638-4ca0` | no |
| `cloud.pause-resume` | live | PASS | 2026-10-18T23:21:27Z | `c-20261018224638-4ca0` | no |
| `cloud.oversize-stream` | live | PASS | 2026-10-18T23:24:58Z | `c-20261018224638-4ca0` | no |
| `exthost.platform` | auto | PASS | 2026-10-18T23:01:21Z | `c-20261018224638-4ca0` | no |
| `exthost.armed-freeze` | operator, takeover | PASS | 2026-10-18T23:03:09Z | `c-20261018224638-4ca0` | no |
| `forgectrl.tls-records` | auto | PASS | 2026-10-18T23:03:15Z | `c-20261018224638-4ca0` | no |
Artifact sha256: `97018f73354c1b768b893f1538c43d8c506e06e8bd788ab4e194968910c4e5f1`
+25
View File
@@ -0,0 +1,25 @@
ForgeFIRM v0.0.9 is a beta release.
The setup is simpler, and it is safe to run again. On some machines, running First fire a second time, or after a power cycle, drove the head into the side of the machine. The setup sheet and its steps (Place the sheet, First fire, Dose curve, Corner rolloff, Flow under load) are gone. Focus and the optional Laser floor now each burn on their own small scrap in the back-left corner, starting from home against the gantry stops. A new Lens travel check measures the lens with the laser off. Only the first steps, through Intake fans and Coolant flow, are required. The rest are recommended, and Finish later takes you to the control panel.
Also fixed:
- Stopping mid-move (a reset from your Grbl sender, Abort in the control panel, or a cooling fault) now slows the head to a stop and keeps its position, so the machine stays homed.
- A rare stream fault that could replay leftover motion, with the laser off, at the start of the next job now stops with an alarm.
- The setup's burns no longer need the head's beam sensor, which reads nothing on some heads.
- The setup no longer shows the head as "not detected".
- The TEC check is gone. It could never pass, and it turned the TEC off on Pro machines. Your answer in the Your machine step now sets whether a TEC is fitted.
- A setup question or wait that times out now ends its step with the reason and a way to run it again.
- The coolant flow calibration no longer leaves its trial settings behind after an abort.
- The logs now record which firmware runs at every start and every update, so a log export shows how a machine was updated.
- Extensions: a rollback or an interrupted install no longer leaves an extension broken.
After you update:
- If your machine has not done the Intake fans or Coolant flow step, the setup asks for it, and the machine waits for it before it runs a job.
- Pro owners who ran the setup on an earlier release: make sure Cooling protection > Coolant loop > TEC (Pro chiller) is on. The old TEC check turned it off.
- If you ran the setup sheet on an earlier release and your jobs now land offset from where you placed them, send G92.1 once from your Grbl sender.
Expect bugs. When you find one, open an issue in this repository or report it on the community forum at https://community.openglow.org so it can be fixed.
Every release below 0.1.0 is a beta release. Installation, usage, and safety are documented at https://docs.forgefirm.org/.
+1 -1
View File
@@ -31,7 +31,7 @@ page's takeover does that; from a host, stop them first.
| `pwm_stream_test.py` | LASER_PWM stream-path scope test (runs on the board, controller and forgectrl stopped): streams power bytes only (no step bytes, no FIRE bits, `motor_lock=15`, latch locked) through `/dev/glowforge` so the scope verifies the real power path, including the run-start duty reset and the consecutive-power-byte drop; position counters compared before/after. Exit 0 = counters unmoved, idle at the end, no FIRE/emission read back. |
| `gate_a_kernel_drills.py` | Kernel laser-safety drills (run on the board with forgectrl stopped so the pulse device is free): `K1` controlled-stop deceleration floor, `K2` resume waypoint honors the locked latch, `K3` a mid-ramp latch unlock never re-arms the FIRE drive. Software witnesses (`cnc/state`, `laser_enable`, `laser_on`, `laser_on_sampled`, interlock bit 3) plus the PSU-connector LASER_ON scope point; K3 refuses to run if HV reports good. |
| `laser_stream_test.py` | Host-side laser pulse-stream emission harness: runs the native null-sink controller with `GFSINK_DUMP`, drives small laser jobs over TCP, and checks the dumped bytes against the kernel feeder contract (leading power byte, no back-to-back power bytes, FIRE only inside cutting moves, every stream ends FIRE-clear, no FIRE on a stepless gap, no FIRE leak across cycle churn, the rapids after an M5 executed at idle ship dark, and the next job in the same process fires at the level the previous one ended at). Runs in the grblHAL repo's CI. |
| `laser_lifecycle_test.py` | Host-side operator-armed-window lifecycle harness (null-sink controller): arm once per job with M5/M3 persistence, the M2 close, sender-change re-consent, the disarm grace counting down in Hold, and arm refusal under a blocking cooling verdict. Runs in the grblHAL repo's CI. |
| `laser_lifecycle_test.py` | Host-side lifecycle harness of the operator-armed window (null-sink controller) ([Test](https://docs.forgefirm.org/developers/testing/)). Runs in the grblHAL repo's CI. |
| `z_envelope_test.py` | Host-side Z envelope harness (null-sink controller): the Z soft limit belongs to the driver, not to `$20`, so the driver re-applies `sys.work_envelope`, `sys.homed` and `sys.soft_limits` for Z from the settings-changed chain. Checks that an unreferenced Z is collapsed to where the lens stands and blocks a move each way, that X and Y stay free, and that neither a `$20` write (the core clears the soft-limit mask in the setter) nor a `$132` write (which un-homes the axis as well) frees Z. Runs in the grblHAL repo's CI. |
| `ctlport_test.py` | Host-side controller port and line-multiplexer harness (null-sink controller): a scripted sender that counts every `ok` and `error` runs beside the port's one client on `grbl.ctl`. A port jog's status goes to the port and never to the sender, a port error does not reach the sender's parser, a sender line cancels a port jog and gets its own `ok` (a line queued right behind the port's included), plus the refusals, the single client, a CR LF sender, a soft reset, and the client as the dead-man. `python3 ctlport_test.py <grblHAL_glowforge>`. |
| `cool_report_test.py` | Host-side cooling report harness (null-sink controller, a stand-in for forgectrl's listener on `FORGECTRL_PORT`): every `POST /cool/state` carries the secret the supervisor hands the controller at its spawn (`GF_REPORT_SECRET`) as `X-ForgeFIRM-Report`; with no secret the header is absent; a value that is not 32 hex digits, a CR LF with a header behind it included, never reaches the wire; and the homing runner the controller starts is handed the secret (it reports in the controller's place during a gfcloud `$H`), and none when the controller has none or a malformed one. `python3 cool_report_test.py <grblHAL_glowforge>`. |
+5 -6
View File
@@ -379,8 +379,8 @@ def test_sender_wins_queued(s):
class NewlinePoller(threading.Thread):
"""A sender that polls the way LightBurn does: '?' with an end of line
behind it, on a timer. The '?' is a realtime character; the end of line
"""A sender that polls '?' with an end of line behind it, on a timer,
as some Grbl senders do. The '?' is a realtime character; the end of line
is an empty line, which the core answers ok."""
def __init__(self, sender, period=0.25):
@@ -402,10 +402,9 @@ class NewlinePoller(threading.Thread):
def poll_case(tag, eol):
"""An empty line is not the sender speaking. With a sender polling
'?'<eol> four times a second (LightBurn polls about twice a second): a
port jog is never refused for it, a long port jog runs whole under it,
and every poll still draws its one ok, during the jog too. A real line
still goes first."""
'?'<eol> four times a second: a port jog is never refused for it, a
long port jog runs whole under it, and every poll still draws its one
ok, during the jog too. A real line still goes first."""
s = Session(eol=eol)
try:
s.sender.wait_state("Idle")
+234 -2
View File
@@ -16,7 +16,10 @@ reported messages:
2. program end (M2) closing the window, and the next job arming
afresh: laser_stream_test session K
3. a sender change closes the window: a reconnected client must
re-arm - the displaced session's consent does not carry over
re-arm - the displaced session's consent does not carry over; the
disarm and the relock at the change are laser_arm_test.c case H,
and a new sender's soft reset of the held job is the core's reset
from a completed hold, session hold-reset-restart under rule 10
4. the spindle-off grace counts down in HOLD, not only in Idle - a
job abandoned in feed hold disarms after laser_disarm_s
5. arming refused while the cooling verdict blocks fire, or before
@@ -64,6 +67,17 @@ reported messages:
cancels it from the hold - reason reported, the reset banner, no
alarm, the return to the job start - and a later press resumes
nothing
17. a jog cancel (0x85) stops a jog short of its target, Idle; a ^X
mid-move asks the kernel for its ramp (cnc/stop in GFSINK_ATTR_LOG)
and lands in Alarm with the position kept: MPos is the X that the
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
@@ -214,9 +228,13 @@ class Session:
"""One controller process with the lifecycle overrides applied."""
def __init__(self, name, fire_ok=True, disarm_s=2, switches=None, conf_extra="",
armed_ack=True, ack_after_s=0.0):
armed_ack=True, ack_after_s=0.0, dump=False, attr_log=False):
self.name = name
self.workdir = tempfile.mkdtemp(prefix="laser-lifecycle-")
# The stream the controller ships, when the case reads it.
self.dump = os.path.join(self.workdir, "stream") if dump else None
# The sysfs writes the hardware would get, when the case reads them.
self.attr_log = os.path.join(self.workdir, "attr.log") if attr_log else None
conf = os.path.join(self.workdir, "forgefirm.conf")
with open(conf, "w") as f:
# The fan grace at 0 keeps the arm's acknowledgment budget at its
@@ -228,6 +246,12 @@ class Session:
GF_STATE_DIR=self.workdir, FFLOG_STDERR="1")
env.pop("GFSINK", None)
env.pop("GF_SWITCH_FILE", None)
env.pop("GFSINK_DUMP", None)
env.pop("GFSINK_ATTR_LOG", None)
if self.dump:
env["GFSINK_DUMP"] = self.dump
if self.attr_log:
env["GFSINK_ATTR_LOG"] = self.attr_log
self.switch_file = None
if switches is not None:
self.switch_file = os.path.join(self.workdir, "switches")
@@ -318,6 +342,39 @@ 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)
send_line(self.sock, "$$", self.log)
m = re.search(r"\$%d=(-?[\d.]+)" % n, "".join(self.log[mark:]))
return float(m.group(1)) if m else None
def attr_count(self, write):
"""How many times the hardware would have been given `write`
("<attr> <value>") so far."""
try:
with open(self.attr_log) as f:
return sum(1 for line in f if line.strip() == write)
except OSError:
return 0
def shipped_x_steps(self):
"""The X steps the shipped stream moved, net: a tick byte (bit 7
clear) with the X step bit (0x01), the X direction bit (0x02) set
for -X."""
try:
with open(self.dump, "rb") as f:
data = f.read()
except OSError:
return 0
return sum((-1 if b & 0x02 else 1) for b in data if not b & 0x80 and b & 0x01)
def armed_count(self):
return "".join(self.log).count(ARMED)
@@ -868,6 +925,179 @@ def test_pause_then_lid_cancel_returns_to_the_job_start():
s.close()
def test_cancel_and_abort():
"""Rule 17: a jog cancel (0x85) stops a jog short of its target and
leaves the controller Idle. A ^X mid-move is a controlled abort: the
kernel is told to stop with its ramp, Alarm, the position kept, and
no return to the job start (that move is the lid policy's alone). The
position kept is the X that the shipped stream moved, and the lens,
which has no reference here, is still held where it stands. $X
recovers to Idle, the head does not move on its own, and the next jog
ships steps."""
s = Session("cancel-abort", disarm_s=60, dump=True, attr_log=True)
try:
spm = s.setting(100)
if not spm:
fail("[cancel-abort] no $100 in the settings dump")
x0 = s.mpos_x()
if x0 is None:
fail("[cancel-abort] no position in the status report")
s.send_raw("$J=G91X40F2400")
if not s.wait_state("Jog", 3):
fail("[cancel-abort] the jog never reported Jog (state %s)" % s.state())
time.sleep(0.3)
s.sock.sendall(b"\x85")
if not s.wait_state("Idle", 5):
fail("[cancel-abort] not Idle after the jog cancel (state %s)" % s.state())
x1 = s.mpos_x()
if x1 is None or not 0.5 < x1 - x0 < 39.0:
fail("[cancel-abort] the jog cancel did not stop short of the target (X %s from %.3f)"
% (x1, x0))
s.send_raw("M5")
s.send_raw("G91")
s.send_raw("G1 X120 F3000") # 2.4 s
if not s.wait_state("Run", 5):
fail("[cancel-abort] the move never reported Run (state %s)" % s.state())
time.sleep(0.5)
stops = s.attr_count("cnc/stop 1")
s.sock.sendall(b"\x18")
if not s.wait_state("Alarm", 5):
fail("[cancel-abort] ^X mid-move did not land in Alarm (state %s)" % s.state())
if s.attr_count("cnc/stop 1") <= stops:
fail("[cancel-abort] ^X mid-move did not ask the kernel to stop with its ramp "
"(no cnc/stop write)")
x2 = s.mpos_x()
if x2 is None or not 0.5 < x2 - x1 < 119.5:
fail("[cancel-abort] the abort did not keep a plausible position (X %s from %.3f)" % (x2, x1))
moved = s.shipped_x_steps()
if abs(x2 * spm - moved) > 0.5:
fail("[cancel-abort] the position after the abort is not the X the stream moved "
"(X %.3f is %.1f steps, the stream moved %d)" % (x2, x2 * spm, moved))
send_line(s.sock, "$X", s.log)
if not s.wait_state("Idle", 5):
fail("[cancel-abort] $X did not recover to Idle (state %s)" % s.state())
read_avail(s.sock, s.log, 1.0)
if "returned to the job start" in "".join(s.log):
fail("[cancel-abort] a sender abort triggered the return to the job start")
x3 = s.mpos_x()
if x3 is None or abs(x3 - x2) > 0.05:
fail("[cancel-abort] the head moved on its own after the abort (X %s, was %.3f)" % (x3, x2))
# The lens has no reference in this session, so it is held where it
# stands, and the abort keeps it held.
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("[cancel-abort] a Z jog after the abort was not refused: the lens is no longer held")
shipped = os.path.getsize(s.dump) if os.path.exists(s.dump) else 0
s.send_raw("$J=G91X-5F2400")
if not s.wait_state("Jog", 3) or not s.wait_state("Idle", 5):
fail("[cancel-abort] the jog after $X did not run (state %s)" % s.state())
grew = (os.path.getsize(s.dump) if os.path.exists(s.dump) else 0) - shipped
if grew <= 0:
fail("[cancel-abort] the jog after $X shipped no steps")
print("PASS [cancel-abort]: 0x85 stopped the jog at %.3f mm of 40, Idle; ^X mid-move asked "
"for the kernel's stop and landed in Alarm at %.3f mm, the X the stream moved, with no "
"return and the lens still held; $X recovered to Idle and the next jog shipped %d bytes"
% (x1 - x0, x2 - x1, grew))
finally:
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
@@ -1008,6 +1238,8 @@ def main():
test_button_pause_resume()
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()
+83 -19
View File
@@ -24,7 +24,10 @@ over TCP, then checks the dumps against the kernel feeder contract:
the stream must never lean on the kernel's end-of-data backstop
8. no FIRE bit ever rides a zero-step gap: a stepless run of stream
bytes carrying FIRE longer than any legitimate between-step
interval is a stationary dwell burn
interval is a stationary dwell burn, and so is a FIRE tick deeper
into a stepless run than that interval, however it is spaced (the
density dither masks a stale fire state into bursts split by dark
ticks, so FIRE continuity says nothing under it; depth does)
9. rules 7-8 hold across rapid cycle stop/start churn (planner-starve
shaped jobs), where the FIRE state of the previous cycle must not
leak into the idle-gap pad bytes
@@ -36,8 +39,9 @@ over TCP, then checks the dumps against the kernel feeder contract:
rung's opening ticks carry the previous rung's duty
11. under the density dose model no level ever reaches PWMSAR: every
power byte carries full duty (one still leads each kernel run)
12. density matches the level the core commanded, rung by rung, and no
burst is longer than the base period
12. density matches the level the core commanded, rung by rung, each
rung lit for its whole length, and no burst is longer than the base
period
13. the model is a mask and never a source: run the same job under both
models and every FIRE tick of the density run is a FIRE tick of the
analog run, on an identical motion grid
@@ -49,7 +53,9 @@ over TCP, then checks the dumps against the kernel feeder contract:
next run: a standalone S word between moves, from a sender slow
enough to drain the planner, must still cut at the level it asked
for rather than dark or at a stale level (judged under the shipped
density model, where the level is the FIRE density)
density model, where the level is the FIRE density), and over the
whole move: a level applied late shows as a lit span shorter than
the move
16. and the off transition survives the same way: an M5 executed with
the planner drained and the kernel run over must darken the rapids
that follow it, and a bare G0 sent with the spindle off must ship
@@ -76,9 +82,11 @@ over TCP, then checks the dumps against the kernel feeder contract:
is 0 (the floor, not $31, sets the bottom of the S range) and $35
is the board's floor, landing at PWMSAR_FLOOR_MIN or above
19. the dose curve's mapping of S onto delivered light is
laser_arm_test.c case P's; every session here but the rolloff ones
runs with laser_dose_curve = off so its S-to-level arithmetic stays
exact
laser_arm_test.c case P's, and the rolloff reference run (rule 23)
pins one point of it end to end: the S300 cruise renders the density
the curve maps 30 % light onto; every laser session but the rolloff
ones (rules 20 and 23) and the jog run (rule 22) runs with
laser_dose_curve = off so its S-to-level arithmetic stays exact
20. the corner rolloff starves the slow spots: under M4 with the curve
in force, the accelerate-in head of a line renders less density at
the default gamma of 2 than at gamma 1, and the cruise middle
@@ -256,12 +264,16 @@ CHURN_BUDGET_S = (CHURN_MOTION_S + CHURN_GAPS_S) * 1.5 + 0.2
LADDER_S = (20, 30, 60, 120, 200, 300)
LADDER_DUTY = tuple(duty_for(s) for s in LADDER_S)
LADDER_MM = 5.0
LADDER_FEED = 300
JOB_LADDER = ["G91", "G21", "M3"]
for _i, _s in enumerate(LADDER_S):
JOB_LADDER.append("S%d" % _s)
JOB_LADDER.append("G1 X%g F300" % (LADDER_MM if _i % 2 == 0 else -LADDER_MM))
JOB_LADDER.append("G1 X%g F%d" % (LADDER_MM if _i % 2 == 0 else -LADDER_MM,
LADDER_FEED))
JOB_LADDER.append("G0 Y1")
JOB_LADDER.append("M5")
# One rung's length in machine ticks: the span each rung must stay lit for.
LADDER_TICKS = LADDER_MM / (LADDER_FEED / 60.0) * MACHINE_TICK_HZ
# Sessions E-G: the density dose model. $35 = 0 for the ladder because
@@ -299,6 +311,13 @@ DENSITY_CONF_FLOORED = ("laser_power_model = density\n"
"laser_dose_curve = off\n" + DENSITY_CONF_BASE)
# The shipped default: the bench curve in force (no keys at all).
DENSITY_CONF_CURVED = "laser_power_model = density\n" + DENSITY_CONF_BASE
# Rule 19's end-to-end point: 30 % light (S300) through the bench-default
# curve (glowforge_laser.c curve_default) lands between its (45 % density,
# 21 % light) and (60 %, 37 %) points, 45 + 9/16 x 15 = 53.44 % density,
# level 68 of 127. Mirrored here the way the floor is: changing the curve
# changes this expectation.
CURVE_S300_LEVEL = 68
CURVE_S300_DENSITY = CURVE_S300_LEVEL / float(PWM_PERIOD)
DENSITY_LEVEL = tuple(int(x * PWM_PERIOD / RPM_MAX) for x in LADDER_S)
# A $35 typed ahead of the job: rule 18 says the arm overwrites it.
@@ -324,6 +343,8 @@ for _i, _s in enumerate(IDLE_S_LEVELS):
JOB_IDLE_S.append("G1 X%g F%d" % (IDLE_S_MM if _i % 2 == 0 else -IDLE_S_MM,
IDLE_S_FEED))
JOB_IDLE_S.append("M5")
# One move's length in machine ticks: the span each level must stay lit for.
IDLE_S_TICKS = IDLE_S_MM / (IDLE_S_FEED / 60.0) * MACHINE_TICK_HZ
# Session J: the bench ladder's shape. M5 executes with the planner
@@ -692,19 +713,47 @@ def tick_bytes(data):
def check_fire_gaps(name, data):
"""Rule 8: no stepless run carrying FIRE longer than the limit."""
run = 0
"""Rule 8, read both ways so it holds under either power model: no
stepless run carrying FIRE on every tick for the limit (a stale fire
state as the analog model renders it), and no FIRE tick farther than
the limit from every step, the one before it and the one after it
(the same state as the density model renders it: the dither masks it
into bursts shorter than the limit, split by dark ticks, so the first
reading never trips). A cut's first FIRE tick leads its first step by
the accel-from-rest interval and its last trails its last step by
less, both inside the limit; a FIRE tick with no step that near on
either side is in a gap, and lit."""
ticks = tick_bytes(data)
n = len(ticks)
# Ticks from each tick to the next step tick at or after it.
ahead = [n] * n
nxt = None
for i in range(n - 1, -1, -1):
if ticks[i] & 0x25: # an X/Y/Z step
nxt = i
if nxt is not None:
ahead[i] = nxt - i
run = 0 # consecutive stepless ticks carrying FIRE
since = n # ticks since the last step
worst = 0
for tick, b in enumerate(tick_bytes(data)):
if b & 0x10 and not b & 0x25: # FIRE, no X/Y/Z step
for tick, b in enumerate(ticks):
if b & 0x25:
run = since = 0
continue
since += 1
if not b & 0x10:
run = 0
continue
run += 1
worst = max(worst, run)
if run >= FIRE_GAP_LIMIT_TICKS:
fail("[%s] FIRE carried across a %d-tick zero-step gap "
"ending at tick %d (stationary dwell burn)"
% (name, run, tick))
else:
run = 0
if since > FIRE_GAP_LIMIT_TICKS and ahead[tick] > FIRE_GAP_LIMIT_TICKS:
fail("[%s] FIRE %d ticks after a step and %d before the next, "
"at tick %d (stationary dwell burn, masked by the dither)"
% (name, since, ahead[tick], tick))
return worst
@@ -860,8 +909,9 @@ def fire_spans(ticks, gap=RUNG_SPLIT_TICKS):
return spans
def check_density(name, data, levels, period, min_ticks):
"""Rules 11-12: pinned duty, and density per rung matching the level."""
def check_density(name, data, levels, period, min_ticks, move_ticks=None):
"""Rules 11-12: pinned duty, density per rung matching the level, and
with move_ticks given, each rung's span the whole move long."""
# A power byte still leads every kernel run - the run start resets the
# hardware duty - but under this model it only ever carries full duty:
# the level rides the FIRE bits, never PWMSAR.
@@ -878,6 +928,14 @@ def check_density(name, data, levels, period, min_ticks):
out = []
for (a, b), level in zip(spans, levels):
# The span is the whole move: a level applied late renders the
# right density over a shorter span, which the density alone
# cannot see. The lowest levels open and close a few periods of
# carried debt inside the move, well under the margin.
if move_ticks is not None and not 0.9 * move_ticks <= b - a <= 1.1 * move_ticks:
fail("[%s] level %d fire span of %d ticks, expected ~%d (the whole "
"move): the move ran dark before the level was applied, or "
"FIRE outlived it" % (name, level, b - a, move_ticks))
seg = ticks[a:b]
got = sum(1 for t in seg if t & 0x10) / float(len(seg))
want = level / float(PWM_PERIOD)
@@ -987,7 +1045,7 @@ def main():
# the arm loads into $35.
dens = run_session("density", JOB_LADDER, conf=DENSITY_CONF)
rendered = check_density("density", dens, DENSITY_LEVEL, DENSITY_PERIOD_TICKS,
DENSITY_MIN_MACHINE_TICKS)
DENSITY_MIN_MACHINE_TICKS, LADDER_TICKS)
check_termination("density", dens)
if "laser armed (density, floor 0 %, curve off)" not in run_session.text:
fail("[density] the arm did not select the density model at floor 0")
@@ -1043,7 +1101,7 @@ def main():
# the one before it, and the stream ends dark.
idle_s = run_session("idle-s", JOB_IDLE_S, conf=DENSITY_CONF_FLOORED)
got = check_density("idle-s", idle_s, tuple(duty_for(l) for l in IDLE_S_LEVELS),
DENSITY_PERIOD_TICKS, DENSITY_MIN_MACHINE_TICKS)
DENSITY_PERIOD_TICKS, DENSITY_MIN_MACHINE_TICKS, IDLE_S_TICKS)
check_termination("idle-s", idle_s)
print("PASS [idle-s]: standalone S across idle gaps -> densities %s, each its own level"
% [d for _l, d in got])
@@ -1084,7 +1142,7 @@ def main():
text = run_session.text
expect_levels = tuple(duty_for(x) for x in LADDER_S)
check_density("floor-derived", floored, expect_levels, DENSITY_PERIOD_TICKS,
DENSITY_MIN_MACHINE_TICKS)
DENSITY_MIN_MACHINE_TICKS, LADDER_TICKS)
if "laser armed (density, floor %g %%, curve off)" % PWM_MIN_PCT not in text:
fail("[floor-derived] the arm report does not name the derived floor "
"(text: %r)" % text[-400:])
@@ -1482,6 +1540,12 @@ def main():
a, b = rs[0]
mid = rt[(a + b) // 2 - 1000:(a + b) // 2 + 1000]
dens_ref = sum(1 for t in mid if t & 0x10) / float(len(mid))
# Rule 19: the reference cut is one M4 line at a known S under the
# shipped curve, so its cruise (velocity ratio 1, no rolloff) pins the
# curve's rendering end to end.
if abs(dens_ref - CURVE_S300_DENSITY) > 0.02:
fail("[rolloff-ref] the S300 cruise renders %.3f, expected %.3f through the "
"bench-default curve" % (dens_ref, CURVE_S300_DENSITY))
tt = tick_bytes(two)
ts = fire_spans(tt)
if len(ts) != 1:
+11 -20
View File
@@ -241,18 +241,14 @@ else
fi
# Root policy gate. The release image ships root WITHOUT a password (the
# serial console is the recovery path) and sshd refuses root and empty
# passwords: PermitRootLogin no and PermitEmptyPasswords no must be
# active (uncommented) in the built sshd_config, and root's shadow field
# must be empty. Read the built files, not the recipes: this catches a
# drift however it got in (recipe, local.conf, an inherited class).
# serial console is the recovery path): root's shadow field must be empty.
# sshd's refusal of root and of empty passwords is the rootfs lint's
# (meta-forgefirm/recipes-forgefirm/images/rootfs-lint.sh), which fails
# the build. Read the built file, not the recipes: this catches a drift
# however it got in (recipe, local.conf, an inherited class).
SSHD_CONFIG=$(debugfs -R "cat /etc/ssh/sshd_config" "$EXT4" 2>/dev/null)
[ -n "$SSHD_CONFIG" ] \
|| die "release rootfs carries no /etc/ssh/sshd_config"
printf '%s\n' "$SSHD_CONFIG" | grep -Eq '^PermitRootLogin[[:space:]]+no[[:space:]]*$' \
|| die "release sshd_config has no active 'PermitRootLogin no' (recipes-connectivity/openssh drift?)"
printf '%s\n' "$SSHD_CONFIG" | grep -Eq '^PermitEmptyPasswords[[:space:]]+no[[:space:]]*$' \
|| die "release sshd_config has no active 'PermitEmptyPasswords no' (recipes-connectivity/openssh drift?)"
ROOT_SHADOW=$(debugfs -R "cat /etc/shadow" "$EXT4" 2>/dev/null \
| awk -F: '$1=="root"{print; exit}')
[ -n "$ROOT_SHADOW" ] \
@@ -260,27 +256,22 @@ ROOT_SHADOW=$(debugfs -R "cat /etc/shadow" "$EXT4" 2>/dev/null \
ROOT_PW=$(printf '%s\n' "$ROOT_SHADOW" | awk -F: '{print $2}')
[ -z "$ROOT_PW" ] \
|| die "release rootfs has a non-empty root password field: the policy is an empty field (empty-root-password in forgefirm-image.bb); a build drift"
echo "root policy gate OK (root field empty; sshd refuses root and empty passwords)"
echo "root policy gate OK (root field empty)"
# Read-only rootfs gate. The release rootfs mounts read-only: the root
# line of the built fstab carries ro, the rcS default agrees (the
# read-only-rootfs image feature), no factory-slot mount is in the
# release fstab (those belong to the dev image), and sshd keeps its host
# keys on /data, where the read-only rootfs cannot hold them.
# line of the built fstab carries ro, and sshd keeps its host keys on
# /data, where the read-only rootfs cannot hold them. rcS's
# ROOTFS_READ_ONLY and the fstab with no factory slot are the rootfs
# lint's.
FSTAB=$(debugfs -R "cat /etc/fstab" "$EXT4" 2>/dev/null)
[ -n "$FSTAB" ] \
|| die "release rootfs carries no /etc/fstab"
printf '%s\n' "$FSTAB" | awk '$1 == "/dev/root" && $2 == "/" { print $4 }' \
| grep -Eq '(^|,)ro(,|$)' \
|| die "release fstab does not mount / read-only (base-files fstab or read-only-rootfs drift?)"
printf '%s\n' "$FSTAB" | grep -Eq '^[^#]*[[:space:]]/factory/' \
&& die "release fstab mounts a factory slot under /factory (dev image only)"
RCS=$(debugfs -R "cat /etc/default/rcS" "$EXT4" 2>/dev/null)
printf '%s\n' "$RCS" | grep -q '^ROOTFS_READ_ONLY=yes$' \
|| die "release rcS has no ROOTFS_READ_ONLY=yes (read-only-rootfs image feature drift?)"
[ "$(printf '%s\n' "$SSHD_CONFIG" | grep -c '^HostKey /data/forgefirm/ssh/')" = 3 ] \
|| die "release sshd_config does not keep the host keys under /data/forgefirm/ssh (recipes-connectivity/openssh drift?)"
echo "read-only rootfs gate OK (/ ro, no /factory mounts, host keys on /data)"
echo "read-only rootfs gate OK (/ ro, host keys on /data)"
# Config-level guard: debug-tweaks must not sit in the shared kas config,
# where it would apply to every target including the release image.