wstp-node 0.6.2 → 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/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.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.
@@ -882,13 +887,18 @@ static EvalResult DrainToEvalResult(WSLINK lp, EvalOptions* opts = nullptr) {
882
887
  // Drain any stale packets (e.g. late BEGINDLGPKT from a concurrent
883
888
  // interrupt that arrived just as this cell's RETURNPKT was sent).
884
889
  // 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)
890
+ //
891
+ // IMPORTANT: In interactive mode (EnterExpressionPacket), the kernel
892
+ // follows RETURNEXPRPKT with a trailing INPUTNAMEPKT ("In[n+1]:=").
893
+ // drainStalePackets must NOT run here because it would consume that
894
+ // INPUTNAMEPKT and discard it, causing the outer loop to block forever
895
+ // waiting for a packet that was already eaten. Drain is deferred to
896
+ // the INPUTNAMEPKT handler below.
897
+ if (!opts || !opts->interactive) {
898
+ if (!r.aborted) drainStalePackets(lp, opts);
899
+ break;
900
+ }
901
+ // Interactive mode: fall through to consume trailing INPUTNAMEPKT.
892
902
  }
893
903
  else if (pkt == INPUTNAMEPKT) {
894
904
  const char* s = nullptr; WSGetString(lp, &s);
@@ -906,7 +916,9 @@ static EvalResult DrainToEvalResult(WSLINK lp, EvalOptions* opts = nullptr) {
906
916
  break;
907
917
  }
908
918
  // Trailing INPUTNAMEPKT after RETURNEXPRPKT — consume and exit.
909
- // (Next eval starts with no leftover INPUTNAMEPKT on the wire.)
919
+ // Now safe to drain stale packets (Pattern D prevention)
920
+ // the trailing INPUTNAMEPKT has already been consumed above.
921
+ if (!r.aborted) drainStalePackets(lp, opts);
910
922
  break;
911
923
  }
912
924
  r.cellIndex = parseCellIndex(nameStr);
@@ -985,6 +997,12 @@ static EvalResult DrainToEvalResult(WSLINK lp, EvalOptions* opts = nullptr) {
985
997
  WSFlush(lp);
986
998
  DiagLog("[Eval] rejectDialog: sent Return[$Failed], draining until ENDDLGPKT");
987
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;
988
1006
  {
989
1007
  auto dlgDeadline = std::chrono::steady_clock::now() +
990
1008
  std::chrono::milliseconds(2000);
@@ -995,11 +1013,25 @@ static EvalResult DrainToEvalResult(WSLINK lp, EvalOptions* opts = nullptr) {
995
1013
  }
996
1014
  int rp = WSNextPacket(lp);
997
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
+ }
998
1021
  WSNewPacket(lp);
999
1022
  if (rp == ENDDLGPKT) break;
1000
1023
  if (rp == 0 || rp == ILLEGALPKT) { WSClearError(lp); break; }
1001
1024
  }
1002
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
+ }
1003
1035
  // Continue outer drain loop — the original RETURNPKT is still coming.
1004
1036
  continue;
1005
1037
  }
@@ -1194,6 +1226,8 @@ static EvalResult DrainToEvalResult(WSLINK lp, EvalOptions* opts = nullptr) {
1194
1226
  WSEndPacket(lp);
1195
1227
  WSFlush(lp);
1196
1228
  DiagLog("[Eval] BEGINDLGPKT safety: sent Return[$Failed], draining until ENDDLGPKT");
1229
+ WExpr safetyCaptured;
1230
+ bool safetyGotOuter = false;
1197
1231
  {
1198
1232
  auto dlgDeadline = std::chrono::steady_clock::now() +
1199
1233
  std::chrono::milliseconds(2000);
@@ -1204,11 +1238,25 @@ static EvalResult DrainToEvalResult(WSLINK lp, EvalOptions* opts = nullptr) {
1204
1238
  }
1205
1239
  int rp = WSNextPacket(lp);
1206
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
+ }
1207
1246
  WSNewPacket(lp);
1208
1247
  if (rp == ENDDLGPKT) break;
1209
1248
  if (rp == 0 || rp == ILLEGALPKT) { WSClearError(lp); break; }
1210
1249
  }
1211
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
+ }
1212
1260
  // Continue outer drain loop — original RETURNPKT is still coming.
1213
1261
  continue;
1214
1262
  }
