wstp-node 0.6.1 → 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.
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/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.1",
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);
@@ -1159,6 +1166,60 @@ static EvalResult DrainToEvalResult(WSLINK lp, EvalOptions* opts = nullptr) {
1159
1166
  continue;
1160
1167
  }
1161
1168
 
1169
+ // ---- Safety fallback: no onDialogBegin callback registered -----
1170
+ // Legacy dialog loop (below) requires JS to call dialogEval()/
1171
+ // exitDialog() in response to the onDialogBegin callback. If no
1172
+ // callback is registered (e.g. stale ScheduledTask Dialog[] call
1173
+ // arriving during a non-Dynamic cell), nobody will ever call those
1174
+ // functions and the eval hangs permanently. Auto-close the dialog
1175
+ // using the same inline path as rejectDialog.
1176
+ if (!opts || !opts->hasOnDialogBegin) {
1177
+ DiagLog("[Eval] BEGINDLGPKT: no onDialogBegin callback — auto-closing "
1178
+ "(dynAutoMode=false, hasOnDialogBegin=false)");
1179
+ // Pre-drain INPUTNAMEPKT — Dialog[] from ScheduledTask sends
1180
+ // INPUTNAMEPKT before accepting EnterTextPacket.
1181
+ {
1182
+ auto preDl = std::chrono::steady_clock::now() +
1183
+ std::chrono::milliseconds(500);
1184
+ while (std::chrono::steady_clock::now() < preDl) {
1185
+ if (!WSReady(lp)) {
1186
+ std::this_thread::sleep_for(std::chrono::milliseconds(2));
1187
+ continue;
1188
+ }
1189
+ int ipkt = WSNextPacket(lp);
1190
+ DiagLog("[Eval] BEGINDLGPKT safety: pre-drain pkt=" + std::to_string(ipkt));
1191
+ WSNewPacket(lp);
1192
+ if (ipkt == INPUTNAMEPKT) break;
1193
+ if (ipkt == 0 || ipkt == ILLEGALPKT) { WSClearError(lp); break; }
1194
+ }
1195
+ }
1196
+ const char* closeExpr = "Return[$Failed]";
1197
+ WSPutFunction(lp, "EnterTextPacket", 1);
1198
+ WSPutUTF8String(lp,
1199
+ reinterpret_cast<const unsigned char*>(closeExpr),
1200
+ static_cast<int>(std::strlen(closeExpr)));
1201
+ WSEndPacket(lp);
1202
+ WSFlush(lp);
1203
+ DiagLog("[Eval] BEGINDLGPKT safety: sent Return[$Failed], draining until ENDDLGPKT");
1204
+ {
1205
+ auto dlgDeadline = std::chrono::steady_clock::now() +
1206
+ std::chrono::milliseconds(2000);
1207
+ while (std::chrono::steady_clock::now() < dlgDeadline) {
1208
+ if (!WSReady(lp)) {
1209
+ std::this_thread::sleep_for(std::chrono::milliseconds(2));
1210
+ continue;
1211
+ }
1212
+ int rp = WSNextPacket(lp);
1213
+ DiagLog("[Eval] BEGINDLGPKT safety: drain pkt=" + std::to_string(rp));
1214
+ WSNewPacket(lp);
1215
+ if (rp == ENDDLGPKT) break;
1216
+ if (rp == 0 || rp == ILLEGALPKT) { WSClearError(lp); break; }
1217
+ }
1218
+ }
1219
+ // Continue outer drain loop — original RETURNPKT is still coming.
1220
+ continue;
1221
+ }
1222
+
1162
1223
  if (opts && opts->dialogOpen)
1163
1224
  opts->dialogOpen->store(true);
1164
1225
  if (opts && opts->hasOnDialogBegin)
@@ -1297,9 +1358,23 @@ static EvalResult DrainToEvalResult(WSLINK lp, EvalOptions* opts = nullptr) {
1297
1358
  // Legacy dialog path: when dynAutoMode is false and the eval has
1298
1359
  // dialog callbacks, respond 'i' (inspect) so the kernel enters
1299
1360
  // inspect mode and the Internal`AddHandler fires Dialog[].
1300
- // 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.
1301
1369
  bool wantInspect = opts && !opts->dynAutoMode && opts->hasOnDialogBegin;
1302
- 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
+ }
1303
1378
  DiagLog("[Eval] MENUPKT type=" + std::to_string(menuType_) + " — responding '" + resp + "'");
