wstp-node 0.4.6 → 0.6.1

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 CHANGED
@@ -91,6 +91,21 @@ struct DialogRequest {
91
91
  Napi::ThreadSafeFunction resolve; // NonBlockingCall'd with the WExpr result
92
92
  };
93
93
 
94
+ // ---------------------------------------------------------------------------
95
+ // Dynamic evaluation registration and result types. Defined at file scope so
96
+ // both EvalOptions and WstpSession can use them without circular dependencies.
97
+ // ---------------------------------------------------------------------------
98
+ struct DynRegistration {
99
+ std::string id;
100
+ std::string expr;
101
+ };
102
+ struct DynResult {
103
+ std::string id;
104
+ std::string value;
105
+ double timestamp = 0.0; // ms since epoch (set when stored)
106
+ std::string error; // non-empty if evaluation failed
107
+ };
108
+
94
109
  struct EvalOptions {
95
110
  Napi::ThreadSafeFunction onPrint; // fires once per Print[] line
96
111
  Napi::ThreadSafeFunction onMessage; // fires once per kernel message
@@ -105,6 +120,21 @@ struct EvalOptions {
105
120
  // When true, DrainToEvalResult expects EnterExpressionPacket protocol:
106
121
  // INPUTNAMEPKT → OUTPUTNAMEPKT → RETURNEXPRPKT (or INPUTNAMEPKT for Null).
107
122
  bool interactive = false;
123
+ // When true, any BEGINDLGPKT received during the drain is immediately
124
+ // auto-closed (Return[$Failed] sent) without informing the JS layer.
125
+ // Use for non-interactive VsCodeRender/handler-install evals to prevent
126
+ // a concurrent interrupt from hanging the evaluation forever (Pattern C).
127
+ bool rejectDialog = false;
128
+ // Phase 2 Dynamic eval: pointers wired up by Evaluate() so DrainToEvalResult
129
+ // can inline-evaluate registered Dynamic expressions inside BEGINDLGPKT.
130
+ // When dynAutoMode is false, legacy JS-callback dialog path is used instead.
131
+ std::mutex* dynMutex = nullptr;
132
+ std::vector<DynRegistration>* dynRegistry = nullptr; // non-owning
133
+ std::vector<DynResult>* dynResults = nullptr; // non-owning
134
+ std::chrono::steady_clock::time_point* dynLastEval = nullptr;
135
+ bool dynAutoMode = true; // mirrors dynAutoMode_ at time of queue dispatch
136
+ int dynIntervalMs = 0; // mirrors dynIntervalMs_ at time of queue dispatch
137
+ int* dynTaskInstalledInterval = nullptr; // non-owning; tracks installed ScheduledTask interval
108
138
  CompleteCtx* ctx = nullptr; // non-owning; set when TSFNs are in use
109
139
 
110
140
  // Pointers to session's dialog queue — set by WstpSession::Evaluate() so the
@@ -262,6 +292,210 @@ static void drainDialogAbortResponse(WSLINK lp) {
262
292
  }
263
293
  }
264
294
 
295
+ // ---------------------------------------------------------------------------
296
+ // drainStalePackets — after RETURNPKT, check for stale packets that arrived
297
+ // in the 50ms window after the kernel sent the main result.
298
+ //
299
+ // Scenario: an interrupt was sent just as the cell completed; the kernel may
300
+ // have queued a BEGINDLGPKT that arrives after RETURNPKT. If left unread it
301
+ // corrupts the NEXT evaluation (Pattern D).
302
+ //
303
+ // If opts->rejectDialog is true the dialog is also closed via our inline
304
+ // path (same as the main BEGINDLGPKT handler). If false, we still close
305
+ // stale dialogs silently to keep the link clean — the JS side never knew
306
+ // about this dialog so there's nobody to call exitDialog().
307
+ // ---------------------------------------------------------------------------
308
+ static void drainStalePackets(WSLINK lp, EvalOptions* opts) {
309
+ // When dynAutoMode is active, use a short timeout (50ms) so we do NOT
310
+ // accidentally consume a ScheduledTask Dialog[] that the main eval's
311
+ // dynAutoMode handler should handle. The stale packets we are looking
312
+ // for (from PREVIOUS evals) are already sitting on the link and arrive
313
+ // instantly, so 50ms is plenty. The longer 200ms timeout is only needed
314
+ // for non-dynAutoMode scenarios.
315
+ int timeoutMs = (opts && opts->dynAutoMode) ? 50 : 200;
316
+ auto deadline = std::chrono::steady_clock::now() +
317
+ std::chrono::milliseconds(timeoutMs);
318
+ while (std::chrono::steady_clock::now() < deadline) {
319
+ if (!WSReady(lp)) {
320
+ std::this_thread::sleep_for(std::chrono::milliseconds(5));
321
+ continue;
322
+ }
323
+ int pkt = WSNextPacket(lp);
324
+ if (pkt == BEGINDLGPKT) {
325
+ // Stale dialog — consume level int then auto-close.
326
+ wsint64 lvl = 0;
327
+ if (WSGetType(lp) == WSTKINT) WSGetInteger64(lp, &lvl);
328
+ WSNewPacket(lp);
329
+ DiagLog("[Eval] drainStalePackets: stale BEGINDLGPKT level=" + std::to_string(lvl) + " — auto-closing");
330
+ // Pre-drain INPUTNAMEPKT — Dialog[] sends INPUTNAMEPKT before
331
+ // accepting EnterTextPacket.
332
+ {
333
+ auto preDl = std::chrono::steady_clock::now() +
334
+ std::chrono::milliseconds(500);
335
+ while (std::chrono::steady_clock::now() < preDl) {
336
+ if (!WSReady(lp)) {
337
+ std::this_thread::sleep_for(std::chrono::milliseconds(2));
338
+ continue;
339
+ }
340
+ int ipkt = WSNextPacket(lp);
341
+ DiagLog("[Eval] drainStale: pre-drain pkt=" + std::to_string(ipkt));
342
+ WSNewPacket(lp);
343
+ if (ipkt == INPUTNAMEPKT) break;
344
+ if (ipkt == 0 || ipkt == ILLEGALPKT) { WSClearError(lp); break; }
345
+ }
346
+ }
347
+ const char* closeExpr = "Return[$Failed]";
348
+ WSPutFunction(lp, "EnterTextPacket", 1);
349
+ WSPutUTF8String(lp,
350
+ reinterpret_cast<const unsigned char*>(closeExpr),
351
+ static_cast<int>(std::strlen(closeExpr)));
352
+ WSEndPacket(lp);
353
+ WSFlush(lp);
354
+ DiagLog("[Eval] drainStale: sent Return[$Failed], draining...");
355
+ // Drain until ENDDLGPKT.
356
+ auto dlgDeadline = std::chrono::steady_clock::now() +
357
+ std::chrono::milliseconds(2000);
358
+ while (std::chrono::steady_clock::now() < dlgDeadline) {
359
+ if (!WSReady(lp)) {
360
+ std::this_thread::sleep_for(std::chrono::milliseconds(2));
361
+ continue;
362
+ }
363
+ int rp = WSNextPacket(lp);
364
+ DiagLog("[Eval] drainStale: drain pkt=" + std::to_string(rp));
365
+ WSNewPacket(lp);
366
+ if (rp == ENDDLGPKT) break;
367
+ if (rp == 0 || rp == ILLEGALPKT) { WSClearError(lp); break; }
368
+ }
369
+ } else if (pkt == MENUPKT) {
370
+ // Stale interrupt menu — dismiss with empty response.
371
+ wsint64 menuType = 0; WSGetInteger64(lp, &menuType);
372
+ const char* menuPrompt = nullptr; WSGetString(lp, &menuPrompt);
373
+ if (menuPrompt) WSReleaseString(lp, menuPrompt);
374
+ WSNewPacket(lp);
375
+ DiagLog("[Eval] drainStalePackets: stale MENUPKT type=" + std::to_string(menuType) + " — dismissing");
376
+ WSPutString(lp, "");
377
+ WSEndPacket(lp); WSFlush(lp);
378
+ } else {
379
+ WSNewPacket(lp); // discard any other stale packet
380
+ }
381
+ }
382
+ }
383
+
384
+ // ---------------------------------------------------------------------------
385
+ // drainUntilEndDialog — reads packets until ENDDLGPKT (dialog closed).
386
+ // Used by the C++-internal Dynamic eval path to finish a dialog level cleanly.
387
+ // Returns true on success, false on timeout or link error.
388
+ //
389
+ // If capturedOuterResult is non-null and a RETURNPKT/RETURNEXPRPKT arrives
390
+ // that looks like the outer eval's result (rather than Return[$Failed]'s
391
+ // response), it is saved there for the caller to use.
392
+ // ---------------------------------------------------------------------------
393
+ static bool drainUntilEndDialog(WSLINK lp, int timeoutMs,
394
+ WExpr* capturedOuterResult = nullptr) {
395
+ auto deadline = std::chrono::steady_clock::now() +
396
+ std::chrono::milliseconds(timeoutMs);
397
+ while (std::chrono::steady_clock::now() < deadline) {
398
+ if (!WSReady(lp)) {
399
+ std::this_thread::sleep_for(std::chrono::milliseconds(2));
400
+ continue;
401
+ }
402
+ int pkt = WSNextPacket(lp);
403
+ DiagLog("[drainEndDlg] pkt=" + std::to_string(pkt));
404
+ if (pkt == ENDDLGPKT) {
405
+ WSNewPacket(lp);
406
+ return true;
407
+ }
408
+ if (pkt == RETURNPKT || pkt == RETURNEXPRPKT) {
409
+ // Capture the outer eval's RETURNPKT if requested and not yet set.
410
+ if (capturedOuterResult &&
411
+ capturedOuterResult->kind == WExpr::WError &&
412
+ capturedOuterResult->strVal.empty()) {
413
+ *capturedOuterResult = ReadExprRaw(lp);
414
+ DiagLog("[drainEndDlg] captured outer result (pkt=" +
415
+ std::to_string(pkt) + ")");
416
+ }
417
+ WSNewPacket(lp);
418
+ continue;
419
+ }
420
+ if (pkt == 0 || pkt == ILLEGALPKT) {
421
+ WSClearError(lp);
422
+ return false;
423
+ }
424
+ WSNewPacket(lp); // discard TEXTPKT, MESSAGEPKT, INPUTNAMEPKT, etc.
425
+ }
426
+ return false; // timeout
427
+ }
428
+
429
+ // ---------------------------------------------------------------------------
430
+ // readDynResultWithTimeout — reads one result from an open Dialog level after
431
+ // the caller has already sent the expression (EnterTextPacket).
432
+ // On success sets dr.value (string form) and returns true.
433
+ // On failure sets dr.error and returns false.
434
+ //
435
+ // If capturedOuterResult is non-null and a RETURNPKT/RETURNEXPRPKT arrives
436
+ // (which is the outer eval's result that got evaluated inside the Dialog[]
437
+ // context), the value is saved there and the function continues waiting for
438
+ // the RETURNTEXTPKT that EnterTextPacket produces.
439
+ // ---------------------------------------------------------------------------
440
+ static bool readDynResultWithTimeout(WSLINK lp, DynResult& dr, int timeoutMs,
441
+ WExpr* capturedOuterResult = nullptr) {
442
+ auto deadline = std::chrono::steady_clock::now() +
443
+ std::chrono::milliseconds(timeoutMs);
444
+ while (std::chrono::steady_clock::now() < deadline) {
445
+ if (!WSReady(lp)) {
446
+ std::this_thread::sleep_for(std::chrono::milliseconds(2));
447
+ continue;
448
+ }
449
+ int pkt = WSNextPacket(lp);
450
+ DiagLog("[DynRead] pkt=" + std::to_string(pkt));
451
+ if (pkt == RETURNPKT || pkt == RETURNEXPRPKT || pkt == RETURNTEXTPKT) {
452
+ WExpr val = ReadExprRaw(lp);
453
+ WSNewPacket(lp);
454
+ DiagLog("[DynRead] accepted pkt=" + std::to_string(pkt) +
455
+ " kind=" + std::to_string(val.kind) +
456
+ " val=" + (val.kind == WExpr::String ? val.strVal :
457
+ val.kind == WExpr::Integer ? std::to_string(val.intVal) :
458
+ val.kind == WExpr::Symbol ? val.strVal :
459
+ val.kind == WExpr::Real ? std::to_string(val.realVal) :
460
+ val.head.empty() ? "?" : val.head));
461
+ // RETURNPKT/RETURNEXPRPKT inside a dialog means the outer eval's
462
+ // EvaluatePacket was processed inside this Dialog[] context (race
463
+ // condition when ScheduledTask fires between evals). Save the
464
+ // value for the caller and keep waiting for our RETURNTEXTPKT.
465
+ if (pkt != RETURNTEXTPKT && capturedOuterResult) {
466
+ DiagLog("[DynRead] captured outer result (pkt=" + std::to_string(pkt) + "), continuing");
467
+ *capturedOuterResult = std::move(val);
468
+ continue;
469
+ }
470
+ if (val.kind == WExpr::WError) {
471
+ dr.error = val.strVal;
472
+ return false;
473
+ }
474
+ switch (val.kind) {
475
+ case WExpr::String: dr.value = val.strVal; break;
476
+ case WExpr::Integer: dr.value = std::to_string(val.intVal); break;
477
+ case WExpr::Real: dr.value = std::to_string(val.realVal); break;
478
+ case WExpr::Symbol: dr.value = val.strVal; break;
479
+ default: dr.value = val.head.empty() ? "?" : val.head; break;
480
+ }
481
+ return true;
482
+ }
483
+ if (pkt == TEXTPKT || pkt == MESSAGEPKT ||
484
+ pkt == OUTPUTNAMEPKT || pkt == INPUTNAMEPKT) {
485
+ WSNewPacket(lp);
486
+ continue;
487
+ }
488
+ if (pkt == 0 || pkt == ILLEGALPKT) {
489
+ WSClearError(lp);
490
+ dr.error = "WSTP link error during Dynamic eval";
491
+ return false;
492
+ }
493
+ WSNewPacket(lp);
494
+ }
495
+ dr.error = "timeout";
496
+ return false;
497
+ }
498
+
265
499
  // ---------------------------------------------------------------------------
266
500
  // DrainToEvalResult — consume all packets for one cell, capturing Print[]
267
501
  // output and messages. Blocks until RETURNPKT. Thread-pool thread only.
@@ -645,6 +879,10 @@ static EvalResult DrainToEvalResult(WSLINK lp, EvalOptions* opts = nullptr) {
645
879
  if (r.result.kind == WExpr::Symbol && stripCtx(r.result.strVal) == "$Aborted")
646
880
  r.aborted = true;
647
881
  gotResult = true;
882
+ // Drain any stale packets (e.g. late BEGINDLGPKT from a concurrent
883
+ // interrupt that arrived just as this cell's RETURNPKT was sent).
884
+ // This prevents Pattern D: stale BEGINDLGPKT corrupting next eval.
885
+ if (!r.aborted) drainStalePackets(lp, opts);
648
886
  // In interactive mode the kernel always follows RETURNEXPRPKT with a
649
887
  // trailing INPUTNAMEPKT (the next "In[n+1]:=" prompt). We must consume
650
888
  // that packet before returning so the wire is clean for the next eval.
@@ -710,12 +948,217 @@ static EvalResult DrainToEvalResult(WSLINK lp, EvalOptions* opts = nullptr) {
710
948
  else if (pkt == BEGINDLGPKT) {
711
949
  // ----------------------------------------------------------------
712
950
  // Dialog subsession opened by the kernel.
713
- // Read the dialog level integer, set dialogOpen_ flag, fire callback.
714
951
  // ----------------------------------------------------------------
715
952
  wsint64 level = 0;
716
953
  if (WSGetType(lp) == WSTKINT) WSGetInteger64(lp, &level);
717
954
  WSNewPacket(lp);
718
955
 
956
+ // ---- rejectDialog: auto-close without informing JS layer --------
957
+ // Non-interactive evals (VsCodeRender, handler install, sub() exprs)
958
+ // must never block waiting for JS dialogEval() that never comes.
959
+ // Send Return[$Failed] immediately and continue waiting for RETURNPKT.
960
+ if (opts && opts->rejectDialog) {
961
+ DiagLog("[Eval] rejectDialog: auto-closing BEGINDLGPKT level=" + std::to_string(level));
962
+ // Pre-drain INPUTNAMEPKT — Dialog[] from ScheduledTask sends
963
+ // INPUTNAMEPKT before accepting EnterTextPacket.
964
+ {
965
+ auto preDl = std::chrono::steady_clock::now() +
966
+ std::chrono::milliseconds(500);
967
+ while (std::chrono::steady_clock::now() < preDl) {
968
+ if (!WSReady(lp)) {
969
+ std::this_thread::sleep_for(std::chrono::milliseconds(2));
970
+ continue;
971
+ }
972
+ int ipkt = WSNextPacket(lp);
973
+ DiagLog("[Eval] rejectDialog: pre-drain pkt=" + std::to_string(ipkt));
974
+ WSNewPacket(lp);
975
+ if (ipkt == INPUTNAMEPKT) break;
976
+ if (ipkt == 0 || ipkt == ILLEGALPKT) { WSClearError(lp); break; }
977
+ }
978
+ }
979
+ const char* closeExpr = "Return[$Failed]";
980
+ WSPutFunction(lp, "EnterTextPacket", 1);
981
+ WSPutUTF8String(lp,
982
+ reinterpret_cast<const unsigned char*>(closeExpr),
983
+ static_cast<int>(std::strlen(closeExpr)));
984
+ WSEndPacket(lp);
985
+ WSFlush(lp);
986
+ DiagLog("[Eval] rejectDialog: sent Return[$Failed], draining until ENDDLGPKT");
987
+ // Drain until ENDDLGPKT — kernel will close the dialog level.
988
+ {
989
+ auto dlgDeadline = std::chrono::steady_clock::now() +
990
+ std::chrono::milliseconds(2000);
991
+ while (std::chrono::steady_clock::now() < dlgDeadline) {
992
+ if (!WSReady(lp)) {
993
+ std::this_thread::sleep_for(std::chrono::milliseconds(2));
994
+ continue;
995
+ }
996
+ int rp = WSNextPacket(lp);
997
+ DiagLog("[Eval] rejectDialog: drain pkt=" + std::to_string(rp));
998
+ WSNewPacket(lp);
999
+ if (rp == ENDDLGPKT) break;
1000
+ if (rp == 0 || rp == ILLEGALPKT) { WSClearError(lp); break; }
1001
+ }
1002
+ }
1003
+ // Continue outer drain loop — the original RETURNPKT is still coming.
1004
+ continue;
1005
+ }
1006
+
1007
+ // ---- dynAutoMode: C++-internal Dynamic evaluation ---------------
1008
+ // When dynAutoMode is true, all registered Dynamic expressions are
1009
+ // evaluated inline inside the dialog — no JS roundtrip needed.
1010
+ // The dialog is then closed unconditionally with Return[$Failed].
1011
+ if (!opts || opts->dynAutoMode) {
1012
+ // Check if aborted before entering evaluation.
1013
+ if (opts && opts->abortFlag && opts->abortFlag->load()) {
1014
+ if (opts && opts->dialogOpen) opts->dialogOpen->store(false);
1015
+ r.result = WExpr::mkSymbol("System`$Aborted");
1016
+ r.aborted = true;
1017
+ drainDialogAbortResponse(lp);
1018
+ return r;
1019
+ }
1020
+
1021
+ // If the outer eval's EvaluatePacket was processed inside
1022
+ // this Dialog[] context (between-eval ScheduledTask fire),
1023
+ // its RETURNPKT will appear before our RETURNTEXTPKT.
1024
+ // capturedOuterResult captures that value so we can return
1025
+ // it as the cell result.
1026
+ WExpr capturedOuterResult;
1027
+
1028
+ if (opts && opts->dynMutex && opts->dynRegistry && opts->dynResults) {
1029
+ std::lock_guard<std::mutex> lk(*opts->dynMutex);
1030
+
1031
+ // Drain any initial packets (INPUTNAMEPKT, etc.) before
1032
+ // sending expressions. Dialog[] from ScheduledTask may
1033
+ // send INPUTNAMEPKT or TEXTPKT before accepting input.
1034
+ {
1035
+ auto drainDl = std::chrono::steady_clock::now() +
1036
+ std::chrono::milliseconds(500);
1037
+ while (std::chrono::steady_clock::now() < drainDl) {
1038
+ if (!WSReady(lp)) {
1039
+ std::this_thread::sleep_for(std::chrono::milliseconds(2));
1040
+ continue;
1041
+ }
1042
+ int ipkt = WSNextPacket(lp);
1043
+ DiagLog("[Eval] dynAutoMode(BEGINDLG): pre-drain pkt=" + std::to_string(ipkt));
1044
+ if (ipkt == INPUTNAMEPKT) {
1045
+ WSNewPacket(lp);
1046
+ break; // ready for input
1047
+ }
1048
+ if (ipkt == 0 || ipkt == ILLEGALPKT) {
1049
+ WSClearError(lp);
1050
+ break;
1051
+ }
1052
+ WSNewPacket(lp); // consume TEXTPKT, MESSAGEPKT, etc.
1053
+ }
1054
+ }
1055
+
1056
+ // Current time in ms since epoch for timestamps.
1057
+ auto nowMs = static_cast<double>(
1058
+ std::chrono::duration_cast<std::chrono::milliseconds>(
1059
+ std::chrono::system_clock::now().time_since_epoch())
1060
+ .count());
1061
+
1062
+ for (const auto& reg : *opts->dynRegistry) {
1063
+ // Send expression via EnterTextPacket so the kernel
1064
+ // evaluates it in OutputForm text mode (string result).
1065
+ DiagLog("[Eval] dynAutoMode(BEGINDLG): sending expr id=" + reg.id + " expr='" + reg.expr + "'");
1066
+ bool sentDyn =
1067
+ WSPutFunction(lp, "EnterTextPacket", 1) &&
1068
+ WSPutUTF8String(lp,
1069
+ reinterpret_cast<const unsigned char*>(reg.expr.c_str()),
1070
+ static_cast<int>(reg.expr.size())) &&
1071
+ WSEndPacket(lp) &&
1072
+ WSFlush(lp);
1073
+
1074
+ DynResult dr;
1075
+ dr.id = reg.id;
1076
+ dr.timestamp = nowMs;
1077
+ if (!sentDyn) {
1078
+ dr.error = "failed to send Dynamic expression";
1079
+ } else {
1080
+ readDynResultWithTimeout(lp, dr, 2000, &capturedOuterResult);
1081
+ }
1082
+ opts->dynResults->push_back(std::move(dr));
1083
+
1084
+ // If the outer result was captured, stop processing
1085
+ // further dynamic expressions — the eval is done.
1086
+ if (capturedOuterResult.kind != WExpr::WError ||
1087
+ !capturedOuterResult.strVal.empty()) break;
1088
+
1089
+ // Abort check between expressions.
1090
+ if (opts->abortFlag && opts->abortFlag->load()) break;
1091
+ }
1092
+
1093
+ if (opts->dynLastEval)
1094
+ *opts->dynLastEval = std::chrono::steady_clock::now();
1095
+
1096
+ // Check if the outer eval's RETURNPKT was captured inside
1097
+ // this dialog. If so, close the dialog and return the
1098
+ // captured result directly — the outer eval is already done.
1099
+ bool outerCaptured = capturedOuterResult.kind != WExpr::WError ||
1100
+ !capturedOuterResult.strVal.empty();
1101
+ if (outerCaptured) {
1102
+ DiagLog("[Eval] dynAutoMode: outer RETURNPKT captured inside dialog — returning directly");
1103
+ // Close the dialog.
1104
+ {
1105
+ const char* closeExpr = "Return[$Failed]";
1106
+ WSPutFunction(lp, "EnterTextPacket", 1);
1107
+ WSPutUTF8String(lp,
1108
+ reinterpret_cast<const unsigned char*>(closeExpr),
1109
+ static_cast<int>(std::strlen(closeExpr)));
1110
+ WSEndPacket(lp);
1111
+ WSFlush(lp);
1112
+ }
1113
+ drainUntilEndDialog(lp, 3000);
1114
+ r.result = std::move(capturedOuterResult);
1115
+ if (r.result.kind == WExpr::Symbol &&
1116
+ stripCtx(r.result.strVal) == "$Aborted")
1117
+ r.aborted = true;
1118
+ // Drain any remaining stale packets.
1119
+ drainStalePackets(lp, opts);
1120
+ return r;
1121
+ }
1122
+ }
1123
+
1124
+ // Close the dialog: send Return[$Failed] then drain ENDDLGPKT.
1125
+ {
1126
+ const char* closeExpr = "Return[$Failed]";
1127
+ WSPutFunction(lp, "EnterTextPacket", 1);
1128
+ WSPutUTF8String(lp,
1129
+ reinterpret_cast<const unsigned char*>(closeExpr),
1130
+ static_cast<int>(std::strlen(closeExpr)));
1131
+ WSEndPacket(lp);
1132
+ WSFlush(lp);
1133
+ }
1134
+ bool exitOk = drainUntilEndDialog(lp, 3000, &capturedOuterResult);
1135
+ if (!exitOk) {
1136
+ DiagLog("[Eval] dynAutoMode: drainUntilEndDialog timed out; aborting");
1137
+ r.aborted = true;
1138
+ r.result = WExpr::mkSymbol("System`$Aborted");
1139
+ drainDialogAbortResponse(lp);
1140
+ return r;
1141
+ }
1142
+ // Check if the outer eval's RETURNPKT was captured during the
1143
+ // drain (e.g. empty registry but between-eval Dialog[] race).
1144
+ {
1145
+ bool outerCapturedInDrain =
1146
+ capturedOuterResult.kind != WExpr::WError ||
1147
+ !capturedOuterResult.strVal.empty();
1148
+ if (outerCapturedInDrain) {
1149
+ DiagLog("[Eval] dynAutoMode: outer RETURNPKT captured during drain — returning directly");
1150
+ r.result = std::move(capturedOuterResult);
1151
+ if (r.result.kind == WExpr::Symbol &&
1152
+ stripCtx(r.result.strVal) == "$Aborted")
1153
+ r.aborted = true;
1154
+ drainStalePackets(lp, opts);
1155
+ return r;
1156
+ }
1157
+ }
1158
+ // Dialog closed — continue outer loop waiting for the original RETURNPKT.
1159
+ continue;
1160
+ }
1161
+
719
1162
  if (opts && opts->dialogOpen)
720
1163
  opts->dialogOpen->store(true);
721
1164
  if (opts && opts->hasOnDialogBegin)
@@ -844,166 +1287,23 @@ static EvalResult DrainToEvalResult(WSLINK lp, EvalOptions* opts = nullptr) {
844
1287
  }
845
1288
  else if (pkt == MENUPKT) {
846
1289
  // ----------------------------------------------------------------
847
- // MENUPKT (6) — interrupt menu protocol per JLink source:
848
- //
849
- // Protocol (from JLink InterruptDialog.java):
850
- // 1. WSNextPacket(lp) → MENUPKT
851
- // 2. WSGetInteger(lp, &type) — menu type (1=interrupt, 3=LinkRead)
852
- // 3. WSGetString(lp, &prompt) — prompt string
853
- // 4. WSNewPacket(lp)
854
- // 5. WSPutString(lp, "i") — respond with bare string
855
- // Options: a=abort, c=continue, i=inspect/dialog
856
- // 6. WSEndPacket; WSFlush
857
- //
858
- // After "i", the kernel enters interactive inspect mode and sends
859
- // MENUPKT(inspect) as the dialog prompt → handled in menuDlgDone.
1290
+ // MENUPKT (6) — interrupt menu
860
1291
  // ----------------------------------------------------------------
861
- {
862
- wsint64 menuType_ = 0; WSGetInteger64(lp, &menuType_);
863
- const char* menuPrompt_ = nullptr; WSGetString(lp, &menuPrompt_);
864
- if (menuPrompt_) WSReleaseString(lp, menuPrompt_);
865
- WSNewPacket(lp);
866
- DiagLog("[Eval] MENUPKT type=" + std::to_string(menuType_) + " — responding 'i' (inspect)");
1292
+ wsint64 menuType_ = 0; WSGetInteger64(lp, &menuType_);
1293
+ const char* menuPrompt_ = nullptr; WSGetString(lp, &menuPrompt_);
1294
+ if (menuPrompt_) WSReleaseString(lp, menuPrompt_);
1295
+ WSNewPacket(lp);
867
1296
 
868
- // Respond with bare string 'i' to enter inspect/dialog mode.
869
- // Per JLink protocol: WSPutString(link, "i"). Not wrapped in any packet.
870
- WSPutString(lp, "i");
871
- WSEndPacket(lp);
872
- WSFlush(lp);
873
- }
874
- // The kernel will now send an inspect-mode MENUPKT as the dialog prompt.
875
- bool menuDlgDone = false;
876
- while (!menuDlgDone) {
877
- if (opts && opts->abortFlag && opts->abortFlag->load()) {
878
- if (opts->dialogOpen) opts->dialogOpen->store(false);
879
- r.result = WExpr::mkSymbol("System`$Aborted");
880
- r.aborted = true;
881
- drainDialogAbortResponse(lp); // consume pending abort response
882
- return r;
883
- }
884
- if (opts && opts->dialogPending && opts->dialogPending->load()) {
885
- if (!serviceDialogRequest(/*menuDlgProto=*/true)) {
886
- menuDlgDone = true;
887
- continue;
888
- }
889
- }
890
- if (!WSReady(lp)) {
891
- std::this_thread::sleep_for(std::chrono::milliseconds(2));
892
- continue;
893
- }
894
- int dpkt = WSNextPacket(lp);
895
- DiagLog("[MenuDlg] dpkt=" + std::to_string(dpkt));
896
- if (dpkt == ENDDLGPKT) {
897
- wsint64 el = 0;
898
- if (WSGetType(lp) == WSTKINT) WSGetInteger64(lp, &el);
899
- WSNewPacket(lp);
900
- if (opts->dialogOpen) opts->dialogOpen->store(false);
901
- if (opts->hasOnDialogEnd)
902
- opts->onDialogEnd.NonBlockingCall(
903
- [el](Napi::Env e, Napi::Function cb){
904
- cb.Call({Napi::Number::New(e, static_cast<double>(el))}); });
905
- menuDlgDone = true;
906
- } else if (dpkt == BEGINDLGPKT) {
907
- // BEGINDLGPKT: dialog subsession started.
908
- // This arrives after 'i' response to MENUPKT, before INPUTNAMEPKT.
909
- // The dialog level integer follows.
910
- wsint64 beginLevel = 0;
911
- if (WSGetType(lp) == WSTKINT) WSGetInteger64(lp, &beginLevel);
912
- WSNewPacket(lp);
913
- DiagLog("[MenuDlg] BEGINDLGPKT level=" + std::to_string(beginLevel) + " — dialog is open");
914
- // isDialogOpen will be set when INPUTNAMEPKT arrives (see below).
915
- // Just consume and wait for INPUTNAMEPKT.
916
- } else if (dpkt == RETURNPKT) {
917
- WExpr inner = ReadExprRaw(lp); WSNewPacket(lp);
918
- if (inner.kind == WExpr::Symbol &&
919
- stripCtx(inner.strVal) == "$Aborted") {
920
- if (opts->dialogOpen) opts->dialogOpen->store(false);
921
- r.result = inner; r.aborted = true; return r;
922
- }
923
- } else if (dpkt == MENUPKT) {
924
- // In the menuDlgDone loop, MENUPKTs serve two purposes:
925
- // 1. FIRST MENUPKT (isDialogOpen still false):
926
- // The dialog subsession has just opened.
927
- // Set isDialogOpen = true, fire onDialogBegin, consume & wait.
928
- // 2. LATER MENUPKTs (isDialogOpen true):
929
- // Either the interrupt menu again (if interrupt handler also opened
930
- // Dialog[], these menus pile up) — respond "c" to dismiss it.
931
- // Or if exitDialog sent 'c' and cleared dialogOpen → we're done.
932
- bool isOpen = opts->dialogOpen && opts->dialogOpen->load();
933
- if (!isOpen) {
934
- // First MENUPKT in menuDlgDone: the inspect/dialog-mode prompt.
935
- // The kernel has entered interactive inspection mode.
936
- // Read the type and prompt following JLink MENUPKT protocol.
937
- wsint64 menuTypeInsp = 0; WSGetInteger64(lp, &menuTypeInsp);
938
- const char* menuPromptInsp = nullptr; WSGetString(lp, &menuPromptInsp);
939
- if (menuPromptInsp) WSReleaseString(lp, menuPromptInsp);
940
- WSNewPacket(lp);
941
- DiagLog("[MenuDlg] inspect MENUPKT type=" + std::to_string(menuTypeInsp) + " — isDialogOpen=true");
942
- if (opts->dialogOpen) opts->dialogOpen->store(true);
943
- if (opts->hasOnDialogBegin)
944
- opts->onDialogBegin.NonBlockingCall(
945
- [](Napi::Env e, Napi::Function cb){
946
- cb.Call({Napi::Number::New(e, 1.0)}); });
947
- } else if (opts->dialogOpen && !opts->dialogOpen->load()) {
948
- // exitDialog already closed the dialog (cleared by serviceDialogRequest)
949
- WSNewPacket(lp);
950
- menuDlgDone = true;
951
- } else {
952
- // Subsequent MENUPKT while dialog is open — this is likely the
953
- // interrupt-level menu that appeared AFTER the dialog-open MENUPKT.
954
- // Dismiss it with bare string 'c' (continue) per JLink MENUPKT protocol.
955
- DiagLog("[MenuDlg] subsequent MENUPKT while dialog open — responding 'c' (continue)");
956
- wsint64 menuTypeDis = 0; WSGetInteger64(lp, &menuTypeDis);
957
- const char* menuPromptDis = nullptr; WSGetString(lp, &menuPromptDis);
958
- if (menuPromptDis) WSReleaseString(lp, menuPromptDis);
959
- WSNewPacket(lp);
960
- WSPutString(lp, "c");
961
- WSEndPacket(lp);
962
- WSFlush(lp);
963
- }
964
- } else if (dpkt == INPUTNAMEPKT || dpkt == OUTPUTNAMEPKT) {
965
- const char* nm = nullptr; WSGetString(lp, &nm);
966
- if (nm) {
967
- std::string nml = nm; WSReleaseString(lp, nm);
968
- DiagLog("[MenuDlg] pkt=" + std::to_string(dpkt) + " name='" + nml + "'");
969
- // INPUTNAMEPKT in menuDlgDone may mean the dialog/inspect
970
- // subsession is prompting for input (alternative to MENUPKT).
971
- if (dpkt == INPUTNAMEPKT && opts && opts->dialogOpen &&
972
- !opts->dialogOpen->load()) {
973
- DiagLog("[MenuDlg] INPUTNAMEPKT while !dialogOpen — dialog opened via INPUTNAMEPKT");
974
- opts->dialogOpen->store(true);
975
- if (opts->hasOnDialogBegin)
976
- opts->onDialogBegin.NonBlockingCall(
977
- [](Napi::Env e, Napi::Function cb){
978
- cb.Call({Napi::Number::New(e, 1.0)}); });
979
- }
980
- }
981
- WSNewPacket(lp);
982
- } else if (dpkt == TEXTPKT) {
983
- const char* ts = nullptr; WSGetString(lp, &ts);
984
- if (ts) {
985
- std::string tl = rtrimNL(ts); WSReleaseString(lp, ts);
986
- DiagLog("[MenuDlg] TEXTPKT text='" + tl.substr(0, 80) + "'");
987
- // Filter out interrupt menu options text (informational only).
988
- bool isMenuOptions = (tl.find("Your options are") != std::string::npos);
989
- if (!isMenuOptions && opts && opts->hasOnDialogPrint)
990
- opts->onDialogPrint.NonBlockingCall(
991
- [tl](Napi::Env e, Napi::Function cb){
992
- cb.Call({Napi::String::New(e, tl)}); });
993
- }
994
- WSNewPacket(lp);
995
- } else if (dpkt == MESSAGEPKT) {
996
- handleMessage(true);
997
- } else if (dpkt == 0 || dpkt == ILLEGALPKT) {
998
- const char* em = WSErrorMessage(lp); WSClearError(lp);
999
- if (opts->dialogOpen) opts->dialogOpen->store(false);
1000
- r.result = WExpr::mkError(em ? em : "WSTP link error in dialog");
1001
- return r;
1002
- } else {
1003
- WSNewPacket(lp);
1004
- }
1005
- }
1006
- // Dialog closed — outer loop continues to collect main eval result.
1297
+ // Legacy dialog path: when dynAutoMode is false and the eval has
1298
+ // dialog callbacks, respond 'i' (inspect) so the kernel enters
1299
+ // inspect mode and the Internal`AddHandler fires Dialog[].
1300
+ // Otherwise respond 'c' (continue) to dismiss the menu.
1301
+ bool wantInspect = opts && !opts->dynAutoMode && opts->hasOnDialogBegin;
1302
+ const char* resp = wantInspect ? "i" : "c";
1303
+ DiagLog("[Eval] MENUPKT type=" + std::to_string(menuType_) + " responding '" + resp + "'");
1304
+ WSPutString(lp, resp);
1305
+ WSEndPacket(lp);
1306
+ WSFlush(lp);
1007
1307
  }
1008
1308
  else {
1009
1309
  DiagLog("[Eval] unknown pkt=" + std::to_string(pkt) + ", discarding");
@@ -1113,6 +1413,61 @@ public:
1113
1413
 
1114
1414
  // ---- thread-pool thread: send packet; block until response ----
1115
1415
  void Execute() override {
1416
+ // ---- Phase 2: ScheduledTask[Dialog[], interval] management ----
1417
+ // Install a kernel-side ScheduledTask that calls Dialog[] periodically.
1418
+ // Only install when:
1419
+ // (a) dynAutoMode is active
1420
+ // (b) there are registered Dynamic expressions
1421
+ // (c) interval > 0
1422
+ // (d) the currently installed task has a DIFFERENT interval (or none)
1423
+ //
1424
+ // The task expression includes a stop-flag check so the old task can
1425
+ // be suppressed during the install eval (which uses rejectDialog=true).
1426
+ //
1427
+ // When there are no registrations (or interval=0), we do NOT send any
1428
+ // stop/remove eval. The old task keeps running but the dynAutoMode
1429
+ // handler handles empty-registry Dialog[]s by simply closing them.
1430
+ if (opts_.dynAutoMode && opts_.dynIntervalMs > 0) {
1431
+ bool hasRegs = false;
1432
+ if (opts_.dynMutex && opts_.dynRegistry) {
1433
+ std::lock_guard<std::mutex> lk(*opts_.dynMutex);
1434
+ hasRegs = !opts_.dynRegistry->empty();
1435
+ }
1436
+
1437
+ int installedInterval = opts_.dynTaskInstalledInterval
1438
+ ? *opts_.dynTaskInstalledInterval : 0;
1439
+ bool needInstall = hasRegs && (installedInterval != opts_.dynIntervalMs);
1440
+
1441
+ if (needInstall) {
1442
+ double intervalSec = opts_.dynIntervalMs / 1000.0;
1443
+ std::string taskExpr =
1444
+ "Quiet[$wstpDynTaskStop = True;"
1445
+ " If[ValueQ[$wstpDynTask], RemoveScheduledTask[$wstpDynTask]];"
1446
+ " $wstpDynTaskStop =.;"
1447
+ " $wstpDynTask = RunScheduledTask["
1448
+ "If[!TrueQ[$wstpDynTaskStop], Dialog[]], " +
1449
+ std::to_string(intervalSec) + "]]";
1450
+ DiagLog("[Eval] dynAutoMode: installing ScheduledTask interval=" +
1451
+ std::to_string(intervalSec) + "s");
1452
+ EvalOptions taskOpts;
1453
+ taskOpts.rejectDialog = true;
1454
+ bool sentT =
1455
+ WSPutFunction(lp_, "EvaluatePacket", 1) &&
1456
+ WSPutFunction(lp_, "ToExpression", 1) &&
1457
+ WSPutUTF8String(lp_,
1458
+ reinterpret_cast<const unsigned char*>(taskExpr.c_str()),
1459
+ static_cast<int>(taskExpr.size())) &&
1460
+ WSEndPacket(lp_) &&
1461
+ WSFlush(lp_);
1462
+ if (sentT) {
1463
+ DrainToEvalResult(lp_, &taskOpts);
1464
+ // Track the installed interval so we don't reinstall next time.
1465
+ if (opts_.dynTaskInstalledInterval)
1466
+ *opts_.dynTaskInstalledInterval = opts_.dynIntervalMs;
1467
+ }
1468
+ }
1469
+ }
1470
+
1116
1471
  bool sent;
1117
1472
  if (!interactive_) {
1118
1473
  // EvaluatePacket + ToExpression: non-interactive, does NOT populate In[n]/Out[n].
@@ -1137,6 +1492,24 @@ public:
1137
1492
  } else {
1138
1493
  opts_.interactive = interactive_;
1139
1494
  result_ = DrainToEvalResult(lp_, &opts_);
1495
+ // DrainToEvalResult may leave dialogOpen_=true (dynAutoMode suppresses
1496
+ // the timer during the exit protocol). Clear it now that we're done.
1497
+ if (opts_.dialogOpen) opts_.dialogOpen->store(false);
1498
+
1499
+ // ---- Cleanup: ScheduledTask that fires Dialog[] ----
1500
+ // The task is left running after the main eval completes.
1501
+ // Between evaluations, Dialog[]s from the task accumulate on the
1502
+ // link as stale BEGINDLGPKT packets. These are drained at the
1503
+ // start of the next eval by drainStalePackets(). The install
1504
+ // code at the top of Execute() also removes any old task before
1505
+ // installing a new one. On session close(), the kernel is
1506
+ // killed, which stops the task automatically.
1507
+ //
1508
+ // We CANNOT send a cleanup EvaluatePacket here because the
1509
+ // ScheduledTask fires Dialog[] preemptively during the cleanup
1510
+ // eval, and the RETURNPKT from the cleanup can get interleaved
1511
+ // with dialog packets, causing the drain to hang.
1512
+
1140
1513
  workerReadingLink_.store(false, std::memory_order_release); // lp_ no longer in use
1141
1514
  if (!interactive_) {
1142
1515
  // EvaluatePacket mode: kernel never sends INPUTNAMEPKT/OUTPUTNAMEPKT,
@@ -1241,6 +1614,7 @@ public:
1241
1614
  Napi::Function func = DefineClass(env, "WstpSession", {
1242
1615
  InstanceMethod<&WstpSession::Evaluate> ("evaluate"),
1243
1616
  InstanceMethod<&WstpSession::Sub> ("sub"),
1617
+ InstanceMethod<&WstpSession::SubWhenIdle> ("subWhenIdle"),
1244
1618
  InstanceMethod<&WstpSession::DialogEval> ("dialogEval"),
1245
1619
  InstanceMethod<&WstpSession::ExitDialog> ("exitDialog"),
1246
1620
  InstanceMethod<&WstpSession::Interrupt> ("interrupt"),
@@ -1248,9 +1622,18 @@ public:
1248
1622
  InstanceMethod<&WstpSession::CloseAllDialogs> ("closeAllDialogs"),
1249
1623
  InstanceMethod<&WstpSession::CreateSubsession>("createSubsession"),
1250
1624
  InstanceMethod<&WstpSession::Close> ("close"),
1625
+ // Dynamic evaluation API (Phase 2)
1626
+ InstanceMethod<&WstpSession::RegisterDynamic> ("registerDynamic"),
1627
+ InstanceMethod<&WstpSession::UnregisterDynamic> ("unregisterDynamic"),
1628
+ InstanceMethod<&WstpSession::ClearDynamicRegistry>("clearDynamicRegistry"),
1629
+ InstanceMethod<&WstpSession::GetDynamicResults> ("getDynamicResults"),
1630
+ InstanceMethod<&WstpSession::SetDynamicInterval> ("setDynamicInterval"),
1631
+ InstanceMethod<&WstpSession::SetDynAutoMode> ("setDynAutoMode"),
1251
1632
  InstanceAccessor<&WstpSession::IsOpen> ("isOpen"),
1252
1633
  InstanceAccessor<&WstpSession::IsDialogOpen> ("isDialogOpen"),
1253
1634
  InstanceAccessor<&WstpSession::IsReady> ("isReady"),
1635
+ InstanceAccessor<&WstpSession::KernelPid> ("kernelPid"),
1636
+ InstanceAccessor<&WstpSession::DynamicActive> ("dynamicActive"),
1254
1637
  });
1255
1638
 
1256
1639
  Napi::FunctionReference* ctor = new Napi::FunctionReference();
@@ -1308,7 +1691,10 @@ public:
1308
1691
  DiagLog("[Session] restart attempt " + std::to_string(attempt) +
1309
1692
  " — $Output routing broken on previous kernel");
1310
1693
  WSClose(lp_); lp_ = nullptr;
1311
- if (kernelPid_ > 0) { kill(kernelPid_, SIGTERM); kernelPid_ = 0; }
1694
+ // Kill the stale kernel before relaunching; use the same guard
1695
+ // as CleanUp() to avoid double-kill on already-dead process.
1696
+ if (kernelPid_ > 0 && !kernelKilled_) { kernelKilled_ = true; kill(kernelPid_, SIGTERM); }
1697
+ kernelKilled_ = false; // reset for the fresh kernel about to launch
1312
1698
  std::this_thread::sleep_for(std::chrono::milliseconds(200));
1313
1699
  }
1314
1700
 
@@ -1387,6 +1773,10 @@ public:
1387
1773
  // Per-call interactive override: opts.interactive = true/false
1388
1774
  if (optsObj.Has("interactive") && optsObj.Get("interactive").IsBoolean())
1389
1775
  interactiveOverride = optsObj.Get("interactive").As<Napi::Boolean>().Value() ? 1 : 0;
1776
+ // Per-call rejectDialog: auto-close any BEGINDLGPKT without JS roundtrip.
1777
+ if (optsObj.Has("rejectDialog") && optsObj.Get("rejectDialog").IsBoolean() &&
1778
+ optsObj.Get("rejectDialog").As<Napi::Boolean>().Value())
1779
+ opts.rejectDialog = true;
1390
1780
 
1391
1781
  // CompleteCtx: count = numTsfns + 1 (the extra slot is for OnOK/OnError).
1392
1782
  // This ensures the Promise resolves ONLY after every TSFN has been
@@ -1416,6 +1806,15 @@ public:
1416
1806
  opts.dialogPending = &dialogPending_;
1417
1807
  opts.dialogOpen = &dialogOpen_;
1418
1808
  opts.abortFlag = &abortFlag_;
1809
+ // Wire up Dynamic eval pointers so DrainToEvalResult can evaluate
1810
+ // registered Dynamic expressions inline when BEGINDLGPKT arrives.
1811
+ opts.dynMutex = &dynMutex_;
1812
+ opts.dynRegistry = &dynRegistry_;
1813
+ opts.dynResults = &dynResults_;
1814
+ opts.dynLastEval = &dynLastEval_;
1815
+ opts.dynAutoMode = dynAutoMode_.load();
1816
+ opts.dynIntervalMs = dynIntervalMs_.load();
1817
+ opts.dynTaskInstalledInterval = &dynTaskInstalledInterval_;
1419
1818
 
1420
1819
  {
1421
1820
  std::lock_guard<std::mutex> lk(queueMutex_);
@@ -1435,6 +1834,16 @@ public:
1435
1834
  Napi::Promise::Deferred deferred;
1436
1835
  };
1437
1836
 
1837
+ // Queue entry — one pending subWhenIdle() call (lowest priority).
1838
+ // Executed only when both subIdleQueue_ and queue_ are empty.
1839
+ struct QueuedWhenIdle {
1840
+ std::string expr;
1841
+ Napi::Promise::Deferred deferred;
1842
+ // Absolute deadline; time_point::max() = no timeout.
1843
+ std::chrono::steady_clock::time_point deadline =
1844
+ std::chrono::steady_clock::time_point::max();
1845
+ };
1846
+
1438
1847
  // Sub-idle evals are preferred over normal evals so sub()-when-idle gets
1439
1848
  // a quick result without waiting for a queued evaluate().
1440
1849
  // -----------------------------------------------------------------------
@@ -1455,6 +1864,26 @@ public:
1455
1864
  }
1456
1865
 
1457
1866
  if (queue_.empty()) {
1867
+ // No normal evaluate() items — check lowest-priority when-idle queue.
1868
+ // Drain any timeout-expired entries first, then run the next live one.
1869
+ while (!whenIdleQueue_.empty()) {
1870
+ auto& front = whenIdleQueue_.front();
1871
+ bool expired =
1872
+ (front.deadline != std::chrono::steady_clock::time_point::max() &&
1873
+ std::chrono::steady_clock::now() >= front.deadline);
1874
+ if (expired) {
1875
+ Napi::Env wenv = front.deferred.Env();
1876
+ front.deferred.Reject(
1877
+ Napi::Error::New(wenv, "subWhenIdle: timeout").Value());
1878
+ whenIdleQueue_.pop();
1879
+ continue;
1880
+ }
1881
+ auto wiItem = std::move(whenIdleQueue_.front());
1882
+ whenIdleQueue_.pop();
1883
+ lk.unlock();
1884
+ StartWhenIdleWorker(std::move(wiItem));
1885
+ return;
1886
+ }
1458
1887
  busy_.store(false);
1459
1888
  return;
1460
1889
  }
@@ -1537,6 +1966,63 @@ public:
1537
1966
  [this]() { busy_.store(false); MaybeStartNext(); }))->Queue();
1538
1967
  }
1539
1968
 
1969
+ // Launch a lightweight EvaluateWorker for one subWhenIdle() entry.
1970
+ // Identical to StartSubIdleWorker — reuses the same SubIdleWorker inner
1971
+ // class pattern; separated here so it can receive QueuedWhenIdle.
1972
+ void StartWhenIdleWorker(QueuedWhenIdle item) {
1973
+ struct WhenIdleWorker : public Napi::AsyncWorker {
1974
+ WhenIdleWorker(Napi::Promise::Deferred d, WSLINK lp, std::string expr,
1975
+ std::atomic<bool>& workerReadingLink,
1976
+ std::function<void()> done)
1977
+ : Napi::AsyncWorker(d.Env()),
1978
+ deferred_(std::move(d)), lp_(lp), expr_(std::move(expr)),
1979
+ workerReadingLink_(workerReadingLink), done_(std::move(done)) {}
1980
+
1981
+ void Execute() override {
1982
+ if (!WSPutFunction(lp_, "EvaluatePacket", 1) ||
1983
+ !WSPutFunction(lp_, "ToExpression", 1) ||
1984
+ !WSPutUTF8String(lp_, (const unsigned char *)expr_.c_str(), (int)expr_.size()) ||
1985
+ !WSEndPacket(lp_) ||
1986
+ !WSFlush(lp_)) {
1987
+ workerReadingLink_.store(false, std::memory_order_release);
1988
+ SetError("subWhenIdle: failed to send EvaluatePacket");
1989
+ return;
1990
+ }
1991
+ result_ = DrainToEvalResult(lp_);
1992
+ workerReadingLink_.store(false, std::memory_order_release);
1993
+ }
1994
+ void OnOK() override {
1995
+ Napi::Env env = Env();
1996
+ if (result_.result.kind == WExpr::WError) {
1997
+ deferred_.Reject(Napi::Error::New(env, result_.result.strVal).Value());
1998
+ } else {
1999
+ Napi::Value v = WExprToNapi(env, result_.result);
2000
+ if (env.IsExceptionPending())
2001
+ deferred_.Reject(env.GetAndClearPendingException().Value());
2002
+ else
2003
+ deferred_.Resolve(v);
2004
+ }
2005
+ done_();
2006
+ }
2007
+ void OnError(const Napi::Error& e) override {
2008
+ deferred_.Reject(e.Value());
2009
+ done_();
2010
+ }
2011
+ private:
2012
+ Napi::Promise::Deferred deferred_;
2013
+ WSLINK lp_;
2014
+ std::string expr_;
2015
+ std::atomic<bool>& workerReadingLink_;
2016
+ std::function<void()> done_;
2017
+ EvalResult result_;
2018
+ };
2019
+
2020
+ workerReadingLink_.store(true, std::memory_order_release);
2021
+ (new WhenIdleWorker(std::move(item.deferred), lp_, std::move(item.expr),
2022
+ workerReadingLink_,
2023
+ [this]() { busy_.store(false); MaybeStartNext(); }))->Queue();
2024
+ }
2025
+
1540
2026
  // -----------------------------------------------------------------------
1541
2027
  // sub(expr) → Promise<WExpr>
1542
2028
  //
@@ -1568,6 +2054,53 @@ public:
1568
2054
  return promise;
1569
2055
  }
1570
2056
 
2057
+ // -----------------------------------------------------------------------
2058
+ // subWhenIdle(expr, opts?) → Promise<WExpr>
2059
+ //
2060
+ // Queues a lightweight evaluation at the LOWEST priority: it runs only
2061
+ // when both subIdleQueue_ and queue_ are empty (kernel truly idle).
2062
+ // Ideal for background queries that must not compete with cell evaluations.
2063
+ //
2064
+ // opts may contain:
2065
+ // timeout?: number — milliseconds to wait before the Promise rejects
2066
+ // -----------------------------------------------------------------------
2067
+ Napi::Value SubWhenIdle(const Napi::CallbackInfo& info) {
2068
+ Napi::Env env = info.Env();
2069
+ auto deferred = Napi::Promise::Deferred::New(env);
2070
+ auto promise = deferred.Promise();
2071
+
2072
+ if (!open_) {
2073
+ deferred.Reject(Napi::Error::New(env, "Session is closed").Value());
2074
+ return promise;
2075
+ }
2076
+ if (info.Length() < 1 || !info[0].IsString()) {
2077
+ deferred.Reject(Napi::TypeError::New(env,
2078
+ "subWhenIdle(expr: string, opts?: {timeout?: number})").Value());
2079
+ return promise;
2080
+ }
2081
+ std::string expr = info[0].As<Napi::String>().Utf8Value();
2082
+
2083
+ // Parse optional timeout from opts object.
2084
+ auto deadline = std::chrono::steady_clock::time_point::max();
2085
+ if (info.Length() >= 2 && info[1].IsObject()) {
2086
+ auto optsObj = info[1].As<Napi::Object>();
2087
+ if (optsObj.Has("timeout") && optsObj.Get("timeout").IsNumber()) {
2088
+ double ms = optsObj.Get("timeout").As<Napi::Number>().DoubleValue();
2089
+ if (ms > 0)
2090
+ deadline = std::chrono::steady_clock::now() +
2091
+ std::chrono::milliseconds(static_cast<int64_t>(ms));
2092
+ }
2093
+ }
2094
+
2095
+ {
2096
+ std::lock_guard<std::mutex> lk(queueMutex_);
2097
+ whenIdleQueue_.push(QueuedWhenIdle{
2098
+ std::move(expr), std::move(deferred), deadline });
2099
+ }
2100
+ MaybeStartNext();
2101
+ return promise;
2102
+ }
2103
+
1571
2104
  // -----------------------------------------------------------------------
1572
2105
  // exitDialog(retVal?) → Promise<void>
1573
2106
  //
@@ -1713,16 +2246,134 @@ public:
1713
2246
  }
1714
2247
 
1715
2248
  // -----------------------------------------------------------------------
1716
- // abort() interrupt the currently running evaluation.
1717
- //
1718
- // Sends WSAbortMessage on the link. Per the WSTP spec WSPutMessage() is
1719
- // thread-safe and will cause WSNextPacket() on the thread-pool thread to
1720
- // return ILLEGALPKT (link reset). The promise then rejects; a fresh
1721
- // session must be created for further work after the kernel crashes/exits.
1722
- //
1723
- // For a softer (recoverable) interrupt use evaluate("Interrupt[]")
1724
- // before the long computation, or wrap the computation in TimeConstrained.
2249
+ // registerDynamic(id, expr) void
2250
+ // Register or replace a Dynamic expression for periodic evaluation.
2251
+ // -----------------------------------------------------------------------
2252
+ Napi::Value RegisterDynamic(const Napi::CallbackInfo& info) {
2253
+ Napi::Env env = info.Env();
2254
+ if (info.Length() < 2 || !info[0].IsString() || !info[1].IsString()) {
2255
+ Napi::TypeError::New(env, "registerDynamic(id: string, expr: string)")
2256
+ .ThrowAsJavaScriptException();
2257
+ return env.Undefined();
2258
+ }
2259
+ std::string id = info[0].As<Napi::String>().Utf8Value();
2260
+ std::string expr = info[1].As<Napi::String>().Utf8Value();
2261
+ {
2262
+ std::lock_guard<std::mutex> lk(dynMutex_);
2263
+ for (auto& reg : dynRegistry_) {
2264
+ if (reg.id == id) { reg.expr = expr; return env.Undefined(); }
2265
+ }
2266
+ dynRegistry_.push_back({id, expr});
2267
+ }
2268
+ return env.Undefined();
2269
+ }
2270
+
2271
+ // -----------------------------------------------------------------------
2272
+ // unregisterDynamic(id) → void
2273
+ // -----------------------------------------------------------------------
2274
+ Napi::Value UnregisterDynamic(const Napi::CallbackInfo& info) {
2275
+ Napi::Env env = info.Env();
2276
+ if (info.Length() < 1 || !info[0].IsString()) {
2277
+ Napi::TypeError::New(env, "unregisterDynamic(id: string)")
2278
+ .ThrowAsJavaScriptException();
2279
+ return env.Undefined();
2280
+ }
2281
+ std::string id = info[0].As<Napi::String>().Utf8Value();
2282
+ {
2283
+ std::lock_guard<std::mutex> lk(dynMutex_);
2284
+ dynRegistry_.erase(
2285
+ std::remove_if(dynRegistry_.begin(), dynRegistry_.end(),
2286
+ [&id](const DynRegistration& r){ return r.id == id; }),
2287
+ dynRegistry_.end());
2288
+ }
2289
+ return env.Undefined();
2290
+ }
2291
+
2292
+ // -----------------------------------------------------------------------
2293
+ // clearDynamicRegistry() → void
2294
+ // -----------------------------------------------------------------------
2295
+ Napi::Value ClearDynamicRegistry(const Napi::CallbackInfo& info) {
2296
+ std::lock_guard<std::mutex> lk(dynMutex_);
2297
+ dynRegistry_.clear();
2298
+ dynResults_.clear();
2299
+ return info.Env().Undefined();
2300
+ }
2301
+
1725
2302
  // -----------------------------------------------------------------------
2303
+ // getDynamicResults() → Record<string, DynResult>
2304
+ // Swaps and returns accumulated results; clears the internal buffer.
2305
+ // -----------------------------------------------------------------------
2306
+ Napi::Value GetDynamicResults(const Napi::CallbackInfo& info) {
2307
+ Napi::Env env = info.Env();
2308
+ std::vector<DynResult> snap;
2309
+ {
2310
+ std::lock_guard<std::mutex> lk(dynMutex_);
2311
+ snap.swap(dynResults_);
2312
+ }
2313
+ auto obj = Napi::Object::New(env);
2314
+ for (const auto& dr : snap) {
2315
+ auto entry = Napi::Object::New(env);
2316
+ entry.Set("value", Napi::String::New(env, dr.value));
2317
+ entry.Set("timestamp", Napi::Number::New(env, dr.timestamp));
2318
+ if (!dr.error.empty())
2319
+ entry.Set("error", Napi::String::New(env, dr.error));
2320
+ obj.Set(dr.id, entry);
2321
+ }
2322
+ return obj;
2323
+ }
2324
+
2325
+ // -----------------------------------------------------------------------
2326
+ // setDynamicInterval(ms) → void
2327
+ // Set the auto-interrupt interval. 0 = disabled.
2328
+ // Starting/stopping the timer thread is handled here.
2329
+ // -----------------------------------------------------------------------
2330
+ Napi::Value SetDynamicInterval(const Napi::CallbackInfo& info) {
2331
+ Napi::Env env = info.Env();
2332
+ if (info.Length() < 1 || !info[0].IsNumber()) {
2333
+ Napi::TypeError::New(env, "setDynamicInterval(ms: number)")
2334
+ .ThrowAsJavaScriptException();
2335
+ return env.Undefined();
2336
+ }
2337
+ int ms = static_cast<int>(info[0].As<Napi::Number>().Int32Value());
2338
+ if (ms < 0) ms = 0;
2339
+ int prev = dynIntervalMs_.exchange(ms);
2340
+ // Auto-enable dynAutoMode when interval > 0, auto-disable when 0.
2341
+ dynAutoMode_.store(ms > 0);
2342
+ // Start timer thread if transitioning from 0 to non-zero.
2343
+ if (prev == 0 && ms > 0 && !dynTimerRunning_.load()) {
2344
+ StartDynTimer();
2345
+ }
2346
+ return env.Undefined();
2347
+ }
2348
+
2349
+ // -----------------------------------------------------------------------
2350
+ // setDynAutoMode(auto) → void
2351
+ // true = C++-internal inline dialog path (default)
2352
+ // false = legacy JS dialogEval/exitDialog path (for debugger)
2353
+ // -----------------------------------------------------------------------
2354
+ Napi::Value SetDynAutoMode(const Napi::CallbackInfo& info) {
2355
+ Napi::Env env = info.Env();
2356
+ if (info.Length() < 1 || !info[0].IsBoolean()) {
2357
+ Napi::TypeError::New(env, "setDynAutoMode(auto: boolean)")
2358
+ .ThrowAsJavaScriptException();
2359
+ return env.Undefined();
2360
+ }
2361
+ dynAutoMode_.store(info[0].As<Napi::Boolean>().Value());
2362
+ return env.Undefined();
2363
+ }
2364
+
2365
+ // -----------------------------------------------------------------------
2366
+ // dynamicActive (accessor) → boolean
2367
+ // True if registry non-empty and interval > 0.
2368
+ // -----------------------------------------------------------------------
2369
+ Napi::Value DynamicActive(const Napi::CallbackInfo& info) {
2370
+ std::lock_guard<std::mutex> lk(dynMutex_);
2371
+ bool active = !dynRegistry_.empty() && dynIntervalMs_.load() > 0;
2372
+ return Napi::Boolean::New(info.Env(), active);
2373
+ }
2374
+
2375
+ // -----------------------------------------------------------------------
2376
+ // abort() — interrupt the currently running evaluation.
1726
2377
  Napi::Value Abort(const Napi::CallbackInfo& info) {
1727
2378
  Napi::Env env = info.Env();
1728
2379
  if (!open_) return Napi::Boolean::New(env, false);
@@ -1730,7 +2381,12 @@ public:
1730
2381
  // Sending WSAbortMessage to an idle kernel causes it to emit a
1731
2382
  // spurious RETURNPKT[$Aborted] that would corrupt the next evaluation.
1732
2383
  if (!busy_.load()) return Napi::Boolean::New(env, false);
1733
- abortFlag_.store(true);
2384
+ // Deduplication: if abortFlag_ is already true, another abort() is already
2385
+ // in flight — sending a second WSAbortMessage causes stale response packets
2386
+ // that corrupt the next evaluation. Just return true (already aborting).
2387
+ bool expected = false;
2388
+ if (!abortFlag_.compare_exchange_strong(expected, true))
2389
+ return Napi::Boolean::New(env, true); // already aborting — no-op
1734
2390
  // Flush any queued dialogEval/exitDialog requests so their promises
1735
2391
  // reject immediately instead of hanging forever.
1736
2392
  FlushDialogQueueWithError("abort");
@@ -1816,6 +2472,17 @@ public:
1816
2472
  && subIdleQueue_.empty());
1817
2473
  }
