@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/target.js CHANGED
@@ -4,17 +4,42 @@
4
4
  import { Buffer } from "node:buffer";
5
5
  import process from "node:process";
6
6
  import { spawn } from "node:child_process";
7
+ import { createRequire } from "node:module";
7
8
  import { join } from "node:path";
8
- import { asEvaluateResult, CdpAbandoned, CdpSession } from "./cdp.js";
9
+ import { fileURLToPath } from "node:url";
10
+ import { asEvaluateResult, CdpAbandoned, CdpError, CdpSession } from "./cdp.js";
9
11
  import { SHIM, transformCell } from "./transform.js";
10
12
  import { hostRuntime, isInsideNodeModules, runtimeDir } from "./package.js";
11
- const BINDING = "__cdpk_send";
13
+ import { killOnExit } from "./children.js";
14
+ /**
15
+ * The inspector keeps every object it returns a remote reference to alive
16
+ * until it is released, so a cell's result would never be collected (#286).
17
+ * Cells and lookups put theirs in these groups, which are released once the
18
+ * kernel is done with them.
19
+ */
20
+ const CELL_GROUP = "cdpk-cell";
21
+ const LOOKUP_GROUP = "cdpk-lookup";
22
+ /** How long a target may take from spawn to a usable session. */
23
+ const START_TIMEOUT_MS = 60_000;
12
24
  const MARKER_RE = /\x1b\[cdpk:(\d+)(m?)\]\x1b/;
13
25
  const MAX_MARKER_LEN = 24;
14
26
  // Lines the runtimes print to stderr on their own: inspector notices, and
15
27
  // Deno's npm/jsr "Download https://..." progress (which `deno jupyter`
