@tlockney/cdp-kernel 0.7.2 → 0.9.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/dist/target.js CHANGED
@@ -12,6 +12,7 @@ import { SHIM, transformCell } from "./transform.js";
12
12
  import { hostRuntime, isInsideNodeModules, runtimeDir } from "./package.js";
13
13
  import { killOnExit } from "./children.js";
14
14
  import { Interrupted, isTargetMessage, parseBundle, parseJson, withStartTimeout, } from "./target-api.js";
15
+ import { processUsage } from "./usage.js";
15
16
  export { Interrupted, isTargetMessage, parseBundle, parseJson };
16
17
  /**
17
18
  * The inspector keeps every object it returns a remote reference to alive
@@ -330,7 +331,10 @@ export class Target {
330
331
  #messages = Promise.resolve();
331
332
  /** Messages received but not yet delivered. */
332
333
  #undelivered = 0;
333
- #interrupted = false;
334
+ /** Counts interrupt() calls: a call that saw one start during it was interrupted (#417). */
335
+ #interrupts = 0;
336
+ /** Cells running: a comm handler that starts with none gets its own jupyter.signal (#421). */
337
+ #cells = 0;
334
338
  #paused = false;
335
339
  /** What the debugger asked to pause on (#307), and how many kernel calls hold it off. */
336
340
  #pauseOnExceptions = "none";
