nebula-notebook 0.1.1 → 0.2.1
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/README.md +110 -16
- package/dist/assets/errorwidget-Wy5ajPiM.js +5 -0
- package/dist/assets/fa-brands-400-CEJbCg16.woff +0 -0
- package/dist/assets/fa-brands-400-CSYNqBb_.ttf +0 -0
- package/dist/assets/fa-brands-400-DnkPfk3o.eot +0 -0
- package/dist/assets/fa-brands-400-UxlILjvJ.woff2 +0 -0
- package/dist/assets/fa-brands-400-cH1MgKbP.svg +3717 -0
- package/dist/assets/fa-regular-400-BhTwtT8w.eot +0 -0
- package/dist/assets/fa-regular-400-D1vz6WBx.ttf +0 -0
- package/dist/assets/fa-regular-400-DFnMcJPd.woff +0 -0
- package/dist/assets/fa-regular-400-DGzu1beS.woff2 +0 -0
- package/dist/assets/fa-regular-400-gwj8Pxq-.svg +801 -0
- package/dist/assets/fa-solid-900-B4ZZ7kfP.svg +5034 -0
- package/dist/assets/fa-solid-900-B6Axprfb.eot +0 -0
- package/dist/assets/fa-solid-900-BUswJgRo.woff2 +0 -0
- package/dist/assets/fa-solid-900-DOXgCApm.woff +0 -0
- package/dist/assets/fa-solid-900-mxuxnBEa.ttf +0 -0
- package/dist/assets/index-B_Bs5koo.js +1 -0
- package/dist/assets/index-BtWv4MIT.css +7 -0
- package/dist/assets/index-DK4nS74r.js +829 -0
- package/dist/assets/index-DeQN03nW.js +81 -0
- package/dist/assets/index-DzjpmUak.css +32 -0
- package/dist/assets/index-g6MjT-VG.js +1 -0
- package/dist/assets/services-shim-BirHVsih.js +33 -0
- package/dist/assets/viewlist-uomDf7I7.js +1 -0
- package/dist/assets/widgets-X7J3NxEn.css +1 -0
- package/dist/index.html +2 -2
- package/node-server/dist/auth/auth-middleware.js +22 -4
- package/node-server/dist/auth/auth-service.js +37 -7
- package/node-server/dist/cluster/client-registration.d.ts +12 -1
- package/node-server/dist/cluster/client-registration.js +38 -2
- package/node-server/dist/cluster/kernel-proxy.js +43 -12
- package/node-server/dist/cluster/server-registry.d.ts +17 -0
- package/node-server/dist/cluster/server-registry.js +57 -7
- package/node-server/dist/discovery/conda-locations.d.ts +73 -0
- package/node-server/dist/discovery/conda-locations.js +427 -0
- package/node-server/dist/discovery/discovery-service.d.ts +73 -4
- package/node-server/dist/discovery/discovery-service.js +211 -127
- package/node-server/dist/discovery/types.d.ts +0 -1
- package/node-server/dist/discovery/types.js +1 -2
- package/node-server/dist/environment.d.ts +12 -0
- package/node-server/dist/environment.js +104 -0
- package/node-server/dist/fs/fs-service.d.ts +86 -8
- package/node-server/dist/fs/fs-service.js +640 -99
- package/node-server/dist/fs/notebook-formats/percent.d.ts +25 -0
- package/node-server/dist/fs/notebook-formats/percent.js +286 -0
- package/node-server/dist/fs/notebook-formats/qmd.d.ts +29 -0
- package/node-server/dist/fs/notebook-formats/qmd.js +307 -0
- package/node-server/dist/fs/notebook-formats/registry.d.ts +12 -0
- package/node-server/dist/fs/notebook-formats/registry.js +77 -0
- package/node-server/dist/fs/notebook-formats/types.d.ts +37 -0
- package/node-server/dist/fs/notebook-formats/types.js +13 -0
- package/node-server/dist/fs/types.d.ts +9 -0
- package/node-server/dist/fs/types.js +8 -0
- package/node-server/dist/idle-exit.d.ts +52 -0
- package/node-server/dist/idle-exit.js +83 -0
- package/node-server/dist/index.js +198 -8
- package/node-server/dist/kernel/kernel-service.d.ts +130 -2
- package/node-server/dist/kernel/kernel-service.js +834 -64
- package/node-server/dist/kernel/kernelspec.d.ts +27 -0
- package/node-server/dist/kernel/kernelspec.js +75 -4
- package/node-server/dist/notebook/headless-handler.d.ts +12 -0
- package/node-server/dist/notebook/headless-handler.js +125 -29
- package/node-server/dist/notebook/operation-router.js +7 -2
- package/node-server/dist/notebook/undoRedoManager.d.ts +12 -1
- package/node-server/dist/notebook/undoRedoManager.js +49 -18
- package/node-server/dist/output/display-data.js +2 -0
- package/node-server/dist/private-tmp.d.ts +14 -0
- package/node-server/dist/private-tmp.js +70 -0
- package/node-server/dist/routes/autocomplete.d.ts +19 -0
- package/node-server/dist/routes/autocomplete.js +294 -0
- package/node-server/dist/routes/cluster.js +2 -2
- package/node-server/dist/routes/compute.d.ts +8 -0
- package/node-server/dist/routes/compute.js +136 -0
- package/node-server/dist/routes/fs.js +23 -6
- package/node-server/dist/routes/kernel.js +156 -3
- package/node-server/dist/routes/notebook.js +37 -3
- package/node-server/dist/routes/python.d.ts +1 -2
- package/node-server/dist/routes/python.js +139 -3
- package/node-server/dist/scheduler/allocation-service.d.ts +63 -0
- package/node-server/dist/scheduler/allocation-service.js +285 -0
- package/node-server/dist/scheduler/job-template.d.ts +30 -0
- package/node-server/dist/scheduler/job-template.js +85 -0
- package/node-server/dist/scheduler/mock-scheduler.d.ts +30 -0
- package/node-server/dist/scheduler/mock-scheduler.js +125 -0
- package/node-server/dist/scheduler/slurm-scheduler.d.ts +31 -0
- package/node-server/dist/scheduler/slurm-scheduler.js +406 -0
- package/node-server/dist/scheduler/types.d.ts +118 -0
- package/node-server/dist/scheduler/types.js +8 -0
- package/node-server/dist/scheduler/util.d.ts +7 -0
- package/node-server/dist/scheduler/util.js +20 -0
- package/node-server/dist/terminal/agent-registry.d.ts +87 -0
- package/node-server/dist/terminal/agent-registry.js +246 -0
- package/node-server/dist/terminal/binding-store.d.ts +57 -0
- package/node-server/dist/terminal/binding-store.js +0 -0
- package/node-server/dist/terminal/pty-manager.d.ts +23 -6
- package/node-server/dist/terminal/pty-manager.js +138 -19
- package/node-server/dist/terminal/server.js +155 -23
- package/node-server/dist/terminal/types.d.ts +2 -0
- package/node-server/dist/update-check.d.ts +20 -0
- package/node-server/dist/update-check.js +114 -0
- package/node-server/package.json +2 -0
- package/node_modules/nebula-autocomplete/README.md +145 -0
- package/node_modules/nebula-autocomplete/dist/backends/claude.d.ts +69 -0
- package/node_modules/nebula-autocomplete/dist/backends/claude.js +449 -0
- package/node_modules/nebula-autocomplete/dist/backends/codex.d.ts +45 -0
- package/node_modules/nebula-autocomplete/dist/backends/codex.js +108 -0
- package/node_modules/nebula-autocomplete/dist/client/fetcher.d.ts +16 -0
- package/node_modules/nebula-autocomplete/dist/client/fetcher.js +41 -0
- package/node_modules/nebula-autocomplete/dist/codemirror/ghostText.d.ts +43 -0
- package/node_modules/nebula-autocomplete/dist/codemirror/ghostText.js +175 -0
- package/node_modules/nebula-autocomplete/dist/core/engine.d.ts +17 -0
- package/node_modules/nebula-autocomplete/dist/core/engine.js +135 -0
- package/node_modules/nebula-autocomplete/dist/core/lru.d.ts +7 -0
- package/node_modules/nebula-autocomplete/dist/core/lru.js +26 -0
- package/node_modules/nebula-autocomplete/dist/core/prompt.d.ts +12 -0
- package/node_modules/nebula-autocomplete/dist/core/prompt.js +0 -0
- package/node_modules/nebula-autocomplete/dist/core/text.d.ts +53 -0
- package/node_modules/nebula-autocomplete/dist/core/text.js +169 -0
- package/node_modules/nebula-autocomplete/dist/index.d.ts +7 -0
- package/node_modules/nebula-autocomplete/dist/index.js +5 -0
- package/node_modules/nebula-autocomplete/dist/server/fastify.d.ts +40 -0
- package/node_modules/nebula-autocomplete/dist/server/fastify.js +52 -0
- package/node_modules/nebula-autocomplete/dist/transport.d.ts +55 -0
- package/node_modules/nebula-autocomplete/dist/transport.js +66 -0
- package/node_modules/nebula-autocomplete/dist/types.d.ts +103 -0
- package/node_modules/nebula-autocomplete/dist/types.js +1 -0
- package/node_modules/nebula-autocomplete/package.json +58 -0
- package/node_modules/nebula-autocomplete/server.d.ts +3 -0
- package/node_modules/nebula-autocomplete/server.js +3 -0
- package/package.json +18 -5
- package/scripts/postinstall.cjs +25 -5
- package/dist/assets/index-BvrHu37J.js +0 -648
- package/dist/assets/index-Dfj_xsKU.css +0 -32
- package/node-server/dist/llm/index.d.ts +0 -5
- package/node-server/dist/llm/index.js +0 -21
- package/node-server/dist/llm/llm-service.d.ts +0 -77
- package/node-server/dist/llm/llm-service.js +0 -454
- package/node-server/dist/llm/types.d.ts +0 -40
- package/node-server/dist/llm/types.js +0 -15
- package/node-server/dist/routes/llm.d.ts +0 -8
- package/node-server/dist/routes/llm.js +0 -105
|
@@ -39,7 +39,8 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
39
39
|
};
|
|
40
40
|
})();
|
|
41
41
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
42
|
-
exports.kernelService = exports.KernelService = void 0;
|
|
42
|
+
exports.kernelService = exports.KernelService = exports.IOPUB_QUEUE_TIMEOUT = exports.IOPUB_QUEUE_CLOSED = void 0;
|
|
43
|
+
exports.rewriteShellLines = rewriteShellLines;
|
|
43
44
|
const fs = __importStar(require("fs"));
|
|
44
45
|
const path = __importStar(require("path"));
|
|
45
46
|
const os = __importStar(require("os"));
|
|
@@ -53,7 +54,130 @@ const types_1 = require("./types");
|
|
|
53
54
|
const display_data_1 = require("../output/display-data");
|
|
54
55
|
const output_limits_1 = require("../config/output-limits");
|
|
55
56
|
const session_store_1 = require("./session-store");
|
|
57
|
+
const private_tmp_1 = require("../private-tmp");
|
|
56
58
|
const kernelspec_1 = require("./kernelspec");
|
|
59
|
+
const discovery_service_1 = require("../discovery/discovery-service");
|
|
60
|
+
const types_2 = require("../discovery/types");
|
|
61
|
+
/** Resolved by BoundedAsyncQueue.next() when the queue has been closed. */
|
|
62
|
+
exports.IOPUB_QUEUE_CLOSED = Symbol('iopub-queue-closed');
|
|
63
|
+
/** Resolved by BoundedAsyncQueue.next(timeoutMs) when the wait timed out. */
|
|
64
|
+
exports.IOPUB_QUEUE_TIMEOUT = Symbol('iopub-queue-timeout');
|
|
65
|
+
// Back-pressure bounds for the unified iopub reader. Parent-keyed queues carry
|
|
66
|
+
// full execution output streams, so they get a generous bound; drop-oldest
|
|
67
|
+
// keeps the terminal `status: idle` reachable so executions can't hang.
|
|
68
|
+
const MAX_PARENT_QUEUE_MESSAGES = 5000;
|
|
69
|
+
const MAX_CATCHALL_QUEUE_MESSAGES = 500;
|
|
70
|
+
// Light v1 comm-state store bound (late-joiner replay of open widget comms).
|
|
71
|
+
const MAX_TRACKED_COMMS_PER_SESSION = 512;
|
|
72
|
+
/**
|
|
73
|
+
* Bounded producer/consumer queue used to fan iopub messages out from the
|
|
74
|
+
* single reader loop. Never blocks the producer: when full, the oldest item
|
|
75
|
+
* is dropped (newest wins, so terminal status messages still arrive).
|
|
76
|
+
*/
|
|
77
|
+
class BoundedAsyncQueue {
|
|
78
|
+
maxSize;
|
|
79
|
+
items = [];
|
|
80
|
+
waiters = [];
|
|
81
|
+
closed = false;
|
|
82
|
+
dropped = 0;
|
|
83
|
+
constructor(maxSize) {
|
|
84
|
+
this.maxSize = maxSize;
|
|
85
|
+
}
|
|
86
|
+
push(item) {
|
|
87
|
+
if (this.closed)
|
|
88
|
+
return;
|
|
89
|
+
const waiter = this.waiters.shift();
|
|
90
|
+
if (waiter) {
|
|
91
|
+
waiter(item);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (this.items.length >= this.maxSize) {
|
|
95
|
+
this.items.shift();
|
|
96
|
+
this.dropped++;
|
|
97
|
+
if (this.dropped === 1 || this.dropped % 1000 === 0) {
|
|
98
|
+
console.warn(`[Kernel] iopub queue overflow: dropped ${this.dropped} message(s)`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
this.items.push(item);
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Take the next item. Resolves IOPUB_QUEUE_CLOSED once the queue is closed
|
|
105
|
+
* and drained. With timeoutMs, resolves IOPUB_QUEUE_TIMEOUT if nothing
|
|
106
|
+
* arrives in time — the waiter is deregistered on timeout so no message can
|
|
107
|
+
* be lost to an abandoned waiter.
|
|
108
|
+
*/
|
|
109
|
+
next(timeoutMs) {
|
|
110
|
+
if (this.items.length > 0) {
|
|
111
|
+
return Promise.resolve(this.items.shift());
|
|
112
|
+
}
|
|
113
|
+
if (this.closed) {
|
|
114
|
+
return Promise.resolve(exports.IOPUB_QUEUE_CLOSED);
|
|
115
|
+
}
|
|
116
|
+
return new Promise((resolve) => {
|
|
117
|
+
let timer = null;
|
|
118
|
+
const waiter = (value) => {
|
|
119
|
+
if (timer)
|
|
120
|
+
clearTimeout(timer);
|
|
121
|
+
resolve(value);
|
|
122
|
+
};
|
|
123
|
+
this.waiters.push(waiter);
|
|
124
|
+
if (timeoutMs !== undefined) {
|
|
125
|
+
timer = setTimeout(() => {
|
|
126
|
+
const idx = this.waiters.indexOf(waiter);
|
|
127
|
+
if (idx !== -1) {
|
|
128
|
+
this.waiters.splice(idx, 1);
|
|
129
|
+
resolve(exports.IOPUB_QUEUE_TIMEOUT);
|
|
130
|
+
}
|
|
131
|
+
}, timeoutMs);
|
|
132
|
+
timer.unref?.();
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
close() {
|
|
137
|
+
if (this.closed)
|
|
138
|
+
return;
|
|
139
|
+
this.closed = true;
|
|
140
|
+
this.items = [];
|
|
141
|
+
for (const waiter of this.waiters.splice(0)) {
|
|
142
|
+
waiter(exports.IOPUB_QUEUE_CLOSED);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Polyfill IPython-style `!command` lines for kernels whose language lacks
|
|
148
|
+
* them, by rewriting each such line into the language's own shell-call idiom
|
|
149
|
+
* before the code reaches the kernel. Because the rewritten code still runs
|
|
150
|
+
* in the kernel process, cwd, environment (conda env, loaded modules), and
|
|
151
|
+
* machine (allocation node) are all the kernel's — the same guarantees
|
|
152
|
+
* IPython's native `!` gives Python users.
|
|
153
|
+
*
|
|
154
|
+
* Python kernels are deliberately untouched: IPython's `!` is richer than a
|
|
155
|
+
* plain shell call (output capture `x = !ls`, `{var}` interpolation,
|
|
156
|
+
* streaming output) and a rewrite would break it.
|
|
157
|
+
*
|
|
158
|
+
* Only whole lines that start with `!` are rewritten. Known limits (v1):
|
|
159
|
+
* a line inside a multi-line string that happens to start with `!` would be
|
|
160
|
+
* rewritten too, and R's intern=TRUE buffers output until the command exits.
|
|
161
|
+
*/
|
|
162
|
+
function rewriteShellLines(code, language) {
|
|
163
|
+
const lang = (language || '').toLowerCase();
|
|
164
|
+
if (lang !== 'r')
|
|
165
|
+
return code; // python: native support; others: no safe idiom mapped yet
|
|
166
|
+
if (!/^[ \t]*!/m.test(code))
|
|
167
|
+
return code;
|
|
168
|
+
return code
|
|
169
|
+
.split('\n')
|
|
170
|
+
.map((line) => {
|
|
171
|
+
const m = line.match(/^([ \t]*)!(.+)$/);
|
|
172
|
+
if (!m)
|
|
173
|
+
return line;
|
|
174
|
+
// Merge stderr at the shell level — intern=TRUE captures only stdout.
|
|
175
|
+
const cmd = `${m[2].trim()} 2>&1`;
|
|
176
|
+
const rString = cmd.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
177
|
+
return `${m[1]}cat(system("${rString}", intern = TRUE), sep = "\\n")`;
|
|
178
|
+
})
|
|
179
|
+
.join('\n');
|
|
180
|
+
}
|
|
57
181
|
class KernelService {
|
|
58
182
|
sessions = new Map();
|
|
59
183
|
fileToSession = new Map();
|
|
@@ -77,11 +201,98 @@ class KernelService {
|
|
|
77
201
|
// Per-cell truncation tracking: sessionId -> cellId -> tracking state
|
|
78
202
|
cellOutputTracking = new Map();
|
|
79
203
|
executingCellIds = new Map();
|
|
204
|
+
// ---- Unified iopub reader state ----
|
|
205
|
+
// One long-lived reader loop per session owns ALL iopub .receive() calls.
|
|
206
|
+
// Consumers (execute loop, busy monitor) subscribe to bounded queues.
|
|
207
|
+
iopubReaders = new Map();
|
|
208
|
+
// sessionId -> parent msg_id -> queue (execute-parented message routing)
|
|
209
|
+
iopubParentSubscribers = new Map();
|
|
210
|
+
// sessionId -> catch-all queues (busy monitor and other any-parent consumers)
|
|
211
|
+
iopubCatchAllSubscribers = new Map();
|
|
212
|
+
// Comm-state store for late joiners: sessionId -> comm_id -> open info
|
|
213
|
+
commStates = new Map();
|
|
214
|
+
// ---- Unified shell reply reader state ----
|
|
215
|
+
// One long-lived reader loop per session owns ALL shell .receive() calls.
|
|
216
|
+
// Replies are dispatched by parent msg_id to one-shot waiters; a reply with
|
|
217
|
+
// no registered waiter (e.g. execute_reply — executions are iopub-driven —
|
|
218
|
+
// or a reply whose waiter already timed out) is dropped. This means a
|
|
219
|
+
// timed-out request can never hold the socket or eat a later reply.
|
|
220
|
+
shellReaders = new Map();
|
|
221
|
+
// sessionId -> request msg_id -> one-shot resolver
|
|
222
|
+
shellReplyWaiters = new Map();
|
|
80
223
|
constructor(config, sessionStore) {
|
|
81
224
|
this.config = { ...types_1.DEFAULT_CONFIG, ...config };
|
|
82
225
|
this.sessionStore = sessionStore || new session_store_1.SessionStore();
|
|
83
226
|
this.serverId = process.env.NEBULA_SERVER_ID || 'local';
|
|
84
227
|
this.serverInstanceId = process.env.NEBULA_SERVER_INSTANCE_ID || (0, uuid_1.v4)();
|
|
228
|
+
this.startLivenessSweep();
|
|
229
|
+
}
|
|
230
|
+
// ---- Kernel death notification --------------------------------------
|
|
231
|
+
// Process exit/error handlers (and the liveness sweep below) notify
|
|
232
|
+
// listeners so the route layer can broadcast status to WebSocket clients.
|
|
233
|
+
// Without this, a crashed kernel leaves every client showing 'busy'.
|
|
234
|
+
deadListeners = [];
|
|
235
|
+
onSessionDead(cb) {
|
|
236
|
+
this.deadListeners.push(cb);
|
|
237
|
+
}
|
|
238
|
+
notifySessionDead(sessionId) {
|
|
239
|
+
for (const cb of this.deadListeners) {
|
|
240
|
+
try {
|
|
241
|
+
cb(sessionId);
|
|
242
|
+
}
|
|
243
|
+
catch (err) {
|
|
244
|
+
console.error('[Kernel] onSessionDead listener failed:', err);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
// ---- Comm message notification (ipywidgets et al.) -------------------
|
|
249
|
+
// The unified iopub reader demuxes comm_open/comm_msg/comm_close messages
|
|
250
|
+
// (regardless of parent — including idle-time traffic from widget
|
|
251
|
+
// observers) to these listeners so the route layer can broadcast them.
|
|
252
|
+
commListeners = [];
|
|
253
|
+
onComm(cb) {
|
|
254
|
+
this.commListeners.push(cb);
|
|
255
|
+
}
|
|
256
|
+
notifyComm(sessionId, comm) {
|
|
257
|
+
for (const cb of this.commListeners) {
|
|
258
|
+
try {
|
|
259
|
+
cb(sessionId, comm);
|
|
260
|
+
}
|
|
261
|
+
catch (err) {
|
|
262
|
+
console.error('[Kernel] onComm listener failed:', err);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
// ---- Liveness sweep ---------------------------------------------------
|
|
267
|
+
// ChildProcess handles get 'exit' events, but REATTACHED sessions only
|
|
268
|
+
// have a PID — nothing fires when that process dies. Sweep PIDs so a dead
|
|
269
|
+
// reattached kernel is noticed within ~15s instead of at the next execute.
|
|
270
|
+
livenessTimer = null;
|
|
271
|
+
startLivenessSweep() {
|
|
272
|
+
this.livenessTimer = setInterval(() => this.sweepLiveness(), 15000);
|
|
273
|
+
// Never keep the process alive just for the sweep
|
|
274
|
+
this.livenessTimer.unref?.();
|
|
275
|
+
}
|
|
276
|
+
/** One liveness pass (exposed for tests; normally driven by the timer). */
|
|
277
|
+
sweepLiveness() {
|
|
278
|
+
for (const [sessionId, session] of this.sessions.entries()) {
|
|
279
|
+
if (session.status === 'dead')
|
|
280
|
+
continue;
|
|
281
|
+
// Sessions with a live ChildProcess handle are covered by 'exit'
|
|
282
|
+
if (this.kernelProcesses.has(sessionId))
|
|
283
|
+
continue;
|
|
284
|
+
if (!session.pid)
|
|
285
|
+
continue;
|
|
286
|
+
try {
|
|
287
|
+
process.kill(session.pid, 0);
|
|
288
|
+
}
|
|
289
|
+
catch {
|
|
290
|
+
console.log(`[Kernel] Liveness sweep: reattached kernel ${sessionId} (pid ${session.pid}) is gone`);
|
|
291
|
+
session.status = 'dead';
|
|
292
|
+
this.cleanupKernelResources(sessionId);
|
|
293
|
+
this.notifySessionDead(sessionId);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
85
296
|
}
|
|
86
297
|
setServerIdentity(serverId, serverInstanceId) {
|
|
87
298
|
this.serverId = serverId;
|
|
@@ -108,6 +319,7 @@ class KernelService {
|
|
|
108
319
|
}
|
|
109
320
|
console.error(`Kernel ${sessionId} error:`, err);
|
|
110
321
|
session.status = 'dead';
|
|
322
|
+
this.notifySessionDead(sessionId);
|
|
111
323
|
};
|
|
112
324
|
const onExit = (code) => {
|
|
113
325
|
if (this.kernelProcesses.get(sessionId) !== proc) {
|
|
@@ -116,6 +328,7 @@ class KernelService {
|
|
|
116
328
|
console.log(`Kernel ${sessionId} exited with code ${code}`);
|
|
117
329
|
session.status = 'dead';
|
|
118
330
|
this.cleanupKernelResources(sessionId);
|
|
331
|
+
this.notifySessionDead(sessionId);
|
|
119
332
|
};
|
|
120
333
|
this.processLifecycleHandlers.set(proc, { onError, onExit });
|
|
121
334
|
proc.on('error', onError);
|
|
@@ -288,6 +501,9 @@ class KernelService {
|
|
|
288
501
|
console.log(`[Kernel] Failed to reattach session ${sessionId}: ${err instanceof Error ? err.message : err}`);
|
|
289
502
|
}
|
|
290
503
|
}
|
|
504
|
+
// Rows just marked terminated (process gone) are pure bookkeeping —
|
|
505
|
+
// remove them now so they never surface as "orphaned" in the UI.
|
|
506
|
+
await this.autoCleanupDeadSessions().catch((err) => console.warn('[Kernel] Auto-cleanup after reattach failed:', err));
|
|
291
507
|
return {
|
|
292
508
|
attempted: orphanedSessions.length,
|
|
293
509
|
reattached,
|
|
@@ -396,8 +612,9 @@ class KernelService {
|
|
|
396
612
|
hbPort,
|
|
397
613
|
};
|
|
398
614
|
// Write connection file
|
|
399
|
-
|
|
400
|
-
|
|
615
|
+
// Per-user private dir (0700): connection files carry the HMAC key —
|
|
616
|
+
// world-readable means arbitrary code execution as this user.
|
|
617
|
+
const connDir = (0, private_tmp_1.privateTmpDir)('kernels');
|
|
401
618
|
const connFile = path.join(connDir, `kernel-${sessionId}.json`);
|
|
402
619
|
const connData = {
|
|
403
620
|
ip: config.ip,
|
|
@@ -410,7 +627,7 @@ class KernelService {
|
|
|
410
627
|
iopub_port: config.iopubPort,
|
|
411
628
|
hb_port: config.hbPort,
|
|
412
629
|
};
|
|
413
|
-
fs.writeFileSync(connFile, JSON.stringify(connData, null, 2));
|
|
630
|
+
fs.writeFileSync(connFile, JSON.stringify(connData, null, 2), { mode: 0o600 });
|
|
414
631
|
return { config, filePath: connFile };
|
|
415
632
|
}
|
|
416
633
|
/**
|
|
@@ -418,7 +635,27 @@ class KernelService {
|
|
|
418
635
|
*/
|
|
419
636
|
async startKernel(options = {}) {
|
|
420
637
|
const kernelName = options.kernelName || 'python3';
|
|
421
|
-
|
|
638
|
+
// env:<pythonPath> kernels raw-launch a Python environment directly
|
|
639
|
+
// (VSCode-style) — no kernelspec registration involved. Preflight here so
|
|
640
|
+
// callers get a crisp, coded error instead of a spawn failure + ready
|
|
641
|
+
// timeout: python_not_found for a dead path, needs_ipykernel (with an
|
|
642
|
+
// ecosystem-appropriate install hint) when the env can't host a kernel yet.
|
|
643
|
+
const envPython = (0, kernelspec_1.envKernelPythonPath)(kernelName);
|
|
644
|
+
let spec;
|
|
645
|
+
if (envPython) {
|
|
646
|
+
if (!fs.existsSync(envPython)) {
|
|
647
|
+
throw new types_2.KernelProvisionError(`Python not found: ${envPython}`, 'python_not_found');
|
|
648
|
+
}
|
|
649
|
+
const probe = await discovery_service_1.pythonDiscovery.probeForKernel(envPython);
|
|
650
|
+
if (!probe.hasIpykernel) {
|
|
651
|
+
throw new types_2.KernelProvisionError(`ipykernel is not installed in ${envPython}`, 'needs_ipykernel', probe.installHint || undefined);
|
|
652
|
+
}
|
|
653
|
+
const cached = discovery_service_1.pythonDiscovery.getFromCache()?.[envPython];
|
|
654
|
+
spec = (0, kernelspec_1.makeEnvKernelSpec)(envPython, cached?.displayName);
|
|
655
|
+
}
|
|
656
|
+
else {
|
|
657
|
+
spec = (0, kernelspec_1.getKernelSpec)(kernelName);
|
|
658
|
+
}
|
|
422
659
|
if (!spec) {
|
|
423
660
|
throw new Error(`Kernel '${kernelName}' not found`);
|
|
424
661
|
}
|
|
@@ -523,6 +760,12 @@ class KernelService {
|
|
|
523
760
|
iopub.connect(iopubEndpoint);
|
|
524
761
|
iopub.subscribe('');
|
|
525
762
|
this.zmqSockets.set(sessionId, { shell, iopub });
|
|
763
|
+
// Start the unified readers: from here on, these loops own every
|
|
764
|
+
// iopub.receive()/shell.receive() for the session. The shell reader must
|
|
765
|
+
// be live before the kernel_info probing below, which awaits its reply
|
|
766
|
+
// through the waiter mechanism.
|
|
767
|
+
this.startIopubReader(sessionId, iopub);
|
|
768
|
+
this.startShellReader(sessionId, shell);
|
|
526
769
|
// Send kernel_info_request to verify connectivity
|
|
527
770
|
const startTime = Date.now();
|
|
528
771
|
const actualTimeout = timeoutSeconds ?? this.config.startupTimeoutSeconds;
|
|
@@ -545,6 +788,374 @@ class KernelService {
|
|
|
545
788
|
}
|
|
546
789
|
throw new Error(`Kernel did not start within ${actualTimeout} seconds`);
|
|
547
790
|
}
|
|
791
|
+
// ---- Unified iopub reader ---------------------------------------------
|
|
792
|
+
// Exactly one loop per session calls iopub.receive(). It demuxes:
|
|
793
|
+
// - comm_open/comm_msg/comm_close (any parent) -> comm store + onComm
|
|
794
|
+
// - everything else -> the parent-keyed queue (if a consumer registered
|
|
795
|
+
// for that parent msg_id) AND every catch-all queue (busy monitor).
|
|
796
|
+
// The loop never spins: receive() blocks until a message arrives, and a
|
|
797
|
+
// socket close (stop/restart/cleanup) rejects the pending receive, which
|
|
798
|
+
// exits the loop and closes all subscriber queues.
|
|
799
|
+
startIopubReader(sessionId, iopub) {
|
|
800
|
+
// Never allow two loops for one session (e.g. restart replaces sockets).
|
|
801
|
+
this.stopIopubReader(sessionId);
|
|
802
|
+
const reader = { stopped: false };
|
|
803
|
+
this.iopubReaders.set(sessionId, reader);
|
|
804
|
+
void (async () => {
|
|
805
|
+
try {
|
|
806
|
+
while (!reader.stopped) {
|
|
807
|
+
let frames;
|
|
808
|
+
try {
|
|
809
|
+
frames = await iopub.receive();
|
|
810
|
+
}
|
|
811
|
+
catch {
|
|
812
|
+
// Socket closed or errored (kernel stop/restart/cleanup) — exit.
|
|
813
|
+
break;
|
|
814
|
+
}
|
|
815
|
+
if (reader.stopped)
|
|
816
|
+
break;
|
|
817
|
+
try {
|
|
818
|
+
const parsed = this.parseJupyterMessage(frames);
|
|
819
|
+
this.dispatchIopubMessage(sessionId, parsed);
|
|
820
|
+
}
|
|
821
|
+
catch (err) {
|
|
822
|
+
console.warn(`[Kernel] Dropping unparseable iopub message for ${sessionId}:`, err);
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
finally {
|
|
827
|
+
// Only tear down if we're still the current reader — on restart a new
|
|
828
|
+
// reader (with fresh subscriber queues) may already have taken over.
|
|
829
|
+
if (this.iopubReaders.get(sessionId) === reader) {
|
|
830
|
+
this.iopubReaders.delete(sessionId);
|
|
831
|
+
this.closeIopubSubscribers(sessionId);
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
})();
|
|
835
|
+
}
|
|
836
|
+
stopIopubReader(sessionId) {
|
|
837
|
+
const reader = this.iopubReaders.get(sessionId);
|
|
838
|
+
if (reader) {
|
|
839
|
+
reader.stopped = true;
|
|
840
|
+
this.iopubReaders.delete(sessionId);
|
|
841
|
+
}
|
|
842
|
+
this.closeIopubSubscribers(sessionId);
|
|
843
|
+
}
|
|
844
|
+
closeIopubSubscribers(sessionId) {
|
|
845
|
+
const parents = this.iopubParentSubscribers.get(sessionId);
|
|
846
|
+
if (parents) {
|
|
847
|
+
for (const queue of parents.values())
|
|
848
|
+
queue.close();
|
|
849
|
+
this.iopubParentSubscribers.delete(sessionId);
|
|
850
|
+
}
|
|
851
|
+
const catchAll = this.iopubCatchAllSubscribers.get(sessionId);
|
|
852
|
+
if (catchAll) {
|
|
853
|
+
for (const queue of catchAll)
|
|
854
|
+
queue.close();
|
|
855
|
+
this.iopubCatchAllSubscribers.delete(sessionId);
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
dispatchIopubMessage(sessionId, msg) {
|
|
859
|
+
if (msg.msgType === 'comm_open' || msg.msgType === 'comm_msg' || msg.msgType === 'comm_close') {
|
|
860
|
+
this.handleKernelCommMessage(sessionId, msg);
|
|
861
|
+
return;
|
|
862
|
+
}
|
|
863
|
+
if (msg.parentMsgId) {
|
|
864
|
+
this.iopubParentSubscribers.get(sessionId)?.get(msg.parentMsgId)?.push(msg);
|
|
865
|
+
}
|
|
866
|
+
const catchAll = this.iopubCatchAllSubscribers.get(sessionId);
|
|
867
|
+
if (catchAll) {
|
|
868
|
+
for (const queue of catchAll)
|
|
869
|
+
queue.push(msg);
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
/**
|
|
873
|
+
* Subscribe to iopub messages parented by a specific msg_id. Must be called
|
|
874
|
+
* BEFORE sending the request so no reply can slip past the demux. Callers
|
|
875
|
+
* must unsubscribe in a finally block.
|
|
876
|
+
*/
|
|
877
|
+
subscribeIopubParent(sessionId, parentMsgId) {
|
|
878
|
+
let parents = this.iopubParentSubscribers.get(sessionId);
|
|
879
|
+
if (!parents) {
|
|
880
|
+
parents = new Map();
|
|
881
|
+
this.iopubParentSubscribers.set(sessionId, parents);
|
|
882
|
+
}
|
|
883
|
+
const queue = new BoundedAsyncQueue(MAX_PARENT_QUEUE_MESSAGES);
|
|
884
|
+
parents.set(parentMsgId, queue);
|
|
885
|
+
// If no reader loop is alive (it crashed or was never started), close the
|
|
886
|
+
// queue immediately so consumers fail fast instead of hanging forever.
|
|
887
|
+
if (!this.iopubReaders.has(sessionId)) {
|
|
888
|
+
queue.close();
|
|
889
|
+
}
|
|
890
|
+
return queue;
|
|
891
|
+
}
|
|
892
|
+
unsubscribeIopubParent(sessionId, parentMsgId) {
|
|
893
|
+
const parents = this.iopubParentSubscribers.get(sessionId);
|
|
894
|
+
const queue = parents?.get(parentMsgId);
|
|
895
|
+
if (!parents || !queue)
|
|
896
|
+
return;
|
|
897
|
+
queue.close();
|
|
898
|
+
parents.delete(parentMsgId);
|
|
899
|
+
if (parents.size === 0) {
|
|
900
|
+
this.iopubParentSubscribers.delete(sessionId);
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
/** Subscribe to ALL iopub messages for a session (any parent). */
|
|
904
|
+
subscribeIopubCatchAll(sessionId) {
|
|
905
|
+
let queues = this.iopubCatchAllSubscribers.get(sessionId);
|
|
906
|
+
if (!queues) {
|
|
907
|
+
queues = new Set();
|
|
908
|
+
this.iopubCatchAllSubscribers.set(sessionId, queues);
|
|
909
|
+
}
|
|
910
|
+
const queue = new BoundedAsyncQueue(MAX_CATCHALL_QUEUE_MESSAGES);
|
|
911
|
+
queues.add(queue);
|
|
912
|
+
if (!this.iopubReaders.has(sessionId)) {
|
|
913
|
+
queue.close();
|
|
914
|
+
}
|
|
915
|
+
return queue;
|
|
916
|
+
}
|
|
917
|
+
unsubscribeIopubCatchAll(sessionId, queue) {
|
|
918
|
+
queue.close();
|
|
919
|
+
const queues = this.iopubCatchAllSubscribers.get(sessionId);
|
|
920
|
+
if (!queues)
|
|
921
|
+
return;
|
|
922
|
+
queues.delete(queue);
|
|
923
|
+
if (queues.size === 0) {
|
|
924
|
+
this.iopubCatchAllSubscribers.delete(sessionId);
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
// ---- Unified shell reply reader -----------------------------------------
|
|
928
|
+
// Mirrors the iopub reader: exactly one loop per session calls
|
|
929
|
+
// shell.receive(). Consumers register a one-shot waiter keyed by their
|
|
930
|
+
// request msg_id BEFORE sending, then await the reply with a timeout. A
|
|
931
|
+
// timeout only deregisters the waiter — the reader keeps owning the socket,
|
|
932
|
+
// so a slow reply (e.g. complete_request against a busy kernel) can never
|
|
933
|
+
// block or swallow a later request's reply.
|
|
934
|
+
startShellReader(sessionId, shell) {
|
|
935
|
+
// Never allow two loops for one session (e.g. restart replaces sockets).
|
|
936
|
+
this.stopShellReader(sessionId);
|
|
937
|
+
const reader = { stopped: false };
|
|
938
|
+
this.shellReaders.set(sessionId, reader);
|
|
939
|
+
void (async () => {
|
|
940
|
+
try {
|
|
941
|
+
while (!reader.stopped) {
|
|
942
|
+
let frames;
|
|
943
|
+
try {
|
|
944
|
+
frames = await shell.receive();
|
|
945
|
+
}
|
|
946
|
+
catch {
|
|
947
|
+
// Socket closed or errored (kernel stop/restart/cleanup) — exit.
|
|
948
|
+
break;
|
|
949
|
+
}
|
|
950
|
+
if (reader.stopped)
|
|
951
|
+
break;
|
|
952
|
+
try {
|
|
953
|
+
const parsed = this.parseJupyterMessage(frames);
|
|
954
|
+
const waiters = this.shellReplyWaiters.get(sessionId);
|
|
955
|
+
const waiter = parsed.parentMsgId ? waiters?.get(parsed.parentMsgId) : undefined;
|
|
956
|
+
if (waiters && waiter) {
|
|
957
|
+
waiters.delete(parsed.parentMsgId);
|
|
958
|
+
if (waiters.size === 0)
|
|
959
|
+
this.shellReplyWaiters.delete(sessionId);
|
|
960
|
+
waiter(parsed);
|
|
961
|
+
}
|
|
962
|
+
else {
|
|
963
|
+
// No waiter: execute_reply (executions are iopub-driven), or the
|
|
964
|
+
// waiter timed out and deregistered. Drop it.
|
|
965
|
+
console.debug(`[Kernel] Dropping unclaimed shell ${parsed.msgType} for ${sessionId}`);
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
catch (err) {
|
|
969
|
+
console.warn(`[Kernel] Dropping unparseable shell message for ${sessionId}:`, err);
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
finally {
|
|
974
|
+
// Only tear down if we're still the current reader — on restart a new
|
|
975
|
+
// reader (with fresh waiters) may already have taken over.
|
|
976
|
+
if (this.shellReaders.get(sessionId) === reader) {
|
|
977
|
+
this.shellReaders.delete(sessionId);
|
|
978
|
+
this.closeShellReplyWaiters(sessionId);
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
})();
|
|
982
|
+
}
|
|
983
|
+
stopShellReader(sessionId) {
|
|
984
|
+
const reader = this.shellReaders.get(sessionId);
|
|
985
|
+
if (reader) {
|
|
986
|
+
reader.stopped = true;
|
|
987
|
+
this.shellReaders.delete(sessionId);
|
|
988
|
+
}
|
|
989
|
+
this.closeShellReplyWaiters(sessionId);
|
|
990
|
+
}
|
|
991
|
+
closeShellReplyWaiters(sessionId) {
|
|
992
|
+
const waiters = this.shellReplyWaiters.get(sessionId);
|
|
993
|
+
if (!waiters)
|
|
994
|
+
return;
|
|
995
|
+
this.shellReplyWaiters.delete(sessionId);
|
|
996
|
+
for (const resolve of waiters.values()) {
|
|
997
|
+
resolve('closed');
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
/**
|
|
1001
|
+
* Register a one-shot waiter for the shell reply parented by msgId. Must be
|
|
1002
|
+
* called BEFORE sending the request so the reply cannot slip past the
|
|
1003
|
+
* dispatch. Resolves 'closed' immediately if no reader loop is alive, or
|
|
1004
|
+
* later when the reader stops (kernel stop/restart/cleanup).
|
|
1005
|
+
*/
|
|
1006
|
+
registerShellReplyWaiter(sessionId, msgId) {
|
|
1007
|
+
if (!this.shellReaders.has(sessionId)) {
|
|
1008
|
+
return Promise.resolve('closed');
|
|
1009
|
+
}
|
|
1010
|
+
let waiters = this.shellReplyWaiters.get(sessionId);
|
|
1011
|
+
if (!waiters) {
|
|
1012
|
+
waiters = new Map();
|
|
1013
|
+
this.shellReplyWaiters.set(sessionId, waiters);
|
|
1014
|
+
}
|
|
1015
|
+
const map = waiters;
|
|
1016
|
+
return new Promise((resolve) => {
|
|
1017
|
+
map.set(msgId, resolve);
|
|
1018
|
+
});
|
|
1019
|
+
}
|
|
1020
|
+
removeShellReplyWaiter(sessionId, msgId) {
|
|
1021
|
+
const waiters = this.shellReplyWaiters.get(sessionId);
|
|
1022
|
+
if (!waiters)
|
|
1023
|
+
return;
|
|
1024
|
+
waiters.delete(msgId);
|
|
1025
|
+
if (waiters.size === 0) {
|
|
1026
|
+
this.shellReplyWaiters.delete(sessionId);
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
/**
|
|
1030
|
+
* Await a previously registered shell reply with a timeout. The waiter is
|
|
1031
|
+
* always deregistered on the way out (timeout, reply, or error), so a late
|
|
1032
|
+
* reply is dropped by the reader instead of leaking a waiter.
|
|
1033
|
+
*/
|
|
1034
|
+
async waitForShellReply(sessionId, msgId, replyPromise, timeoutMs) {
|
|
1035
|
+
let timer = null;
|
|
1036
|
+
const timeout = new Promise((resolve) => {
|
|
1037
|
+
timer = setTimeout(() => resolve('timeout'), timeoutMs);
|
|
1038
|
+
timer.unref?.();
|
|
1039
|
+
});
|
|
1040
|
+
try {
|
|
1041
|
+
return await Promise.race([replyPromise, timeout]);
|
|
1042
|
+
}
|
|
1043
|
+
finally {
|
|
1044
|
+
if (timer)
|
|
1045
|
+
clearTimeout(timer);
|
|
1046
|
+
this.removeShellReplyWaiter(sessionId, msgId);
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
// ---- Comm handling ------------------------------------------------------
|
|
1050
|
+
handleKernelCommMessage(sessionId, msg) {
|
|
1051
|
+
const commId = typeof msg.content.comm_id === 'string' ? msg.content.comm_id : null;
|
|
1052
|
+
if (!commId)
|
|
1053
|
+
return;
|
|
1054
|
+
const data = (msg.content.data && typeof msg.content.data === 'object')
|
|
1055
|
+
? msg.content.data
|
|
1056
|
+
: {};
|
|
1057
|
+
const targetName = typeof msg.content.target_name === 'string' ? msg.content.target_name : undefined;
|
|
1058
|
+
// Maintain the comm-state store so late-joining clients can discover
|
|
1059
|
+
// open comms (comm_info) after the fact.
|
|
1060
|
+
if (msg.msgType === 'comm_open') {
|
|
1061
|
+
this.rememberOpenComm(sessionId, commId, targetName ?? '', data);
|
|
1062
|
+
}
|
|
1063
|
+
else if (msg.msgType === 'comm_close') {
|
|
1064
|
+
this.commStates.get(sessionId)?.delete(commId);
|
|
1065
|
+
}
|
|
1066
|
+
const comm = {
|
|
1067
|
+
msgType: msg.msgType,
|
|
1068
|
+
commId,
|
|
1069
|
+
data,
|
|
1070
|
+
};
|
|
1071
|
+
if (targetName !== undefined) {
|
|
1072
|
+
comm.targetName = targetName;
|
|
1073
|
+
}
|
|
1074
|
+
if (msg.buffers.length > 0) {
|
|
1075
|
+
comm.buffers = msg.buffers.map((b) => b.toString('base64'));
|
|
1076
|
+
}
|
|
1077
|
+
this.notifyComm(sessionId, comm);
|
|
1078
|
+
}
|
|
1079
|
+
rememberOpenComm(sessionId, commId, targetName, openData) {
|
|
1080
|
+
let comms = this.commStates.get(sessionId);
|
|
1081
|
+
if (!comms) {
|
|
1082
|
+
comms = new Map();
|
|
1083
|
+
this.commStates.set(sessionId, comms);
|
|
1084
|
+
}
|
|
1085
|
+
// Bounded store: evict the oldest tracked comm when full (light v1).
|
|
1086
|
+
if (!comms.has(commId) && comms.size >= MAX_TRACKED_COMMS_PER_SESSION) {
|
|
1087
|
+
const oldest = comms.keys().next().value;
|
|
1088
|
+
if (oldest !== undefined) {
|
|
1089
|
+
comms.delete(oldest);
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
comms.set(commId, { targetName, openData });
|
|
1093
|
+
}
|
|
1094
|
+
/**
|
|
1095
|
+
* Open comms known for a session (for late-joining clients).
|
|
1096
|
+
* Returns comm_id -> { targetName, openData } where openData is the last
|
|
1097
|
+
* state-carrying comm_open payload observed for that comm.
|
|
1098
|
+
*/
|
|
1099
|
+
getOpenComms(sessionId) {
|
|
1100
|
+
const comms = this.commStates.get(sessionId);
|
|
1101
|
+
const result = {};
|
|
1102
|
+
if (!comms)
|
|
1103
|
+
return result;
|
|
1104
|
+
for (const [commId, info] of comms) {
|
|
1105
|
+
result[commId] = { targetName: info.targetName, openData: info.openData };
|
|
1106
|
+
}
|
|
1107
|
+
return result;
|
|
1108
|
+
}
|
|
1109
|
+
/**
|
|
1110
|
+
* Send a comm message (comm_open / comm_msg / comm_close) to the kernel on
|
|
1111
|
+
* the shell channel. Comm messages produce NO shell reply, so the queued
|
|
1112
|
+
* slot releases as soon as the send completes — we never block waiting for
|
|
1113
|
+
* a reply that will not come.
|
|
1114
|
+
*
|
|
1115
|
+
* @param buffers Optional binary buffer frames, base64-encoded.
|
|
1116
|
+
*/
|
|
1117
|
+
async sendCommMessage(sessionId, msgType, content, buffers) {
|
|
1118
|
+
const session = this.sessions.get(sessionId);
|
|
1119
|
+
if (!session || !session.connectionConfig) {
|
|
1120
|
+
throw new Error(`Session ${sessionId} not found`);
|
|
1121
|
+
}
|
|
1122
|
+
if (!this.zmq || !this.zmqSockets.has(sessionId)) {
|
|
1123
|
+
throw new Error('ZeroMQ not available for kernel communication');
|
|
1124
|
+
}
|
|
1125
|
+
const binaryBuffers = (buffers ?? []).map((b64) => Buffer.from(b64, 'base64'));
|
|
1126
|
+
const header = {
|
|
1127
|
+
msg_id: (0, uuid_1.v4)(),
|
|
1128
|
+
session: sessionId,
|
|
1129
|
+
username: 'nebula',
|
|
1130
|
+
msg_type: msgType,
|
|
1131
|
+
version: '5.3',
|
|
1132
|
+
date: new Date().toISOString(),
|
|
1133
|
+
};
|
|
1134
|
+
const message = this.createJupyterMessage(header, {}, content, session.connectionConfig.key, binaryBuffers);
|
|
1135
|
+
// Track client-opened comms too, so comm_info reflects both directions.
|
|
1136
|
+
const commId = typeof content.comm_id === 'string' ? content.comm_id : null;
|
|
1137
|
+
if (commId) {
|
|
1138
|
+
if (msgType === 'comm_open') {
|
|
1139
|
+
const targetName = typeof content.target_name === 'string' ? content.target_name : '';
|
|
1140
|
+
const data = (content.data && typeof content.data === 'object')
|
|
1141
|
+
? content.data
|
|
1142
|
+
: {};
|
|
1143
|
+
this.rememberOpenComm(sessionId, commId, targetName, data);
|
|
1144
|
+
}
|
|
1145
|
+
else if (msgType === 'comm_close') {
|
|
1146
|
+
this.commStates.get(sessionId)?.delete(commId);
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
// Route through the shell queue so we never interleave with an in-flight
|
|
1150
|
+
// request/reply pair on the Dealer socket. Send-only: no receive() here.
|
|
1151
|
+
await this.enqueueShellRequest(sessionId, async () => {
|
|
1152
|
+
const sockets = this.zmqSockets.get(sessionId);
|
|
1153
|
+
if (!sockets) {
|
|
1154
|
+
throw new Error(`Session ${sessionId} sockets are gone (kernel stopped?)`);
|
|
1155
|
+
}
|
|
1156
|
+
await sockets.shell.send(message);
|
|
1157
|
+
});
|
|
1158
|
+
}
|
|
548
1159
|
/**
|
|
549
1160
|
* Monitor a busy reattached kernel on iopub. When its current execution
|
|
550
1161
|
* finishes (status: idle on iopub), verify shell connectivity and update
|
|
@@ -552,19 +1163,17 @@ class KernelService {
|
|
|
552
1163
|
*/
|
|
553
1164
|
monitorBusyKernel(sessionId) {
|
|
554
1165
|
const session = this.sessions.get(sessionId);
|
|
555
|
-
|
|
556
|
-
if (!session || !sockets)
|
|
1166
|
+
if (!session || !this.zmqSockets.has(sessionId))
|
|
557
1167
|
return;
|
|
558
1168
|
console.log(`[Kernel] Monitoring busy kernel ${sessionId} for idle transition...`);
|
|
1169
|
+
// Consume from the unified reader's catch-all channel — the monitor cares
|
|
1170
|
+
// about status messages from executions we did not initiate (any parent).
|
|
1171
|
+
const channel = this.subscribeIopubCatchAll(sessionId);
|
|
559
1172
|
const poll = async () => {
|
|
560
1173
|
try {
|
|
561
1174
|
while (session.status === 'busy') {
|
|
562
|
-
const
|
|
563
|
-
|
|
564
|
-
setTimeout(() => resolve(null), 5000);
|
|
565
|
-
});
|
|
566
|
-
const frames = await Promise.race([receivePromise, timeoutPromise]);
|
|
567
|
-
if (!frames) {
|
|
1175
|
+
const msg = await channel.next(5000);
|
|
1176
|
+
if (msg === exports.IOPUB_QUEUE_TIMEOUT) {
|
|
568
1177
|
// Periodic liveness check
|
|
569
1178
|
if (session.pid && !this.isPidAlive(session.pid)) {
|
|
570
1179
|
console.log(`[Kernel] Busy kernel ${sessionId} PID ${session.pid} died`);
|
|
@@ -573,8 +1182,11 @@ class KernelService {
|
|
|
573
1182
|
}
|
|
574
1183
|
continue;
|
|
575
1184
|
}
|
|
576
|
-
|
|
577
|
-
|
|
1185
|
+
if (msg === exports.IOPUB_QUEUE_CLOSED) {
|
|
1186
|
+
// Kernel stopped/restarted while monitoring — nothing more to do.
|
|
1187
|
+
return;
|
|
1188
|
+
}
|
|
1189
|
+
if (msg.msgType === 'status' && msg.content.execution_state === 'idle') {
|
|
578
1190
|
break;
|
|
579
1191
|
}
|
|
580
1192
|
}
|
|
@@ -593,6 +1205,9 @@ class KernelService {
|
|
|
593
1205
|
console.error(`[Kernel] Error monitoring busy kernel ${sessionId}:`, err);
|
|
594
1206
|
session.status = 'idle'; // Assume idle — next operation will detect real issues
|
|
595
1207
|
}
|
|
1208
|
+
finally {
|
|
1209
|
+
this.unsubscribeIopubCatchAll(sessionId, channel);
|
|
1210
|
+
}
|
|
596
1211
|
};
|
|
597
1212
|
void poll();
|
|
598
1213
|
}
|
|
@@ -616,35 +1231,42 @@ class KernelService {
|
|
|
616
1231
|
date: new Date().toISOString(),
|
|
617
1232
|
};
|
|
618
1233
|
const message = this.createJupyterMessage(header, {}, {}, session.connectionConfig.key);
|
|
619
|
-
|
|
620
|
-
//
|
|
621
|
-
const
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
}
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
1234
|
+
// Register the waiter before sending so the reply cannot race the
|
|
1235
|
+
// dispatch, then wait for kernel_info_reply with timeout.
|
|
1236
|
+
const replyPromise = this.registerShellReplyWaiter(sessionId, msgId);
|
|
1237
|
+
try {
|
|
1238
|
+
await this.enqueueShellRequest(sessionId, () => sockets.shell.send(message));
|
|
1239
|
+
}
|
|
1240
|
+
catch (err) {
|
|
1241
|
+
this.removeShellReplyWaiter(sessionId, msgId);
|
|
1242
|
+
throw err;
|
|
1243
|
+
}
|
|
1244
|
+
const reply = await this.waitForShellReply(sessionId, msgId, replyPromise, 1000);
|
|
1245
|
+
if (reply === 'timeout' || reply === 'closed') {
|
|
1246
|
+
throw new Error('Kernel not responding');
|
|
1247
|
+
}
|
|
1248
|
+
if (reply.msgType !== 'kernel_info_reply') {
|
|
1249
|
+
throw new Error(`Unexpected message type: ${reply.msgType}`);
|
|
629
1250
|
}
|
|
630
1251
|
// Kernel is ready!
|
|
631
1252
|
}
|
|
632
1253
|
/**
|
|
633
1254
|
* Create a Jupyter protocol message
|
|
634
1255
|
*/
|
|
635
|
-
createJupyterMessage(header, parentHeader, content, key) {
|
|
1256
|
+
createJupyterMessage(header, parentHeader, content, key, buffers = []) {
|
|
636
1257
|
const headerJson = JSON.stringify(header);
|
|
637
1258
|
const parentHeaderJson = JSON.stringify(parentHeader);
|
|
638
1259
|
const metadataJson = JSON.stringify({});
|
|
639
1260
|
const contentJson = JSON.stringify(content);
|
|
640
|
-
// Create HMAC signature
|
|
1261
|
+
// Create HMAC signature (buffers are NOT part of the signature per the
|
|
1262
|
+
// Jupyter wire protocol — only header/parent_header/metadata/content).
|
|
641
1263
|
const hmac = crypto.createHmac('sha256', key);
|
|
642
1264
|
hmac.update(headerJson);
|
|
643
1265
|
hmac.update(parentHeaderJson);
|
|
644
1266
|
hmac.update(metadataJson);
|
|
645
1267
|
hmac.update(contentJson);
|
|
646
1268
|
const signature = hmac.digest('hex');
|
|
647
|
-
// Build multipart message
|
|
1269
|
+
// Build multipart message; binary buffer frames follow content
|
|
648
1270
|
return [
|
|
649
1271
|
Buffer.from('<IDS|MSG>'),
|
|
650
1272
|
Buffer.from(signature),
|
|
@@ -652,13 +1274,31 @@ class KernelService {
|
|
|
652
1274
|
Buffer.from(parentHeaderJson),
|
|
653
1275
|
Buffer.from(metadataJson),
|
|
654
1276
|
Buffer.from(contentJson),
|
|
1277
|
+
...buffers,
|
|
655
1278
|
];
|
|
656
1279
|
}
|
|
657
1280
|
/**
|
|
658
1281
|
* Get or create kernel for a file (one notebook = one kernel).
|
|
659
1282
|
* Returns whether a new session was created.
|
|
660
1283
|
*/
|
|
1284
|
+
/** In-flight create/attach per normalized file path (single-flight).
|
|
1285
|
+
* Without this, two near-simultaneous requests (UI re-render + agent op,
|
|
1286
|
+
* double-click) both miss the fileToSession check and spawn TWO kernel
|
|
1287
|
+
* processes — the second overwrites the mapping and the first leaks. */
|
|
1288
|
+
inflightKernelCreates = new Map();
|
|
661
1289
|
async getOrCreateKernel(filePath, kernelName = 'python3') {
|
|
1290
|
+
const key = this.normalizePath(filePath);
|
|
1291
|
+
const inflight = this.inflightKernelCreates.get(key);
|
|
1292
|
+
if (inflight) {
|
|
1293
|
+
return inflight;
|
|
1294
|
+
}
|
|
1295
|
+
const run = this.getOrCreateKernelInternal(key, kernelName).finally(() => {
|
|
1296
|
+
this.inflightKernelCreates.delete(key);
|
|
1297
|
+
});
|
|
1298
|
+
this.inflightKernelCreates.set(key, run);
|
|
1299
|
+
return run;
|
|
1300
|
+
}
|
|
1301
|
+
async getOrCreateKernelInternal(filePath, kernelName) {
|
|
662
1302
|
const normalizedPath = this.normalizePath(filePath);
|
|
663
1303
|
// Check for existing session in fileToSession map
|
|
664
1304
|
let existingSessionId = this.fileToSession.get(normalizedPath);
|
|
@@ -769,6 +1409,14 @@ class KernelService {
|
|
|
769
1409
|
* Execute code in a kernel session
|
|
770
1410
|
*/
|
|
771
1411
|
async executeCode(sessionId, code, onOutput, onQueueInfo, cellId) {
|
|
1412
|
+
// Polyfill `!command` lines for kernels whose language lacks them (see
|
|
1413
|
+
// rewriteShellLines) — rewritten code still runs IN the kernel process,
|
|
1414
|
+
// so cwd/env/node are the kernel's, exactly like IPython's native `!`.
|
|
1415
|
+
{
|
|
1416
|
+
const kernelName = this.sessions.get(sessionId)?.kernelName;
|
|
1417
|
+
const spec = kernelName ? (0, kernelspec_1.getKernelSpec)(kernelName) : null;
|
|
1418
|
+
code = rewriteShellLines(code, spec?.language);
|
|
1419
|
+
}
|
|
772
1420
|
const queueInfo = this.reserveExecutionSlot(sessionId);
|
|
773
1421
|
if (onQueueInfo) {
|
|
774
1422
|
onQueueInfo(queueInfo);
|
|
@@ -815,7 +1463,11 @@ class KernelService {
|
|
|
815
1463
|
return run;
|
|
816
1464
|
}
|
|
817
1465
|
/**
|
|
818
|
-
*
|
|
1466
|
+
* Serialize shell socket SENDS. Receives are owned by the unified shell
|
|
1467
|
+
* reader, so tasks queued here must be send-only and release the slot as
|
|
1468
|
+
* soon as the send completes (register any reply waiter BEFORE enqueueing,
|
|
1469
|
+
* await the reply AFTER the slot is released). Holding the slot across a
|
|
1470
|
+
* reply wait would let one slow request delay every later send.
|
|
819
1471
|
*/
|
|
820
1472
|
enqueueShellRequest(sessionId, task) {
|
|
821
1473
|
const previous = this.shellRequestQueues.get(sessionId) || Promise.resolve();
|
|
@@ -868,9 +1520,12 @@ class KernelService {
|
|
|
868
1520
|
session.status = 'busy';
|
|
869
1521
|
const sockets = this.zmqSockets.get(sessionId);
|
|
870
1522
|
let execCount = null;
|
|
1523
|
+
// Subscribe to iopub messages for this execution BEFORE sending the
|
|
1524
|
+
// request so the unified reader can't demux replies into the void.
|
|
1525
|
+
const msgId = (0, uuid_1.v4)();
|
|
1526
|
+
const channel = this.subscribeIopubParent(sessionId, msgId);
|
|
871
1527
|
try {
|
|
872
1528
|
// Create execute_request message
|
|
873
|
-
const msgId = (0, uuid_1.v4)();
|
|
874
1529
|
const header = {
|
|
875
1530
|
msg_id: msgId,
|
|
876
1531
|
session: sessionId,
|
|
@@ -888,15 +1543,20 @@ class KernelService {
|
|
|
888
1543
|
stop_on_error: true,
|
|
889
1544
|
};
|
|
890
1545
|
const message = this.createJupyterMessage(header, {}, content, session.connectionConfig.key);
|
|
891
|
-
|
|
892
|
-
//
|
|
1546
|
+
// Serialize the send with other shell sends; no shell reply waiter —
|
|
1547
|
+
// execution progress and completion are driven entirely by iopub
|
|
1548
|
+
// (execute_reply is dropped by the shell reader as unclaimed).
|
|
1549
|
+
await this.enqueueShellRequest(sessionId, () => sockets.shell.send(message));
|
|
1550
|
+
// Process iopub messages for our execution (already parent-filtered by
|
|
1551
|
+
// the unified reader; comm messages are demuxed to onComm separately)
|
|
893
1552
|
while (true) {
|
|
894
|
-
const
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
1553
|
+
const msg = await channel.next();
|
|
1554
|
+
if (typeof msg === 'symbol') {
|
|
1555
|
+
// Queue closed: kernel stopped/restarted mid-execution. Surface the
|
|
1556
|
+
// same way a socket-close used to — via the catch block below.
|
|
1557
|
+
throw new Error('Kernel iopub stream closed during execution');
|
|
899
1558
|
}
|
|
1559
|
+
const { msgType, content: msgContent } = msg;
|
|
900
1560
|
// Handle different message types
|
|
901
1561
|
if (msgType === 'execute_input') {
|
|
902
1562
|
execCount = msgContent.execution_count;
|
|
@@ -931,11 +1591,24 @@ class KernelService {
|
|
|
931
1591
|
return { status: 'ok', executionCount: execCount };
|
|
932
1592
|
}
|
|
933
1593
|
catch (err) {
|
|
1594
|
+
// A restart closes the old sockets while we may be mid-receive: surface
|
|
1595
|
+
// that as a clean cancellation, and never clobber 'starting'/'dead'
|
|
1596
|
+
// status set concurrently by the restart/exit paths (re-read through
|
|
1597
|
+
// the map — TS narrowing can't see cross-async mutation).
|
|
1598
|
+
const currentStatus = this.sessions.get(sessionId)?.status;
|
|
1599
|
+
if (currentStatus === 'starting' || currentStatus === 'dead') {
|
|
1600
|
+
const msg = currentStatus === 'dead' ? 'Kernel died during execution' : 'Kernel was restarted — execution cancelled';
|
|
1601
|
+
await onOutput({ type: 'error', content: msg });
|
|
1602
|
+
return { status: 'error', executionCount: null, error: msg };
|
|
1603
|
+
}
|
|
934
1604
|
session.status = 'idle';
|
|
935
1605
|
const errorMsg = this.formatExecutionError(err);
|
|
936
1606
|
await onOutput({ type: 'error', content: errorMsg });
|
|
937
1607
|
return { status: 'error', executionCount: null, error: errorMsg };
|
|
938
1608
|
}
|
|
1609
|
+
finally {
|
|
1610
|
+
this.unsubscribeIopubParent(sessionId, msgId);
|
|
1611
|
+
}
|
|
939
1612
|
}
|
|
940
1613
|
/**
|
|
941
1614
|
* Request code completion from the kernel
|
|
@@ -949,11 +1622,13 @@ class KernelService {
|
|
|
949
1622
|
if (!this.zmq || !this.zmqSockets.has(sessionId)) {
|
|
950
1623
|
return { status: 'error', matches: [], cursor_start: cursorPos, cursor_end: cursorPos };
|
|
951
1624
|
}
|
|
952
|
-
|
|
953
|
-
return this.enqueueShellRequest(sessionId, () => this.completeInternal(sessionId, code, cursorPos));
|
|
1625
|
+
return this.completeInternal(sessionId, code, cursorPos);
|
|
954
1626
|
}
|
|
955
1627
|
/**
|
|
956
|
-
* Internal completion implementation
|
|
1628
|
+
* Internal completion implementation. The send is serialized through the
|
|
1629
|
+
* shell queue; the reply is awaited via the unified shell reader, so a
|
|
1630
|
+
* timeout here simply drops the waiter and can never block or swallow a
|
|
1631
|
+
* later request's reply.
|
|
957
1632
|
*/
|
|
958
1633
|
async completeInternal(sessionId, code, cursorPos) {
|
|
959
1634
|
const session = this.sessions.get(sessionId);
|
|
@@ -977,30 +1652,30 @@ class KernelService {
|
|
|
977
1652
|
cursor_pos: cursorPos,
|
|
978
1653
|
};
|
|
979
1654
|
const message = this.createJupyterMessage(header, {}, content, session.connectionConfig.key);
|
|
980
|
-
|
|
981
|
-
//
|
|
982
|
-
const
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
matches: msgContent.matches || [],
|
|
993
|
-
cursor_start: msgContent.cursor_start ?? cursorPos,
|
|
994
|
-
cursor_end: msgContent.cursor_end ?? cursorPos,
|
|
995
|
-
};
|
|
996
|
-
}
|
|
997
|
-
}
|
|
998
|
-
})();
|
|
999
|
-
const result = await Promise.race([receivePromise, timeoutPromise]);
|
|
1000
|
-
if (result === null) {
|
|
1655
|
+
// Register the waiter before sending so the reply cannot race the
|
|
1656
|
+
// dispatch, then wait for complete_reply with timeout.
|
|
1657
|
+
const replyPromise = this.registerShellReplyWaiter(sessionId, msgId);
|
|
1658
|
+
try {
|
|
1659
|
+
await this.enqueueShellRequest(sessionId, () => sockets.shell.send(message));
|
|
1660
|
+
}
|
|
1661
|
+
catch (err) {
|
|
1662
|
+
this.removeShellReplyWaiter(sessionId, msgId);
|
|
1663
|
+
throw err;
|
|
1664
|
+
}
|
|
1665
|
+
const reply = await this.waitForShellReply(sessionId, msgId, replyPromise, 3000);
|
|
1666
|
+
if (reply === 'timeout') {
|
|
1001
1667
|
return { status: 'timeout', matches: [], cursor_start: cursorPos, cursor_end: cursorPos };
|
|
1002
1668
|
}
|
|
1003
|
-
|
|
1669
|
+
if (reply === 'closed' || reply.msgType !== 'complete_reply') {
|
|
1670
|
+
return { status: 'error', matches: [], cursor_start: cursorPos, cursor_end: cursorPos };
|
|
1671
|
+
}
|
|
1672
|
+
const msgContent = reply.content;
|
|
1673
|
+
return {
|
|
1674
|
+
status: msgContent.status || 'ok',
|
|
1675
|
+
matches: msgContent.matches || [],
|
|
1676
|
+
cursor_start: msgContent.cursor_start ?? cursorPos,
|
|
1677
|
+
cursor_end: msgContent.cursor_end ?? cursorPos,
|
|
1678
|
+
};
|
|
1004
1679
|
}
|
|
1005
1680
|
catch (err) {
|
|
1006
1681
|
console.error('[KernelService] Completion error:', err);
|
|
@@ -1028,10 +1703,13 @@ class KernelService {
|
|
|
1028
1703
|
const parentHeader = JSON.parse(frames[delimIdx + 3].toString());
|
|
1029
1704
|
const metadata = JSON.parse(frames[delimIdx + 4].toString());
|
|
1030
1705
|
const content = JSON.parse(frames[delimIdx + 5].toString());
|
|
1706
|
+
// Any frames after content are binary buffers (e.g. ipywidgets comm data)
|
|
1707
|
+
const buffers = frames.slice(delimIdx + 6);
|
|
1031
1708
|
return {
|
|
1032
1709
|
msgType: header.msg_type,
|
|
1033
1710
|
content,
|
|
1034
1711
|
parentMsgId: parentHeader.msg_id || '',
|
|
1712
|
+
buffers,
|
|
1035
1713
|
};
|
|
1036
1714
|
}
|
|
1037
1715
|
/**
|
|
@@ -1281,6 +1959,10 @@ class KernelService {
|
|
|
1281
1959
|
// stopKernel() already handles these, but unexpected process exits and failed startups
|
|
1282
1960
|
// may call cleanup directly. Ensure we don't leak sockets or stale ChildProcess handles.
|
|
1283
1961
|
this.detachProcessLifecycle(this.kernelProcesses.get(sessionId));
|
|
1962
|
+
// Stop the unified readers and release any waiting consumers
|
|
1963
|
+
this.stopIopubReader(sessionId);
|
|
1964
|
+
this.stopShellReader(sessionId);
|
|
1965
|
+
this.commStates.delete(sessionId);
|
|
1284
1966
|
const sockets = this.zmqSockets.get(sessionId);
|
|
1285
1967
|
if (sockets) {
|
|
1286
1968
|
try {
|
|
@@ -1329,6 +2011,9 @@ class KernelService {
|
|
|
1329
2011
|
this.fileToSession.delete(session.filePath);
|
|
1330
2012
|
}
|
|
1331
2013
|
this.detachProcessLifecycle(this.kernelProcesses.get(sessionId));
|
|
2014
|
+
this.stopIopubReader(sessionId);
|
|
2015
|
+
this.stopShellReader(sessionId);
|
|
2016
|
+
this.commStates.delete(sessionId);
|
|
1332
2017
|
const sockets = this.zmqSockets.get(sessionId);
|
|
1333
2018
|
if (sockets) {
|
|
1334
2019
|
try {
|
|
@@ -1393,6 +2078,22 @@ class KernelService {
|
|
|
1393
2078
|
const { kernelName, filePath, connectionFile: oldConnFile } = session;
|
|
1394
2079
|
// Set status to restarting
|
|
1395
2080
|
session.status = 'starting';
|
|
2081
|
+
// Restart hygiene: drop buffered outputs from the old process (otherwise
|
|
2082
|
+
// stale outputs resurface on the next sync), and reset execution queues
|
|
2083
|
+
// so nothing chains onto promises from the old kernel. In-flight executes
|
|
2084
|
+
// fail when the old sockets close below; executeCode surfaces that as a
|
|
2085
|
+
// clean 'Kernel was restarted' error because status is 'starting'.
|
|
2086
|
+
this.cellOutputBuffers.delete(sessionId);
|
|
2087
|
+
this.cellOutputTracking.delete(sessionId);
|
|
2088
|
+
this.executionQueues.delete(sessionId);
|
|
2089
|
+
this.shellRequestQueues.delete(sessionId);
|
|
2090
|
+
// Stop the old kernel's readers and drop its comm state — widget
|
|
2091
|
+
// comms do not survive a restart. In-flight executes see their queues
|
|
2092
|
+
// close and fail with a clean 'Kernel was restarted' error; pending
|
|
2093
|
+
// shell reply waiters resolve 'closed'.
|
|
2094
|
+
this.stopIopubReader(sessionId);
|
|
2095
|
+
this.stopShellReader(sessionId);
|
|
2096
|
+
this.commStates.delete(sessionId);
|
|
1396
2097
|
// Close ZeroMQ sockets
|
|
1397
2098
|
const sockets = this.zmqSockets.get(sessionId);
|
|
1398
2099
|
if (sockets) {
|
|
@@ -1542,6 +2243,23 @@ class KernelService {
|
|
|
1542
2243
|
createdAt: session.createdAt,
|
|
1543
2244
|
};
|
|
1544
2245
|
}
|
|
2246
|
+
/**
|
|
2247
|
+
* Activity snapshot for the idle auto-release monitor (client mode):
|
|
2248
|
+
* whether any kernel is busy/starting, and the most recent kernel activity
|
|
2249
|
+
* across sessions in ms since epoch (sessions track it in seconds).
|
|
2250
|
+
*/
|
|
2251
|
+
getIdleSnapshot() {
|
|
2252
|
+
let anyBusy = false;
|
|
2253
|
+
let lastActivityMs = null;
|
|
2254
|
+
for (const session of this.sessions.values()) {
|
|
2255
|
+
if (session.status === 'busy' || session.status === 'starting')
|
|
2256
|
+
anyBusy = true;
|
|
2257
|
+
const ms = session.lastActivity * 1000;
|
|
2258
|
+
if (lastActivityMs === null || ms > lastActivityMs)
|
|
2259
|
+
lastActivityMs = ms;
|
|
2260
|
+
}
|
|
2261
|
+
return { anyBusy, lastActivityMs };
|
|
2262
|
+
}
|
|
1545
2263
|
/**
|
|
1546
2264
|
* Get all sessions
|
|
1547
2265
|
*/
|
|
@@ -1574,7 +2292,16 @@ class KernelService {
|
|
|
1574
2292
|
*/
|
|
1575
2293
|
getDeadSessions() {
|
|
1576
2294
|
const deadSessions = this.sessionStore.getDeadSessions(this.serverId);
|
|
1577
|
-
return deadSessions
|
|
2295
|
+
return deadSessions
|
|
2296
|
+
// A session that is live in memory is NOT dead — e.g. it was
|
|
2297
|
+
// successfully reattached (or is mid-reattach) after a server restart
|
|
2298
|
+
// and the DB 'orphaned' status is stale or racing the reattach loop.
|
|
2299
|
+
// Only sessions with no live entry (or a dead one) are true orphans.
|
|
2300
|
+
.filter(s => {
|
|
2301
|
+
const live = this.sessions.get(s.sessionId);
|
|
2302
|
+
return !live || live.status === 'dead';
|
|
2303
|
+
})
|
|
2304
|
+
.map(s => ({
|
|
1578
2305
|
sessionId: s.sessionId,
|
|
1579
2306
|
kernelName: s.kernelName,
|
|
1580
2307
|
filePath: s.filePath,
|
|
@@ -1582,11 +2309,50 @@ class KernelService {
|
|
|
1582
2309
|
lastHeartbeat: s.lastHeartbeat,
|
|
1583
2310
|
}));
|
|
1584
2311
|
}
|
|
2312
|
+
/**
|
|
2313
|
+
* Auto-delete dead session rows whose kernel process is CONFIRMED gone
|
|
2314
|
+
* (no PID, PID not running, or PID reused by another process). These are
|
|
2315
|
+
* pure bookkeeping — nothing to kill, nothing to lose — so they need no
|
|
2316
|
+
* user confirmation. Rows whose PID is still alive are kept for the
|
|
2317
|
+
* explicit "Clean Up" flow (killing a process should stay a user action,
|
|
2318
|
+
* and legacy rows without a start-time fingerprint can't be verified).
|
|
2319
|
+
*/
|
|
2320
|
+
async autoCleanupDeadSessions() {
|
|
2321
|
+
const dead = this.sessionStore.getDeadSessions(this.serverId).filter(s => {
|
|
2322
|
+
const live = this.sessions.get(s.sessionId);
|
|
2323
|
+
return !live || live.status === 'dead';
|
|
2324
|
+
});
|
|
2325
|
+
const deletable = [];
|
|
2326
|
+
for (const s of dead) {
|
|
2327
|
+
if (!s.kernelPid || !this.isPidAlive(s.kernelPid)) {
|
|
2328
|
+
deletable.push(s.sessionId);
|
|
2329
|
+
continue;
|
|
2330
|
+
}
|
|
2331
|
+
if (s.kernelStartTime) {
|
|
2332
|
+
const currentStartTime = await this.getProcessStartTime(s.kernelPid);
|
|
2333
|
+
if (!currentStartTime || currentStartTime !== s.kernelStartTime) {
|
|
2334
|
+
// PID reused by an unrelated process — the kernel itself is gone
|
|
2335
|
+
deletable.push(s.sessionId);
|
|
2336
|
+
}
|
|
2337
|
+
}
|
|
2338
|
+
}
|
|
2339
|
+
if (deletable.length > 0) {
|
|
2340
|
+
this.sessionStore.deleteSessions(deletable);
|
|
2341
|
+
console.log(`[Kernel] Auto-cleaned ${deletable.length} dead session record(s) (process gone)`);
|
|
2342
|
+
}
|
|
2343
|
+
return deletable.length;
|
|
2344
|
+
}
|
|
1585
2345
|
/**
|
|
1586
2346
|
* Cleanup dead sessions by deleting them from the database
|
|
1587
2347
|
*/
|
|
1588
2348
|
async cleanupDeadSessions(sessionIds) {
|
|
1589
|
-
|
|
2349
|
+
// Same live-session guard as getDeadSessions: never clean up a session
|
|
2350
|
+
// that is currently live in memory (e.g. reattached after a restart while
|
|
2351
|
+
// its DB row still says 'orphaned').
|
|
2352
|
+
const deadSessions = this.sessionStore.getDeadSessions(this.serverId).filter(s => {
|
|
2353
|
+
const live = this.sessions.get(s.sessionId);
|
|
2354
|
+
return !live || live.status === 'dead';
|
|
2355
|
+
});
|
|
1590
2356
|
const targets = sessionIds && sessionIds.length > 0
|
|
1591
2357
|
? deadSessions.filter(s => sessionIds.includes(s.sessionId))
|
|
1592
2358
|
: deadSessions;
|
|
@@ -1635,6 +2401,10 @@ class KernelService {
|
|
|
1635
2401
|
* If preserveKernels is true, keep kernel processes running and close the session store.
|
|
1636
2402
|
*/
|
|
1637
2403
|
async shutdown(options) {
|
|
2404
|
+
if (this.livenessTimer) {
|
|
2405
|
+
clearInterval(this.livenessTimer);
|
|
2406
|
+
this.livenessTimer = null;
|
|
2407
|
+
}
|
|
1638
2408
|
if (options?.preserveKernels) {
|
|
1639
2409
|
try {
|
|
1640
2410
|
this.sessionStore.markAllOrphaned(this.serverId, this.serverInstanceId);
|