@tlockney/cdp-kernel 0.3.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.
Files changed (56) hide show
  1. package/README.md +60 -404
  2. package/THIRD-PARTY-NOTICES +62 -0
  3. package/dist/bun-target.d.ts +4 -2
  4. package/dist/bun-target.js +35 -8
  5. package/dist/cdp.d.ts +6 -1
  6. package/dist/cdp.js +25 -10
  7. package/dist/celld-target.d.ts +1 -1
  8. package/dist/celld-target.js +46 -8
  9. package/dist/children.d.ts +7 -0
  10. package/dist/children.js +32 -0
  11. package/dist/debugger.d.ts +42 -0
  12. package/dist/debugger.js +545 -0
  13. package/dist/globals.d.ts +3 -3
  14. package/dist/globals.js +3 -3
  15. package/dist/history.d.ts +18 -0
  16. package/dist/history.js +45 -0
  17. package/dist/kernel.js +211 -32
  18. package/dist/main.js +3 -1
  19. package/dist/mod.d.ts +5 -3
  20. package/dist/mod.js +5 -3
  21. package/dist/murmur2.d.ts +2 -0
  22. package/dist/murmur2.js +41 -0
  23. package/dist/package.d.ts +16 -2
  24. package/dist/package.js +26 -3
  25. package/dist/sourcemap.d.ts +22 -0
  26. package/dist/sourcemap.js +86 -0
  27. package/dist/target.d.ts +39 -3
  28. package/dist/target.js +281 -30
  29. package/dist/text.d.ts +11 -1
  30. package/dist/text.js +72 -2
  31. package/dist/transform.d.ts +19 -0
  32. package/dist/transform.js +74 -13
  33. package/dist/wire.d.ts +8 -1
  34. package/dist/wire.js +3 -1
  35. package/dist/workerd-target.d.ts +9 -1
  36. package/dist/workerd-target.js +108 -9
  37. package/dist/ws-client.d.ts +5 -1
  38. package/dist/ws-client.js +46 -13
  39. package/dist/zmtp.d.ts +7 -1
  40. package/dist/zmtp.js +30 -1
  41. package/package.json +10 -4
  42. package/runtime/bootstrap.mjs +138 -12
  43. package/runtime/bun-channel.d.mts +3 -0
  44. package/runtime/bun-channel.mjs +2 -2
  45. package/runtime/comm.mjs +11 -1
  46. package/runtime/deno-compat.d.mts +2 -0
  47. package/runtime/deno-compat.mjs +17 -2
  48. package/runtime/display.d.mts +2 -0
  49. package/runtime/display.mjs +17 -2
  50. package/runtime/evaluator.d.mts +4 -0
  51. package/runtime/evaluator.mjs +8 -0
  52. package/runtime/introspect.d.mts +13 -0
  53. package/runtime/introspect.mjs +120 -0
  54. package/runtime/node-hooks.d.mts +61 -1
  55. package/runtime/node-hooks.mjs +354 -21
  56. package/runtime/workerd-worker.mjs +82 -6
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.3.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",
@@ -57,7 +62,7 @@
57
62
  "install:workerd": "node src/main.ts install --runtime workerd",
58
63
  "install:celld": "node src/main.ts install --runtime celld",
59
64
  "prepack": "npm run build",
60
- "test": "node --test test/*.test.ts",
65
+ "test": "node --test --test-timeout=300000 test/*.test.ts",
61
66
  "e2e": "uv run --with nbclient --with nbformat --with jupyter_client python test/e2e.py",
62
67
  "test:lab": "uv run --with jupyterlab --with anywidget --with playwright --with nbformat python test/lab_widgets.py"
63
68
  },
@@ -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
  }
@@ -12,6 +12,7 @@
12
12
 
13
13
  import { fileURLToPath, pathToFileURL } from "node:url";
14
14
  import { createJupyter } from "./display.mjs";
15
+ import { describeValue, inspectPath, resolvePath } from "./introspect.mjs";
15
16
 
16
17
  const isDeno = typeof globalThis.Deno?.version?.deno === "string";
17
18
  const isBun = typeof globalThis.Bun?.version === "string";
@@ -26,7 +27,7 @@ const cwdUrl = pathToFileURL(cwd.endsWith("/") ? cwd : cwd + "/");
26
27
  // Stand-in referrer for cell code, like Deno's ./$deno$jupyter_repl.mts.