@@ -1351,9 +1399,23 @@ static EvalResult DrainToEvalResult(WSLINK lp, EvalOptions* opts = nullptr) {
1351
1399
  // Legacy dialog path: when dynAutoMode is false and the eval has
1352
1400
  // dialog callbacks, respond 'i' (inspect) so the kernel enters
1353
1401
  // inspect mode and the Internal`AddHandler fires Dialog[].
1354
- // Otherwise respond 'c' (continue) to dismiss the menu.
1402
+ // For type-1 (interrupt menu) without dialog callbacks, respond
1403
+ // 'a' (abort) rather than 'c' (continue). On ARM64/Wolfram 3,
1404
+ // 'c' can leave the kernel in a stuck state when the interrupt
1405
+ // handler (Internal`AddHandler["Interrupt", Function[.., Dialog[]]])
1406
+ // is installed — Dialog[] interferes with the continuation and
1407
+ // the kernel never sends RETURNPKT. Aborting cleanly produces
1408
+ // $Aborted and keeps the session alive.
1409
+ // For non-interrupt menus (type != 1), 'c' is still safe.
1355
1410
  bool wantInspect = opts && !opts->dynAutoMode && opts->hasOnDialogBegin;
1356
- const char* resp = wantInspect ? "i" : "c";
1411
+ const char* resp;
1412
+ if (wantInspect) {
1413
+ resp = "i";
1414
+ } else if (menuType_ == 1) {
1415
+ resp = "a";
1416
+ } else {
1417
+ resp = "c";
1418
+ }
1357
1419
  DiagLog("[Eval] MENUPKT type=" + std::to_string(menuType_) + " — responding '" + resp + "'");
1358
1420
  WSPutString(lp, resp);
1359
1421
  WSEndPacket(lp);
@@ -1467,6 +1529,21 @@ public:
1467
1529
 
1468
1530
  // ---- thread-pool thread: send packet; block until response ----
1469
1531
  void Execute() override {
1532
+ // ---- Pre-eval drain: consume stale packets on the link --------
1533
+ // If an interrupt was sent just as the previous eval completed,
1534
+ // the kernel may have opened a Dialog[] (via the interrupt handler)
1535
+ // while idle. The resulting BEGINDLGPKT sits unread on the link.
1536
+ // Without draining it first, our EvaluatePacket is processed inside
1537
+ // the stale Dialog context and its RETURNPKT is consumed during the
1538
+ // BEGINDLGPKT handler's drain — leaving the outer DrainToEvalResult
1539
+ // loop waiting forever for a RETURNPKT that was already eaten.
1540
+ // Only check if data is already buffered (WSReady) to avoid adding
1541
+ // latency in the normal case (no stale packets).
1542
+ if (WSReady(lp_)) {
1543
+ DiagLog("[Eval] pre-eval: stale data on link — draining");
1544
+ drainStalePackets(lp_, nullptr);
1545
+ }
1546
+
1470
1547
  // ---- Phase 2: ScheduledTask[Dialog[], interval] management ----
1471
1548
  // Install a kernel-side ScheduledTask that calls Dialog[] periodically.
1472
1549
  // Only install when:
@@ -1522,12 +1599,33 @@ public:
1522
1599
  }
1523
1600
  }
1524
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
+
1525
1623
  bool sent;
1526
1624
  if (!interactive_) {
1527
1625
  // EvaluatePacket + ToExpression: non-interactive, does NOT populate In[n]/Out[n].
1528
1626
  sent = WSPutFunction(lp_, "EvaluatePacket", 1) &&
1529
1627
  WSPutFunction(lp_, "ToExpression", 1) &&
1530
- WSPutUTF8String(lp_, (const unsigned char *)expr_.c_str(), (int)expr_.size()) &&
1628
+ WSPutUTF8String(lp_, (const unsigned char *)sendExpr.c_str(), (int)sendExpr.size()) &&
1531
1629
  WSEndPacket (lp_) &&
1532
1630
  WSFlush (lp_);
1533
1631
  } else {
@@ -1536,7 +1634,7 @@ public:
1536
1634
  // as the real Mathematica frontend does. Responds with RETURNEXPRPKT.
1537
1635
  sent = WSPutFunction(lp_, "EnterExpressionPacket", 1) &&
1538
1636
  WSPutFunction(lp_, "ToExpression", 1) &&
1539
- WSPutUTF8String(lp_, (const unsigned char *)expr_.c_str(), (int)expr_.size()) &&
1637
+ WSPutUTF8String(lp_, (const unsigned char *)sendExpr.c_str(), (int)sendExpr.size()) &&
1540
1638
  WSEndPacket (lp_) &&
1541
1639
  WSFlush (lp_);
1542
1640
  }
