wstp-node 0.6.3 → 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/package.json +1 -1
- package/src/addon.cc +95 -16
- package/test.js +60 -18
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.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.
|
|
@@ -992,6 +997,12 @@ static EvalResult DrainToEvalResult(WSLINK lp, EvalOptions* opts = nullptr) {
|
|
|
992
997
|
WSFlush(lp);
|
|
993
998
|
DiagLog("[Eval] rejectDialog: sent Return[$Failed], draining until ENDDLGPKT");
|
|
994
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;
|
|
995
1006
|
{
|
|
996
1007
|
auto dlgDeadline = std::chrono::steady_clock::now() +
|
|
997
1008
|
std::chrono::milliseconds(2000);
|
|
@@ -1002,11 +1013,25 @@ static EvalResult DrainToEvalResult(WSLINK lp, EvalOptions* opts = nullptr) {
|
|
|
1002
1013
|
}
|
|
1003
1014
|
int rp = WSNextPacket(lp);
|
|
1004
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
|
+
}
|
|
1005
1021
|
WSNewPacket(lp);
|
|
1006
1022
|
if (rp == ENDDLGPKT) break;
|
|
1007
1023
|
if (rp == 0 || rp == ILLEGALPKT) { WSClearError(lp); break; }
|
|
1008
1024
|
}
|
|
1009
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
|
+
}
|
|
1010
1035
|
// Continue outer drain loop — the original RETURNPKT is still coming.
|
|
1011
1036
|
continue;
|
|
1012
1037
|
}
|
|
@@ -1201,6 +1226,8 @@ static EvalResult DrainToEvalResult(WSLINK lp, EvalOptions* opts = nullptr) {
|
|
|
1201
1226
|
WSEndPacket(lp);
|
|
1202
1227
|
WSFlush(lp);
|
|
1203
1228
|
DiagLog("[Eval] BEGINDLGPKT safety: sent Return[$Failed], draining until ENDDLGPKT");
|
|
1229
|
+
WExpr safetyCaptured;
|
|
1230
|
+
bool safetyGotOuter = false;
|
|
1204
1231
|
{
|
|
1205
1232
|
auto dlgDeadline = std::chrono::steady_clock::now() +
|
|
1206
1233
|
std::chrono::milliseconds(2000);
|
|
@@ -1211,11 +1238,25 @@ static EvalResult DrainToEvalResult(WSLINK lp, EvalOptions* opts = nullptr) {
|
|
|
1211
1238
|
}
|
|
1212
1239
|
int rp = WSNextPacket(lp);
|
|
1213
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
|
+
}
|
|
1214
1246
|
WSNewPacket(lp);
|
|
1215
1247
|
if (rp == ENDDLGPKT) break;
|
|
1216
1248
|
if (rp == 0 || rp == ILLEGALPKT) { WSClearError(lp); break; }
|
|
1217
1249
|
}
|
|
1218
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
|
+
}
|
|
1219
1260
|
// Continue outer drain loop — original RETURNPKT is still coming.
|
|
1220
1261
|
continue;
|
|
1221
1262
|
}
|
|
@@ -1558,12 +1599,33 @@ public:
|
|
|
1558
1599
|
}
|
|
1559
1600
|
}
|
|
1560
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
|
+
|
|
1561
1623
|
bool sent;
|
|
1562
1624
|
if (!interactive_) {
|
|
1563
1625
|
// EvaluatePacket + ToExpression: non-interactive, does NOT populate In[n]/Out[n].
|
|
1564
1626
|
sent = WSPutFunction(lp_, "EvaluatePacket", 1) &&
|
|
1565
1627
|
WSPutFunction(lp_, "ToExpression", 1) &&
|
|
1566
|
-
WSPutUTF8String(lp_, (const unsigned char *)
|
|
1628
|
+
WSPutUTF8String(lp_, (const unsigned char *)sendExpr.c_str(), (int)sendExpr.size()) &&
|
|
1567
1629
|
WSEndPacket (lp_) &&
|
|
1568
1630
|
WSFlush (lp_);
|
|
1569
1631
|
} else {
|
|
@@ -1572,7 +1634,7 @@ public:
|
|
|
1572
1634
|
// as the real Mathematica frontend does. Responds with RETURNEXPRPKT.
|
|
1573
1635
|
sent = WSPutFunction(lp_, "EnterExpressionPacket", 1) &&
|
|
1574
1636
|
WSPutFunction(lp_, "ToExpression", 1) &&
|
|
1575
|
-
WSPutUTF8String(lp_, (const unsigned char *)
|
|
1637
|
+
WSPutUTF8String(lp_, (const unsigned char *)sendExpr.c_str(), (int)sendExpr.size()) &&
|
|
1576
1638
|
WSEndPacket (lp_) &&
|
|
1577
1639
|
WSFlush (lp_);
|
|
1578
1640
|
}
|
|
@@ -2681,11 +2743,28 @@ private:
|
|
|
2681
2743
|
FlushDialogQueueWithError("session closed");
|
|
2682
2744
|
dialogOpen_.store(false);
|
|
2683
2745
|
WSPutMessage(lp_, WSAbortMessage);
|
|
2684
|
-
|
|
2685
|
-
|
|
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);
|
|
2686
2749
|
while (workerReadingLink_.load(std::memory_order_acquire) &&
|
|
2687
2750
|
std::chrono::steady_clock::now() < deadline)
|
|
2688
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
|
+
}
|
|
2689
2768
|
}
|
|
2690
2769
|
if (lp_) { WSClose(lp_); lp_ = nullptr; }
|
|
2691
2770
|
if (wsEnv_) { WSDeinitialize(wsEnv_); wsEnv_ = nullptr; }
|
|
@@ -3120,7 +3199,7 @@ Napi::Object InitModule(Napi::Env env, Napi::Object exports) {
|
|
|
3120
3199
|
exports.Set("setDiagHandler",
|
|
3121
3200
|
Napi::Function::New(env, SetDiagHandler, "setDiagHandler"));
|
|
3122
3201
|
// version — mirrors package.json "version"; read-only string constant.
|
|
3123
|
-
exports.Set("version", Napi::String::New(env, "0.6.
|
|
3202
|
+
exports.Set("version", Napi::String::New(env, "0.6.6"));
|
|
3124
3203
|
return exports;
|
|
3125
3204
|
}
|
|
3126
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
|
|
|
@@ -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',
|
|
@@ -1631,9 +1669,13 @@ async function main() {
|
|
|
1631
1669
|
}, 30000);
|
|
1632
1670
|
|
|
1633
1671
|
// ── Teardown ──────────────────────────────────────────────────────────
|
|
1672
|
+
_mainSession = null;
|
|
1634
1673
|
session.close();
|
|
1635
1674
|
assert(!session.isOpen, 'main session not closed');
|
|
1636
1675
|
|
|
1676
|
+
// Kill the watchdog process — suite completed in time.
|
|
1677
|
+
try { _watchdogProc.kill('SIGKILL'); } catch (_) {}
|
|
1678
|
+
|
|
1637
1679
|
console.log();
|
|
1638
1680
|
if (failed === 0) {
|
|
1639
1681
|
console.log(`All ${passed} tests passed${skipped ? ` (${skipped} skipped)` : ''}.`);
|