27
28
  const replUrl = new URL("$cdpk$repl.mjs", cwdUrl);
28
29
 
29
- const { inspect, readFile, writeOut, writeErr, readStdinLine } = await platform();
30
+ const { inspect, inspectPlain, readFile, writeOut, writeErr, readStdinLine } = await platform();
30
31
 
31
32
  function base64(bytes) {
32
33
  let binary = "";
@@ -54,7 +55,12 @@ function barrier(gated = false) {
54
55
  function send(payload) {
55
56
  const binding = globalThis.__cdpk_send;
56
57
  if (typeof binding !== "function") return; // host not attached yet
57
- binding(JSON.stringify({ ...payload, barrier: barrier(true) }));
58
+ // Serialize before writing the marker: if the payload can't be
59
+ // serialized (a BigInt, a cycle), the error reaches the cell without a
60
+ // gated marker the host would wait on forever (#129). `payload` always
61
+ // has a `kind`, so `body` is never "{}".
62
+ const body = JSON.stringify(payload);
63
+ binding(`{"barrier":${barrier(true)},${body.slice(1)}`);
58
64
  }
59
65
 
60
66
  const jupyter = createJupyter({
@@ -71,6 +77,13 @@ const jupyter = createJupyter({
71
77
  const line = readStdinLine();
72
78
  if (line === null) return null;
73
79
  const reply = JSON.parse(line);
80
+ if (reply.interrupted === true) {
81
+ // Interrupted while waiting for the answer: unwind the cell, as
82
+ // KeyboardInterrupt does in Python's input() (#121).
83
+ const err = new Error("Execution interrupted");
84
+ err.name = "Interrupted";
85
+ throw err;
86
+ }
74
87
  return typeof reply.value === "string" ? reply.value : null;
75
88
  },
76
89
  });
@@ -122,7 +135,7 @@ function bunResolve(specifier) {
122
135
  if (specifier.startsWith("npm:")) pkg = packages.parsePackageSpecifier(specifier.slice(4));
123
136
  else if (specifier.startsWith("jsr:")) pkg = packages.jsrToNpm(specifier.slice(4));
124
137
  if (pkg) {
125
- const { dir, alias } = packages.ensureInstalled(pkg, "bun");
138
+ const { dir, alias } = packages.ensureInstalled(pkg, "bun", { lockFile: packages.findLockFile(cwd) });
126
139
  return Bun.resolveSync(alias + pkg.subpath, dir);
127
140
  }
128
141
  // Relative, absolute, or a URL scheme such as node:, bun: or file:.
@@ -130,6 +143,24 @@ function bunResolve(specifier) {
130
143
  return Bun.resolveSync(specifier, cwd);
131
144
  }
132
145
 
146
+ // The inspector holds a promise it awaits (callFunctionOn with awaitPromise)
147
+ // only weakly until its then-callback runs, so a GC after the promise
148
+ // settles but before that callback fails the call with "Promise was
149
+ // collected" (#55). hold() keeps the promise until a macrotask after it
150
+ // settles, by which time the inspector's callback has run.
151
+ const held = new Set();
152
+ function hold(value) {
153
+ const promise = Promise.resolve(value);
154
+ held.add(promise);
155
+ const release = () => setTimeout(() => held.delete(promise), 0);
156
+ promise.then(release, release);
157
+ return promise;
158
+ }
159
+
160
+ // Adds the cause to an error from a Deno member the --deno-compat shim
161
+ // lacks (#172); the identity everywhere else.
162
+ let explainError = (text) => text;
163
+
133
164
  globalThis.__cdpk = {
134
165
  // Cell code awaits this first; see PRELUDE in src/transform.ts.
135
166
  tick: () => new Promise((resolve) => setTimeout(resolve, 0)),
@@ -148,7 +179,20 @@ globalThis.__cdpk = {
148
179
  resolve: (specifier) => new URL(specifier, replUrl).href,
149
180
  },
150
181
  barrier,
182
+ hold,
183
+ // inspect_request (#10). CDP targets find the value with
184
+ // throwOnSideEffect and describe it here; Bun resolves the path here
185
+ // too, by descriptor, so nothing runs.
186
+ describeForInspect: (value, detail) => describeValue(value, { inspect: inspectPlain, detail }),
187
+ inspectPath: (path, detail) => inspectPath(path, { inspect: inspectPlain, detail }),
188
+ resolvePath,
151
189
  broadcastResult: jupyter.broadcastResult,
190
+ // The debugger's rich variable viewer (#18), synchronously: it runs while
191
+ // the target is paused. A value whose $display is async shows as text.
192
+ richBundle(value) {
193
+ const bundle = jupyter.formatNow(value);
194
+ return JSON.stringify(typeof bundle?.then === "function" ? { "text/plain": inspectPlain(value) } : bundle);
195
+ },
152
196
  // Frontend comm messages (#9), from the host's shell channel. Handlers
153
197
  // run after one macrotask, like cells, so interrupt can reach them.
154
198
  async commDispatch(msgType, content, metadata, buffers64) {
@@ -158,7 +202,9 @@ globalThis.__cdpk = {
158
202
  },
159
203
  describeError(value) {
160
204
  if (value instanceof Error) {
161
- return JSON.stringify({ name: value.name, message: value.message, stack: value.stack ?? "" });
205
+ const message = explainError(value.message);
206
+ const stack = stackOf(value).replace(value.message, () => message);
207
+ return JSON.stringify({ name: value.name, message, stack });
162
208
  }
163
209
  return JSON.stringify({ name: "Error", message: inspect(value), stack: "" });
164
210
  },
@@ -171,26 +217,48 @@ globalThis.confirm = jupyter.confirm;
171
217
  if (isDeno) {
172
218
  // Existing Deno notebooks use Deno.jupyter, but outside `deno jupyter`
173
219
  // that is a non-configurable getter that throws. The global binding
174
- // itself is configurable, so swap in a proxy that answers `jupyter` and
175
- // forwards everything else to the real namespace.
220
+ // itself is configurable, so swap in an object with its own `jupyter`
221
+ // that inherits everything else from the real namespace. Not a Proxy:
222
+ // completion can't list a Proxy's properties, and Shift-Tab's
223
+ // side-effect-free evaluation can't read through one (#132). So
224
+ // Object.keys(Deno) and console.log(Deno) show only `jupyter`, and
225
+ // Deno's functions are called with `this` set to this object (they
226
+ // don't use it).
176
227
  const real = globalThis.Deno;
177
228
  const descriptor = Object.getOwnPropertyDescriptor(globalThis, "Deno");
178
- const proxy = new Proxy(real, {
179
- get: (target, key) => (key === "jupyter" ? jupyter.namespace : Reflect.get(target, key, target)),
229
+ const shim = Object.create(real, {
230
+ jupyter: { value: jupyter.namespace, enumerable: true, configurable: true, writable: true },
180
231
  });
181
- Object.defineProperty(globalThis, "Deno", { ...descriptor, value: proxy });
232
+ Object.defineProperty(globalThis, "Deno", { ...descriptor, value: shim });
182
233
  } else if (process.env.CDPK_DENO_COMPAT === "1" && globalThis.Deno === undefined) {
183
234
  // Opt-in (install --deno-compat): a Deno namespace for notebooks written
184
235
  // against `deno jupyter`. See runtime/deno-compat.mjs for the trade-off.
185
236
  // Absolute URL: the resolve hook re-parents this module's relative
186
237
  // imports to the notebook directory.
187
- const { createDenoShim } = await import(new URL("./deno-compat.mjs", import.meta.url).href);
188
- globalThis.Deno = createDenoShim({ jupyter: jupyter.namespace });
238
+ const { createDenoShim, explainMissing } = await import(new URL("./deno-compat.mjs", import.meta.url).href);
239
+ const shim = createDenoShim({ jupyter: jupyter.namespace });
240
+ globalThis.Deno = shim;
241
+ explainError = (text) => explainMissing(text, shim);
242
+ }
243
+
244
+ /**
245
+ * An error's stack, with its first line naming the error. Bun now and then
246
+ * (about one throw in twenty, #216) records a stack whose first line is a
247
+ * bare "Error", though name and message are set; the frames are right, so
248
+ * only the header is rebuilt.
249
+ */
250
+ function stackOf(err) {
251
+ const stack = err.stack;
252
+ if (typeof stack !== "string") return "";
253
+ const nl = stack.indexOf("\n");
254
+ const first = nl === -1 ? stack : stack.slice(0, nl);
255
+ if (first !== "Error" || !err.message) return stack;
256
+ return `${err.name}: ${err.message}${nl === -1 ? "" : stack.slice(nl)}`;
189
257
  }
190
258
 
191
259
  // An error thrown later by a cell's timer or promise must not kill the kernel.
192
260
  function reportUncaught(kind, err) {
193
- writeErr(`${kind}: ${err instanceof Error ? (err.stack ?? err.message) : inspect(err)}\n`);
261
+ writeErr(`${kind}: ${explainError(err instanceof Error ? (stackOf(err) || err.message) : inspect(err))}\n`);
194
262
  }
195
263
  if (isDeno) {
196
264
  globalThis.addEventListener("unhandledrejection", (e) => {
@@ -209,6 +277,29 @@ if (isDeno) {
209
277
  // Keep the event loop alive between cells.
210
278
  setInterval(() => {}, 1 << 30);
211
279
 
280
+ // A host killed by SIGKILL can't stop its runtime, and nothing visible to
281
+ // JS here changes when its inspector sessions drop, so watch for the host
282
+ // itself going: reparented, or no longer there (#219). Bun exits on its
283
+ // channel's EOF instead.
284
+ if (!isBun) {
285
+ // Held here, so a cell that reassigns them can't break the watch.
286
+ const proc = process;
287
+ const exit = process.exit.bind(process);
288
+ const host = proc.ppid;
289
+ const hostGone = () => {
290
+ if (proc.ppid !== host) return true;
291
+ try {
292
+ proc.kill(host, 0);
293
+ return false;
294
+ } catch (err) {
295
+ return err?.code === "ESRCH";
296
+ }
297
+ };
298
+ setInterval(() => {
299
+ if (hostGone()) exit(1);
300
+ }, 2000);
301
+ }
302
+
212
303
  if (isBun) {
213
304
  const { startChannel } = await import("./bun-channel.mjs");
214
305
  const { createHandler } = await import("./evaluator.mjs");
@@ -222,6 +313,7 @@ async function platform() {
222
313
  const stdinBuf = new Uint8Array(1);
223
314
  return {
224
315
  inspect: (v) => Deno.inspect(v, { colors: true, depth: 4 }),
316
+ inspectPlain: (v) => Deno.inspect(v, { colors: false, depth: 2 }),
225
317
  readFile: (p) => Deno.readFileSync(p),
226
318
  writeOut: (s) => Deno.stdout.writeSync(ENC.encode(s)),
227
319
  writeErr: (s) => Deno.stderr.writeSync(ENC.encode(s)),
@@ -231,8 +323,17 @@ async function platform() {
231
323
  const util = await import("node:util");
232
324
  const fs = await import("node:fs");
233
325
  const buf = Buffer.alloc(1);
326
+ // Node writes to a pipe without blocking and queues what doesn't fit for
327
+ // the event loop to flush. input() blocks the main thread in readSync, so
328
+ // queued output, and the input_request's barrier marker after it, would
329
+ // never reach the host (#131). Blocking writes finish before readSync
330
+ // starts, as Node already does on Windows. Bun has no _handle and needs
331
+ // its own fix (#180).
332
+ if (isBun) await syncBunOutput();
333
+ else for (const stream of [process.stdout, process.stderr]) stream._handle?.setBlocking?.(true);
234
334
  return {
235
335
  inspect: (v) => util.inspect(v, { colors: true, depth: 4 }),
336
+ inspectPlain: (v) => util.inspect(v, { colors: false, depth: 2 }),
236
337
  readFile: (p) => fs.readFileSync(p),
237
338
  writeOut: (s) => process.stdout.write(s),
238
339
  writeErr: (s) => process.stderr.write(s),
@@ -250,6 +351,31 @@ async function platform() {
250
351
  };
251
352
  }
252
353
 
354
+ // Bun makes stdout and stderr non-blocking once process.stdout or
355
+ // process.stderr exists, and then queues what doesn't fit in the pipe for
356
+ // its event loop, console.log's output included. input() blocks the main
357
+ // thread in readSync, so queued output, and the input_request's barrier
358
+ // marker after it, would never reach the host (#180). Here every write
359
+ // finishes before it returns: process.stdout.write and process.stderr.write
360
+ // write through, and console is Node's Console over them, so console output
361
+ // is formatted as on Node.
362
+ async function syncBunOutput() {
363
+ const { writeAll } = await import("./bun-channel.mjs");
364
+ const { Console } = await import("node:console");
365
+ for (const [stream, fd] of [[process.stdout, 1], [process.stderr, 2]]) {
366
+ stream.write = (chunk, encoding, callback) => {
367
+ if (typeof encoding === "function") [callback, encoding] = [encoding, undefined];
368
+ writeAll(fd, typeof chunk === "string" ? Buffer.from(chunk, encoding) : chunk);
369
+ if (callback) process.nextTick(callback);
370
+ return true;
371
+ };
372
+ }
373
+ const sync = new Console({ stdout: process.stdout, stderr: process.stderr, colorMode: true });
374
+ for (const key of Object.keys(console)) {
375
+ if (typeof sync[key] === "function") console[key] = sync[key].bind(sync);
376
+ }
377
+ }
378
+
253
379
  function readLine(nextByte) {
254
380
  const bytes = [];
255
381
  for (;;) {
@@ -1,3 +1,6 @@
1
1
  import type { Handler } from "./evaluator.mjs";
2
2
 
3
3
  export function startChannel(handle: Handler, ready: { [key: string]: unknown }, fd?: number): void;
4
+
5
+ /** Writes all of `text` to `fd` before returning, even if the fd is non-blocking. */
6
+ export function writeAll(fd: number, text: string | Uint8Array): void;
@@ -14,8 +14,8 @@ import fs from "node:fs";
14
14
  const CHANNEL_FD = 3;
15
15
 
16
16
  /** Synchronous, like a CDP binding call, so output order is kept. */
17
- function writeAll(fd, text) {
18
- const bytes = Buffer.from(text);
17
+ export function writeAll(fd, text) {
18
+ const bytes = typeof text === "string" ? Buffer.from(text) : Buffer.from(text.buffer, text.byteOffset, text.byteLength);
19
19
  for (let offset = 0; offset < bytes.length;) {
20
20
  try {
21
21
  offset += fs.writeSync(fd, bytes, offset);
package/runtime/comm.mjs CHANGED
@@ -89,7 +89,17 @@ export function createComms(send, newId = () => crypto.randomUUID()) {
89
89
  if (msgType === "comm_open") {
90
90
  const handler = targets.get(content.target_name);
91
91
  if (!handler) return false;
92
- await handler(track(content.comm_id, content.target_name), msg);
92
+ const comm = track(content.comm_id, content.target_name);
93
+ try {
94
+ await handler(comm, msg);
95
+ } catch (err) {
96
+ // The host answers a failed comm_open with comm_close, so the comm
97
+ // is gone on the frontend: drop it here too, without running close
98
+ // handlers or sending anything (#137).
99
+ comm.closed = true;
100
+ forget(comm.id);
101
+ throw err;
102
+ }
93
103
  return true;
94
104
  }
95
105
  const comm = comms.get(content.comm_id);
@@ -114,3 +114,5 @@ export interface DenoShim {
114
114
 
115
115
  export function denoError<E>(err: E): E | NotFound | AlreadyExists | PermissionDenied | IsADirectory | NotADirectory;
116
116
  export function createDenoShim(options: { jupyter: unknown }): DenoShim;
117
+ /** Adds the cause to "Deno.x is not a function" when `shim` lacks `x`. */
118
+ export function explainMissing(message: string, shim: object): string;
@@ -7,7 +7,8 @@
7
7
  // shim installed they take their Deno code paths, which may call APIs not
8
8
  // covered here (Deno.serve, Deno.Command, Deno.openKv, permissions...).
9
9
  // Uncovered APIs are simply absent rather than throwing on access, so
10
- // feature checks like `typeof Deno.serve === "function"` still work.
10
+ // feature checks like `typeof Deno.serve === "function"` still work. The
11
+ // error from calling one says so instead (explainMissing, #172).
11
12
 
12
13
  import fs from "node:fs";
13
14
  import fsp from "node:fs/promises";
@@ -100,6 +101,18 @@ const writeFlag = (options) => (options?.append ? "a" : options?.createNew ? "wx
100
101
  const OS_NAMES = { darwin: "darwin", linux: "linux", win32: "windows", freebsd: "freebsd" };
101
102
  const ARCH_NAMES = { arm64: "aarch64", x64: "x86_64" };
102
103
 
104
+ const MEMBERS_DOC = "https://cdp-kernel.org/docs/jupyter-api#notebooks-written-for-deno";
105
+
106
+ /**
107
+ * Adds the cause to V8's "Deno.serve is not a function" when `shim` lacks
108
+ * that member (#172), or returns `message` unchanged.
109
+ */
110
+ export function explainMissing(message, shim) {
111
+ return message.replace(/\b(Deno\.([\w$]+)) is not a (?:function|constructor)\b/, (all, expr, name) =>
112
+ name in shim ? all : `${all}: ${expr} is not implemented by cdp-kernel's --deno-compat namespace (see ${MEMBERS_DOC})`
113
+ );
114
+ }
115
+
103
116
  export function createDenoShim({ jupyter }) {
104
117
  return {
105
118
  jupyter,
@@ -161,7 +174,9 @@ export function createDenoShim({ jupyter }) {
161
174
  rename: async((from, to) => fsp.rename(toPath(from), toPath(to))),
162
175
  renameSync: sync((from, to) => fs.renameSync(toPath(from), toPath(to))),
163
176
  realPath: async((p) => fsp.realpath(toPath(p))),
164
- realPathSync: sync((p) => fs.realpathSync(toPath(p))),
177
+ // The native call, like fsp.realpath above: the JavaScript one leaves
178
+ // Windows 8.3 names (RUNNER~1) unexpanded, so the two disagreed (#32).
179
+ realPathSync: sync((p) => fs.realpathSync.native(toPath(p))),
165
180
  makeTempDir: async((options) =>
166
181
  fsp.mkdtemp(path.join(options?.dir ?? os.tmpdir(), options?.prefix ?? "deno-"))
167
182
  ),
@@ -47,6 +47,8 @@ export interface JupyterNamespace {
47
47
  export interface Jupyter {
48
48
  namespace: JupyterNamespace;
49
49
  broadcastResult(executionCount: number, result: unknown): Promise<void>;
50
+ /** format without awaiting; a $display method's promise is returned as is. */
51
+ formatNow(obj: unknown): unknown;
50
52
  confirm(message?: string): boolean;
51
53
  prompt(message?: string, defaultValue?: string, options?: { password?: boolean }): string | null;
52
54
  /** Routes a frontend comm message into the kernel's comms. */
@@ -146,7 +146,14 @@ function isMediaBundle(obj) {
146
146
  */
147
147
  export function createJupyter(host) {
148
148
  async function format(obj) {
149
- if (hasDisplaySymbol(obj)) return await obj[$display]();
149
+ return await formatNow(obj);
150
+ }
151
+
152
+ // format without awaiting: the debugger's variable viewer formats values
153
+ // while the target is paused, when no promise settles (#18). A $display
154
+ // method's promise is the caller's to handle.
155
+ function formatNow(obj) {
156
+ if (hasDisplaySymbol(obj)) return obj[$display]();
150
157
  if (typeof obj !== "object" || obj === null) return { "text/plain": host.inspect(obj) };
151
158
  if (isCanvasLike(obj)) {
152
159
  const [prefix, data] = obj.toDataURL().split(",");
@@ -173,7 +180,14 @@ export function createJupyter(host) {
173
180
  }
174
181
 
175
182
  function image(obj) {
176
- if (typeof obj === "string") obj = host.readFile(obj);
183
+ if (typeof obj === "string") {
184
+ // As Deno does: a path that can't be read gets the TypeError below.
185
+ try {
186
+ obj = host.readFile(obj);
187
+ } catch {
188
+ // fall through
189
+ }
190
+ }
177
191
  if (isJpg(obj)) return { [$display]: () => ({ "image/jpeg": host.base64(obj) }) };
178
192
  if (isPng(obj)) return { [$display]: () => ({ "image/png": host.base64(obj) }) };
179
193
  throw new TypeError(
@@ -240,6 +254,7 @@ export function createJupyter(host) {
240
254
  widget: createWidgetFactory(comms.api, $display),
241
255
  },
242
256
  broadcastResult,
257
+ formatNow,
243
258
  confirm,
244
259
  prompt,
245
260
  commDispatch: comms.dispatch,
@@ -4,6 +4,10 @@ export interface EvaluatorShim {
4
4
  describeError(value: unknown): string;
5
5
  commDispatch(msgType: string, content: object, metadata: object, buffers64: string[]): Promise<unknown>;
6
6
  barrier(): number;
7
+ /** inspect_request's text for a dotted path, or null (#10). */
8
+ inspectPath(path: string, detail: number): string | null;
9
+ /** Resolves a dotted path without running getters; see introspect.mjs. */
10
+ resolvePath?(path: string): import("./introspect.mjs").Resolved;
7
11
  }
8
12
 
9
13
  /** A request from the host: an `op` plus its parameters. */