concept-collection / jupyterlite-numbl-kernel
jupyterlite-numbl-kernel / src / kernel.ts
337 lines · 11.6 KBBlameHistoryRaw
1import type { Contents, KernelMessage } from '@jupyterlab/services';
3import { BaseKernel } from '@jupyterlite/services';
4import type { IKernel } from '@jupyterlite/services';
6import { createNumblSession } from 'numbl/browser';
7import type { NumblSession } from 'numbl/browser';
9import { registerInterruptor, unregisterInterruptor } from './interruptBridge';
11/** Mime type carrying a cell's plot instructions (see src/mime.tsx). */
12export const FIGURE_MIME = 'application/vnd.numbl.figure+json';
14/**
15 * A JupyterLite kernel that runs numbl (a MATLAB-syntax numerical
16 * computing engine) entirely in the browser.
17 *
18 * Each kernel owns one numbl session (a Web Worker managed by numbl):
19 * variables persist across cells, console output streams to the running
20 * cell, and figures are published as display_data with the numbl figure
21 * mime type. Restarting the kernel disposes the session, so the next
22 * execution boots a fresh workspace.
23 *
24 * Before every execution, `.m` files sitting next to the notebook (in the
25 * JupyterLite contents) are synced into the session, so named functions can
26 * be defined in the file browser, edited in the Jupyter editor, and called
27 * from cells. numbl rescans the working directory on each execution, so
28 * edits apply on the next run. The sync is one-way and additive: deleting a
29 * `.m` file leaves its function defined until the kernel restarts.
30 */
31export class NumblKernel extends BaseKernel {
32 /**
33 * @param options Standard kernel options.
34 * @param contents The (browser-side) contents manager used to read `.m`
35 * workspace files from the notebook's directory.
36 */
37 constructor(options: IKernel.IOptions, contents?: Contents.IManager) {
38 super(options);
39 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());
43 }
44 /**
45 * Handle a kernel_info_request message.
46 */
47 async kernelInfoRequest(): Promise<KernelMessage.IInfoReplyMsg['content']> {
48 const content: KernelMessage.IInfoReply = {
49 implementation: 'numbl',
50 implementation_version: '0.1.0',
51 language_info: {
52 // numbl uses MATLAB syntax; the octave/matlab modes drive
53 // highlighting in the notebook and on nbconvert export.
54 codemirror_mode: 'octave',
55 file_extension: '.m',
56 mimetype: 'text/x-octave',
57 name: 'numbl',
58 nbconvert_exporter: 'script',
59 pygments_lexer: 'matlab',
60 version: 'numbl'
61 },
62 protocol_version: '5.3',
63 status: 'ok',
64 banner:
65 'numbl: MATLAB-syntax numerical computing, running in the browser',
66 help_links: [
67 {
68 text: 'numbl',
69 url: 'https://numbl.org'
70 }
71 ]
72 };
73 return content;
74 }
76 /**
77 * Handle an `execute_request` message: run the cell against the numbl
78 * session's persistent workspace.
79 */
80 async executeRequest(
81 content: KernelMessage.IExecuteRequestMsg['content']
82 ): Promise<KernelMessage.IExecuteReplyMsg['content']> {
83 let session: NumblSession;
84 try {
85 session = await this._sessionPromise();
86 } catch (err) {
87 // Boot failure (e.g. the mip download was unreachable). Reset so a
88 // later cell can retry, and report the failure on this cell.
89 this._session = null;
90 const message = err instanceof Error ? err.message : String(err);
91 return this._errorReply(
92 'SessionError',
93 `Failed to start numbl: ${message}`
94 );
95 }
97 await this._syncWorkspaceFiles(session);
98 const result = await session.execute(content.code);
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 }
111 if (!result.ok) {
112 return this._errorReply('NumblError', result.error ?? 'Unknown error');
113 }
115 if (result.plotInstructions.length > 0) {
116 // Round-trip through JSON so the live render path sees exactly what a
117 // reloaded notebook sees (structured-clone NaNs become nulls; the
118 // renderer restores them).
119 const instructions = JSON.parse(JSON.stringify(result.plotInstructions));
120 this.displayData({
121 data: {
122 [FIGURE_MIME]: { version: 1, plotInstructions: instructions },
123 'text/plain':
124 '<numbl figure (install jupyterlite-numbl-kernel to render)>'
125 },
126 metadata: {}
127 });
128 }
130 return {
131 status: 'ok',
132 execution_count: this.executionCount,
133 user_expressions: {}
134 };
135 }
137 /**
138 * Handle a `complete_request` message. Completion is not implemented.
139 */
140 async completeRequest(
141 content: KernelMessage.ICompleteRequestMsg['content']
142 ): Promise<KernelMessage.ICompleteReplyMsg['content']> {
143 return {
144 status: 'ok',
145 matches: [],
146 cursor_start: content.cursor_pos,
147 cursor_end: content.cursor_pos,
148 metadata: {}
149 };
150 }
152 /**
153 * Handle an `inspect_request` message. Inspection is not implemented.
154 */
155 async inspectRequest(
156 content: KernelMessage.IInspectRequestMsg['content']
157 ): Promise<KernelMessage.IInspectReplyMsg['content']> {
158 return { status: 'ok', found: false, data: {}, metadata: {} };
159 }
161 /**
162 * Handle an `is_complete_request` message: treat every submission as a
163 * complete numbl statement (the console runs on Enter).
164 */
165 async isCompleteRequest(
166 content: KernelMessage.IIsCompleteRequestMsg['content']
167 ): Promise<KernelMessage.IIsCompleteReplyMsg['content']> {
168 return { status: 'complete' };
169 }
171 /**
172 * Handle a `comm_info_request` message. Comms are not implemented.
173 */
174 async commInfoRequest(
175 content: KernelMessage.ICommInfoRequestMsg['content']
176 ): Promise<KernelMessage.ICommInfoReplyMsg['content']> {
177 return { status: 'ok', comms: {} };
178 }
180 /**
181 * Handle an `input_reply`: hand the user's line to the numbl worker, which
182 * is blocked inside `input()` waiting for it. JupyterLite delivers
183 * `input_reply` out of band (it bypasses the message mutex that the running
184 * cell's `execute_request` still holds), so this arrives mid-execution as
185 * intended. Requires cross-origin isolation; without it `input()` errors in
186 * numbl before ever prompting, so this is never reached.
187 */
188 inputReply(content: KernelMessage.IInputReplyMsg['content']): void {
189 const value =
190 'value' in content && typeof content.value === 'string'
191 ? content.value
192 : '';
193 void this._session
194 ?.then(session => session.provideInput(value))
195 .catch(() => undefined);
196 }
198 async commOpen(msg: KernelMessage.ICommOpenMsg): Promise<void> {
199 // no-op
200 }
202 async commMsg(msg: KernelMessage.ICommMsgMsg): Promise<void> {
203 // no-op
204 }
206 async commClose(msg: KernelMessage.ICommCloseMsg): Promise<void> {
207 // no-op
208 }
210 /**
211 * Cooperatively interrupt the running cell. Signals the numbl session to
212 * abort its current `execute` at the next loop iteration or function call,
213 * leaving the persistent workspace intact (the interrupted cell resolves
214 * with `aborted: true`, reported as a KeyboardInterrupt). Invoked out of
215 * band by the interrupt bridge, since JupyterLite can't message a busy
216 * kernel (see src/interruptBridge.ts).
217 *
218 * A no-op when no run is in flight, or when the page is not cross-origin
219 * isolated — then `SharedArrayBuffer` is unavailable, `session.interrupt()`
220 * can't signal the worker, and a runaway cell can still only be stopped by
221 * restarting the kernel.
222 */
223 interrupt(): void {
224 void this._session
225 ?.then(session => session.interrupt())
226 .catch(() => undefined);
227 }
229 /**
230 * Dispose the kernel and its numbl session (worker).
231 */
232 dispose(): void {
233 if (this.isDisposed) {
234 return;
235 }
236 unregisterInterruptor(this.id);
237 void this._session?.then(s => s.dispose()).catch(() => undefined);
238 this._session = null;
239 super.dispose();
240 }
242 /**
243 * Sync `.m` files from the notebook's directory (recursively) into the
244 * session VFS, preserving the relative layout. Recursing matters for
245 * MATLAB's folder-based constructs: `+namespace/`, `@class/`, and
246 * `private/` folders all live in subdirectories and must reach the
247 * session at the right paths. numbl rescans its working directory on
248 * every execution, so a file written here is callable on this same
249 * execute() call, and an edit in the Jupyter editor takes effect on the
250 * next run. Best-effort: a contents-manager error (e.g. no browser drive
251 * mounted) just skips the sync rather than failing the cell.
252 */
253 private async _syncWorkspaceFiles(session: NumblSession): Promise<void> {
254 if (!this._contents) {
255 return;
256 }
257 const root = this.location;
258 const rootPrefix = root ? root.replace(/\/$/, '') + '/' : '';
260 const syncDir = async (dir: string): Promise<void> => {
261 let listing: Contents.IModel;
262 try {
263 listing = await this._contents!.get(dir, { content: true });
264 } catch {
265 return;
266 }
267 const entries = Array.isArray(listing.content) ? listing.content : [];
268 for (const entry of entries as Contents.IModel[]) {
269 if (entry.type === 'directory') {
270 await syncDir(entry.path);
271 continue;
272 }
273 if (entry.type !== 'file' || !entry.name.endsWith('.m')) {
274 continue;
275 }
276 if (this._syncedMTimes.get(entry.path) === entry.last_modified) {
277 continue;
278 }
279 try {
280 const file = await this._contents!.get(entry.path, {
281 content: true,
282 type: 'file',
283 format: 'text'
284 });
285 // Write at the path relative to the notebook directory so that
286 // +pkg/@class/private layouts land correctly under the session root.
287 const rel = entry.path.startsWith(rootPrefix)
288 ? entry.path.slice(rootPrefix.length)
289 : entry.name;
290 session.writeFile(rel, String(file.content));
291 this._syncedMTimes.set(entry.path, entry.last_modified);
292 } catch {
293 // Skip this file; other workspace files still sync.
294 }
295 }
296 };
298 await syncDir(root);
299 }
301 /**
302 * Boot the numbl session lazily on first use, so creating the kernel is
303 * instant and boot progress (mip download, cached-package restore) streams
304 * into the first executed cell.
305 */
306 private _sessionPromise(): Promise<NumblSession> {
307 this._session ??= createNumblSession({
308 onOutput: text => this.stream({ name: 'stdout', text }),
309 onProgress: message =>
310 this.stream({ name: 'stdout', text: `[numbl] ${message}\n` }),
311 // `input()` in the running cell: prompt the front end. The worker is
312 // blocked waiting for the reply, which arrives via inputReply().
313 onInputRequest: prompt => this.inputRequest({ prompt, password: false })
314 });
315 return this._session;
316 }
318 private _errorReply(
319 ename: string,
320 formatted: string
321 ): KernelMessage.IExecuteReplyMsg['content'] {
322 const traceback = formatted.split('\n');
323 const evalue = traceback[0] ?? '';
324 this.publishExecuteError({ ename, evalue, traceback });
325 return {
326 status: 'error',
327 execution_count: this.executionCount,
328 ename,
329 evalue,
330 traceback
331 };
332 }
334 private _session: Promise<NumblSession> | null = null;
335 private readonly _contents: Contents.IManager | null;
336 private readonly _syncedMTimes = new Map<string, string>();