@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.
- package/LICENSE +24 -0
- package/README.md +175 -0
- package/docs/api/README.md +55 -0
- package/docs/api/session.md +143 -0
- package/docs/api/types.md +120 -0
- package/docs/architecture/overview.md +218 -0
- package/docs/cpp-usage.md +60 -0
- package/docs/development.md +105 -0
- package/docs/getting-started.md +127 -0
- package/docs/guides/comms.md +70 -0
- package/docs/guides/environments.md +80 -0
- package/docs/guides/history.md +44 -0
- package/docs/guides/interactive-input.md +48 -0
- package/docs/guides/interrupting.md +45 -0
- package/docs/guides/playground.md +33 -0
- package/docs/guides/sessions-lifecycle.md +70 -0
- package/docs/kernels.md +117 -0
- package/docs/protocol.md +135 -0
- package/docs/releasing.md +73 -0
- package/docs/troubleshooting.md +110 -0
- package/lib/execution/execution-queue.d.ts +20 -0
- package/lib/execution/execution-queue.js +256 -0
- package/lib/handlers/display-handler.d.ts +7 -0
- package/lib/handlers/display-handler.js +10 -0
- package/lib/handlers/error-handler.d.ts +7 -0
- package/lib/handlers/error-handler.js +8 -0
- package/lib/handlers/result-handler.d.ts +7 -0
- package/lib/handlers/result-handler.js +10 -0
- package/lib/handlers/stream-handler.d.ts +7 -0
- package/lib/handlers/stream-handler.js +8 -0
- package/lib/index.d.ts +7 -0
- package/lib/index.js +5 -0
- package/lib/messaging/message-parser.d.ts +6 -0
- package/lib/messaging/message-parser.js +33 -0
- package/lib/messaging/message-router.d.ts +14 -0
- package/lib/messaging/message-router.js +41 -0
- package/lib/middleware/index.d.ts +5 -0
- package/lib/middleware/index.js +5 -0
- package/lib/middleware/middleware-chain.d.ts +7 -0
- package/lib/middleware/middleware-chain.js +14 -0
- package/lib/middleware/middleware.d.ts +5 -0
- package/lib/middleware/middleware.js +2 -0
- package/lib/middleware/plugins/logging-plugin.d.ts +6 -0
- package/lib/middleware/plugins/logging-plugin.js +9 -0
- package/lib/middleware/plugins/metrics-plugin.d.ts +8 -0
- package/lib/middleware/plugins/metrics-plugin.js +13 -0
- package/lib/session/comm.d.ts +39 -0
- package/lib/session/comm.js +58 -0
- package/lib/session/native-paths.d.ts +48 -0
- package/lib/session/native-paths.js +108 -0
- package/lib/session/session-manager.d.ts +229 -0
- package/lib/session/session-manager.js +842 -0
- package/lib/session/supervisor-client.d.ts +36 -0
- package/lib/session/supervisor-client.js +147 -0
- package/lib/types/engine.d.ts +269 -0
- package/lib/types/engine.js +2 -0
- package/lib/types/index.d.ts +3 -0
- package/lib/types/index.js +3 -0
- package/lib/types/messages.d.ts +68 -0
- package/lib/types/messages.js +2 -0
- package/lib/utils/logger.d.ts +12 -0
- package/lib/utils/logger.js +58 -0
- package/lib/utils/network.d.ts +11 -0
- package/lib/utils/network.js +50 -0
- package/package.json +57 -0
- package/packages/hera/DESCRIPTION +29 -0
- package/packages/hera/LICENSE +2 -0
- package/packages/hera/LICENSE.md +21 -0
- package/packages/hera/NAMESPACE +32 -0
- package/packages/hera/NEWS.md +7 -0
- package/packages/hera/R/cell_options.R +13 -0
- package/packages/hera/R/comm.R +228 -0
- package/packages/hera/R/completion.R +54 -0
- package/packages/hera/R/execute.R +199 -0
- package/packages/hera/R/inspect.R +73 -0
- package/packages/hera/R/log.R +14 -0
- package/packages/hera/R/mime_bundle.R +65 -0
- package/packages/hera/R/routines.R +86 -0
- package/packages/hera/R/utils.R +32 -0
- package/packages/hera/R/zzz.R +128 -0
- package/packages/hera/man/Comm.Rd +179 -0
- package/packages/hera/man/CommManager.Rd +215 -0
- package/packages/hera/man/View.Rd +22 -0
- package/packages/hera/man/cell_options.Rd +20 -0
- package/packages/hera/man/clear_output.Rd +23 -0
- package/packages/hera/man/complete.Rd +23 -0
- package/packages/hera/man/display_data.Rd +22 -0
- package/packages/hera/man/is_elara.Rd +18 -0
- package/packages/hera/man/mime_bundle.Rd +25 -0
- package/packages/hera/man/mime_types.Rd +22 -0
- 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
|