wstp-node 0.6.3 → 0.6.6

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.
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wstp-node",
3
- "version": "0.6.3",
3
+ "version": "0.6.6",
4
4
  "description": "Native Node.js addon for Wolfram/Mathematica WSTP — kernel sessions with evaluation queue, streaming Print/messages, Dialog subsessions, and side-channel WstpReader",
5
5
  "main": "build/Release/wstp.node",
6
6
  "types": "index.d.ts",
package/src/addon.cc CHANGED
@@ -306,20 +306,25 @@ static void drainDialogAbortResponse(WSLINK lp) {
306
306
  // about this dialog so there's nobody to call exitDialog().
307
307
  // ---------------------------------------------------------------------------
308
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) {
309
+ // Stale packets (from PREVIOUS evals) are already sitting on the link
310
+ // and arrive instantly. Use a short idle timeout: if nothing arrives
311
+ // for idleMs, exit early. The hard timeout caps the total drain time
312
+ // in case packets keep arriving (e.g. nested Dialog[] rejections).
313
+ int hardTimeoutMs = (opts && opts->dynAutoMode) ? 50 : 200;
314
+ int idleMs = 5; // exit after 5ms of silence
315
+ auto hardDeadline = std::chrono::steady_clock::now() +
316
+ std::chrono::milliseconds(hardTimeoutMs);
317
+ auto idleDeadline = std::chrono::steady_clock::now() +
318
+ std::chrono::milliseconds(idleMs);
319
+ while (std::chrono::steady_clock::now() < hardDeadline &&
320
+ std::chrono::steady_clock::now() < idleDeadline) {
319
321
  if (!WSReady(lp)) {
320
- std::this_thread::sleep_for(std::chrono::milliseconds(5));
322
+ std::this_thread::sleep_for(std::chrono::milliseconds(1));
321
323
  continue;
322
324
  }
325
+ // Packet arrived — reset idle deadline.
326
+ idleDeadline = std::chrono::steady_clock::now() +
327
+ std::chrono::milliseconds(idleMs);
323
328
  int pkt = WSNextPacket(lp);
324
329
  if (pkt == BEGINDLGPKT) {
325
330
  // Stale dialog — consume level int then auto-close.
@@ -992,6 +997,12 @@ static EvalResult DrainToEvalResult(WSLINK lp, EvalOptions* opts = nullptr) {
992
997
  WSFlush(lp);
993
998
  DiagLog("[Eval] rejectDialog: sent Return[$Failed], draining until ENDDLGPKT");
994
999
  // Drain until ENDDLGPKT — kernel will close the dialog level.
1000
+ // IMPORTANT: the outer eval's RETURNPKT may land inside the
1001
+ // dialog context (race with ScheduledTask Dialog[]). We MUST
1002
+ // capture it here; otherwise the outer DrainToEvalResult loop
1003
+ // waits forever for a RETURNPKT that was already consumed.
1004
+ WExpr rejectCaptured;
1005
+ bool rejectGotOuter = false;
995
1006
  {
996
1007
  auto dlgDeadline = std::chrono::steady_clock::now() +
997
1008
  std::chrono::milliseconds(2000);
@@ -1002,11 +1013,25 @@ static EvalResult DrainToEvalResult(WSLINK lp, EvalOptions* opts = nullptr) {
1002
1013
  }
1003
1014
  int rp = WSNextPacket(lp);
1004
1015
  DiagLog("[Eval] rejectDialog: drain pkt=" + std::to_string(rp));
1016
+ if ((rp == RETURNPKT || rp == RETURNEXPRPKT) && !rejectGotOuter) {
1017
+ rejectCaptured = ReadExprRaw(lp);
1018
+ rejectGotOuter = true;
1019
+ DiagLog("[Eval] rejectDialog: captured outer RETURNPKT");
1020
+ }
1005
1021
  WSNewPacket(lp);
1006
1022
  if (rp == ENDDLGPKT) break;
1007
1023
  if (rp == 0 || rp == ILLEGALPKT) { WSClearError(lp); break; }
1008
1024
  }
1009
1025
  }
1026
+ if (rejectGotOuter) {
1027
+ DiagLog("[Eval] rejectDialog: returning captured outer result");
1028
+ r.result = std::move(rejectCaptured);
1029
+ if (r.result.kind == WExpr::Symbol &&
1030
+ stripCtx(r.result.strVal) == "$Aborted")
1031
+ r.aborted = true;
1032
+ drainStalePackets(lp, opts);
1033
+ return r;
1034
+ }
1010
1035
  // Continue outer drain loop — the original RETURNPKT is still coming.
1011
1036
  continue;
1012
1037
  }
@@ -1201,6 +1226,8 @@ static EvalResult DrainToEvalResult(WSLINK lp, EvalOptions* opts = nullptr) {
1201
1226
  WSEndPacket(lp);
1202
1227
  WSFlush(lp);
1203
1228
  DiagLog("[Eval] BEGINDLGPKT safety: sent Return[$Failed], draining until ENDDLGPKT");
1229
+ WExpr safetyCaptured;
1230
+ bool safetyGotOuter = false;
1204
1231
  {
1205
1232
  auto dlgDeadline = std::chrono::steady_clock::now() +
1206
1233
  std::chrono::milliseconds(2000);
@@ -1211,11 +1238,25 @@ static EvalResult DrainToEvalResult(WSLINK lp, EvalOptions* opts = nullptr) {
1211
1238
  }
1212
1239
  int rp = WSNextPacket(lp);
1213
1240
  DiagLog("[Eval] BEGINDLGPKT safety: drain pkt=" + std::to_string(rp));
1241
+ if ((rp == RETURNPKT || rp == RETURNEXPRPKT) && !safetyGotOuter) {
1242
+ safetyCaptured = ReadExprRaw(lp);
1243
+ safetyGotOuter = true;
1244
+ DiagLog("[Eval] BEGINDLGPKT safety: captured outer RETURNPKT");
1245
+ }
1214
1246
  WSNewPacket(lp);
1215
1247
  if (rp == ENDDLGPKT) break;
1216
1248
  if (rp == 0 || rp == ILLEGALPKT) { WSClearError(lp); break; }
1217
1249
  }
1218
1250
  }
1251
+ if (safetyGotOuter) {
1252
+ DiagLog("[Eval] BEGINDLGPKT safety: returning captured outer result");
1253
+ r.result = std::move(safetyCaptured);
1254
+ if (r.result.kind == WExpr::Symbol &&
1255
+ stripCtx(r.result.strVal) == "$Aborted")
1256
+ r.aborted = true;
1257
+ drainStalePackets(lp, opts);
1258
+ return r;
1259
+ }
1219
1260
  // Continue outer drain loop — original RETURNPKT is still coming.
1220
1261
  continue;
1221
1262
  }
@@ -1558,12 +1599,33 @@ public:
1558
1599
  }
