okami.mom/albedo

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

dawndaemon: load an idle session a reattached kernel wakes after a restart132fa12

main
8.0 KiB191 linesraw
1"""A session's kernel outlives its daemon and its connection.
2
3The kernel runs detached and the daemon connects to its socket directly; it
4reattaches after a restart (graceful or crash) and after the connection drops
5mid-cell, so the namespace, the kernel process, and an in-flight cell's result
6all survive. A kernel found dead after a restart has its jobs ended.
7"""
8
9import ast
10import json
11import os
12import signal
13import subprocess
14import time
15import unittest
16
17from harness import alive
18from harness import Albedo, Provider, exclusive, python, text
19
20
21def bridges(home):
22    """Pids of the bridges whose kernel lives under this daemon's home."""
23    listing = subprocess.run(
24        ["ps", "-axo", "pid=,command="], capture_output=True, text=True, check=True
25    ).stdout
26    return [
27        int(line.split(None, 1)[0])
28        for line in listing.splitlines()
29        if "albedo_bridge.py" in line and str(home) in line
30    ]
31
32
33# exclusive: restarts the daemon
34@exclusive
35class KernelDetachTests(unittest.TestCase):
36    def setUp(self):
37        self.cells = []
38
39        def script(request):
40            if request["input"][-1].get("role") == "user" and self.cells:
41                return python(self.cells.pop(0))
42            return text("ok")
43
44        self.provider = Provider(script)
45        self.addCleanup(self.provider.close)
46
47        self.app = Albedo(self.provider, protocol="responses")
48        self.app.__enter__()
49        self.addCleanup(self.app.__exit__, None, None, None)
50        self.session = self.app.session()
51
52    def cell(self, code):
53        """Run one cell through a model turn and return its tool result."""
54        self.cells.append(code)
55        self.app.prompt(self.session, "run it").close()
56        self.app.idle(self.session)
57        results = [
58            json.loads(part["value"])
59            for entry in self.app.history(self.session)["items"]
60            if entry["kind"] == "tool_result" and entry["tool"]["name"] == "python"
61            for part in entry["content"]
62            if part["kind"] == "json" and part["field"] == "result"
63        ]
64        return results[-1]
65
66    def test_variables_survive_graceful_and_crash_restarts(self):
67        first = self.cell("import os\nsurvivor = 41\nos.getpid()")
68        self.app.restart()
69        after_stop = self.cell("survivor += 1\n(survivor, os.getpid())")
70        self.assertEqual(after_stop["value"], f"(42, {first['value']})")
71        self.app.restart(crash=True)
72        after_crash = self.cell("survivor += 1\n(survivor, os.getpid())")
73        self.assertEqual(after_crash["value"], f"(43, {first['value']})")
74        notes = [
75            part["text"]
76            for entry in self.app.history(self.session)["items"]
77            if entry["kind"] == "note"
78            for part in entry["content"]
79            if part["kind"] == "text"
80        ]
81        self.assertFalse([note for note in notes if "variables are gone" in note])
82
83    def test_a_connection_dropped_mid_cell_still_delivers_the_result(self):
84        first = self.cell("import os\nos.getpid()")
85        self.assertEqual(bridges(self.app.home), [], "a local kernel kept a bridge")
86        # The kernel drops the daemon's connection from its side a second in.
87        slept = self.cell(
88            "import socket, sys, threading\n"
89            "link = sys.modules['__main__'].LINK\n"
90            "threading.Timer(1, link.connection.shutdown, [socket.SHUT_RDWR]).start()\n"
91            "threading.Event().wait(3)\nprint('woke')\nos.getpid()"
92        )
93        self.assertEqual(slept["status"], "ok", slept)
94        self.assertEqual(slept["output"].strip(), "woke")
95        self.assertEqual(slept["value"], first["value"])
96        self.assertEqual(bridges(self.app.home), [])
97
98    def test_concurrent_jobs_keep_running_and_deadlines_count_wall_time(self):
99        # Legacy admission settings must not throttle new kernels.
100        self.app.daemon.env.update(
101            ALBEDO_MAX_LOCAL_JOBS="1", ALBEDO_JOB_GRACE_SECONDS="0.2"
102        )
103        self.app.restart()
104        program = "import time; time.sleep(5.5); print('progress', flush=True); time.sleep(60)"
105        result = self.cell(
106            "import sys\n"
107            f"workers = [run(sys.executable, '-c', {program!r}, timeout=7) for _ in range(3)]\n"
108            "for worker in workers:\n    await worker\n"
109            "[(worker.timed_out, worker.duration, worker.tail().startswith('progress')) for worker in workers]"
110        )
111        self.assertEqual(result["status"], "ok", result)
112        outcomes = ast.literal_eval(result["value"])
113        self.assertEqual(len(outcomes), 3)
114        for timed_out, duration, progressed in outcomes:
115            self.assertTrue(timed_out)
116            self.assertTrue(progressed)
117            self.assertGreaterEqual(duration, 7)
118            self.assertLess(duration, 10)
119
120    def last_prompt(self):
121        """The newest model request's user text: where a notice would ride."""
122        request = next(
123            record["request"]
124            for record in reversed(self.provider.requests)
125            if any(
126                item.get("role") == "user"
127                for item in record["request"].get("input", [])
128            )
129        )
130        return [
131            item["content"] for item in request["input"] if item.get("role") == "user"
132        ][-1]
133
134    def test_a_restart_tells_the_model_what_the_kernel_is_still_running(self):
135        started = self.cell(
136            "import asyncio, os\nj = run('sleep', '120')\n"
137            "await asyncio.sleep(0.3)\n(os.getpid(), j.id)"
138        )
139        kernel, job = (
140            part.strip(" '\"") for part in started["value"].strip("()").split(",")
141        )
142        self.app.restart()
143
144        # The next turn runs on the reattached kernel, and its request says
145        # the kernel carried on and names the job still running, so a live
146        # handle is not mistaken for one the restart killed.
147        self.cells.append("import os\nos.getpid()")
148        self.app.prompt(self.session, "run it").close()
149        self.app.idle(self.session)
150        prompt = self.last_prompt()
151        self.assertIn("<system-note>The python kernel reattached", prompt)
152        self.assertIn("Jobs still running: " + job, prompt)
153        self.assertIn("sleep 120", prompt)
154        self.assertEqual(self.cell("import os\nos.getpid()")["value"], str(int(kernel)))
155        # The notice rides the first request after the reattach, once.
156        self.cells.append("import os\nos.getpid()")
157        self.app.prompt(self.session, "run it").close()
158        self.app.idle(self.session)
159        self.assertNotIn("The python kernel reattached", self.last_prompt())
160
161    def test_a_job_ending_after_a_restart_wakes_its_idle_session(self):
162        started = self.cell("j = run('sleep', '4')\nj.id")
163        job = started["value"].strip("'\"")
164        requests = len(self.provider.requests)
165        # The restart leaves the session idle and unloaded; only its kernel
166        # comes back, and the job's end must still reach the model.
167        self.app.restart()
168        deadline = time.monotonic() + 30
169        while len(self.provider.requests) == requests and time.monotonic() < deadline:
170            time.sleep(0.1)
171        self.assertGreater(len(self.provider.requests), requests, "no wake")
172        self.app.idle(self.session)
173        self.assertIn(job, self.last_prompt())
174
175    def test_a_kernel_killed_while_the_daemon_was_down_has_its_jobs_ended(self):
176        started = self.cell(
177            "import asyncio, os\nj = run('sleep', '300')\nawait asyncio.sleep(0.5)\n(os.getpid(), j.process.pid)"
178        )
179        kernel, job = (int(part) for part in started["value"].strip("()").split(","))
180
181        # Graceful stop leaves the kernel running; it dies hard before the
182        # next daemon reaches it, and its job's group outlives it.
183        self.app.restart(prepare=lambda _: os.kill(kernel, signal.SIGKILL))
184        deadline = time.monotonic() + 15
185        while alive(job) and time.monotonic() < deadline:
186            time.sleep(0.1)
187        self.assertFalse(alive(job), "the dead kernel's job is still running")
188
189
190if __name__ == "__main__":
191    unittest.main()