pi-repl-py 0.7.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bridge.py +495 -0
- package/docs/ARCHITECTURE.md +71 -51
- package/index.ts +56 -44
- package/package.json +2 -1
- package/scripts/setup-venv.mjs +22 -9
- package/src/engine/index.ts +55 -136
- package/src/engine/kernel.ts +272 -496
- package/src/extension/prompt.ts +12 -17
- package/src/extension/session-engine.ts +1 -3
- package/src/engine/session.ts +0 -146
- package/src/engine/zmtp.ts +0 -239
- package/src/extension/tool-meta.ts +0 -8
package/bridge.py
ADDED
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""pi-repl bridge: owns ipykernel and the Jupyter protocol; talks to the TS host over one stdio pipe.
|
|
3
|
+
|
|
4
|
+
Protocol (JSON lines; host -> bridge on stdin, bridge -> host on stdout):
|
|
5
|
+
in: {"op":"boot","helpers":[{"name":str,"source":str}]} first message, before anything
|
|
6
|
+
{"op":"exec","id":str,"code":str} run a cell (streams + one result)
|
|
7
|
+
{"op":"snapshot","id":str,"path":str,"max_bytes":int} snapshot the namespace to path
|
|
8
|
+
{"op":"restore","id":str,"path":str} revive path into the namespace
|
|
9
|
+
{"op":"listNames","id":str} current public names
|
|
10
|
+
{"op":"interrupt"} KeyboardInterrupt in the kernel
|
|
11
|
+
{"op":"shutdown","id":str} graceful teardown, then exit 0
|
|
12
|
+
out: {"type":"ready","helpers":[{"name":str,"ok":bool,"error":str?}]} kernel up, helpers loaded
|
|
13
|
+
{"type":"stream","id":str,"name":"stdout"|"stderr","text":str} cell output, streamed
|
|
14
|
+
{"type":"result","id":str,"status":"ok"|"error"|"aborted", cell settled
|
|
15
|
+
"result":str?,"error":{"name":str,"message":str,"stack":[str]}?}
|
|
16
|
+
{"type":"reply","id":str,"..."} snapshot/restore/names/shutdown
|
|
17
|
+
{"type":"error","id":str?,"message":str} protocol-level failure
|
|
18
|
+
{"type":"dying","message":str?} best-effort before exit(1)
|
|
19
|
+
|
|
20
|
+
The bridge is single-threaded over the kernel (one op at a time); the host serializes ops anyway.
|
|
21
|
+
A stdin reader thread only relays lines; all kernel I/O stays on the main thread. When the kernel
|
|
22
|
+
dies (os._exit, SIGKILL, crash) the bridge exits: the host treats bridge exit as kernel death and
|
|
23
|
+
rebuilds from the last snapshot. stdin EOF (host gone) shuts the kernel down cleanly.
|
|
24
|
+
"""
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import queue
|
|
28
|
+
import sys
|
|
29
|
+
import threading
|
|
30
|
+
import time
|
|
31
|
+
|
|
32
|
+
from jupyter_client import KernelManager
|
|
33
|
+
from jupyter_client.kernelspec import NoSuchKernel
|
|
34
|
+
|
|
35
|
+
SNAPSHOT_MIME = "application/vnd.pi-repl.snapshot+json"
|
|
36
|
+
RESTORE_MIME = "application/vnd.pi-repl.restore+json"
|
|
37
|
+
NAMES_MIME = "application/vnd.pi-repl.names+json"
|
|
38
|
+
READY_TIMEOUT_MS = 30_000
|
|
39
|
+
|
|
40
|
+
# --- kernel-side programs. These run inside ipykernel, so they stay strings (now plain Python). ---
|
|
41
|
+
|
|
42
|
+
def skip_set_literal(helper_names):
|
|
43
|
+
skip = list(helper_names) + ["helper_description", "In", "Out", "get_ipython", "exit", "quit", "open"]
|
|
44
|
+
return json.dumps(skip)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def snapshot_code(helper_names, max_bytes):
|
|
48
|
+
# v4: cloudpickle serializes functions and classes by value — no source-capture machinery.
|
|
49
|
+
# Entries are zlib-compressed cloudpickle streams; one unpicklable binding costs itself only.
|
|
50
|
+
return ("import cloudpickle as _cp, base64 as _b64, json as _js, zlib as _zl\n"
|
|
51
|
+
+ "__repl_skip = set(" + skip_set_literal(helper_names) + ")\n"
|
|
52
|
+
+ "__repl_max = " + str(max_bytes) + "\n"
|
|
53
|
+
+ """__repl_e = []
|
|
54
|
+
__repl_f = []
|
|
55
|
+
__repl_total = 0
|
|
56
|
+
for _k, _v in list(globals().items()):
|
|
57
|
+
if _k.startswith('_') or _k in __repl_skip:
|
|
58
|
+
continue
|
|
59
|
+
try:
|
|
60
|
+
__repl_p = _b64.b64encode(_zl.compress(_cp.dumps(_v), 1)).decode()
|
|
61
|
+
__repl_b = len(__repl_p)
|
|
62
|
+
if __repl_b > __repl_max:
|
|
63
|
+
__repl_f.append({'name': _k, 'reason': 'exceeds per-entry snapshot cap'})
|
|
64
|
+
elif __repl_total + __repl_b > __repl_max:
|
|
65
|
+
__repl_f.append({'name': _k, 'reason': 'exceeds total snapshot cap'})
|
|
66
|
+
else:
|
|
67
|
+
__repl_e.append({'name': _k, 'kind': 'value', 'payload': __repl_p})
|
|
68
|
+
__repl_total += __repl_b
|
|
69
|
+
except Exception as _e:
|
|
70
|
+
__repl_f.append({'name': _k, 'reason': str(_e)})
|
|
71
|
+
"""
|
|
72
|
+
+ "get_ipython().display_pub.publish({" + json.dumps(SNAPSHOT_MIME) + ": _js.dumps({'version': 4, 'entries': __repl_e, 'failed': __repl_f})})\n")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _restore_body(name, kind, payload, version):
|
|
76
|
+
n = json.dumps(name)
|
|
77
|
+
pl = json.dumps(payload)
|
|
78
|
+
if version >= 4:
|
|
79
|
+
body = "globals()[" + n + "] = _cp.loads(_zl.decompress(_b64.b64decode(" + pl + ")))"
|
|
80
|
+
elif version == 3 and kind == "def":
|
|
81
|
+
# re-execute captured source and register it in linecache under the code
|
|
82
|
+
# so a later snapshot can capture it again
|
|
83
|
+
body = (
|
|
84
|
+
"__repl_src = _b64.b64decode(" + pl + ").decode()\n"
|
|
85
|
+
+ " exec(__repl_src, globals())\n"
|
|
86
|
+
+ " __repl_obj = globals().get(" + n + ")\n"
|
|
87
|
+
+ " if __repl_obj is not None:\n"
|
|
88
|
+
+ " __repl_fname = getattr(getattr(__repl_obj, '__code__', None), 'co_filename', None)\n"
|
|
89
|
+
+ " if __repl_fname is None:\n"
|
|
90
|
+
+ " __repl_init = getattr(__repl_obj, '__init__', None)\n"
|
|
91
|
+
+ " __repl_fname = getattr(getattr(__repl_init, '__code__', None), 'co_filename', None)\n"
|
|
92
|
+
+ " if __repl_fname:\n"
|
|
93
|
+
+ " _lc.cache[__repl_fname] = (len(__repl_src.splitlines()), None, __repl_src.splitlines(True), __repl_fname)"
|
|
94
|
+
)
|
|
95
|
+
else:
|
|
96
|
+
inner = "_b64.b64decode(" + pl + ")"
|
|
97
|
+
if version == 3:
|
|
98
|
+
inner = "_zl.decompress(" + inner + ")"
|
|
99
|
+
body = "globals()[" + n + "] = _pk.loads(" + inner + ")"
|
|
100
|
+
return ("try:\n " + body + "\n __repl_r['restored'].append(" + n + ")\n"
|
|
101
|
+
+ "except Exception as _e:\n __repl_r['failed'].append({'name': " + n + ", 'reason': str(_e)})\n")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def restore_code(entries, version):
|
|
105
|
+
if version >= 4:
|
|
106
|
+
head = "import cloudpickle as _cp, base64 as _b64, json as _js, zlib as _zl\n"
|
|
107
|
+
elif version == 3:
|
|
108
|
+
head = "import pickle as _pk, base64 as _b64, json as _js, zlib as _zl, linecache as _lc\n"
|
|
109
|
+
else:
|
|
110
|
+
head = "import pickle as _pk, base64 as _b64, json as _js\n"
|
|
111
|
+
per = "".join(_restore_body(e["name"], e.get("kind", "value"), e["payload"], version) for e in entries)
|
|
112
|
+
return (head
|
|
113
|
+
+ "__repl_r = {'restored': [], 'failed': []}\n"
|
|
114
|
+
+ per
|
|
115
|
+
+ "get_ipython().display_pub.publish({" + json.dumps(RESTORE_MIME) + ": _js.dumps(__repl_r)})\n")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def names_code(helper_names):
|
|
119
|
+
return ("import json as _js\n"
|
|
120
|
+
+ "__repl_skip = set(" + skip_set_literal(helper_names) + ")\n"
|
|
121
|
+
+ "__repl_n = sorted(n for n in globals() if not n.startswith('_') and n not in __repl_skip)\n"
|
|
122
|
+
+ "get_ipython().display_pub.publish({" + json.dumps(NAMES_MIME) + ": _js.dumps(__repl_n)})\n")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class Bridge:
|
|
126
|
+
def __init__(self):
|
|
127
|
+
self.km = None
|
|
128
|
+
self.kc = None
|
|
129
|
+
self.helper_names = []
|
|
130
|
+
self.requests = queue.Queue()
|
|
131
|
+
self.op_id = None # id of the exec currently streaming, for stream routing
|
|
132
|
+
self.op_payload_mime = None # private MIME awaited for the current internal cell
|
|
133
|
+
# snapshot policy + name-diff gate: the bridge schedules snapshots itself in quiet gaps,
|
|
134
|
+
# so a pickle can never land in front of a user cell (the bridge IS the queue)
|
|
135
|
+
self.policy = None # {"path": str, "max_bytes": int, "period_ms": int} | None
|
|
136
|
+
self.last_names = None # names at the last persisted write; None = never written
|
|
137
|
+
self.last_persisted_at = 0.0
|
|
138
|
+
self.last_bytes = 0
|
|
139
|
+
self.pending_check = False # a cell just completed; the gate owes a name diff
|
|
140
|
+
self.deferred_restore = None # background revive: runs at the first quiet gap
|
|
141
|
+
|
|
142
|
+
# ----- IO -----
|
|
143
|
+
|
|
144
|
+
def emit(self, msg):
|
|
145
|
+
sys.stdout.write(json.dumps(msg) + "\n")
|
|
146
|
+
sys.stdout.flush()
|
|
147
|
+
|
|
148
|
+
def _stdin_reader(self):
|
|
149
|
+
# BufferedReader.read(n) blocks until n bytes arrive; line iteration returns per line
|
|
150
|
+
for line in sys.stdin:
|
|
151
|
+
line = line.strip()
|
|
152
|
+
if not line:
|
|
153
|
+
continue
|
|
154
|
+
try:
|
|
155
|
+
self.requests.put(json.loads(line))
|
|
156
|
+
except Exception as e:
|
|
157
|
+
self.emit({"type": "error", "message": "bad request line: %s" % e})
|
|
158
|
+
self.requests.put({"op": "shutdown", "id": "bad-line"})
|
|
159
|
+
self.requests.put({"op": "__eof__"})
|
|
160
|
+
|
|
161
|
+
# ----- kernel lifecycle -----
|
|
162
|
+
|
|
163
|
+
def start_kernel(self):
|
|
164
|
+
km = KernelManager(kernel_name="python3")
|
|
165
|
+
try:
|
|
166
|
+
km.start_kernel()
|
|
167
|
+
except NoSuchKernel:
|
|
168
|
+
km = KernelManager(kernel_name="python3", kernel_cmd=[
|
|
169
|
+
sys.executable, "-m", "ipykernel_launcher", "-f", "{connection_file}"])
|
|
170
|
+
km.start_kernel()
|
|
171
|
+
kc = km.client()
|
|
172
|
+
kc.start_channels()
|
|
173
|
+
try:
|
|
174
|
+
kc.wait_for_ready(timeout=READY_TIMEOUT_MS / 1000)
|
|
175
|
+
except Exception:
|
|
176
|
+
km.shutdown_kernel(now=True)
|
|
177
|
+
raise
|
|
178
|
+
self.km, self.kc = km, kc
|
|
179
|
+
|
|
180
|
+
def stop_kernel(self):
|
|
181
|
+
try:
|
|
182
|
+
self.kc.stop_channels()
|
|
183
|
+
except Exception:
|
|
184
|
+
pass
|
|
185
|
+
try:
|
|
186
|
+
self.km.shutdown_kernel(now=True)
|
|
187
|
+
except Exception:
|
|
188
|
+
pass
|
|
189
|
+
|
|
190
|
+
# ----- cells -----
|
|
191
|
+
|
|
192
|
+
def run_cell(self, code, emit_stream, payload_mime=None, emit_id=None):
|
|
193
|
+
"""Run one cell; settle on shell reply AND iopub idle (a reply alone can beat big output).
|
|
194
|
+
Returns {status, result?, error?, payload?}. Raises when the kernel died."""
|
|
195
|
+
kc = self.kc
|
|
196
|
+
msg_id = kc.execute(code, silent=False, store_history=False,
|
|
197
|
+
user_expressions={}, allow_stdin=False, stop_on_error=True)
|
|
198
|
+
self.op_id = msg_id
|
|
199
|
+
self.op_payload_mime = payload_mime
|
|
200
|
+
result = None
|
|
201
|
+
error = None
|
|
202
|
+
payload = None
|
|
203
|
+
quiet_for = 0.0
|
|
204
|
+
try:
|
|
205
|
+
while True:
|
|
206
|
+
# pending interrupts/shutdowns act within one poll cadence
|
|
207
|
+
self._drain_control_queue()
|
|
208
|
+
try:
|
|
209
|
+
msg = kc.get_iopub_msg(timeout=0.25)
|
|
210
|
+
except Exception:
|
|
211
|
+
# timeout: poll the heartbeat instead of timing out the whole bridge
|
|
212
|
+
quiet_for += 0.25
|
|
213
|
+
if quiet_for >= 2.0:
|
|
214
|
+
if not self.kernel_alive():
|
|
215
|
+
self.emit({"type": "dying", "message": "kernel heartbeat lost"})
|
|
216
|
+
sys.exit(1)
|
|
217
|
+
quiet_for = 0.0
|
|
218
|
+
continue
|
|
219
|
+
if msg.get("parent_header", {}).get("msg_id") != msg_id:
|
|
220
|
+
continue
|
|
221
|
+
quiet_for = 0.0
|
|
222
|
+
ctype = msg["msg_type"]
|
|
223
|
+
content = msg["content"]
|
|
224
|
+
if ctype == "stream":
|
|
225
|
+
if emit_stream:
|
|
226
|
+
self.emit({"type": "stream", "id": emit_id or msg_id, "name": "stderr" if content.get("name") == "stderr" else "stdout", "text": content.get("text") or ""})
|
|
227
|
+
elif ctype == "execute_result":
|
|
228
|
+
data = content.get("data") or {}
|
|
229
|
+
if "text/plain" in data:
|
|
230
|
+
result = data["text/plain"]
|
|
231
|
+
payload = self._take_payload(content)
|
|
232
|
+
elif ctype == "display_data":
|
|
233
|
+
payload = self._take_payload(content)
|
|
234
|
+
elif ctype == "error":
|
|
235
|
+
error = {
|
|
236
|
+
"name": content.get("ename") or "Error",
|
|
237
|
+
"message": content.get("evalue") or "",
|
|
238
|
+
"stack": content.get("traceback") or [],
|
|
239
|
+
}
|
|
240
|
+
elif ctype == "status" and content.get("execution_state") == "idle":
|
|
241
|
+
break
|
|
242
|
+
except Exception as e:
|
|
243
|
+
if not self.kernel_alive():
|
|
244
|
+
self.emit({"type": "dying", "message": "kernel died mid-cell"})
|
|
245
|
+
sys.exit(1)
|
|
246
|
+
raise
|
|
247
|
+
status = "ok"
|
|
248
|
+
reply = None
|
|
249
|
+
for _ in range(20):
|
|
250
|
+
try:
|
|
251
|
+
candidate = kc.get_shell_msg(timeout=10)
|
|
252
|
+
except Exception:
|
|
253
|
+
break
|
|
254
|
+
if candidate.get("parent_header", {}).get("msg_id") == msg_id:
|
|
255
|
+
reply = candidate
|
|
256
|
+
break
|
|
257
|
+
# stale reply from an earlier op (a late kernel_info, or an "incomplete
|
|
258
|
+
# input" error flushed by this request): drain it, keep looking
|
|
259
|
+
if reply:
|
|
260
|
+
status = reply.get("content", {}).get("status", "ok")
|
|
261
|
+
if status != "aborted" and error is not None:
|
|
262
|
+
# iopub error wins over an "ok" reply (ipykernel: compile-phase failures reply ok)
|
|
263
|
+
status = "error"
|
|
264
|
+
if status == "error" and error is None:
|
|
265
|
+
c = reply.get("content", {})
|
|
266
|
+
error = {"name": c.get("ename") or "Error", "message": c.get("evalue") or "cell failed", "stack": []}
|
|
267
|
+
if status == "aborted" and error is None:
|
|
268
|
+
error = {"name": "KeyboardInterrupt", "message": "cell interrupted", "stack": []}
|
|
269
|
+
self.op_id = None
|
|
270
|
+
self.op_payload_mime = None
|
|
271
|
+
return {"status": status, "result": result, "error": error, "payload": payload}
|
|
272
|
+
|
|
273
|
+
def _take_payload(self, content):
|
|
274
|
+
data = content.get("data") or {}
|
|
275
|
+
mime = self.op_payload_mime
|
|
276
|
+
if mime is not None and mime in data:
|
|
277
|
+
return data[mime]
|
|
278
|
+
return None
|
|
279
|
+
|
|
280
|
+
def _drain_control_queue(self):
|
|
281
|
+
# control-plane ops may jump the queue mid-cell; anything else is left in order
|
|
282
|
+
while True:
|
|
283
|
+
try:
|
|
284
|
+
op = self.requests.get_nowait()
|
|
285
|
+
except queue.Empty:
|
|
286
|
+
break
|
|
287
|
+
if op["op"] == "interrupt":
|
|
288
|
+
self.interrupt()
|
|
289
|
+
elif op["op"] in ("shutdown", "__eof__"):
|
|
290
|
+
self.stop_kernel()
|
|
291
|
+
sys.exit(0)
|
|
292
|
+
else:
|
|
293
|
+
self.requests.put(op)
|
|
294
|
+
break
|
|
295
|
+
|
|
296
|
+
def interrupt(self):
|
|
297
|
+
# BlockingKernelClient has no interrupt() in jupyter_client 8.x; the old host sent
|
|
298
|
+
# interrupt_request on the control channel directly — do the same
|
|
299
|
+
msg = self.kc.session.msg("interrupt_request", content={})
|
|
300
|
+
self.kc.control_channel.send(msg)
|
|
301
|
+
|
|
302
|
+
def kernel_alive(self):
|
|
303
|
+
try:
|
|
304
|
+
return self.km.is_alive()
|
|
305
|
+
except Exception:
|
|
306
|
+
return False
|
|
307
|
+
|
|
308
|
+
# ----- ops -----
|
|
309
|
+
|
|
310
|
+
FORCED_SNAPSHOT_MAX_BYTES = 8 * 1024 * 1024
|
|
311
|
+
QUIET_WINDOW_S = 0.15
|
|
312
|
+
|
|
313
|
+
def _names(self):
|
|
314
|
+
res = self.run_cell(names_code(self.helper_names), emit_stream=False, payload_mime=NAMES_MIME)
|
|
315
|
+
return json.loads(res["payload"]) if res["payload"] else []
|
|
316
|
+
|
|
317
|
+
def _snapshot_due(self, names):
|
|
318
|
+
changed = self.last_names is None or names != self.last_names
|
|
319
|
+
period = (self.policy or {}).get("period_ms") or 0
|
|
320
|
+
stale = (period > 0 and self.last_persisted_at > 0
|
|
321
|
+
and (time.time() - self.last_persisted_at) * 1000 >= period
|
|
322
|
+
and self.last_bytes <= self.FORCED_SNAPSHOT_MAX_BYTES)
|
|
323
|
+
return changed or stale
|
|
324
|
+
|
|
325
|
+
def quiet_seconds(self):
|
|
326
|
+
"""How long the serve loop may block waiting for ops before the gap counts as quiet.
|
|
327
|
+
None = nothing is due; block indefinitely."""
|
|
328
|
+
if self.deferred_restore is not None:
|
|
329
|
+
return self.QUIET_WINDOW_S
|
|
330
|
+
if self.pending_check and self.policy:
|
|
331
|
+
return self.QUIET_WINDOW_S
|
|
332
|
+
return None
|
|
333
|
+
|
|
334
|
+
def quiet_tick(self):
|
|
335
|
+
"""The queue stayed empty for the quiet window: run the deferred revive, then the
|
|
336
|
+
owed snapshot. The gate advances only on a persisted write, so a failed write
|
|
337
|
+
retries at the next gap."""
|
|
338
|
+
if self.deferred_restore is not None:
|
|
339
|
+
op, self.deferred_restore = self.deferred_restore, None
|
|
340
|
+
self.op_restore(op)
|
|
341
|
+
return
|
|
342
|
+
if not (self.pending_check and self.policy):
|
|
343
|
+
return
|
|
344
|
+
self.pending_check = False
|
|
345
|
+
names = self._names()
|
|
346
|
+
if not self._snapshot_due(names):
|
|
347
|
+
return
|
|
348
|
+
self._write_snapshot(self.policy["path"], self.policy["max_bytes"], names)
|
|
349
|
+
|
|
350
|
+
def op_boot(self, boot):
|
|
351
|
+
self.helper_names = [h["name"] for h in boot.get("helpers") or []]
|
|
352
|
+
self.policy = boot.get("snapshot")
|
|
353
|
+
self.start_kernel()
|
|
354
|
+
report = []
|
|
355
|
+
for h in boot.get("helpers") or []:
|
|
356
|
+
res = self.run_cell(h["source"], emit_stream=False)
|
|
357
|
+
if res["status"] == "ok":
|
|
358
|
+
report.append({"name": h["name"], "ok": True})
|
|
359
|
+
else:
|
|
360
|
+
err = res["error"] or {}
|
|
361
|
+
report.append({"name": h["name"], "ok": False,
|
|
362
|
+
"error": (err.get("name") + ": " + err.get("message", "")).strip() or "failed to load"})
|
|
363
|
+
self.emit({"type": "ready", "helpers": report})
|
|
364
|
+
|
|
365
|
+
def op_exec(self, op):
|
|
366
|
+
res = self.run_cell(op["code"], emit_stream=True, emit_id=op["id"])
|
|
367
|
+
out = {"type": "result", "id": op["id"], "status": res["status"]}
|
|
368
|
+
if res["result"] is not None:
|
|
369
|
+
out["result"] = res["result"]
|
|
370
|
+
if res["error"] is not None:
|
|
371
|
+
out["error"] = res["error"]
|
|
372
|
+
self.emit(out)
|
|
373
|
+
|
|
374
|
+
def _write_snapshot(self, path, max_bytes, names=None):
|
|
375
|
+
import cloudpickle # noqa: F401 — fail loudly here, not at bridge boot
|
|
376
|
+
res = self.run_cell(snapshot_code(self.helper_names, max_bytes), emit_stream=False,
|
|
377
|
+
payload_mime=SNAPSHOT_MIME)
|
|
378
|
+
payload = res["payload"]
|
|
379
|
+
if payload is None:
|
|
380
|
+
return {"saved": [], "failed": [], "complete": False, "bytes": 0}
|
|
381
|
+
body = json.loads(payload)
|
|
382
|
+
entries, failed = body.get("entries", []), body.get("failed", [])
|
|
383
|
+
# atomic write: temp + rename, so a crash can't corrupt the last good snapshot
|
|
384
|
+
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
|
385
|
+
tmp = path + ".tmp"
|
|
386
|
+
with open(tmp, "w", encoding="utf-8") as fh:
|
|
387
|
+
json.dump({"version": 4, "entries": entries, "failed": failed}, fh)
|
|
388
|
+
os.replace(tmp, path)
|
|
389
|
+
# only a persisted write advances the gate
|
|
390
|
+
self.last_names = names if names is not None else [e["name"] for e in entries]
|
|
391
|
+
self.last_persisted_at = time.time()
|
|
392
|
+
self.last_bytes = sum(len(e["payload"]) for e in entries)
|
|
393
|
+
return {"saved": [e["name"] for e in entries], "failed": failed,
|
|
394
|
+
"complete": True, "bytes": self.last_bytes}
|
|
395
|
+
|
|
396
|
+
def op_snapshot(self, op):
|
|
397
|
+
try:
|
|
398
|
+
import cloudpickle # noqa: F401 — fail loudly here, not at bridge boot
|
|
399
|
+
except ImportError as e:
|
|
400
|
+
self.emit({"type": "reply", "id": op["id"], "error":
|
|
401
|
+
"cloudpickle is missing from the evaluator venv: %s" % e})
|
|
402
|
+
return
|
|
403
|
+
out = self._write_snapshot(op["path"], op["max_bytes"])
|
|
404
|
+
self.emit({"type": "reply", "id": op["id"], **out})
|
|
405
|
+
|
|
406
|
+
def op_restore(self, op):
|
|
407
|
+
try:
|
|
408
|
+
with open(op["path"], encoding="utf-8") as fh:
|
|
409
|
+
body = json.load(fh)
|
|
410
|
+
except Exception as e:
|
|
411
|
+
self.emit({"type": "reply", "id": op["id"], "error": "snapshot unreadable: %s" % e})
|
|
412
|
+
return
|
|
413
|
+
version = body.get("version")
|
|
414
|
+
if version is not None and version >= 2:
|
|
415
|
+
entries = body.get("entries") or []
|
|
416
|
+
failed_at_save = body.get("failed") or []
|
|
417
|
+
else:
|
|
418
|
+
# v1 files (pre-source-capture) restore via plain pickles
|
|
419
|
+
entries = [{"name": n, "kind": "value", "payload": p} for n, p in (body.get("vars") or {}).items()]
|
|
420
|
+
failed_at_save = []
|
|
421
|
+
res = self.run_cell(restore_code(entries, version or 4), emit_stream=False, payload_mime=RESTORE_MIME)
|
|
422
|
+
restored, failed = [], []
|
|
423
|
+
if res["payload"] is not None:
|
|
424
|
+
body2 = json.loads(res["payload"])
|
|
425
|
+
restored, failed = body2.get("restored", []), body2.get("failed", [])
|
|
426
|
+
# merge save-time skips (oversized bindings) so the resume notice names every loss
|
|
427
|
+
seen = {f["name"] for f in failed}
|
|
428
|
+
for f in failed_at_save:
|
|
429
|
+
if f["name"] not in seen:
|
|
430
|
+
failed.append(f)
|
|
431
|
+
seen.add(f["name"])
|
|
432
|
+
self.emit({"type": "reply", "id": op["id"], "restored": restored, "failed": failed})
|
|
433
|
+
|
|
434
|
+
def op_names(self, op):
|
|
435
|
+
res = self.run_cell(names_code(self.helper_names), emit_stream=False, payload_mime=NAMES_MIME)
|
|
436
|
+
names = json.loads(res["payload"]) if res["payload"] is not None else []
|
|
437
|
+
self.emit({"type": "reply", "id": op["id"], "names": names})
|
|
438
|
+
|
|
439
|
+
# ----- main loop -----
|
|
440
|
+
|
|
441
|
+
def serve(self):
|
|
442
|
+
threading.Thread(target=self._stdin_reader, daemon=True).start()
|
|
443
|
+
boot = self.requests.get()
|
|
444
|
+
if boot.get("op") == "__eof__":
|
|
445
|
+
return
|
|
446
|
+
try:
|
|
447
|
+
self.op_boot(boot)
|
|
448
|
+
except Exception as e:
|
|
449
|
+
self.emit({"type": "error", "message": "boot failed: %s" % e})
|
|
450
|
+
sys.exit(1)
|
|
451
|
+
while True:
|
|
452
|
+
try:
|
|
453
|
+
op = self.requests.get(timeout=self.quiet_seconds())
|
|
454
|
+
except queue.Empty:
|
|
455
|
+
self.quiet_tick()
|
|
456
|
+
continue
|
|
457
|
+
o = op.get("op")
|
|
458
|
+
if o == "__eof__":
|
|
459
|
+
self.stop_kernel()
|
|
460
|
+
sys.exit(0)
|
|
461
|
+
try:
|
|
462
|
+
if o == "exec":
|
|
463
|
+
self.op_exec(op)
|
|
464
|
+
self.pending_check = True
|
|
465
|
+
elif o == "snapshot":
|
|
466
|
+
self.op_snapshot(op)
|
|
467
|
+
elif o == "restore":
|
|
468
|
+
if op.get("defer"):
|
|
469
|
+
# background revive: run at the first quiet gap, never ahead of a cell;
|
|
470
|
+
# the reply (same id) settles the host's restoreResult promise
|
|
471
|
+
self.deferred_restore = op
|
|
472
|
+
else:
|
|
473
|
+
self.op_restore(op)
|
|
474
|
+
elif o == "listNames":
|
|
475
|
+
self.op_names(op)
|
|
476
|
+
elif o == "interrupt":
|
|
477
|
+
self.interrupt()
|
|
478
|
+
elif o == "shutdown":
|
|
479
|
+
self.stop_kernel()
|
|
480
|
+
sys.exit(0)
|
|
481
|
+
else:
|
|
482
|
+
self.emit({"type": "error", "id": op.get("id"), "message": "unknown op: %s" % o})
|
|
483
|
+
except Exception as e:
|
|
484
|
+
self.emit({"type": "error", "id": op.get("id"), "message": "%s" % e})
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def main():
|
|
488
|
+
try:
|
|
489
|
+
Bridge().serve()
|
|
490
|
+
except KeyboardInterrupt:
|
|
491
|
+
pass
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
if __name__ == "__main__":
|
|
495
|
+
main()
|