1559
1600
  }
1560
1601
 
1602
+ // ---- ScheduledTask Dialog[] suppression ----
1603
+ // When a ScheduledTask is installed (dynTaskInstalledInterval > 0),
1604
+ // its Dialog[] calls can fire between evals and during rejectDialog
1605
+ // setup/render evals, causing hangs and adding ~500ms per rejection.
1606
+ // Suppress the task during rejectDialog evals by prepending
1607
+ // $wstpDynTaskStop=True to the expression. Resume it for the main
1608
+ // eval by prepending $wstpDynTaskStop=. so the dynAutoMode handler
1609
+ // can process Dynamic expressions during the cell's execution.
1610
+ int installedTask = opts_.dynTaskInstalledInterval
1611
+ ? *opts_.dynTaskInstalledInterval : 0;
1612
+ std::string sendExpr = expr_;
1613
+ if (installedTask > 0) {
1614
+ if (opts_.rejectDialog) {
1615
+ sendExpr = "$wstpDynTaskStop=True;" + expr_;
1616
+ DiagLog("[Eval] prepend $wstpDynTaskStop=True (rejectDialog)");
1617
+ } else {
1618
+ sendExpr = "$wstpDynTaskStop=.;" + expr_;
1619
+ DiagLog("[Eval] prepend $wstpDynTaskStop=. (main eval)");
1620
+ }
1621
+ }
1622
+
1561
1623
  bool sent;