1304
1379
  WSPutString(lp, resp);
1305
1380
  WSEndPacket(lp);
@@ -1413,6 +1488,21 @@ public:
1413
1488
 
1414
1489
  // ---- thread-pool thread: send packet; block until response ----
1415
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
+
1416
1506
  // ---- Phase 2: ScheduledTask[Dialog[], interval] management ----
1417
1507
  // Install a kernel-side ScheduledTask that calls Dialog[] periodically.
1418
1508
  // Only install when:
@@ -2348,8 +2438,8 @@ public:
2348
2438
 
2349
2439
  // -----------------------------------------------------------------------
2350
2440
  // setDynAutoMode(auto) → void
2351
- // true = C++-internal inline dialog path (default)
2352
- // false = legacy JS dialogEval/exitDialog path (for debugger)
2441
+ // true = C++-internal inline dialog path
2442
+ // false = legacy JS dialogEval/exitDialog path (default; for debugger)
2353
2443
  // -----------------------------------------------------------------------
2354
2444
  Napi::Value SetDynAutoMode(const Napi::CallbackInfo& info) {
2355
2445
  Napi::Env env = info.Env();
@@ -2358,7 +2448,15 @@ public:
2358
2448
  .ThrowAsJavaScriptException();
2359
2449
  return env.Undefined();
2360
2450
  }
2361
- dynAutoMode_.store(info[0].As<Napi::Boolean>().Value());
2451
+ bool newMode = info[0].As<Napi::Boolean>().Value();
2452
+ bool oldMode = dynAutoMode_.exchange(newMode);
2453
+ // When transitioning true→false (Dynamic cleanup), stop the timer
2454
+ // thread. Prevents stale ScheduledTask Dialog[] calls from reaching
2455
+ // the BEGINDLGPKT handler after cleanup. The safety fallback in
2456
+ // BEGINDLGPKT handles any Dialog[] that fires before this takes effect.
2457
+ if (oldMode && !newMode) {
2458
+ dynIntervalMs_.store(0);
2459
+ }
2362
2460
  return env.Undefined();
2363
2461
  }
2364
2462
 
@@ -3021,6 +3119,8 @@ Napi::Object InitModule(Napi::Env env, Napi::Object exports) {
3021
3119
  WstpReader::Init(env, exports);
3022
3120
  exports.Set("setDiagHandler",
3023
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"));
3024
3124
  return exports;
3025
3125
  }
3026
3126
 
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
 
@@ -728,14 +731,14 @@ async function main() {
728
731
  // 2500ms because Pause[] ignores WSInterruptMessage during a sleep.
729
732
  // This test documents the fundamental limitation: Dynamic cannot read a
730
733
  // live variable while Pause[N] is running.
731
- await run('P1: Pause[8] ignores interrupt within 2500ms', async () => {
734
+ await run('P1: Pause[4] ignores interrupt within 1500ms', async () => {
732
735
  const s = mkSession();
733
736
  try {
734
737
  await installHandler(s);
735
738
 
736
739
  let evalDone = false;
737
740
  const mainProm = s.evaluate(
738
- 'pP1 = 0; Pause[8]; pP1 = 1; "p1-done"'
741
+ 'pP1 = 0; Pause[4]; pP1 = 1; "p1-done"'
739
742
  ).then(() => { evalDone = true; });
740
743
 
741
744
  await sleep(300);
@@ -744,12 +747,12 @@ async function main() {
744
747
  assert(sent === true, 'interrupt() should return true mid-eval');
745
748
 
746
749
  const t0 = Date.now();
747
- while (!s.isDialogOpen && Date.now() - t0 < 2500) await sleep(25);
750
+ while (!s.isDialogOpen && Date.now() - t0 < 1500) await sleep(25);
748
751
 
749
752
  const dlgOpened = s.isDialogOpen;
750
- assert(!dlgOpened, 'Pause[8] should NOT open a dialog within 2500ms');
753
+ assert(!dlgOpened, 'Pause[4] should NOT open a dialog within 1500ms');
751
754
 
752
- // 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
753
756
  // will fire Dialog[] once Pause[] releases. Abort to unstick the eval
754
757
  // rather than waiting for it to return on its own (which could take forever
755
758
  // if Dialog[] opens and nobody services it).
@@ -758,7 +761,7 @@ async function main() {
758
761
  } finally {
759
762
  s.close();
760
763
  }
761
- }, 20_000);
764
+ }, 12_000);
762
765
 
763
766
  // ── P2: Pause[0.3] loop — interrupt opens Dialog and dialogEval works ──
764
767
  // Expected: interrupt during a short Pause[] loop opens a Dialog[],
@@ -770,7 +773,7 @@ async function main() {
770
773
 
771
774
  let evalDone = false;
772
775
  const mainProm = s.evaluate(
773
- 'Do[nP2 = k; Pause[0.3], {k, 1, 30}]; "p2-done"',
776
+ 'Do[nP2 = k; Pause[0.1], {k, 1, 15}]; "p2-done"',
774
777
  { onDialogBegin: () => {}, onDialogEnd: () => {} }
775
778
  ).then(() => { evalDone = true; });
776
779
 
@@ -778,19 +781,19 @@ async function main() {
778
781
 
779
782
  s.interrupt();
780
783
  try { await pollUntil(() => s.isDialogOpen, 3000); }
781
- 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]'); }
782
785
 
783
786
  const val = await withTimeout(s.dialogEval('nP2'), 5000, 'dialogEval nP2');
784
787
  assert(val && typeof val.value === 'number' && val.value >= 1,
785
788
  `expected nP2 >= 1, got ${JSON.stringify(val)}`);
786
789
 
787
790
  await s.exitDialog();
788
- await withTimeout(mainProm, 15_000, 'P2 main eval');
791
+ await withTimeout(mainProm, 8_000, 'P2 main eval');
789
792
  } finally {
790
793
  try { s.abort(); } catch (_) {}
791
794
  s.close();
792
795
  }
793
- }, 30_000);
796
+ }, 15_000);
794
797
 
