okami.mom/albedo

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

dawnkernel: a job_stop frame lets /jobs stop remote background jobs57a82be

main
22.0 KiB546 linesraw
1"""Check remote relay, streaming jobs and degraded boot without SSH credentials.
2
3Malformed frames cannot come from the normal daemon. Feed them into the real
4remote reader to catch abandoned handshake and call futures on reader exits.
5"""
6
7import asyncio
8import contextlib
9import hashlib
10import io
11import json
12import struct
13import os
14import sys
15import tempfile
16import unittest
17from pathlib import Path
18from unittest.mock import patch
19
20ROOT = Path(__file__).resolve().parents[2]
21sys.path.insert(0, str(ROOT / "priv" / "python"))
22from albedo_api import PythonApi  # noqa: E402
23from albedo_capture import Capture  # noqa: E402
24from albedo_plugins import remote  # noqa: E402
25
26sys.path.insert(0, str(ROOT / "test"))
27import scratch  # noqa: E402
28
29# Temporary workspaces and homes go under this run's scratch directory, which
30# is removed at exit.
31scratch.claim("harness")
32
33
34class FakeHostError(Exception):
35    def __init__(self, code, message):
36        super().__init__(message)
37        self.code, self.message = code, message
38
39
40def fake_ssh_path() -> str:
41    """A loopback ssh: option pairs, target, then run the command locally."""
42    script = Path(tempfile.mkdtemp(prefix="albedo-fake-ssh-")) / "ssh"
43    script.write_text(
44        "#!/bin/sh\n"
45        "while [ $# -gt 0 ]; do\n"
46        '  case "$1" in\n'
47        "    -*) shift 2 ;;\n"
48        "    *) break ;;\n"
49        "  esac\n"
50        "done\n"
51        "shift\n"
52        'exec sh -c "$*"\n'
53    )
54    script.chmod(0o755)
55    return str(script)
56
57
58FAKE_SSH = fake_ssh_path()
59
60
61notices = []  # every jobs.completed notice that reached the daemon
62
63
64async def daemon_host(method, args):
65    if method == "jobs.completed":
66        notices.append(args)
67        return None
68    if method == "work.list":
69        return [
70            {
71                "id": 1,
72                "title": "from-the-daemon",
73                "notes": "",
74                "status": "open",
75                "parent": None,
76                "session": None,
77                "run": None,
78                "revision": 1,
79            }
80        ]
81    if method == "work.get":
82        raise FakeHostError("missing", f"no work item {args['id']}")
83    return {}
84
85
86class RemotePluginTest(unittest.IsolatedAsyncioTestCase):
87    def setUp(self):
88        remote.configured.clear()
89        remote.connections.clear()
90        notices.clear()
91        self.home = tempfile.mkdtemp(prefix="albedo-remote-home-")
92        self.workspace = tempfile.mkdtemp(prefix="albedo-remote-ws-")
93        os.chdir(self.workspace)
94        self.environment = patch.dict(
95            os.environ,
96            {
97                "HOME": self.home,
98                "ALBEDO_HOME": os.path.join(self.home, ".albedo"),
99                "ALBEDO_SSH": "loopback",
100                "SHELL": "/bin/sh",
101            },
102        )
103        self.environment.start()
104        self.addCleanup(self.environment.stop)
105        self.addCleanup(os.chdir, ROOT)
106        self.fake_ssh = patch.object(remote, "ssh_base", lambda target: [FAKE_SSH])
107        self.fake_ssh.start()
108        self.addCleanup(self.fake_ssh.stop)
109
110    async def asyncSetUp(self):
111        self.loop = asyncio.get_running_loop()
112        self.api = PythonApi(
113            self.loop,
114            daemon_host,
115            FakeHostError,
116            Capture,
117            64 * 1024,
118            lambda event: None,
119            lambda close: None,
120            lambda cls: None,
121            2,
122            ["run", "files", "work", "skills", "remote"],
123        )
124        self.namespace = {}
125        remote.setup(self.api)
126        self.remote = remote.Remote()
127
128    async def connect(self, **kwargs):
129        return await self.remote.connect(python=sys.executable, **kwargs)
130
131    async def test_reader_exits_fail_handshake_and_calls(self):
132        frames = [
133            [],
134            None,
135            {},
136            {"type": "unknown"},
137            {"type": "ready", "pid": True, "pgid": None, "leader": None},
138            {"type": "startup_error"},
139            {"type": "invoked", "id": "call", "ok": 1, "value": None},
140            {"type": "invoked", "id": "call", "ok": True},
141            {"type": "invoked", "id": [], "ok": True, "value": None},
142            {"type": "invoked", "id": "call", "ok": False, "error": []},
143            {
144                "type": "invoked",
145                "id": "call",
146                "ok": False,
147                "error": {"ename": "Error", "evalue": "bad", "traceback": [1]},
148            },
149            {"type": "invoked", "id": "call", "ok": True, "handle": 1},
150            {"type": "invoked", "id": "call", "ok": True, "handle": "h", "state": []},
151            {"type": "mirror", "handle": "h", "exit_code": True},
152            {"type": "mirror", "handle": "h", "timed_out": 1},
153            {"type": "introspected", "id": "call", "names": [], "handles": [None]},
154            {"type": "call", "id": "x", "method": "work.list", "args": []},
155            {"type": "trace", "id": []},
156            {"type": "cleanup", "module": None},
157            {"type": "job_stop", "id": "x", "ok": 1},
158        ]
159        payloads = [json.dumps(frame).encode() for frame in frames] + [b"{bad json"]
160        wires = [struct.pack(">I", len(data)) + data for data in payloads]
161        wires += [struct.pack(">I", remote.MAX_FRAME + 1), b"", b"\x00\x00"]
162        for wire in wires:
163            with self.subTest(wire=wire[:160]):
164                connection, stream, call = self.reader_connection()
165                stream.feed_data(wire)
166                stream.feed_eof()
167                await self.assert_reader_lost(connection, call)
168
169    def reader_connection(self):
170        connection = remote.RemoteConnection({"host": "loopback"})
171        stream = asyncio.StreamReader()
172        connection._stdout = stream
173        connection._handshake = self.loop.create_future()
174        call = self.loop.create_future()
175        connection._pending["call"] = call
176        return connection, stream, call
177
178    async def assert_reader_lost(self, connection, call, *, cancel=False):
179        reader = self.loop.create_task(connection._read_frames())
180        if cancel:
181            await asyncio.sleep(0)
182            reader.cancel()
183            with self.assertRaises(asyncio.CancelledError):
184                await asyncio.wait_for(reader, 1)
185        else:
186            await asyncio.wait_for(reader, 1)
187        with self.assertRaises(remote.RemoteBootError):
188            await asyncio.wait_for(connection._handshake, 1)
189        with self.assertRaises(remote.RemoteLost):
190            await asyncio.wait_for(call, 1)
191        self.assertTrue(connection.closed)
192
193    async def test_cancellation_and_unexpected_reader_errors_fail_futures(self):
194        connection, _, call = self.reader_connection()
195        await self.assert_reader_lost(connection, call, cancel=True)
196        connection, stream, call = self.reader_connection()
197        stream.set_exception(RuntimeError("unexpected read failure"))
198        await self.assert_reader_lost(connection, call)
199
200    async def test_ignored_events_and_extra_fields_allow_a_following_reply(self):
201        connection, stream, call = self.reader_connection()
202        frames = [
203            {"type": "trace", "id": "cell", "trace": None},
204            {"type": "done", "id": "cell", "unused": []},
205            {"type": "cleanup", "module": "plugin", "failures": None},
206            {"type": "ready", "pid": 1, "pgid": None, "leader": None, "extra": []},
207            {
208                "type": "invoked",
209                "id": "call",
210                "ok": True,
211                "value": [None, True, {"x": 1}],
212                "extra": [],
213            },
214        ]
215        for frame in frames:
216            data = json.dumps(frame).encode()
217            stream.feed_data(struct.pack(">I", len(data)) + data)
218        stream.feed_eof()
219        await asyncio.wait_for(connection._read_frames(), 1)
220        self.assertEqual((await call)["value"], [None, True, {"x": 1}])
221        self.assertEqual((await connection._handshake)["type"], "ready")
222
223    def test_settings_validate_strings_and_preserve_fallback_and_precedence(self):
224        home = Path(os.environ["ALBEDO_HOME"])
225        home.mkdir()
226        settings = home / "extensions.json"
227        self.assertEqual(remote._settings(), {})
228        for field in ("host", "remoteCwd", "python"):
229            for value in (None, 1, True, [], {}):
230                with self.subTest(field=field, value=value):
231                    settings.write_text(json.dumps({"remote": {field: value}}))
232                    with self.assertRaisesRegex(remote.RemoteError, field):
233                        remote._settings()
234        values = {
235            "host": "settings-host",
236            "remoteCwd": "/remote",
237            "python": "python-custom",
238        }
239        settings.write_text(json.dumps({"ssh": {**values, "unrelated": []}}))
240        self.assertEqual(remote._settings(), values)
241        self.assertEqual(remote.resolve()["host"], "loopback")
242        remote.remote.configure("configured:/configured")
243        self.assertEqual(remote.resolve()["host"], "configured")
244        target = remote.resolve("explicit:/path", "/override", "python-explicit")
245        self.assertEqual(
246            target,
247            {
248                "host": "explicit",
249                "remote_cwd": "/override",
250                "python": "python-explicit",
251            },
252        )
253        settings.write_text(json.dumps({"remote": {}, "ssh": values}))
254        self.assertEqual(remote._settings(), {})
255        # ssh takes a URI target with a port; its path is the remote cwd
256        self.assertEqual(
257            remote.parse_target("ssh://me@box:2222/srv/app"),
258            {"host": "ssh://me@box:2222", "remote_cwd": "/srv/app"},
259        )
260        self.assertEqual(
261            remote.parse_target("ssh://box:2222"),
262            {"host": "ssh://box:2222", "remote_cwd": None},
263        )
264
265    async def test_a_host_that_needs_a_sign_in_names_the_command_for_it(self):
266        locked = Path(tempfile.mkdtemp(prefix="albedo-locked-ssh-")) / "ssh"
267        locked.write_text(
268            "#!/bin/sh\necho 'box: Permission denied (publickey).' >&2\nexit 255\n"
269        )
270        locked.chmod(0o755)
271        with patch.object(remote, "ssh_base", lambda target: [str(locked)]):
272            with self.assertRaises(remote.RemoteError) as raised:
273                await self.connect(host="ssh://box:2222")
274        message = str(raised.exception)
275        self.assertIn("needs a person to sign in", message)
276        self.assertIn("ssh -M -fN -o ControlPath=", message)
277        self.assertIn(" ssh://box:2222`", message)
278
279    async def test_connect_boots_the_kernel_and_answers_every_tool(self):
280        rem = await self.connect()
281        self.addCleanup(rem.close)
282        self.assertIn("kernel", repr(rem))
283        self.assertIn("run", (await rem.tools())["names"])
284
285        text = await rem.files.read("/etc/hosts")
286        self.assertIsInstance(text, str)
287
288        # The call is synchronous like local run; the reference settles on await.
289        job = rem.run(
290            sys.executable,
291            "-c",
292            "import time; print('loopback-echo', flush=True); time.sleep(0.2)",
293        )
294        self.assertIn("pending", repr(job))
295        self.assertIsNone(job.poll())
296        self.assertEqual(job.tail(), "")
297        awaited = await job
298        self.assertIs(awaited, job)
299        self.assertEqual(job.poll(), 0)
300        self.assertEqual(job.exit_code, 0)
301        self.assertEqual(job.returncode, 0)  # subprocess's spelling answers too
302        self.assertFalse(job.timed_out)
303        self.assertIsNotNone(job.duration)
304        self.assertGreaterEqual(job.duration, 0.2)
305        self.assertEqual(job.tail(), "loopback-echo\n")
306        self.assertIsNotNone(job.id)
307        self.assertIn("tail", dir(job))  # dir() mixes local and mirrored names
308        ending = await job.stop  # awaiting an uncalled method runs it
309        self.assertTrue(ending.gone)
310
311    async def test_a_job_awaited_here_does_not_wake_the_session(self):
312        rem = await self.connect()
313        self.addCleanup(rem.close)
314        # The await crosses a round trip after the call's own reply; over a slow
315        # link a quick job is done by then, and its wake must not reach the
316        # idle session a backgrounded cell's waiting loop leaves behind.
317        send = rem._send
318
319        async def slow_link(frame):
320            if frame.get("await"):
321                await asyncio.sleep(0.5)
322            await send(frame)
323
324        with patch.object(rem, "_send", slow_link):
325            job = rem.run("true")
326            await job
327        self.assertEqual(job.exit_code, 0)
328        rem.run("echo", "left-unread")
329        for _ in range(100):
330            if notices:
331                break
332            await asyncio.sleep(0.05)
333        [notice] = notices
334        self.assertEqual(
335            [fact["command"] for fact in notice["jobs"]], ["echo left-unread"]
336        )
337        # That job lives in the remote kernel's jobs, not the model's.
338        self.assertIn("rem.output.read(", notice["text"])
339        self.assertNotIn("jobs[", notice["text"])
340
341    async def test_an_await_cut_short_leaves_the_job_waking_the_session(self):
342        rem = await self.connect()
343        self.addCleanup(rem.close)
344        # A cell deadline cancels the await here; the interrupt must reach the
345        # remote kernel's await too, or neither side ever sends the wake.
346        job = rem.run(sys.executable, "-c", "import time; time.sleep(1)")
347        for _ in range(100):
348            if job.id is not None:
349                break
350            await asyncio.sleep(0.05)
351        with self.assertRaises(asyncio.TimeoutError):
352            await asyncio.wait_for(job, 0.2)
353        for _ in range(100):
354            if notices:
355                break
356            await asyncio.sleep(0.05)
357        [notice] = notices
358        self.assertEqual([fact["id"] for fact in notice["jobs"]], [job.id])
359
360    async def test_a_call_that_fails_unawaited_says_so_on_its_reference(self):
361        rem = await self.connect()
362        self.addCleanup(rem.close)
363        # Left to wake the session, a job that never started must not look
364        # pending forever.
365        job = rem.run("true", timeout=0)
366        for _ in range(100):
367            if "failed" in repr(job):
368                break
369            await asyncio.sleep(0.05)
370        self.assertIn("timeout is in seconds", repr(job))
371        with self.assertRaisesRegex(
372            remote.RemoteExecutionError, "timeout is in seconds"
373        ):
374            _ = job.id
375        with self.assertRaisesRegex(
376            remote.RemoteExecutionError, "timeout is in seconds"
377        ):
378            await job
379
380    async def test_a_file_larger_than_a_command_line_writes_and_reads_back(self):
381        rem = await self.connect()
382        self.addCleanup(rem.close)
383        # Past macOS's whole ARG_MAX and Linux's 128 KiB per argument, so the
384        # text must travel on stdin rather than in the remote command.
385        content = "".join(f"line {index} ü\n" for index in range(150_000))
386        await rem.write("big.lock", content)
387        self.assertEqual(Path(self.workspace, "big.lock").read_text(), content)
388        self.assertEqual(await rem.read("big.lock"), content)
389
390    async def test_binary_stdin_and_bytes_writes_cross_intact(self):
391        rem = await self.connect()
392        self.addCleanup(rem.close)
393        # Vent 9: bytes arguments crossed as {"__bytes__": b64} markers that
394        # the remote kernel passed through as dicts, so run(..., stdin=b"...")
395        # refused its own stdin and rem.write(path, b"...") called .encode()
396        # on bytes. Every byte value, so the round trip cannot pass by luck.
397        payload = bytes(range(256)) * 64
398        job = rem.run(
399            sys.executable,
400            "-c",
401            "import hashlib, sys; d = sys.stdin.buffer.read();"
402            "print(len(d), hashlib.sha256(d).hexdigest());"
403            "open('sink.bin', 'wb').write(d)",
404            stdin=payload,
405        )
406        await job
407        self.assertEqual(job.exit_code, 0)
408        count, digest = job.tail().split()
409        self.assertEqual(int(count), len(payload))
410        self.assertEqual(digest, hashlib.sha256(payload).hexdigest())
411        self.assertEqual(Path(self.workspace, "sink.bin").read_bytes(), payload)
412        blob = b"\x00\xff\xfe" + bytes(range(256))
413        await rem.write("blob.bin", blob)
414        self.assertEqual(Path(self.workspace, "blob.bin").read_bytes(), blob)
415        # A binary read decoded with errors="replace", so a PNG came back as
416        # U+FFFD text and the model fell back to base64 through a capped read.
417        self.assertEqual(await rem.read("blob.bin"), blob)
418
419    async def test_awaiting_a_non_awaitable_remote_object_keeps_its_reference(self):
420        rem = await self.connect()
421        self.addCleanup(rem.close)
422        # Vent 9: awaiting a call whose result is a live, non-awaitable object
423        # (rem.browser.spawn() is the real case; a spawned Browser) made the
424        # remote kernel `await` the settled object and raise TypeError, with
425        # no handle back, so every retry leaked another process. The remote
426        # jobs dict is the same shape: non-crossable, non-awaitable, retained.
427        job = rem.run("echo", "vent-nine")
428        live = await rem.jobs.copy()
429        self.assertIn("remote reference", repr(live))
430        found = await live.get(job.id)  # a method call on the live object
431        self.assertEqual(found.exit_code, 0)
432
433    async def test_an_unsettled_reference_argument_resolves_in_order(self):
434        rem = await self.connect()
435        self.addCleanup(rem.close)
436        # Vent 9, the argument form: a reference still in flight crossed as
437        # {"pending": id}, which the remote kernel passed through as a dict,
438        # so run(..., stdin=<remote job>) saw stdin it refuses. It must
439        # resolve to the producer's own object, like a pending target.
440        producer = rem.run(sys.executable, "-c", "print('pending-arg-fed', flush=True)")
441        consumer = rem.run("cat", stdin=producer)
442        await consumer
443        self.assertEqual(consumer.exit_code, 0)
444        self.assertEqual(consumer.tail().strip(), "pending-arg-fed")
445
446    async def test_session_tools_relay_to_this_daemon(self):
447        rem = await self.connect()
448        self.addCleanup(rem.close)
449        items = await rem.work.list()
450        self.assertEqual(items[0]["title"], "from-the-daemon")
451        with self.assertRaises(remote.RemoteExecutionError) as raised:
452            await rem.work.get(99)
453        self.assertEqual(raised.exception.ename, "WorkError")
454
455    async def test_remote_jobs_pipe_and_read_lines_like_local_ones(self):
456        rem = await self.connect()
457        self.addCleanup(rem.close)
458        Path(self.workspace, "sub").mkdir()
459        Path(self.workspace, "sub", "notes.txt").write_text("b\na\nb\n")
460        job = (
461            await rem.run("cat", "notes.txt", cwd="sub").pipe("sort").pipe("uniq", "-c")
462        )
463        self.assertEqual(job.exit_code, 0)
464        self.assertEqual(job.tail(lines=1).split(), ["2", "b"])
465        self.assertEqual((await job.head(lines=1)).split(), ["1", "a"])
466
467    async def test_a_kernel_that_cannot_boot_degrades_with_a_warning(self):
468        warning = io.StringIO()
469        with contextlib.redirect_stdout(warning):
470            rem = await self.connect(modules=["definitely-not-a-module"])
471        self.addCleanup(rem.close)
472        self.assertIn("degraded", repr(rem))
473        self.assertIn(
474            "for pipes or shell syntax use rem.shell('git log --oneline | rg fix')",
475            warning.getvalue(),
476        )
477        Path(self.workspace, "sub").mkdir()
478        parity = rem.run(
479            sys.executable,
480            "-c",
481            "import os, sys; print(os.path.basename(os.getcwd()), os.environ['WHO'], sys.stdin.read())",
482            cwd="sub",
483            env={"WHO": "me"},
484            stdin="fed",
485        )
486        await parity
487        self.assertEqual(parity.tail(), "sub me fed\n")
488        self.assertEqual(parity.head(lines=1), "sub me fed\n")
489        piped = await rem.shell("printf 'b\\na\\nb\\n' | sort | uniq -c", cwd="sub")
490        self.assertEqual(piped.exit_code, 0)
491        self.assertEqual(piped.tail().split(), ["1", "a", "2", "b"])
492        self.assertEqual(piped.command, "printf 'b\\na\\nb\\n' | sort | uniq -c")
493        with self.assertRaisesRegex(TypeError, "stdin is text or bytes"):
494            rem.shell("cat", stdin=piped)
495        job = rem.run("echo", "degraded-echo")
496        await job
497        self.assertEqual(job.poll(), 0)
498        self.assertEqual(job.returncode, 0)  # subprocess's spelling answers too
499        self.assertIsNotNone(job.duration)
500        self.assertFalse(job.timed_out)
501        self.assertEqual(job.tail(), "degraded-echo\n")
502        with self.assertRaises(AttributeError) as raised:
503            rem.files
504        self.assertIn("degraded to ssh command mode", str(raised.exception))
505        tools = await rem.tools()
506        self.assertIn("degraded", tools)
507
508    async def test_stop_remote_job_routes_to_connection_and_resolves(self):
509        rem = await self.connect()
510        self.addCleanup(rem.close)
511        job = rem.run(sys.executable, "-c", "import time; time.sleep(60)")
512        for _ in range(100):
513            if job.id in rem._live:
514                break
515            await asyncio.sleep(0.05)
516        self.assertIn(job.id, rem._live)
517        result = await remote.stop_remote_job(job.id)
518        self.assertEqual(result, {"ok": True})
519        for _ in range(100):
520            if job.id not in rem._live:
521                break
522            await asyncio.sleep(0.05)
523        self.assertNotIn(job.id, rem._live)
524
525    async def test_stop_remote_job_unknown_returns_none(self):
526        result = await remote.stop_remote_job("unknown-id")
527        self.assertIsNone(result)
528
529    async def test_stop_remote_job_fallback_job_stops_locally(self):
530        rem = await self.connect(modules=["definitely-not-a-module"])
531        self.addCleanup(rem.close)
532        job = rem.run(sys.executable, "-c", "import time; time.sleep(60)")
533        self.assertIn(job.id, remote._fallback_jobs)
534        self.assertIn(job.id, remote._fallback_live)
535        result = await remote.stop_remote_job(job.id)
536        self.assertEqual(result, {"ok": True})
537        self.assertIn(
538            "the remote command may still be running",
539            job.tail(),
540        )
541        self.assertNotIn(job.id, remote._fallback_jobs)
542        self.assertNotIn(job.id, remote._fallback_live)
543
544
545if __name__ == "__main__":
546    unittest.main()