wstp-node 0.6.2 → 0.6.3

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/index.d.ts CHANGED
@@ -429,3 +429,12 @@ export class WstpReader {
429
429
  * or `null` / `undefined` to clear a previously-set handler.
430
430
  */
431
431
  export function setDiagHandler(fn: ((msg: string) => void) | null | undefined): void;
432
+
433
+ /**
434
+ * Addon version string — mirrors the `version` field in `package.json`.
435
+ *
436
+ * @example
437
+ * const { version } = require('./build/Release/wstp.node');
438
+ * console.log(version); // "0.6.2"
439
+ */
440
+ export declare const version: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wstp-node",
3
- "version": "0.6.2",
3
+ "version": "0.6.3",
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
@@ -882,13 +882,18 @@ static EvalResult DrainToEvalResult(WSLINK lp, EvalOptions* opts = nullptr) {
882
882
  // Drain any stale packets (e.g. late BEGINDLGPKT from a concurrent
883
883
  // interrupt that arrived just as this cell's RETURNPKT was sent).
884
884
  // This prevents Pattern D: stale BEGINDLGPKT corrupting next eval.
885
- if (!r.aborted) drainStalePackets(lp, opts);
886
- // In interactive mode the kernel always follows RETURNEXPRPKT with a
887
- // trailing INPUTNAMEPKT (the next "In[n+1]:=" prompt). We must consume
888
- // that packet before returning so the wire is clean for the next eval.
889
- // Continue the loop; the INPUTNAMEPKT branch below will break cleanly.
890
- if (!opts || !opts->interactive) break;
891
- // (fall through to the next WSNextPacket iteration)
885
+ //
886
+ // IMPORTANT: In interactive mode (EnterExpressionPacket), the kernel
887
+ // follows RETURNEXPRPKT with a trailing INPUTNAMEPKT ("In[n+1]:=").
888
+ // drainStalePackets must NOT run here because it would consume that
889
+ // INPUTNAMEPKT and discard it, causing the outer loop to block forever
890
+ // waiting for a packet that was already eaten. Drain is deferred to
891
+ // the INPUTNAMEPKT handler below.
892
+ if (!opts || !opts->interactive) {
893
+ if (!r.aborted) drainStalePackets(lp, opts);
894
+ break;
895
+ }
896
+ // Interactive mode: fall through to consume trailing INPUTNAMEPKT.
892
897
  }
893
898
  else if (pkt == INPUTNAMEPKT) {
894
899
  const char* s = nullptr; WSGetString(lp, &s);
@@ -906,7 +911,9 @@ static EvalResult DrainToEvalResult(WSLINK lp, EvalOptions* opts = nullptr) {
906
911
  break;
907
912
  }
908
913
  // Trailing INPUTNAMEPKT after RETURNEXPRPKT — consume and exit.
909
- // (Next eval starts with no leftover INPUTNAMEPKT on the wire.)
914
+ // Now safe to drain stale packets (Pattern D prevention)
915
+ // the trailing INPUTNAMEPKT has already been consumed above.
916
+ if (!r.aborted) drainStalePackets(lp, opts);
910
917
  break;
911
918
  }
912
919
  r.cellIndex = parseCellIndex(nameStr);
@@ -1351,9 +1358,23 @@ static EvalResult DrainToEvalResult(WSLINK lp, EvalOptions* opts = nullptr) {
1351
1358
  // Legacy dialog path: when dynAutoMode is false and the eval has
1352
1359
  // dialog callbacks, respond 'i' (inspect) so the kernel enters
1353
1360
  // inspect mode and the Internal`AddHandler fires Dialog[].
1354
- // Otherwise respond 'c' (continue) to dismiss the menu.
1361
+ // For type-1 (interrupt menu) without dialog callbacks, respond
1362
+ // 'a' (abort) rather than 'c' (continue). On ARM64/Wolfram 3,
1363
+ // 'c' can leave the kernel in a stuck state when the interrupt
1364
+ // handler (Internal`AddHandler["Interrupt", Function[.., Dialog[]]])
1365
+ // is installed — Dialog[] interferes with the continuation and
1366
+ // the kernel never sends RETURNPKT. Aborting cleanly produces
1367
+ // $Aborted and keeps the session alive.
1368
+ // For non-interrupt menus (type != 1), 'c' is still safe.
1355
1369
  bool wantInspect = opts && !opts->dynAutoMode && opts->hasOnDialogBegin;
1356
- const char* resp = wantInspect ? "i" : "c";
1370
+ const char* resp;
1371
+ if (wantInspect) {
1372
+ resp = "i";
1373
+ } else if (menuType_ == 1) {
1374
+ resp = "a";
1375
+ } else {
1376
+ resp = "c";
1377
+ }
1357
1378
  DiagLog("[Eval] MENUPKT type=" + std::to_string(menuType_) + " — responding '" + resp + "'");
1358
1379
  WSPutString(lp, resp);
1359
1380
  WSEndPacket(lp);
@@ -1467,6 +1488,21 @@ public:
1467
1488
 
1468
1489
  // ---- thread-pool thread: send packet; block until response ----
1469
1490
  void Execute() override {
1491
+ // ---- Pre-eval drain: consume stale packets on the link --------
1492
+ // If an interrupt was sent just as the previous eval completed,
1493
+ // the kernel may have opened a Dialog[] (via the interrupt handler)
1494
+ // while idle. The resulting BEGINDLGPKT sits unread on the link.
1495
+ // Without draining it first, our EvaluatePacket is processed inside
1496
+ // the stale Dialog context and its RETURNPKT is consumed during the
1497
+ // BEGINDLGPKT handler's drain — leaving the outer DrainToEvalResult
1498
+ // loop waiting forever for a RETURNPKT that was already eaten.
1499
+ // Only check if data is already buffered (WSReady) to avoid adding
1500
+ // latency in the normal case (no stale packets).
1501
+ if (WSReady(lp_)) {
1502
+ DiagLog("[Eval] pre-eval: stale data on link — draining");
1503
+ drainStalePackets(lp_, nullptr);
1504
+ }
1505
+
1470
1506
  // ---- Phase 2: ScheduledTask[Dialog[], interval] management ----
1471
1507
  // Install a kernel-side ScheduledTask that calls Dialog[] periodically.
1472
1508
  // Only install when:
@@ -3083,6 +3119,8 @@ Napi::Object InitModule(Napi::Env env, Napi::Object exports) {
3083
3119
  WstpReader::Init(env, exports);
3084
3120
  exports.Set("setDiagHandler",
3085
3121
  Napi::Function::New(env, SetDiagHandler, "setDiagHandler"));
3122
+ // version — mirrors package.json "version"; read-only string constant.
3123
+ exports.Set("version", Napi::String::New(env, "0.6.3"));
3086
3124
  return exports;
3087
3125
  }
3088
3126
 
package/test.js CHANGED
@@ -731,14 +731,14 @@ async function main() {
731
731
  // 2500ms because Pause[] ignores WSInterruptMessage during a sleep.
732
732
  // This test documents the fundamental limitation: Dynamic cannot read a
733
733
  // live variable while Pause[N] is running.
734
- await run('P1: Pause[8] ignores interrupt within 2500ms', async () => {
734
+ await run('P1: Pause[4] ignores interrupt within 1500ms', async () => {
735
735
  const s = mkSession();
736
736
  try {
737
737
  await installHandler(s);
738
738
 
739
739
  let evalDone = false;
740
740
  const mainProm = s.evaluate(
741
- 'pP1 = 0; Pause[8]; pP1 = 1; "p1-done"'
741
+ 'pP1 = 0; Pause[4]; pP1 = 1; "p1-done"'
742
742
  ).then(() => { evalDone = true; });
743
743
 
744
744
  await sleep(300);
@@ -747,12 +747,12 @@ async function main() {
747
747
  assert(sent === true, 'interrupt() should return true mid-eval');
748
748
 
749
749
  const t0 = Date.now();
750
- while (!s.isDialogOpen && Date.now() - t0 < 2500) await sleep(25);
750
+ while (!s.isDialogOpen && Date.now() - t0 < 1500) await sleep(25);
751
751
 
752
752
  const dlgOpened = s.isDialogOpen;
753
- assert(!dlgOpened, 'Pause[8] should NOT open a dialog within 2500ms');
753
+ assert(!dlgOpened, 'Pause[4] should NOT open a dialog within 1500ms');
754
754
 
755
- // After the 2500ms window the interrupt may still be queued — the kernel
755
+ // After the 1500ms window the interrupt may still be queued — the kernel
756
756
  // will fire Dialog[] once Pause[] releases. Abort to unstick the eval
757
757
  // rather than waiting for it to return on its own (which could take forever
758
758
  // if Dialog[] opens and nobody services it).
@@ -761,7 +761,7 @@ async function main() {
761
761
  } finally {
762
762
  s.close();
763
763
  }
764
- }, 20_000);
764
+ }, 12_000);
765
765
 
766
766
  // ── P2: Pause[0.3] loop — interrupt opens Dialog and dialogEval works ──
767
767
  // Expected: interrupt during a short Pause[] loop opens a Dialog[],
@@ -773,7 +773,7 @@ async function main() {
773
773
 
774
774
  let evalDone = false;
775
775
  const mainProm = s.evaluate(
776
- 'Do[nP2 = k; Pause[0.3], {k, 1, 30}]; "p2-done"',
776
+ 'Do[nP2 = k; Pause[0.1], {k, 1, 15}]; "p2-done"',
777
777
  { onDialogBegin: () => {}, onDialogEnd: () => {} }
778
778
  ).then(() => { evalDone = true; });
779
779
 
@@ -781,19 +781,19 @@ async function main() {
781
781
 
782
782
  s.interrupt();
783
783
  try { await pollUntil(() => s.isDialogOpen, 3000); }
784
- catch (_) { throw new Error('Dialog never opened — interrupt not working with Pause[0.3]'); }
784
+ catch (_) { throw new Error('Dialog never opened — interrupt not working with Pause[0.1]'); }
785
785
 
786
786
  const val = await withTimeout(s.dialogEval('nP2'), 5000, 'dialogEval nP2');
787
787
  assert(val && typeof val.value === 'number' && val.value >= 1,
788
788
  `expected nP2 >= 1, got ${JSON.stringify(val)}`);
789
789
 
790
790
  await s.exitDialog();
791
- await withTimeout(mainProm, 15_000, 'P2 main eval');
791
+ await withTimeout(mainProm, 8_000, 'P2 main eval');
792
792
  } finally {
793
793
  try { s.abort(); } catch (_) {}
794
794
  s.close();
795
795
  }
796
- }, 30_000);
796
+ }, 15_000);
797
797
 
798
798
  // ── P3: dialogEval timeout — kernel state after (diagnostic) ───────────
799
799
  // Simulates the extension failure: dialogEval times out without exitDialog.
@@ -806,7 +806,7 @@ async function main() {
806
806
 
807
807
  let evalDone = false;
808
808
  const mainProm = s.evaluate(
809
- 'Do[nP3 = k; Pause[0.3], {k, 1, 200}]; "p3-done"',
809
+ 'Do[nP3 = k; Pause[0.1], {k, 1, 15}]; "p3-done"',
810
810
  { onDialogBegin: () => {}, onDialogEnd: () => {} }
811
811
  ).then(() => { evalDone = true; });
812
812
 
@@ -841,7 +841,7 @@ async function main() {
841
841
  await s.exitDialog().catch(() => {});
842
842
  }
843
843
 
844
- if (!evalDone) { try { await withTimeout(mainProm, 20_000, 'P3 loop'); } catch (_) {} }
844
+ if (!evalDone) { try { await withTimeout(mainProm, 6_000, 'P3 loop'); } catch (_) {} }
845
845
 
846
846
  // Diagnostic: document observed outcome but do not hard-fail on dlg2
847
847
  if (!exitOk) {
@@ -853,7 +853,7 @@ async function main() {
853
853
  try { s.abort(); } catch (_) {}
854
854
  s.close();
855
855
  }
856
- }, 45_000);
856
+ }, 20_000);
857
857
 
858
858
  // ── P4: abort() after stuck dialog — session stays alive ────────────────
859
859
  // Note: abort() sends WSAbortMessage which resets the Wolfram kernel's
@@ -867,7 +867,7 @@ async function main() {
867
867
  await installHandler(s);
868
868
 
869
869
  const mainProm = s.evaluate(
870
- 'Do[nP4=k; Pause[0.3], {k,1,200}]; "p4-done"',
870
+ 'Do[nP4=k; Pause[0.1], {k,1,15}]; "p4-done"',
871
871
  { onDialogBegin: () => {}, onDialogEnd: () => {} }
872
872
  ).catch(() => {});
873
873
 
@@ -899,7 +899,7 @@ async function main() {
899
899
  try { s.abort(); } catch (_) {}
900
900
  s.close();
901
901
  }
902
- }, 30_000);
902
+ }, 15_000);
903
903
 