1818
2474
 
2475
+ // -----------------------------------------------------------------------
2476
+ // kernelPid (read-only accessor)
2477
+ // Returns the OS process ID of the WolframKernel child process.
2478
+ // Returns 0 if the PID could not be fetched (non-fatal, rare fallback).
2479
+ // The PID can be used by the caller to monitor or force-terminate the
2480
+ // kernel process independently of the WSTP link (e.g. after a restart).
2481
+ // -----------------------------------------------------------------------
2482
+ Napi::Value KernelPid(const Napi::CallbackInfo& info) {
2483
+ return Napi::Number::New(info.Env(), static_cast<double>(kernelPid_));
2484
+ }
2485
+
1819
2486
  private:
1820
2487
  // Queue entry — one pending evaluate() call.
1821
2488
  // interactiveOverride: -1 = use session default, 0 = force batch, 1 = force interactive
@@ -1851,7 +2518,57 @@ private:
1851
2518
  }
1852
2519
  }
1853
2520
 
2521
+ // -----------------------------------------------------------------------
2522
+ // StartDynTimer — launches the background timer thread that sends
2523
+ // WSInterruptMessage at the configured interval when the kernel is busy
2524
+ // and Dynamic expressions are registered.
2525
+ // Called on the JS main thread; harmless to call if already running.
2526
+ // -----------------------------------------------------------------------
2527
+ void StartDynTimer() {
2528
+ if (dynTimerRunning_.exchange(true)) return; // already running
2529
+ if (dynTimerThread_.joinable()) dynTimerThread_.join();
2530
+ dynTimerThread_ = std::thread([this]() {
2531
+ while (open_ && dynIntervalMs_.load() > 0) {
2532
+ int ms = dynIntervalMs_.load();
2533
+ std::this_thread::sleep_for(std::chrono::milliseconds(ms > 0 ? ms : 100));
2534
+ if (!open_) break;
2535
+ if (!busy_.load()) continue; // kernel idle
2536
+ {
2537
+ std::lock_guard<std::mutex> lk(dynMutex_);
2538
+ if (dynRegistry_.empty()) continue; // nothing registered
2539
+ }
2540
+ if (dynIntervalMs_.load() == 0) break;
2541
+ // Check enough time has elapsed since last eval.
2542
+ auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
2543
+ std::chrono::steady_clock::now() - dynLastEval_).count();
2544
+ if (elapsed < dynIntervalMs_.load()) continue;
2545
+
2546
+ if (workerReadingLink_.load() && !dialogOpen_.load() && !dynAutoMode_.load()) {
2547
+ WSPutMessage(lp_, WSInterruptMessage);
2548
+ }
2549
+ }
2550
+ dynTimerRunning_.store(false);
2551
+ });
2552
+ dynTimerThread_.detach();
2553
+ }
2554
+
1854
2555
  void CleanUp() {
2556
+ // Stop the Dynamic timer thread.
2557
+ dynIntervalMs_.store(0);
2558
+ dynTimerRunning_.store(false);
2559
+
2560
+ // Immediately reject all queued subWhenIdle() requests before the link
2561
+ // is torn down. These items have never been dispatched to a worker so
2562
+ // they won't receive an OnError callback — we must reject them here.
2563
+ {
2564
+ std::lock_guard<std::mutex> lk(queueMutex_);
2565
+ while (!whenIdleQueue_.empty()) {
2566
+ auto& wi = whenIdleQueue_.front();
2567
+ wi.deferred.Reject(
2568
+ Napi::Error::New(wi.deferred.Env(), "Session is closed").Value());
2569
+ whenIdleQueue_.pop();
2570
+ }
2571
+ }
1855
2572
  // If a worker thread is currently reading from lp_, calling WSClose()
1856
2573
  // on it from the JS main thread causes a concurrent-access crash
1857
2574
  // (heap-use-after-free / SIGSEGV).
@@ -1877,7 +2594,11 @@ private:
1877
2594
  // Kill the child kernel process so it doesn't become a zombie.
1878
2595
  // WSClose() closes the link but does not terminate the WolframKernel
1879
2596
  // child process — without this, each session leaks a kernel.
1880
- if (kernelPid_ > 0) { kill(kernelPid_, SIGTERM); kernelPid_ = 0; }
2597
+ // kernelPid_ is intentionally NOT zeroed here so .kernelPid remains
2598
+ // readable after close() (useful for post-mortem logging / force-kill).
2599
+ // kernelKilled_ prevents a double-kill when the destructor later calls
2600
+ // CleanUp() a second time.
2601
+ if (kernelPid_ > 0 && !kernelKilled_) { kernelKilled_ = true; kill(kernelPid_, SIGTERM); }
1881
2602
  }
