@tlockney/cdp-kernel 0.4.0 → 0.6.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 (66) hide show
  1. package/README.md +78 -478
  2. package/THIRD-PARTY-NOTICES +62 -0
  3. package/dist/bun-target.d.ts +2 -2
  4. package/dist/bun-target.js +30 -8
  5. package/dist/cdp.d.ts +8 -1
  6. package/dist/cdp.js +31 -11
  7. package/dist/celld-target.d.ts +1 -1
  8. package/dist/celld-target.js +54 -19
  9. package/dist/children.d.ts +29 -0
  10. package/dist/children.js +114 -0
  11. package/dist/debugger.d.ts +1 -1
  12. package/dist/debugger.js +21 -10
  13. package/dist/deno-types.d.ts +30 -0
  14. package/dist/deno-types.js +93 -0
  15. package/dist/globals.d.ts +3 -3
  16. package/dist/globals.js +3 -3
  17. package/dist/kernel.d.ts +7 -1
  18. package/dist/kernel.js +348 -68
  19. package/dist/language-service-worker.d.ts +1 -0
  20. package/dist/language-service-worker.js +88 -0
  21. package/dist/language-service.d.ts +64 -0
  22. package/dist/language-service.js +128 -0
  23. package/dist/main.js +102 -8
  24. package/dist/mod.d.ts +6 -3
  25. package/dist/mod.js +6 -3
  26. package/dist/notebook-types.d.ts +62 -0
  27. package/dist/notebook-types.js +310 -0
  28. package/dist/package.d.ts +2 -2
  29. package/dist/package.js +3 -3
  30. package/dist/parent.d.ts +12 -0
  31. package/dist/parent.js +39 -0
  32. package/dist/port-holder.d.ts +13 -0
  33. package/dist/port-holder.js +105 -0
  34. package/dist/sourcemap.d.ts +7 -2
  35. package/dist/sourcemap.js +9 -3
  36. package/dist/strip-esbuild.d.ts +2 -0
  37. package/dist/strip-esbuild.js +44 -0
  38. package/dist/strip-sucrase.d.ts +2 -0
  39. package/dist/strip-sucrase.js +78 -0
  40. package/dist/target.d.ts +58 -6
  41. package/dist/target.js +412 -54
  42. package/dist/text.d.ts +5 -1
  43. package/dist/text.js +55 -2
  44. package/dist/transform.d.ts +30 -1
  45. package/dist/transform.js +68 -44
  46. package/dist/wire.d.ts +8 -1
  47. package/dist/wire.js +3 -1
  48. package/dist/workerd-target.d.ts +7 -1
  49. package/dist/workerd-target.js +102 -8
  50. package/dist/workers-project.js +13 -2
  51. package/dist/ws-client.d.ts +5 -1
  52. package/dist/ws-client.js +46 -13
  53. package/dist/zmtp.d.ts +9 -1
  54. package/dist/zmtp.js +42 -1
  55. package/package.json +17 -6
  56. package/runtime/bootstrap.mjs +233 -42
  57. package/runtime/bun-channel.d.mts +3 -0
  58. package/runtime/bun-channel.mjs +2 -2
  59. package/runtime/comm.mjs +11 -1
  60. package/runtime/deno-compat.d.mts +2 -0
  61. package/runtime/deno-compat.mjs +14 -1
  62. package/runtime/display.mjs +8 -1
  63. package/runtime/introspect.mjs +1 -1
  64. package/runtime/node-hooks.d.mts +48 -0
  65. package/runtime/node-hooks.mjs +257 -41
  66. package/runtime/workerd-worker.mjs +77 -6
package/dist/kernel.js CHANGED
@@ -5,20 +5,25 @@
5
5
  // The thirteen op_jupyter_* calls into Deno's REPL isolate are replaced by
6
6
  // a Target driven over CDP (src/target.ts).
7
7
  import { Buffer } from "node:buffer";
8
+ import { existsSync } from "node:fs";
9
+ import { dirname, join } from "node:path";
8
10
  import process from "node:process";
9
11
  import { randomUUID } from "node:crypto";
10
- import { decodeMessage, encodeMessage, PROTOCOL_VERSION, verify } from "./wire.js";
11
- import { PubSocket, RouterSocket, startHeartbeat } from "./zmtp.js";
12
+ import { decodeMessage, encodeMessage, makeHeader, PROTOCOL_VERSION, verify } from "./wire.js";
13
+ import { listening, PubSocket, RouterSocket, startHeartbeat } from "./zmtp.js";
12
14
  import { Interrupted, Target } from "./target.js";
13
15
  import { BunTarget } from "./bun-target.js";
