@damurka/jovian 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/LICENSE +24 -0
  2. package/README.md +175 -0
  3. package/docs/api/README.md +55 -0
  4. package/docs/api/session.md +143 -0
  5. package/docs/api/types.md +120 -0
  6. package/docs/architecture/overview.md +218 -0
  7. package/docs/cpp-usage.md +60 -0
  8. package/docs/development.md +105 -0
  9. package/docs/getting-started.md +127 -0
  10. package/docs/guides/comms.md +70 -0
  11. package/docs/guides/environments.md +80 -0
  12. package/docs/guides/history.md +44 -0
  13. package/docs/guides/interactive-input.md +48 -0
  14. package/docs/guides/interrupting.md +45 -0
  15. package/docs/guides/playground.md +33 -0
  16. package/docs/guides/sessions-lifecycle.md +70 -0
  17. package/docs/kernels.md +117 -0
  18. package/docs/protocol.md +135 -0
  19. package/docs/releasing.md +73 -0
  20. package/docs/troubleshooting.md +110 -0
  21. package/lib/execution/execution-queue.d.ts +20 -0
  22. package/lib/execution/execution-queue.js +256 -0
  23. package/lib/handlers/display-handler.d.ts +7 -0
  24. package/lib/handlers/display-handler.js +10 -0
  25. package/lib/handlers/error-handler.d.ts +7 -0
  26. package/lib/handlers/error-handler.js +8 -0
  27. package/lib/handlers/result-handler.d.ts +7 -0
  28. package/lib/handlers/result-handler.js +10 -0
  29. package/lib/handlers/stream-handler.d.ts +7 -0
  30. package/lib/handlers/stream-handler.js +8 -0
  31. package/lib/index.d.ts +7 -0
  32. package/lib/index.js +5 -0
  33. package/lib/messaging/message-parser.d.ts +6 -0
  34. package/lib/messaging/message-parser.js +33 -0
  35. package/lib/messaging/message-router.d.ts +14 -0
  36. package/lib/messaging/message-router.js +41 -0
  37. package/lib/middleware/index.d.ts +5 -0
  38. package/lib/middleware/index.js +5 -0
  39. package/lib/middleware/middleware-chain.d.ts +7 -0
  40. package/lib/middleware/middleware-chain.js +14 -0
  41. package/lib/middleware/middleware.d.ts +5 -0
  42. package/lib/middleware/middleware.js +2 -0
  43. package/lib/middleware/plugins/logging-plugin.d.ts +6 -0
  44. package/lib/middleware/plugins/logging-plugin.js +9 -0
  45. package/lib/middleware/plugins/metrics-plugin.d.ts +8 -0
  46. package/lib/middleware/plugins/metrics-plugin.js +13 -0
  47. package/lib/session/comm.d.ts +39 -0
  48. package/lib/session/comm.js +58 -0
  49. package/lib/session/native-paths.d.ts +48 -0
  50. package/lib/session/native-paths.js +108 -0
  51. package/lib/session/session-manager.d.ts +229 -0
  52. package/lib/session/session-manager.js +842 -0
  53. package/lib/session/supervisor-client.d.ts +36 -0
  54. package/lib/session/supervisor-client.js +147 -0
  55. package/lib/types/engine.d.ts +269 -0
  56. package/lib/types/engine.js +2 -0
  57. package/lib/types/index.d.ts +3 -0
  58. package/lib/types/index.js +3 -0
  59. package/lib/types/messages.d.ts +68 -0
  60. package/lib/types/messages.js +2 -0
  61. package/lib/utils/logger.d.ts +12 -0
  62. package/lib/utils/logger.js +58 -0
  63. package/lib/utils/network.d.ts +11 -0
  64. package/lib/utils/network.js +50 -0
  65. package/package.json +57 -0
  66. package/packages/hera/DESCRIPTION +29 -0
  67. package/packages/hera/LICENSE +2 -0
  68. package/packages/hera/LICENSE.md +21 -0
  69. package/packages/hera/NAMESPACE +32 -0
  70. package/packages/hera/NEWS.md +7 -0
  71. package/packages/hera/R/cell_options.R +13 -0
  72. package/packages/hera/R/comm.R +228 -0
  73. package/packages/hera/R/completion.R +54 -0
  74. package/packages/hera/R/execute.R +199 -0
  75. package/packages/hera/R/inspect.R +73 -0
  76. package/packages/hera/R/log.R +14 -0
  77. package/packages/hera/R/mime_bundle.R +65 -0
  78. package/packages/hera/R/routines.R +86 -0
  79. package/packages/hera/R/utils.R +32 -0
  80. package/packages/hera/R/zzz.R +128 -0
  81. package/packages/hera/man/Comm.Rd +179 -0
  82. package/packages/hera/man/CommManager.Rd +215 -0
  83. package/packages/hera/man/View.Rd +22 -0
  84. package/packages/hera/man/cell_options.Rd +20 -0
  85. package/packages/hera/man/clear_output.Rd +23 -0
  86. package/packages/hera/man/complete.Rd +23 -0
  87. package/packages/hera/man/display_data.Rd +22 -0
  88. package/packages/hera/man/is_elara.Rd +18 -0
  89. package/packages/hera/man/mime_bundle.Rd +25 -0
  90. package/packages/hera/man/mime_types.Rd +22 -0
  91. package/packages/hera/man/reexports.Rd +16 -0
