|
|
@@ -214,6 +214,7 @@ class FakeSaddle:
|
|
|
self.output_seq = 100
|
|
|
self.subtask_status_events = [] # recorded POST /subtask-status payloads
|
|
|
self.fail_subtask_status = False # exercise the web's retry path
|
|
|
+ self.hang_next = {} # path suffix -> hang count (never responds)
|
|
|
|
|
|
def set_state(self, state, summary=None):
|
|
|
"""Advances the scripted lifecycle.
|
|
|
@@ -261,6 +262,15 @@ class FakeSaddle:
|
|
|
def log_message(self, fmt, *args):
|
|
|
pass
|
|
|
|
|
|
+ def maybe_hang(self, path):
|
|
|
+ """True when this request should hang forever (never respond),
|
|
|
+ consuming one unit of the path's hang budget."""
|
|
|
+ for suffix, count in list(self.daemon.hang_next.items()):
|
|
|
+ if count > 0 and path.startswith(suffix):
|
|
|
+ self.daemon.hang_next[suffix] = count - 1
|
|
|
+ return True
|
|
|
+ return False
|
|
|
+
|
|
|
def _send(self, status, obj):
|
|
|
body = json.dumps(obj).encode()
|
|
|
self.send_response(status)
|
|
|
@@ -309,6 +319,9 @@ class FakeSaddle:
|
|
|
self._send(404, {"error": "no such route"})
|
|
|
|
|
|
def do_GET(self):
|
|
|
+ if self.maybe_hang(self.path):
|
|
|
+ time.sleep(600)
|
|
|
+ return
|
|
|
if self.path == "/status":
|
|
|
response = {"state": self.daemon.state, "summary": self.daemon.summary}
|
|
|
if self.daemon.resumed_pending:
|
|
|
@@ -369,6 +382,7 @@ def start_app():
|
|
|
"SPRY_SEED_ADMIN_PASSWORD": "adminpass123",
|
|
|
"SADDLE_LLM_BASE_URL": "http://127.0.0.1:%d" % DAEMON_PORT,
|
|
|
"SADDLE_PROJECTS_ROOT": os.path.join(WORK, "workspace"),
|
|
|
+ "SADDLE_HTTP_TIMEOUT_S": "5",
|
|
|
# Never inherit a caller-poisoned LD_LIBRARY_PATH (e.g. one that
|
|
|
# resolves the ancient /lib64 copies): empty makes the loader use
|
|
|
# the binary's RUNPATH, which is the dev stack it was built
|
|
|
@@ -1768,6 +1782,35 @@ def main():
|
|
|
status, body = saddle_api("GET", "/api/saddle/task/99999", r4_token)
|
|
|
check(status == 404, "unknown task id returns 404")
|
|
|
|
|
|
+
|
|
|
+ print("== R6 F1: a hanging request cannot freeze the tick ==")
|
|
|
+ # One wedged /output (never responds) blocks a poll; the Soup
|
|
|
+ # timeout must fail it and later ticks keep dispatching.
|
|
|
+ task_state(auth, lineage_task)
|
|
|
+ conn = db()
|
|
|
+ conn.execute("insert into tasks (project_id, title, status, priority, created_at)"
|
|
|
+ " values (1, 'Hang survivor', 'queued', 2, strftime('%s','now'))")
|
|
|
+ hang_task = conn.execute("select max(id) from tasks").fetchone()[0]
|
|
|
+ conn.close()
|
|
|
+ fake.hang_next["/output"] = 1
|
|
|
+ # Dispatch a task first so a poll is in flight against the hang,
|
|
|
+ # then verify a later task still dispatches (tick not frozen).
|
|
|
+ act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
|
|
|
+ "project_id": "1",
|
|
|
+ "title": "Hang runner",
|
|
|
+ "priority": "3",
|
|
|
+ })
|
|
|
+ hang_runner = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
|
|
|
+ if t["title"] == "Hang runner"][0])
|
|
|
+ check(wait_task_done(auth, hang_runner, timeout=90) is not None,
|
|
|
+ "dispatching task survives a hung daemon request")
|
|
|
+ # The hung poll itself fails after the timeout; its task requeues
|
|
|
+ # or completes on later ticks — the system kept moving either way.
|
|
|
+ with open(os.path.join(WORK, "app.log")) as handle:
|
|
|
+ app_log = handle.read()
|
|
|
+ check("tick exceeded" not in app_log or "Dispatch tick failed" in app_log,
|
|
|
+ "a hung request never freezes the tick latch")
|
|
|
+
|
|
|
finally:
|
|
|
# Exit-code diagnostic: distinguishes a crashed app (negative
|
|
|
# returncode, e.g. -11 SIGSEGV) from a clean shutdown. When
|