nebula-notebook 0.1.0 → 0.2.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/README.md +124 -24
- package/dist/assets/errorwidget-C4r2j2DQ.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-7-YBurka.js +716 -0
- package/dist/assets/index-BtWv4MIT.css +7 -0
- package/dist/assets/index-CFBUnxSZ.css +32 -0
- package/dist/assets/index-CsHoPQy-.js +1 -0
- package/dist/assets/index-D5w21_Z8.js +81 -0
- package/dist/assets/index-Day3QcNs.js +1 -0
- package/dist/assets/services-shim-D6p_A67v.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/cluster/client-registration.js +3 -0
- package/node-server/dist/cluster/kernel-proxy.js +24 -9
- package/node-server/dist/cluster/server-registry.d.ts +8 -0
- package/node-server/dist/cluster/server-registry.js +31 -7
- package/node-server/dist/fs/fs-service.d.ts +55 -7
- package/node-server/dist/fs/fs-service.js +489 -80
- 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/idle-exit.d.ts +52 -0
- package/node-server/dist/idle-exit.js +83 -0
- package/node-server/dist/index.js +129 -9
- package/node-server/dist/kernel/kernel-service.d.ts +113 -2
- package/node-server/dist/kernel/kernel-service.js +762 -60
- package/node-server/dist/notebook/cell-hash.d.ts +13 -0
- package/node-server/dist/notebook/cell-hash.js +26 -0
- package/node-server/dist/notebook/headless-handler.d.ts +9 -0
- package/node-server/dist/notebook/headless-handler.js +124 -23
- package/node-server/dist/notebook/operation-router.d.ts +36 -0
- package/node-server/dist/notebook/operation-router.js +224 -9
- package/node-server/dist/notebook/undoRedoManager.d.ts +4 -1
- package/node-server/dist/notebook/undoRedoManager.js +10 -2
- package/node-server/dist/output/display-data.js +2 -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 +2 -2
- package/node-server/dist/routes/kernel.js +115 -3
- package/node-server/dist/routes/notebook.js +35 -1
- package/node-server/dist/scheduler/allocation-service.d.ts +43 -0
- package/node-server/dist/scheduler/allocation-service.js +169 -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 +121 -0
- package/node-server/dist/scheduler/slurm-scheduler.d.ts +31 -0
- package/node-server/dist/scheduler/slurm-scheduler.js +393 -0
- package/node-server/dist/scheduler/types.d.ts +117 -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/pty-manager.js +8 -0
- package/node-server/dist/terminal/server.js +43 -2
- package/node-server/dist/update-check.d.ts +20 -0
- package/node-server/dist/update-check.js +114 -0
- package/node-server/package.json +1 -3
- package/package.json +3 -5
- package/dist/assets/index-C1h_sArD.css +0 -32
- package/dist/assets/index-CDSTBon8.js +0 -658
|
@@ -39,7 +39,7 @@ 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
43
|
const fs = __importStar(require("fs"));
|
|
44
44
|
const path = __importStar(require("path"));
|
|
45
45
|
const os = __importStar(require("os"));
|
|
@@ -54,6 +54,91 @@ const display_data_1 = require("../output/display-data");
|
|
|
54
54
|
const output_limits_1 = require("../config/output-limits");
|
|
55
55
|
const session_store_1 = require("./session-store");
|
|
56
56
|
const kernelspec_1 = require("./kernelspec");
|
|
57
|
+
/** Resolved by BoundedAsyncQueue.next() when the queue has been closed. */
|
|
58
|
+
exports.IOPUB_QUEUE_CLOSED = Symbol('iopub-queue-closed');
|
|
59
|
+
/** Resolved by BoundedAsyncQueue.next(timeoutMs) when the wait timed out. */
|
|
60
|
+
exports.IOPUB_QUEUE_TIMEOUT = Symbol('iopub-queue-timeout');
|
|
61
|
+
// Back-pressure bounds for the unified iopub reader. Parent-keyed queues carry
|
|
62
|
+
// full execution output streams, so they get a generous bound; drop-oldest
|
|
63
|
+
// keeps the terminal `status: idle` reachable so executions can't hang.
|
|
64
|
+
const MAX_PARENT_QUEUE_MESSAGES = 5000;
|
|
65
|
+
const MAX_CATCHALL_QUEUE_MESSAGES = 500;
|
|
66
|
+
// Light v1 comm-state store bound (late-joiner replay of open widget comms).
|
|
67
|
+
const MAX_TRACKED_COMMS_PER_SESSION = 512;
|
|
68
|
+
/**
|
|
69
|
+
* Bounded producer/consumer queue used to fan iopub messages out from the
|
|
70
|
+
* single reader loop. Never blocks the producer: when full, the oldest item
|
|
71
|
+
* is dropped (newest wins, so terminal status messages still arrive).
|
|
72
|
+
*/
|
|
73
|
+
class BoundedAsyncQueue {
|
|
74
|
+
maxSize;
|
|
75
|
+
items = [];
|
|
76
|
+
waiters = [];
|
|
77
|
+
closed = false;
|
|
78
|
+
dropped = 0;
|
|
79
|
+
constructor(maxSize) {
|
|
80
|
+
this.maxSize = maxSize;
|
|
81
|
+
}
|
|
82
|
+
push(item) {
|
|
83
|
+
if (this.closed)
|
|
84
|
+
return;
|
|
85
|
+
const waiter = this.waiters.shift();
|
|
86
|
+
if (waiter) {
|
|
87
|
+
waiter(item);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (this.items.length >= this.maxSize) {
|
|
91
|
+
this.items.shift();
|
|
92
|
+
this.dropped++;
|
|
93
|
+
if (this.dropped === 1 || this.dropped % 1000 === 0) {
|
|
94
|
+
console.warn(`[Kernel] iopub queue overflow: dropped ${this.dropped} message(s)`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
this.items.push(item);
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Take the next item. Resolves IOPUB_QUEUE_CLOSED once the queue is closed
|
|
101
|
+
* and drained. With timeoutMs, resolves IOPUB_QUEUE_TIMEOUT if nothing
|
|
102
|
+
* arrives in time — the waiter is deregistered on timeout so no message can
|
|
103
|
+
* be lost to an abandoned waiter.
|
|
104
|
+
*/
|
|
105
|
+
next(timeoutMs) {
|
|
106
|
+
if (this.items.length > 0) {
|
|
107
|
+
return Promise.resolve(this.items.shift());
|
|
108
|
+
}
|
|
109
|
+
if (this.closed) {
|
|
110
|
+
return Promise.resolve(exports.IOPUB_QUEUE_CLOSED);
|
|
111
|
+
}
|
|
112
|
+
return new Promise((resolve) => {
|
|
113
|
+
let timer = null;
|
|
114
|
+
const waiter = (value) => {
|
|
115
|
+
if (timer)
|
|
116
|
+
clearTimeout(timer);
|
|
117
|
+
resolve(value);
|
|
118
|
+
};
|
|
119
|
+
this.waiters.push(waiter);
|
|
120
|
+
if (timeoutMs !== undefined) {
|
|
121
|
+
timer = setTimeout(() => {
|
|
122
|
+
const idx = this.waiters.indexOf(waiter);
|
|
123
|
+
if (idx !== -1) {
|
|
124
|
+
this.waiters.splice(idx, 1);
|
|
125
|
+
resolve(exports.IOPUB_QUEUE_TIMEOUT);
|
|
126
|
+
}
|
|
127
|
+
}, timeoutMs);
|
|
128
|
+
timer.unref?.();
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
close() {
|
|
133
|
+
if (this.closed)
|
|
134
|
+
return;
|
|
135
|
+
this.closed = true;
|
|
136
|
+
this.items = [];
|
|
137
|
+
for (const waiter of this.waiters.splice(0)) {
|
|
138
|
+
waiter(exports.IOPUB_QUEUE_CLOSED);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
57
142
|
class KernelService {
|
|
58
143
|
sessions = new Map();
|
|
59
144
|
fileToSession = new Map();
|
|
@@ -77,11 +162,98 @@ class KernelService {
|
|
|
77
162
|
// Per-cell truncation tracking: sessionId -> cellId -> tracking state
|
|
78
163
|
cellOutputTracking = new Map();
|
|
79
164
|
executingCellIds = new Map();
|
|
165
|
+
// ---- Unified iopub reader state ----
|
|
166
|
+
// One long-lived reader loop per session owns ALL iopub .receive() calls.
|
|
167
|
+
// Consumers (execute loop, busy monitor) subscribe to bounded queues.
|
|
168
|
+
iopubReaders = new Map();
|
|
169
|
+
// sessionId -> parent msg_id -> queue (execute-parented message routing)
|
|
170
|
+
iopubParentSubscribers = new Map();
|
|
171
|
+
// sessionId -> catch-all queues (busy monitor and other any-parent consumers)
|
|
172
|
+
iopubCatchAllSubscribers = new Map();
|
|
173
|
+
// Comm-state store for late joiners: sessionId -> comm_id -> open info
|
|
174
|
+
commStates = new Map();
|
|
175
|
+
// ---- Unified shell reply reader state ----
|
|
176
|
+
// One long-lived reader loop per session owns ALL shell .receive() calls.
|
|
177
|
+
// Replies are dispatched by parent msg_id to one-shot waiters; a reply with
|
|
178
|
+
// no registered waiter (e.g. execute_reply — executions are iopub-driven —
|
|
179
|
+
// or a reply whose waiter already timed out) is dropped. This means a
|
|
180
|
+
// timed-out request can never hold the socket or eat a later reply.
|
|
181
|
+
shellReaders = new Map();
|
|
182
|
+
// sessionId -> request msg_id -> one-shot resolver
|
|
183
|
+
shellReplyWaiters = new Map();
|
|
80
184
|
constructor(config, sessionStore) {
|
|
81
185
|
this.config = { ...types_1.DEFAULT_CONFIG, ...config };
|
|
82
186
|
this.sessionStore = sessionStore || new session_store_1.SessionStore();
|
|
83
187
|
this.serverId = process.env.NEBULA_SERVER_ID || 'local';
|
|
84
188
|
this.serverInstanceId = process.env.NEBULA_SERVER_INSTANCE_ID || (0, uuid_1.v4)();
|
|
189
|
+
this.startLivenessSweep();
|
|
190
|
+
}
|
|
191
|
+
// ---- Kernel death notification --------------------------------------
|
|
192
|
+
// Process exit/error handlers (and the liveness sweep below) notify
|
|
193
|
+
// listeners so the route layer can broadcast status to WebSocket clients.
|
|
194
|
+
// Without this, a crashed kernel leaves every client showing 'busy'.
|
|
195
|
+
deadListeners = [];
|
|
196
|
+
onSessionDead(cb) {
|
|
197
|
+
this.deadListeners.push(cb);
|
|
198
|
+
}
|
|
199
|
+
notifySessionDead(sessionId) {
|
|
200
|
+
for (const cb of this.deadListeners) {
|
|
201
|
+
try {
|
|
202
|
+
cb(sessionId);
|
|
203
|
+
}
|
|
204
|
+
catch (err) {
|
|
205
|
+
console.error('[Kernel] onSessionDead listener failed:', err);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
// ---- Comm message notification (ipywidgets et al.) -------------------
|
|
210
|
+
// The unified iopub reader demuxes comm_open/comm_msg/comm_close messages
|
|
211
|
+
// (regardless of parent — including idle-time traffic from widget
|
|
212
|
+
// observers) to these listeners so the route layer can broadcast them.
|
|
213
|
+
commListeners = [];
|
|
214
|
+
onComm(cb) {
|
|
215
|
+
this.commListeners.push(cb);
|
|
216
|
+
}
|
|
217
|
+
notifyComm(sessionId, comm) {
|
|
218
|
+
for (const cb of this.commListeners) {
|
|
219
|
+
try {
|
|
220
|
+
cb(sessionId, comm);
|
|
221
|
+
}
|
|
222
|
+
catch (err) {
|
|
223
|
+
console.error('[Kernel] onComm listener failed:', err);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
// ---- Liveness sweep ---------------------------------------------------
|
|
228
|
+
// ChildProcess handles get 'exit' events, but REATTACHED sessions only
|
|
229
|
+
// have a PID — nothing fires when that process dies. Sweep PIDs so a dead
|
|
230
|
+
// reattached kernel is noticed within ~15s instead of at the next execute.
|
|
231
|
+
livenessTimer = null;
|
|
232
|
+
startLivenessSweep() {
|
|
233
|
+
this.livenessTimer = setInterval(() => this.sweepLiveness(), 15000);
|
|
234
|
+
// Never keep the process alive just for the sweep
|
|
235
|
+
this.livenessTimer.unref?.();
|
|
236
|
+
}
|
|
237
|
+
/** One liveness pass (exposed for tests; normally driven by the timer). */
|
|
238
|
+
sweepLiveness() {
|
|
239
|
+
for (const [sessionId, session] of this.sessions.entries()) {
|
|
240
|
+
if (session.status === 'dead')
|
|
241
|
+
continue;
|
|
242
|
+
// Sessions with a live ChildProcess handle are covered by 'exit'
|
|
243
|
+
if (this.kernelProcesses.has(sessionId))
|
|
244
|
+
continue;
|
|
245
|
+
if (!session.pid)
|
|
246
|
+
continue;
|
|
247
|
+
try {
|
|
248
|
+
process.kill(session.pid, 0);
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
console.log(`[Kernel] Liveness sweep: reattached kernel ${sessionId} (pid ${session.pid}) is gone`);
|
|
252
|
+
session.status = 'dead';
|
|
253
|
+
this.cleanupKernelResources(sessionId);
|
|
254
|
+
this.notifySessionDead(sessionId);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
85
257
|
}
|
|
86
258
|
setServerIdentity(serverId, serverInstanceId) {
|
|
87
259
|
this.serverId = serverId;
|
|
@@ -108,6 +280,7 @@ class KernelService {
|
|
|
108
280
|
}
|
|
109
281
|
console.error(`Kernel ${sessionId} error:`, err);
|
|
110
282
|
session.status = 'dead';
|
|
283
|
+
this.notifySessionDead(sessionId);
|
|
111
284
|
};
|
|
112
285
|
const onExit = (code) => {
|
|
113
286
|
if (this.kernelProcesses.get(sessionId) !== proc) {
|
|
@@ -116,6 +289,7 @@ class KernelService {
|
|
|
116
289
|
console.log(`Kernel ${sessionId} exited with code ${code}`);
|
|
117
290
|
session.status = 'dead';
|
|
118
291
|
this.cleanupKernelResources(sessionId);
|
|
292
|
+
this.notifySessionDead(sessionId);
|
|
119
293
|
};
|
|
120
294
|
this.processLifecycleHandlers.set(proc, { onError, onExit });
|
|
121
295
|
proc.on('error', onError);
|
|
@@ -288,6 +462,9 @@ class KernelService {
|
|
|
288
462
|
console.log(`[Kernel] Failed to reattach session ${sessionId}: ${err instanceof Error ? err.message : err}`);
|
|
289
463
|
}
|
|
290
464
|
}
|
|
465
|
+
// Rows just marked terminated (process gone) are pure bookkeeping —
|
|
466
|
+
// remove them now so they never surface as "orphaned" in the UI.
|
|
467
|
+
await this.autoCleanupDeadSessions().catch((err) => console.warn('[Kernel] Auto-cleanup after reattach failed:', err));
|
|
291
468
|
return {
|
|
292
469
|
attempted: orphanedSessions.length,
|
|
293
470
|
reattached,
|
|
@@ -523,6 +700,12 @@ class KernelService {
|
|
|
523
700
|
iopub.connect(iopubEndpoint);
|
|
524
701
|
iopub.subscribe('');
|
|
525
702
|
this.zmqSockets.set(sessionId, { shell, iopub });
|
|
703
|
+
// Start the unified readers: from here on, these loops own every
|
|
704
|
+
// iopub.receive()/shell.receive() for the session. The shell reader must
|
|
705
|
+
// be live before the kernel_info probing below, which awaits its reply
|
|
706
|
+
// through the waiter mechanism.
|
|
707
|
+
this.startIopubReader(sessionId, iopub);
|
|
708
|
+
this.startShellReader(sessionId, shell);
|
|
526
709
|
// Send kernel_info_request to verify connectivity
|
|
527
710
|
const startTime = Date.now();
|
|
528
711
|
const actualTimeout = timeoutSeconds ?? this.config.startupTimeoutSeconds;
|
|
@@ -545,6 +728,374 @@ class KernelService {
|
|
|
545
728
|
}
|
|
546
729
|
throw new Error(`Kernel did not start within ${actualTimeout} seconds`);
|
|
547
730
|
}
|
|
731
|
+
// ---- Unified iopub reader ---------------------------------------------
|
|
732
|
+
// Exactly one loop per session calls iopub.receive(). It demuxes:
|
|
733
|
+
// - comm_open/comm_msg/comm_close (any parent) -> comm store + onComm
|
|
734
|
+
// - everything else -> the parent-keyed queue (if a consumer registered
|
|
735
|
+
// for that parent msg_id) AND every catch-all queue (busy monitor).
|
|
736
|
+
// The loop never spins: receive() blocks until a message arrives, and a
|
|
737
|
+
// socket close (stop/restart/cleanup) rejects the pending receive, which
|
|
738
|
+
// exits the loop and closes all subscriber queues.
|
|
739
|
+
startIopubReader(sessionId, iopub) {
|
|
740
|
+
// Never allow two loops for one session (e.g. restart replaces sockets).
|
|
741
|
+
this.stopIopubReader(sessionId);
|
|
742
|
+
const reader = { stopped: false };
|
|
743
|
+
this.iopubReaders.set(sessionId, reader);
|
|
744
|
+
void (async () => {
|
|
745
|
+
try {
|
|
746
|
+
while (!reader.stopped) {
|
|
747
|
+
let frames;
|
|
748
|
+
try {
|
|
749
|
+
frames = await iopub.receive();
|
|
750
|
+
}
|
|
751
|
+
catch {
|
|
752
|
+
// Socket closed or errored (kernel stop/restart/cleanup) — exit.
|
|
753
|
+
break;
|
|
754
|
+
}
|
|
755
|
+
if (reader.stopped)
|
|
756
|
+
break;
|
|
757
|
+
try {
|
|
758
|
+
const parsed = this.parseJupyterMessage(frames);
|
|
759
|
+
this.dispatchIopubMessage(sessionId, parsed);
|
|
760
|
+
}
|
|
761
|
+
catch (err) {
|
|
762
|
+
console.warn(`[Kernel] Dropping unparseable iopub message for ${sessionId}:`, err);
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
finally {
|
|
767
|
+
// Only tear down if we're still the current reader — on restart a new
|
|
768
|
+
// reader (with fresh subscriber queues) may already have taken over.
|
|
769
|
+
if (this.iopubReaders.get(sessionId) === reader) {
|
|
770
|
+
this.iopubReaders.delete(sessionId);
|
|
771
|
+
this.closeIopubSubscribers(sessionId);
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
})();
|
|
775
|
+
}
|
|
776
|
+
stopIopubReader(sessionId) {
|
|
777
|
+
const reader = this.iopubReaders.get(sessionId);
|
|
778
|
+
if (reader) {
|
|
779
|
+
reader.stopped = true;
|
|
780
|
+
this.iopubReaders.delete(sessionId);
|
|
781
|
+
}
|
|
782
|
+
this.closeIopubSubscribers(sessionId);
|
|
783
|
+
}
|
|
784
|
+
closeIopubSubscribers(sessionId) {
|
|
785
|
+
const parents = this.iopubParentSubscribers.get(sessionId);
|
|
786
|
+
if (parents) {
|
|
787
|
+
for (const queue of parents.values())
|
|
788
|
+
queue.close();
|
|
789
|
+
this.iopubParentSubscribers.delete(sessionId);
|
|
790
|
+
}
|
|
791
|
+
const catchAll = this.iopubCatchAllSubscribers.get(sessionId);
|
|
792
|
+
if (catchAll) {
|
|
793
|
+
for (const queue of catchAll)
|
|
794
|
+
queue.close();
|
|
795
|
+
this.iopubCatchAllSubscribers.delete(sessionId);
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
dispatchIopubMessage(sessionId, msg) {
|
|
799
|
+
if (msg.msgType === 'comm_open' || msg.msgType === 'comm_msg' || msg.msgType === 'comm_close') {
|
|
800
|
+
this.handleKernelCommMessage(sessionId, msg);
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
if (msg.parentMsgId) {
|
|
804
|
+
this.iopubParentSubscribers.get(sessionId)?.get(msg.parentMsgId)?.push(msg);
|
|
805
|
+
}
|
|
806
|
+
const catchAll = this.iopubCatchAllSubscribers.get(sessionId);
|
|
807
|
+
if (catchAll) {
|
|
808
|
+
for (const queue of catchAll)
|
|
809
|
+
queue.push(msg);
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
/**
|
|
813
|
+
* Subscribe to iopub messages parented by a specific msg_id. Must be called
|
|
814
|
+
* BEFORE sending the request so no reply can slip past the demux. Callers
|
|
815
|
+
* must unsubscribe in a finally block.
|
|
816
|
+
*/
|
|
817
|
+
subscribeIopubParent(sessionId, parentMsgId) {
|
|
818
|
+
let parents = this.iopubParentSubscribers.get(sessionId);
|
|
819
|
+
if (!parents) {
|
|
820
|
+
parents = new Map();
|
|
821
|
+
this.iopubParentSubscribers.set(sessionId, parents);
|
|
822
|
+
}
|
|
823
|
+
const queue = new BoundedAsyncQueue(MAX_PARENT_QUEUE_MESSAGES);
|
|
824
|
+
parents.set(parentMsgId, queue);
|
|
825
|
+
// If no reader loop is alive (it crashed or was never started), close the
|
|
826
|
+
// queue immediately so consumers fail fast instead of hanging forever.
|
|
827
|
+
if (!this.iopubReaders.has(sessionId)) {
|
|
828
|
+
queue.close();
|
|
829
|
+
}
|
|
830
|
+
return queue;
|
|
831
|
+
}
|
|
832
|
+
unsubscribeIopubParent(sessionId, parentMsgId) {
|
|
833
|
+
const parents = this.iopubParentSubscribers.get(sessionId);
|
|
834
|
+
const queue = parents?.get(parentMsgId);
|
|
835
|
+
if (!parents || !queue)
|
|
836
|
+
return;
|
|
837
|
+
queue.close();
|
|
838
|
+
parents.delete(parentMsgId);
|
|
839
|
+
if (parents.size === 0) {
|
|
840
|
+
this.iopubParentSubscribers.delete(sessionId);
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
/** Subscribe to ALL iopub messages for a session (any parent). */
|
|
844
|
+
subscribeIopubCatchAll(sessionId) {
|
|
845
|
+
let queues = this.iopubCatchAllSubscribers.get(sessionId);
|
|
846
|
+
if (!queues) {
|
|
847
|
+
queues = new Set();
|
|
848
|
+
this.iopubCatchAllSubscribers.set(sessionId, queues);
|
|
849
|
+
}
|
|
850
|
+
const queue = new BoundedAsyncQueue(MAX_CATCHALL_QUEUE_MESSAGES);
|
|
851
|
+
queues.add(queue);
|
|
852
|
+
if (!this.iopubReaders.has(sessionId)) {
|
|
853
|
+
queue.close();
|
|
854
|
+
}
|
|
855
|
+
return queue;
|
|
856
|
+
}
|
|
857
|
+
unsubscribeIopubCatchAll(sessionId, queue) {
|
|
858
|
+
queue.close();
|
|
859
|
+
const queues = this.iopubCatchAllSubscribers.get(sessionId);
|
|
860
|
+
if (!queues)
|
|
861
|
+
return;
|
|
862
|
+
queues.delete(queue);
|
|
863
|
+
if (queues.size === 0) {
|
|
864
|
+
this.iopubCatchAllSubscribers.delete(sessionId);
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
// ---- Unified shell reply reader -----------------------------------------
|
|
868
|
+
// Mirrors the iopub reader: exactly one loop per session calls
|
|
869
|
+
// shell.receive(). Consumers register a one-shot waiter keyed by their
|
|
870
|
+
// request msg_id BEFORE sending, then await the reply with a timeout. A
|
|
871
|
+
// timeout only deregisters the waiter — the reader keeps owning the socket,
|
|
872
|
+
// so a slow reply (e.g. complete_request against a busy kernel) can never
|
|
873
|
+
// block or swallow a later request's reply.
|
|
874
|
+
startShellReader(sessionId, shell) {
|
|
875
|
+
// Never allow two loops for one session (e.g. restart replaces sockets).
|
|
876
|
+
this.stopShellReader(sessionId);
|
|
877
|
+
const reader = { stopped: false };
|
|
878
|
+
this.shellReaders.set(sessionId, reader);
|
|
879
|
+
void (async () => {
|
|
880
|
+
try {
|
|
881
|
+
while (!reader.stopped) {
|
|
882
|
+
let frames;
|
|
883
|
+
try {
|
|
884
|
+
frames = await shell.receive();
|
|
885
|
+
}
|
|
886
|
+
catch {
|
|
887
|
+
// Socket closed or errored (kernel stop/restart/cleanup) — exit.
|
|
888
|
+
break;
|
|
889
|
+
}
|
|
890
|
+
if (reader.stopped)
|
|
891
|
+
break;
|
|
892
|
+
try {
|
|
893
|
+
const parsed = this.parseJupyterMessage(frames);
|
|
894
|
+
const waiters = this.shellReplyWaiters.get(sessionId);
|
|
895
|
+
const waiter = parsed.parentMsgId ? waiters?.get(parsed.parentMsgId) : undefined;
|
|
896
|
+
if (waiters && waiter) {
|
|
897
|
+
waiters.delete(parsed.parentMsgId);
|
|
898
|
+
if (waiters.size === 0)
|
|
899
|
+
this.shellReplyWaiters.delete(sessionId);
|
|
900
|
+
waiter(parsed);
|
|
901
|
+
}
|
|
902
|
+
else {
|
|
903
|
+
// No waiter: execute_reply (executions are iopub-driven), or the
|
|
904
|
+
// waiter timed out and deregistered. Drop it.
|
|
905
|
+
console.debug(`[Kernel] Dropping unclaimed shell ${parsed.msgType} for ${sessionId}`);
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
catch (err) {
|
|
909
|
+
console.warn(`[Kernel] Dropping unparseable shell message for ${sessionId}:`, err);
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
finally {
|
|
914
|
+
// Only tear down if we're still the current reader — on restart a new
|
|
915
|
+
// reader (with fresh waiters) may already have taken over.
|
|
916
|
+
if (this.shellReaders.get(sessionId) === reader) {
|
|
917
|
+
this.shellReaders.delete(sessionId);
|
|
918
|
+
this.closeShellReplyWaiters(sessionId);
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
})();
|
|
922
|
+
}
|
|
923
|
+
stopShellReader(sessionId) {
|
|
924
|
+
const reader = this.shellReaders.get(sessionId);
|
|
925
|
+
if (reader) {
|
|
926
|
+
reader.stopped = true;
|
|
927
|
+
this.shellReaders.delete(sessionId);
|
|
928
|
+
}
|
|
929
|
+
this.closeShellReplyWaiters(sessionId);
|
|
930
|
+
}
|
|
931
|
+
closeShellReplyWaiters(sessionId) {
|
|
932
|
+
const waiters = this.shellReplyWaiters.get(sessionId);
|
|
933
|
+
if (!waiters)
|
|
934
|
+
return;
|
|
935
|
+
this.shellReplyWaiters.delete(sessionId);
|
|
936
|
+
for (const resolve of waiters.values()) {
|
|
937
|
+
resolve('closed');
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
/**
|
|
941
|
+
* Register a one-shot waiter for the shell reply parented by msgId. Must be
|
|
942
|
+
* called BEFORE sending the request so the reply cannot slip past the
|
|
943
|
+
* dispatch. Resolves 'closed' immediately if no reader loop is alive, or
|
|
944
|
+
* later when the reader stops (kernel stop/restart/cleanup).
|
|
945
|
+
*/
|
|
946
|
+
registerShellReplyWaiter(sessionId, msgId) {
|
|
947
|
+
if (!this.shellReaders.has(sessionId)) {
|
|
948
|
+
return Promise.resolve('closed');
|
|
949
|
+
}
|
|
950
|
+
let waiters = this.shellReplyWaiters.get(sessionId);
|
|
951
|
+
if (!waiters) {
|
|
952
|
+
waiters = new Map();
|
|
953
|
+
this.shellReplyWaiters.set(sessionId, waiters);
|
|
954
|
+
}
|
|
955
|
+
const map = waiters;
|
|
956
|
+
return new Promise((resolve) => {
|
|
957
|
+
map.set(msgId, resolve);
|
|
958
|
+
});
|
|
959
|
+
}
|
|
960
|
+
removeShellReplyWaiter(sessionId, msgId) {
|
|
961
|
+
const waiters = this.shellReplyWaiters.get(sessionId);
|
|
962
|
+
if (!waiters)
|
|
963
|
+
return;
|
|
964
|
+
waiters.delete(msgId);
|
|
965
|
+
if (waiters.size === 0) {
|
|
966
|
+
this.shellReplyWaiters.delete(sessionId);
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
/**
|
|
970
|
+
* Await a previously registered shell reply with a timeout. The waiter is
|
|
971
|
+
* always deregistered on the way out (timeout, reply, or error), so a late
|
|
972
|
+
* reply is dropped by the reader instead of leaking a waiter.
|
|
973
|
+
*/
|
|
974
|
+
async waitForShellReply(sessionId, msgId, replyPromise, timeoutMs) {
|
|
975
|
+
let timer = null;
|
|
976
|
+
const timeout = new Promise((resolve) => {
|
|
977
|
+
timer = setTimeout(() => resolve('timeout'), timeoutMs);
|
|
978
|
+
timer.unref?.();
|
|
979
|
+
});
|
|
980
|
+
try {
|
|
981
|
+
return await Promise.race([replyPromise, timeout]);
|
|
982
|
+
}
|
|
983
|
+
finally {
|
|
984
|
+
if (timer)
|
|
985
|
+
clearTimeout(timer);
|
|
986
|
+
this.removeShellReplyWaiter(sessionId, msgId);
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
// ---- Comm handling ------------------------------------------------------
|
|
990
|
+
handleKernelCommMessage(sessionId, msg) {
|
|
991
|
+
const commId = typeof msg.content.comm_id === 'string' ? msg.content.comm_id : null;
|
|
992
|
+
if (!commId)
|
|
993
|
+
return;
|
|
994
|
+
const data = (msg.content.data && typeof msg.content.data === 'object')
|
|
995
|
+
? msg.content.data
|
|
996
|
+
: {};
|
|
997
|
+
const targetName = typeof msg.content.target_name === 'string' ? msg.content.target_name : undefined;
|
|
998
|
+
// Maintain the comm-state store so late-joining clients can discover
|
|
999
|
+
// open comms (comm_info) after the fact.
|
|
1000
|
+
if (msg.msgType === 'comm_open') {
|
|
1001
|
+
this.rememberOpenComm(sessionId, commId, targetName ?? '', data);
|
|
1002
|
+
}
|
|
1003
|
+
else if (msg.msgType === 'comm_close') {
|
|
1004
|
+
this.commStates.get(sessionId)?.delete(commId);
|
|
1005
|
+
}
|
|
1006
|
+
const comm = {
|
|
1007
|
+
msgType: msg.msgType,
|
|
1008
|
+
commId,
|
|
1009
|
+
data,
|
|
1010
|
+
};
|
|
1011
|
+
if (targetName !== undefined) {
|
|
1012
|
+
comm.targetName = targetName;
|
|
1013
|
+
}
|
|
1014
|
+
if (msg.buffers.length > 0) {
|
|
1015
|
+
comm.buffers = msg.buffers.map((b) => b.toString('base64'));
|
|
1016
|
+
}
|
|
1017
|
+
this.notifyComm(sessionId, comm);
|
|
1018
|
+
}
|
|
1019
|
+
rememberOpenComm(sessionId, commId, targetName, openData) {
|
|
1020
|
+
let comms = this.commStates.get(sessionId);
|
|
1021
|
+
if (!comms) {
|
|
1022
|
+
comms = new Map();
|
|
1023
|
+
this.commStates.set(sessionId, comms);
|
|
1024
|
+
}
|
|
1025
|
+
// Bounded store: evict the oldest tracked comm when full (light v1).
|
|
1026
|
+
if (!comms.has(commId) && comms.size >= MAX_TRACKED_COMMS_PER_SESSION) {
|
|
1027
|
+
const oldest = comms.keys().next().value;
|
|
1028
|
+
if (oldest !== undefined) {
|
|
1029
|
+
comms.delete(oldest);
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
comms.set(commId, { targetName, openData });
|
|
1033
|
+
}
|
|
1034
|
+
/**
|
|
1035
|
+
* Open comms known for a session (for late-joining clients).
|
|
1036
|
+
* Returns comm_id -> { targetName, openData } where openData is the last
|
|
1037
|
+
* state-carrying comm_open payload observed for that comm.
|
|
1038
|
+
*/
|
|
1039
|
+
getOpenComms(sessionId) {
|
|
1040
|
+
const comms = this.commStates.get(sessionId);
|
|
1041
|
+
const result = {};
|
|
1042
|
+
if (!comms)
|
|
1043
|
+
return result;
|
|
1044
|
+
for (const [commId, info] of comms) {
|
|
1045
|
+
result[commId] = { targetName: info.targetName, openData: info.openData };
|
|
1046
|
+
}
|
|
1047
|
+
return result;
|
|
1048
|
+
}
|
|
1049
|
+
/**
|
|
1050
|
+
* Send a comm message (comm_open / comm_msg / comm_close) to the kernel on
|
|
1051
|
+
* the shell channel. Comm messages produce NO shell reply, so the queued
|
|
1052
|
+
* slot releases as soon as the send completes — we never block waiting for
|
|
1053
|
+
* a reply that will not come.
|
|
1054
|
+
*
|
|
1055
|
+
* @param buffers Optional binary buffer frames, base64-encoded.
|
|
1056
|
+
*/
|
|
1057
|
+
async sendCommMessage(sessionId, msgType, content, buffers) {
|
|
1058
|
+
const session = this.sessions.get(sessionId);
|
|
1059
|
+
if (!session || !session.connectionConfig) {
|
|
1060
|
+
throw new Error(`Session ${sessionId} not found`);
|
|
1061
|
+
}
|
|
1062
|
+
if (!this.zmq || !this.zmqSockets.has(sessionId)) {
|
|
1063
|
+
throw new Error('ZeroMQ not available for kernel communication');
|
|
1064
|
+
}
|
|
1065
|
+
const binaryBuffers = (buffers ?? []).map((b64) => Buffer.from(b64, 'base64'));
|
|
1066
|
+
const header = {
|
|
1067
|
+
msg_id: (0, uuid_1.v4)(),
|
|
1068
|
+
session: sessionId,
|
|
1069
|
+
username: 'nebula',
|
|
1070
|
+
msg_type: msgType,
|
|
1071
|
+
version: '5.3',
|
|
1072
|
+
date: new Date().toISOString(),
|
|
1073
|
+
};
|
|
1074
|
+
const message = this.createJupyterMessage(header, {}, content, session.connectionConfig.key, binaryBuffers);
|
|
1075
|
+
// Track client-opened comms too, so comm_info reflects both directions.
|
|
1076
|
+
const commId = typeof content.comm_id === 'string' ? content.comm_id : null;
|
|
1077
|
+
if (commId) {
|
|
1078
|
+
if (msgType === 'comm_open') {
|
|
1079
|
+
const targetName = typeof content.target_name === 'string' ? content.target_name : '';
|
|
1080
|
+
const data = (content.data && typeof content.data === 'object')
|
|
1081
|
+
? content.data
|
|
1082
|
+
: {};
|
|
1083
|
+
this.rememberOpenComm(sessionId, commId, targetName, data);
|
|
1084
|
+
}
|
|
1085
|
+
else if (msgType === 'comm_close') {
|
|
1086
|
+
this.commStates.get(sessionId)?.delete(commId);
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
// Route through the shell queue so we never interleave with an in-flight
|
|
1090
|
+
// request/reply pair on the Dealer socket. Send-only: no receive() here.
|
|
1091
|
+
await this.enqueueShellRequest(sessionId, async () => {
|
|
1092
|
+
const sockets = this.zmqSockets.get(sessionId);
|
|
1093
|
+
if (!sockets) {
|
|
1094
|
+
throw new Error(`Session ${sessionId} sockets are gone (kernel stopped?)`);
|
|
1095
|
+
}
|
|
1096
|
+
await sockets.shell.send(message);
|
|
1097
|
+
});
|
|
1098
|
+
}
|
|
548
1099
|
/**
|
|
549
1100
|
* Monitor a busy reattached kernel on iopub. When its current execution
|
|
550
1101
|
* finishes (status: idle on iopub), verify shell connectivity and update
|
|
@@ -552,19 +1103,17 @@ class KernelService {
|
|
|
552
1103
|
*/
|
|
553
1104
|
monitorBusyKernel(sessionId) {
|
|
554
1105
|
const session = this.sessions.get(sessionId);
|
|
555
|
-
|
|
556
|
-
if (!session || !sockets)
|
|
1106
|
+
if (!session || !this.zmqSockets.has(sessionId))
|
|
557
1107
|
return;
|
|
558
1108
|
console.log(`[Kernel] Monitoring busy kernel ${sessionId} for idle transition...`);
|
|
1109
|
+
// Consume from the unified reader's catch-all channel — the monitor cares
|
|
1110
|
+
// about status messages from executions we did not initiate (any parent).
|
|
1111
|
+
const channel = this.subscribeIopubCatchAll(sessionId);
|
|
559
1112
|
const poll = async () => {
|
|
560
1113
|
try {
|
|
561
1114
|
while (session.status === 'busy') {
|
|
562
|
-
const
|
|
563
|
-
|
|
564
|
-
setTimeout(() => resolve(null), 5000);
|
|
565
|
-
});
|
|
566
|
-
const frames = await Promise.race([receivePromise, timeoutPromise]);
|
|
567
|
-
if (!frames) {
|
|
1115
|
+
const msg = await channel.next(5000);
|
|
1116
|
+
if (msg === exports.IOPUB_QUEUE_TIMEOUT) {
|
|
568
1117
|
// Periodic liveness check
|
|
569
1118
|
if (session.pid && !this.isPidAlive(session.pid)) {
|
|
570
1119
|
console.log(`[Kernel] Busy kernel ${sessionId} PID ${session.pid} died`);
|
|
@@ -573,8 +1122,11 @@ class KernelService {
|
|
|
573
1122
|
}
|
|
574
1123
|
continue;
|
|
575
1124
|
}
|
|
576
|
-
|
|
577
|
-
|
|
1125
|
+
if (msg === exports.IOPUB_QUEUE_CLOSED) {
|
|
1126
|
+
// Kernel stopped/restarted while monitoring — nothing more to do.
|
|
1127
|
+
return;
|
|
1128
|
+
}
|
|
1129
|
+
if (msg.msgType === 'status' && msg.content.execution_state === 'idle') {
|
|
578
1130
|
break;
|
|
579
1131
|
}
|
|
580
1132
|
}
|
|
@@ -593,6 +1145,9 @@ class KernelService {
|
|
|
593
1145
|
console.error(`[Kernel] Error monitoring busy kernel ${sessionId}:`, err);
|
|
594
1146
|
session.status = 'idle'; // Assume idle — next operation will detect real issues
|
|
595
1147
|
}
|
|
1148
|
+
finally {
|
|
1149
|
+
this.unsubscribeIopubCatchAll(sessionId, channel);
|
|
1150
|
+
}
|
|
596
1151
|
};
|
|
597
1152
|
void poll();
|
|
598
1153
|
}
|
|
@@ -616,35 +1171,42 @@ class KernelService {
|
|
|
616
1171
|
date: new Date().toISOString(),
|
|
617
1172
|
};
|
|
618
1173
|
const message = this.createJupyterMessage(header, {}, {}, session.connectionConfig.key);
|
|
619
|
-
|
|
620
|
-
//
|
|
621
|
-
const
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
}
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
1174
|
+
// Register the waiter before sending so the reply cannot race the
|
|
1175
|
+
// dispatch, then wait for kernel_info_reply with timeout.
|
|
1176
|
+
const replyPromise = this.registerShellReplyWaiter(sessionId, msgId);
|
|
1177
|
+
try {
|
|
1178
|
+
await this.enqueueShellRequest(sessionId, () => sockets.shell.send(message));
|
|
1179
|
+
}
|
|
1180
|
+
catch (err) {
|
|
1181
|
+
this.removeShellReplyWaiter(sessionId, msgId);
|
|
1182
|
+
throw err;
|
|
1183
|
+
}
|
|
1184
|
+
const reply = await this.waitForShellReply(sessionId, msgId, replyPromise, 1000);
|
|
1185
|
+
if (reply === 'timeout' || reply === 'closed') {
|
|
1186
|
+
throw new Error('Kernel not responding');
|
|
1187
|
+
}
|
|
1188
|
+
if (reply.msgType !== 'kernel_info_reply') {
|
|
1189
|
+
throw new Error(`Unexpected message type: ${reply.msgType}`);
|
|
629
1190
|
}
|
|
630
1191
|
// Kernel is ready!
|
|
631
1192
|
}
|
|
632
1193
|
/**
|
|
633
1194
|
* Create a Jupyter protocol message
|
|
634
1195
|
*/
|
|
635
|
-
createJupyterMessage(header, parentHeader, content, key) {
|
|
1196
|
+
createJupyterMessage(header, parentHeader, content, key, buffers = []) {
|
|
636
1197
|
const headerJson = JSON.stringify(header);
|
|
637
1198
|
const parentHeaderJson = JSON.stringify(parentHeader);
|
|
638
1199
|
const metadataJson = JSON.stringify({});
|
|
639
1200
|
const contentJson = JSON.stringify(content);
|
|
640
|
-
// Create HMAC signature
|
|
1201
|
+
// Create HMAC signature (buffers are NOT part of the signature per the
|
|
1202
|
+
// Jupyter wire protocol — only header/parent_header/metadata/content).
|
|
641
1203
|
const hmac = crypto.createHmac('sha256', key);
|
|
642
1204
|
hmac.update(headerJson);
|
|
643
1205
|
hmac.update(parentHeaderJson);
|
|
644
1206
|
hmac.update(metadataJson);
|
|
645
1207
|
hmac.update(contentJson);
|
|
646
1208
|
const signature = hmac.digest('hex');
|
|
647
|
-
// Build multipart message
|
|
1209
|
+
// Build multipart message; binary buffer frames follow content
|
|
648
1210
|
return [
|
|
649
1211
|
Buffer.from('<IDS|MSG>'),
|
|
650
1212
|
Buffer.from(signature),
|
|
@@ -652,13 +1214,31 @@ class KernelService {
|
|
|
652
1214
|
Buffer.from(parentHeaderJson),
|
|
653
1215
|
Buffer.from(metadataJson),
|
|
654
1216
|
Buffer.from(contentJson),
|
|
1217
|
+
...buffers,
|
|
655
1218
|
];
|
|
656
1219
|
}
|
|
657
1220
|
/**
|
|
658
1221
|
* Get or create kernel for a file (one notebook = one kernel).
|
|
659
1222
|
* Returns whether a new session was created.
|
|
660
1223
|
*/
|
|
1224
|
+
/** In-flight create/attach per normalized file path (single-flight).
|
|
1225
|
+
* Without this, two near-simultaneous requests (UI re-render + agent op,
|
|
1226
|
+
* double-click) both miss the fileToSession check and spawn TWO kernel
|
|
1227
|
+
* processes — the second overwrites the mapping and the first leaks. */
|
|
1228
|
+
inflightKernelCreates = new Map();
|
|
661
1229
|
async getOrCreateKernel(filePath, kernelName = 'python3') {
|
|
1230
|
+
const key = this.normalizePath(filePath);
|
|
1231
|
+
const inflight = this.inflightKernelCreates.get(key);
|
|
1232
|
+
if (inflight) {
|
|
1233
|
+
return inflight;
|
|
1234
|
+
}
|
|
1235
|
+
const run = this.getOrCreateKernelInternal(key, kernelName).finally(() => {
|
|
1236
|
+
this.inflightKernelCreates.delete(key);
|
|
1237
|
+
});
|
|
1238
|
+
this.inflightKernelCreates.set(key, run);
|
|
1239
|
+
return run;
|
|
1240
|
+
}
|
|
1241
|
+
async getOrCreateKernelInternal(filePath, kernelName) {
|
|
662
1242
|
const normalizedPath = this.normalizePath(filePath);
|
|
663
1243
|
// Check for existing session in fileToSession map
|
|
664
1244
|
let existingSessionId = this.fileToSession.get(normalizedPath);
|
|
@@ -815,7 +1395,11 @@ class KernelService {
|
|
|
815
1395
|
return run;
|
|
816
1396
|
}
|
|
817
1397
|
/**
|
|
818
|
-
*
|
|
1398
|
+
* Serialize shell socket SENDS. Receives are owned by the unified shell
|
|
1399
|
+
* reader, so tasks queued here must be send-only and release the slot as
|
|
1400
|
+
* soon as the send completes (register any reply waiter BEFORE enqueueing,
|
|
1401
|
+
* await the reply AFTER the slot is released). Holding the slot across a
|
|
1402
|
+
* reply wait would let one slow request delay every later send.
|
|
819
1403
|
*/
|
|
820
1404
|
enqueueShellRequest(sessionId, task) {
|
|
821
1405
|
const previous = this.shellRequestQueues.get(sessionId) || Promise.resolve();
|
|
@@ -868,9 +1452,12 @@ class KernelService {
|
|
|
868
1452
|
session.status = 'busy';
|
|
869
1453
|
const sockets = this.zmqSockets.get(sessionId);
|
|
870
1454
|
let execCount = null;
|
|
1455
|
+
// Subscribe to iopub messages for this execution BEFORE sending the
|
|
1456
|
+
// request so the unified reader can't demux replies into the void.
|
|
1457
|
+
const msgId = (0, uuid_1.v4)();
|
|
1458
|
+
const channel = this.subscribeIopubParent(sessionId, msgId);
|
|
871
1459
|
try {
|
|
872
1460
|
// Create execute_request message
|
|
873
|
-
const msgId = (0, uuid_1.v4)();
|
|
874
1461
|
const header = {
|
|
875
1462
|
msg_id: msgId,
|
|
876
1463
|
session: sessionId,
|
|
@@ -888,15 +1475,20 @@ class KernelService {
|
|
|
888
1475
|
stop_on_error: true,
|
|
889
1476
|
};
|
|
890
1477
|
const message = this.createJupyterMessage(header, {}, content, session.connectionConfig.key);
|
|
891
|
-
|
|
892
|
-
//
|
|
1478
|
+
// Serialize the send with other shell sends; no shell reply waiter —
|
|
1479
|
+
// execution progress and completion are driven entirely by iopub
|
|
1480
|
+
// (execute_reply is dropped by the shell reader as unclaimed).
|
|
1481
|
+
await this.enqueueShellRequest(sessionId, () => sockets.shell.send(message));
|
|
1482
|
+
// Process iopub messages for our execution (already parent-filtered by
|
|
1483
|
+
// the unified reader; comm messages are demuxed to onComm separately)
|
|
893
1484
|
while (true) {
|
|
894
|
-
const
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
1485
|
+
const msg = await channel.next();
|
|
1486
|
+
if (typeof msg === 'symbol') {
|
|
1487
|
+
// Queue closed: kernel stopped/restarted mid-execution. Surface the
|
|
1488
|
+
// same way a socket-close used to — via the catch block below.
|
|
1489
|
+
throw new Error('Kernel iopub stream closed during execution');
|
|
899
1490
|
}
|
|
1491
|
+
const { msgType, content: msgContent } = msg;
|
|
900
1492
|
// Handle different message types
|
|
901
1493
|
if (msgType === 'execute_input') {
|
|
902
1494
|
execCount = msgContent.execution_count;
|
|
@@ -931,11 +1523,24 @@ class KernelService {
|
|
|
931
1523
|
return { status: 'ok', executionCount: execCount };
|
|
932
1524
|
}
|
|
933
1525
|
catch (err) {
|
|
1526
|
+
// A restart closes the old sockets while we may be mid-receive: surface
|
|
1527
|
+
// that as a clean cancellation, and never clobber 'starting'/'dead'
|
|
1528
|
+
// status set concurrently by the restart/exit paths (re-read through
|
|
1529
|
+
// the map — TS narrowing can't see cross-async mutation).
|
|
1530
|
+
const currentStatus = this.sessions.get(sessionId)?.status;
|
|
1531
|
+
if (currentStatus === 'starting' || currentStatus === 'dead') {
|
|
1532
|
+
const msg = currentStatus === 'dead' ? 'Kernel died during execution' : 'Kernel was restarted — execution cancelled';
|
|
1533
|
+
await onOutput({ type: 'error', content: msg });
|
|
1534
|
+
return { status: 'error', executionCount: null, error: msg };
|
|
1535
|
+
}
|
|
934
1536
|
session.status = 'idle';
|
|
935
1537
|
const errorMsg = this.formatExecutionError(err);
|
|
936
1538
|
await onOutput({ type: 'error', content: errorMsg });
|
|
937
1539
|
return { status: 'error', executionCount: null, error: errorMsg };
|
|
938
1540
|
}
|
|
1541
|
+
finally {
|
|
1542
|
+
this.unsubscribeIopubParent(sessionId, msgId);
|
|
1543
|
+
}
|
|
939
1544
|
}
|
|
940
1545
|
/**
|
|
941
1546
|
* Request code completion from the kernel
|
|
@@ -949,11 +1554,13 @@ class KernelService {
|
|
|
949
1554
|
if (!this.zmq || !this.zmqSockets.has(sessionId)) {
|
|
950
1555
|
return { status: 'error', matches: [], cursor_start: cursorPos, cursor_end: cursorPos };
|
|
951
1556
|
}
|
|
952
|
-
|
|
953
|
-
return this.enqueueShellRequest(sessionId, () => this.completeInternal(sessionId, code, cursorPos));
|
|
1557
|
+
return this.completeInternal(sessionId, code, cursorPos);
|
|
954
1558
|
}
|
|
955
1559
|
/**
|
|
956
|
-
* Internal completion implementation
|
|
1560
|
+
* Internal completion implementation. The send is serialized through the
|
|
1561
|
+
* shell queue; the reply is awaited via the unified shell reader, so a
|
|
1562
|
+
* timeout here simply drops the waiter and can never block or swallow a
|
|
1563
|
+
* later request's reply.
|
|
957
1564
|
*/
|
|
958
1565
|
async completeInternal(sessionId, code, cursorPos) {
|
|
959
1566
|
const session = this.sessions.get(sessionId);
|
|
@@ -977,30 +1584,30 @@ class KernelService {
|
|
|
977
1584
|
cursor_pos: cursorPos,
|
|
978
1585
|
};
|
|
979
1586
|
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) {
|
|
1587
|
+
// Register the waiter before sending so the reply cannot race the
|
|
1588
|
+
// dispatch, then wait for complete_reply with timeout.
|
|
1589
|
+
const replyPromise = this.registerShellReplyWaiter(sessionId, msgId);
|
|
1590
|
+
try {
|
|
1591
|
+
await this.enqueueShellRequest(sessionId, () => sockets.shell.send(message));
|
|
1592
|
+
}
|
|
1593
|
+
catch (err) {
|
|
1594
|
+
this.removeShellReplyWaiter(sessionId, msgId);
|
|
1595
|
+
throw err;
|
|
1596
|
+
}
|
|
1597
|
+
const reply = await this.waitForShellReply(sessionId, msgId, replyPromise, 3000);
|
|
1598
|
+
if (reply === 'timeout') {
|
|
1001
1599
|
return { status: 'timeout', matches: [], cursor_start: cursorPos, cursor_end: cursorPos };
|
|
1002
1600
|
}
|
|
1003
|
-
|
|
1601
|
+
if (reply === 'closed' || reply.msgType !== 'complete_reply') {
|
|
1602
|
+
return { status: 'error', matches: [], cursor_start: cursorPos, cursor_end: cursorPos };
|
|
1603
|
+
}
|
|
1604
|
+
const msgContent = reply.content;
|
|
1605
|
+
return {
|
|
1606
|
+
status: msgContent.status || 'ok',
|
|
1607
|
+
matches: msgContent.matches || [],
|
|
1608
|
+
cursor_start: msgContent.cursor_start ?? cursorPos,
|
|
1609
|
+
cursor_end: msgContent.cursor_end ?? cursorPos,
|
|
1610
|
+
};
|
|
1004
1611
|
}
|
|
1005
1612
|
catch (err) {
|
|
1006
1613
|
console.error('[KernelService] Completion error:', err);
|
|
@@ -1028,10 +1635,13 @@ class KernelService {
|
|
|
1028
1635
|
const parentHeader = JSON.parse(frames[delimIdx + 3].toString());
|
|
1029
1636
|
const metadata = JSON.parse(frames[delimIdx + 4].toString());
|
|
1030
1637
|
const content = JSON.parse(frames[delimIdx + 5].toString());
|
|
1638
|
+
// Any frames after content are binary buffers (e.g. ipywidgets comm data)
|
|
1639
|
+
const buffers = frames.slice(delimIdx + 6);
|
|
1031
1640
|
return {
|
|
1032
1641
|
msgType: header.msg_type,
|
|
1033
1642
|
content,
|
|
1034
1643
|
parentMsgId: parentHeader.msg_id || '',
|
|
1644
|
+
buffers,
|
|
1035
1645
|
};
|
|
1036
1646
|
}
|
|
1037
1647
|
/**
|
|
@@ -1281,6 +1891,10 @@ class KernelService {
|
|
|
1281
1891
|
// stopKernel() already handles these, but unexpected process exits and failed startups
|
|
1282
1892
|
// may call cleanup directly. Ensure we don't leak sockets or stale ChildProcess handles.
|
|
1283
1893
|
this.detachProcessLifecycle(this.kernelProcesses.get(sessionId));
|
|
1894
|
+
// Stop the unified readers and release any waiting consumers
|
|
1895
|
+
this.stopIopubReader(sessionId);
|
|
1896
|
+
this.stopShellReader(sessionId);
|
|
1897
|
+
this.commStates.delete(sessionId);
|
|
1284
1898
|
const sockets = this.zmqSockets.get(sessionId);
|
|
1285
1899
|
if (sockets) {
|
|
1286
1900
|
try {
|
|
@@ -1329,6 +1943,9 @@ class KernelService {
|
|
|
1329
1943
|
this.fileToSession.delete(session.filePath);
|
|
1330
1944
|
}
|
|
1331
1945
|
this.detachProcessLifecycle(this.kernelProcesses.get(sessionId));
|
|
1946
|
+
this.stopIopubReader(sessionId);
|
|
1947
|
+
this.stopShellReader(sessionId);
|
|
1948
|
+
this.commStates.delete(sessionId);
|
|
1332
1949
|
const sockets = this.zmqSockets.get(sessionId);
|
|
1333
1950
|
if (sockets) {
|
|
1334
1951
|
try {
|
|
@@ -1393,6 +2010,22 @@ class KernelService {
|
|
|
1393
2010
|
const { kernelName, filePath, connectionFile: oldConnFile } = session;
|
|
1394
2011
|
// Set status to restarting
|
|
1395
2012
|
session.status = 'starting';
|
|
2013
|
+
// Restart hygiene: drop buffered outputs from the old process (otherwise
|
|
2014
|
+
// stale outputs resurface on the next sync), and reset execution queues
|
|
2015
|
+
// so nothing chains onto promises from the old kernel. In-flight executes
|
|
2016
|
+
// fail when the old sockets close below; executeCode surfaces that as a
|
|
2017
|
+
// clean 'Kernel was restarted' error because status is 'starting'.
|
|
2018
|
+
this.cellOutputBuffers.delete(sessionId);
|
|
2019
|
+
this.cellOutputTracking.delete(sessionId);
|
|
2020
|
+
this.executionQueues.delete(sessionId);
|
|
2021
|
+
this.shellRequestQueues.delete(sessionId);
|
|
2022
|
+
// Stop the old kernel's readers and drop its comm state — widget
|
|
2023
|
+
// comms do not survive a restart. In-flight executes see their queues
|
|
2024
|
+
// close and fail with a clean 'Kernel was restarted' error; pending
|
|
2025
|
+
// shell reply waiters resolve 'closed'.
|
|
2026
|
+
this.stopIopubReader(sessionId);
|
|
2027
|
+
this.stopShellReader(sessionId);
|
|
2028
|
+
this.commStates.delete(sessionId);
|
|
1396
2029
|
// Close ZeroMQ sockets
|
|
1397
2030
|
const sockets = this.zmqSockets.get(sessionId);
|
|
1398
2031
|
if (sockets) {
|
|
@@ -1542,6 +2175,23 @@ class KernelService {
|
|
|
1542
2175
|
createdAt: session.createdAt,
|
|
1543
2176
|
};
|
|
1544
2177
|
}
|
|
2178
|
+
/**
|
|
2179
|
+
* Activity snapshot for the idle auto-release monitor (client mode):
|
|
2180
|
+
* whether any kernel is busy/starting, and the most recent kernel activity
|
|
2181
|
+
* across sessions in ms since epoch (sessions track it in seconds).
|
|
2182
|
+
*/
|
|
2183
|
+
getIdleSnapshot() {
|
|
2184
|
+
let anyBusy = false;
|
|
2185
|
+
let lastActivityMs = null;
|
|
2186
|
+
for (const session of this.sessions.values()) {
|
|
2187
|
+
if (session.status === 'busy' || session.status === 'starting')
|
|
2188
|
+
anyBusy = true;
|
|
2189
|
+
const ms = session.lastActivity * 1000;
|
|
2190
|
+
if (lastActivityMs === null || ms > lastActivityMs)
|
|
2191
|
+
lastActivityMs = ms;
|
|
2192
|
+
}
|
|
2193
|
+
return { anyBusy, lastActivityMs };
|
|
2194
|
+
}
|
|
1545
2195
|
/**
|
|
1546
2196
|
* Get all sessions
|
|
1547
2197
|
*/
|
|
@@ -1574,7 +2224,16 @@ class KernelService {
|
|
|
1574
2224
|
*/
|
|
1575
2225
|
getDeadSessions() {
|
|
1576
2226
|
const deadSessions = this.sessionStore.getDeadSessions(this.serverId);
|
|
1577
|
-
return deadSessions
|
|
2227
|
+
return deadSessions
|
|
2228
|
+
// A session that is live in memory is NOT dead — e.g. it was
|
|
2229
|
+
// successfully reattached (or is mid-reattach) after a server restart
|
|
2230
|
+
// and the DB 'orphaned' status is stale or racing the reattach loop.
|
|
2231
|
+
// Only sessions with no live entry (or a dead one) are true orphans.
|
|
2232
|
+
.filter(s => {
|
|
2233
|
+
const live = this.sessions.get(s.sessionId);
|
|
2234
|
+
return !live || live.status === 'dead';
|
|
2235
|
+
})
|
|
2236
|
+
.map(s => ({
|
|
1578
2237
|
sessionId: s.sessionId,
|
|
1579
2238
|
kernelName: s.kernelName,
|
|
1580
2239
|
filePath: s.filePath,
|
|
@@ -1582,11 +2241,50 @@ class KernelService {
|
|
|
1582
2241
|
lastHeartbeat: s.lastHeartbeat,
|
|
1583
2242
|
}));
|
|
1584
2243
|
}
|
|
2244
|
+
/**
|
|
2245
|
+
* Auto-delete dead session rows whose kernel process is CONFIRMED gone
|
|
2246
|
+
* (no PID, PID not running, or PID reused by another process). These are
|
|
2247
|
+
* pure bookkeeping — nothing to kill, nothing to lose — so they need no
|
|
2248
|
+
* user confirmation. Rows whose PID is still alive are kept for the
|
|
2249
|
+
* explicit "Clean Up" flow (killing a process should stay a user action,
|
|
2250
|
+
* and legacy rows without a start-time fingerprint can't be verified).
|
|
2251
|
+
*/
|
|
2252
|
+
async autoCleanupDeadSessions() {
|
|
2253
|
+
const dead = this.sessionStore.getDeadSessions(this.serverId).filter(s => {
|
|
2254
|
+
const live = this.sessions.get(s.sessionId);
|
|
2255
|
+
return !live || live.status === 'dead';
|
|
2256
|
+
});
|
|
2257
|
+
const deletable = [];
|
|
2258
|
+
for (const s of dead) {
|
|
2259
|
+
if (!s.kernelPid || !this.isPidAlive(s.kernelPid)) {
|
|
2260
|
+
deletable.push(s.sessionId);
|
|
2261
|
+
continue;
|
|
2262
|
+
}
|
|
2263
|
+
if (s.kernelStartTime) {
|
|
2264
|
+
const currentStartTime = await this.getProcessStartTime(s.kernelPid);
|
|
2265
|
+
if (!currentStartTime || currentStartTime !== s.kernelStartTime) {
|
|
2266
|
+
// PID reused by an unrelated process — the kernel itself is gone
|
|
2267
|
+
deletable.push(s.sessionId);
|
|
2268
|
+
}
|
|
2269
|
+
}
|
|
2270
|
+
}
|
|
2271
|
+
if (deletable.length > 0) {
|
|
2272
|
+
this.sessionStore.deleteSessions(deletable);
|
|
2273
|
+
console.log(`[Kernel] Auto-cleaned ${deletable.length} dead session record(s) (process gone)`);
|
|
2274
|
+
}
|
|
2275
|
+
return deletable.length;
|
|
2276
|
+
}
|
|
1585
2277
|
/**
|
|
1586
2278
|
* Cleanup dead sessions by deleting them from the database
|
|
1587
2279
|
*/
|
|
1588
2280
|
async cleanupDeadSessions(sessionIds) {
|
|
1589
|
-
|
|
2281
|
+
// Same live-session guard as getDeadSessions: never clean up a session
|
|
2282
|
+
// that is currently live in memory (e.g. reattached after a restart while
|
|
2283
|
+
// its DB row still says 'orphaned').
|
|
2284
|
+
const deadSessions = this.sessionStore.getDeadSessions(this.serverId).filter(s => {
|
|
2285
|
+
const live = this.sessions.get(s.sessionId);
|
|
2286
|
+
return !live || live.status === 'dead';
|
|
2287
|
+
});
|
|
1590
2288
|
const targets = sessionIds && sessionIds.length > 0
|
|
1591
2289
|
? deadSessions.filter(s => sessionIds.includes(s.sessionId))
|
|
1592
2290
|
: deadSessions;
|
|
@@ -1635,6 +2333,10 @@ class KernelService {
|
|
|
1635
2333
|
* If preserveKernels is true, keep kernel processes running and close the session store.
|
|
1636
2334
|
*/
|
|
1637
2335
|
async shutdown(options) {
|
|
2336
|
+
if (this.livenessTimer) {
|
|
2337
|
+
clearInterval(this.livenessTimer);
|
|
2338
|
+
this.livenessTimer = null;
|
|
2339
|
+
}
|
|
1638
2340
|
if (options?.preserveKernels) {
|
|
1639
2341
|
try {
|
|
1640
2342
|
this.sessionStore.markAllOrphaned(this.serverId, this.serverInstanceId);
|