1562
1624
  if (!interactive_) {
1563
1625
  // EvaluatePacket + ToExpression: non-interactive, does NOT populate In[n]/Out[n].
1564
1626
  sent = WSPutFunction(lp_, "EvaluatePacket", 1) &&
1565
1627
  WSPutFunction(lp_, "ToExpression", 1) &&
1566
- WSPutUTF8String(lp_, (const unsigned char *)expr_.c_str(), (int)expr_.size()) &&
1628
+ WSPutUTF8String(lp_, (const unsigned char *)sendExpr.c_str(), (int)sendExpr.size()) &&
1567
1629
  WSEndPacket (lp_) &&
1568
1630
  WSFlush (lp_);
1569
1631
  } else {
@@ -1572,7 +1634,7 @@ public:
1572
1634
  // as the real Mathematica frontend does. Responds with RETURNEXPRPKT.
1573
1635
  sent = WSPutFunction(lp_, "EnterExpressionPacket", 1) &&
1574
1636
  WSPutFunction(lp_, "ToExpression", 1) &&
1575
- WSPutUTF8String(lp_, (const unsigned char *)expr_.c_str(), (int)expr_.size()) &&
1637
+ WSPutUTF8String(lp_, (const unsigned char *)sendExpr.c_str(), (int)sendExpr.size()) &&
1576
1638
  WSEndPacket (lp_) &&
1577
1639
  WSFlush (lp_);
1578
1640
  }
@@ -2681,11 +2743,28 @@ private:
2681
2743
  FlushDialogQueueWithError("session closed");
2682
2744
  dialogOpen_.store(false);
2683
2745
  WSPutMessage(lp_, WSAbortMessage);
2684
- const auto deadline =
2685
- std::chrono::steady_clock::now() + std::chrono::seconds(10);
2746
+ // Phase 1: wait up to 2s for WSAbortMessage to unblock the worker.
2747
+ auto deadline =
2748
+ std::chrono::steady_clock::now() + std::chrono::seconds(2);
2686
2749
  while (workerReadingLink_.load(std::memory_order_acquire) &&
2687
2750
  std::chrono::steady_clock::now() < deadline)
2688
2751
  std::this_thread::sleep_for(std::chrono::milliseconds(5));
2752
+ // Phase 2: if the worker is still stuck (e.g. Pause[] ignoring
2753
+ // WSAbortMessage), SIGKILL the kernel to break the link. This
2754
+ // makes WSNextPacket return an error so the worker exits promptly.
2755
+ if (workerReadingLink_.load(std::memory_order_acquire) &&
2756
+ kernelPid_ > 0 && !kernelKilled_) {
2757
+ kernelKilled_ = true;
2758
+ kill(kernelPid_, SIGKILL);
2759
+ DiagLog("[CleanUp] SIGKILL pid " + std::to_string(kernelPid_) +
2760
+ " — worker still reading after 2s");
2761
+ // Brief wait for the worker to notice the dead link.
2762
+ deadline = std::chrono::steady_clock::now() +
2763
+ std::chrono::seconds(2);
2764
+ while (workerReadingLink_.load(std::memory_order_acquire) &&
2765
+ std::chrono::steady_clock::now() < deadline)
2766
+ std::this_thread::sleep_for(std::chrono::milliseconds(5));
2767
+ }
2689
2768
  }
2690
2769
  if (lp_) { WSClose(lp_); lp_ = nullptr; }
2691
2770
  if (wsEnv_) { WSDeinitialize(wsEnv_); wsEnv_ = nullptr; }