@@ -2645,11 +2743,28 @@ private:
2645
2743
  FlushDialogQueueWithError("session closed");
2646
2744
  dialogOpen_.store(false);
2647
2745
  WSPutMessage(lp_, WSAbortMessage);
2648
- const auto deadline =
2649
- 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);
2650
2749
  while (workerReadingLink_.load(std::memory_order_acquire) &&
2651
2750
  std::chrono::steady_clock::now() < deadline)
2652
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
+ }
2653
2768
  }
2654
2769
  if (lp_) { WSClose(lp_); lp_ = nullptr; }
2655
2770
  if (wsEnv_) { WSDeinitialize(wsEnv_); wsEnv_ = nullptr; }
@@ -3083,6 +3198,8 @@ Napi::Object InitModule(Napi::Env env, Napi::Object exports) {
3083
3198
  WstpReader::Init(env, exports);
3084
3199
  exports.Set("setDiagHandler",
3085
3200
  Napi::Function::New(env, SetDiagHandler, "setDiagHandler"));
3201
+ // version — mirrors package.json "version"; read-only string constant.
3202
+ exports.Set("version", Napi::String::New(env, "0.6.6"));
3086
3203
  return exports;
3087
3204
  }
3088
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
 
@@ -731,14 +767,14 @@ async function main() {
731
767
  // 2500ms because Pause[] ignores WSInterruptMessage during a sleep.
732
768
  // This test documents the fundamental limitation: Dynamic cannot read a
733
769
  // live variable while Pause[N] is running.
734
- await run('P1: Pause[8] ignores interrupt within 2500ms', async () => {
770
+ await run('P1: Pause[4] ignores interrupt within 1500ms', async () => {
735
771
  const s = mkSession();
736
772
  try {
737
773
  await installHandler(s);
738
774
 
739
775
  let evalDone = false;
740
776
  const mainProm = s.evaluate(
741
- 'pP1 = 0; Pause[8]; pP1 = 1; "p1-done"'
777
+ 'pP1 = 0; Pause[4]; pP1 = 1; "p1-done"'
742
778
  ).then(() => { evalDone = true; });
743
779
 
744
780
  await sleep(300);
@@ -747,12 +783,12 @@ async function main() {
747
783
  assert(sent === true, 'interrupt() should return true mid-eval');
748
784
 
749
785
  const t0 = Date.now();
750
- while (!s.isDialogOpen && Date.now() - t0 < 2500) await sleep(25);
786
+ while (!s.isDialogOpen && Date.now() - t0 < 1500) await sleep(25);
751
787
 
752
788
  const dlgOpened = s.isDialogOpen;
753
- assert(!dlgOpened, 'Pause[8] should NOT open a dialog within 2500ms');
789
+ assert(!dlgOpened, 'Pause[4] should NOT open a dialog within 1500ms');
754
790
 
755
- // After the 2500ms window the interrupt may still be queued — the kernel
791
+ // After the 1500ms window the interrupt may still be queued — the kernel
756
792
  // will fire Dialog[] once Pause[] releases. Abort to unstick the eval
757
793
  // rather than waiting for it to return on its own (which could take forever
758
794
  // if Dialog[] opens and nobody services it).
@@ -761,7 +797,7 @@ async function main() {
761
797
  } finally {
762
798
  s.close();
763
799
  }
764
- }, 20_000);
800
+ }, 12_000);
765
801
 
766
802
  // ── P2: Pause[0.3] loop — interrupt opens Dialog and dialogEval works ──
767
803
  // Expected: interrupt during a short Pause[] loop opens a Dialog[],
@@ -773,7 +809,7 @@ async function main() {
773
809
 
774
810
  let evalDone = false;
775
811
  const mainProm = s.evaluate(
776
- 'Do[nP2 = k; Pause[0.3], {k, 1, 30}]; "p2-done"',
812
+ 'Do[nP2 = k; Pause[0.1], {k, 1, 15}]; "p2-done"',
777
813
  { onDialogBegin: () => {}, onDialogEnd: () => {} }
778
814
  ).then(() => { evalDone = true; });
779
815
 
@@ -781,19 +817,19 @@ async function main() {
781
817
 
782
818
  s.interrupt();
783
819
  try { await pollUntil(() => s.isDialogOpen, 3000); }
784
- catch (_) { throw new Error('Dialog never opened — interrupt not working with Pause[0.3]'); }
820
+ catch (_) { throw new Error('Dialog never opened — interrupt not working with Pause[0.1]'); }
785
821
 
786
822
  const val = await withTimeout(s.dialogEval('nP2'), 5000, 'dialogEval nP2');
787
823
  assert(val && typeof val.value === 'number' && val.value >= 1,
788
824
  `expected nP2 >= 1, got ${JSON.stringify(val)}`);
789
825
 
790
826
  await s.exitDialog();
791
- await withTimeout(mainProm, 15_000, 'P2 main eval');
827
+ await withTimeout(mainProm, 8_000, 'P2 main eval');
792
828
  } finally {
793
829
  try { s.abort(); } catch (_) {}
794
830
  s.close();
795
831
  }
796
- }, 30_000);
832
+ }, 15_000);
797
833
 
