|
|
@@ -217,6 +217,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.scripted_log = None # fixed chunk set served by /log
|
|
|
+ self.scripted_next = 0 # the daemon-style next for it
|
|
|
+ self.scripted_corpus = None # chunks "produced" when a task starts
|
|
|
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
|
|
|
@@ -263,6 +266,9 @@ class FakeSaddle:
|
|
|
|
|
|
def log_since(self, since):
|
|
|
with self.lock:
|
|
|
+ if self.scripted_log is not None:
|
|
|
+ chunks = [c for c in self.scripted_log if c["seq"] >= since]
|
|
|
+ return chunks, self.scripted_next
|
|
|
# `since` is the inclusive resume point (wire contract: a
|
|
|
# response ending at seq 12 carries next=13, and polling
|
|
|
# with since=13 must still deliver the chunk numbered 13).
|
|
|
@@ -332,6 +338,11 @@ class FakeSaddle:
|
|
|
self.daemon.append_chunk("hello\r\n")
|
|
|
self.daemon.set_state("failing" if self.daemon.fail_next else "working")
|
|
|
self.daemon.fail_next = False
|
|
|
+ if self.daemon.scripted_corpus is not None:
|
|
|
+ with self.daemon.lock:
|
|
|
+ for chunk in self.daemon.scripted_corpus:
|
|
|
+ self.daemon.scripted_log.append(dict(chunk))
|
|
|
+ self.daemon.scripted_next = self.daemon.scripted_corpus[-1]["seq"] + 1
|
|
|
self._send(200, {})
|
|
|
elif self.path == "/answer":
|
|
|
self._send(200, {})
|
|
|
@@ -2178,6 +2189,75 @@ def main():
|
|
|
check(wait_task_done(auth, wedge, timeout=90) is not None,
|
|
|
"wedged task requeues and completes after recovery")
|
|
|
|
|
|
+ print("== R8: raw-log ingestion is lossless ==")
|
|
|
+ # Dynamic scripted ring: empty at seed (the pre-push probe
|
|
|
+ # baselines at next=1), then the daemon "produces" the corpus on
|
|
|
+ # /start — seq 1 coloured prompt run, 2 plain, 3 a bare CRLF
|
|
|
+ # between prompts, 4..6 following. Every non-empty served chunk
|
|
|
+ # must land, exactly once.
|
|
|
+ corpus = [
|
|
|
+ {"seq": 1, "text": "\x1b[01;32muser@host\x1b[00m:\x1b[01;34m~/task-1\x1b[00m$ "},
|
|
|
+ {"seq": 2, "text": "echo hello\r\n"},
|
|
|
+ {"seq": 3, "text": "\r\n"},
|
|
|
+ {"seq": 4, "text": "hello\r\n"},
|
|
|
+ {"seq": 5, "text": "\x1b[01;32muser@host\x1b[00m:\x1b[01;34m~/task-1\x1b[00m$ "},
|
|
|
+ {"seq": 6, "text": "done\r\n"},
|
|
|
+ ]
|
|
|
+ fake.scripted_corpus = corpus
|
|
|
+ fake.scripted_log = []
|
|
|
+ fake.scripted_next = 1
|
|
|
+ fake.state = "working"
|
|
|
+ fake.hold_state = True
|
|
|
+ act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
|
|
|
+ "project_id": "1",
|
|
|
+ "title": "Lossless log task",
|
|
|
+ "priority": "2",
|
|
|
+ })
|
|
|
+ lossless = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
|
|
|
+ if t["title"] == "Lossless log task"][0])
|
|
|
+
|
|
|
+ def stored_seqs(task_id):
|
|
|
+ conn = db()
|
|
|
+ try:
|
|
|
+ return [r[0] for r in conn.execute(
|
|
|
+ "select seq from session_raw_log where session_id in"
|
|
|
+ " (select id from sessions where task_id = ?)"
|
|
|
+ " order by seq", (task_id,))]
|
|
|
+ finally:
|
|
|
+ conn.close()
|
|
|
+
|
|
|
+ check(wait_for(lambda: task_state(auth, lossless).get("status") == "working"
|
|
|
+ or False, timeout=60, what="lossless task working") is not None,
|
|
|
+ "lossless task dispatches with a pre-push probe on an empty ring")
|
|
|
+ check(wait_for(lambda: stored_seqs(lossless) == [1, 2, 3, 4, 5, 6] or False,
|
|
|
+ timeout=30, what="all six chunks stored") is not None,
|
|
|
+ "every served chunk lands in session_raw_log, CRLF-only included (%s)"
|
|
|
+ % stored_seqs(lossless))
|
|
|
+ time.sleep(7)
|
|
|
+ check(stored_seqs(lossless) == [1, 2, 3, 4, 5, 6],
|
|
|
+ "no duplicates or losses on later polls")
|
|
|
+ request = urllib.request.Request(
|
|
|
+ WEB + "/terminal-stream/%d" % [r[0] for r in db().execute(
|
|
|
+ "select id from sessions where task_id = ?", (lossless,))][0])
|
|
|
+ with urllib.request.urlopen(request, timeout=15) as stream:
|
|
|
+ texts = []
|
|
|
+ deadline = time.monotonic() + 8
|
|
|
+ while time.monotonic() < deadline and len(texts) < 6:
|
|
|
+ line = stream.readline().decode().strip()
|
|
|
+ if not line.startswith("data: ") or line == "data: ping":
|
|
|
+ continue
|
|
|
+ event = json.loads(line[6:])
|
|
|
+ if event.get("type") == "raw":
|
|
|
+ texts.append(event.get("text") or "")
|
|
|
+ check("\r\n" in texts and len(texts) == 6,
|
|
|
+ "SSE serves the whitespace chunk unfiltered")
|
|
|
+ fake.hold_state = False
|
|
|
+ fake.state = "idle"
|
|
|
+ fake.scripted_log = None
|
|
|
+ fake.scripted_corpus = None
|
|
|
+ check(wait_task_done(auth, lossless, timeout=60) is not None,
|
|
|
+ "lossless task completes")
|
|
|
+
|
|
|
# The daemon vanishing mid-task: three refused polls → offline,
|
|
|
# task requeued with a bot comment, UI reflects.
|
|
|
act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
|