okami.mom/albedo

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

dawnkernel: list remote jobs beside local ones and share one awaited-job definition60047c7

main
13.3 KiB374 linesraw
1"""The kernel's half of the durable session layer.
2
3A detached kernel listens on a unix socket in its run directory. Whoever
4attaches (a bridge the daemon runs, locally today and over ssh later) presents
5the kernel's secret token; the newest attach wins and cuts the previous
6connection off. Every frame after the handshake travels as
7``{"seq": n, "ack": m, "frame": {...}}``: the kernel keeps what it sent until
8the daemon acknowledges it and resends the rest on the next attach, and it
9drops any daemon frame it has already seen, so a reconnect neither loses nor
10repeats a message. A kernel run over plain stdio (the remote plugin's targets)
11keeps the raw frames and ends with its pipe.
12"""
13
14from __future__ import annotations
15
16import collections
17import hmac
18import json
19import os
20import socket
21import struct
22import threading
23import time
24from collections.abc import Callable
25from typing import Any, BinaryIO
26
27PROTOCOL = 1  # framing version; hello fields describe the current bundle
28MAX_FRAME = 8 * 1024 * 1024
29SOCKET = "kernel.sock"
30ATTACH_TIMEOUT = 10.0  # seconds a fresh connection has to present its token
31OUTBOX_FRAMES = 4096
32OUTBOX_BYTES = 64 * 1024 * 1024
33# Frames whose newest copy supersedes every older unacknowledged one, by the
34# field naming what they describe. Everything else must be delivered.
35COALESCE = {"mirror": "handle", "trace": "id", "jobs": None, "cells": None}
36
37
38def encode(value: object) -> bytes:
39    return json.dumps(value, ensure_ascii=True, separators=(",", ":")).encode()
40
41
42def envelope(seq: int, ack: int, frame: bytes) -> bytes:
43    return b'{"seq":%d,"ack":%d,"frame":%s}' % (seq, ack, frame)
44
45
46def count_of(value: object) -> int | None:
47    """A frame's count: a non-negative integer, else None."""
48    if isinstance(value, int) and not isinstance(value, bool) and value >= 0:
49        return value
50    return None
51
52
53def write_all(write: Callable[[Any], int | None], data: bytes) -> None:
54    remaining = memoryview(data)
55    while remaining:
56        written = write(remaining)
57        remaining = remaining[len(remaining) if written is None else written :]
58
59
60def write_frame(write: Callable[[Any], int | None], data: bytes) -> None:
61    write_all(write, struct.pack(">I", len(data)) + data)
62
63
64def read_exact(read: Callable[[int], bytes], size: int) -> bytes:
65    data = bytearray()
66    while len(data) < size:
67        chunk = read(size - len(data))
68        if not chunk:
69            raise EOFError()
70        data.extend(chunk)
71    return bytes(data)
72
73
74def read_frame(read: Callable[[int], bytes]) -> object:
75    size = struct.unpack(">I", read_exact(read, 4))[0]
76    if size > MAX_FRAME:
77        raise ValueError("control frame too large")
78    return json.loads(read_exact(read, size))
79
80
81class Outbox:
82    """Frames sent and not yet acknowledged, oldest first.
83
84    Coalescing frames replace their older unacknowledged copy, so a flood of
85    output mirrors costs one entry. Past the bound, the oldest coalescing
86    frames go first; a must-deliver frame is dropped only when nothing else is
87    left, and `dropped` counts every such loss for the next hello.
88    """
89
90    def __init__(self, frames: int = OUTBOX_FRAMES, size: int = OUTBOX_BYTES):
91        self.limit_frames = frames
92        self.limit_bytes = size
93        self.seq = 0
94        self.size = 0
95        self.dropped = 0
96        self.entries: collections.OrderedDict[int, tuple[bytes, object]] = (
97            collections.OrderedDict()
98        )
99        self.keys: dict[object, int] = {}
100
101    def add(self, frame: dict[str, object]) -> tuple[int, bytes]:
102        data = encode(frame)
103        self.seq += 1
104        kind = frame.get("type")
105        key: object = None
106        if isinstance(kind, str) and kind in COALESCE:
107            field = COALESCE[kind]
108            key = (kind, frame.get(field) if field else None)
109            older = self.keys.pop(key, None)
110            if older is not None:
111                self._remove(older)
112        self.entries[self.seq] = (data, key)
113        self.size += len(data)
114        if key is not None:
115            self.keys[key] = self.seq
116        self._bound()
117        return self.seq, data
118
119    def ack(self, upto: int) -> None:
120        while self.entries:
121            seq = next(iter(self.entries))
122            if seq > upto:
123                return
124            self._remove(seq)
125
126    def pending(self) -> list[tuple[int, bytes]]:
127        return [(seq, data) for seq, (data, _) in self.entries.items()]
128
129    def _remove(self, seq: int) -> None:
130        entry = self.entries.pop(seq, None)
131        if entry is None:
132            return
133        data, key = entry
134        self.size -= len(data)
135        if key is not None and self.keys.get(key) == seq:
136            del self.keys[key]
137
138    def _bound(self) -> None:
139        def over() -> bool:
140            return len(self.entries) > self.limit_frames or self.size > self.limit_bytes
141
142        if not over():
143            return
144        # `keys` indexes every coalescing entry (one per live handle or cell),
145        # so shedding them oldest first never walks the whole outbox.
146        while over() and self.keys:
147            self._remove(min(self.keys.values()))
148        while over() and len(self.entries) > 1:
149            self._remove(next(iter(self.entries)))
150            self.dropped += 1
151
152
153class Inbound:
154    """The newest daemon frame applied; anything at or below it is a replay."""
155
156    def __init__(self) -> None:
157        self.last = 0
158
159    def accept(self, seq: int) -> bool:
160        if seq <= self.last:
161            return False
162        self.last = seq
163        return True
164
165
166class JobBook:
167    """Job groups this kernel still owns, read from the frames it sends.
168
169    The same bookkeeping the daemon keeps: a started job's group is owned
170    until a job frame proves it gone. A reattaching daemon learns the groups
171    from the hello. The kernel stays alive past its grace while a job is live.
172    """
173
174    def __init__(self) -> None:
175        self.started: dict[str, dict[str, object]] = {}
176        self.external = 0
177        self.awaited_external = 0
178        self.external_jobs: list[object] = []
179
180    def observe(self, frame: dict[str, object]) -> None:
181        kind, id = frame.get("type"), frame.get("id")
182        if kind == "job_start" and isinstance(id, str):
183            pgid = frame.get("pgid")
184            if isinstance(pgid, int) and not isinstance(pgid, bool) and pgid > 1:
185                self.started[id] = frame
186        elif kind == "job" and isinstance(id, str):
187            cleanup = frame.get("cleanup")
188            if isinstance(cleanup, dict) and cleanup.get("gone") is True:
189                self.started.pop(id, None)
190        elif kind == "jobs":
191            live = count_of(frame.get("live"))
192            awaited = count_of(frame.get("awaited", 0))
193            items = frame.get("items")
194            if live is not None and awaited is not None:
195                self.external, self.awaited_external = live, awaited
196                self.external_jobs = items if isinstance(items, list) else []
197
198    def live(self) -> int:
199        return len(self.started) + self.external
200
201
202class StdioLink:
203    """Raw frames over the inherited stdin/stdout; the pipe closing ends us."""
204
205    def __init__(self) -> None:
206        self.control_in: BinaryIO = os.fdopen(os.dup(0), "rb", buffering=0)
207        self.control_out: BinaryIO = os.fdopen(os.dup(1), "wb", buffering=0)
208        self.lock = threading.Lock()
209        self.jobs = JobBook()
210
211    def send(self, frame: dict[str, object]) -> None:
212        data = encode(frame)
213        with self.lock:
214            self.jobs.observe(frame)
215            write_frame(self.control_out.write, data)
216
217    def serve(self, dispatch: Callable[[object], None]) -> None:
218        """Deliver frames until the pipe closes; raises when it does."""
219        while True:
220            dispatch(read_frame(self.control_in.read))
221
222    def attached(self) -> bool:
223        return True
224
225
226class SocketLink:
227    """The session layer over a unix socket in the kernel's run directory."""
228
229    def __init__(
230        self,
231        run_dir: str,
232        token: str,
233        hello: Callable[[], dict[str, object]],
234        *,
235        bundle: str,
236        grace: float,
237    ) -> None:
238        self.run_dir = run_dir
239        self.token = token
240        self.hello = hello
241        self.bundle = bundle
242        self.grace = grace
243        self.lock = threading.RLock()
244        self.outbox = Outbox()
245        self.inbound = Inbound()
246        self.jobs = JobBook()
247        self.epoch = 0
248        self.connection: socket.socket | None = None
249        self.detached_at = time.monotonic()
250        self.listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
251        # Bound relative to the run directory: sun_path holds ~104 bytes and a
252        # temporary ALBEDO_HOME alone can use most of that.
253        previous = os.getcwd()
254        os.chdir(run_dir)
255        try:
256            if os.path.exists(SOCKET):
257                os.unlink(SOCKET)
258            self.listener.bind(SOCKET)
259            os.chmod(SOCKET, 0o600)
260        finally:
261            os.chdir(previous)
262        self.listener.listen(4)
263
264    def send(self, frame: dict[str, object]) -> None:
265        with self.lock:
266            self.jobs.observe(frame)
267            seq, data = self.outbox.add(frame)
268            self._write(envelope(seq, self.inbound.last, data))
269
270    def attached(self) -> bool:
271        return self.connection is not None
272
273    def idle_for(self) -> float:
274        """Seconds with nothing attached; zero while attached."""
275        if self.connection is not None:
276            return 0.0
277        return time.monotonic() - self.detached_at
278
279    def serve(self, dispatch: Callable[[object], None]) -> None:
280        """Accept attaches forever; each connection reads on its own thread."""
281        while True:
282            connection, _ = self.listener.accept()
283            threading.Thread(
284                target=self._session, args=(connection, dispatch), daemon=True
285            ).start()
286
287    def _write(self, data: bytes) -> None:
288        connection = self.connection
289        if connection is None:
290            return
291        try:
292            write_frame(connection.send, data)
293        except OSError:
294            self._detach(connection)
295
296    def _detach(self, connection: socket.socket) -> None:
297        with self.lock:
298            if self.connection is connection:
299                self.connection = None
300                self.detached_at = time.monotonic()
301        try:
302            connection.close()
303        except OSError:
304            pass
305
306    def _attach(self, connection: socket.socket) -> bool:
307        connection.settimeout(ATTACH_TIMEOUT)
308        message = read_frame(connection.recv)
309        attach = message.get("attach") if isinstance(message, dict) else None
310        token = attach.get("token") if isinstance(attach, dict) else None
311        if not isinstance(attach, dict) or not isinstance(token, str):
312            write_frame(connection.send, encode({"refused": "expected attach"}))
313            return False
314        if not hmac.compare_digest(token.encode(), self.token.encode()):
315            write_frame(connection.send, encode({"refused": "wrong token"}))
316            return False
317        connection.settimeout(None)
318        grace = attach.get("grace")
319        ack = attach.get("ack")
320        with self.lock:
321            if isinstance(grace, int | float) and not isinstance(grace, bool):
322                self.grace = max(float(grace), 0.0)
323            previous, self.connection = self.connection, connection
324            self.epoch += 1
325            if previous is not None:
326                try:
327                    previous.shutdown(socket.SHUT_RDWR)
328                except OSError:
329                    pass
330            if isinstance(ack, int) and not isinstance(ack, bool):
331                self.outbox.ack(ack)
332            hello = {
333                **self.hello(),
334                "protocol": PROTOCOL,
335                "bundle": self.bundle,
336                "epoch": self.epoch,
337                "ack": self.inbound.last,
338                "jobs": list(self.jobs.started.values()),
339                "external": self.jobs.external,
340                "awaited_external": self.jobs.awaited_external,
341                "external_jobs": self.jobs.external_jobs,
342                "dropped": self.outbox.dropped,
343            }
344            write_frame(connection.send, encode({"hello": hello}))
345            for seq, data in self.outbox.pending():
346                write_frame(connection.send, envelope(seq, self.inbound.last, data))
347        return True
348
349    def _session(
350        self, connection: socket.socket, dispatch: Callable[[object], None]
351    ) -> None:
352        try:
353            if not self._attach(connection):
354                return
355            while True:
356                message = read_frame(connection.recv)
357                if not isinstance(message, dict):
358                    raise ValueError("expected an envelope")
359                ack = message.get("ack")
360                seq = message.get("seq")
361                with self.lock:
362                    if isinstance(ack, int) and not isinstance(ack, bool):
363                        self.outbox.ack(ack)
364                    fresh = isinstance(seq, int) and self.inbound.accept(seq)
365                if fresh:
366                    dispatch(message.get("frame"))
367                if isinstance(seq, int):
368                    with self.lock:
369                        if self.connection is connection:
370                            self._write(encode({"ack": self.inbound.last}))
371        except (OSError, EOFError, ValueError):
372            pass
373        finally:
374            self._detach(connection)