6b31a9aSync .m workspace files from the notebook directory into the sessionJeremy Magland 1import type { Contents, KernelMessage } from '@jupyterlab/services';
3import { BaseKernel } from '@jupyterlite/services';
6b31a9aSync .m workspace files from the notebook directory into the sessionJeremy Magland 4import type { IKernel } from '@jupyterlite/services';
6import { createNumblSession } from 'numbl/browser';
7import type { NumblSession } from 'numbl/browser';
cf2d698Add cooperative cell interrupt (Stop button)Jeremy Magland 9import { registerInterruptor, unregisterInterruptor } from './interruptBridge';
41c072cjupyterlite-numbl-kernel: MATLAB-syntax kernel for JupyterLiteJeremy Magland 11/** Mime type carrying a cell's plot instructions (see src/mime.tsx). */
12export const FIGURE_MIME = 'application/vnd.numbl.figure+json';
14/**
80cd178Update numbl links and tidy copyJeremy Magland 15 * A JupyterLite kernel that runs numbl (a MATLAB-syntax numerical
16 * computing engine) entirely in the browser.
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.
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
80cd178Update numbl links and tidy copyJeremy Magland 27 * from cells. numbl rescans the working directory on each execution, so
6b31a9aSync .m workspace files from the notebook directory into the sessionJeremy Magland 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.
31export class NumblKernel extends BaseKernel {
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;
cf2d698Add cooperative cell interrupt (Stop button)Jeremy Magland 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());
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: {
f44f6a3Rebrand numbl-first: this runs numbl (which uses MATLAB syntax)Jeremy Magland 52 // numbl uses MATLAB syntax; the octave/matlab modes drive
53 // highlighting in the notebook and on nbconvert export.
41c072cjupyterlite-numbl-kernel: MATLAB-syntax kernel for JupyterLiteJeremy Magland 54 codemirror_mode: 'octave',
55 file_extension: '.m',
56 mimetype: 'text/x-octave',
f44f6a3Rebrand numbl-first: this runs numbl (which uses MATLAB syntax)Jeremy Magland 57 name: 'numbl',
41c072cjupyterlite-numbl-kernel: MATLAB-syntax kernel for JupyterLiteJeremy Magland 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',
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 }
6b31a9aSync .m workspace files from the notebook directory into the sessionJeremy Magland 97 await this._syncWorkspaceFiles(session);
41c072cjupyterlite-numbl-kernel: MATLAB-syntax kernel for JupyterLiteJeremy Magland 98 const result = await session.execute(content.code);
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 }
41c072cjupyterlite-numbl-kernel: MATLAB-syntax kernel for JupyterLiteJeremy Magland 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':
80cd178Update numbl links and tidy copyJeremy Magland 124 '<numbl figure (install jupyterlite-numbl-kernel to render)>'
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
f44f6a3Rebrand numbl-first: this runs numbl (which uses MATLAB syntax)Jeremy Magland 163 * complete numbl statement (the console runs on Enter).
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 /**
851d14eSupport input() (stdin)Jeremy Magland 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.
188 inputReply(content: KernelMessage.IInputReplyMsg['content']): void {
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);
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 }
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 }
230 * Dispose the kernel and its numbl session (worker).
231 */
232 dispose(): void {
233 if (this.isDisposed) {
234 return;
235 }
cf2d698Add cooperative cell interrupt (Stop button)Jeremy Magland 236 unregisterInterruptor(this.id);
41c072cjupyterlite-numbl-kernel: MATLAB-syntax kernel for JupyterLiteJeremy Magland 237 void this._session?.then(s => s.dispose()).catch(() => undefined);
238 this._session = null;
239 super.dispose();
240 }
9ff4e5bRestructure demo into a full guided tour with OOP and namespacesJeremy Magland 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.
253 private async _syncWorkspaceFiles(session: NumblSession): Promise<void> {
254 if (!this._contents) {
255 return;
256 }
9ff4e5bRestructure demo into a full guided tour with OOP and namespacesJeremy Magland 257 const root = this.location;
258 const rootPrefix = root ? root.replace(/\/$/, '') + '/' : '';
260 const syncDir = async (dir: string): Promise<void> => {
261 let listing: Contents.IModel;
9ff4e5bRestructure demo into a full guided tour with OOP and namespacesJeremy Magland 263 listing = await this._contents!.get(dir, { content: true });
6b31a9aSync .m workspace files from the notebook directory into the sessionJeremy Magland 264 } catch {
9ff4e5bRestructure demo into a full guided tour with OOP and namespacesJeremy Magland 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);
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 =>
851d14eSupport input() (stdin)Jeremy Magland 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 })
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;
6b31a9aSync .m workspace files from the notebook directory into the sessionJeremy Magland 335 private readonly _contents: Contents.IManager | null;
336 private readonly _syncedMTimes = new Map<string, string>();