mirror of
https://github.com/openglow-org/forgefirm.git
synced 2026-09-28 09:11:11 -07:00
8570c0141eebe136bf2e63d400cdd7f3c5e8e7a1
100
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
8570c0141e |
forgetest: motion.soft-limits runs its own camera home and hands the head back
motion.soft-limits homed only when the machine was not homed already, and then with whatever homing_mode the machine had: it counted on the home cloud.mode-switch used to leave behind. cloud.mode-switch now drops its camera home when it hands the head back, so the test started unhomed, and under the operator's homing_mode = manual its $H was a manual home, declared at once where the head stood (2.2 s, no web-service session), and the test failed on gfhome's missing completion line. When it did home, it left the head at the corner and the machine homed. The test now sets homing_mode = gfcloud and unsets the camera-home offsets for its run, so the envelope is the bed from the home corner, and always runs its own camera home, every motion of it judged whole (homeoff.judge_whole_motions). A setting is written when the controller takes it (it refuses one for a moment after a Grbl client closes). At the end the settings are put back as found and homeoff.camera_home_return drops the home and jogs the head back to where the test found it. The helpers are imported inside the function: homeoff imports this module. Proof: on the bench reference, image 20260925183749 with this file mounted, motion.soft-limits PASS: the camera home in 52.6 s, every motion completed; X max, Y max and X min refused with ALARM:2 and no motion, the jog past the bed refused with error 15, the move inside the bed ran and came back; the settings put back; the head jogged back 28.95/13.03 mm to where the test found it; the baseline clean. forgetest 499 OK. Acceptance: the test is the change; its fingerprint moves and no other test's does. |
||
|
|
800863fb09 |
forgetest: the cloud tests wait out the service's thinking
wait_quiet took the machine as quiet after 8 s without the start of a motion, a park, a run or a lens homing. Between a lid image and its next move the service is working on the image and the log is silent, and on the bench reference the re-hunt's moves came 9.6 s apart: cloud.mode-switch switched back to GRBL mode in the middle of the re-hunt, twice, the first time canceling a motion at 988 of its 1002 steps. Every line of a service action now counts as activity: the requests, the image uploads, the action ends, the runs, the parks, the motions and the lens homing. The quiet is 30 s. Over 1901 motion, lid-image and hunt requests in the bench reference's logs, half came within 1.6 s of the line before, 99 percent within 12.8 s, and two after more than 30 s (32.8 and 62.2 s). return_head goes: cloud.mode-switch hands its cloud stretch back through the cloud client's log (homeoff.cloud_mode_return), and nothing else called it. RETURN_MAX_MM moves to homeoff, its one user. Proof: tests/test_cloud_suite.py's new case lands a motion, a lid image 0.8 s later and the next move 1.6 s after the motion, with the quiet at 1 s: the quiet comes after the move. With the old activity marks it is declared after the image, before the move. forgetest 499 OK. Acceptance: cloud.mode-switch gates the change on a machine, its re-hunt waited out before the switch back. The fingerprints of every test in suite/cloud.py and of every module that imports from it move: 29 tests, the cloud tests, events.button-telemetry, the exthost tests, homing.cloud-offsets and setup.check-envelope. |
||
|
|
cfef6a1b84 |
forgetest: the camera-home tests hand the head back where they found it
homing.cloud-offsets, setup.check-envelope and cloud.mode-switch let the
service move the head, and ended with it where the service left it: at
the camera home, or, in cloud.mode-switch, under the camera, where the
service's re-hunt had taken it. Each told the baseline the counters had
been re-zeroed at the starting position, so the hand-back saw nothing to
do. No counter reading can say where the head was found: every service
motion zeroes the counters at its start, and the home and every
controller start zero them again.
suite/homeoff.py now holds the helpers for a test that lets the service
move the head:
- session_travel sums a client's own record of each motion's end ("end
positions (x, y, z)", the counters the motion zeroed at its start, in
x8 steps, the one mode a service motion runs at). A motion with no end
on record, or a log rotated under the run, leaves the travel unknown.
- camera_home_return drops the camera home, jogs the head back by the
session's travel plus what the counters read since the home, starts
the controller once more so the counters read zero where the head
began, and tells the baseline. cloud_mode_return does the same for a
stay in cloud mode, from the cloud client's log.
- A travel that cannot be known fails the run and moves nothing. A
hand-back that fails while the test is already failing is logged, and
the test's own failure is the one reported.
- judge_whole_motions fails a homing motion that stopped short: the
position declared after it is false.
cloud.mode-switch imports the helpers inside its function, so no other
cloud test's fingerprint moves. Its cloud stretch no longer goes through
return_head, which read 0/0 after the controller's start and left the
head under the camera.
Proof: tests/test_camera_home_return.py, 15 cases on the machine's own
log lines (a session stopped short, a whole three-motion one, a cut log,
a refused motion, homed and unhomed counters, a restart since the home,
the first failure winning). tests/test_cloud_suite.py's mode-switch fakes
now zero the counters and remove the anchor at a controller start, write
the anchor at the home, and move the counters on a jog: 10 cases, a
failed hand-back that must not hide the test's failure among them.
forgetest 498 OK. On the bench reference, with the driver and the runner
fixes: homing.cloud-offsets, setup.check-envelope and cloud.mode-switch
PASS, each ending with the head where it was found; cloud.mode-switch
jogged its cloud stretch 246.06/139.01 mm back with the counters across
the jog agreeing, and every baseline was clean.
Acceptance: the three tests are the change; their fingerprints move and
no other test's does.
|
||
|
|
d40ebb090e |
cool_report_test: the homing runner is handed the report secret
The runner case asserted that the homing runner the controller starts does not inherit GF_REPORT_SECRET. The runner is the reporter while a gfcloud $H holds the machine (the controller sends no report then), so the assertion described the defect: every camera home ran with nobody reporting, and the cooling engine's hung-controller dead-man stopped the session's first motion. The case now requires the runner to be handed exactly the controller's secret, and two new cases require that it be handed none when the controller was started with no secret (runner-none) or with one that is not 32 hex digits (runner-malformed). The harness's descriptions in scripts/bench/README.md and in the bench page's catalog say so. Proof: all 11 cases pass on the driver that hands the secret over (grblHAL-glowforge, "gfhome: the homing runner is handed the report secret"); the driver before it fails the runner case: "the homing runner was handed [], not '0123456789abcdef0123456789abcdef'". Acceptance: a host harness in the driver's CI; no catalog test runs it. forgetest/forgetest/bench.py changes one description string, which no test judges. |
||
|
|
ef2390f7d4 |
forgetest: a takeover hands back the cloud client it found
In cloud mode forgectrl's start at the end of a takeover starts a cloud
client, and what that client is comes only from gfcloud's one-start
markers, which the client that starts reads and takes down. The takeover
started it bare: the online client, with the service's connect-time
hunt. Seen on the bench with exthost.armed-freeze, which runs its print
under the offline service and puts the setup record back under a
takeover: the machine came back online in cloud mode, the hunt homed the
head at 14:21:01, the run's result was written at 14:21:02, and the
service sent a head move at 14:21:08, after the run had ended, where the
next test's switch to GRBL mode would have cut it off. It also broke the
harness's own rule that every cloud client start it makes comes up
without the hunt.
The takeover now reads, on entry, whether the running cloud client is
the offline service: the client itself holds the listening socket at
/run/gfcloud-offline.sock (hw.listens_on reads /proc/net/unix and the
process's own descriptors, so a socket file another process left behind
does not count). forgectrl's start is made under the no-hunt marker in
cloud mode, and under the offline marker as well when the offline
service was found. After the start the client that came up must listen
on the offline socket within 60 s; a miss is recorded on the run's
baseline capture and the post pass turns it into a leftover ("cloud
client at the takeover end", not restored: a cloud test starts the
client it needs), which fails the run. A marker no client read (the
gate, standby, a fault) is taken down, so a later start never comes up
under it. The judge never breaks the exit path.
Proof: forgetest's unit tests pass on the host (481 OK). The new
tests/test_takeover_client.py drives a real enter and exit against the
fake forgectrl, a fake /proc, and an init script that plays gfcloud's
start: the offline service comes back offline, an online client after
it fails the run, a start that never happened takes the markers down,
an online client comes back without the hunt, a stale socket file is not
the offline service, GRBL mode starts under no marker. Negative
controls: without the markers four of them fail, without the judge
three. Bench drill on the bench reference with this package from /tmp:
GRBL mode, the offline service started as enter_offline starts it (pid
4834), a takeover; the client forgectrl brought back (pid 4898) read
both markers, logged "OFFLINE service", listened on the socket, and in
the 10 s after had no web session, no hunt, and no motion. The bench was
then handed back to GRBL mode as found.
Acceptance: runner.py, baseline.py and hw.py are in no test's
fingerprint, so no result moves; the catalog test that exercises the
change is exthost.armed-freeze, whose takeover now fails the run unless
the offline service comes back.
|
||
|
|
82869cc45c |
No acceptance test requests a counted release file
update.job-locks started a real download in every campaign: POST /update/download fetched the published release's forgefirm.fw, whose GitHub download count is how installs are tracked. ext.catalog asked POST /ext/catalog/refresh, which fetches the extension index's fixed address, index.ffi, counted the same way. update.job-locks now starts the download job with probe=1, forgectrl's probe download: the release's acceptance.json through the download's own path, refused by the signature check and discarded. The record is small and the job can end within a second, so the test writes the settings at once after the 202 (the lease is taken before it) and runs the probe again, up to three times, when the job ended first. A posted job is refused in the job's name beside it, /status names the holder when it is read while the job runs, the job must end refused by the signature check, and the staged download must be as found (size and mtime) with no probe file left. Its covers gain forgectrl's src/relcheck.*. ext.catalog no longer asks the refresh. The refusals before a fetch stay, and the index kept must be unchanged after them. The refresh (curl, https alone and bounded, 502 in curl's words, 409 in the host's, the file removed) is proven on the host by forgectrl's extpkg_test with a stand-in for curl, and the fetch of a listed package and the tiers an install takes from it by forgeext's install_test, as the description now says. Nothing else in the tree requests a counted release file: the installer's own request of forgefirm.fw is an install, and its unit test puts a stand-in curl first in PATH. Proof: forgetest's unit tests pass on the host (474 OK, 4 skipped) and pyflakes finds no undefined name. Acceptance: the change is the two catalog tests themselves; their fingerprints move, and update.job-locks now covers src/relcheck.* too. |
||
|
|
b7dd23d539 |
forgetest: a takeover judges where the head stands
forgectrl's start at the end of a takeover is a controller start, which
re-zeroes the step counters wherever the head then stands. After it a
head left out reads as home, so the baseline's position check after the
run could not see a head a test left out before its takeover.
setup.check-envelope did exactly that on 2026-09-23 and passed (fixed by
|
||
|
|
308a033146 |
laser.verdict-cut judges the hold by the gated output; laser.disarm-in-hold presses at the arm
Both tests failed for the operator on the bench reference on 2026-09-24, image 20260923232513, from the harness and not the machine. laser.verdict-cut (00:10:52) needed the kernel's sampled LASER_ON count to read 0 within 2.5 s of the hold. That count latches once a second over the second before, so it reads 0 only once a whole window has closed inside the hold, up to 2 s in, and this hold lasted 1.37 s. Every sample of the hold read bit 0 of interlock_circuit as 1: the gated LASER_ON, active low, dark. The dark judge now reads the gated output itself, cnc/laser_on (1 = on), about 3300 times a second on the bench reference, yielding the CPU between reads (the stream threads run SCHED_FIFO). Its witness is the same reads over the second before the pause, which must see the cut lit (10 reads or more, none failed). The hold is dark when every read that falls wholly inside it, from 0.3 s after the first Hold:0 to the last Hold row, reads off, none failed, and there are at least 500. The 0.3 s is the pause tier's first deceleration, lit on purpose, still playing out of the driver's 200 ms queue and its 10 ms lead when the controller reports Hold:0. The daemon's pause is 3.0 s, as the code already had and the description now says. dark_span() is pure, kept inside the test's function, and tests/test_laser_verdict.py runs it through the function's code object over synthetic trails (8 cases: a dark hold, the lit deceleration inside the drain, emission after it caught, a burst the resume ends, failed reads counted, the drain counted from the first Hold:0, no Hold:0, and a hold too short for the drain). laser.disarm-in-hold (00:06:23, 180 s) pressed the button through ctx.act at once, before the job reached its arm wait, so the press was lost and the move never started. It now uses ctx.arm_press(), which presses when the button lights, and waits for Run. Proof: forgetest's unit tests pass on the host (474 OK, 4 skipped). Only the fingerprints of laser.verdict-cut and laser.disarm-in-hold move. Acceptance: the change is the two catalog tests themselves; both are attended (laser emission, the operator present) and a campaign runs both again. |
||
|
|
dc9bede034 |
forgetest: the first /state parses each suite module once
The first GET /state after forgetest starts hashes every test's implementation, and it took from 81 s to more than 12 minutes on the bench reference. Two causes: - sibling_imports() read and parsed a test's module and every sibling it imports, transitively, once per test: 280 ast.parse calls for 26 suite modules. module_parts() kept its own parse, sibling_imports() kept none. - The page gives up on a poll after 20 s and polls again; the server thread goes on computing. Each new poll started the same cold work beside the first, all of it on the one CPU, so the longer the first took the more copies ran. That is the spread between 81 s and 12 minutes. Each module's text and tree are now read and parsed once and kept, as are its direct sibling imports, and the implementation hash is filled by one thread at a time: a poll that arrives while it is computed waits for it instead of repeating it. catalog.forget(path) drops what is kept about a file, for the unit tests that edit their modules. The hashes do not move: every test's implementation hash and domain fingerprint on the image manifest of 20260923220034 is byte-identical before and after, so no result is invalidated. On the host the cold computation went from 7.00 s to 0.29 s (280 parses to 25). On the bench reference (the file bind-mounted on image 20260923220034, the page open) the first /state answered in 8 to 12 s after a restart, where the same restart earlier in the evening had not answered after 7 minutes. tests/test_responsiveness.py pins both: each suite module parsed at most once while every test's implementation hash is computed, and three threads reading one test's hash compute it once. With the old behavior put back by a patch both fail (280 parses for 26 modules; the hash computed 3 times). forgetest's unit tests 454 OK. forgetest is the dev-only harness, outside the catalog's coverage; its catalog consequence is none, since no fingerprint moves. |
||
|
|
56af6eaab2 |
setup.check-envelope: the head goes back to the home, or the run fails
The test's cleanup jogged the head back to the camera home through the panel's relative jog, the increment taken from the port's position. The core refused it every time (error:15, "the move would leave the work envelope"), eight tries each run, and the test logged "the head is not back at the home" and passed. The takeover that puts the setup record back restarts the controller, which zeroes the kernel counters where the head stands, so the baseline read (0, 0, 0) and called the machine clean. On the bench reference the head was left 10 mm from the home in X and in Y by every run of 2026-09-23 that got that far (three PASSes and one FAIL, images 20260922225653, 20260923084705, 20260923220034). Why the jog was refused: the port reports the step counters, while the core adds a relative jog to its own parser position. The bed check's last jogs, sized from the counters, leave the two apart by up to half a step. The arithmetic reproduces every logged value: the parser held X 60.1 after the answers, the port read 60.099, the check jogged -50.099 toward X 10, so the parser took 10.001, which the step grid (213.33 steps/mm) turned into 2134 steps, read back as 10.003. The test's return of -10.003 then targeted X -0.002, and a camera home's envelope begins at exactly 0, so the core refused the whole jog. No product path returns the head by a relative jog sized from the counters; the panel's Jog card sends fixed steps. Now the head goes back in one jog from a Grbl client to the home in machine coordinates ($J=G90 G53, 0.5 um inside the envelope's start, far under half a step), and the run fails unless the port reads the head on the home's step. That reading is taken before the restart, because nothing after it can see a head left out. Bench reference, image 20260923220034 with the suite file bind-mounted: a negative control that aims the return 1 mm off the home FAILs with "the head is not back at the home (0.0, 0.0, 3.08): [0.998, 0.998, 3.08]" while the baseline still says clean; the fix PASSes with "$J=G90 G53 X0.0005 Y0.0005 F1200 -> ok" and the head at [0.0, 0.0, 3.08]. forgetest's unit tests 454 OK; the coverage lint passes with --enforce on the image's manifest. |
||
|
|
c95b5aa771 |
update.job-locks: ask the releases API when nothing was checked
The test took the release to download from GET /update/release, the kept answer of the daily check. That answer does not outlive a restart of forgectrl, and a campaign restarts it many times after update.release-check has run, so on the bench reference (image 20260923084705) the test found "not checked yet" and failed with "no release is published to download". When the kept answer was never checked, the test now asks POST /update/check itself, the way update.release-check does. PASS on the bench in the unattended queue (the check asked, 200; the download of v0.0.6 held the lease and ended; no archive left behind); forgetest's unit tests 452 OK. |
||
|
|
d45520c218 |
exthost.motion-jog: the test needs GRBL mode
The package's jog goes through the controller port, which only the GRBL controller serves. The test declared no mode, so it ran in whatever mode it found. Alone it always found GRBL mode; in the unattended queue it runs after the cloud tests, which leave the machine in cloud mode, and on the bench reference (image 20260923084705) the jog came back 409 "the GRBL controller is not running". It now declares mode="grbl", so the runner switches before it starts. The other tests without a mode were checked for the same need: none of them moves the machine or reaches the port (update.job-locks posts a job, and the job route refuses on the lease before anything else, in either mode). exthost.motion-jog PASS on the bench in the queue after the cloud tests; forgetest's unit tests 452 OK. |
||
|
|
2870daa2aa |
logs.routing: forgeext is one of the loggers
The extension host has had its own logger (log_forgeext_disk and log_forgeext_remote, a block in the rendered rules, and /data/log/forgefirm/forgeext/) since it joined the image, and GET /logs reports it. The test's list of loggers never gained it, so the first run of logs.routing on an image that carries forgeext failed on the bench reference (image 20260923084705): "loggers reported: [... 'forgeext' ...]". The list now names forgeext, so the test also holds its rules block, its effective levels, and its directory, and a stray program's line must not land in forgeext.log either. logs.routing PASS on the bench with this file mounted; forgetest's unit tests 452 OK. |
||
|
|
82c354d70e |
setup suite: the port wait, and the first run's accounts on a failure
Two harness faults found on the bench reference in the campaign on image
20260923084705.
setup.gate-blocks-controllers probed the Grbl port once, at the moment
GET /mode first read the controller running. The supervisor reports
running from the fork and the controller binds TCP 23 a few tens of
milliseconds later, so the probe failed whenever the 0.25 s poll landed
inside that window ("the Grbl port is closed with the controller
running"). Timed on the board with the controller restarted through
forgectrl: /mode read running at 0.066 s, the port accepted at 0.087 s.
The test now waits up to 10 s for the port (50 ms polls) and records
how long it took (port_open_after_s). PASS on the bench (0.001 s).
first_run() replayed the system accounts (forgefirm-users reload) and
removed the temporary home only after its block succeeded. When the
block failed, the records came back but /etc/passwd kept the temporary
account in place of the operator's, and image.health failed after it
(the record's account missing from /etc/passwd). The replay and
the home removal now run in a finally. Proof on the bench: the failing
case run on purpose (setup.first-run-flow against the image's own
forgectrl, which refuses a home by hand at the cloud step) leaves the
operator's account the one account from uid 1000 up and no temporary
home; the same failure before the fix left the temporary account in
/etc/passwd and its home in /data/forgefirm/home.
forgetest's unit tests: 452 OK. setup.first-run-flow and
setup.gate-blocks-controllers PASS on the bench with this file mounted.
|
||
|
|
853f862f03 | Update release | ||
|
|
ac2456261e |
The measured envelope's harness, and setup.check-envelope
scripts/bench/envelope_test.py drives the null-sink controller with the scripted sender and port client of ctlport_test.py, homing manually: unset keys are the axis travel; envelope_x_mm and envelope_y_mm are the far edges at a home and are held to 50 mm up to the travel plus 30 mm; the port's envelope open is refused before a home and under an open armed window, and lets the port's jogs reach the travel plus 30 mm; envelope apply puts the keys' edges back without a home; a status poll and an empty line leave an open envelope open, while the sender's first line closes it before the core reads it (a jog into the margin draws error:15), a running port jog canceled first; a soft reset and the port client going away each close it. It is registered in the bench registry as a CI harness, not a bench-page tool. setup.check-envelope (suite/bedsize.py, its own module): with cloud mode on, the machine is homed with the camera, and the Bed size check runs with its jog prompts answered as the page would, out 60.1 mm in X and 61.1 mm in Y from the home. The ends it reports are the home plus those, the far edges 1 mm short of them are written as envelope_x_mm and envelope_y_mm, and the controller holds them at once: a panel jog 0.5 mm short of each edge runs, and one 0.5 mm past it is refused with nothing moved. A second run is opened, and while its first prompt waits a Grbl client sends one line, which draws the controller's word that the envelope closed and then ok; the port then says the envelope is closed, the next answer ends the check in words, and the keys are as the first run left them. The head is jogged back to the home, and the keys, the homing mode, and the setup record are put back as found, the record under a restart. Proof: envelope_test.py ALL PASS on the host build of the controller of this change. On the bench reference, with that controller and forgectrl of the same change bind-mounted: the first two campaigns found flaws in the test, not the check (jogs of 20 mm made an edge below the 50 mm forgectrl refuses; the second run started while the machine was still busy; the head was not returned to the home), then setup.check-envelope PASS, with forgectrl.auth, kernel.latch-locked-idle, motion.pacing, motion.jog-roundtrip, and motion.port-jog PASS in the same campaign, and cloud.mode-switch, motion.soft-limits, and homing.cloud-offsets PASS after it. forgetest's unit tests pass (452), and the coverage lint passes with --enforce. |
||
|
|
8fc07c47ee |
forgetest: update.job-locks and homing.cloud-offsets
update.job-locks (suite/updlock.py, its own module): with a release published on the releases API, POST /update/download starts the download job (202), which fetches and checks the release's archive and applies nothing. While it runs, /status names update:download, of kind system, as the lease's holder, and a settings write and a posted job are each refused in its name (409). When it ends, the lease is free and the same settings write is taken. The downloaded archive is removed when it was not there before. homing.cloud-offsets (suite/homeoff.py, its own module): with cloud mode on, homing_mode = gfcloud, and the camera-home offsets at (4.5, -3.25), $H runs the web-service homing session, and when it ends the controller declares (4.5, -3.25) on the step grid. The work envelope reaches back to the home on the negative axis: a jog 2 mm out and 2 mm back in Y and then in X is accepted and ends at the home. The homing mode and the offsets are put back as found, each retried until the daemon takes it, since a settings write is refused for a moment after the suite's Grbl client closes. Proof: on the bench reference, update.job-locks PASS after update.release-check, and homing.cloud-offsets PASS in a campaign with forgectrl.auth, kernel.latch-locked-idle, and motion.pacing, which also PASS. forgetest's unit tests pass (452), and the coverage lint passes with --enforce. |
||
|
|
69b56ec1ff |
forgetest: exthost.wizard, a package's own check on the Setup page
exthost.wizard (suite/extwizard.py, its own module): a package with the reference id that asks for wizard, whose service runs a check on its call socket and keeps whether it passed. GET /wiz lists it among the extensions as pkg:<id> with the package's title, not done. POST /wiz/pkg:<id>/start runs it on the wizard runner: GET /wiz/dark shows the package's log line, phase, progress, and confirm prompt, and the machine lease stays free while it waits. The operator's Yes reaches the service as yes, the run ends complete with the package's summary, and GET /wiz then lists the check as done from the package's own answer. The machine's setup record is the same before and after, with no pkg: wizard in it and the gate as it was. A second run is aborted from its prompt, and the service is told. A check of a package that is not installed is 404. Proof: on the bench reference, with forgectrl and forgeext of the wizard change bind-mounted, exthost.wizard PASS, beside setup.check-sensors, exthost.service, and exthost.package-routes PASS on the same daemons. forgetest's unit tests pass (452), and the coverage lint passes with --enforce. |
||
|
|
99d9830991 |
The M-code barrier's harness, and exthost.mcode
scripts/bench/mcode_test.py drives the null-sink controller with the scripted sender and port client of ctlport_test.py: the port's mcodes table and its refusals, error:20 for a number nothing answers, an answered M-code's wait (the head still, the port's state naming it with its words, a port jog busy:mcode, stale and out-of-form answers refused, the answer's words on the console), a refusal and a timeout each holding the job, a soft reset ending the wait, and a wait under an open armed window with M3 S500 shipping no FIRE tick. It is registered in the bench registry as a CI harness, not a bench-page tool. exthost.mcode (suite/extmcode.py, its own module): a package with the reference id that asks for mcode:160 and job_time.run, the one granted, and whose service answers POST /mcode on its call socket. The host's status names it as the one that answers M160. A dark job (out 5 mm, M160 P1, back) through POST /job waits at the M-code with the port's state naming it and the head still over 1 s (the kernel's counters and the port's position), the service answering after 2 s; the job then ends done, every line acknowledged, the head back, the service asked once with the code and its words, no discharge. A job naming M161 fails at that line with error:20 and nothing moved. A job at M160 P2, which the service refuses, is held, and is aborted from there. A job left running by a failed check is aborted before the put-back. Proof: on the bench reference, with the controller, forgectrl, and forgeext of this change bind-mounted, exthost.mcode first FAILED and found the controller announcing the M-code before the kernel had played the last move (the driver now waits for the kernel to go idle), then PASS; exthost.service, motion.job, exthost.page-call, exthost.motion-jog, motion.port-jog, and exthost.package-routes PASS on the same daemons. mcode_test.py ALL PASS on the host build. forgetest's unit tests pass, and the coverage lint passes with --enforce. |
||
|
|
4e47b7fbf1 |
forgetest: exthost.catalog, and the coverage lint no longer allows an advisory away
exthost.catalog (suite/extcat.py, its own module): GET /ext/catalog answers the index the host keeps and the one address it is fetched from. On a scratch root under /tmp, with a throwaway key standing in for the OpenGlow extension key, the machine's own forgeext keeps an index signed with it, and the author key it names for one id makes a package of that id read as community and endorsed, where before it was unverified; the same key on another id counts for nothing. On the machine's own root that index is refused in words, a package handed over as an index is refused by the product gate, and the index kept is left as it was. The relay refuses an id with no such form (400) and one the kept index does not list (404, or 409 with none kept) before anything is fetched, and a refresh from the fixed address keeps OpenGlow's index when one is published there and is 502 in curl's words when none is, the kept index left as it was. Nothing is left in the staging directory. The coverage lint had a gap: coverage_report() let the allowlist's docs/** and **/*.md take out a path the BEHAVIORAL list keeps in every fingerprint, so the four first-run advisory documents were covered by no test and the lint passed. A change to the privacy advisory would have invalidated nothing. A behavioral path is now never allowed away, and setup.advisories-rehash, which accepts every first-run document at its current hash, covers the four. Proof: on the bench reference, with forgectrl 848ccc1 and forgeext a64b933 bind-mounted and the privacy document accepted again at its new hash with the fixture's press, exthost.catalog PASS (the refresh was 502: GitHub answered 404, nothing is published at the address yet), and setup.advisories-rehash PASS with the rest of the campaign. The lint's new unit test reports the uncovered advisory, and the old reading (the override ignored) reports nothing for it, which is the gap. forgetest's unit tests pass (452), and the coverage lint passes with --enforce. |
||
|
|
675fc11cb1 |
forgetest: events.button-telemetry and exthost.lifecycle
Two new tests, each in its own module so that no other test's fingerprint moves. events.button-telemetry (suite/evmore.py): a stream opened at forgectrl's read-only listener, from a loopback source of its own, gets a telemetry.tick within 14 s with exactly its fields (phase, verdict, fire_ok, down_c, up_c, state, lid). With the machine idle and nothing waiting for the button, one press (the fixture's, or the operator's) arrives as pressed true and then false, in that order. That a press the machine is waiting for is no event, and the update and setup edges, are events_test's. exthost.lifecycle (suite/extlife.py): a package that follows its feed is running when extensions are turned off. It reads ext.shutdown with the host's reason and writes it to its data directory before its process ends, the host stops it a second later, and nothing is frozen in that second. Its covers name forgeext's run, machine (the armed question asked during the grace), evfeed, and api. Proof: on the bench reference, with forgectrl a9c45fc and forgeext dbb00e5 bind-mounted, both PASS. exthost.lifecycle FAILed on the host before its grace fix (the service frozen for want of an armed reading) and PASSed after it. forgetest's unit tests pass (451), and the coverage lint passes with --enforce. |
||
|
|
71b44598eb |
test_cloud_suite: the quiet wait's deadline is no longer the test
The cloud suite's host tests replay a print's log from threads, and the suite's quiet wait gave up after 3 s (QUIET_TIMEOUT_S in setUp). On a loaded host the last replayed lines landed after that, and a test failed with "still running service moves after 3 s" instead of its own finding: about one run of the module in five, a different test each time, alone as well as under the full suite. The deadline is now 15 s. It is a deadline and not a wait: a quiet machine is seen at once, so a passing run is no slower. The one test that waits the deadline out on purpose (a motion that never goes idle) sets 3 s for itself; tearDown puts the module's value back. Proof: before, test_cloud_suite failed 2 of 10 runs by itself, every failure the quiet deadline (test_lid_during_button_wait_on_the_bench_excerpt, test_pause_resume_fails_without_the_retraced_restart, test_mode_switch_fails_when_gfhome_never_saw_the_head_move, test_pause_resume_fails_when_the_kernel_refuses_the_resume). After, 10 of 10. forgetest's whole unit suite: 451 tests OK, 4 skipped, in 492 s against 491 s before. A host-test change: no catalog consequence. |
||
|
|
fab8920cd9 |
exthost.operator-destinations: the operator names where a package may connect
A new catalog test in a module of its own (suite/extdest.py). It installs the reference package's id and key asking for net.outbound.operator, with a service that reports at every start what GET /v0/self says it may reach and what one dial to the machine's DNS resolver on TCP port 53 does, and one to the port above it. The resolver is an address every network gives the machine, that is not the machine, and that answers; named by address, it gives the service no resolver of its own. It holds: before anything is named, no destination and the dial refused inside the sandbox (EACCES), and no rule for it in the package's chain; the machine's LAN address and loopback refused in the host's words, a destination out of form 400, an unnamed removal refused; named through POST /ext/dest, the service started again with the destination, connected, still refused one port up, and the chain carrying the rule, with no crash; taken away, started again and refused again, the rule gone. A resolver that takes no TCP connection even from root fails the test in words rather than passing it on nothing. covers: forgeext src/install.*, state.*, run.*, super.*, api.*, caps.*, manifest.*, netrules.*, main.c; forgectrl src/extpkg.*, main.c. Proof: on the bench reference (image 20260922225653, with forgeext 28f829e and forgectrl e2d0d05 cross-built and bind-mounted, and this suite bind-mounted over the installed one), exthost.operator-destinations PASS in 22 s, with exthost.service, exthost.page-call, exthost.ui-delivery, exthost.package-routes, exthost.events, and exthost.panel-install PASS in the same campaign. The first run of it failed in its own reading of /etc/resolv.conf (bytes against text); the fix is in the test's module alone, and the PASS is on the fixed module. |
||
|
|
861f5597e6 |
exthost.page-call: a package's page asks its own service
A new catalog test, in a module of its own (suite/extcall.py) so that no other test's fingerprint moves. It installs the reference package's id and key with a page and a service of its own, whose service reports what it was handed and answers its page's calls on the listening end the host gave it. It holds: FFX_CALL_FD=4 with descriptor 4 a listening socket and 3 and 5 closed; the socket root's 0600 in root's 0700 directory; POST /ext/call relaying a POST and a GET, the service's own refusal passed through as its status and words, and a service asking the API while it answers; five calls out of form refused, 400 by the relay or 409 in the host's words; forgeext call answering alike; and a disabled package refused in words, its service stopped and its socket's name gone, then answering again once enabled. The package, the key, the setting, and the setup record are put back as found, and no socket name is left. covers: forgeext src/call.*, src/run.*, src/sandbox.*, src/main.c; forgectrl src/extpkg.*, src/main.c. Proof: on the bench reference (image 20260922225653, with forgeext 5c13f1c and forgectrl 2cf6b12 cross-built and bind-mounted, and this suite bind-mounted over the installed one), exthost.page-call PASS in 16 s, and exthost.service, exthost.ui-delivery, and exthost.package-routes PASS in the same campaign. forgetest's unit tests: 451, one failure in test_cloud_suite, whose replayed prints flake on their timing alone as well (1 of 3 runs of that module by itself, in a different test each time) and touch nothing of this change. |
||
|
|
b7e7c0fb2c |
Manifest: the advisories are behavior, and forgeext's kit and packages are not
forgectrl embeds its advisory documents in the binary, serves them, and records the operator's consent to one by its hash, so an edited advisory is a changed consent. They are Markdown under docs/, which the non-behavioral list takes out of every fingerprint, and that left setup.extensions-consent's covers entry for docs/advisories/extensions.md selecting nothing: the enforced coverage lint fails on it (exit 1 on the dev image's manifest of 20260922225653), and an edited advisory moved no fingerprint at all. A BEHAVIORAL list now keeps forgectrl's docs/advisories/** in, ahead of the non-behavioral one. forgeext's recipe installs the binary and its init script and nothing else, so packages/ (the official packages, which carry their own acceptance artifact), sdk/ (the author's kit), template/ and tools/ are non-behavioral for the image: without that, an edit to the alignment page or the kit would make every exthost test stale on the next image. Proof: test_manifest passes its 25 cases, the new ones among them; the enforced coverage lint on the dev image's manifest of 20260922225653 exits 0 with no empty entry and nothing uncovered, where it exited 1 before. The whole forgetest suite ran its 451 tests; one, test_cloud_suite's test_pause_resume_passes_on_the_machines_lines, errored under the suite's load and passes 16 runs of 16 alone, at HEAD and on this tree alike: it replays a print against timed hooks. |
||
|
|
2f51e38178 |
exthost.service: a package's frame with the lamp named
The reference service takes two more lid-camera frames through its API socket, with the camera's lamp at 0 and at 1023, and one with a lamp past its range. The test holds both named frames to being JPEGs, the lit one the larger (the lamp reached the camera and changed the picture), and the out-of-range one to a 400 that names the lamp. The service's text is shared by every exthost test, and every one of them already covers forgeext's api.c and run.c, which this change moves, so no test is made stale that was not stale already. Proof: on the bench reference, image 20260922225653 with forgeext cd8b471b and forgectrl 9c9ee557 bind-mounted, exthost.platform and exthost.service PASS: 51,561 bytes at lamp 0, 109,850 at lamp 1023, and lamp 5000 refused. |
||
|
|
d9cc61f198 |
forgectrl.panel-serves: the page lets no frame navigate
The panel page now carries Content-Security-Policy: frame-src 'none'
(forgectrl 95970cf), the one thing that keeps a package's page from
navigating its own frame to another address with what it was shown in
the URL. What the header does in each browser is the frame-isolation
harness's to prove (forgectrl tools/frame_isolation.py); this test holds
the machine's own served page to carrying it, read with the headers from
the loopback listener. The edit is inside the test's own function, so no
other test's fingerprint moves.
Proof: on the bench reference, image 20260922225653, PASS with forgectrl
95970cf bind-mounted ("GET / Content-Security-Policy: frame-src 'none'"),
and FAIL against the image's own daemon: "the panel page lets a frame
navigate: 200 None".
|
||
|
|
1bdc11f6fe |
exthost.ui-delivery: the refusals in the host's words, and the settings
The test held a missing package's page to any status of 400 or more, and the relay's 502 passed it: the host's refusal lost on the way to the panel looked the same as a refusal. It is 404 in the host's words now. The same relay carries a package's settings, which no test asked for through forgectrl: the test reads them, sends a value over its bound, and holds the answer to 400 in the host's words with the stored value unmoved. The edits are inside the test's own function, so no other test's fingerprint moves. Proof: on the bench reference, image 20260922152358, the image's own forgectrl fails the test (a disabled package's page 502, "the extension host did not answer"); with forgectrl 8055000 and this file bind-mounted it passes: 404 "that package is not installed", 404 "this package is disabled: its interface is not served", and 400 '"threshold" is at most 100' with the threshold still 40. |
||
|
|
625d0acec7 |
exthost.core-range: the evidence is a dict
The test wrote its evidence through ev.note(), which the run's evidence does not have: it is a plain dict, as every other test uses it. On the bench the test stopped at its first record with an AttributeError, an ERROR before anything was asked of the host. The records are keys now, and the three ranges are one list. The description said a package asking for a range was installed; the test only inspects, and it says accepted. No host test runs a suite function, which is how this reached the image. Proof: on the bench reference, image 20260922152358 with this file bind-mounted over the installed one, exthost.core-range PASS: the image stamps "20260922152358", which is no version; inspect answers core_checked false with no range, with a minimum of 99.0.0, a maximum of 0.0.1, and both ends, and installs nothing. |
||
|
|
d410c6f696 |
manifest-from-tree: forgeext is a component
forgeext ships from its own repository with its own recipe and pin, and 62 covers entries across the catalog name it - but the tree manifest the coverage lint runs on did not know the component existed, so every one of those entries selected nothing. The lint exits 1 on an empty entry, so the merge would have failed on it; worse, until then forgeext's sources were not coverage-checked at all. A pin nothing has been pushed to yet (SRCREV all zeros, which is how a work branch carries this component until the merge) names the component with no files instead of failing the whole manifest. The lint then reports its entries as empty and exits 1, which is what an unbumped pin should do, and the script stays usable in the meantime. Proof: the script names forgeext and says its pin is not set; with the component's file list stood in, the 31 empty (forgeext, ...) entries become zero, and the 32 that remain are forgectrl and grblHAL paths that do not exist at their pinned commits yet - the pre-push state the work branches are in. |
||
|
|
a552541acd |
logs.tree-tail-export: the bundle's extension listing
The bundle carries system/extensions.json now. The case reads it out of the tar, holds it to being the status document, and holds every listed package to naming its id, version, tier, whether it is enabled, and what the operator granted it - which is what a report from a machine running extensions has to be read against. covers gains forgectrl's src/extpkg.*, where the document is built. |
||
|
|
5915408147 |
exthost: the effective capability list, and a disabled package's page
exthost.package-routes reads the new `effective` field beside the manifest's own list: what the package may use is never wider than what it asked for, holds everything the operator granted, and the panel's list and the host's own command line say the same thing. That is the list the panel's bridge decides on, so it is the one the case has to hold. exthost.ui-delivery gains the disabled package: its page is refused in words, and served again once the operator enables it. Disabling a package is the way out of everything it does, and its interface was the one door that stayed open. Both are changes inside the tests' own functions, so no other case's fingerprint moves. |
||
|
|
59887ac742 |
exthost.armed-freeze: the camera is shut for the window too
The case proved the freeze and stopped there. A package with the operator's job_time.run grant is not frozen, and a capture costs kernel-side work beside the step stream, so the window has a second half: no program takes a picture during a cut. From inside the same real armed window the case already opens, the sampler now takes one background capture and keeps what the machine said. It must be refused (409, in words that name the window), and a second one after the window closes must come back a JPEG. The refusal is decided before any frame is taken, so the probe costs the print nothing - and if it were served instead, the capture that happened is the one the rule forbids, which is what the case is here to catch. covers gains forgectrl's src/cam.* and src/main.c, where the refusal is. |
||
|
|
4388d69816 |
exthost.core-range: the host reads the image's own version file
A package's core range is judged against the version the host reads
from /etc/forgefirm-version, and forgeext read no version at all out of
the form a release image writes it in, so a release image judged no
range. That fault is fixed and proven in forgeext 4fefdb6.
What a campaign can add to that is narrower than it first looks. The
release image does not ship this suite, so a campaign only ever runs on
a dev image, and a dev image stamps ${DATETIME} (dev), which is no
version. The branch where a range is actually judged is therefore
unreachable from here on every machine this suite can run on, and it is
not this test's to claim: version_test in forgeext holds that branch,
over the file as each image build writes it.
So this test proves the half a machine can, which is the half that ties
the daemon to the image it is on: the host reads the image's own file,
finds a build stamp, and judges nothing rather than guessing a version
out of it. A package asking for a minimum far above or a maximum far
below installs all the same, and inspect answers core_checked false. A
dev image that began stamping something version-shaped would fail here,
and that is the regression this test is for; it says so in its own
words rather than leaving a reader to wonder why it only ever takes one
branch.
It is a module of its own, and that is deliberate. A test's fingerprint
holds the shared text of the module it lives in, and the shared text is
every line outside a @test function, the blank lines between them
included - so a test cannot be added to exthost.py without moving the
fingerprint of every test there. Adding it there moved all ten,
exthost.platform (which covers linux-fslc alone) and the operator drill
exthost.panel-install among them. In suite/extcore.py it moves none:
measured across the change, 113 tests before and 114 after, no
fingerprint moved. The helpers come from exthost by import, which puts
that module's shared text in this test's fingerprint and not the other
way round.
Proven on the host: the catalog loads and registers 114 tests, the
coverage lint passes with 0 uncovered paths, pyflakes reports nothing,
and the unit tests run 451 and pass, as they do on the revision before
this one.
|
||
|
|
77e4c03369 |
exthost.motion-job: a package runs a program, dark and witnessed
The reference package is installed with motion.job granted, writes a program into its own data directory, and asks the host to run it. What it proves on the machine: a program named with a path and one that is not there are refused by the host, with the machine never asked; the real one is taken by the machine as the package, every line sent and acknowledged; the head moves and the head accelerometer sees it; and the laser latch stays locked with every emission witness at zero. The program commands no laser, so the runner ends the job as one that ended without a discharge. That is what a dark program is, and the test asserts it rather than glossing it: the whole program ran and nothing fired. A job that does fire is cloud.dark-print's and laser.emission-witness's. Three things the first runs taught. A button press needs an "until" - what the press is expected to do - and a dark program never opens an armed window, so there is nothing for a press to arm and the test does not press. The job's lease owner is prefixed "job:". And the job record's emission.samples counts samples taken, not samples that saw light; the witnesses are laser_on_samples, lit_s and thermopile_delta. Proven. The unit suite: 451 tests; install_test: 225 checks. On the bench reference, image 20260922014545 with the cross-built daemons mounted over the image's: exthost.motion-job PASS, with exthost.service, exthost.motion-jog and motion.job PASS beside it. Against the image's own daemons the route is 404. |
||
|
|
0ba0fb112b |
exthost.ui-delivery: a package's page, served and refused
A package that asks for ui must ship ui/index.html and one that ships it must ask: the test packs an archive each way and checks the install refuses both in words. With a proper one it checks GET /ext/ui hands the page back byte for byte, and that a package which is not installed is refused. Each control archive is packed in a directory of its own, because the packer makes a signing key there and fwup will not remake one over an existing file - the first version shared a directory and errored. Proven. The unit suite: 451 tests. On the bench reference, image 20260921225820 with the cross-built daemons mounted over the image's: exthost.ui-delivery PASS, with exthost.service and exthost.package-routes PASS beside it. Against the image's own daemons it FAILS: the older host installs a package that asks for ui and ships no file. |
||
|
|
f74382984c |
exthost.motion-jog: a package moves the machine, watched
A new catalog test for the one capability that moves the head. The reference package is installed with motion.jog and jogs on the test's word, through a file the service watches, so the motion happens at a moment the test chose and can be witnessed while it does. What it proves on the machine: the host's credential is 0600 and root's and holds motion.jog and not a camera; a jog past the bounds is refused by the host with the machine never asked; the package's jog moves the head and the head accelerometer sees it; and across the whole of it the laser latch stays locked and the emission witness stays at zero. The head is jogged back to where it started. The laser check reads the field names the machine actually publishes (laser_locked, laser.emission_samples). The first version of it asked for names that are not in /status, so it compared None to None and could not have failed. Proven. The unit suite: 451 tests. On the bench reference, image 20260921220023 with the cross-built daemons mounted over the image's: exthost.motion-jog PASS, and exthost.service and motion.port-jog PASS beside it. Against the image's own daemons it FAILS: the route is 404. |
||
|
|
75ef8bbc95 |
exthost.service: a package's camera, and how it yields
The reference package asks for the lid camera and not the head's. From inside its sandbox the test checks that the granted camera comes back as a JPEG with the image type to match, that the camera it does not hold is 403, and that a camera there is none of is 400. It then holds a stream open and asks the service for a capture through a file the service watches, so the capture happens at a moment of the test's choosing: it is refused in the machine's own words while somebody is watching, and served once the viewer stops. Proven. The unit suite: 451 tests. On the bench reference, image 20260921204711 with the cross-built forgectrl and extension host mounted over the image's: exthost.service PASS, and exthost.events, exthost.hold-pause-tier and camera.snapshot PASS beside it. Acceptance. exthost.service already covers forgeext's **; a package's camera is part of what a confined service can reach. |
||
|
|
2216e5d3f2 |
camera.snapshot: a background capture yields to a viewer
The test opens a stream, holds it, and checks that a capture marked background=1 is refused in words while somebody is watching, that the operator's own unmarked capture is still served, and that the background capture is served again once the viewer stops. A background value that is neither 0 nor 1 is refused. Proven. On the bench reference, image 20260921204711 with the cross-built forgectrl mounted over the image's: camera.snapshot PASS. Against the image's own daemon it FAILS at the first of the new checks, answering a parameter it does not know with a JPEG. Acceptance. camera.snapshot already covers the camera sources and src/main.c. |
||
|
|
bb28c7855a |
exthost.service: the storage quota, on the machine
The reference package declares storage:1. The test writes 2 MiB into its data directory and watches the host measure it, stop it, and show the quota's reason, then clears the data, enables it again, and sees the service come back. That is the operator's way out, so the test walks it. Proven. The unit suite: 451 tests. On the bench reference, image 20260921190848 with the cross-built extension host mounted over the image's: exthost.service PASS, and exthost.events and exthost.hold-pause-tier PASS beside it. The first run of this check caught a real bug: the reason arrived empty, because the host built it in the buffer the supervisor overwrites when it stops a service. Acceptance. exthost.service already covers forgeext's **; the quota is part of what the host does to a confined service, so it is exercised there. |
||
|
|
447f90205d |
exthost.service: the reference package has settings of its own
The package now declares three settings and, from inside its sandbox, reads them, sets two, and is refused an undeclared key and a value out of bounds. The store on disk is checked for its mode and its contents, so the test proves the host keeps them and not only that it answered. Proven. The unit suite: 451 tests. On the bench reference, image 20260921190848 with the cross-built forgectrl and extension host mounted over the image's: exthost.service PASS. Against the image's own daemons it FAILS where it must, the older parser refusing the manifest with unknown key "settings". Acceptance. exthost.service already covers forgeext's ** and forgectrl's src/main.c; the settings are part of what a confined service can reach, so they are exercised there rather than in a test of their own. |
||
|
|
952e7dc32d |
exthost.events: the machine's events reach a package
A new catalog test for the one subscription the extension host holds and the poll a package reads it with. The reference service learns from GET /v0/self whether it was granted events and polls POST /v0/events only then, so every other test keeps the half-second heartbeat those tests watch; what it polled and what it saw go in its data directory. What it proves on the machine: the host holds the stream while one package wants it and lets it go when none does; a poll that names its place and has nothing to be told comes back at its own deadline and not before, while the first poll, which only asks where the present is, comes back at once; the lid opened and closed on the fixture reaches the package as lid events, in order and numbered, which also proves a waiting poll is woken rather than left to time out; and with the three ordinary streams taken the host's subscription is still there while a LAN client that asks for the host's slot by name is refused, because only a loopback peer may claim it. The three ordinary streams each bind a source address of their own. The cap counts peer addresses, and the kernel sends every loopback connection from 127.0.0.1 whatever address it was aimed at, so three streams without that would replace each other and leave two slots free. The first run of this test said a LAN client had been admitted; it had been, correctly, to a slot the test had failed to fill. Proven. The unit suite: 451 tests, and the coverage lint passes. On the bench reference, image 20260921171224 with the cross-built forgectrl and extension host mounted over the image's: exthost.events PASS, and beside it exthost.service, exthost.armed-freeze, exthost.hold-pause-tier and events.stream PASS. Against the image's own daemons exthost.events FAILS at its first new check, as it should. The bench was left as found, GRBL mode, no mounts. Acceptance. exthost.events covers forgeext's src/evfeed.*, src/api.* and src/run.*, and forgectrl's src/events.* and src/main.c. |
||
|
|
eb0a1e7838 |
exthost.panel-install: the key goes in through the panel, and the box holds the button
The test added the owner's key by copying the file. It now adds it the way an operator does: POST /ext/key with the machine's button held. Without the button it is 409 and no file lands; a name with a space, a name that is a path, and no key at all are 400; a key that is no key is 409 from the host; with the button held it is added and listed with its id. Removed again, the same archive reads unverified, and removing a key that is not there is 409. The bench actuator's press was 200 ms, the firmware's default, and a request that must reach the machine while the button is down often missed it: the unverified install took three presses on one run and all ten on another. ctx.act() now takes `ms`, the fixture client passes it to the box (the firmware clamps it into 20 to 500), and the two button-held steps ask for 500. Both landed on the first or second press afterward. Proven. The unit suite: 451 tests, 0 undefined names (both stub fixtures take the new argument, and the fixture test pins that the box is asked for the longest press). On the bench reference, image 20260921161446 with the cross-built forgectrl and extension host mounted over the image's: exthost.panel-install PASS, exthost.package-routes PASS, exthost.hold-pause-tier PASS. Against the image's own daemons the install test FAILS, as it should. Acceptance. exthost.panel-install covers forgectrl's src/extpkg.*, src/main.c, src/auth.* and forgeext's src/main.c, src/install.*, src/pkg.*; the runner and the fixture client are harness, outside the suite and outside every fingerprint. |
||
|
|
963bded9b3 |
exthost.package-routes: the panel page carries the card, and its script is in order
The page the image serves must hold the card (extpkgs, extswitch, extfile, extstaged, extinstallphrase, extAct, loadExt) and must define loadExt before the call that runs while panel.js parses. That second check is the bug the browser found: ext.js loaded after panel.js is an undefined loadExt, and it takes the rest of the System tab's loads down with it. Proven. The unit suite: 451 tests, 0 undefined names. On the bench reference, image 20260921154037 with the cross-built forgectrl mounted over the image's: PASS. Against a forgectrl built with the scripts in the old order the order check FAILS, as it should. Acceptance. exthost.package-routes gains forgectrl's src/ui/ext.js, src/ui/index.html, and src/ui/embed.cmake in its covers map. |
||
|
|
9c36df9ee9 |
exthost.panel-install: a package installed through the panel at each tier's consent
Kind operator with the button as its action, GRBL mode, nothing moves and nothing fires; extensions stay as found and the packages never run. The reference package signed with a key nobody trusts uploads as unverified with consent button: refused without the button held (the phrase is no substitute, the staged file stays), installed while it is held, listed unverified with the hold the request granted. A person holds the button; the bench actuator's press is a half-second pulse, so the test presses again until a request has landed inside one. With the same key added as the owner's the upload reads community with consent typed, and goes through no phrase, the phrase in another case, no grant (the host's words), a grant with a shell's words, a grant that is an option of the host's, and then installs. An upload without the login, bytes that are no archive, an install with nothing staged, and a discarded upload are refused as they should be. Both packages are removed through the route and the extension root is as found, the staged file included. Proven. The unit suite: 451 tests, 0 undefined names. On the bench reference, image 20260921150233 with the cross-built forgectrl mounted over the image's: PASS (the third press landed); against the image's own forgectrl FAIL at its first request. Acceptance. The test is new. It covers forgectrl's src/extpkg.*, src/main.c, src/auth.* and forgeext's src/main.c, src/install.*, src/pkg.*, and requires exthost.package-routes. |
||
|
|
121d7d3f81 |
exthost.package-routes: the panel's package routes against the host's own state
The reference package is installed with the hold grant through the host's command line and never runs (extensions stay as found, so the test needs no takeover). GET /ext/status is refused without the login and, with it, lists the package as the host does beside enabled, safe_mode, and the host's own status with running true and the living host's pid. POST /ext/package: hold-required and hold-advisory name and un-name the package under required-holds; disable and enable change the host's state file; an action outside the closed list, an action with a shell's words in it, an id that has not the form of one, and an id with a path in it are 400 and leave the state file byte for byte; a package that is not installed is 409 in the host's words; without the login 403 with the package still there; remove takes the package, its data, and its name away. The key and the work directory are removed and the extension root is as found. Proven. The unit suite: 451 tests, 0 undefined names. On the bench reference, image 20260921140920 with the cross-built forgectrl and extension host mounted over the image's: PASS; against the image's own forgectrl, FAIL at its first request. Acceptance. The test is new: auto. It covers forgectrl's src/extpkg.* and src/main.c and forgeext's src/main.c, src/install.*, and src/state.*, and requires exthost.service and forgectrl.auth. |
||
|
|
68fd35b8b7 |
exthost: the reference package uses its API socket, and raises its own hold
The reference service talks to the host over the socket named in FFX_API and takes machine.read. exthost.service adds, from the inside: GET /v0/self names the package and what it may use; GET /v0/machine/mode is forgectrl's answer; a hold it was not granted is 403, a path the API does not have 404, a path with .. 400; FFX_API is in the fixed environment. From the outside: the socket is root's and the account's at 0660, and another pool account that connects to it gets EACCES (a new probe mode). exthost.hold-pause-tier adds the package's own word. The test leaves what to say in the service's data directory and the service passes it on as POST /v0/hold: raised, the verdict is EXT with the package's words and fire withheld; words the form does not take are refused and the hold reads as before; cleared, the verdict is OK. Then the rest as before: the host's own raise for a package that ends at every start, the operator's exits, the stale file under a suspended host. Proven. The unit suite: 451 tests, 0 undefined names. On the bench reference, image 20260921130558 with the cross-built host mounted over the image's: exthost.service PASS, exthost.hold-pause-tier PASS (the package's hold stood 1.5 s after it raised it; held 2.0 s after the service was killed; safe mode released it in 0.8 s, extensions off in 1.1 s), exthost.armed-freeze PASS with the changed reference service. Against the image's own host, which gives a service no socket, exthost.service FAILS, as it should. Acceptance. Both tests already cover forgeext whole (exthost.service) and the hold's files by name (exthost.hold-pause-tier); neither covers map changes. |
||
|
|
ee89b31c07 |
exthost.hold-pause-tier: a required hold from the grant to the verdict and out
The reference package takes the hold capability (its service ends at once when the test leaves a file in its data directory: a package that cannot speak for itself). The install is refused without the operator's grant; a granted hold is advisory until `forgeext hold <id> required` names the package under required-holds. With extensions on, the required hold stands in the host's words until the service has run healthy, then the host keeps its file fresh and clear and GET /cool/status reads OK. The test then makes the service end at every start: verdict EXT, fire_ok false, hold true, the reason naming the package, and never clear in 24 looks over the crash loop. Safe mode ends it within four seconds and leaving safe mode brings it back; marked advisory it is dropped; marked required again and the host suspended, the reason becomes that the host is not answering, and resumed it is the package's again; ext_enabled=0 ends it. The verdict is read at idle: nothing moves and nothing fires. Everything is put back as exthost.service puts it back, the required holds are empty, and the verdict is OK at the end. Proven. The unit suite: 451 tests, 0 undefined names. On the bench reference, image 20260921121235 with the cross-built forgectrl and extension host mounted over the image's: exthost.hold-pause-tier PASS (held 1.0 s after the service was killed; safe mode released it in 0.8 s, extensions off in 0.8 s); against the image's own forgectrl, whose engine knows no holds, it FAILS with the verdict OK, as it should. Acceptance. The test is new: auto, takeover. It covers forgeext's src/holdkeep.*, src/run.*, src/install.*, src/state.*, src/main.c, and forgectrl's src/holds.* and src/cool.*, and requires exthost.service. |
||
|
|
9ee277f977 |
forgefirm-users: an operator account reaches its home
On the bench reference /data/forgefirm is 0700, and the operator account
could not enter its own home under it (su scott -c "cd ~": permission
denied). forgefirm-users.init makes the directory of homes 0755 on purpose
("a login traverses it to reach its home") and said nothing about the data
directory above it. No script in the tree makes that directory 0700:
settings.c makes it 0755, forgefirm-logging makes it under rcS's umask, and
this script's own mkdir -p under umask 077 never makes it, because the
record it waits for lives inside it (forgectrl 35684ca's message names
that mkdir as a possible author, and it cannot be). The bench reference's
directory dates from 2026-09-11. The render does not depend on who made it.
replay() now puts the search bit for group and others on the data directory
at every render (boot, and every reload forgectrl asks for), whoever made
it and under whatever umask. Nothing is taken away and nothing in it
becomes listable; what is private there is closed file by file. The same
bit is what lets an extension package's account walk to its files.
setup.account-login sets the data directory to 0700 before it makes its
temporary account, as a strict umask leaves it, and then tries the account
from the inside (a child that becomes it): the render that made the account
left the directory at 0711, the home can be entered and written, the data
directory cannot be listed, and the account record cannot be read. Its
put-back (the found mode, the replay, the temporary home) now runs on every
exit path; before, a failure inside the account's lifetime left the
temporary home and its passwd line behind. image.health asks for the search
bit on a machine with an account record.
Proven. On the bench reference, image 20260921022220, the suite files
mounted over the image's: with the image's own init script
setup.account-login FAILS (cd EACCES, write EACCES, the directory still
0700), as it should; with this script mounted over /etc/init.d it PASSES
(0700 to 0711 at the account's render, cd ok, write ok, the listing and the
record EACCES), image.health PASSES reading mode 711, and the operator
account enters /data/forgefirm/home/scott and is refused the listing of
/data/forgefirm. The mounts were taken away and /tmp cleared; the
directory stays at 0711, which is the repair. The unit suite passes (451
tests, 0 undefined names).
Also on image 20260921022220 as flashed, before any of this was mounted:
image.health (its extension host section on a machine for the first time),
exthost.platform, setup.extensions-consent, exthost.service (a killed
host's service gone in 0.05 s, the host back after 5.7 s),
update.product-gate, and exthost.armed-freeze (the window open 38.6 s,
frozen 0.21 s after it opened and 6.02 s before the latch unlocked for the
run, in all 177 samples; thawed 0.41 s after the close) all PASS, and the
host logs under its own logger after a plain boot.
Acceptance. setup.account-login is the regression test, and image.health
proves the boot-time render on every campaign's first test. The init
script is layer content, in the platform identity of every fingerprint.
|
||
|
|
800d6890a4 |
exthost.armed-freeze: a package's service is frozen for a real armed window
The freeze is the rule that keeps a package off the core while a job can fire, and it had a host test against a stand-in forgectrl and one drill by hand. This is its test on the machine, against the real engine. The reference package runs, its heartbeat advancing twice a second. cloud.dark-print's own job and body (a 30 s square at S0, the fixture's press, the latch locked at the button, unlocked for the run, locked after) open a real armed window over it. A sampler reads five times a second: the engine's armed flag (GET /cool/status), the group's frozen state as the kernel reports it (cgroup.events), the heartbeat, the service's pid, and the latch. The test asks for a window of 10 s or more; frozen in every sample from 2 s in to the close, with the heartbeat still; the first frozen sample no later than the first unlocked-latch sample, so the freeze is in place before the run; thawed, with the heartbeat moving again, 3 s after the close; and one process throughout. exthost.service's put-back and its as-found checks became _put_back() and _as_found(), shared by both tests. exthost's top-level imports pulled the setup suite (and now the cloud suite) in ahead of it and reordered the catalog, so exthost is imported last: image, kernel, forgectrl, setup, logs, motion, cooling, laser, camera, update, cloud, exthost; 106 tests. Proven. The unit suite: 451 tests, 0 undefined names. On the bench reference, image 20260920211625 with the cross-built forgectrl and host from /tmp, the coolant taken from 27.2 to 25.4 C with an M8 session first (the cloud client starts no print above 27): exthost.armed-freeze PASS, the window open 37.5 s, the group frozen 0.42 s after it opened and 4.97 s before the latch unlocked for the run, frozen in all 172 samples from 2 s in to the close with the heartbeat still, thawed 0.62 s after the close. With the host suspended (SIGSTOP) across the window, so that nobody freezes anything, it FAILS: not frozen in 177 of 177 samples. exthost.service PASS again after the refactor (a killed host's service gone in 0.05 s, the host back and the service running after 5.6 s). The bench was left as found. Acceptance. exthost.armed-freeze is new: kind operator (the fixture presses), takeover; it covers forgeext's src/super.*, src/run.*, src/machine.*, src/cgroup.*, and forgectrl's src/cool.*, and requires exthost.service and cloud.dark-print. |
||
|
|
1a306d5d8d |
forgetest: a campaign's machine is extension-free
An extension package is software the image does not carry, and a result taken beside one is not a result about the image. Two places hold the line. The baseline (_ext_side, in every pre and post pass): a package under the tests' own prefix (org.forgetest.) and an owner key named forgetest-*.pub are what a test made and left behind; they are removed (forgeext remove, the key's file), recorded as restored, and the host stops the service on its next turn. A process that still runs under a pool account after that belongs to the operator's own packages: it is recorded as unrestorable and never touched. An installed package that does not run is nobody's leftover. hw.pool_pids() reads each process's Uid line; hw.ext_packages() lists the package directory. image.health (5b): the extension host is one process (/usr/bin/forgeext run), its start link sorts after forgectrl's and its kill link before it, no package is installed, and nothing runs under a pool account. Proven. test_baseline.py, ExtensionFreeTests, over a stand-in forgeext and a fake package tree: an installed package that does not run leaves nothing; a test's package and key are removed and the operator's package and key stay; a removal that fails says so in the host's own words; running extensions are reported and left alone. The unit suite passes (451 tests, 0 undefined names). The link order image.health asks for is the one the built root filesystems of image 20260921014201 have: S90forgectrl before S91forgeext, K09forgeext before K90forgectrl. Acceptance. image.health gains forgeext's init/** in its covers map; it runs first in every campaign and is the on-image proof of 5b. The baseline is harness, outside the suite and outside every fingerprint. |
||
|
|
1e98b37e45 |
forgeext on the image, and the tests of the host and of the consent
The image installs forgeext beside forgefirm-sandbox. The recipe installs the init script from forgeext's own tree (start 91, after forgectrl at 90, whose read-only routes the host takes the machine's state from; stop 9, down before it). The host runs nothing while ext_enabled is 0, the default. setup.extensions-consent (takeover): the Extensions advisory is served and is no first-run document; ext_enabled=1 is refused without the advisory's hash, with a stale one, without the phrase, with the phrase in another case, and whole beside a write the daemon refuses, each leaving the setting and the record on disk untouched; with the hash and the phrase it is accepted, recorded under on_demand.extensions, and the data directory gains the search bit and nothing else. The setting, the directory's mode, and the record are put back, the record under a forgectrl restart. exthost.service (takeover): a reference package built on the board, signed with a key made there and added as an owner key. It is unverified before the key is the owner's and community after; the install is refused without the consent. Turned on over the advisory, the service is looked at from outside (account, no_new_privs, seccomp, group, limits, chain) and from inside (what it can read, write, dial, and open), its first line is looked for in the forgeext logger's file, safe mode stops it, a host killed with the service in its quiet loop takes it along within 2 s and comes back, and ext_enabled=0 leaves no group and no chain. The package, the key, the setting, the mode, and the record are put back, and the extension root is compared with how it was found. The reference package exists only while the test runs: no image carries it, and no image trusts its key. Proven. forgetest's unit suite: 422 tests pass, 0 undefined names. On the bench reference, image 20260920211625 with the cross-built forgectrl and host from /tmp: setup.extensions-consent PASS and, against the image's own daemon, FAIL at its first request; exthost.service PASS and, with the init wrapper's kill taken out, FAIL at the killed-host check. The first runs of exthost.service found what the host tests could not: /data/forgefirm is 0700 on the bench reference, and the service ended with EACCES on its own entry point until forgectrl opened the directory for search with the consent. Acceptance. Both tests are new. exthost.service covers forgeext whole and forgectrl's src/main.c and src/logs.*; setup.extensions-consent covers forgectrl's consent path. The recipe, the image line, and the init script's install are layer content, in the platform identity of every fingerprint. forgeext joins scripts/manifest-from-tree.py with its first pin. |
||
|
|
2894269115 |
The deny rules: the machine itself is never a destination
The way through the extension sandbox's deny rules is an allowlist, and an allowlist names addresses. The machine's own LAN address is not a fact anybody can pin: a new DHCP lease can turn a peer's address on some package's list into the machine's, and with it open the Grbl port or forgectrl's listeners to that package. ffx.nft now refuses it structurally. Everything a host sends to one of its own addresses, the LAN one included, leaves through lo, so chain pool refuses `oifname "lo"` before it looks at the allow map; the two refusals (a reset for TCP, a drop for the rest, both counted) move into chain refuse, which pool jumps to from both places. No kernel option is new: oifname is in the nf_tables core. scripts/sandbox-rules-test.py gets a destination that is not the machine: a second network namespace joined by a veth pair, with listeners of its own. A pool uid is refused on loopback, IPv6 loopback, its own LAN address, and the peer; an allow chain opens one port of the peer to one uid and nothing else; with loopback, IPv6 loopback, and the machine's own address added to that list the uid is still refused at all three while the peer still answers; a reload closes it. It needs ip and nsenter now. exthost.platform reads its counters from chain refuse, holds the rule's place ahead of the map, and adds the case on the machine: an allow chain for the last pool uid that names forgectrl on loopback and on the LAN address opens neither, and the chain is removed. Proven. The rules test passes with nft 1.0.9, and four controls each fail it: the range one uid short, the TCP reject turned to accept, the delete-table line removed, and the lo rule removed (the uid then reaches all three of the machine's addresses). On the bench reference, image 20260920211625, this rule file loaded from /tmp with nft -f and this suite file mounted: exthost.platform PASS, uid 831 refused at 127.0.0.1:443 and 172.16.1.97:443 with both on its allowlist, the counters [0, 0] to [12, 4]. Against the image's own rules the same test fails on the rule's absence, which is the control. The image's rules were reloaded after. The unit suite passes (422). Acceptance. exthost.platform gates the rule on the machine; sandbox-ci gates the file. The rule file is layer content, in the platform identity of every fingerprint. |
||
|
|
5ad7ee6faa |
forgeext: the recipe, and the extension-signing key in the keyring
forgeext is the extension host, a component of its own: recipe recipes-forgefirm/forgeext (cmake, pkgconfig, forgefirm-manifest, so it is a manifest component with its own pin file). It links jansson, libarchive, and libsodium, and runs with fwup, the keyring, and forgefirm-sandbox. libarchive and jansson are on the image already; libsodium comes with it. The pin is all zeros because the repository has no pushed commit to name: the recipe builds from a working tree through externalsrc, and a build from pins cannot fetch it until the first push sets the pin. It is not in the image's install list. forgefirm-keys installs a third trust anchor, /etc/forgefirm/keys/ext/forgefirm-ext.pub: the OpenGlow extension-signing public key, the official tier of extension packages. It is a different key from the release key on purpose: it signs more often, and its loss must not sign firmware. forgeext refuses an extension archive whose only valid signature is the release key's or a factory key's. Proven. The recipe cross-builds forgeext from the working tree, and that binary ran the verify-and-install cases on the bench reference (image 20260920211625, from /tmp, with the board's own fwup 1.16.0 and the image's keyring) with the results of the host test. forgefirm-keys builds and packages the key 0644 under ext/ (0755), byte-identical to the file here; it is 32 key bytes and is not the release key. Acceptance. No catalog test reads either yet: forgeext's tests are the exthost suite that comes with its daemon, and the key is layer content, in the platform identity of every fingerprint. |
||
|
|
9128f0e98e |
update.product-gate: extension packages at the firmware upload
forgectrl's firmware paths now read an archive's product and take firmware only (fwproduct.c). The upload route was not exercised by any catalog test; this one is its door. Archives made on the spot with the machine's own fwup go to POST /update/upload as a multipart form: an extension package unsigned, one signed with a throwaway key, and one that carries a task are each refused with 400 "this archive is an extension package, not firmware" and nothing is staged; an archive of another product is refused as not firmware; one whose product is ForgeFIRM firmware is taken as the unsigned upload it is, and the staged file is removed. Nothing is applied: the apply calls the same gate before fwup -a, and its proof is the daemon's host test, because a catalog test that could fail there would write a slot. Proven. On the bench reference, image 20260920211625 with the gated daemon and this file mounted over the image's: update.product-gate PASS, and update.slots-and-signature PASS beside it (the signature refusal still comes first). Against the image's own daemon, which has no gate, the test fails on its first upload (200), and its cleanup leaves nothing staged. The unit suite passes (422) with no undefined name. Acceptance. update.product-gate covers forgectrl's src/fwproduct.* with the update sources. |
||
|
|
619414ed2b |
cloud suite: a print turned away before the button fails at once, with the reason
The cloud client turns a print away before the button wait for four reasons of its own (machine._safe_to_move): the lid or the interlock, a machine that is not idle, the coolant above its start ceiling (THERMAL.max_start_temp), a coolant sensor that reads nothing. It logs the reason and finishes the print ':cancelled' within a millisecond. The four tests that wait for the button looked only for the wait, so on the bench reference, with the loop at 27.4 C against the ceiling of 27, cloud.dark-print sat out its 120 s and said "the print never reached the button wait". wait_button_wait replaces the four waits: it ends on the print's finish line as well, and fails with the client's own lines, for example "the client turned the print away before the button wait: INFO machine:_safe_to_move machine temp is too high, temp: 27.4 (... finished with event ":cancelled")". The ceiling is the client's and stays where it is: the remedy for a warm loop is airflow, a run session for a minute or two. Proven. test_a_print_turned_away_before_the_button_fails_at_once_with_the_reason replays the bench reference's lines and fails in under 30 s with that reason; with the early exit disabled it fails after the full 120 s with the old words. Each reason is a line the pinned cloud library can log, so the phrase check passes. The unit suite passes (422) with no undefined name. On the bench reference, image 20260920211625 with this cloud.py over the image's, cloud.dark-print passes through the new wait with the loop at 25.8 C. Acceptance. cloud.dark-print, cloud.verdict-refuse, cloud.lid-during-button-wait, and every test that starts an offline print go through the new wait. The helper is module text, so every cloud.py test's fingerprint moves; no product behavior changes. |
||
|
|
474bf9db09 |
motion.job: measure the job's reach from where the test began
_job_wait read its own starting X when it was called, which is after the lease wait and three refused requests beside the running job. On image 20260920204744 those took long enough that the head was already 2.55 mm out, and the test failed with "the kernel saw 17.450 mm of the 20" on a job whose record said done, 7 of 7 acknowledged, no emission sample, and the counters back at 0.0. It now takes the caller's X, read before the job was posted; laser.recorder-dark, the helper's other caller, reads no distance and passes none. Proven. On the bench reference with the fixed file over image 20260920204744's: motion.job PASS three times running, laser.recorder-dark PASS. The file is in image 20260920211625. The unit suite passes (422). Acceptance. motion.job is the test. The helper is module text, so every motion.py test's fingerprint moves; no product behavior changes. |
||
|
|
d627ee32f1 |
The extension sandbox platform: accounts, cgroups, and the deny rules
What the image holds ready before any extension package exists, so that
the first one starts inside it.
forgefirm-sandbox (new recipe, on both images):
- the account pool: ffx0 to ffx31, uid and gid 800 to 831, one group
each, /nonexistent, /bin/false, locked. Below 1000 on purpose: the
forgefirm-users render replaces only the accounts from 1000 up, so an
account reset leaves the pool alone and the read-only rootfs never
needs an account made at run time. The image's dynamic system ids
count down from 999 and stop at 997.
- an rcS script at S30: cgroup v2 mounted at /sys/fs/cgroup, the cpu,
memory, and pids controllers handed down to /sys/fs/cgroup/ffx, and
ffx marked idle-class (cpu.idle; the kernel refuses a cpu.weight on
top of it, so none is written). The firmware's processes stay in the
root group. `status` reports both halves and exits nonzero when
either is missing.
- /etc/forgefirm/ffx.nft, loaded by the same script, before the network
starts in rc5: table inet ffx, an output-hook filter with policy
accept that sends uid 800-831 to chain pool; pool looks the uid up in
the verdict map `allow`, then answers TCP with a reset (a drop would
leave a connect to time out) and drops the rest (the sender sees
EPERM), both counted. The map is the one way through: a uid mapped to
a chain of that package's destinations. Loading the file again
replaces the table, allowlists included: it fails closed.
nftables comes in as its runtime dependency, trimmed in the distro config
to the binary and its library with JSON output: no interactive shell, no
Python binding. gmp and jansson were on the image; libmnl and libnftnl are
new. The release rootfs goes from 34.5 to 34.1 MiB free.
scripts/sandbox-rules-test.py, and the workflow sandbox-ci that runs it:
the rule file loaded into a network namespace of its own and sent at from
real uids. Root, 799, and 832 are not touched; 800, 815, and 831 are
refused on 127.0.0.1 and ::1 at once, UDP with EPERM, and a receiver hears
nobody from the pool; an allow chain opens one port on one address to one
uid and nothing else; a reload closes it.
exthost.platform (new suite module exthost.py): the platform proven on a
probe process, not read off a config. In a probe group under ffx, as the
last pool uid: held to cpu.max, stopped by cgroup.freeze and running again
after, stopped at pids.max, killed by the group's own OOM at memory.max
while forgectrl keeps its pid. The 32 accounts as the boot's render left
them. Pool uids 800 and 831 refused TCP to forgectrl on loopback (both
ports, IPv4 and IPv6), to the LAN address, and to the Grbl port, at once,
UDP EPERM, with the rules' counters moving by at least the attempts, while
root reaches the same listeners. A root probe under landlock loses /etc
and TCP connects and keeps /usr; a seccomp filter returns EPERM for the
filtered call. The probe group is removed whatever happens.
Proven. The rules test passes with nft 1.0.9, the image's version, and
three controls each fail it: the range one uid short, the TCP reject
turned to accept, the delete-table line removed. The unit suite passes
(422) with no undefined name. Image 20260920211625 carries all of it (read
back from both rootfs images: 32 accounts in passwd, group, and shadow,
S30forgefirm-sandbox, the rule file and the script byte-identical, nft
with its libraries and no Python binding). On the bench reference, that
image: exthost.platform PASS (5.0 percent of the core under a 5 percent
cpu.max, 0 us frozen and 87358 us thawed over 1.5 s each, 5 of 12 forks
then EAGAIN, rc -9 with oom_kill 1 at a 24 MiB memory.max, the counters
[0, 0] to [12, 4], landlock ABI 6), and forgefirm-sandbox status reports
both halves in place.
Acceptance. exthost.platform gates the platform; sandbox-ci gates the rule
file. The recipe, the rules, and the distro option are layer content, in
the platform identity of every fingerprint.
|
||
|
|
965f7c3ca9 |
The cooling report secret: a driver harness, and two catalog tests
forgectrl's POST /cool/state now asks for the secret the supervisor hands the running controller at its spawn; both controllers' cooling clients send it. scripts/bench/cool_report_test.py is the driver's host harness for it (null-sink controller, a stand-in for forgectrl's listener on FORGECTRL_PORT): every report carries the secret and no other new header; none does when there is none; a value that is not 32 hex digits, a CR LF with a header behind it included, never reaches the wire; the homing runner the controller starts does not inherit the secret. It is in the bench registry and the bench README, and the driver's CI runs it. forgectrl.auth: the loopback case used to assert that any local peer is accepted. It now asserts the three answers: no secret 403, a made-up secret 403, and the running controller's own secret 200, read as only root on the machine can read it, out of the controller's environment, and never logged. The LAN cases carry the secret too and are still refused. cooling.report-channel is new, the drill the change exists for: M8 opens a run session, three forged idle reports from this host (no secret, a made-up one, a made-up one with a forged Host) are each refused, and over the next five seconds the engine stays in phase run and no commanded fan duty drops; M9 ends the session on the controller's own report. Proven. The harness passes on the host-built controller, with three negative controls that each fail as they should. The unit suite passes (421) with no undefined name. On the bench reference, forgectrl and both clients hot-deployed over image 20260920152153: forgectrl.auth PASS, cooling.report-channel PASS (phase run throughout, the exhaust at 65535 and the intake at 43278), and cooling.fans-quiet-after-motion and motion.job PASS on the same binaries. Acceptance. forgectrl.auth and cooling.report-channel are the gate for the report channel's secret; cloud.dark-print gates the cloud client's side. |
||
|
|
19ce4d78d2 |
forgetest: the built-in extensions in setup.cloud-disabled-surface
forgectrl gains a table of built-in extensions, with cloud mode as entry one, GET /extensions to serve it, and POST /settings asking it which selections point at the cloud, how to refuse them, and what they fall back to. It also gains an example client under examples/, and its test tokens lose the names of clients nobody is building. setup.cloud-disabled-surface is the gate for "nothing points at the cloud while it is off", so it now holds the list to the settings three times: as found (enabled as cloud_enabled says, the two roles with their providers and fallbacks, each active exactly when its setting selects it), with the cloud off (not enabled, no role active), and as restored. The two refusals are held to the table's words. Its covers name src/builtin.*. The helper lives inside the test's own function, so no other test of the module changes its fingerprint. The unit test's fake daemon serves the route the way builtin.c does, and gains a case with two lists that lie (enabled after the cloud went off; a role that stays active), each of which must fail the test. forgectrl.tokens: the jog token is "forgetest jogger". manifest: forgectrl's examples/** joins the non-behavioral paths. They are clients that run on another computer: outside the image, outside every fingerprint, and outside the coverage lint. Proven. The unit suite passes (421) with no undefined name; the two lying lists fail the test as they should. On the bench reference, forgectrl's registry daemon hot-deployed over image 20260920152153, on a machine with cloud mode on: setup.cloud-disabled-surface PASS through the whole path (off, the sweep, the refusals, the restore), and forgectrl.tokens PASS with the renamed token. Acceptance. setup.cloud-disabled-surface is the gate for forgectrl's built-in table through the settings route. |
||
|
|
ebc4222182 |
forgetest: forgectrl.tokens
forgectrl gains scoped API tokens: a credential made in the panel that reaches the routes it was granted and nothing else, with no login session. forgectrl.tokens creates three through the panel's route (machine.read with camera.lid; motion.jog; camera.lid alone) and uses each the way a client on the network does: over HTTPS to the machine's own LAN address, so the daemon sees a peer that is not this host, and the dev image's and the loopback's allowances do not apply. The first reads /status as a bearer token, /mode in the panel token's header, and /cam/status. The camera-only token reads /cam/status as ?key=. Refused with 403 and the reason in words: the head camera, the first token in a URL (it holds more than a camera), a jog, a settings write, a mode switch, the token list, a token of its own, the panel's ?token= form, a stranger's token, and /status for the jog token. The jog token then moves the head 1 mm with no session and no panel token, the kernel's counters as the witness. A token over plain HTTP from the LAN is refused and the daemon's log names it. With panel_open_reads=0 the LAN reads /status with machine.read and with nothing else. The list names the three with a last-used time and carries neither a token nor a hash; the store is mode 0600 and holds no token. A revoked token is refused at once, with the reads open again, and the other tokens live on. Every token is revoked, the setting restored, and the head returned on the way out, and tokens a broken run left behind are revoked at the start. No token is written to the log or the evidence. The refused requests are ones that would do no harm if the guard failed: a settings write of the value in force, a mode switch to the mode in force. Proven on the bench reference, forgectrl's scoped-token daemon hot-deployed over image 20260920152153: PASS with a clean hand-back, the jog at 1.000 mm. The unit suite passes (420) with no undefined name. Acceptance. This test is the gate for forgectrl's scoped tokens. |
||
|
|
f0ba4b9249 |
forgetest: motion.job and laser.recorder-dark
forgectrl's job runner (POST /job, and the dose-curve recorder and the sheet wizards moved onto it) needs a test of a posted program, and nothing in the catalog ran the recorder's start: only its refusal and its status. motion.job posts programs the way a client does, as a multipart form. A program with a $ line and a job with no name are refused with 400, and a job beside the suite's own Grbl client with 409, each with nothing moved. A dark program (out 20 mm, a dwell, back) then plays: /status names job:forgetest as the lease holder of kind sender, GET /job reports it running, and a second job, a port jog, and a settings write are each refused in the job's name. At its end the record says done with every line and the runner's own M2 acknowledged, no discharge and no LASER_ON sample, the kernel's counters saw the 20 mm and are back where they began, and the lease is free. POST /job/abort stops a 40 mm move short, into the controller's alarm state, with the lease free. A job sent into the alarm fails at its first line with nothing moved; the same job with unlock=1 clears the alarm and returns the head. laser.recorder-dark starts the recorder and never presses. While the controller waits at its arm: the recorder holds the machine as a sender, GET /job shows its run (not a posted program's), the floor and the curve read 0 and off, POST /job/abort refuses to stop a run it did not start, a posted job is refused in the recorder's name, and two seconds of witnesses stay dark. POST /curve/stop ends it: stopped before the ladder fired, the lease free, both laser keys as they were. The ladder begins at the controller's X0 Y0, so the head is returned by a posted job. covers: setup.sheet and forgectrl.lease name src/jobrun.*, since the sheet wizards stream through the runner and the recorder's refusal comes from it; setup.sheet names src/lease.* for the hold its jobs take inside the wizard's. Proven on the bench reference, the job runner's daemon hot-deployed over image 20260920130136. motion.job: PASS; the kernel saw 20.000 mm out and 0.000 back, 6 lines with 7 sent and acknowledged, every witness zero, the abort at 9.83 mm of 40 into Alarm, the unlocking job with its $X and its M2 the two lines more. Its first run failed, and found a defect in the daemon and not in the test: the record said done with the head 0.19 mm short of its end, because the controller acknowledges M2 before the pulse engine has played the last of its ring. The runner now waits for the kernel to be idle, and the test passes unchanged. laser.recorder-dark: PASS; the controller reached its arm wait, 16 samples dark, the stop left the controller Idle and the head where it was. setup.sheet: PASS with the operator at the machine. forgectrl.lease: PASS. The unit suite passes (420) with no undefined name. Acceptance. These two tests are the gate for forgectrl's job runner, with setup.sheet for the sheet wizards' side of it. |
||
|
|
888b47abe8 |
forgetest: the hand-back never moves the head across a lost counter frame
Seen on the bench reference, twice in one session: at the end of a passing run the hand-back jogged the head 30 mm into the back-left stop blocks, from a head that had not moved. The baseline compares the kernel's step counters at the end of a run with the start and jogs the head back by the difference. The GRBL controller zeroes those counters at every start (the lens's startup reference) and at every home (home_completed()), and rewrites its anchor, /run/grblhal.homed, each time. Across either event the difference between two counter readings is not a distance the head traveled. It stayed hidden because the counters normally read zero between tests. homing.manual broke that: a manual home zeroes the counters 30 mm out from where the test began, so after the test's own correct return they read -6400, and the next test that restarts the controller (setup.check-flow-verify, then motion.release) ended at 0, "expected -6400", and was "returned" by 30 mm. An operator who jogs the head from a Grbl client and then starts any takeover test from the page would have met the same thing, by whatever distance they had jogged. The baseline's capture() now records the counters' frame, the anchor's inode and mtime. If the frame changed during the run and the test did not vouch for the new one, the hand-back logs that the two readings share no frame, and moves nothing. ctx.counters_rezeroed() is how a test vouches: it now sets rezero_declared beside the position it expects. The start_reads argument it briefly took is gone, since it made the baseline accept counters that nothing after it could live with. homing.manual restarts the controller once more after returning the head, so it ends with the counters at zero where it began, and declares that. events.stream waits for its three places. A stream an earlier test closed keeps its place until the daemon's next write to it (its keep-alive), as documented, so run straight after forgectrl.lease the third stream drew 503. The test now opens the three once they can be opened, and says so in its log. Proven. test_baseline gains test_a_lost_counter_frame_never_moves_the_head: counters at -6400, a new anchor, counters at 0: no jog, no leftover, and the log says why; the same counters with the frame intact are still a displaced head; a declared re-zero is held to the position it declared. With the frame check unable to see the change (the first cut of the test reused an inode inside one clock tick) the case fails with ['position'], which is the old behavior. The unit suite passes. On the bench reference, arranged so a failure would move the head away from the stop: the head jogged to +60 mm (counters 12800), motion.release run, PASS, "the controller re-zeroed its counters during the run ... the head is not moved", and nothing moved. forgectrl.lease then events.stream: two logged waits, PASS. homing.manual then setup.check-flow-verify, the sequence that drove the head into the stop: both PASS with a clean hand-back. |
||
|
|
520bf6023d |
forgetest: forgectrl.lease
The acceptance test for forgectrl's machine lease. The switches check is started and left waiting at its first prompt: it moves nothing, and it holds the lease for as long as it waits. /status must name it as the holder (wizard:switches, kind hardware), and everything that asks the lease must be refused with 409 and the holder's name: a diagnostic, the dose-curve recorder, a log export, a mode switch to the mode already in force, POST /controller/start, and POST /settings, the last with "settings are locked". Only requests that would do no harm if the lease failed are made: no reboot, no boot-slot change, no update job; and whatever a failed refusal may have started is stopped on the way out. The check is then aborted: the lease must read free, POST /settings must be accepted again, and the event stream, open through all of it, must have reported lease.changed with the owner and then with null. Passes on the bench reference. The nested hold (a diagnostic under its cooling wizard) is setup.check-flow-verify's to exercise, and it passes there with /status reading diag:flow-verify under wizard:cooling.flow-verify through the run. The unit suite passes (418). It covers forgectrl's src/lease.*, src/main.c, src/status.*, src/wizdark.*, src/diag.*, src/curverec.*, src/update.*, src/logs.*, src/super.*, and src/events.*. |
||
|
|
25cc42b879 |
forgetest: events.stream
The acceptance test for forgectrl's GET /events. The daemon counts event streams per peer address, so the test is several peers at once: it binds its client sockets to 127.0.0.2 through 127.0.0.5, which are all this host. Three streams must each get 200, text/event-stream, and the hello event; a fourth address must get 503 with the reason while GET /settings still answers; a second stream from the first address must be served, and the older one must get bye and the end of its response; POST /motion/release and /motion/energize must arrive on a stream that was open all along as motors.released and motors.energized, in order, with ids that count up; and with every stream closed a place must come back within 25 s, since a closed client is only noticed at the daemon's next write to it (its keep-alive). The SSE client was run against a host-built daemon before any bench time was spent, which found two defects in it: its reads blocked for the socket timeout, not the window the caller asked for, and a replaced stream ends with the last chunk of the response, not with the socket closing. Passes on the bench reference: the fourth stream refused in words, the replacement told and ended, alarm, motors.released, and motors.energized as ids 1 to 3, and a place back 3 s after every stream closed. It covers forgectrl's src/events.*, src/main.c, src/status.*, and src/grblport.*. |
||
|
|
8856fb0099 |
forgetest: the motor release, the manual home, and the controller port
Four catalog tests for the controller and daemon change of the same area, and one hand-back rule they needed. motion.release (takeover, 8 min): $MD takes the X and Y step currents to 0 with the 40 V rail untouched (no supply line in the kernel log, cnc/state idle, no fault), drops the X and Y reference, and locks the machine in alarm; a jog, a G0, $X, and $X after a soft reset are refused; forgectrl refuses a switch to cloud mode with 409; the kernel's position record is the witness that nothing was shipped, since the accelerometer cannot say "still" across a release (the rotors relax and the head feels it); $ME restores 33 and 5 with no fault; and the drivers are proven alive the way the machine proves it to itself, by the Setup motion check's liveness probe and witnessed jogs. homing.manual: a manual $H after an outbound jog plays no pulse byte, clears the counters, declares manual_home_x and _y, keeps Z, turns the soft limits on (error 15 behind the home), tells the client the home was set by hand, and reads back through forgectrl as source manual. motion.port-jog: with the suite connected as the Grbl client, POST /motion/jog moves the head by what was asked on the kernel's counters, the client is told and not displaced, and /motion/state agrees. The client then polls the way LightBurn does, '?' with an end of line behind it: no port jog may be refused for it, a 30 mm jog must run whole with at least three polls landing inside it, and every poll must draw its ok. The cancel stops a long jog short; the client's own line stops a fast one (100 mm at F6000, fast on purpose: a slow jog stops at once and would pass with no hold) and must draw ok, never an error; the 100 mm bound holds with nothing moved; the release and the energize go through their routes with the currents read; and the head is returned, in requests of 100 mm at most. laser.port-dark (live, one button press): a 20 mm line at M3 S400 with no M5 and no program end after it leaves the armed window open with M3 modal and S above zero, the state in which an injected G1 would fire. The line must be witnessed lit or the case proves nothing, the window must read armed before and after, and through three port jogs back over the line the LASER_ON sample count stays 0, the HV current stays idle, and the head's beam detector does not rise over its level before the jogs (its own pre-jog level, because it may still be settling after the cut). The hand-back. The baseline compares the kernel's step counters at the end of a run with the start. A manual home clears them by design, and homing.manual takes its home 30 mm out from where it began, so after the test's own correct return the counters read -6400: on the bench reference the baseline "returned" the head 30 mm the wrong way and failed a run whose body had passed. ctx.counters_rezeroed() now takes start_reads, what the counters read in the new frame with the head at its starting position; homing.manual passes the start minus the counters at the home. Without the argument it means what it meant: re-zeroed at the start. All four pass on the bench reference: motion.release (the rail untouched, every refusal, the 409, the probe and four witnessed jogs), homing.manual with offsets 12.5 and 8 (declared 12.502, 8.002, a clean hand-back expecting -6400), motion.port-jog (10.000 mm for 10; 8 of 8 accepted under the poll, 30.000 mm with 5 polls inside the jog, 14 polls and 14 oks; the cancel at 10.3 of 40; the client's line at 33 of 100 with ok; drift 0.000), and laser.port-dark (emission peak 148 on the line; 20 samples across the jogs with 0 emission, HV 0, beam rise 5). The unit suite passes (418). Coverage: motion.port-jog names forgectrl's src/grblport.*, src/main.c, and src/status.*; motion.release adds src/status.* and src/wizdark.*; laser.port-dark adds src/grblport.*; the driver's new sources fall under src/**, which every motion and laser test already names. |
||
|
|
b29bb9e023 |
bench: the controller port and manual home harnesses
Two host harnesses for the grblHAL driver's null-sink build, in the bench
registry and the README with the others. The driver's CI runs them.
ctlport_test.py drives the controller port beside a scripted Grbl sender
that counts every ok and error it is sent, which is the only way to see a
status routed to the wrong source. 13 cases: the socket's mode; a port
jog's status going to the port with the sender's count exact and the jog
run whole under the sender's '?' polls; a port error the sender's next line
does not inherit; the sender's line canceling a fast port jog and drawing
its own ok (fast on purpose: a slow jog stops at once and would pass with no
hold at all); a sender line queued right behind the port's; the refusals;
one client, and five reconnects right after a close; the status hook across
a soft reset; the dead-man; a CR LF sender; a sender that polls the way
LightBurn does, '?' with an end of line behind it, LF and CR LF (every port
jog accepted, a 60 mm port jog run whole with the polls landing inside it,
one ok per poll, a real line still canceling a port jog); and every
operation of both sets under an open armed window with M3 modal and S500,
where the dump must hold no FIRE tick.
manual_home_test.py reads the stream dump (GFSINK_DUMP) and the attribute
log (GFSINK_ATTR_LOG), so it can say that nothing was shipped and which
current was written, and how many times. 9 cases: a manual $H ships no step
and no FIRE tick and declares the offsets with the soft limits on and Z
kept; $H refused in a cycle; $MD refused under an open armed window with
nothing written; every motion source and $X refused while released; each
energize written exactly once; the port's panel operations; both pairs of
home offsets, alone and at once; and a controller killed under a release,
whose replacement writes only 0 and 0.
Both pass against the driver's extensions tree. The poll cases fail against
the driver without its empty-line rule ("8 of 8 port jogs were refused under
a status poll"), which is the defect they were written from: it was found
on the bench reference with LightBurn connected, and the sender these
harnesses had until then polled a bare '?'.
|
||
|
|
11f4c42705 |
forgefirm-image: install python3-asyncio
The release image carries only the standard-library module packages its own Python recipes declare, and none of them imports asyncio, so it was left out. An extension runs on the image's interpreter and brings no standard-library module of its own: the set it may import is the set installed. Its event-stream and socket clients are written on asyncio, so the image installs python3-asyncio on purpose, with the reason in the recipe. The other modules the trim leaves out (multiprocessing, xmlrpc, the debugger, venv, ensurepip) stay out until something asks for them. Proven: an image pair built from this recipe has /usr/lib/python3.12/asyncio/__init__.py in the release rootfs, read out of the ext4 image; on the bench reference, running the dev image of that pair, python3 -c "import asyncio" succeeds. Acceptance: the change is recipe body, so it is platform content and takes the full campaign. No catalog test can hold the module itself: forgetest runs on the dev image, which installs python3-asyncio with the rest of the standard library whatever this recipe says, so the release rootfs is the thing to read, as it was here. |
||
|
|
08b29fee01 | Release v0.0.6 | ||
|
|
f306c9983f |
forgetest: the hand-back reads an engine hold again past the engine's next tick
The cooling engine publishes its state once a tick (1 Hz). A /cool/status
read inside the tick after a run ended still shows the run: while a
diagnostic owns the hardware every tick publishes phase "diag" with the
hold set, and a fail tier's hold stands until the tick that ends its
session. The baseline took one read, and by its rule an arm or a hold is
the run's doing, so a test that finished inside that second failed its
hand-back on a hold the engine's next tick cleared.
Seen on the bench reference twice. cooling.aa-offset-calibrate in campaign
c-20260919215024-c402: the diagnostic reported done at 22:07:14 with its
offset measured (15.7 counts, spread 0.7), and the hand-back at 22:07:15
read "cool=diag/armed=False/hold=True ... -> waited" and failed the run;
the test had passed on five images before, the last one earlier the same
day. cooling.fail-tier-stop in c-20260919202934-3d3a, the same way on the
crash fault's hold (
|
||
|
|
dc7170d0d1 |
forgetest: cooling.flow-under-load finds the verdict by offset, not by a tail count
The test counted "heater rise" in the last 300 lines of the forgectrl log before the job, then waited for the count to grow. A tail of fixed length cannot show that: the new verdict line comes in at the bottom as an old one leaves at the top, the count does not move, and a check that verified reads as one that never judged. Seen on the bench reference in campaign c-20260919210959-8780: the engine logged "coolant flow verified (heater rise 11.5 C, dT 9.5 C; laser 1.6 off 13.1)" at 21:37:46, 70 s into the job and inside the wait, and the test failed at 21:39:21 with "the engine published no flow verdict within 120 s". The 21:17:27 verdict of an earlier test sat near the top of the 300-line tail when the test began. Replayed against that log, the old method reads 4 before the job and 4 with the new verdict in the tail; the search from the byte offset finds the 21:37:46 line. The test now takes the log's byte offset before the job (_log_offset, as the fail-tier and liveness tests do) and searches what the file gained since with the verdict expression, stopping only on a line that matches. _log_since reads a file that is now shorter than the offset from its start: a rotation under the test leaves only newer lines. The /logs/tail helper and its line count are gone with their one user. Proven: tests/test_cooling_suite.py FlowVerdictLogTests - the text after the offset alone, the real verdict line through the expression, an old verdict before the offset not taken for the new one, a rotated log read whole, a missing log read as nothing; the cooling host tests pass under Linux, 24 tests. No component source changed, so no pin moves. |
||
|
|
0ceb4abc4a |
forgetest: cooling.fail-tier-stop waits for the crash fault to end with its session
The test raised a crash fault and handed the machine back with it still
standing. The engine trips the crash tier on one 1 Hz tick and ends the run
session on its next tick, which is when the fault and its hold clear ("head
crash fault cleared with the run session"). The supervisor has the new
controller up inside that second, and the test returned as soon as it saw
the new pid and the two log lines, so the runner's hand-back check could
read /cool/status before the clearing tick: phase run, hold true. A hold is
the run's doing by the baseline's rule, so the check recorded a leftover
and failed the test, though the wait that followed ended on its own.
Seen on the bench reference in campaign c-20260919202934-3d3a, the only
failure among 65 results: the trip at 20:54:44.871, the new controller at
20:54:45.094, the test's PASS line at 20:54:45, the session end at
20:54:45.863, and "cool=run/armed=False/hold=True ... -> waited" from the
hand-back check. The engine opened no session after that one; the 16 s the
check waited were the smoke phase that follows an armed session.
The test now asserts what the engine documents: after the restart checks it
waits up to 10 s for the verdict to leave CRASH, records how long that took
(fault_cleared_s) with the phase, verdict, hold and armed flags it found,
and fails when the fault does not end with the session. The machine is then
handed back in the engine's own post-job phase, which the baseline already
treats as the engine's work and not a leftover.
Proven: the cooling, artifact, campaign and queue host tests pass and the
catalog loads with the new source. No component source changed, so no pin
moves; the test's own source hash does, so its result comes from this
revision.
|
||
|
|
aa628d684c |
kas lock: meta-openglow onto the head that drops the DHCPv6 client
meta-openglow 31c52050bc70 -> 71fad52e8ff5 The layer head removes the wlan0 inet6 stanza and the busybox bbappend that built udhcpc6, the client that held a field machine's boot in ifup. The acceptance test image.network-boot fails on any image that still carries that client, so the lock moves with it. poky, meta-openembedded, meta-freescale and meta-freescale-distro keep their commits. Proven: kas checks layers/meta-openglow out at 71fad52, the layer holds no busybox recipe directory and its interfaces file names no udhcpc6, and bitbake -c fetch busybox init-ifupdown succeeds against it. |
||
|
|
29b00cc056 |
Pin forgectrl 0.1.27 (the export carries the installer's log)
forgectrl 7a9de005ede5 -> 0dae758cb93e (PV 0.1.26 -> 0.1.27) forgectrl 0dae758 stages logs/install/ into the log export, which is what logs.tree-tail-export now requires of the bundle. The PV moves with the SRCREV so the hash-derived package version stays monotonic. Proven: bitbake -c fetch forgectrl resolves the pin (forgectrl-1_0.1.27-r0 do_fetch succeeded). |
||
|
|
d482e76402 |
installer: a download that resumes and retries, and an install log
A field install failed on "firmware download failed", and worked after a reboot. The download was one bare curl -fL: no retry, no resume, no bound on a stalled transfer, and nothing on the machine recorded what had gone wrong. The download. download_fw makes up to five tries, 5, 15, 30 and 60 seconds apart. Each try resumes the partial file (curl -C -) and is bounded: 20 s to connect, and a transfer below 1 KB/s for 30 s ends the try. The file is written as forgefirm.fw.part and takes its name only when curl finished; the signature check that follows is what vouches for its content. A full disk (curl 23) and a release that is not there (HTTP 404) end the tries at once, because waiting cannot fix them. A partial file the server will not resume (curl 33 or 36, HTTP 416) starts over. The loop is the installer's own rather than curl --retry: the factory curl on the bench reference is 7.69.1, whose --retry does not count a resolver failure or a dropped transfer as retryable, and older factory builds carry older curls. The owner sees the reason in words with each retry, and the final failure says that a re-run goes straight to the download, because the archives are kept. The log. Every run appends to /data/log/forgefirm/install/install.log, in the log tree's own line format (UTC, program "install"): the installer's md5 (which revision ran), the factory version and the slots, the owner's answers, each archive, each download try with curl's exit code, the HTTP code and the reason, the machine's clock at each try (a wrong clock breaks TLS), and after a failed try the address, the default route, the resolver and whether github.com resolves; then the signature and identity checks, the write, the boot selection, and the reason for any failure through die(). The log is appended across runs, so the run that failed is still there after the run that worked. Logging never fails the install. forgectrl's log export carries the directory (forgectrl 0dae758). Proven: tests/test_installer.py runs the installer's own functions under sh against a scripted curl - a clean download, a resolver failure and a dropped transfer that resume to the full file, the tries running out, 404 and a full disk ending them at once, a stale partial file starting over, the TLS reason naming the clock, every log line in the tree format, die() leaving its reason, and an unwritable log not failing the run. The whole host suite, 409 tests, passes under Linux and the coverage lint is clean. Bench: the same functions under the factory firmware's own shell (busybox 1.31.1 ash, the factory slot of the bench reference in a chroot) resumed, retried, ran out of tries and logged exactly as under sh. Acceptance: logs.tree-tail-export now plants a probe file in the install directory and requires it back in the export bundle, its line intact and its MAC and IPv4 address redacted, and requires an install log in the bundle when the machine has one. The installer itself is not on the image: the install page fetches it from master, so it is live with this push. |
||
|
|
8232c8c9fe |
forgetest: image.network-boot - the boot does not wait on the network
A client that waits in ifup's foreground on a server's answer holds the whole machine, because init starts the rest of the boot - sshd, forgectrl, the console login - only after S01networking returns. A field machine sat there forever on a router that refused DHCPv6 (the record is in the meta-openglow commit that drops the DHCPv6 client). Nothing in the catalog looked at the network boot path; this test does. It asserts: wlan0 is in ifupdown's state file and no ifup is running; the console getty is up; udhcpc runs with -b (it leaves ifup after three unanswered discovers) and has been reparented to init; no DHCPv6 client is named in /etc/network/interfaces, running, or on the image, and its hook script is gone; IPv6 on wlan0 is the kernel's own - enabled, router advertisements accepted, a link-local address up. A global address is evidence only: a network whose router advertisement offers no SLAAC prefix gives none. What a hostile server does to a client is a bench drill, not a test. covers is empty by design, as with setup.machine-name: the interfaces file is layer content, in the platform identity of every fingerprint, so a change there already makes every test necessary again. Proven: the host tests for the parsers and the registration (tests/test_image.py), and the whole host suite, 398 tests, under Linux. The test's logic, run read-only on the bench reference against an image that carries the client, fails exactly the four DHCPv6 checks and passes every other one. The test fails on any image built from a meta-openglow that still carries the client, so the kas lock moves to the layer head that drops it. |
||
|
|
260f466400 |
release.sh: no slot size gate on the dev path, which is SD-only
check_size guards the 200 MiB eMMC slot. The dev path called it against the
release rootfs until
|
||
|
|
cf7b51fb32 |
kas lock: meta-openglow onto the pinned head with the bumped component pins
The lock held meta-openglow at dd8f74c, three commits behind the pushed scarthgap head, so a build from this repository resolved the old BSP and none of the component pins just bumped there. Moves that one entry to 31c5205. poky, meta-openembedded, meta-freescale and meta-freescale-distro keep their commits: the platform is deliberately unchanged, so the build exercises the pins and nothing else. Proven: bitbake -c fetch and -c populate_lic over forgectrl, grblhal-glowforge, gfcloud, gfhome, python3-ffmachine, python3-gfhardware, python3-gfutilities and kernel-module-glowforge all succeed - the four LIC_FILES_CHKSUM values written ahead of their pins now match the fetched license text. A full bitbake forgefirm-image forgefirm-image-dev run completed, 5841 tasks, no errors, and the release rootfs manifest carries forgectrl 0.1.26, grblhal-glowforge 0.1.18, kernel-module-glowforge 0.0.6, python3-gfutilities 0.9.19+git0+0a93ebf30b and gfcloud/gfhome/python3-ffmachine 0.1.30+git0+6cc4f45d31. Release rootfs 125 MiB, inside the 195 MiB gate. |
||
|
|
e2be3ca4c5 |
Pin forgectrl, grblHAL-glowforge and forgefirm-app on the pushed heads
forgectrl da3eddcc0f43 -> 7a9de005ede5 (PV 0.1.25 -> 0.1.26) grblhal-glowforge ecebe9c8eb8d -> f93aca89821a (PV 0.1.17 -> 0.1.18) forgefirm-app 5ca279a1f600 -> 6cc4f45d311a (PV 0.1.29 -> 0.1.30) forgectrl brings the jobstream_test SIGPIPE fix and the x32 setting comment; grblHAL-glowforge brings the x32 xy_microsteps default and the rewritten COPYING this layer's LIC_FILES_CHKSUM already expects, which no longer fails the fetch now that the pin resolves to it. forgefirm-app tracks the same python3-gfhardware revision meta-openglow just pinned. Each PV moves with its SRCREV so the hash-derived package version stays monotonic. |
||
|
|
cd4c176a87 |
Finish the x32 xy_microsteps default in the baseline test and the stream harness
The x32 default landed in forgetest/baseline.py and the driver, but two callers still judged the machine at x8 and both failed on the host. forgetest/tests/test_baseline.py: setUp seeded the fake machine from the x8 FIXED_SYSFS literals while enforce() compares against fixed_sysfs() of the resolved mode, so x_mode, y_mode, step_freq and ramp_rate read as deviations on a clean machine - 23 failures across BaselineTests and TransientNotLeftoverTests. It seeds from fixed_sysfs() now, and the tick expectations come from it (DEFAULT_TICK) rather than a typed 28160. The xy_mode_of and ref_xy_mode unset/invalid cases expect 32, with an explicit "8" case added that had no coverage. Two reference_preconfig dumps taken on an x8 machine carry xy_microsteps = 8, because the markers are read at the reference's own mode. wait_configured wrote the static CONFIGURED_MARKERS where the function watches configured_markers() of the mode in force, and the held-controller jog typed 221 steps for "4.144 mm", which is 1.036 mm at x32; both derive from the mode now. 69 tests, all pass. scripts/bench/laser_stream_test.py: STEPS_PER_MM was the x8 53.333, so the X-peak check failed at 2133 steps against an expected 533. The whole harness now derives from XY_MICROSTEPS_BASE/DEFAULT the way glowforge.h and baseline.py do, which uncovered five more x8-only expectations behind the first: the machine tick, the fire-gap limit (it grows as sqrt(k), not k - a finer mode shortens the accel interval by sqrt(k) while speeding the tick by k), the rung split in fire_spans, the density period and minimum burst (laser_pulse_ticks is in x8 ticks and the stream scales it, so the config keeps the x8 numbers and the measured lengths scale), and the decel/hold budgets. Run against the null-sink build: all stream emission rules hold. xy_mode_test.py's docstring still described the no-key case as x8 while its own assertions had moved to x32. No behavior change and no acceptance-catalog consequence: these are test expectations and a bench harness, not image component sources. The coverage lint is unchanged at 0 uncovered paths. |
||
|
|
8850adcc5e | Update release | ||
|
|
f9f4de31c1 | Fixed artifact exporter | ||
|
|
9ddab969e0 | Attribution | ||
|
|
12e7d50746 | Update contributing | ||
|
|
b3f0cd54d9 | Attribution | ||
|
|
fbac17a485 | Updated release to guard against publishing without source bundle | ||
|
|
0eb764bf75 | Added SPDX | ||
|
|
07158e5d7f | Update key prov | ||
|
|
091702c26f | Updated grblhal-glowforge license checksum | ||
|
|
9a288b175b |
Track the x32 xy_microsteps default in forgetest and the bench tools
Acceptance baseline: XY_MODE_DEFAULT 8 -> 32, split out XY_MODE_BASE (8) for the tick/ramp scaling, matching the driver. The mode-aware comparisons (fixed_sysfs of the resolved mode) judge an unset machine at x32. Host CI: xy_mode_test.py default and invalid cases expect x32/213.333/112640. Bench tools: raster_dry.py and xy_pattern_accel.py set_mode() cleared the key for mode 8, which relied on clear == the x8 default; clearing now yields x32, so they set the mode explicitly and restore to x32. live_fire_drills STREAM_RATE_HZ is the 28160 Hz laser reference tick, the same at every mode. |
||
|
|
147393322f |
CI: run the push workflows on master only, not on tag pushes
on: push had a paths filter but no branch restriction, so a tag push (a release's v<version> tag) re-ran forgetest-ci and fixture-ci on the commit master had already tested - a tag push bypasses the paths filter. branches: [master] scopes the push trigger to the branch; pull_request and workflow_dispatch are unchanged. |
||
|
|
7a9f655916 |
Release v0.0.5
FORGEFIRM_RELEASE = 0.0.5 and the acceptance artifact that authorizes it:
campaign c-20260915231006 on the dev image 20260915225440 (grbl pin
0.1.17, forgefirm
|
||
|
|
52a91385cd |
Pin grblHAL-glowforge 0.1.17
The clean-parser-on-connect fix (ecebe9c): a soft-limit-rejected jog held subsequent g-code until a blank line, and the hold outlived the connection - a new sender got error:15 on its first g-code line. |
||
|
|
b17778f538 |
forgetest: verdict-cut holds the daemon under the report dead-man
laser.verdict-cut freezes the daemon so the verdict the controller caches expires and the driver's pause tier holds the job, then resumes and proves the job ran dark through the hold with the laser latch never locked. The freeze was 3.5 s. The engine's report dead-man locks the laser latch when the controller's report is older than 5.0 s while armed, and the last report is up to 1 s old when the freeze starts, so a 3.5 s freeze left only a half second of margin - a dead-man latch lock reads exactly like the failure the test refuses. The freeze is now 3.0 s: still past the 2.0 s the cached verdict takes to expire (so the pause tier holds and the hold is seen), a full second under the dead-man. No behavior changed, only the freeze the test holds. |
||
|
|
d5551d7e8a |
Pin grblHAL-glowforge 0.1.16
The fresh-controller button-lamp reset (a87837a): a controller ended mid-arm by a fail tier left the button ring glowing 'press to arm', and the respawned controller did not clear it. |
||
|
|
41417f820b |
forgetest: clear a latched controller error, bound the move-start check, and follow the fail-tier restart
Three acceptance tests broke on the WI-4/WI-5 firmware, all from a behavior a change moved and no test tracked. motion: soft limits are armed after a home (the driver's, since the bed has no switches), so a jog past the bed is refused with error:15 - and grblHAL then answers error:15 to every following G-code line, across a fresh connection, until a blank line acknowledges it. A prior test's or the baseline hand-back's rejected jog left that latch, and the next test's first move failed with a stale error unrelated to the move. clean_slate now clears it with a blank line before the first move. The four "send G1, sleep, check Run once" sites are replaced by start_move, which waits for Run on a bound and, when it does not come, names the reply, the state, the drained messages and a forgectrl snapshot - the capture that turned "the move did not start" into "answered error:15". cooling.fire-watch-tiers: the FIRE fail tier now stops the controller through the supervisor and starts it again, so leg 2's Grbl connection dies mid-leg. It now records the pid, reads FIRE from the engine and the latch from sysfs, waits out the restart, proves the supervisor logged the fail-tier stop, and opens a fresh session for the legs that follow; src/super.* added to its covers. Proof: full forgetest host suite green; coverage lint clean (90 tests); bench reference on the dev image, unattended queue: motion.* and cooling.* pass, cooling.fire-watch-tiers and cooling.fail-tier-stop pass with the button-lamp driver fix (grblHAL-glowforge). |
||
|
|
db6015dc81 |
forgetest: cloud.mode-switch opens the lid behind the controller's start, ahead of the hunt
The supervisor holds every controller spawn until the enclosure is closed (forgectrl 0.1.25), and the test opened the lid before it asked for the cloud controller: POST /mode answered "waiting, the lid is open" and the controller never came up. The round trip now switches with the lid closed, polls /mode five times a second, and opens the lid the moment the controller is running. The client requests its connect-time hunt a few seconds after its start, right behind its session, so the hunt still finds the lid open. The order is recorded and judged: the hunt's request line must not be in the client's log when the lid reads open (hunt_before_lid_open), and the test refuses to start with the lid open. The catalog text tells the operator to open the lid at once, with a hand ready on it. Proof. Host: test_cloud_suite drives the round trip with the hunt landing only once the lid reads open, as on the bench, plus the lost race (the hunt requested before the lid opened fails the test with "before the lid was open") and the start with the lid open refused; 8 mode-switch cases green. Bench reference (dev image 20260915001814, forgectrl 0.1.25): cloud.mode-switch PASS in 118 s, the lid open 4 s before the client requested its hunt, no refusal before the hunt's end, the lens homed, the exhaust row unjudged, 5 service motions after the lid closed, $H under gfhome homed in 48.4 s with 9 motion windows. No catalog consequence beyond the test itself: its covers map is unchanged. |
||
|
|
0c87927388 |
Pin grblHAL-glowforge 0.1.15
The driver at its AGENTS.md and header-comment revision (9bfaa98): no code change since 0.1.14; the pinned source now matches the head of the repository for the release. |
||
|
|
ea8a94a7d1 |
AGENTS.md: unreadable is fail-closed, and the drills that are catalog tests
The project-wide safety rule joins every repository: a failed read of a safety input, a state file, or a verdict is never treated as still running or keep waiting without a bound. The acceptance-coverage rules add that a silence-while-armed injection and a crash during $H are catalog drills, not one-off bench scripts. |
||
|
|
79c03641bd |
Lock meta-openglow at the python3-gfhardware pin
The BSP layer at dd8f74c: python3-gfhardware pinned on the cloud client's latch and feed changes (5ca279a), the same revision the forgefirm-app recipes pin, and the layer's line endings normalized. |