wstp-node 0.6.1 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -98,7 +98,7 @@ The script automatically locates the WSTP SDK inside the default Wolfram install
98
98
  node test.js
99
99
  ```
100
100
 
101
- Expected last line: `All 52 tests passed.`
101
+ Expected last line: `All 61 tests passed.`
102
102
 
103
103
  A more comprehensive suite (both modes + In/Out + comparison) lives in `tmp/tests_all.js`:
104
104
 
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wstp-node",
3
- "version": "0.6.1",
3
+ "version": "0.6.2",
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
@@ -1159,6 +1159,60 @@ static EvalResult DrainToEvalResult(WSLINK lp, EvalOptions* opts = nullptr) {
1159
1159
  continue;
1160
1160
  }
1161
1161
 
1162
+ // ---- Safety fallback: no onDialogBegin callback registered -----
1163
+ // Legacy dialog loop (below) requires JS to call dialogEval()/
1164
+ // exitDialog() in response to the onDialogBegin callback. If no
1165
+ // callback is registered (e.g. stale ScheduledTask Dialog[] call
1166
+ // arriving during a non-Dynamic cell), nobody will ever call those
1167
+ // functions and the eval hangs permanently. Auto-close the dialog
1168
+ // using the same inline path as rejectDialog.
1169
+ if (!opts || !opts->hasOnDialogBegin) {
1170
+ DiagLog("[Eval] BEGINDLGPKT: no onDialogBegin callback — auto-closing "
1171
+ "(dynAutoMode=false, hasOnDialogBegin=false)");
1172
+ // Pre-drain INPUTNAMEPKT — Dialog[] from ScheduledTask sends
1173
+ // INPUTNAMEPKT before accepting EnterTextPacket.
1174
+ {
1175
+ auto preDl = std::chrono::steady_clock::now() +
1176
+ std::chrono::milliseconds(500);
1177
+ while (std::chrono::steady_clock::now() < preDl) {
1178
+ if (!WSReady(lp)) {
1179
+ std::this_thread::sleep_for(std::chrono::milliseconds(2));
1180
+ continue;
1181
+ }
1182
+ int ipkt = WSNextPacket(lp);
1183
+ DiagLog("[Eval] BEGINDLGPKT safety: pre-drain pkt=" + std::to_string(ipkt));
1184
+ WSNewPacket(lp);
1185
+ if (ipkt == INPUTNAMEPKT) break;
1186
+ if (ipkt == 0 || ipkt == ILLEGALPKT) { WSClearError(lp); break; }
1187
+ }
1188
+ }
1189
+ const char* closeExpr = "Return[$Failed]";
1190
+ WSPutFunction(lp, "EnterTextPacket", 1);
1191
+ WSPutUTF8String(lp,
1192
+ reinterpret_cast<const unsigned char*>(closeExpr),
1193
+ static_cast<int>(std::strlen(closeExpr)));
1194
+ WSEndPacket(lp);
1195
+ WSFlush(lp);
1196
+ DiagLog("[Eval] BEGINDLGPKT safety: sent Return[$Failed], draining until ENDDLGPKT");
1197
+ {
1198
+ auto dlgDeadline = std::chrono::steady_clock::now() +
1199
+ std::chrono::milliseconds(2000);
1200
+ while (std::chrono::steady_clock::now() < dlgDeadline) {
1201
+ if (!WSReady(lp)) {
1202
+ std::this_thread::sleep_for(std::chrono::milliseconds(2));
1203
+ continue;
1204
+ }
1205
+ int rp = WSNextPacket(lp);
1206
+ DiagLog("[Eval] BEGINDLGPKT safety: drain pkt=" + std::to_string(rp));
1207
+ WSNewPacket(lp);
1208
+ if (rp == ENDDLGPKT) break;
1209
+ if (rp == 0 || rp == ILLEGALPKT) { WSClearError(lp); break; }
1210
+ }
1211
+ }
1212
+ // Continue outer drain loop — original RETURNPKT is still coming.
1213
+ continue;
1214
+ }
1215
+
1162
1216
  if (opts && opts->dialogOpen)
1163
1217
  opts->dialogOpen->store(true);
1164
1218
  if (opts && opts->hasOnDialogBegin)
@@ -2348,8 +2402,8 @@ public:
2348
2402
 
2349
2403
  // -----------------------------------------------------------------------
2350
2404
  // setDynAutoMode(auto) → void
2351
- // true = C++-internal inline dialog path (default)
2352
- // false = legacy JS dialogEval/exitDialog path (for debugger)
2405
+ // true = C++-internal inline dialog path
2406
+ // false = legacy JS dialogEval/exitDialog path (default; for debugger)
2353
2407
  // -----------------------------------------------------------------------
2354
2408
  Napi::Value SetDynAutoMode(const Napi::CallbackInfo& info) {
2355
2409
  Napi::Env env = info.Env();
@@ -2358,7 +2412,15 @@ public:
2358
2412
  .ThrowAsJavaScriptException();
2359
2413
  return env.Undefined();
2360
2414
  }
2361
- dynAutoMode_.store(info[0].As<Napi::Boolean>().Value());
2415
+ bool newMode = info[0].As<Napi::Boolean>().Value();
2416
+ bool oldMode = dynAutoMode_.exchange(newMode);
2417
+ // When transitioning true→false (Dynamic cleanup), stop the timer
2418
+ // thread. Prevents stale ScheduledTask Dialog[] calls from reaching
2419
+ // the BEGINDLGPKT handler after cleanup. The safety fallback in
2420
+ // BEGINDLGPKT handles any Dialog[] that fires before this takes effect.
2421
+ if (oldMode && !newMode) {
2422
+ dynIntervalMs_.store(0);
2423
+ }
2362
2424
  return env.Undefined();
2363
2425
  }
2364
2426
 
package/test.js CHANGED
@@ -1,12 +1,13 @@
1
1
  'use strict';
2
2
 
3
- // ── Test suite for wstp-backend v0.6.0 ────────────────────────────────────
3
+ // ── Test suite for wstp-backend v0.6.2 ────────────────────────────────────
4
4
  // Covers: evaluation queue, streaming callbacks, sub() priority, abort
5
5
  // behaviour, WstpReader side-channel, edge cases, Dialog[] subsession
6
6
  // (dialogEval, exitDialog, isDialogOpen, onDialogBegin/End/Print),
7
7
  // subWhenIdle() (background queue, timeout, close rejection), kernelPid,
8
8
  // Dynamic eval API (registerDynamic, getDynamicResults, setDynamicInterval,
9
9
  // setDynAutoMode, dynamicActive, rejectDialog, abort deduplication).
10
+ // 0.6.2: BEGINDLGPKT safety fallback (Bug 1A), setDynAutoMode cleanup (Bug 1B).
10
11
 
11
12
  const { WstpSession, WstpReader, setDiagHandler } = require('./build/Release/wstp.node');
12
13
 
@@ -508,11 +509,12 @@ async function main() {
508
509
  });
509
510
 
510
511
  // ── 17. Unhandled dialog does not corrupt the link ────────────────────
511
- // Call Dialog[] from a plain evaluate(). No onDialogBegin callback is
512
- // registered. We still use dialogEval('DialogReturn[]') to close it and
513
- // confirm the outer Promise resolves correctly afterwards.
512
+ // Call Dialog[] from a plain evaluate() using only isDialogOpen polling
513
+ // (no onDialogBegin handler). onDialogBegin: () => {} is still required
514
+ // to opt into the legacy dialog loop without it Fix 1A auto-closes the
515
+ // dialog before JS can interact with it.
514
516
  await run('17. unhandled dialog does not corrupt link', async () => {
515
- const evalPromise = session.evaluate('Dialog[]; 42');
517
+ const evalPromise = session.evaluate('Dialog[]; 42', { onDialogBegin: () => {} });
516
518
 
517
519
  await pollUntil(() => session.isDialogOpen);
518
520
  assert(session.isDialogOpen, 'dialog should have opened');
@@ -529,7 +531,7 @@ async function main() {
529
531
  // exitDialog('21') sends EnterTextPacket["Return[21]"]. The value 21
530
532
  // becomes the value of Dialog[] in the outer expression, so x$$*2 == 42.
531
533
  await run('19. exitDialog() with a return value', async () => {
532
- const p = session.evaluate('x$$ = Dialog[]; x$$ * 2');
534
+ const p = session.evaluate('x$$ = Dialog[]; x$$ * 2', { onDialogBegin: () => {} });
533
535
  await pollUntil(() => session.isDialogOpen);
534
536
  await session.exitDialog('21');
535
537
  const r = await p;
@@ -542,7 +544,7 @@ async function main() {
542
544
  // ── 20. dialogEval() sees outer variable state ────────────────────────
543
545
  // Variables set before Dialog[] are in scope inside the subsession.
544
546
  await run('20. dialogEval sees outer variable state', async () => {
545
- const p = session.evaluate('myVar$$ = 123; Dialog[]; myVar$$');
547
+ const p = session.evaluate('myVar$$ = 123; Dialog[]; myVar$$', { onDialogBegin: () => {} });
546
548
  await pollUntil(() => session.isDialogOpen);
547
549
  const v = await session.dialogEval('myVar$$');
548
550
  assert(
@@ -556,7 +558,7 @@ async function main() {
556
558
  // ── 21. dialogEval() can mutate kernel state ──────────────────────────
557
559
  // Variables set inside the dialog persist after the dialog closes.
558
560
  await run('21. dialogEval can mutate kernel state', async () => {
559
- const p = session.evaluate('Dialog[]; mutated$$');
561
+ const p = session.evaluate('Dialog[]; mutated$$', { onDialogBegin: () => {} });
560
562
  await pollUntil(() => session.isDialogOpen);
561
563
  await session.dialogEval('mutated$$ = 777');
562
564
  await session.exitDialog();
@@ -571,7 +573,7 @@ async function main() {
571
573
  // ── 22. Multiple dialogEval() calls are serviced FIFO ─────────────────
572
574
  // Three concurrent dialogEval() calls queue up and resolve in order.
573
575
  await run('22. multiple dialogEval calls are serviced FIFO', async () => {
574
- const p = session.evaluate('Dialog[]');
576
+ const p = session.evaluate('Dialog[]', { onDialogBegin: () => {} });
575
577
  await pollUntil(() => session.isDialogOpen);
576
578
  const [a, b, c] = await Promise.all([
577
579
  session.dialogEval('1'),
@@ -590,7 +592,7 @@ async function main() {
590
592
  // false → true (on Dialog[]) → false (after exitDialog).
591
593
  await run('23. isDialogOpen transitions correctly', async () => {
592
594
  assert(!session.isDialogOpen, 'initially false');
593
- const p = session.evaluate('Dialog[]');
595
+ const p = session.evaluate('Dialog[]', { onDialogBegin: () => {} });
594
596
  await pollUntil(() => session.isDialogOpen);
595
597
  assert(session.isDialogOpen, 'true while dialog open');
596
598
  await session.exitDialog();
@@ -604,6 +606,7 @@ async function main() {
604
606
  let dlgPrintResolve;
605
607
  const dlgPrintDelivered = new Promise(r => { dlgPrintResolve = r; });
606
608
  const p = session.evaluate('Dialog[]', {
609
+ onDialogBegin: () => {},
607
610
  onDialogPrint: (line) => { dialogLines.push(line); dlgPrintResolve(); },
608
611
  });
609
612
  await pollUntil(() => session.isDialogOpen);
@@ -659,7 +662,7 @@ async function main() {
659
662
  // A plain evaluate() queued while the dialog inner loop is running must
660
663
  // wait and then be dispatched normally after ENDDLGPKT.
661
664
  await run('27. evaluate() queued during dialog runs after dialog closes', async () => {
662
- const p1 = session.evaluate('Dialog[]');
665
+ const p1 = session.evaluate('Dialog[]', { onDialogBegin: () => {} });
663
666
  await pollUntil(() => session.isDialogOpen);
664
667
  // Queue a normal eval WHILE the dialog is open — it must wait.
665
668
  const p2 = session.evaluate('"queued-during-dialog"');
@@ -688,7 +691,7 @@ async function main() {
688
691
  const sub = session.createSubsession();
689
692
  try {
690
693
  // Open a Dialog[] on the subsession.
691
- const pEval = sub.evaluate('Dialog[]');
694
+ const pEval = sub.evaluate('Dialog[]', { onDialogBegin: () => {} });
692
695
  await pollUntil(() => sub.isDialogOpen);
693
696
  assert(sub.isDialogOpen, 'dialog should be open');
694
697
 
@@ -1029,7 +1032,7 @@ async function main() {
1029
1032
  // evaluations. Tests 26 and 27 need a clean session, so test 25 runs last
1030
1033
  // (just before test 18 which also corrupts the link via WSInterruptMessage).
1031
1034
  await run('25. abort while dialog is open', async () => {
1032
- const p = session.evaluate('Dialog[]');
1035
+ const p = session.evaluate('Dialog[]', { onDialogBegin: () => {} });
1033
1036
  await pollUntil(() => session.isDialogOpen);
1034
1037
  session.abort();
1035
1038
  const r = await p;
@@ -1474,6 +1477,71 @@ async function main() {
1474
1477
  }
1475
1478
  });
1476
1479
 
1480
+ // ── 53. Bug 1A: stale ScheduledTask + non-Dynamic cell does not hang ───
1481
+ // Simulates the subsession.js teardown: Dynamic cell runs → cleanup calls
1482
+ // setDynAutoMode(false) but the kernel-side ScheduledTask may still fire
1483
+ // one more Dialog[]. The next plain cell (no onDialogBegin) must not hang.
1484
+ await run('53. stale ScheduledTask Dialog[] after setDynAutoMode(false) — no hang (Bug 1A)', async () => {
1485
+ const s = mkSession();
1486
+ try {
1487
+ // Step 1: register a Dynamic expr and start the interval.
1488
+ s.registerDynamic('dyn0', 'ToString[AbsoluteTime[], InputForm]');
1489
+ s.setDynamicInterval(300);
1490
+
1491
+ // Step 2: long enough eval for ScheduledTask to fire at least once.
1492
+ const r1 = await withTimeout(
1493
+ s.evaluate('Do[Pause[0.1], {20}]; "cell1"'),
1494
+ 12000, '53 cell1'
1495
+ );
1496
+ assert(!r1.aborted && r1.result.value === 'cell1',
1497
+ `cell1 result: ${JSON.stringify(r1.result)}`);
1498
+
1499
+ // Step 3: simulate subsession.js cleanup.
1500
+ // Fix 1B: setDynAutoMode(false) now also sets dynIntervalMs_=0.
1501
+ s.unregisterDynamic('dyn0');
1502
+ s.setDynAutoMode(false);
1503
+
1504
+ // Step 4: plain cell with no onDialogBegin — must not hang.
1505
+ const r2 = await withTimeout(
1506
+ s.evaluate('n = 1'),
1507
+ 10000, '53 cell2 — would hang without Fix 1A'
1508
+ );
1509
+ assert(!r2.aborted, 'cell2 must not be aborted');
1510
+
1511
+ // Step 5: follow-up eval also works.
1512
+ const r3 = await withTimeout(s.evaluate('1 + 1'), 6000, '53 cell3');
1513
+ assert(r3.result.value === 2, `cell3 expected 2, got ${r3.result.value}`);
1514
+ } finally {
1515
+ s.close();
1516
+ }
1517
+ }, 45000);
1518
+
1519
+ // ── 54. Bug 1A: BEGINDLGPKT with dynAutoMode=false, no JS callback ────
1520
+ // With dynAutoMode=false and no onDialogBegin registered, any BEGINDLGPKT
1521
+ // must be auto-closed by the safety fallback rather than entering the
1522
+ // legacy loop where nobody will ever call exitDialog().
1523
+ await run('54. BEGINDLGPKT safety fallback — auto-close when no onDialogBegin (Bug 1A)', async () => {
1524
+ const s = mkSession();
1525
+ try {
1526
+ s.setDynAutoMode(false);
1527
+ // Eval runs for ~2s; no onDialogBegin registered. Any BEGINDLGPKT
1528
+ // that arrives (from a stale ScheduledTask or concurrent interrupt)
1529
+ // must be auto-closed by the safety fallback.
1530
+ const r = await withTimeout(
1531
+ s.evaluate('Do[Pause[0.2], {10}]; "done"'),
1532
+ 15000, '54 eval — would hang without Fix 1A'
1533
+ );
1534
+ assert(!r.aborted, 'eval must not be aborted');
1535
+ assert(r.result.value === 'done', `expected "done", got "${r.result.value}"`);
1536
+
1537
+ // Follow-up eval must work — no leftover packets.
1538
+ const r2 = await withTimeout(s.evaluate('2 + 2'), 6000, '54 follow-up');
1539
+ assert(r2.result.value === 4, `follow-up expected 4, got ${r2.result.value}`);
1540
+ } finally {
1541
+ s.close();
1542
+ }
1543
+ }, 30000);
1544
+
1477
1545
  // ── Teardown ──────────────────────────────────────────────────────────
1478
1546
  session.close();
1479
1547
  assert(!session.isOpen, 'main session not closed');