@tlockney/cdp-kernel 0.3.0 → 0.5.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 (56) hide show
  1. package/README.md +60 -404
  2. package/THIRD-PARTY-NOTICES +62 -0
  3. package/dist/bun-target.d.ts +4 -2
  4. package/dist/bun-target.js +35 -8
  5. package/dist/cdp.d.ts +6 -1
  6. package/dist/cdp.js +25 -10
  7. package/dist/celld-target.d.ts +1 -1
  8. package/dist/celld-target.js +46 -8
  9. package/dist/children.d.ts +7 -0
  10. package/dist/children.js +32 -0
  11. package/dist/debugger.d.ts +42 -0
  12. package/dist/debugger.js +545 -0
  13. package/dist/globals.d.ts +3 -3
  14. package/dist/globals.js +3 -3
  15. package/dist/history.d.ts +18 -0
  16. package/dist/history.js +45 -0
  17. package/dist/kernel.js +211 -32
  18. package/dist/main.js +3 -1
  19. package/dist/mod.d.ts +5 -3
  20. package/dist/mod.js +5 -3
  21. package/dist/murmur2.d.ts +2 -0
  22. package/dist/murmur2.js +41 -0
  23. package/dist/package.d.ts +16 -2
  24. package/dist/package.js +26 -3
  25. package/dist/sourcemap.d.ts +22 -0
  26. package/dist/sourcemap.js +86 -0
  27. package/dist/target.d.ts +39 -3
  28. package/dist/target.js +281 -30
  29. package/dist/text.d.ts +11 -1
  30. package/dist/text.js +72 -2
  31. package/dist/transform.d.ts +19 -0
  32. package/dist/transform.js +74 -13
  33. package/dist/wire.d.ts +8 -1
  34. package/dist/wire.js +3 -1
  35. package/dist/workerd-target.d.ts +9 -1
  36. package/dist/workerd-target.js +108 -9
  37. package/dist/ws-client.d.ts +5 -1
  38. package/dist/ws-client.js +46 -13
  39. package/dist/zmtp.d.ts +7 -1
  40. package/dist/zmtp.js +30 -1
  41. package/package.json +10 -4
  42. package/runtime/bootstrap.mjs +138 -12
  43. package/runtime/bun-channel.d.mts +3 -0
  44. package/runtime/bun-channel.mjs +2 -2
  45. package/runtime/comm.mjs +11 -1
  46. package/runtime/deno-compat.d.mts +2 -0
  47. package/runtime/deno-compat.mjs +17 -2
  48. package/runtime/display.d.mts +2 -0
  49. package/runtime/display.mjs +17 -2
  50. package/runtime/evaluator.d.mts +4 -0
  51. package/runtime/evaluator.mjs +8 -0
  52. package/runtime/introspect.d.mts +13 -0
  53. package/runtime/introspect.mjs +120 -0
  54. package/runtime/node-hooks.d.mts +61 -1
  55. package/runtime/node-hooks.mjs +354 -21
  56. package/runtime/workerd-worker.mjs +82 -6
