okami.mom/albedo

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

nekomimi.petkernel: keep output once, bound snapshots and replays, drop the outbox scanf479db5

main
7.1 KiB210 linesraw
1"""Bounded, atomic snapshots of the kernel-owned namespace and definitions."""
2
3from __future__ import annotations
4
5import os
6import pickle
7import tempfile
8import types
9from typing import Any, cast
10
11# Saved state is the session's own namespace, written by this kernel into the
12# daemon's home. Loading pickle and replaying definitions share the transcript's trust domain.
13STATE_MAX = 64 * 1024 * 1024
14STATE_MAX_VALUE = 8 * 1024 * 1024
15DEFINITION_MAX = 256 * 1024
16DEFINITION_MAX_VALUE = 32 * 1024
17
18
19def engine() -> tuple[object, str]:
20    """dill when it is installed; pickle keeps plain data working without it."""
21    try:
22        import dill
23
24        dill.settings["recurse"] = True
25        return dill, "dill"
26    except ImportError:
27        return pickle, "pickle"
28
29
30class TooLarge(Exception):
31    """The value's serialisation passed the per-variable cap."""
32
33
34class _Bounded:
35    """A write sink that stops a serialiser once it has produced more than
36    `limit` bytes, so an oversized value costs the cap, not its full size."""
37
38    def __init__(self, limit: int) -> None:
39        self.limit = limit
40        self.chunks: list[bytes] = []
41        self.size = 0
42
43    def write(self, data: bytes) -> int:
44        self.size += len(data)
45        if self.size > self.limit:
46            raise TooLarge
47        self.chunks.append(bytes(data))
48        return len(data)
49
50    def getvalue(self) -> bytes:
51        return b"".join(self.chunks)
52
53
54def dumps_within(serialiser: Any, value: object, limit: int) -> bytes:
55    """`serialiser.dumps(value)`, or TooLarge once it passes `limit` bytes."""
56    sink = _Bounded(limit)
57    serialiser.dump(value, sink)
58    return sink.getvalue()
59
60
61def save_state(
62    path: str,
63    namespace: dict[str, object],
64    definitions: dict[str, str],
65    injected: set[str],
66) -> dict[str, object]:
67    """Serialise values and readable top-level definitions within fixed caps."""
68    serialiser, kind = cast("Any", engine())
69    payload: dict[str, bytes] = {}
70    skipped: list[dict[str, str]] = []
71    total = 0
72    largest: list[tuple[str, int]] = []
73    for name in list(namespace.keys()):
74        if name.startswith("_") or name in injected:
75            continue
76        value = namespace.get(name, injected)
77        if value is injected:
78            continue
79        replayed = name in definitions and isinstance(
80            value, (types.FunctionType, type, types.ModuleType)
81        )
82        try:
83            blob = dumps_within(serialiser, value, STATE_MAX_VALUE)
84        except BaseException as error:
85            reason = (
86                f"over the {STATE_MAX_VALUE}-byte per-variable cap"
87                if isinstance(error, TooLarge)
88                else f"{type(error).__name__}: {error}"[:200]
89            )
90            if not replayed:
91                skipped.append({"name": name, "reason": reason})
92            continue
93        if total + len(blob) > STATE_MAX:
94            skipped.append({"name": name, "reason": "saved state is full"})
95        else:
96            payload[name] = blob
97            total += len(blob)
98            if len(blob) >= 64 * 1024:
99                largest.append((name, len(blob)))
100    saved_definitions: list[dict[str, str]] = []
101    definition_bytes = 0
102    for name, source in definitions.items():
103        size = len(source.encode("utf-8"))
104        if size > DEFINITION_MAX_VALUE:
105            skipped.append(
106                {
107                    "name": name,
108                    "reason": f"definition is {size} bytes; per-definition cap is {DEFINITION_MAX_VALUE}",
109                }
110            )
111        elif definition_bytes + size > DEFINITION_MAX:
112            skipped.append(
113                {"name": name, "reason": "definitions are over the total source cap"}
114            )
115        else:
116            saved_definitions.append({"name": name, "source": source})
117            definition_bytes += size
118    try:
119        os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
120        handle, temporary = tempfile.mkstemp(
121            dir=os.path.dirname(path) or ".", prefix=os.path.basename(path) + "."
122        )
123        try:
124            with os.fdopen(handle, "wb") as file:
125                pickle.dump(
126                    {
127                        "cwd": os.getcwd(),
128                        "names": payload,
129                        "definitions": saved_definitions,
130                    },
131                    file,
132                )
133            os.replace(temporary, path)
134        except BaseException:
135            os.unlink(temporary)
136            raise
137    except OSError as error:
138        return {"error": f"could not write saved state: {error}"}
139    largest.sort(key=lambda item: item[1], reverse=True)
140    return {
141        "saved": sorted(set(payload) | {item["name"] for item in saved_definitions}),
142        "defs": [item["name"] for item in saved_definitions],
143        "skipped": skipped,
144        "largest": [{"name": name, "bytes": size} for name, size in largest[:5]],
145        "bytes": total,
146        "engine": kind,
147    }
148
149
150def load_state(
151    path: str, namespace: dict[str, object], definitions: dict[str, str]
152) -> dict[str, object]:
153    """Restore pickled values, then replay definitions in their saved order."""
154    try:
155        with open(path, "rb") as file:
156            saved = cast(dict[str, object], pickle.load(file))
157    except FileNotFoundError:
158        return {"restored": [], "defs": [], "failed": [], "error": "no saved state"}
159    except BaseException as error:
160        return {
161            "restored": [],
162            "defs": [],
163            "failed": [],
164            "error": f"unreadable saved state: {type(error).__name__}",
165        }
166    serialiser, kind = cast("Any", engine())
167    restored: list[str] = []
168    failed: list[dict[str, str]] = []
169    for name, blob in cast(dict[str, bytes], saved.get("names", {})).items():
170        try:
171            namespace[name] = cast(object, serialiser.loads(blob))
172        except BaseException as error:
173            failed.append(
174                {"name": name, "reason": f"{type(error).__name__}: {error}"[:200]}
175            )
176        else:
177            restored.append(name)
178    defs: list[str] = []
179    for item in cast(list[object], saved.get("definitions", [])):
180        if (
181            not isinstance(item, dict)
182            or not isinstance(item.get("name"), str)
183            or not isinstance(item.get("source"), str)
184        ):
185            continue
186        name, source = item["name"], item["source"]
187        try:
188            exec(compile(source, "<albedo:state>", "exec"), namespace)
189        except BaseException as error:
190            failed.append(
191                {"name": name, "reason": f"{type(error).__name__}: {error}"[:200]}
192            )
193        else:
194            if source not in definitions.values():
195                key = name
196                while key in definitions:
197                    key += "#2"
198                definitions[key] = source
199            if name not in restored:
200                restored.append(name)
201            defs.append(name)
202    directory = saved.get("cwd")
203    if isinstance(directory, str) and os.path.isdir(directory):
204        os.chdir(directory)
205    return {
206        "restored": sorted(set(restored)),
207        "defs": defs,
208        "failed": failed,
209        "engine": kind,
210    }