795
798
  // ── P3: dialogEval timeout — kernel state after (diagnostic) ───────────
796
799
  // Simulates the extension failure: dialogEval times out without exitDialog.
@@ -803,7 +806,7 @@ async function main() {
803
806
 
804
807
  let evalDone = false;
805
808
  const mainProm = s.evaluate(
806
- 'Do[nP3 = k; Pause[0.3], {k, 1, 200}]; "p3-done"',
809
+ 'Do[nP3 = k; Pause[0.1], {k, 1, 15}]; "p3-done"',
807
810
  { onDialogBegin: () => {}, onDialogEnd: () => {} }
808
811
  ).then(() => { evalDone = true; });
809
812
 
@@ -838,7 +841,7 @@ async function main() {
838
841
  await s.exitDialog().catch(() => {});
839
842
  }
840
843
 
841
- if (!evalDone) { try { await withTimeout(mainProm, 20_000, 'P3 loop'); } catch (_) {} }
844
+ if (!evalDone) { try { await withTimeout(mainProm, 6_000, 'P3 loop'); } catch (_) {} }
842
845
 
843
846
  // Diagnostic: document observed outcome but do not hard-fail on dlg2
844
847
  if (!exitOk) {
@@ -850,7 +853,7 @@ async function main() {
850
853
  try { s.abort(); } catch (_) {}
851
854
  s.close();
852
855
  }
853
- }, 45_000);
856
+ }, 20_000);
854
857
 
855
858
  // ── P4: abort() after stuck dialog — session stays alive ────────────────
856
859
  // Note: abort() sends WSAbortMessage which resets the Wolfram kernel's
@@ -864,7 +867,7 @@ async function main() {
864
867
  await installHandler(s);
865
868
 
866
869
  const mainProm = s.evaluate(
867
- 'Do[nP4=k; Pause[0.3], {k,1,200}]; "p4-done"',
870
+ 'Do[nP4=k; Pause[0.1], {k,1,15}]; "p4-done"',
868
871
  { onDialogBegin: () => {}, onDialogEnd: () => {} }
869
872
  ).catch(() => {});
870
873
 
@@ -896,7 +899,7 @@ async function main() {
896
899
  try { s.abort(); } catch (_) {}
897
900
  s.close();
898
901
  }
899
- }, 30_000);
902
+ }, 15_000);
900
903
 
901
904
  // ── P5: closeAllDialogs()+abort() recovery → reinstall handler → new dialog works
902
905
  // closeAllDialogs() is designed to be paired with abort(). It rejects all