@@ -338,11 +342,14 @@ export class Target {
338
342
  /** Set once the main context is gone: the runtime is exiting (#130). */
339
343
  #exiting = false;
340
344
  #exited;
345
+ /** When the runtime was spawned: the OS counts its CPU time from then (#409). */
346
+ #startedAt;
341
347
  #baselineGlobals = new Set();
342
348
  versions;
343
- constructor(runtime, child, shell, control, stdout, stderr, gates, contextId, versions) {
349
+ constructor(runtime, child, shell, control, stdout, stderr, gates, contextId, versions, startedAt) {
344
350
  this.runtime = runtime;
345
351
  this.#child = child;
352
+ this.#startedAt = startedAt;
346
353
  this.#exited = new Promise((resolve) => {
347
354
  if (child.exitCode !== null || child.signalCode !== null)
348
355
  resolve();
@@ -376,6 +383,7 @@ export class Target {
376
383
  if (esbuild !== null)
377
384
  env.CDPK_ESBUILD_BINARY = esbuild;
378
385
  delete env.NO_COLOR;
386
+ const startedAt = Date.now();
379
387
  const child = spawn(cmd, args, { cwd: options.cwd, stdio: ["pipe", "pipe", "pipe"], env });
380
388
  killOnExit(child);
381
389
  // Prompt replies and comm messages go to stdin; one written as the
@@ -385,7 +393,7 @@ export class Target {
385
393
  // pipes and inspector sockets would keep this process alive (#52).
386
394
  const sessions = [];
387
395
  try {
388
- return await withStartTimeout(Target.#start(runtime, events, cmd, child, sessions), cmd, options.startTimeoutMs);
396
+ return await withStartTimeout(Target.#start(runtime, events, cmd, child, startedAt, sessions), cmd, options.startTimeoutMs);
389
397
  }
390
398
  catch (error) {
391
399
  for (const session of sessions)
@@ -394,7 +402,7 @@ export class Target {
394
402
  throw error;
395
403
  }
396
404
  }
397
- static async #start(runtime, events, cmd, child, sessions) {
405
+ static async #start(runtime, events, cmd, child, startedAt, sessions) {
398
406
  child.stdout.setEncoding("utf8");
399
407
  child.stderr.setEncoding("utf8");
400
408
  const gates = new Gates();
@@ -452,7 +460,7 @@ export class Target {
452
460
  // messages come over stdout. The control session watches for exit.
453
461
  await shell.send("Runtime.disable");
454
462
  await control.send("Runtime.enable");
455
- target = new Target(runtime, child, shell, control, stdout, stderr, gates, contextId, { runtime: "", engine: "" });
463
+ target = new Target(runtime, child, shell, control, stdout, stderr, gates, contextId, { runtime: "", engine: "" }, startedAt);
456
464
  // process.exit() or Deno.exit() destroys the context, then the runtime
457
465
  // waits for its inspector clients to disconnect before it exits. Let go,
458
466
  // so it exits and the kernel starts a fresh one (#130).
@@ -544,6 +552,26 @@ export class Target {
544
552
  this.#gates.open(msg.barrier);
545
553
  });
546
554
  }
555
+ /**
556
+ * The runtime process's resident memory and CPU time, from the OS (#409).
557
+ * Where the OS can't say, the memory is the V8 heap's size instead,
558
+ * which leaves out native memory such as Buffers; CDP has no CPU time.
559
+ */
560
+ async usage() {
561
+ const pid = this.#child.pid;
562
+ const usage = { startedAt: this.#startedAt, ...(pid === undefined ? {} : await processUsage(pid)) };
563
+ if (usage.memory === undefined) {
564
+ // Over the control session, which no cell holds; a runtime that
565
+ // doesn't answer within a second leaves the memory out.
566
+ const heap = await Promise.race([
567
+ this.#control.send("Runtime.getHeapUsage").catch(() => null),
568
+ new Promise((resolve) => setTimeout(() => resolve(null), 1000).unref()),
569
+ ]);
570
+ if (typeof heap?.totalSize === "number")
571
+ usage.memory = heap.totalSize;
572
+ }
573
+ return usage;
574
+ }
547
575
  /**
548
576
  * What the host is waiting on, whether the runtime still evaluates, runs
549
577
  * immediates and runs timers, and where its JavaScript is if it is
@@ -624,12 +652,13 @@ export class Target {
624
652
  }
625
653
  /** Runs transformed cell code. Rejects with Interrupted after interrupt(). */
626
654
  async evaluate(script) {
627
- this.#interrupted = false;
655
+ const interrupts = this.#interrupts;
656
+ this.#cells++;
628
657
  try {
629
658
  return await this.#evaluateRaw(script);
630
659
  }
631
660
  catch (err) {
632
- if (err instanceof CdpAbandoned || this.#interrupted)
661
+ if (err instanceof CdpAbandoned || this.#interrupts !== interrupts)
633
662
  throw new Interrupted("Execution interrupted");
634
663
  // The control session's executionContextDestroyed event can arrive
635
664
  // after the shell session's error reply (#271 moved it there).
@@ -643,6 +672,9 @@ export class Target {
643
672
  }
644
673
  throw err;
645
674
  }
675
+ finally {
676
+ this.#cells--;
677
+ }
646
678
  }
647
679
  async releaseResults() {
648
680
  if (this.#exiting)
@@ -666,15 +698,35 @@ export class Target {
666
698
  * CDP equivalent is Runtime.terminateExecution, sent on a second session
667
699
  * because the shell session is the one blocked on the running evaluate.
668
700
  * Terminated code never settles the awaited promise, so the pending
669
- * evaluate is abandoned here and the session stays usable.
701
+ * evaluate is abandoned here and the session stays usable; jupyter.signal
702
+ * tells code still waiting on such a promise to stop (#418).
670
703
  */
671
704
  async interrupt() {
672
- this.#interrupted = true;
705
+ this.#interrupts++;
706
+ // Before abandoning the evaluate, which ends the count.
707
+ const cellRunning = this.#cells > 0;
673
708
  // A paused isolate runs nothing to terminate: let it go first (#18).
674
709
  if (this.#paused)
675
710
  await this.#shell.send("Debugger.resume").catch(() => { });
676
711
  await this.#control.send("Runtime.terminateExecution");
677
712
  this.#shell.abandonPending();
713
+ await this.#abortSignal(cellRunning);
714
+ }
715
+ /**
716
+ * Aborts jupyter.signal (#418), once the cell is terminated: cell code
717
+ * waiting on a promise that only it can settle, such as a comm reply,
718
+ * can then stop. A termination still pending would end the first call,
719
+ * so it gets a second.
720
+ */
721
+ async #abortSignal(cellRunning) {
722
+ for (let attempt = 0; attempt < 2; attempt++) {
723
+ const res = await this.callFunctionOn(`function (cell) { globalThis.${SHIM}.abortSignal(cell); return true; }`, [
724
+ { value: cellRunning },
725
+ ])
726
+ .catch(() => ({}));
727
+ if (res.result?.value === true)
728
+ return;
729
+ }
678
730
  }
679
731
  /**
680
732
  * For synchronous functions. With awaitPromise the inspector would wrap
@@ -734,7 +786,7 @@ export class Target {
734
786
  * threw. Rejects with Interrupted after interrupt().
735
787
  */
736
788
  async dispatchComm(msgType, content, metadata, buffers) {
737
- this.#interrupted = false;
789
+ const interrupts = this.#interrupts;
738
790
  // Over stdin, not as call arguments: V8 caches an argument's source,
739
791
  // so the data would stay in the target's memory (#287).
740
792
  const record = Buffer.from(JSON.stringify({
@@ -742,6 +794,7 @@ export class Target {
742
794
  content,
743
795
  metadata,
744
796
  buffers: buffers.map((b) => Buffer.from(b).toString("base64")),
797
+ ownSignal: this.#cells === 0,
745
798
  }));
746
799
  this.#child.stdin.write(`comm ${record.length}\n`);
747
800
  this.#child.stdin.write(record);
@@ -750,7 +803,7 @@ export class Target {
750
803
  res = await this.#callAsyncFunctionOn(`function () { return globalThis.${SHIM}.commDispatchFromStdin(); }`, []);
751
804
  }
752
805
  catch (err) {
753
- if (err instanceof CdpAbandoned || this.#interrupted)
806
+ if (err instanceof CdpAbandoned || this.#interrupts !== interrupts)
754
807
  throw new Interrupted("Comm handler interrupted");
755
808
  throw err;
756
809
  }
@@ -832,7 +885,8 @@ export class Target {
832
885
  return res.names ?? [];
833
886
  }
834
887
  replyInput(value, interrupted = false) {
835
- this.#child.stdin.write(JSON.stringify({ value, interrupted }) + "\n");
888
+ // `cell`: an interrupted prompt aborts a cell's jupyter.signal only if one runs (#421).
889
+ this.#child.stdin.write(JSON.stringify({ value, interrupted, cell: this.#cells > 0 }) + "\n");
836
890
  }
837
891
  kill() {
838
892
  this.#shell.close();
@@ -51,7 +51,16 @@ export declare function isTypeStripperName(value: unknown): value is TypeStrippe
51
51
  * imported (#238).
52
52
  */
53
53
  export declare function loadTypeStripper(name: TypeStripperName): Promise<TypeStripper>;
54
- /** Rewrites module-only syntax in plain JavaScript into script syntax. */
54
+ /**
55
+ * Rewrites module-only syntax in plain JavaScript into script syntax.
56
+ *
57
+ * Top-level `const` becomes `let`, as do import bindings (#444). Under
58
+ * replMode a later evaluation may redeclare a top-level name only with the
59
+ * same kind of declaration: `const a` then `let a`, or `const a` then
60
+ * `import { a }`, fails with "already been declared" until a restart. With
61
+ * one kind, cells can be re-run and edited freely; the cost is that
62
+ * assigning to a top-level `const` from another cell doesn't throw.
63
+ */
55
64
  export declare function rewriteModuleSyntax(js: string): string;
56
65
  export declare function transformCell(code: string, options?: CellTransformOptions): string;
57
66
  /**
package/dist/transform.js CHANGED
@@ -30,7 +30,9 @@ export const SHIM = "__cdpk";
30
30
  // collected" (#329). Yielding first moves the throw into a task, where
31
31
  // SETTLE's reasoning applies. The debug console uses YIELD for this.
32
32
  export const YIELD = `await ${SHIM}.tick();\n`;
33
- const PRELUDE = `'use strict'; ${YIELD}`;
33
+ // startCell gives the cell its own jupyter.signal (#421) before any of its
34
+ // code runs.
35
+ const PRELUDE = `'use strict'; ${SHIM}.startCell(); ${YIELD}`;
34
36
  /**
35
37
  * Each replMode cell also ends by yielding one macrotask, so the script
36
38
  * settles with no other microtasks queued ahead of the inspector's
@@ -111,7 +113,9 @@ function rewriteImport(code, node, index) {
111
113
  return `${local} = ${ns}`;
112
114
  return `${local} = ${ns}[${JSON.stringify(importedName(spec))}]`;
113
115
  });
114
- return `var ${ns} = ${load}; var ${bindings.join(", ")};`;
116
+ // `let`, like top-level `const` (see toLet): replMode lets a later
117
+ // cell redeclare a name only with the same kind of declaration.
118
+ return `var ${ns} = ${load}; let ${bindings.join(", ")};`;
115
119
  }
116
120
  function parseCell(js) {
117
121
  try {
@@ -126,10 +130,26 @@ function parseCell(js) {
126
130
  throw new CellSyntaxError(err instanceof Error ? err.message : String(err));
127
131
  }
128
132
  }
129
- /** Rewrites module-only syntax in plain JavaScript into script syntax. */
133
+ /**
134
+ * Rewrites module-only syntax in plain JavaScript into script syntax.
135
+ *
136
+ * Top-level `const` becomes `let`, as do import bindings (#444). Under
137
+ * replMode a later evaluation may redeclare a top-level name only with the
138
+ * same kind of declaration: `const a` then `let a`, or `const a` then
139
+ * `import { a }`, fails with "already been declared" until a restart. With
140
+ * one kind, cells can be re-run and edited freely; the cost is that
141
+ * assigning to a top-level `const` from another cell doesn't throw.
142
+ */
130
143
  export function rewriteModuleSyntax(js) {
131
144
  const program = parseCell(js);
132
145
  const edits = [];
146
+ for (const statement of program.body) {
147
+ const decl = statement.type === "ExportNamedDeclaration" ? statement.declaration : statement;
148
+ if (decl?.type === "VariableDeclaration" && decl.kind === "const") {
149
+ // Padded to `const`'s length, so columns stay where the debugger expects them.
150
+ edits.push({ start: decl.start, end: decl.start + "const".length, text: "let " });
151
+ }
152
+ }
133
153
  let importIndex = 0;
134
154
  full(program, (node) => {
135
155
  switch (node.type) {
@@ -0,0 +1,25 @@
1
+ import type { JsonObject } from "./message.ts";
2
+ import type { KernelTarget } from "./target-api.ts";
3
+ /** A process's resident memory in bytes and its CPU time so far in µs, from the OS. */
4
+ export interface ProcessUsage {
5
+ memory?: number;
6
+ cpuTime?: number;
7
+ }
8
+ /** A process's usage on Linux (/proc) or macOS (`ps`); empty elsewhere or if it can't be read. */
9
+ export declare function processUsage(pid: number): Promise<ProcessUsage>;
10
+ /**
11
+ * Several processes' usage added up, for a target that runs in more than
12
+ * one (Miniflare's workerd). A figure any of them can't report is left out.
13
+ */
14
+ export declare function processesUsage(pids: readonly number[]): Promise<ProcessUsage>;
15
+ /** `ps`'s `time`: [[dd-]hh:]mm:ss[.cc], in seconds; null if it isn't one. */
16
+ export declare function parseCpuTime(text: string): number | null;
17
+ /**
18
+ * Builds usage_reply content. CPU percentages cover the time since the
19
+ * previous request: since the process started, for the first one.
20
+ */
21
+ export declare class UsageMeter {
22
+ #private;
23
+ /** `target` is the running target, or null while none runs. */
24
+ reply(target: KernelTarget | null): Promise<JsonObject>;
25
+ }
package/dist/usage.js ADDED
@@ -0,0 +1,197 @@
1
+ // usage_request (#409): the kernel's memory and CPU, and the host's, in
2
+ // the shape ipykernel replies with. Cells run in the target, a separate
3
+ // process, so the kernel's figures are the host process's plus the
4
+ // target's. A field this platform or target can't measure is left out.
5
+ import { execFile } from "node:child_process";
6
+ import { readFile } from "node:fs/promises";
7
+ import os from "node:os";
8
+ import process from "node:process";
9
+ /**
10
+ * Linux reports CPU times in clock ticks of USER_HZ, which the kernel's
11
+ * ABI fixes at 100 on the architectures Node and Deno run on.
12
+ */
13
+ const USER_HZ = 100;
14
+ /** How long `ps` may take: Ganymede waits 2 s for a first usage_reply (tlockney/ganymede#364). */
15
+ const PS_TIMEOUT_MS = 1_000;
16
+ /** A process's usage on Linux (/proc) or macOS (`ps`); empty elsewhere or if it can't be read. */
17
+ export async function processUsage(pid) {
18
+ try {
19
+ if (process.platform === "linux")
20
+ return await procUsage(pid);
21
+ if (process.platform === "darwin")
22
+ return await psUsage(pid);
23
+ }
24
+ catch {
25
+ // Gone, or not ours to read.
26
+ }
27
+ return {};
28
+ }
29
+ async function procUsage(pid) {
30
+ const [stat, status] = await Promise.all([
31
+ readFile(`/proc/${pid}/stat`, "utf8"),
32
+ readFile(`/proc/${pid}/status`, "utf8"),
33
+ ]);
34
+ const usage = {};
35
+ // Fields after the command name, which may itself hold spaces and ")":
36
+ // the third field (state) is fields[0], so utime and stime (14, 15) are 11, 12.
37
+ const fields = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
38
+ const ticks = Number(fields[11]) + Number(fields[12]);
39
+ if (Number.isFinite(ticks))
40
+ usage.cpuTime = ticks * (1_000_000 / USER_HZ);
41
+ const rss = /^VmRSS:\s+(\d+) kB$/m.exec(status);
42
+ if (rss !== null)
43
+ usage.memory = Number(rss[1]) * 1024;
44
+ return usage;
45
+ }
46
+ async function psUsage(pid) {
47
+ const out = await new Promise((resolve, reject) => {
48
+ execFile("ps", ["-o", "rss=,time=", "-p", String(pid)], { timeout: PS_TIMEOUT_MS }, (err, stdout) => {
49
+ if (err)
50
+ reject(err);
51
+ else
52
+ resolve(stdout);
53
+ });
54
+ });
55
+ const [rss, time] = out.trim().split(/\s+/);
56
+ const usage = {};
57
+ if (rss !== undefined && /^\d+$/.test(rss))
58
+ usage.memory = Number(rss) * 1024;
59
+ const seconds = time === undefined ? null : parseCpuTime(time);
60
+ if (seconds !== null)
61
+ usage.cpuTime = Math.round(seconds * 1_000_000);
62
+ return usage;
63
+ }
64
+ /**
65
+ * Several processes' usage added up, for a target that runs in more than
66
+ * one (Miniflare's workerd). A figure any of them can't report is left out.
67
+ */
68
+ export async function processesUsage(pids) {
69
+ const each = await Promise.all(pids.map((pid) => processUsage(pid)));
70
+ const sum = (key) => each.length > 0 && each.every((u) => u[key] !== undefined) ? each.reduce((n, u) => n + u[key], 0) : undefined;
71
+ const usage = {};
72
+ const memory = sum("memory");
73
+ const cpuTime = sum("cpuTime");
74
+ if (memory !== undefined)
75
+ usage.memory = memory;
76
+ if (cpuTime !== undefined)
77
+ usage.cpuTime = cpuTime;
78
+ return usage;
79
+ }
80
+ /** `ps`'s `time`: [[dd-]hh:]mm:ss[.cc], in seconds; null if it isn't one. */
81
+ export function parseCpuTime(text) {
82
+ const match = /^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+(?:\.\d+)?)$/.exec(text);
83
+ if (match === null)
84
+ return null;
85
+ const [, days, hours, minutes, seconds] = match;
86
+ return Number(days ?? 0) * 86_400 + Number(hours ?? 0) * 3_600 + Number(minutes) * 60 + Number(seconds);
87
+ }
88
+ /** All CPUs' idle and total time so far, in ms; null if the OS lists none. */
89
+ function hostCpuTimes() {
90
+ const cpus = os.cpus();
91
+ if (cpus.length === 0)
92
+ return null;
93
+ let idle = 0;
94
+ let total = 0;
95
+ for (const { times } of cpus) {
96
+ idle += times.idle;
97
+ total += times.user + times.nice + times.sys + times.idle + times.irq;
98
+ }
99
+ return { idle, total };
100
+ }
101
+ /** psutil.virtual_memory()'s fields that can be read here. */
102
+ async function hostMemory() {
103
+ const total = os.totalmem();
104
+ const memory = { total };
105
+ if (process.platform === "linux") {
106
+ try {
107
+ // MemAvailable is what psutil reports as available; os.freemem()
108
+ // reads the same field, so it is read here alongside MemFree.
109
+ const meminfo = await readFile("/proc/meminfo", "utf8");
110
+ const kib = (name) => {
111
+ const match = new RegExp(`^${name}:\\s+(\\d+) kB$`, "m").exec(meminfo);
112
+ return match === null ? undefined : Number(match[1]) * 1024;
113
+ };
114
+ const available = kib("MemAvailable");
115
+ const free = kib("MemFree");
116
+ if (available !== undefined) {
117
+ memory.available = available;
118
+ memory.percent = Math.round((total - available) / total * 1000) / 10;
119
+ }
120
+ if (free !== undefined)
121
+ memory.free = free;
122
+ }
123
+ catch {
124
+ // No /proc: total alone.
125
+ }
126
+ }
127
+ else {
128
+ // macOS's free pages; psutil's "available" also counts inactive ones,
129
+ // which Node doesn't report, so that is left out.
130
+ memory.free = os.freemem();
131
+ }
132
+ return memory;
133
+ }
134
+ /**
135
+ * Builds usage_reply content. CPU percentages cover the time since the
136
+ * previous request: since the process started, for the first one.
137
+ */
138
+ export class UsageMeter {
139
+ /** The host process's CPU time when last asked, and when that was (ms). */
140
+ #host = { cpuTime: 0, at: Date.now() - process.uptime() * 1000 };
141
+ /** The target's CPU time when last measured, keyed by when it started (not the target, which may be gone). */
142
+ #target = null;
143
+ #hostCpu = hostCpuTimes();
144
+ /** `target` is the running target, or null while none runs. */
145
+ async reply(target) {
146
+ const [targetUsage, virtualMemory] = await Promise.all([
147
+ target?.usage ? target.usage().catch(() => ({})) : null,
148
+ hostMemory(),
149
+ ]);
150
+ const now = Date.now();
151
+ const content = { hostname: os.hostname(), pid: process.pid };
152
+ // The host process, then the target's process: no target running
153
+ // means the kernel is only the host. A target that can't be measured
154
+ // leaves the figure out rather than reporting the host's alone.
155
+ const { user, system } = process.cpuUsage();
156
+ const hostCpu = user + system;
157
+ let cpu = percentOf(hostCpu - this.#host.cpuTime, now - this.#host.at);
158
+ this.#host = { cpuTime: hostCpu, at: now };
159
+ let memory = process.memoryUsage().rss;
160
+ if (target !== null) {
161
+ if (targetUsage?.cpuTime === undefined) {
162
+ // The previous sample stays the baseline, so one missed sample
163
+ // doesn't make the next reply average over the target's life.
164
+ cpu = null;
165
+ }
166
+ else {
167
+ const { startedAt } = targetUsage;
168
+ const last = startedAt !== undefined && this.#target?.startedAt === startedAt
169
+ ? this.#target
170
+ : { cpuTime: 0, at: startedAt ?? now };
171
+ cpu += percentOf(targetUsage.cpuTime - last.cpuTime, now - last.at);
172
+ this.#target = { startedAt, cpuTime: targetUsage.cpuTime, at: now };
173
+ }
174
+ memory = targetUsage?.memory === undefined ? null : memory + targetUsage.memory;
175
+ }
176
+ if (cpu !== null)
177
+ content.kernel_cpu = Math.round(cpu * 10) / 10;
178
+ if (memory !== null)
179
+ content.kernel_memory = memory;
180
+ const times = hostCpuTimes();
181
+ if (times !== null && this.#hostCpu !== null) {
182
+ const total = times.total - this.#hostCpu.total;
183
+ if (total > 0)
184
+ content.host_cpu_percent = Math.round((1 - (times.idle - this.#hostCpu.idle) / total) * 1000) / 10;
185
+ }
186
+ this.#hostCpu = times;
187
+ const cpuCount = os.cpus().length;
188
+ if (cpuCount > 0)
189
+ content.cpu_count = cpuCount;
190
+ content.host_virtual_memory = virtualMemory;
191
+ return content;
192
+ }
193
+ }
194
+ /** CPU time (µs) over wall time (ms), as a percentage of one CPU. */
195
+ function percentOf(cpuMicros, wallMs) {
196
+ return wallMs > 0 ? Math.max(0, cpuMicros / (wallMs * 1000) * 100) : 0;
197
+ }
@@ -1,6 +1,7 @@
1
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
+ import { type TargetUsage } from "./target-api.ts";
4
5
  import { type WsChannel } from "./ws-client.ts";
5
6
  /** The driver Worker's modules; the first is its main module. */
6
7
  export declare const WORKER_MODULES: string[];
@@ -59,6 +60,12 @@ export interface WorkerHost {
59
60
  readonly project?: string;
60
61
  /** The open session with the driver. */
61
62
  readonly channel: WsChannel;
63
+ /**
64
+ * The workerd processes that run the driver, for usage_request (#410).
65
+ * Left out where they aren't ours to measure (celld), and the reply
66
+ * then leaves the kernel's memory and CPU out.
67
+ */
68
+ readonly pids?: readonly number[];
62
69
  /** Stops the runtime; `now` even while a cell is busy. */
63
70
  stop(now: boolean): void;
64
71
  onExit(listener: (code: number | null) => void): void;
@@ -74,6 +81,7 @@ export declare class RawWorkerd implements WorkerHost {
74
81
  #private;
75
82
  readonly runtime: string;
76
83
  readonly channel: WsChannel;
84
+ get pids(): readonly number[];
77
85
  private constructor();
78
86
  static start(cwd: string, executable?: string): Promise<RawWorkerd>;
79
87
  stop(now: boolean): void;
@@ -113,8 +121,9 @@ export declare class WorkerdTarget implements KernelTarget {
113
121
  /**
114
122
  * A cell that's waiting on something leaves the driver free: it answers a
115
123
  * ping, and the cell is abandoned with the session intact (its pending
116
- * work still runs, as on Bun). A busy cell can't be reached, so workerd is
117
- * stopped and restarts on the next cell.
124
+ * work still runs, as on Bun, and jupyter.signal tells it to stop). A busy
125
+ * cell can't be reached, so workerd is stopped and restarts on the next
126
+ * cell.
118
127
  */
119
128
  interrupt(): Promise<void>;
120
129
  /** Output arrives on the session before the reply it precedes. */
@@ -142,5 +151,7 @@ export declare class WorkerdTarget implements KernelTarget {
142
151
  lexicalNames(): Promise<string[]>;
143
152
  /** Never asked for: prompt() in workerd answers null without asking. */
144
153
  replyInput(): void;
154
+ /** The workerd processes' resident memory and CPU time, from the OS (#410). */
155
+ usage(): Promise<TargetUsage>;
145
156
  kill(): void;
146
157
  }
@@ -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.9.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>",