@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.
@@ -55,7 +55,12 @@ function barrier(gated = false) {
55
55
  function send(payload) {
56
56
  const binding = globalThis.__cdpk_send;
57
57
  if (typeof binding !== "function") return; // host not attached yet
58
- 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)}`);
59
64
  }
60
65
 
61
66
  const jupyter = createJupyter({
@@ -72,6 +77,13 @@ const jupyter = createJupyter({
72
77
  const line = readStdinLine();
73
78
  if (line === null) return null;
74
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
+ }
75
87
  return typeof reply.value === "string" ? reply.value : null;
76
88
  },
77
89
  });
@@ -131,6 +143,24 @@ function bunResolve(specifier) {
131
143
  return Bun.resolveSync(specifier, cwd);
132
144
  }
133
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
+
134
164
  globalThis.__cdpk = {
135
165
  // Cell code awaits this first; see PRELUDE in src/transform.ts.
136
166
  tick: () => new Promise((resolve) => setTimeout(resolve, 0)),
@@ -149,6 +179,7 @@ globalThis.__cdpk = {
149
179
  resolve: (specifier) => new URL(specifier, replUrl).href,
150
180
  },
151
181
  barrier,
182
+ hold,
152
183
  // inspect_request (#10). CDP targets find the value with
153
184
  // throwOnSideEffect and describe it here; Bun resolves the path here
154
185
  // too, by descriptor, so nothing runs.
@@ -171,7 +202,9 @@ globalThis.__cdpk = {
171
202
  },
172
203
  describeError(value) {
173
204
  if (value instanceof Error) {
174
- 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 });
175
208
  }
176
209
  return JSON.stringify({ name: "Error", message: inspect(value), stack: "" });
177
210
  },
@@ -184,26 +217,48 @@ globalThis.confirm = jupyter.confirm;
184
217
  if (isDeno) {
185
218
  // Existing Deno notebooks use Deno.jupyter, but outside `deno jupyter`
186
219
  // that is a non-configurable getter that throws. The global binding
187
- // itself is configurable, so swap in a proxy that answers `jupyter` and
188
- // 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).
189
227
  const real = globalThis.Deno;
190
228
  const descriptor = Object.getOwnPropertyDescriptor(globalThis, "Deno");
191
- const proxy = new Proxy(real, {
192
- 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 },
193
231
  });
194
- Object.defineProperty(globalThis, "Deno", { ...descriptor, value: proxy });
232
+ Object.defineProperty(globalThis, "Deno", { ...descriptor, value: shim });
195
233
  } else if (process.env.CDPK_DENO_COMPAT === "1" && globalThis.Deno === undefined) {
196
234
  // Opt-in (install --deno-compat): a Deno namespace for notebooks written
197
235
  // against `deno jupyter`. See runtime/deno-compat.mjs for the trade-off.
198
236
  // Absolute URL: the resolve hook re-parents this module's relative
199
237
  // imports to the notebook directory.
200
- const { createDenoShim } = await import(new URL("./deno-compat.mjs", import.meta.url).href);
201
- 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)}`;
202
257
  }
203
258
 
204
259
  // An error thrown later by a cell's timer or promise must not kill the kernel.
