wstp-node 0.6.0 → 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 +1 -1
- package/build/Release/wstp.node +0 -0
- package/package.json +1 -1
- package/src/addon.cc +68 -4
- package/test.js +82 -15
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
|
|
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
|
|
package/build/Release/wstp.node
CHANGED
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wstp-node",
|
|
3
|
-
"version": "0.6.
|
|
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)
|
|
@@ -2337,6 +2391,8 @@ public:
|
|
|
2337
2391
|
int ms = static_cast<int>(info[0].As<Napi::Number>().Int32Value());
|
|
2338
2392
|
if (ms < 0) ms = 0;
|
|
2339
2393
|
int prev = dynIntervalMs_.exchange(ms);
|
|
2394
|
+
// Auto-enable dynAutoMode when interval > 0, auto-disable when 0.
|
|
2395
|
+
dynAutoMode_.store(ms > 0);
|
|
2340
2396
|
// Start timer thread if transitioning from 0 to non-zero.
|
|
2341
2397
|
if (prev == 0 && ms > 0 && !dynTimerRunning_.load()) {
|
|
2342
2398
|
StartDynTimer();
|
|
@@ -2346,8 +2402,8 @@ public:
|
|
|
2346
2402
|
|
|
2347
2403
|
// -----------------------------------------------------------------------
|
|
2348
2404
|
// setDynAutoMode(auto) → void
|
|
2349
|
-
// true = C++-internal inline dialog path
|
|
2350
|
-
// 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)
|
|
2351
2407
|
// -----------------------------------------------------------------------
|
|
2352
2408
|
Napi::Value SetDynAutoMode(const Napi::CallbackInfo& info) {
|
|
2353
2409
|
Napi::Env env = info.Env();
|
|
@@ -2356,7 +2412,15 @@ public:
|
|
|
2356
2412
|
.ThrowAsJavaScriptException();
|
|
2357
2413
|
return env.Undefined();
|
|
2358
2414
|
}
|
|
2359
|
-
|
|
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
|
+
}
|
|
2360
2424
|
return env.Undefined();
|
|
2361
2425
|
}
|
|
2362
2426
|
|
|
@@ -2717,7 +2781,7 @@ private:
|
|
|
2717
2781
|
std::vector<DynRegistration> dynRegistry_; // registered exprs
|
|
2718
2782
|
std::vector<DynResult> dynResults_; // accumulated results (swapped on getDynamicResults)
|
|
2719
2783
|
std::atomic<int> dynIntervalMs_{0}; // 0 = disabled
|
|
2720
|
-
std::atomic<bool> dynAutoMode_{
|
|
2784
|
+
std::atomic<bool> dynAutoMode_{false}; // true = inline C++ path; false = legacy JS path
|
|
2721
2785
|
int dynTaskInstalledInterval_{0}; // interval of currently installed ScheduledTask (0 = none)
|
|
2722
2786
|
std::chrono::steady_clock::time_point dynLastEval_{}; // time of last successful dialog eval
|
|
2723
2787
|
std::thread dynTimerThread_;
|
package/test.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
// ── Test suite for wstp-backend v0.6.
|
|
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()
|
|
512
|
-
//
|
|
513
|
-
//
|
|
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
|
|
|
@@ -735,8 +738,7 @@ async function main() {
|
|
|
735
738
|
|
|
736
739
|
let evalDone = false;
|
|
737
740
|
const mainProm = s.evaluate(
|
|
738
|
-
'pP1 = 0; Pause[8]; pP1 = 1; "p1-done"'
|
|
739
|
-
{ onDialogBegin: () => {}, onDialogEnd: () => {} }
|
|
741
|
+
'pP1 = 0; Pause[8]; pP1 = 1; "p1-done"'
|
|
740
742
|
).then(() => { evalDone = true; });
|
|
741
743
|
|
|
742
744
|
await sleep(300);
|
|
@@ -1030,7 +1032,7 @@ async function main() {
|
|
|
1030
1032
|
// evaluations. Tests 26 and 27 need a clean session, so test 25 runs last
|
|
1031
1033
|
// (just before test 18 which also corrupts the link via WSInterruptMessage).
|
|
1032
1034
|
await run('25. abort while dialog is open', async () => {
|
|
1033
|
-
const p = session.evaluate('Dialog[]');
|
|
1035
|
+
const p = session.evaluate('Dialog[]', { onDialogBegin: () => {} });
|
|
1034
1036
|
await pollUntil(() => session.isDialogOpen);
|
|
1035
1037
|
session.abort();
|
|
1036
1038
|
const r = await p;
|
|
@@ -1475,6 +1477,71 @@ async function main() {
|
|
|
1475
1477
|
}
|
|
1476
1478
|
});
|
|
1477
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
|
+
|
|
1478
1545
|
// ── Teardown ──────────────────────────────────────────────────────────
|
|
1479
1546
|
session.close();
|
|
1480
1547
|
assert(!session.isOpen, 'main session not closed');
|