wstp-node 0.3.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.
package/README.md ADDED
@@ -0,0 +1,1046 @@
1
+ # wstp-backend — API Reference
2
+
3
+ **Author:** Nikolay Gromov
4
+
5
+ Native Node.js addon for Wolfram kernel communication over WSTP — supports full
6
+ notebook-style evaluation with automatic `In`/`Out` history, real-time streaming,
7
+ and an internal evaluation queue. All blocking I/O runs on the libuv thread pool;
8
+ the JS event loop is never stalled.
9
+
10
+ ```js
11
+ const { WstpSession, WstpReader, setDiagHandler } = require('./build/Release/wstp.node');
12
+ ```
13
+
14
+ ---
15
+
16
+ ## Table of Contents
17
+
18
+ 1. [Installation](#installation)
19
+ 2. [Batch mode vs interactive mode](#batch-mode-vs-interactive-mode)
20
+ 3. [Return types — `WExpr` and `EvalResult`](#return-types)
21
+ 4. [`WstpSession` — main evaluation session](#wstpsession)
22
+ - [Constructor](#constructor) — launch a kernel and open a session
23
+ - [`evaluate(expr, opts?)`](#evaluateexpr-opts) — queue an expression for evaluation; supports streaming `Print` callbacks
24
+ - [`sub(expr)`](#subexpr) — priority evaluation that jumps ahead of the queue, for quick queries during a long computation
25
+ - [`abort()`](#abort) — interrupt the currently running evaluation
26
+ - [`dialogEval(expr)`](#dialogevalexpr) — evaluate inside an active `Dialog[]` subsession
27
+ - [`exitDialog(retVal?)`](#exitdialogretval) — exit the current dialog and resume the main evaluation
28
+ - [`interrupt()`](#interrupt) — send a low-level interrupt signal to the kernel
29
+ - [`createSubsession(kernelPath?)`](#createsubsessionkernelpath) — spawn an independent parallel kernel session
30
+ - [`close()`](#close) — gracefully shut down the kernel and free resources
31
+ - [`isOpen` / `isDialogOpen`](#isopen--isdialogopen) — read-only status flags
32
+ 5. [`WstpReader` — kernel-pushed side channel](#wstpreader)
33
+ 6. [`setDiagHandler(fn)`](#setdiaghandlerfn)
34
+ 5. [Usage examples](#usage-examples)
35
+ - [Basic evaluation](#basic-evaluation)
36
+ - [Interactive mode — In/Out history](#interactive-mode--inout-history)
37
+ - [Streaming output](#streaming-output)
38
+ - [Concurrent evaluations](#concurrent-evaluations)
39
+ - [Priority `sub()` calls](#priority-sub-calls)
40
+ - [Abort a long computation](#abort-a-long-computation)
41
+ - [Dialog subsessions](#dialog-subsessions)
42
+ - [Variable monitor — peeking at a running loop](#variable-monitor--peeking-at-a-running-loop)
43
+ - [Real-time side channel (`WstpReader`)](#real-time-side-channel-wstpreader)
44
+ - [Parallel independent kernels](#parallel-independent-kernels)
45
+ 6. [Error handling](#error-handling)
46
+ 7. [Diagnostic logging](#diagnostic-logging)
47
+
48
+ ---
49
+
50
+ ## Installation
51
+
52
+ ### Prerequisites
53
+
54
+ | Requirement | Notes |
55
+ |-------------|-------|
56
+ | macOS | Tested on macOS 13+; Linux should work with minor path changes |
57
+ | Node.js ≥ 18 | Earlier versions may work but are untested |
58
+ | Clang / Xcode Command Line Tools | `xcode-select --install` |
59
+ | Wolfram Mathematica or Wolfram Engine | Provides `WolframKernel` and the WSTP SDK headers/libraries |
60
+
61
+ ### 1. Clone
62
+
63
+ ```bash
64
+ git clone https://github.com/vanbaalon/mathematica-wstp-node.git
65
+ cd mathematica-wstp-node
66
+ ```
67
+
68
+ ### 2. Install Node dependencies
69
+
70
+ ```bash
71
+ npm install
72
+ ```
73
+
74
+ This pulls in `node-addon-api` and `node-gyp` (used by the build script).
75
+
76
+ ### 3. Compile the native addon
77
+
78
+ ```bash
79
+ bash build.sh
80
+ ```
81
+
82
+ Output: `build/Release/wstp.node`
83
+
84
+ The script automatically locates the WSTP SDK inside the default Wolfram installation
85
+ (`/Applications/Wolfram 3.app/...`). If your Wolfram is installed elsewhere, edit the
86
+ `WSTP_INC` and `WSTP_LIB` variables at the top of `build.sh`.
87
+
88
+ ### 4. Run the test suite
89
+
90
+ ```bash
91
+ node test.js
92
+ ```
93
+
94
+ Expected last line: `All 28 tests passed.`
95
+
96
+ A more comprehensive suite (both modes + In/Out + comparison) lives in `tmp/tests_all.js`:
97
+
98
+ ```bash
99
+ node tmp/tests_all.js
100
+ ```
101
+
102
+ Expected last line: `All 56 tests passed.`
103
+
104
+ ### 5. Quick smoke test
105
+
106
+ ```js
107
+ const { WstpSession } = require('./build/Release/wstp.node');
108
+
109
+ (async () => {
110
+ const session = new WstpSession();
111
+ const r = await session.evaluate('Prime[10]');
112
+ console.log(r.result.value); // 29
113
+ console.log(r.cellIndex); // 1
114
+ console.log(r.outputName); // "Out[1]=" (may vary by session)
115
+ session.close();
116
+ })();
117
+ ```
118
+
119
+ **Default kernel path** (macOS): `/Applications/Wolfram 3.app/Contents/MacOS/WolframKernel`
120
+
121
+ Pass an explicit path as the first argument to `new WstpSession(path)` if yours differs.
122
+
123
+ ---
124
+
125
+ ## Batch mode vs interactive mode
126
+
127
+ The session constructor accepts an optional second argument:
128
+
129
+ ```js
130
+ // Batch mode (default) — EvaluatePacket, bypasses kernel main loop
131
+ const session = new WstpSession(kernelPath);
132
+ // or explicitly:
133
+ const session = new WstpSession(kernelPath, { interactive: false });
134
+
135
+ // Interactive mode — EnterExpressionPacket, full notebook-style evaluation
136
+ const session = new WstpSession(kernelPath, { interactive: true });
137
+ ```
138
+
139
+ ### Batch mode (`interactive: false`, default)
140
+
141
+ Uses `EvaluatePacket` which **bypasses** the kernel's main evaluation loop.
142
+ Fast and lightweight — the kernel evaluates the expression and returns the result
143
+ without touching `In[n]`/`Out[n]`/`$Line`.
144
+
145
+ - `In[n]`, `Out[n]`, `%`, `%%` are **not** populated by evaluations
146
+ - `$Line` stays at 1 regardless of how many evaluations are run
147
+ - `outputName` in `EvalResult` is always `""` in steady state
148
+ - Suitable for scripting and batch processing where history is not needed
149
+
150
+ ### Interactive mode (`interactive: true`)
151
+
152
+ Uses `EnterExpressionPacket` which runs each evaluation through the kernel's full
153
+ main loop — exactly as a Mathematica notebook does.
154
+
155
+ - `In[n]`, `Out[n]`, `%`, `%%` all work and persist across evaluations
156
+ - `$Line` increments with every evaluation
157
+ - `cellIndex` in `EvalResult` reflects the actual kernel `$Line` (not a JS counter)
158
+ - `outputName` in `EvalResult` is `"Out[n]="` for non-Null results, `""` for suppressed
159
+ - `$HistoryLength` controls memory usage (default `100`)
160
+ - Suitable for notebook-like workflows and sessions that rely on running history
161
+
162
+ ---
163
+
164
+ ## Return Types
165
+
166
+ ### `WExpr`
167
+
168
+ A Wolfram Language expression as a plain JS object. The `type` field determines the shape:
169
+
170
+ | `type` | Additional fields | Example |
171
+ |--------|------------------|---------|
172
+ | `"integer"` | `value: number` | `{ type: "integer", value: 42 }` |
173
+ | `"real"` | `value: number` | `{ type: "real", value: 3.14 }` |
174
+ | `"string"` | `value: string` | `{ type: "string", value: "hello" }` |
175
+ | `"symbol"` | `value: string` | `{ type: "symbol", value: "Pi" }` |
176
+ | `"function"` | `head: string`, `args: WExpr[]` | `{ type: "function", head: "Plus", args: [{type:"integer",value:1}, {type:"symbol",value:"x"}] }` |
177
+ | *(absent)* | `error: string` | internal error — normally never seen |
178
+
179
+ Symbols are returned with their context stripped: `System\`Pi` → `"Pi"`.
180
+
181
+ ### `EvalResult`
182
+
183
+ The full result of one `evaluate()` call:
184
+
185
+ ```ts
186
+ {
187
+ cellIndex: number; // Batch: JS-tracked counter (1-based per session).
188
+ // Interactive: kernel $Line at time of evaluation.
189
+ outputName: string; // Interactive: "Out[n]=" for non-Null results; "" for suppressed.
190
+ // Batch: derived from kernel OUTPUTNAMEPKT if sent.
191
+ result: WExpr; // the expression returned by the kernel
192
+ print: string[]; // lines written by Print[], EchoFunction[], etc.
193
+ messages: string[]; // kernel messages, e.g. "Power::infy: Infinite expression..."
194
+ aborted: boolean; // true when the evaluation was stopped by abort()
195
+ }
196
+ ```
197
+
198
+ ---
199
+
200
+ ## `WstpSession`
201
+
202
+ ### Constructor
203
+
204
+ ```js
205
+ const session = new WstpSession(kernelPath?, options?);
206
+ ```
207
+
208
+ Launches a `WolframKernel` process, connects over WSTP, and verifies that `$Output` routing
209
+ is working. Consecutive kernel launches occasionally start with broken output routing (a WSTP
210
+ quirk); the constructor detects this automatically and retries up to 3 times, so it is
211
+ transparent to callers.
212
+
213
+ | Parameter | Type | Default |
214
+ |-----------|------|---------|
215
+ | `kernelPath` | `string` | `/Applications/Wolfram 3.app/Contents/MacOS/WolframKernel` |
216
+ | `options.interactive` | `boolean` | `false` — see [Batch mode vs interactive mode](#batch-mode-vs-interactive-mode) |
217
+
218
+ Throws if the kernel cannot be launched or the WSTP link fails to activate.
219
+
220
+ ---
221
+
222
+ ### `evaluate(expr, opts?)`
223
+
224
+ ```ts
225
+ session.evaluate(expr: string, opts?: EvalOptions): Promise<EvalResult>
226
+ ```
227
+
228
+ Evaluate `expr` in the kernel and return the full result.
229
+
230
+ `expr` is passed to `ToExpression[]` on the kernel side, so it must be valid Wolfram Language
231
+ syntax. Multiple concurrent calls are serialised through an internal queue — it is safe to
232
+ fire them without awaiting.
233
+
234
+ **One call = one cell.** Newlines and semicolons inside `expr` do not split it into multiple
235
+ evaluations; the kernel sees them as a single `CompoundExpression` and returns only the last
236
+ value. Use separate `evaluate()` calls to get separate `cellIndex` / `outputName` values.
237
+ A trailing semicolon suppresses the return value (the kernel returns `Null` and `outputName`
238
+ will be `""`).
239
+
240
+ **`opts` fields** (all optional):
241
+
242
+ | Option | Type | Description |
243
+ |--------|------|-------------|
244
+ | `onPrint(line: string)` | callback | Each `Print[]` or similar output line, as it arrives |
245
+ | `onMessage(msg: string)` | callback | Each kernel warning/error, as it arrives |
246
+ | `onDialogBegin(level: number)` | callback | When `Dialog[]` opens |
247
+ | `onDialogPrint(line: string)` | callback | `Print[]` output inside a dialog |
248
+ | `onDialogEnd(level: number)` | callback | When the dialog closes |
249
+ | `interactive` | `boolean` | **Per-call override** of the session's interactive mode. `true` forces `EnterExpressionPacket` (populates `In`/`Out`); `false` forces `EvaluatePacket` (batch, no history). Omit to use the session default set in the constructor. |
250
+
251
+ All callbacks fire on the JS main thread before the Promise resolves. The Promise is
252
+ guaranteed not to resolve until all queued callback deliveries have completed.
253
+
254
+ ```js
255
+ const r = await session.evaluate('Do[Print[i]; Pause[0.3], {i,1,4}]', {
256
+ onPrint: (line) => console.log('live:', line), // fires 4 times during eval
257
+ });
258
+ // r.print is also ['1','2','3','4'] — same data, delivered after eval completes
259
+
260
+ // Session is interactive (default), but force this one call to be batch (no Out/In side-effects):
261
+ const internal = await session.evaluate('VsCodeRenderNth[1, "SVG", 1.0]', { interactive: false });
262
+
263
+ // Session is batch (default), but force this one call to go through the main loop:
264
+ const r2 = await session.evaluate('x = 42', { interactive: true });
265
+ console.log(r2.outputName); // "Out[1]="
266
+ console.log(r2.cellIndex); // 1
267
+ ```
268
+
269
+ ---
270
+
271
+ ### `sub(expr)`
272
+
273
+ ```ts
274
+ session.sub(expr: string): Promise<WExpr>
275
+ ```
276
+
277
+ Lightweight evaluation that resolves with just the result `WExpr` (no cell index,
278
+ no print/messages arrays).
279
+
280
+ `sub()` has **higher priority** than `evaluate()`: it always runs before the next
281
+ queued `evaluate()`, regardless of arrival order. If the session is currently busy,
282
+ `sub()` waits for the in-flight evaluation to finish, then runs ahead of all other
283
+ queued `evaluate()` calls. Multiple `sub()` calls are ordered FIFO among themselves.
284
+
285
+ ```js
286
+ const pid = await session.sub('$ProcessID'); // { type: 'integer', value: 12345 }
287
+ const info = await session.sub('$Version'); // { type: 'string', value: '14.1 ...' }
288
+ ```
289
+
290
+ ---
291
+
292
+ ### `abort()`
293
+
294
+ ```ts
295
+ session.abort(): boolean
296
+ ```
297
+
298
+ Interrupt the currently-running `evaluate()` call. Thread-safe — safe to call from
299
+ any callback or timer.
300
+
301
+ The in-flight `evaluate()` Promise resolves (not rejects) with:
302
+ ```js
303
+ { aborted: true, result: { type: 'symbol', value: '$Aborted' }, ... }
304
+ ```
305
+
306
+ The kernel remains alive after abort. Subsequent `evaluate()` and `sub()` calls work
307
+ normally.
308
+
309
+ ```js
310
+ const p = session.evaluate('Do[Pause[0.1], {1000}]'); // ~100 s computation
311
+ await sleep(500);
312
+ session.abort(); // cancels after ~500 ms
313
+ const r = await p;
314
+ // r.aborted === true
315
+ // r.result === { type: 'symbol', value: '$Aborted' }
316
+ ```
317
+
318
+ ---
319
+
320
+ ### `dialogEval(expr)`
321
+
322
+ ```ts
323
+ session.dialogEval(expr: string): Promise<WExpr>
324
+ ```
325
+
326
+ Evaluate `expr` inside the currently-open `Dialog[]` subsession. Rejects immediately
327
+ if `isDialogOpen` is false.
328
+
329
+ Returns just the `WExpr` result, not a full `EvalResult`.
330
+
331
+ > **Important**: kernel global state persists into and out of a dialog.
332
+ > Variables set before `Dialog[]` are in scope inside; mutations made with `dialogEval()`
333
+ > persist after the dialog closes.
334
+
335
+ ```js
336
+ const p = session.evaluate('x = 10; Dialog[]; x^2');
337
+ await pollUntil(() => session.isDialogOpen);
338
+
339
+ const xVal = await session.dialogEval('x'); // { value: 10 }
340
+ await session.dialogEval('x = 99'); // mutates kernel state
341
+ await session.exitDialog();
342
+
343
+ const r = await p; // r.result.value === 9801 (99^2)
344
+ ```
345
+
346
+ ---
347
+
348
+ ### `exitDialog(retVal?)`
349
+
350
+ ```ts
351
+ session.exitDialog(retVal?: string): Promise<null>
352
+ ```
353
+
354
+ Close the currently-open `Dialog[]` subsession.
355
+
356
+ Sends `EnterTextPacket["Return[retVal]"]` — the interactive-REPL packet that the kernel
357
+ recognises as "exit the dialog". This is **not** the same as `dialogEval('Return[]')`,
358
+ which uses `EvaluatePacket` and leaves `Return[]` unevaluated.
359
+
360
+ Resolves with `null` when `EndDialogPacket` is received.
361
+ Rejects immediately if `isDialogOpen` is false.
362
+
363
+ | Call | Effect |
364
+ |------|--------|
365
+ | `exitDialog()` | `Dialog[]` evaluates to `Null` |
366
+ | `exitDialog('42')` | `Dialog[]` evaluates to `42` |
367
+ | `exitDialog('myVar')` | `Dialog[]` evaluates to the current value of `myVar` |
368
+
369
+ ```js
370
+ // Pattern: open dialog, interact, close with a return value
371
+ const p = session.evaluate('result = Dialog[]; result * 2');
372
+ await pollUntil(() => session.isDialogOpen);
373
+ await session.dialogEval('Print["inside the dialog"]');
374
+ await session.exitDialog('21');
375
+ const r = await p; // r.result.value === 42
376
+ ```
377
+
378
+ ---
379
+
380
+ ### `interrupt()`
381
+
382
+ ```ts
383
+ session.interrupt(): boolean
384
+ ```
385
+
386
+ Send `WSInterruptMessage` to the kernel (best-effort). The C++ backend handles the
387
+ kernel's `MENUPKT` interrupt-menu response automatically by reading the type+prompt
388
+ payload and replying with bare string `"i"` (inspect mode), causing the kernel to open a
389
+ `Dialog[]` subsession. `isDialogOpen` will flip to `true` when `BEGINDLGPKT` arrives.
390
+
391
+ > The evaluated expression **must** have been started with `onDialogBegin` / `onDialogEnd`
392
+ > callbacks for the dialog to be serviced correctly.
393
+
394
+ ```js
395
+ // interrupt() — no Wolfram-side handler needed (C++ handles MENUPKT automatically)
396
+ // The evaluate() call must include dialog callbacks:
397
+ const mainEval = session.evaluate('Do[Pause[0.1], {1000}]', {
398
+ onDialogBegin: () => {},
399
+ onDialogEnd: () => {},
400
+ });
401
+ session.interrupt();
402
+ await pollUntil(() => session.isDialogOpen);
403
+ const val = await session.dialogEval('$Line');
404
+ await session.exitDialog();
405
+ await mainEval;
406
+ ```
407
+
408
+ > Optionally, install a Wolfram-side handler to bypass `MENUPKT` entirely:
409
+ > ```js
410
+ > await session.evaluate('Internal`AddHandler["Interrupt", Function[Null, Dialog[]]]');
411
+ > ```
412
+
413
+ ---
414
+
415
+ ### `createSubsession(kernelPath?)`
416
+
417
+ ```ts
418
+ session.createSubsession(kernelPath?: string): WstpSession
419
+ ```
420
+
421
+ Launch a completely independent kernel as a new `WstpSession`. The child has isolated
422
+ state (variables, definitions, memory) and must be closed with `child.close()`.
423
+
424
+ ---
425
+
426
+ ### `close()`
427
+
428
+ ```ts
429
+ session.close(): void
430
+ ```
431
+
432
+ Terminate the kernel process, close the WSTP link, and free all resources.
433
+ Idempotent — safe to call multiple times. After `close()`, calls to `evaluate()` reject
434
+ immediately with `"Session is closed"`.
435
+
436
+ ---
437
+
438
+ ### `isOpen` / `isDialogOpen`
439
+
440
+ ```ts
441
+ session.isOpen: boolean // true while the link is open and the kernel is running
442
+ session.isDialogOpen: boolean // true while inside a Dialog[] subsession
443
+ ```
444
+
445
+ ---
446
+
447
+ ## `WstpReader`
448
+
449
+ A reader that **connects to** a named WSTP link created by the kernel (via `LinkCreate`)
450
+ and receives expressions pushed from the kernel side (via `LinkWrite`).
451
+
452
+ Use this when you need real-time data from the kernel while the main link is blocked on a
453
+ long evaluation.
454
+
455
+ ### Constructor
456
+
457
+ ```js
458
+ const reader = new WstpReader(linkName, protocol?);
459
+ ```
460
+
461
+ | Parameter | Type | Default |
462
+ |-----------|------|---------|
463
+ | `linkName` | `string` | *(required)* — value of `linkObject[[1]]` or `LinkName[linkObject]` on the Wolfram side |
464
+ | `protocol` | `string` | `"TCPIP"` |
465
+
466
+ Throws if the connection fails. The WSTP handshake (`WSActivate`) is deferred to the
467
+ first `readNext()` call, so the constructor never blocks the JS main thread.
468
+
469
+ ### `readNext()`
470
+
471
+ ```ts
472
+ reader.readNext(): Promise<WExpr>
473
+ ```
474
+
475
+ Block (on the thread pool) until the kernel writes the next expression with `LinkWrite`.
476
+ Resolves with the expression as a `WExpr`.
477
+
478
+ Rejects when the kernel closes the link (`LinkClose[link]`) or the link encounters an error.
479
+
480
+ ### `close()` / `isOpen`
481
+
482
+ ```ts
483
+ reader.close(): void
484
+ reader.isOpen: boolean
485
+ ```
486
+
487
+ ### Full pattern
488
+
489
+ ```wolfram
490
+ (* Wolfram side — create a push link *)
491
+ $mon = LinkCreate[LinkProtocol -> "TCPIP"];
492
+ linkName = $mon[[1]]; (* share this string with the JS side somehow *)
493
+
494
+ (* Write immediately, then pause (not: pause then write) *)
495
+ Do[
496
+ LinkWrite[$mon, {i, randomVal}];
497
+ Pause[0.5],
498
+ {i, 1, 20}
499
+ ];
500
+ Pause[1]; (* give reader time to drain final value *)
501
+ LinkClose[$mon];
502
+ ```
503
+
504
+ ```js
505
+ // JS side — connect and read
506
+ const reader = new WstpReader(linkName, 'TCPIP');
507
+ try {
508
+ while (reader.isOpen) {
509
+ const v = await reader.readNext();
510
+ console.log('received:', JSON.stringify(v));
511
+ }
512
+ } catch (e) {
513
+ if (!e.message.includes('closed')) throw e; // normal link-close rejection
514
+ } finally {
515
+ reader.close();
516
+ }
517
+ ```
518
+
519
+ > **Timing rules for reliable delivery**:
520
+ > - Call `LinkWrite[link, expr]` *before* any `Pause[]` after each value.
521
+ > A `Pause` before `LinkWrite` can cause the reader to block inside `WSGetType`, which
522
+ > then races with the simultaneous `LinkClose` on the last value.
523
+ > - Add `Pause[1]` before `LinkClose` so the reader receives the final expression before
524
+ > the link-close signal arrives.
525
+
526
+ ---
527
+
528
+ ## `setDiagHandler(fn)`
529
+
530
+ ```ts
531
+ setDiagHandler(fn: ((msg: string) => void) | null | undefined): void
532
+ ```
533
+
534
+ Register a JS callback that receives internal diagnostic messages from the C++ layer.
535
+ The callback fires on the JS main thread. Pass `null` to clear.
536
+
537
+ Messages cover:
538
+ - `[Session]` — kernel launch, restart attempts, WarmUp results
539
+ - `[WarmUp]` — per-attempt `$WARMUP$` probe
540
+ - `[Eval] pkt=N` — every WSTP packet in the evaluation drain loop
541
+ - `[TSFN][onPrint] dispatch +Nms "..."` — TSFN call timestamp (compare with your
542
+ JS callback timestamp to measure delivery latency)
543
+ - `[WstpReader]` — WSActivate, spin-wait trace, ReadExprRaw result
544
+
545
+ ```js
546
+ setDiagHandler((msg) => {
547
+ const ts = new Date().toISOString().slice(11, 23);
548
+ process.stderr.write(`[diag ${ts}] ${msg}\n`);
549
+ });
550
+
551
+ // Disable:
552
+ setDiagHandler(null);
553
+ ```
554
+
555
+ **Alternative** — set `DEBUG_WSTP=1` in the environment to write the same messages
556
+ directly to `stderr` (no JS handler needed, useful in scripts):
557
+
558
+ ```bash
559
+ DEBUG_WSTP=1 node compute.js 2>diag.txt
560
+ ```
561
+
562
+ ---
563
+
564
+ ## Usage Examples
565
+
566
+ ### Basic evaluation
567
+
568
+ ```js
569
+ const { WstpSession } = require('./build/Release/wstp.node');
570
+
571
+ const KERNEL = '/Applications/Wolfram 3.app/Contents/MacOS/WolframKernel';
572
+ const session = new WstpSession(KERNEL);
573
+
574
+ // Simple expression
575
+ const r = await session.evaluate('Expand[(a + b)^4]');
576
+ console.log(r.result);
577
+ // { type: 'function', head: 'Plus', args: [ ... ] }
578
+
579
+ // Integer result
580
+ const n = await session.evaluate('Prime[100]');
581
+ console.log(n.result.value); // 541
582
+
583
+ // String result
584
+ const v = await session.evaluate('"Hello, " <> "World"');
585
+ console.log(v.result.value); // "Hello, World"
586
+
587
+ session.close();
588
+ ```
589
+
590
+ ---
591
+
592
+ ### Interactive mode — In/Out history
593
+
594
+ With `{ interactive: true }` the kernel runs each evaluation through its full main loop,
595
+ populating `In[n]`, `Out[n]`, and `%`/`%%` exactly as in a Mathematica notebook.
596
+
597
+ ```js
598
+ const session = new WstpSession(KERNEL, { interactive: true });
599
+
600
+ // Out[n] is populated automatically; cellIndex reflects the kernel's actual $Line
601
+ const r1 = await session.evaluate('Prime[10]');
602
+ console.log(r1.cellIndex); // e.g. 1
603
+ console.log(r1.outputName); // "Out[1]="
604
+ console.log(r1.result.value); // 29
605
+
606
+ // % and %% return last / second-to-last outputs
607
+ const r2 = await session.evaluate('42');
608
+ const pct = await session.evaluate('%');
609
+ console.log(pct.result.value); // 42
610
+
611
+ // Out[n] is accessible from later evaluations
612
+ const r3 = await session.evaluate('6 * 7');
613
+ const arith = await session.evaluate(`Out[${r3.cellIndex}] + 1`);
614
+ console.log(arith.result.value); // 43
615
+
616
+ // In[n] stores the input; it evaluates to the result of the expression
617
+ const r4 = await session.evaluate('2 + 2');
618
+ const inVal = await session.sub(`In[${r4.cellIndex}]`);
619
+ console.log(inVal.value); // 4
620
+
621
+ // Suppressed evaluations (trailing ;) have empty outputName
622
+ // but Out[n] is still stored internally by the kernel
623
+ const r5 = await session.evaluate('77;');
624
+ console.log(r5.outputName); // ""
625
+ console.log(r5.result.value); // "System`Null"
626
+ const out5 = await session.sub(`Out[${r5.cellIndex}]`);
627
+ console.log(out5.value); // 77 (stored internally)
628
+
629
+ // $Line tracks the kernel counter
630
+ const line = await session.sub('$Line');
631
+ console.log(line.value); // r5.cellIndex + 1
632
+
633
+ session.close();
634
+ ```
635
+
636
+ ---
637
+
638
+ ### Streaming output
639
+
640
+ Callbacks fire in real time as the kernel produces output, before the Promise resolves.
641
+
642
+ ```js
643
+ const lines = [];
644
+ const r = await session.evaluate(
645
+ 'Do[Print["step " <> ToString[k]]; Pause[0.5], {k, 1, 5}]',
646
+ {
647
+ onPrint: (line) => { lines.push(line); console.log('[live]', line); },
648
+ onMessage: (msg) => console.warn('[msg]', msg),
649
+ }
650
+ );
651
+ // lines === ['step 1', 'step 2', 'step 3', 'step 4', 'step 5']
652
+ // r.print === ['step 1', 'step 2', 'step 3', 'step 4', 'step 5'] (same data)
653
+ // r.result.value === 'Null'
654
+
655
+ // Use a promise latch if you need to confirm delivery before acting:
656
+ let resolveAll;
657
+ const allFired = new Promise(r => resolveAll = r);
658
+ let count = 0;
659
+ await session.evaluate('Do[Print[i]; Pause[0.2], {i, 4}]', {
660
+ onPrint: () => { if (++count === 4) resolveAll(); }
661
+ });
662
+ await Promise.race([allFired, timeout(5000)]);
663
+ console.assert(count === 4);
664
+ ```
665
+
666
+ ---
667
+
668
+ ### Concurrent evaluations
669
+
670
+ All queued evaluations run in strict FIFO order — the link is never corrupted.
671
+
672
+ ```js
673
+ // Fire all three at once; results arrive in the same order they were queued.
674
+ const [r1, r2, r3] = await Promise.all([
675
+ session.evaluate('Pause[1]; "first"'),
676
+ session.evaluate('Pause[1]; "second"'),
677
+ session.evaluate('Pause[1]; "third"'),
678
+ ]);
679
+ // Total time: ~3 s (serialised, not parallel)
680
+ // r1.result.value === 'first', r2.result.value === 'second', etc.
681
+ ```
682
+
683
+ ---
684
+
685
+ ### Priority `sub()` calls
686
+
687
+ `sub()` always jumps ahead of queued `evaluate()` calls — ideal for UI queries like
688
+ "what is the current value of this variable?" while a long computation is running.
689
+
690
+ ```js
691
+ // Start a slow batch job
692
+ const batch = session.evaluate('Pause[5]; result = 42');
693
+
694
+ // While it runs, query progress via sub() — fires after the in-flight eval finishes
695
+ // but before any other queued evaluate():
696
+ const val = await session.sub('$Version'); // runs next
697
+ const pid = await session.sub('$ProcessID'); // runs after val
698
+
699
+ await batch;
700
+ ```
701
+
702
+ ---
703
+
704
+ ### Abort a long computation
705
+
706
+ ```js
707
+ // Use Do[Pause[...]] so the kernel checks for abort signals regularly
708
+ const p = session.evaluate('Do[Pause[0.1], {1000}]');
709
+
710
+ await new Promise(r => setTimeout(r, 500));
711
+ session.abort();
712
+
713
+ const r = await p;
714
+ console.log(r.aborted); // true
715
+ console.log(r.result.value); // '$Aborted'
716
+
717
+ // Session is still alive — keep evaluating
718
+ const r2 = await session.evaluate('2 + 2');
719
+ console.log(r2.result.value); // 4
720
+ ```
721
+
722
+ ---
723
+
724
+ ### Dialog subsessions
725
+
726
+ `Dialog[]` opens an interactive subsession inside the kernel. Use `dialogEval()` to
727
+ send expressions to it and `exitDialog()` to close it.
728
+
729
+ ```js
730
+ // Basic dialog round-trip
731
+ const evalDone = session.evaluate('Dialog[]; "finished"', {
732
+ onDialogBegin: (level) => console.log('dialog opened at level', level),
733
+ onDialogEnd: (level) => console.log('dialog closed at level', level),
734
+ });
735
+
736
+ // Wait for the dialog to open (isDialogOpen flips to true when BEGINDLGPKT arrives)
737
+ await pollUntil(() => session.isDialogOpen);
738
+
739
+ const two = await session.dialogEval('1 + 1'); // { type: 'integer', value: 2 }
740
+ const pi = await session.dialogEval('N[Pi]'); // { type: 'real', value: 3.14159... }
741
+
742
+ await session.exitDialog(); // sends EnterTextPacket["Return[]"]
743
+ const r = await evalDone; // r.result.value === 'finished'
744
+
745
+ // exitDialog with a return value
746
+ const p2 = session.evaluate('x = Dialog[]; x^2');
747
+ await pollUntil(() => session.isDialogOpen);
748
+ await session.exitDialog('7'); // Dialog[] returns 7
749
+ const r2 = await p2; // r2.result.value === 49
750
+
751
+ // dialogEval with Print[] inside
752
+ const prints = [];
753
+ const p3 = session.evaluate('Dialog[]', {
754
+ onDialogPrint: (line) => prints.push(line),
755
+ });
756
+ await pollUntil(() => session.isDialogOpen);
757
+ await session.dialogEval('Print["hello from the dialog"]');
758
+ // Use a promise latch if you need delivery confirmation before exitDialog:
759
+ await session.exitDialog();
760
+ await p3;
761
+ // prints === ['hello from the dialog']
762
+ ```
763
+
764
+ > **`dialogEval('Return[]')` does NOT close the dialog.**
765
+ > `Return[]` via `EvaluatePacket` is unevaluated at top level — there is no enclosing
766
+ > structure to return from. Only `exitDialog()` (which uses `EnterTextPacket`) truly
767
+ > exits the dialog.
768
+
769
+ ---
770
+
771
+ ### Variable monitor — peeking at a running loop
772
+
773
+ You can inspect the current value of any variable while a long computation runs, without
774
+ aborting it. The trick is to use the `Dialog[]`/interrupt mechanism to pause the kernel
775
+ briefly, peek, then resume — all transparent to the running evaluation.
776
+
777
+ #### How it works
778
+
779
+ 1. `session.interrupt()` posts `WSInterruptMessage` to the kernel.
780
+ 2. The kernel suspends the running evaluation and sends `MENUPKT` (the interrupt menu).
781
+ 3. The C++ backend automatically handles `MENUPKT` by reading its type+prompt payload and
782
+ responding with the bare string `"i"` (inspect mode) — no Wolfram-side handler needed.
783
+ 4. The kernel opens `Dialog[]` inline: sends `TEXTPKT` (option list) → `BEGINDLGPKT` →
784
+ `INPUTNAMEPKT`. `isDialogOpen` flips to `true` when `INPUTNAMEPKT` arrives.
785
+ 5. The JS monitor calls `session.dialogEval('i')` to read the current loop variable, then
786
+ `session.exitDialog()` to resume.
787
+ 6. When the main eval resolves the monitor stops.
788
+
789
+ > **Wolfram-side interrupt handler (optional):**
790
+ > If `Internal\`AddHandler["Interrupt", Function[{}, Dialog[]]]` is installed in
791
+ > `init.wl`, the Wolfram handler opens `Dialog[]` directly and the kernel sends
792
+ > `BEGINDLGPKT` without going through `MENUPKT`. Either path works — the C++ backend
793
+ > handles both the `MENUPKT` (interrupt-menu) path and the direct `BEGINDLGPKT` path.
794
+
795
+ #### Prerequisites
796
+
797
+ No special Wolfram-side configuration is required. The C++ backend handles `MENUPKT`
798
+ automatically. The evaluated expression **must** be started with `onDialogBegin` /
799
+ `onDialogEnd` callbacks so the drain loop is in dialog-aware mode:
800
+
801
+ ```js
802
+ // The evaluate() call must include dialog callbacks so the drain loop
803
+ // handles BEGINDLGPKT / ENDDLGPKT correctly.
804
+ const mainEval = session.evaluate(expr, {
805
+ onDialogBegin: (_level) => {},
806
+ onDialogEnd: (_level) => {},
807
+ });
808
+ ```
809
+
810
+ #### Full example
811
+
812
+ ```js
813
+ const { WstpSession } = require('./build/Release/wstp.node');
814
+ const KERNEL = '/Applications/Wolfram 3.app/Contents/MacOS/WolframKernel';
815
+ const session = new WstpSession(KERNEL);
816
+
817
+ // Helper: poll until predicate returns true or deadline expires
818
+ const pollUntil = (pred, intervalMs = 50, timeoutMs = 3000) =>
819
+ new Promise((resolve, reject) => {
820
+ const deadline = Date.now() + timeoutMs;
821
+ const tick = () => {
822
+ if (pred()) return resolve();
823
+ if (Date.now() > deadline) return reject(new Error('pollUntil: timeout'));
824
+ setTimeout(tick, intervalMs);
825
+ };
826
+ tick();
827
+ });
828
+
829
+ // Step 1: install the interrupt handler
830
+ await session.evaluate(
831
+ 'Internal`AddHandler["Interrupt", Function[{}, Dialog[]]]'
832
+ );
833
+
834
+ // Step 2: start the long computation — NOT awaited, runs in background
835
+ // evaluate() accepts onDialogBegin/End so the drain loop services dialogs
836
+ const mainEval = session.evaluate(
837
+ 'Do[i = k; Pause[0.2], {k, 1, 50}]; "done"',
838
+ {
839
+ onDialogBegin: (_level) => { /* optional: log */ },
840
+ onDialogEnd: (_level) => { /* optional: log */ },
841
+ }
842
+ );
843
+
844
+ // Step 3: variable monitor — peek at `i` every second until mainEval resolves
845
+ let running = true;
846
+ mainEval.finally(() => { running = false; });
847
+
848
+ async function monitor() {
849
+ while (running) {
850
+ await new Promise(r => setTimeout(r, 1000));
851
+ if (!running) break;
852
+
853
+ // Send WSInterruptMessage → Wolfram handler opens Dialog[]
854
+ const sent = session.interrupt();
855
+ if (!sent) break; // session closed or idle
856
+
857
+ // Wait for BEGINDLGPKT to arrive (C++ sets isDialogOpen = true)
858
+ try {
859
+ await pollUntil(() => session.isDialogOpen, 50, 3000);
860
+ } catch (_) {
861
+ // Dialog didn't open — computation may have already finished
862
+ break;
863
+ }
864
+
865
+ // Read current value of the loop variable
866
+ let val;
867
+ try {
868
+ val = await session.dialogEval('i');
869
+ } catch (e) {
870
+ await session.exitDialog().catch(() => {});
871
+ break;
872
+ }
873
+ console.log(`[monitor] i = ${val.value}`);
874
+
875
+ // Resume the main evaluation
876
+ await session.exitDialog();
877
+ }
878
+ }
879
+
880
+ await Promise.all([
881
+ mainEval,
882
+ monitor(),
883
+ ]);
884
+
885
+ const r = await mainEval;
886
+ console.log('final:', r.result.value); // "done"
887
+ session.close();
888
+ ```
889
+
890
+ Expected output (approximate — timing depends on CPU load):
891
+
892
+ ```
893
+ [monitor] i = 5
894
+ [monitor] i = 10
895
+ [monitor] i = 15
896
+ ...
897
+ final: done
898
+ ```
899
+
900
+ #### Extension implementation note
901
+
902
+ For a VS Code notebook extension using this backend:
903
+
904
+ - **No Wolfram-side interrupt handler is required** — the C++ backend handles `MENUPKT`
905
+ automatically by responding with `"i"` (inspect mode). Optionally, installing
906
+ `Internal\`AddHandler["Interrupt", Function[{}, Dialog[]]]` in `init.wl` bypasses
907
+ `MENUPKT` entirely and sends `BEGINDLGPKT` directly; both paths are supported.
908
+ - The ⌥⇧↵ command flow is: `session.interrupt()` → poll `isDialogOpen` → `session
909
+ .dialogEval(cellCode)` → render result as `"Dialog: Out"` in the cell → `session
910
+ .exitDialog()`.
911
+ - The `evaluate()` call for the main long computation **must** pass `onDialogBegin`,
912
+ `onDialogPrint`, and `onDialogEnd` callbacks; these wire the C++ `BEGINDLGPKT` /
913
+ `ENDDLGPKT` handlers that drive `isDialogOpen` and the dialog inner loop.
914
+ - `dialogEval()` and `exitDialog()` push to `dialogQueue_` in C++, which the drain loop
915
+ on the thread-pool thread services between kernel packets — no second link/thread needed.
916
+ - Dialog results from inspect mode are returned as `RETURNTEXTPKT` (OutputForm text) rather
917
+ than `RETURNPKT` (full WL expression); the C++ SDR layer parses these transparently.
918
+
919
+ ---
920
+
921
+ ### Real-time side channel (`WstpReader`)
922
+
923
+ Use `WstpReader` to receive kernel-pushed data while a long evaluation is running on the
924
+ main link.
925
+
926
+ ```js
927
+ // Step 1: create the push link inside the kernel and get its name
928
+ await session.evaluate('$pushLink = LinkCreate[LinkProtocol -> "TCPIP"]');
929
+ const { result: nameExpr } = await session.evaluate('$pushLink[[1]]');
930
+ const linkName = nameExpr.value; // e.g. "60423@127.0.0.1,0@127.0.0.1"
931
+
932
+ // Step 2: connect the JS reader
933
+ const reader = new WstpReader(linkName, 'TCPIP');
934
+
935
+ // Step 3: start the kernel writer (NOT awaited — runs concurrently)
936
+ const bgWriter = session.evaluate(
937
+ 'Do[LinkWrite[$pushLink, {i, RandomReal[]}]; Pause[0.5], {i, 1, 10}];' +
938
+ 'Pause[1]; LinkClose[$pushLink]; "writer done"'
939
+ );
940
+
941
+ // Step 4: read 10 values in real time
942
+ const received = [];
943
+ try {
944
+ for (let i = 0; i < 10; i++) {
945
+ const v = await reader.readNext();
946
+ // v = { type: 'function', head: 'List', args: [{value:i}, {value:rand}] }
947
+ received.push(v);
948
+ console.log(`item ${i + 1}:`, v.args[0].value, v.args[1].value);
949
+ }
950
+ } finally {
951
+ reader.close();
952
+ try { await bgWriter; } catch (_) {}
953
+ }
954
+ ```
955
+
956
+ ---
957
+
958
+ ### Parallel independent kernels
959
+
960
+ Each `WstpSession` is an entirely separate process with its own state.
961
+
962
+ ```js
963
+ // Launch two kernels in parallel
964
+ const [ka, kb] = await Promise.all([
965
+ Promise.resolve(new WstpSession(KERNEL)),
966
+ Promise.resolve(new WstpSession(KERNEL)),
967
+ ]);
968
+
969
+ // Run independent computations simultaneously
970
+ const [ra, rb] = await Promise.all([
971
+ ka.evaluate('Sum[1/k^2, {k, 1, 10000}]'),
972
+ kb.evaluate('Sum[1/k^3, {k, 1, 10000}]'),
973
+ ]);
974
+
975
+ console.log(ra.result); // Pi^2/6 approximation
976
+ console.log(rb.result); // Apéry's constant approximation
977
+
978
+ ka.close();
979
+ kb.close();
980
+ ```
981
+
982
+ ---
983
+
984
+ ## Error Handling
985
+
986
+ | Situation | Behaviour |
987
+ |-----------|-----------|
988
+ | Syntax error in `expr` | Kernel sends a message; `evaluate()` resolves with `messages: ['...']` and `result: { type: 'symbol', value: 'Null' }` or `'$Failed'` |
989
+ | Expression too deep (> 512 nesting levels) | `evaluate()` **rejects** with `"expression too deep"` — the session stays alive |
990
+ | Abort | `evaluate()` **resolves** with `aborted: true`, `result.value === '$Aborted'` |
991
+ | Kernel crashes | `evaluate()` rejects with a link error message — create a new `WstpSession` |
992
+ | `dialogEval()` / `exitDialog()` when no dialog open | Rejects with `"no dialog subsession is open"` |
993
+ | `evaluate()` after `close()` | Rejects with `"Session is closed"` |
994
+ | `WstpReader.readNext()` after link closes | Rejects with a link-closed error |
995
+
996
+ ```js
997
+ // Robust evaluate wrapper
998
+ async function safeEval(session, expr) {
999
+ try {
1000
+ const r = await session.evaluate(expr);
1001
+ if (r.aborted) return { ok: false, reason: 'aborted' };
1002
+ if (r.messages.length) console.warn('kernel messages:', r.messages);
1003
+ return { ok: true, result: r.result };
1004
+ } catch (e) {
1005
+ return { ok: false, reason: e.message };
1006
+ }
1007
+ }
1008
+ ```
1009
+
1010
+ ---
1011
+
1012
+ ## Diagnostic Logging
1013
+
1014
+ Two mechanisms — both disabled by default, zero overhead when off:
1015
+
1016
+ ### `setDiagHandler(fn)` — JS callback
1017
+
1018
+ ```js
1019
+ const { setDiagHandler } = require('./build/Release/wstp.node');
1020
+
1021
+ setDiagHandler((msg) => {
1022
+ process.stderr.write(`[${new Date().toISOString().slice(11, 23)}] ${msg}\n`);
1023
+ });
1024
+
1025
+ // Measure TSFN delivery latency:
1026
+ // C++ logs: "[TSFN][onPrint] dispatch +142ms ..."
1027
+ // Your handler timestamp - 142ms = module load time offset
1028
+ // Comparing both gives end-to-end callback delivery time
1029
+
1030
+ setDiagHandler(null); // disable
1031
+ ```
1032
+
1033
+ ### `DEBUG_WSTP=1` — direct stderr from C++
1034
+
1035
+ ```bash
1036
+ DEBUG_WSTP=1 node compute.js 2>diag.txt
1037
+ cat diag.txt
1038
+ # [wstp +23ms] [Session] restart attempt 1 — $Output routing broken on previous kernel
1039
+ # [wstp +1240ms] [WarmUp] $Output routing verified on attempt 1
1040
+ # [wstp +1503ms] [Eval] pkt=8
1041
+ # [wstp +1503ms] [Eval] pkt=2
1042
+ # [wstp +1503ms] [TSFN][onPrint] dispatch +1503ms "step 1"
1043
+ # ...
1044
+ ```
1045
+
1046
+ Timestamps are module-relative milliseconds (since the addon was loaded).