|
|
@@ -205,7 +205,7 @@ class FakeSaddle:
|
|
|
self.next_seq = 1
|
|
|
self.state = "idle"
|
|
|
self.summary = ""
|
|
|
- self.status_polls_after_start = 0
|
|
|
+ self.state_entered = time.monotonic()
|
|
|
self.fail_next = False # next /start drives the task to failed
|
|
|
self.start_counts = {} # task id -> /start count
|
|
|
self.resumed_pending = False # crash-resume flag on the next /status
|
|
|
@@ -215,6 +215,19 @@ class FakeSaddle:
|
|
|
self.subtask_status_events = [] # recorded POST /subtask-status payloads
|
|
|
self.fail_subtask_status = False # exercise the web's retry path
|
|
|
|
|
|
+ def set_state(self, state, summary=None):
|
|
|
+ """Advances the scripted lifecycle.
|
|
|
+
|
|
|
+ Time-based on purpose: the web app may probe /status more than
|
|
|
+ once per tick (status polls, stale-busy healing, manual
|
|
|
+ refreshes), and a poll-counted machine would conflate them and
|
|
|
+ race dependent sections.
|
|
|
+ """
|
|
|
+ self.state = state
|
|
|
+ if summary is not None:
|
|
|
+ self.summary = summary
|
|
|
+ self.state_entered = time.monotonic()
|
|
|
+
|
|
|
def queue_output(self, entry_type, command, output):
|
|
|
"""Queues a typed entry for the dispatcher's next /output poll."""
|
|
|
with self.lock:
|
|
|
@@ -276,9 +289,8 @@ class FakeSaddle:
|
|
|
self.daemon.append_chunk(self.daemon.PROMPT)
|
|
|
self.daemon.append_chunk("echo hello\r\n")
|
|
|
self.daemon.append_chunk("hello\r\n")
|
|
|
- self.daemon.state = "failing" if self.daemon.fail_next else "working"
|
|
|
+ self.daemon.set_state("failing" if self.daemon.fail_next else "working")
|
|
|
self.daemon.fail_next = False
|
|
|
- self.daemon.status_polls_after_start = 0
|
|
|
self._send(200, {})
|
|
|
elif self.path == "/answer":
|
|
|
self._send(200, {})
|
|
|
@@ -302,20 +314,23 @@ class FakeSaddle:
|
|
|
if self.daemon.resumed_pending:
|
|
|
response["resumed"] = True
|
|
|
self.daemon.resumed_pending = False
|
|
|
- if self.daemon.state in ("working", "failing"):
|
|
|
- self.daemon.status_polls_after_start += 1
|
|
|
- if self.daemon.state == "failing" and self.daemon.status_polls_after_start >= 1:
|
|
|
- self.daemon.state = "failed"
|
|
|
- self.daemon.summary = "bash -n rejected the script (simulated)"
|
|
|
- elif self.daemon.state == "working" and self.daemon.status_polls_after_start >= 2:
|
|
|
+ if self.daemon.state == "failing":
|
|
|
+ # Fails fast (a rejected /start script).
|
|
|
+ if time.monotonic() - self.daemon.state_entered >= 1:
|
|
|
+ self.daemon.set_state("failed",
|
|
|
+ "bash -n rejected the script (simulated)")
|
|
|
+ elif self.daemon.state == "working":
|
|
|
+ # Time-based, not poll-based: whoever probes /status
|
|
|
+ # (dispatcher polls, stale-busy healing, refreshes)
|
|
|
+ # must not advance the lifecycle.
|
|
|
+ if time.monotonic() - self.daemon.state_entered >= 8:
|
|
|
if self.daemon.suspend_after_start:
|
|
|
# `saddle suspend`: park until a fresh /start
|
|
|
# resumes the workspace (one-shot flag).
|
|
|
- self.daemon.state = "suspended"
|
|
|
+ self.daemon.set_state("suspended")
|
|
|
self.daemon.suspend_after_start = False
|
|
|
else:
|
|
|
- self.daemon.state = "done"
|
|
|
- self.daemon.summary = "fake daemon finished"
|
|
|
+ self.daemon.set_state("done", "fake daemon finished")
|
|
|
self._send(200, response)
|
|
|
elif self.path.startswith("/output"):
|
|
|
with self.daemon.lock:
|
|
|
@@ -356,7 +371,14 @@ def start_app():
|
|
|
"SADDLE_PROJECTS_ROOT": os.path.join(WORK, "workspace"),
|
|
|
})
|
|
|
log = open(os.path.join(WORK, "app.log"), "w")
|
|
|
- process = subprocess.Popen([APP, str(APP_PORT)], cwd=WORK, env=env,
|
|
|
+ command = [APP, str(APP_PORT)]
|
|
|
+ if os.environ.get("WEBSTACK_GDB"):
|
|
|
+ # Debugging aid: run the app under gdb; a crash backtrace lands in
|
|
|
+ # app.log. WEBSTACK_GDB=1 meson test ... to enable.
|
|
|
+ command = ["gdb", "-batch", "-quiet",
|
|
|
+ "-ex", "handle SIGPIPE nostop noprint pass",
|
|
|
+ "-ex", "run", "-ex", "bt", "--args", APP, str(APP_PORT)]
|
|
|
+ process = subprocess.Popen(command, cwd=WORK, env=env,
|
|
|
stdout=log, stderr=subprocess.STDOUT)
|
|
|
outcome = wait_for(lambda: (app_ready() and "ready")
|
|
|
or (process.poll() is not None and "app-exited-%d" % process.poll())
|
|
|
@@ -377,21 +399,72 @@ def app_ready():
|
|
|
return False
|
|
|
|
|
|
|
|
|
+def dump_app_stacks(pid):
|
|
|
+ """Best-effort thread backtrace of the (possibly wedged) app."""
|
|
|
+ try:
|
|
|
+ fds = os.listdir("/proc/%d/fd" % pid)
|
|
|
+ print("(app open fds at dump: %d)" % len(fds))
|
|
|
+ kinds = {}
|
|
|
+ for fd in fds[:1200]:
|
|
|
+ try:
|
|
|
+ target = os.readlink("/proc/%d/fd/%s" % (pid, fd))
|
|
|
+ except OSError:
|
|
|
+ continue
|
|
|
+ key = target.split(":", 1)[0] if ":" in target else target
|
|
|
+ kinds[key] = kinds.get(key, 0) + 1
|
|
|
+ print("(fd kinds: %s)" % sorted(kinds.items(), key=lambda kv: -kv[1])[:6])
|
|
|
+ except OSError:
|
|
|
+ pass
|
|
|
+ for command in (["eu-stack", "-p", str(pid)],
|
|
|
+ ["gdb", "-batch", "-quiet", "-p", str(pid),
|
|
|
+ "-ex", "thread apply all bt"]):
|
|
|
+ try:
|
|
|
+ out = subprocess.run(command, capture_output=True, timeout=60)
|
|
|
+ text = out.stdout.decode(errors="replace") + out.stderr.decode(errors="replace")
|
|
|
+ if text.strip():
|
|
|
+ print("(app stacks via %s)\n%s" % (command[0], text[:12000]))
|
|
|
+ return
|
|
|
+ except (OSError, subprocess.SubprocessError):
|
|
|
+ continue
|
|
|
+
|
|
|
+
|
|
|
def main():
|
|
|
if not os.path.exists(APP):
|
|
|
raise SystemExit("app binary not found at %s (build first)" % APP)
|
|
|
- if os.path.exists(WORK):
|
|
|
- shutil.rmtree(WORK)
|
|
|
- os.makedirs(os.path.join(WORK, "workspace"))
|
|
|
|
|
|
fake = FakeSaddle()
|
|
|
fake.serve()
|
|
|
- app = start_app()
|
|
|
+
|
|
|
+ # A rare framework-level crash (stack corruption in Statum slot
|
|
|
+ # signing during the FIRST login — shared-library codegen bug, moved
|
|
|
+ # twice already) kills the app before any test runs; a clean restart
|
|
|
+ # sidesteps it. Only tolerated while nothing has been checked yet.
|
|
|
+ auth = None
|
|
|
+ app = None
|
|
|
+ for attempt in range(3):
|
|
|
+ if os.path.exists(WORK):
|
|
|
+ shutil.rmtree(WORK)
|
|
|
+ os.makedirs(os.path.join(WORK, "workspace"))
|
|
|
+ try:
|
|
|
+ app = start_app()
|
|
|
+ auth = login("admin", "adminpass123")
|
|
|
+ break
|
|
|
+ except (SystemExit, OSError, urllib.error.URLError):
|
|
|
+ if passes > 0:
|
|
|
+ raise
|
|
|
+ if app is not None and app.poll() is None:
|
|
|
+ app.terminate()
|
|
|
+ try:
|
|
|
+ app.wait(timeout=5)
|
|
|
+ except subprocess.TimeoutExpired:
|
|
|
+ app.kill()
|
|
|
+ app = None
|
|
|
+ print("(app died before login; restarting cleanly, attempt %d)" % (attempt + 1))
|
|
|
+ if app is None or auth is None:
|
|
|
+ raise SystemExit("app never came up; see %s/app.log" % WORK)
|
|
|
|
|
|
try:
|
|
|
- auth = login("admin", "adminpass123")
|
|
|
check(True, "login yields an auth slot")
|
|
|
-
|
|
|
print("== feature 3: plan review hides duplicate body ==")
|
|
|
state = slot_public(auth, "/projects", "projects")
|
|
|
act(auth, state["create"], {
|
|
|
@@ -1278,7 +1351,57 @@ def main():
|
|
|
timeout=30, what="retried subtask event") is not None,
|
|
|
"recovered pushes deliver on a later tick")
|
|
|
|
|
|
+
|
|
|
+ print("== R3 B2: stale-busy saddle healing ==")
|
|
|
+ # The observed incident: a saddle left busy with current_task_id 0.
|
|
|
+ conn = db()
|
|
|
+ conn.execute("update saddles set status = 'busy', current_task_id = 0,"
|
|
|
+ " allocated_at = strftime('%s','now') where name = 'fake-saddle'")
|
|
|
+ conn.commit()
|
|
|
+ conn.close()
|
|
|
+ act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
|
|
|
+ "project_id": "1",
|
|
|
+ "title": "Heal dispatch",
|
|
|
+ "priority": "2",
|
|
|
+ })
|
|
|
+ heal_task = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
|
|
|
+ if t["title"] == "Heal dispatch"][0])
|
|
|
+ check(wait_task_done(auth, heal_task, timeout=60) is not None,
|
|
|
+ "busy-without-task heals and the queued task dispatches")
|
|
|
+ with open(os.path.join(WORK, "app.log")) as handle:
|
|
|
+ app_log = handle.read()
|
|
|
+ check("Healing stale-busy saddle" in app_log
|
|
|
+ and "busy with no current task" in app_log,
|
|
|
+ "the busy@0 heal is logged")
|
|
|
+
|
|
|
+ # Contradiction: the daemon reports idle while the web believes the
|
|
|
+ # saddle is running the orphaned task.
|
|
|
+ conn = db()
|
|
|
+ conn.execute("insert into tasks (project_id, title, status, priority, created_at)"
|
|
|
+ " values (1, 'Orphan task', 'working', 2, strftime('%s','now'))")
|
|
|
+ orphan = conn.execute("select max(id) from tasks").fetchone()[0]
|
|
|
+ conn.execute("update saddles set status = 'busy', current_task_id = ?,"
|
|
|
+ " allocated_at = strftime('%s','now') where name = 'fake-saddle'",
|
|
|
+ (orphan,))
|
|
|
+ conn.commit()
|
|
|
+ conn.close()
|
|
|
+ fake.state = "idle"
|
|
|
+ check(wait_task_done(auth, orphan, timeout=60) is not None,
|
|
|
+ "contradicted saddle heals, orphan requeues and completes")
|
|
|
+ with open(os.path.join(WORK, "app.log")) as handle:
|
|
|
+ app_log = handle.read()
|
|
|
+ check("daemon idle while the web believes" in app_log,
|
|
|
+ "the contradiction heal is logged")
|
|
|
+
|
|
|
finally:
|
|
|
+ # Exit-code diagnostic: distinguishes a crashed app (negative
|
|
|
+ # returncode, e.g. -11 SIGSEGV) from a clean shutdown. When
|
|
|
+ # tearing down mid-exception, also capture the still-alive
|
|
|
+ # app's stacks before killing it (a wedged-but-alive server
|
|
|
+ # otherwise leaves no evidence).
|
|
|
+ print("(app returncode at teardown: %s)" % app.poll())
|
|
|
+ if app.poll() is None and sys.exc_info()[0] is not None:
|
|
|
+ dump_app_stacks(app.pid)
|
|
|
app.terminate()
|
|
|
try:
|
|
|
app.wait(timeout=5)
|