@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.
- package/README.md +78 -478
- package/THIRD-PARTY-NOTICES +62 -0
- package/dist/bun-target.d.ts +2 -2
- package/dist/bun-target.js +30 -8
- package/dist/cdp.d.ts +8 -1
- package/dist/cdp.js +31 -11
- package/dist/celld-target.d.ts +1 -1
- package/dist/celld-target.js +54 -19
- package/dist/children.d.ts +29 -0
- package/dist/children.js +114 -0
- package/dist/debugger.d.ts +1 -1
- package/dist/debugger.js +21 -10
- package/dist/deno-types.d.ts +30 -0
- package/dist/deno-types.js +93 -0
- package/dist/globals.d.ts +3 -3
- package/dist/globals.js +3 -3
- package/dist/kernel.d.ts +7 -1
- package/dist/kernel.js +348 -68
- package/dist/language-service-worker.d.ts +1 -0
- package/dist/language-service-worker.js +88 -0
- package/dist/language-service.d.ts +64 -0
- package/dist/language-service.js +128 -0
- package/dist/main.js +102 -8
- package/dist/mod.d.ts +6 -3
- package/dist/mod.js +6 -3
- package/dist/notebook-types.d.ts +62 -0
- package/dist/notebook-types.js +310 -0
- package/dist/package.d.ts +2 -2
- package/dist/package.js +3 -3
- package/dist/parent.d.ts +12 -0
- package/dist/parent.js +39 -0
- package/dist/port-holder.d.ts +13 -0
- package/dist/port-holder.js +105 -0
- package/dist/sourcemap.d.ts +7 -2
- package/dist/sourcemap.js +9 -3
- package/dist/strip-esbuild.d.ts +2 -0
- package/dist/strip-esbuild.js +44 -0
- package/dist/strip-sucrase.d.ts +2 -0
- package/dist/strip-sucrase.js +78 -0
- package/dist/target.d.ts +58 -6
- package/dist/target.js +412 -54
- package/dist/text.d.ts +5 -1
- package/dist/text.js +55 -2
- package/dist/transform.d.ts +30 -1
- package/dist/transform.js +68 -44
- package/dist/wire.d.ts +8 -1
- package/dist/wire.js +3 -1
- package/dist/workerd-target.d.ts +7 -1
- package/dist/workerd-target.js +102 -8
- package/dist/workers-project.js +13 -2
- package/dist/ws-client.d.ts +5 -1
- package/dist/ws-client.js +46 -13
- package/dist/zmtp.d.ts +9 -1
- package/dist/zmtp.js +42 -1
- package/package.json +17 -6
- package/runtime/bootstrap.mjs +233 -42
- package/runtime/bun-channel.d.mts +3 -0
- package/runtime/bun-channel.mjs +2 -2
- package/runtime/comm.mjs +11 -1
- package/runtime/deno-compat.d.mts +2 -0
- package/runtime/deno-compat.mjs +14 -1
- package/runtime/display.mjs +8 -1
- package/runtime/introspect.mjs +1 -1
- package/runtime/node-hooks.d.mts +48 -0
- package/runtime/node-hooks.mjs +257 -41
- 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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
84
|
-
this.#items.push({ text: this.#buffer.slice(0,
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
219
|
-
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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.#
|
|
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", {
|
|
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
|
-
|
|
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.#
|
|
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,
|
|
415
|
-
.then(asEvaluateResult
|
|
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();
|