798
834
  // ── P3: dialogEval timeout — kernel state after (diagnostic) ───────────
799
835
  // Simulates the extension failure: dialogEval times out without exitDialog.
@@ -806,7 +842,7 @@ async function main() {
806
842
 
807
843
  let evalDone = false;
808
844
  const mainProm = s.evaluate(
809
- 'Do[nP3 = k; Pause[0.3], {k, 1, 200}]; "p3-done"',
845
+ 'Do[nP3 = k; Pause[0.1], {k, 1, 15}]; "p3-done"',
810
846
  { onDialogBegin: () => {}, onDialogEnd: () => {} }
811
847
  ).then(() => { evalDone = true; });
812
848
 
@@ -841,7 +877,7 @@ async function main() {
841
877
  await s.exitDialog().catch(() => {});
842
878
  }
843
879
 
844
- if (!evalDone) { try { await withTimeout(mainProm, 20_000, 'P3 loop'); } catch (_) {} }
880
+ if (!evalDone) { try { await withTimeout(mainProm, 6_000, 'P3 loop'); } catch (_) {} }
845
881
 
846
882
  // Diagnostic: document observed outcome but do not hard-fail on dlg2
847
883
  if (!exitOk) {
@@ -853,7 +889,7 @@ async function main() {
853
889
  try { s.abort(); } catch (_) {}
854
890
  s.close();
855
891
  }
856
- }, 45_000);
892
+ }, 20_000);
857
893
 
858
894
  // ── P4: abort() after stuck dialog — session stays alive ────────────────
859
895
  // Note: abort() sends WSAbortMessage which resets the Wolfram kernel's
@@ -867,7 +903,7 @@ async function main() {
867
903
  await installHandler(s);
868
904
 
869
905
  const mainProm = s.evaluate(
870
- 'Do[nP4=k; Pause[0.3], {k,1,200}]; "p4-done"',
906
+ 'Do[nP4=k; Pause[0.1], {k,1,15}]; "p4-done"',
871
907
  { onDialogBegin: () => {}, onDialogEnd: () => {} }
872
908
  ).catch(() => {});
873
909
 
@@ -899,7 +935,7 @@ async function main() {
899
935
  try { s.abort(); } catch (_) {}
900
936
  s.close();
901
937
  }
902
- }, 30_000);
938
+ }, 15_000);
903
939
 
904
940
  // ── P5: closeAllDialogs()+abort() recovery → reinstall handler → new dialog works
905
941
  // closeAllDialogs() is designed to be paired with abort(). It rejects all
@@ -912,7 +948,7 @@ async function main() {
912
948
  await installHandler(s);
913
949
 
914
950
  const mainProm = s.evaluate(
915
- 'Do[nP5 = k; Pause[0.3], {k, 1, 200}]; "p5-done"',
951
+ 'Do[nP5 = k; Pause[0.1], {k, 1, 15}]; "p5-done"',
916
952
  { onDialogBegin: () => {}, onDialogEnd: () => {} }
917
953
  ).catch(() => {});
918
954
 
@@ -938,7 +974,7 @@ async function main() {
938
974
 
939
975
  let dlg2 = false;
940
976
  const mainProm2 = s.evaluate(
941
- 'Do[nP5b = k; Pause[0.3], {k, 1, 200}]; "p5b-done"',
977
+ 'Do[nP5b = k; Pause[0.1], {k, 1, 15}]; "p5b-done"',
942
978
  { onDialogBegin: () => {}, onDialogEnd: () => {} }
943
979
  ).catch(() => {});
944
980
 
@@ -960,7 +996,7 @@ async function main() {
960
996
  try { s.abort(); } catch (_) {}
961
997
  s.close();
962
998
  }
963
- }, 60_000);
999
+ }, 25_000);
964
1000
 