904
904
  // ── P5: closeAllDialogs()+abort() recovery → reinstall handler → new dialog works
905
905
  // closeAllDialogs() is designed to be paired with abort(). It rejects all
@@ -912,7 +912,7 @@ async function main() {
912
912
  await installHandler(s);
913
913
 
914
914
  const mainProm = s.evaluate(
915
- 'Do[nP5 = k; Pause[0.3], {k, 1, 200}]; "p5-done"',
915
+ 'Do[nP5 = k; Pause[0.1], {k, 1, 15}]; "p5-done"',
916
916
  { onDialogBegin: () => {}, onDialogEnd: () => {} }
917
917
  ).catch(() => {});
918
918
 
@@ -938,7 +938,7 @@ async function main() {
938
938
 
939
939
  let dlg2 = false;
940
940
  const mainProm2 = s.evaluate(
941
- 'Do[nP5b = k; Pause[0.3], {k, 1, 200}]; "p5b-done"',
941
+ 'Do[nP5b = k; Pause[0.1], {k, 1, 15}]; "p5b-done"',
942
942
  { onDialogBegin: () => {}, onDialogEnd: () => {} }
943
943
  ).catch(() => {});
944
944
 
@@ -960,7 +960,7 @@ async function main() {
960
960
  try { s.abort(); } catch (_) {}
961
961
  s.close();
962
962
  }
963
- }, 60_000);
963
+ }, 25_000);
964
964
 