1882
2603
 
1883
2604
 
@@ -1971,7 +2692,8 @@ private:
1971
2692
  WSLINK lp_;
1972
2693
  bool open_;
1973
2694
  bool interactiveMode_ = false; // true → EnterTextPacket, populates In/Out
1974
- pid_t kernelPid_ = 0; // child process — killed on CleanUp
2695
+ pid_t kernelPid_ = 0; // child process PID preserved after close() for .kernelPid accessor
2696
+ bool kernelKilled_ = false; // guards against double-kill across close() + destructor
1975
2697
  std::atomic<int64_t> nextLine_{1}; // 1-based In[n] counter for EvalResult.cellIndex
1976
2698
  std::atomic<bool> abortFlag_{false};
1977
2699
  std::atomic<bool> busy_{false};
@@ -1983,11 +2705,25 @@ private:
1983
2705
  std::mutex queueMutex_;
1984
2706
  std::queue<QueuedEval> queue_;
1985
2707
  std::queue<QueuedSubIdle> subIdleQueue_; // sub() — runs before queue_ items
2708
+ std::queue<QueuedWhenIdle> whenIdleQueue_; // subWhenIdle() — lowest priority, runs when truly idle
1986
2709
  // Dialog subsession state — written on main thread, consumed on thread pool
1987
2710
  std::mutex dialogMutex_;
1988
2711
  std::queue<DialogRequest> dialogQueue_; // dialogEval() requests
1989
2712
  std::atomic<bool> dialogPending_{false};
1990
2713
  std::atomic<bool> dialogOpen_{false};
2714
+
2715
+ // -----------------------------------------------------------------------
2716
+ // Dynamic evaluation state (Phase 2 — C++-internal Dynamic eval)
2717
+ // -----------------------------------------------------------------------
2718
+ std::mutex dynMutex_;
2719
+ std::vector<DynRegistration> dynRegistry_; // registered exprs
2720
+ std::vector<DynResult> dynResults_; // accumulated results (swapped on getDynamicResults)
2721
+ std::atomic<int> dynIntervalMs_{0}; // 0 = disabled
2722
+ std::atomic<bool> dynAutoMode_{false}; // true = inline C++ path; false = legacy JS path
2723
+ int dynTaskInstalledInterval_{0}; // interval of currently installed ScheduledTask (0 = none)
2724
+ std::chrono::steady_clock::time_point dynLastEval_{}; // time of last successful dialog eval
2725
+ std::thread dynTimerThread_;
2726
+ std::atomic<bool> dynTimerRunning_{false};
1991
2727
  };
1992
2728
 
1993
2729
  // ===========================================================================