965
1001
  // ── P6: Simulate Dynamic + Pause[5] full scenario (diagnostic) ─────────
966
1002
  // Reproduces: n=RandomInteger[100]; Pause[5] with interrupt every 2.5s.
@@ -973,18 +1009,18 @@ async function main() {
973
1009
 
974
1010
  let evalDone = false;
975
1011
  const mainProm = s.evaluate(
976
- 'n = RandomInteger[100]; Pause[5]; "p6-done"',
1012
+ 'n = RandomInteger[100]; Pause[3]; "p6-done"',
977
1013
  { onDialogBegin: () => {}, onDialogEnd: () => {} }
978
1014
  ).then(() => { evalDone = true; });
979
1015
 
980
1016
  await sleep(200);
981
1017
 
982
- const INTERRUPT_WAIT_MS = 2500;
1018
+ const INTERRUPT_WAIT_MS = 1500;
983
1019
  const DIALOG_EVAL_TIMEOUT_MS = 8000;
984
1020
  let dialogReadSucceeded = false;
985
1021
  let pauseIgnoredInterrupt = false;
986
1022
 
987
- for (let cycle = 1; cycle <= 5 && !evalDone; cycle++) {
1023
+ for (let cycle = 1; cycle <= 2 && !evalDone; cycle++) {
988
1024
  const t0 = Date.now();
989
1025
  s.interrupt();
990
1026
  while (!s.isDialogOpen && Date.now() - t0 < INTERRUPT_WAIT_MS) await sleep(25);
@@ -1012,11 +1048,11 @@ async function main() {
1012
1048
  }
1013
1049
  }
1014
1050
 
1015
- try { await withTimeout(mainProm, 12_000, 'P6 main eval'); } catch (_) {}
1051
+ try { await withTimeout(mainProm, 7_000, 'P6 main eval'); } catch (_) {}
1016
1052
 
1017
1053
  // Diagnostic: always passes — log observed outcome
1018
1054
  if (pauseIgnoredInterrupt && !dialogReadSucceeded) {
1019
- console.log(' P6 note: Pause[5] blocked all interrupts — expected behaviour');
1055
+ console.log(' P6 note: Pause[3] blocked all interrupts — expected behaviour');
1020
1056
  } else if (dialogReadSucceeded) {
1021
1057
  console.log(' P6 note: at least one read succeeded despite Pause');
1022
1058
  }
@@ -1024,7 +1060,7 @@ async function main() {
1024
1060
  try { s.abort(); } catch (_) {}
1025
1061
  s.close();
1026
1062
  }
1027
- }, 60_000);
1063
+ }, 20_000);
1028
1064
 
1029
1065
  // ── 25. abort() while dialog is open ──────────────────────────────────
1030
1066
  // Must run AFTER all other dialog tests — abort() sends WSAbortMessage
@@ -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',
@@ -1542,10 +1580,102 @@ async function main() {
1542
1580
  }
1543
1581
  }, 30000);
1544
1582
 