@@ -3120,7 +3199,7 @@ Napi::Object InitModule(Napi::Env env, Napi::Object exports) {
3120
3199
  exports.Set("setDiagHandler",
3121
3200
  Napi::Function::New(env, SetDiagHandler, "setDiagHandler"));
3122
3201
  // version — mirrors package.json "version"; read-only string constant.
3123
- exports.Set("version", Napi::String::New(env, "0.6.3"));
3202
+ exports.Set("version", Napi::String::New(env, "0.6.6"));
3124
3203
  return exports;
3125
3204
  }
3126
3205
 
package/test.js CHANGED
@@ -52,10 +52,20 @@ function withTimeout(p, ms, label) {
52
52
  }
53
53
 
54
54
  // mkSession — open a fresh WstpSession.
55
+ // _lastMkSession tracks the most recently created per-test session so that
56
+ // the test runner can kill it on timeout (unblocking a stuck native WSTP call).
57
+ let _lastMkSession = null;
58
+ let _mainSession = null;
55
59
  function mkSession() {
56
- return new WstpSession(KERNEL_PATH);
60
+ const s = new WstpSession(KERNEL_PATH);
61
+ _lastMkSession = s;
62
+ return s;
57
63
  }
58
64
 
65
+ // Suppress unhandled rejections from timed-out test bodies whose async
66
+ // functions continue running in the background after Promise.race rejects.
67
+ process.on('unhandledRejection', () => {});
68
+
59
69
  // installHandler — install Interrupt→Dialog[] handler on a session.
60
70
  // Must be done via evaluate() (EnterExpressionPacket context) so the handler
61
71
  // fires on WSInterruptMessage; EvaluatePacket context does not receive it.
