okami.mom/albedo

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

Aly Raffaufrefactor(tests): share repeated E2E fixtures5bdc0ee

main
13.1 KiB320 linesraw
1"""A stale kernel is swapped for a current one, namespace included.
2
3After a daemon restart the reattached kernel may run an older python bundle,
4have booted with another module set than its session has now, or speak another
5session protocol. At the session's next idle moment it is replaced by a current
6kernel, its variables carried through a snapshot and a restore. A live job
7holds an older bundle or module set until it ends; a kernel on another protocol
8goes at once, its outbox dropped rather than replayed, carrying nothing when
9its snapshot fails, and its process group ends either way.
10"""
11
12import json
13import os
14from pathlib import Path
15import sqlite3
16import time
17import unittest
18
19import harness
20from harness import alive
21from harness import Albedo, Provider, exclusive, python, text
22from kernel_support import MARKER, ModelCells, editable_daemon
23
24
25class Swaps(ModelCells, unittest.TestCase):
26    """Cells through model turns against one session, on a daemon that may run
27    an editable copy of the python bundle."""
28
29    bundle: Path
30
31    def boot(self, *, editable):
32        self.cells = []
33
34        def script(request):
35            if request["input"][-1].get("role") == "user" and self.cells:
36                return python(self.cells.pop(0))
37            return text("ok")
38
39        def prepare(app):
40            launcher, self.bundle = editable_daemon(app.root)
41            app.env["ALBEDO_DAEMON"] = str(launcher)
42
43        self.provider = Provider(script)
44        self.addCleanup(self.provider.close)
45        self.app = Albedo(
46            self.provider, protocol="responses", prepare=prepare if editable else None
47        )
48        self.app.__enter__()
49        self.addCleanup(self.app.__exit__, None, None, None)
50        self.session = self.app.session()
51
52    def kernel(self):
53        with self.app.api(f"/sessions/{self.session}?tail=0") as response:
54            return json.load(response)["kernel"]
55
56    def stale(self):
57        return self.kernel()["stale"]
58
59    def held_job(self):
60        """A gate and the cell code for a job that runs until the gate is
61        released: the test decides when the job ends, not a sleep that a slow
62        restart under load can outlast."""
63        gate = self.app.workspace / "job-gate"
64        os.mkfifo(gate)
65        self.addCleanup(harness.release_fifo, gate)
66        program = f"open({str(gate)!r}, 'rb').read(1)"
67        return gate, f"import sys\nj = run(sys.executable, '-c', {program!r})"
68
69    def end_job(self, gate):
70        harness.wait_until(
71            lambda: harness.release_fifo(gate), 10, "the held job never opened its gate"
72        )
73
74    def wait_until_current(self):
75        deadline = time.monotonic() + 30
76        while self.stale() and time.monotonic() < deadline:
77            # The job's wake is the idle moment that swaps the kernel.
78            time.sleep(0.2)
79        self.app.idle(self.session)
80
81
82# exclusive: boots an editable Python bundle and restarts the daemon
83@exclusive
84class KernelUpgradeTests(Swaps):
85    def setUp(self):
86        self.boot(editable=True)
87
88    def edit_bundle(self):
89        with (self.bundle / "albedo_bundle.py").open("a") as source:
90            source.write(MARKER)
91
92    def probe(self):
93        return self.cell(
94            "import albedo_bundle, os\n(survivor, getattr(albedo_bundle, 'MARKER', None), os.getpid())"
95        )
96
97    def test_restart_onto_a_new_bundle_swaps_the_kernel_and_keeps_variables(self):
98        first, _ = self.cell("import os\nsurvivor = 41\nos.getpid()")
99        self.edit_bundle()
100        self.app.restart()
101        swapped, prompt = self.probe()
102        self.assertEqual(swapped.get("status"), "ok", swapped)
103        survivor, marker, pid = swapped["value"].strip("()").split(", ")
104        self.assertEqual((survivor, marker), ("41", "'new bundle'"), swapped)
105        self.assertNotEqual(pid, first["value"])
106        self.assertIn("The python kernel was upgraded to the new python bundle", prompt)
107        # the restored names include `os`, re-imported from source
108        self.assertRegex(prompt, r"Restored: [^.]*\bsurvivor\b")
109        self.assertFalse(self.stale())
110
111    def test_a_live_job_holds_the_old_kernel_until_it_ends(self):
112        gate, job = self.held_job()
113        self.cell(f"import os\nsurvivor = 41\n{job}\nos.getpid()")
114        self.edit_bundle()
115        self.app.restart()
116        held, _ = self.probe()
117        self.assertIn("None", held["value"], "the old kernel must still run")
118        self.assertTrue(self.stale())
119        self.end_job(gate)
120        self.wait_until_current()
121        swapped, _ = self.probe()
122        self.assertIn("'new bundle'", swapped["value"])
123        self.assertTrue(swapped["value"].startswith("(41,"), swapped)
124
125    def test_kernel_upgrade_forces_the_swap_past_a_live_job(self):
126        self.cell("import os\nsurvivor = 41\nj = run('sleep', '60')\nos.getpid()")
127        self.edit_bundle()
128        self.app.restart()
129        with self.app.api(
130            f"/sessions/{self.session}/kernel/upgrade",
131            {},
132        ) as response:
133            result = json.load(response)
134        self.assertEqual(len(result["stopped_jobs"]), 1, result)
135        swapped, _ = self.probe()
136        self.assertIn("'new bundle'", swapped["value"])
137        self.assertTrue(swapped["value"].startswith("(41,"), swapped)
138        self.assertFalse(self.stale())
139
140
141# exclusive: restarts the daemon and changes global extension enablement
142@exclusive
143class ModuleSkewTests(Swaps):
144    """The session's module set changes while the daemon is down: disabling
145    skills drops the `skills` module the recorded kernel booted with."""
146
147    def setUp(self):
148        self.boot(editable=False)
149
150    def drop_skills(self, app):
151        settings = json.loads((app.home / "extensions.json").read_text())
152        settings.setdefault("enabled", {})["skills"] = False
153        (app.home / "extensions.json").write_text(json.dumps(settings))
154
155    def probe(self):
156        return self.cell("import os\n(survivor, 'skills' in globals(), os.getpid())")
157
158    def test_a_changed_module_set_swaps_the_kernel_and_keeps_variables(self):
159        first, _ = self.cell(
160            "import os\nsurvivor = 41\n('skills' in globals(), os.getpid())"
161        )
162        self.assertTrue(first["value"].startswith("(True,"), first)
163        self.app.restart(prepare=self.drop_skills)
164        swapped, prompt = self.probe()
165        self.assertEqual(swapped.get("status"), "ok", swapped)
166        survivor, skills, pid = swapped["value"].strip("()").split(", ")
167        self.assertEqual((survivor, skills), ("41", "False"), swapped)
168        self.assertNotEqual(pid, first["value"].strip("()").split(", ")[1])
169        self.assertIn(
170            "The python kernel was upgraded to the session's new extension modules",
171            prompt,
172        )
173        self.assertRegex(prompt, r"Restored: [^.]*\bsurvivor\b")
174        self.assertFalse(self.stale())
175
176    def test_a_live_job_holds_the_old_module_set_until_it_ends(self):
177        gate, job = self.held_job()
178        self.cell(f"import os\nsurvivor = 41\n{job}")
179        self.app.restart(prepare=self.drop_skills)
180        held, _ = self.probe()
181        self.assertTrue(held["value"].startswith("(41, True,"), held)
182        self.assertIn(
183            "modules",
184            [reason["code"] for reason in self.kernel()["staleness_reasons"]],
185        )
186        self.end_job(gate)
187        self.wait_until_current()
188        swapped, _ = self.probe()
189        self.assertTrue(swapped["value"].startswith("(41, False,"), swapped)
190
191
192# exclusive: restarts the daemon and edits its kernel outbox offline
193@exclusive
194class ProtocolSkewTests(Swaps):
195    """The kernel's session layer is told to speak protocol 2 from inside a
196    cell, so the hello it gives the restarted daemon differs from the
197    bridge's protocol 1, as an older kernel's would; its bundle stays current."""
198
199    def setUp(self):
200        self.boot(editable=False)
201
202    def start(self):
203        """Variables and a long job in the old kernel: its pid and the job's."""
204        started, _ = self.cell(
205            "import albedo_link, asyncio, os\nalbedo_link.PROTOCOL = 2\n"
206            "survivor = 41\nj = run('sleep', '60')\n"
207            "await asyncio.sleep(0.5)\n(os.getpid(), j.process.pid)"
208        )
209        kernel, job = (int(part) for part in started["value"].strip("()").split(","))
210        return kernel, job
211
212    def restart_current(self, *, unsaveable=False):
213        """Restart onto protocol 1 with a frame for the old kernel waiting in
214        the daemon's outbox; it would leave `replayed` behind if it ran."""
215        marker = self.app.root / "replayed"
216
217        def prepare(app):
218            with sqlite3.connect(app.home / "albedo.sqlite") as db:
219                kernel, run_dir, seq = db.execute(
220                    "SELECT kernel, run_dir, out_seq + 1 FROM kernel_links WHERE session=?",
221                    (self.session,),
222                ).fetchone()
223                frame = {
224                    "type": "execute",
225                    "id": "left-in-the-outbox",
226                    "code": f"open({str(marker)!r}, 'w').close()",
227                    "durable": False,
228                    "max_edge": 0,
229                }
230                db.execute(
231                    "INSERT INTO kernel_outbox (session, kernel, seq, frame) VALUES (?,?,?,?)",
232                    (self.session, kernel, seq, json.dumps(frame)),
233                )
234                db.execute(
235                    "UPDATE kernel_links SET out_seq=? WHERE kernel=?", (seq, kernel)
236                )
237            if unsaveable:
238                # A directory where the snapshot is written: the save fails.
239                (Path(run_dir) / "namespace.state" / "taken").mkdir(parents=True)
240
241        self.app.restart(prepare=prepare)
242        return marker
243
244    def probe(self):
245        return self.cell("import os\n(globals().get('survivor'), os.getpid())")
246
247    def assert_gone(self, marker, *pids):
248        """The old kernel and its job ended, its outbox dropped unreplayed."""
249        deadline = time.monotonic() + 15
250        while any(alive(pid) for pid in pids) and time.monotonic() < deadline:
251            time.sleep(0.1)
252        self.assertFalse([pid for pid in pids if alive(pid)], "the old kernel lives on")
253        self.assertFalse(marker.exists(), "the old outbox was replayed")
254        with sqlite3.connect(self.app.home / "albedo.sqlite") as db:
255            (left,) = db.execute(
256                "SELECT count(*) FROM kernel_outbox"
257                " WHERE CAST(frame AS TEXT) LIKE '%left-in-the-outbox%'"
258            ).fetchone()
259        self.assertEqual(left, 0)
260
261    def test_another_protocol_swaps_past_live_jobs_and_drops_the_outbox(self):
262        kernel, job = self.start()
263        marker = self.restart_current()
264        swapped, prompt = self.probe()
265        self.assertEqual(swapped.get("status"), "ok", swapped)
266        survivor, pid = swapped["value"].strip("()").split(", ")
267        self.assertEqual(survivor, "41", swapped)
268        self.assertNotEqual(int(pid), kernel)
269        self.assertIn(
270            "The python kernel was upgraded to the current kernel protocol", prompt
271        )
272        self.assertFalse(self.stale())
273        self.assert_gone(marker, kernel, job)
274
275    def test_a_bundle_out_of_step_with_the_daemon_refuses_to_boot(self):
276        """A python bundle on another protocol than the daemon's own makes
277        every fresh kernel stale at birth: the boot fails and says why,
278        instead of swapping one fresh kernel for another forever."""
279
280        def prepare(app):
281            launcher, bundle = editable_daemon(app.root)
282            app.env["ALBEDO_DAEMON"] = str(launcher)
283            link = bundle / "albedo_link.py"
284            link.write_text(link.read_text().replace("PROTOCOL = 1", "PROTOCOL = 2", 1))
285
286        self.app.restart(prepare=prepare)
287        started = time.monotonic()
288        with self.app.prompt(self.session, "run it") as response:
289            input_id = json.load(response)["id"]
290        deadline = started + 10
291        while time.monotonic() < deadline:
292            with self.app.api(
293                f"/sessions/{self.session}/inputs/{input_id}"
294            ) as response:
295                receipt = json.load(response)
296            if receipt["blocking_reason"]:
297                break
298            time.sleep(0.05)
299        else:
300            self.fail("incompatible kernel did not expose a blocked input")
301        self.assertEqual(receipt["delivery"], "pending")
302        self.assertIn("out of step", receipt["blocking_reason"]["detail"], receipt)
303        self.assertIn("protocol", receipt["blocking_reason"]["detail"])
304        with self.app.api(f"/sessions/{self.session}?tail=0") as response:
305            self.assertEqual(json.load(response)["kernel"]["state"], "none")
306
307    def test_another_protocol_whose_snapshot_fails_is_swapped_carrying_nothing(self):
308        kernel, job = self.start()
309        marker = self.restart_current(unsaveable=True)
310        swapped, prompt = self.probe()
311        self.assertEqual(swapped.get("status"), "ok", swapped)
312        self.assertTrue(swapped["value"].startswith("(None,"), swapped)
313        self.assertIn("No variables were restored", prompt)
314        self.assertIn("Not carried: namespace", prompt)
315        self.assertFalse(self.stale())
316        self.assert_gone(marker, kernel, job)
317
318
319if __name__ == "__main__":
320    unittest.main()