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/src/engine/kernel.ts
CHANGED
|
@@ -1,32 +1,23 @@
|
|
|
1
|
+
// --- KernelClient over one stdio pipe: bridge.py owns ipykernel and the Jupyter protocol;
|
|
2
|
+
// --- this side spawns it, speaks one JSON line at a time, and applies the output caps. ---
|
|
3
|
+
|
|
1
4
|
import { type ChildProcess, spawn } from "node:child_process";
|
|
2
|
-
import {
|
|
3
|
-
import { existsSync, readdirSync, readFileSync, rmSync } from "node:fs";
|
|
4
|
-
import { tmpdir } from "node:os";
|
|
5
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
5
6
|
import { join } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
6
8
|
import { resolveHelperDirs } from "./helpers-locate.js";
|
|
7
|
-
|
|
8
|
-
type ConnectionFile,
|
|
9
|
-
executeRequest,
|
|
10
|
-
isTrustedMessage,
|
|
11
|
-
JupyterSession,
|
|
12
|
-
NAMES_MIME,
|
|
13
|
-
type ParsedMessage,
|
|
14
|
-
RESTORE_MIME,
|
|
15
|
-
readConnectionFile,
|
|
16
|
-
readPayload,
|
|
17
|
-
SNAPSHOT_MIME,
|
|
18
|
-
} from "./session.js";
|
|
19
|
-
import { ZmtpSocket } from "./zmtp.js";
|
|
20
|
-
|
|
21
|
-
const KERNEL_READY_TIMEOUT_MS = 30_000;
|
|
9
|
+
|
|
22
10
|
const SILENCE_KILL_GRACE_MS = 2000;
|
|
23
|
-
|
|
11
|
+
/** The bridge ships at the package root, next to index.ts; src/engine is two levels deep. */
|
|
12
|
+
const BRIDGE_PATH = fileURLToPath(new URL("../../bridge.py", import.meta.url));
|
|
24
13
|
|
|
25
14
|
export interface KernelOptions {
|
|
26
15
|
cwd?: string;
|
|
27
16
|
env?: Record<string, string>;
|
|
28
17
|
/** Silence watchdog in ms; 0 = no cap (a silent-but-working cell may run on). */
|
|
29
18
|
timeoutMs?: number;
|
|
19
|
+
/** When present, the bridge self-schedules snapshots in its own quiet gaps. */
|
|
20
|
+
snapshot?: { path: string; max_bytes: number; period_ms: number };
|
|
30
21
|
}
|
|
31
22
|
|
|
32
23
|
export interface CellResult {
|
|
@@ -41,20 +32,19 @@ export interface CellResult {
|
|
|
41
32
|
export interface CellOptions {
|
|
42
33
|
signal?: AbortSignal;
|
|
43
34
|
onStream?: (chunk: string, name: "stdout" | "stderr") => void;
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
export interface SnapshotEntry {
|
|
48
|
-
name: string;
|
|
49
|
-
/** "value" = zlib-compressed pickle (v3 files; v2 are plain); "def" re-executes captured source (functions and classes). */
|
|
50
|
-
kind: "value" | "def";
|
|
51
|
-
payload: string;
|
|
35
|
+
/** The engine owns cap policy (DEFAULT_MAX_OUTPUT_CHARS); the kernel is transport and must not default its own. */
|
|
36
|
+
maxOutputChars: number;
|
|
52
37
|
}
|
|
53
38
|
|
|
54
39
|
export interface SnapshotReply {
|
|
55
|
-
|
|
40
|
+
/** Names persisted; payloads never cross the pipe — the bridge wrote the file itself. */
|
|
41
|
+
saved?: string[];
|
|
42
|
+
/** Only present for tests that fake the kernel; the real bridge replies with counts only. */
|
|
43
|
+
entries?: { name: string; kind: "value" | "def"; payload: string }[];
|
|
56
44
|
failed: { name: string; reason: string }[];
|
|
57
45
|
complete: boolean;
|
|
46
|
+
/** Payload bytes written; drives the periodic-refresh stand-down. */
|
|
47
|
+
bytes?: number;
|
|
58
48
|
}
|
|
59
49
|
|
|
60
50
|
export interface HelperLoadResult {
|
|
@@ -64,12 +54,46 @@ export interface HelperLoadResult {
|
|
|
64
54
|
error?: string;
|
|
65
55
|
}
|
|
66
56
|
|
|
67
|
-
|
|
57
|
+
interface BridgeMessage {
|
|
58
|
+
type?: string;
|
|
59
|
+
id?: string;
|
|
60
|
+
[key: string]: unknown;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface ActiveCell {
|
|
64
|
+
id: string;
|
|
65
|
+
stdout: string[];
|
|
66
|
+
stderr: string[];
|
|
67
|
+
outLen: number;
|
|
68
|
+
errLen: number;
|
|
69
|
+
maxChars: number;
|
|
70
|
+
result?: string;
|
|
71
|
+
error?: { name: string; message: string; stack: string[] };
|
|
72
|
+
status: CellResult["status"];
|
|
73
|
+
lastActivity: number;
|
|
74
|
+
timedOut: boolean;
|
|
75
|
+
stdoutTruncated: boolean;
|
|
76
|
+
stderrTruncated: boolean;
|
|
77
|
+
signal?: AbortSignal;
|
|
78
|
+
onStream?: (chunk: string, name: "stdout" | "stderr") => void;
|
|
79
|
+
resolve(result: CellResult): void;
|
|
80
|
+
reject(error: Error): void;
|
|
81
|
+
settled: boolean;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
interface PendingRequest {
|
|
85
|
+
resolve(msg: BridgeMessage): void;
|
|
86
|
+
reject(error: Error): void;
|
|
87
|
+
timer?: ReturnType<typeof setTimeout>;
|
|
88
|
+
}
|
|
89
|
+
|
|
68
90
|
/** Kernel cwd falls back to the host cwd when the requested dir is gone (a deleted project is a real resume case). */
|
|
69
91
|
function resolveCwd(requested?: string): string {
|
|
70
92
|
if (requested && existsSync(requested)) return requested;
|
|
71
93
|
return process.cwd();
|
|
72
94
|
}
|
|
95
|
+
|
|
96
|
+
/** Read the helpers dir (same skip rules as the extension's prompt loader); sources travel in the boot line. */
|
|
73
97
|
function readHelperSources(dirs: string[]): { name: string; source: string }[] {
|
|
74
98
|
// --- merged dirs come pre-ordered (project first, global last); first-seen name wins ---
|
|
75
99
|
const seen = new Set<string>();
|
|
@@ -90,427 +114,159 @@ function readHelperSources(dirs: string[]): { name: string; source: string }[] {
|
|
|
90
114
|
return out;
|
|
91
115
|
}
|
|
92
116
|
|
|
93
|
-
function buildSkipList(helperNames: string[]): string {
|
|
94
|
-
const names = new Set([...helperNames, "helper_description", "In", "Out", "get_ipython", "exit", "quit", "open"]);
|
|
95
|
-
return JSON.stringify([...names]);
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
function snapshotCode(helperNames: string[], maxBytes: number): string {
|
|
99
|
-
const skip = buildSkipList(helperNames);
|
|
100
|
-
// --- defs/classes can't be pickled by reference: capture source and re-exec on restore; oversized entries are skipped by name ---
|
|
101
|
-
return `import pickle as _pk, base64 as _b64, json as _js, zlib as _zl, inspect as _in, linecache as _lc
|
|
102
|
-
def _repl_class_source(_c):
|
|
103
|
-
_m = getattr(_c, '__init__', None)
|
|
104
|
-
if _m is None or not _in.isfunction(_m):
|
|
105
|
-
for _v in vars(_c).values():
|
|
106
|
-
if _in.isfunction(_v):
|
|
107
|
-
_m = _v
|
|
108
|
-
break
|
|
109
|
-
if _m is None:
|
|
110
|
-
raise ValueError('class has no member methods')
|
|
111
|
-
_start = _m.__code__.co_firstlineno
|
|
112
|
-
_all = _lc.getlines(_m.__code__.co_filename)
|
|
113
|
-
if not _all:
|
|
114
|
-
raise ValueError('source not in linecache')
|
|
115
|
-
_ln = _start - 1
|
|
116
|
-
while _ln > 0:
|
|
117
|
-
_prev = _all[_ln - 1].lstrip()
|
|
118
|
-
if _prev.startswith('class ') and _c.__name__ in _prev:
|
|
119
|
-
break
|
|
120
|
-
_ln -= 1
|
|
121
|
-
if _ln == 0:
|
|
122
|
-
raise ValueError('class header not found')
|
|
123
|
-
_head = _ln - 1
|
|
124
|
-
while _head > 0:
|
|
125
|
-
_p = _all[_head - 1].lstrip()
|
|
126
|
-
if _p == '' or _p.startswith('@'):
|
|
127
|
-
_head -= 1
|
|
128
|
-
else:
|
|
129
|
-
break
|
|
130
|
-
_indent = len(_all[_head]) - len(_all[_head].lstrip())
|
|
131
|
-
_block = [_all[_head]]
|
|
132
|
-
_j = _head + 1
|
|
133
|
-
while _j < len(_all):
|
|
134
|
-
_line = _all[_j]
|
|
135
|
-
if _line.strip() == '':
|
|
136
|
-
_block.append(_line)
|
|
137
|
-
_j += 1
|
|
138
|
-
continue
|
|
139
|
-
if len(_line) - len(_line.lstrip()) > _indent:
|
|
140
|
-
_block.append(_line)
|
|
141
|
-
_j += 1
|
|
142
|
-
else:
|
|
143
|
-
break
|
|
144
|
-
return ''.join(_block)
|
|
145
|
-
__repl_skip = set(${skip})
|
|
146
|
-
__repl_max = ${maxBytes}
|
|
147
|
-
__repl_e = []
|
|
148
|
-
__repl_f = []
|
|
149
|
-
__repl_total = 0
|
|
150
|
-
for _k, _v in list(globals().items()):
|
|
151
|
-
if _k.startswith('_') or _k in __repl_skip:
|
|
152
|
-
continue
|
|
153
|
-
__repl_p = None
|
|
154
|
-
__repl_kind = 'value'
|
|
155
|
-
try:
|
|
156
|
-
if _in.isfunction(_v):
|
|
157
|
-
__repl_src = _in.getsource(_v)
|
|
158
|
-
if __repl_src:
|
|
159
|
-
__repl_p = _b64.b64encode(__repl_src.encode()).decode()
|
|
160
|
-
__repl_kind = 'def'
|
|
161
|
-
elif _in.isclass(_v):
|
|
162
|
-
__repl_src = _repl_class_source(_v)
|
|
163
|
-
if __repl_src:
|
|
164
|
-
__repl_p = _b64.b64encode(__repl_src.encode()).decode()
|
|
165
|
-
__repl_kind = 'def'
|
|
166
|
-
except Exception:
|
|
167
|
-
__repl_p = None
|
|
168
|
-
__repl_kind = 'value'
|
|
169
|
-
try:
|
|
170
|
-
if __repl_p is None:
|
|
171
|
-
__repl_p = _b64.b64encode(_zl.compress(_pk.dumps(_v), 1)).decode()
|
|
172
|
-
__repl_b = len(__repl_p)
|
|
173
|
-
if __repl_b > __repl_max:
|
|
174
|
-
__repl_f.append({'name': _k, 'reason': 'exceeds per-entry snapshot cap'})
|
|
175
|
-
elif __repl_total + __repl_b > __repl_max:
|
|
176
|
-
__repl_f.append({'name': _k, 'reason': 'exceeds total snapshot cap'})
|
|
177
|
-
else:
|
|
178
|
-
__repl_e.append({'name': _k, 'kind': __repl_kind, 'payload': __repl_p})
|
|
179
|
-
__repl_total += __repl_b
|
|
180
|
-
except Exception as _e:
|
|
181
|
-
__repl_f.append({'name': _k, 'reason': str(_e)})
|
|
182
|
-
get_ipython().display_pub.publish({${JSON.stringify(SNAPSHOT_MIME)}: _js.dumps({'version': 2, 'entries': __repl_e, 'failed': __repl_f})})`;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
function restoreCode(entries: SnapshotEntry[], compressedValues: boolean): string {
|
|
186
|
-
const per = entries
|
|
187
|
-
.map(({ name, kind, payload }) => {
|
|
188
|
-
const n = JSON.stringify(name);
|
|
189
|
-
const body =
|
|
190
|
-
kind === "def"
|
|
191
|
-
? // re-execute captured source and register it in linecache under the code
|
|
192
|
-
// --- exec also registers the source in linecache so a later snapshot can capture it again ---
|
|
193
|
-
`__repl_src = _b64.b64decode(${JSON.stringify(payload)}).decode()
|
|
194
|
-
exec(__repl_src, globals())
|
|
195
|
-
__repl_obj = globals().get(${n})
|
|
196
|
-
if __repl_obj is not None:
|
|
197
|
-
__repl_fname = getattr(getattr(__repl_obj, '__code__', None), 'co_filename', None)
|
|
198
|
-
if __repl_fname is None:
|
|
199
|
-
__repl_init = getattr(__repl_obj, '__init__', None)
|
|
200
|
-
__repl_fname = getattr(getattr(__repl_init, '__code__', None), 'co_filename', None)
|
|
201
|
-
if __repl_fname:
|
|
202
|
-
_lc.cache[__repl_fname] = (len(__repl_src.splitlines()), None, __repl_src.splitlines(True), __repl_fname)`
|
|
203
|
-
: compressedValues
|
|
204
|
-
? `globals()[${n}] = _pk.loads(_zl.decompress(_b64.b64decode(${JSON.stringify(payload)})))`
|
|
205
|
-
: `globals()[${n}] = _pk.loads(_b64.b64decode(${JSON.stringify(payload)}))`;
|
|
206
|
-
return `try:
|
|
207
|
-
${body}
|
|
208
|
-
__repl_r['restored'].append(${n})
|
|
209
|
-
except Exception as _e:
|
|
210
|
-
__repl_r['failed'].append({'name': ${n}, 'reason': str(_e)})`;
|
|
211
|
-
})
|
|
212
|
-
.join("\n");
|
|
213
|
-
return `import pickle as _pk, base64 as _b64, json as _js, zlib as _zl, linecache as _lc
|
|
214
|
-
__repl_r = {'restored': [], 'failed': []}
|
|
215
|
-
${per}
|
|
216
|
-
get_ipython().display_pub.publish({${JSON.stringify(RESTORE_MIME)}: _js.dumps(__repl_r)})`;
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
function namesCode(helperNames: string[]): string {
|
|
220
|
-
const skip = buildSkipList(helperNames);
|
|
221
|
-
return (
|
|
222
|
-
"import json as _js\n" +
|
|
223
|
-
`__repl_skip = set(${skip})\n` +
|
|
224
|
-
"__repl_n = sorted(n for n in globals() if not n.startswith('_') and n not in __repl_skip)\n" +
|
|
225
|
-
`get_ipython().display_pub.publish({${JSON.stringify(NAMES_MIME)}: _js.dumps(__repl_n)})\n`
|
|
226
|
-
);
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
interface ActiveCell {
|
|
230
|
-
msgId: string;
|
|
231
|
-
stdout: string[];
|
|
232
|
-
stderr: string[];
|
|
233
|
-
outLen: number;
|
|
234
|
-
errLen: number;
|
|
235
|
-
maxChars: number;
|
|
236
|
-
result?: string;
|
|
237
|
-
error?: { name: string; message: string; stack: string[] };
|
|
238
|
-
status: CellResult["status"];
|
|
239
|
-
payloads: Record<string, string>;
|
|
240
|
-
lastActivity: number;
|
|
241
|
-
timedOut: boolean;
|
|
242
|
-
stdoutTruncated: boolean;
|
|
243
|
-
stderrTruncated: boolean;
|
|
244
|
-
signal?: AbortSignal;
|
|
245
|
-
onStream?: (chunk: string, name: "stdout" | "stderr") => void;
|
|
246
|
-
resolve(result: CellResult & { payloads: Record<string, string> }): void;
|
|
247
|
-
reject(error: Error): void;
|
|
248
|
-
settled: boolean;
|
|
249
|
-
replySeen: boolean;
|
|
250
|
-
idleSeen: boolean;
|
|
251
|
-
reply?: ParsedMessage;
|
|
252
|
-
}
|
|
253
|
-
|
|
254
117
|
export class KernelClient {
|
|
255
118
|
private child?: ChildProcess;
|
|
256
|
-
private shell?: ZmtpSocket;
|
|
257
|
-
private control?: ZmtpSocket;
|
|
258
|
-
private iopub?: ZmtpSocket;
|
|
259
|
-
private readonly session: JupyterSession;
|
|
260
|
-
private readonly helperSources: { name: string; source: string }[];
|
|
261
|
-
private helperBootReport: HelperLoadResult[] = [];
|
|
262
119
|
private readonly timeoutMs: number;
|
|
263
|
-
private activeCell?: ActiveCell;
|
|
264
|
-
private connectionFilePath?: string;
|
|
265
120
|
private ready = false;
|
|
266
121
|
/** Serializes all kernel ops: one execute at a time, snapshots between cells. */
|
|
267
122
|
private queue: Promise<unknown> = Promise.resolve();
|
|
123
|
+
private pending = new Map<string, PendingRequest>();
|
|
124
|
+
private activeCell?: ActiveCell;
|
|
125
|
+
private inputBuffer = "";
|
|
126
|
+
private readyWaiters: { resolve(msg: BridgeMessage): void; reject(error: Error): void }[] = [];
|
|
127
|
+
private helperBootReport: HelperLoadResult[] = [];
|
|
128
|
+
private nextId = 0;
|
|
268
129
|
private _onUnexpectedExit?: () => void;
|
|
269
|
-
get helperReport(): readonly HelperLoadResult[] {
|
|
270
|
-
return this.helperBootReport;
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
/** Engine hook: an unexpected kernel death (not a deliberate kill) should drop the instance. */
|
|
274
|
-
setOnUnexpectedExit(fn: () => void): void {
|
|
275
|
-
this._onUnexpectedExit = fn;
|
|
276
|
-
}
|
|
277
130
|
private watchdog?: ReturnType<typeof setInterval>;
|
|
278
131
|
private silenceKillTimer?: ReturnType<typeof setTimeout>;
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
{ resolve(m: ParsedMessage): void; timer?: ReturnType<typeof setTimeout>; expectedType: string }
|
|
282
|
-
>();
|
|
283
|
-
|
|
284
|
-
private constructor(conn: ConnectionFile, opts: KernelOptions) {
|
|
285
|
-
this.session = new JupyterSession({ key: conn.key });
|
|
286
|
-
this.helperSources = opts.env?.PI_HELPERS_DIR
|
|
287
|
-
? readHelperSources([opts.env.PI_HELPERS_DIR])
|
|
288
|
-
: readHelperSources(resolveHelperDirs(opts.cwd, opts.env?.PI_HELPERS_GLOBAL_DIR));
|
|
132
|
+
|
|
133
|
+
private constructor(opts: KernelOptions) {
|
|
289
134
|
this.timeoutMs = opts.timeoutMs ?? 0;
|
|
290
135
|
}
|
|
291
136
|
|
|
292
137
|
static async start(pythonPath: string, opts: KernelOptions = {}): Promise<KernelClient> {
|
|
293
|
-
const
|
|
294
|
-
const child = spawn(pythonPath, ["-m", "ipykernel", "-f", connPath, "--no-stdout"], {
|
|
138
|
+
const child = spawn(pythonPath, [BRIDGE_PATH], {
|
|
295
139
|
cwd: resolveCwd(opts.cwd),
|
|
296
140
|
env: { ...process.env, ...(opts.env ?? {}) },
|
|
297
|
-
stdio: ["
|
|
141
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
142
|
+
// its own process group: kill(-pid) reaches the kernel the bridge spawns too
|
|
143
|
+
detached: true,
|
|
298
144
|
});
|
|
299
|
-
//
|
|
145
|
+
// keep stderr for post-mortems (ipykernel warnings, bridge tracebacks)
|
|
300
146
|
let stderrTail = "";
|
|
301
147
|
child.stderr?.on("data", (b: Buffer) => {
|
|
302
148
|
stderrTail = (stderrTail + b.toString()).slice(-4000);
|
|
303
149
|
});
|
|
304
150
|
|
|
305
|
-
const
|
|
306
|
-
// --- poll until present AND fully written: existsSync fires before the write finishes ---
|
|
307
|
-
let conn: ConnectionFile;
|
|
308
|
-
while (true) {
|
|
309
|
-
if (child.exitCode !== null) {
|
|
310
|
-
throw new Error(
|
|
311
|
-
`ipykernel exited before writing its connection file (code=${child.exitCode})` +
|
|
312
|
-
(stderrTail ? `\nkernel stderr:\n${stderrTail}` : ""),
|
|
313
|
-
);
|
|
314
|
-
}
|
|
315
|
-
if (Date.now() > deadline) {
|
|
316
|
-
child.kill("SIGKILL");
|
|
317
|
-
throw new Error("ipykernel did not write a valid connection file in time");
|
|
318
|
-
}
|
|
319
|
-
try {
|
|
320
|
-
conn = readConnectionFile(connPath);
|
|
321
|
-
break;
|
|
322
|
-
} catch {
|
|
323
|
-
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
const kc = new KernelClient(conn, opts);
|
|
151
|
+
const kc = new KernelClient(opts);
|
|
328
152
|
kc.child = child;
|
|
329
|
-
|
|
153
|
+
child.stdout?.on("data", (chunk) => kc.onData(chunk));
|
|
330
154
|
child.on("exit", () => {
|
|
331
|
-
// ---
|
|
155
|
+
// --- bridge exit IS kernel death: settle the running cell and drop the zombie ---
|
|
332
156
|
kc.settleActive(new Error("kernel process exited"));
|
|
333
|
-
kc.child = undefined;
|
|
334
157
|
kc.ready = false;
|
|
335
|
-
kc.
|
|
336
|
-
kc.
|
|
337
|
-
kc.iopub?.close();
|
|
338
|
-
kc.shell = undefined;
|
|
339
|
-
kc.control = undefined;
|
|
340
|
-
kc.iopub = undefined;
|
|
158
|
+
kc.child = undefined;
|
|
159
|
+
kc.rejectBootWaiters(new Error("bridge exited before it was ready"));
|
|
341
160
|
kc._onUnexpectedExit?.();
|
|
342
161
|
});
|
|
343
162
|
|
|
163
|
+
// helpers resolve host-side (one canonical list, same skip rules as the prompt); sources preload in the bridge
|
|
164
|
+
const helpers = (
|
|
165
|
+
opts.env?.PI_HELPERS_DIR
|
|
166
|
+
? readHelperSources([opts.env.PI_HELPERS_DIR])
|
|
167
|
+
: readHelperSources(resolveHelperDirs(opts.cwd, opts.env?.PI_HELPERS_GLOBAL_DIR))
|
|
168
|
+
).map((h) => ({ name: h.name, source: h.source }));
|
|
169
|
+
kc.send({ op: "boot", helpers, snapshot: opts.snapshot });
|
|
170
|
+
|
|
344
171
|
try {
|
|
345
|
-
await kc.
|
|
346
|
-
await kc.probeReady();
|
|
347
|
-
kc.helperBootReport = await kc.preload();
|
|
348
|
-
kc.ready = true;
|
|
172
|
+
await kc.waitReady();
|
|
349
173
|
} catch (error) {
|
|
350
174
|
kc.kill();
|
|
351
|
-
throw error
|
|
175
|
+
throw error instanceof Error
|
|
176
|
+
? new Error(`${error.message}${stderrTail ? `\nbridge stderr:\n${stderrTail}` : ""}`)
|
|
177
|
+
: error;
|
|
352
178
|
}
|
|
179
|
+
kc.ready = true;
|
|
353
180
|
return kc;
|
|
354
181
|
}
|
|
355
182
|
|
|
356
|
-
private
|
|
357
|
-
|
|
358
|
-
|
|
183
|
+
private onData(chunk: Uint8Array): void {
|
|
184
|
+
this.inputBuffer += Buffer.from(chunk).toString("utf8");
|
|
185
|
+
let idx = this.inputBuffer.indexOf("\n");
|
|
186
|
+
while (idx >= 0) {
|
|
187
|
+
const line = this.inputBuffer.slice(0, idx).trim();
|
|
188
|
+
this.inputBuffer = this.inputBuffer.slice(idx + 1);
|
|
189
|
+
if (line) this.handleLine(line);
|
|
190
|
+
idx = this.inputBuffer.indexOf("\n");
|
|
359
191
|
}
|
|
360
|
-
const [shell, control, iopub] = await Promise.all([
|
|
361
|
-
ZmtpSocket.connect({ host: conn.ip, port: conn.shell_port, socketType: "DEALER" }),
|
|
362
|
-
ZmtpSocket.connect({ host: conn.ip, port: conn.control_port, socketType: "DEALER" }),
|
|
363
|
-
ZmtpSocket.connect({ host: conn.ip, port: conn.iopub_port, socketType: "SUB" }),
|
|
364
|
-
]);
|
|
365
|
-
this.shell = shell;
|
|
366
|
-
this.control = control;
|
|
367
|
-
this.iopub = iopub;
|
|
368
|
-
shell.onMessage = (frames) => this.onShellMessage(frames);
|
|
369
|
-
control.onMessage = (frames) => this.onControlMessage(frames);
|
|
370
|
-
iopub.onMessage = (frames) => this.onIopubMessage(frames);
|
|
371
|
-
iopub.subscribe(Buffer.from([])); // all traffic
|
|
372
192
|
}
|
|
373
193
|
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
private async preload(): Promise<HelperLoadResult[]> {
|
|
382
|
-
const report: HelperLoadResult[] = [];
|
|
383
|
-
for (const h of this.helperSources) {
|
|
384
|
-
const res = await this.executeCell(h.source, { maxOutputChars: DEFAULT_MAX_OUTPUT_CHARS });
|
|
385
|
-
if (res.status === "ok") {
|
|
386
|
-
report.push({ name: h.name, ok: true });
|
|
387
|
-
} else {
|
|
388
|
-
const err = res.error;
|
|
389
|
-
report.push({
|
|
390
|
-
name: h.name,
|
|
391
|
-
ok: false,
|
|
392
|
-
error: err ? `${err.name}: ${err.message}` : "failed to load",
|
|
393
|
-
});
|
|
394
|
-
}
|
|
395
|
-
}
|
|
396
|
-
return report;
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
private onShellMessage(frames: Buffer[]): void {
|
|
400
|
-
const msg = this.session.parseMessage(frames);
|
|
401
|
-
if (!isTrustedMessage(msg)) return;
|
|
402
|
-
const active = this.activeCell;
|
|
403
|
-
if (msg.msg_type === "execute_reply" && active && msg.parent.msg_id === active.msgId) {
|
|
404
|
-
// --- the shell reply races the iopub stream: record it, settle only after idle ---
|
|
405
|
-
active.reply = msg;
|
|
406
|
-
active.replySeen = true;
|
|
407
|
-
this.maybeSettle(active);
|
|
194
|
+
/** Route-gate: a line that is not valid JSON is not the bridge. */
|
|
195
|
+
private handleLine(line: string): void {
|
|
196
|
+
let msg: BridgeMessage;
|
|
197
|
+
try {
|
|
198
|
+
msg = JSON.parse(line) as BridgeMessage;
|
|
199
|
+
} catch {
|
|
200
|
+
console.error("[pi-repl] unparseable bridge line:", line.slice(0, 200));
|
|
408
201
|
return;
|
|
409
202
|
}
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
if (!isTrustedMessage(msg)) return;
|
|
416
|
-
// interrupt_reply / shutdown_reply — nothing awaits them; keep draining.
|
|
417
|
-
this.resolveReply(msg);
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
private onIopubMessage(frames: Buffer[]): void {
|
|
421
|
-
const msg = this.session.parseMessage(frames);
|
|
422
|
-
if (!isTrustedMessage(msg)) return;
|
|
423
|
-
const active = this.activeCell;
|
|
424
|
-
if (!active || msg.parent.msg_id !== active.msgId) return;
|
|
425
|
-
active.lastActivity = Date.now();
|
|
426
|
-
const c = msg.content;
|
|
427
|
-
switch (msg.msg_type) {
|
|
428
|
-
case "stream": {
|
|
429
|
-
const text = (c.text as string) ?? "";
|
|
430
|
-
this.accumulate(active, c.name === "stderr" ? "stderr" : "stdout", text);
|
|
203
|
+
switch (msg.type) {
|
|
204
|
+
case "ready": {
|
|
205
|
+
const helpers = Array.isArray(msg.helpers) ? (msg.helpers as HelperLoadResult[]) : [];
|
|
206
|
+
this.helperBootReport = helpers;
|
|
207
|
+
this.readyWaiters.shift()?.resolve(msg);
|
|
431
208
|
break;
|
|
432
209
|
}
|
|
433
|
-
case "
|
|
434
|
-
const
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
210
|
+
case "stream": {
|
|
211
|
+
const active = this.activeCell;
|
|
212
|
+
if (active && msg.id === active.id) {
|
|
213
|
+
active.lastActivity = Date.now();
|
|
214
|
+
this.accumulate(active, msg.name === "stderr" ? "stderr" : "stdout", String(msg.text ?? ""));
|
|
215
|
+
}
|
|
438
216
|
break;
|
|
439
217
|
}
|
|
440
|
-
case "
|
|
441
|
-
this.
|
|
218
|
+
case "result": {
|
|
219
|
+
const active = this.activeCell;
|
|
220
|
+
if (active && msg.id === active.id) this.settleFromResult(active, msg);
|
|
442
221
|
break;
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
222
|
+
}
|
|
223
|
+
case "reply": {
|
|
224
|
+
const id = msg.id;
|
|
225
|
+
if (id !== undefined) {
|
|
226
|
+
const pending = this.pending.get(id);
|
|
227
|
+
if (pending) {
|
|
228
|
+
this.pending.delete(id);
|
|
229
|
+
if (pending.timer) clearTimeout(pending.timer);
|
|
230
|
+
pending.resolve(msg);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
450
233
|
break;
|
|
451
234
|
}
|
|
452
|
-
case "
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
this.
|
|
235
|
+
case "error": {
|
|
236
|
+
const error = new Error(String(msg.message ?? "bridge error"));
|
|
237
|
+
const id = msg.id;
|
|
238
|
+
if (id !== undefined && this.pending.has(id)) {
|
|
239
|
+
const pending = this.pending.get(id)!;
|
|
240
|
+
this.pending.delete(id);
|
|
241
|
+
if (pending.timer) clearTimeout(pending.timer);
|
|
242
|
+
pending.reject(error);
|
|
243
|
+
} else if (this.readyWaiters.length > 0) {
|
|
244
|
+
this.readyWaiters.shift()!.reject(error); // boot failure
|
|
245
|
+
} else {
|
|
246
|
+
console.error("[pi-repl] bridge error:", error.message);
|
|
457
247
|
}
|
|
458
248
|
break;
|
|
459
|
-
}
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
private collectPayload(active: ActiveCell, content: Record<string, unknown>): void {
|
|
463
|
-
for (const mime of [SNAPSHOT_MIME, RESTORE_MIME, NAMES_MIME]) {
|
|
464
|
-
const payload = readPayload(content, mime);
|
|
465
|
-
if (payload !== null) {
|
|
466
|
-
active.payloads[mime] = payload;
|
|
467
|
-
return;
|
|
468
249
|
}
|
|
250
|
+
default:
|
|
251
|
+
console.error("[pi-repl] unknown bridge event:", JSON.stringify(msg).slice(0, 200));
|
|
469
252
|
}
|
|
470
253
|
}
|
|
471
254
|
|
|
472
|
-
private
|
|
473
|
-
|
|
474
|
-
const len = name === "stdout" ? active.outLen : active.errLen;
|
|
475
|
-
const room = active.maxChars - len;
|
|
476
|
-
const keep = Math.min(text.length, Math.max(0, room));
|
|
477
|
-
if (keep > 0) {
|
|
478
|
-
arr.push(text.slice(0, keep));
|
|
479
|
-
if (name === "stdout") active.outLen += keep;
|
|
480
|
-
else active.errLen += keep;
|
|
481
|
-
}
|
|
482
|
-
// --- one oversized stream frame (10 MB print) overflows the cap within this call ---
|
|
483
|
-
if (text.length > keep) {
|
|
484
|
-
if (name === "stdout") active.stdoutTruncated = true;
|
|
485
|
-
else active.stderrTruncated = true;
|
|
486
|
-
}
|
|
487
|
-
// --- beyond the cap we drop text but keep draining ---
|
|
488
|
-
active.onStream?.(text.slice(0, keep), name);
|
|
255
|
+
private send(msg: object): void {
|
|
256
|
+
this.child?.stdin?.write(JSON.stringify(msg) + "\n");
|
|
489
257
|
}
|
|
490
258
|
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
}, timeoutMs);
|
|
497
|
-
timer.unref?.();
|
|
498
|
-
this.pendingReplies.set(msgId, { resolve, timer, expectedType });
|
|
259
|
+
/** Resolves on the bridge's ready event; rejects on a boot error or a bridge exit.
|
|
260
|
+
* A wedged (alive but silent) bridge is bounded by the lifecycle's boot race. */
|
|
261
|
+
private waitReady(): Promise<void> {
|
|
262
|
+
return new Promise<void>((resolve, reject) => {
|
|
263
|
+
this.readyWaiters.push({ resolve: () => resolve(), reject });
|
|
499
264
|
});
|
|
500
265
|
}
|
|
501
266
|
|
|
502
|
-
private
|
|
503
|
-
const
|
|
504
|
-
|
|
505
|
-
// --- any reply echoes the parent id; only the awaited type settles the wait, the timer stays armed for it ---
|
|
506
|
-
if (msg.msg_type !== pending.expectedType) return;
|
|
507
|
-
this.pendingReplies.delete(msg.parent.msg_id as string);
|
|
508
|
-
if (pending.timer) clearTimeout(pending.timer);
|
|
509
|
-
pending.resolve(msg);
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
executeCell(code: string, opts: CellOptions = {}): Promise<CellResult> {
|
|
513
|
-
return this.enqueue(() => this.executeCellNow(code, opts)).then(({ payloads: _payloads, ...rest }) => rest);
|
|
267
|
+
private rejectBootWaiters(error: Error): void {
|
|
268
|
+
const waiters = this.readyWaiters.splice(0);
|
|
269
|
+
for (const w of waiters) w.reject(error);
|
|
514
270
|
}
|
|
515
271
|
|
|
516
272
|
private enqueue<T>(fn: () => Promise<T>): Promise<T> {
|
|
@@ -519,18 +275,39 @@ export class KernelClient {
|
|
|
519
275
|
return result;
|
|
520
276
|
}
|
|
521
277
|
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
278
|
+
/** A request/reply op: snapshot, restore, listNames, shutdown. Serialized with cells. */
|
|
279
|
+
private request(msg: Record<string, unknown>, timeoutMs?: number): Promise<BridgeMessage> {
|
|
280
|
+
return this.enqueue(() => {
|
|
281
|
+
const id = `r${this.nextId++}`;
|
|
282
|
+
return new Promise<BridgeMessage>((resolve, reject) => {
|
|
283
|
+
const timer = timeoutMs
|
|
284
|
+
? setTimeout(() => {
|
|
285
|
+
this.pending.delete(id);
|
|
286
|
+
reject(new Error(`bridge did not answer ${String(msg.op)} in time`));
|
|
287
|
+
}, timeoutMs)
|
|
288
|
+
: undefined;
|
|
289
|
+
timer?.unref?.();
|
|
290
|
+
this.pending.set(id, { resolve, reject, timer });
|
|
291
|
+
this.send({ ...msg, id });
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
executeCell(code: string, opts: CellOptions): Promise<CellResult> {
|
|
297
|
+
return this.enqueue(() => this.executeCellNow(code, opts));
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
private executeCellNow(code: string, opts: CellOptions): Promise<CellResult> {
|
|
301
|
+
const maxChars = opts.maxOutputChars;
|
|
302
|
+
// one op id per request; the bridge echoes it on every stream and the result event
|
|
303
|
+
const id = `e${this.nextId++}`;
|
|
526
304
|
const active: ActiveCell = {
|
|
527
|
-
|
|
305
|
+
id,
|
|
528
306
|
stdout: [],
|
|
529
307
|
stderr: [],
|
|
530
308
|
outLen: 0,
|
|
531
309
|
errLen: 0,
|
|
532
310
|
maxChars,
|
|
533
|
-
payloads: {},
|
|
534
311
|
lastActivity: Date.now(),
|
|
535
312
|
timedOut: false,
|
|
536
313
|
stdoutTruncated: false,
|
|
@@ -539,8 +316,6 @@ export class KernelClient {
|
|
|
539
316
|
onStream: opts.onStream,
|
|
540
317
|
status: "ok",
|
|
541
318
|
settled: false,
|
|
542
|
-
replySeen: false,
|
|
543
|
-
idleSeen: false,
|
|
544
319
|
resolve: () => {},
|
|
545
320
|
reject: () => {},
|
|
546
321
|
};
|
|
@@ -548,11 +323,11 @@ export class KernelClient {
|
|
|
548
323
|
const onAbort = () => this.interrupt();
|
|
549
324
|
opts.signal?.addEventListener("abort", onAbort, { once: true });
|
|
550
325
|
|
|
551
|
-
this.
|
|
326
|
+
this.send({ op: "exec", id, code });
|
|
552
327
|
this.startWatchdog(active);
|
|
553
328
|
|
|
554
|
-
return new Promise<CellResult
|
|
555
|
-
active.resolve =
|
|
329
|
+
return new Promise<CellResult>((resolve, reject) => {
|
|
330
|
+
active.resolve = resolve;
|
|
556
331
|
active.reject = reject;
|
|
557
332
|
if (opts.signal?.aborted) this.interrupt();
|
|
558
333
|
}).finally(() => {
|
|
@@ -569,30 +344,29 @@ export class KernelClient {
|
|
|
569
344
|
active.reject(error);
|
|
570
345
|
}
|
|
571
346
|
|
|
572
|
-
private
|
|
573
|
-
//
|
|
574
|
-
if (active.settled || !active.replySeen || !active.idleSeen) return;
|
|
575
|
-
this.settleFromReply(active, active.reply!);
|
|
576
|
-
}
|
|
577
|
-
|
|
578
|
-
private settleFromReply(active: ActiveCell, msg: ParsedMessage): void {
|
|
347
|
+
private settleFromResult(active: ActiveCell, msg: BridgeMessage): void {
|
|
348
|
+
// the bridge settles only once the shell reply AND the iopub idle arrived
|
|
579
349
|
if (active.settled) return;
|
|
580
350
|
active.settled = true;
|
|
581
|
-
const content = msg
|
|
582
|
-
|
|
583
|
-
|
|
351
|
+
const content = msg as {
|
|
352
|
+
status?: string;
|
|
353
|
+
result?: string;
|
|
354
|
+
error?: { name?: string; message?: string; stack?: string[] };
|
|
355
|
+
};
|
|
356
|
+
if (content.result !== undefined) active.result = content.result;
|
|
357
|
+
if (content.error) {
|
|
584
358
|
active.error = {
|
|
585
|
-
name:
|
|
586
|
-
message:
|
|
587
|
-
stack: [],
|
|
359
|
+
name: content.error.name ?? "Error",
|
|
360
|
+
message: content.error.message ?? "",
|
|
361
|
+
stack: content.error.stack ?? [],
|
|
588
362
|
};
|
|
589
363
|
}
|
|
590
|
-
let status: CellResult["status"] =
|
|
364
|
+
let status: CellResult["status"] = content.status === "aborted" ? "aborted" : active.error ? "error" : "ok";
|
|
591
365
|
if (active.signal?.aborted) {
|
|
592
|
-
//
|
|
366
|
+
// the caller withdrew; report aborted even if the cell raised
|
|
593
367
|
status = "aborted";
|
|
594
368
|
} else if (active.timedOut) {
|
|
595
|
-
//
|
|
369
|
+
// silence watchdog tripped: the cell was still running, not done
|
|
596
370
|
status = "error";
|
|
597
371
|
active.error = {
|
|
598
372
|
name: "Timeout",
|
|
@@ -608,10 +382,28 @@ export class KernelClient {
|
|
|
608
382
|
error: active.error,
|
|
609
383
|
status,
|
|
610
384
|
truncated: { stdout: active.stdoutTruncated, stderr: active.stderrTruncated },
|
|
611
|
-
payloads: active.payloads,
|
|
612
385
|
});
|
|
613
386
|
}
|
|
614
387
|
|
|
388
|
+
private accumulate(active: ActiveCell, name: "stdout" | "stderr", text: string): void {
|
|
389
|
+
const arr = name === "stdout" ? active.stdout : active.stderr;
|
|
390
|
+
const len = name === "stdout" ? active.outLen : active.errLen;
|
|
391
|
+
const room = active.maxChars - len;
|
|
392
|
+
const keep = Math.min(text.length, Math.max(0, room));
|
|
393
|
+
if (keep > 0) {
|
|
394
|
+
arr.push(text.slice(0, keep));
|
|
395
|
+
if (name === "stdout") active.outLen += keep;
|
|
396
|
+
else active.errLen += keep;
|
|
397
|
+
}
|
|
398
|
+
// one oversized stream frame (10 MB print) overflows the cap within this call
|
|
399
|
+
if (text.length > keep) {
|
|
400
|
+
if (name === "stdout") active.stdoutTruncated = true;
|
|
401
|
+
else active.stderrTruncated = true;
|
|
402
|
+
}
|
|
403
|
+
// beyond the cap we drop text but keep draining
|
|
404
|
+
active.onStream?.(text.slice(0, keep), name);
|
|
405
|
+
}
|
|
406
|
+
|
|
615
407
|
private startWatchdog(active: ActiveCell): void {
|
|
616
408
|
this.stopWatchdog();
|
|
617
409
|
if (!this.timeoutMs) return;
|
|
@@ -621,7 +413,7 @@ export class KernelClient {
|
|
|
621
413
|
if (quiet >= this.timeoutMs && !active.settled) {
|
|
622
414
|
active.timedOut = true;
|
|
623
415
|
this.interrupt();
|
|
624
|
-
//
|
|
416
|
+
// a cell that swallows the interrupt never replies; escalate to a kill so the queue frees
|
|
625
417
|
this.silenceKillTimer ??= setTimeout(() => {
|
|
626
418
|
if (!active.settled) this.kill();
|
|
627
419
|
}, SILENCE_KILL_GRACE_MS);
|
|
@@ -644,107 +436,91 @@ export class KernelClient {
|
|
|
644
436
|
}
|
|
645
437
|
}
|
|
646
438
|
|
|
647
|
-
/** Genuine KeyboardInterrupt via control
|
|
439
|
+
/** Genuine KeyboardInterrupt via the bridge's control channel; the kernel survives. */
|
|
648
440
|
interrupt(): void {
|
|
649
|
-
|
|
650
|
-
if (!active || active.settled) return;
|
|
651
|
-
this.control?.send(this.session.buildFrames("interrupt_request", {}, null));
|
|
441
|
+
this.send({ op: "interrupt" });
|
|
652
442
|
}
|
|
653
443
|
|
|
654
|
-
snapshot(maxBytes: number): Promise<SnapshotReply> {
|
|
655
|
-
return this.
|
|
656
|
-
const
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
)
|
|
665
|
-
|
|
666
|
-
if (payload === undefined) return { entries: [], failed: [], complete: false };
|
|
667
|
-
try {
|
|
668
|
-
const obj = JSON.parse(payload) as {
|
|
669
|
-
entries?: SnapshotEntry[];
|
|
670
|
-
failed?: { name: string; reason: string }[];
|
|
671
|
-
};
|
|
672
|
-
return { entries: obj.entries ?? [], failed: obj.failed ?? [], complete: true };
|
|
673
|
-
} catch {
|
|
674
|
-
return { entries: [], failed: [], complete: false };
|
|
444
|
+
snapshot(path: string, maxBytes: number): Promise<SnapshotReply> {
|
|
445
|
+
return this.request({ op: "snapshot", path, max_bytes: maxBytes }).then((msg) => {
|
|
446
|
+
const m = msg as {
|
|
447
|
+
saved?: string[];
|
|
448
|
+
entries?: { name: string; kind: "value" | "def"; payload: string }[];
|
|
449
|
+
failed?: { name: string; reason: string }[];
|
|
450
|
+
complete?: boolean;
|
|
451
|
+
bytes?: number;
|
|
452
|
+
error?: string;
|
|
453
|
+
};
|
|
454
|
+
if (m.error !== undefined || m.complete === undefined) {
|
|
455
|
+
throw new Error(String(m.error ?? "snapshot failed"));
|
|
675
456
|
}
|
|
457
|
+
return { saved: m.saved, entries: m.entries, failed: m.failed ?? [], complete: m.complete, bytes: m.bytes };
|
|
676
458
|
});
|
|
677
459
|
}
|
|
678
460
|
|
|
679
|
-
restore(
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
return { restored:
|
|
691
|
-
}
|
|
692
|
-
|
|
693
|
-
|
|
461
|
+
restore(path: string, defer = false): Promise<{ restored: string[]; failed: { name: string; reason: string }[] }> {
|
|
462
|
+
if (defer) {
|
|
463
|
+
// background revive: bypass the op queue — a queued restore would hold every user
|
|
464
|
+
// cell behind its completion. The bridge runs it at its first quiet gap instead.
|
|
465
|
+
const id = `r${this.nextId++}`;
|
|
466
|
+
return new Promise<BridgeMessage>((resolve, reject) => {
|
|
467
|
+
this.pending.set(id, { resolve, reject });
|
|
468
|
+
this.send({ op: "restore", path, defer: true, id });
|
|
469
|
+
}).then((msg) => {
|
|
470
|
+
const m = msg as { restored?: string[]; failed?: { name: string; reason: string }[]; error?: string };
|
|
471
|
+
if (m.error !== undefined) throw new Error(m.error);
|
|
472
|
+
return { restored: m.restored ?? [], failed: m.failed ?? [] };
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
return this.request({ op: "restore", path }).then((msg) => {
|
|
476
|
+
const m = msg as { restored?: string[]; failed?: { name: string; reason: string }[]; error?: string };
|
|
477
|
+
if (m.error !== undefined) throw new Error(m.error);
|
|
478
|
+
return { restored: m.restored ?? [], failed: m.failed ?? [] };
|
|
694
479
|
});
|
|
695
480
|
}
|
|
696
481
|
|
|
697
482
|
listNames(): Promise<string[]> {
|
|
698
|
-
return this.
|
|
699
|
-
const
|
|
700
|
-
|
|
701
|
-
});
|
|
702
|
-
const payload = res.payloads[NAMES_MIME];
|
|
703
|
-
if (payload === undefined) return [];
|
|
704
|
-
try {
|
|
705
|
-
const arr = JSON.parse(payload) as unknown;
|
|
706
|
-
return Array.isArray(arr) ? (arr as string[]) : [];
|
|
707
|
-
} catch {
|
|
708
|
-
return [];
|
|
709
|
-
}
|
|
483
|
+
return this.request({ op: "listNames" }).then((msg) => {
|
|
484
|
+
const names = (msg as { names?: unknown }).names;
|
|
485
|
+
return Array.isArray(names) ? (names as string[]) : [];
|
|
710
486
|
});
|
|
711
487
|
}
|
|
712
488
|
|
|
713
|
-
/** Graceful stop:
|
|
489
|
+
/** Graceful stop: shutdown op, then SIGKILL the process group as backstop. */
|
|
714
490
|
async shutdown(): Promise<void> {
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
this.control.send(this.session.buildFrames("shutdown_request", { restart: false }, null));
|
|
718
|
-
await Promise.race([this.childExit(), new Promise((resolve) => setTimeout(resolve, 2000).unref?.())]).catch(
|
|
719
|
-
() => {},
|
|
720
|
-
);
|
|
491
|
+
if (this.ready && this.child) {
|
|
492
|
+
await this.request({ op: "shutdown" }, 2000).catch(() => {});
|
|
721
493
|
}
|
|
722
494
|
this.kill();
|
|
723
495
|
}
|
|
724
496
|
|
|
725
|
-
private childExit(): Promise<void> {
|
|
726
|
-
const child = this.child;
|
|
727
|
-
if (!child) return Promise.resolve();
|
|
728
|
-
return child.exitCode !== null ? Promise.resolve() : new Promise((resolve) => child.once("exit", () => resolve()));
|
|
729
|
-
}
|
|
730
|
-
|
|
731
497
|
kill(): void {
|
|
732
498
|
this.stopWatchdog();
|
|
733
499
|
this.settleActive(new Error("kernel killed"));
|
|
734
|
-
this.
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
this.child?.kill("SIGKILL");
|
|
738
|
-
this.child = undefined;
|
|
739
|
-
if (this.connectionFilePath) {
|
|
500
|
+
const pid = this.child?.pid;
|
|
501
|
+
if (pid !== undefined) {
|
|
502
|
+
// group kill: the bridge's kernel is in the same process group (detached spawn)
|
|
740
503
|
try {
|
|
741
|
-
|
|
742
|
-
} catch {
|
|
743
|
-
|
|
504
|
+
process.kill(-pid, "SIGKILL");
|
|
505
|
+
} catch {
|
|
506
|
+
try {
|
|
507
|
+
process.kill(pid, "SIGKILL");
|
|
508
|
+
} catch {}
|
|
509
|
+
}
|
|
744
510
|
}
|
|
511
|
+
this.child = undefined;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/** Engine hook: an unexpected bridge exit (not a deliberate kill) should drop the instance. */
|
|
515
|
+
setOnUnexpectedExit(fn: () => void): void {
|
|
516
|
+
this._onUnexpectedExit = fn;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
get helperReport(): readonly HelperLoadResult[] {
|
|
520
|
+
return this.helperBootReport;
|
|
745
521
|
}
|
|
746
522
|
|
|
747
523
|
get isRunning(): boolean {
|
|
748
|
-
return this.ready && this.child !== undefined;
|
|
524
|
+
return this.ready && this.child !== undefined && this.child.exitCode === null;
|
|
749
525
|
}
|
|
750
526
|
}
|