@@ -71,14 +81,16 @@ async function installHandler(s) {
71
81
  const TEST_TIMEOUT_MS = 30_000;
72
82
 
73
83
  // Hard suite-level watchdog: if the entire suite takes longer than this the
74
- // process is force-killed. Covers cases where a test hangs AND the per-test
75
- // timeout itself is somehow bypassed (e.g. a blocked native thread).
76
- const SUITE_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
77
- const suiteWatchdog = setTimeout(() => {
78
- console.error('\nFATAL: suite watchdog expired process hung, force-exiting.');
79
- process.exit(2);
80
- }, SUITE_TIMEOUT_MS);
81
- suiteWatchdog.unref(); // does not prevent normal exit
84
+ // process is force-killed. Uses a SEPARATE OS process that sends SIGKILL,
85
+ // because setTimeout callbacks can't fire while the JS event loop is blocked
86
+ // by synchronous C++ code (e.g. CleanUp() spin-waiting for a stuck worker
87
+ // thread, or the constructor's WSActivate blocking on kernel launch).
88
+ const SUITE_TIMEOUT_S = 180; // 3 minutes
89
+ const { spawn } = require('child_process');
90
+ const _watchdogProc = spawn('sh',
91
+ ['-c', `sleep ${SUITE_TIMEOUT_S}; kill -9 ${process.pid} 2>/dev/null`],
92
+ { stdio: 'ignore', detached: true });
93
+ _watchdogProc.unref();
82
94
 
83
95
  // ── Test filtering ─────────────────────────────────────────────────────────
84
96
  // Usage: node test.js --only 38,39,40 or --only 38-52
@@ -114,10 +126,12 @@ async function run(name, fn, timeoutMs = TEST_TIMEOUT_MS) {
114
126
  skipped++;
115
127
  return;
116
128
  }
117
- // Race the test body against a per-test timeout.
118
- const timeout = new Promise((_, reject) =>
119
- setTimeout(() => reject(new Error(`TIMED OUT after ${timeoutMs} ms`)),
120
- timeoutMs));
129
+ let timer;
130
+ _lastMkSession = null; // reset — set by mkSession() if the test creates one
131
+ const timeout = new Promise((_, reject) => {
132
+ timer = setTimeout(() => reject(new Error(`TIMED OUT after ${timeoutMs} ms`)),
133
+ timeoutMs);
134
+ });
121
135
  try {
122
136
  await Promise.race([fn(), timeout]);
123
137
  console.log(` ✓ ${name}`);
@@ -126,6 +140,27 @@ async function run(name, fn, timeoutMs = TEST_TIMEOUT_MS) {
126
140
  console.error(` ✗ ${name}: ${e.message}`);
127
141
  failed++;
128
142
  process.exitCode = 1;
143
+ // On timeout: kill the kernel process to immediately unblock the
144
+ // native WSTP worker thread. Without this, the stuck evaluate()
145
+ // blocks the session (cascade-hanging all subsequent tests on the
146
+ // shared session) and prevents process.exit() from completing.
147
+ // Match both run()-level "TIMED OUT" and inner withTimeout "TIMEOUT".
148
+ if (/TIMED? OUT/i.test(e.message)) {
149
+ const sess = _lastMkSession || _mainSession;
150
+ if (sess) {
151
+ const pid = sess.kernelPid;
152
+ if (pid > 0) {
153
+ try { process.kill(pid, 'SIGKILL'); } catch (_) {}
154
+ console.error(` → killed kernel pid ${pid} to unblock stuck WSTP call`);
155
+ }
156
+ // Force-close the session so the test body's background async
157
+ // cannot further interact with the dead kernel/link.
158
+ try { sess.close(); } catch (_) {}
159
+ }
160
+ _lastMkSession = null;
161
+ }
162
+ } finally {
163
+ clearTimeout(timer);
129
164
  }
130
165
  }
131
166
 
@@ -134,6 +169,7 @@ async function run(name, fn, timeoutMs = TEST_TIMEOUT_MS) {
134
169
  async function main() {
135
170
  console.log('Opening session…');
136
171
  const session = new WstpSession(KERNEL_PATH);
172
+ _mainSession = session;
137
173
  assert(session.isOpen, 'session did not open');
138
174
  console.log('Session open.\n');
139
175
 
@@ -1407,11 +1443,11 @@ async function main() {
1407
1443
  const s = mkSession();
1408
1444
  try {
1409
1445
  s.registerDynamic('tick', 'ToString[$Line]');
1410
- s.setDynamicInterval(80);
1411
- for (let i = 0; i < 8; i++) {
1446
+ s.setDynamicInterval(200);
1447
+ for (let i = 0; i < 5; i++) {
1412
1448
  const r = await withTimeout(
1413
- s.evaluate(`Pause[0.15]; ${i}`),
1414
- 6000, `test 49 cell ${i}`
1449
+ s.evaluate(`Pause[0.3]; ${i}`),
1450
+ 10000, `test 49 cell ${i}`
1415
1451
  );
1416
1452
  assert(!r.aborted, `cell ${i} must not be aborted`);
1417
1453
  assert(String(r.result.value) === String(i),
@@ -1489,8 +1525,10 @@ async function main() {
1489
1525
  s.setDynamicInterval(300);
1490
1526
 
1491
1527
  // Step 2: long enough eval for ScheduledTask to fire at least once.
1528
+ // Use interactive mode — matches the extension's actual cell-eval path
1529
+ // and lets the kernel process Dialog[] commands during Pause[].
1492
1530
  const r1 = await withTimeout(
1493
- s.evaluate('Do[Pause[0.1], {20}]; "cell1"'),
1531
+ s.evaluate('Do[Pause[0.1], {20}]; "cell1"', { interactive: true }),
1494
1532
  12000, '53 cell1'
1495
1533
  );
1496
1534
  assert(!r1.aborted && r1.result.value === 'cell1',
@@ -1631,9 +1669,13 @@ async function main() {
1631
1669
  }, 30000);
1632
1670
 
1633
1671
  // ── Teardown ──────────────────────────────────────────────────────────
1672
+ _mainSession = null;
1634
1673
  session.close();
1635
1674
  assert(!session.isOpen, 'main session not closed');
1636
1675
 
1676
+ // Kill the watchdog process — suite completed in time.
1677
+ try { _watchdogProc.kill('SIGKILL'); } catch (_) {}
1678
+
1637
1679
  console.log();
1638
1680
  if (failed === 0) {
1639
1681
  console.log(`All ${passed} tests passed${skipped ? ` (${skipped} skipped)` : ''}.`);