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.
- package/build/Release/wstp.node +0 -0
- package/index.d.ts +9 -0
- package/package.json +1 -1
- package/src/addon.cc +142 -25
- package/test.js +173 -42
package/build/Release/wstp.node
CHANGED
|
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.
|
|
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
|
-
//
|
|
310
|
-
//
|
|
311
|
-
//
|
|
312
|
-
//
|
|
313
|
-
|
|
314
|
-
//
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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(
|
|
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
|
-
|
|
886
|
-
// In interactive mode the kernel
|
|
887
|
-
// trailing INPUTNAMEPKT (
|
|
888
|
-
//
|
|
889
|
-
//
|
|
890
|
-
|
|
891
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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 *)
|
|
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 *)
|
|
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
|
-
|
|
2649
|
-
|
|
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
|
-
|
|
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.
|
|
75
|
-
//
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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[
|
|
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[
|
|
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 <
|
|
786
|
+
while (!s.isDialogOpen && Date.now() - t0 < 1500) await sleep(25);
|
|
751
787
|
|
|
752
788
|
const dlgOpened = s.isDialogOpen;
|
|
753
|
-
assert(!dlgOpened, 'Pause[
|
|
789
|
+
assert(!dlgOpened, 'Pause[4] should NOT open a dialog within 1500ms');
|
|
754
790
|
|
|
755
|
-
// After the
|
|
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
|
-
},
|
|
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.
|
|
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.
|
|
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,
|
|
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
|
-
},
|
|
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.
|
|
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,
|
|
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
|
-
},
|
|
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.
|
|
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
|
-
},
|
|
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.
|
|
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.
|
|
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
|
-
},
|
|
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[
|
|
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 =
|
|
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 <=
|
|
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,
|
|
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[
|
|
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
|
-
},
|
|
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(
|
|
1411
|
-
for (let i = 0; i <
|
|
1446
|
+
s.setDynamicInterval(200);
|
|
1447
|
+
for (let i = 0; i < 5; i++) {
|
|
1412
1448
|
const r = await withTimeout(
|
|
1413
|
-
s.evaluate(`Pause[0.
|
|
1414
|
-
|
|
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
|
|