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/src/addon.cc ADDED
@@ -0,0 +1,2116 @@
1
+ // =============================================================================
2
+ // wstp-backend/src/addon.cc (v6 — dialog subsession support)
3
+ //
4
+ // Architecture:
5
+ // Execute() runs on the libuv thread pool → does ALL blocking WSTP I/O,
6
+ // stores result in plain C++ structs (WExpr / EvalResult, no NAPI).
7
+ // OnOK() runs on the JS main thread → converts to Napi::Value.
8
+ //
9
+ // EvalResult captures everything the kernel sends for one cell:
10
+ // cellIndex (1-based, tracked by WstpSession::nextLine_), outputName,
11
+ // return value, Print[] lines, and messages.
12
+ // This means the Node.js event loop is NEVER blocked, so abort() fires
13
+ // correctly while Execute() is waiting inside WSNextPacket() on the thread pool.
14
+ // =============================================================================
15
+
16
+ #include <napi.h>
17
+ #include <wstp.h>
18
+
19
+ #include <atomic>
20
+ #include <chrono>
21
+ #include <cstdint>
22
+ #include <thread>
23
+ #include <functional>
24
+ #include <memory>
25
+ #include <mutex>
26
+ #include <queue>
27
+ #include <string>
28
+ #include <thread>
29
+ #include <vector>
30
+
31
+ #include <signal.h> // kill(), SIGTERM
32
+ #include <sys/types.h>
33
+
34
+ // ===========================================================================
35
+ // Plain C++ expression tree — no NAPI, safe to build on any thread.
36
+ // ===========================================================================
37
+ struct WExpr {
38
+ enum Kind { Integer, Real, String, Symbol, Function, WError } kind = WError;
39
+
40
+ int64_t intVal = 0;
41
+ double realVal = 0.0;
42
+ std::string strVal; // string content, symbol name, or error msg
43
+ std::string head; // function head symbol
44
+ std::vector<WExpr> args; // function arguments
45
+
46
+ static WExpr mkError(const std::string& msg) {
47
+ WExpr e; e.kind = WError; e.strVal = msg; return e;
48
+ }
49
+ static WExpr mkSymbol(const std::string& name) {
50
+ WExpr e; e.kind = Symbol; e.strVal = name; return e;
51
+ }
52
+ };
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // EvalResult — everything the kernel sends for one cell (thread-pool safe).
56
+ // ---------------------------------------------------------------------------
57
+ struct EvalResult {
58
+ int64_t cellIndex = 0; // 1-based counter, tracked by WstpSession::nextLine_
59
+ std::string outputName; // "Out[n]=" when result is non-Null, "" otherwise
60
+ WExpr result; // the ReturnPacket payload
61
+ std::vector<std::string> print; // TextPacket lines in order
62
+ std::vector<std::string> messages; // e.g. "Power::infy: Infinite expression..."
63
+ bool aborted = false;
64
+ };
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // EvalOptions — optional streaming callbacks for one evaluate() call.
68
+ // ThreadSafeFunctions (TSFNs) are safe to call from any thread, which is
69
+ // what makes streaming from Execute() (thread pool) possible.
70
+ // ---------------------------------------------------------------------------
71
+
72
+ // Latch that fires fn() once after ALL parties have called done().
73
+ // remaining is initialised to numTsfns + 1 (the +1 is for OnOK / OnError).
74
+ // IMPORTANT: all calls happen on the main thread — no atomics needed.
75
+ struct CompleteCtx {
76
+ int remaining;
77
+ std::function<void()> fn; // set by OnOK/OnError; called when remaining → 0
78
+ void done() {
79
+ if (--remaining == 0) { if (fn) fn(); delete this; }
80
+ }
81
+ };
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // DialogRequest — one dialogEval() call queued for the thread pool.
85
+ // Written on the main thread; consumed exclusively on the thread pool while
86
+ // the dialog inner loop is running. The TSFN delivers the result back.
87
+ // ---------------------------------------------------------------------------
88
+ struct DialogRequest {
89
+ std::string expr;
90
+ bool useEnterText = false; // true → EnterTextPacket; false → EvaluatePacket
91
+ Napi::ThreadSafeFunction resolve; // NonBlockingCall'd with the WExpr result
92
+ };
93
+
94
+ struct EvalOptions {
95
+ Napi::ThreadSafeFunction onPrint; // fires once per Print[] line
96
+ Napi::ThreadSafeFunction onMessage; // fires once per kernel message
97
+ Napi::ThreadSafeFunction onDialogBegin; // fires with dialog level (int)
98
+ Napi::ThreadSafeFunction onDialogPrint; // fires with dialog Print[] lines
99
+ Napi::ThreadSafeFunction onDialogEnd; // fires with dialog level (int)
100
+ bool hasOnPrint = false;
101
+ bool hasOnMessage = false;
102
+ bool hasOnDialogBegin = false;
103
+ bool hasOnDialogPrint = false;
104
+ bool hasOnDialogEnd = false;
105
+ // When true, DrainToEvalResult expects EnterExpressionPacket protocol:
106
+ // INPUTNAMEPKT → OUTPUTNAMEPKT → RETURNEXPRPKT (or INPUTNAMEPKT for Null).
107
+ bool interactive = false;
108
+ CompleteCtx* ctx = nullptr; // non-owning; set when TSFNs are in use
109
+
110
+ // Pointers to session's dialog queue — set by WstpSession::Evaluate() so the
111
+ // drain loop can service dialogEval() requests from the thread pool.
112
+ // Non-owning; valid for the lifetime of the EvaluateWorker.
113
+ std::mutex* dialogMutex = nullptr;
114
+ std::queue<DialogRequest>* dialogQueue = nullptr;
115
+ std::atomic<bool>* dialogPending = nullptr;
116
+ std::atomic<bool>* dialogOpen = nullptr;
117
+ // Session-level abort flag — set by abort() on the main thread; checked in
118
+ // the dialog inner loop to break out proactively when abort() is called.
119
+ std::atomic<bool>* abortFlag = nullptr;
120
+ };
121
+
122
+ // ===========================================================================
123
+ // Module-level diagnostic channel.
124
+ // setDiagHandler(fn) registers a JS callback; DiagLog(msg) fires it from any
125
+ // C++ thread. Both the global flag and the TSFN are guarded by g_diagMutex.
126
+ // The TSFN is Unref()'d so it does not prevent the Node.js event loop from
127
+ // exiting normally.
128
+ // ===========================================================================
129
+ static std::mutex g_diagMutex;
130
+ static Napi::ThreadSafeFunction g_diagTsfn;
131
+ static bool g_diagActive = false;
132
+
133
+ // If DEBUG_WSTP=1 is set in the environment at module-load time, every
134
+ // DiagLog message is also written synchronously to stderr via fwrite.
135
+ // Useful when no JS setDiagHandler is registered (e.g. bare node runs).
136
+ static const bool g_debugToStderr = []() {
137
+ const char* v = getenv("DEBUG_WSTP");
138
+ return v && v[0] == '1';
139
+ }();
140
+
141
+ // Module-relative timestamp helper — milliseconds since module load.
142
+ // Used to embed C++ dispatch times in log messages so JS-side handler
143
+ // timestamps can be compared to measure TSFN delivery latency.
144
+ static auto g_startTime = std::chrono::steady_clock::now();
145
+ static long long diagMs() {
146
+ return std::chrono::duration_cast<std::chrono::milliseconds>(
147
+ std::chrono::steady_clock::now() - g_startTime).count();
148
+ }
149
+
150
+ static void DiagLog(const std::string& msg) {
151
+ if (g_debugToStderr) {
152
+ std::string out = "[wstp +" + std::to_string(diagMs()) + "ms] " + msg + "\n";
153
+ fwrite(out.c_str(), 1, out.size(), stderr);
154
+ }
155
+ std::lock_guard<std::mutex> lk(g_diagMutex);
156
+ if (!g_diagActive) return;
157
+ // NonBlockingCall with lambda — copies msg by value into the captured closure.
158
+ g_diagTsfn.NonBlockingCall(
159
+ [msg](Napi::Env env, Napi::Function fn) {
160
+ fn.Call({ Napi::String::New(env, msg) });
161
+ });
162
+ }
163
+
164
+ // ---------------------------------------------------------------------------
165
+ // ReadExprRaw — build a WExpr from one WSTP token/expression (any thread).
166
+ // ---------------------------------------------------------------------------
167
+ static WExpr ReadExprRaw(WSLINK lp, int depth = 0) {
168
+ if (depth > 512) return WExpr::mkError("expression too deep");
169
+
170
+ int type = WSGetType(lp);
171
+
172
+ if (type == WSTKINT) {
173
+ wsint64 i = 0;
174
+ if (!WSGetInteger64(lp, &i))
175
+ return WExpr::mkError("WSGetInteger64 failed");
176
+ WExpr e; e.kind = WExpr::Integer; e.intVal = i;
177
+ return e;
178
+ }
179
+ if (type == WSTKREAL) {
180
+ double d = 0.0;
181
+ if (!WSGetReal64(lp, &d))
182
+ return WExpr::mkError("WSGetReal64 failed");
183
+ WExpr e; e.kind = WExpr::Real; e.realVal = d;
184
+ return e;
185
+ }
186
+ if (type == WSTKSTR) {
187
+ const char* s = nullptr;
188
+ if (!WSGetString(lp, &s))
189
+ return WExpr::mkError("WSGetString failed");
190
+ WExpr e; e.kind = WExpr::String; e.strVal = s;
191
+ WSReleaseString(lp, s);
192
+ return e;
193
+ }
194
+ if (type == WSTKSYM) {
195
+ const char* s = nullptr;
196
+ if (!WSGetSymbol(lp, &s))
197
+ return WExpr::mkError("WSGetSymbol failed");
198
+ WExpr e; e.kind = WExpr::Symbol; e.strVal = s;
199
+ WSReleaseSymbol(lp, s);
200
+ return e;
201
+ }
202
+ if (type == WSTKFUNC) {
203
+ const char* head = nullptr;
204
+ int argc = 0;
205
+ if (!WSGetFunction(lp, &head, &argc))
206
+ return WExpr::mkError("WSGetFunction failed");
207
+ WExpr e;
208
+ e.kind = WExpr::Function;
209
+ e.head = head;
210
+ WSReleaseSymbol(lp, head);
211
+ e.args.reserve(argc);
212
+ for (int i = 0; i < argc; ++i) {
213
+ WExpr child = ReadExprRaw(lp, depth + 1);
214
+ if (child.kind == WExpr::WError) return child;
215
+ e.args.push_back(std::move(child));
216
+ }
217
+ return e;
218
+ }
219
+ return WExpr::mkError("unexpected token type: " + std::to_string(type));
220
+ }
221
+
222
+ // Forward declaration — WExprToNapi is defined after WstpSession.
223
+ static Napi::Value WExprToNapi(Napi::Env env, const WExpr& e);
224
+
225
+ // ---------------------------------------------------------------------------
226
+ // DrainToEvalResult — consume all packets for one cell, capturing Print[]
227
+ // output and messages. Blocks until RETURNPKT. Thread-pool thread only.
228
+ // opts may be nullptr (no streaming callbacks).
229
+ // ---------------------------------------------------------------------------
230
+ static EvalResult DrainToEvalResult(WSLINK lp, EvalOptions* opts = nullptr) {
231
+ EvalResult r;
232
+
233
+ // Parse "In[42]:=" → 42
234
+ auto parseCellIndex = [](const std::string& s) -> int64_t {
235
+ auto a = s.find('[');
236
+ auto b = s.find(']');
237
+ if (a == std::string::npos || b == std::string::npos) return 0;
238
+ try { return std::stoll(s.substr(a + 1, b - a - 1)); }
239
+ catch (...) { return 0; }
240
+ };
241
+
242
+ // Strip "Context`" prefix from fully-qualified symbol names.
243
+ // WSTP sends e.g. "System`MessageName" or "System`Power"; we want the bare name.
244
+ auto stripCtx = [](const std::string& s) -> std::string {
245
+ auto p = s.rfind('`');
246
+ return p != std::string::npos ? s.substr(p + 1) : s;
247
+ };
248
+
249
+ // Remove trailing newline(s) that Print[] appends to TextPacket content.
250
+ // WSTP delivers actual '\n' bytes on some platforms and the 4-char literal
251
+ // "\012" (backslash + '0' + '1' + '2') on others (Wolfram's octal escape).
252
+ auto rtrimNL = [](std::string s) -> std::string {
253
+ // Strip actual ASCII newlines / carriage returns
254
+ while (!s.empty() && (s.back() == '\n' || s.back() == '\r'))
255
+ s.pop_back();
256
+ // Strip Wolfram's 4-char literal octal escape "\\012"
257
+ while (s.size() >= 4 && s.compare(s.size() - 4, 4, "\\012") == 0)
258
+ s.resize(s.size() - 4);
259
+ return s;
260
+ };
261
+
262
+ // -----------------------------------------------------------------------
263
+ // Helper: handle a MESSAGEPKT (already current) — shared by outer and
264
+ // dialog inner loops. Reads the follow-up TEXTPKT, extracts the message
265
+ // string, appends it to r.messages, and fires the onMessage TSFN.
266
+ // -----------------------------------------------------------------------
267
+ auto handleMessage = [&](bool forDialog) {
268
+ WSNewPacket(lp); // discard message-name expression
269
+ if (WSNextPacket(lp) == TEXTPKT) {
270
+ const char* s = nullptr; WSGetString(lp, &s);
271
+ if (s) {
272
+ std::string text = s;
273
+ WSReleaseString(lp, s);
274
+ static const std::string NL = "\\012";
275
+ std::string msg;
276
+ auto dc = text.find("::");
277
+ if (dc != std::string::npos) {
278
+ auto nl_before = text.rfind(NL, dc);
279
+ size_t start = (nl_before != std::string::npos) ? nl_before + 4 : 0;
280
+ auto nl_after = text.find(NL, dc);
281
+ size_t end = (nl_after != std::string::npos) ? nl_after : text.size();
282
+ while (start < end && text[start] == ' ') ++start;
283
+ msg = text.substr(start, end - start);
284
+ } else {
285
+ for (size_t i = 0; i < text.size(); ) {
286
+ if (text.compare(i, 4, NL) == 0) { msg += ' '; i += 4; }
287
+ else { msg += text[i++]; }
288
+ }
289
+ }
290
+ if (!forDialog) {
291
+ r.messages.push_back(msg);
292
+ if (opts && opts->hasOnMessage)
293
+ opts->onMessage.NonBlockingCall(
294
+ [msg](Napi::Env e, Napi::Function cb){
295
+ cb.Call({Napi::String::New(e, msg)}); });
296
+ } else {
297
+ // Dialog messages: still append to outer result so nothing is lost.
298
+ r.messages.push_back(msg);
299
+ if (opts && opts->hasOnMessage)
300
+ opts->onMessage.NonBlockingCall(
301
+ [msg](Napi::Env e, Napi::Function cb){
302
+ cb.Call({Napi::String::New(e, msg)}); });
303
+ }
304
+ }
305
+ }
306
+ WSNewPacket(lp);
307
+ };
308
+
309
+ // -----------------------------------------------------------------------
310
+ // Helper: service one pending DialogRequest from dialogQueue_.
311
+ //
312
+ // menuDlgProto = false (default, BEGINDLGPKT path):
313
+ // Sends EvaluatePacket[ToExpression[...]], drains until RETURNPKT.
314
+ //
315
+ // menuDlgProto = true (MENUPKT-dialog path, interrupt-triggered Dialog[]):
316
+ // Sends EnterExpressionPacket[ToExpression[...]], drains until RETURNEXPRPKT.
317
+ // The kernel uses MENUPKT as the dialog-prompt between evaluations.
318
+ //
319
+ // Returns true → dialog still open.
320
+ // Returns false → dialog closed (ENDDLGPKT or exitDialog via MENUPKT 'c').
321
+ // -----------------------------------------------------------------------
322
+ auto serviceDialogRequest = [&](bool menuDlgProto = false) -> bool {
323
+ DialogRequest req;
324
+ {
325
+ std::lock_guard<std::mutex> lk(*opts->dialogMutex);
326
+ if (opts->dialogQueue->empty()) return true;
327
+ req = std::move(opts->dialogQueue->front());
328
+ opts->dialogQueue->pop();
329
+ if (opts->dialogQueue->empty())
330
+ opts->dialogPending->store(false);
331
+ }
332
+ // Send the expression to the kernel's dialog REPL.
333
+ // menuDlgProto / EnterExpressionPacket — interrupt-triggered Dialog[] context
334
+ // EvaluatePacket — BEGINDLGPKT Dialog[] context
335
+ // EnterTextPacket — exitDialog (Return[] in interactive ctx)
336
+ bool sent;
337
+ if (!req.useEnterText) {
338
+ if (menuDlgProto) {
339
+ // MENUPKT-dialog (interrupt-triggered): text-mode I/O.
340
+ // The kernel expects EnterTextPacket and returns OutputForm via TEXTPKT.
341
+ sent = WSPutFunction(lp, "EnterTextPacket", 1) &&
342
+ WSPutUTF8String(lp, (const unsigned char *)req.expr.c_str(), (int)req.expr.size()) &&
343
+ WSEndPacket (lp) &&
344
+ WSFlush (lp);
345
+ } else {
346
+ // BEGINDLGPKT dialog: batch mode.
347
+ sent = WSPutFunction(lp, "EvaluatePacket", 1) &&
348
+ WSPutFunction(lp, "ToExpression", 1) &&
349
+ WSPutUTF8String(lp, (const unsigned char *)req.expr.c_str(), (int)req.expr.size()) &&
350
+ WSEndPacket (lp) &&
351
+ WSFlush (lp);
352
+ }
353
+ } else {
354
+ sent = WSPutFunction(lp, "EnterTextPacket", 1) &&
355
+ WSPutUTF8String(lp, (const unsigned char *)req.expr.c_str(), (int)req.expr.size()) &&
356
+ WSEndPacket (lp) &&
357
+ WSFlush (lp);
358
+ }
359
+ if (!sent) {
360
+ // Send failure: resolve with a WError.
361
+ WExpr err = WExpr::mkError("dialogEval: failed to send to kernel");
362
+ req.resolve.NonBlockingCall(
363
+ [err](Napi::Env e, Napi::Function cb){
364
+ Napi::Object o = Napi::Object::New(e);
365
+ o.Set("type", Napi::String::New(e, "error"));
366
+ o.Set("error", Napi::String::New(e, err.strVal));
367
+ cb.Call({o});
368
+ });
369
+ req.resolve.Release();
370
+ return true; // link might still work; let outer loop decide
371
+ }
372
+ // Read response packets until RETURNPKT/RETURNEXPRPKT or ENDDLGPKT.
373
+ WExpr result;
374
+ std::string lastDlgText; // accumulated OutputForm text for menuDlgProto dialogEval
375
+ bool dialogEndedHere = false;
376
+ bool menuDlgFirstSkipped = false; // whether the pre-result setup MENUPKT was already skipped
377
+ DiagLog("[SDR] waiting for response, menuDlgProto=" + std::to_string(menuDlgProto)
378
+ + " useEnterText=" + std::to_string(req.useEnterText));
379
+ while (true) {
380
+ int p2 = WSNextPacket(lp);
381
+ DiagLog("[SDR] p2=" + std::to_string(p2));
382
+ if (p2 == RETURNPKT) {
383
+ result = ReadExprRaw(lp);
384
+ WSNewPacket(lp);
385
+ break;
386
+ }
387
+ if (p2 == RETURNTEXTPKT) {
388
+ // ReturnTextPacket: the dialog/inspect-mode result as OutputForm text.
389
+ // This is how 'i' (inspect) mode returns results in Wolfram 3.
390
+ const char* s = nullptr; WSGetString(lp, &s);
391
+ std::string txt = s ? rtrimNL(s) : ""; if (s) WSReleaseString(lp, s);
392
+ WSNewPacket(lp);
393
+ DiagLog("[SDR] RETURNTEXTPKT text='" + txt.substr(0, 60) + "'");
394
+ if (txt.empty()) {
395
+ result = WExpr::mkSymbol("System`Null");
396
+ } else {
397
+ // Try integer
398
+ try {
399
+ size_t pos = 0;
400
+ long long iv = std::stoll(txt, &pos);
401
+ if (pos == txt.size()) { result.kind = WExpr::Integer; result.intVal = iv; }
402
+ else { throw std::exception(); }
403
+ } catch (...) {
404
+ // Try real
405
+ try {
406
+ size_t pos = 0;
407
+ double rv = std::stod(txt, &pos);
408
+ if (pos == txt.size()) { result.kind = WExpr::Real; result.realVal = rv; }
409
+ else { throw std::exception(); }
410
+ } catch (...) {
411
+ result.kind = WExpr::String; result.strVal = txt;
412
+ }
413
+ }
414
+ }
415
+ break;
416
+ }
417
+ if (p2 == RETURNEXPRPKT) {
418
+ // EnterExpressionPacket path (menuDlgProto): collect structured result.
419
+ // The kernel will follow with INPUTNAMEPKT or MENUPKT (next prompt);
420
+ // we break after collecting and let the outer loop consume that.
421
+ result = ReadExprRaw(lp);
422
+ WSNewPacket(lp);
423
+ if (!menuDlgProto) break; // BEGINDLGPKT: also terminates
424
+ // menuDlgProto: keep looping to consume OUTPUTNAMEPKT/INPUTNAMEPKT
425
+ // and the trailing MENUPKT (which triggers the outer break below)
426
+ continue;
427
+ }
428
+ if (p2 == ENDDLGPKT) {
429
+ // The expression exited the dialog (e.g. Return[]).
430
+ // Handle ENDDLGPKT here so the outer inner-loop doesn't see it.
431
+ wsint64 endLevel = 0;
432
+ if (WSGetType(lp) == WSTKINT) WSGetInteger64(lp, &endLevel);
433
+ WSNewPacket(lp);
434
+ if (opts->dialogOpen) opts->dialogOpen->store(false);
435
+ if (opts->hasOnDialogEnd)
436
+ opts->onDialogEnd.NonBlockingCall(
437
+ [endLevel](Napi::Env e, Napi::Function cb){
438
+ cb.Call({Napi::Number::New(e, static_cast<double>(endLevel))}); });
439
+ // Resolve with Null — the caller asked for Return[], gets Null back.
440
+ result.kind = WExpr::Symbol;
441
+ result.strVal = "Null";
442
+ dialogEndedHere = true;
443
+ break;
444
+ }
445
+ if (p2 == MESSAGEPKT) { handleMessage(true); continue; }
446
+ if (p2 == TEXTPKT) {
447
+ const char* s = nullptr; WSGetString(lp, &s);
448
+ if (s) {
449
+ std::string line = rtrimNL(s); WSReleaseString(lp, s);
450
+ if (menuDlgProto && !req.useEnterText) {
451
+ DiagLog("[SDR] TEXTPKT(menuDlg) text='" + line + "'");
452
+ if (!line.empty()) {
453
+ if (!lastDlgText.empty()) lastDlgText += "\n";
454
+ lastDlgText += line;
455
+ }
456
+ } else {
457
+ if (opts->hasOnDialogPrint)
458
+ opts->onDialogPrint.NonBlockingCall(
459
+ [line](Napi::Env e, Napi::Function cb){
460
+ cb.Call({Napi::String::New(e, line)}); });
461
+ }
462
+ }
463
+ WSNewPacket(lp); continue;
464
+ }
465
+ if (p2 == INPUTNAMEPKT || p2 == OUTPUTNAMEPKT) { WSNewPacket(lp); continue; }
466
+ if (p2 == MENUPKT) {
467
+ // MENUPKT in dialog context:
468
+ // * menuDlgProto && exitDialog: respond 'c' to resume main eval.
469
+ // * menuDlgProto && dialogEval: result arrived via TEXTPKT; parse it.
470
+ // * BEGINDLGPKT path: result via RETURNPKT (should already have it).
471
+ if (req.useEnterText) {
472
+ // exitDialog: respond bare string 'c' (continue) per JLink MENUPKT protocol.
473
+ wsint64 menuType2_ = 0; WSGetInteger64(lp, &menuType2_);
474
+ const char* menuPrompt2_ = nullptr; WSGetString(lp, &menuPrompt2_);
475
+ if (menuPrompt2_) WSReleaseString(lp, menuPrompt2_);
476
+ WSNewPacket(lp);
477
+ WSPutString(lp, "c");
478
+ WSEndPacket(lp);
479
+ WSFlush(lp);
480
+ if (opts->dialogOpen) opts->dialogOpen->store(false);
481
+ if (opts->hasOnDialogEnd)
482
+ opts->onDialogEnd.NonBlockingCall(
483
+ [](Napi::Env e, Napi::Function cb){
484
+ cb.Call({Napi::Number::New(e, 0.0)}); });
485
+ result.kind = WExpr::Symbol;
486
+ result.strVal = "Null";
487
+ dialogEndedHere = true;
488
+ } else if (menuDlgProto) {
489
+ // dialogEval in text-mode dialog: result arrives as TEXTPKT before MENUPKT.
490
+ // However: the kernel may send a "setup" MENUPKT immediately after we
491
+ // send our expression (buffered from the dialog-open sequence), before
492
+ // sending the TEXTPKT result. Skip that one MENUPKT and keep waiting.
493
+ WSNewPacket(lp);
494
+ if (lastDlgText.empty() && !menuDlgFirstSkipped) {
495
+ menuDlgFirstSkipped = true;
496
+ DiagLog("[SDR] menuDlg: skipping pre-result MENUPKT, waiting for TEXTPKT");
497
+ continue; // keep waiting for TEXTPKT result
498
+ }
499
+ // Second MENUPKT (or first if we already have text): end of result.
500
+ DiagLog("[SDR] menuDlg: end-of-result MENUPKT, lastDlgText='" + lastDlgText + "'");
501
+ if (lastDlgText.empty()) {
502
+ result = WExpr::mkSymbol("System`Null");
503
+ } else {
504
+ // Try integer
505
+ try {
506
+ size_t pos = 0;
507
+ long long iv = std::stoll(lastDlgText, &pos);
508
+ if (pos == lastDlgText.size()) {
509
+ result.kind = WExpr::Integer;
510
+ result.intVal = iv;
511
+ } else { throw std::exception(); }
512
+ } catch (...) {
513
+ // Try real
514
+ try {
515
+ size_t pos = 0;
516
+ double rv = std::stod(lastDlgText, &pos);
517
+ if (pos == lastDlgText.size()) {
518
+ result.kind = WExpr::Real;
519
+ result.realVal = rv;
520
+ } else { throw std::exception(); }
521
+ } catch (...) {
522
+ result.kind = WExpr::String;
523
+ result.strVal = lastDlgText;
524
+ }
525
+ }
526
+ }
527
+ } else {
528
+ // BEGINDLGPKT path: result should have arrived via RETURNPKT.
529
+ WSNewPacket(lp);
530
+ if (result.kind == WExpr::WError)
531
+ result = WExpr::mkSymbol("System`Null");
532
+ }
533
+ break;
534
+ }
535
+ if (p2 == 0 || p2 == ILLEGALPKT) {
536
+ const char* m = WSErrorMessage(lp); WSClearError(lp);
537
+ result = WExpr::mkError(m ? m : "WSTP error in dialogEval");
538
+ break;
539
+ }
540
+ WSNewPacket(lp);
541
+ }
542
+ // Deliver result to the JS Promise via TSFN.
543
+ WExpr res = result;
544
+ req.resolve.NonBlockingCall(
545
+ [res](Napi::Env e, Napi::Function cb){ cb.Call({WExprToNapi(e, res)}); });
546
+ req.resolve.Release();
547
+ return !dialogEndedHere;
548
+ };
549
+
550
+ // -----------------------------------------------------------------------
551
+ // Outer drain loop — blocking WSNextPacket (unchanged for normal evals).
552
+ // Dialog packets trigger the inner loop below.
553
+ // -----------------------------------------------------------------------
554
+ // In EnterExpressionPacket mode the kernel sends:
555
+ // Non-Null result: OUTPUTNAMEPKT → RETURNEXPRPKT
556
+ // followed by a trailing INPUTNAMEPKT (next prompt)
557
+ // Null result: INPUTNAMEPKT only (no OUTPUTNAMEPKT/RETURNEXPRPKT)
558
+ //
559
+ // So: INPUTNAMEPKT as the FIRST packet (before any OUTPUTNAMEPKT) = Null.
560
+ // INPUTNAMEPKT after RETURNEXPRPKT = next-prompt trailer, consumed here
561
+ // so the next evaluate() starts clean.
562
+ bool gotOutputName = false;
563
+ bool gotResult = false;
564
+ while (true) {
565
+ int pkt = WSNextPacket(lp);
566
+ DiagLog("[Eval] pkt=" + std::to_string(pkt));
567
+
568
+ if (pkt == RETURNPKT || pkt == RETURNEXPRPKT) {
569
+ // RETURNPKT: response to EvaluatePacket — no In/Out populated.
570
+ // RETURNEXPRPKT: response to EnterExpressionPacket — main loop ran,
571
+ // In[n] and Out[n] are populated by the kernel.
572
+ //
573
+ // Safety for interactive RETURNEXPRPKT: peek at the token type before
574
+ // calling ReadExprRaw. Atomic results (symbol, int, real, string) are
575
+ // safe to read — this covers $Aborted and simple values.
576
+ // Complex results (List, Graphics, …) are skipped with WSNewPacket to
577
+ // avoid a potential WSGetFunction crash on deep expression trees.
578
+ // Out[n] is already set inside the kernel; JS renders via VsCodeRenderNth
579
+ // which reads from $vsCodeLastResultList, not from the transferred result.
580
+ if (opts && opts->interactive && pkt == RETURNEXPRPKT) {
581
+ int tok = WSGetType(lp);
582
+ if (tok == WSTKSYM || tok == WSTKINT || tok == WSTKREAL || tok == WSTKSTR) {
583
+ r.result = ReadExprRaw(lp);
584
+ } else {
585
+ // Complex result — discard; Out[n] intact in kernel.
586
+ r.result = WExpr::mkSymbol("System`__VsCodeHasResult__");
587
+ }
588
+ } else {
589
+ r.result = ReadExprRaw(lp);
590
+ }
591
+ WSNewPacket(lp);
592
+ if (r.result.kind == WExpr::Symbol && stripCtx(r.result.strVal) == "$Aborted")
593
+ r.aborted = true;
594
+ gotResult = true;
595
+ // In interactive mode the kernel always follows RETURNEXPRPKT with a
596
+ // trailing INPUTNAMEPKT (the next "In[n+1]:=" prompt). We must consume
597
+ // that packet before returning so the wire is clean for the next eval.
598
+ // Continue the loop; the INPUTNAMEPKT branch below will break cleanly.
599
+ if (!opts || !opts->interactive) break;
600
+ // (fall through to the next WSNextPacket iteration)
601
+ }
602
+ else if (pkt == INPUTNAMEPKT) {
603
+ const char* s = nullptr; WSGetString(lp, &s);
604
+ std::string nameStr = s ? s : "";
605
+ if (s) WSReleaseString(lp, s);
606
+ WSNewPacket(lp);
607
+ if (opts && opts->interactive) {
608
+ if (!gotOutputName && !gotResult) {
609
+ // First packet = INPUTNAMEPKT with no preceding OUTPUTNAMEPKT:
610
+ // kernel evaluated to Null/suppressed — this IS the result signal.
611
+ // cellIndex is one less than this prompt's index.
612
+ int64_t nextIdx = parseCellIndex(nameStr);
613
+ r.cellIndex = (nextIdx > 1) ? nextIdx - 1 : nextIdx;
614
+ r.result = WExpr::mkSymbol("System`Null");
615
+ break;
616
+ }
617
+ // Trailing INPUTNAMEPKT after RETURNEXPRPKT — consume and exit.
618
+ // (Next eval starts with no leftover INPUTNAMEPKT on the wire.)
619
+ break;
620
+ }
621
+ r.cellIndex = parseCellIndex(nameStr);
622
+ }
623
+ else if (pkt == OUTPUTNAMEPKT) {
624
+ const char* s = nullptr; WSGetString(lp, &s);
625
+ if (s) {
626
+ std::string name = s; WSReleaseString(lp, s);
627
+ // Kernel sends "Out[n]= " with trailing space — normalize to "Out[n]=".
628
+ while (!name.empty() && name.back() == ' ') name.pop_back();
629
+ r.outputName = name;
630
+ // Parse the n from "Out[n]=" to set cellIndex (interactive mode).
631
+ r.cellIndex = parseCellIndex(name);
632
+ }
633
+ WSNewPacket(lp);
634
+ gotOutputName = true;
635
+ }
636
+ else if (pkt == TEXTPKT) {
637
+ const char* s = nullptr; WSGetString(lp, &s);
638
+ if (s) {
639
+ std::string line = rtrimNL(s);
640
+ WSReleaseString(lp, s);
641
+ DiagLog("[Eval] TEXTPKT content='" + line.substr(0, 60) + "'");
642
+ r.print.emplace_back(line);
643
+ if (opts && opts->hasOnPrint) {
644
+ // Log C++ dispatch time so the JS handler timestamp gives latency.
645
+ DiagLog("[TSFN][onPrint] dispatch +" + std::to_string(diagMs())
646
+ + "ms \"" + line.substr(0, 30) + "\"");
647
+ opts->onPrint.NonBlockingCall(
648
+ [line](Napi::Env env, Napi::Function cb){
649
+ cb.Call({ Napi::String::New(env, line) }); });
650
+ }
651
+ }
652
+ WSNewPacket(lp);
653
+ }
654
+ else if (pkt == MESSAGEPKT) {
655
+ handleMessage(false);
656
+ }
657
+ else if (pkt == BEGINDLGPKT) {
658
+ // ----------------------------------------------------------------
659
+ // Dialog subsession opened by the kernel.
660
+ // Read the dialog level integer, set dialogOpen_ flag, fire callback.
661
+ // ----------------------------------------------------------------
662
+ wsint64 level = 0;
663
+ if (WSGetType(lp) == WSTKINT) WSGetInteger64(lp, &level);
664
+ WSNewPacket(lp);
665
+
666
+ if (opts && opts->dialogOpen)
667
+ opts->dialogOpen->store(true);
668
+ if (opts && opts->hasOnDialogBegin)
669
+ opts->onDialogBegin.NonBlockingCall(
670
+ [level](Napi::Env e, Napi::Function cb){
671
+ cb.Call({Napi::Number::New(e, static_cast<double>(level))}); });
672
+
673
+ // ----------------------------------------------------------------
674
+ // Dialog inner loop — WSReady-gated so dialogQueue_ can be serviced
675
+ // between kernel packets without blocking indefinitely.
676
+ // ----------------------------------------------------------------
677
+ bool dialogDone = false;
678
+ while (!dialogDone) {
679
+ // Abort check — abort() sets abortFlag_ and sends WSAbortMessage.
680
+ // The kernel may be slow to respond (or send RETURNPKT[$Aborted]
681
+ // without a preceding ENDDLGPKT); bail out proactively to avoid
682
+ // spinning forever.
683
+ if (opts && opts->abortFlag && opts->abortFlag->load()) {
684
+ if (opts->dialogOpen) opts->dialogOpen->store(false);
685
+ r.result = WExpr::mkSymbol("System`$Aborted");
686
+ r.aborted = true;
687
+ return r;
688
+ }
689
+
690
+ // Service any pending dialogEval() requests first.
691
+ if (opts && opts->dialogPending && opts->dialogPending->load()) {
692
+ if (!serviceDialogRequest()) {
693
+ dialogDone = true; // ENDDLGPKT arrived inside the request
694
+ continue;
695
+ }
696
+ }
697
+
698
+ // Non-blocking check: is the kernel ready to send a packet?
699
+ if (!WSReady(lp)) {
700
+ std::this_thread::sleep_for(std::chrono::milliseconds(2));
701
+ continue;
702
+ }
703
+
704
+ int dpkt = WSNextPacket(lp);
705
+ DiagLog("[Dialog] dpkt=" + std::to_string(dpkt));
706
+ if (dpkt == ENDDLGPKT) {
707
+ wsint64 endLevel = 0;
708
+ if (WSGetType(lp) == WSTKINT) WSGetInteger64(lp, &endLevel);
709
+ WSNewPacket(lp);
710
+ if (opts && opts->dialogOpen)
711
+ opts->dialogOpen->store(false);
712
+ if (opts && opts->hasOnDialogEnd)
713
+ opts->onDialogEnd.NonBlockingCall(
714
+ [endLevel](Napi::Env e, Napi::Function cb){
715
+ cb.Call({Napi::Number::New(e, static_cast<double>(endLevel))}); });
716
+ dialogDone = true;
717
+ }
718
+ else if (dpkt == RETURNPKT) {
719
+ // Could be the abort response (RETURNPKT[$Aborted] without a
720
+ // preceding ENDDLGPKT) or an unsolicited in-dialog result.
721
+ WExpr innerExpr = ReadExprRaw(lp);
722
+ WSNewPacket(lp);
723
+ if (innerExpr.kind == WExpr::Symbol &&
724
+ stripCtx(innerExpr.strVal) == "$Aborted") {
725
+ // Kernel ended the dialog due to abort — propagate upward.
726
+ if (opts && opts->dialogOpen) opts->dialogOpen->store(false);
727
+ r.result = innerExpr; // already the $Aborted symbol
728
+ r.aborted = true;
729
+ return r;
730
+ }
731
+ // Otherwise discard — outer loop will see final RETURNPKT.
732
+ }
733
+ else if (dpkt == INPUTNAMEPKT || dpkt == OUTPUTNAMEPKT) {
734
+ WSNewPacket(lp); // dialog prompts — discard
735
+ }
736
+ else if (dpkt == TEXTPKT) {
737
+ DiagLog("[Dialog] TEXTPKT");
738
+ const char* s = nullptr; WSGetString(lp, &s);
739
+ if (s) {
740
+ std::string line = rtrimNL(s); WSReleaseString(lp, s);
741
+ if (opts && opts->hasOnDialogPrint)
742
+ opts->onDialogPrint.NonBlockingCall(
743
+ [line](Napi::Env e, Napi::Function cb){
744
+ cb.Call({Napi::String::New(e, line)}); });
745
+ }
746
+ WSNewPacket(lp);
747
+ }
748
+ else if (dpkt == MESSAGEPKT) {
749
+ handleMessage(true);
750
+ }
751
+ else if (dpkt == 0 || dpkt == ILLEGALPKT) {
752
+ const char* m = WSErrorMessage(lp);
753
+ WSClearError(lp);
754
+ if (opts && opts->dialogOpen) opts->dialogOpen->store(false);
755
+ // If abort() was in flight, the ILLEGALPKT is the expected
756
+ // link-reset response — treat as clean abort, not an error.
757
+ if (opts && opts->abortFlag && opts->abortFlag->load()) {
758
+ r.result = WExpr::mkSymbol("System`$Aborted");
759
+ r.aborted = true;
760
+ } else {
761
+ r.result = WExpr::mkError(m ? m : "WSTP error in dialog");
762
+ }
763
+ return r; // unrecoverable — bail out entirely
764
+ }
765
+ else {
766
+ WSNewPacket(lp);
767
+ }
768
+ }
769
+ // Dialog closed — continue outer loop waiting for the original RETURNPKT.
770
+ }
771
+ else if (pkt == 0 || pkt == ILLEGALPKT) {
772
+ const char* m = WSErrorMessage(lp);
773
+ std::string s = m ? m : "WSTP link error";
774
+ WSClearError(lp);
775
+ r.result = WExpr::mkError(s);
776
+ break;
777
+ }
778
+ else if (pkt == RETURNTEXTPKT) {
779
+ // ReturnTextPacket carries the string-form of the result.
780
+ // This should not normally appear with EvaluatePacket, but handle
781
+ // it defensively so the loop always terminates.
782
+ DiagLog("[Eval] unexpected RETURNTEXTPKT — treating as empty return");
783
+ WSNewPacket(lp);
784
+ // Leave r.result as default WError so the caller gets an informative error.
785
+ r.result = WExpr::mkError("unexpected ReturnTextPacket from kernel");
786
+ break;
787
+ }
788
+ else if (pkt == MENUPKT) {
789
+ // ----------------------------------------------------------------
790
+ // MENUPKT (6) — interrupt menu protocol per JLink source:
791
+ //
792
+ // Protocol (from JLink InterruptDialog.java):
793
+ // 1. WSNextPacket(lp) → MENUPKT
794
+ // 2. WSGetInteger(lp, &type) — menu type (1=interrupt, 3=LinkRead)
795
+ // 3. WSGetString(lp, &prompt) — prompt string
796
+ // 4. WSNewPacket(lp)
797
+ // 5. WSPutString(lp, "i") — respond with bare string
798
+ // Options: a=abort, c=continue, i=inspect/dialog
799
+ // 6. WSEndPacket; WSFlush
800
+ //
801
+ // After "i", the kernel enters interactive inspect mode and sends
802
+ // MENUPKT(inspect) as the dialog prompt → handled in menuDlgDone.
803
+ // ----------------------------------------------------------------
804
+ {
805
+ wsint64 menuType_ = 0; WSGetInteger64(lp, &menuType_);
806
+ const char* menuPrompt_ = nullptr; WSGetString(lp, &menuPrompt_);
807
+ if (menuPrompt_) WSReleaseString(lp, menuPrompt_);
808
+ WSNewPacket(lp);
809
+ DiagLog("[Eval] MENUPKT type=" + std::to_string(menuType_) + " — responding 'i' (inspect)");
810
+
811
+ // Respond with bare string 'i' to enter inspect/dialog mode.
812
+ // Per JLink protocol: WSPutString(link, "i"). Not wrapped in any packet.
813
+ WSPutString(lp, "i");
814
+ WSEndPacket(lp);
815
+ WSFlush(lp);
816
+ }
817
+ // The kernel will now send an inspect-mode MENUPKT as the dialog prompt.
818
+ bool menuDlgDone = false;
819
+ while (!menuDlgDone) {
820
+ if (opts && opts->abortFlag && opts->abortFlag->load()) {
821
+ if (opts->dialogOpen) opts->dialogOpen->store(false);
822
+ r.result = WExpr::mkSymbol("System`$Aborted");
823
+ r.aborted = true;
824
+ return r;
825
+ }
826
+ if (opts && opts->dialogPending && opts->dialogPending->load()) {
827
+ if (!serviceDialogRequest(/*menuDlgProto=*/true)) {
828
+ menuDlgDone = true;
829
+ continue;
830
+ }
831
+ }
832
+ if (!WSReady(lp)) {
833
+ std::this_thread::sleep_for(std::chrono::milliseconds(2));
834
+ continue;
835
+ }
836
+ int dpkt = WSNextPacket(lp);
837
+ DiagLog("[MenuDlg] dpkt=" + std::to_string(dpkt));
838
+ if (dpkt == ENDDLGPKT) {
839
+ wsint64 el = 0;
840
+ if (WSGetType(lp) == WSTKINT) WSGetInteger64(lp, &el);
841
+ WSNewPacket(lp);
842
+ if (opts->dialogOpen) opts->dialogOpen->store(false);
843
+ if (opts->hasOnDialogEnd)
844
+ opts->onDialogEnd.NonBlockingCall(
845
+ [el](Napi::Env e, Napi::Function cb){
846
+ cb.Call({Napi::Number::New(e, static_cast<double>(el))}); });
847
+ menuDlgDone = true;
848
+ } else if (dpkt == BEGINDLGPKT) {
849
+ // BEGINDLGPKT: dialog subsession started.
850
+ // This arrives after 'i' response to MENUPKT, before INPUTNAMEPKT.
851
+ // The dialog level integer follows.
852
+ wsint64 beginLevel = 0;
853
+ if (WSGetType(lp) == WSTKINT) WSGetInteger64(lp, &beginLevel);
854
+ WSNewPacket(lp);
855
+ DiagLog("[MenuDlg] BEGINDLGPKT level=" + std::to_string(beginLevel) + " — dialog is open");
856
+ // isDialogOpen will be set when INPUTNAMEPKT arrives (see below).
857
+ // Just consume and wait for INPUTNAMEPKT.
858
+ } else if (dpkt == RETURNPKT) {
859
+ WExpr inner = ReadExprRaw(lp); WSNewPacket(lp);
860
+ if (inner.kind == WExpr::Symbol &&
861
+ stripCtx(inner.strVal) == "$Aborted") {
862
+ if (opts->dialogOpen) opts->dialogOpen->store(false);
863
+ r.result = inner; r.aborted = true; return r;
864
+ }
865
+ } else if (dpkt == MENUPKT) {
866
+ // In the menuDlgDone loop, MENUPKTs serve two purposes:
867
+ // 1. FIRST MENUPKT (isDialogOpen still false):
868
+ // The dialog subsession has just opened.
869
+ // Set isDialogOpen = true, fire onDialogBegin, consume & wait.
870
+ // 2. LATER MENUPKTs (isDialogOpen true):
871
+ // Either the interrupt menu again (if interrupt handler also opened
872
+ // Dialog[], these menus pile up) — respond "c" to dismiss it.
873
+ // Or if exitDialog sent 'c' and cleared dialogOpen → we're done.
874
+ bool isOpen = opts->dialogOpen && opts->dialogOpen->load();
875
+ if (!isOpen) {
876
+ // First MENUPKT in menuDlgDone: the inspect/dialog-mode prompt.
877
+ // The kernel has entered interactive inspection mode.
878
+ // Read the type and prompt following JLink MENUPKT protocol.
879
+ wsint64 menuTypeInsp = 0; WSGetInteger64(lp, &menuTypeInsp);
880
+ const char* menuPromptInsp = nullptr; WSGetString(lp, &menuPromptInsp);
881
+ if (menuPromptInsp) WSReleaseString(lp, menuPromptInsp);
882
+ WSNewPacket(lp);
883
+ DiagLog("[MenuDlg] inspect MENUPKT type=" + std::to_string(menuTypeInsp) + " — isDialogOpen=true");
884
+ if (opts->dialogOpen) opts->dialogOpen->store(true);
885
+ if (opts->hasOnDialogBegin)
886
+ opts->onDialogBegin.NonBlockingCall(
887
+ [](Napi::Env e, Napi::Function cb){
888
+ cb.Call({Napi::Number::New(e, 1.0)}); });
889
+ } else if (opts->dialogOpen && !opts->dialogOpen->load()) {
890
+ // exitDialog already closed the dialog (cleared by serviceDialogRequest)
891
+ WSNewPacket(lp);
892
+ menuDlgDone = true;
893
+ } else {
894
+ // Subsequent MENUPKT while dialog is open — this is likely the
895
+ // interrupt-level menu that appeared AFTER the dialog-open MENUPKT.
896
+ // Dismiss it with bare string 'c' (continue) per JLink MENUPKT protocol.
897
+ DiagLog("[MenuDlg] subsequent MENUPKT while dialog open — responding 'c' (continue)");
898
+ wsint64 menuTypeDis = 0; WSGetInteger64(lp, &menuTypeDis);
899
+ const char* menuPromptDis = nullptr; WSGetString(lp, &menuPromptDis);
900
+ if (menuPromptDis) WSReleaseString(lp, menuPromptDis);
901
+ WSNewPacket(lp);
902
+ WSPutString(lp, "c");
903
+ WSEndPacket(lp);
904
+ WSFlush(lp);
905
+ }
906
+ } else if (dpkt == INPUTNAMEPKT || dpkt == OUTPUTNAMEPKT) {
907
+ const char* nm = nullptr; WSGetString(lp, &nm);
908
+ if (nm) {
909
+ std::string nml = nm; WSReleaseString(lp, nm);
910
+ DiagLog("[MenuDlg] pkt=" + std::to_string(dpkt) + " name='" + nml + "'");
911
+ // INPUTNAMEPKT in menuDlgDone may mean the dialog/inspect
912
+ // subsession is prompting for input (alternative to MENUPKT).
913
+ if (dpkt == INPUTNAMEPKT && opts && opts->dialogOpen &&
914
+ !opts->dialogOpen->load()) {
915
+ DiagLog("[MenuDlg] INPUTNAMEPKT while !dialogOpen — dialog opened via INPUTNAMEPKT");
916
+ opts->dialogOpen->store(true);
917
+ if (opts->hasOnDialogBegin)
918
+ opts->onDialogBegin.NonBlockingCall(
919
+ [](Napi::Env e, Napi::Function cb){
920
+ cb.Call({Napi::Number::New(e, 1.0)}); });
921
+ }
922
+ }
923
+ WSNewPacket(lp);
924
+ } else if (dpkt == TEXTPKT) {
925
+ const char* ts = nullptr; WSGetString(lp, &ts);
926
+ if (ts) {
927
+ std::string tl = rtrimNL(ts); WSReleaseString(lp, ts);
928
+ DiagLog("[MenuDlg] TEXTPKT text='" + tl.substr(0, 80) + "'");
929
+ // Filter out interrupt menu options text (informational only).
930
+ bool isMenuOptions = (tl.find("Your options are") != std::string::npos);
931
+ if (!isMenuOptions && opts && opts->hasOnDialogPrint)
932
+ opts->onDialogPrint.NonBlockingCall(
933
+ [tl](Napi::Env e, Napi::Function cb){
934
+ cb.Call({Napi::String::New(e, tl)}); });
935
+ }
936
+ WSNewPacket(lp);
937
+ } else if (dpkt == MESSAGEPKT) {
938
+ handleMessage(true);
939
+ } else if (dpkt == 0 || dpkt == ILLEGALPKT) {
940
+ const char* em = WSErrorMessage(lp); WSClearError(lp);
941
+ if (opts->dialogOpen) opts->dialogOpen->store(false);
942
+ r.result = WExpr::mkError(em ? em : "WSTP link error in dialog");
943
+ return r;
944
+ } else {
945
+ WSNewPacket(lp);
946
+ }
947
+ }
948
+ // Dialog closed — outer loop continues to collect main eval result.
949
+ }
950
+ else {
951
+ DiagLog("[Eval] unknown pkt=" + std::to_string(pkt) + ", discarding");
952
+ WSNewPacket(lp); // discard unknown packets
953
+ }
954
+ }
955
+
956
+ return r;
957
+ }
958
+
959
+ // ---------------------------------------------------------------------------
960
+ // ---------------------------------------------------------------------------
961
+ // WExprToNapi — convert WExpr → Napi::Value. Main thread only.
962
+ // ---------------------------------------------------------------------------
963
+ static Napi::Value WExprToNapi(Napi::Env env, const WExpr& e) {
964
+ switch (e.kind) {
965
+ case WExpr::Integer: {
966
+ Napi::Object o = Napi::Object::New(env);
967
+ o.Set("type", Napi::String::New(env, "integer"));
968
+ o.Set("value", Napi::Number::New(env, static_cast<double>(e.intVal)));
969
+ return o;
970
+ }
971
+ case WExpr::Real: {
972
+ Napi::Object o = Napi::Object::New(env);
973
+ o.Set("type", Napi::String::New(env, "real"));
974
+ o.Set("value", Napi::Number::New(env, e.realVal));
975
+ return o;
976
+ }
977
+ case WExpr::String: {
978
+ Napi::Object o = Napi::Object::New(env);
979
+ o.Set("type", Napi::String::New(env, "string"));
980
+ o.Set("value", Napi::String::New(env, e.strVal));
981
+ return o;
982
+ }
983
+ case WExpr::Symbol: {
984
+ Napi::Object o = Napi::Object::New(env);
985
+ o.Set("type", Napi::String::New(env, "symbol"));
986
+ o.Set("value", Napi::String::New(env, e.strVal));
987
+ return o;
988
+ }
989
+ case WExpr::Function: {
990
+ Napi::Array argsArr = Napi::Array::New(env, e.args.size());
991
+ for (size_t i = 0; i < e.args.size(); ++i)
992
+ argsArr.Set(static_cast<uint32_t>(i), WExprToNapi(env, e.args[i]));
993
+ Napi::Object o = Napi::Object::New(env);
994
+ o.Set("type", Napi::String::New(env, "function"));
995
+ o.Set("head", Napi::String::New(env, e.head));
996
+ o.Set("args", argsArr);
997
+ return o;
998
+ }
999
+ case WExpr::WError:
1000
+ default:
1001
+ Napi::Error::New(env, e.strVal).ThrowAsJavaScriptException();
1002
+ return env.Undefined();
1003
+ }
1004
+ }
1005
+
1006
+ // ---------------------------------------------------------------------------
1007
+ // EvalResultToNapi — convert EvalResult → Napi::Object. Main thread only.
1008
+ // ---------------------------------------------------------------------------
1009
+ static Napi::Value EvalResultToNapi(Napi::Env env, const EvalResult& r) {
1010
+ auto obj = Napi::Object::New(env);
1011
+
1012
+ obj.Set("cellIndex", Napi::Number::New(env, static_cast<double>(r.cellIndex)));
1013
+ obj.Set("outputName", Napi::String::New(env, r.outputName));
1014
+ obj.Set("result", WExprToNapi(env, r.result));
1015
+ obj.Set("aborted", Napi::Boolean::New(env, r.aborted));
1016
+
1017
+ auto print = Napi::Array::New(env, r.print.size());
1018
+ for (size_t i = 0; i < r.print.size(); ++i)
1019
+ print.Set(static_cast<uint32_t>(i), Napi::String::New(env, r.print[i]));
1020
+ obj.Set("print", print);
1021
+
1022
+ auto msgs = Napi::Array::New(env, r.messages.size());
1023
+ for (size_t i = 0; i < r.messages.size(); ++i)
1024
+ msgs.Set(static_cast<uint32_t>(i), Napi::String::New(env, r.messages[i]));
1025
+ obj.Set("messages", msgs);
1026
+
1027
+ return obj;
1028
+ }
1029
+
1030
+ // ===========================================================================
1031
+ // EvaluateWorker — ALL blocking WSTP I/O runs on the libuv thread pool.
1032
+ // ===========================================================================
1033
+ class EvaluateWorker : public Napi::AsyncWorker {
1034
+ public:
1035
+ EvaluateWorker(Napi::Promise::Deferred deferred,
1036
+ WSLINK lp,
1037
+ std::string expr,
1038
+ EvalOptions opts,
1039
+ std::atomic<bool>& abortFlag,
1040
+ std::function<void()> completionCb,
1041
+ int64_t cellIndex,
1042
+ bool interactive = false)
1043
+ : Napi::AsyncWorker(deferred.Env()),
1044
+ deferred_(std::move(deferred)),
1045
+ lp_(lp),
1046
+ expr_(std::move(expr)),
1047
+ interactive_(interactive),
1048
+ opts_(std::move(opts)),
1049
+ abortFlag_(abortFlag),
1050
+ completionCb_(std::move(completionCb)),
1051
+ cellIndex_(cellIndex)
1052
+ {}
1053
+
1054
+ // ---- thread-pool thread: send packet; block until response ----
1055
+ void Execute() override {
1056
+ bool sent;
1057
+ if (!interactive_) {
1058
+ // EvaluatePacket + ToExpression: non-interactive, does NOT populate In[n]/Out[n].
1059
+ sent = WSPutFunction(lp_, "EvaluatePacket", 1) &&
1060
+ WSPutFunction(lp_, "ToExpression", 1) &&
1061
+ WSPutUTF8String(lp_, (const unsigned char *)expr_.c_str(), (int)expr_.size()) &&
1062
+ WSEndPacket (lp_) &&
1063
+ WSFlush (lp_);
1064
+ } else {
1065
+ // EnterExpressionPacket[ToExpression[str]]: goes through the kernel's
1066
+ // full main loop — populates In[n] and Out[n] automatically, exactly
1067
+ // as the real Mathematica frontend does. Responds with RETURNEXPRPKT.
1068
+ sent = WSPutFunction(lp_, "EnterExpressionPacket", 1) &&
1069
+ WSPutFunction(lp_, "ToExpression", 1) &&
1070
+ WSPutUTF8String(lp_, (const unsigned char *)expr_.c_str(), (int)expr_.size()) &&
1071
+ WSEndPacket (lp_) &&
1072
+ WSFlush (lp_);
1073
+ }
1074
+ if (!sent) {
1075
+ SetError("Failed to send packet to kernel");
1076
+ } else {
1077
+ opts_.interactive = interactive_;
1078
+ result_ = DrainToEvalResult(lp_, &opts_);
1079
+ if (!interactive_) {
1080
+ // EvaluatePacket mode: kernel never sends INPUTNAMEPKT/OUTPUTNAMEPKT,
1081
+ // so stamp the pre-captured counter and derive outputName manually.
1082
+ result_.cellIndex = cellIndex_;
1083
+ if (!result_.aborted && result_.result.kind == WExpr::Symbol) {
1084
+ const std::string& sv = result_.result.strVal;
1085
+ std::string bare = sv;
1086
+ auto tick = sv.rfind('`');
1087
+ if (tick != std::string::npos) bare = sv.substr(tick + 1);
1088
+ if (bare != "Null")
1089
+ result_.outputName = "Out[" + std::to_string(cellIndex_) + "]=";
1090
+ } else if (!result_.aborted && result_.result.kind != WExpr::WError) {
1091
+ result_.outputName = "Out[" + std::to_string(cellIndex_) + "]=";
1092
+ }
1093
+ } else {
1094
+ // EnterExpressionPacket mode: DrainToEvalResult already populated
1095
+ // cellIndex/outputName from INPUTNAMEPKT/OUTPUTNAMEPKT.
1096
+ // Use our counter as fallback if the kernel didn't send them.
1097
+ if (result_.cellIndex == 0)
1098
+ result_.cellIndex = cellIndex_;
1099
+ }
1100
+ }
1101
+ if (opts_.hasOnPrint) opts_.onPrint.Release();
1102
+ if (opts_.hasOnMessage) opts_.onMessage.Release();
1103
+ if (opts_.hasOnDialogBegin) opts_.onDialogBegin.Release();
1104
+ if (opts_.hasOnDialogPrint) opts_.onDialogPrint.Release();
1105
+ if (opts_.hasOnDialogEnd) opts_.onDialogEnd.Release();
1106
+ }
1107
+
1108
+ // ---- main thread: resolve promise after all TSFN callbacks delivered ----
1109
+ void OnOK() override {
1110
+ Napi::Env env = Env();
1111
+ abortFlag_.store(false);
1112
+
1113
+ EvalResult r = std::move(result_);
1114
+ Napi::Promise::Deferred d = std::move(deferred_);
1115
+ std::function<void()> cb = completionCb_;
1116
+
1117
+ auto resolveFn = [env, r = std::move(r), d = std::move(d), cb]() mutable {
1118
+ if (r.result.kind == WExpr::WError) {
1119
+ d.Reject(Napi::Error::New(env, r.result.strVal).Value());
1120
+ } else {
1121
+ Napi::Value v = EvalResultToNapi(env, r);
1122
+ if (env.IsExceptionPending())
1123
+ d.Reject(env.GetAndClearPendingException().Value());
1124
+ else
1125
+ d.Resolve(v);
1126
+ }
1127
+ cb();
1128
+ };
1129
+
1130
+ if (opts_.ctx) {
1131
+ opts_.ctx->fn = std::move(resolveFn);
1132
+ opts_.ctx->done();
1133
+ } else {
1134
+ resolveFn();
1135
+ }
1136
+ }
1137
+
1138
+ void OnError(const Napi::Error& e) override {
1139
+ Napi::Env env = Env();
1140
+ std::string msg = e.Message();
1141
+ Napi::Promise::Deferred d = std::move(deferred_);
1142
+ std::function<void()> cb = completionCb_;
1143
+
1144
+ auto rejectFn = [env, msg, d = std::move(d), cb]() mutable {
1145
+ d.Reject(Napi::Error::New(env, msg).Value());
1146
+ cb();
1147
+ };
1148
+
1149
+ if (opts_.ctx) {
1150
+ opts_.ctx->fn = std::move(rejectFn);
1151
+ opts_.ctx->done();
1152
+ } else {
1153
+ rejectFn();
1154
+ }
1155
+ }
1156
+
1157
+ private:
1158
+ Napi::Promise::Deferred deferred_;
1159
+ WSLINK lp_;
1160
+ std::string expr_;
1161
+ EvalOptions opts_;
1162
+ std::atomic<bool>& abortFlag_;
1163
+ std::function<void()> completionCb_;
1164
+ int64_t cellIndex_;
1165
+ bool interactive_;
1166
+ EvalResult result_;
1167
+ };
1168
+
1169
+ // ===========================================================================
1170
+ // WstpSession — JS class: new WstpSession(kernelPath?)
1171
+ // ===========================================================================
1172
+ static const char* kDefaultKernel =
1173
+ "/Applications/Wolfram 3.app/Contents/MacOS/WolframKernel";
1174
+
1175
+ class WstpSession : public Napi::ObjectWrap<WstpSession> {
1176
+ public:
1177
+ static Napi::Object Init(Napi::Env env, Napi::Object exports) {
1178
+ Napi::Function func = DefineClass(env, "WstpSession", {
1179
+ InstanceMethod<&WstpSession::Evaluate> ("evaluate"),
1180
+ InstanceMethod<&WstpSession::Sub> ("sub"),
1181
+ InstanceMethod<&WstpSession::DialogEval> ("dialogEval"),
1182
+ InstanceMethod<&WstpSession::ExitDialog> ("exitDialog"),
1183
+ InstanceMethod<&WstpSession::Interrupt> ("interrupt"),
1184
+ InstanceMethod<&WstpSession::Abort> ("abort"),
1185
+ InstanceMethod<&WstpSession::CreateSubsession>("createSubsession"),
1186
+ InstanceMethod<&WstpSession::Close> ("close"),
1187
+ InstanceAccessor<&WstpSession::IsOpen> ("isOpen"),
1188
+ InstanceAccessor<&WstpSession::IsDialogOpen> ("isDialogOpen"),
1189
+ });
1190
+
1191
+ Napi::FunctionReference* ctor = new Napi::FunctionReference();
1192
+ *ctor = Napi::Persistent(func);
1193
+ env.SetInstanceData(ctor);
1194
+
1195
+ exports.Set("WstpSession", func);
1196
+ return exports;
1197
+ }
1198
+
1199
+ // -----------------------------------------------------------------------
1200
+ // Constructor new WstpSession(kernelPath?)
1201
+ // -----------------------------------------------------------------------
1202
+ WstpSession(const Napi::CallbackInfo& info)
1203
+ : Napi::ObjectWrap<WstpSession>(info), wsEnv_(nullptr), lp_(nullptr), open_(false)
1204
+ {
1205
+ Napi::Env env = info.Env();
1206
+
1207
+ std::string kernelPath = kDefaultKernel;
1208
+ if (info.Length() > 0 && info[0].IsString())
1209
+ kernelPath = info[0].As<Napi::String>().Utf8Value();
1210
+
1211
+ // Parse options object: new WstpSession(opts?) or new WstpSession(path, opts?)
1212
+ // Supported options: { interactive: true } — use EnterTextPacket instead of
1213
+ // EvaluatePacket so the kernel populates In[n]/Out[n] variables.
1214
+ int optsIdx = (info.Length() > 0 && info[0].IsObject()) ? 0
1215
+ : (info.Length() > 1 && info[1].IsObject()) ? 1 : -1;
1216
+ if (optsIdx >= 0) {
1217
+ auto o = info[optsIdx].As<Napi::Object>();
1218
+ if (o.Has("interactive") && o.Get("interactive").IsBoolean())
1219
+ interactiveMode_ = o.Get("interactive").As<Napi::Boolean>().Value();
1220
+ }
1221
+
1222
+ WSEnvironmentParameter params = WSNewParameters(WSREVISION, WSAPIREVISION);
1223
+ wsEnv_ = WSInitialize(params);
1224
+ WSReleaseParameters(params);
1225
+ if (!wsEnv_) {
1226
+ Napi::Error::New(env, "WSInitialize failed").ThrowAsJavaScriptException();
1227
+ return;
1228
+ }
1229
+
1230
+ // Shell-quote the kernel path so spaces ("Wolfram 3.app") survive
1231
+ // being passed through /bin/sh by WSOpenArgcArgv.
1232
+ std::string linkName = "\"" + kernelPath + "\" -wstp";
1233
+ const char* argv[] = { "wstp", "-linkname", linkName.c_str(),
1234
+ "-linkmode", "launch" };
1235
+
1236
+ // Retry the entire kernel-launch sequence up to 3 times. On ~20% of
1237
+ // consecutive launches the kernel starts with $Output routing broken
1238
+ // (Print[]/Message[] produce no TextPacket). Killing the stale kernel
1239
+ // and spawning a fresh one resolves it reliably within 1-2 attempts.
1240
+ int err = 0;
1241
+ for (int attempt = 0; attempt <= 2; ++attempt) {
1242
+ if (attempt > 0) {
1243
+ DiagLog("[Session] restart attempt " + std::to_string(attempt) +
1244
+ " — $Output routing broken on previous kernel");
1245
+ WSClose(lp_); lp_ = nullptr;
1246
+ if (kernelPid_ > 0) { kill(kernelPid_, SIGTERM); kernelPid_ = 0; }
1247
+ std::this_thread::sleep_for(std::chrono::milliseconds(200));
1248
+ }
1249
+
1250
+ err = 0;
1251
+ lp_ = WSOpenArgcArgv(wsEnv_, 5, const_cast<char**>(argv), &err);
1252
+ if (!lp_ || err != WSEOK) {
1253
+ std::string msg = "WSOpenArgcArgv failed (code " + std::to_string(err) + ")";
1254
+ WSDeinitialize(wsEnv_); wsEnv_ = nullptr;
1255
+ Napi::Error::New(env, msg).ThrowAsJavaScriptException();
1256
+ return;
1257
+ }
1258
+
1259
+ if (!WSActivate(lp_)) {
1260
+ const char* m = WSErrorMessage(lp_);
1261
+ std::string s = std::string("WSActivate failed: ") + (m ? m : "?");
1262
+ WSClose(lp_); lp_ = nullptr;
1263
+ WSDeinitialize(wsEnv_); wsEnv_ = nullptr;
1264
+ Napi::Error::New(env, s).ThrowAsJavaScriptException();
1265
+ return;
1266
+ }
1267
+
1268
+ // Get the kernel PID so CleanUp() can SIGTERM it.
1269
+ kernelPid_ = FetchKernelPid(lp_);
1270
+
1271
+ // Confirm $Output→TextPacket routing is live. If not, loop back
1272
+ // and restart with a fresh kernel process.
1273
+ if (WarmUpOutputRouting(lp_)) break; // success — proceed
1274
+
1275
+ // Last attempt — give up and continue with broken $Output rather
1276
+ // than looping indefinitely.
1277
+ if (attempt == 2)
1278
+ DiagLog("[Session] WARNING: $Output broken after 3 kernel launches");
1279
+ }
1280
+
1281
+ open_ = true;
1282
+ abortFlag_.store(false);
1283
+ }
1284
+
1285
+ ~WstpSession() { CleanUp(); }
1286
+
1287
+ // -----------------------------------------------------------------------
1288
+ // evaluate(expr, opts?) → Promise<EvalResult>
1289
+ //
1290
+ // opts may contain { onPrint, onMessage } streaming callbacks.
1291
+ // Multiple calls are serialised through an internal queue — no link
1292
+ // corruption even if the caller doesn't await between calls.
1293
+ // -----------------------------------------------------------------------
1294
+ Napi::Value Evaluate(const Napi::CallbackInfo& info) {
1295
+ Napi::Env env = info.Env();
1296
+ auto deferred = Napi::Promise::Deferred::New(env);
1297
+ auto promise = deferred.Promise();
1298
+
1299
+ if (!open_) {
1300
+ deferred.Reject(Napi::Error::New(env, "Session is closed").Value());
1301
+ return promise;
1302
+ }
1303
+ if (info.Length() < 1 || !info[0].IsString()) {
1304
+ deferred.Reject(Napi::TypeError::New(env, "evaluate(expr: string, opts?: object)").Value());
1305
+ return promise;
1306
+ }
1307
+
1308
+ std::string expr = info[0].As<Napi::String>().Utf8Value();
1309
+
1310
+ // Parse optional second argument: { onPrint?, onMessage?, onDialogBegin?,
1311
+ // onDialogPrint?, onDialogEnd?,
1312
+ // interactive?: boolean (override session default) }
1313
+ EvalOptions opts;
1314
+ int interactiveOverride = -1; // -1 = use session default
1315
+ if (info.Length() >= 2 && info[1].IsObject()) {
1316
+ auto optsObj = info[1].As<Napi::Object>();
1317
+ bool wantPrint = optsObj.Has("onPrint") && optsObj.Get("onPrint").IsFunction();
1318
+ bool wantMsg = optsObj.Has("onMessage") && optsObj.Get("onMessage").IsFunction();
1319
+ bool wantDBegin = optsObj.Has("onDialogBegin") && optsObj.Get("onDialogBegin").IsFunction();
1320
+ bool wantDPrint = optsObj.Has("onDialogPrint") && optsObj.Get("onDialogPrint").IsFunction();
1321
+ bool wantDEnd = optsObj.Has("onDialogEnd") && optsObj.Get("onDialogEnd").IsFunction();
1322
+ // Per-call interactive override: opts.interactive = true/false
1323
+ if (optsObj.Has("interactive") && optsObj.Get("interactive").IsBoolean())
1324
+ interactiveOverride = optsObj.Get("interactive").As<Napi::Boolean>().Value() ? 1 : 0;
1325
+
1326
+ // CompleteCtx: count = numTsfns + 1 (the extra slot is for OnOK/OnError).
1327
+ // This ensures the Promise resolves ONLY after every TSFN has been
1328
+ // finalized (= all queued callbacks delivered), regardless of whether
1329
+ // the TSFN finalizers or OnOK/OnError happen to run first.
1330
+ int tsfnCount = (wantPrint ? 1 : 0) + (wantMsg ? 1 : 0)
1331
+ + (wantDBegin ? 1 : 0) + (wantDPrint ? 1 : 0)
1332
+ + (wantDEnd ? 1 : 0);
1333
+ CompleteCtx* ctx = (tsfnCount > 0) ? new CompleteCtx{ tsfnCount + 1, {} } : nullptr;
1334
+
1335
+ auto makeTsfn = [&](const char* name, const char* key) {
1336
+ return Napi::ThreadSafeFunction::New(
1337
+ env, optsObj.Get(key).As<Napi::Function>(), name, 0, 1,
1338
+ ctx, [](Napi::Env, CompleteCtx* c) { c->done(); });
1339
+ };
1340
+ if (wantPrint) { opts.onPrint = makeTsfn("onPrint", "onPrint"); opts.hasOnPrint = true; }
1341
+ if (wantMsg) { opts.onMessage = makeTsfn("onMessage", "onMessage"); opts.hasOnMessage = true; }
1342
+ if (wantDBegin) { opts.onDialogBegin = makeTsfn("onDialogBegin", "onDialogBegin"); opts.hasOnDialogBegin = true; }
1343
+ if (wantDPrint) { opts.onDialogPrint = makeTsfn("onDialogPrint", "onDialogPrint"); opts.hasOnDialogPrint = true; }
1344
+ if (wantDEnd) { opts.onDialogEnd = makeTsfn("onDialogEnd", "onDialogEnd"); opts.hasOnDialogEnd = true; }
1345
+ opts.ctx = ctx;
1346
+ }
1347
+ // Wire up the dialog queue pointers so DrainToEvalResult can service
1348
+ // dialogEval() calls from the thread pool during a Dialog[] subsession.
1349
+ opts.dialogMutex = &dialogMutex_;
1350
+ opts.dialogQueue = &dialogQueue_;
1351
+ opts.dialogPending = &dialogPending_;
1352
+ opts.dialogOpen = &dialogOpen_;
1353
+ opts.abortFlag = &abortFlag_;
1354
+
1355
+ {
1356
+ std::lock_guard<std::mutex> lk(queueMutex_);
1357
+ queue_.push(QueuedEval{ std::move(expr), std::move(opts), std::move(deferred), interactiveOverride });
1358
+ }
1359
+ MaybeStartNext();
1360
+ return promise;
1361
+ }
1362
+
1363
+ // -----------------------------------------------------------------------
1364
+ // MaybeStartNext — pop the front of the queue and launch it, but only if
1365
+ // no evaluation is currently running (busy_ CAS ensures atomicity).
1366
+ // Called from: Evaluate() on main thread; completionCb_ on main thread.
1367
+ // Queue entry — one pending sub() call when the session is idle.
1368
+ struct QueuedSubIdle {
1369
+ std::string expr;
1370
+ Napi::Promise::Deferred deferred;
1371
+ };
1372
+
1373
+ // Sub-idle evals are preferred over normal evals so sub()-when-idle gets
1374
+ // a quick result without waiting for a queued evaluate().
1375
+ // -----------------------------------------------------------------------
1376
+ void MaybeStartNext() {
1377
+ bool expected = false;
1378
+ if (!busy_.compare_exchange_strong(expected, true))
1379
+ return; // already running
1380
+
1381
+ std::unique_lock<std::mutex> lk(queueMutex_);
1382
+
1383
+ // Check sub-idle queue first.
1384
+ if (!subIdleQueue_.empty()) {
1385
+ auto item = std::move(subIdleQueue_.front());
1386
+ subIdleQueue_.pop();
1387
+ lk.unlock();
1388
+ StartSubIdleWorker(std::move(item));
1389
+ return;
1390
+ }
1391
+
1392
+ if (queue_.empty()) {
1393
+ busy_.store(false);
1394
+ return;
1395
+ }
1396
+ auto item = std::move(queue_.front());
1397
+ queue_.pop();
1398
+ lk.unlock();
1399
+
1400
+ // Resolve interactive mode: per-call override takes precedence over session default.
1401
+ bool evalInteractive = (item.interactiveOverride == -1)
1402
+ ? interactiveMode_
1403
+ : (item.interactiveOverride == 1);
1404
+ auto* worker = new EvaluateWorker(
1405
+ std::move(item.deferred),
1406
+ lp_,
1407
+ std::move(item.expr),
1408
+ std::move(item.opts),
1409
+ abortFlag_,
1410
+ [this]() { busy_.store(false); MaybeStartNext(); },
1411
+ nextLine_.fetch_add(1),
1412
+ evalInteractive
1413
+ );
1414
+ worker->Queue();
1415
+ }
1416
+
1417
+ // Launch a lightweight EvaluateWorker that resolves with just the WExpr
1418
+ // (not a full EvalResult) — used by sub() when the session is idle.
1419
+ void StartSubIdleWorker(QueuedSubIdle item) {
1420
+ struct SubIdleWorker : public Napi::AsyncWorker {
1421
+ SubIdleWorker(Napi::Promise::Deferred d, WSLINK lp, std::string expr,
1422
+ std::function<void()> done)
1423
+ : Napi::AsyncWorker(d.Env()),
1424
+ deferred_(std::move(d)), lp_(lp), expr_(std::move(expr)),
1425
+ done_(std::move(done)) {}
1426
+
1427
+ void Execute() override {
1428
+ if (!WSPutFunction(lp_, "EvaluatePacket", 1) ||
1429
+ !WSPutFunction(lp_, "ToExpression", 1) ||
1430
+ !WSPutUTF8String(lp_, (const unsigned char *)expr_.c_str(), (int)expr_.size()) ||
1431
+ !WSEndPacket(lp_) ||
1432
+ !WSFlush(lp_)) {
1433
+ SetError("sub (idle): failed to send EvaluatePacket");
1434
+ return;
1435
+ }
1436
+ result_ = DrainToEvalResult(lp_);
1437
+ }
1438
+ void OnOK() override {
1439
+ Napi::Env env = Env();
1440
+ if (result_.result.kind == WExpr::WError) {
1441
+ deferred_.Reject(Napi::Error::New(env, result_.result.strVal).Value());
1442
+ } else {
1443
+ Napi::Value v = WExprToNapi(env, result_.result);
1444
+ if (env.IsExceptionPending())
1445
+ deferred_.Reject(env.GetAndClearPendingException().Value());
1446
+ else
1447
+ deferred_.Resolve(v);
1448
+ }
1449
+ done_();
1450
+ }
1451
+ void OnError(const Napi::Error& e) override {
1452
+ deferred_.Reject(e.Value());
1453
+ done_();
1454
+ }
1455
+ private:
1456
+ Napi::Promise::Deferred deferred_;
1457
+ WSLINK lp_;
1458
+ std::string expr_;
1459
+ std::function<void()> done_;
1460
+ EvalResult result_;
1461
+ };
1462
+
1463
+ (new SubIdleWorker(std::move(item.deferred), lp_, std::move(item.expr),
1464
+ [this]() { busy_.store(false); MaybeStartNext(); }))->Queue();
1465
+ }
1466
+
1467
+ // -----------------------------------------------------------------------
1468
+ // sub(expr) → Promise<WExpr>
1469
+ //
1470
+ // Queues a lightweight evaluation that resolves with just the WExpr result
1471
+ // (not a full EvalResult). If the session is busy the sub() is prioritised
1472
+ // over any pending evaluate() calls and starts as soon as the current eval
1473
+ // finishes. If idle it starts immediately.
1474
+ // -----------------------------------------------------------------------
1475
+ Napi::Value Sub(const Napi::CallbackInfo& info) {
1476
+ Napi::Env env = info.Env();
1477
+ auto deferred = Napi::Promise::Deferred::New(env);
1478
+ auto promise = deferred.Promise();
1479
+
1480
+ if (!open_) {
1481
+ deferred.Reject(Napi::Error::New(env, "Session is closed").Value());
1482
+ return promise;
1483
+ }
1484
+ if (info.Length() < 1 || !info[0].IsString()) {
1485
+ deferred.Reject(Napi::TypeError::New(env, "sub(expr: string)").Value());
1486
+ return promise;
1487
+ }
1488
+ std::string expr = info[0].As<Napi::String>().Utf8Value();
1489
+
1490
+ {
1491
+ std::lock_guard<std::mutex> lk(queueMutex_);
1492
+ subIdleQueue_.push(QueuedSubIdle{ std::move(expr), std::move(deferred) });
1493
+ }
1494
+ MaybeStartNext();
1495
+ return promise;
1496
+ }
1497
+
1498
+ // -----------------------------------------------------------------------
1499
+ // exitDialog(retVal?) → Promise<void>
1500
+ //
1501
+ // Exits the currently-open Dialog[] subsession by entering
1502
+ // "Return[retVal]" as if the user typed it at the interactive prompt
1503
+ // (EnterTextPacket). This is different from plain dialogEval('Return[]')
1504
+ // which uses EvaluatePacket and does NOT exit the dialog because Return[]
1505
+ // at the top level of EvaluatePacket is unevaluated.
1506
+ //
1507
+ // Returns a Promise that resolves (with Null) when ENDDLGPKT is received,
1508
+ // or rejects immediately if no dialog is open.
1509
+ // -----------------------------------------------------------------------
1510
+ Napi::Value ExitDialog(const Napi::CallbackInfo& info) {
1511
+ Napi::Env env = info.Env();
1512
+ auto deferred = Napi::Promise::Deferred::New(env);
1513
+ auto promise = deferred.Promise();
1514
+
1515
+ if (!open_) {
1516
+ deferred.Reject(Napi::Error::New(env, "Session is closed").Value());
1517
+ return promise;
1518
+ }
1519
+ if (!dialogOpen_.load()) {
1520
+ deferred.Reject(Napi::Error::New(env,
1521
+ "no dialog subsession is open").Value());
1522
+ return promise;
1523
+ }
1524
+ // Build "Return[]" or "Return[retVal]" as the exit expression.
1525
+ std::string exitExpr = "Return[]";
1526
+ if (info.Length() >= 1 && info[0].IsString())
1527
+ exitExpr = "Return[" + info[0].As<Napi::String>().Utf8Value() + "]";
1528
+
1529
+ auto tsfn = Napi::ThreadSafeFunction::New(
1530
+ env,
1531
+ Napi::Function::New(env, [deferred](const Napi::CallbackInfo& ci) mutable {
1532
+ Napi::Env e = ci.Env();
1533
+ if (ci.Length() > 0) {
1534
+ auto obj = ci[0];
1535
+ if (obj.IsObject()) {
1536
+ auto o = obj.As<Napi::Object>();
1537
+ if (o.Has("error") && o.Get("error").IsString()) {
1538
+ deferred.Reject(Napi::Error::New(e,
1539
+ o.Get("error").As<Napi::String>().Utf8Value()).Value());
1540
+ return;
1541
+ }
1542
+ }
1543
+ }
1544
+ deferred.Resolve(e.Null());
1545
+ }),
1546
+ "exitDialogResolve", 0, 1);
1547
+
1548
+ DialogRequest req;
1549
+ req.expr = std::move(exitExpr);
1550
+ req.useEnterText = true;
1551
+ req.resolve = std::move(tsfn);
1552
+ {
1553
+ std::lock_guard<std::mutex> lk(dialogMutex_);
1554
+ dialogQueue_.push(std::move(req));
1555
+ }
1556
+ dialogPending_.store(true);
1557
+ return promise;
1558
+ }
1559
+
1560
+ // -----------------------------------------------------------------------
1561
+ // dialogEval(expr) → Promise<WExpr>
1562
+ // Rejects immediately if no dialog is open.
1563
+ // -----------------------------------------------------------------------
1564
+ Napi::Value DialogEval(const Napi::CallbackInfo& info) {
1565
+ Napi::Env env = info.Env();
1566
+ auto deferred = Napi::Promise::Deferred::New(env);
1567
+ auto promise = deferred.Promise();
1568
+
1569
+ if (!open_) {
1570
+ deferred.Reject(Napi::Error::New(env, "Session is closed").Value());
1571
+ return promise;
1572
+ }
1573
+ if (!dialogOpen_.load()) {
1574
+ deferred.Reject(Napi::Error::New(env,
1575
+ "no dialog subsession is open — call Dialog[] first").Value());
1576
+ return promise;
1577
+ }
1578
+ if (info.Length() < 1 || !info[0].IsString()) {
1579
+ deferred.Reject(Napi::TypeError::New(env, "dialogEval(expr: string)").Value());
1580
+ return promise;
1581
+ }
1582
+ std::string expr = info[0].As<Napi::String>().Utf8Value();
1583
+
1584
+ // Create a TSFN that resolves/rejects the deferred on the main thread.
1585
+ // The TSFN is Released by serviceDialogRequest() after the single call.
1586
+ auto tsfn = Napi::ThreadSafeFunction::New(
1587
+ env,
1588
+ Napi::Function::New(env, [deferred](const Napi::CallbackInfo& ci) mutable {
1589
+ Napi::Env e = ci.Env();
1590
+ if (ci.Length() > 0 && ci[0].IsObject()) {
1591
+ auto obj = ci[0].As<Napi::Object>();
1592
+ // Check for error sentinel: { error: string }
1593
+ if (obj.Has("error") && obj.Get("error").IsString()) {
1594
+ deferred.Reject(Napi::Error::New(e,
1595
+ obj.Get("error").As<Napi::String>().Utf8Value()).Value());
1596
+ } else {
1597
+ deferred.Resolve(ci[0]);
1598
+ }
1599
+ } else {
1600
+ deferred.Reject(Napi::Error::New(e, "dialogEval: bad result").Value());
1601
+ }
1602
+ }),
1603
+ "dialogResolve", 0, 1);
1604
+
1605
+ DialogRequest req;
1606
+ req.expr = std::move(expr);
1607
+ req.useEnterText = false;
1608
+ req.resolve = std::move(tsfn);
1609
+ {
1610
+ std::lock_guard<std::mutex> lk(dialogMutex_);
1611
+ dialogQueue_.push(std::move(req));
1612
+ }
1613
+ dialogPending_.store(true);
1614
+ return promise;
1615
+ }
1616
+
1617
+ // -----------------------------------------------------------------------
1618
+ // interrupt() — send WSInterruptMessage (best-effort).
1619
+ //
1620
+ // This is NOT abort() — it does not cancel the evaluation. Its effect
1621
+ // depends entirely on whether a Wolfram-side interrupt handler has been
1622
+ // installed (e.g. Internal`AddHandler["Interrupt", Function[Null, Dialog[]]]).
1623
+ // On kernels without such a handler this is a no-op.
1624
+ // Main-thread only — same thread-safety guarantee as abort().
1625
+ // -----------------------------------------------------------------------
1626
+ Napi::Value Interrupt(const Napi::CallbackInfo& info) {
1627
+ Napi::Env env = info.Env();
1628
+ if (!open_) return Napi::Boolean::New(env, false);
1629
+ int ok = WSPutMessage(lp_, WSInterruptMessage);
1630
+ return Napi::Boolean::New(env, ok != 0);
1631
+ }
1632
+
1633
+ // -----------------------------------------------------------------------
1634
+ // abort() — interrupt the currently running evaluation.
1635
+ //
1636
+ // Sends WSAbortMessage on the link. Per the WSTP spec WSPutMessage() is
1637
+ // thread-safe and will cause WSNextPacket() on the thread-pool thread to
1638
+ // return ILLEGALPKT (link reset). The promise then rejects; a fresh
1639
+ // session must be created for further work after the kernel crashes/exits.
1640
+ //
1641
+ // For a softer (recoverable) interrupt use evaluate("Interrupt[]")
1642
+ // before the long computation, or wrap the computation in TimeConstrained.
1643
+ // -----------------------------------------------------------------------
1644
+ Napi::Value Abort(const Napi::CallbackInfo& info) {
1645
+ Napi::Env env = info.Env();
1646
+ if (!open_) return Napi::Boolean::New(env, false);
1647
+ // Only signal the kernel if an evaluation is actually in flight.
1648
+ // Sending WSAbortMessage to an idle kernel causes it to emit a
1649
+ // spurious RETURNPKT[$Aborted] that would corrupt the next evaluation.
1650
+ if (!busy_.load()) return Napi::Boolean::New(env, false);
1651
+ abortFlag_.store(true);
1652
+ int ok = WSPutMessage(lp_, WSAbortMessage);
1653
+ return Napi::Boolean::New(env, ok != 0);
1654
+ }
1655
+
1656
+ // -----------------------------------------------------------------------
1657
+ // createSubsession(kernelPath?) → WstpSession
1658
+ //
1659
+ // Spawns a completely independent WolframKernel process. Its entire
1660
+ // state (variables, definitions, memory) is isolated from the parent
1661
+ // and from every other session. Ideal for sandboxed or parallel work.
1662
+ // -----------------------------------------------------------------------
1663
+ Napi::Value CreateSubsession(const Napi::CallbackInfo& info) {
1664
+ Napi::Env env = info.Env();
1665
+ Napi::FunctionReference* ctor = env.GetInstanceData<Napi::FunctionReference>();
1666
+ if (info.Length() > 0 && info[0].IsString())
1667
+ return ctor->New({ info[0] });
1668
+ return ctor->New({});
1669
+ }
1670
+
1671
+ // -----------------------------------------------------------------------
1672
+ // close()
1673
+ // -----------------------------------------------------------------------
1674
+ Napi::Value Close(const Napi::CallbackInfo& info) {
1675
+ CleanUp();
1676
+ return info.Env().Undefined();
1677
+ }
1678
+
1679
+ // -----------------------------------------------------------------------
1680
+ // isOpen (read-only accessor)
1681
+ // -----------------------------------------------------------------------
1682
+ Napi::Value IsOpen(const Napi::CallbackInfo& info) {
1683
+ return Napi::Boolean::New(info.Env(), open_);
1684
+ }
1685
+
1686
+ // -----------------------------------------------------------------------
1687
+ // isDialogOpen (read-only accessor)
1688
+ // -----------------------------------------------------------------------
1689
+ Napi::Value IsDialogOpen(const Napi::CallbackInfo& info) {
1690
+ return Napi::Boolean::New(info.Env(), dialogOpen_.load());
1691
+ }
1692
+
1693
+ private:
1694
+ // Queue entry — one pending evaluate() call.
1695
+ // interactiveOverride: -1 = use session default, 0 = force batch, 1 = force interactive
1696
+ struct QueuedEval {
1697
+ std::string expr;
1698
+ EvalOptions opts;
1699
+ Napi::Promise::Deferred deferred;
1700
+ int interactiveOverride = -1;
1701
+ };
1702
+
1703
+ void CleanUp() {
1704
+ if (lp_) { WSClose(lp_); lp_ = nullptr; }
1705
+ if (wsEnv_) { WSDeinitialize(wsEnv_); wsEnv_ = nullptr; }
1706
+ // Kill the child kernel process so it doesn't become a zombie.
1707
+ // WSClose() closes the link but does not terminate the WolframKernel
1708
+ // child process — without this, each session leaks a kernel.
1709
+ if (kernelPid_ > 0) { kill(kernelPid_, SIGTERM); kernelPid_ = 0; }
1710
+ open_ = false;
1711
+ }
1712
+
1713
+
1714
+ // ---------------------------------------------------------------------------
1715
+ // Verify that $Output→TextPacket routing is live before the session is used.
1716
+ // Sends Print["$WARMUP$"] and looks for a TextPacket response, retrying up to
1717
+ // 5 times with a 100 ms pause between attempts. On some WolframKernel
1718
+ // launches (observed ~20% of consecutive runs) the kernel processes the
1719
+ // first EvaluatePacket before its internal $Output stream is wired to the
1720
+ // WSTP link, causing ALL subsequent Print[]/Message[] calls to silently
1721
+ // drop their output. One extra round-trip here forces the kernel to
1722
+ // complete its output-routing setup before any user code is evaluated.
1723
+ // Returns true if output routing is confirmed; false if it cannot be fixed.
1724
+ // ---------------------------------------------------------------------------
1725
+ static bool WarmUpOutputRouting(WSLINK lp) {
1726
+ // Brief initial pause after WSActivate: lets the kernel scheduler
1727
+ // fully wire $Output → WSTP TextPacket before the first probe.
1728
+ std::this_thread::sleep_for(std::chrono::milliseconds(100));
1729
+ for (int attempt = 0; attempt < 4; ++attempt) {
1730
+ if (attempt > 0)
1731
+ std::this_thread::sleep_for(std::chrono::milliseconds(200));
1732
+
1733
+ // Send EvaluatePacket[Print["$WARMUP$"]]
1734
+ if (!WSPutFunction(lp, "EvaluatePacket", 1) ||
1735
+ !WSPutFunction(lp, "Print", 1) ||
1736
+ !WSPutString (lp, "$WARMUP$") ||
1737
+ !WSEndPacket (lp) ||
1738
+ !WSFlush (lp))
1739
+ return false; // link error
1740
+
1741
+ bool gotText = false;
1742
+ while (true) {
1743
+ int pkt = WSNextPacket(lp);
1744
+ if (pkt == TEXTPKT) {
1745
+ WSNewPacket(lp);
1746
+ gotText = true;
1747
+ } else if (pkt == RETURNPKT) {
1748
+ WSNewPacket(lp);
1749
+ break;
1750
+ } else if (pkt == 0 || pkt == ILLEGALPKT) {
1751
+ WSClearError(lp);
1752
+ return false;
1753
+ } else {
1754
+ WSNewPacket(lp);
1755
+ }
1756
+ }
1757
+ if (gotText) {
1758
+ DiagLog("[WarmUp] $Output routing verified on attempt " + std::to_string(attempt + 1));
1759
+ return true;
1760
+ }
1761
+ DiagLog("[WarmUp] no TextPacket on attempt " + std::to_string(attempt + 1) + ", retrying...");
1762
+ }
1763
+ DiagLog("[WarmUp] $Output routing NOT confirmed — kernel restart needed");
1764
+ return false;
1765
+ }
1766
+
1767
+ // ---------------------------------------------------------------------------
1768
+ // Synchronously evaluate $ProcessID and return the kernel's PID.
1769
+ // Called once from the constructor after WSActivate, while the link is idle.
1770
+ // Returns 0 on failure (non-fatal — cleanup falls back to no-kill).
1771
+ // ---------------------------------------------------------------------------
1772
+ static pid_t FetchKernelPid(WSLINK lp) {
1773
+ // Send: EvaluatePacket[ToExpression["$ProcessID"]]
1774
+ if (!WSPutFunction(lp, "EvaluatePacket", 1) ||
1775
+ !WSPutFunction(lp, "ToExpression", 1) ||
1776
+ !WSPutString (lp, "$ProcessID") ||
1777
+ !WSEndPacket (lp) ||
1778
+ !WSFlush (lp))
1779
+ return 0;
1780
+
1781
+ // Drain packets until we see ReturnPacket with an integer.
1782
+ pid_t pid = 0;
1783
+ while (true) {
1784
+ int pkt = WSNextPacket(lp);
1785
+ if (pkt == RETURNPKT) {
1786
+ if (WSGetType(lp) == WSTKINT) {
1787
+ wsint64 v = 0;
1788
+ WSGetInteger64(lp, &v);
1789
+ pid = static_cast<pid_t>(v);
1790
+ }
1791
+ WSNewPacket(lp);
1792
+ break;
1793
+ }
1794
+ if (pkt == 0 || pkt == ILLEGALPKT) { WSClearError(lp); break; }
1795
+ WSNewPacket(lp); // skip INPUTNAMEPKT, OUTPUTNAMEPKT, etc.
1796
+ }
1797
+ return pid;
1798
+ }
1799
+
1800
+ WSEnvironment wsEnv_;
1801
+ WSLINK lp_;
1802
+ bool open_;
1803
+ bool interactiveMode_ = false; // true → EnterTextPacket, populates In/Out
1804
+ pid_t kernelPid_ = 0; // child process — killed on CleanUp
1805
+ std::atomic<int64_t> nextLine_{1}; // 1-based In[n] counter for EvalResult.cellIndex
1806
+ std::atomic<bool> abortFlag_{false};
1807
+ std::atomic<bool> busy_{false};
1808
+ std::mutex queueMutex_;
1809
+ std::queue<QueuedEval> queue_;
1810
+ std::queue<QueuedSubIdle> subIdleQueue_; // sub() — runs before queue_ items
1811
+ // Dialog subsession state — written on main thread, consumed on thread pool
1812
+ std::mutex dialogMutex_;
1813
+ std::queue<DialogRequest> dialogQueue_; // dialogEval() requests
1814
+ std::atomic<bool> dialogPending_{false};
1815
+ std::atomic<bool> dialogOpen_{false};
1816
+ };
1817
+
1818
+ // ===========================================================================
1819
+ // ReadNextWorker — async "read one expression" for WstpReader
1820
+ // ===========================================================================
1821
+ class ReadNextWorker : public Napi::AsyncWorker {
1822
+ public:
1823
+ ReadNextWorker(Napi::Promise::Deferred deferred, WSLINK lp, std::atomic<bool>& activated)
1824
+ : Napi::AsyncWorker(deferred.Env()), deferred_(deferred), lp_(lp), activated_(activated) {}
1825
+
1826
+ // Thread-pool: activate on first call, then read next expression.
1827
+ void Execute() override {
1828
+ // WSActivate must happen on a thread-pool thread, never on the JS
1829
+ // main thread. The kernel's WSTP runtime can only complete the
1830
+ // handshake once it is inside the Do-loop calling LinkWrite.
1831
+ if (!activated_.load()) {
1832
+ DiagLog("[WstpReader] WSActivate starting...");
1833
+ if (!WSActivate(lp_)) {
1834
+ const char* msg = WSErrorMessage(lp_);
1835
+ std::string err = std::string("WstpReader: WSActivate failed: ") + (msg ? msg : "?");
1836
+ DiagLog("[WstpReader] " + err);
1837
+ SetError(err);
1838
+ WSClearError(lp_);
1839
+ return;
1840
+ }
1841
+ // DO NOT call WSGetType / WSNewPacket here.
1842
+ // On TCPIP connect-mode links WSGetType is non-blocking: it returns 0
1843
+ // for *both* "no data in buffer yet" and a genuine WSTKEND boundary
1844
+ // token. Calling WSNewPacket when there is no data in the buffer
1845
+ // corrupts the link's internal read state so that every subsequent
1846
+ // WSGetType also returns 0, making all future reads hang forever.
1847
+ // The WSTKEND / preamble case (if it occurs at all) is handled below
1848
+ // after WSWaitForLinkActivity returns and data is confirmed present.
1849
+ DiagLog("[WstpReader] activated.");
1850
+ activated_.store(true);
1851
+ }
1852
+
1853
+ // Spin on WSGetType() waiting for the link buffer to become non-empty.
1854
+ //
1855
+ // WSGetType() is non-blocking on TCPIP connect-mode links: it returns
1856
+ // 0 immediately if no data is buffered. We spin in 5 ms increments
1857
+ // rather than using WSWaitForLinkActivity(), which has been observed to
1858
+ // return WSWAITSUCCESS before the buffer is actually readable on fast
1859
+ // consecutive runs, leading to a ReadExprRaw(type=0) error.
1860
+ //
1861
+ // The first 500 ms are traced at each distinct type value so failures
1862
+ // can be diagnosed from the log. Hard timeout: 5 seconds.
1863
+ {
1864
+ auto spinStart = std::chrono::steady_clock::now();
1865
+ auto deadline = spinStart + std::chrono::seconds(5);
1866
+ auto traceWindow = spinStart + std::chrono::milliseconds(500);
1867
+ int iters = 0, lastLoggedType = -999;
1868
+ while (true) {
1869
+ int t = WSGetType(lp_);
1870
+ ++iters;
1871
+ auto now = std::chrono::steady_clock::now();
1872
+ // Within the first 500 ms log every distinct type change.
1873
+ if (now < traceWindow && t != lastLoggedType) {
1874
+ auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(
1875
+ now - spinStart).count();
1876
+ DiagLog("[WstpReader] spin t=" + std::to_string(t)
1877
+ + " +" + std::to_string(ms) + "ms iter=" + std::to_string(iters));
1878
+ lastLoggedType = t;
1879
+ }
1880
+ if (t != 0) {
1881
+ auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(
1882
+ now - spinStart).count();
1883
+ DiagLog("[WstpReader] spin done: type=" + std::to_string(t)
1884
+ + " after " + std::to_string(iters) + " iters +"
1885
+ + std::to_string(ms) + "ms");
1886
+ break;
1887
+ }
1888
+ if (now >= deadline) {
1889
+ DiagLog("[WstpReader] spin TIMEOUT after " + std::to_string(iters)
1890
+ + " iters (5s)");
1891
+ SetError("WstpReader: 5-second timeout — link dead or data never arrived");
1892
+ return;
1893
+ }
1894
+ std::this_thread::sleep_for(std::chrono::milliseconds(5));
1895
+ }
1896
+ }
1897
+
1898
+ result_ = ReadExprRaw(lp_);
1899
+
1900
+ // If ReadExprRaw still encountered WSTKEND (type=0) it means the spin
1901
+ // exited on a protocol boundary token, not an expression token. Skip
1902
+ // it once with WSNewPacket and re-spin for the real expression.
1903
+ if (result_.kind == WExpr::WError
1904
+ && result_.strVal.find("unexpected token type: 0") != std::string::npos) {
1905
+ DiagLog("[WstpReader] got WSTKEND from ReadExprRaw — skipping preamble, re-spinning");
1906
+ WSNewPacket(lp_);
1907
+ auto spinStart2 = std::chrono::steady_clock::now();
1908
+ auto deadline2 = spinStart2 + std::chrono::seconds(5);
1909
+ int iters2 = 0;
1910
+ while (true) {
1911
+ int t = WSGetType(lp_);
1912
+ ++iters2;
1913
+ if (t != 0) {
1914
+ DiagLog("[WstpReader] re-spin done: type=" + std::to_string(t)
1915
+ + " after " + std::to_string(iters2) + " iters");
1916
+ break;
1917
+ }
1918
+ if (std::chrono::steady_clock::now() >= deadline2) {
1919
+ DiagLog("[WstpReader] re-spin TIMEOUT after " + std::to_string(iters2)
1920
+ + " iters");
1921
+ result_ = WExpr::mkError("WstpReader: 5-second timeout after WSTKEND skip");
1922
+ return;
1923
+ }
1924
+ std::this_thread::sleep_for(std::chrono::milliseconds(5));
1925
+ }
1926
+ result_ = ReadExprRaw(lp_);
1927
+ }
1928
+
1929
+ // After a successful expression read, advance past the expression
1930
+ // boundary so the next WSGetType() call sees the next expression's
1931
+ // start marker rather than a residual WSTKEND.
1932
+ if (result_.kind != WExpr::WError)
1933
+ WSNewPacket(lp_);
1934
+
1935
+ DiagLog("[WstpReader] ReadExprRaw kind=" + std::to_string((int)result_.kind)
1936
+ + (result_.kind == WExpr::WError ? " err=" + result_.strVal : ""));
1937
+ }
1938
+
1939
+ void OnOK() override {
1940
+ Napi::Env env = Env();
1941
+ if (result_.kind == WExpr::WError) {
1942
+ deferred_.Reject(Napi::Error::New(env, result_.strVal).Value());
1943
+ return;
1944
+ }
1945
+ Napi::Value v = WExprToNapi(env, result_);
1946
+ if (env.IsExceptionPending())
1947
+ deferred_.Reject(env.GetAndClearPendingException().Value());
1948
+ else
1949
+ deferred_.Resolve(v);
1950
+ }
1951
+
1952
+ void OnError(const Napi::Error& e) override {
1953
+ deferred_.Reject(e.Value());
1954
+ }
1955
+
1956
+ private:
1957
+ Napi::Promise::Deferred deferred_;
1958
+ WSLINK lp_;
1959
+ std::atomic<bool>& activated_;
1960
+ WExpr result_;
1961
+ };
1962
+
1963
+ // ===========================================================================
1964
+ // WstpReader — connects to a named WSTP link created by a Wolfram kernel.
1965
+ //
1966
+ // Usage pattern:
1967
+ // 1. Main kernel: $link = LinkCreate[LinkProtocol->"TCPIP"]
1968
+ // linkName = LinkName[$link] // → "port@host,0@host"
1969
+ // 2. JS: const reader = new WstpReader(linkName)
1970
+ // 3. Loop: while (reader.isOpen) { const v = await reader.readNext() }
1971
+ //
1972
+ // Each call to readNext() blocks (on the thread pool) until the next
1973
+ // expression is available, then resolves with an ExprTree.
1974
+ // When the kernel closes the link (LinkClose[$link]), readNext() rejects
1975
+ // with a "link closed" error.
1976
+ // ===========================================================================
1977
+ class WstpReader : public Napi::ObjectWrap<WstpReader> {
1978
+ public:
1979
+ static Napi::Object Init(Napi::Env env, Napi::Object exports) {
1980
+ Napi::Function func = DefineClass(env, "WstpReader", {
1981
+ InstanceMethod<&WstpReader::ReadNext>("readNext"),
1982
+ InstanceMethod<&WstpReader::Close> ("close"),
1983
+ InstanceAccessor<&WstpReader::IsOpen>("isOpen"),
1984
+ });
1985
+ exports.Set("WstpReader", func);
1986
+ return exports;
1987
+ }
1988
+
1989
+ // -----------------------------------------------------------------------
1990
+ // Constructor new WstpReader(linkName, protocol?)
1991
+ // linkName — value returned by Wolfram's LinkName[$link]
1992
+ // protocol — link protocol string, default "TCPIP"
1993
+ // -----------------------------------------------------------------------
1994
+ WstpReader(const Napi::CallbackInfo& info)
1995
+ : Napi::ObjectWrap<WstpReader>(info), wsEnv_(nullptr), lp_(nullptr), open_(false)
1996
+ {
1997
+ Napi::Env env = info.Env();
1998
+ if (info.Length() < 1 || !info[0].IsString()) {
1999
+ Napi::TypeError::New(env, "WstpReader(linkName: string, protocol?: string)")
2000
+ .ThrowAsJavaScriptException();
2001
+ return;
2002
+ }
2003
+ std::string linkName = info[0].As<Napi::String>().Utf8Value();
2004
+ std::string protocol = "TCPIP";
2005
+ if (info.Length() >= 2 && info[1].IsString())
2006
+ protocol = info[1].As<Napi::String>().Utf8Value();
2007
+
2008
+ WSEnvironmentParameter params = WSNewParameters(WSREVISION, WSAPIREVISION);
2009
+ wsEnv_ = WSInitialize(params);
2010
+ WSReleaseParameters(params);
2011
+ if (!wsEnv_) {
2012
+ Napi::Error::New(env, "WSInitialize failed (WstpReader)")
2013
+ .ThrowAsJavaScriptException();
2014
+ return;
2015
+ }
2016
+
2017
+ // Connect to the already-listening link.
2018
+ const char* argv[] = {
2019
+ "reader",
2020
+ "-linkname", linkName.c_str(),
2021
+ "-linkmode", "connect",
2022
+ "-linkprotocol", protocol.c_str()
2023
+ };
2024
+ int err = 0;
2025
+ lp_ = WSOpenArgcArgv(wsEnv_, 7, const_cast<char**>(argv), &err);
2026
+ if (!lp_ || err != WSEOK) {
2027
+ std::string msg = "WstpReader: WSOpenArgcArgv failed (code "
2028
+ + std::to_string(err) + ") for link \"" + linkName + "\"";
2029
+ WSDeinitialize(wsEnv_); wsEnv_ = nullptr;
2030
+ Napi::Error::New(env, msg).ThrowAsJavaScriptException();
2031
+ return;
2032
+ }
2033
+
2034
+ // Do NOT call WSActivate here — it would block the JS main thread.
2035
+ // Activation is deferred to the first ReadNextWorker::Execute() call,
2036
+ // which runs on the libuv thread pool. The kernel's WSTP runtime
2037
+ // completes the handshake once it enters the Do-loop and calls LinkWrite.
2038
+ open_ = true;
2039
+ activated_.store(false);
2040
+ }
2041
+
2042
+ ~WstpReader() { CleanUp(); }
2043
+
2044
+ // readNext() → Promise<ExprTree>
2045
+ Napi::Value ReadNext(const Napi::CallbackInfo& info) {
2046
+ Napi::Env env = info.Env();
2047
+ auto deferred = Napi::Promise::Deferred::New(env);
2048
+ if (!open_) {
2049
+ deferred.Reject(Napi::Error::New(env, "WstpReader is closed").Value());
2050
+ return deferred.Promise();
2051
+ }
2052
+ (new ReadNextWorker(deferred, lp_, activated_))->Queue();
2053
+ return deferred.Promise();
2054
+ }
2055
+
2056
+ // close()
2057
+ Napi::Value Close(const Napi::CallbackInfo& info) {
2058
+ CleanUp();
2059
+ return info.Env().Undefined();
2060
+ }
2061
+
2062
+ // isOpen
2063
+ Napi::Value IsOpen(const Napi::CallbackInfo& info) {
2064
+ return Napi::Boolean::New(info.Env(), open_);
2065
+ }
2066
+
2067
+ private:
2068
+ void CleanUp() {
2069
+ if (lp_) { WSClose(lp_); lp_ = nullptr; }
2070
+ if (wsEnv_) { WSDeinitialize(wsEnv_); wsEnv_ = nullptr; }
2071
+ open_ = false;
2072
+ }
2073
+
2074
+ WSEnvironment wsEnv_;
2075
+ WSLINK lp_;
2076
+ bool open_;
2077
+ std::atomic<bool> activated_{false};
2078
+ };
2079
+
2080
+ // ===========================================================================
2081
+ // setDiagHandler(fn) — JS-callable; registers the global diagnostic callback.
2082
+ // Pass null / no argument to clear. The TSFN is Unref()'d so it does not
2083
+ // hold the Node.js event loop open.
2084
+ // ===========================================================================
2085
+ static Napi::Value SetDiagHandler(const Napi::CallbackInfo& info) {
2086
+ Napi::Env env = info.Env();
2087
+ std::lock_guard<std::mutex> lk(g_diagMutex);
2088
+ if (g_diagActive) {
2089
+ g_diagTsfn.Release();
2090
+ g_diagActive = false;
2091
+ }
2092
+ if (info.Length() >= 1 && info[0].IsFunction()) {
2093
+ g_diagTsfn = Napi::ThreadSafeFunction::New(
2094
+ env,
2095
+ info[0].As<Napi::Function>(),
2096
+ "diagHandler",
2097
+ /*maxQueueSize=*/0,
2098
+ /*initialThreadCount=*/1);
2099
+ g_diagTsfn.Unref(env); // do not prevent process exit
2100
+ g_diagActive = true;
2101
+ }
2102
+ return env.Undefined();
2103
+ }
2104
+
2105
+ // ===========================================================================
2106
+ // Module entry point
2107
+ // ===========================================================================
2108
+ Napi::Object InitModule(Napi::Env env, Napi::Object exports) {
2109
+ WstpSession::Init(env, exports);
2110
+ WstpReader::Init(env, exports);
2111
+ exports.Set("setDiagHandler",
2112
+ Napi::Function::New(env, SetDiagHandler, "setDiagHandler"));
2113
+ return exports;
2114
+ }
2115
+
2116
+ NODE_API_MODULE(wstp, InitModule)