@tlockney/cdp-kernel 0.7.2 → 0.8.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.
@@ -0,0 +1,47 @@
1
+ /** The cdp-kernel project's DSN. It only lets a client send events. */
2
+ export declare const DSN = "https://91c2265acb5b0b0115914a43375be9ba@o4512380800598016.ingest.us.sentry.io/4512380976168960";
3
+ export interface Frame {
4
+ filename: string;
5
+ function?: string;
6
+ lineno?: number;
7
+ colno?: number;
8
+ in_app: boolean;
9
+ }
10
+ /** `text` with home directories as `~`, URLs cut to their path, and at most MAX_MESSAGE characters. */
11
+ export declare function scrubText(text: string): string;
12
+ /**
13
+ * A frame's file as cdp-kernel's own, `app:///src/kernel-core.ts`, or null
14
+ * when it isn't: its path from the package root (`src/`, `dist/` or
15
+ * `runtime/` and below), wherever the package is installed.
16
+ */
17
+ export declare function packageFile(file: string): string | null;
18
+ /** V8's stack lines as Sentry frames, oldest first. Files outside the package are dropped to `<other>`. */
19
+ export declare function parseStack(stack: string | undefined): Frame[];
20
+ export interface ReportContext {
21
+ /** Where it was caught, e.g. `shell handler`. */
22
+ where: string;
23
+ /** The target runtime: node, deno, bun, workerd or celld. */
24
+ target?: string;
25
+ }
26
+ /** The Sentry event for `err`. Pure but for the clock and the ids. */
27
+ export declare function buildEvent(err: unknown, context: ReportContext): Record<string, unknown>;
28
+ export interface Reporter {
29
+ /** Sends a report; resolves once sent or given up on. Never throws. */
30
+ report(err: unknown, where: string): Promise<void>;
31
+ }
32
+ /**
33
+ * The reporter for this process: a real one when CDP_KERNEL_CRASH_REPORTS
34
+ * is `1`, otherwise one that does nothing. `send` is for tests.
35
+ */
36
+ export declare function crashReporter(options?: {
37
+ env?: Record<string, string | undefined>;
38
+ target?: string;
39
+ send?: typeof fetch;
40
+ }): Reporter | null;
41
+ /**
42
+ * Reports an exception that would end the process, then ends it as Node
43
+ * would have: the stack on stderr and exit code 1. Only installed when
44
+ * reporting is on, so with it off a crash is exactly as before. An
45
+ * unhandled rejection arrives here too (Node's default `throw` mode).
46
+ */
47
+ export declare function reportUncaught(reporter: Reporter): void;
@@ -0,0 +1,155 @@
1
+ // Opt-in reports of faults in cdp-kernel's own code (#422,
2
+ // tlockney/ganymede#384), sent to Sentry's cdp-kernel project.
3
+ //
4
+ // Off unless the host sets CDP_KERNEL_CRASH_REPORTS=1, which Ganymede does
5
+ // when its user opted in. While off nothing here runs: no handler, no
6
+ // request. Errors from user cells never come through here: the target
7
+ // reports those to KernelCore as exception details, and KernelCore answers
8
+ // them as cell errors. What does: an exception that escapes a message
9
+ // handler, the target-message handler, or the process.
10
+ //
11
+ // No Sentry SDK: an event is a small JSON envelope, posted with fetch.
12
+ // That keeps the npm and JSR packages free of a dependency and its
13
+ // install-time cost for everyone who never opts in.
14
+ //
15
+ // Sent: the error's type, message (home directories as `~`, URLs cut to
16
+ // their path, 300 characters at most) and stack (frames in cdp-kernel's
17
+ // own files by their path in the package; others by name only), the
18
+ // version, host and target runtime, OS and architecture. Not sent: cell
19
+ // source, outputs, variables, file paths outside the package, env vars,
20
+ // host name.
21
+ import process from "node:process";
22
+ import { arch, platform, release } from "node:os";
23
+ import { hostRuntime, VERSION } from "./package.js";
24
+ /** The cdp-kernel project's DSN. It only lets a client send events. */
25
+ export const DSN = "https://91c2265acb5b0b0115914a43375be9ba@o4512380800598016.ingest.us.sentry.io/4512380976168960";
26
+ /** At most this many reports per process: a fault in a loop sends a few, not thousands. */
27
+ const MAX_REPORTS = 10;
28
+ const MAX_MESSAGE = 300;
29
+ const SEND_TIMEOUT_MS = 5000;
30
+ /** `text` with home directories as `~`, URLs cut to their path, and at most MAX_MESSAGE characters. */
31
+ export function scrubText(text) {
32
+ const out = text
33
+ .replace(/\b(?:https?|wss?|file):\/\/[^\s/'"`)]*([^\s?#'"`)]*)(?:[?#][^\s'"`)]*)?/g, "$1")
34
+ .replace(/(?:\/Users|\/home)\/[^/\s'"`)]+/g, "~")
35
+ .replace(/[A-Za-z]:\\Users\\[^\\\s'"`)]+/g, "~");
36
+ return out.length > MAX_MESSAGE ? `${out.slice(0, MAX_MESSAGE)}…` : out;
37
+ }
38
+ /**
39
+ * A frame's file as cdp-kernel's own, `app:///src/kernel-core.ts`, or null
40
+ * when it isn't: its path from the package root (`src/`, `dist/` or
41
+ * `runtime/` and below), wherever the package is installed.
42
+ */
43
+ export function packageFile(file) {
44
+ const path = file.replace(/^file:\/\//, "").replaceAll("\\", "/");
45
+ // From npm, from JSR (jsr.io/@tlockney/cdp-kernel/<version>/src/…), or a checkout.
46
+ const match = /(?:^|\/)(?:@tlockney\/cdp-kernel(?:\/[^/]+)?|cdp-kernel)\/((?:src|dist|runtime)\/[^?#]+)$/.exec(path);
47
+ return match ? `app:///${match[1]}` : null;
48
+ }
49
+ const V8_FRAME = /^\s*at (?:(.+?) \()?(.+?):(\d+):(\d+)\)?$/;
50
+ /** V8's stack lines as Sentry frames, oldest first. Files outside the package are dropped to `<other>`. */
51
+ export function parseStack(stack) {
52
+ const frames = [];
53
+ for (const line of (stack ?? "").split("\n")) {
54
+ const m = V8_FRAME.exec(line);
55
+ if (!m)
56
+ continue;
57
+ const [, fn, file, lineno, colno] = m;
58
+ const own = packageFile(file);
59
+ const internal = file.startsWith("node:") || file.startsWith("ext:");
60
+ frames.push({
61
+ filename: own ?? (internal ? file : "<other>"),
62
+ function: fn,
63
+ lineno: own || internal ? Number(lineno) : undefined,
64
+ colno: own || internal ? Number(colno) : undefined,
65
+ in_app: own !== null,
66
+ });
67
+ }
68
+ return frames.reverse();
69
+ }
70
+ /** The Sentry event for `err`. Pure but for the clock and the ids. */
71
+ export function buildEvent(err, context) {
72
+ const error = err instanceof Error ? err : new Error(String(err));
73
+ return {
74
+ event_id: crypto.randomUUID().replaceAll("-", ""),
75
+ timestamp: Date.now() / 1000,
76
+ platform: "node",
77
+ level: "error",
78
+ release: `cdp-kernel@${VERSION}`,
79
+ environment: "production",
80
+ tags: { where: context.where, host: hostRuntime(), ...(context.target ? { target: context.target } : {}) },
81
+ contexts: {
82
+ os: { name: platform(), kernel_version: release() },
83
+ device: { arch: arch() },
84
+ runtime: { name: hostRuntime(), version: process.versions.deno ?? process.versions.node },
85
+ },
86
+ exception: {
87
+ values: [{
88
+ type: error.name,
89
+ value: scrubText(error.message),
90
+ stacktrace: { frames: parseStack(error.stack) },
91
+ mechanism: { type: context.where, handled: context.where !== "uncaught" },
92
+ }],
93
+ },
94
+ };
95
+ }
96
+ function parseDsn(dsn) {
97
+ const url = new URL(dsn);
98
+ const project = url.pathname.replace(/^\//, "");
99
+ return { key: url.username, envelope: `${url.protocol}//${url.host}/api/${project}/envelope/` };
100
+ }
101
+ /**
102
+ * The reporter for this process: a real one when CDP_KERNEL_CRASH_REPORTS
103
+ * is `1`, otherwise one that does nothing. `send` is for tests.
104
+ */
105
+ export function crashReporter(options = {}) {
106
+ const env = options.env ?? process.env;
107
+ if (env.CDP_KERNEL_CRASH_REPORTS !== "1")
108
+ return null;
109
+ const dsn = parseDsn(env.CDP_KERNEL_SENTRY_DSN || DSN);
110
+ const send = options.send ?? fetch;
111
+ let sent = 0;
112
+ return {
113
+ async report(err, where) {
114
+ if (sent >= MAX_REPORTS)
115
+ return;
116
+ sent++;
117
+ try {
118
+ const event = buildEvent(err, { where, target: options.target });
119
+ const body = [
120
+ JSON.stringify({ event_id: event.event_id, sent_at: new Date().toISOString() }),
121
+ JSON.stringify({ type: "event" }),
122
+ JSON.stringify(event),
123
+ ].join("\n");
124
+ await send(dsn.envelope, {
125
+ method: "POST",
126
+ headers: {
127
+ "content-type": "application/x-sentry-envelope",
128
+ "x-sentry-auth": `Sentry sentry_version=7, sentry_key=${dsn.key}, sentry_client=cdp-kernel/${VERSION}`,
129
+ },
130
+ body,
131
+ signal: AbortSignal.timeout(SEND_TIMEOUT_MS),
132
+ });
133
+ }
134
+ catch {
135
+ // Reporting must never take the kernel down.
136
+ }
137
+ },
138
+ };
139
+ }
140
+ /**
141
+ * Reports an exception that would end the process, then ends it as Node
142
+ * would have: the stack on stderr and exit code 1. Only installed when
143
+ * reporting is on, so with it off a crash is exactly as before. An
144
+ * unhandled rejection arrives here too (Node's default `throw` mode).
145
+ */
146
+ export function reportUncaught(reporter) {
147
+ let ending = false;
148
+ process.on("uncaughtException", (err) => {
149
+ if (ending)
150
+ return;
151
+ ending = true;
152
+ console.error(err instanceof Error ? err.stack ?? String(err) : err);
153
+ void reporter.report(err, "uncaught").finally(() => process.exit(1));
154
+ });
155
+ }
@@ -52,11 +52,18 @@ export interface KernelHost {
52
52
  readonly capabilities?: JsonObject;
53
53
  /** JupyterLab's visual debugger, for targets driven over CDP (#18). */
54
54
  createDebugger?(publish: (event: JsonObject) => void, transformOptions: () => CellTransformOptions): Debugger;
55
+ /** usage_reply's content: the host's and the running target's (null while none runs) memory and CPU (#409). */
56
+ usage?(target: KernelTarget | null): Promise<JsonObject>;
55
57
  /** A file's source, for the lines around a traceback frame (#322). */
56
58
  readSource?(file: string): string | undefined;
57
59
  /** Path or URL prefixes of the kernel's own runtime files, whose frames a traceback marks as library code. */
58
60
  readonly kernelFiles?: readonly string[];
59
61
  log(text: string): void;
62
+ /**
63
+ * A fault in the kernel's own code, caught where it would otherwise only
64
+ * be logged: for opt-in crash reports (#422). Never a cell's error.
65
+ */
66
+ fault?(err: unknown, where: string): void;
60
67
  /** After shutdown_reply has gone out. */
61
68
  exit(): void;
62
69
  }
@@ -19,6 +19,8 @@ import { buildTraceback, cellName, TRACEBACK_MIME } from "./traceback.js";
19
19
  * installed TypeScript's: the major version package.json depends on.
20
20
  */
21
21
  export const TYPESCRIPT_VERSION = "6";
22
+ /** Errors from launching a target, which `KernelHost.fault` doesn't get. */
23
+ const launchFailures = new WeakSet();
22
24
  function fromBase64(text) {
23
25
  return Uint8Array.from(atob(text), (c) => c.charCodeAt(0));
24
26
  }
@@ -28,8 +30,46 @@ function fromBase64(text) {
28
30
  */
29
31
  const INTERRUPT_REPLY_WAIT_MS = 1_000;
30
32
  const NO_INPUT = { value: null, interrupted: false };
33
+ /**
34
+ * Shell messages handled one at a time, in order: the main shell, or a
35
+ * kernel subshell (JEP 91, #417), each with its own.
36
+ */
37
+ class ShellQueue {
38
+ items = [];
39
+ #wake = null;
40
+ #closed = false;
41
+ push(item) {
42
+ if (this.#closed)
43
+ return;
44
+ this.items.push(item);
45
+ this.#wake?.();
46
+ }
47
+ /** The next message; null once closed. */
48
+ async next() {
49
+ for (;;) {
50
+ if (this.#closed)
51
+ return null;
52
+ const item = this.items.shift();
53
+ if (item !== undefined)
54
+ return item;
55
+ await new Promise((resolve) => (this.#wake = resolve));
56
+ this.#wake = null;
57
+ }
58
+ }
59
+ /** Stops taking messages; returns those still queued. */
60
+ close() {
61
+ this.#closed = true;
62
+ const dropped = this.items.splice(0);
63
+ this.#wake?.();
64
+ return dropped;
65
+ }
66
+ }
31
67
  /** Cells remembered for tracebacks: a frame can be in a function any earlier cell defined. */
32
68
  const MAX_CELLS = 1000;
69
+ /** The error reply for a subshell that doesn't exist (#417). */
70
+ function subshellNotFound(evalue) {
71
+ return { status: "error", ename: "SubshellNotFound", evalue, traceback: [`SubshellNotFound: ${evalue}`] };
72
+ }
33
73
  /** Shell requests other than execute_request, and their reply types. */
34
74
  const SHELL_REPLIES = {
35
75
  kernel_info_request: "kernel_info_reply",
@@ -84,13 +124,16 @@ export class KernelCore {
84
124
  #executionCount = 0;
85
125
  #parent = {};
86
126
  #allowStdin = false;
87
- #executing = false;
88
- /**
89
- * stop_on_error (#123): how many of the shell messages queued when a cell
90
- * failed are still to come, and whether the one being handled is one.
91
- */
92
- #abortQueued = 0;
127
+ /** Cells and comm handlers running in the target. */
128
+ #running = 0;
129
+ /** A cell is running: its parent header and allow_stdin hold until it ends (#417). */
130
+ #cellRunning = false;
131
+ /** stop_on_error (#123): the message being handled was queued behind a failed cell. */
93
132
  #aborting = false;
133
+ /** Messages without a subshell_id, in the order they came. */
134
+ #mainShell = new ShellQueue();
135
+ /** Kernel subshells (JEP 91, #417): subshell_id -> its queue. */
136
+ #subshells = new Map();
94
137
  /** The input_request waiting for its input_reply, if any. */
95
138
  #input = null;
96
139
  /** An interrupt is waiting on the target. */
@@ -117,7 +160,7 @@ export class KernelCore {
117
160
  // Transforms are synchronous, so the stripper loads before any cell.
118
161
  if (typeof this.#typeStrip === "string")
119
162
  await loadTypeStripper(this.#typeStrip);
120
- await Promise.all([this.#loop("shell", (m, p) => this.#handleShell(m, p)),
163
+ await Promise.all([this.#readShell(), this.#runShell(this.#mainShell),
121
164
  this.#loop("control", (m, p) => this.#handleControl(m, p)),
122
165
  this.#loop("stdin", (m) => this.#handleStdin(m))]);
123
166
  }
@@ -141,8 +184,9 @@ export class KernelCore {
141
184
  targetState = await target.diagnose().catch((err) => `diagnose failed: ${err}`);
142
185
  this.#log(`state: ${JSON.stringify({
143
186
  shell: handling && { msgType: handling.msgType, msgId: handling.msgId, ms: Date.now() - handling.since },
144
- queued: this.#channels.shellQueued(),
145
- executing: this.#executing,
187
+ queued: this.#mainShell.items.length + this.#channels.shellQueued(),
188
+ subshells: this.#subshells.size,
189
+ running: this.#running,
146
190
  interrupting: this.#interrupting,
147
191
  awaitingInput: this.#input !== null,
148
192
  target: targetState,
@@ -175,16 +219,21 @@ export class KernelCore {
175
219
  const type = msg.header.msg_type;
176
220
  const commId = String(msg.content.comm_id ?? "");
177
221
  const target = await this.#ensureTarget();
178
- // Kernel messages the handlers send are replies to this message.
179
- this.#parent = msg.header;
180
- this.#allowStdin = false;
181
- this.#executing = true;
222
+ // Kernel messages the handlers send are replies to this message, unless
223
+ // it came on a subshell while a cell runs (#417): the target's output
224
+ // doesn't say which it came from, so it stays the cell's.
225
+ if (!this.#cellRunning) {
226
+ this.#parent = msg.header;
227
+ this.#allowStdin = false;
228
+ }
229
+ const parent = msg.header;
182
230
  let handled = false;
231
+ this.#running++;
183
232
  try {
184
233
  const result = await target.dispatchComm(type, msg.content, msg.metadata, msg.buffers);
185
234
  if ("error" in result) {
186
235
  const { name, message, stack } = result.error;
187
- this.#publish("stream", { name: "stderr", text: `Error in ${type} handler: ${stack || `${name}: ${message}`}\n` });
236
+ this.#publish("stream", { name: "stderr", text: `Error in ${type} handler: ${stack || `${name}: ${message}`}\n` }, { parent });
188
237
  }
189
238
  else {
190
239
  handled = result.handled;
@@ -193,10 +242,10 @@ export class KernelCore {
193
242
  catch (err) {
194
243
  if (!(err instanceof Interrupted))
195
244
  throw err;
196
- this.#publish("stream", { name: "stderr", text: `${type} handler interrupted\n` });
245
+ this.#publish("stream", { name: "stderr", text: `${type} handler interrupted\n` }, { parent });
197
246
  }
198
247
  finally {
199
- this.#executing = false;
248
+ this.#running--;
200
249
  }
201
250
  await target.flush().catch(() => { });
202
251
  if (type === "comm_open") {
@@ -206,7 +255,7 @@ export class KernelCore {
206
255
  else {
207
256
  // An unknown target, or a handler that failed: the protocol asks the
208
257
  // kernel to close the comm so the frontend doesn't wait on it.
209
- this.#publish("comm_close", { comm_id: commId, data: {} });
258
+ this.#publish("comm_close", { comm_id: commId, data: {} }, { parent });
210
259
  }
211
260
  }
212
261
  else if (type === "comm_close") {
@@ -228,6 +277,10 @@ export class KernelCore {
228
277
  return target;
229
278
  }, (err) => {
230
279
  this.#targetStarting = null;
280
+ // Usually the user's environment (a missing runtime, a bad config),
281
+ // not a kernel fault, and its message quotes the child's stderr.
282
+ if (typeof err === "object" && err !== null)
283
+ launchFailures.add(err);
231
284
  throw err;
232
285
  });
233
286
  return this.#targetStarting;
@@ -249,10 +302,19 @@ export class KernelCore {
249
302
  this.#interruptStart = null;
250
303
  }
251
304
  }
305
+ /** Tells the host of a fault in the kernel's own code; a target that failed to launch isn't one (#422). */
306
+ #fault(err, where) {
307
+ if (typeof err === "object" && err !== null && launchFailures.has(err))
308
+ return;
309
+ this.#host.fault?.(err, where);
310
+ }
252
311
  #targetEvents() {
253
312
  return {
254
313
  stream: (name, text) => this.#publish("stream", { name, text }),
255
- message: (msg) => void this.#onTargetMessage(msg).catch((err) => this.#log(`error handling target message: ${err}`)),
314
+ message: (msg) => void this.#onTargetMessage(msg).catch((err) => {
315
+ this.#log(`error handling target message: ${err}`);
316
+ this.#fault(err, "target message");
317
+ }),
256
318
  exit: (code, note) => {
257
319
  this.#target = null;
258
320
  // What its cells declared died with it.
@@ -302,7 +364,7 @@ export class KernelCore {
302
364
  this.#interruptStart();
303
365
  return;
304
366
  }
305
- if (!this.#target || !this.#executing)
367
+ if (!this.#target || this.#running === 0)
306
368
  return;
307
369
  this.#interrupting = true;
308
370
  try {
@@ -334,27 +396,85 @@ export class KernelCore {
334
396
  async #loop(channel, handle) {
335
397
  while (!this.#shuttingDown) {
336
398
  const received = await this.#channels.recv(channel);
337
- // Before the drop below: shellQueued() counted this message too, so
338
- // a dropped one takes its own abort slot, not a queued cell's (#340).
339
- if (channel === "shell") {
340
- this.#aborting = this.#abortQueued > 0;
341
- if (this.#aborting)
342
- this.#abortQueued--;
399
+ // Malformed, or on ZeroMQ unsigned or forged: dropped, as in Deno.
400
+ if (received === null)
401
+ continue;
402
+ try {
403
+ await handle(received.msg, received.peer);
404
+ }
405
+ catch (err) {
406
+ this.#log(`error handling message: ${err instanceof Error ? err.stack : err}`);
407
+ this.#fault(err, `${channel} handler`);
343
408
  }
409
+ }
410
+ }
411
+ /**
412
+ * Takes every shell message as it comes and queues it for the main shell
413
+ * or its subshell, so a subshell's message isn't stuck behind a running
414
+ * cell (#417).
415
+ */
416
+ async #readShell() {
417
+ while (!this.#shuttingDown) {
418
+ const received = await this.#channels.recv("shell");
344
419
  // Malformed, or on ZeroMQ unsigned or forged: dropped, as in Deno.
345
420
  if (received === null)
346
421
  continue;
347
- const { peer, msg } = received;
422
+ const subshellId = received.msg.header.subshell_id;
423
+ if (typeof subshellId !== "string") {
424
+ this.#mainShell.push({ ...received, abort: false });
425
+ continue;
426
+ }
427
+ const queue = this.#subshells.get(subshellId);
428
+ if (queue === undefined) {
429
+ this.#refuseShell(received, `No subshell with id ${subshellId}`);
430
+ }
431
+ else if (received.msg.header.msg_type === "execute_request") {
432
+ // Cells share one target and one parent header, so a cell sent on
433
+ // a subshell waits its turn on the main shell.
434
+ this.#mainShell.push({ ...received, abort: false });
435
+ }
436
+ else {
437
+ queue.push({ ...received, abort: false });
438
+ }
439
+ }
440
+ }
441
+ /**
442
+ * Answers a shell message for a subshell that doesn't exist, or was
443
+ * deleted with the message queued, so the frontend doesn't wait on it.
444
+ */
445
+ #refuseShell({ peer, msg }, evalue) {
446
+ const type = msg.header.msg_type;
447
+ this.#log(`refusing ${type}: ${evalue}`);
448
+ this.#status("busy", msg.header);
449
+ const replyType = type === "execute_request" ? "execute_reply" : SHELL_REPLIES[type];
450
+ if (replyType !== undefined)
451
+ this.#reply("shell", peer, msg, replyType, subshellNotFound(evalue));
452
+ // As for an unknown target: the frontend shouldn't wait on the comm.
453
+ if (type === "comm_open")
454
+ this.#publish("comm_close", { comm_id: String(msg.content.comm_id ?? ""), data: {} }, { parent: msg.header });
455
+ this.#status("idle", msg.header);
456
+ }
457
+ /** Handles one queue's messages in turn. */
458
+ async #runShell(queue) {
459
+ const main = queue === this.#mainShell;
460
+ while (!this.#shuttingDown) {
461
+ const queued = await queue.next();
462
+ if (queued === null)
463
+ return;
464
+ const { peer, msg } = queued;
348
465
  try {
349
- if (channel === "shell")
466
+ if (main) {
467
+ this.#aborting = queued.abort;
350
468
  this.#handling = { msgType: msg.header.msg_type, msgId: msg.header.msg_id, since: Date.now() };
351
- await handle(msg, peer);
469
+ }
470
+ await this.#handleShell(msg, peer);
352
471
  }
353
472
  catch (err) {
354
473
  this.#log(`error handling message: ${err instanceof Error ? err.stack : err}`);
474
+ this.#fault(err, "shell handler");
355
475
  }
356
476
  finally {
357
- if (channel === "shell")
477
+ if (main)
358
478
  this.#handling = null;
359
479
  }
360
480
  }
@@ -369,7 +489,12 @@ export class KernelCore {
369
489
  language_info: {
370
490
  name: "typescript",
371
491
  version: this.#host.languageVersion ?? TYPESCRIPT_VERSION,
372
- mimetype: "text/x.typescript",
492
+ // JupyterLab's LSP adapter picks the language server by a language
493
+ // it derives from this MIME type, not from name: it drops the type
494
+ // and an "x-" prefix, so "text/x.typescript" became "x.typescript"
495
+ // and matched no server (#400). This is CodeMirror's MIME type for
496
+ // TypeScript and in jupyter-lsp's typescript-language-server spec.
497
+ mimetype: "application/typescript",
373
498
  file_extension: ".ts",
374
499
  pygments_lexer: "typescript",
375
500
  codemirror_mode: "typescript",
@@ -381,12 +506,22 @@ export class KernelCore {
381
506
  debugger: this.#debugger !== null,
382
507
  // What current ipykernel advertises; JupyterLab itself goes by the
383
508
  // kernelspec's metadata.debugger.
384
- supported_features: this.#debugger !== null ? ["debugger"] : [],
509
+ supported_features: [...(this.#debugger !== null ? ["debugger"] : []), "kernel subshells"],
385
510
  ...(this.#host.capabilities ? { capabilities: this.#host.capabilities } : {}),
386
511
  };
387
512
  }
388
513
  // --- control channel ------------------------------------------------------
389
514
  async #handleControl(msg, peer) {
515
+ // A frontend polls usage, and measuring can take a moment (`ps` on
516
+ // macOS): answer it beside the queue, so an interrupt or shutdown
517
+ // never waits behind a poll (#409).
518
+ if (msg.header.msg_type === "usage_request") {
519
+ void this.#handleControlRequest(msg, peer).catch((err) => this.#log(`usage_request failed: ${err}`));
520
+ return;
521
+ }
522
+ return this.#handleControlRequest(msg, peer);
523
+ }
524
+ async #handleControlRequest(msg, peer) {
390
525
  // Every request is wrapped in busy/idle, as on shell (#124). JupyterLab
391
526
  // treats a debug request as done only once the kernel reports idle for
392
527
  // it. (Jupyter Server leaves debug_request out of the kernel's status.)
@@ -412,6 +547,42 @@ export class KernelCore {
412
547
  ]);
413
548
  this.#reply(channel, peer, msg, "interrupt_reply", { status: "ok" });
414
549
  break;
550
+ case "create_subshell_request": {
551
+ const subshellId = crypto.randomUUID();
552
+ const queue = new ShellQueue();
553
+ this.#subshells.set(subshellId, queue);
554
+ void this.#runShell(queue);
555
+ this.#reply(channel, peer, msg, "create_subshell_reply", { status: "ok", subshell_id: subshellId });
556
+ break;
557
+ }
558
+ case "delete_subshell_request": {
559
+ const subshellId = String(msg.content.subshell_id ?? "");
560
+ const queue = this.#subshells.get(subshellId);
561
+ if (queue === undefined) {
562
+ this.#reply(channel, peer, msg, "delete_subshell_reply", subshellNotFound(`No subshell with id ${subshellId}`));
563
+ break;
564
+ }
565
+ // A message it is handling finishes; what's queued is refused.
566
+ this.#subshells.delete(subshellId);
567
+ for (const dropped of queue.close())
568
+ this.#refuseShell(dropped, `Subshell ${subshellId} was deleted`);
569
+ this.#reply(channel, peer, msg, "delete_subshell_reply", { status: "ok" });
570
+ break;
571
+ }
572
+ case "list_subshell_request":
573
+ this.#reply(channel, peer, msg, "list_subshell_reply", { status: "ok", subshell_id: [...this.#subshells.keys()] });
574
+ break;
575
+ case "usage_request": {
576
+ // Without starting a target: a frontend polls this (#409). A host
577
+ // that can't measure anything still answers, so the frontend
578
+ // doesn't wait out its timeout.
579
+ const usage = this.#host.usage?.(this.#target).catch((err) => {
580
+ this.#log(`usage_request failed: ${err}`);
581
+ return {};
582
+ });
583
+ this.#reply(channel, peer, msg, "usage_reply", (await usage) ?? {});
584
+ break;
585
+ }
415
586
  case "debug_request": {
416
587
  let reply;
417
588
  if (this.#debugger === null) {
@@ -745,7 +916,8 @@ export class KernelCore {
745
916
  let userExpressions = {};
746
917
  // nteract's structured traceback, sent as the error message's metadata (#322).
747
918
  let structured = null;
748
- this.#executing = true;
919
+ this.#running++;
920
+ this.#cellRunning = true;
749
921
  try {
750
922
  const target = await this.#targetForCell();
751
923
  if (target.notice !== undefined && this.#noticeShownFor !== target) {
@@ -796,7 +968,8 @@ export class KernelCore {
796
968
  }
797
969
  }
798
970
  finally {
799
- this.#executing = false;
971
+ this.#running--;
972
+ this.#cellRunning = false;
800
973
  }
801
974
  // The result and error are shown: nothing refers to them any more (#286).
802
975
  await this.#target?.releaseResults?.();
@@ -805,10 +978,14 @@ export class KernelCore {
805
978
  // Abort what was queued behind this cell (JupyterLab's Run All sends
806
979
  // every cell at once), but nothing sent after the client sees the
807
980
  // error. A macrotask first lets bytes already received be parsed.
981
+ // Shell messages are queued as they're read, so by then each is.
808
982
  await new Promise((r) => setTimeout(r, 0));
809
- this.#abortQueued = this.#channels.shellQueued();
983
+ for (const queued of this.#mainShell.items)
984
+ queued.abort = true;
810
985
  }
811
- this.#publish("error", error, structured ? { metadata: { [TRACEBACK_MIME]: structured } } : {});
986
+ // With its parent: a subshell's comm handler may have taken #parent
987
+ // since the cell ended (#417).
988
+ this.#publish("error", error, { parent, ...(structured ? { metadata: { [TRACEBACK_MIME]: structured } } : {}) });
812
989
  this.#reply("shell", peer, msg, "execute_reply", { status: "error", execution_count: count, ...error });
813
990
  }
814
991
  else {
package/dist/kernel.js CHANGED
@@ -18,6 +18,8 @@ import { cacheRoot, VERSION } from "./package.js";
18
18
  import { watchParent } from "./parent.js";
19
19
  import { KernelCore } from "./kernel-core.js";
20
20
  import { ZmqChannels } from "./zmq-channels.js";
21
+ import { UsageMeter } from "./usage.js";
22
+ import { crashReporter, reportUncaught } from "./crash-reports.js";
21
23
  /** The installed TypeScript's version, for kernel_info's language_info (#340). */
22
24
  function typescriptVersion() {
23
25
  try {
@@ -141,6 +143,12 @@ export class Kernel {
141
143
  }
142
144
  }
143
145
  const denoCompat = options.denoCompat ?? false;
146
+ const usage = new UsageMeter();
147
+ // Opt-in reports of the kernel's own faults (#422): null, and nothing
148
+ // installed, unless CDP_KERNEL_CRASH_REPORTS=1.
149
+ const reporter = crashReporter({ target: runtime });
150
+ if (reporter)
151
+ reportUncaught(reporter);
144
152
  this.#core = new KernelCore({
145
153
  runtime,
146
154
  version: VERSION,
@@ -164,9 +172,11 @@ export class Kernel {
164
172
  createDebugger: runtime === "node" || runtime === "deno"
165
173
  ? (publish, transformOptions) => new Debugger(publish, transformOptions)
166
174
  : undefined,
175
+ usage: (target) => usage.reply(target),
167
176
  readSource,
168
177
  kernelFiles: kernelFiles(),
169
178
  log,
179
+ fault: reporter ? (err, where) => void reporter.report(err, where) : undefined,
170
180
  exit: () => process.exit(0),
171
181
  });
172
182
  }