fix(webconfig): publish the board probe safely and test the gap
Codex review of the previous commit found two real problems. The routes go live in startLanMode()/startSetupMode(), one tick before probeBoardCommands() first runs, so a status request can land in between. That made _board_cmds a genuine cross-task race despite the comment claiming otherwise, and — worse — let /api/status answer "no board commands" before the board had been asked. The page reads status once, so a browser that loaded in that window hid working FEM controls for the whole session. The probe result is now published and read under _mux, and /api/status OMITS board_cmds until the probe has run, which is not the same as reporting an empty list. The page treats the absent field as "not known yet" and re-polls with backoff; the audit waits for it the same way, rather than auditing a board it only half knows. Probing from the start path instead would close the window, but Callbacks::execCommand is documented as loop-task-only and `start webconfig` is itself mid-handleCommand, so that trades a race for re-entrancy. The audit never executed fan.lo or fan.hi, which have no getter and so no round-trip: they were only ever matched as strings. They now run for real, including the four rejection paths, and the mock grew --probe-delay so the retry path is exercised rather than assumed.
This commit is contained in:
1 parent
3d2d0b5da6
commit
2c057b0fc6
5 files changed
+106
-18
No files matched your search
@@ -12,6 +12,13 @@ that come back an error, so the two stay honest about each other.
|
||||
python3 scripts/webconfig_mock_server.py --port 8137 &
|
||||
python3 scripts/webconfig_cli_audit.py
|
||||
|
||||
Board-specific commands (Board::handleCommand) are offered only when the node
|
||||
says it answers them, so one run exercises one shape of board. The mock claims
|
||||
none by default, like a Heltec V3; drive the other shapes with --board-cmds:
|
||||
|
||||
python3 scripts/webconfig_mock_server.py --port 8137 \
|
||||
--board-cmds radio.fem.rxgain,radio.fem.txgain,fan &
|
||||
|
||||
Exits non-zero if anything fails that is not in EXPECTED_FAILURES. Stdlib only.
|
||||
"""
|
||||
|
||||
@@ -169,6 +176,21 @@ BOARD_ROUND_TRIPS = {
|
||||
"fan": [("set fan on", "get fan", "on 41.0C fan=on cd=0s")],
|
||||
}
|
||||
|
||||
# Board commands with no getter to read back, so a round-trip cannot reach them.
|
||||
# Run in order, and drive the rejection path too: a set-only key that silently
|
||||
# accepted anything would look identical to one that works.
|
||||
# [(command, should_succeed, expected substring of the reply)]
|
||||
BOARD_SET_PROBES = {
|
||||
"fan": [
|
||||
("set fan.lo 40", True, "OK"),
|
||||
("set fan.hi 70", True, "OK"),
|
||||
("set fan.lo 200", False, "0..100"), # out of range
|
||||
("set fan.lo 90", False, "< fan.hi"), # crosses the upper bound
|
||||
("set fan.hi 10", False, "> fan.lo"), # crosses the lower bound
|
||||
("set fan.hi 130", False, "<= 120"), # out of range
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class Client:
|
||||
def __init__(self, base):
|
||||
@@ -179,8 +201,17 @@ class Client:
|
||||
# rather than hardcoding a number that drifts when the slot is resized.
|
||||
status = json.load(self._open("/api/status"))
|
||||
self.max_cmds = status.get("max_cmds", 24)
|
||||
# Board::handleCommand() commands this node probed for at startup.
|
||||
self.board_cmds = [c for c in status.get("board_cmds", "").split(",") if c]
|
||||
# Board::handleCommand() commands this node probed for at startup. The
|
||||
# field is absent until the probe has run, which is NOT the same as an
|
||||
# empty list, so wait for it rather than audit a board half-known.
|
||||
for _ in range(20):
|
||||
if "board_cmds" in status:
|
||||
break
|
||||
time.sleep(0.25)
|
||||
status = json.load(self._open("/api/status"))
|
||||
else:
|
||||
sys.exit("node never reported board_cmds; it has not probed its board")
|
||||
self.board_cmds = [c for c in status["board_cmds"].split(",") if c]
|
||||
|
||||
def _open(self, path, data=None):
|
||||
headers = {"Content-Type": "application/json"}
|
||||
@@ -295,6 +326,16 @@ def main():
|
||||
% (getc, got, want, setr["reply"]))
|
||||
failures.append((getc, got))
|
||||
|
||||
# Set-only board commands, which no round-trip can reach.
|
||||
probes = [p for gate in cli.board_cmds for p in BOARD_SET_PROBES.get(gate, [])]
|
||||
if probes:
|
||||
print("\nset-only board commands : %d" % len(probes))
|
||||
for (cmd, want_ok, want), (_, res) in zip(probes, cli.run([p[0] for p in probes])):
|
||||
if res["ok"] == want_ok and want in res["reply"]:
|
||||
continue
|
||||
print(" FAIL %-30s %s" % (cmd, res["reply"]))
|
||||
failures.append((cmd, res["reply"]))
|
||||
|
||||
# Commands the portal refuses must be refused clearly, not run and fudged.
|
||||
print("\nrefused with a reason : ", end="")
|
||||
refused = []
|
||||
|
||||
@@ -155,6 +155,7 @@ class State:
|
||||
# Board::handleCommand() commands this "board" answers. Empty by default:
|
||||
# the mock is a Heltec V3, which implements no such hook at all.
|
||||
self.board_cmds = [c for c in args.board_cmds.split(",") if c]
|
||||
self.probe_delay = args.probe_delay
|
||||
self.cfg = default_config(args.setup)
|
||||
# latched at AP start, like WebConfigServer::_initial_setup
|
||||
self.initial_setup = args.setup and self.cfg["wifi"]["ssid"] == ""
|
||||
@@ -186,7 +187,7 @@ class State:
|
||||
return c
|
||||
|
||||
def status_json(self, authed):
|
||||
return {
|
||||
out = {
|
||||
"mode": "setup" if self.setup_mode else "lan",
|
||||
"auth": authed,
|
||||
"needs_setup": self.cfg["wifi"]["ssid"] == "",
|
||||
@@ -198,9 +199,14 @@ class State:
|
||||
"role": "Repeater", "board": "Heltec V3 (mock)",
|
||||
"uptime_s": int(time.time() - self.start),
|
||||
"runtime_slots": 6, "max_slots": 6, "active_slots": self.active_slots,
|
||||
"board_cmds": ",".join(self.board_cmds),
|
||||
"max_cmds": CLI_MAX_CMDS,
|
||||
}
|
||||
# ABSENT, not empty, until the board has been probed: the node's routes
|
||||
# go live a tick before probeBoardCommands() runs, and the page has to
|
||||
# retry rather than latch "this board has no board commands".
|
||||
if time.time() - self.start >= self.probe_delay:
|
||||
out["board_cmds"] = ",".join(self.board_cmds)
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1119,6 +1125,9 @@ def main():
|
||||
ap.add_argument("--port", type=int, default=8080)
|
||||
ap.add_argument("--setup", action="store_true", help="first-boot setup wizard mode")
|
||||
ap.add_argument("--active-slots", type=int, default=5, help="server slots to expose (2 or 5)")
|
||||
ap.add_argument("--probe-delay", type=float, default=0.0,
|
||||
help="seconds to withhold board_cmds from /api/status, simulating "
|
||||
"the gap before the node has probed its board")
|
||||
ap.add_argument("--board-cmds", default="",
|
||||
help="comma list of Board::handleCommand() commands to answer, e.g. "
|
||||
"radio.fem.rxgain,radio.fem.txgain,fan (default: none, like a Heltec V3)")
|
||||
|
||||
Reference in new issue
Block a user