@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
@@ -1,4 +1,4 @@
1
- import type { CdpParams, EvaluateResult, RemoteObject } from "./cdp.ts";
1
+ import { type CdpParams, type EvaluateResult, type RemoteObject } from "./cdp.ts";
2
2
  import { transformCellForEval } from "./transform.ts";
3
3
  import { type KernelTarget, type TargetEvents } from "./target.ts";
4
4
  /**
@@ -55,6 +55,6 @@ export declare class BunTarget implements KernelTarget {
55
55
  inspect(path: string, detail: number): Promise<string | null>;
56
56
  /** None: top-level declarations here are properties of globalThis. */
57
57
  lexicalNames(): Promise<string[]>;
58
- replyInput(value: string | null): void;
58
+ replyInput(value: string | null, interrupted?: boolean): void;
59
59
  kill(): void;
60
60
  }
@@ -9,9 +9,11 @@ import { Buffer } from "node:buffer";
9
9
  import process from "node:process";
10
10
  import { spawn } from "node:child_process";
11
11
  import { join } from "node:path";
12
+ import { asEvaluateResult } from "./cdp.js";
12
13
  import { transformCellForEval } from "./transform.js";
13
14
  import { runtimeDir } from "./package.js";
14
- import { Gates, Interrupted, isTargetMessage, PipeDemux } from "./target.js";
15
+ import { killOnExit } from "./children.js";
16
+ import { Gates, Interrupted, isTargetMessage, parseJson, PipeDemux } from "./target.js";
15
17
  /**
16
18
  * How long an interrupted target has to answer a ping. A cell waiting on a
17
19
  * promise leaves the event loop free, so the answer is near-instant; a busy
@@ -45,6 +47,9 @@ export class BunTarget {
45
47
  #gates = new Gates();
46
48
  #pending = new Map();
47
49
  #nextId = 0;
50
+ /** Ids of requests interrupt() may cut short: eval and comm. */
51
+ #interruptibleIds = new Set();
52
+ #exitedOnce = false;
48
53
  #messages = Promise.resolve();
49
54
  #interrupted = false;
50
55
  #stopNote;