@@ -0,0 +1,842 @@
1
+ import { EventEmitter } from 'events';
2
+ import { randomUUID } from 'crypto';
3
+ import { Logger } from '../utils/logger.js';
4
+ import { MessageRouter } from '../messaging/message-router.js';
5
+ import { ExecutionQueue } from '../execution/execution-queue.js';
6
+ import { MiddlewareChain } from '../middleware/middleware-chain.js';
7
+ import { LoggingMiddleware } from '../middleware/plugins/logging-plugin.js';
8
+ import { MetricsMiddleware } from '../middleware/plugins/metrics-plugin.js';
9
+ import { StreamHandler } from '../handlers/stream-handler.js';
10
+ import { ResultHandler } from '../handlers/result-handler.js';
11
+ import { ErrorHandler } from '../handlers/error-handler.js';
12
+ import { DisplayHandler } from '../handlers/display-handler.js';
13
+ import { findFreePort, waitForPort } from '../utils/network.js';
14
+ import { SupervisorClient } from './supervisor-client.js';
15
+ import { Comm } from './comm.js';
16
+ const DEFAULT_REQUEST_TIMEOUT_MS = 10000;
17
+ // How long stop() waits for the kernel's shutdown_reply after the
18
+ // supervisor reports the stop done (it is normally already here by then).
19
+ const SHUTDOWN_REPLY_WAIT_MS = 250;
20
+ function streamLength(message) {
21
+ const text = message.content?.text;
22
+ return typeof text === 'string' ? text.length : 0;
23
+ }
24
+ function replyTypeOf(requestType) {
25
+ return requestType.replace(/_request$/, '_reply');
26
+ }
27
+ /**
28
+ * One R session running in its own OS process (elara, spawned and
29
+ * supervised by themisto -- see lib/session/supervisor-client.ts),
30
+ * proxying execute()/createShiny()/stop() over a per-session WebSocket. The
31
+ * supervisor is the only process in this tree that ever links a native ZMQ
32
+ * binding; this class only ever does plain HTTP/WS, so it's safe to run
33
+ * inside Electron/VS Code's Shared Process without any native-addon-loading
34
+ * concerns.
35
+ *
36
+ * The MessageRouter/handlers/ExecutionQueue/MiddlewareChain pipeline below
37
+ * doesn't care where its raw JSON envelope strings come from -- here, that's
38
+ * the WebSocket's 'message' frames.
39
+ */
40
+ // Local, in-memory only -- same reasoning as the playground's own
41
+ // MAX_HISTORY_CELLS (tools/playground/server.js): bounds a long-lived
42
+ // Session's memory use against a session that just keeps running forever,
43
+ // without needing every caller to remember to cap it themselves.
44
+ const MAX_EXECUTION_HISTORY_ENTRIES = 200;
45
+ // Per entry: an execution that prints millions of lines must not make
46
+ // getHistory() (and anything that serializes it, e.g. a browser reloading its
47
+ // transcript) hold and ship hundreds of megabytes. The newest stream text is
48
+ // kept.
49
+ const MAX_HISTORY_STREAM_CHARS = 500_000;
50
+ export class Session extends EventEmitter {
51
+ ws;
52
+ // Public (not just for this class's own use): callers that need to
53
+ // talk to the supervisor's HTTP API directly for something this class
54
+ // doesn't itself expose (e.g. the playground's PID/memory-usage
55
+ // display, via GET {httpBase}/sessions/{sessionId}) can, instead of
56
+ // needing a new method here for every such diagnostic.
57
+ info;
58
+ // The exact options this session was created with -- e.g. so a caller
59
+ // that only has a `Session` handle (not the options it was originally
60
+ // built from) can still answer "what R_HOME/PYTHONHOME is this",
61
+ // without needing its own separate bookkeeping (a real gap: the
62
+ // playground tool used to duplicate this into its own per-session
63
+ // `entry.config` purely because nothing on Session itself exposed it).
64
+ currentOptions;
65
+ supervisor;
66
+ logger;
67
+ router;
68
+ middleware;
69
+ queue;
70
+ /**
71
+ * The options this session is currently running with: what it was
72
+ * created with, updated by any `restart(options)` that changed them.
73
+ */
74
+ get options() {
75
+ return this.currentOptions;
76
+ }
77
+ readyPromise;
78
+ stopped = false;
79
+ comms = new Map();
80
+ busyRequests = new Set();
81
+ pendingRequests = new Map();
82
+ kernelExecutionState;
83
+ // Every execute() call's code + the iopub messages it produced, bucketed
84
+ // by the execute_request's own msg id (execute_input's parentMsgId) --
85
+ // see getHistory()'s doc comment for what this is actually for.
86
+ executionHistory = [];
87
+ executionHistoryByMsgId = new Map();
88
+ historyStreamChars = new WeakMap();
89
+ constructor(info, options, supervisor) {
90
+ super();
91
+ this.info = info;
92
+ this.currentOptions = options;
93
+ this.supervisor = supervisor;
94
+ this.logger = new Logger(options.logger);
95
+ this.on('message', (message) => {
96
+ this.recordExecutionHistory(message);
97
+ this.settleRequest(message);
98
+ this.trackExecutionState(message);
99
+ this.routeComm(message);
100
+ });
101
+ this.router = new MessageRouter(this);
102
+ this.router.registerHandler('stream', new StreamHandler());
103
+ this.router.registerHandler('execute_result', new ResultHandler());
104
+ this.router.registerHandler('display_data', new DisplayHandler());
105
+ this.router.registerHandler('error', new ErrorHandler());
106
+ this.middleware = new MiddlewareChain();
107
+ if (options.enableLogging) {
108
+ this.middleware.use(new LoggingMiddleware());
109
+ }
110
+ if (options.enableMetrics) {
111
+ this.middleware.use(new MetricsMiddleware());
112
+ }
113
+ // Adapter exposing the same `{ execute(code): msgId }` shape the
114
+ // native addon used to provide directly -- ExecutionQueue's
115
+ // single-flight/timeout/msgId-correlation logic
116
+ // (lib/execution/execution-queue.ts) is reused completely
117
+ // unmodified, it just sends over the WebSocket now instead of
118
+ // calling into an in-process addon. The id is generated here
119
+ // (client-side) rather than returned from the "addon", since the
120
+ // supervisor has no synchronous return path over a WS send.
121
+ const wsAddon = {
122
+ execute: (code, options = {}) => {
123
+ const id = randomUUID();
124
+ this.send({ type: 'execute', id, code, options });
125
+ return id;
126
+ }
127
+ };
128
+ this.queue = new ExecutionQueue(wsAddon, this, options.queueSize, this.logger, () => {
129
+ void this.interrupt();
130
+ });
131
+ this.readyPromise = this.connect();
132
+ }
133
+ /**
134
+ * (Re)establishes the WebSocket to this.info's session and resolves
135
+ * once it's ready. Used both by the constructor and by restart() --
136
+ * info.sessionId/httpBase/wsBase don't change across a restart
137
+ * (SessionRegistry::restartSession() replaces the kernel in place under
138
+ * the same id), so reconnecting to the exact same URL is enough to pick
139
+ * back up a session the supervisor just gave a fresh kernel.
140
+ */
141
+ connect() {
142
+ // Defensive, not just for restart()'s benefit: closing an
143
+ // already-closed/undefined socket is a no-op, so this is safe to
144
+ // call unconditionally even from the constructor where this.ws is
145
+ // still undefined.
146
+ this.ws?.close();
147
+ return new Promise((resolve, reject) => {
148
+ const url = `${this.info.wsBase}/sessions/${this.info.sessionId}/messages`;
149
+ this.logger.debug(`Connecting to session ${this.info.sessionId} at ${url}`);
150
+ const ws = new WebSocket(url);
151
+ this.ws = ws;
152
+ const onOpenError = () => reject(new Error(`WebSocket connection to session ${this.info.sessionId} failed`));
153
+ const onCloseBeforeReady = () => reject(new Error(`Session ${this.info.sessionId} closed before it was ready`));
154
+ const onReady = () => {
155
+ cleanup();
156
+ resolve();
157
+ };
158
+ const cleanup = () => {
159
+ ws.removeEventListener('error', onOpenError);
160
+ ws.removeEventListener('close', onCloseBeforeReady);
161
+ };
162
+ ws.addEventListener('error', onOpenError);
163
+ ws.addEventListener('close', onCloseBeforeReady);
164
+ ws.addEventListener('message', (event) => {
165
+ void this.handleFrame(String(event.data), onReady);
166
+ });
167
+ // Unlike the ready-phase handlers above (removed once ready
168
+ // resolves), this listener stays for this socket's whole
169
+ // lifetime. Without it, a kernel crash mid-execution left every
170
+ // pending execute()/createShiny() call hanging forever --
171
+ // nothing else ever settles those promises. Mirrors the old
172
+ // child.on('exit') handler this replaces.
173
+ //
174
+ // `this.ws !== ws` guards against a stale event from a socket
175
+ // restart() already superseded: closing the old one above is
176
+ // async from the browser/runtime WebSocket's perspective, so
177
+ // its 'close' can still fire after this.ws has moved on to a
178
+ // newer connection.
179
+ ws.addEventListener('close', () => {
180
+ if (this.stopped || this.ws !== ws) {
181
+ return;
182
+ }
183
+ this.logger.error(`Session ${this.info.sessionId} connection closed unexpectedly`);
184
+ this.emit('exit', { reason: 'WebSocket connection to the supervisor closed unexpectedly' });
185
+ this.queue.clear();
186
+ this.closeAllComms('connection lost');
187
+ this.rejectPendingRequests(new Error('WebSocket connection to the supervisor closed unexpectedly'));
188
+ });
189
+ });
190
+ }
191
+ /**
192
+ * Replaces this session's kernel process in place, keeping the same
193
+ * session id -- recovers a crashed session (kernelExit/unexpected close
194
+ * leaves the Session object itself alive but every execute() rejecting
195
+ * forever otherwise), and doubles as Jupyter's "Restart Kernel" for a
196
+ * still-healthy one. Not available after an explicit stop()/kill(): at
197
+ * that point the caller's intent was to end the session, not reset it
198
+ * -- create a new one instead via SessionManager.createSession().
199
+ *
200
+ * `options`, if given, switches this session's R installation on the
201
+ * restart (rHome/rPath/etc) instead of reusing whatever it was created
202
+ * with -- e.g. flip from R 4.4 to R 4.6 on the fly, without closing
203
+ * this session and opening a new one (a different session id/WS URL)
204
+ * just to pick a different R.
205
+ */
206
+ async restart(options) {
207
+ if (this.stopped) {
208
+ throw new Error(`Cannot restart session ${this.info.sessionId}: it was already stopped`);
209
+ }
210
+ this.logger.info(`Restarting session ${this.info.sessionId}`);
211
+ this.queue.clear();
212
+ this.closeAllComms('kernel restarted');
213
+ // The supervisor replaces a session's options wholesale, so send the
214
+ // merge -- a restart that only switches rHome must keep the
215
+ // workingDirectory, rLibs, ... the session was created with.
216
+ const mergedOptions = options ? { ...this.currentOptions, ...options } : undefined;
217
+ // Reassigned synchronously, before awaiting anything below, so a
218
+ // concurrent execute()/createShiny() call that reads this.readyPromise
219
+ // while the restart is still in flight waits for the new connection
220
+ // instead of racing the old (already-dead-or-dying) one.
221
+ this.rejectPendingRequests(new Error('Session is restarting'));
222
+ const shutdownReply = this.watchFor('shutdown_reply');
223
+ this.readyPromise = (async () => {
224
+ try {
225
+ await this.supervisor.restartSession(this.info, mergedOptions);
226
+ if (mergedOptions) {
227
+ this.currentOptions = mergedOptions;
228
+ }
229
+ }
230
+ finally {
231
+ // The supervisor sent the old kernel a real shutdown_request
232
+ // (restart: true); its shutdown_reply arrives on this (old)
233
+ // socket as a normal 'shutdown_reply' event -- give it a
234
+ // moment to land before that socket is replaced. (A kernel
235
+ // that had already crashed never sends one, hence the short
236
+ // cap.) In a finally so the watcher is always cleaned up.
237
+ await shutdownReply(200);
238
+ }
239
+ await this.connect();
240
+ })();
241
+ await this.readyPromise;
242
+ this.logger.info(`Session ${this.info.sessionId} restarted`);
243
+ this.emit('restarted');
244
+ }
245
+ send(frame) {
246
+ this.ws?.send(JSON.stringify(frame));
247
+ }
248
+ // Buckets every iopub message this session produces by which
249
+ // execute_request it belongs to, purely from the messages themselves --
250
+ // execute_input's own content.code/execution_count is enough to start a
251
+ // new entry, so this needs no separate bookkeeping of the original
252
+ // execute() call. Mirrors tools/playground/server.js's recordHistory(),
253
+ // now available to every consumer of this library, not just that one
254
+ // demo tool.
255
+ recordExecutionHistory(message) {
256
+ if (message.msgType === 'execute_input') {
257
+ const entry = {
258
+ code: message.content?.code ?? '',
259
+ executionCount: message.content?.execution_count,
260
+ time: Date.now(),
261
+ messages: []
262
+ };
263
+ this.executionHistory.push(entry);
264
+ this.executionHistoryByMsgId.set(message.parentMsgId, entry);
265
+ if (this.executionHistory.length > MAX_EXECUTION_HISTORY_ENTRIES) {
266
+ const removed = this.executionHistory.shift();
267
+ if (removed) {
268
+ for (const [msgId, e] of this.executionHistoryByMsgId) {
269
+ if (e === removed) {
270
+ this.executionHistoryByMsgId.delete(msgId);
271
+ break;
272
+ }
273
+ }
274
+ }
275
+ }
276
+ return;
277
+ }
278
+ // A stale input_request makes no sense to keep around -- by the
279
+ // time anyone reads getHistory(), it's either long since been
280
+ // answered (over the stdin channel, which never shows up as a
281
+ // 'message' event -- see the class doc on sendInputReply()) or
282
+ // whatever was blocked on it is long gone either way.
283
+ if (message.msgType === 'input_request') {
284
+ return;
285
+ }
286
+ const entry = this.executionHistoryByMsgId.get(message.parentMsgId);
287
+ if (entry) {
288
+ entry.messages.push(message);
289
+ if (message.msgType === 'stream') {
290
+ this.boundStreamHistory(entry, streamLength(message));
291
+ }
292
+ }
293
+ }
294
+ // Drops the oldest stream messages of `entry` until it holds at most
295
+ // MAX_HISTORY_STREAM_CHARS of stream text.
296
+ boundStreamHistory(entry, added) {
297
+ let total = (this.historyStreamChars.get(entry) ?? 0) + added;
298
+ while (total > MAX_HISTORY_STREAM_CHARS) {
299
+ const index = entry.messages.findIndex((m) => m.msgType === 'stream');
300
+ // Keep at least the message that was just added.
301
+ if (index < 0 || index === entry.messages.length - 1)
302
+ break;
303
+ total -= streamLength(entry.messages[index]);
304
+ entry.messages.splice(index, 1);
305
+ entry.truncated = true;
306
+ }
307
+ this.historyStreamChars.set(entry, total);
308
+ }
309
+ async handleFrame(text, onReady) {
310
+ let frame;
311
+ try {
312
+ frame = JSON.parse(text);
313
+ }
314
+ catch {
315
+ return;
316
+ }
317
+ switch (frame.type) {
318
+ case 'ready':
319
+ onReady();
320
+ break;
321
+ case 'message':
322
+ try {
323
+ const processed = await this.middleware.process(text);
324
+ await this.router.route(processed);
325
+ }
326
+ catch (error) {
327
+ this.logger.error('Error handling message', error);
328
+ this.emit('error', error);
329
+ }
330
+ break;
331
+ case 'log': {
332
+ // A logger callback can't cross the process boundary to the
333
+ // supervisor/kernel, so log output arrives as
334
+ // {type:'log', level, message, data} frames instead and gets
335
+ // replayed through this session's own Logger (built from the
336
+ // original caller-supplied callback) here.
337
+ const level = frame.level ?? 'info';
338
+ this.logger[level](String(frame.message ?? ''), frame.data);
339
+ break;
340
+ }
341
+ case 'kernelExit':
342
+ if (!this.stopped) {
343
+ const reason = typeof frame.reason === 'string' ? frame.reason : 'unknown reason';
344
+ this.logger.error(`R session process for ${this.info.sessionId} exited unexpectedly: ${reason}`);
345
+ this.emit('exit', { reason });
346
+ this.queue.clear();
347
+ this.closeAllComms('kernel exited');
348
+ this.rejectPendingRequests(new Error(`Session process exited: ${reason}`));
349
+ }
350
+ break;
351
+ case 'requestError': {
352
+ // The supervisor refused a request outright (unknown type
353
+ // for that channel, session gone) -- no reply will ever come.
354
+ const id = typeof frame.id === 'string' ? frame.id : '';
355
+ const error = new Error(typeof frame.error === 'string' ? frame.error : 'request rejected by the supervisor');
356
+ const pending = this.pendingRequests.get(id);
357
+ if (pending) {
358
+ clearTimeout(pending.timer);
359
+ this.pendingRequests.delete(id);
360
+ pending.reject(error);
361
+ }
362
+ else {
363
+ // Fire-and-forget (comm_*): nothing awaiting it.
364
+ this.emit('requestError', { id, error });
365
+ }
366
+ break;
367
+ }
368
+ default:
369
+ break;
370
+ }
371
+ }
372
+ /** Resolves once this session's R interpreter has started. */
373
+ ready() {
374
+ return this.readyPromise;
375
+ }
376
+ async execute(code, options = {}) {
377
+ await this.readyPromise;
378
+ return this.queue.execute(code, options);
379
+ }
380
+ /**
381
+ * Answers a pending input_request -- this session emits one (see the
382
+ * 'input_request' event, content: {prompt, password}) whenever the
383
+ * kernel calls input()/readline()/scan() during an execute() that was
384
+ * given { allowStdin: true }, and genuinely blocks its single execution
385
+ * thread until this arrives (ServerZmqImpl::sendStdin() in
386
+ * native/src/adrastea/transport/server/server_zmq_impl.cpp does a real,
387
+ * untimed ZMQ recv underneath). Fire-and-forget like interrupt(): the
388
+ * reply that eventually unblocks the kernel surfaces through the
389
+ * *execute_request's own* execute_reply/stream messages, not through a
390
+ * reply to this call.
391
+ */
392
+ sendInputReply(value) {
393
+ this.send({ type: 'inputReply', value });
394
+ }
395
+ /**
396
+ * This session's own local record of every execute() call it has made
397
+ * and what each one produced (code + every iopub message), for as long
398
+ * as this Session object has been alive. Purely in-memory and
399
+ * process-local -- gone if the process holding this Session restarts,
400
+ * same as the Session object itself. Useful for e.g. rebuilding a UI's
401
+ * transcript after some *other* thing (not this process) reconnects to
402
+ * it, or for inspecting what actually ran without threading your own
403
+ * bookkeeping through every execute() call site.
404
+ *
405
+ * Not the same thing as queryKernelHistory(): this is this session's
406
+ * own bookkeeping (full fidelity -- includes actual output, which the
407
+ * kernel's own history manager doesn't track), while that one asks the
408
+ * *kernel itself* what it remembers running (input code only,
409
+ * authoritative even if some other client executed it, but capped by
410
+ * this process's own historical view of it, and lost across a kernel
411
+ * restart the same as the kernel's own memory of it is).
412
+ */
413
+ getHistory() {
414
+ return this.executionHistory;
415
+ }
416
+ /**
417
+ * Sends a real Jupyter history_request and resolves with the kernel's
418
+ * own history_reply (KernelCore::historyRequest() ->
419
+ * HistoryManager::processRequest(), native/src/adrastea/core/history/)
420
+ * -- the kernel's own authoritative record of what it has executed,
421
+ * independent of which client (or how many, over how many reconnects)
422
+ * actually ran it. Defaults to the 100 most recent executions ('tail').
423
+ * See KernelHistoryOptions' own doc comment for the other access modes,
424
+ * and getHistory()'s doc comment for how this differs from that.
425
+ */
426
+ async queryKernelHistory(options = {}) {
427
+ // Wire field names are snake_case (Jupyter's history_request).
428
+ const content = {
429
+ hist_access_type: options.histAccessType ?? 'tail',
430
+ output: options.output ?? false,
431
+ raw: options.raw ?? true,
432
+ n: options.n ?? 100
433
+ };
434
+ for (const key of ['session', 'start', 'stop', 'pattern', 'unique']) {
435
+ if (options[key] !== undefined)
436
+ content[key] = options[key];
437
+ }
438
+ const reply = await this.request('history_request', content);
439
+ return reply.history ?? [];
440
+ }
441
+ /**
442
+ * Sends interrupt_request over the control channel and resolves true if
443
+ * the kernel acknowledged it (interrupt_reply, status ok), false if it
444
+ * didn't within `options.timeout` (default 5s) or the session is gone --
445
+ * never rejects, since a caller typically fires this from a "stop"
446
+ * button and has nothing useful to do with an error.
447
+ *
448
+ * A real interrupt: the kernel services its control channel on a
449
+ * separate thread while code runs, so this is answered immediately even
450
+ * mid-execution, and the running code is broken out of exactly as Ctrl-C
451
+ * would (R: an interrupt condition; Python: KeyboardInterrupt). The
452
+ * interrupted execute() resolves with success: false. Interrupting an
453
+ * idle kernel does nothing. Limits: code blocked inside a native call
454
+ * that never returns to the interpreter (a long C extension call, a
455
+ * blocking socket read) is only interrupted once it does, and a kernel
456
+ * waiting on an input() / readline() reply must be answered (or its
457
+ * execute() timed out) first.
458
+ */
459
+ async interrupt(options = {}) {
460
+ try {
461
+ const reply = await this.request('interrupt_request', {}, { timeout: options.timeout ?? 5000 });
462
+ return reply.status === 'ok';
463
+ }
464
+ catch {
465
+ return false;
466
+ }
467
+ }
468
+ /**
469
+ * The latest iopub `status` the kernel reported ('busy' while it is
470
+ * handling a request, 'idle' between them) -- undefined until the first
471
+ * one arrives. Also available as the 'status' event.
472
+ */
473
+ get executionState() {
474
+ return this.kernelExecutionState;
475
+ }
476
+ /**
477
+ * Sends any of the Jupyter requests that have a plain request/reply
478
+ * shape and resolves with the kernel's reply content: complete_request,
479
+ * inspect_request, is_complete_request, kernel_info_request,
480
+ * history_request, comm_info_request (shell) and interrupt_request
481
+ * (control) -- the typed methods below (complete(), inspect(), ...) are
482
+ * this with the right message type and content filled in. Rejects on a
483
+ * reply whose status is 'error'/'aborted', when the supervisor refuses
484
+ * the request, on timeout, or if the session goes away first.
485
+ *
486
+ * Deliberately not for execute_request (use execute(): it owns the
487
+ * queue/timeout/stdin semantics), input_reply (sendInputReply()) or
488
+ * shutdown_request (stop()/restart()) -- the supervisor rejects those.
489
+ */
490
+ async request(msgType, content = {}, options = {}) {
491
+ await this.readyPromise;
492
+ const id = randomUUID();
493
+ const timeoutMs = options.timeout ?? DEFAULT_REQUEST_TIMEOUT_MS;
494
+ const channel = msgType === 'interrupt_request' ? 'control' : 'shell';
495
+ const replyType = replyTypeOf(msgType);
496
+ return new Promise((resolve, reject) => {
497
+ const timer = setTimeout(() => {
498
+ this.pendingRequests.delete(id);
499
+ reject(new Error(`Timed out waiting for a ${replyType} after ${timeoutMs}ms`));
500
+ }, timeoutMs);
501
+ this.pendingRequests.set(id, {
502
+ replyType,
503
+ resolve: resolve,
504
+ reject,
505
+ timer
506
+ });
507
+ this.send({ type: 'request', id, channel, msgType, content });
508
+ });
509
+ }
510
+ // busy/idle come in pairs per request the kernel handles -- and an
511
+ // interrupt is handled WHILE an execution is running, so its idle must
512
+ // not flip the state to idle under a still-running execution. Busy while
513
+ // any request is outstanding.
514
+ trackExecutionState(message) {
515
+ if (message.msgType !== 'status') {
516
+ return;
517
+ }
518
+ const state = message.content?.execution_state;
519
+ if (state === 'busy') {
520
+ this.busyRequests.add(message.parentMsgId);
521
+ this.kernelExecutionState = 'busy';
522
+ }
523
+ else if (state === 'idle') {
524
+ this.busyRequests.delete(message.parentMsgId);
525
+ this.kernelExecutionState = this.busyRequests.size > 0 ? 'busy' : 'idle';
526
+ }
527
+ else {
528
+ this.kernelExecutionState = state;
529
+ }
530
+ }
531
+ // Routes the kernel's comm traffic to the Comm objects.
532
+ routeComm(message) {
533
+ const content = message.content;
534
+ const commId = content?.comm_id;
535
+ if (!commId) {
536
+ return;
537
+ }
538
+ switch (message.msgType) {
539
+ case 'comm_open': {
540
+ if (this.comms.has(commId)) {
541
+ return;
542
+ }
543
+ const comm = new Comm(commId, content?.target_name ?? '', this);
544
+ this.comms.set(commId, comm);
545
+ this.emit('comm', comm, content?.data ?? {});
546
+ break;
547
+ }
548
+ case 'comm_msg':
549
+ this.comms.get(commId)?.receiveMessage(content?.data ?? {});
550
+ break;
551
+ case 'comm_close': {
552
+ const comm = this.comms.get(commId);
553
+ this.comms.delete(commId);
554
+ comm?.receiveClose(content?.data ?? {});
555
+ break;
556
+ }
557
+ default:
558
+ break;
559
+ }
560
+ }
561
+ closeAllComms(reason) {
562
+ for (const comm of this.comms.values()) {
563
+ comm.receiveClose({ reason });
564
+ }
565
+ this.comms.clear();
566
+ this.busyRequests.clear();
567
+ }
568
+ settleRequest(message) {
569
+ const pending = this.pendingRequests.get(message.parentMsgId);
570
+ if (!pending || message.msgType !== pending.replyType) {
571
+ return;
572
+ }
573
+ clearTimeout(pending.timer);
574
+ this.pendingRequests.delete(message.parentMsgId);
575
+ const content = message.content;
576
+ if (content?.status === 'error') {
577
+ pending.reject(new Error(content.evalue ?? content.ename ?? `${pending.replyType} reported an error`));
578
+ }
579
+ else if (content?.status === 'aborted') {
580
+ pending.reject(new Error(`${pending.replyType} was aborted: an earlier request failed with stopOnError`));
581
+ }
582
+ else {
583
+ pending.resolve(message.content);
584
+ }
585
+ }
586
+ rejectPendingRequests(error) {
587
+ for (const [id, pending] of this.pendingRequests) {
588
+ clearTimeout(pending.timer);
589
+ pending.reject(error);
590
+ this.pendingRequests.delete(id);
591
+ }
592
+ }
593
+ // Starts listening for `msgType` NOW and returns a function that waits
594
+ // (up to its timeout) for it -- so a message that arrives before the
595
+ // caller gets around to waiting (e.g. a shutdown_reply that lands while
596
+ // the HTTP stop call is still in flight) is not missed. Never rejects:
597
+ // resolves undefined on timeout.
598
+ watchFor(msgType) {
599
+ let received;
600
+ let notify;
601
+ const listener = (message) => {
602
+ if (message.msgType !== msgType)
603
+ return;
604
+ received = message;
605
+ this.off('message', listener);
606
+ notify?.();
607
+ };
608
+ this.on('message', listener);
609
+ return (timeoutMs) => new Promise((resolve) => {
610
+ if (received) {
611
+ resolve(received);
612
+ return;
613
+ }
614
+ const timer = setTimeout(() => {
615
+ this.off('message', listener);
616
+ resolve(undefined);
617
+ }, timeoutMs);
618
+ notify = () => {
619
+ clearTimeout(timer);
620
+ resolve(received);
621
+ };
622
+ });
623
+ }
624
+ /**
625
+ * What the supervisor knows about this session's kernel process right
626
+ * now: lifecycle status, pid, memory, working directory and the
627
+ * heartbeat (round-trip time of the last ping, missed pings). The
628
+ * heartbeat is answered by a kernel thread separate from the one that
629
+ * runs code, so it stays live while the kernel is busy -- unlike
630
+ * kernelInfo(), which would wait for the running code to finish.
631
+ */
632
+ async status() {
633
+ const res = await fetch(`${this.info.httpBase}/sessions/${this.info.sessionId}`);
634
+ if (!res.ok) {
635
+ throw new Error(`Could not read the status of session ${this.info.sessionId} (HTTP ${res.status})`);
636
+ }
637
+ return await res.json();
638
+ }
639
+ /** complete_request: completions for the code at `cursorPos` (default: the end of `code`). */
640
+ complete(code, cursorPos = code.length) {
641
+ return this.request('complete_request', { code, cursor_pos: cursorPos });
642
+ }
643
+ /** inspect_request: documentation/details for the symbol at `cursorPos` (default: the end of `code`). */
644
+ inspect(code, cursorPos = code.length, detailLevel = 0) {
645
+ return this.request('inspect_request', { code, cursor_pos: cursorPos, detail_level: detailLevel });
646
+ }
647
+ /**
648
+ * is_complete_request: whether `code` is a complete statement, needs more
649
+ * lines ('incomplete', with an `indent` hint when the kernel has one),
650
+ * or can never parse ('invalid') -- what a console needs to decide
651
+ * between "run it" and "keep prompting".
652
+ */
653
+ isComplete(code) {
654
+ return this.request('is_complete_request', { code });
655
+ }
656
+ /** kernel_info_request: what the kernel is -- implementation, language and its version, protocol version, banner. */
657
+ kernelInfo() {
658
+ return this.request('kernel_info_request');
659
+ }
660
+ /** comm_info_request: the comms currently open in the kernel, optionally only those for one target. */
661
+ commInfo(targetName) {
662
+ return this.request('comm_info_request', targetName === undefined ? {} : { target_name: targetName });
663
+ }
664
+ // comm_open/comm_msg/comm_close have no reply -- the kernel's side of a
665
+ // comm arrives as 'comm_open'/'comm_msg'/'comm_close' events (and a
666
+ // comm_open for a target the kernel doesn't know is answered with a
667
+ // comm_close whose parentMsgId is the msgId returned here). Each returns
668
+ // the msg id it was sent under for that correlation.
669
+ async sendComm(msgType, content) {
670
+ await this.readyPromise;
671
+ const id = randomUUID();
672
+ this.send({ type: 'request', id, channel: 'shell', msgType, content });
673
+ return id;
674
+ }
675
+ /** Opens a comm to a kernel-side `targetName`; resolves with its comm id and the msg id it was sent under. */
676
+ async commOpen(targetName, data = {}, commId = randomUUID()) {
677
+ const msgId = await this.sendComm('comm_open', { comm_id: commId, target_name: targetName, data });
678
+ return { commId, msgId };
679
+ }
680
+ /**
681
+ * Opens a comm to a kernel-side `targetName` and returns it as a Comm
682
+ * object (send()/close(), 'message'/'close' events). If the kernel has
683
+ * no such target it answers with a comm_close, so the returned comm
684
+ * emits 'close' shortly after. Kernel-initiated comms arrive as this
685
+ * session's 'comm' event instead: `session.on('comm', (comm, data) => ...)`.
686
+ */
687
+ async openComm(targetName, data = {}) {
688
+ const commId = randomUUID();
689
+ const comm = new Comm(commId, targetName, this);
690
+ // Registered before the request goes out so nothing the kernel
691
+ // sends in reply can arrive for a comm we haven't heard of yet.
692
+ this.comms.set(commId, comm);
693
+ try {
694
+ await this.commOpen(targetName, data, commId);
695
+ }
696
+ catch (error) {
697
+ this.comms.delete(commId);
698
+ throw error;
699
+ }
700
+ return comm;
701
+ }
702
+ /** Sends `data` over an open comm. */
703
+ commMsg(commId, data = {}) {
704
+ return this.sendComm('comm_msg', { comm_id: commId, data });
705
+ }
706
+ /** Closes a comm. */
707
+ commClose(commId, data = {}) {
708
+ return this.sendComm('comm_close', { comm_id: commId, data });
709
+ }
710
+ /**
711
+ * Launches a Shiny app in this session's R process and resolves once
712
+ * it's actually accepting connections. shiny::runApp() blocks the R
713
+ * session for as long as the app runs, so -- unlike execute() --
714
+ * resolving here does not mean the app is done; that's what the
715
+ * returned `done` promise is for.
716
+ */
717
+ async createShiny(options) {
718
+ await this.readyPromise;
719
+ const host = options.host ?? '127.0.0.1';
720
+ const port = options.port ?? await findFreePort(host);
721
+ const launchBrowser = options.launchBrowser ?? false;
722
+ const readyTimeout = options.readyTimeout ?? 10000;
723
+ const appDir = rStringLiteral(options.appDir.replace(/\\/g, '/'));
724
+ const setEnvPrefix = buildSetEnvCode(options.env);
725
+ const code = `${setEnvPrefix}shiny::runApp(${appDir}, port = ${port}, host = '${host}', launch.browser = ${launchBrowser ? 'TRUE' : 'FALSE'})`;
726
+ this.logger.info('Starting Shiny app', { appDir: options.appDir, host, port, readyTimeout });
727
+ // timeout: 0 -- this call is expected to block indefinitely.
728
+ const done = this.execute(code, { timeout: 0 });
729
+ done.then((result) => this.logger.info(`Shiny app at ${host}:${port} exited`, { success: result.success }), (error) => this.logger.error(`Shiny app at ${host}:${port} execution failed`, error));
730
+ const earlyExit = done.then((result) => {
731
+ throw new Error(`Shiny app exited before it started listening (status: ${result.success ? 'ok' : 'error'})`);
732
+ });
733
+ earlyExit.catch(() => { });
734
+ try {
735
+ await Promise.race([waitForPort(host, port, readyTimeout), earlyExit]);
736
+ }
737
+ catch (error) {
738
+ this.logger.error(`Shiny app at ${host}:${port} failed to start`, error);
739
+ throw error;
740
+ }
741
+ this.logger.info(`Shiny app listening at http://${host}:${port}`);
742
+ return { host, port, url: `http://${host}:${port}`, done };
743
+ }
744
+ /** Stops the R session and waits for its process to exit. */
745
+ async stop() {
746
+ if (this.stopped) {
747
+ return;
748
+ }
749
+ this.stopped = true;
750
+ this.logger.info(`Stopping session ${this.info.sessionId}`);
751
+ this.queue.clear();
752
+ this.closeAllComms('session stopped');
753
+ this.rejectPendingRequests(new Error('Session stopped'));
754
+ const shutdownReply = this.watchFor('shutdown_reply');
755
+ try {
756
+ await this.supervisor.stopSession(this.info);
757
+ }
758
+ finally {
759
+ await shutdownReply(SHUTDOWN_REPLY_WAIT_MS);
760
+ }
761
+ this.ws?.close();
762
+ this.emit('stopped');
763
+ }
764
+ /** Skips the graceful shutdown protocol -- only for cleanup on the way out. */
765
+ kill() {
766
+ if (!this.stopped) {
767
+ this.stopped = true;
768
+ this.logger.warn(`Force-closing session ${this.info.sessionId}`);
769
+ // Without this, an execute() call still in flight when kill()
770
+ // runs (e.g. one blocked waiting on a crashed kernel) never
771
+ // settles -- closing the socket alone doesn't reject it.
772
+ this.queue.clear();
773
+ this.rejectPendingRequests(new Error('Session was killed'));
774
+ this.ws?.close();
775
+ }
776
+ }
777
+ }
778
+ export class SessionManager {
779
+ supervisor = new SupervisorClient(new Logger());
780
+ sessions = new Set();
781
+ exitHandlerRegistered = false;
782
+ /** Creates a new R session in its own OS process and waits for it to be ready. */
783
+ async createSession(options = {}) {
784
+ const info = await this.supervisor.createSession(options);
785
+ const session = new Session(info, options, this.supervisor);
786
+ this.sessions.add(session);
787
+ this.registerExitHandler();
788
+ try {
789
+ await session.ready();
790
+ }
791
+ catch (error) {
792
+ this.sessions.delete(session);
793
+ throw error;
794
+ }
795
+ return session;
796
+ }
797
+ /** Gracefully stops every session managed by this instance. */
798
+ async stopAll() {
799
+ await Promise.all([...this.sessions].map((session) => session.stop()));
800
+ this.sessions.clear();
801
+ this.supervisor.kill();
802
+ }
803
+ /**
804
+ * Forcibly terminates every session. Prefer stopAll(), but a session
805
+ * whose R interpreter is blocked in a long-running call (e.g.
806
+ * shiny::runApp()) can't process a graceful shutdown until that call
807
+ * returns -- callers wanting a bounded-time exit (e.g. a Ctrl+C
808
+ * handler) should race stopAll() against a timeout and fall back to this.
809
+ */
810
+ killAll() {
811
+ for (const session of this.sessions)
812
+ session.kill();
813
+ this.sessions.clear();
814
+ this.supervisor.kill();
815
+ }
816
+ // Safety net: if the parent process exits (including via Ctrl+C) without
817
+ // an explicit stopAll(), don't leave the supervisor and its spawned
818
+ // kernel processes orphaned in the background.
819
+ registerExitHandler() {
820
+ if (this.exitHandlerRegistered)
821
+ return;
822
+ this.exitHandlerRegistered = true;
823
+ process.once('exit', () => {
824
+ for (const session of this.sessions)
825
+ session.kill();
826
+ this.supervisor.kill();
827
+ });
828
+ }
829
+ }
830
+ function rStringLiteral(value) {
831
+ return `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
832
+ }
833
+ function buildSetEnvCode(env) {
834
+ if (!env || Object.keys(env).length === 0) {
835
+ return '';
836
+ }
837
+ const args = Object.entries(env)
838
+ .map(([key, value]) => `${rStringLiteral(key)} = ${rStringLiteral(value)}`)
839
+ .join(', ');
840
+ return `Sys.setenv(${args}); `;
841
+ }
842
+ //# sourceMappingURL=session-manager.js.map