965
965
  // ── P6: Simulate Dynamic + Pause[5] full scenario (diagnostic) ─────────
966
966
  // Reproduces: n=RandomInteger[100]; Pause[5] with interrupt every 2.5s.
@@ -973,18 +973,18 @@ async function main() {
973
973
 
974
974
  let evalDone = false;
975
975
  const mainProm = s.evaluate(
976
- 'n = RandomInteger[100]; Pause[5]; "p6-done"',
976
+ 'n = RandomInteger[100]; Pause[3]; "p6-done"',
977
977
  { onDialogBegin: () => {}, onDialogEnd: () => {} }
978
978
  ).then(() => { evalDone = true; });
979
979
 
980
980
  await sleep(200);
981
981
 
982
- const INTERRUPT_WAIT_MS = 2500;
982
+ const INTERRUPT_WAIT_MS = 1500;
983
983
  const DIALOG_EVAL_TIMEOUT_MS = 8000;
984
984
  let dialogReadSucceeded = false;
985
985
  let pauseIgnoredInterrupt = false;
986
986
 
987
- for (let cycle = 1; cycle <= 5 && !evalDone; cycle++) {
987
+ for (let cycle = 1; cycle <= 2 && !evalDone; cycle++) {
988
988
  const t0 = Date.now();
989
989
  s.interrupt();
990
990
  while (!s.isDialogOpen && Date.now() - t0 < INTERRUPT_WAIT_MS) await sleep(25);
@@ -1012,11 +1012,11 @@ async function main() {
1012
1012
  }
1013
1013
  }
1014
1014
 
1015
- try { await withTimeout(mainProm, 12_000, 'P6 main eval'); } catch (_) {}
1015
+ try { await withTimeout(mainProm, 7_000, 'P6 main eval'); } catch (_) {}
1016
1016
 
1017
1017
  // Diagnostic: always passes — log observed outcome
1018
1018
  if (pauseIgnoredInterrupt && !dialogReadSucceeded) {
1019
- console.log(' P6 note: Pause[5] blocked all interrupts — expected behaviour');
1019
+ console.log(' P6 note: Pause[3] blocked all interrupts — expected behaviour');
1020
1020
  } else if (dialogReadSucceeded) {
1021
1021
  console.log(' P6 note: at least one read succeeded despite Pause');
1022
1022
  }
@@ -1024,7 +1024,7 @@ async function main() {
1024
1024
  try { s.abort(); } catch (_) {}
1025
1025
  s.close();
1026
1026
  }
1027
- }, 60_000);
1027
+ }, 20_000);
1028
1028
 
