nebula-notebook 0.1.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 +222 -0
- package/bin/nebula-notebook.js +33 -0
- package/dist/assets/index-C1h_sArD.css +32 -0
- package/dist/assets/index-CDSTBon8.js +658 -0
- package/dist/favicon.svg +11 -0
- package/dist/index.html +73 -0
- package/node-server/dist/app.d.ts +5 -0
- package/node-server/dist/app.js +38 -0
- package/node-server/dist/auth/auth-middleware.d.ts +24 -0
- package/node-server/dist/auth/auth-middleware.js +276 -0
- package/node-server/dist/auth/auth-service.d.ts +84 -0
- package/node-server/dist/auth/auth-service.js +265 -0
- package/node-server/dist/auth/index.d.ts +3 -0
- package/node-server/dist/auth/index.js +8 -0
- package/node-server/dist/cluster/client-registration.d.ts +43 -0
- package/node-server/dist/cluster/client-registration.js +217 -0
- package/node-server/dist/cluster/cluster-secret.d.ts +11 -0
- package/node-server/dist/cluster/cluster-secret.js +90 -0
- package/node-server/dist/cluster/kernel-proxy.d.ts +100 -0
- package/node-server/dist/cluster/kernel-proxy.js +361 -0
- package/node-server/dist/cluster/server-registry.d.ts +109 -0
- package/node-server/dist/cluster/server-registry.js +217 -0
- package/node-server/dist/config/output-limits.d.ts +7 -0
- package/node-server/dist/config/output-limits.js +10 -0
- package/node-server/dist/discovery/discovery-service.d.ts +198 -0
- package/node-server/dist/discovery/discovery-service.js +811 -0
- package/node-server/dist/discovery/index.d.ts +5 -0
- package/node-server/dist/discovery/index.js +21 -0
- package/node-server/dist/discovery/types.d.ts +48 -0
- package/node-server/dist/discovery/types.js +24 -0
- package/node-server/dist/fs/fs-service.d.ts +218 -0
- package/node-server/dist/fs/fs-service.js +1422 -0
- package/node-server/dist/fs/index.d.ts +5 -0
- package/node-server/dist/fs/index.js +21 -0
- package/node-server/dist/fs/types.d.ts +132 -0
- package/node-server/dist/fs/types.js +5 -0
- package/node-server/dist/index.d.ts +13 -0
- package/node-server/dist/index.js +556 -0
- package/node-server/dist/kernel/default-kernel.d.ts +5 -0
- package/node-server/dist/kernel/default-kernel.js +138 -0
- package/node-server/dist/kernel/index.d.ts +7 -0
- package/node-server/dist/kernel/index.js +23 -0
- package/node-server/dist/kernel/kernel-service.d.ts +290 -0
- package/node-server/dist/kernel/kernel-service.js +1714 -0
- package/node-server/dist/kernel/kernelspec.d.ts +29 -0
- package/node-server/dist/kernel/kernelspec.js +210 -0
- package/node-server/dist/kernel/session-store.d.ts +87 -0
- package/node-server/dist/kernel/session-store.js +303 -0
- package/node-server/dist/kernel/types.d.ts +143 -0
- package/node-server/dist/kernel/types.js +17 -0
- package/node-server/dist/llm/index.d.ts +5 -0
- package/node-server/dist/llm/index.js +21 -0
- package/node-server/dist/llm/llm-service.d.ts +77 -0
- package/node-server/dist/llm/llm-service.js +454 -0
- package/node-server/dist/llm/types.d.ts +40 -0
- package/node-server/dist/llm/types.js +15 -0
- package/node-server/dist/notebook/cell-metadata.d.ts +27 -0
- package/node-server/dist/notebook/cell-metadata.js +76 -0
- package/node-server/dist/notebook/headless-handler.d.ts +127 -0
- package/node-server/dist/notebook/headless-handler.js +1530 -0
- package/node-server/dist/notebook/notebook-websocket.d.ts +12 -0
- package/node-server/dist/notebook/notebook-websocket.js +103 -0
- package/node-server/dist/notebook/operation-router.d.ts +115 -0
- package/node-server/dist/notebook/operation-router.js +641 -0
- package/node-server/dist/notebook/undoRedoManager.d.ts +194 -0
- package/node-server/dist/notebook/undoRedoManager.js +558 -0
- package/node-server/dist/output/display-data.d.ts +14 -0
- package/node-server/dist/output/display-data.js +134 -0
- package/node-server/dist/resources/resource-service.d.ts +69 -0
- package/node-server/dist/resources/resource-service.js +363 -0
- package/node-server/dist/routes/auth.d.ts +5 -0
- package/node-server/dist/routes/auth.js +61 -0
- package/node-server/dist/routes/cluster.d.ts +7 -0
- package/node-server/dist/routes/cluster.js +94 -0
- package/node-server/dist/routes/fs.d.ts +7 -0
- package/node-server/dist/routes/fs.js +392 -0
- package/node-server/dist/routes/kernel.d.ts +13 -0
- package/node-server/dist/routes/kernel.js +637 -0
- package/node-server/dist/routes/llm.d.ts +8 -0
- package/node-server/dist/routes/llm.js +105 -0
- package/node-server/dist/routes/notebook.d.ts +10 -0
- package/node-server/dist/routes/notebook.js +335 -0
- package/node-server/dist/routes/python.d.ts +8 -0
- package/node-server/dist/routes/python.js +187 -0
- package/node-server/dist/routes/resources.d.ts +7 -0
- package/node-server/dist/routes/resources.js +77 -0
- package/node-server/dist/scripts/show-auth-qr.d.ts +1 -0
- package/node-server/dist/scripts/show-auth-qr.js +81 -0
- package/node-server/dist/terminal/pty-manager.d.ts +100 -0
- package/node-server/dist/terminal/pty-manager.js +246 -0
- package/node-server/dist/terminal/server.d.ts +19 -0
- package/node-server/dist/terminal/server.js +254 -0
- package/node-server/dist/terminal/types.d.ts +50 -0
- package/node-server/dist/terminal/types.js +9 -0
- package/node-server/package.json +45 -0
- package/package.json +99 -0
- package/scripts/postinstall.cjs +26 -0
|
@@ -0,0 +1,1714 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Kernel Service
|
|
4
|
+
*
|
|
5
|
+
* Manages Jupyter kernel sessions - spawning, execution, and lifecycle.
|
|
6
|
+
* Uses ZeroMQ for kernel communication following the Jupyter messaging protocol.
|
|
7
|
+
*/
|
|
8
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
9
|
+
if (k2 === undefined) k2 = k;
|
|
10
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
11
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
12
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
13
|
+
}
|
|
14
|
+
Object.defineProperty(o, k2, desc);
|
|
15
|
+
}) : (function(o, m, k, k2) {
|
|
16
|
+
if (k2 === undefined) k2 = k;
|
|
17
|
+
o[k2] = m[k];
|
|
18
|
+
}));
|
|
19
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
20
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
21
|
+
}) : function(o, v) {
|
|
22
|
+
o["default"] = v;
|
|
23
|
+
});
|
|
24
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
25
|
+
var ownKeys = function(o) {
|
|
26
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
27
|
+
var ar = [];
|
|
28
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
29
|
+
return ar;
|
|
30
|
+
};
|
|
31
|
+
return ownKeys(o);
|
|
32
|
+
};
|
|
33
|
+
return function (mod) {
|
|
34
|
+
if (mod && mod.__esModule) return mod;
|
|
35
|
+
var result = {};
|
|
36
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
37
|
+
__setModuleDefault(result, mod);
|
|
38
|
+
return result;
|
|
39
|
+
};
|
|
40
|
+
})();
|
|
41
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
42
|
+
exports.kernelService = exports.KernelService = void 0;
|
|
43
|
+
const fs = __importStar(require("fs"));
|
|
44
|
+
const path = __importStar(require("path"));
|
|
45
|
+
const os = __importStar(require("os"));
|
|
46
|
+
const crypto = __importStar(require("crypto"));
|
|
47
|
+
const net = __importStar(require("net"));
|
|
48
|
+
const uuid_1 = require("uuid");
|
|
49
|
+
const child_process_1 = require("child_process");
|
|
50
|
+
const util_1 = require("util");
|
|
51
|
+
const execAsync = (0, util_1.promisify)(child_process_1.exec);
|
|
52
|
+
const types_1 = require("./types");
|
|
53
|
+
const display_data_1 = require("../output/display-data");
|
|
54
|
+
const output_limits_1 = require("../config/output-limits");
|
|
55
|
+
const session_store_1 = require("./session-store");
|
|
56
|
+
const kernelspec_1 = require("./kernelspec");
|
|
57
|
+
class KernelService {
|
|
58
|
+
sessions = new Map();
|
|
59
|
+
fileToSession = new Map();
|
|
60
|
+
kernelProcesses = new Map();
|
|
61
|
+
processLifecycleHandlers = new WeakMap();
|
|
62
|
+
zmqSockets = new Map();
|
|
63
|
+
sessionStore;
|
|
64
|
+
config;
|
|
65
|
+
kernelSpecsCache = null;
|
|
66
|
+
ready = false;
|
|
67
|
+
zmq = null;
|
|
68
|
+
executionQueues = new Map();
|
|
69
|
+
executionQueueSizes = new Map();
|
|
70
|
+
shellRequestQueues = new Map();
|
|
71
|
+
reattachInProgress = false;
|
|
72
|
+
serverId;
|
|
73
|
+
serverInstanceId;
|
|
74
|
+
legacyCleanupWarned = new Set();
|
|
75
|
+
// Cell-indexed output buffer: sessionId -> cellId -> outputs
|
|
76
|
+
cellOutputBuffers = new Map();
|
|
77
|
+
// Per-cell truncation tracking: sessionId -> cellId -> tracking state
|
|
78
|
+
cellOutputTracking = new Map();
|
|
79
|
+
executingCellIds = new Map();
|
|
80
|
+
constructor(config, sessionStore) {
|
|
81
|
+
this.config = { ...types_1.DEFAULT_CONFIG, ...config };
|
|
82
|
+
this.sessionStore = sessionStore || new session_store_1.SessionStore();
|
|
83
|
+
this.serverId = process.env.NEBULA_SERVER_ID || 'local';
|
|
84
|
+
this.serverInstanceId = process.env.NEBULA_SERVER_INSTANCE_ID || (0, uuid_1.v4)();
|
|
85
|
+
}
|
|
86
|
+
setServerIdentity(serverId, serverInstanceId) {
|
|
87
|
+
this.serverId = serverId;
|
|
88
|
+
if (serverInstanceId) {
|
|
89
|
+
this.serverInstanceId = serverInstanceId;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Check if a PID is still alive.
|
|
94
|
+
*/
|
|
95
|
+
isPidAlive(pid) {
|
|
96
|
+
try {
|
|
97
|
+
process.kill(pid, 0);
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
attachProcessLifecycle(sessionId, proc, session) {
|
|
105
|
+
const onError = (err) => {
|
|
106
|
+
if (this.kernelProcesses.get(sessionId) !== proc) {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
console.error(`Kernel ${sessionId} error:`, err);
|
|
110
|
+
session.status = 'dead';
|
|
111
|
+
};
|
|
112
|
+
const onExit = (code) => {
|
|
113
|
+
if (this.kernelProcesses.get(sessionId) !== proc) {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
console.log(`Kernel ${sessionId} exited with code ${code}`);
|
|
117
|
+
session.status = 'dead';
|
|
118
|
+
this.cleanupKernelResources(sessionId);
|
|
119
|
+
};
|
|
120
|
+
this.processLifecycleHandlers.set(proc, { onError, onExit });
|
|
121
|
+
proc.on('error', onError);
|
|
122
|
+
proc.on('exit', onExit);
|
|
123
|
+
}
|
|
124
|
+
detachProcessLifecycle(proc) {
|
|
125
|
+
if (!proc) {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const handlers = this.processLifecycleHandlers.get(proc);
|
|
129
|
+
if (!handlers) {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
proc.off('error', handlers.onError);
|
|
133
|
+
proc.off('exit', handlers.onExit);
|
|
134
|
+
this.processLifecycleHandlers.delete(proc);
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Load a Jupyter connection file.
|
|
138
|
+
*/
|
|
139
|
+
loadConnectionFile(connFile) {
|
|
140
|
+
try {
|
|
141
|
+
if (!connFile || !fs.existsSync(connFile)) {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
const raw = JSON.parse(fs.readFileSync(connFile, 'utf-8'));
|
|
145
|
+
return {
|
|
146
|
+
ip: raw.ip,
|
|
147
|
+
transport: raw.transport,
|
|
148
|
+
signatureScheme: raw.signature_scheme,
|
|
149
|
+
key: raw.key,
|
|
150
|
+
shellPort: raw.shell_port,
|
|
151
|
+
stdinPort: raw.stdin_port,
|
|
152
|
+
controlPort: raw.control_port,
|
|
153
|
+
iopubPort: raw.iopub_port,
|
|
154
|
+
hbPort: raw.hb_port,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Initialize the service (lazy load ZeroMQ and discover kernels)
|
|
163
|
+
*/
|
|
164
|
+
async initialize() {
|
|
165
|
+
if (this.ready)
|
|
166
|
+
return;
|
|
167
|
+
// Try to load ZeroMQ
|
|
168
|
+
try {
|
|
169
|
+
this.zmq = await Promise.resolve().then(() => __importStar(require('zeromq')));
|
|
170
|
+
}
|
|
171
|
+
catch (err) {
|
|
172
|
+
console.warn('ZeroMQ not available, kernel execution will be limited:', err);
|
|
173
|
+
}
|
|
174
|
+
// Discover kernels in background
|
|
175
|
+
this.kernelSpecsCache = (0, kernelspec_1.discoverKernelSpecs)();
|
|
176
|
+
this.ready = true;
|
|
177
|
+
console.log(`Kernel service initialized. Found ${this.kernelSpecsCache.length} kernels.`);
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Attempt to reattach to orphaned kernel sessions from a previous server run.
|
|
181
|
+
*/
|
|
182
|
+
async reattachOrphanedSessions() {
|
|
183
|
+
if (this.reattachInProgress) {
|
|
184
|
+
return { attempted: 0, reattached: 0, failed: 0, skipped: 0 };
|
|
185
|
+
}
|
|
186
|
+
this.reattachInProgress = true;
|
|
187
|
+
try {
|
|
188
|
+
await this.initialize();
|
|
189
|
+
// Mark any previously active sessions (for this server) as orphaned
|
|
190
|
+
this.sessionStore.markAllOrphaned(this.serverId, this.serverInstanceId);
|
|
191
|
+
const orphanedSessions = this.sessionStore.getOrphanedSessions(this.serverId);
|
|
192
|
+
if (orphanedSessions.length === 0) {
|
|
193
|
+
return { attempted: 0, reattached: 0, failed: 0, skipped: 0 };
|
|
194
|
+
}
|
|
195
|
+
let reattached = 0;
|
|
196
|
+
let failed = 0;
|
|
197
|
+
let skipped = 0;
|
|
198
|
+
for (const session of orphanedSessions) {
|
|
199
|
+
const sessionId = session.sessionId;
|
|
200
|
+
// Check if kernel process is still alive and matches recorded start time (if available)
|
|
201
|
+
if (session.kernelPid) {
|
|
202
|
+
if (!this.isPidAlive(session.kernelPid)) {
|
|
203
|
+
this.sessionStore.updateStatus(sessionId, 'terminated');
|
|
204
|
+
skipped++;
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
if (session.kernelStartTime) {
|
|
208
|
+
const currentStartTime = await this.getProcessStartTime(session.kernelPid);
|
|
209
|
+
if (!currentStartTime || currentStartTime !== session.kernelStartTime) {
|
|
210
|
+
this.sessionStore.updateStatus(sessionId, 'terminated');
|
|
211
|
+
skipped++;
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
// Try to get connection config: from file first, then from DB
|
|
217
|
+
let connectionConfig = null;
|
|
218
|
+
if (session.connectionFile) {
|
|
219
|
+
connectionConfig = this.loadConnectionFile(session.connectionFile);
|
|
220
|
+
}
|
|
221
|
+
// Fall back to DB-stored config if file is missing (e.g., temp cleanup)
|
|
222
|
+
if (!connectionConfig && session.connectionConfig) {
|
|
223
|
+
try {
|
|
224
|
+
connectionConfig = JSON.parse(session.connectionConfig);
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
// Invalid JSON in DB
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
if (!connectionConfig) {
|
|
231
|
+
console.log(`[Kernel] Skipping session ${sessionId}: no connection config available`);
|
|
232
|
+
this.sessionStore.updateStatus(sessionId, 'terminated');
|
|
233
|
+
skipped++;
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
const normalizedFilePath = session.filePath ? this.normalizePath(session.filePath) : null;
|
|
237
|
+
const now = Date.now() / 1000;
|
|
238
|
+
const kernelSession = {
|
|
239
|
+
id: sessionId,
|
|
240
|
+
kernelName: session.kernelName,
|
|
241
|
+
filePath: normalizedFilePath,
|
|
242
|
+
status: 'starting',
|
|
243
|
+
executionCount: 0,
|
|
244
|
+
pid: session.kernelPid ?? null,
|
|
245
|
+
connectionFile: session.connectionFile,
|
|
246
|
+
connectionConfig,
|
|
247
|
+
createdAt: session.createdAt,
|
|
248
|
+
lastActivity: now,
|
|
249
|
+
};
|
|
250
|
+
this.sessions.set(sessionId, kernelSession);
|
|
251
|
+
if (normalizedFilePath) {
|
|
252
|
+
this.fileToSession.set(normalizedFilePath, sessionId);
|
|
253
|
+
}
|
|
254
|
+
try {
|
|
255
|
+
// Use shorter timeout for reattach — kernel should already be running.
|
|
256
|
+
// Pass PID so waitForReady can distinguish "busy" from "dead" on timeout.
|
|
257
|
+
const detectedStatus = await this.waitForReady(sessionId, 10, session.kernelPid);
|
|
258
|
+
kernelSession.status = detectedStatus;
|
|
259
|
+
if (detectedStatus === 'busy') {
|
|
260
|
+
// Kernel is alive but mid-execution. Monitor iopub in background
|
|
261
|
+
// so we can flip to 'idle' when the execution finishes.
|
|
262
|
+
this.monitorBusyKernel(sessionId);
|
|
263
|
+
}
|
|
264
|
+
const kernelStartTime = session.kernelStartTime
|
|
265
|
+
?? (session.kernelPid ? await this.getProcessStartTime(session.kernelPid) : null);
|
|
266
|
+
this.sessionStore.saveSession({
|
|
267
|
+
sessionId,
|
|
268
|
+
kernelName: session.kernelName,
|
|
269
|
+
filePath: normalizedFilePath,
|
|
270
|
+
kernelPid: session.kernelPid ?? null,
|
|
271
|
+
serverId: this.serverId,
|
|
272
|
+
serverInstanceId: this.serverInstanceId,
|
|
273
|
+
kernelStartTime,
|
|
274
|
+
status: 'active',
|
|
275
|
+
createdAt: session.createdAt,
|
|
276
|
+
lastHeartbeat: now,
|
|
277
|
+
connectionFile: session.connectionFile,
|
|
278
|
+
connectionConfig: JSON.stringify(connectionConfig),
|
|
279
|
+
});
|
|
280
|
+
reattached++;
|
|
281
|
+
console.log(`[Kernel] Reattached session ${sessionId} for ${normalizedFilePath || 'no file'}`);
|
|
282
|
+
}
|
|
283
|
+
catch (err) {
|
|
284
|
+
failed++;
|
|
285
|
+
kernelSession.status = 'dead';
|
|
286
|
+
this.cleanupInMemorySession(sessionId);
|
|
287
|
+
this.sessionStore.updateStatus(sessionId, 'terminated');
|
|
288
|
+
console.log(`[Kernel] Failed to reattach session ${sessionId}: ${err instanceof Error ? err.message : err}`);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return {
|
|
292
|
+
attempted: orphanedSessions.length,
|
|
293
|
+
reattached,
|
|
294
|
+
failed,
|
|
295
|
+
skipped,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
finally {
|
|
299
|
+
this.reattachInProgress = false;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Check if service is ready
|
|
304
|
+
*/
|
|
305
|
+
get isReady() {
|
|
306
|
+
return this.ready;
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Get available kernelspecs
|
|
310
|
+
*/
|
|
311
|
+
getAvailableKernels() {
|
|
312
|
+
if (this.kernelSpecsCache) {
|
|
313
|
+
return this.kernelSpecsCache;
|
|
314
|
+
}
|
|
315
|
+
return (0, kernelspec_1.discoverKernelSpecs)();
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* Re-run kernelspec discovery and refresh the in-memory cache.
|
|
319
|
+
*
|
|
320
|
+
* `getAvailableKernels()` caches once at startup and never expires, so a kernel
|
|
321
|
+
* registered while the server is running would otherwise stay invisible until a
|
|
322
|
+
* restart. Call this after registering/installing a kernel.
|
|
323
|
+
*/
|
|
324
|
+
refreshKernelSpecs() {
|
|
325
|
+
this.kernelSpecsCache = (0, kernelspec_1.discoverKernelSpecs)(true);
|
|
326
|
+
return this.kernelSpecsCache;
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* Normalize file path for consistent lookup
|
|
330
|
+
*/
|
|
331
|
+
normalizePath(filePath) {
|
|
332
|
+
return path.resolve(filePath.replace(/^~/, os.homedir()));
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Normalize notebook path for external callers (e.g., routes).
|
|
336
|
+
*/
|
|
337
|
+
normalizeNotebookPath(filePath) {
|
|
338
|
+
return this.normalizePath(filePath);
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Save kernel preference for a notebook file.
|
|
342
|
+
*/
|
|
343
|
+
saveNotebookKernelPreference(filePath, kernelName, serverId) {
|
|
344
|
+
const normalizedPath = this.normalizePath(filePath);
|
|
345
|
+
this.sessionStore.saveNotebookKernelPreference(normalizedPath, kernelName, serverId);
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* Get kernel preference for a notebook file.
|
|
349
|
+
*/
|
|
350
|
+
getNotebookKernelPreference(filePath) {
|
|
351
|
+
const normalizedPath = this.normalizePath(filePath);
|
|
352
|
+
return this.sessionStore.getNotebookKernelPreference(normalizedPath);
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Generate a connection file for the kernel
|
|
356
|
+
*/
|
|
357
|
+
async allocateEphemeralPort() {
|
|
358
|
+
return new Promise((resolve, reject) => {
|
|
359
|
+
const server = net.createServer();
|
|
360
|
+
server.unref();
|
|
361
|
+
server.once('error', reject);
|
|
362
|
+
server.listen(0, '127.0.0.1', () => {
|
|
363
|
+
const addr = server.address();
|
|
364
|
+
if (!addr || typeof addr === 'string') {
|
|
365
|
+
server.close(() => reject(new Error('Failed to allocate ephemeral port')));
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
const port = addr.port;
|
|
369
|
+
server.close(() => resolve(port));
|
|
370
|
+
});
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
async generateConnectionFile(sessionId) {
|
|
374
|
+
// Avoid random collisions by asking the OS for currently-free ephemeral ports.
|
|
375
|
+
// There's still a small TOCTOU window between close() and kernel bind, but this
|
|
376
|
+
// is far more reliable than picking random ports blindly.
|
|
377
|
+
const ports = [];
|
|
378
|
+
const seen = new Set();
|
|
379
|
+
while (ports.length < 5) {
|
|
380
|
+
const port = await this.allocateEphemeralPort();
|
|
381
|
+
if (seen.has(port))
|
|
382
|
+
continue;
|
|
383
|
+
seen.add(port);
|
|
384
|
+
ports.push(port);
|
|
385
|
+
}
|
|
386
|
+
const [shellPort, stdinPort, controlPort, iopubPort, hbPort] = ports;
|
|
387
|
+
const config = {
|
|
388
|
+
ip: '127.0.0.1',
|
|
389
|
+
transport: 'tcp',
|
|
390
|
+
signatureScheme: 'hmac-sha256',
|
|
391
|
+
key: crypto.randomBytes(16).toString('hex'),
|
|
392
|
+
shellPort,
|
|
393
|
+
stdinPort,
|
|
394
|
+
controlPort,
|
|
395
|
+
iopubPort,
|
|
396
|
+
hbPort,
|
|
397
|
+
};
|
|
398
|
+
// Write connection file
|
|
399
|
+
const connDir = path.join(os.tmpdir(), 'nebula-kernels');
|
|
400
|
+
fs.mkdirSync(connDir, { recursive: true });
|
|
401
|
+
const connFile = path.join(connDir, `kernel-${sessionId}.json`);
|
|
402
|
+
const connData = {
|
|
403
|
+
ip: config.ip,
|
|
404
|
+
transport: config.transport,
|
|
405
|
+
signature_scheme: config.signatureScheme,
|
|
406
|
+
key: config.key,
|
|
407
|
+
shell_port: config.shellPort,
|
|
408
|
+
stdin_port: config.stdinPort,
|
|
409
|
+
control_port: config.controlPort,
|
|
410
|
+
iopub_port: config.iopubPort,
|
|
411
|
+
hb_port: config.hbPort,
|
|
412
|
+
};
|
|
413
|
+
fs.writeFileSync(connFile, JSON.stringify(connData, null, 2));
|
|
414
|
+
return { config, filePath: connFile };
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* Start a new kernel session
|
|
418
|
+
*/
|
|
419
|
+
async startKernel(options = {}) {
|
|
420
|
+
const kernelName = options.kernelName || 'python3';
|
|
421
|
+
const spec = (0, kernelspec_1.getKernelSpec)(kernelName);
|
|
422
|
+
if (!spec) {
|
|
423
|
+
throw new Error(`Kernel '${kernelName}' not found`);
|
|
424
|
+
}
|
|
425
|
+
const sessionId = (0, uuid_1.v4)();
|
|
426
|
+
const normalizedFilePath = options.filePath ? this.normalizePath(options.filePath) : null;
|
|
427
|
+
// Generate connection file
|
|
428
|
+
const { config: connConfig, filePath: connFile } = await this.generateConnectionFile(sessionId);
|
|
429
|
+
// Build kernel command
|
|
430
|
+
const argv = (spec.argv || []).map(arg => arg.replace('{connection_file}', connFile));
|
|
431
|
+
if (argv.length === 0) {
|
|
432
|
+
throw new Error(`Kernel '${kernelName}' has no argv defined`);
|
|
433
|
+
}
|
|
434
|
+
// Spawn the kernel process
|
|
435
|
+
const cwd = options.cwd || (normalizedFilePath ? path.dirname(normalizedFilePath) : process.cwd());
|
|
436
|
+
const proc = (0, child_process_1.spawn)(argv[0], argv.slice(1), {
|
|
437
|
+
cwd,
|
|
438
|
+
env: { ...process.env, ...spec.env },
|
|
439
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
440
|
+
});
|
|
441
|
+
// Log kernel stdout/stderr for debugging
|
|
442
|
+
proc.stdout?.on('data', (data) => {
|
|
443
|
+
console.log(`[Kernel ${sessionId.slice(0, 8)}] stdout: ${data.toString().trim()}`);
|
|
444
|
+
});
|
|
445
|
+
proc.stderr?.on('data', (data) => {
|
|
446
|
+
console.error(`[Kernel ${sessionId.slice(0, 8)}] stderr: ${data.toString().trim()}`);
|
|
447
|
+
});
|
|
448
|
+
const now = Date.now() / 1000;
|
|
449
|
+
const session = {
|
|
450
|
+
id: sessionId,
|
|
451
|
+
kernelName,
|
|
452
|
+
filePath: normalizedFilePath,
|
|
453
|
+
status: 'starting',
|
|
454
|
+
executionCount: 0,
|
|
455
|
+
pid: proc.pid || null,
|
|
456
|
+
connectionFile: connFile,
|
|
457
|
+
connectionConfig: connConfig,
|
|
458
|
+
createdAt: now,
|
|
459
|
+
lastActivity: now,
|
|
460
|
+
};
|
|
461
|
+
this.sessions.set(sessionId, session);
|
|
462
|
+
this.kernelProcesses.set(sessionId, proc);
|
|
463
|
+
if (normalizedFilePath) {
|
|
464
|
+
this.fileToSession.set(normalizedFilePath, sessionId);
|
|
465
|
+
}
|
|
466
|
+
// Handle process events
|
|
467
|
+
this.attachProcessLifecycle(sessionId, proc, session);
|
|
468
|
+
// Wait for kernel to be ready. If startup fails, clean up aggressively so we
|
|
469
|
+
// don't leave behind orphaned sessions/processes/connection files.
|
|
470
|
+
try {
|
|
471
|
+
await this.waitForReady(sessionId);
|
|
472
|
+
}
|
|
473
|
+
catch (err) {
|
|
474
|
+
await this.stopKernel(sessionId).catch(() => undefined);
|
|
475
|
+
throw err;
|
|
476
|
+
}
|
|
477
|
+
const kernelStartTime = proc.pid ? await this.getProcessStartTime(proc.pid) : null;
|
|
478
|
+
// Save to persistence store (include connection config for reattach if file is deleted)
|
|
479
|
+
this.sessionStore.saveSession({
|
|
480
|
+
sessionId,
|
|
481
|
+
kernelName,
|
|
482
|
+
filePath: normalizedFilePath,
|
|
483
|
+
kernelPid: proc.pid || null,
|
|
484
|
+
serverId: this.serverId,
|
|
485
|
+
serverInstanceId: this.serverInstanceId,
|
|
486
|
+
kernelStartTime,
|
|
487
|
+
status: 'active',
|
|
488
|
+
createdAt: now,
|
|
489
|
+
lastHeartbeat: now,
|
|
490
|
+
connectionFile: connFile,
|
|
491
|
+
connectionConfig: JSON.stringify(session.connectionConfig),
|
|
492
|
+
});
|
|
493
|
+
session.status = 'idle';
|
|
494
|
+
return sessionId;
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* Wait for kernel to be ready by connecting to ZeroMQ channels.
|
|
498
|
+
*
|
|
499
|
+
* Returns the detected kernel execution state:
|
|
500
|
+
* - 'idle': kernel responded to kernel_info_request (ready for work)
|
|
501
|
+
* - 'busy': kernel PID is alive but shell channel is blocked (mid-execution)
|
|
502
|
+
*
|
|
503
|
+
* @param timeoutSeconds Optional override for startup timeout (default: use config)
|
|
504
|
+
* @param pid Optional kernel PID — used to distinguish "busy" from "dead" on timeout
|
|
505
|
+
*/
|
|
506
|
+
async waitForReady(sessionId, timeoutSeconds, pid) {
|
|
507
|
+
const session = this.sessions.get(sessionId);
|
|
508
|
+
if (!session || !session.connectionConfig) {
|
|
509
|
+
throw new Error('Session or connection config not found');
|
|
510
|
+
}
|
|
511
|
+
if (!this.zmq) {
|
|
512
|
+
// No ZeroMQ available, just wait a bit and hope the kernel starts
|
|
513
|
+
await this.sleep(1000);
|
|
514
|
+
return 'idle';
|
|
515
|
+
}
|
|
516
|
+
const config = session.connectionConfig;
|
|
517
|
+
const shellEndpoint = `tcp://${config.ip}:${config.shellPort}`;
|
|
518
|
+
const iopubEndpoint = `tcp://${config.ip}:${config.iopubPort}`;
|
|
519
|
+
// Create ZeroMQ sockets
|
|
520
|
+
const shell = new this.zmq.Dealer();
|
|
521
|
+
const iopub = new this.zmq.Subscriber();
|
|
522
|
+
shell.connect(shellEndpoint);
|
|
523
|
+
iopub.connect(iopubEndpoint);
|
|
524
|
+
iopub.subscribe('');
|
|
525
|
+
this.zmqSockets.set(sessionId, { shell, iopub });
|
|
526
|
+
// Send kernel_info_request to verify connectivity
|
|
527
|
+
const startTime = Date.now();
|
|
528
|
+
const actualTimeout = timeoutSeconds ?? this.config.startupTimeoutSeconds;
|
|
529
|
+
const timeout = actualTimeout * 1000;
|
|
530
|
+
while (Date.now() - startTime < timeout) {
|
|
531
|
+
try {
|
|
532
|
+
await this.sendKernelInfoRequest(sessionId);
|
|
533
|
+
// If we get here without error, kernel is ready
|
|
534
|
+
return 'idle';
|
|
535
|
+
}
|
|
536
|
+
catch {
|
|
537
|
+
await this.sleep(100);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
// Shell channel didn't respond within timeout.
|
|
541
|
+
// If the kernel PID is alive, it's likely busy executing code
|
|
542
|
+
// (shell requests queue behind the current execution in Jupyter protocol).
|
|
543
|
+
if (pid && this.isPidAlive(pid)) {
|
|
544
|
+
return 'busy';
|
|
545
|
+
}
|
|
546
|
+
throw new Error(`Kernel did not start within ${actualTimeout} seconds`);
|
|
547
|
+
}
|
|
548
|
+
/**
|
|
549
|
+
* Monitor a busy reattached kernel on iopub. When its current execution
|
|
550
|
+
* finishes (status: idle on iopub), verify shell connectivity and update
|
|
551
|
+
* the in-memory session status.
|
|
552
|
+
*/
|
|
553
|
+
monitorBusyKernel(sessionId) {
|
|
554
|
+
const session = this.sessions.get(sessionId);
|
|
555
|
+
const sockets = this.zmqSockets.get(sessionId);
|
|
556
|
+
if (!session || !sockets)
|
|
557
|
+
return;
|
|
558
|
+
console.log(`[Kernel] Monitoring busy kernel ${sessionId} for idle transition...`);
|
|
559
|
+
const poll = async () => {
|
|
560
|
+
try {
|
|
561
|
+
while (session.status === 'busy') {
|
|
562
|
+
const receivePromise = sockets.iopub.receive();
|
|
563
|
+
const timeoutPromise = new Promise((resolve) => {
|
|
564
|
+
setTimeout(() => resolve(null), 5000);
|
|
565
|
+
});
|
|
566
|
+
const frames = await Promise.race([receivePromise, timeoutPromise]);
|
|
567
|
+
if (!frames) {
|
|
568
|
+
// Periodic liveness check
|
|
569
|
+
if (session.pid && !this.isPidAlive(session.pid)) {
|
|
570
|
+
console.log(`[Kernel] Busy kernel ${sessionId} PID ${session.pid} died`);
|
|
571
|
+
session.status = 'dead';
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
continue;
|
|
575
|
+
}
|
|
576
|
+
const { msgType, content } = this.parseJupyterMessage(frames);
|
|
577
|
+
if (msgType === 'status' && content.execution_state === 'idle') {
|
|
578
|
+
break;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
// Prior execution finished — verify shell connectivity
|
|
582
|
+
try {
|
|
583
|
+
await this.sendKernelInfoRequest(sessionId);
|
|
584
|
+
}
|
|
585
|
+
catch {
|
|
586
|
+
// Non-fatal; next executeCode will detect issues
|
|
587
|
+
}
|
|
588
|
+
session.status = 'idle';
|
|
589
|
+
session.lastActivity = Date.now() / 1000;
|
|
590
|
+
console.log(`[Kernel] Busy kernel ${sessionId} is now idle`);
|
|
591
|
+
}
|
|
592
|
+
catch (err) {
|
|
593
|
+
console.error(`[Kernel] Error monitoring busy kernel ${sessionId}:`, err);
|
|
594
|
+
session.status = 'idle'; // Assume idle — next operation will detect real issues
|
|
595
|
+
}
|
|
596
|
+
};
|
|
597
|
+
void poll();
|
|
598
|
+
}
|
|
599
|
+
/**
|
|
600
|
+
* Send kernel_info_request and wait for reply to verify kernel is ready
|
|
601
|
+
* Throws if no reply received within timeout
|
|
602
|
+
*/
|
|
603
|
+
async sendKernelInfoRequest(sessionId) {
|
|
604
|
+
const sockets = this.zmqSockets.get(sessionId);
|
|
605
|
+
const session = this.sessions.get(sessionId);
|
|
606
|
+
if (!sockets || !session) {
|
|
607
|
+
throw new Error('Session or sockets not found');
|
|
608
|
+
}
|
|
609
|
+
const msgId = (0, uuid_1.v4)();
|
|
610
|
+
const header = {
|
|
611
|
+
msg_id: msgId,
|
|
612
|
+
session: sessionId,
|
|
613
|
+
username: 'nebula',
|
|
614
|
+
msg_type: 'kernel_info_request',
|
|
615
|
+
version: '5.3',
|
|
616
|
+
date: new Date().toISOString(),
|
|
617
|
+
};
|
|
618
|
+
const message = this.createJupyterMessage(header, {}, {}, session.connectionConfig.key);
|
|
619
|
+
await sockets.shell.send(message);
|
|
620
|
+
// Wait for kernel_info_reply with timeout
|
|
621
|
+
const receivePromise = sockets.shell.receive();
|
|
622
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
623
|
+
setTimeout(() => reject(new Error('Kernel not responding')), 1000);
|
|
624
|
+
});
|
|
625
|
+
const frames = await Promise.race([receivePromise, timeoutPromise]);
|
|
626
|
+
const { msgType } = this.parseJupyterMessage(frames);
|
|
627
|
+
if (msgType !== 'kernel_info_reply') {
|
|
628
|
+
throw new Error(`Unexpected message type: ${msgType}`);
|
|
629
|
+
}
|
|
630
|
+
// Kernel is ready!
|
|
631
|
+
}
|
|
632
|
+
/**
|
|
633
|
+
* Create a Jupyter protocol message
|
|
634
|
+
*/
|
|
635
|
+
createJupyterMessage(header, parentHeader, content, key) {
|
|
636
|
+
const headerJson = JSON.stringify(header);
|
|
637
|
+
const parentHeaderJson = JSON.stringify(parentHeader);
|
|
638
|
+
const metadataJson = JSON.stringify({});
|
|
639
|
+
const contentJson = JSON.stringify(content);
|
|
640
|
+
// Create HMAC signature
|
|
641
|
+
const hmac = crypto.createHmac('sha256', key);
|
|
642
|
+
hmac.update(headerJson);
|
|
643
|
+
hmac.update(parentHeaderJson);
|
|
644
|
+
hmac.update(metadataJson);
|
|
645
|
+
hmac.update(contentJson);
|
|
646
|
+
const signature = hmac.digest('hex');
|
|
647
|
+
// Build multipart message
|
|
648
|
+
return [
|
|
649
|
+
Buffer.from('<IDS|MSG>'),
|
|
650
|
+
Buffer.from(signature),
|
|
651
|
+
Buffer.from(headerJson),
|
|
652
|
+
Buffer.from(parentHeaderJson),
|
|
653
|
+
Buffer.from(metadataJson),
|
|
654
|
+
Buffer.from(contentJson),
|
|
655
|
+
];
|
|
656
|
+
}
|
|
657
|
+
/**
|
|
658
|
+
* Get or create kernel for a file (one notebook = one kernel).
|
|
659
|
+
* Returns whether a new session was created.
|
|
660
|
+
*/
|
|
661
|
+
async getOrCreateKernel(filePath, kernelName = 'python3') {
|
|
662
|
+
const normalizedPath = this.normalizePath(filePath);
|
|
663
|
+
// Check for existing session in fileToSession map
|
|
664
|
+
let existingSessionId = this.fileToSession.get(normalizedPath);
|
|
665
|
+
// Fallback: search all sessions for matching filePath (handles race after server restart)
|
|
666
|
+
if (!existingSessionId) {
|
|
667
|
+
for (const [sessionId, session] of this.sessions.entries()) {
|
|
668
|
+
if (session.filePath === normalizedPath && session.status !== 'dead') {
|
|
669
|
+
console.log(`[Kernel] Found orphaned session ${sessionId} for ${normalizedPath}, restoring fileToSession mapping`);
|
|
670
|
+
this.fileToSession.set(normalizedPath, sessionId);
|
|
671
|
+
existingSessionId = sessionId;
|
|
672
|
+
break;
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
if (existingSessionId) {
|
|
677
|
+
const session = this.sessions.get(existingSessionId);
|
|
678
|
+
if (session && session.status !== 'dead') {
|
|
679
|
+
// If session is stuck in 'starting' for too long, treat as dead
|
|
680
|
+
if (session.status === 'starting') {
|
|
681
|
+
const startingDuration = Date.now() / 1000 - session.lastActivity;
|
|
682
|
+
// Allow a longer grace period for slow environments before declaring it stuck
|
|
683
|
+
if (startingDuration > 120) {
|
|
684
|
+
console.log(`[Kernel] Session ${existingSessionId} stuck in 'starting' for ${Math.round(startingDuration)}s, restarting`);
|
|
685
|
+
await this.stopKernel(existingSessionId);
|
|
686
|
+
}
|
|
687
|
+
else {
|
|
688
|
+
// Wait a bit and check if it becomes ready
|
|
689
|
+
for (let i = 0; i < 20; i++) {
|
|
690
|
+
await this.sleep(500);
|
|
691
|
+
const updated = this.sessions.get(existingSessionId);
|
|
692
|
+
if (!updated || updated.status === 'dead')
|
|
693
|
+
break;
|
|
694
|
+
if (updated.status === 'idle' || updated.status === 'busy') {
|
|
695
|
+
if (updated.kernelName === kernelName) {
|
|
696
|
+
return { sessionId: existingSessionId, created: false };
|
|
697
|
+
}
|
|
698
|
+
break;
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
// Still starting after wait; keep it rather than restarting unless it exceeds grace
|
|
702
|
+
const stillStarting = this.sessions.get(existingSessionId);
|
|
703
|
+
if (stillStarting?.status === 'starting') {
|
|
704
|
+
console.log(`[Kernel] Session ${existingSessionId} still starting after wait; keeping it alive`);
|
|
705
|
+
return { sessionId: existingSessionId, created: false };
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
else {
|
|
710
|
+
// Session is idle or busy
|
|
711
|
+
if (session.kernelName === kernelName) {
|
|
712
|
+
return { sessionId: existingSessionId, created: false };
|
|
713
|
+
}
|
|
714
|
+
// Kernel type changed, stop old and start new
|
|
715
|
+
await this.stopKernel(existingSessionId);
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
// Start new kernel
|
|
720
|
+
const sessionId = await this.startKernel({
|
|
721
|
+
kernelName,
|
|
722
|
+
filePath: normalizedPath,
|
|
723
|
+
cwd: path.dirname(normalizedPath),
|
|
724
|
+
});
|
|
725
|
+
return { sessionId, created: true };
|
|
726
|
+
}
|
|
727
|
+
/**
|
|
728
|
+
* Get existing kernel session ID for a notebook file (if any).
|
|
729
|
+
* Returns null if no live session is associated with the file.
|
|
730
|
+
*/
|
|
731
|
+
getSessionIdForFile(filePath) {
|
|
732
|
+
const normalizedPath = this.normalizePath(filePath);
|
|
733
|
+
let existingSessionId = this.fileToSession.get(normalizedPath);
|
|
734
|
+
if (existingSessionId) {
|
|
735
|
+
const session = this.sessions.get(existingSessionId);
|
|
736
|
+
if (session && session.status !== 'dead') {
|
|
737
|
+
return existingSessionId;
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
for (const [sessionId, session] of this.sessions.entries()) {
|
|
741
|
+
if (session.filePath === normalizedPath && session.status !== 'dead') {
|
|
742
|
+
this.fileToSession.set(normalizedPath, sessionId);
|
|
743
|
+
existingSessionId = sessionId;
|
|
744
|
+
break;
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
return existingSessionId || null;
|
|
748
|
+
}
|
|
749
|
+
/**
|
|
750
|
+
* Check if a kernel session exists.
|
|
751
|
+
*/
|
|
752
|
+
hasSession(sessionId) {
|
|
753
|
+
return this.sessions.has(sessionId);
|
|
754
|
+
}
|
|
755
|
+
/**
|
|
756
|
+
* Get the notebook file path associated with a session (if any).
|
|
757
|
+
* Used for output persistence when no UI is connected.
|
|
758
|
+
*/
|
|
759
|
+
getSessionFilePath(sessionId) {
|
|
760
|
+
return this.sessions.get(sessionId)?.filePath ?? null;
|
|
761
|
+
}
|
|
762
|
+
/**
|
|
763
|
+
* Get the kernel name associated with a session (if any).
|
|
764
|
+
*/
|
|
765
|
+
getSessionKernelName(sessionId) {
|
|
766
|
+
return this.sessions.get(sessionId)?.kernelName ?? null;
|
|
767
|
+
}
|
|
768
|
+
/**
|
|
769
|
+
* Execute code in a kernel session
|
|
770
|
+
*/
|
|
771
|
+
async executeCode(sessionId, code, onOutput, onQueueInfo, cellId) {
|
|
772
|
+
const queueInfo = this.reserveExecutionSlot(sessionId);
|
|
773
|
+
if (onQueueInfo) {
|
|
774
|
+
onQueueInfo(queueInfo);
|
|
775
|
+
}
|
|
776
|
+
return this.enqueueExecution(sessionId, async () => {
|
|
777
|
+
// Clear previous outputs for this cell before starting new execution
|
|
778
|
+
if (cellId) {
|
|
779
|
+
this.clearCellOutputs(sessionId, cellId);
|
|
780
|
+
}
|
|
781
|
+
this.executingCellIds.set(sessionId, cellId ?? null);
|
|
782
|
+
try {
|
|
783
|
+
const result = await this.executeCodeInternal(sessionId, code, async (output) => {
|
|
784
|
+
const stored = this.bufferOutput(sessionId, output, cellId);
|
|
785
|
+
for (const o of stored) {
|
|
786
|
+
await onOutput(o, cellId);
|
|
787
|
+
}
|
|
788
|
+
});
|
|
789
|
+
return { ...result, ...queueInfo };
|
|
790
|
+
}
|
|
791
|
+
catch (err) {
|
|
792
|
+
const errorMsg = this.formatExecutionError(err);
|
|
793
|
+
const stored = this.bufferOutput(sessionId, { type: 'error', content: errorMsg }, cellId);
|
|
794
|
+
for (const o of stored) {
|
|
795
|
+
await onOutput(o, cellId);
|
|
796
|
+
}
|
|
797
|
+
return { status: 'error', executionCount: null, error: errorMsg, ...queueInfo };
|
|
798
|
+
}
|
|
799
|
+
finally {
|
|
800
|
+
this.executingCellIds.delete(sessionId);
|
|
801
|
+
this.releaseExecutionSlot(sessionId);
|
|
802
|
+
}
|
|
803
|
+
});
|
|
804
|
+
}
|
|
805
|
+
enqueueExecution(sessionId, task) {
|
|
806
|
+
const previous = this.executionQueues.get(sessionId) || Promise.resolve();
|
|
807
|
+
const run = previous.catch(() => undefined).then(task);
|
|
808
|
+
const chain = run.catch(() => undefined);
|
|
809
|
+
this.executionQueues.set(sessionId, chain);
|
|
810
|
+
chain.finally(() => {
|
|
811
|
+
if (this.executionQueues.get(sessionId) === chain) {
|
|
812
|
+
this.executionQueues.delete(sessionId);
|
|
813
|
+
}
|
|
814
|
+
});
|
|
815
|
+
return run;
|
|
816
|
+
}
|
|
817
|
+
/**
|
|
818
|
+
* Queue shell socket requests to prevent concurrent receive operations
|
|
819
|
+
*/
|
|
820
|
+
enqueueShellRequest(sessionId, task) {
|
|
821
|
+
const previous = this.shellRequestQueues.get(sessionId) || Promise.resolve();
|
|
822
|
+
const run = previous.catch(() => undefined).then(task);
|
|
823
|
+
const chain = run.catch(() => undefined);
|
|
824
|
+
this.shellRequestQueues.set(sessionId, chain);
|
|
825
|
+
chain.finally(() => {
|
|
826
|
+
if (this.shellRequestQueues.get(sessionId) === chain) {
|
|
827
|
+
this.shellRequestQueues.delete(sessionId);
|
|
828
|
+
}
|
|
829
|
+
});
|
|
830
|
+
return run;
|
|
831
|
+
}
|
|
832
|
+
reserveExecutionSlot(sessionId) {
|
|
833
|
+
const currentSize = this.executionQueueSizes.get(sessionId) ?? 0;
|
|
834
|
+
const info = {
|
|
835
|
+
queuePosition: currentSize,
|
|
836
|
+
queueLength: currentSize + 1,
|
|
837
|
+
};
|
|
838
|
+
this.executionQueueSizes.set(sessionId, currentSize + 1);
|
|
839
|
+
return info;
|
|
840
|
+
}
|
|
841
|
+
releaseExecutionSlot(sessionId) {
|
|
842
|
+
const currentSize = this.executionQueueSizes.get(sessionId) ?? 0;
|
|
843
|
+
if (currentSize <= 1) {
|
|
844
|
+
this.executionQueueSizes.delete(sessionId);
|
|
845
|
+
}
|
|
846
|
+
else {
|
|
847
|
+
this.executionQueueSizes.set(sessionId, currentSize - 1);
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
formatExecutionError(error) {
|
|
851
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
852
|
+
if (message.includes('Socket is busy reading')) {
|
|
853
|
+
return 'Kernel output stream is busy because another execution is running. Wait for it to finish or poll with read_output before starting a new execute_cell.';
|
|
854
|
+
}
|
|
855
|
+
return message;
|
|
856
|
+
}
|
|
857
|
+
async executeCodeInternal(sessionId, code, onOutput) {
|
|
858
|
+
const session = this.sessions.get(sessionId);
|
|
859
|
+
if (!session) {
|
|
860
|
+
throw new Error(`Session ${sessionId} not found`);
|
|
861
|
+
}
|
|
862
|
+
if (!this.zmq || !this.zmqSockets.has(sessionId)) {
|
|
863
|
+
// Fallback: no ZeroMQ, cannot execute
|
|
864
|
+
const errorMsg = 'ZeroMQ not available for kernel communication';
|
|
865
|
+
await onOutput({ type: 'error', content: errorMsg });
|
|
866
|
+
return { status: 'error', executionCount: null, error: errorMsg };
|
|
867
|
+
}
|
|
868
|
+
session.status = 'busy';
|
|
869
|
+
const sockets = this.zmqSockets.get(sessionId);
|
|
870
|
+
let execCount = null;
|
|
871
|
+
try {
|
|
872
|
+
// Create execute_request message
|
|
873
|
+
const msgId = (0, uuid_1.v4)();
|
|
874
|
+
const header = {
|
|
875
|
+
msg_id: msgId,
|
|
876
|
+
session: sessionId,
|
|
877
|
+
username: 'nebula',
|
|
878
|
+
msg_type: 'execute_request',
|
|
879
|
+
version: '5.3',
|
|
880
|
+
date: new Date().toISOString(),
|
|
881
|
+
};
|
|
882
|
+
const content = {
|
|
883
|
+
code,
|
|
884
|
+
silent: false,
|
|
885
|
+
store_history: true,
|
|
886
|
+
user_expressions: {},
|
|
887
|
+
allow_stdin: false,
|
|
888
|
+
stop_on_error: true,
|
|
889
|
+
};
|
|
890
|
+
const message = this.createJupyterMessage(header, {}, content, session.connectionConfig.key);
|
|
891
|
+
await sockets.shell.send(message);
|
|
892
|
+
// Process iopub messages
|
|
893
|
+
while (true) {
|
|
894
|
+
const frames = await sockets.iopub.receive();
|
|
895
|
+
const { msgType, content: msgContent, parentMsgId } = this.parseJupyterMessage(frames);
|
|
896
|
+
// Only process messages for our execution
|
|
897
|
+
if (parentMsgId !== msgId) {
|
|
898
|
+
continue;
|
|
899
|
+
}
|
|
900
|
+
// Handle different message types
|
|
901
|
+
if (msgType === 'execute_input') {
|
|
902
|
+
execCount = msgContent.execution_count;
|
|
903
|
+
}
|
|
904
|
+
else if (msgType === 'stream') {
|
|
905
|
+
const streamName = msgContent.name;
|
|
906
|
+
await onOutput({
|
|
907
|
+
type: streamName === 'stderr' ? 'stderr' : 'stdout',
|
|
908
|
+
content: msgContent.text,
|
|
909
|
+
});
|
|
910
|
+
}
|
|
911
|
+
else if (msgType === 'execute_result' || msgType === 'display_data') {
|
|
912
|
+
const output = this.formatDisplayData(msgContent.data || {}, msgContent.metadata || undefined);
|
|
913
|
+
if (output) {
|
|
914
|
+
await onOutput(output);
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
else if (msgType === 'error') {
|
|
918
|
+
const traceback = msgContent.traceback || [];
|
|
919
|
+
const cleanTb = traceback.map(line => this.stripAnsi(line)).join('\n');
|
|
920
|
+
await onOutput({ type: 'error', content: cleanTb });
|
|
921
|
+
}
|
|
922
|
+
else if (msgType === 'status') {
|
|
923
|
+
if (msgContent.execution_state === 'idle') {
|
|
924
|
+
break;
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
session.executionCount = execCount || session.executionCount;
|
|
929
|
+
session.status = 'idle';
|
|
930
|
+
session.lastActivity = Date.now() / 1000;
|
|
931
|
+
return { status: 'ok', executionCount: execCount };
|
|
932
|
+
}
|
|
933
|
+
catch (err) {
|
|
934
|
+
session.status = 'idle';
|
|
935
|
+
const errorMsg = this.formatExecutionError(err);
|
|
936
|
+
await onOutput({ type: 'error', content: errorMsg });
|
|
937
|
+
return { status: 'error', executionCount: null, error: errorMsg };
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
/**
|
|
941
|
+
* Request code completion from the kernel
|
|
942
|
+
* Uses a separate queue for shell socket operations to avoid "socket busy" errors
|
|
943
|
+
*/
|
|
944
|
+
async complete(sessionId, code, cursorPos) {
|
|
945
|
+
const session = this.sessions.get(sessionId);
|
|
946
|
+
if (!session) {
|
|
947
|
+
return { status: 'error', matches: [], cursor_start: cursorPos, cursor_end: cursorPos };
|
|
948
|
+
}
|
|
949
|
+
if (!this.zmq || !this.zmqSockets.has(sessionId)) {
|
|
950
|
+
return { status: 'error', matches: [], cursor_start: cursorPos, cursor_end: cursorPos };
|
|
951
|
+
}
|
|
952
|
+
// Queue completion requests to avoid "socket busy" errors
|
|
953
|
+
return this.enqueueShellRequest(sessionId, () => this.completeInternal(sessionId, code, cursorPos));
|
|
954
|
+
}
|
|
955
|
+
/**
|
|
956
|
+
* Internal completion implementation (runs within shell queue)
|
|
957
|
+
*/
|
|
958
|
+
async completeInternal(sessionId, code, cursorPos) {
|
|
959
|
+
const session = this.sessions.get(sessionId);
|
|
960
|
+
const sockets = this.zmqSockets.get(sessionId);
|
|
961
|
+
if (!session || !sockets) {
|
|
962
|
+
return { status: 'error', matches: [], cursor_start: cursorPos, cursor_end: cursorPos };
|
|
963
|
+
}
|
|
964
|
+
try {
|
|
965
|
+
// Create complete_request message
|
|
966
|
+
const msgId = (0, uuid_1.v4)();
|
|
967
|
+
const header = {
|
|
968
|
+
msg_id: msgId,
|
|
969
|
+
session: sessionId,
|
|
970
|
+
username: 'nebula',
|
|
971
|
+
msg_type: 'complete_request',
|
|
972
|
+
version: '5.3',
|
|
973
|
+
date: new Date().toISOString(),
|
|
974
|
+
};
|
|
975
|
+
const content = {
|
|
976
|
+
code,
|
|
977
|
+
cursor_pos: cursorPos,
|
|
978
|
+
};
|
|
979
|
+
const message = this.createJupyterMessage(header, {}, content, session.connectionConfig.key);
|
|
980
|
+
await sockets.shell.send(message);
|
|
981
|
+
// Wait for complete_reply with timeout
|
|
982
|
+
const timeoutPromise = new Promise((resolve) => {
|
|
983
|
+
setTimeout(() => resolve(null), 3000);
|
|
984
|
+
});
|
|
985
|
+
const receivePromise = (async () => {
|
|
986
|
+
while (true) {
|
|
987
|
+
const frames = await sockets.shell.receive();
|
|
988
|
+
const { msgType, content: msgContent, parentMsgId } = this.parseJupyterMessage(frames);
|
|
989
|
+
if (parentMsgId === msgId && msgType === 'complete_reply') {
|
|
990
|
+
return {
|
|
991
|
+
status: msgContent.status || 'ok',
|
|
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) {
|
|
1001
|
+
return { status: 'timeout', matches: [], cursor_start: cursorPos, cursor_end: cursorPos };
|
|
1002
|
+
}
|
|
1003
|
+
return result;
|
|
1004
|
+
}
|
|
1005
|
+
catch (err) {
|
|
1006
|
+
console.error('[KernelService] Completion error:', err);
|
|
1007
|
+
return { status: 'error', matches: [], cursor_start: cursorPos, cursor_end: cursorPos };
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
/**
|
|
1011
|
+
* Parse a Jupyter protocol message
|
|
1012
|
+
*/
|
|
1013
|
+
parseJupyterMessage(frames) {
|
|
1014
|
+
// Find delimiter
|
|
1015
|
+
let delimIdx = -1;
|
|
1016
|
+
for (let i = 0; i < frames.length; i++) {
|
|
1017
|
+
if (frames[i].toString() === '<IDS|MSG>') {
|
|
1018
|
+
delimIdx = i;
|
|
1019
|
+
break;
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
if (delimIdx === -1) {
|
|
1023
|
+
throw new Error('Invalid Jupyter message: no delimiter found');
|
|
1024
|
+
}
|
|
1025
|
+
// Parse message parts after delimiter
|
|
1026
|
+
const signature = frames[delimIdx + 1].toString();
|
|
1027
|
+
const header = JSON.parse(frames[delimIdx + 2].toString());
|
|
1028
|
+
const parentHeader = JSON.parse(frames[delimIdx + 3].toString());
|
|
1029
|
+
const metadata = JSON.parse(frames[delimIdx + 4].toString());
|
|
1030
|
+
const content = JSON.parse(frames[delimIdx + 5].toString());
|
|
1031
|
+
return {
|
|
1032
|
+
msgType: header.msg_type,
|
|
1033
|
+
content,
|
|
1034
|
+
parentMsgId: parentHeader.msg_id || '',
|
|
1035
|
+
};
|
|
1036
|
+
}
|
|
1037
|
+
/**
|
|
1038
|
+
* Format display data for output
|
|
1039
|
+
*/
|
|
1040
|
+
formatDisplayData(data, metadata) {
|
|
1041
|
+
const normalizedOutput = (0, display_data_1.buildDisplayOutput)(data, metadata);
|
|
1042
|
+
if (!normalizedOutput) {
|
|
1043
|
+
return null;
|
|
1044
|
+
}
|
|
1045
|
+
return {
|
|
1046
|
+
type: normalizedOutput.type,
|
|
1047
|
+
content: normalizedOutput.content,
|
|
1048
|
+
mimeBundle: normalizedOutput.mimeBundle,
|
|
1049
|
+
metadata: normalizedOutput.metadata,
|
|
1050
|
+
preferredMimeType: normalizedOutput.preferredMimeType,
|
|
1051
|
+
};
|
|
1052
|
+
}
|
|
1053
|
+
/**
|
|
1054
|
+
* Strip ANSI escape codes from text
|
|
1055
|
+
*/
|
|
1056
|
+
stripAnsi(text) {
|
|
1057
|
+
return text.replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, '');
|
|
1058
|
+
}
|
|
1059
|
+
getOutputStats(output) {
|
|
1060
|
+
const isError = output.type === 'error';
|
|
1061
|
+
const chars = output.content?.length || 0;
|
|
1062
|
+
const lines = output.type === 'image' || output.type === 'html' || output.type === 'display_data'
|
|
1063
|
+
? 0
|
|
1064
|
+
: (output.content?.match(/\n/g) || []).length + 1;
|
|
1065
|
+
return { lines, chars, isError };
|
|
1066
|
+
}
|
|
1067
|
+
ensureCellBuffers(sessionId) {
|
|
1068
|
+
let buffers = this.cellOutputBuffers.get(sessionId);
|
|
1069
|
+
if (!buffers) {
|
|
1070
|
+
buffers = new Map();
|
|
1071
|
+
this.cellOutputBuffers.set(sessionId, buffers);
|
|
1072
|
+
}
|
|
1073
|
+
return buffers;
|
|
1074
|
+
}
|
|
1075
|
+
ensureCellTracking(sessionId, cellId) {
|
|
1076
|
+
let tracking = this.cellOutputTracking.get(sessionId);
|
|
1077
|
+
if (!tracking) {
|
|
1078
|
+
tracking = new Map();
|
|
1079
|
+
this.cellOutputTracking.set(sessionId, tracking);
|
|
1080
|
+
}
|
|
1081
|
+
let cellTrack = tracking.get(cellId);
|
|
1082
|
+
if (!cellTrack) {
|
|
1083
|
+
cellTrack = { lines: 0, chars: 0, truncated: false };
|
|
1084
|
+
tracking.set(cellId, cellTrack);
|
|
1085
|
+
}
|
|
1086
|
+
return cellTrack;
|
|
1087
|
+
}
|
|
1088
|
+
/**
|
|
1089
|
+
* Buffer an output for a cell. Returns the outputs actually stored (0 if truncated, 1 normally).
|
|
1090
|
+
*/
|
|
1091
|
+
bufferOutput(sessionId, output, cellId) {
|
|
1092
|
+
const effectiveCellId = cellId ?? '__unknown__';
|
|
1093
|
+
const buffers = this.ensureCellBuffers(sessionId);
|
|
1094
|
+
const cellTrack = this.ensureCellTracking(sessionId, effectiveCellId);
|
|
1095
|
+
const stats = this.getOutputStats(output);
|
|
1096
|
+
if (cellTrack.lines + stats.lines > output_limits_1.MAX_OUTPUT_LINES || cellTrack.chars + stats.chars > output_limits_1.MAX_OUTPUT_CHARS) {
|
|
1097
|
+
if (!cellTrack.truncated) {
|
|
1098
|
+
cellTrack.truncated = true;
|
|
1099
|
+
const warning = {
|
|
1100
|
+
type: 'stderr',
|
|
1101
|
+
content: `\n⚠️ Output limit reached. Additional output not displayed.`,
|
|
1102
|
+
};
|
|
1103
|
+
let arr = buffers.get(effectiveCellId);
|
|
1104
|
+
if (!arr) {
|
|
1105
|
+
arr = [];
|
|
1106
|
+
buffers.set(effectiveCellId, arr);
|
|
1107
|
+
}
|
|
1108
|
+
arr.push(warning);
|
|
1109
|
+
return [warning];
|
|
1110
|
+
}
|
|
1111
|
+
return [];
|
|
1112
|
+
}
|
|
1113
|
+
cellTrack.lines += stats.lines;
|
|
1114
|
+
cellTrack.chars += stats.chars;
|
|
1115
|
+
let arr = buffers.get(effectiveCellId);
|
|
1116
|
+
if (!arr) {
|
|
1117
|
+
arr = [];
|
|
1118
|
+
buffers.set(effectiveCellId, arr);
|
|
1119
|
+
}
|
|
1120
|
+
arr.push(output);
|
|
1121
|
+
return [output];
|
|
1122
|
+
}
|
|
1123
|
+
/**
|
|
1124
|
+
* Clear outputs for a specific cell (called on re-execute).
|
|
1125
|
+
*/
|
|
1126
|
+
clearCellOutputs(sessionId, cellId) {
|
|
1127
|
+
this.cellOutputBuffers.get(sessionId)?.delete(cellId);
|
|
1128
|
+
this.cellOutputTracking.get(sessionId)?.delete(cellId);
|
|
1129
|
+
}
|
|
1130
|
+
/**
|
|
1131
|
+
* Get all cell outputs for a session, grouped by cellId.
|
|
1132
|
+
*/
|
|
1133
|
+
getAllCellOutputs(sessionId) {
|
|
1134
|
+
return this.cellOutputBuffers.get(sessionId) || new Map();
|
|
1135
|
+
}
|
|
1136
|
+
getExecutingCellId(sessionId) {
|
|
1137
|
+
return this.executingCellIds.get(sessionId) ?? null;
|
|
1138
|
+
}
|
|
1139
|
+
/**
|
|
1140
|
+
* Stop a kernel session
|
|
1141
|
+
*/
|
|
1142
|
+
async stopKernel(sessionId) {
|
|
1143
|
+
const session = this.sessions.get(sessionId);
|
|
1144
|
+
if (!session) {
|
|
1145
|
+
return false;
|
|
1146
|
+
}
|
|
1147
|
+
// Close ZeroMQ sockets
|
|
1148
|
+
const sockets = this.zmqSockets.get(sessionId);
|
|
1149
|
+
if (sockets) {
|
|
1150
|
+
try {
|
|
1151
|
+
sockets.shell.close();
|
|
1152
|
+
sockets.iopub.close();
|
|
1153
|
+
}
|
|
1154
|
+
catch {
|
|
1155
|
+
// Ignore close errors
|
|
1156
|
+
}
|
|
1157
|
+
this.zmqSockets.delete(sessionId);
|
|
1158
|
+
}
|
|
1159
|
+
// Kill kernel process
|
|
1160
|
+
const proc = this.kernelProcesses.get(sessionId);
|
|
1161
|
+
if (proc) {
|
|
1162
|
+
this.detachProcessLifecycle(proc);
|
|
1163
|
+
try {
|
|
1164
|
+
proc.kill('SIGTERM');
|
|
1165
|
+
// Wait for graceful shutdown
|
|
1166
|
+
await this.sleep(1000);
|
|
1167
|
+
if (!proc.killed) {
|
|
1168
|
+
proc.kill('SIGKILL');
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
catch {
|
|
1172
|
+
// Ignore kill errors
|
|
1173
|
+
}
|
|
1174
|
+
this.kernelProcesses.delete(sessionId);
|
|
1175
|
+
}
|
|
1176
|
+
else if (session.pid) {
|
|
1177
|
+
try {
|
|
1178
|
+
process.kill(session.pid, 'SIGTERM');
|
|
1179
|
+
await this.sleep(1000);
|
|
1180
|
+
if (this.isPidAlive(session.pid)) {
|
|
1181
|
+
process.kill(session.pid, 'SIGKILL');
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
catch {
|
|
1185
|
+
// Ignore kill errors
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
// Cleanup resources
|
|
1189
|
+
this.cleanupKernelResources(sessionId);
|
|
1190
|
+
return true;
|
|
1191
|
+
}
|
|
1192
|
+
/**
|
|
1193
|
+
* Check command line for a PID (best-effort).
|
|
1194
|
+
*/
|
|
1195
|
+
async getPidCommand(pid) {
|
|
1196
|
+
try {
|
|
1197
|
+
if (process.platform === 'linux') {
|
|
1198
|
+
const raw = fs.readFileSync(`/proc/${pid}/cmdline`);
|
|
1199
|
+
const cmdline = raw.toString('utf8').replace(/\0/g, ' ').trim();
|
|
1200
|
+
return cmdline || null;
|
|
1201
|
+
}
|
|
1202
|
+
const { stdout } = await execAsync(`ps -o command= -p ${pid}`, { timeout: 500 });
|
|
1203
|
+
const cmd = stdout.trim();
|
|
1204
|
+
return cmd.length > 0 ? cmd : null;
|
|
1205
|
+
}
|
|
1206
|
+
catch {
|
|
1207
|
+
return null;
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
/**
|
|
1211
|
+
* Get process start time (best-effort), used to prevent PID reuse mistakes.
|
|
1212
|
+
*/
|
|
1213
|
+
async getProcessStartTime(pid) {
|
|
1214
|
+
try {
|
|
1215
|
+
const { stdout } = await execAsync(`ps -o lstart= -p ${pid}`, { timeout: 500 });
|
|
1216
|
+
const startTime = stdout.trim();
|
|
1217
|
+
return startTime.length > 0 ? startTime : null;
|
|
1218
|
+
}
|
|
1219
|
+
catch {
|
|
1220
|
+
return null;
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
/**
|
|
1224
|
+
* Best-effort check to ensure the PID is a kernel process we started.
|
|
1225
|
+
*/
|
|
1226
|
+
async isExpectedKernelProcess(pid, connectionFile, expectedStartTime) {
|
|
1227
|
+
if (expectedStartTime) {
|
|
1228
|
+
const currentStartTime = await this.getProcessStartTime(pid);
|
|
1229
|
+
if (currentStartTime && currentStartTime === expectedStartTime) {
|
|
1230
|
+
return true;
|
|
1231
|
+
}
|
|
1232
|
+
return false;
|
|
1233
|
+
}
|
|
1234
|
+
const cmd = await this.getPidCommand(pid);
|
|
1235
|
+
if (!cmd)
|
|
1236
|
+
return false;
|
|
1237
|
+
if (connectionFile && cmd.includes(connectionFile)) {
|
|
1238
|
+
return true;
|
|
1239
|
+
}
|
|
1240
|
+
// Fallback: ensure it's an ipykernel process before killing
|
|
1241
|
+
return cmd.includes('ipykernel_launcher') || cmd.includes('ipykernel');
|
|
1242
|
+
}
|
|
1243
|
+
/**
|
|
1244
|
+
* Attempt to terminate a PID gracefully, then force kill.
|
|
1245
|
+
*/
|
|
1246
|
+
async terminatePid(pid) {
|
|
1247
|
+
if (!this.isPidAlive(pid))
|
|
1248
|
+
return true;
|
|
1249
|
+
try {
|
|
1250
|
+
process.kill(pid, 'SIGTERM');
|
|
1251
|
+
}
|
|
1252
|
+
catch {
|
|
1253
|
+
// Ignore kill errors
|
|
1254
|
+
}
|
|
1255
|
+
await this.sleep(500);
|
|
1256
|
+
if (this.isPidAlive(pid)) {
|
|
1257
|
+
try {
|
|
1258
|
+
process.kill(pid, 'SIGKILL');
|
|
1259
|
+
}
|
|
1260
|
+
catch {
|
|
1261
|
+
// Ignore kill errors
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
await this.sleep(200);
|
|
1265
|
+
return !this.isPidAlive(pid);
|
|
1266
|
+
}
|
|
1267
|
+
cleanupPersistedSessionArtifacts(session) {
|
|
1268
|
+
if (session.connectionFile && fs.existsSync(session.connectionFile)) {
|
|
1269
|
+
try {
|
|
1270
|
+
fs.unlinkSync(session.connectionFile);
|
|
1271
|
+
}
|
|
1272
|
+
catch {
|
|
1273
|
+
// Ignore delete errors
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
/**
|
|
1278
|
+
* Cleanup kernel resources
|
|
1279
|
+
*/
|
|
1280
|
+
cleanupKernelResources(sessionId) {
|
|
1281
|
+
// stopKernel() already handles these, but unexpected process exits and failed startups
|
|
1282
|
+
// may call cleanup directly. Ensure we don't leak sockets or stale ChildProcess handles.
|
|
1283
|
+
this.detachProcessLifecycle(this.kernelProcesses.get(sessionId));
|
|
1284
|
+
const sockets = this.zmqSockets.get(sessionId);
|
|
1285
|
+
if (sockets) {
|
|
1286
|
+
try {
|
|
1287
|
+
sockets.shell.close();
|
|
1288
|
+
sockets.iopub.close();
|
|
1289
|
+
}
|
|
1290
|
+
catch {
|
|
1291
|
+
// Ignore close errors
|
|
1292
|
+
}
|
|
1293
|
+
this.zmqSockets.delete(sessionId);
|
|
1294
|
+
}
|
|
1295
|
+
this.kernelProcesses.delete(sessionId);
|
|
1296
|
+
const session = this.sessions.get(sessionId);
|
|
1297
|
+
if (session) {
|
|
1298
|
+
// Remove file mapping
|
|
1299
|
+
if (session.filePath) {
|
|
1300
|
+
this.fileToSession.delete(session.filePath);
|
|
1301
|
+
}
|
|
1302
|
+
// Delete connection file
|
|
1303
|
+
if (session.connectionFile && fs.existsSync(session.connectionFile)) {
|
|
1304
|
+
try {
|
|
1305
|
+
fs.unlinkSync(session.connectionFile);
|
|
1306
|
+
}
|
|
1307
|
+
catch {
|
|
1308
|
+
// Ignore delete errors
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
// Remove from sessions
|
|
1312
|
+
this.sessions.delete(sessionId);
|
|
1313
|
+
// Update persistence store
|
|
1314
|
+
this.sessionStore.deleteSession(sessionId);
|
|
1315
|
+
}
|
|
1316
|
+
this.executionQueues.delete(sessionId);
|
|
1317
|
+
this.executionQueueSizes.delete(sessionId);
|
|
1318
|
+
this.shellRequestQueues.delete(sessionId);
|
|
1319
|
+
this.cellOutputBuffers.delete(sessionId);
|
|
1320
|
+
this.cellOutputTracking.delete(sessionId);
|
|
1321
|
+
this.executingCellIds.delete(sessionId);
|
|
1322
|
+
}
|
|
1323
|
+
/**
|
|
1324
|
+
* Cleanup only in-memory tracking for a session (keeps connection file and session store).
|
|
1325
|
+
*/
|
|
1326
|
+
cleanupInMemorySession(sessionId) {
|
|
1327
|
+
const session = this.sessions.get(sessionId);
|
|
1328
|
+
if (session?.filePath) {
|
|
1329
|
+
this.fileToSession.delete(session.filePath);
|
|
1330
|
+
}
|
|
1331
|
+
this.detachProcessLifecycle(this.kernelProcesses.get(sessionId));
|
|
1332
|
+
const sockets = this.zmqSockets.get(sessionId);
|
|
1333
|
+
if (sockets) {
|
|
1334
|
+
try {
|
|
1335
|
+
sockets.shell.close();
|
|
1336
|
+
sockets.iopub.close();
|
|
1337
|
+
}
|
|
1338
|
+
catch {
|
|
1339
|
+
// Ignore close errors
|
|
1340
|
+
}
|
|
1341
|
+
this.zmqSockets.delete(sessionId);
|
|
1342
|
+
}
|
|
1343
|
+
this.sessions.delete(sessionId);
|
|
1344
|
+
this.kernelProcesses.delete(sessionId);
|
|
1345
|
+
this.executionQueues.delete(sessionId);
|
|
1346
|
+
this.executionQueueSizes.delete(sessionId);
|
|
1347
|
+
this.shellRequestQueues.delete(sessionId);
|
|
1348
|
+
}
|
|
1349
|
+
/**
|
|
1350
|
+
* Interrupt kernel execution
|
|
1351
|
+
*/
|
|
1352
|
+
async interruptKernel(sessionId) {
|
|
1353
|
+
const proc = this.kernelProcesses.get(sessionId);
|
|
1354
|
+
if (proc) {
|
|
1355
|
+
try {
|
|
1356
|
+
proc.kill('SIGINT');
|
|
1357
|
+
return true;
|
|
1358
|
+
}
|
|
1359
|
+
catch {
|
|
1360
|
+
return false;
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
const session = this.sessions.get(sessionId);
|
|
1364
|
+
const pid = session?.pid ?? null;
|
|
1365
|
+
if (!pid)
|
|
1366
|
+
return false;
|
|
1367
|
+
try {
|
|
1368
|
+
// Best-effort: validate PID still matches the kernel we started/reattached to.
|
|
1369
|
+
// This matters when a server restarts and we no longer have a ChildProcess handle.
|
|
1370
|
+
const persisted = this.sessionStore.getSession(sessionId);
|
|
1371
|
+
const expectedStartTime = persisted?.kernelStartTime ?? null;
|
|
1372
|
+
const connectionFile = session?.connectionFile ?? persisted?.connectionFile ?? null;
|
|
1373
|
+
const ok = await this.isExpectedKernelProcess(pid, connectionFile, expectedStartTime);
|
|
1374
|
+
if (!ok) {
|
|
1375
|
+
console.warn(`[Kernel] Refusing to interrupt PID ${pid} for session ${sessionId}: unexpected process`);
|
|
1376
|
+
return false;
|
|
1377
|
+
}
|
|
1378
|
+
process.kill(pid, 'SIGINT');
|
|
1379
|
+
return true;
|
|
1380
|
+
}
|
|
1381
|
+
catch {
|
|
1382
|
+
return false;
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
/**
|
|
1386
|
+
* Restart a kernel (in-place, preserving session ID like Python)
|
|
1387
|
+
*/
|
|
1388
|
+
async restartKernel(sessionId) {
|
|
1389
|
+
const session = this.sessions.get(sessionId);
|
|
1390
|
+
if (!session) {
|
|
1391
|
+
return false;
|
|
1392
|
+
}
|
|
1393
|
+
const { kernelName, filePath, connectionFile: oldConnFile } = session;
|
|
1394
|
+
// Set status to restarting
|
|
1395
|
+
session.status = 'starting';
|
|
1396
|
+
// Close ZeroMQ sockets
|
|
1397
|
+
const sockets = this.zmqSockets.get(sessionId);
|
|
1398
|
+
if (sockets) {
|
|
1399
|
+
try {
|
|
1400
|
+
sockets.shell.close();
|
|
1401
|
+
sockets.iopub.close();
|
|
1402
|
+
}
|
|
1403
|
+
catch {
|
|
1404
|
+
// Ignore close errors
|
|
1405
|
+
}
|
|
1406
|
+
this.zmqSockets.delete(sessionId);
|
|
1407
|
+
}
|
|
1408
|
+
// Kill existing kernel process
|
|
1409
|
+
const proc = this.kernelProcesses.get(sessionId);
|
|
1410
|
+
if (proc) {
|
|
1411
|
+
this.detachProcessLifecycle(proc);
|
|
1412
|
+
try {
|
|
1413
|
+
proc.kill('SIGTERM');
|
|
1414
|
+
await this.sleep(500);
|
|
1415
|
+
if (!proc.killed) {
|
|
1416
|
+
proc.kill('SIGKILL');
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
catch {
|
|
1420
|
+
// Ignore kill errors
|
|
1421
|
+
}
|
|
1422
|
+
this.kernelProcesses.delete(sessionId);
|
|
1423
|
+
}
|
|
1424
|
+
else if (session.pid) {
|
|
1425
|
+
// Reattached session: no ChildProcess handle, but we may still have a PID.
|
|
1426
|
+
// Validate before terminating to avoid PID reuse mistakes.
|
|
1427
|
+
const persisted = this.sessionStore.getSession(sessionId);
|
|
1428
|
+
const expectedStartTime = persisted?.kernelStartTime ?? null;
|
|
1429
|
+
const connectionFile = oldConnFile ?? persisted?.connectionFile ?? null;
|
|
1430
|
+
const ok = await this.isExpectedKernelProcess(session.pid, connectionFile, expectedStartTime);
|
|
1431
|
+
if (!ok) {
|
|
1432
|
+
console.warn(`[Kernel] Refusing to restart PID ${session.pid} for session ${sessionId}: unexpected process`);
|
|
1433
|
+
session.status = 'dead';
|
|
1434
|
+
return false;
|
|
1435
|
+
}
|
|
1436
|
+
await this.terminatePid(session.pid);
|
|
1437
|
+
session.pid = null;
|
|
1438
|
+
}
|
|
1439
|
+
// Delete old connection file
|
|
1440
|
+
if (oldConnFile && fs.existsSync(oldConnFile)) {
|
|
1441
|
+
try {
|
|
1442
|
+
fs.unlinkSync(oldConnFile);
|
|
1443
|
+
}
|
|
1444
|
+
catch {
|
|
1445
|
+
// Ignore delete errors
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
// Get kernel spec
|
|
1449
|
+
const spec = (0, kernelspec_1.getKernelSpec)(kernelName);
|
|
1450
|
+
if (!spec) {
|
|
1451
|
+
session.status = 'dead';
|
|
1452
|
+
return false;
|
|
1453
|
+
}
|
|
1454
|
+
try {
|
|
1455
|
+
// Generate new connection file (reusing same session ID)
|
|
1456
|
+
const { config: connConfig, filePath: connFile } = await this.generateConnectionFile(sessionId);
|
|
1457
|
+
// Build kernel command
|
|
1458
|
+
const argv = (spec.argv || []).map(arg => arg.replace('{connection_file}', connFile));
|
|
1459
|
+
// Spawn new kernel process
|
|
1460
|
+
const cwd = filePath ? path.dirname(filePath) : process.cwd();
|
|
1461
|
+
const newProc = (0, child_process_1.spawn)(argv[0], argv.slice(1), {
|
|
1462
|
+
cwd,
|
|
1463
|
+
env: { ...process.env, ...spec.env },
|
|
1464
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1465
|
+
});
|
|
1466
|
+
// Log kernel stdout/stderr
|
|
1467
|
+
newProc.stdout?.on('data', (data) => {
|
|
1468
|
+
console.log(`[Kernel ${sessionId.slice(0, 8)}] stdout: ${data.toString().trim()}`);
|
|
1469
|
+
});
|
|
1470
|
+
newProc.stderr?.on('data', (data) => {
|
|
1471
|
+
console.error(`[Kernel ${sessionId.slice(0, 8)}] stderr: ${data.toString().trim()}`);
|
|
1472
|
+
});
|
|
1473
|
+
// Update session in-place
|
|
1474
|
+
session.connectionFile = connFile;
|
|
1475
|
+
session.connectionConfig = connConfig;
|
|
1476
|
+
session.pid = newProc.pid || null;
|
|
1477
|
+
session.executionCount = 0;
|
|
1478
|
+
session.lastActivity = Date.now() / 1000;
|
|
1479
|
+
this.kernelProcesses.set(sessionId, newProc);
|
|
1480
|
+
// Handle process events
|
|
1481
|
+
this.attachProcessLifecycle(sessionId, newProc, session);
|
|
1482
|
+
// Wait for kernel to be ready
|
|
1483
|
+
await this.waitForReady(sessionId);
|
|
1484
|
+
const kernelStartTime = newProc.pid ? await this.getProcessStartTime(newProc.pid) : null;
|
|
1485
|
+
// Update persistence store
|
|
1486
|
+
this.sessionStore.saveSession({
|
|
1487
|
+
sessionId,
|
|
1488
|
+
kernelName,
|
|
1489
|
+
filePath,
|
|
1490
|
+
kernelPid: newProc.pid || null,
|
|
1491
|
+
serverId: this.serverId,
|
|
1492
|
+
serverInstanceId: this.serverInstanceId,
|
|
1493
|
+
kernelStartTime,
|
|
1494
|
+
status: 'active',
|
|
1495
|
+
createdAt: session.createdAt,
|
|
1496
|
+
lastHeartbeat: Date.now() / 1000,
|
|
1497
|
+
connectionFile: connFile,
|
|
1498
|
+
connectionConfig: JSON.stringify(session.connectionConfig),
|
|
1499
|
+
});
|
|
1500
|
+
session.status = 'idle';
|
|
1501
|
+
return true;
|
|
1502
|
+
}
|
|
1503
|
+
catch (err) {
|
|
1504
|
+
console.error(`Failed to restart kernel ${sessionId}:`, err);
|
|
1505
|
+
await this.stopKernel(sessionId).catch(() => undefined);
|
|
1506
|
+
return false;
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
/**
|
|
1510
|
+
* Get session status
|
|
1511
|
+
*/
|
|
1512
|
+
getSessionStatusFast(sessionId) {
|
|
1513
|
+
const session = this.sessions.get(sessionId);
|
|
1514
|
+
if (!session) {
|
|
1515
|
+
return null;
|
|
1516
|
+
}
|
|
1517
|
+
return {
|
|
1518
|
+
id: session.id,
|
|
1519
|
+
kernelName: session.kernelName,
|
|
1520
|
+
filePath: session.filePath,
|
|
1521
|
+
status: session.status,
|
|
1522
|
+
executionCount: session.executionCount,
|
|
1523
|
+
memoryMb: null,
|
|
1524
|
+
pid: session.pid,
|
|
1525
|
+
createdAt: session.createdAt,
|
|
1526
|
+
};
|
|
1527
|
+
}
|
|
1528
|
+
async getSessionStatus(sessionId) {
|
|
1529
|
+
const session = this.sessions.get(sessionId);
|
|
1530
|
+
if (!session) {
|
|
1531
|
+
return null;
|
|
1532
|
+
}
|
|
1533
|
+
const memoryMap = session.pid ? await this.getProcessMemoryMap([session.pid]) : new Map();
|
|
1534
|
+
return {
|
|
1535
|
+
id: session.id,
|
|
1536
|
+
kernelName: session.kernelName,
|
|
1537
|
+
filePath: session.filePath,
|
|
1538
|
+
status: session.status,
|
|
1539
|
+
executionCount: session.executionCount,
|
|
1540
|
+
pid: session.pid,
|
|
1541
|
+
memoryMb: session.pid ? (memoryMap.get(session.pid) ?? null) : null,
|
|
1542
|
+
createdAt: session.createdAt,
|
|
1543
|
+
};
|
|
1544
|
+
}
|
|
1545
|
+
/**
|
|
1546
|
+
* Get all sessions
|
|
1547
|
+
*/
|
|
1548
|
+
async getAllSessions() {
|
|
1549
|
+
// Collect all PIDs for batch memory lookup
|
|
1550
|
+
const pids = [];
|
|
1551
|
+
for (const session of this.sessions.values()) {
|
|
1552
|
+
if (session.pid)
|
|
1553
|
+
pids.push(session.pid);
|
|
1554
|
+
}
|
|
1555
|
+
// Single ps call for all PIDs
|
|
1556
|
+
const memoryMap = await this.getProcessMemoryMap(pids);
|
|
1557
|
+
const sessions = [];
|
|
1558
|
+
for (const [_, session] of this.sessions) {
|
|
1559
|
+
sessions.push({
|
|
1560
|
+
id: session.id,
|
|
1561
|
+
kernelName: session.kernelName,
|
|
1562
|
+
filePath: session.filePath,
|
|
1563
|
+
status: session.status,
|
|
1564
|
+
executionCount: session.executionCount,
|
|
1565
|
+
pid: session.pid,
|
|
1566
|
+
memoryMb: session.pid ? (memoryMap.get(session.pid) ?? null) : null,
|
|
1567
|
+
createdAt: session.createdAt,
|
|
1568
|
+
});
|
|
1569
|
+
}
|
|
1570
|
+
return sessions;
|
|
1571
|
+
}
|
|
1572
|
+
/**
|
|
1573
|
+
* Get dead sessions (orphaned or terminated) that can be cleaned up
|
|
1574
|
+
*/
|
|
1575
|
+
getDeadSessions() {
|
|
1576
|
+
const deadSessions = this.sessionStore.getDeadSessions(this.serverId);
|
|
1577
|
+
return deadSessions.map(s => ({
|
|
1578
|
+
sessionId: s.sessionId,
|
|
1579
|
+
kernelName: s.kernelName,
|
|
1580
|
+
filePath: s.filePath,
|
|
1581
|
+
status: s.status,
|
|
1582
|
+
lastHeartbeat: s.lastHeartbeat,
|
|
1583
|
+
}));
|
|
1584
|
+
}
|
|
1585
|
+
/**
|
|
1586
|
+
* Cleanup dead sessions by deleting them from the database
|
|
1587
|
+
*/
|
|
1588
|
+
async cleanupDeadSessions(sessionIds) {
|
|
1589
|
+
const deadSessions = this.sessionStore.getDeadSessions(this.serverId);
|
|
1590
|
+
const targets = sessionIds && sessionIds.length > 0
|
|
1591
|
+
? deadSessions.filter(s => sessionIds.includes(s.sessionId))
|
|
1592
|
+
: deadSessions;
|
|
1593
|
+
const deletableIds = [];
|
|
1594
|
+
for (const session of targets) {
|
|
1595
|
+
let canDelete = true;
|
|
1596
|
+
if (session.kernelPid && this.isPidAlive(session.kernelPid)) {
|
|
1597
|
+
if (!session.kernelStartTime && !this.legacyCleanupWarned.has(session.sessionId)) {
|
|
1598
|
+
console.warn(`[Kernel] Deprecated cleanup fallback for session ${session.sessionId}: kernel_start_time missing. ` +
|
|
1599
|
+
'This fallback will be removed after legacy sessions are cleaned up.');
|
|
1600
|
+
this.legacyCleanupWarned.add(session.sessionId);
|
|
1601
|
+
}
|
|
1602
|
+
const expected = await this.isExpectedKernelProcess(session.kernelPid, session.connectionFile, session.kernelStartTime);
|
|
1603
|
+
if (!expected) {
|
|
1604
|
+
canDelete = false;
|
|
1605
|
+
}
|
|
1606
|
+
else {
|
|
1607
|
+
const killed = await this.terminatePid(session.kernelPid);
|
|
1608
|
+
if (!killed) {
|
|
1609
|
+
canDelete = false;
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
1613
|
+
if (canDelete) {
|
|
1614
|
+
this.cleanupPersistedSessionArtifacts(session);
|
|
1615
|
+
deletableIds.push(session.sessionId);
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1618
|
+
if (deletableIds.length === 0) {
|
|
1619
|
+
return 0;
|
|
1620
|
+
}
|
|
1621
|
+
return this.sessionStore.deleteSessions(deletableIds);
|
|
1622
|
+
}
|
|
1623
|
+
/**
|
|
1624
|
+
* Cleanup all sessions
|
|
1625
|
+
*/
|
|
1626
|
+
async cleanup() {
|
|
1627
|
+
const sessionIds = Array.from(this.sessions.keys());
|
|
1628
|
+
for (const sessionId of sessionIds) {
|
|
1629
|
+
await this.stopKernel(sessionId);
|
|
1630
|
+
}
|
|
1631
|
+
this.sessionStore.close();
|
|
1632
|
+
}
|
|
1633
|
+
/**
|
|
1634
|
+
* Shutdown kernel service.
|
|
1635
|
+
* If preserveKernels is true, keep kernel processes running and close the session store.
|
|
1636
|
+
*/
|
|
1637
|
+
async shutdown(options) {
|
|
1638
|
+
if (options?.preserveKernels) {
|
|
1639
|
+
try {
|
|
1640
|
+
this.sessionStore.markAllOrphaned(this.serverId, this.serverInstanceId);
|
|
1641
|
+
}
|
|
1642
|
+
catch {
|
|
1643
|
+
// Ignore errors marking orphaned
|
|
1644
|
+
}
|
|
1645
|
+
this.sessionStore.close();
|
|
1646
|
+
return;
|
|
1647
|
+
}
|
|
1648
|
+
await this.cleanup();
|
|
1649
|
+
}
|
|
1650
|
+
/**
|
|
1651
|
+
* Helper: sleep for ms milliseconds
|
|
1652
|
+
*/
|
|
1653
|
+
sleep(ms) {
|
|
1654
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
1655
|
+
}
|
|
1656
|
+
/**
|
|
1657
|
+
* Get memory usage (RSS) for multiple processes in MB
|
|
1658
|
+
* Returns a map of pid -> memoryMb
|
|
1659
|
+
* Cross-platform: uses /proc on Linux, ps on macOS
|
|
1660
|
+
*/
|
|
1661
|
+
async getProcessMemoryMap(pids) {
|
|
1662
|
+
const result = new Map();
|
|
1663
|
+
if (pids.length === 0)
|
|
1664
|
+
return result;
|
|
1665
|
+
const isLinux = process.platform === 'linux';
|
|
1666
|
+
if (isLinux) {
|
|
1667
|
+
// On Linux, read directly from /proc/{pid}/statm (more reliable)
|
|
1668
|
+
// statm format: size resident shared text lib data dt (all in pages)
|
|
1669
|
+
// resident (2nd field) * page_size = RSS in bytes
|
|
1670
|
+
const pageSize = 4096; // Standard page size, could use os.constants.POSIX.PAGE_SIZE
|
|
1671
|
+
const fs = await Promise.resolve().then(() => __importStar(require('fs/promises')));
|
|
1672
|
+
await Promise.all(pids.map(async (pid) => {
|
|
1673
|
+
try {
|
|
1674
|
+
const statm = await fs.readFile(`/proc/${pid}/statm`, 'utf8');
|
|
1675
|
+
const fields = statm.trim().split(/\s+/);
|
|
1676
|
+
if (fields.length >= 2) {
|
|
1677
|
+
const residentPages = parseInt(fields[1], 10);
|
|
1678
|
+
if (!isNaN(residentPages)) {
|
|
1679
|
+
const rssBytes = residentPages * pageSize;
|
|
1680
|
+
result.set(pid, Math.round(rssBytes / 1024 / 1024 * 10) / 10);
|
|
1681
|
+
}
|
|
1682
|
+
}
|
|
1683
|
+
}
|
|
1684
|
+
catch {
|
|
1685
|
+
// Process may have exited or permission denied
|
|
1686
|
+
}
|
|
1687
|
+
}));
|
|
1688
|
+
}
|
|
1689
|
+
else {
|
|
1690
|
+
// macOS/BSD: use ps command with comma-separated PIDs
|
|
1691
|
+
try {
|
|
1692
|
+
const pidList = pids.join(',');
|
|
1693
|
+
const { stdout } = await execAsync(`ps -o pid=,rss= -p ${pidList}`, { timeout: 500 });
|
|
1694
|
+
for (const line of stdout.trim().split('\n')) {
|
|
1695
|
+
const parts = line.trim().split(/\s+/);
|
|
1696
|
+
if (parts.length >= 2) {
|
|
1697
|
+
const pid = parseInt(parts[0], 10);
|
|
1698
|
+
const rssKb = parseInt(parts[1], 10);
|
|
1699
|
+
if (!isNaN(pid) && !isNaN(rssKb)) {
|
|
1700
|
+
result.set(pid, Math.round(rssKb / 1024 * 10) / 10);
|
|
1701
|
+
}
|
|
1702
|
+
}
|
|
1703
|
+
}
|
|
1704
|
+
}
|
|
1705
|
+
catch {
|
|
1706
|
+
// ps command failed or timed out
|
|
1707
|
+
}
|
|
1708
|
+
}
|
|
1709
|
+
return result;
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
exports.KernelService = KernelService;
|
|
1713
|
+
// Global instance
|
|
1714
|
+
exports.kernelService = new KernelService();
|