Files
forgefirm/scripts/bench/cool_report_test.py
T
ScottW514 8e1fe5090d Harnesses: the null-sink cases other tests prove leave
The grblHAL harnesses keep what only a running controller shows; the
cases a host unit test in grblHAL-glowforge proves, and the log and
reply wording, leave.

- laser_lifecycle_test: test_arm_waits_for_the_fans goes. laser_arm_test
  case A4 (grblHAL-glowforge) proves the fan grace stretching the arm's
  acknowledgment budget, on the same arm path. The docstring names it.
- xy_mode_test: case_x32, case_invalid and the log checks go; the
  default, x16 and the low tick stay. xy_scale_test holds each mode's
  tick and the not-a-mode parse.
- planner_blocks_test: one restart, at $398 1000; the 400 pass and the
  fresh-store probe go.
- ctlport_test: test_poll_lf and test_crlf go (the CR LF poll stays); in
  test_sender_out the network turn-away goes (serial_test holds it), and
  the busy gating, the grant and the outlives-client part stay.
  test_port_dark is whole.
- mcode_test: the timeout case runs on the host build's 5 s
  GFMCODE_WAIT_S (grblHAL-glowforge's CI configures it; the image keeps
  30 s) and still shows the hold, at 5.1 s; 5 s outlasts the 3 s
  test_dark watches the wait; test_table refuses one form per
  parser branch. test_timeout_holds and test_dark stay.
- envelope_test: test_unset and test_bounds go (lens_home_test holds the
  clamp); test_armed and the open, apply and close cases stay.
- manual_home_test: the out-of-range offset block goes, a repeat of
  gfhome_clamp_home_mm (lens_home_test); the modal-M3 FIRE check and
  every release and energize case stay.
- cool_report_test: one non-hex secret and one CR LF injection stay;
  runner-malformed and the exact header-set checks go. A bad secret is
  never sent and a good one is.
- tray_test: test_offset keeps 20 mm; test_errors keeps a bad P and a
  bad axis; the frame shift, envelope, barrier, persistence, port op,
  unreferenced lens and camera home stay.

Proof: with grblHAL-glowforge's matching commit, the eleven harnesses
its CI runs pass under Linux against the null-sink build: nine on a
build with a 1 s M-code wait, and mcode_test and tray_test, the two
that wait at an M-code, on the 5 s build CI configures
(-DGFMCODE_WAIT_S=5.0). pyflakes reports nothing in the nine changed
files.

Push this before grblHAL-glowforge's commit.

Acceptance: none; host harnesses only.
2026-10-13 21:20:31 -04:00

195 lines
7.6 KiB
Python