16
- // sends to the server log rather than the notebook).
17
- const INSPECTOR_CHATTER = /^(?:\x1b\[[0-9;]*m)*(Debugger listening on |Debugger attached\.|For help, see: |Visit chrome:\/\/inspect|Waiting for the debugger to disconnect|Download(?:\x1b\[[0-9;]*m)* https?:\/\/).*\n/gm;
28
+ // sends to the server log rather than the notebook). Each entry is the
29
+ // line's start once colour codes are removed.
30
+ const INSPECTOR_CHATTER = [
31
+ "Debugger listening on ",
32
+ "Debugger attached.",
33
+ "For help, see: https://nodejs.org/",
34
+ "Visit chrome://inspect",
35
+ "Waiting for the debugger to disconnect",
36
+ ];
37
+ // Deno prints "Debugger session started." for each session the host opens
38
+ // (#281).
39
+ const DENO_CHATTER = [...INSPECTOR_CHATTER, "Debugger session started.", "Download http://", "Download https://"];
40
+ const ANSI_SGR = /\x1b\[[0-9;]*m/g;
41
+ /** A colour code cut off at the end of a chunk. */
42
+ const PARTIAL_SGR = /\x1b(?:\[[0-9;]*)?$/;
18
43
  /** `bootstrap` is the local path of runtime/bootstrap.mjs (see runtimeDir). */
19
44
  export function launchCommand(runtime, bootstrap, { executable, denoConfig } = {}) {
20
45
  // The CDP WebSocket has no authentication, so its URL must stay private:
@@ -41,6 +66,33 @@ export function launchCommand(runtime, bootstrap, { executable, denoConfig } = {
41
66
  const node = hostRuntime() === "node" ? process.execPath : "node";
42
67
  return { cmd: executable ?? node, args: [...inspect, bootstrap] };
43
68
  }
69
+ /**
70
+ * The esbuild binary this host's esbuild runs, for a Node target to compile
71
+ * imported TypeScript Node can't strip (#73); null when it can't be found.
72
+ * Resolved from esbuild's own location, so it's found in a node_modules
73
+ * install and in Deno's npm cache alike.
74
+ */
75
+ export function esbuildBinary(resolve = import.meta.resolve, platform = process.platform, arch = process.arch) {
76
+ if (process.env.ESBUILD_BINARY_PATH)
77
+ return process.env.ESBUILD_BINARY_PATH;
78
+ const subpath = platform === "win32" ? "esbuild.exe" : "bin/esbuild";
79
+ try {
80
+ const main = fileURLToPath(resolve("esbuild"));
81
+ return createRequire(main).resolve(`@esbuild/${platform}-${arch}/${subpath}`);
82
+ }
83
+ catch {
84
+ return null;
85
+ }
86
+ }
87
+ /** The parsed value, or undefined for text that isn't JSON. */
88
+ export function parseJson(text) {
89
+ try {
90
+ return JSON.parse(text);
91
+ }
92
+ catch {
93
+ return undefined;
94
+ }
95
+ }
44
96
  export function isTargetMessage(value) {
45
97
  if (typeof value !== "object" || value === null)
46
98
  return false;
@@ -49,6 +101,10 @@ export function isTargetMessage(value) {
49
101
  typeof value.barrier === "number";
50
102
  }
51
103
  const MARKER_PREFIX = "\x1b[cdpk:";
104
+ // A message record: the message's JSON, which never holds a raw ESC, between
105
+ // "\x1b[cdpk:<" and "\x1b]" (#271).
106
+ const RECORD_PREFIX = "\x1b[cdpk:<";
107
+ const RECORD_RE = /\x1b\[cdpk:<([^\x1b]*)\x1b\]/;
52
108
  function isPartialMarker(tail) {
53
109
  if (tail.length >= MAX_MARKER_LEN)
54
110
  return false;
@@ -56,10 +112,13 @@ function isPartialMarker(tail) {
56
112
  }
57
113
  /**
58
114
  * Splits one output pipe into text and barrier markers, and releases them
59
- * in order. A gated marker (one that announces a binding message) pauses
115
+ * in order. A gated marker (one that announces a message) pauses
60
116
  * the pipe until `gate(n)` resolves, i.e. until that message has been
61
117
  * delivered, so later output can't overtake it. A possible partial marker
62
118
  * at the end of a chunk is held back until the next chunk decides it.
119
+ *
120
+ * With `onMessage`, the pipe also carries message records. A record stands
121
+ * where its gated marker would, and pauses the pipe the same way.
63
122
  */
64
123
  export class PipeDemux {
65
124
  #buffer = "";
@@ -68,28 +127,49 @@ export class PipeDemux {
68
127
  #closed = false;
69
128
  #reached = 0;
70
129
  #waiters = [];
130
+ /** The gated marker the pipe is paused on, if any. */
131
+ #gatedOn = null;
71
132
  onText;
72
133
  gate;
73
- constructor(onText, gate = () => Promise.resolve()) {
134
+ onMessage;
135
+ constructor(onText, gate = () => Promise.resolve(), onMessage = null) {
74
136
  this.onText = onText;
75
137
  this.gate = gate;
138
+ this.onMessage = onMessage;
76
139
  }
77
140
  push(chunk) {
78
141
  this.#buffer += chunk;
79
142
  for (;;) {
80
143
  const m = MARKER_RE.exec(this.#buffer);
81
- if (!m)
144
+ const r = this.onMessage ? RECORD_RE.exec(this.#buffer) : null;
145
+ const first = m && r ? (m.index < r.index ? m : r) : m ?? r;
146
+ if (!first)
82
147
  break;
83
- if (m.index > 0)
84
- this.#items.push({ text: this.#buffer.slice(0, m.index) });
85
- this.#items.push({ barrier: Number(m[1]), gated: m[2] === "m" });
86
- this.#buffer = this.#buffer.slice(m.index + m[0].length);
148
+ if (first.index > 0)
149
+ this.#items.push({ text: this.#buffer.slice(0, first.index) });
150
+ if (first === m) {
151
+ this.#items.push({ barrier: Number(m[1]), gated: m[2] === "m" });
152
+ }
153
+ else {
154
+ // Anything else a cell prints in this shape is dropped (#122).
155
+ const msg = parseJson(first[1]);
156
+ if (isTargetMessage(msg))
157
+ this.#items.push({ message: msg });
158
+ }
159
+ this.#buffer = this.#buffer.slice(first.index + first[0].length);
87
160
  }
88
161
  // Hold back a tail that could still grow into a marker, including one
89
- // cut inside the "\x1b[cdpk:" prefix itself.
162
+ // cut inside the "\x1b[cdpk:" prefix itself, or into a record: one
163
+ // whose only ESC after its prefix is a final one.
90
164
  const esc = this.#buffer.lastIndexOf("\x1b");
91
165
  const tail = esc === -1 ? "" : this.#buffer.slice(esc);
92
- const holdFrom = esc !== -1 && isPartialMarker(tail) ? esc : this.#buffer.length;
166
+ let holdFrom = esc !== -1 && isPartialMarker(tail) ? esc : this.#buffer.length;
167
+ const record = this.onMessage ? this.#buffer.lastIndexOf(RECORD_PREFIX) : -1;
168
+ if (record !== -1) {
169
+ const next = this.#buffer.indexOf("\x1b", record + RECORD_PREFIX.length);
170
+ if (next === -1 || next === this.#buffer.length - 1)
171
+ holdFrom = Math.min(holdFrom, record);
172
+ }
93
173
  if (holdFrom > 0) {
94
174
  this.#items.push({ text: this.#buffer.slice(0, holdFrom) });
95
175
  this.#buffer = this.#buffer.slice(holdFrom);
@@ -106,9 +186,22 @@ export class PipeDemux {
106
186
  this.onText(item.text);
107
187
  continue;
108
188
  }
189
+ if ("message" in item) {
190
+ this.#reach(item.message.barrier);
191
+ this.onMessage?.(item.message);
192
+ if (!this.#closed) {
193
+ this.#gatedOn = item.message.barrier;
194
+ await this.gate(item.message.barrier);
195
+ this.#gatedOn = null;
196
+ }
197
+ continue;
198
+ }
109
199
  this.#reach(item.barrier);
110
- if (item.gated && !this.#closed)
200
+ if (item.gated && !this.#closed) {
201
+ this.#gatedOn = item.barrier;
111
202
  await this.gate(item.barrier);
203
+ this.#gatedOn = null;
204
+ }
112
205
  }
113
206
  }
114
207
  finally {
@@ -129,6 +222,16 @@ export class PipeDemux {
129
222
  return Promise.resolve();
130
223
  return new Promise((resolve) => this.#waiters.push({ n, resolve }));
131
224
  }
225
+ /** Where the pipe stands, for a state dump (#237). */
226
+ state() {
227
+ return {
228
+ reached: this.#reached,
229
+ waitingFor: this.#waiters.map((w) => w.n),
230
+ gatedOn: this.#gatedOn,
231
+ queued: this.#items.length,
232
+ held: this.#buffer.length,
233
+ };
234
+ }
132
235
  /** Release everything (the process died). */
133
236
  close() {
134
237
  this.#closed = true;
@@ -139,6 +242,66 @@ export class PipeDemux {
139
242
  void this.#pump();
140
243
  }
141
244
  }
245
+ /**
246
+ * Removes the runtime's own stderr lines (see INSPECTOR_CHATTER) and passes
247
+ * everything else on. A chatter line can arrive split across chunks, so a
248
+ * trailing partial line that could still turn out to be one is held back
249
+ * until its newline arrives; any other partial line passes straight through.
250
+ */
251
+ export class StderrFilter {
252
+ #starts;
253
+ #emit;
254
+ #held = "";
255
+ #atLineStart = true;
256
+ constructor(runtime, emit) {
257
+ this.#starts = runtime === "deno" ? DENO_CHATTER : INSPECTOR_CHATTER;
258
+ this.#emit = emit;
259
+ }
260
+ push(chunk) {
261
+ let text = this.#held + chunk;
262
+ this.#held = "";
263
+ let out = "";
264
+ if (!this.#atLineStart) {
265
+ // The rest of a line that already went out unfiltered.
266
+ const nl = text.indexOf("\n");
267
+ if (nl === -1) {
268
+ this.#emit(text);
269
+ return;
270
+ }
271
+ out = text.slice(0, nl + 1);
272
+ text = text.slice(nl + 1);
273
+ }
274
+ const lines = text.split("\n");
275
+ const last = lines.pop();
276
+ for (const line of lines)
277
+ if (!this.#isChatter(line, false))
278
+ out += line + "\n";
279
+ if (last && this.#isChatter(last, true))
280
+ this.#held = last;
281
+ else
282
+ out += last;
283
+ this.#atLineStart = last === "" || this.#held !== "";
284
+ if (out)
285
+ this.#emit(out);
286
+ }
287
+ /** Releases a held partial line (the process died). */
288
+ flush() {
289
+ if (this.#held)
290
+ this.#emit(this.#held);
291
+ this.#held = "";
292
+ this.#atLineStart = true;
293
+ }
294
+ /** Whether `line` is chatter or, when `partial`, could still become chatter. */
295
+ #isChatter(line, partial) {
296
+ let plain = line.replace(ANSI_SGR, "");
297
+ if (partial)
298
+ plain = plain.replace(PARTIAL_SGR, "");
299
+ // Any other escape may be a PipeDemux marker, which must never be dropped or held.
300
+ if (plain.includes("\x1b"))
301
+ return false;
302
+ return this.#starts.some((start) => plain.startsWith(start) || (partial && start.startsWith(plain)));
303
+ }
304
+ }
142
305
  /** Promises keyed by barrier number, resolvable before or after first use. */
143
306
  export class Gates {
144
307
  #map = new Map();
@@ -167,6 +330,12 @@ export class Gates {
167
330
  export class Interrupted extends Error {
168
331
  name = "Interrupted";
169
332
  }
333
+ /** A catch handler: a call interrupt() abandoned becomes Interrupted. */
334
+ function interrupted(what) {
335
+ return (err) => {
336
+ throw err instanceof CdpAbandoned ? new Interrupted(`${what} interrupted`) : err;
337
+ };
338
+ }
170
339
  export class Target {
171
340
  runtime;
172
341
  transform = transformCell;
@@ -178,13 +347,24 @@ export class Target {
178
347
  #gates;
179
348
  #contextId;
180
349
  #messages = Promise.resolve();
350
+ /** Messages received but not yet delivered. */
351
+ #undelivered = 0;
181
352
  #interrupted = false;
182
353
  #paused = false;
354
+ /** Set once the main context is gone: the runtime is exiting (#130). */
355
+ #exiting = false;
356
+ #exited;
183
357
  #baselineGlobals = new Set();
184
358
  versions;
185
359
  constructor(runtime, child, shell, control, stdout, stderr, gates, contextId, versions) {
186
360
  this.runtime = runtime;
187
361
  this.#child = child;
362
+ this.#exited = new Promise((resolve) => {
363
+ if (child.exitCode !== null || child.signalCode !== null)
364
+ resolve();
365
+ else
366
+ child.once("exit", () => resolve());
367
+ });
188
368
  this.#shell = shell;
189
369
  this.#control = control;
190
370
  this.#stdout = stdout;
@@ -208,18 +388,49 @@ export class Target {
208
388
  env.CDPK_DENO_COMPAT = "1";
209
389
  if (runtime === "deno" && options.denoConfig !== undefined)
210
390
  env.CDPK_DENO_PROJECT = "1";
391
+ const esbuild = runtime === "node" ? esbuildBinary() : null;
392
+ if (esbuild !== null)
393
+ env.CDPK_ESBUILD_BINARY = esbuild;
211
394
  delete env.NO_COLOR;
212
395
  const child = spawn(cmd, args, { cwd: options.cwd, stdio: ["pipe", "pipe", "pipe"], env });
396
+ killOnExit(child);
397
+ // Prompt replies and comm messages go to stdin; one written as the
398
+ // runtime exits fails with EPIPE, and the exit is handled on its own.
399
+ child.stdin.on("error", () => { });
400
+ // A start that fails or stalls must not leave the runtime running: its
401
+ // pipes and inspector sockets would keep this process alive (#52).
402
+ const sessions = [];
403
+ const timeoutMs = options.startTimeoutMs ?? START_TIMEOUT_MS;
404
+ let timer;
405
+ const stalled = new Promise((_, reject) => {
406
+ timer = setTimeout(() => reject(new Error(`${cmd} did not start within ${timeoutMs / 1000}s`)), timeoutMs);
407
+ });
408
+ try {
409
+ return await Promise.race([Target.#start(runtime, events, cmd, child, sessions), stalled]);
410
+ }
411
+ catch (error) {
412
+ for (const session of sessions)
413
+ session.close();
414
+ child.kill();
415
+ throw error;
416
+ }
417
+ finally {
418
+ clearTimeout(timer);
419
+ }
420
+ }
421
+ static async #start(runtime, events, cmd, child, sessions) {
213
422
  child.stdout.setEncoding("utf8");
214
423
  child.stderr.setEncoding("utf8");
215
424
  const gates = new Gates();
216
- const stdout = new PipeDemux((text) => events.stream("stdout", text), (n) => gates.wait(n));
425
+ // Messages come only after attach(), so the target exists by then.
426
+ let target;
427
+ const stdout = new PipeDemux((text) => events.stream("stdout", text), (n) => gates.wait(n), (msg) => {
428
+ if (target)
429
+ target.#deliver(msg, events);
430
+ });
217
431
  const stderr = new PipeDemux((text) => events.stream("stderr", text), (n) => gates.wait(n));
218
- const pushStderr = (chunk) => {
219
- const text = chunk.replace(INSPECTOR_CHATTER, "");
220
- if (text)
221
- stderr.push(text);
222
- };
432
+ const filter = new StderrFilter(runtime, (text) => stderr.push(text));
433
+ const pushStderr = (chunk) => filter.push(chunk);
223
434
  // Until the inspector URL appears, stderr carries the runtime's banner.
224
435
  const wsUrl = await new Promise((resolve, reject) => {
225
436
  let banner = "";
@@ -240,12 +451,15 @@ export class Target {
240
451
  child.stdout.on("data", (c) => stdout.push(c));
241
452
  child.once("exit", (code) => {
242
453
  gates.openAll();
454
+ filter.flush();
243
455
  stdout.close();
244
456
  stderr.close();
245
457
  events.exit(code);
246
458
  });
247
459
  const shell = await CdpSession.connect(wsUrl);
460
+ sessions.push(shell);
248
461
  const control = await CdpSession.connect(wsUrl);
462
+ sessions.push(control);
249
463
  const contextReady = new Promise((resolve) => {
250
464
  shell.on("Runtime.executionContextCreated", (params) => {
251
465
  const context = params.context;
@@ -254,24 +468,34 @@ export class Target {
254
468
  });
255
469
  });
256
470
  await shell.send("Runtime.enable");
257
- await shell.send("Runtime.addBinding", { name: BINDING });
258
471
  const contextId = await contextReady;
259
- const target = new Target(runtime, child, shell, control, stdout, stderr, gates, contextId, { runtime: "", engine: "" });
260
- shell.on("Runtime.bindingCalled", (params) => {
261
- if (params.name !== BINDING || typeof params.payload !== "string")
262
- return;
263
- const msg = JSON.parse(params.payload);
264
- if (isTargetMessage(msg))
265
- target.#deliver(msg, events);
472
+ // The inspector socket leaves Nagle's algorithm on, so an event written
473
+ // just before an evaluate's reply holds that reply until the event is
474
+ // acknowledged, ~40 ms on Linux (#271). So the shell session, which runs
475
+ // the cells, takes no Runtime events (console calls among them), and
476
+ // messages come over stdout. The control session watches for exit.
477
+ await shell.send("Runtime.disable");
478
+ await control.send("Runtime.enable");
479
+ target = new Target(runtime, child, shell, control, stdout, stderr, gates, contextId, { runtime: "", engine: "" });
480
+ // process.exit() or Deno.exit() destroys the context, then the runtime
481
+ // waits for its inspector clients to disconnect before it exits. Let go,
482
+ // so it exits and the kernel starts a fresh one (#130).
483
+ control.on("Runtime.executionContextDestroyed", (params) => {
484
+ if (params.executionContextId === contextId && target)
485
+ target.#onExit();
266
486
  });
267
- // Wait for the bootstrap's top-level await to finish installing the shim.
268
- for (let i = 0; i < 200; i++) {
269
- const probe = await target.#evaluateRaw(`typeof globalThis.${SHIM}?.tick === "function"`);
487
+ // Wait for the bootstrap's top-level await to finish installing the
488
+ // shim. There is no cap here: launch's start timeout bounds the wait and
489
+ // kills the child, so a slow bootstrap never yields a target without
490
+ // the shim (#134).
491
+ for (;;) {
492
+ const probe = await target.#evaluateSync(`typeof globalThis.${SHIM}?.tick === "function"`);
270
493
  if (probe.result.value === true)
271
494
  break;
272
495
  await new Promise((r) => setTimeout(r, 25));
273
496
  }
274
- const versions = await target.#evaluateRaw(`JSON.stringify(typeof Deno !== "undefined" ? { runtime: Deno.version.deno, engine: "V8 " + Deno.version.v8 } : { runtime: process.versions.node, engine: "V8 " + process.versions.v8 })`);
497
+ await target.#evaluateSync(`${SHIM}.attach()`);
498
+ const versions = await target.#evaluateSync(`JSON.stringify(typeof Deno !== "undefined" ? { runtime: Deno.version.deno, engine: "V8 " + Deno.version.v8 } : { runtime: process.versions.node, engine: "V8 " + process.versions.v8 })`);
275
499
  Object.assign(target.versions, JSON.parse(String(versions.result.value)));
276
500
  // The runtime's own enumerable globals, before any cell: the debugger's
277
501
  // variable views show only what cells added (#18).
@@ -293,25 +517,93 @@ export class Target {
293
517
  baselineGlobals: this.#baselineGlobals,
294
518
  };
295
519
  }
296
- // Binding messages wait until both pipes have caught up to their marker,
520
+ // Messages wait until both pipes have caught up to their marker,
297
521
  // and are delivered strictly in order.
298
522
  #deliver(msg, events) {
523
+ this.#undelivered++;
299
524
  this.#messages = this.#messages.then(async () => {
300
525
  await Promise.all([this.#stdout.waitFor(msg.barrier), this.#stderr.waitFor(msg.barrier)]);
526
+ this.#undelivered--;
301
527
  events.message(msg);
302
528
  this.#gates.open(msg.barrier);
303
529
  });
304
530
  }
531
+ /**
532
+ * What the host is waiting on, whether the runtime still evaluates, runs
533
+ * immediates and runs timers, and where its JavaScript is if it is
534
+ * running any, for a kernel that has stopped answering (#237). The probes
535
+ * go over the control session, which no cell holds.
536
+ */
537
+ async diagnose() {
538
+ const within = (promise, fallback) => Promise.race([promise, new Promise((resolve) => setTimeout(() => resolve(fallback), 2000).unref())]);
539
+ const probe = (expression, awaitPromise) => within(this.#control.send("Runtime.evaluate", { expression, contextId: this.#contextId, awaitPromise, returnByValue: true })
540
+ .then((res) => res.exceptionDetails ? `threw: ${JSON.stringify(res.exceptionDetails)}` : String(res.result?.value), (err) => `failed: ${err}`), "no answer in 2 s");
541
+ const [evaluates, immediates, timers] = await Promise.all([
542
+ probe("'yes'", false),
543
+ probe("new Promise((resolve) => setImmediate(() => resolve('yes')))", true),
544
+ probe("new Promise((resolve) => setTimeout(() => resolve('yes'), 0))", true),
545
+ ]);
546
+ // A runtime whose event loop is stuck may be stuck in JavaScript: say
547
+ // where. (An idle one would take the pause in whatever runs next.)
548
+ let running = "event loop runs";
549
+ if (immediates !== "yes") {
550
+ try {
551
+ const paused = new Promise((resolve) => this.#control.on("Debugger.paused", resolve));
552
+ await this.#control.send("Debugger.enable");
553
+ await this.#control.send("Debugger.pause");
554
+ const event = await within(paused, null);
555
+ running = event === null ? "no" : event.callFrames
556
+ .slice(0, 10)
557
+ .map((f) => `${f.functionName || "(anonymous)"} ${f.url || "(no url)"}:${f.location.lineNumber + 1}`);
558
+ await this.#control.send("Debugger.resume").catch(() => { });
559
+ await this.#control.send("Debugger.disable");
560
+ }
561
+ catch (err) {
562
+ running = `failed: ${err}`;
563
+ }
564
+ }
565
+ return {
566
+ pid: this.#child.pid ?? null,
567
+ calls: { shell: this.#shell.pendingCalls(), control: this.#control.pendingCalls() },
568
+ stdout: this.#stdout.state(),
569
+ stderr: this.#stderr.state(),
570
+ undelivered: this.#undelivered,
571
+ paused: this.#paused,
572
+ exiting: this.#exiting,
573
+ evaluates,
574
+ immediates,
575
+ timers,
576
+ running,
577
+ };
578
+ }
305
579
  /** Resolves once all output and messages sent so far have been delivered. */
306
580
  async flush() {
307
- const n = await this.#evaluateRaw(`${SHIM}.barrier()`);
581
+ const n = await this.#evaluateSync(`${SHIM}.barrier()`).catch(interrupted("Execution"));
308
582
  const barrier = Number(n.result.value);
309
583
  await Promise.all([this.#stdout.waitFor(barrier), this.#stderr.waitFor(barrier)]);
310
584
  await this.#messages;
311
585
  }
586
+ /**
587
+ * For expressions that return a value without awaiting. With replMode or
588
+ * awaitPromise, the inspector holds the result promise only weakly until
589
+ * its then-callback runs, even when the expression returns a plain value:
590
+ * a GC in that window fails the call with "Promise was collected" (#50).
591
+ * A plain evaluate returns the value directly.
592
+ */
593
+ #evaluateSync(expression) {
594
+ return this.#shell
595
+ .send("Runtime.evaluate", { expression, contextId: this.#contextId, returnByValue: true })
596
+ .then(asEvaluateResult);
597
+ }
312
598
  #evaluateRaw(expression, session = this.#shell) {
313
599
  return session
314
- .send("Runtime.evaluate", { expression, contextId: this.#contextId, replMode: true, awaitPromise: true })
600
+ .send("Runtime.evaluate", {
601
+ expression,
602
+ contextId: this.#contextId,
603
+ replMode: true,
604
+ awaitPromise: true,
605
+ objectGroup: CELL_GROUP,
606
+ })
315
607
  .then(asEvaluateResult);
316
608
  }
317
609
  /** Runs transformed cell code. Rejects with Interrupted after interrupt(). */
@@ -323,9 +615,36 @@ export class Target {
323
615
  catch (err) {
324
616
  if (err instanceof CdpAbandoned || this.#interrupted)
325
617
  throw new Interrupted("Execution interrupted");
618
+ // The control session's executionContextDestroyed event can arrive
619
+ // after the shell session's error reply (#271 moved it there).
620
+ if (err instanceof CdpError && err.message.includes("Execution context was destroyed"))
621
+ this.#onExit();
622
+ if (this.#exiting) {
623
+ // The child's exit, which drops this target in the kernel, comes
624
+ // first, so the next cell starts a fresh one.
625
+ await this.#exited;
626
+ throw new Error(`${this.runtime} exited`);
627
+ }
326
628
  throw err;
327
629
  }
328
630
  }
631
+ async releaseResults() {
632
+ if (this.#exiting)
633
+ return;
634
+ await this.#shell.send("Runtime.releaseObjectGroup", { objectGroup: CELL_GROUP }).catch(() => { });
635
+ }
636
+ #onExit() {
637
+ if (this.#exiting)
638
+ return;
639
+ this.#exiting = true;
640
+ this.#shell.close();
641
+ this.#control.close();
642
+ // In case it still doesn't exit once both sessions are gone.
643
+ setTimeout(() => {
644
+ if (this.#child.exitCode === null && this.#child.signalCode === null)
645
+ this.#child.kill("SIGKILL");
646
+ }, 2000).unref();
647
+ }
329
648
  /**
330
649
  * Deno calls IsolateHandle::terminate_execution from another thread. The
331
650
  * CDP equivalent is Runtime.terminateExecution, sent on a second session
@@ -341,11 +660,27 @@ export class Target {
341
660
  await this.#control.send("Runtime.terminateExecution");
342
661
  this.#shell.abandonPending();
343
662
  }
663
+ /**
664
+ * For synchronous functions. With awaitPromise the inspector would wrap
665
+ * even a plain return value in a promise it holds only weakly (#55).
666
+ */
344
667
  callFunctionOn(functionDeclaration, args) {
345
668
  return this.#shell.send("Runtime.callFunctionOn", {
346
669
  functionDeclaration,
347
670
  arguments: args,
348
671
  executionContextId: this.#contextId,
672
+ returnByValue: true,
673
+ });
674
+ }
675
+ /**
676
+ * For functions that return a promise. The target holds it until after
677
+ * the inspector's then-callback has run, so a GC can't collect it first.
678
+ */
679
+ #callAsyncFunctionOn(functionDeclaration, args) {
680
+ return this.#shell.send("Runtime.callFunctionOn", {
681
+ functionDeclaration: `function (...args) { return globalThis.${SHIM}.hold((${functionDeclaration}).apply(this, args)); }`,
682
+ arguments: args,
683
+ executionContextId: this.#contextId,
349
684
  awaitPromise: true,
350
685
  returnByValue: true,
351
686
  });
@@ -357,10 +692,12 @@ export class Target {
357
692
  : result.unserializableValue !== undefined
358
693
  ? { unserializableValue: result.unserializableValue }
359
694
  : { value: result.value };
360
- await this.callFunctionOn(`function (n, r) { return globalThis.${SHIM}.broadcastResult(n, r); }`, [
695
+ // An async $display method can still be running when interrupt()
696
+ // abandons the call (#135).
697
+ await this.#callAsyncFunctionOn(`function (n, r) { return globalThis.${SHIM}.broadcastResult(n, r); }`, [
361
698
  { value: executionCount },
362
699
  arg,
363
- ]);
700
+ ]).catch(interrupted("Execution"));
364
701
  }
365
702
  /**
366
703
  * Hands a frontend comm message to the target's comms. Resolves to
@@ -369,20 +706,19 @@ export class Target {
369
706
  */
370
707
  async dispatchComm(msgType, content, metadata, buffers) {
371
708
  this.#interrupted = false;
709
+ // Over stdin, not as call arguments: V8 caches an argument's source,
710
+ // so the data would stay in the target's memory (#287).
711
+ const record = Buffer.from(JSON.stringify({
712
+ msgType,
713
+ content,
714
+ metadata,
715
+ buffers: buffers.map((b) => Buffer.from(b).toString("base64")),
716
+ }));
717
+ this.#child.stdin.write(`comm ${record.length}\n`);
718
+ this.#child.stdin.write(record);
372
719
  let res;
373
720
  try {
374
- res = await this.#shell.send("Runtime.callFunctionOn", {
375
- functionDeclaration: `function (t, c, m, b) { return globalThis.${SHIM}.commDispatch(t, c, m, b); }`,
376
- arguments: [
377
- { value: msgType },
378
- { value: content },
379
- { value: metadata },
380
- { value: buffers.map((b) => Buffer.from(b).toString("base64")) },
381
- ],
382
- executionContextId: this.#contextId,
383
- awaitPromise: true,
384
- returnByValue: true,
385
- });
721
+ res = await this.#callAsyncFunctionOn(`function () { return globalThis.${SHIM}.commDispatchFromStdin(); }`, []);
386
722
  }
387
723
  catch (err) {
388
724
  if (err instanceof CdpAbandoned || this.#interrupted)
@@ -407,14 +743,28 @@ export class Target {
407
743
  /**
408
744
  * Evaluates without side effects: V8 throws instead of running anything
409
745
  * that would change state (a call, a getter that writes), so completing
410
- * or inspecting `obj.` never runs user code (#10).
746
+ * or inspecting `obj.` never runs user code (#10). Not replMode, which
747
+ * returns a promise the inspector holds only weakly (#55); a plain
748
+ * evaluate sees the cells' let and const too.
411
749
  */
412
750
  #evaluatePure(expression) {
413
751
  return this.#shell
414
- .send("Runtime.evaluate", { expression, contextId: this.#contextId, replMode: true, throwOnSideEffect: true })
415
- .then(asEvaluateResult, () => null);
752
+ .send("Runtime.evaluate", { expression, contextId: this.#contextId, throwOnSideEffect: true, objectGroup: LOOKUP_GROUP })
753
+ .then(asEvaluateResult)
754
+ .catch(() => null);
755
+ }
756
+ #releaseLookup() {
757
+ return this.#shell.send("Runtime.releaseObjectGroup", { objectGroup: LOOKUP_GROUP }).then(() => { }, () => { });
416
758
  }
417
759
  async propertyNames(expression) {
760
+ try {
761
+ return await this.#propertyNames(expression);
762
+ }
763
+ finally {
764
+ await this.#releaseLookup();
765
+ }
766
+ }
767
+ async #propertyNames(expression) {
418
768
  const evaluated = await this.#evaluatePure(`(${expression})`);
419
769
  const objectId = evaluated?.result.objectId;
420
770
  if (!objectId || evaluated?.exceptionDetails)
@@ -424,6 +774,14 @@ export class Target {
424
774
  return (list ?? []).map((p) => p.name);
425
775
  }
426
776
  async inspect(path, detail) {
777
+ try {
778
+ return await this.#inspect(path, detail);
779
+ }
780
+ finally {
781
+ await this.#releaseLookup();
782
+ }
783
+ }
784
+ async #inspect(path, detail) {
427
785
  const found = await this.#evaluatePure(path);
428
786
  if (!found || found.exceptionDetails)
429
787
  return null;
@@ -444,8 +802,8 @@ export class Target {
444
802
  const res = await this.#shell.send("Runtime.globalLexicalScopeNames", { executionContextId: this.#contextId });
445
803
  return res.names ?? [];
446
804
  }
447
- replyInput(value) {
448
- this.#child.stdin.write(JSON.stringify({ value }) + "\n");
805
+ replyInput(value, interrupted = false) {
806
+ this.#child.stdin.write(JSON.stringify({ value, interrupted }) + "\n");
449
807
  }
450
808
  kill() {
451
809
  this.#shell.close();