okami.mom/albedo

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

nekomimi.petfix kernel output truncation with automatic disk-backed pagination1f92415

main
13.4 KiB347 linesraw
1"""Bounded cell output and image validation, independent of kernel lifetime."""
2
3from __future__ import annotations
4
5import base64
6import os
7import re
8import struct
9import tempfile
10import time
11from typing import BinaryIO
12
13import albedo_api
14import albedo_trace
15
16PREVIEW = albedo_api.OUTPUT_PREVIEW
17RETAIN = albedo_api.RETAIN
18MAX_IMAGES = 4
19# Base64 growth and the output preview must fit the 8 MiB result frame.
20MAX_IMAGE_BYTES = 5 * 1024 * 1024
21# Cells spill past the preview, jobs past RETAIN. Files have the same disk cap
22# and lifetime; the kernel sets the directory and prunes files past SPILL_KEEP.
23SPILL_LIMIT = 16 * 1024 * 1024
24SPILL_KEEP = 14 * 24 * 3600  # seconds, the python state expiry
25SPILL_DIR: str | None = None
26
27
28def prune_spills(directory: str) -> None:
29    """Make `directory` the spill directory, without the files past SPILL_KEEP."""
30    global SPILL_DIR
31    SPILL_DIR = directory
32    try:
33        os.makedirs(directory, exist_ok=True)
34        with os.scandir(directory) as entries:
35            stale = [
36                entry.path
37                for entry in entries
38                if entry.is_file() and time.time() - entry.stat().st_mtime > SPILL_KEEP
39            ]
40    except OSError:
41        SPILL_DIR = None
42        return
43    for path in stale:
44        try:
45            os.unlink(path)
46        except OSError:
47            pass
48
49
50class Capture:
51    """Output as the bytes that were written: the first RETAIN of them in
52    `data`, and once more than that has arrived, the last PREVIEW in a tail
53    buffer that exists only from then on. A pipe reader gets the exact bytes
54    back; text readers decode a window."""
55
56    def __init__(
57        self, id: str, kind: str = "cell", max_edge: int | None = None
58    ) -> None:
59        self.id: str = id
60        self.kind: str = kind
61        self.max_edge: int | None = max_edge  # the session provider's, sent per cell
62        self.data: bytearray = bytearray()
63        self._tail: bytearray | None = None
64        self.seen: int = 0
65        self.trace: albedo_trace.Trace = albedo_trace.Trace()
66        self.interruption: str = "cancelled"
67        self.images: list[bytes] = []
68        # The file holding the whole output once it passed the preview, and
69        # how much of it is there; `spill` stays set after the file closes.
70        self.spill: str | None = None
71        self.spilled: int = 0
72        self._spill_file: BinaryIO | None = None
73
74    def spills(self) -> bool:
75        return self.kind in {"cell", "job"} and SPILL_DIR is not None
76
77    def write(self, text: str) -> None:
78        room = RETAIN - len(self.data)
79        if text.isascii() and len(text) > room + PREVIEW and not self.spills():
80            # One byte per character: keep both ends without encoding the middle.
81            self.write_bytes(text[:room].encode())
82            self.seen += len(text) - room - PREVIEW
83            self.write_bytes(text[-PREVIEW:].encode())
84            return
85        self.write_bytes(text.encode("utf-8", errors="replace"))
86
87    def write_bytes(self, data: bytes | bytearray | memoryview) -> None:
88        size = len(data)
89        self.seen += size
90        room = RETAIN - len(self.data)
91        if size <= room:
92            self.data += data
93        else:
94            if self._tail is None:
95                # First overflow: `data` holds the whole stream so far, and from
96                # now on never grows again, so drop its growth slack.
97                self._tail = self.data[-PREVIEW:]
98                self.data += data[:room]
99                self.data = self.data[:]
100            if size >= PREVIEW:
101                self._tail = bytearray(data[-PREVIEW:])
102            else:
103                self._tail += data
104                del self._tail[:-PREVIEW]
105        threshold = PREVIEW if self.kind == "cell" else RETAIN
106        if self.seen > threshold and self.spills():
107            self._spill(data, size)
108
109    def _spill(self, data: bytes | bytearray | memoryview, size: int) -> None:
110        """Append `data` to the spill file, opening it with everything before
111        it on the first write past the spill threshold. Unbuffered, so a backgrounded
112        cell's file reads live. A file that will not open leaves `spill` unset."""
113        if self._spill_file is None:
114            if self.spill is not None or self.spilled or SPILL_DIR is None:
115                return  # closed, full, refused, or failed
116            try:
117                fd, path = tempfile.mkstemp(
118                    prefix=re.sub(r"[^\w.-]", "_", self.id) + "-",
119                    suffix=".txt",
120                    dir=SPILL_DIR,
121                )
122                self._spill_file = os.fdopen(fd, "wb", buffering=0)
123                self.spill = path
124                # Before this write the stream fit in `data` whole.
125                prefix = self.data[: self.seen - size]
126                self.spilled = self._spill_file.write(prefix)
127                if self.spilled != len(prefix):
128                    raise OSError("incomplete output spill write")
129            except OSError:
130                self.end_spill()
131                self.spilled = -1  # do not retry after losing bytes
132                self.spill = None
133                return
134        try:
135            piece = data[: SPILL_LIMIT - self.spilled]
136            written = self._spill_file.write(piece)
137            self.spilled += written
138            if written != len(piece):
139                raise OSError("incomplete output spill write")
140        except OSError:
141            self.end_spill()
142            self.spill = None  # a file missing its end is not offered
143            self.spilled = -1
144            return
145        if self.spilled >= SPILL_LIMIT:
146            self.end_spill()
147
148    def end_spill(self) -> None:
149        if self._spill_file is not None:
150            self._spill_file.close()
151            self._spill_file = None
152
153    def spill_note(self) -> str:
154        """What the result says about output past the preview, or nothing."""
155        if self.spill is None:
156            return ""
157        kept = (
158            f"its first {SPILL_LIMIT >> 20} MiB are"
159            if self.spilled >= SPILL_LIMIT
160            else "all of it is"
161        )
162        return (
163            f"\n[output was {self.seen} bytes; {kept} in {self.spill}:"
164            f" files.read(path, start_line=...) reads it, await files.find(pattern, path) searches it]\n"
165        )
166
167    def tail(self, limit: int = PREVIEW) -> bytes:
168        """The last `limit` bytes written, at most PREVIEW."""
169        limit = min(limit, PREVIEW)
170        if limit <= 0:
171            return b""
172        source = self.data if self._tail is None else self._tail
173        return bytes(source[-limit:])
174
175    @property
176    def retained(self) -> int:
177        return max(len(self.data), self.spilled if self.spill else 0)
178
179    def read_bytes(self, offset: int, limit: int) -> bytes:
180        if limit <= 0 or offset >= self.seen:
181            return b""
182        end = min(offset + limit, self.seen)
183        if end <= len(self.data):
184            return bytes(self.data[offset:end])
185        if self.spill is None or end > self.spilled:
186            raise ValueError("requested output was not retained")
187        with open(self.spill, "rb") as file:
188            file.seek(offset)
189            data = file.read(end - offset)
190        if len(data) != end - offset:
191            raise OSError(f"output spill file is incomplete: {self.spill}")
192        return data
193
194    def read(self, offset: int = 0, limit: int = 4000) -> str:
195        return self.read_bytes(max(0, offset), min(max(0, limit), PREVIEW)).decode(
196            "utf-8", errors="ignore"
197        )
198
199    def preview(self, status: str = "ok") -> str:
200        if status == "ok":
201            return self.read(0, PREVIEW)
202        return self.tail().decode("utf-8", errors="ignore")
203
204    def attach(self, data: bytes) -> str:
205        """Queue one image for this cell's result; raises ValueError past the limits."""
206        mime = image_type(data)
207        if mime is None:
208            raise ValueError("image must be PNG, JPEG, or WebP bytes")
209        found = image_header(data)
210        # A header this parser cannot read is left for the daemon to judge.
211        if found is not None:
212            _, width, height, _ = found
213            if not image_complete(data, found):
214                raise ValueError(
215                    f"this {len(data)}-byte {mime} stops before its {IMAGE_ENDS[mime]}: "
216                    "the data was cut short, so read the whole file before showing it"
217                )
218            if self.max_edge is not None and max(width, height) > self.max_edge:
219                raise ValueError(
220                    f"{width}x{height} image is over this model's {self.max_edge}px edge limit"
221                )
222        if len(self.images) >= MAX_IMAGES:
223            raise ValueError(f"a cell returns at most {MAX_IMAGES} images")
224        if sum(map(len, self.images)) + len(data) > MAX_IMAGE_BYTES:
225            raise ValueError(
226                f"a cell's images total at most {MAX_IMAGE_BYTES} bytes; "
227                f"this {len(data)}-byte image does not fit"
228            )
229        self.images.append(data)
230        return f"{mime}, {len(data)} bytes"
231
232    def encoded_images(self) -> list[str]:
233        return [base64.b64encode(image).decode("ascii") for image in self.images]
234
235
236def image_size(data: bytes) -> tuple[str, int, int] | None:
237    """MIME type, width and height of a whole image, or None. A port of
238    `inspect` in image.gleam, which stays the authority: the daemon checks
239    every image again, and image_header_parity_test holds the two equal."""
240    found = image_header(data)
241    if found is None or not image_complete(data, found):
242        return None
243    return found[:3]
244
245
246def image_header(data: bytes) -> tuple[str, int, int, int] | None:
247    """MIME type, width, height, and where image_complete looks for the end."""
248    if data[:16] == b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR" and len(data) >= 24:
249        width, height = struct.unpack(">II", data[16:24])
250        return ("image/png", width, height, 8) if width > 0 and height > 0 else None
251    if data[:2] == b"\xff\xd8":
252        return jpeg_size(data, 2)
253    if data[:4] == b"RIFF" and data[8:12] == b"WEBP" and len(data) >= 12:
254        if struct.unpack("<I", data[4:8])[0] + 8 == len(data):
255            size = webp_size(data, 12)
256            return None if size is None else (*size, len(data))
257    return None
258
259
260IMAGE_ENDS = {"image/png": "IEND chunk", "image/jpeg": "end-of-image marker"}
261
262
263def image_complete(data: bytes, header: tuple[str, int, int, int]) -> bool:
264    """A PNG's chunks run whole up to IEND; a JPEG has its end-of-image marker
265    after the frame header (stuffed entropy data never forges one). WebP's
266    RIFF size already covers the whole file."""
267    mime, _, _, at = header
268    if mime == "image/png":
269        while at + 12 <= len(data):
270            size, kind = struct.unpack(">I4s", data[at : at + 8])
271            if size == 0 and kind == b"IEND":
272                return True
273            at += 12 + size
274        return False
275    if mime == "image/jpeg":
276        return data.find(b"\xff\xd9", at) >= 0
277    return True
278
279
280JPEG_FRAMES = {0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7}
281JPEG_FRAMES |= {0xC9, 0xCA, 0xCB, 0xCD, 0xCE, 0xCF}
282
283
284def jpeg_size(data: bytes, at: int) -> tuple[str, int, int, int] | None:
285    while True:
286        at = data.find(b"\xff", at)
287        if at < 0:
288            return None
289        at += 1
290        while at < len(data) and data[at] == 0xFF:
291            at += 1
292        if at >= len(data):
293            return None
294        marker = data[at]
295        if marker in (0xD8, 0x01) or 0xD0 <= marker <= 0xD7:
296            at += 1
297            continue
298        if at + 3 > len(data):
299            return None
300        length = struct.unpack(">H", data[at + 1 : at + 3])[0]
301        end = at + 1 + length
302        if length < 2 or end > len(data):
303            return None
304        if marker in JPEG_FRAMES:
305            frame = data[at + 3 : end]
306            if len(frame) < 5:
307                return None
308            height, width = struct.unpack(">HH", frame[1:5])
309            if width > 0 and height > 0:
310                return ("image/jpeg", width, height, end)
311            return None
312        if marker in (0xDA, 0xD9):
313            return None
314        at = end
315
316
317def webp_size(data: bytes, at: int) -> tuple[str, int, int] | None:
318    while at + 8 <= len(data):
319        kind = data[at : at + 4]
320        size = struct.unpack("<I", data[at + 4 : at + 8])[0]
321        body = data[at + 8 :]
322        if kind == b"VP8X" and size == 10 and len(body) >= 10:
323            width = int.from_bytes(body[4:7], "little") + 1
324            height = int.from_bytes(body[7:10], "little") + 1
325            return ("image/webp", width, height)
326        if kind == b"VP8L" and size >= 5 and len(body) >= 5 and body[0] == 0x2F:
327            bits = struct.unpack("<I", body[1:5])[0]
328            return ("image/webp", (bits & 0x3FFF) + 1, ((bits >> 14) & 0x3FFF) + 1)
329        if kind == b"VP8 " and size >= 10 and len(body) >= 10:
330            if body[3:6] == b"\x9d\x01\x2a":
331                width, height = struct.unpack("<HH", body[6:10])
332                width, height = width & 0x3FFF, height & 0x3FFF
333                return ("image/webp", width, height) if width and height else None
334        at += 8 + size + size % 2
335        if at > len(data):
336            return None
337    return None
338
339
340def image_type(data: bytes) -> str | None:
341    if data.startswith(b"\x89PNG\r\n\x1a\n"):
342        return "image/png"
343    if data.startswith(b"\xff\xd8"):
344        return "image/jpeg"
345    if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
346        return "image/webp"
347    return None