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