nebula-notebook 0.1.1 → 0.2.1

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