1583
+ // ── 55. Interactive mode with non-Null result (no trailing semicolon) ──
1584
+ // In interactive mode (EnterExpressionPacket), a non-Null result produces:
1585
+ // RETURNEXPRPKT → INPUTNAMEPKT.
1586
+ // Bug: drainStalePackets() consumed the trailing INPUTNAMEPKT, causing the
1587
+ // outer DrainToEvalResult loop to hang forever waiting for a packet already
1588
+ // consumed. This test verifies that interactive evals with visible results
1589
+ // (no trailing semicolon) complete without hanging.
1590
+ await run('55. interactive eval with non-Null result (drainStalePackets fix)', async () => {
1591
+ const s = mkSession();
1592
+ try {
1593
+ // Simple assignment without semicolon — returns non-Null.
1594
+ const r1 = await withTimeout(
1595
+ s.evaluate('n = 1', { interactive: true }),
1596
+ 10000, '55 n=1 interactive — would hang without drainStalePackets fix'
1597
+ );
1598
+ assert(!r1.aborted, 'n=1 must not be aborted');
1599
+ assert(r1.result.value === 1, `n=1 expected 1, got ${r1.result.value}`);
1600
+
1601
+ // Follow-up: another non-Null interactive eval.
1602
+ const r2 = await withTimeout(
1603
+ s.evaluate('n + 41', { interactive: true }),
1604
+ 10000, '55 n+41 interactive'
1605
+ );
1606
+ assert(!r2.aborted, 'n+41 must not be aborted');
1607
+ assert(r2.result.value === 42, `n+41 expected 42, got ${r2.result.value}`);
1608
+
1609
+ // Follow-up with semicolon (Null result) — should also work.
1610
+ const r3 = await withTimeout(
1611
+ s.evaluate('m = 99;', { interactive: true }),
1612
+ 10000, '55 m=99; interactive'
1613
+ );
1614
+ assert(!r3.aborted, 'm=99; must not be aborted');
1615
+
1616
+ // Follow-up: non-interactive eval still works after interactive ones.
1617
+ const r4 = await withTimeout(
1618
+ s.evaluate('m + 1'),
1619
+ 10000, '55 m+1 non-interactive follow-up'
1620
+ );
1621
+ assert(r4.result.value === 100, `m+1 expected 100, got ${r4.result.value}`);
1622
+ } finally {
1623
+ s.close();
1624
+ }
1625
+ }, 45000);
1626
+
1627
+ // ── 56. Stale interrupt aborts eval cleanly (no hang) ──────────────────
1628
+ // When live-watch sends an interrupt just as a cell completes, the kernel
1629
+ // may fire the queued interrupt during the NEXT evaluation. Without
1630
+ // dialog callbacks, the C++ MENUPKT handler responds 'a' (abort) so the
1631
+ // eval returns $Aborted rather than hanging. This is safe: the caller
1632
+ // can retry. A follow-up eval (without stale interrupt) must succeed.
1633
+ await run('56. stale interrupt aborts eval — no hang', async () => {
1634
+ const s = mkSession();
1635
+ try {
1636
+ await installHandler(s);
1637
+
1638
+ // Warm up
1639
+ const r0 = await withTimeout(s.evaluate('1 + 1'), 5000, '56 warmup');
1640
+ assert(r0.result.value === 2);
1641
+
1642
+ // Send interrupt to idle kernel — may fire during next eval
1643
+ s.interrupt();
1644
+ await sleep(500); // give kernel time to queue interrupt
1645
+
1646
+ // Evaluate — stale interrupt fires → MENUPKT → 'a' → $Aborted
1647
+ const r1 = await withTimeout(
1648
+ s.evaluate('42'),
1649
+ 10000, '56 eval — would hang without abort-on-MENUPKT fix'
1650
+ );
1651
+ // The eval may return $Aborted (interrupt fired) or 42 (interrupt
1652
+ // was ignored by idle kernel). Either is acceptable — the critical
1653
+ // thing is that it does NOT hang.
1654
+ if (r1.aborted) {
1655
+ assert(r1.result.value === '$Aborted',
1656
+ `expected $Aborted, got ${JSON.stringify(r1.result)}`);
1657
+ } else {
1658
+ assert(r1.result.value === 42,
1659
+ `expected 42, got ${JSON.stringify(r1.result)}`);
1660
+ }
1661
+
1662
+ // Follow-up eval must work regardless
1663
+ const r2 = await withTimeout(s.evaluate('2 + 2'), 5000, '56 follow-up');
1664
+ assert(!r2.aborted, '56 follow-up should not be aborted');
1665
+ assert(r2.result.value === 4);
1666
+ } finally {
1667
+ s.close();
1668
+ }
1669
+ }, 30000);
1670
+
1545
1671
  // ── Teardown ──────────────────────────────────────────────────────────
1672
+ _mainSession = null;
1546
1673
  session.close();
1547
1674
  assert(!session.isOpen, 'main session not closed');
1548
1675
 
1676
+ // Kill the watchdog process — suite completed in time.
1677
+ try { _watchdogProc.kill('SIGKILL'); } catch (_) {}
1678
+
1549
1679
  console.log();
1550
1680
  if (failed === 0) {
1551
1681
  console.log(`All ${passed} tests passed${skipped ? ` (${skipped} skipped)` : ''}.`);
@@ -1555,6 +1685,7 @@ async function main() {
1555
1685
  process.exit(0);
1556
1686
  } else {
1557
1687
  console.log(`${passed} passed, ${failed} failed${skipped ? `, ${skipped} skipped` : ''}.`);
1688
+ process.exit(1);
1558
1689
  }
1559
1690
  }
1560
1691