205
260
  function reportUncaught(kind, err) {
206
- 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`);
207
262
  }
208
263
  if (isDeno) {
209
264
  globalThis.addEventListener("unhandledrejection", (e) => {
@@ -222,6 +277,29 @@ if (isDeno) {
222
277
  // Keep the event loop alive between cells.
223
278
  setInterval(() => {}, 1 << 30);
224
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
+
225
303
  if (isBun) {
226
304
  const { startChannel } = await import("./bun-channel.mjs");
227
305
  const { createHandler } = await import("./evaluator.mjs");
@@ -245,6 +323,14 @@ async function platform() {
245
323
  const util = await import("node:util");
246
324
  const fs = await import("node:fs");
247
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);
248
334
  return {
249
335
  inspect: (v) => util.inspect(v, { colors: true, depth: 4 }),
250
336
  inspectPlain: (v) => util.inspect(v, { colors: false, depth: 2 }),
@@ -265,6 +351,31 @@ async function platform() {
265
351
  };
266
352
  }
267
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
+
268
379
  function readLine(nextByte) {
269
380
  const bytes = [];
270
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,
@@ -180,7 +180,14 @@ export function createJupyter(host) {
180
180
  }
181
181
 
182
182
  function image(obj) {
183
- 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
+ }
184
191
  if (isJpg(obj)) return { [$display]: () => ({ "image/jpeg": host.base64(obj) }) };
185
192
  if (isPng(obj)) return { [$display]: () => ({ "image/png": host.base64(obj) }) };
186
193
  throw new TypeError(
@@ -8,12 +8,24 @@ export function parsePackageSpecifier(spec: string): PackageSpecifier;
8
8
  export function jsrToNpm(spec: string): PackageSpecifier;
9
9
  export function aliasFor(name: string, range: string): string;
10
10
  export type Installer = "npm" | "bun";
11
+ /** Where `installer` keeps its packages: $XDG_CACHE_HOME/cdp-kernel/<installer>, or CDPK_CACHE_DIR for npm. */
12
+ export function cacheDirPath(
13
+ installer: Installer,
14
+ env?: { [name: string]: string | undefined },
15
+ home?: string,
16
+ ): string;
11
17
  export function installCommand(
12
18
  installer: Installer,
13
19
  alias: string,
14
20
  name: string,
15
21
  range: string,
16
22
  ): { cmd: string; args: string[]; env?: { [name: string]: string } };
23
+ /** How to spawn an install command; on Windows, npm runs through cmd.exe. */
24
+ export function spawnCommand(
25
+ cmd: string,
26
+ args: string[],
27
+ platform?: string,
28
+ ): { cmd: string; args: string[]; shell: boolean };
17
29
  /** Runs an install; `env` is added to the process environment. */
18
30
  export type InstallRunner = (
19
31
  cmd: string,
@@ -26,6 +38,15 @@ export function ensureInstalled(
26
38
  installer?: Installer,
27
39
  options?: { lockFile?: string | null; run?: InstallRunner },
28
40
  ): { dir: string; alias: string };
41
+ /** ensureInstalled without blocking the event loop; `run` may return a promise. */
42
+ export function ensureInstalledAsync(
43
+ pkg: { name: string; range: string },
44
+ installer?: Installer,
45
+ options?: {
46
+ lockFile?: string | null;
47
+ run?: (...args: Parameters<InstallRunner>) => ReturnType<InstallRunner> | Promise<ReturnType<InstallRunner>>;
48
+ },
49
+ ): Promise<{ dir: string; alias: string }>;
29
50
 
30
51
  export const LOCK_FILE: string;
31
52
  export interface Lock {
@@ -36,4 +57,25 @@ export function findLockFile(dir: string): string | null;
36
57
  export function readLock(path: string): Lock;
37
58
  export function writeLock(path: string, lock: Lock): void;
38
59
  export function withInstallLock<T>(dir: string, fn: () => T, options?: { timeoutMs?: number }): T;
60
+ export function withInstallLockAsync<T>(dir: string, fn: () => T | Promise<T>, options?: { timeoutMs?: number }): Promise<T>;
61
+ /** Compiles one TypeScript or JSX file with the esbuild binary at `binary`; `run` is replaceable in tests. */
62
+ export function compileTypeScript(
63
+ source: string,
64
+ filename: string,
65
+ binary: string,
66
+ run?: (cmd: string, args: string[], options: object) => { status: number | null; stdout: string; stderr: string; error?: Error },
67
+ loader?: "ts" | "tsx" | "jsx",
68
+ ): string;
69
+ export interface LoadResult {
70
+ format?: string | null;
71
+ source?: string | ArrayBuffer | ArrayBufferView | null;
72
+ shortCircuit?: boolean;
73
+ }
74
+ /** The load hook: Node's own result unless its type stripping would reject the file; .tsx/.jsx always compiled. */
75
+ export function loadTypeScript(
76
+ url: string,
77
+ context: object,
78
+ nextLoad: (url: string, context: object) => LoadResult,
79
+ binary: string | undefined,
80
+ ): LoadResult;
39
81
  export function installNodeResolveHooks(urls: { shimUrl: string; replUrl: string }): void;