#!/usr/bin/env python3
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""Host harness: the controller's cooling reports carry the supervisor's secret.
POST /cool/state is the running controller's channel alone: forgectrl hands
each controller it spawns a secret in its environment (GF_REPORT_SECRET), and
the route asks for it. This harness stands in for forgectrl's listener, runs
the null-sink controller against it, and reads every report as it arrives.
secret every report carries X-ForgeFIRM-Report with the secret it was
started with
none started with no secret, the reports carry no such header
malformed a value that is not 32 hex digits (a letter out of range, or a
CR LF and a header of its own behind it, 32 characters long) is
never sent, and nothing of it reaches the wire
runner the homing runner the controller starts is handed the secret (it
reports in the controller's place while a gfcloud $H holds the
machine, and the controller is quiet then), and the reports
still carry it afterwards; a controller with no secret hands the
runner none
Usage: cool_report_test.py <path-to-grblHAL_glowforge>
"""
import http.server
import os
import shutil
import socket
import subprocess
import sys
import tempfile
import threading
import time
BIN = os.path.abspath(sys.argv[1]) if len(sys.argv) > 1 else "build/grblHAL_glowforge"
SECRET = "0123456789abcdef0123456789abcdef"
GRBL_PORT = 23960
def fail(msg):
print("FAIL:", msg)
sys.exit(1)
class Listener:
"""forgectrl's HTTP listener, as far as the reports need it: every
request is kept whole (the raw header block included) and answered 200."""
def __init__(self):
self.seen = []
outer = self
class H(http.server.BaseHTTPRequestHandler):
def do_POST(self):
outer.seen.append({"path": self.path, "headers": {k.lower(): v for k, v in self.headers.items()},
"raw": bytes(self.headers)})
self.send_response(200)
self.send_header("Content-Length", "2")
self.end_headers()
self.wfile.write(b"{}")
def log_message(self, *a):
pass
self.srv = http.server.ThreadingHTTPServer(("127.0.0.1", 0), H)
self.port = self.srv.server_address[1]
threading.Thread(target=self.srv.serve_forever, daemon=True).start()
def wait(self, n, timeout=8.0):
end = time.time() + timeout
while time.time() < end and len(self.seen) < n:
time.sleep(0.05)
return list(self.seen)
def close(self):
self.srv.shutdown()
def publish_verdicts(path, stop):
while not stop.is_set():
tmp = path + ".tmp"
with open(tmp, "w") as f:
f.write('{"ts_mono":%.3f,"fire_ok":true,"verdict":"OK","hold":false,'
'"resume_ok":true,"armed":false,"reason":""}' % time.clock_gettime(time.CLOCK_MONOTONIC))
os.replace(tmp, path)
stop.wait(0.5)
class Controller:
def __init__(self, listener, secret, runner_cmd=None):
self.workdir = tempfile.mkdtemp(prefix="cool-report-")
conf = os.path.join(self.workdir, "forgefirm.conf")
with open(conf, "w") as f:
f.write("cool_fan_grace_s = 0\nhoming_mode = %s\n" % ("gfcloud" if runner_cmd else "none"))
if runner_cmd:
f.write("gfcloud_home_cmd = %s\n" % runner_cmd)
verdict = os.path.join(self.workdir, "cooling.state")
self.stop = threading.Event()
threading.Thread(target=publish_verdicts, args=(verdict, self.stop), daemon=True).start()
env = dict(os.environ, GFHOME_CONF=conf, GF_STATE_DIR=self.workdir, GF_VERDICT_FILE=verdict,
GFSINK_DUMP=os.path.join(self.workdir, "stream.bin"), FFLOG_STDERR="1",
FORGECTRL_PORT=str(listener.port))
for k in ("GFSINK", "GF_SWITCH_FILE", "GF_REPORT_SECRET"):
env.pop(k, None)
if secret is not None:
env["GF_REPORT_SECRET"] = secret
self.proc = subprocess.Popen([BIN, "-p", str(GRBL_PORT)], cwd=self.workdir, env=env,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
def sender(self):
for _ in range(50):
try:
s = socket.create_connection(("127.0.0.1", GRBL_PORT), timeout=2)
s.settimeout(6)
return s
except OSError:
time.sleep(0.1)
fail("the controller's Grbl socket never opened")
def close(self):
self.stop.set()
self.proc.terminate()
try:
self.proc.wait(5)
except subprocess.TimeoutExpired:
self.proc.kill()
shutil.rmtree(self.workdir, ignore_errors=True)
def run(name, secret, check, runner=False, handed=None):
lis = Listener()
ctl = Controller(lis, secret, runner_cmd=("env > %s" % "RUNNER_ENV") if runner else None)
try:
if runner:
env_file = os.path.join(ctl.workdir, "RUNNER_ENV")
s = ctl.sender()
time.sleep(0.5)
s.sendall(b"$H\n")
end = time.time() + 10
while time.time() < end and not os.path.exists(env_file):
time.sleep(0.1)
if not os.path.exists(env_file):
fail("[%s] the stand-in homing runner never ran" % name)
time.sleep(0.3)
with open(env_file) as f:
runner_env = f.read()
got = [ln.split("=", 1)[1] for ln in runner_env.splitlines() if ln.startswith("GF_REPORT_SECRET=")]
if got != ([handed] if handed else []):
fail("[%s] the homing runner was handed %r, not %r" % (name, got, handed))
if not handed and SECRET in runner_env:
fail("[%s] the secret reached the homing runner another way" % name)
if "GF_STATE_DIR" not in runner_env:
fail("[%s] the runner's environment was not read: %r" % (name, runner_env[:80]))
s.close()
lis.seen.clear() # what matters is what is reported after it
reports = lis.wait(3)
if len(reports) < 3:
fail("[%s] %d reports in 8 s: the level-triggered report did not arrive" % (name, len(reports)))
for r in reports:
if not r["path"].startswith("/cool/state?mode="):
fail("[%s] a request that is no report: %s" % (name, r["path"]))
check(name, r)
print("ok: %s (%d reports)" % (name, len(reports)))
finally:
ctl.close()
lis.close()
def with_secret(name, r):
if r["headers"].get("x-forgefirm-report") != SECRET:
fail("[%s] a report without the secret: %s" % (name, r["headers"]))
def without(name, r):
if "x-forgefirm-report" in r["headers"] or "x-evil" in r["headers"] or b"Evil" in r["raw"]:
fail("[%s] a header that must not be sent: %s" % (name, r["headers"]))
def main():
if not os.path.exists(BIN):
fail("no controller binary at %s" % BIN)
run("secret", SECRET, with_secret)
run("none", None, without)
for i, bad in enumerate((SECRET[:-1] + "g",
SECRET[:16] + "\r\nX-Evil: 1\r\nX:1"), 1): # the second is 32 long
run("malformed-%d" % i, bad, without)
run("runner", SECRET, with_secret, runner=True, handed=SECRET)
run("runner-none", None, without, runner=True)
print("cool_report_test: all passed")
if __name__ == "__main__":
main()