okami.mom/albedo

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

Aly Raffaufrefactor(tests): share repeated E2E fixtures5bdc0ee

main
6.0 KiB148 linesraw
1"""A pending cell-completion wake must survive the idle swap of a stale kernel.
2
3The incident behind this suite: a session ran a long background cell on a
4kernel left stale by a daemon restart. At the turn's end a park nobody kept
5asked for a kernel, so the runtime started the swap; the arriving replacement
6was dropped with no notice, the old kernel (still holding the cell's pending
7completion wake) was stopped, and the model was never told anything. The wake
8must instead be admitted before the swap, ride it, and carry the upgrade
9notice; a swap that does end a running cell must journal it interrupted and
10say so.
11"""
12
13import json
14import os
15from pathlib import Path
16import time
17import unittest
18
19import harness
20from harness import Provider, exclusive, latest_user, python, text
21from kernel_support import MARKER, ModelCells, editable_daemon
22
23
24# exclusive: boots an editable python bundle and restarts the daemon
25@exclusive
26class SwapWakeTests(ModelCells, unittest.TestCase):
27    bundle: Path
28
29    def setUp(self):
30        self.cells = []
31
32        def script(request):
33            user = latest_user(request)
34            if "background python cells finished" in user:
35                return text("wake seen")
36            if user.endswith("hold"):
37                return text("holding", delay=4.0)
38            if request["input"][-1].get("role") == "user" and self.cells:
39                return python(self.cells.pop(0))
40            return text("ok")
41
42        def prepare(app):
43            launcher, self.bundle = editable_daemon(app.root)
44            app.env["ALBEDO_DAEMON"] = str(launcher)
45            app.env["ALBEDO_CELL_BACKGROUND_SECONDS"] = "0.5"
46
47        self.provider = Provider(script)
48        self.addCleanup(self.provider.close)
49        self.app = harness.Albedo(self.provider, protocol="responses", prepare=prepare)
50        self.app.__enter__()
51        self.addCleanup(self.app.__exit__, None, None, None)
52        self.session = self.app.session()
53
54    def kernel(self):
55        with self.app.api(f"/sessions/{self.session}?tail=0") as response:
56            return json.load(response)["kernel"]
57
58    def gate(self):
59        gate = self.app.workspace / "cell-gate"
60        os.mkfifo(gate)
61        self.addCleanup(harness.release_fifo, gate)
62        return gate
63
64    def held_cell(self, *, protocol_skew=False):
65        """Cell code whose backgrounded job runs until the gate is released:
66        the cell finishes only when the test decides, like the incident's
67        build, and the kernel's loop stays free to answer the daemon."""
68        gate = self.gate()
69        program = f"open({str(gate)!r}, 'rb').read(1)"
70        prefix = (
71            "import albedo_link, os, sys\nalbedo_link.PROTOCOL = 2\n"
72            if protocol_skew
73            else "import os, sys\n"
74        )
75        code = (
76            f"{prefix}survivor = 41\n"
77            f"j = run(sys.executable, '-c', {program!r})\n"
78            "await j\n('gate done',)"
79        )
80        return gate, code
81
82    def wait_for_wake(self):
83        deadline = time.monotonic() + 40
84        while time.monotonic() < deadline:
85            for record in reversed(self.provider.requests):
86                if "background python cells finished" in latest_user(record["request"]):
87                    return latest_user(record["request"])
88            time.sleep(0.1)
89        self.fail("the cell completion wake never reached the model")
90
91    def test_a_pending_cell_wake_survives_the_idle_kernel_swap(self):
92        gate, code = self.held_cell()
93        first, _ = self.cell(code)
94        self.assertEqual(first["status"], "backgrounded", first)
95
96        with (self.bundle / "albedo_bundle.py").open("a") as source:
97            source.write(MARKER)
98        self.app.restart()
99
100        # One slow turn on the still-stale kernel (its live job holds the
101        # swap). The gate is released mid-turn, so the background cell
102        # finishes and its wake retries while the session is busy, exactly as
103        # in the incident; the spurious park used to fire when this turn
104        # ended, starting a swap that killed the wake with the old kernel.
105        self.app.prompt(self.session, "hold").close()
106        time.sleep(0.8)
107        harness.wait_until(
108            lambda: harness.release_fifo(gate), 10, "the held job never opened its gate"
109        )
110        self.app.idle(self.session)
111
112        wake = self.wait_for_wake()
113        self.app.idle(self.session)
114        # The wake was admitted before the swap, so it rides the swapped
115        # kernel's turn together with the upgrade notice.
116        self.assertIn("The python kernel was upgraded to the new python bundle", wake)
117        self.assertRegex(wake, r"Restored: [^.]*\bsurvivor\b")
118
119        swapped, _ = self.cell(
120            "import albedo_bundle, os\n"
121            "(survivor, getattr(albedo_bundle, 'MARKER', None), os.getpid())"
122        )
123        self.assertEqual(swapped.get("status"), "ok", swapped)
124        survivor, marker, _pid = swapped["value"].strip("()").split(", ")
125        self.assertEqual((survivor, marker), ("41", "'new bundle'"), swapped)
126        self.assertFalse(self.kernel()["stale"])
127
128    def test_a_swap_past_a_running_cell_ends_it_as_interrupted(self):
129        # A kernel on another protocol is swapped regardless of live work, so
130        # the swap ends the running cell: it must be journaled interrupted and
131        # named, not left `started` with its effects unknown. (The explicit
132        # upgrade refuses while a cell runs: "a cell is still running".)
133        gate, code = self.held_cell(protocol_skew=True)
134        first, _ = self.cell(code)
135        self.assertEqual(first["status"], "backgrounded", first)
136        cell_id = first["cell_id"]
137
138        self.app.restart()
139        ended, prompt = self.cell(f"print(await cells.info({cell_id!r}))")
140        self.assertIn("'status': 'interrupted'", ended["output"], ended)
141        self.assertIn(
142            "The python kernel was upgraded to the current kernel protocol", prompt
143        )
144        self.assertIn(f"Cells ended by the swap: {cell_id}", prompt)
145
146
147if __name__ == "__main__":
148    unittest.main()