1029
1029
  // ── 25. abort() while dialog is open ──────────────────────────────────
1030
1030
  // Must run AFTER all other dialog tests — abort() sends WSAbortMessage
@@ -1542,6 +1542,94 @@ async function main() {
1542
1542
  }
1543
1543
  }, 30000);
1544
1544
 
1545
+ // ── 55. Interactive mode with non-Null result (no trailing semicolon) ──
1546
+ // In interactive mode (EnterExpressionPacket), a non-Null result produces:
1547
+ // RETURNEXPRPKT → INPUTNAMEPKT.
1548
+ // Bug: drainStalePackets() consumed the trailing INPUTNAMEPKT, causing the
1549
+ // outer DrainToEvalResult loop to hang forever waiting for a packet already
1550
+ // consumed. This test verifies that interactive evals with visible results
1551
+ // (no trailing semicolon) complete without hanging.
1552
+ await run('55. interactive eval with non-Null result (drainStalePackets fix)', async () => {
1553
+ const s = mkSession();
1554
+ try {
1555
+ // Simple assignment without semicolon — returns non-Null.
1556
+ const r1 = await withTimeout(
1557
+ s.evaluate('n = 1', { interactive: true }),
1558
+ 10000, '55 n=1 interactive — would hang without drainStalePackets fix'
1559
+ );
1560
+ assert(!r1.aborted, 'n=1 must not be aborted');
1561
+ assert(r1.result.value === 1, `n=1 expected 1, got ${r1.result.value}`);
1562
+
1563
+ // Follow-up: another non-Null interactive eval.
1564
+ const r2 = await withTimeout(
1565
+ s.evaluate('n + 41', { interactive: true }),
1566
+ 10000, '55 n+41 interactive'
1567
+ );
1568
+ assert(!r2.aborted, 'n+41 must not be aborted');
1569
+ assert(r2.result.value === 42, `n+41 expected 42, got ${r2.result.value}`);
1570
+
1571
+ // Follow-up with semicolon (Null result) — should also work.
1572
+ const r3 = await withTimeout(
1573
+ s.evaluate('m = 99;', { interactive: true }),
1574
+ 10000, '55 m=99; interactive'
1575
+ );
1576
+ assert(!r3.aborted, 'm=99; must not be aborted');
1577
+
1578
+ // Follow-up: non-interactive eval still works after interactive ones.
1579
+ const r4 = await withTimeout(
1580
+ s.evaluate('m + 1'),
1581
+ 10000, '55 m+1 non-interactive follow-up'
1582
+ );
1583
+ assert(r4.result.value === 100, `m+1 expected 100, got ${r4.result.value}`);
1584
+ } finally {
1585
+ s.close();
1586
+ }
1587
+ }, 45000);
1588
+
1589
+ // ── 56. Stale interrupt aborts eval cleanly (no hang) ──────────────────
1590
+ // When live-watch sends an interrupt just as a cell completes, the kernel
1591
+ // may fire the queued interrupt during the NEXT evaluation. Without
1592
+ // dialog callbacks, the C++ MENUPKT handler responds 'a' (abort) so the
1593
+ // eval returns $Aborted rather than hanging. This is safe: the caller
1594
+ // can retry. A follow-up eval (without stale interrupt) must succeed.
1595
+ await run('56. stale interrupt aborts eval — no hang', async () => {
1596
+ const s = mkSession();
1597
+ try {
1598
+ await installHandler(s);
1599
+
1600
+ // Warm up
1601
+ const r0 = await withTimeout(s.evaluate('1 + 1'), 5000, '56 warmup');
1602
+ assert(r0.result.value === 2);
1603
+
1604
+ // Send interrupt to idle kernel — may fire during next eval
1605
+ s.interrupt();
1606
+ await sleep(500); // give kernel time to queue interrupt
1607
+
1608
+ // Evaluate — stale interrupt fires → MENUPKT → 'a' → $Aborted
1609
+ const r1 = await withTimeout(
1610
+ s.evaluate('42'),
1611
+ 10000, '56 eval — would hang without abort-on-MENUPKT fix'
1612
+ );
1613
+ // The eval may return $Aborted (interrupt fired) or 42 (interrupt
1614
+ // was ignored by idle kernel). Either is acceptable — the critical
1615
+ // thing is that it does NOT hang.
1616
+ if (r1.aborted) {
1617
+ assert(r1.result.value === '$Aborted',
1618
+ `expected $Aborted, got ${JSON.stringify(r1.result)}`);
1619
+ } else {
1620
+ assert(r1.result.value === 42,
1621
+ `expected 42, got ${JSON.stringify(r1.result)}`);
1622
+ }
1623
+
1624
+ // Follow-up eval must work regardless
1625
+ const r2 = await withTimeout(s.evaluate('2 + 2'), 5000, '56 follow-up');
1626
+ assert(!r2.aborted, '56 follow-up should not be aborted');
1627
+ assert(r2.result.value === 4);
1628
+ } finally {
1629
+ s.close();
1630
+ }
1631
+ }, 30000);
1632
+
1545
1633
  // ── Teardown ──────────────────────────────────────────────────────────
1546
1634
  session.close();
1547
1635
  assert(!session.isOpen, 'main session not closed');
@@ -1555,6 +1643,7 @@ async function main() {
1555
1643
  process.exit(0);
1556
1644
  } else {
1557
1645
  console.log(`${passed} passed, ${failed} failed${skipped ? `, ${skipped} skipped` : ''}.`);
1646
+ process.exit(1);
1558
1647
  }
1559
1648
  }
1560
1649