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.
Files changed (97) hide show
  1. package/README.md +222 -0
  2. package/bin/nebula-notebook.js +33 -0
  3. package/dist/assets/index-C1h_sArD.css +32 -0
  4. package/dist/assets/index-CDSTBon8.js +658 -0
  5. package/dist/favicon.svg +11 -0
  6. package/dist/index.html +73 -0
  7. package/node-server/dist/app.d.ts +5 -0
  8. package/node-server/dist/app.js +38 -0
  9. package/node-server/dist/auth/auth-middleware.d.ts +24 -0
  10. package/node-server/dist/auth/auth-middleware.js +276 -0
  11. package/node-server/dist/auth/auth-service.d.ts +84 -0
  12. package/node-server/dist/auth/auth-service.js +265 -0
  13. package/node-server/dist/auth/index.d.ts +3 -0
  14. package/node-server/dist/auth/index.js +8 -0
  15. package/node-server/dist/cluster/client-registration.d.ts +43 -0
  16. package/node-server/dist/cluster/client-registration.js +217 -0
  17. package/node-server/dist/cluster/cluster-secret.d.ts +11 -0
  18. package/node-server/dist/cluster/cluster-secret.js +90 -0
  19. package/node-server/dist/cluster/kernel-proxy.d.ts +100 -0
  20. package/node-server/dist/cluster/kernel-proxy.js +361 -0
  21. package/node-server/dist/cluster/server-registry.d.ts +109 -0
  22. package/node-server/dist/cluster/server-registry.js +217 -0
  23. package/node-server/dist/config/output-limits.d.ts +7 -0
  24. package/node-server/dist/config/output-limits.js +10 -0
  25. package/node-server/dist/discovery/discovery-service.d.ts +198 -0
  26. package/node-server/dist/discovery/discovery-service.js +811 -0
  27. package/node-server/dist/discovery/index.d.ts +5 -0
  28. package/node-server/dist/discovery/index.js +21 -0
  29. package/node-server/dist/discovery/types.d.ts +48 -0
  30. package/node-server/dist/discovery/types.js +24 -0
  31. package/node-server/dist/fs/fs-service.d.ts +218 -0
  32. package/node-server/dist/fs/fs-service.js +1422 -0
  33. package/node-server/dist/fs/index.d.ts +5 -0
  34. package/node-server/dist/fs/index.js +21 -0
  35. package/node-server/dist/fs/types.d.ts +132 -0
  36. package/node-server/dist/fs/types.js +5 -0
  37. package/node-server/dist/index.d.ts +13 -0
  38. package/node-server/dist/index.js +556 -0
  39. package/node-server/dist/kernel/default-kernel.d.ts +5 -0
  40. package/node-server/dist/kernel/default-kernel.js +138 -0
  41. package/node-server/dist/kernel/index.d.ts +7 -0
  42. package/node-server/dist/kernel/index.js +23 -0
  43. package/node-server/dist/kernel/kernel-service.d.ts +290 -0
  44. package/node-server/dist/kernel/kernel-service.js +1714 -0
  45. package/node-server/dist/kernel/kernelspec.d.ts +29 -0
  46. package/node-server/dist/kernel/kernelspec.js +210 -0
  47. package/node-server/dist/kernel/session-store.d.ts +87 -0
  48. package/node-server/dist/kernel/session-store.js +303 -0
  49. package/node-server/dist/kernel/types.d.ts +143 -0
  50. package/node-server/dist/kernel/types.js +17 -0
  51. package/node-server/dist/llm/index.d.ts +5 -0
  52. package/node-server/dist/llm/index.js +21 -0
  53. package/node-server/dist/llm/llm-service.d.ts +77 -0
  54. package/node-server/dist/llm/llm-service.js +454 -0
  55. package/node-server/dist/llm/types.d.ts +40 -0
  56. package/node-server/dist/llm/types.js +15 -0
  57. package/node-server/dist/notebook/cell-metadata.d.ts +27 -0
  58. package/node-server/dist/notebook/cell-metadata.js +76 -0
  59. package/node-server/dist/notebook/headless-handler.d.ts +127 -0
  60. package/node-server/dist/notebook/headless-handler.js +1530 -0
  61. package/node-server/dist/notebook/notebook-websocket.d.ts +12 -0
  62. package/node-server/dist/notebook/notebook-websocket.js +103 -0
  63. package/node-server/dist/notebook/operation-router.d.ts +115 -0
  64. package/node-server/dist/notebook/operation-router.js +641 -0
  65. package/node-server/dist/notebook/undoRedoManager.d.ts +194 -0
  66. package/node-server/dist/notebook/undoRedoManager.js +558 -0
  67. package/node-server/dist/output/display-data.d.ts +14 -0
  68. package/node-server/dist/output/display-data.js +134 -0
  69. package/node-server/dist/resources/resource-service.d.ts +69 -0
  70. package/node-server/dist/resources/resource-service.js +363 -0
  71. package/node-server/dist/routes/auth.d.ts +5 -0
  72. package/node-server/dist/routes/auth.js +61 -0
  73. package/node-server/dist/routes/cluster.d.ts +7 -0
  74. package/node-server/dist/routes/cluster.js +94 -0
  75. package/node-server/dist/routes/fs.d.ts +7 -0
  76. package/node-server/dist/routes/fs.js +392 -0
  77. package/node-server/dist/routes/kernel.d.ts +13 -0
  78. package/node-server/dist/routes/kernel.js +637 -0
  79. package/node-server/dist/routes/llm.d.ts +8 -0
  80. package/node-server/dist/routes/llm.js +105 -0
  81. package/node-server/dist/routes/notebook.d.ts +10 -0
  82. package/node-server/dist/routes/notebook.js +335 -0
  83. package/node-server/dist/routes/python.d.ts +8 -0
  84. package/node-server/dist/routes/python.js +187 -0
  85. package/node-server/dist/routes/resources.d.ts +7 -0
  86. package/node-server/dist/routes/resources.js +77 -0
  87. package/node-server/dist/scripts/show-auth-qr.d.ts +1 -0
  88. package/node-server/dist/scripts/show-auth-qr.js +81 -0
  89. package/node-server/dist/terminal/pty-manager.d.ts +100 -0
  90. package/node-server/dist/terminal/pty-manager.js +246 -0
  91. package/node-server/dist/terminal/server.d.ts +19 -0
  92. package/node-server/dist/terminal/server.js +254 -0
  93. package/node-server/dist/terminal/types.d.ts +50 -0
  94. package/node-server/dist/terminal/types.js +9 -0
  95. package/node-server/package.json +45 -0
  96. package/package.json +99 -0
  97. 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();