From ab0a515a854f3bf3b05be6ff981493612de7bac7 Mon Sep 17 00:00:00 2001 From: ScottW514 Date: Sun, 23 Aug 2026 12:10:20 -0400 Subject: [PATCH] forgetest: the supervisor's levers get a timeout above the daemon's own waits POST /mode, /controller/start and /controller/stop answer only when the switch is done: the old controller gone, the new one started after any pending liveness probe, and its first job-state report in (15 s without one). The client's 10 s timeout read a slow but honest switch as a dead daemon and errored cloud.service-protocol on the bench; those three paths now get 120 s. No catalog consequence: the tests and their covers are unchanged, the client only waits longer. --- forgetest/forgetest/hw.py | 19 +++++++++++++++++-- forgetest/tests/test_mode.py | 21 +++++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/forgetest/forgetest/hw.py b/forgetest/forgetest/hw.py index d9f154e..d75a312 100644 --- a/forgetest/forgetest/hw.py +++ b/forgetest/forgetest/hw.py @@ -26,14 +26,29 @@ class HwError(Exception): # ------------------------------------------------------------ forgectrl +# The supervisor's levers answer only when the switch is done: a mode +# switch waits for the old controller to exit, the new one to start (a +# pending liveness probe first) and its first job-state report (up to +# 15 s; the emulator's comes with its start, a real client's with its +# machine); a stop waits for the child to go. They get their own timeout, +# above the daemon's own deadlines, so a slow but honest switch is never +# read as a dead daemon. +SLOW_PATHS = ("/mode", "/controller/start", "/controller/stop") +SLOW_TIMEOUT_S = 120.0 + + class Forgectrl: """Thin client for the machine-services daemon.""" - def __init__(self, base=None, token=None, timeout=10.0): + def __init__(self, base=None, token=None, timeout=10.0, slow_timeout=SLOW_TIMEOUT_S): self.base = (base or os.environ.get("FORGECTRL_URL") or "http://127.0.0.1:8080").rstrip("/") self.timeout = timeout + self.slow_timeout = slow_timeout self._token = token + def timeout_for(self, method, path): + return self.slow_timeout if method == "POST" and path in SLOW_PATHS else self.timeout + @property def token(self): if self._token is None: @@ -71,7 +86,7 @@ class Forgectrl: hdrs.setdefault("X-ForgeFIRM-Token", self.token) req = urllib.request.Request(url, data=body, method=method, headers=hdrs) try: - with urllib.request.urlopen(req, timeout=self.timeout) as resp: + with urllib.request.urlopen(req, timeout=self.timeout_for(method, path)) as resp: status = resp.status content = resp.read() ctype = resp.headers.get("Content-Type", "") diff --git a/forgetest/tests/test_mode.py b/forgetest/tests/test_mode.py index 6d96be9..40f85da 100644 --- a/forgetest/tests/test_mode.py +++ b/forgetest/tests/test_mode.py @@ -78,6 +78,27 @@ class SwitchModeTests(unittest.TestCase): self.assertTrue(ok, detail) self.assertEqual(self.fc.posts, []) + def test_the_supervisors_levers_get_the_slow_timeout(self): + from forgetest import hw + fc = hw.Forgectrl(timeout=1.0, slow_timeout=2.0) + self.assertEqual(fc.timeout_for("POST", "/mode"), 2.0) + self.assertEqual(fc.timeout_for("POST", "/controller/start"), 2.0) + self.assertEqual(fc.timeout_for("POST", "/controller/stop"), 2.0) + self.assertEqual(fc.timeout_for("GET", "/mode"), 1.0) + self.assertEqual(fc.timeout_for("POST", "/settings"), 1.0) + self.assertGreater(hw.SLOW_TIMEOUT_S, 15.0) # forgectrl's first-report wait + # a switch that answers after the ordinary timeout still lands + import time + + def on_post(path, form): + time.sleep(1.5) + self.fc.state["mode"] = dict(self.fc.state["mode"], mode="cloud") + return None + self.fc.on_post = on_post + st, body = fc.post("/mode", data={"controller": "cloud"}) + self.assertEqual(st, 200) # 1.5 s > timeout, < slow_timeout + self.assertEqual(self.fc.state["mode"]["mode"], "cloud") + def test_cloud_to_grbl_switches_and_waits_for_the_port(self): cloud(self.fc) b = self.bl()