@@ -60,6 +65,7 @@ export class BunTarget {
60
65
  const env = { ...process.env, FORCE_COLOR: "1" };
61
66
  delete env.NO_COLOR;
62
67
  const child = spawn(cmd, args, { cwd: options.cwd, stdio: ["pipe", "pipe", "pipe", "pipe"], env });
68
+ killOnExit(child);
63
69
  const channel = child.stdio[3];
64
70
  if (!child.stdout || !child.stderr || !isDuplex(channel)) {
65
71
  child.kill();
@@ -97,7 +103,8 @@ export class BunTarget {
97
103
  this.#channel.on("data", (chunk) => {
98
104
  buffer += chunk;
99
105
  for (let nl = buffer.indexOf("\n"); nl !== -1; nl = buffer.indexOf("\n")) {
100
- const msg = JSON.parse(buffer.slice(0, nl));
106
+ // Cells can write here through __cdpk_send too (#122).
107
+ const msg = parseJson(buffer.slice(0, nl));
101
108
  buffer = buffer.slice(nl + 1);
102
109
  if (isTargetMessage(msg))
103
110
  this.#deliver(msg, events);
@@ -116,12 +123,14 @@ export class BunTarget {
116
123
  if (!pending)
117
124
  return; // abandoned
118
125
  this.#pending.delete(id);
126
+ this.#interruptibleIds.delete(id);
119
127
  if (typeof reply.failure === "string")
120
128
  pending.reject(new Error(reply.failure));
121
129
  else
122
130
  pending.resolve(reply);
123
131
  }
124
132
  #exited(code, events) {
133
+ this.#exitedOnce = true;
125
134
  this.#gates.openAll();
126
135
  this.#stdout.close();
127
136
  this.#stderr.close();
@@ -132,6 +141,7 @@ export class BunTarget {
132
141
  for (const pending of this.#pending.values())
133
142
  pending.reject(new Abandoned(reason));
134
143
  this.#pending.clear();
144
+ this.#interruptibleIds.clear();
135
145
  }
136
146
  // Binding messages wait until both pipes have caught up to their marker,
137
147
  // and are delivered strictly in order (as in Target).
@@ -142,10 +152,15 @@ export class BunTarget {
142
152
  this.#gates.open(msg.barrier);
143
153
  });
144
154
  }
145
- #request(op, params = {}) {
155
+ #request(op, params = {}, interruptible = false) {
156
+ // Bun is gone; a write to its channel would never be answered.
157
+ if (this.#exitedOnce)
158
+ return Promise.reject(new Abandoned("the bun session is closed"));
146
159
  const id = ++this.#nextId;
147
160
  return new Promise((resolve, reject) => {
148
161
  this.#pending.set(id, { resolve, reject });
162
+ if (interruptible)
163
+ this.#interruptibleIds.add(id);
149
164
  this.#channel.write(JSON.stringify({ id, op, ...params }) + "\n");
150
165
  });
151
166
  }
@@ -153,7 +168,7 @@ export class BunTarget {
153
168
  async #interruptible(op, params, what) {
154
169
  this.#interrupted = false;
155
170
  try {
156
- return await this.#request(op, params);
171
+ return await this.#request(op, params, true);
157
172
  }
158
173
  catch (err) {
159
174
  if (err instanceof Abandoned || this.#interrupted)
@@ -162,7 +177,7 @@ export class BunTarget {
162
177
  }
163
178
  }
164
179
  async evaluate(script) {
165
- return (await this.#interruptible("eval", { code: script }, "Execution"));
180
+ return asEvaluateResult(await this.#interruptible("eval", { code: script }, "Execution"));
166
181
  }
167
182
  /**
168
183
  * There's no terminateExecution, but a cell that's waiting on a promise
@@ -173,12 +188,19 @@ export class BunTarget {
173
188
  */
174
189
  async interrupt() {
175
190
  this.#interrupted = true;
191
+ const running = [...this.#interruptibleIds];
176
192
  const answered = await Promise.race([
177
193
  this.#request("ping").then(() => true, () => false),
178
194
  new Promise((resolve) => setTimeout(() => resolve(false), INTERRUPT_GRACE_MS)),
179
195
  ]);
180
196
  if (answered) {
181
- this.#abandonPending("interrupted");
197
+ // Only the cell or comm handler: a barrier or broadcast request in
198
+ // flight would otherwise fail as a KernelError (#135).
199
+ for (const id of running) {
200
+ this.#pending.get(id)?.reject(new Abandoned("interrupted"));
201
+ this.#pending.delete(id);
202
+ this.#interruptibleIds.delete(id);
203
+ }
182
204
  return;
183
205
  }
184
206
  this.#stopNote = RESTART_NOTE;
@@ -227,8 +249,8 @@ export class BunTarget {
227
249
  lexicalNames() {
228
250
  return Promise.resolve([]);
229
251
  }
230
- replyInput(value) {
231
- this.#child.stdin?.write(JSON.stringify({ value }) + "\n");
252
+ replyInput(value, interrupted = false) {
253
+ this.#child.stdin?.write(JSON.stringify({ value, interrupted }) + "\n");
232
254
  }
233
255
  kill() {
234
256
  this.#child.kill();
package/dist/cdp.d.ts CHANGED
@@ -29,13 +29,20 @@ export declare class CdpAbandoned extends Error {
29
29
  }
30
30
  export declare class CdpSession {
31
31
  #private;
32
- readonly closed: Promise<void>;
33
32
  private constructor();
34
33
  static connect(url: string): Promise<CdpSession>;
35
34
  on(method: string, listener: (params: CdpParams) => void): void;
36
35
  send(method: string, params?: CdpParams): Promise<CdpParams>;
36
+ /** In-flight calls, oldest first, with how long each has waited: for a state dump (#237). */
37
+ pendingCalls(): string[];
37
38
  /** Reject every in-flight call; their late responses are then ignored. */
38
39
  abandonPending(): void;
39
40
  close(): void;
40
41
  }
42
+ export declare function isRecord(value: unknown): value is CdpParams;
43
+ export declare function isRemoteObject(value: unknown): value is RemoteObject;
44
+ /**
45
+ * A Runtime.evaluate-shaped reply, checked: a value, or exceptionDetails.
46
+ * The Bun and workerd drivers send no `result` with a thrown exception.
47
+ */
41
48
  export declare function asEvaluateResult(raw: CdpParams): EvaluateResult;
package/dist/cdp.js CHANGED
@@ -21,7 +21,6 @@ export class CdpSession {
21
21
  #nextId = 0;
22
22
  #pending = new Map();
23
23
  #listeners = new Map();
24
- closed;
25
24
  constructor(ws) {
26
25
  this.#ws = ws;
27
26
  ws.addEventListener("message", (event) => {
@@ -43,14 +42,11 @@ export class CdpSession {
43
42
  listener(msg.params);
44
43
  }
45
44
  });
46
- this.closed = new Promise((resolve) => {
47
- ws.addEventListener("close", () => {
48
- for (const pending of this.#pending.values()) {
49
- pending.reject(new Error("CDP connection closed"));
50
- }
51
- this.#pending.clear();
52
- resolve();
53
- });
45
+ ws.addEventListener("close", () => {
46
+ for (const pending of this.#pending.values()) {
47
+ pending.reject(new Error("CDP connection closed"));
48
+ }
49
+ this.#pending.clear();
54
50
  });
55
51
  }
56
52
  static async connect(url) {
@@ -67,12 +63,21 @@ export class CdpSession {
67
63
  this.#listeners.set(method, list);
68
64
  }
69
65
  send(method, params = {}) {
66
+ // send() on a closing or closed socket drops the frame silently, so
67
+ // nothing would ever settle the call (#133).
68
+ if (this.#ws.readyState !== WebSocket.OPEN)
69
+ return Promise.reject(new Error("CDP connection closed"));
70
70
  const id = ++this.#nextId;
71
71
  return new Promise((resolve, reject) => {
72
- this.#pending.set(id, { method, resolve, reject });
72
+ this.#pending.set(id, { method, sent: Date.now(), resolve, reject });
73
73
  this.#ws.send(JSON.stringify({ id, method, params }));
74
74
  });
75
75
  }
76
+ /** In-flight calls, oldest first, with how long each has waited: for a state dump (#237). */
77
+ pendingCalls() {
78
+ const now = Date.now();
79
+ return [...this.#pending.values()].map((p) => `${p.method} (${now - p.sent} ms)`);
80
+ }
76
81
  /** Reject every in-flight call; their late responses are then ignored. */
77
82
  abandonPending() {
78
83
  for (const pending of this.#pending.values()) {
@@ -84,6 +89,21 @@ export class CdpSession {
84
89
  this.#ws.close();
85
90
  }
86
91
  }
92
+ export function isRecord(value) {
93
+ return typeof value === "object" && value !== null;
94
+ }
95
+ export function isRemoteObject(value) {
96
+ return isRecord(value) && typeof value.type === "string";
97
+ }
98
+ /**
99
+ * A Runtime.evaluate-shaped reply, checked: a value, or exceptionDetails.
100
+ * The Bun and workerd drivers send no `result` with a thrown exception.
101
+ */
87
102
  export function asEvaluateResult(raw) {
88
- return raw;
103
+ const details = raw.exceptionDetails;
104
+ if (details !== undefined ? !isRecord(details) || typeof details.text !== "string" : !isRemoteObject(raw.result)) {
105
+ throw new Error(`the target sent a malformed evaluate result: ${JSON.stringify(raw).slice(0, 200)}`);
106
+ }
107
+ const result = isRemoteObject(raw.result) ? raw.result : { type: "undefined" };
108
+ return details === undefined ? { result } : { result, exceptionDetails: details };
89
109
  }
@@ -1,4 +1,4 @@
1
- import type { CdpParams } from "./cdp.ts";
1
+ import { type CdpParams } from "./cdp.ts";
2
2
  import type { TargetEvents } from "./target.ts";
3
3
  import { WorkerdTarget } from "./workerd-target.ts";
4
4
  /** The project keys the generated config keeps: bindings and compatibility. */
@@ -21,8 +21,10 @@ import { createServer } from "node:net";
21
21
  import { homedir, tmpdir } from "node:os";
22
22
  import { dirname, join, resolve } from "node:path";
23
23
  import { build } from "esbuild";
24
+ import { isRecord } from "./cdp.js";
24
25
  import { parseJsonc } from "./deno-config.js";
25
26
  import { runtimeDir } from "./package.js";
27
+ import { childPids, killOnExit, reapWith } from "./children.js";
26
28
  import { cellMeta, WorkerdTarget } from "./workerd-target.js";
27
29
  import { builtinAliases, findWranglerConfig } from "./workers-project.js";
28
30
  /** The project keys the generated config keeps: bindings and compatibility. */
@@ -120,16 +122,10 @@ function channelOf(ws) {
120
122
  },
121
123
  };
122
124
  }
123
- /** celld dev's own children, which run in their own process groups. */
124
- function childPids(pid) {
125
- try {
126
- return execFileSync("pgrep", ["-P", String(pid)], { encoding: "utf8" }).split(/\s+/).filter(Boolean).map(Number);
127
- }
128
- catch {
129
- return []; // none
130
- }
131
- }
132
- /** Kills celld dev's node, then the supervisor itself. */
125
+ /**
126
+ * Kills celld dev's own children, which run in their own process groups,
127
+ * then the supervisor itself.
128
+ */
133
129
  function killTree(child) {
134
130
  for (const pid of childPids(child.pid ?? -1)) {
135
131
  try {
@@ -141,6 +137,8 @@ function killTree(child) {
141
137
  }
142
138
  child.kill("SIGKILL");
143
139
  }
140
+ /** Starts tried when a port is taken (see CelldHost.start). */
141
+ const PORT_ATTEMPTS = 3;
144
142
  /** The driver on `celld dev`. */
145
143
  class CelldHost {
146
144
  runtime;
@@ -153,7 +151,23 @@ class CelldHost {
153
151
  this.runtime = runtime;
154
152
  this.project = project;
155
153
  }
154
+ /**
155
+ * celld dev, retried when a port is taken: both its --port (from
156
+ * freePort) and its node's own listener are picked free and bound later,
157
+ * so another process can take one in between (#54).
158
+ */
156
159
  static async start(cwd, projectConfig, executable) {
160
+ for (let attempt = 1;; attempt++) {
161
+ try {
162
+ return await CelldHost.#start(cwd, projectConfig, executable);
163
+ }
164
+ catch (err) {
165
+ if (attempt >= PORT_ATTEMPTS || !(err instanceof Error && /Address already in use/.test(err.message)))
166
+ throw err;
167
+ }
168
+ }
169
+ }
170
+ static async #start(cwd, projectConfig, executable) {
157
171
  const celld = executable ?? findCelld();
158
172
  const version = celldVersionOf(execFileSync(celld, ["--version"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }));
159
173
  const source = await runtimeDir(new URL("../runtime/", import.meta.url).href);
@@ -163,7 +177,7 @@ class CelldHost {
163
177
  if (projectConfig.endsWith(".toml"))
164
178
  throw new Error("celld reads wrangler.jsonc or wrangler.json, not wrangler.toml");
165
179
  const parsed = parseJsonc(readFileSync(projectConfig, "utf8"));
166
- if (typeof parsed !== "object" || parsed === null)
180
+ if (!isRecord(parsed) || Array.isArray(parsed))
167
181
  throw new Error(`${projectConfig} is not an object`);
168
182
  project = parsed;
169
183
  dir = join(dirname(projectConfig), ".celld", "cdp-kernel");
@@ -173,9 +187,14 @@ class CelldHost {
173
187
  dir = mkdtempSync(join(tmpdir(), "cdpk-celld-"));
174
188
  }
175
189
  const main = project && typeof project.main === "string" ? resolve(dirname(projectConfig ?? cwd), project.main) : undefined;
176
- const define = project && typeof project.define === "object" && project.define !== null
177
- ? project.define
178
- : {};
190
+ const define = {};
191
+ if (project && isRecord(project.define)) {
192
+ for (const [name, value] of Object.entries(project.define)) {
193
+ if (typeof value !== "string")
194
+ throw new Error(`define.${name} in ${projectConfig} must be a string`);
195
+ define[name] = value;
196
+ }
197
+ }
179
198
  const token = randomBytes(32).toString("hex");
180
199
  // 0600: the config carries this launch's token.
181
200
  writeFileSync(join(dir, "driver.mjs"), await bundleDriver(source, main, define));
@@ -186,25 +205,35 @@ class CelldHost {
186
205
  const child = spawn(celld, ["dev", dir, "--host", "127.0.0.1", "--port", String(port), "--no-watch"], {
187
206
  stdio: ["ignore", "pipe", "pipe"],
188
207
  });
189
- child.once("exit", () => {
208
+ const removeFiles = () => {
190
209
  if (projectConfig === null)
191
210
  rmSync(dir, { recursive: true, force: true });
192
211
  else
193
212
  for (const name of ["driver.mjs", "wrangler.jsonc"])
194
213
  rmSync(join(dir, name), { force: true });
195
- });
214
+ };
215
+ child.once("exit", removeFiles);
216
+ killOnExit(child, () => {
217
+ killTree(child);
218
+ removeFiles();
219
+ }, { dir: projectConfig === null ? dir : undefined });
196
220
  // Cell output is captured in the driver; celld's own lines go to the
197
221
  // server log.
222
+ // `early` keeps that output for a startup error only, and stops growing
223
+ // once the session is open.
198
224
  let early = "";
225
+ let starting = true;
199
226
  const log = (chunk) => {
200
- early += chunk.toString();
227
+ if (starting)
228
+ early += chunk.toString();
201
229
  process.stderr.write(`[cdp-kernel celld] ${chunk}`);
202
230
  };
203
231
  child.stdout?.on("data", log);
204
232
  child.stderr?.on("data", log);
205
233
  const exited = new Promise((_, reject) => {
206
234
  child.once("error", reject);
207
- child.once("exit", (code) => reject(new Error(`celld exited (${code}) before it was ready:\n${early}`)));
235
+ // On close, not exit, so its last output is in the message.
236
+ child.once("close", (code) => reject(new Error(`celld exited (${code}) before it was ready:\n${early}`)));
208
237
  });
209
238
  exited.catch(() => { });
210
239
  const url = `127.0.0.1:${port}/?token=${token}`;
@@ -226,7 +255,13 @@ class CelldHost {
226
255
  return channelOf(ws);
227
256
  })();
228
257
  try {
229
- return new CelldHost(child, await Promise.race([ready, exited]), `celld ${version}`.trim(), projectConfig ?? undefined);
258
+ const host = new CelldHost(child, await Promise.race([ready, exited]), `celld ${version}`.trim(), projectConfig ?? undefined);
259
+ // Its server, in its own process group, outlives a SIGKILLed celld dev.
260
+ for (const pid of childPids(child.pid ?? -1))
261
+ reapWith(pid, child);
262
+ starting = false;
263
+ early = "";
264
+ return host;
230
265
  }
231
266
  catch (err) {
232
267
  killTree(child);
@@ -0,0 +1,29 @@
1
+ import { type ChildProcess } from "node:child_process";
2
+ /**
3
+ * The helper's script. Lines on stdin: `+pid` registers a pid, `-pid`
4
+ * forgets it (it exited, and the pid may be reused), `d<path>` registers a
5
+ * directory. In its own session, so a killpg of this process's group (as
6
+ * Jupyter's kill does) doesn't take it down first.
7
+ */
8
+ export declare const REAPER_SCRIPT = "trap '' HUP INT\npids=\" \"\ndirs=\"\"\nwhile IFS= read -r line; do\n case $line in\n +*) pids=\"$pids${line#?} \" ;;\n -*) pids=$(printf '%s' \"$pids\" | sed \"s/ ${line#?} / /\") ;;\n d*) dirs=\"$dirs${line#?}\n\" ;;\n esac\ndone\nkill -9 $pids 2>/dev/null\nprintf '%s' \"$dirs\" | while IFS= read -r dir; do rm -rf -- \"$dir\"; done\n";
9
+ /**
10
+ * Kills `pid` if this process dies, until the returned function is called
11
+ * (when the process is known to have exited: its pid may be reused).
12
+ */
13
+ export declare function reap(pid: number): () => void;
14
+ /**
15
+ * Kills `pid`, which isn't this process's child (a runtime's own child in
16
+ * its own process group), if this process dies while `owner` runs.
17
+ */
18
+ export declare function reapWith(pid: number, owner: ChildProcess): void;
19
+ /** `pid`'s children whose command line matches `pattern` (pgrep's -f). */
20
+ export declare function childPids(pid: number, pattern?: string): number[];
21
+ /**
22
+ * Kills `child` if it's still running when this process exits. `cleanup`
23
+ * replaces the default SIGKILL; it must be synchronous, as exit hooks are,
24
+ * and do whatever the child's own exit listener would have done. If this
25
+ * process is SIGKILLed, the helper kills `child` and removes `dir`.
26
+ */
27
+ export declare function killOnExit(child: ChildProcess, cleanup?: () => void, { dir }?: {
28
+ dir?: string;
29
+ }): void;
@@ -0,0 +1,114 @@
1
+ // Runtimes this process started, killed when it exits however it exits
2
+ // short of SIGKILL: a target left behind keeps running, reparented to pid 1,
3
+ // with whatever temp directory it uses (#127, #218).
4
+ //
5
+ // SIGKILL runs no exit hook, so a helper outside this process also knows the
6
+ // runtimes' pids (#240): a detached sh reading this process's pipe, which
7
+ // kills whatever is still registered, and removes the registered temp
8
+ // directories, once the pipe closes. That covers what nothing in a runtime
9
+ // can notice: celld dev and its server, and a Node or Deno runtime stuck in
10
+ // a cell's synchronous loop, where the #219 watchdog never gets to run.
11
+ import process from "node:process";
12
+ import { execFileSync, spawn } from "node:child_process";
13
+ const cleanups = new Set();
14
+ function runAll() {
15
+ for (const cleanup of cleanups) {
16
+ try {
17
+ cleanup();
18
+ }
19
+ catch {
20
+ // Best effort: the process is exiting.
21
+ }
22
+ }
23
+ cleanups.clear();
24
+ }
25
+ let hooked = false;
26
+ /**
27
+ * The helper's script. Lines on stdin: `+pid` registers a pid, `-pid`
28
+ * forgets it (it exited, and the pid may be reused), `d<path>` registers a
29
+ * directory. In its own session, so a killpg of this process's group (as
30
+ * Jupyter's kill does) doesn't take it down first.
31
+ */
32
+ export const REAPER_SCRIPT = `trap '' HUP INT
33
+ pids=" "
34
+ dirs=""
35
+ while IFS= read -r line; do
36
+ case $line in
37
+ +*) pids="$pids\${line#?} " ;;
38
+ -*) pids=$(printf '%s' "$pids" | sed "s/ \${line#?} / /") ;;
39
+ d*) dirs="$dirs\${line#?}
40
+ " ;;
41
+ esac
42
+ done
43
+ kill -9 $pids 2>/dev/null
44
+ printf '%s' "$dirs" | while IFS= read -r dir; do rm -rf -- "$dir"; done
45
+ `;
46
+ let reaper;
47
+ /** Sends `line` to the helper, starting it first; a no-op on Windows. */
48
+ function tellReaper(line) {
49
+ if (reaper === undefined) {
50
+ reaper = null;
51
+ if (process.platform === "win32")
52
+ return;
53
+ try {
54
+ const child = spawn("/bin/sh", ["-c", REAPER_SCRIPT], { cwd: "/", detached: true, stdio: ["pipe", "ignore", "ignore"] });
55
+ child.once("error", () => (reaper = null));
56
+ child.stdin?.on("error", () => (reaper = null));
57
+ // Neither the helper nor its pipe keeps this process running.
58
+ child.unref();
59
+ child.stdin?.unref?.();
60
+ reaper = child;
61
+ }
62
+ catch {
63
+ return;
64
+ }
65
+ }
66
+ reaper?.stdin?.write(`${line}\n`);
67
+ }
68
+ /**
69
+ * Kills `pid` if this process dies, until the returned function is called
70
+ * (when the process is known to have exited: its pid may be reused).
71
+ */
72
+ export function reap(pid) {
73
+ tellReaper(`+${pid}`);
74
+ return () => tellReaper(`-${pid}`);
75
+ }
76
+ /**
77
+ * Kills `pid`, which isn't this process's child (a runtime's own child in
78
+ * its own process group), if this process dies while `owner` runs.
79
+ */
80
+ export function reapWith(pid, owner) {
81
+ if (owner.exitCode !== null || owner.signalCode !== null)
82
+ return;
83
+ owner.once("exit", reap(pid));
84
+ }
85
+ /** `pid`'s children whose command line matches `pattern` (pgrep's -f). */
86
+ export function childPids(pid, pattern) {
87
+ try {
88
+ const args = ["-P", String(pid), ...(pattern === undefined ? [] : ["-f", pattern])];
89
+ return execFileSync("pgrep", args, { encoding: "utf8" }).split(/\s+/).filter(Boolean).map(Number);
90
+ }
91
+ catch {
92
+ return []; // none, or no pgrep
93
+ }
94
+ }
95
+ /**
96
+ * Kills `child` if it's still running when this process exits. `cleanup`
97
+ * replaces the default SIGKILL; it must be synchronous, as exit hooks are,
98
+ * and do whatever the child's own exit listener would have done. If this
99
+ * process is SIGKILLed, the helper kills `child` and removes `dir`.
100
+ */
101
+ export function killOnExit(child, cleanup = () => child.kill("SIGKILL"), { dir } = {}) {
102
+ if (!hooked) {
103
+ hooked = true;
104
+ process.on("exit", runAll);
105
+ }
106
+ if (child.exitCode !== null || child.signalCode !== null)
107
+ return;
108
+ cleanups.add(cleanup);
109
+ child.once("exit", () => cleanups.delete(cleanup));
110
+ if (dir !== undefined)
111
+ tellReaper(`d${dir}`);
112
+ if (child.pid !== undefined)
113
+ reapWith(child.pid, child);
114
+ }
@@ -1,4 +1,4 @@
1
- import type { CdpParams, RemoteObject } from "./cdp.ts";
1
+ import { type CdpParams, type RemoteObject } from "./cdp.ts";
2
2
  import { type CellTransformOptions } from "./transform.ts";
3
3
  /** What the debugger needs from a CDP target: its session, with the Debugger domain. */
4
4
  export interface DebugChannel {
package/dist/debugger.js CHANGED
@@ -8,13 +8,14 @@
8
8
  // as their //# sourceURL, so breakpoints bind and frames point at the cell.
9
9
  // Lines go through the cell's source map (src/sourcemap.ts), since esbuild
10
10
  // rewrites TypeScript.
11
- import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
11
+ import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
12
12
  import { tmpdir } from "node:os";
13
13
  import { join, sep } from "node:path";
14
14
  import process from "node:process";
15
15
  import { fileURLToPath } from "node:url";
16
+ import { isRecord, isRemoteObject } from "./cdp.js";
16
17
  import { murmur2 } from "./murmur2.js";
17
- import { transformCellWithLines } from "./transform.js";
18
+ import { SETTLE, transformCellWithLines } from "./transform.js";
18
19
  /** ipykernel's seed, so a kernel switch doesn't move JupyterLab's breakpoints. */
19
20
  export const HASH_SEED = 0xc70f6907;
20
21
  export const THREAD_ID = 1;
@@ -35,9 +36,6 @@ const SCOPE_NAMES = {
35
36
  eval: "Eval",
36
37
  global: "Globals",
37
38
  };
38
- function isRecord(value) {
39
- return typeof value === "object" && value !== null;
40
- }
41
39
  /**
42
40
  * CDPK_DEBUG_TRACE=1 logs every debug request, reply and event to stderr
43
41
  * (the Jupyter server's log), for diagnosing a frontend.
@@ -48,8 +46,11 @@ function trace(kind, ...parts) {
48
46
  const text = parts.map((p) => JSON.stringify(p)).join(" -> ");
49
47
  process.stderr.write(`[cdp-kernel debugger] ${kind} ${text.length > 600 ? `${text.slice(0, 600)}…` : text}\n`);
50
48
  }
51
- function isRemoteObject(value) {
52
- return isRecord(value) && typeof value.type === "string";
49
+ function isCallFrame(value) {
50
+ return isRecord(value) && typeof value.callFrameId === "string" && typeof value.functionName === "string" &&
51
+ isRecord(value.location) && typeof value.location.scriptId === "string" &&
52
+ typeof value.location.lineNumber === "number" && Array.isArray(value.scopeChain) &&
53
+ value.scopeChain.every((scope) => isRecord(scope) && typeof scope.type === "string" && isRemoteObject(scope.object));
53
54
  }
54
55
  /** A DAP value string for a CDP RemoteObject. */
55
56
  export function describeRemote(object) {
@@ -88,6 +89,7 @@ export class Debugger {
88
89
  #pauseWaiters = [];
89
90
  #emit;
90
91
  #transformOptions;
92
+ #cleanupRegistered = false;
91
93
  constructor(emit, transformOptions = () => ({})) {
92
94
  this.#emit = emit;
93
95
  this.#transformOptions = transformOptions;
@@ -330,7 +332,7 @@ export class Debugger {
330
332
  }
331
333
  // --- stopped state ----------------------------------------------------------
332
334
  #onPaused(params) {
333
- this.#frames = Array.isArray(params.callFrames) ? params.callFrames : [];
335
+ this.#frames = Array.isArray(params.callFrames) ? params.callFrames.filter(isCallFrame) : [];
334
336
  this.#stopped = true;
335
337
  for (const notify of this.#pauseWaiters.splice(0))
336
338
  notify();
@@ -453,7 +455,6 @@ export class Debugger {
453
455
  const res = await this.#unlessPaused(channel.send("Runtime.evaluate", {
454
456
  expression: name,
455
457
  contextId: channel.contextId,
456
- replMode: true,
457
458
  throwOnSideEffect: true,
458
459
  }));
459
460
  if (res === null)
@@ -470,9 +471,14 @@ export class Debugger {
470
471
  throw new Error("no target to debug");
471
472
  // While paused, only a frame can evaluate: use the top one if none was named.
472
473
  const frame = typeof frameId === "number" ? this.#frames[frameId] : this.#stopped ? this.#frames[0] : undefined;
474
+ // replMode (for the console's `await`) returns a promise the inspector
475
+ // holds only weakly: SETTLE closes that window, as for cells (#55). A
476
+ // side-effect-free lookup can't await, so it uses a plain evaluate.
473
477
  const res = frame
474
478
  ? await channel.send("Debugger.evaluateOnCallFrame", { callFrameId: frame.callFrameId, expression, throwOnSideEffect: pure })
475
- : await channel.send("Runtime.evaluate", { expression, contextId: channel.contextId, replMode: true, throwOnSideEffect: pure });
479
+ : pure
480
+ ? await channel.send("Runtime.evaluate", { expression, contextId: channel.contextId, throwOnSideEffect: true })
481
+ : await channel.send("Runtime.evaluate", { expression: expression + SETTLE, contextId: channel.contextId, replMode: true });
476
482
  if (isRecord(res.exceptionDetails)) {
477
483
  const exception = res.exceptionDetails.exception;
478
484
  throw new Error(exception ? describeRemote(exception) : String(res.exceptionDetails.text ?? "evaluation failed"));
@@ -519,6 +525,11 @@ export class Debugger {
519
525
  }
520
526
  #writeCell(path, code) {
521
527
  try {
528
+ if (!this.#cleanupRegistered) {
529
+ // The files only serve this kernel's frontend: remove them with it.
530
+ process.once("exit", () => rmSync(this.prefix, { recursive: true, force: true }));
531
+ this.#cleanupRegistered = true;
532
+ }
522
533
  mkdirSync(this.prefix, { recursive: true });
523
534
  writeFileSync(path, code);
524
535
  }
@@ -0,0 +1,30 @@
1
+ export type DenoResolution = {
2
+ kind: "npm";
3
+ dir: string;
4
+ subpath: string;
5
+ } | {
6
+ kind: "remote";
7
+ path: string;
8
+ };
9
+ export type RunDeno = (args: string[]) => {
10
+ status: number | null;
11
+ stdout: string;
12
+ };
13
+ export declare class DenoResolver {
14
+ #private;
15
+ /** Where remote modules appear to the language service. */
16
+ readonly root: string;
17
+ constructor({ deno, config, root }: {
18
+ deno?: string;
19
+ config?: string | null;
20
+ root: string;
21
+ }, run?: RunDeno);
22
+ /** The virtual path for a remote module's URL, e.g. <root>/https/jsr.io/@std/path/1.1.6/mod.ts. */
23
+ pathFor(url: string): string | null;
24
+ /** The cached file behind a virtual path, if a lookup has found it. */
25
+ localFile(path: string): string | undefined;
26
+ /** Where `specifier` resolves for Deno, from what's already in its cache. */
27
+ lookup(specifier: string): DenoResolution | null;
28
+ }
29
+ /** `deno types`: declarations for the Deno namespace and its web globals. */
30
+ export declare function denoTypes(run?: RunDeno): string | null;