14
16
  import { Debugger } from "./debugger.js";
15
17
  import { WorkerdTarget } from "./workerd-target.js";
16
18
  import { launchCelld } from "./celld-target.js";
17
- import { CellSyntaxError, objectLiteralCandidate } from "./transform.js";
19
+ import { CellSyntaxError, loadTypeStripper, objectLiteralCandidate, } from "./transform.js";
18
20
  import { loadDenoProject } from "./deno-config.js";
19
21
  import { checkIsComplete, codePointToUtf16Index, exprAtCursor, inspectTarget, utf16ToCodePointIndex } from "./text.js";
20
22
  import { queryHistory } from "./history.js";
23
+ import { LanguageService } from "./language-service.js";
24
+ import { findWranglerConfig } from "./workers-project.js";
21
25
  import { VERSION } from "./package.js";
26
+ import { watchParent } from "./parent.js";
22
27
  export function parseConnectionInfo(json) {
23
28
  const raw = JSON.parse(json);
24
29
  if (typeof raw !== "object" || raw === null)
@@ -28,6 +33,10 @@ export function parseConnectionInfo(json) {
28
33
  if (typeof info[port] !== "number")
29
34
  throw new Error(`connection file: missing ${port}`);
30
35
  }
36
+ for (const field of ["ip", "key"]) {
37
+ if (info[field] !== undefined && typeof info[field] !== "string")
38
+ throw new Error(`connection file: ${field} is not a string`);
39
+ }
31
40
  if (info.transport !== undefined && info.transport !== "tcp") {
32
41
  throw new Error(`unsupported transport ${info.transport}; only tcp is implemented`);
33
42
  }
@@ -46,16 +55,88 @@ export function parseConnectionInfo(json) {
46
55
  hb_port: info.hb_port,
47
56
  };
48
57
  }