@@ -909,7 +912,7 @@ async function main() {
909
912
  await installHandler(s);
910
913
 
911
914
  const mainProm = s.evaluate(
912
- 'Do[nP5 = k; Pause[0.3], {k, 1, 200}]; "p5-done"',
915
+ 'Do[nP5 = k; Pause[0.1], {k, 1, 15}]; "p5-done"',
913
916
  { onDialogBegin: () => {}, onDialogEnd: () => {} }
914
917
  ).catch(() => {});
915
918
 
@@ -935,7 +938,7 @@ async function main() {
935
938
 
936
939
  let dlg2 = false;
937
940
  const mainProm2 = s.evaluate(
938
- 'Do[nP5b = k; Pause[0.3], {k, 1, 200}]; "p5b-done"',
941
+ 'Do[nP5b = k; Pause[0.1], {k, 1, 15}]; "p5b-done"',
939
942
  { onDialogBegin: () => {}, onDialogEnd: () => {} }
940
943
  ).catch(() => {});
941
944
 
@@ -957,7 +960,7 @@ async function main() {
957
960
  try { s.abort(); } catch (_) {}
958
961
  s.close();
959
962
  }
960
- }, 60_000);
963
+ }, 25_000);
961
964
 
962
965
  // ── P6: Simulate Dynamic + Pause[5] full scenario (diagnostic) ─────────
963
966
  // Reproduces: n=RandomInteger[100]; Pause[5] with interrupt every 2.5s.
@@ -970,18 +973,18 @@ async function main() {
970
973
 
971
974
  let evalDone = false;
972
975
  const mainProm = s.evaluate(
973
- 'n = RandomInteger[100]; Pause[5]; "p6-done"',
976
+ 'n = RandomInteger[100]; Pause[3]; "p6-done"',
974
977
  { onDialogBegin: () => {}, onDialogEnd: () => {} }
975
978
  ).then(() => { evalDone = true; });
976
979
 
977
980
  await sleep(200);
978
981
 
979
- const INTERRUPT_WAIT_MS = 2500;
982
+ const INTERRUPT_WAIT_MS = 1500;
980
983
  const DIALOG_EVAL_TIMEOUT_MS = 8000;
981
984
  let dialogReadSucceeded = false;
982
985
  let pauseIgnoredInterrupt = false;
983
986
 
984
- for (let cycle = 1; cycle <= 5 && !evalDone; cycle++) {
987
+ for (let cycle = 1; cycle <= 2 && !evalDone; cycle++) {
985
988
  const t0 = Date.now();
986
989
  s.interrupt();
987
990
  while (!s.isDialogOpen && Date.now() - t0 < INTERRUPT_WAIT_MS) await sleep(25);
@@ -1009,11 +1012,11 @@ async function main() {
1009
1012
  }
1010
1013
  }
1011
1014
 
1012
- try { await withTimeout(mainProm, 12_000, 'P6 main eval'); } catch (_) {}
1015
+ try { await withTimeout(mainProm, 7_000, 'P6 main eval'); } catch (_) {}
1013
1016
 
1014
1017
  // Diagnostic: always passes — log observed outcome
1015
1018
  if (pauseIgnoredInterrupt && !dialogReadSucceeded) {
1016
- console.log(' P6 note: Pause[5] blocked all interrupts — expected behaviour');
1019
+ console.log(' P6 note: Pause[3] blocked all interrupts — expected behaviour');
1017
1020
  } else if (dialogReadSucceeded) {
1018
1021
  console.log(' P6 note: at least one read succeeded despite Pause');
1019
1022
  }
@@ -1021,7 +1024,7 @@ async function main() {
1021
1024
  try { s.abort(); } catch (_) {}
1022
1025
  s.close();
1023
1026
  }
1024
- }, 60_000);
1027
+ }, 20_000);
1025
1028
 
1026
1029
  // ── 25. abort() while dialog is open ──────────────────────────────────
1027
1030
  // Must run AFTER all other dialog tests — abort() sends WSAbortMessage
@@ -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,159 @@ 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
+
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
+
1477
1633
  // ── Teardown ──────────────────────────────────────────────────────────
1478
1634
  session.close();
1479
1635
  assert(!session.isOpen, 'main session not closed');
@@ -1487,6 +1643,7 @@ async function main() {
1487
1643
  process.exit(0);
1488
1644
  } else {
1489
1645
  console.log(`${passed} passed, ${failed} failed${skipped ? `, ${skipped} skipped` : ''}.`);
1646
+ process.exit(1);
1490
1647
  }
1491
1648
  }
1492
1649