@tlockney/cdp-kernel 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/wire.js CHANGED
@@ -41,7 +41,7 @@ export function makeHeader(session, msgType) {
41
41
  }
42
42
  export function encodeMessage(session, key, msgType, content, options = {}) {
43
43
  const parts = [
44
- makeHeader(session, msgType),
44
+ options.header ?? makeHeader(session, msgType),
45
45
  options.parentHeader ?? {},
46
46
  options.metadata ?? {},
47
47
  content,
@@ -70,6 +70,8 @@ function parseObject(frame) {
70
70
  }
71
71
  return value;
72
72
  }
73
+ // Only msg_id and msg_type are required of a received header; nothing
74
+ // reads the rest, so a client that leaves them out still works.
73
75
  function isHeader(value) {
74
76
  return typeof value.msg_id === "string" && typeof value.msg_type === "string";
75
77
  }
@@ -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
  import { type WsChannel } from "./ws-client.ts";
@@ -53,6 +53,12 @@ export interface WorkerHost {
53
53
  stop(now: boolean): void;
54
54
  onExit(listener: (code: number | null) => void): void;
55
55
  }
56
+ /**
57
+ * Removes workerd directories under `root` whose kernel has died without
58
+ * cleaning up (SIGKILL runs no exit hook), killing their workerd if it's
59
+ * still running (#220).
60
+ */
61
+ export declare function sweepStaleWorkerdDirs(root?: string): void;
56
62
  /** The driver on a workerd of its own, behind a Unix socket. */
57
63
  export declare class RawWorkerd implements WorkerHost {
58
64
  #private;
@@ -18,18 +18,20 @@ import { Buffer } from "node:buffer";
18
18
  import process from "node:process";
19
19
  import { execFileSync, spawn } from "node:child_process";
20
20
  import { randomBytes } from "node:crypto";
21
- import { copyFileSync, existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
21
+ import { copyFileSync, existsSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, rmSync, writeFileSync } from "node:fs";
22
22
  import { builtinModules } from "node:module";
23
23
  import { tmpdir } from "node:os";
24
24
  import { dirname, join } from "node:path";
25
25
  import { pathToFileURL } from "node:url";
26
26
  import { build } from "esbuild";
27
+ import { asEvaluateResult } from "./cdp.js";
27
28
  import { transformCellForEval } from "./transform.js";
28
29
  import { runtimeDir } from "./package.js";
30
+ import { killOnExit } from "./children.js";
29
31
  import { Interrupted, isTargetMessage } from "./target.js";
30
32
  import { connectUnixWebSocket } from "./ws-client.js";
31
33
  import { findWranglerConfig, startMiniflare } from "./workers-project.js";
32
- import { ensureInstalled, findLockFile, jsrToNpm, parsePackageSpecifier } from "../runtime/node-hooks.mjs";
34
+ import { ensureInstalledAsync, findLockFile, jsrToNpm, parsePackageSpecifier } from "../runtime/node-hooks.mjs";
33
35
  /** The driver Worker's modules; the first is its main module. */
34
36
  export const WORKER_MODULES = ["workerd-worker.mjs", "display.mjs", "comm.mjs", "widget.mjs", "evaluator.mjs", "introspect.mjs"];
35
37
  /** See BunTarget: how long an interrupted cell has to show it isn't busy. */
@@ -121,7 +123,8 @@ export async function bundleModule(specifier, cwd) {
121
123
  ? jsrToNpm(specifier.slice(4))
122
124
  : null;
123
125
  if (pkg) {
124
- const { dir, alias } = ensureInstalled(pkg, "npm", { lockFile: findLockFile(cwd) });
126
+ // Async: a sync install would stall the heartbeat and interrupts (#149).
127
+ const { dir, alias } = await ensureInstalledAsync(pkg, "npm", { lockFile: findLockFile(cwd) });
125
128
  entry = alias + pkg.subpath;
126
129
  resolveDir = dir;
127
130
  }
@@ -168,6 +171,65 @@ export function cellMeta(cwd) {
168
171
  const replUrl = new URL("$cdpk$repl.mjs", pathToFileURL(cwd.endsWith("/") ? cwd : cwd + "/"));
169
172
  return { url: replUrl.href, dirname: cwd, filename: join(cwd, "$cdpk$repl.mjs") };
170
173
  }
174
+ const WORKERD_DIR_PREFIX = "cdpk-workerd-";
175
+ /** In each workerd directory: `{ host, workerd }` pids. */
176
+ const OWNER_FILE = "owner.json";
177
+ function pidAlive(pid) {
178
+ try {
179
+ process.kill(pid, 0);
180
+ return true;
181
+ }
182
+ catch (err) {
183
+ return err.code === "EPERM";
184
+ }
185
+ }
186
+ /** Whether `pid` is still the workerd serving `dir`, not a reused pid. */
187
+ function isWorkerdIn(pid, dir) {
188
+ try {
189
+ if (process.platform === "linux")
190
+ return readlinkSync(`/proc/${pid}/cwd`) === dir;
191
+ const args = execFileSync("ps", ["-o", "args=", "-p", String(pid)], { encoding: "utf8" });
192
+ return /workerd/.test(args) && args.includes("config.capnp");
193
+ }
194
+ catch {
195
+ return false;
196
+ }
197
+ }
198
+ /**
199
+ * Removes workerd directories under `root` whose kernel has died without
200
+ * cleaning up (SIGKILL runs no exit hook), killing their workerd if it's
201
+ * still running (#220).
202
+ */
203
+ export function sweepStaleWorkerdDirs(root = tmpdir()) {
204
+ let names;
205
+ try {
206
+ names = readdirSync(root).filter((name) => name.startsWith(WORKERD_DIR_PREFIX));
207
+ }
208
+ catch {
209
+ return;
210
+ }
211
+ for (const name of names) {
212
+ const dir = join(root, name);
213
+ let owner;
214
+ try {
215
+ owner = JSON.parse(readFileSync(join(dir, OWNER_FILE), "utf8"));
216
+ }
217
+ catch {
218
+ continue; // Another user's, from before owner files, or being set up.
219
+ }
220
+ if (typeof owner.host !== "number" || pidAlive(owner.host))
221
+ continue;
222
+ if (typeof owner.workerd === "number" && isWorkerdIn(owner.workerd, dir)) {
223
+ try {
224
+ process.kill(owner.workerd, "SIGKILL");
225
+ }
226
+ catch {
227
+ // Gone already.
228
+ }
229
+ }
230
+ rmSync(dir, { recursive: true, force: true });
231
+ }
232
+ }
171
233
  /** The driver on a workerd of its own, behind a Unix socket. */
172
234
  export class RawWorkerd {
173
235
  runtime;
@@ -184,7 +246,9 @@ export class RawWorkerd {
184
246
  const source = await runtimeDir(new URL("../runtime/", import.meta.url).href);
185
247
  // mkdtemp makes the directory 0700: only this user reaches the socket,
186
248
  // and the token stops anything else running as them by accident.
187
- const dir = mkdtempSync(join(tmpdir(), "cdpk-workerd-"));
249
+ sweepStaleWorkerdDirs();
250
+ const dir = mkdtempSync(join(tmpdir(), WORKERD_DIR_PREFIX));
251
+ writeFileSync(join(dir, OWNER_FILE), JSON.stringify({ host: process.pid }));
188
252
  const socket = join(dir, "sock");
189
253
  for (const name of WORKER_MODULES)
190
254
  copyFileSync(join(source, name), join(dir, name));
@@ -194,11 +258,21 @@ export class RawWorkerd {
194
258
  // --experimental: the UnsafeEval binding.
195
259
  const child = spawn(workerd, ["serve", "config.capnp", "--experimental"], { cwd: dir, stdio: ["ignore", "pipe", "pipe"], env });
196
260
  child.once("exit", () => rmSync(dir, { recursive: true, force: true }));
261
+ if (child.pid !== undefined)
262
+ writeFileSync(join(dir, OWNER_FILE), JSON.stringify({ host: process.pid, workerd: child.pid }));
263
+ killOnExit(child, () => {
264
+ child.kill("SIGKILL");
265
+ rmSync(dir, { recursive: true, force: true });
266
+ });
197
267
  // Cell output is captured inside the Worker; what workerd itself prints
198
268
  // (its own warnings, errors from background work) goes to the server log.
269
+ // `early` keeps that output for a startup error only, and stops growing
270
+ // once the session is open.
199
271
  let early = "";
272
+ let starting = true;
200
273
  const log = (chunk) => {
201
- early += chunk.toString();
274
+ if (starting)
275
+ early += chunk.toString();
202
276
  process.stderr.write(`[cdp-kernel workerd] ${chunk}`);
203
277
  };
204
278
  child.stdout?.on("data", log);
@@ -221,7 +295,10 @@ export class RawWorkerd {
221
295
  }
222
296
  })();
223
297
  try {
224
- return new RawWorkerd(child, await Promise.race([ready, exited]), compatibilityDate);
298
+ const host = new RawWorkerd(child, await Promise.race([ready, exited]), compatibilityDate);
299
+ starting = false;
300
+ early = "";
301
+ return host;
225
302
  }
226
303
  catch (err) {
227
304
  child.kill();
@@ -229,7 +306,16 @@ export class RawWorkerd {
229
306
  }
230
307
  }
231
308
  stop(now) {
232
- this.#child.kill(now ? "SIGKILL" : "SIGTERM");
309
+ if (now) {
310
+ this.#child.kill("SIGKILL");
311
+ return;
312
+ }
313
+ this.#child.kill("SIGTERM");
314
+ // A busy isolate keeps workerd's event loop from ever seeing SIGTERM (#128).
315
+ setTimeout(() => {
316
+ if (this.#child.exitCode === null && this.#child.signalCode === null)
317
+ this.#child.kill("SIGKILL");
318
+ }, 3000).unref();
233
319
  }
234
320
  onExit(listener) {
235
321
  this.#child.once("exit", listener);
@@ -342,6 +428,9 @@ export class WorkerdTarget {
342
428
  this.#pending.clear();
343
429
  }
344
430
  #request(op, params = {}, interruptible = false) {
431
+ // The channel drops writes after it closes, so nothing would answer.
432
+ if (this.#exitedOnce)
433
+ return Promise.reject(new Abandoned("the workerd session is closed"));
345
434
  const id = ++this.#nextId;
346
435
  return new Promise((resolve, reject) => {
347
436
  this.#pending.set(id, { resolve, reject });
@@ -377,7 +466,12 @@ export class WorkerdTarget {
377
466
  if (!modules[specifier].startsWith("Promise.reject("))
378
467
  this.#sent.add(specifier);
379
468
  }
380
- return (await this.#interruptibly("eval", { code: script, modules }, "Execution"));
469
+ const { failedModules, ...reply } = await this.#interruptibly("eval", { code: script, modules }, "Execution");
470
+ // A module that threw while loading is rebuilt for its next import (#147).
471
+ if (Array.isArray(failedModules))
472
+ for (const specifier of failedModules)
473
+ this.#sent.delete(specifier);
474
+ return asEvaluateResult(reply);
381
475
  }
382
476
  /**
383
477
  * A cell that's waiting on something leaves the driver free: it answers a
@@ -1,6 +1,7 @@
1
1
  import { Buffer } from "node:buffer";
2
2
  export declare const OP_CONTINUATION = 0;
3
3
  export declare const OP_TEXT = 1;
4
+ export declare const OP_BINARY = 2;
4
5
  export declare const OP_CLOSE = 8;
5
6
  export declare const OP_PING = 9;
6
7
  export declare const OP_PONG = 10;
@@ -11,7 +12,10 @@ export interface Frame {
11
12
  }
12
13
  /** One frame; clients must mask what they send. */
13
14
  export declare function encodeFrame(opcode: number, payload: Buffer, mask?: Buffer | null): Buffer;
14
- /** Splits a byte stream into frames, holding back a partial one. */
15
+ /**
16
+ * Splits a byte stream into frames, holding back a partial one. Chunks of a
17
+ * large frame are only joined once the whole frame has arrived.
18
+ */
15
19
  export declare class FrameDecoder {
16
20
  #private;
17
21
  push(chunk: Buffer): Frame[];
package/dist/ws-client.js CHANGED
@@ -2,13 +2,14 @@
2
2
  // target's session with its driver Worker (#26). The WebSocket that Node
3
3
  // and Deno provide can't connect to a Unix socket, and a TCP listener would
4
4
  // be reachable by every local process. Text messages only: that's all the
5
- // session carries.
5
+ // session carries, so binary messages are dropped.
6
6
  import { Buffer } from "node:buffer";
7
7
  import { createHash, randomBytes } from "node:crypto";
8
8
  import { connect } from "node:net";
9
9
  const GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
10
10
  export const OP_CONTINUATION = 0x0;
11
11
  export const OP_TEXT = 0x1;
12
+ export const OP_BINARY = 0x2;
12
13
  export const OP_CLOSE = 0x8;
13
14
  export const OP_PING = 0x9;
14
15
  export const OP_PONG = 0xa;
@@ -40,14 +41,24 @@ export function encodeFrame(opcode, payload, mask = randomBytes(4)) {
40
41
  }
41
42
  return out;
42
43
  }
43
- /** Splits a byte stream into frames, holding back a partial one. */
44
+ /**
45
+ * Splits a byte stream into frames, holding back a partial one. Chunks of a
46
+ * large frame are only joined once the whole frame has arrived.
47
+ */
44
48
  export class FrameDecoder {
45
- #buffer = Buffer.alloc(0);
49
+ #chunks = [];
50
+ #length = 0;
51
+ /** Bytes the frame at the front needs, once its header is known. */
52
+ #needed = 0;
46
53
  push(chunk) {
47
- this.#buffer = Buffer.concat([this.#buffer, chunk]);
54
+ this.#chunks.push(chunk);
55
+ this.#length += chunk.length;
56
+ if (this.#length < this.#needed)
57
+ return [];
58
+ let b = this.#chunks.length === 1 ? this.#chunks[0] : Buffer.concat(this.#chunks, this.#length);
59
+ this.#needed = 0;
48
60
  const frames = [];
49
61
  for (;;) {
50
- const b = this.#buffer;
51
62
  if (b.length < 2)
52
63
  break;
53
64
  const masked = (b[1] & 0x80) !== 0;
@@ -68,15 +79,19 @@ export class FrameDecoder {
68
79
  const maskAt = offset;
69
80
  if (masked)
70
81
  offset += 4;
71
- if (b.length < offset + len)
82
+ if (b.length < offset + len) {
83
+ this.#needed = offset + len;
72
84
  break;
85
+ }
73
86
  const payload = Buffer.from(b.subarray(offset, offset + len));
74
87
  if (masked)
75
88
  for (let i = 0; i < len; i++)
76
89
  payload[i] ^= b[maskAt + (i & 3)];
77
90
  frames.push({ fin: (b[0] & 0x80) !== 0, opcode: b[0] & 0x0f, payload });
78
- this.#buffer = b.subarray(offset + len);
91
+ b = b.subarray(offset + len);
79
92
  }
93
+ this.#chunks = b.length ? [b] : [];
94
+ this.#length = b.length;
80
95
  return frames;
81
96
  }
82
97
  }
@@ -95,6 +110,9 @@ export function connectUnixWebSocket(socketPath, { path = "/", headers = {} } =
95
110
  let head = Buffer.alloc(0);
96
111
  const decoder = new FrameDecoder();
97
112
  let parts = [];
113
+ // The opcode of the message whose fragments are arriving.
114
+ let messageOpcode = null;
115
+ let closeSent = false;
98
116
  const finish = () => {
99
117
  if (closed)
100
118
  return;
@@ -108,30 +126,45 @@ export function connectUnixWebSocket(socketPath, { path = "/", headers = {} } =
108
126
  if (!closed)
109
127
  socket.write(encodeFrame(opcode, payload));
110
128
  };
129
+ const sendClose = (payload) => {
130
+ if (closeSent)
131
+ return;
132
+ closeSent = true;
133
+ write(OP_CLOSE, payload);
134
+ };
111
135
  const channel = {
112
136
  send: (text) => write(OP_TEXT, Buffer.from(text)),
113
137
  onMessage: (listener) => void messageListeners.push(listener),
114
138
  onClose: (listener) => void closeListeners.push(listener),
115
139
  close() {
116
- write(OP_CLOSE, Buffer.alloc(0));
140
+ sendClose(Buffer.alloc(0));
117
141
  socket.end();
118
142
  },
119
143
  };
120
144
  const onFrames = (frames) => {
121
145
  for (const frame of frames) {
122
- if (frame.opcode === OP_TEXT || frame.opcode === OP_CONTINUATION) {
123
- parts.push(frame.payload);
146
+ if (frame.opcode === OP_TEXT || frame.opcode === OP_BINARY || frame.opcode === OP_CONTINUATION) {
147
+ if (frame.opcode !== OP_CONTINUATION) {
148
+ messageOpcode = frame.opcode;
149
+ parts = [];
150
+ }
151
+ if (messageOpcode === OP_TEXT)
152
+ parts.push(frame.payload);
124
153
  if (frame.fin) {
125
- const text = Buffer.concat(parts).toString("utf8");
154
+ const text = messageOpcode === OP_TEXT ? Buffer.concat(parts).toString("utf8") : null;
126
155
  parts = [];
127
- for (const listener of messageListeners)
128
- listener(text);
156
+ messageOpcode = null;
157
+ if (text !== null)
158
+ for (const listener of messageListeners)
159
+ listener(text);
129
160
  }
130
161
  }
131
162
  else if (frame.opcode === OP_PING) {
132
163
  write(OP_PONG, frame.payload);
133
164
  }
134
165
  else if (frame.opcode === OP_CLOSE) {
166
+ // Echo the status code, as RFC 6455 section 5.5.1 asks.
167
+ sendClose(frame.payload.subarray(0, 2));
135
168
  socket.end();
136
169
  }
137
170
  }
package/dist/zmtp.d.ts CHANGED
@@ -20,6 +20,8 @@ export declare function recvMultipart(reader: SocketReader): Promise<Uint8Array[
20
20
  /** Async FIFO with blocking pop. */
21
21
  export declare class Queue<T> {
22
22
  #private;
23
+ /** Items waiting to be popped. */
24
+ get size(): number;
23
25
  push(item: T): void;
24
26
  pop(): Promise<T>;
25
27
  }
@@ -29,7 +31,11 @@ export interface RoutedMessage {
29
31
  peer: string;
30
32
  frames: Uint8Array[];
31
33
  }
32
- /** ROUTER socket: each TCP connection is one peer with a generated identity. */
34
+ /**
35
+ * ROUTER socket: each TCP connection is one peer with a generated identity.
36
+ * Writes ignore socket.write's backpressure signal: node:net buffers the
37
+ * rest, which is fine for loopback peers that read promptly.
38
+ */
33
39
  export declare class RouterSocket {
34
40
  #private;
35
41
  readonly incoming: Queue<RoutedMessage>;
package/dist/zmtp.js CHANGED
@@ -36,6 +36,10 @@ export class SocketReader {
36
36
  this.#waiter = null;
37
37
  waiter?.();
38
38
  }
39
+ // Each call joins every buffered chunk, so a reader that keeps a large
40
+ // backlog while small chunks trickle in recopies it per call (quadratic).
41
+ // Jupyter traffic over loopback arrives in few, large chunks; a chunk
42
+ // list with an offset would avoid the copies if that ever changes.
39
43
  async readExact(n) {
40
44
  while (this.#buffered < n) {
41
45
  if (this.#error)
@@ -136,11 +140,28 @@ export async function recvMultipart(reader) {
136
140
  return frames;
137
141
  }
138
142
  }
143
+ /** How long a port from the connection file may stay taken before the kernel gives up. */
144
+ const LISTEN_RETRY_MS = 5000;
145
+ const LISTEN_RETRY_INTERVAL_MS = 100;
139
146
  function listen(port, host, onConn) {
140
147
  const server = createServer((socket) => {
141
148
  socket.setNoDelay(true);
142
149
  onConn(socket);
143
150
  });
151
+ // The frontend picks these ports by binding and releasing them, so another
152
+ // socket can take one before the kernel binds it. Wait for it to be freed
153
+ // rather than die before kernel_info.
154
+ const deadline = Date.now() + LISTEN_RETRY_MS;
155
+ const onError = (err) => {
156
+ if (err.code !== "EADDRINUSE" || Date.now() >= deadline) {
157
+ server.off("error", onError);
158
+ server.emit("error", err);
159
+ return;
160
+ }
161
+ setTimeout(() => server.listen(port, host), LISTEN_RETRY_INTERVAL_MS);
162
+ };
163
+ server.on("error", onError);
164
+ server.once("listening", () => server.off("error", onError));
144
165
  server.listen(port, host);
145
166
  return server;
146
167
  }
@@ -148,6 +169,10 @@ function listen(port, host, onConn) {
148
169
  export class Queue {
149
170
  #items = [];
150
171
  #waiters = [];
172
+ /** Items waiting to be popped. */
173
+ get size() {
174
+ return this.#items.length;
175
+ }
151
176
  push(item) {
152
177
  const waiter = this.#waiters.shift();
153
178
  if (waiter)
@@ -173,7 +198,11 @@ export function startHeartbeat(port, host) {
173
198
  })().catch(() => socket.destroy());
174
199
  });
175
200
  }
176
- /** ROUTER socket: each TCP connection is one peer with a generated identity. */
201
+ /**
202
+ * ROUTER socket: each TCP connection is one peer with a generated identity.
203
+ * Writes ignore socket.write's backpressure signal: node:net buffers the
204
+ * rest, which is fine for loopback peers that read promptly.
205
+ */
177
206
  export class RouterSocket {
178
207
  incoming = new Queue();
179
208
  peers = new Map();
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@tlockney/cdp-kernel",
3
- "version": "0.4.0",
4
- "description": "Deno's Jupyter kernel, extracted to run notebooks on any V8 runtime over the Chrome DevTools Protocol",
3
+ "version": "0.5.0",
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>",
7
7
  "repository": {
@@ -42,7 +42,8 @@
42
42
  "files": [
43
43
  "dist/",
44
44
  "runtime/*.mjs",
45
- "runtime/*.d.mts"
45
+ "runtime/*.d.mts",
46
+ "THIRD-PARTY-NOTICES"
46
47
  ],
47
48
  "publishConfig": {
48
49
  "access": "public"
@@ -50,6 +51,10 @@
50
51
  "scripts": {
51
52
  "build": "rm -rf dist && tsc -p tsconfig.build.json",
52
53
  "check": "tsc -p tsconfig.json",
54
+ "docs:dev": "vitepress dev docs",
55
+ "docs:build": "vitepress build docs",
56
+ "docs:notebooks": "node docs/.vitepress/run-notebooks.ts",
57
+ "docs:widgets": "uv run --with jupyterlab --with anywidget --with playwright --with nbformat python test/widget_screenshots.py",
53
58
  "install:node": "node src/main.ts install --runtime node",
54
59
  "install:deno": "node src/main.ts install --runtime deno",
55
60
  "install:node-deno": "node src/main.ts install --runtime node --deno-compat",
@@ -69,6 +74,7 @@
69
74
  "devDependencies": {
70
75
  "@types/node": "^24.19.2",
71
76
  "typescript": "^5.9.3",
77
+ "vitepress": "^1.6.4",
72
78
  "workerd": "1.20261015.1",
73
79
  "wrangler": "4.151.0"
74
80
  }