58
+ /**
59
+ * How long interrupt_request may wait on the target before it's answered
60
+ * anyway (#121). Under the 5 s clients allow control requests (#253).
61
+ */
62
+ const INTERRUPT_REPLY_WAIT_MS = 1_000;
63
+ const NO_INPUT = { value: null, interrupted: false };
64
+ /** Shell requests other than execute_request, and their reply types. */
65
+ const SHELL_REPLIES = {
66
+ kernel_info_request: "kernel_info_reply",
67
+ complete_request: "complete_reply",
68
+ is_complete_request: "is_complete_reply",
69
+ inspect_request: "inspect_reply",
70
+ history_request: "history_reply",
71
+ comm_info_request: "comm_info_reply",
72
+ };
73
+ /** The target Node's major version, for @types/node: the host's, which is usually the same binary. */
74
+ function nodeMajor() {
75
+ return process.versions.node?.split(".")[0] ?? "*";
76
+ }
77
+ /**
78
+ * Where the language service gets each target's globals and imports from
79
+ * (#273, #275). A `--deno-compat` Node target gets Node's types only: its
80
+ * `Deno` namespace is partial, and Deno's declarations would promise
81
+ * members it doesn't have.
82
+ */
83
+ function languageServiceOptions(runtime, notebookDir, project) {
84
+ const base = { notebookDir, npmCache: "npm", deno: null, denoTypes: false, typeFiles: [], typePackages: [] };
85
+ switch (runtime) {
86
+ case "node":
87
+ return { ...base, typePackages: [{ name: "@types/node", range: nodeMajor() }] };
88
+ case "deno":
89
+ return { ...base, npmCache: null, deno: { config: project?.configPath ?? null }, denoTypes: true };
90
+ case "bun":
91
+ return { ...base, npmCache: "bun", typePackages: [{ name: "@types/bun", range: "1" }] };
92
+ case "workerd":
93
+ case "celld": {
94
+ // A Workers project's own types (`wrangler types`) know its bindings.
95
+ const config = findWranglerConfig(notebookDir);
96
+ const generated = config === null ? null : join(dirname(config), "worker-configuration.d.ts");
97
+ return generated !== null && existsSync(generated)
98
+ ? { ...base, typeFiles: [generated] }
99
+ : { ...base, typePackages: [{ name: "@cloudflare/workers-types", range: "*" }] };
100
+ }
101
+ }
102
+ }
103
+ /** Runtime inspection text without its Type and Signature lines, which the declared type replaces. */
104
+ function withoutSignature(text) {
105
+ return text.split("\n").filter((line) => !/^(?:Type|Signature): /.test(line)).join("\n");
106
+ }
107
+ /** A TypeScript completion kind as a Jupyter completion type (JupyterLab picks its icon by these). */
108
+ function jupyterType(kind) {
109
+ switch (kind) {
110
+ case undefined:
111
+ return "<unknown>";
112
+ case "var":
113
+ case "let":
114
+ case "const":
115
+ case "local var":
116
+ case "parameter":
117
+ return "variable";
118
+ case "local function":
119
+ return "function";
120
+ case "type":
121
+ case "alias":
122
+ return "type";
123
+ default:
124
+ return kind;
125
+ }
126
+ }
49
127
  export class Kernel {
50
128
  #conn;
51
129
  #runtime;
52
130
  #denoCompat;
131
+ #typeStrip;
53
132
  // A Deno target in a Deno project uses its config (#13).
54
133
  #project;
55
134
  #projectWarning = null;
56
135
  #noticeShownFor = null;
57
136
  /** JupyterLab's visual debugger, for targets driven over CDP (#18). */
58
137
  #debugger;
138
+ /** Typed completion from TypeScript's language service (#273). */
139
+ #types;
59
140
  /** Cells run with store_history, for history_request (#10). */
60
141
  #history = [];
61
142
  #session = randomUUID();
@@ -63,12 +144,26 @@ export class Kernel {
63
144
  #control;
64
145
  #stdin;
65
146
  #iopub;
147
+ /** Settles once all five ports are bound (#257). */
148
+ #bound;
66
149
  #target = null;
67
150
  #targetStarting = null;
68
151
  #executionCount = 0;
69
152
  #parent = {};
70
153
  #allowStdin = false;
71
154
  #executing = false;
155
+ /**
156
+ * stop_on_error (#123): how many of the shell messages queued when a cell
157
+ * failed are still to come, and whether the one being handled is one.
158
+ */
159
+ #abortQueued = 0;
160
+ #aborting = false;
161
+ /** The input_request waiting for its input_reply, if any. */
162
+ #input = null;
163
+ /** An interrupt is waiting on the target. */
164
+ #interrupting = false;
165
+ /** The shell message being handled, and since when. */
166
+ #handling = null;
72
167
  // Open comms, for comm_info_request: comm_id -> target_name (#9).
73
168
  #comms = new Map();
74
169
  #shuttingDown = false;
@@ -76,26 +171,68 @@ export class Kernel {
76
171
  this.#conn = conn;
77
172
  this.#runtime = runtime;
78
173
  this.#denoCompat = options.denoCompat ?? false;
79
- this.#project = runtime === "deno" ? this.#loadProject(options.notebookDir ?? process.cwd()) : null;
174
+ this.#typeStrip = options.typeStrip ?? "esbuild";
175
+ const notebookDir = options.notebookDir ?? process.cwd();
176
+ this.#project = runtime === "deno" ? this.#loadProject(notebookDir) : null;
177
+ this.#types = options.languageService === false
178
+ ? null
179
+ : new LanguageService(languageServiceOptions(runtime, notebookDir, this.#project), { log: (text) => this.#log(text) });
80
180
  this.#debugger = runtime === "node" || runtime === "deno"
81
181
  ? new Debugger((event) => this.#publish("debug_event", event), () => this.#transformOptions())
82
182
  : null;
83
- startHeartbeat(conn.hb_port, conn.ip);
183
+ const heartbeat = startHeartbeat(conn.hb_port, conn.ip);
84
184
  this.#shell = new RouterSocket(conn.shell_port, conn.ip);
85
185
  this.#control = new RouterSocket(conn.control_port, conn.ip);
86
186
  this.#stdin = new RouterSocket(conn.stdin_port, conn.ip);
87
187
  this.#iopub = new PubSocket(conn.iopub_port, conn.ip);
188
+ this.#bound = Promise.all([heartbeat, this.#shell.server, this.#control.server, this.#stdin.server, this.#iopub.server].map(listening));
88
189
  }
89
190
  async run() {
90
191
  // Start the runtime eagerly so the first cell doesn't pay for it.
91
192
  this.#ensureTarget().catch((err) => this.#log(`target failed to start: ${err}`));
92
193
  process.on("SIGINT", () => void this.#interrupt());
194
+ // Exiting runs the exit hook that kills the runtime (src/children.ts);
195
+ // these signals' default action would skip it (#218).
196
+ for (const [signal, code] of [["SIGTERM", 143], ["SIGHUP", 129]]) {
197
+ process.on(signal, () => {
198
+ this.#shuttingDown = true;
199
+ process.exit(code);
200
+ });
201
+ }
202
+ // A server killed before it could shut the kernel down (#254).
203
+ watchParent(() => {
204
+ this.#log(`parent ${process.env.JPY_PARENT_PID} exited; shutting down`);
205
+ this.#shuttingDown = true;
206
+ process.exit(1);
207
+ });
208
+ // A state dump on stderr, for a kernel that has stopped answering (#237).
209
+ if (process.platform !== "win32")
210
+ process.on("SIGUSR2", () => void this.#logState());
211
+ // Transforms are synchronous, so the stripper loads before any cell.
212
+ if (typeof this.#typeStrip === "string")
213
+ await loadTypeStripper(this.#typeStrip);
93
214
  await Promise.all([this.#loop(this.#shell, (m, p) => this.#handleShell(m, p)),
94
- this.#loop(this.#control, (m, p) => this.#handleControl(m, p))]);
215
+ this.#loop(this.#control, (m, p) => this.#handleControl(m, p)),
216
+ this.#loop(this.#stdin, (m) => this.#handleStdin(m))]);
95
217
  }
96
218
  #log(text) {
97
219
  process.stderr.write(`[cdp-kernel] ${text}\n`);
98
220
  }
221
+ async #logState() {
222
+ const handling = this.#handling;
223
+ const target = this.#target;
224
+ let targetState = target === null ? (this.#targetStarting === null ? null : "starting") : target.runtime;
225
+ if (target?.diagnose)
226
+ targetState = await target.diagnose().catch((err) => `diagnose failed: ${err}`);
227
+ this.#log(`state: ${JSON.stringify({
228
+ shell: handling && { msgType: handling.msgType, msgId: handling.msgId, ms: Date.now() - handling.since },
229
+ queued: this.#shell.incoming.size,
230
+ executing: this.#executing,
231
+ interrupting: this.#interrupting,
232
+ awaitingInput: this.#input !== null,
233
+ target: targetState,
234
+ })}`);
235
+ }
99
236
  #loadProject(notebookDir) {
100
237
  try {
101
238
  return loadDenoProject(notebookDir);
@@ -110,7 +247,7 @@ export class Kernel {
110
247
  }
111
248
  }
112
249
  #transformOptions() {
113
- return this.#project?.transform ?? {};
250
+ return { ...this.#project?.transform, typeStrip: this.#typeStrip };
114
251
  }
115
252
  // --- comms (#9) ------------------------------------------------------------
116
253
  #trackComm(msgType, content) {
@@ -190,7 +327,10 @@ export class Kernel {
190
327
  #ensureTarget() {
191
328
  if (this.#target)
192
329
  return Promise.resolve(this.#target);
193
- this.#targetStarting ??= this.#launchTarget().then(async (target) => {
330
+ // Not before the kernel's own ports are bound: a Deno runtime binds its
331
+ // inspector to port 0 within milliseconds of starting and can take a
332
+ // connection-file port the kernel is still binding (#257).
333
+ this.#targetStarting ??= this.#bound.then(() => this.#launchTarget()).then(async (target) => {
194
334
  // Before any cell runs on it, so breakpoints already apply.
195
335
  await this.#debugger?.attachTarget(target.debug ?? null);
196
336
  this.#target = target;
@@ -205,9 +345,13 @@ export class Kernel {
205
345
  #targetEvents() {
206
346
  return {
207
347
  stream: (name, text) => this.#publish("stream", { name, text }),
208
- message: (msg) => void this.#onTargetMessage(msg),
348
+ message: (msg) => void this.#onTargetMessage(msg).catch((err) => this.#log(`error handling target message: ${err}`)),
209
349
  exit: (code, note) => {
210
350
  this.#target = null;
351
+ // What its cells declared died with it.
352
+ this.#types?.reset();
353
+ // Its prompt, if any, died with it; a late reply is dropped.
354
+ this.#input = null;
211
355
  void this.#debugger?.attachTarget(null);
212
356
  if (!this.#shuttingDown) {
213
357
  const text = note ?? `[cdp-kernel] ${this.#runtime} exited (${code}); restarting on next cell`;
@@ -225,18 +369,37 @@ export class Kernel {
225
369
  });
226
370
  return;
227
371
  }
228
- let value = null;
372
+ // The answer goes to the target that asked, not one started since.
373
+ const target = this.#target;
374
+ let answer = NO_INPUT;
229
375
  try {
230
- value = await this.#requestInput(msg.prompt, msg.password);
376
+ answer = await this.#requestInput(msg.prompt, msg.password);
231
377
  }
232
378
  finally {
233
379
  // Always answer exactly once so the target's blocking read returns.
234
- this.#target?.replyInput(value);
380
+ if (target !== null && target === this.#target)
381
+ target.replyInput(answer.value, answer.interrupted);
235
382
  }
236
383
  }
237
384
  async #interrupt() {
238
- if (this.#target && this.#executing)
385
+ // A target blocked reading its prompt's answer can't run
386
+ // terminateExecution, and would never answer it. Make the prompt throw
387
+ // instead, which ends the cell (#121).
388
+ const input = this.#input;
389
+ if (input !== null) {
390
+ this.#input = null;
391
+ input.settle({ value: null, interrupted: true });
392
+ return;
393
+ }
394
+ if (!this.#target || !this.#executing)
395
+ return;
396
+ this.#interrupting = true;
397
+ try {
239
398
  await this.#target.interrupt();
399
+ }
400
+ finally {
401
+ this.#interrupting = false;
402
+ }
240
403
  }
241
404
  // --- messaging --------------------------------------------------------------
242
405
  #encode(msgType, content, parentHeader, extra = {}) {
@@ -254,16 +417,27 @@ export class Kernel {
254
417
  async #loop(socket, handle) {
255
418
  while (!this.#shuttingDown) {
256
419
  const { peer, frames } = await socket.recv();
420
+ if (socket === this.#shell) {
421
+ this.#aborting = this.#abortQueued > 0;
422
+ if (this.#aborting)
423
+ this.#abortQueued--;
424
+ }
257
425
  try {
258
426
  const msg = decodeMessage(frames);
259
427
  // Unsigned or forged messages are dropped, as in Deno.
260
428
  if (!verify(this.#conn.key, msg.signedParts, msg.sig))
261
429
  continue;
430
+ if (socket === this.#shell)
431
+ this.#handling = { msgType: msg.header.msg_type, msgId: msg.header.msg_id, since: Date.now() };
262
432
  await handle(msg, peer);
263
433
  }
264
434
  catch (err) {
265
435
  this.#log(`error handling message: ${err instanceof Error ? err.stack : err}`);
266
436
  }
437
+ finally {
438
+ if (socket === this.#shell)
439
+ this.#handling = null;
440
+ }
267
441
  }
268
442
  }
269
443
  #kernelInfo() {
@@ -282,7 +456,8 @@ export class Kernel {
282
456
  codemirror_mode: "typescript",
283
457
  nbconvert_exporter: "script",
284
458
  },
285
- banner: `cdp-kernel on ${this.#runtime} ${versions?.runtime ?? ""} (${versions?.engine || "?"})`,
459
+ // The runtime's versions are known once it is up.
460
+ banner: versions ? `cdp-kernel on ${this.#runtime} ${versions.runtime} (${versions.engine || "?"})` : `cdp-kernel on ${this.#runtime}`,
286
461
  help_links: [],
287
462
  debugger: this.#debugger !== null,
288
463
  // What current ipykernel advertises; JupyterLab itself goes by the
@@ -292,40 +467,48 @@ export class Kernel {
292
467
  }
293
468
  // --- control channel ------------------------------------------------------
294
469
  async #handleControl(msg, peer) {
470
+ // Every request is wrapped in busy/idle, as on shell (#124). JupyterLab
471
+ // treats a debug request as done only once the kernel reports idle for
472
+ // it. (Jupyter Server leaves debug_request out of the kernel's status.)
473
+ this.#status("busy", msg.header);
474
+ try {
475
+ await this.#controlRequest(this.#control, msg, peer);
476
+ }
477
+ finally {
478
+ this.#status("idle", msg.header);
479
+ }
480
+ }
481
+ /** Control requests; shutdown and interrupt may also arrive on shell (#124). */
482
+ async #controlRequest(socket, msg, peer) {
295
483
  switch (msg.header.msg_type) {
296
484
  case "kernel_info_request":
297
- this.#reply(this.#control, peer, msg, "kernel_info_reply", this.#kernelInfo());
485
+ this.#reply(socket, peer, msg, "kernel_info_reply", this.#kernelInfo());
298
486
  break;
299
487
  case "interrupt_request":
300
- await this.#interrupt();
301
- this.#reply(this.#control, peer, msg, "interrupt_reply", { status: "ok" });
488
+ // Answer even if the target doesn't (#121); the interrupt goes on.
489
+ await Promise.race([
490
+ this.#interrupt().catch((err) => this.#log(`interrupt failed: ${err}`)),
491
+ new Promise((r) => setTimeout(r, INTERRUPT_REPLY_WAIT_MS)),
492
+ ]);
493
+ this.#reply(socket, peer, msg, "interrupt_reply", { status: "ok" });
302
494
  break;
303
495
  case "debug_request": {
304
- // JupyterLab treats a debug request as done only once the kernel
305
- // reports idle for it, as ipykernel does for control messages.
306
- // (Jupyter Server leaves debug_request out of the kernel's status.)
307
- this.#status("busy", msg.header);
308
496
  let reply;
309
- try {
310
- if (this.#debugger === null) {
311
- reply = { type: "response", request_seq: msg.content.seq, command: msg.content.command, success: false, message: `${this.#runtime} targets have no debugger`, body: {} };
312
- }
313
- else {
314
- // Everything but debugInfo talks to the target, so make sure there is one.
315
- if (msg.content.command !== "debugInfo")
316
- await this.#ensureTarget().catch(() => null);
317
- reply = await this.#debugger.handle(msg.content);
318
- }
319
- this.#reply(this.#control, peer, msg, "debug_reply", reply);
497
+ if (this.#debugger === null) {
498
+ reply = { type: "response", request_seq: msg.content.seq, command: msg.content.command, success: false, message: `${this.#runtime} targets have no debugger`, body: {} };
320
499
  }
321
- finally {
322
- this.#status("idle", msg.header);
500
+ else {
501
+ // Everything but debugInfo talks to the target, so make sure there is one.
502
+ if (msg.content.command !== "debugInfo")
503
+ await this.#ensureTarget().catch(() => null);
504
+ reply = await this.#debugger.handle(msg.content);
323
505
  }
506
+ this.#reply(socket, peer, msg, "debug_reply", reply);
324
507
  break;
325
508
  }
326
509
  case "shutdown_request": {
327
510
  const restart = msg.content.restart === true;
328
- this.#reply(this.#control, peer, msg, "shutdown_reply", { status: "ok", restart });
511
+ this.#reply(socket, peer, msg, "shutdown_reply", { status: "ok", restart });
329
512
  this.#shuttingDown = true;
330
513
  this.#target?.kill();
331
514
  // Frontends spawn a fresh kernel even on restart; exit after the
@@ -344,7 +527,8 @@ export class Kernel {
344
527
  try {
345
528
  switch (type) {
346
529
  case "kernel_info_request":
347
- await this.#ensureTarget().catch(() => null);
530
+ // Without waiting for the runtime: clients probe with kernel_info
531
+ // while it starts, and re-send until answered (#253).
348
532
  this.#reply(this.#shell, peer, msg, "kernel_info_reply", this.#kernelInfo());
349
533
  break;
350
534
  case "complete_request":
@@ -367,21 +551,49 @@ export class Kernel {
367
551
  case "comm_close":
368
552
  await this.#handleComm(msg);
369
553
  break;
554
+ case "shutdown_request":
555
+ case "interrupt_request":
556
+ // Deprecated on shell, but still allowed, and ipykernel takes them.
557
+ await this.#controlRequest(this.#shell, msg, peer);
558
+ break;
559
+ default:
560
+ this.#log(`ignoring unknown shell message ${type}`);
370
561
  }
371
562
  }
563
+ catch (err) {
564
+ // E.g. the runtime failed to launch. Answer anyway, so the frontend
565
+ // doesn't wait on the request forever (#125).
566
+ const replyType = SHELL_REPLIES[type];
567
+ if (replyType !== undefined) {
568
+ const name = err instanceof Error ? err.name : "Error";
569
+ const message = err instanceof Error ? err.message : String(err);
570
+ const error = { ename: name, evalue: message, traceback: [`${name}: ${message}`] };
571
+ this.#reply(this.#shell, peer, msg, replyType, { status: "error", ...error });
572
+ }
573
+ throw err;
574
+ }
372
575
  finally {
373
576
  this.#status("idle", msg.header);
374
577
  }
375
578
  }
376
- /** The tooltip (Shift-Tab) for the name at the cursor (#10). */
579
+ /**
580
+ * The tooltip (Shift-Tab) for the name at the cursor (#10): its declared
581
+ * type, signature and JSDoc when the language service knows them (#274),
582
+ * then what the runtime finds.
583
+ */
377
584
  async #inspect(msg) {
378
585
  const code = String(msg.content.code ?? "");
379
586
  const cursorCp = typeof msg.content.cursor_pos === "number"
380
587
  ? msg.content.cursor_pos
381
588
  : utf16ToCodePointIndex(code, code.length);
382
- const path = inspectTarget(code, codePointToUtf16Index(code, cursorCp));
589
+ const cursor = codePointToUtf16Index(code, cursorCp);
590
+ const path = inspectTarget(code, cursor);
383
591
  const detail = msg.content.detail_level === 1 ? 1 : 0;
384
- const text = path === null ? null : await (await this.#ensureTarget()).inspect(path, detail);
592
+ const [runtime, typed] = await Promise.all([
593
+ path === null ? null : (async () => (await this.#ensureTarget()).inspect(path, detail))(),
594
+ this.#types?.quickInfo(code, cursor) ?? null,
595
+ ]);
596
+ const text = typed === null ? runtime : runtime === null ? typed : `${typed}\n\n${withoutSignature(runtime)}`;
385
597
  return text === null
386
598
  ? { status: "ok", found: false, data: {}, metadata: {} }
387
599
  : { status: "ok", found: true, data: { "text/plain": text }, metadata: {} };
@@ -392,48 +604,96 @@ export class Kernel {
392
604
  ? msg.content.cursor_pos
393
605
  : utf16ToCodePointIndex(code, code.length);
394
606
  const cursor = codePointToUtf16Index(code, cursorCp);
395
- const expr = exprAtCursor(code, cursor);
396
- const target = await this.#ensureTarget();
397
- let matches;
398
- let start;
399
- const dot = expr.lastIndexOf(".");
400
- if (dot !== -1) {
401
- const prefix = expr.slice(dot + 1);
402
- matches = (await target.propertyNames(expr.slice(0, dot)))
403
- .filter((name) => name.startsWith(prefix) && !name.startsWith("Symbol("));
404
- start = cursor - prefix.length;
607
+ // Both at once: the language service may not answer within its budget.
608
+ const [runtime, typed] = await Promise.all([
609
+ this.#runtimeCompletions(code, cursor),
610
+ this.#types?.complete(code, cursor) ?? null,
611
+ ]);
612
+ // Declared types first, then what only the runtime knows (a property
613
+ // added at runtime, a global defined by a library).
614
+ let matches = runtime.matches;
615
+ let start = runtime.start;
616
+ const kinds = new Map();
617
+ if (typed !== null && typed.start === runtime.start) {
618
+ for (const entry of typed.entries)
619
+ kinds.set(entry.name, entry.kind);
620
+ matches = [...kinds.keys(), ...runtime.matches.filter((name) => !kinds.has(name))];
405
621
  }
406
- else {
407
- const names = new Set([...(await target.propertyNames("globalThis")), ...(await target.lexicalNames())]);
408
- matches = [...names].filter((name) => name.startsWith(expr) && !name.startsWith("__cdpk")).sort();
409
- start = cursor - expr.length;
622
+ else if (typed !== null && typed.entries.length > 0 && runtime.matches.length === 0) {
623
+ // An expression the runtime can't complete, e.g. inside a call.
624
+ for (const entry of typed.entries)
625
+ kinds.set(entry.name, entry.kind);
626
+ matches = [...kinds.keys()];
627
+ start = typed.start;
410
628
  }
629
+ const cursorStart = utf16ToCodePointIndex(code, start);
411
630
  return {
412
631
  status: "ok",
413
632
  matches,
414
- cursor_start: utf16ToCodePointIndex(code, start),
633
+ cursor_start: cursorStart,
415
634
  cursor_end: cursorCp,
416
- metadata: {},
635
+ metadata: kinds.size === 0 ? {} : {
636
+ // JupyterLab shows each match's type as an icon.
637
+ _jupyter_types_experimental: matches.map((text) => ({
638
+ start: cursorStart,
639
+ end: cursorCp,
640
+ text,
641
+ type: jupyterType(kinds.get(text)),
642
+ })),
643
+ },
417
644
  };
418
645
  }
646
+ /** Completion from the runtime's values: property names, globals, lexical names. */
647
+ async #runtimeCompletions(code, cursor) {
648
+ const expr = exprAtCursor(code, cursor);
649
+ const target = await this.#ensureTarget();
650
+ const dot = expr.lastIndexOf(".");
651
+ if (dot !== -1) {
652
+ const prefix = expr.slice(dot + 1);
653
+ const matches = (await target.propertyNames(expr.slice(0, dot)))
654
+ .filter((name) => name.startsWith(prefix) && !name.startsWith("Symbol("));
655
+ return { matches, start: cursor - prefix.length };
656
+ }
657
+ const names = new Set([...(await target.propertyNames("globalThis")), ...(await target.lexicalNames())]);
658
+ const matches = [...names].filter((name) => name.startsWith(expr) && !name.startsWith("__cdpk")).sort();
659
+ return { matches, start: cursor - expr.length };
660
+ }
419
661
  async #requestInput(prompt, password) {
662
+ // The target asked while an interrupt was already waiting on it.
663
+ if (this.#interrupting)
664
+ return { value: null, interrupted: true };
420
665
  const parent = this.#parent;
421
666
  if (!this.#allowStdin || typeof parent.msg_id !== "string")
422
- return null;
667
+ return NO_INPUT;
423
668
  if (this.#stdin.peers.size === 0)
424
669
  await new Promise((r) => setTimeout(r, 100));
425
670
  if (this.#stdin.peers.size === 0)
426
- return null;
427
- this.#stdin.sendAll(this.#encode("input_request", { prompt, password }, parent));
428
- for (;;) {
429
- const { frames } = await this.#stdin.recv();
430
- const reply = decodeMessage(frames);
431
- if (!verify(this.#conn.key, reply.signedParts, reply.sig))
432
- continue;
433
- if (reply.header.msg_type === "input_reply") {
434
- return typeof reply.content.value === "string" ? reply.content.value : null;
435
- }
436
- }
671
+ return NO_INPUT;
672
+ const header = makeHeader(this.#session, "input_request");
673
+ const answer = new Promise((settle) => (this.#input = { msgId: header.msg_id, settle }));
674
+ this.#stdin.sendAll(encodeMessage(this.#session, this.#conn.key, "input_request", { prompt, password }, {
675
+ header,
676
+ parentHeader: parent,
677
+ }));
678
+ return answer;
679
+ }
680
+ /** stdin carries only input_replys. */
681
+ async #handleStdin(reply) {
682
+ if (reply.header.msg_type !== "input_reply")
683
+ return;
684
+ const input = this.#input;
685
+ // A reply with nothing waiting is late (its prompt was interrupted), and
686
+ // one naming another request is for an earlier prompt: drop both rather
687
+ // than answer the next prompt with them (#121). Some clients
688
+ // (jupyter_client's input()) send no parent header, so only a present
689
+ // one is checked.
690
+ if (input === null)
691
+ return;
692
+ const parentId = reply.parentHeader.msg_id;
693
+ if (typeof parentId === "string" && parentId !== "" && parentId !== input.msgId)
694
+ return;
695
+ this.#input = null;
696
+ input.settle({ value: typeof reply.content.value === "string" ? reply.content.value : null, interrupted: false });
437
697
  }
438
698
  async #evaluateCell(target, code) {
439
699
  // Object-literal wrapping (Deno's evaluate_line_with_object_wrapping):
@@ -458,6 +718,13 @@ export class Kernel {
458
718
  }
459
719
  async #execute(msg, peer) {
460
720
  const parent = msg.header;
721
+ if (this.#aborting) {
722
+ // Queued behind a failed cell: not run, not counted (#123).
723
+ this.#status("busy", parent);
724
+ this.#reply(this.#shell, peer, msg, "execute_reply", { status: "aborted" });
725
+ this.#status("idle", parent);
726
+ return;
727
+ }
461
728
  const code = String(msg.content.code ?? "");
462
729
  const silent = msg.content.silent === true;
463
730
  if (!silent && msg.content.store_history !== false) {
@@ -468,7 +735,9 @@ export class Kernel {
468
735
  this.#parent = parent;
469
736
  this.#allowStdin = msg.content.allow_stdin === true;
470
737
  this.#status("busy", parent);
471
- this.#publish("execute_input", { code, execution_count: count });
738
+ // Silent requests broadcast nothing on iopub but status (#124).
739
+ if (!silent)
740
+ this.#publish("execute_input", { code, execution_count: count });
472
741
  if (this.#projectWarning !== null) {
473
742
  // Shown once, in the first cell, where the user will see it.
474
743
  this.#publish("stream", { name: "stderr", text: `[cdp-kernel] ${this.#projectWarning}\n` });
@@ -484,6 +753,8 @@ export class Kernel {
484
753
  this.#publish("stream", { name: "stderr", text: `${target.notice}\n` });
485
754
  }
486
755
  const result = await this.#evaluateCell(target, code);
756
+ // It ran, so its declarations exist, even if it threw part way.
757
+ this.#types?.addCell(code);
487
758
  if (result.exceptionDetails) {
488
759
  const exception = result.exceptionDetails.exception;
489
760
  const described = exception
@@ -517,7 +788,16 @@ export class Kernel {
517
788
  finally {
518
789
  this.#executing = false;
519
790
  }
791
+ // The result and error are shown: nothing refers to them any more (#286).
792
+ await this.#target?.releaseResults?.();
520
793
  if (error) {
794
+ if (!silent && msg.content.stop_on_error !== false) {
795
+ // Abort what was queued behind this cell (JupyterLab's Run All sends
796
+ // every cell at once), but nothing sent after the client sees the
797
+ // error. A macrotask first lets bytes already received be parsed.
798
+ await new Promise((r) => setTimeout(r, 0));
799
+ this.#abortQueued = this.#shell.incoming.size;
800
+ }
521
801
  this.#publish("error", error);
522
802
  this.#reply(this.#shell, peer, msg, "execute_reply", { status: "error", execution_count: count, ...error });
523
803
  }
@@ -0,0 +1 @@
1
+ export {};