@tlockney/cdp-kernel 0.7.2 → 0.8.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.
@@ -30,6 +30,7 @@ import { runtimeDir } from "./package.js";
30
30
  import { killOnExit } from "./children.js";
31
31
  import { Interrupted, isTargetMessage, parseBundle, parseJson } from "./target.js";
32
32
  import { withStartTimeout } from "./target-api.js";
33
+ import { processesUsage } from "./usage.js";
33
34
  import { connectUnixWebSocket } from "./ws-client.js";
34
35
  import { findWranglerConfig, startMiniflare } from "./workers-project.js";
35
36
  import { ensureInstalledAsync, findLockFile, jsrToNpm, parsePackageSpecifier } from "../runtime/node-hooks.mjs";
@@ -309,6 +310,9 @@ export class RawWorkerd {
309
310
  runtime;
310
311
  channel;
311
312
  #child;
313
+ get pids() {
314
+ return this.#child.pid === undefined ? [] : [this.#child.pid];
315
+ }
312
316
  constructor(child, channel, runtime) {
313
317
  this.#child = child;
314
318
  this.channel = channel;
@@ -409,9 +413,13 @@ export class WorkerdTarget {
409
413
  /** Requests that interrupt() abandons when the driver can still answer. */
410
414
  #interruptible = new Set();
411
415
  #nextId = 0;
412
- #interrupted = false;
416
+ /** Counts interrupt() calls: a call that saw one start during it was interrupted (#417). */
417
+ #interrupts = 0;
418
+ /** Cells running: a comm handler that starts with none gets its own jupyter.signal (#421). */
419
+ #cells = 0;
413
420
  #stopNote;
414
421
  #exitedOnce = false;
422
+ #startedAt = Date.now();
415
423
  constructor(host, cwd, events, runtime) {
416
424
  this.runtime = runtime;
417
425
  this.#host = host;
@@ -530,17 +538,26 @@ export class WorkerdTarget {
530
538
  });
531
539
  }
532
540
  async #interruptibly(op, params, what) {
533
- this.#interrupted = false;
541
+ const interrupts = this.#interrupts;
534
542
  try {
535
543
  return await this.#request(op, params, true);
536
544
  }
537
545
  catch (err) {
538
- if (err instanceof Abandoned || this.#interrupted)
546
+ if (err instanceof Abandoned || this.#interrupts !== interrupts)
539
547
  throw new Interrupted(`${what} interrupted`);
540
548
  throw err;
541
549
  }
542
550
  }
543
551
  async evaluate(script) {
552
+ this.#cells++;
553
+ try {
554
+ return await this.#evaluate(script);
555
+ }
556
+ finally {
557
+ this.#cells--;
558
+ }
559
+ }
560
+ async #evaluate(script) {
544
561
  const modules = {};
545
562
  for (const specifier of importSpecifiers(script)) {
546
563
  if (BUILTIN.test(specifier) || this.#sent.has(specifier))
@@ -560,11 +577,14 @@ export class WorkerdTarget {
560
577
  /**
561
578
  * A cell that's waiting on something leaves the driver free: it answers a
562
579
  * ping, and the cell is abandoned with the session intact (its pending
563
- * work still runs, as on Bun). A busy cell can't be reached, so workerd is
564
- * stopped and restarts on the next cell.
580
+ * work still runs, as on Bun, and jupyter.signal tells it to stop). A busy
581
+ * cell can't be reached, so workerd is stopped and restarts on the next
582
+ * cell.
565
583
  */
566
584
  async interrupt() {
567
- this.#interrupted = true;
585
+ this.#interrupts++;
586
+ // Before abandoning the cell's request, which ends the count.
587
+ const cellRunning = this.#cells > 0;
568
588
  const running = [...this.#interruptible];
569
589
  const answered = await Promise.race([
570
590
  this.#request("ping").then(() => true, () => false),
@@ -576,6 +596,10 @@ export class WorkerdTarget {
576
596
  this.#pending.delete(id);
577
597
  this.#interruptible.delete(id);
578
598
  }
599
+ // After the cell is abandoned, so its reply can't end it first
600
+ // (#418). Not awaited: requests are answered in order, and an abort
601
+ // listener that never returns mustn't hold up the interrupt.
602
+ void this.#request("abort", { cell: cellRunning }).catch(() => { });
579
603
  return;
580
604
  }
581
605
  this.#stopNote = restartNote(this.runtime);
@@ -596,6 +620,7 @@ export class WorkerdTarget {
596
620
  content,
597
621
  metadata,
598
622
  buffers: buffers.map((b) => Buffer.from(b).toString("base64")),
623
+ ownSignal: this.#cells === 0,
599
624
  }, "Comm handler");
600
625
  if (isRecord(reply.error)) {
601
626
  const { name, message, stack } = reply.error;
@@ -628,6 +653,11 @@ export class WorkerdTarget {
628
653
  }
629
654
  /** Never asked for: prompt() in workerd answers null without asking. */
630
655
  replyInput() { }
656
+ /** The workerd processes' resident memory and CPU time, from the OS (#410). */
657
+ async usage() {
658
+ const pids = this.#host.pids ?? [];
659
+ return { startedAt: this.#startedAt, ...(await processesUsage(pids)) };
660
+ }
631
661
  kill() {
632
662
  // workerd's graceful shutdown waits for open connections, the session
633
663
  // included, so close it first.
@@ -132,8 +132,10 @@ class MiniflareHost {
132
132
  #exitListeners = [];
133
133
  #stopped = false;
134
134
  #forget;
135
+ pids;
135
136
  constructor(mf, channel, version, project, workerd) {
136
137
  this.#mf = mf;
138
+ this.pids = workerd;
137
139
  // Miniflare kills its workerd only on a normal exit; the helper in
138
140
  // src/children.ts kills it if this process is SIGKILLed (#241).
139
141
  this.#forget = workerd.map(reap);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tlockney/cdp-kernel",
3
- "version": "0.7.2",
3
+ "version": "0.8.0",
4
4
  "description": "Deno's Jupyter kernel, extracted to run notebooks on Node, Deno, Bun, workerd and celld",
5
5
  "license": "MIT",
6
6
  "author": "Thomas Lockney <thomas@lockney.net>",
@@ -97,7 +97,9 @@ const jupyter = createJupyter({
97
97
  const reply = JSON.parse(line);
98
98
  if (reply.interrupted === true) {
99
99
  // Interrupted while waiting for the answer: unwind the cell, as
100
- // KeyboardInterrupt does in Python's input() (#121).
100
+ // KeyboardInterrupt does in Python's input() (#121). The host
101
+ // doesn't interrupt the target then, so abort jupyter.signal here.
102
+ jupyter.abortSignal(reply.cell !== false);
101
103
  const err = new Error("Execution interrupted");
102
104
  err.name = "Interrupted";
103
105
  throw err;
@@ -217,6 +219,10 @@ globalThis.__cdpk = {
217
219
  broadcastResult: jupyter.broadcastResult,
218
220
  // execute_request's user_expressions (#340).
219
221
  formatJson: async (value) => JSON.stringify(await jupyter.namespace.format(value)),
222
+ // jupyter.signal (#418, #421): a new one as each cell starts; the host
223
+ // aborts the running ones on interrupt.
224
+ startCell: jupyter.startCell,
225
+ abortSignal: jupyter.abortSignal,
220
226
  // The debugger's rich variable viewer (#18), synchronously: it runs while
221
227
  // the target is paused. A value whose $display is async shows as text.
222
228
  richBundle(value) {
@@ -225,10 +231,10 @@ globalThis.__cdpk = {
225
231
  },
226
232
  // Frontend comm messages (#9), from the host's shell channel. Handlers
227
233
  // run after one macrotask, like cells, so interrupt can reach them.
228
- async commDispatch(msgType, content, metadata, buffers64) {
234
+ async commDispatch(msgType, content, metadata, buffers64, ownSignal) {
229
235
  await yieldTask();
230
236
  const buffers = buffers64.map((b) => Uint8Array.from(atob(b), (c) => c.charCodeAt(0)));
231
- return await jupyter.commDispatch(msgType, content, metadata, buffers);
237
+ return await jupyter.commDispatch(msgType, content, metadata, buffers, ownSignal);
232
238
  },
233
239
  // The same, for a message the host wrote to stdin (Node and Deno). A
234
240
  // value passed as a CDP call argument is compiled as source, and V8's
@@ -236,8 +242,23 @@ globalThis.__cdpk = {
236
242
  // each message's data would stay in memory (#287). Read before the first
237
243
  // await, so messages are taken in the order the host wrote them.
238
244
  commDispatchFromStdin() {
239
- const { msgType, content, metadata, buffers } = readCommRecord();
240
- return globalThis.__cdpk.commDispatch(msgType, content, metadata, buffers);
245
+ const dispatch = () => {
246
+ const { msgType, content, metadata, buffers, ownSignal } = readCommRecord();
247
+ return globalThis.__cdpk.commDispatch(msgType, content, metadata, buffers, ownSignal === true);
248
+ };
249
+ // Node's inspector can run this call in the middle of a blocking stdin
250
+ // read, e.g. a prompt's, when the message came on a kernel subshell
251
+ // (#417): reading then would take bytes from the middle of that read.
252
+ if (stdinReading) {
253
+ const resumed = new Promise((resolve) => afterStdinRead.push(resolve));
254
+ // A read is synchronous, so by the next task it has ended. If the flag
255
+ // is still set, an interrupt terminated the read before its finally.
256
+ yieldTask().then(() => {
257
+ if (stdinReading) endStdinRead();
258
+ });
259
+ return resumed.then(dispatch);
260
+ }
261
+ return dispatch();
241
262
  },
242
263
  describeError(value) {
243
264
  if (value instanceof Error) {
@@ -445,18 +466,55 @@ function readLine() {
445
466
  }
446
467
  }
447
468
 
448
- /** The next prompt reply's line, past any comm record left unread. */
469
+ /**
470
+ * Comm records read while waiting for a prompt's reply. A comm message on
471
+ * a kernel subshell (#417) can be written while a cell waits in input();
472
+ * its dispatch runs after, and takes it from here.
473
+ */
474
+ const commRecords = [];
475
+
476
+ /** Whether a read from stdin is in progress, and what waits for it to end. */
477
+ let stdinReading = false;
478
+ const afterStdinRead = [];
479
+
480
+ function readingStdin(read) {
481
+ stdinReading = true;
482
+ try {
483
+ return read();
484
+ } finally {
485
+ endStdinRead();
486
+ }
487
+ }
488
+
489
+ function endStdinRead() {
490
+ stdinReading = false;
491
+ // In order, so comm records are taken as the host wrote them.
492
+ for (const resume of afterStdinRead.splice(0)) resume();
493
+ }
494
+
495
+ /** The next prompt reply's line; comm records before it are kept. */
449
496
  function readInputReply() {
497
+ return readingStdin(readInputReplyNow);
498
+ }
499
+
500
+ function readInputReplyNow() {
450
501
  for (;;) {
451
502
  const line = readLine();
452
503
  const comm = line === null ? null : COMM_HEADER.exec(line);
453
504
  if (comm === null) return line;
454
- readExactly(Number(comm[1]));
505
+ const body = readExactly(Number(comm[1]));
506
+ if (body !== null) commRecords.push(body);
455
507
  }
456
508
  }
457
509
 
458
510
  /** The next comm message, past any prompt reply left unread. */
459
511
  function readCommRecord() {
512
+ return readingStdin(readCommRecordNow);
513
+ }
514
+
515
+ function readCommRecordNow() {
516
+ const kept = commRecords.shift();
517
+ if (kept !== undefined) return JSON.parse(new TextDecoder().decode(kept));
460
518
  for (;;) {
461
519
  const line = readLine();
462
520
  if (line === null) throw new Error("stdin closed before a comm message arrived");
@@ -164,9 +164,13 @@ function install({ base, cdn: cdnBase }) {
164
164
  broadcastResult: jupyter.broadcastResult,
165
165
  // execute_request's user_expressions (#340).
166
166
  formatJson: async (value) => JSON.stringify(await jupyter.namespace.format(value)),
167
- async commDispatch(msgType, content, metadata, buffers64) {
167
+ // jupyter.signal (#418, #421): a new one as each cell starts; the host
168
+ // aborts the running ones on interrupt.
169
+ startCell: jupyter.startCell,
170
+ abortSignal: jupyter.abortSignal,
171
+ async commDispatch(msgType, content, metadata, buffers64, ownSignal) {
168
172
  const buffers = buffers64.map((b) => Uint8Array.from(atob(b), (c) => c.charCodeAt(0)));
169
- return await jupyter.commDispatch(msgType, content, metadata, buffers);
173
+ return await jupyter.commDispatch(msgType, content, metadata, buffers, ownSignal);
170
174
  },
171
175
  describeError(value) {
172
176
  if (value instanceof Error) {
@@ -42,6 +42,12 @@ export interface JupyterNamespace {
42
42
  comm: CommApi;
43
43
  /** An anywidget-compatible widget; display the result to show it. */
44
44
  widget(options: WidgetOptions): Widget;
45
+ /**
46
+ * Aborted when the kernel interrupts this cell. Each cell gets its own,
47
+ * as does a comm handler that starts while no cell runs. Read it when
48
+ * the work starts: a later read gets the signal of whatever started last.
49
+ */
50
+ readonly signal: AbortSignal;
45
51
  }
46
52
 
47
53
  export interface Jupyter {
@@ -51,8 +57,24 @@ export interface Jupyter {
51
57
  formatNow(obj: unknown): unknown;
52
58
  confirm(message?: string): boolean;
53
59
  prompt(message?: string, defaultValue?: string, options?: { password?: boolean }): string | null;
54
- /** Routes a frontend comm message into the kernel's comms. */
55
- commDispatch: Comms["dispatch"];
60
+ /**
61
+ * Routes a frontend comm message into the kernel's comms. `ownSignal`
62
+ * (no cell is running) gives its handlers their own jupyter.signal.
63
+ */
64
+ commDispatch(
65
+ msgType: string,
66
+ content: { [key: string]: unknown },
67
+ metadata: { [key: string]: unknown },
68
+ buffers: Uint8Array[],
69
+ ownSignal?: boolean,
70
+ ): Promise<boolean>;
71
+ /** A new jupyter.signal: a cell is starting (#421). */
72
+ startCell(): void;
73
+ /**
74
+ * Aborts the running comm handlers' jupyter.signal, and the last cell's
75
+ * unless `cellRunning` is false: the kernel was interrupted.
76
+ */
77
+ abortSignal(cellRunning?: boolean): void;
56
78
  }
57
79
 
58
80
  export function escapeHTML(str: string): string;
@@ -441,8 +441,47 @@ export function createJupyter(host) {
441
441
 
442
442
  const comms = createComms((msgType, content, metadata, buffers) => host.send(msgType, content, metadata, buffers));
443
443
 
444
+ // jupyter.signal (#418): aborted when the kernel is interrupted, so cell
445
+ // code can stop waiting on what the interrupt can't reach, such as a comm
446
+ // reply. Each cell gets its own, as does a comm handler that starts while
447
+ // no cell runs; a handler during a cell shares the cell's. An interrupt
448
+ // aborts only those of the cell and handlers running (#421).
449
+ /** The last cell's controller. */
450
+ let cell = new AbortController();
451
+ /** What jupyter.signal returns: that of whatever started last. */
452
+ let current = cell;
453
+ /** Controllers of running comm handlers, with how many handlers use each. */
454
+ const handlers = new Map();
455
+ function startCell() {
456
+ current = cell = new AbortController();
457
+ }
458
+ /** `cellRunning`: the host has a cell in flight, so the last cell's signal is aborted too. */
459
+ function abortSignal(cellRunning = true) {
460
+ const reason = new DOMException("Execution interrupted", "AbortError");
461
+ if (cellRunning) cell.abort(reason);
462
+ for (const controller of handlers.keys()) controller.abort(reason);
463
+ handlers.clear();
464
+ }
465
+ async function commDispatch(msgType, content, metadata, buffers, ownSignal = false) {
466
+ // A handler during a cell shares the cell's signal, and is tracked
467
+ // apart from it: it can outlive the cell.
468
+ const controller = ownSignal ? new AbortController() : cell;
469
+ if (ownSignal) current = controller;
470
+ handlers.set(controller, (handlers.get(controller) ?? 0) + 1);
471
+ try {
472
+ return await comms.dispatch(msgType, content, metadata, buffers);
473
+ } finally {
474
+ const left = (handlers.get(controller) ?? 0) - 1;
475
+ if (left > 0) handlers.set(controller, left);
476
+ else handlers.delete(controller);
477
+ }
478
+ }
479
+
444
480
  return {
445
481
  namespace: {
482
+ get signal() {
483
+ return current.signal;
484
+ },
446
485
  broadcast,
447
486
  display,
448
487
  format,
@@ -458,6 +497,8 @@ export function createJupyter(host) {
458
497
  formatNow,
459
498
  confirm,
460
499
  prompt,
461
- commDispatch: comms.dispatch,
500
+ commDispatch,
501
+ startCell,
502
+ abortSignal,
462
503
  };
463
504
  }
@@ -2,12 +2,16 @@
2
2
  export interface EvaluatorShim {
3
3
  broadcastResult(executionCount: number, value: unknown): void | Promise<void>;
4
4
  describeError(value: unknown): string;
5
- commDispatch(msgType: string, content: object, metadata: object, buffers64: string[]): Promise<unknown>;
5
+ commDispatch(msgType: string, content: object, metadata: object, buffers64: string[], ownSignal?: boolean): Promise<unknown>;
6
6
  barrier(): number;
7
7
  /** inspect_request's text for a dotted path, or null (#10). */
8
8
  inspectPath(path: string, detail: number): string | null;
9
9
  /** Resolves a dotted path without running getters; see introspect.mjs. */
10
10
  resolvePath?(path: string): import("./introspect.mjs").Resolved;
11
+ /** A new jupyter.signal, as a cell starts (#421). */
12
+ startCell?(): void;
13
+ /** Aborts the running cell's and comm handlers' jupyter.signal, after an interrupt (#418). */
14
+ abortSignal?(cellRunning?: boolean): void;
11
15
  }
12
16
 
13
17
  /** A request from the host: an `op` plus its parameters. */
@@ -44,6 +44,7 @@ export function createHandler(shim, evaluate = (code) => (0, eval)(code)) {
44
44
  case "eval": {
45
45
  // The host uses a result's handle before its next cell.
46
46
  handles.clear();
47
+ shim.startCell?.();
47
48
  try {
48
49
  const box = await evaluate(request.code);
49
50
  return { result: box === undefined ? { type: "undefined" } : remote(box[0]) };
@@ -62,7 +63,13 @@ export function createHandler(shim, evaluate = (code) => (0, eval)(code)) {
62
63
  return { value: shim.describeError(take(request.handle)) };
63
64
  case "comm":
64
65
  try {
65
- const handled = await shim.commDispatch(request.msgType, request.content, request.metadata, request.buffers);
66
+ const handled = await shim.commDispatch(
67
+ request.msgType,
68
+ request.content,
69
+ request.metadata,
70
+ request.buffers,
71
+ request.ownSignal === true,
72
+ );
66
73
  return { handled: handled === true };
67
74
  } catch (err) {
68
75
  return { error: JSON.parse(shim.describeError(err)) };
@@ -86,6 +93,10 @@ export function createHandler(shim, evaluate = (code) => (0, eval)(code)) {
86
93
  return { text: shim.inspectPath(request.path, request.detail ?? 0) };
87
94
  case "barrier":
88
95
  return { value: shim.barrier() };
96
+ case "abort":
97
+ // After an interrupt: jupyter.signal (#418).
98
+ shim.abortSignal?.(request.cell !== false);
99
+ return {};
89
100
  default:
90
101
  throw new Error(`unknown request ${JSON.stringify(request.op)}`);
91
102
  }
@@ -143,9 +143,13 @@ function install(env) {
143
143
  broadcastResult: jupyter.broadcastResult,
144
144
  // execute_request's user_expressions (#340).
145
145
  formatJson: async (value) => JSON.stringify(await jupyter.namespace.format(value)),
146
- async commDispatch(msgType, content, metadata, buffers64) {
146
+ // jupyter.signal (#418, #421): a new one as each cell starts; the host
147
+ // aborts the running ones on interrupt.
148
+ startCell: jupyter.startCell,
149
+ abortSignal: jupyter.abortSignal,
150
+ async commDispatch(msgType, content, metadata, buffers64, ownSignal) {
147
151
  const buffers = buffers64.map((b) => Uint8Array.from(atob(b), (c) => c.charCodeAt(0)));
148
- return await jupyter.commDispatch(msgType, content, metadata, buffers);
152
+ return await jupyter.commDispatch(msgType, content, metadata, buffers, ownSignal);
149
153
  },
150
154
  describeError(value) {
151
155
  if (value instanceof Error) {