From 40c478d56c2ffdeff98cab5a00644d0b5ff0600a Mon Sep 17 00:00:00 2001 From: ScottW514 Date: Wed, 14 Oct 2026 17:33:16 -0400 Subject: [PATCH] Catalog: forgectrl.lease leaves forgectrl.lease leaves for forgectrl's tests/host_lease.sh (0086e65), which passed as build.yml runs it: with the gate open and cnc/state idle, the switches check held at its first wait names wizard:switches, kind hardware, as the holder; POST /diag/flow-verify, /curve/record, /logs/export, /mode with the mode in force, /controller/start and /settings are each 409 in the holder's name, none for an idle machine or a closed gate; the abort frees the lease and the same settings write is 200; lease.changed carries the take before the free. lease_test and super_test case K hold the lease's own rules. The catalog goes from 81 to 80 (80 @test in forgetest/forgetest/suite/). _EventStream, _lease and _text, which only it read, go with it, and so does urllib.parse, which only _EventStream used; socket stays for lan_ip. No test requires the id, and no unit test names it. The coverage lint on the tree manifest then named forgectrl src/events.c and src/events.h, and nothing else beside setup.check-lens's empty src/lensfind.* entry. One HOST_TESTED row takes them, to forgectrl:tests/events_test.c on forgectrl:.github/workflows/build.yml, which the pin c274f19 runs (./build/events_test). The lint then finds no uncovered path and no host-tested row problem across the 80 tests; its one finding is setup.check-lens's. test_manifest.py (34) passes, and pyflakes reports nothing on forgectrl.py or manifest.py. The full forgetest suite was not run. Fingerprints, measured against the parent: 26 tests move with forgectrl.py's shared text: the 4 left in it (auth, panel-serves, settings-bounds, tokens), the 5 left in exthost.py and the 17 of the setup family, whose modules import lan_ip from it (exthost.py and setup.py, and through setup.py the rest). The pending pin bumps from forgectrl c274f19 and forgeext 2d0391e already owe 24 of them; exthost.platform has been owed since 30c0a05 and setup.machine-name since 70e13bd. This deletion adds none to the owed list. releases/ and TEST-AUDIT.md are unchanged. --- forgetest/forgetest/manifest.py | 3 + forgetest/forgetest/suite/forgectrl.py | 154 ------------------------- 2 files changed, 3 insertions(+), 154 deletions(-) diff --git a/forgetest/forgetest/manifest.py b/forgetest/forgetest/manifest.py index 91401e9..3f66ca3 100644 --- a/forgetest/forgetest/manifest.py +++ b/forgetest/forgetest/manifest.py @@ -159,6 +159,9 @@ HOST_TESTED = [ # the closed list of actions, the host's words, the catalog's fetch (the # routes against a live host's stand-in are tests/host_ext.sh). ("forgectrl", "src/extpkg.*", "forgectrl:tests/extpkg_test.c", "forgectrl:.github/workflows/build.yml"), + # The event stream: a button edge, a lease changing hands, telemetry + # (the live order, the take before the free, is tests/host_lease.sh). + ("forgectrl", "src/events.*", "forgectrl:tests/events_test.c", "forgectrl:.github/workflows/build.yml"), ] # This repository's root, when the package runs from a checkout of it; a diff --git a/forgetest/forgetest/suite/forgectrl.py b/forgetest/forgetest/suite/forgectrl.py index 54f8aa2..79ea76c 100644 --- a/forgetest/forgetest/suite/forgectrl.py +++ b/forgetest/forgetest/suite/forgectrl.py @@ -8,7 +8,6 @@ import json import os import socket import time -import urllib.parse from ..catalog import test from .. import hw @@ -455,159 +454,6 @@ def panel_serves(ctx): "GET /curve/ladder.gcode -> %s without the ladder", st) -class _EventStream: - """GET /events over a raw socket bound to a chosen loopback source - address. Every address in 127.0.0.0/8 is this host, so the test can be - several peers at once, and the daemon counts streams per peer address.""" - - def __init__(self, base, source, timeout=5.0): - u = urllib.parse.urlsplit(base) - self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - self.sock.bind((source, 0)) - self.sock.settimeout(timeout) - self.sock.connect((u.hostname or "127.0.0.1", u.port or 80)) - self.sock.sendall(b"GET /events HTTP/1.1\r\nHost: %s\r\nAccept: text/event-stream\r\n" - b"Connection: close\r\n\r\n" % (u.netloc or "127.0.0.1").encode()) - self.buf = b"" - self.eof = False # the response is over: the last chunk, or the socket closed - head = self._until(b"\r\n\r\n") - self.status = int(head.split(b" ", 2)[1]) if head.startswith(b"HTTP/") else 0 - self.head = head.decode("latin-1", "replace") - - def _fill(self): - try: - chunk = self.sock.recv(4096) - except socket.timeout: - return False - if not chunk: - self.eof = True - return False - self.buf += chunk - return True - - def _until(self, mark): - while mark not in self.buf: - if not self._fill(): - break - i = self.buf.find(mark) - if i < 0: - out, self.buf = self.buf, b"" - return out - out, self.buf = self.buf[:i], self.buf[i + len(mark):] - return out - - def text(self, seconds): - """Everything the stream says in the next `seconds` (chunk framing - and all: the checks look for event names inside it).""" - end = time.time() + seconds - self.sock.settimeout(0.2) # the window is the caller's, not one long read - while time.time() < end and not self.eof: - self._fill() - if self.buf.endswith(b"\r\n0\r\n\r\n"): - self.eof = True - out, self.buf = self.buf, b"" - return out.decode("utf-8", "replace") - - def close(self): - try: - self.sock.close() - except OSError: - pass - - -def _lease(fc): - return (fc.status().get("lease") or {}) - - -def _text(body): - return body if isinstance(body, str) else json.dumps(body) - - -@test("forgectrl.lease", title="The machine lease: a holder refuses everything else, by name", - subsystem="forgectrl", kind="auto", est_min=2, - covers=[("forgectrl", "src/lease.*"), ("forgectrl", "src/main.c"), ("forgectrl", "src/status.*"), - ("forgectrl", "src/wizdark.*"), ("forgectrl", "src/diag.*"), ("forgectrl", "src/curverec.*"), - ("forgectrl", "src/jobrun.*"), ("forgectrl", "src/update.*"), ("forgectrl", "src/logs.*"), ("forgectrl", "src/super.*"), - ("forgectrl", "src/events.*")], - description="The switches check is started and left waiting at its first prompt: it moves " - "nothing, and it holds the machine lease for as long as it waits. /status must " - "name it as the holder (wizard:switches, kind hardware), the event stream must " - "report lease.changed, 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 (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. The " - "check is then aborted, and the lease must read free, lease.changed must say so, " - "and POST /settings must be accepted again. Whatever a failed refusal started is " - "stopped on the way out.") -def lease(ctx): - from .setup_dark import dark - - ev = ctx.evidence - fc = hw.Forgectrl() - wid, owner = "switches", "wizard:switches" - rest = _lease(fc) - ctx.check("holder" in rest and rest["holder"] is None, "the machine is not free at the start: %s", rest) - ctx.check(set(rest.get("observed") or {}) == {"sender", "motors_released"}, - "what is observed outside the lease is not reported: %s", rest) - mode = (fc.get("/mode")[1] or {}).get("mode") - units = fc.settings().get("ui_units") or "metric" - stream = _EventStream(fc.base, "127.0.0.6") - ctx.check(stream.status == 200, "GET /events -> %s", stream.status) - stream.text(0.5) - started = False - try: - st, body = fc.post("/wiz/%s/start" % wid) - ctx.check(st == 200, "the %s check did not start (%s %s)", wid, st, _text(body)) - started = True - ok = ctx.wait_for(lambda: (_lease(fc).get("holder") or {}).get("owner") == owner, 10, poll=0.2) - held = _lease(fc).get("holder") or {} - ev["holder"] = held - ctx.log("the lease while the check waits: %s", held) - ctx.check(ok is not None and held.get("kind") == "hardware" and "switches" in (held.get("words") or ""), - "the lease does not name the running check: %s", held) - - refused = {} - for name, path, kw in ( - ("a diagnostic", "/diag/flow-verify", {}), - ("the recorder", "/curve/record", {}), - ("a log export", "/logs/export", {}), - ("a mode switch", "/mode", {"params": {"controller": mode}}), - ("the controller's start", "/controller/start", {}), - ("a settings write", "/settings", {"params": {"ui_units": units}})): - st, body = fc.post(path, **kw) - refused[name] = [st, _text(body)[:160]] - ctx.check(st == 409 and "holds the machine" in _text(body) and "switches" in _text(body), - "%s under the wizard's hold -> %s %s", name, st, _text(body)[:160]) - ctx.check("settings are locked" in refused["a settings write"][1], - "the settings refusal does not say they are locked: %s", refused["a settings write"]) - ev["refused"] = refused - ctx.check((_lease(fc).get("holder") or {}).get("owner") == owner, - "a refused request took the lease away: %s", _lease(fc)) - finally: - if started: - fc.post("/wiz/%s/abort" % wid) - ctx.wait_for(lambda: not dark(fc).get("running"), 30) - # Nothing a failed refusal may have started is left running. - fc.post("/diag/abort") - fc.post("/curve/stop") - - ok = ctx.wait_for(lambda: _lease(fc).get("holder") is None, 30, poll=0.5) - ctx.check(ok is not None, "the lease was not given back after the abort: %s", _lease(fc)) - st, body = fc.post("/settings", params={"ui_units": units}) - ctx.check(st == 200, "POST /settings with the machine free again -> %s %s", st, _text(body)[:120]) - text = stream.text(2.0) - stream.close() - changes = [l for l in text.splitlines() if l.startswith("data:") and "owner" in l] - ev["lease_changed"] = changes - ctx.log("the event stream: %s", changes) - i_take, i_free = text.find('"owner":"%s"' % owner), text.find('"owner":null') - ctx.check("event: lease.changed" in text and 0 <= i_take < i_free, - "lease.changed did not report the hold and then its end: %r", text[-300:]) - ctx.log("PASS: %s held the machine, six requests were refused by name, the abort freed it, and the " - "event stream said both", owner) - - # ---------------------------------------------------------------- scoped tokens def _words_of(body):