okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

dawnrun: wake once for every job still unread when a busy session frees, not once eachefc0462

main
7.5 KiB205 linesraw
1"""Busy-host replies and reading an in-flight job notice are races the daemon E2E wake test cannot reliably force."""
2
3import json
4import os
5import select
6import struct
7import subprocess
8import sys
9import tempfile
10import time
11import unittest
12from pathlib import Path
13
14ROOT = Path(__file__).resolve().parents[2]
15KERNEL = ROOT / "priv" / "python" / "albedo_kernel.py"
16
17sys.path.insert(0, str(ROOT / "test"))
18import scratch  # noqa: E402
19
20# Temporary workspaces and homes go under this run's scratch directory, which
21# is removed at exit.
22scratch.claim("harness")
23MAX_FRAME = 8 * 1024 * 1024
24RETRY_WINDOW = 3.2  # NOTICE_RETRY plus slack; must not mistake a retry for silence
25
26
27class Owner:
28    """A minimal owner that answers host calls and buffers every other frame."""
29
30    def __init__(self, modules, env=None):
31        self.workspace = tempfile.mkdtemp(prefix="albedo-wake-")
32        environment = dict(os.environ)
33        environment.update(env or {})
34        self.process = subprocess.Popen(
35            [sys.executable, "-u", str(KERNEL), json.dumps(modules)],
36            stdin=subprocess.PIPE,
37            stdout=subprocess.PIPE,
38            stderr=subprocess.PIPE,
39            cwd=self.workspace,
40            env=environment,
41            bufsize=0,
42        )
43        self.buffered = []
44
45    def close(self):
46        self.send({"type": "shutdown"})
47        self.process.wait(timeout=10)
48        for stream in (self.process.stdin, self.process.stdout, self.process.stderr):
49            assert stream is not None
50            stream.close()
51        self.buffered.clear()
52
53    def send(self, message):
54        data = json.dumps(message).encode()
55        assert self.process.stdin is not None
56        self.process.stdin.write(struct.pack(">I", len(data)) + data)
57        self.process.stdin.flush()
58
59    def recv(self, timeout=0.05):
60        """One frame, or None when the kernel stays quiet for the window.
61        stdout is unbuffered: a frame read ahead into a buffer is invisible to
62        select(), and would stall until the next one arrives."""
63        assert self.process.stdout is not None
64        ready, _, _ = select.select([self.process.stdout], [], [], timeout)
65        if not ready:
66            return None
67        size = struct.unpack(">I", self.read_exactly(4))[0]
68        assert size <= MAX_FRAME, "control frame exceeds the ceiling"
69        return json.loads(self.read_exactly(size))
70
71    def read_exactly(self, size):
72        assert self.process.stdout is not None
73        data = b""
74        while len(data) < size:
75            chunk = self.process.stdout.read(size - len(data))
76            assert chunk, "kernel closed its control stream"
77            data += chunk
78        return data
79
80    def wait_for(self, predicate, timeout=10.0):
81        deadline = time.monotonic() + timeout
82        while time.monotonic() < deadline:
83            for index, frame in enumerate(self.buffered):
84                if predicate(frame):
85                    return self.buffered.pop(index)
86            frame = self.recv(timeout=max(0.05, deadline - time.monotonic()))
87            if frame is not None:
88                self.buffered.append(frame)
89        raise AssertionError(
90            f"no matching frame; buffered: {[f.get('type') for f in self.buffered]}"
91        )
92
93    def expect_no_call(self, method, window=RETRY_WINDOW):
94        deadline = time.monotonic() + window
95        while time.monotonic() < deadline:
96            try:
97                frame = self.wait_for(
98                    lambda f: f.get("type") == "call" and f.get("method") == method,
99                    timeout=max(0.05, deadline - time.monotonic()),
100                )
101            except AssertionError:
102                return
103            raise AssertionError(f"unexpected {method} call: {frame}")
104
105    def invoke(self, call_id, **frame):
106        self.send({"type": "invoke", "id": call_id, **frame})
107        while True:
108            reply = self.wait_for(
109                lambda f: f.get("id") == call_id and f["type"] == "invoked"
110            )
111            if reply.get("ok") is True or "error" in reply:
112                return reply
113
114
115class WakeProtocolTest(unittest.TestCase):
116    def setUp(self):
117        self.owner = Owner(["run"])
118        self.addCleanup(self.owner.close)
119        self.assertEqual(
120            self.owner.wait_for(lambda f: f.get("type") == "ready")["type"], "ready"
121        )
122
123    def start_job(self, *argv):
124        reply = self.owner.invoke("j1", name="run", args=list(argv))
125        self.assertTrue(reply["ok"])
126        return reply["handle"]
127
128    def wait_job_done(self):
129        """Wait on the raw completion frame; awaiting the job would retire the wake."""
130        start = self.owner.wait_for(lambda f: f.get("type") == "job_start")
131        done = self.owner.wait_for(
132            lambda f: f.get("type") == "job" and f.get("id") == start["id"]
133        )
134        return done
135
136    def test_a_busy_session_is_retried_and_a_refusal_gives_up(self):
137        self.start_job("echo", "retry-echo")
138        self.wait_job_done()
139        # Busy is retried, not dropped; a permanent refusal ends the attempts.
140        frame = self.owner.wait_for(lambda f: f.get("type") == "call")
141        self.owner.send(
142            {
143                "type": "reply",
144                "id": frame["id"],
145                "value": {"ok": False, "code": "busy", "message": "session is busy"},
146            }
147        )
148        retry = self.owner.wait_for(
149            lambda f: f.get("type") == "call" and f.get("method") == "jobs.completed",
150            timeout=RETRY_WINDOW,
151        )
152        self.owner.send(
153            {
154                "type": "reply",
155                "id": retry["id"],
156                "value": {"ok": False, "code": "unavailable", "message": "no session"},
157            }
158        )
159        self.owner.expect_no_call("jobs.completed")
160
161    def test_jobs_owed_when_a_busy_session_frees_share_one_notice(self):
162        self.start_job("echo", "first-echo")
163        self.wait_job_done()
164        frame = self.owner.wait_for(lambda f: f.get("type") == "call")
165        self.assertEqual(len(frame["args"]["jobs"]), 1)
166        self.start_job("echo", "second-echo")
167        self.wait_job_done()
168        self.owner.send(
169            {
170                "type": "reply",
171                "id": frame["id"],
172                "value": {"ok": False, "code": "busy", "message": "session is busy"},
173            }
174        )
175        retry = self.owner.wait_for(
176            lambda f: f.get("type") == "call" and f.get("method") == "jobs.completed",
177            timeout=RETRY_WINDOW,
178        )
179        self.assertEqual(len(retry["args"]["jobs"]), 2)
180        self.assertIn("2 background jobs finished", retry["args"]["text"])
181        self.assertIn("second-echo", retry["args"]["display"])
182        self.owner.send(
183            {"type": "reply", "id": retry["id"], "value": {"ok": True, "value": None}}
184        )
185        self.owner.expect_no_call("jobs.completed")
186
187    def test_a_read_result_retires_a_pending_notice(self):
188        handle = self.start_job("echo", "read-echo")
189        self.wait_job_done()
190        # The notice is in flight (busy) when the result is read; the retry must stop.
191        frame = self.owner.wait_for(lambda f: f.get("type") == "call")
192        self.owner.send(
193            {
194                "type": "reply",
195                "id": frame["id"],
196                "value": {"ok": False, "code": "busy", "message": "session is busy"},
197            }
198        )
199        tail = self.owner.invoke("t1", target={"handle": handle}, name="tail")
200        self.assertIn("read-echo", tail["value"])
201        self.owner.expect_no_call("jobs.completed")
202
203
204if __name__ == "__main__":
205    unittest.main()