@@ -0,0 +1,86 @@
1
+ // Line mapping between a cell as written and the script that runs, for the
2
+ // debugger (#18): a breakpoint set on a cell's line must land on the line
3
+ // esbuild's output put it on, and a paused frame must report the line the
4
+ // user wrote. Only lines: Jupyter's breakpoints are per line.
5
+ const BASE64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
6
+ const DIGIT = new Map([...BASE64].map((c, i) => [c, i]));
7
+ /** One segment's VLQ fields: generated column, source, line, column, name. */
8
+ export function decodeVlq(segment) {
9
+ const values = [];
10
+ let value = 0;
11
+ let shift = 0;
12
+ for (const char of segment) {
13
+ const digit = DIGIT.get(char);
14
+ if (digit === undefined)
15
+ throw new Error(`bad base64 VLQ digit ${JSON.stringify(char)}`);
16
+ value += (digit & 31) << shift;
17
+ if (digit & 32) {
18
+ shift += 5;
19
+ continue;
20
+ }
21
+ values.push(value & 1 ? -(value >>> 1) : value >>> 1);
22
+ value = 0;
23
+ shift = 0;
24
+ }
25
+ return values;
26
+ }
27
+ /**
28
+ * For each generated line (0-based), the first original line (0-based) a
29
+ * segment on it maps to, or -1. Source-map fields are relative to the
30
+ * previous segment's; only the original line is tracked.
31
+ */
32
+ export function generatedToOriginalLines(mappings) {
33
+ const lines = [];
34
+ let original = 0;
35
+ for (const line of mappings.split(";")) {
36
+ let first = -1;
37
+ for (const segment of line.split(",")) {
38
+ if (!segment)
39
+ continue;
40
+ const fields = decodeVlq(segment);
41
+ if (fields.length >= 4) {
42
+ original += fields[2];
43
+ if (first === -1)
44
+ first = original;
45
+ }
46
+ }
47
+ lines.push(first);
48
+ }
49
+ return lines;
50
+ }
51
+ /** Both directions between the lines of a cell and of its script (0-based). */
52
+ export class LineMap {
53
+ #toOriginal;
54
+ #toGenerated = new Map();
55
+ /** `offset`: lines the script adds before esbuild's output (the prelude). */
56
+ constructor(mappings, offset = 0) {
57
+ const fromEsbuild = generatedToOriginalLines(mappings);
58
+ this.#toOriginal = [...Array(offset).fill(-1), ...fromEsbuild];
59
+ this.#toOriginal.forEach((original, generated) => {
60
+ if (original !== -1 && !this.#toGenerated.has(original))
61
+ this.#toGenerated.set(original, generated);
62
+ });
63
+ }
64
+ /** The cell line a script line came from: its own mapping, or the nearest one above. */
65
+ toOriginal(generated) {
66
+ for (let line = Math.min(generated, this.#toOriginal.length - 1); line >= 0; line--) {
67
+ if (this.#toOriginal[line] !== -1)
68
+ return this.#toOriginal[line];
69
+ }
70
+ return 0;
71
+ }
72
+ /**
73
+ * The script line for a cell line, or for the next cell line that has
74
+ * code (a breakpoint on a blank or comment line moves down, as debuggers
75
+ * do), or null past the end.
76
+ */
77
+ toGenerated(original) {
78
+ const last = Math.max(-1, ...this.#toGenerated.keys());
79
+ for (let line = original; line <= last; line++) {
80
+ const generated = this.#toGenerated.get(line);
81
+ if (generated !== undefined)
82
+ return generated;
83
+ }
84
+ return null;
85
+ }
86
+ }
package/dist/target.d.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  import { type CdpParams, type EvaluateResult, type RemoteObject } from "./cdp.ts";
2
2
  import { type CellTransformOptions, transformCell } from "./transform.ts";
3
+ import type { DebugChannel } from "./debugger.ts";
3
4
  /** Targets driven over CDP. */
4
5
  export type CdpRuntime = "node" | "deno";
5
- /** Bun and workerd have their own backends: src/bun-target.ts, src/workerd-target.ts. */
6
+ /** Bun, workerd and celld have their own backends: src/bun-target.ts, src/workerd-target.ts, src/celld-target.ts. */
6
7
  export type RuntimeName = CdpRuntime | "bun" | "workerd" | "celld";
7
8
  /** What the kernel needs from a target runtime. */
8
9
  export interface KernelTarget {
@@ -37,7 +38,12 @@ export interface KernelTarget {
37
38
  }>;
38
39
  propertyNames(expression: string): Promise<string[]>;
39
40
  lexicalNames(): Promise<string[]>;
40
- replyInput(value: string | null): void;
41
+ /** inspect_request (#10): text describing a dotted path's value, without running anything. */
42
+ inspect(path: string, detail: number): Promise<string | null>;
43
+ /** The Debugger domain, on targets driven over CDP (#18). */
44
+ readonly debug?: DebugChannel;
45
+ /** Answers the target's pending prompt; `interrupted` makes it throw instead (#121). */
46
+ replyInput(value: string | null, interrupted?: boolean): void;
41
47
  kill(): void;
42
48
  }
43
49
  export interface LaunchOptions {
@@ -51,6 +57,13 @@ export declare function launchCommand(runtime: CdpRuntime, bootstrap: string, {
51
57
  cmd: string;
52
58
  args: string[];
53
59
  };
60
+ /**
61
+ * The esbuild binary this host's esbuild runs, for a Node target to compile
62
+ * imported TypeScript Node can't strip (#73); null when it can't be found.
63
+ * Resolved from esbuild's own location, so it's found in a node_modules
64
+ * install and in Deno's npm cache alike.
65
+ */
66
+ export declare function esbuildBinary(resolve?: (specifier: string) => string, platform?: string, arch?: string): string | null;
54
67
  /** Messages the bootstrap sends through the CDP binding. */
55
68
  export type TargetMessage = {
56
69
  kind: "iopub";
@@ -65,6 +78,8 @@ export type TargetMessage = {
65
78
  password: boolean;
66
79
  barrier: number;
67
80
  };
81
+ /** The parsed value, or undefined for text that isn't JSON. */
82
+ export declare function parseJson(text: string): unknown;
68
83
  export declare function isTargetMessage(value: unknown): value is TargetMessage;
69
84
  /**
70
85
  * Splits one output pipe into text and barrier markers, and releases them
@@ -83,6 +98,19 @@ export declare class PipeDemux {
83
98
  /** Release everything (the process died). */
84
99
  close(): void;
85
100
  }
101
+ /**
102
+ * Removes the runtime's own stderr lines (see INSPECTOR_CHATTER) and passes
103
+ * everything else on. A chatter line can arrive split across chunks, so a
104
+ * trailing partial line that could still turn out to be one is held back
105
+ * until its newline arrives; any other partial line passes straight through.
106
+ */
107
+ export declare class StderrFilter {
108
+ #private;
109
+ constructor(runtime: CdpRuntime, emit: (text: string) => void);
110
+ push(chunk: string): void;
111
+ /** Releases a held partial line (the process died). */
112
+ flush(): void;
113
+ }
86
114
  /** Promises keyed by barrier number, resolvable before or after first use. */
87
115
  export declare class Gates {
88
116
  #private;
@@ -113,7 +141,10 @@ export declare class Target implements KernelTarget {
113
141
  executable?: string;
114
142
  denoCompat?: boolean;
115
143
  denoConfig?: string;
144
+ startTimeoutMs?: number;
116
145
  }): Promise<Target>;
146
+ /** The Debugger domain on this target's session, for src/debugger.ts. */
147
+ get debug(): DebugChannel;
117
148
  /** Resolves once all output and messages sent so far have been delivered. */
118
149
  flush(): Promise<void>;
119
150
  /** Runs transformed cell code. Rejects with Interrupted after interrupt(). */
@@ -126,6 +157,10 @@ export declare class Target implements KernelTarget {
126
157
  * evaluate is abandoned here and the session stays usable.
127
158
  */
128
159
  interrupt(): Promise<void>;
160
+ /**
161
+ * For synchronous functions. With awaitPromise the inspector would wrap
162
+ * even a plain return value in a promise it holds only weakly (#55).
163
+ */
129
164
  callFunctionOn(functionDeclaration: string, args: CdpParams[]): Promise<CdpParams>;
130
165
  /** The broadcastResult step of Deno's execute flow (jupyter/mod.rs). */
131
166
  broadcastResult(executionCount: number, result: RemoteObject): Promise<void>;
@@ -149,7 +184,8 @@ export declare class Target implements KernelTarget {
149
184
  stack: string;
150
185
  }>;
151
186
  propertyNames(expression: string): Promise<string[]>;
187
+ inspect(path: string, detail: number): Promise<string | null>;
152
188
  lexicalNames(): Promise<string[]>;
153
- replyInput(value: string | null): void;
189
+ replyInput(value: string | null, interrupted?: boolean): void;
154
190
  kill(): void;
155
191
  }
package/dist/target.js CHANGED
@@ -4,17 +4,33 @@
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";
9
+ import { fileURLToPath } from "node:url";
8
10
  import { asEvaluateResult, CdpAbandoned, CdpSession } from "./cdp.js";
9
11
  import { SHIM, transformCell } from "./transform.js";
10
12
  import { hostRuntime, isInsideNodeModules, runtimeDir } from "./package.js";
13
+ import { killOnExit } from "./children.js";
11
14
  const BINDING = "__cdpk_send";
15
+ /** How long a target may take from spawn to a usable session. */
16
+ const START_TIMEOUT_MS = 60_000;
12
17
  const MARKER_RE = /\x1b\[cdpk:(\d+)(m?)\]\x1b/;
13
18
  const MAX_MARKER_LEN = 24;
14
19
  // Lines the runtimes print to stderr on their own: inspector notices, and
15
20
  // 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;
21
+ // sends to the server log rather than the notebook). Each entry is the
22
+ // line's start once colour codes are removed.
23
+ const INSPECTOR_CHATTER = [
24
+ "Debugger listening on ",
25
+ "Debugger attached.",
26
+ "For help, see: https://nodejs.org/",
27
+ "Visit chrome://inspect",
28
+ "Waiting for the debugger to disconnect",
29
+ ];
30
+ const DENO_CHATTER = [...INSPECTOR_CHATTER, "Download http://", "Download https://"];
31
+ const ANSI_SGR = /\x1b\[[0-9;]*m/g;
32
+ /** A colour code cut off at the end of a chunk. */
33
+ const PARTIAL_SGR = /\x1b(?:\[[0-9;]*)?$/;
18
34
  /** `bootstrap` is the local path of runtime/bootstrap.mjs (see runtimeDir). */
19
35
  export function launchCommand(runtime, bootstrap, { executable, denoConfig } = {}) {
20
36
  // The CDP WebSocket has no authentication, so its URL must stay private:
@@ -41,6 +57,33 @@ export function launchCommand(runtime, bootstrap, { executable, denoConfig } = {
41
57
  const node = hostRuntime() === "node" ? process.execPath : "node";
42
58
  return { cmd: executable ?? node, args: [...inspect, bootstrap] };
43
59
  }
60
+ /**
61
+ * The esbuild binary this host's esbuild runs, for a Node target to compile
62
+ * imported TypeScript Node can't strip (#73); null when it can't be found.
63
+ * Resolved from esbuild's own location, so it's found in a node_modules
64
+ * install and in Deno's npm cache alike.
65
+ */
66
+ export function esbuildBinary(resolve = import.meta.resolve, platform = process.platform, arch = process.arch) {
67
+ if (process.env.ESBUILD_BINARY_PATH)
68
+ return process.env.ESBUILD_BINARY_PATH;
69
+ const subpath = platform === "win32" ? "esbuild.exe" : "bin/esbuild";
70
+ try {
71
+ const main = fileURLToPath(resolve("esbuild"));
72
+ return createRequire(main).resolve(`@esbuild/${platform}-${arch}/${subpath}`);
73
+ }
74
+ catch {
75
+ return null;
76
+ }
77
+ }
78
+ /** The parsed value, or undefined for text that isn't JSON. */
79
+ export function parseJson(text) {
80
+ try {
81
+ return JSON.parse(text);
82
+ }
83
+ catch {
84
+ return undefined;
85
+ }
86
+ }
44
87
  export function isTargetMessage(value) {
45
88
  if (typeof value !== "object" || value === null)
46
89
  return false;
@@ -139,6 +182,66 @@ export class PipeDemux {
139
182
  void this.#pump();
140
183
  }
141
184
  }
185
+ /**
186
+ * Removes the runtime's own stderr lines (see INSPECTOR_CHATTER) and passes
187
+ * everything else on. A chatter line can arrive split across chunks, so a
188
+ * trailing partial line that could still turn out to be one is held back
189
+ * until its newline arrives; any other partial line passes straight through.
190
+ */
191
+ export class StderrFilter {
192
+ #starts;
193
+ #emit;
194
+ #held = "";
195
+ #atLineStart = true;
196
+ constructor(runtime, emit) {
197
+ this.#starts = runtime === "deno" ? DENO_CHATTER : INSPECTOR_CHATTER;
198
+ this.#emit = emit;
199
+ }
200
+ push(chunk) {
201
+ let text = this.#held + chunk;
202
+ this.#held = "";
203
+ let out = "";
204
+ if (!this.#atLineStart) {
205
+ // The rest of a line that already went out unfiltered.
206
+ const nl = text.indexOf("\n");
207
+ if (nl === -1) {
208
+ this.#emit(text);
209
+ return;
210
+ }
211
+ out = text.slice(0, nl + 1);
212
+ text = text.slice(nl + 1);
213
+ }
214
+ const lines = text.split("\n");
215
+ const last = lines.pop();
216
+ for (const line of lines)
217
+ if (!this.#isChatter(line, false))
218
+ out += line + "\n";
219
+ if (last && this.#isChatter(last, true))
220
+ this.#held = last;
221
+ else
222
+ out += last;
223
+ this.#atLineStart = last === "" || this.#held !== "";
224
+ if (out)
225
+ this.#emit(out);
226
+ }
227
+ /** Releases a held partial line (the process died). */
228
+ flush() {
229
+ if (this.#held)
230
+ this.#emit(this.#held);
231
+ this.#held = "";
232
+ this.#atLineStart = true;
233
+ }
234
+ /** Whether `line` is chatter or, when `partial`, could still become chatter. */
235
+ #isChatter(line, partial) {
236
+ let plain = line.replace(ANSI_SGR, "");
237
+ if (partial)
238
+ plain = plain.replace(PARTIAL_SGR, "");
239
+ // Any other escape may be a PipeDemux marker, which must never be dropped or held.
240
+ if (plain.includes("\x1b"))
241
+ return false;
242
+ return this.#starts.some((start) => plain.startsWith(start) || (partial && start.startsWith(plain)));
243
+ }
244
+ }
142
245
  /** Promises keyed by barrier number, resolvable before or after first use. */
143
246
  export class Gates {
144
247
  #map = new Map();
@@ -167,6 +270,12 @@ export class Gates {
167
270
  export class Interrupted extends Error {
168
271
  name = "Interrupted";
169
272
  }
273
+ /** A catch handler: a call interrupt() abandoned becomes Interrupted. */
274
+ function interrupted(what) {
275
+ return (err) => {
276
+ throw err instanceof CdpAbandoned ? new Interrupted(`${what} interrupted`) : err;
277
+ };
278
+ }
170
279
  export class Target {
171
280
  runtime;
172
281
  transform = transformCell;
@@ -179,10 +288,21 @@ export class Target {
179
288
  #contextId;
180
289
  #messages = Promise.resolve();
181
290
  #interrupted = false;
291
+ #paused = false;
292
+ /** Set once the main context is gone: the runtime is exiting (#130). */
293
+ #exiting = false;
294
+ #exited;
295
+ #baselineGlobals = new Set();
182
296
  versions;
183
297
  constructor(runtime, child, shell, control, stdout, stderr, gates, contextId, versions) {
184
298
  this.runtime = runtime;
185
299
  this.#child = child;
300
+ this.#exited = new Promise((resolve) => {
301
+ if (child.exitCode !== null || child.signalCode !== null)
302
+ resolve();
303
+ else
304
+ child.once("exit", () => resolve());
305
+ });
186
306
  this.#shell = shell;
187
307
  this.#control = control;
188
308
  this.#stdout = stdout;
@@ -206,18 +326,41 @@ export class Target {
206
326
  env.CDPK_DENO_COMPAT = "1";
207
327
  if (runtime === "deno" && options.denoConfig !== undefined)
208
328
  env.CDPK_DENO_PROJECT = "1";
329
+ const esbuild = runtime === "node" ? esbuildBinary() : null;
330
+ if (esbuild !== null)
331
+ env.CDPK_ESBUILD_BINARY = esbuild;
209
332
  delete env.NO_COLOR;
210
333
  const child = spawn(cmd, args, { cwd: options.cwd, stdio: ["pipe", "pipe", "pipe"], env });
334
+ killOnExit(child);
335
+ // A start that fails or stalls must not leave the runtime running: its
336
+ // pipes and inspector sockets would keep this process alive (#52).
337
+ const sessions = [];
338
+ const timeoutMs = options.startTimeoutMs ?? START_TIMEOUT_MS;
339
+ let timer;
340
+ const stalled = new Promise((_, reject) => {
341
+ timer = setTimeout(() => reject(new Error(`${cmd} did not start within ${timeoutMs / 1000}s`)), timeoutMs);
342
+ });
343
+ try {
344
+ return await Promise.race([Target.#start(runtime, events, cmd, child, sessions), stalled]);
345
+ }
346
+ catch (error) {
347
+ for (const session of sessions)
348
+ session.close();
349
+ child.kill();
350
+ throw error;
351
+ }
352
+ finally {
353
+ clearTimeout(timer);
354
+ }
355
+ }
356
+ static async #start(runtime, events, cmd, child, sessions) {
211
357
  child.stdout.setEncoding("utf8");
212
358
  child.stderr.setEncoding("utf8");
213
359
  const gates = new Gates();
214
360
  const stdout = new PipeDemux((text) => events.stream("stdout", text), (n) => gates.wait(n));
215
361
  const stderr = new PipeDemux((text) => events.stream("stderr", text), (n) => gates.wait(n));
216
- const pushStderr = (chunk) => {
217
- const text = chunk.replace(INSPECTOR_CHATTER, "");
218
- if (text)
219
- stderr.push(text);
220
- };
362
+ const filter = new StderrFilter(runtime, (text) => stderr.push(text));
363
+ const pushStderr = (chunk) => filter.push(chunk);
221
364
  // Until the inspector URL appears, stderr carries the runtime's banner.
222
365
  const wsUrl = await new Promise((resolve, reject) => {
223
366
  let banner = "";
@@ -238,12 +381,15 @@ export class Target {
238
381
  child.stdout.on("data", (c) => stdout.push(c));
239
382
  child.once("exit", (code) => {
240
383
  gates.openAll();
384
+ filter.flush();
241
385
  stdout.close();
242
386
  stderr.close();
243
387
  events.exit(code);
244
388
  });
245
389
  const shell = await CdpSession.connect(wsUrl);
390
+ sessions.push(shell);
246
391
  const control = await CdpSession.connect(wsUrl);
392
+ sessions.push(control);
247
393
  const contextReady = new Promise((resolve) => {
248
394
  shell.on("Runtime.executionContextCreated", (params) => {
249
395
  const context = params.context;
@@ -255,24 +401,54 @@ export class Target {
255
401
  await shell.send("Runtime.addBinding", { name: BINDING });
256
402
  const contextId = await contextReady;
257
403
  const target = new Target(runtime, child, shell, control, stdout, stderr, gates, contextId, { runtime: "", engine: "" });
404
+ // process.exit() or Deno.exit() destroys the context, then the runtime
405
+ // waits for its inspector clients to disconnect before it exits. Let go,
406
+ // so it exits and the kernel starts a fresh one (#130).
407
+ shell.on("Runtime.executionContextDestroyed", (params) => {
408
+ if (params.executionContextId === contextId)
409
+ target.#onExit();
410
+ });
258
411
  shell.on("Runtime.bindingCalled", (params) => {
259
412
  if (params.name !== BINDING || typeof params.payload !== "string")
260
413
  return;
261
- const msg = JSON.parse(params.payload);
414
+ // __cdpk_send is an ordinary global, so a cell can call it with
415
+ // anything; that mustn't take the host down (#122).
416
+ const msg = parseJson(params.payload);
262
417
  if (isTargetMessage(msg))
263
418
  target.#deliver(msg, events);
264
419
  });
265
- // Wait for the bootstrap's top-level await to finish installing the shim.
266
- for (let i = 0; i < 200; i++) {
267
- const probe = await target.#evaluateRaw(`typeof globalThis.${SHIM}?.tick === "function"`);
420
+ // Wait for the bootstrap's top-level await to finish installing the
421
+ // shim. There is no cap here: launch's start timeout bounds the wait and
422
+ // kills the child, so a slow bootstrap never yields a target without
423
+ // the shim (#134).
424
+ for (;;) {
425
+ const probe = await target.#evaluateSync(`typeof globalThis.${SHIM}?.tick === "function"`);
268
426
  if (probe.result.value === true)
269
427
  break;
270
428
  await new Promise((r) => setTimeout(r, 25));
271
429
  }
272
- 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 })`);
430
+ 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 })`);
273
431
  Object.assign(target.versions, JSON.parse(String(versions.result.value)));
432
+ // The runtime's own enumerable globals, before any cell: the debugger's
433
+ // variable views show only what cells added (#18).
434
+ const globals = asEvaluateResult(await shell.send("Runtime.evaluate", { expression: "Object.keys(globalThis)", contextId, returnByValue: true }));
435
+ const names = globals.result.value;
436
+ if (Array.isArray(names))
437
+ for (const name of names)
438
+ target.#baselineGlobals.add(String(name));
439
+ shell.on("Debugger.paused", () => (target.#paused = true));
440
+ shell.on("Debugger.resumed", () => (target.#paused = false));
274
441
  return target;
275
442
  }
443
+ /** The Debugger domain on this target's session, for src/debugger.ts. */
444
+ get debug() {
445
+ return {
446
+ send: (method, params = {}) => this.#shell.send(method, params),
447
+ on: (method, listener) => this.#shell.on(method, listener),
448
+ contextId: this.#contextId,
449
+ baselineGlobals: this.#baselineGlobals,
450
+ };
451
+ }
276
452
  // Binding messages wait until both pipes have caught up to their marker,
277
453
  // and are delivered strictly in order.
278
454
  #deliver(msg, events) {
@@ -284,11 +460,23 @@ export class Target {
284
460
  }
285
461
  /** Resolves once all output and messages sent so far have been delivered. */
286
462
  async flush() {
287
- const n = await this.#evaluateRaw(`${SHIM}.barrier()`);
463
+ const n = await this.#evaluateSync(`${SHIM}.barrier()`).catch(interrupted("Execution"));
288
464
  const barrier = Number(n.result.value);
289
465
  await Promise.all([this.#stdout.waitFor(barrier), this.#stderr.waitFor(barrier)]);
290
466
  await this.#messages;
291
467
  }
468
+ /**
469
+ * For expressions that return a value without awaiting. With replMode or
470
+ * awaitPromise, the inspector holds the result promise only weakly until
471
+ * its then-callback runs, even when the expression returns a plain value:
472
+ * a GC in that window fails the call with "Promise was collected" (#50).
473
+ * A plain evaluate returns the value directly.
474
+ */
475
+ #evaluateSync(expression) {
476
+ return this.#shell
477
+ .send("Runtime.evaluate", { expression, contextId: this.#contextId, returnByValue: true })
478
+ .then(asEvaluateResult);
479
+ }
292
480
  #evaluateRaw(expression, session = this.#shell) {
293
481
  return session
294
482
  .send("Runtime.evaluate", { expression, contextId: this.#contextId, replMode: true, awaitPromise: true })
@@ -303,9 +491,27 @@ export class Target {
303
491
  catch (err) {
304
492
  if (err instanceof CdpAbandoned || this.#interrupted)
305
493
  throw new Interrupted("Execution interrupted");
494
+ if (this.#exiting) {
495
+ // The child's exit, which drops this target in the kernel, comes
496
+ // first, so the next cell starts a fresh one.
497
+ await this.#exited;
498
+ throw new Error(`${this.runtime} exited`);
499
+ }
306
500
  throw err;
307
501
  }
308
502
  }
503
+ #onExit() {
504
+ if (this.#exiting)
505
+ return;
506
+ this.#exiting = true;
507
+ this.#shell.close();
508
+ this.#control.close();
509
+ // In case it still doesn't exit once both sessions are gone.
510
+ setTimeout(() => {
511
+ if (this.#child.exitCode === null && this.#child.signalCode === null)
512
+ this.#child.kill("SIGKILL");
513
+ }, 2000).unref();
514
+ }
309
515
  /**
310
516
  * Deno calls IsolateHandle::terminate_execution from another thread. The
311
517
  * CDP equivalent is Runtime.terminateExecution, sent on a second session
@@ -315,14 +521,33 @@ export class Target {
315
521
  */
316
522
  async interrupt() {
317
523
  this.#interrupted = true;
524
+ // A paused isolate runs nothing to terminate: let it go first (#18).
525
+ if (this.#paused)
526
+ await this.#shell.send("Debugger.resume").catch(() => { });
318
527
  await this.#control.send("Runtime.terminateExecution");
319
528
  this.#shell.abandonPending();
320
529
  }
530
+ /**
531
+ * For synchronous functions. With awaitPromise the inspector would wrap
532
+ * even a plain return value in a promise it holds only weakly (#55).
533
+ */
321
534
  callFunctionOn(functionDeclaration, args) {
322
535
  return this.#shell.send("Runtime.callFunctionOn", {
323
536
  functionDeclaration,
324
537
  arguments: args,
325
538
  executionContextId: this.#contextId,
539
+ returnByValue: true,
540
+ });
541
+ }
542
+ /**
543
+ * For functions that return a promise. The target holds it until after
544
+ * the inspector's then-callback has run, so a GC can't collect it first.
545
+ */
546
+ #callAsyncFunctionOn(functionDeclaration, args) {
547
+ return this.#shell.send("Runtime.callFunctionOn", {
548
+ functionDeclaration: `function (...args) { return globalThis.${SHIM}.hold((${functionDeclaration}).apply(this, args)); }`,
549
+ arguments: args,
550
+ executionContextId: this.#contextId,
326
551
  awaitPromise: true,
327
552
  returnByValue: true,
328
553
  });
@@ -334,10 +559,12 @@ export class Target {
334
559
  : result.unserializableValue !== undefined
335
560
  ? { unserializableValue: result.unserializableValue }
336
561
  : { value: result.value };
337
- await this.callFunctionOn(`function (n, r) { return globalThis.${SHIM}.broadcastResult(n, r); }`, [
562
+ // An async $display method can still be running when interrupt()
563
+ // abandons the call (#135).
564
+ await this.#callAsyncFunctionOn(`function (n, r) { return globalThis.${SHIM}.broadcastResult(n, r); }`, [
338
565
  { value: executionCount },
339
566
  arg,
340
- ]);
567
+ ]).catch(interrupted("Execution"));
341
568
  }
342
569
  /**
343
570
  * Hands a frontend comm message to the target's comms. Resolves to
@@ -348,18 +575,12 @@ export class Target {
348
575
  this.#interrupted = false;
349
576
  let res;
350
577
  try {
351
- res = await this.#shell.send("Runtime.callFunctionOn", {
352
- functionDeclaration: `function (t, c, m, b) { return globalThis.${SHIM}.commDispatch(t, c, m, b); }`,
353
- arguments: [
354
- { value: msgType },
355
- { value: content },
356
- { value: metadata },
357
- { value: buffers.map((b) => Buffer.from(b).toString("base64")) },
358
- ],
359
- executionContextId: this.#contextId,
360
- awaitPromise: true,
361
- returnByValue: true,
362
- });
578
+ res = await this.#callAsyncFunctionOn(`function (t, c, m, b) { return globalThis.${SHIM}.commDispatch(t, c, m, b); }`, [
579
+ { value: msgType },
580
+ { value: content },
581
+ { value: metadata },
582
+ { value: buffers.map((b) => Buffer.from(b).toString("base64")) },
583
+ ]);
363
584
  }
364
585
  catch (err) {
365
586
  if (err instanceof CdpAbandoned || this.#interrupted)
@@ -381,8 +602,21 @@ export class Target {
381
602
  const value = res.result?.value;
382
603
  return JSON.parse(typeof value === "string" ? value : '{"name":"Error","message":"","stack":""}');
383
604
  }
605
+ /**
606
+ * Evaluates without side effects: V8 throws instead of running anything
607
+ * that would change state (a call, a getter that writes), so completing
608
+ * or inspecting `obj.` never runs user code (#10). Not replMode, which
609
+ * returns a promise the inspector holds only weakly (#55); a plain
610
+ * evaluate sees the cells' let and const too.
611
+ */
612
+ #evaluatePure(expression) {
613
+ return this.#shell
614
+ .send("Runtime.evaluate", { expression, contextId: this.#contextId, throwOnSideEffect: true })
615
+ .then(asEvaluateResult)
616
+ .catch(() => null);
617
+ }
384
618
  async propertyNames(expression) {
385
- const evaluated = await this.#evaluateRaw(`(${expression})`).catch(() => null);
619
+ const evaluated = await this.#evaluatePure(`(${expression})`);
386
620
  const objectId = evaluated?.result.objectId;
387
621
  if (!objectId || evaluated?.exceptionDetails)
388
622
  return [];
@@ -390,12 +624,29 @@ export class Target {
390
624
  const list = props.result;
391
625
  return (list ?? []).map((p) => p.name);
392
626
  }
627
+ async inspect(path, detail) {
628
+ const found = await this.#evaluatePure(path);
629
+ if (!found || found.exceptionDetails)
630
+ return null;
631
+ const value = found.result;
632
+ const arg = value.objectId
633
+ ? { objectId: value.objectId }
634
+ : value.unserializableValue !== undefined
635
+ ? { unserializableValue: value.unserializableValue }
636
+ : { value: value.value };
637
+ const res = await this.callFunctionOn(`function (v, d) { return globalThis.${SHIM}.describeForInspect(v, d); }`, [
638
+ arg,
639
+ { value: detail },
640
+ ]);
641
+ const text = res.result?.value;
642
+ return typeof text === "string" ? text : null;
643
+ }
393
644
  async lexicalNames() {
394
645
  const res = await this.#shell.send("Runtime.globalLexicalScopeNames", { executionContextId: this.#contextId });
395
646
  return res.names ?? [];
396
647
  }
397
- replyInput(value) {
398
- this.#child.stdin.write(JSON.stringify({ value }) + "\n");
648
+ replyInput(value, interrupted = false) {
649
+ this.#child.stdin.write(JSON.stringify({ value, interrupted }) + "\n");
399
650
  }
400
651
  kill() {
401
652
  this.#shell.close();
package/dist/text.d.ts CHANGED
@@ -6,9 +6,19 @@ export type IsCompleteReply = {
6
6
  } | {
7
7
  status: "invalid";
8
8
  };
9
- /** Bracket/quote/comment scanner deciding whether a cell is finished. */
9
+ /**
10
+ * Bracket/quote/comment scanner deciding whether a cell is finished. Regex
11
+ * literals are recognised heuristically (see startsRegex), and a template's
12
+ * `${...}` is skipped like the rest of the template.
13
+ */
10
14
  export declare function checkIsComplete(code: string): IsCompleteReply;
11
15
  /** The dotted expression ending at the cursor, e.g. "Math.fl". */
12
16
  export declare function exprAtCursor(code: string, cursor: number): string;
17
+ /**
18
+ * The dotted name an inspect_request means (#10): the one the cursor is in
19
+ * or just after, or, right after "name(" while typing a call, that callee.
20
+ * Null when it isn't a plain path; nothing else is evaluated.
21
+ */
22
+ export declare function inspectTarget(code: string, cursor: number): string | null;
13
23
  export declare function codePointToUtf16Index(str: string, cpOffset: number): number;
14
24
  export declare function utf16ToCodePointIndex(str: string, utf16Offset: number): number;