concept-collection / jupyterlite-numbl-kernel
Add cooperative cell interrupt (Stop button)
The Stop button now aborts a running numbl cell at the next loop iteration or function/builtin call and reports it as a KeyboardInterrupt, leaving the persistent workspace intact. It uses numbl 0.4.15's browser cancellation API (session.interrupt() over a SharedArrayBuffer), so the page must be cross-origin isolated. - kernel: NumblKernel.interrupt() -> session.interrupt(); aborted runs are reported as KeyboardInterrupt. - interruptBridge: JupyterLite can't route an interrupt to a running kernel (LiteKernelClient serializes messages through one mutex and its interrupt() only cancels queued cells; BaseKernel has no interrupt_request hook). Since the kernel and front end share the main-thread realm, patch KernelConnection.interrupt() to also signal the matching numbl kernel by id, then delegate to the original. - demo: ship coi-serviceworker + a post-build injector (inject-coi.mjs) to synthesize COOP/COEP headers so SharedArrayBuffer is available on GitHub Pages; deploy.yml runs the injector after the build. - bump numbl to ^0.4.15; document interrupt and its cross-origin-isolation requirement in the README.
Jeremy Magland <jmagland@flatironinstitute.org> committed commit cf2d6980c446 parent 9ff4e5b Browse files
10 changed files+303−15
.github/workflows/deploy.ymlmodified+3−0View file
@@ -30,6 +30,9 @@ jobs:
3030 - name: Build the JupyterLite site
3131 run: jupyter lite build --lite-dir demo --contents content --output-dir dist
3232
33+ - name: Make the site cross-origin isolated (enables interrupt)
34+ run: node demo/inject-coi.mjs dist
35+
3336 - name: Upload artifact
3437 uses: actions/upload-pages-artifact@v3
3538 with:
.prettierignoremodified+1−0View file
@@ -8,3 +8,4 @@ _output
88 yarn.lock
99 jupyterlite_numbl_kernel/labextension
1010 demo/content/**/*.ipynb
11+demo/coi-serviceworker.js
README.mdmodified+47−9View file
@@ -105,13 +105,46 @@ numbl's own package cache (installed via `mip`) lives in a separate
105105 IndexedDB store and is unaffected, so `mip`-installed packages still
106106 persist across reloads.
107107
108+### Cross-origin isolation (for interrupt)
109+
110+Cell interrupt needs a `SharedArrayBuffer`, which browsers only expose on a
111+**cross-origin-isolated** page (served with `Cross-Origin-Opener-Policy:
112+same-origin` and `Cross-Origin-Embedder-Policy: credentialless`). GitHub Pages
113+serves static files and can't set those headers, so the demo synthesizes them
114+client-side with a small service worker, `demo/coi-serviceworker.js` (based on
115+[coi-serviceworker](https://github.com/niccokunzmann/coi-serviceworker)). It
116+only rewrites **same-origin** responses, so numbl's cross-origin `mip` download
117+from its GitHub release still works.
118+
119+`demo/inject-coi.mjs` runs after `jupyter lite build`: it copies the worker to
120+the site root and adds a `<script>` registering it to every generated page's
121+`<head>` (the deploy workflow does this automatically). The worker adds no
122+caching — it only injects headers — so it doesn't undermine the always-fresh
123+content choice above. Service workers require a secure context, so view the
124+site over `https://` or `http://localhost` / `http://127.0.0.1`; an `http://`
125+LAN IP or an embedded/preview browser has no service worker, and interrupt
126+degrades to a no-op there.
127+
108128 ## Limitations (proof of concept)
109129
110-- **No interrupt**: a runaway cell can only be stopped by restarting the
111- kernel (restart works and gives a fresh workspace). Cooperative
112- cancellation exists in numbl but needs `SharedArrayBuffer`, i.e.
113- cross-origin isolation headers, which plain GitHub Pages doesn't set.
114-- **No `input()`** (stdin), for the same reason.
130+- **Interrupt** works cooperatively: the Stop button aborts the running cell
131+ at the next loop iteration or function/builtin call, reports it as a
132+ `KeyboardInterrupt`, and leaves the workspace intact (variables from before
133+ the cell survive). It relies on a `SharedArrayBuffer` cancel flag that numbl
134+ polls during execution, so the page must be **cross-origin isolated**
135+ (`COOP`/`COEP`). Plain GitHub Pages can't set those headers, so the demo
136+ ships a `coi-serviceworker` that synthesizes them (see [Cross-origin
137+ isolation](#cross-origin-isolation-for-interrupt)). Two caveats: on a
138+ deployment that is **not** cross-origin isolated the interrupt silently
139+ falls back to a no-op (a runaway cell can then only be stopped by
140+ restarting the kernel), and a **tight loop with no function or builtin
141+ calls** (e.g. `while true; x = x + 1; end`) is JIT-compiled straight
142+ through with no cancellation checkpoint, so it too needs a restart.
143+- **No `input()`** (stdin): numbl's browser session has no stdin channel in
144+ its worker protocol yet, so `input()` is unsupported. (This is now a
145+ missing feature, not a headers limitation — the demo is cross-origin
146+ isolated for interrupt, so the `SharedArrayBuffer` such a channel would
147+ need is available.)
115148 - **Figures are per-cell** (like inline matplotlib): each cell renders the
116149 figures its own commands produce; `hold on` does not span cells.
117150 - **Named function definitions are not supported inside cells** (a numbl
@@ -129,10 +162,12 @@ persist across reloads.
129162
130163 ## Development
131164
132-Requires Python ≥ 3.9 and NodeJS ≥ 20, and `numbl >= 0.4.14` on npm (the
133-first release with the incremental `session.execute` browser API). To
134-develop against an unreleased numbl checkout, run `npm pack` there and
135-point the `numbl` dependency at the tarball.
165+Requires Python ≥ 3.9 and NodeJS ≥ 20, and `numbl >= 0.4.15` on npm — the
166+first release with the browser cancellation API (`session.interrupt()` /
167+`canInterrupt`) that cell interrupt uses. (numbl `0.4.14` added the
168+incremental `session.execute` browser API this kernel is built on.) To
169+develop against an unreleased numbl checkout, run `npm pack` there and point
170+the `numbl` dependency at the tarball.
136171
137172 ```bash
138173 python -m venv .venv && source .venv/bin/activate
@@ -145,7 +180,10 @@ pip install -e . # editable install, registers the labextension
145180 # Build and serve the demo site locally
146181 pip install -r demo/requirements.txt
147182 jupyter lite build --lite-dir demo --contents content --output-dir demo/_output
183+node demo/inject-coi.mjs demo/_output # cross-origin isolation, for interrupt
148184 python -m http.server -d demo/_output 8000
185+# then open http://localhost:8000 — a secure context, required for the
186+# service worker (interrupt is a no-op without it)
149187 ```
150188
151189 `jlpm watch` rebuilds on change during development.
demo/coi-serviceworker.jsadded+73−0View file
@@ -0,0 +1,73 @@
1+/*
2+ * Cross-Origin Isolation Service Worker
3+ *
4+ * Adds COOP/COEP headers to responses so that SharedArrayBuffer is available
5+ * on hosts that don't allow custom response headers (e.g. GitHub Pages). numbl
6+ * uses SharedArrayBuffer for cooperative cell interruption; without cross-origin
7+ * isolation the Stop button can't stop a running cell.
8+ *
9+ * Based on https://github.com/niccokunzmann/coi-serviceworker (MIT).
10+ */
11+
12+/* global self, caches, fetch, Response, clients */
13+
14+if (typeof window === "undefined") {
15+ // --- Service Worker scope ---
16+ self.addEventListener("install", () => self.skipWaiting());
17+ self.addEventListener("activate", event =>
18+ event.waitUntil(self.clients.claim())
19+ );
20+
21+ self.addEventListener("fetch", event => {
22+ const request = event.request;
23+ if (request.cache === "only-if-cached" && request.mode !== "same-origin") {
24+ return; // Chrome bug workaround
25+ }
26+
27+ // Only add isolation headers to same-origin responses.
28+ // Wrapping cross-origin responses in a new Response strips CORS
29+ // internal flags, which breaks cross-origin fetch requests (e.g. numbl
30+ // downloading the mip package manager from its GitHub release).
31+ if (new URL(request.url).origin !== self.location.origin) {
32+ return; // let the browser handle cross-origin requests normally
33+ }
34+
35+ event.respondWith(
36+ fetch(request).then(response => {
37+ if (response.status === 0) return response; // opaque response
38+
39+ const headers = new Headers(response.headers);
40+ headers.set("Cross-Origin-Embedder-Policy", "credentialless");
41+ headers.set("Cross-Origin-Opener-Policy", "same-origin");
42+
43+ return new Response(response.body, {
44+ status: response.status,
45+ statusText: response.statusText,
46+ headers,
47+ });
48+ })
49+ );
50+ });
51+} else {
52+ // --- Window scope (registration) ---
53+
54+ // Capture currentScript synchronously — it becomes null after script runs.
55+ const scriptUrl = document.currentScript && document.currentScript.src;
56+
57+ if (!window.crossOriginIsolated && navigator.serviceWorker) {
58+ navigator.serviceWorker.register(scriptUrl || "/coi-serviceworker.js").then(
59+ reg => {
60+ if (reg.installing || reg.waiting) {
61+ const sw = reg.installing || reg.waiting;
62+ sw.addEventListener("statechange", () => {
63+ if (sw.state === "activated") window.location.reload();
64+ });
65+ } else if (reg.active && !navigator.serviceWorker.controller) {
66+ // Active but not yet controlling — reload to let it intercept.
67+ window.location.reload();
68+ }
69+ },
70+ err => console.error("COI service worker registration failed:", err)
71+ );
72+ }
73+}
demo/inject-coi.mjsadded+67−0View file
@@ -0,0 +1,67 @@
1+#!/usr/bin/env node
2+/*
3+ * Make a `jupyter lite build` output cross-origin isolated:
4+ * 1. copy coi-serviceworker.js into the output root, and
5+ * 2. inject a <script> that registers it into every generated page's <head>.
6+ *
7+ * This synthesizes COOP/COEP headers client-side, so SharedArrayBuffer — and
8+ * thus numbl's cooperative cell interruption (the Stop button) — works on hosts
9+ * that can't set response headers, notably GitHub Pages. See
10+ * demo/coi-serviceworker.js.
11+ *
12+ * The <script> src is relative to each page's depth, all pointing at the single
13+ * root copy, so the service worker registers at the site root scope regardless
14+ * of the base path (works under both `/` locally and `/<repo>/` on Pages).
15+ *
16+ * Usage: node demo/inject-coi.mjs <output-dir>
17+ */
18+import {
19+ readFileSync,
20+ writeFileSync,
21+ copyFileSync,
22+ readdirSync
23+} from 'node:fs';
24+import { join, relative, dirname, sep } from 'node:path';
25+import { fileURLToPath } from 'node:url';
26+
27+const here = dirname(fileURLToPath(import.meta.url));
28+const outDir = process.argv[2];
29+if (!outDir) {
30+ console.error('Usage: node demo/inject-coi.mjs <output-dir>');
31+ process.exit(1);
32+}
33+
34+const SW = 'coi-serviceworker.js';
35+copyFileSync(join(here, SW), join(outDir, SW));
36+
37+function* htmlFiles(dir) {
38+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
39+ const p = join(dir, entry.name);
40+ if (entry.isDirectory()) {
41+ yield* htmlFiles(p);
42+ } else if (entry.isFile() && entry.name.endsWith('.html')) {
43+ yield p;
44+ }
45+ }
46+}
47+
48+let count = 0;
49+for (const file of htmlFiles(outDir)) {
50+ const html = readFileSync(file, 'utf8');
51+ if (html.includes(SW)) {
52+ continue; // already injected
53+ }
54+ const depth = relative(outDir, dirname(file)).split(sep).filter(Boolean)
55+ .length;
56+ const prefix = depth === 0 ? './' : '../'.repeat(depth);
57+ const tag = `<script src="${prefix}${SW}"></script>`;
58+ // Inject as the first thing in <head> so isolation is gained (and the
59+ // one-time reload happens) before the heavy app bundle loads.
60+ const replaced = html.replace(/<head(\s[^>]*)?>/i, m => `${m}\n ${tag}`);
61+ if (replaced === html) {
62+ continue; // no <head>, skip
63+ }
64+ writeFileSync(file, replaced);
65+ count++;
66+}
67+console.log(`coi: injected ${SW} into ${count} page(s) under ${outDir}`);
package.jsonmodified+1−1View file
@@ -55,7 +55,7 @@
5555 "@jupyterlab/rendermime-interfaces": "^3.9.0",
5656 "@jupyterlite/services": "^0.8.1",
5757 "@lumino/widgets": "^2.3.0",
58- "numbl": "^0.4.14",
58+ "numbl": "^0.4.15",
5959 "react": "^18.2.0",
6060 "react-dom": "^18.2.0"
6161 },
src/index.tsmodified+3−0View file
@@ -7,6 +7,7 @@ import { IKernelSpecs } from '@jupyterlite/services';
77 import type { IKernel } from '@jupyterlite/services';
88
99 import { NumblKernel } from './kernel';
10+import { installInterruptBridge } from './interruptBridge';
1011
1112 /** numbl's matrix logo, inlined so the spec needs no served resources. */
1213 const NUMBL_LOGO =
@@ -20,6 +21,8 @@ const kernel: JupyterFrontEndPlugin<void> = {
2021 autoStart: true,
2122 requires: [IKernelSpecs],
2223 activate: (app: JupyterFrontEnd, kernelspecs: IKernelSpecs) => {
24+ // Route the Stop button to the running numbl session (see interruptBridge).
25+ installInterruptBridge();
2326 kernelspecs.register({
2427 spec: {
2528 name: 'numbl',
src/interruptBridge.tsadded+67−0View file
@@ -0,0 +1,67 @@
1+import { KernelConnection } from '@jupyterlab/services';
2+
3+/**
4+ * Bridge that lets a *running* numbl cell be interrupted.
5+ *
6+ * JupyterLite (0.8.x) has no in-band way to interrupt a running kernel: its
7+ * `LiteKernelClient` serializes every kernel message through a single
8+ * `async-mutex`, and `interrupt()` only calls `mutex.cancel()`, which rejects
9+ * *queued* messages — the in-flight cell that holds the lock is never
10+ * signaled. `BaseKernel` has no `interrupt_request` handler either, so no
11+ * Jupyter message (control channel included) reaches a busy kernel.
12+ *
13+ * But the in-browser kernel object and the front end share the main-thread JS
14+ * realm, so we deliver the interrupt *out of band*. Every UI interrupt path —
15+ * the notebook/console toolbar Stop button, the Kernel menu, the
16+ * `*:interrupt-kernel` commands — funnels through
17+ * `KernelConnection.interrupt()`. We wrap that single method so it first
18+ * signals the matching numbl kernel (registered here by id) and then delegates
19+ * to the original, which still performs JupyterLite's queued-cell
20+ * cancellation. The numbl side cooperatively aborts the current run via a
21+ * shared `SharedArrayBuffer`, preserving the workspace.
22+ */
23+
24+/** Interrupt callbacks by kernel id (matches `BaseKernel.id`, which equals the
25+ * front-end `KernelConnection.id`). */
26+const interruptors = new Map<string, () => void>();
27+
28+/** Register a kernel's interrupt callback. Call again to replace. */
29+export function registerInterruptor(id: string, interrupt: () => void): void {
30+ interruptors.set(id, interrupt);
31+}
32+
33+/** Drop a kernel's interrupt callback (on dispose). */
34+export function unregisterInterruptor(id: string): void {
35+ interruptors.delete(id);
36+}
37+
38+let bridgeInstalled = false;
39+
40+/**
41+ * Patch `KernelConnection.prototype.interrupt` once so interrupting a kernel
42+ * also signals the registered numbl kernel of the same id. Idempotent.
43+ * `KernelConnection` is a shared singleton from `@jupyterlab/services`, so the
44+ * patch applies to every connection the app creates.
45+ */
46+export function installInterruptBridge(): void {
47+ if (bridgeInstalled) {
48+ return;
49+ }
50+ bridgeInstalled = true;
51+
52+ const proto = KernelConnection.prototype as unknown as {
53+ interrupt(...args: unknown[]): Promise<void>;
54+ };
55+ const original = proto.interrupt;
56+ proto.interrupt = function (
57+ this: { id: string },
58+ ...args: unknown[]
59+ ): Promise<void> {
60+ try {
61+ interruptors.get(this.id)?.();
62+ } catch {
63+ // Never let interrupt signaling break the built-in interrupt path.
64+ }
65+ return original.apply(this, args);
66+ };
67+}
src/kernel.tsmodified+36−0View file
@@ -6,6 +6,8 @@ import type { IKernel } from '@jupyterlite/services';
66 import { createNumblSession } from 'numbl/browser';
77 import type { NumblSession } from 'numbl/browser';
88
9+import { registerInterruptor, unregisterInterruptor } from './interruptBridge';
10+
911 /** Mime type carrying a cell's plot instructions (see src/mime.tsx). */
1012 export const FIGURE_MIME = 'application/vnd.numbl.figure+json';
1113
@@ -35,6 +37,9 @@ export class NumblKernel extends BaseKernel {
3537 constructor(options: IKernel.IOptions, contents?: Contents.IManager) {
3638 super(options);
3739 this._contents = contents ?? null;
40+ // Let the interrupt bridge stop this kernel's running cell (the Stop
41+ // button reaches us out of band; see src/interruptBridge.ts).
42+ registerInterruptor(this.id, () => this.interrupt());
3843 }
3944 /**
4045 * Handle a kernel_info_request message.
@@ -92,6 +97,17 @@ export class NumblKernel extends BaseKernel {
9297 await this._syncWorkspaceFiles(session);
9398 const result = await session.execute(content.code);
9499
100+ if (result.aborted) {
101+ // The Stop button interrupted this cell (cooperative cancellation via
102+ // the shared cancel flag). numbl left the workspace at its pre-run
103+ // state, so variables from before the cell survive. Report it the way
104+ // an interrupted cell is reported elsewhere: a KeyboardInterrupt.
105+ return this._errorReply(
106+ 'KeyboardInterrupt',
107+ result.error ?? 'Execution interrupted'
108+ );
109+ }
110+
95111 if (!result.ok) {
96112 return this._errorReply('NumblError', result.error ?? 'Unknown error');
97113 }
@@ -180,6 +196,25 @@ export class NumblKernel extends BaseKernel {
180196 // no-op
181197 }
182198
199+ /**
200+ * Cooperatively interrupt the running cell. Signals the numbl session to
201+ * abort its current `execute` at the next loop iteration or function call,
202+ * leaving the persistent workspace intact (the interrupted cell resolves
203+ * with `aborted: true`, reported as a KeyboardInterrupt). Invoked out of
204+ * band by the interrupt bridge, since JupyterLite can't message a busy
205+ * kernel (see src/interruptBridge.ts).
206+ *
207+ * A no-op when no run is in flight, or when the page is not cross-origin
208+ * isolated — then `SharedArrayBuffer` is unavailable, `session.interrupt()`
209+ * can't signal the worker, and a runaway cell can still only be stopped by
210+ * restarting the kernel.
211+ */
212+ interrupt(): void {
213+ void this._session
214+ ?.then(session => session.interrupt())
215+ .catch(() => undefined);
216+ }
217+
183218 /**
184219 * Dispose the kernel and its numbl session (worker).
185220 */
@@ -187,6 +222,7 @@ export class NumblKernel extends BaseKernel {
187222 if (this.isDisposed) {
188223 return;
189224 }
225+ unregisterInterruptor(this.id);
190226 void this._session?.then(s => s.dispose()).catch(() => undefined);
191227 this._session = null;
192228 super.dispose();
yarn.lockmodified+5−5View file
@@ -3014,7 +3014,7 @@ __metadata:
30143014 eslint-config-prettier: ^8.10.0
30153015 eslint-plugin-prettier: ^5.0.0
30163016 npm-run-all2: ^7.0.1
3017- numbl: ^0.4.14
3017+ numbl: ^0.4.15
30183018 prettier: ^3.0.0
30193019 react: ^18.2.0
30203020 react-dom: ^18.2.0
@@ -4036,9 +4036,9 @@ __metadata:
40364036 languageName: node
40374037 linkType: hard
40384038
4039-"numbl@npm:^0.4.14":
4040- version: 0.4.14
4041- resolution: "numbl@npm:0.4.14"
4039+"numbl@npm:^0.4.15":
4040+ version: 0.4.15
4041+ resolution: "numbl@npm:0.4.15"
40424042 dependencies:
40434043 fflate: ^0.8.2
40444044 h5wasm: ^0.10.3
@@ -4064,7 +4064,7 @@ __metadata:
40644064 optional: true
40654065 bin:
40664066 numbl: dist-cli/cli.js
4067- checksum: 451322a5cb3508a775b0f43b8cdaaedcd873bc4bb6245c9952fd93efecc97d5173a451f51a001e0e221b157e8cd169a44cc890e514daebca50d2ce3594261e6c
4067+ checksum: dd9b5676a78add735655a3c3fdb2a0ca3f5435cdc5b90288383e2382a2deb677b4786010c27c4d0ddf7090f3bb091c0c9b948835c070428d2321e5bd30f91bb7
40684068 languageName: node
40694069 linkType: hard
40704070