|
|
@@ -216,6 +216,9 @@ class FakeSaddle:
|
|
|
self.fail_subtask_status = False # exercise the web's retry path
|
|
|
self.hang_next = {} # path suffix -> hang count (never responds)
|
|
|
self.fail_log_count = 0 # /log requests failing (500) while > 0
|
|
|
+ self.abort_failures = 0 # /abort requests failing (500) while > 0
|
|
|
+ self.abort_log = [] # task ids aborted via POST /abort
|
|
|
+ self.running_task = 0 # task id the daemon believes it runs
|
|
|
|
|
|
def set_state(self, state, summary=None):
|
|
|
"""Advances the scripted lifecycle.
|
|
|
@@ -290,6 +293,7 @@ class FakeSaddle:
|
|
|
payload = {"raw": raw}
|
|
|
self.daemon.start_payloads.append(payload)
|
|
|
task_id = (payload.get("task") or {}).get("id")
|
|
|
+ self.daemon.running_task = int(task_id or 0)
|
|
|
count = self.daemon.start_counts.get(task_id, 0) + 1
|
|
|
self.daemon.start_counts[task_id] = count
|
|
|
if count > 1:
|
|
|
@@ -305,6 +309,17 @@ class FakeSaddle:
|
|
|
self._send(200, {})
|
|
|
elif self.path == "/answer":
|
|
|
self._send(200, {})
|
|
|
+ elif self.path == "/abort":
|
|
|
+ if self.daemon.abort_failures > 0:
|
|
|
+ self.daemon.abort_failures -= 1
|
|
|
+ self._send(500, {"error": "abort failed (simulated)"})
|
|
|
+ return
|
|
|
+ aborted = self.daemon.running_task
|
|
|
+ self.daemon.set_state("idle")
|
|
|
+ self.daemon.summary = ""
|
|
|
+ self.daemon.running_task = 0
|
|
|
+ self.daemon.abort_log.append(aborted)
|
|
|
+ self._send(200, {"ok": True, "aborted_task_id": aborted})
|
|
|
elif self.path == "/subtask-status":
|
|
|
try:
|
|
|
payload = json.loads(raw)
|
|
|
@@ -1898,6 +1913,66 @@ def main():
|
|
|
check(wait_task_done(auth, stranded, timeout=90) is not None,
|
|
|
"recovered task dispatches and completes")
|
|
|
|
|
|
+
|
|
|
+ print("== R7 B1: cancel reaches the daemon ==")
|
|
|
+ act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
|
|
|
+ "project_id": "1",
|
|
|
+ "title": "Cancel live",
|
|
|
+ "priority": "2",
|
|
|
+ })
|
|
|
+ cancel_live = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
|
|
|
+ if t["title"] == "Cancel live"][0])
|
|
|
+ check(wait_for(lambda: task_state(auth, cancel_live).get("status") == "working"
|
|
|
+ or False, timeout=60, what="cancel-live working") is not None,
|
|
|
+ "task reaches working before the cancel")
|
|
|
+ act(auth, task_state(auth, cancel_live)["cancel"], {})
|
|
|
+ check(wait_for(lambda: cancel_live in fake.abort_log or False,
|
|
|
+ timeout=30, what="daemon abort recorded") is not None,
|
|
|
+ "cancelling POSTs /abort to the daemon")
|
|
|
+ check(any(c["body"] == "cancelled; daemon aborted"
|
|
|
+ for c in task_state(auth, cancel_live).get("comments", [])),
|
|
|
+ "the abort outcome is narrated")
|
|
|
+ check(task_state(auth, cancel_live)["status"] == "failed",
|
|
|
+ "cancelled task lands failed as before")
|
|
|
+
|
|
|
+ # Transport/500 failure on abort fences the saddle; recovery probe
|
|
|
+ # clears the fence; nothing dispatches while fenced.
|
|
|
+ act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
|
|
|
+ "project_id": "1",
|
|
|
+ "title": "Fence runner",
|
|
|
+ "priority": "2",
|
|
|
+ })
|
|
|
+ fence_runner = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
|
|
|
+ if t["title"] == "Fence runner"][0])
|
|
|
+ check(wait_for(lambda: task_state(auth, fence_runner).get("status") == "working"
|
|
|
+ or False, timeout=60, what="fence runner working") is not None,
|
|
|
+ "second task reaches working")
|
|
|
+ fake.abort_failures = 1
|
|
|
+ act(auth, task_state(auth, fence_runner)["cancel"], {})
|
|
|
+ check(any(c["body"] == "cancelled; daemon unreachable — saddle fenced"
|
|
|
+ for c in task_state(auth, fence_runner).get("comments", [])),
|
|
|
+ "failed abort narrates the fence")
|
|
|
+ check(saddle_card("fake-saddle")["status"] == "recovering",
|
|
|
+ "saddle fenced as recovering")
|
|
|
+ act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
|
|
|
+ "project_id": "1",
|
|
|
+ "title": "During fence",
|
|
|
+ "priority": "3",
|
|
|
+ })
|
|
|
+ during_fence = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
|
|
|
+ if t["title"] == "During fence"][0])
|
|
|
+ time.sleep(7)
|
|
|
+ check(task_state(auth, during_fence)["status"] == "queued",
|
|
|
+ "fenced saddle is not dispatchable")
|
|
|
+ # A real daemon lands idle once its (unaborted) round ends; the
|
|
|
+ # fake's scripted lifecycle parks on 'done', so model that.
|
|
|
+ fake.state = "idle"
|
|
|
+ check(wait_for(lambda: saddle_card("fake-saddle")["status"] == "idle" or False,
|
|
|
+ timeout=30, what="fence clears") is not None,
|
|
|
+ "an idle probe clears the fence")
|
|
|
+ check(wait_task_done(auth, during_fence, timeout=60) is not None,
|
|
|
+ "queued work dispatches once the fence clears")
|
|
|
+
|
|
|
finally:
|
|
|
# Exit-code diagnostic: distinguishes a crashed app (negative
|
|
|
# returncode, e.g. -11 SIGSEGV) from a clean shutdown. When
|