nebula-notebook 0.1.0 → 0.2.0

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