wstp-node 0.6.6 → 0.7.0
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 +49 -32
- package/binding.gyp +9 -1
- package/build/Release/wstp.node +0 -0
- package/build.sh +33 -20
- package/package.json +1 -1
- package/src/addon.cc +19 -3171
- package/test.js +0 -1695
- package/test_interrupt_dialog.js +0 -220
package/test.js
DELETED
|
@@ -1,1695 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
// ── Test suite for wstp-backend v0.6.2 ────────────────────────────────────
|
|
4
|
-
// Covers: evaluation queue, streaming callbacks, sub() priority, abort
|
|
5
|
-
// behaviour, WstpReader side-channel, edge cases, Dialog[] subsession
|
|
6
|
-
// (dialogEval, exitDialog, isDialogOpen, onDialogBegin/End/Print),
|
|
7
|
-
// subWhenIdle() (background queue, timeout, close rejection), kernelPid,
|
|
8
|
-
// Dynamic eval API (registerDynamic, getDynamicResults, setDynamicInterval,
|
|
9
|
-
// setDynAutoMode, dynamicActive, rejectDialog, abort deduplication).
|
|
10
|
-
// 0.6.2: BEGINDLGPKT safety fallback (Bug 1A), setDynAutoMode cleanup (Bug 1B).
|
|
11
|
-
|
|
12
|
-
const { WstpSession, WstpReader, setDiagHandler } = require('./build/Release/wstp.node');
|
|
13
|
-
|
|
14
|
-
const KERNEL_PATH = '/Applications/Wolfram 3.app/Contents/MacOS/WolframKernel';
|
|
15
|
-
|
|
16
|
-
// Enable C++ diagnostic channel — writes timestamped messages to stderr.
|
|
17
|
-
// Suppress with: node test.js 2>/dev/null
|
|
18
|
-
setDiagHandler((msg) => {
|
|
19
|
-
const ts = new Date().toISOString().slice(11, 23); // HH:MM:SS.mmm
|
|
20
|
-
process.stderr.write(`[diag ${ts}] ${msg}\n`);
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
24
|
-
|
|
25
|
-
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
|
|
26
|
-
|
|
27
|
-
function assert(cond, msg) {
|
|
28
|
-
if (!cond) throw new Error(msg || 'assertion failed');
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
// pollUntil — re-checks condition every intervalMs until true or timeout.
|
|
32
|
-
function pollUntil(condition, timeoutMs = 3000, intervalMs = 50) {
|
|
33
|
-
return new Promise((resolve, reject) => {
|
|
34
|
-
const start = Date.now();
|
|
35
|
-
const id = setInterval(() => {
|
|
36
|
-
if (condition()) { clearInterval(id); resolve(); }
|
|
37
|
-
else if (Date.now() - start > timeoutMs) {
|
|
38
|
-
clearInterval(id);
|
|
39
|
-
reject(new Error('pollUntil timed out'));
|
|
40
|
-
}
|
|
41
|
-
}, intervalMs);
|
|
42
|
-
});
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
// withTimeout — race a promise against a named deadline.
|
|
46
|
-
function withTimeout(p, ms, label) {
|
|
47
|
-
return Promise.race([
|
|
48
|
-
p,
|
|
49
|
-
new Promise((_, rej) =>
|
|
50
|
-
setTimeout(() => rej(new Error(`TIMEOUT(${ms}ms): ${label}`)), ms)),
|
|
51
|
-
]);
|
|
52
|
-
}
|
|
53
|
-
|
|
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;
|
|
59
|
-
function mkSession() {
|
|
60
|
-
const s = new WstpSession(KERNEL_PATH);
|
|
61
|
-
_lastMkSession = s;
|
|
62
|
-
return s;
|
|
63
|
-
}
|
|
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
|
-
|
|
69
|
-
// installHandler — install Interrupt→Dialog[] handler on a session.
|
|
70
|
-
// Must be done via evaluate() (EnterExpressionPacket context) so the handler
|
|
71
|
-
// fires on WSInterruptMessage; EvaluatePacket context does not receive it.
|
|
72
|
-
async function installHandler(s) {
|
|
73
|
-
await s.evaluate(
|
|
74
|
-
'Quiet[Internal`AddHandler["Interrupt", Function[{}, Dialog[]]]]',
|
|
75
|
-
{ onDialogBegin: () => {}, onDialogEnd: () => {} }
|
|
76
|
-
);
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
// Per-test timeout (ms). Any test that does not complete within this window
|
|
80
|
-
// is failed immediately with a "TIMED OUT" error. Prevents indefinite hangs.
|
|
81
|
-
const TEST_TIMEOUT_MS = 30_000;
|
|
82
|
-
|
|
83
|
-
// Hard suite-level watchdog: if the entire suite takes longer than this the
|
|
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();
|
|
94
|
-
|
|
95
|
-
// ── Test filtering ─────────────────────────────────────────────────────────
|
|
96
|
-
// Usage: node test.js --only 38,39,40 or --only 38-52
|
|
97
|
-
// Omit flag to run all tests.
|
|
98
|
-
const ONLY_TESTS = (() => {
|
|
99
|
-
const idx = process.argv.indexOf('--only');
|
|
100
|
-
if (idx === -1) return null;
|
|
101
|
-
const spec = process.argv[idx + 1] || '';
|
|
102
|
-
const nums = new Set();
|
|
103
|
-
for (const part of spec.split(',')) {
|
|
104
|
-
const range = part.split('-');
|
|
105
|
-
if (range.length === 2) {
|
|
106
|
-
for (let i = parseInt(range[0]); i <= parseInt(range[1]); i++) nums.add(i);
|
|
107
|
-
} else {
|
|
108
|
-
nums.add(parseInt(part));
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
return nums;
|
|
112
|
-
})();
|
|
113
|
-
|
|
114
|
-
// Extract leading integer from a test name like "38. foo bar" → 38.
|
|
115
|
-
function testNum(name) {
|
|
116
|
-
const m = name.match(/^(\d+)/);
|
|
117
|
-
return m ? parseInt(m[1]) : NaN;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
let passed = 0;
|
|
121
|
-
let failed = 0;
|
|
122
|
-
let skipped = 0;
|
|
123
|
-
|
|
124
|
-
async function run(name, fn, timeoutMs = TEST_TIMEOUT_MS) {
|
|
125
|
-
if (ONLY_TESTS !== null && !ONLY_TESTS.has(testNum(name))) {
|
|
126
|
-
skipped++;
|
|
127
|
-
return;
|
|
128
|
-
}
|
|
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
|
-
});
|
|
135
|
-
try {
|
|
136
|
-
await Promise.race([fn(), timeout]);
|
|
137
|
-
console.log(` ✓ ${name}`);
|
|
138
|
-
passed++;
|
|
139
|
-
} catch (e) {
|
|
140
|
-
console.error(` ✗ ${name}: ${e.message}`);
|
|
141
|
-
failed++;
|
|
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);
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
// ── Main ───────────────────────────────────────────────────────────────────
|
|
168
|
-
|
|
169
|
-
async function main() {
|
|
170
|
-
console.log('Opening session…');
|
|
171
|
-
const session = new WstpSession(KERNEL_PATH);
|
|
172
|
-
_mainSession = session;
|
|
173
|
-
assert(session.isOpen, 'session did not open');
|
|
174
|
-
console.log('Session open.\n');
|
|
175
|
-
|
|
176
|
-
// ── 1. Basic queue serialisation ──────────────────────────────────────
|
|
177
|
-
await run('1. queue serialisation', async () => {
|
|
178
|
-
const [r1, r2, r3] = await Promise.all([
|
|
179
|
-
session.evaluate('1'),
|
|
180
|
-
session.evaluate('2'),
|
|
181
|
-
session.evaluate('3'),
|
|
182
|
-
]);
|
|
183
|
-
|
|
184
|
-
assert(r1.result.value === 1,
|
|
185
|
-
`r1 wrong: ${JSON.stringify(r1.result)}`);
|
|
186
|
-
assert(r2.result.value === 2,
|
|
187
|
-
`r2 wrong: ${JSON.stringify(r2.result)}`);
|
|
188
|
-
assert(r3.result.value === 3,
|
|
189
|
-
`r3 wrong: ${JSON.stringify(r3.result)}`);
|
|
190
|
-
|
|
191
|
-
// Note: this WolframKernel only sends INPUTNAMEPKT (In[n]:=) once
|
|
192
|
-
// for the very first evaluation in a session. All subsequent evals
|
|
193
|
-
// return cellIndex=0. We verify the results are correct instead.
|
|
194
|
-
assert(r1.cellIndex >= 0, 'r1 cellIndex is a non-negative integer');
|
|
195
|
-
assert(r2.cellIndex >= 0, 'r2 cellIndex is a non-negative integer');
|
|
196
|
-
assert(r3.cellIndex >= 0, 'r3 cellIndex is a non-negative integer');
|
|
197
|
-
});
|
|
198
|
-
|
|
199
|
-
// ── 2. sub() priority over queued evaluate() ──────────────────────────
|
|
200
|
-
await run('2. sub() priority over queued evaluate()', async () => {
|
|
201
|
-
// Flood the queue with slow evaluations.
|
|
202
|
-
// p1 goes in-flight immediately; p2 and p3 are queued behind it.
|
|
203
|
-
const p1 = session.evaluate('Pause[0.5]; "first"');
|
|
204
|
-
const p2 = session.evaluate('Pause[0.5]; "second"');
|
|
205
|
-
const p3 = session.evaluate('Pause[0.5]; "third"');
|
|
206
|
-
|
|
207
|
-
// sub() is queued after p1 has already started — it must run before
|
|
208
|
-
// p2 and p3 (i.e. finish in ~500 ms, not ~1500 ms).
|
|
209
|
-
const subStart = Date.now();
|
|
210
|
-
const sv = await session.sub('42');
|
|
211
|
-
const subElapsed = Date.now() - subStart;
|
|
212
|
-
|
|
213
|
-
assert(
|
|
214
|
-
sv.type === 'integer' && sv.value === 42,
|
|
215
|
-
`sub result: ${JSON.stringify(sv)}`,
|
|
216
|
-
);
|
|
217
|
-
// p1 takes ~500 ms; sub itself is trivial — so total ≪ 1000 ms.
|
|
218
|
-
// We allow 1200 ms as a generous upper bound.
|
|
219
|
-
assert(subElapsed < 1200,
|
|
220
|
-
`sub elapsed ${subElapsed} ms — sub did not run with priority`);
|
|
221
|
-
|
|
222
|
-
await Promise.all([p1, p2, p3]);
|
|
223
|
-
});
|
|
224
|
-
|
|
225
|
-
// ── 3. Multiple sub() calls queue FIFO among themselves ───────────────
|
|
226
|
-
await run('3. sub() FIFO ordering', async () => {
|
|
227
|
-
// Keep session busy so subs queue up rather than start immediately.
|
|
228
|
-
const bg = session.evaluate('Pause[1]; "bg"');
|
|
229
|
-
|
|
230
|
-
const [sa, sb, sc] = await Promise.all([
|
|
231
|
-
session.sub('1'),
|
|
232
|
-
session.sub('2'),
|
|
233
|
-
session.sub('3'),
|
|
234
|
-
]);
|
|
235
|
-
|
|
236
|
-
assert(sa.value === 1, `sa: ${JSON.stringify(sa)}`);
|
|
237
|
-
assert(sb.value === 2, `sb: ${JSON.stringify(sb)}`);
|
|
238
|
-
assert(sc.value === 3, `sc: ${JSON.stringify(sc)}`);
|
|
239
|
-
|
|
240
|
-
await bg;
|
|
241
|
-
});
|
|
242
|
-
|
|
243
|
-
// ── 4. Streaming onPrint fires before Promise resolves ────────────────
|
|
244
|
-
await run('4. streaming onPrint timing', async () => {
|
|
245
|
-
const lines = [];
|
|
246
|
-
const timestamps = [];
|
|
247
|
-
const evalStart = Date.now();
|
|
248
|
-
|
|
249
|
-
// Latch: the Promise should not resolve until all callbacks have fired
|
|
250
|
-
// (CompleteCtx guarantees this), but we add an explicit wait as a safety
|
|
251
|
-
// net in case TSFNs are still queued when the promise settles.
|
|
252
|
-
let deliveryResolve;
|
|
253
|
-
const allDelivered = new Promise(r => { deliveryResolve = r; });
|
|
254
|
-
let deliveredCount = 0;
|
|
255
|
-
|
|
256
|
-
const r = await session.evaluate(
|
|
257
|
-
'Do[Print["line-" <> ToString[ii$$]]; Pause[0.2], {ii$$, 4}]',
|
|
258
|
-
{
|
|
259
|
-
onPrint: (line) => {
|
|
260
|
-
lines.push(line);
|
|
261
|
-
timestamps.push(Date.now() - evalStart);
|
|
262
|
-
if (++deliveredCount === 4) deliveryResolve();
|
|
263
|
-
},
|
|
264
|
-
},
|
|
265
|
-
);
|
|
266
|
-
|
|
267
|
-
// Wait up to 5 s for all 4 callbacks to actually fire.
|
|
268
|
-
await Promise.race([allDelivered, sleep(5000)]);
|
|
269
|
-
|
|
270
|
-
assert(lines.length === 4,
|
|
271
|
-
`expected 4 streamed lines, got ${lines.length} (r.print=${JSON.stringify(r.print)}, result=${JSON.stringify(r.result)}, msgs=${JSON.stringify(r.messages)})`);
|
|
272
|
-
assert(r.print.length === 4,
|
|
273
|
-
`expected 4 in result.print, got ${r.print.length}`);
|
|
274
|
-
assert(lines[0] === r.print[0],
|
|
275
|
-
`streaming[0]="${lines[0]}" vs batch[0]="${r.print[0]}"`);
|
|
276
|
-
|
|
277
|
-
// Lines arrive ~200 ms apart — confirm inter-arrival gap is plausible.
|
|
278
|
-
const gap = timestamps[1] - timestamps[0];
|
|
279
|
-
assert(gap > 50 && gap < 700,
|
|
280
|
-
`inter-line gap ${gap} ms — expected 50–700 ms`);
|
|
281
|
-
});
|
|
282
|
-
|
|
283
|
-
// ── 5. onMessage fires for kernel warnings ────────────────────────────
|
|
284
|
-
await run('5. streaming onMessage', async () => {
|
|
285
|
-
const msgs = [];
|
|
286
|
-
let msgResolve;
|
|
287
|
-
const msgDelivered = new Promise(r => { msgResolve = r; });
|
|
288
|
-
const r = await session.evaluate('1/0', {
|
|
289
|
-
onMessage: (m) => { msgs.push(m); msgResolve(); },
|
|
290
|
-
});
|
|
291
|
-
|
|
292
|
-
// Wait up to 5 s for the callback to actually fire.
|
|
293
|
-
await Promise.race([msgDelivered, sleep(5000)]);
|
|
294
|
-
|
|
295
|
-
assert(msgs.length > 0, `onMessage callback never fired (r.messages=${JSON.stringify(r.messages)}, r.result=${JSON.stringify(r.result)})`);
|
|
296
|
-
assert(r.messages.length > 0, `result.messages is empty (msgs=${JSON.stringify(msgs)})`);
|
|
297
|
-
assert(msgs[0] === r.messages[0],
|
|
298
|
-
`streaming[0]="${msgs[0]}" vs batch[0]="${r.messages[0]}"`);
|
|
299
|
-
assert(msgs[0].includes('::'),
|
|
300
|
-
`message missing :: separator: "${msgs[0]}"`);
|
|
301
|
-
});
|
|
302
|
-
|
|
303
|
-
// ── 6. abort() returns $Aborted, session stays alive ──────────────────
|
|
304
|
-
await run('6. abort + session survives', async () => {
|
|
305
|
-
// Do[Pause[0.1],{100}] has genuine yield points every 100ms so the
|
|
306
|
-
// kernel will react to WSAbortMessage quickly. Do[Null,{10^8}] is a
|
|
307
|
-
// tight computation loop that may not check for abort signals.
|
|
308
|
-
const p = session.evaluate('Do[Pause[0.1], {100}]');
|
|
309
|
-
await sleep(800); // give the eval time to be well-and-truly running
|
|
310
|
-
session.abort();
|
|
311
|
-
const r = await p;
|
|
312
|
-
|
|
313
|
-
assert(r.aborted === true, `aborted flag: ${r.aborted}`);
|
|
314
|
-
assert(r.result.value === '$Aborted',
|
|
315
|
-
`result: ${JSON.stringify(r.result)}`);
|
|
316
|
-
|
|
317
|
-
// session must still accept evaluations
|
|
318
|
-
const r2 = await session.evaluate('1 + 1');
|
|
319
|
-
assert(r2.result.value === 2,
|
|
320
|
-
`post-abort eval: ${JSON.stringify(r2.result)}`);
|
|
321
|
-
});
|
|
322
|
-
|
|
323
|
-
// ── 7. Abort drains queue — queued evals still run ────────────────────
|
|
324
|
-
await run('7. abort drains queue', async () => {
|
|
325
|
-
const p1 = session.evaluate('Do[Pause[0.1], {100}]'); // will be aborted
|
|
326
|
-
const p2 = session.evaluate('"after"'); // queued, must still run
|
|
327
|
-
|
|
328
|
-
await sleep(400);
|
|
329
|
-
session.abort();
|
|
330
|
-
|
|
331
|
-
const r1 = await p1;
|
|
332
|
-
assert(r1.aborted === true, `p1 not aborted: ${r1.aborted}`);
|
|
333
|
-
|
|
334
|
-
const r2 = await p2;
|
|
335
|
-
assert(
|
|
336
|
-
r2.result.type === 'string' && r2.result.value === 'after',
|
|
337
|
-
`p2 result: ${JSON.stringify(r2.result)}`,
|
|
338
|
-
);
|
|
339
|
-
});
|
|
340
|
-
|
|
341
|
-
// ── 8. sub() after abort — session healthy ────────────────────────────
|
|
342
|
-
await run('8. sub() after abort', async () => {
|
|
343
|
-
const p = session.evaluate('Do[Pause[0.1], {100}]');
|
|
344
|
-
await sleep(400);
|
|
345
|
-
session.abort();
|
|
346
|
-
await p;
|
|
347
|
-
|
|
348
|
-
// $ProcessID is a positive integer and reliably populated in WSTP mode.
|
|
349
|
-
const sv = await session.sub('$ProcessID');
|
|
350
|
-
assert(sv.type === 'integer', `type: ${sv.type}`);
|
|
351
|
-
assert(sv.value > 0, `ProcessID: ${sv.value}`);
|
|
352
|
-
});
|
|
353
|
-
|
|
354
|
-
// ── 9. cellIndex is present and results are correct ───────────────────
|
|
355
|
-
// Note: WolframKernel in -wstp mode only sends INPUTNAMEPKT for In[1]:=
|
|
356
|
-
// (the very first cell in the session). Subsequent evaluations return
|
|
357
|
-
// cellIndex=0. We verify that the first ever eval had cellIndex>=1 and
|
|
358
|
-
// that all results are structurally valid.
|
|
359
|
-
await run('9. cellIndex and result correctness', async () => {
|
|
360
|
-
const r1 = await session.evaluate('100');
|
|
361
|
-
const r2 = await session.evaluate('200');
|
|
362
|
-
const r3 = await session.evaluate('300');
|
|
363
|
-
|
|
364
|
-
assert(r1.result.value === 100, `r1: ${JSON.stringify(r1.result)}`);
|
|
365
|
-
assert(r2.result.value === 200, `r2: ${JSON.stringify(r2.result)}`);
|
|
366
|
-
assert(r3.result.value === 300, `r3: ${JSON.stringify(r3.result)}`);
|
|
367
|
-
assert(typeof r1.cellIndex === 'number', 'cellIndex is a number');
|
|
368
|
-
});
|
|
369
|
-
|
|
370
|
-
// ── 10. outputName and result.print / result.messages are arrays ───────
|
|
371
|
-
// Note: WolframKernel in -wstp mode does not send OUTPUTNAMEPKT (Out[n]=).
|
|
372
|
-
// outputName is always an empty string. We verify the structural shape
|
|
373
|
-
// of EvalResult — print and messages are always arrays, aborted is boolean.
|
|
374
|
-
await run('10. EvalResult structure is correct', async () => {
|
|
375
|
-
const r = await session.evaluate('testVar$$ = 7');
|
|
376
|
-
assert(typeof r.outputName === 'string', 'outputName is a string');
|
|
377
|
-
assert(Array.isArray(r.print), 'print is an array');
|
|
378
|
-
assert(Array.isArray(r.messages), 'messages is an array');
|
|
379
|
-
assert(typeof r.aborted === 'boolean', 'aborted is a boolean');
|
|
380
|
-
assert(r.result.value === 7, `result: ${JSON.stringify(r.result)}`);
|
|
381
|
-
|
|
382
|
-
const r2 = await session.evaluate('testVar$$^2');
|
|
383
|
-
assert(r2.result.value === 49, `squared: ${JSON.stringify(r2.result)}`);
|
|
384
|
-
});
|
|
385
|
-
|
|
386
|
-
// ── 11. WstpReader side-channel delivers real-time values ─────────────
|
|
387
|
-
// Pattern mirrors monitor_demo.js APPROACH 2:
|
|
388
|
-
// 1. Kernel creates link and returns its name.
|
|
389
|
-
// 2. WstpReader connects (JS main thread).
|
|
390
|
-
// 3. Background eval loop starts writing (NOT awaited).
|
|
391
|
-
// 4. readNext() completes WSActivate on first call, then reads data.
|
|
392
|
-
await run('11. WstpReader side-channel', async () => {
|
|
393
|
-
// Step 1: create a TCPIP link inside the kernel.
|
|
394
|
-
await session.evaluate('$sideLink$$ = LinkCreate[LinkProtocol -> "TCPIP"]');
|
|
395
|
-
const nameResult = await session.evaluate('$sideLink$$[[1]]');
|
|
396
|
-
const linkName = nameResult.result.value;
|
|
397
|
-
assert(
|
|
398
|
-
typeof linkName === 'string' && linkName.length > 0,
|
|
399
|
-
`bad linkName: ${JSON.stringify(nameResult.result)}`,
|
|
400
|
-
);
|
|
401
|
-
|
|
402
|
-
// Step 2: connect reader from JS — handshake is deferred to Execute().
|
|
403
|
-
const reader = new WstpReader(linkName, 'TCPIP');
|
|
404
|
-
|
|
405
|
-
// Step 3: start background writer (NOT awaited) so it runs concurrently
|
|
406
|
-
// with readNext(). The kernel enters the Do-loop and calls LinkWrite,
|
|
407
|
-
// which completes the deferred WSActivate handshake on the JS side.
|
|
408
|
-
// Pause AFTER each LinkWrite (not before) so the value is written
|
|
409
|
-
// immediately and the 300ms gap is before the NEXT write. A Pause[1]
|
|
410
|
-
// before LinkClose ensures the reader drains value 5 before the link
|
|
411
|
-
// close signal arrives — simultaneous data+close can cause WSTKEND.
|
|
412
|
-
const bgWrite = session.evaluate(
|
|
413
|
-
'Do[LinkWrite[$sideLink$$, i]; Pause[0.3], {i, 1, 5}]; Pause[1]; LinkClose[$sideLink$$]; "done"',
|
|
414
|
-
);
|
|
415
|
-
|
|
416
|
-
// Step 4: read 5 values in real time.
|
|
417
|
-
// Use try/finally so reader.close() and bgWrite are always awaited
|
|
418
|
-
// even if readNext() rejects — prevents the session being poisoned by
|
|
419
|
-
// an orphaned in-flight bgWrite evaluation.
|
|
420
|
-
let received = [];
|
|
421
|
-
try {
|
|
422
|
-
for (let i = 0; i < 5; i++) {
|
|
423
|
-
const v = await reader.readNext();
|
|
424
|
-
received.push(v.value);
|
|
425
|
-
}
|
|
426
|
-
} finally {
|
|
427
|
-
reader.close();
|
|
428
|
-
// Always drain bgWrite so the session stays clean for subsequent tests.
|
|
429
|
-
try { await bgWrite; } catch (_) {}
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
assert(received.length === 5,
|
|
433
|
-
`expected 5 values, got ${received.length}`);
|
|
434
|
-
assert(
|
|
435
|
-
JSON.stringify(received) === JSON.stringify([1, 2, 3, 4, 5]),
|
|
436
|
-
`values: ${JSON.stringify(received)}`,
|
|
437
|
-
);
|
|
438
|
-
});
|
|
439
|
-
|
|
440
|
-
// ── 12. Large/deep expression — no stack overflow or process crash ───
|
|
441
|
-
// WolframKernel returns a deeply-nested Nest[f,x,600] expression.
|
|
442
|
-
// ReadExprRaw caps recursion at depth 512 and returns a WError, which
|
|
443
|
-
// EvaluateWorker::OnOK() converts to a Promise rejection.
|
|
444
|
-
// The test verifies the process survives: create a new evaluate() after
|
|
445
|
-
// the expected rejection to prove the session (and process) are intact.
|
|
446
|
-
await run('12. deep expression no crash', async () => {
|
|
447
|
-
let threw = false;
|
|
448
|
-
try {
|
|
449
|
-
await session.evaluate('Nest[f, x, 600]');
|
|
450
|
-
} catch (e) {
|
|
451
|
-
threw = true;
|
|
452
|
-
// Expected: 'expression too deep' from ReadExprRaw depth cap.
|
|
453
|
-
assert(e.message.includes('expression too deep') ||
|
|
454
|
-
e.message.includes('deep'),
|
|
455
|
-
`unexpected error: ${e.message}`);
|
|
456
|
-
}
|
|
457
|
-
assert(threw, 'expected a rejection for depth-capped expression');
|
|
458
|
-
|
|
459
|
-
// session must still accept evaluations after the rejection
|
|
460
|
-
const r2 = await session.evaluate('1 + 1');
|
|
461
|
-
assert(r2.result.value === 2, 'session alive after deep-expression rejection');
|
|
462
|
-
});
|
|
463
|
-
|
|
464
|
-
// ── 13. Syntax error produces message, not crash ──────────────────────
|
|
465
|
-
await run('13. syntax error no crash', async () => {
|
|
466
|
-
const r = await session.evaluate('1 +* 2');
|
|
467
|
-
assert(r !== null, 'null result from syntax error');
|
|
468
|
-
// Kernel sends a message for the parse error and returns $Failed / Null.
|
|
469
|
-
assert(
|
|
470
|
-
r.messages.length > 0 || r.result !== null,
|
|
471
|
-
'no message and no result for syntax error',
|
|
472
|
-
);
|
|
473
|
-
});
|
|
474
|
-
|
|
475
|
-
// ── 14. close() is idempotent ─────────────────────────────────────────
|
|
476
|
-
await run('14. close() is idempotent', async () => {
|
|
477
|
-
const s2 = new WstpSession(KERNEL_PATH);
|
|
478
|
-
assert(s2.isOpen, 'fresh session not open');
|
|
479
|
-
s2.close();
|
|
480
|
-
assert(!s2.isOpen, 'isOpen true after first close');
|
|
481
|
-
s2.close(); // must not throw
|
|
482
|
-
assert(!s2.isOpen, 'isOpen true after second close');
|
|
483
|
-
});
|
|
484
|
-
|
|
485
|
-
// ── 15. Cooperative Dialog[] subsession ──────────────────────────────
|
|
486
|
-
// Evaluate an expression that opens Dialog[]. We watch isDialogOpen,
|
|
487
|
-
// check $DialogLevel inside the dialog, then close it with DialogReturn[].
|
|
488
|
-
//
|
|
489
|
-
// Note: within a dialog's EvaluatePacket context:
|
|
490
|
-
// - Return[] evaluates to Return[] (no enclosing structure to return from)
|
|
491
|
-
// - DialogReturn[] explicitly exits the dialog (correct exit mechanism)
|
|
492
|
-
// - Do-loop variables are NOT in scope (EvaluatePacket is a fresh evaluation)
|
|
493
|
-
await run('15. cooperative Dialog[] subsession', async () => {
|
|
494
|
-
let dialogOpened = false;
|
|
495
|
-
let dialogClosed = false;
|
|
496
|
-
|
|
497
|
-
const evalPromise = session.evaluate(
|
|
498
|
-
'Dialog[]; "after-dialog"',
|
|
499
|
-
{
|
|
500
|
-
onDialogBegin: (_level) => { dialogOpened = true; },
|
|
501
|
-
onDialogEnd: (_level) => { dialogClosed = true; },
|
|
502
|
-
},
|
|
503
|
-
);
|
|
504
|
-
|
|
505
|
-
await pollUntil(() => session.isDialogOpen);
|
|
506
|
-
assert(session.isDialogOpen, 'isDialogOpen should be true inside Dialog[]');
|
|
507
|
-
|
|
508
|
-
// A simple expression should evaluate fine inside the dialog.
|
|
509
|
-
const two = await session.dialogEval('1 + 1');
|
|
510
|
-
assert(
|
|
511
|
-
two !== null && two.value === 2,
|
|
512
|
-
`expected 1+1 === 2 inside dialog, got ${JSON.stringify(two)}`,
|
|
513
|
-
);
|
|
514
|
-
|
|
515
|
-
// Close the dialog via exitDialog() — sends EnterTextPacket["Return[]"].
|
|
516
|
-
// (dialogEval('Return[]') does NOT close the dialog: Return[] is evaluated
|
|
517
|
-
// at the top level of EvaluatePacket where there is nothing to return from.)
|
|
518
|
-
await session.exitDialog();
|
|
519
|
-
|
|
520
|
-
// Wait for the outer evaluate() to finish.
|
|
521
|
-
const r = await evalPromise;
|
|
522
|
-
assert(
|
|
523
|
-
r.result !== null && r.result.value === 'after-dialog',
|
|
524
|
-
`expected "after-dialog", got ${JSON.stringify(r.result)}`,
|
|
525
|
-
);
|
|
526
|
-
assert(dialogOpened, 'onDialogBegin was never called');
|
|
527
|
-
assert(dialogClosed, 'onDialogEnd was never called');
|
|
528
|
-
assert(!session.isDialogOpen, 'isDialogOpen should be false after dialog closes');
|
|
529
|
-
});
|
|
530
|
-
|
|
531
|
-
// ── 16. dialogEval rejects when no dialog is open ────────────────────
|
|
532
|
-
await run('16. dialogEval rejects with no open dialog', async () => {
|
|
533
|
-
assert(!session.isDialogOpen, 'precondition: no dialog open');
|
|
534
|
-
let threw = false;
|
|
535
|
-
try {
|
|
536
|
-
await session.dialogEval('1 + 1');
|
|
537
|
-
} catch (e) {
|
|
538
|
-
threw = true;
|
|
539
|
-
assert(
|
|
540
|
-
e.message.includes('no dialog'),
|
|
541
|
-
`unexpected error text: ${e.message}`,
|
|
542
|
-
);
|
|
543
|
-
}
|
|
544
|
-
assert(threw, 'dialogEval should reject when no dialog is open');
|
|
545
|
-
});
|
|
546
|
-
|
|
547
|
-
// ── 17. Unhandled dialog does not corrupt the link ────────────────────
|
|
548
|
-
// Call Dialog[] from a plain evaluate() using only isDialogOpen polling
|
|
549
|
-
// (no onDialogBegin handler). onDialogBegin: () => {} is still required
|
|
550
|
-
// to opt into the legacy dialog loop — without it Fix 1A auto-closes the
|
|
551
|
-
// dialog before JS can interact with it.
|
|
552
|
-
await run('17. unhandled dialog does not corrupt link', async () => {
|
|
553
|
-
const evalPromise = session.evaluate('Dialog[]; 42', { onDialogBegin: () => {} });
|
|
554
|
-
|
|
555
|
-
await pollUntil(() => session.isDialogOpen);
|
|
556
|
-
assert(session.isDialogOpen, 'dialog should have opened');
|
|
557
|
-
await session.exitDialog();
|
|
558
|
-
|
|
559
|
-
const r = await evalPromise;
|
|
560
|
-
assert(
|
|
561
|
-
r.result !== null && r.result.value === 42,
|
|
562
|
-
`expected 42, got ${JSON.stringify(r.result)}`,
|
|
563
|
-
);
|
|
564
|
-
});
|
|
565
|
-
|
|
566
|
-
// ── 19. exitDialog() with a return value ─────────────────────────────
|
|
567
|
-
// exitDialog('21') sends EnterTextPacket["Return[21]"]. The value 21
|
|
568
|
-
// becomes the value of Dialog[] in the outer expression, so x$$*2 == 42.
|
|
569
|
-
await run('19. exitDialog() with a return value', async () => {
|
|
570
|
-
const p = session.evaluate('x$$ = Dialog[]; x$$ * 2', { onDialogBegin: () => {} });
|
|
571
|
-
await pollUntil(() => session.isDialogOpen);
|
|
572
|
-
await session.exitDialog('21');
|
|
573
|
-
const r = await p;
|
|
574
|
-
assert(
|
|
575
|
-
r.result.value === 42,
|
|
576
|
-
`Dialog[] return value: ${JSON.stringify(r.result)}`,
|
|
577
|
-
);
|
|
578
|
-
});
|
|
579
|
-
|
|
580
|
-
// ── 20. dialogEval() sees outer variable state ────────────────────────
|
|
581
|
-
// Variables set before Dialog[] are in scope inside the subsession.
|
|
582
|
-
await run('20. dialogEval sees outer variable state', async () => {
|
|
583
|
-
const p = session.evaluate('myVar$$ = 123; Dialog[]; myVar$$', { onDialogBegin: () => {} });
|
|
584
|
-
await pollUntil(() => session.isDialogOpen);
|
|
585
|
-
const v = await session.dialogEval('myVar$$');
|
|
586
|
-
assert(
|
|
587
|
-
v !== null && v.value === 123,
|
|
588
|
-
`outer var inside dialog: ${JSON.stringify(v)}`,
|
|
589
|
-
);
|
|
590
|
-
await session.exitDialog();
|
|
591
|
-
await p;
|
|
592
|
-
});
|
|
593
|
-
|
|
594
|
-
// ── 21. dialogEval() can mutate kernel state ──────────────────────────
|
|
595
|
-
// Variables set inside the dialog persist after the dialog closes.
|
|
596
|
-
await run('21. dialogEval can mutate kernel state', async () => {
|
|
597
|
-
const p = session.evaluate('Dialog[]; mutated$$', { onDialogBegin: () => {} });
|
|
598
|
-
await pollUntil(() => session.isDialogOpen);
|
|
599
|
-
await session.dialogEval('mutated$$ = 777');
|
|
600
|
-
await session.exitDialog();
|
|
601
|
-
await p; // outer eval returns mutated$$
|
|
602
|
-
const check = await session.sub('mutated$$');
|
|
603
|
-
assert(
|
|
604
|
-
check.value === 777,
|
|
605
|
-
`mutation after dialog exit: ${JSON.stringify(check)}`,
|
|
606
|
-
);
|
|
607
|
-
});
|
|
608
|
-
|
|
609
|
-
// ── 22. Multiple dialogEval() calls are serviced FIFO ─────────────────
|
|
610
|
-
// Three concurrent dialogEval() calls queue up and resolve in order.
|
|
611
|
-
await run('22. multiple dialogEval calls are serviced FIFO', async () => {
|
|
612
|
-
const p = session.evaluate('Dialog[]', { onDialogBegin: () => {} });
|
|
613
|
-
await pollUntil(() => session.isDialogOpen);
|
|
614
|
-
const [a, b, c] = await Promise.all([
|
|
615
|
-
session.dialogEval('1'),
|
|
616
|
-
session.dialogEval('2'),
|
|
617
|
-
session.dialogEval('3'),
|
|
618
|
-
]);
|
|
619
|
-
assert(
|
|
620
|
-
a.value === 1 && b.value === 2 && c.value === 3,
|
|
621
|
-
`FIFO: ${JSON.stringify([a, b, c])}`,
|
|
622
|
-
);
|
|
623
|
-
await session.exitDialog();
|
|
624
|
-
await p;
|
|
625
|
-
});
|
|
626
|
-
|
|
627
|
-
// ── 23. isDialogOpen transitions correctly ────────────────────────────
|
|
628
|
-
// false → true (on Dialog[]) → false (after exitDialog).
|
|
629
|
-
await run('23. isDialogOpen transitions correctly', async () => {
|
|
630
|
-
assert(!session.isDialogOpen, 'initially false');
|
|
631
|
-
const p = session.evaluate('Dialog[]', { onDialogBegin: () => {} });
|
|
632
|
-
await pollUntil(() => session.isDialogOpen);
|
|
633
|
-
assert(session.isDialogOpen, 'true while dialog open');
|
|
634
|
-
await session.exitDialog();
|
|
635
|
-
await p;
|
|
636
|
-
assert(!session.isDialogOpen, 'false after exit');
|
|
637
|
-
});
|
|
638
|
-
|
|
639
|
-
// ── 24. onDialogPrint fires for Print[] inside dialog ─────────────────
|
|
640
|
-
await run('24. onDialogPrint fires for Print[] inside dialog', async () => {
|
|
641
|
-
const dialogLines = [];
|
|
642
|
-
let dlgPrintResolve;
|
|
643
|
-
const dlgPrintDelivered = new Promise(r => { dlgPrintResolve = r; });
|
|
644
|
-
const p = session.evaluate('Dialog[]', {
|
|
645
|
-
onDialogBegin: () => {},
|
|
646
|
-
onDialogPrint: (line) => { dialogLines.push(line); dlgPrintResolve(); },
|
|
647
|
-
});
|
|
648
|
-
await pollUntil(() => session.isDialogOpen);
|
|
649
|
-
await session.dialogEval('Print["hello-from-dialog"]');
|
|
650
|
-
// Wait up to 5 s for the callback to actually fire before exiting the dialog.
|
|
651
|
-
await Promise.race([dlgPrintDelivered, sleep(5000)]);
|
|
652
|
-
await session.exitDialog();
|
|
653
|
-
await p;
|
|
654
|
-
assert(
|
|
655
|
-
dialogLines.includes('hello-from-dialog'),
|
|
656
|
-
`onDialogPrint lines: ${JSON.stringify(dialogLines)}`,
|
|
657
|
-
);
|
|
658
|
-
});
|
|
659
|
-
|
|
660
|
-
// ── S. Streaming stability: 10 consecutive Print[] evals, same session ─
|
|
661
|
-
// Confirms that TSFN delivery is reliable across repeated evaluations
|
|
662
|
-
// without restarting the kernel. Mirrors real VSCode extension use.
|
|
663
|
-
await run('S. streaming stress (10× Print callback)', async () => {
|
|
664
|
-
for (let rep = 0; rep < 10; rep++) {
|
|
665
|
-
const lines = [];
|
|
666
|
-
let resolveAll;
|
|
667
|
-
const allFired = new Promise(r => { resolveAll = r; });
|
|
668
|
-
let count = 0;
|
|
669
|
-
const r = await session.evaluate(
|
|
670
|
-
'Do[Print["s" <> ToString[jj$$]]; Pause[0.05], {jj$$, 4}]',
|
|
671
|
-
{ onPrint: line => { lines.push(line); if (++count === 4) resolveAll(); } },
|
|
672
|
-
);
|
|
673
|
-
await Promise.race([allFired, sleep(5000)]);
|
|
674
|
-
assert(count === 4,
|
|
675
|
-
`rep ${rep + 1}/10: only ${count}/4 callbacks ` +
|
|
676
|
-
`(r.print=${JSON.stringify(r.print)})`);
|
|
677
|
-
}
|
|
678
|
-
});
|
|
679
|
-
|
|
680
|
-
// ── 26. exitDialog() rejects when no dialog is open ───────────────────
|
|
681
|
-
// Symmetric to test 16 which covers dialogEval().
|
|
682
|
-
await run('26. exitDialog() rejects with no open dialog', async () => {
|
|
683
|
-
assert(!session.isDialogOpen, 'precondition: no dialog open');
|
|
684
|
-
let threw = false;
|
|
685
|
-
try {
|
|
686
|
-
await session.exitDialog();
|
|
687
|
-
} catch (e) {
|
|
688
|
-
threw = true;
|
|
689
|
-
assert(
|
|
690
|
-
e.message.includes('no dialog'),
|
|
691
|
-
`unexpected error text: ${e.message}`,
|
|
692
|
-
);
|
|
693
|
-
}
|
|
694
|
-
assert(threw, 'exitDialog should reject when no dialog is open');
|
|
695
|
-
});
|
|
696
|
-
|
|
697
|
-
// ── 27. evaluate() queued during dialog runs after dialog closes ───────
|
|
698
|
-
// A plain evaluate() queued while the dialog inner loop is running must
|
|
699
|
-
// wait and then be dispatched normally after ENDDLGPKT.
|
|
700
|
-
await run('27. evaluate() queued during dialog runs after dialog closes', async () => {
|
|
701
|
-
const p1 = session.evaluate('Dialog[]', { onDialogBegin: () => {} });
|
|
702
|
-
await pollUntil(() => session.isDialogOpen);
|
|
703
|
-
// Queue a normal eval WHILE the dialog is open — it must wait.
|
|
704
|
-
const p2 = session.evaluate('"queued-during-dialog"');
|
|
705
|
-
await session.exitDialog();
|
|
706
|
-
await p1;
|
|
707
|
-
const r2 = await p2;
|
|
708
|
-
assert(
|
|
709
|
-
r2.result.type === 'string' && r2.result.value === 'queued-during-dialog',
|
|
710
|
-
`queued eval: ${JSON.stringify(r2.result)}`,
|
|
711
|
-
);
|
|
712
|
-
});
|
|
713
|
-
|
|
714
|
-
// ── 28. closeAllDialogs() is a no-op when no dialog is open ───────────
|
|
715
|
-
await run('28. closeAllDialogs() no-op when idle', async () => {
|
|
716
|
-
assert(!session.isDialogOpen, 'precondition: no dialog open');
|
|
717
|
-
const closed = session.closeAllDialogs();
|
|
718
|
-
assert(closed === false,
|
|
719
|
-
`closeAllDialogs() should return false when idle, got: ${closed}`);
|
|
720
|
-
assert(!session.isDialogOpen, 'isDialogOpen should stay false');
|
|
721
|
-
});
|
|
722
|
-
|
|
723
|
-
// ── 29. closeAllDialogs() rejects all queued dialogEval promises ───────
|
|
724
|
-
// Uses a subsession so the main session stays intact for tests 25 and 18.
|
|
725
|
-
// Verifies: return value = true, isDialogOpen cleared, queued promises rejected.
|
|
726
|
-
await run('29. closeAllDialogs() rejects queued dialogEval promises', async () => {
|
|
727
|
-
const sub = session.createSubsession();
|
|
728
|
-
try {
|
|
729
|
-
// Open a Dialog[] on the subsession.
|
|
730
|
-
const pEval = sub.evaluate('Dialog[]', { onDialogBegin: () => {} });
|
|
731
|
-
await pollUntil(() => sub.isDialogOpen);
|
|
732
|
-
assert(sub.isDialogOpen, 'dialog should be open');
|
|
733
|
-
|
|
734
|
-
// Queue two dialogEval() calls — neither will be serviced before
|
|
735
|
-
// closeAllDialogs() runs (the JS event loop hasn't yielded yet).
|
|
736
|
-
const pe1 = sub.dialogEval('"expr-1"')
|
|
737
|
-
.then(() => 'resolved').catch(e => 'rejected:' + e.message);
|
|
738
|
-
const pe2 = sub.dialogEval('"expr-2"')
|
|
739
|
-
.then(() => 'resolved').catch(e => 'rejected:' + e.message);
|
|
740
|
-
|
|
741
|
-
// closeAllDialogs() should flush both and return true.
|
|
742
|
-
const closed = sub.closeAllDialogs();
|
|
743
|
-
assert(closed === true,
|
|
744
|
-
`closeAllDialogs() should return true when dialog was open, got: ${closed}`);
|
|
745
|
-
assert(!sub.isDialogOpen,
|
|
746
|
-
'isDialogOpen should be false after closeAllDialogs()');
|
|
747
|
-
|
|
748
|
-
// Both queued promises must reject immediately.
|
|
749
|
-
const [r1, r2] = await Promise.all([pe1, pe2]);
|
|
750
|
-
assert(r1.startsWith('rejected:'),
|
|
751
|
-
`pe1 should have rejected, got: ${r1}`);
|
|
752
|
-
assert(r2.startsWith('rejected:'),
|
|
753
|
-
`pe2 should have rejected, got: ${r2}`);
|
|
754
|
-
|
|
755
|
-
// Abort the subsession to unstick the kernel (still inside Dialog[]).
|
|
756
|
-
sub.abort();
|
|
757
|
-
const ra = await pEval;
|
|
758
|
-
assert(ra.aborted === true,
|
|
759
|
-
`subsession evaluate should resolve with aborted=true, got: ${JSON.stringify(ra.aborted)}`);
|
|
760
|
-
} finally {
|
|
761
|
-
sub.close();
|
|
762
|
-
}
|
|
763
|
-
});
|
|
764
|
-
|
|
765
|
-
// ── P1: Pause[8] ignores interrupt ────────────────────────────────────
|
|
766
|
-
// Expected: interrupt() returns true but isDialogOpen stays false within
|
|
767
|
-
// 2500ms because Pause[] ignores WSInterruptMessage during a sleep.
|
|
768
|
-
// This test documents the fundamental limitation: Dynamic cannot read a
|
|
769
|
-
// live variable while Pause[N] is running.
|
|
770
|
-
await run('P1: Pause[4] ignores interrupt within 1500ms', async () => {
|
|
771
|
-
const s = mkSession();
|
|
772
|
-
try {
|
|
773
|
-
await installHandler(s);
|
|
774
|
-
|
|
775
|
-
let evalDone = false;
|
|
776
|
-
const mainProm = s.evaluate(
|
|
777
|
-
'pP1 = 0; Pause[4]; pP1 = 1; "p1-done"'
|
|
778
|
-
).then(() => { evalDone = true; });
|
|
779
|
-
|
|
780
|
-
await sleep(300);
|
|
781
|
-
|
|
782
|
-
const sent = s.interrupt();
|
|
783
|
-
assert(sent === true, 'interrupt() should return true mid-eval');
|
|
784
|
-
|
|
785
|
-
const t0 = Date.now();
|
|
786
|
-
while (!s.isDialogOpen && Date.now() - t0 < 1500) await sleep(25);
|
|
787
|
-
|
|
788
|
-
const dlgOpened = s.isDialogOpen;
|
|
789
|
-
assert(!dlgOpened, 'Pause[4] should NOT open a dialog within 1500ms');
|
|
790
|
-
|
|
791
|
-
// After the 1500ms window the interrupt may still be queued — the kernel
|
|
792
|
-
// will fire Dialog[] once Pause[] releases. Abort to unstick the eval
|
|
793
|
-
// rather than waiting for it to return on its own (which could take forever
|
|
794
|
-
// if Dialog[] opens and nobody services it).
|
|
795
|
-
s.abort();
|
|
796
|
-
await mainProm; // resolves immediately after abort()
|
|
797
|
-
} finally {
|
|
798
|
-
s.close();
|
|
799
|
-
}
|
|
800
|
-
}, 12_000);
|
|
801
|
-
|
|
802
|
-
// ── P2: Pause[0.3] loop — interrupt opens Dialog and dialogEval works ──
|
|
803
|
-
// Expected: interrupt during a short Pause[] loop opens a Dialog[],
|
|
804
|
-
// dialogEval can read the live variable, and exitDialog resumes the loop.
|
|
805
|
-
await run('P2: Pause[0.3] loop — interrupt opens Dialog and dialogEval succeeds', async () => {
|
|
806
|
-
const s = mkSession();
|
|
807
|
-
try {
|
|
808
|
-
await installHandler(s);
|
|
809
|
-
|
|
810
|
-
let evalDone = false;
|
|
811
|
-
const mainProm = s.evaluate(
|
|
812
|
-
'Do[nP2 = k; Pause[0.1], {k, 1, 15}]; "p2-done"',
|
|
813
|
-
{ onDialogBegin: () => {}, onDialogEnd: () => {} }
|
|
814
|
-
).then(() => { evalDone = true; });
|
|
815
|
-
|
|
816
|
-
await sleep(500);
|
|
817
|
-
|
|
818
|
-
s.interrupt();
|
|
819
|
-
try { await pollUntil(() => s.isDialogOpen, 3000); }
|
|
820
|
-
catch (_) { throw new Error('Dialog never opened — interrupt not working with Pause[0.1]'); }
|
|
821
|
-
|
|
822
|
-
const val = await withTimeout(s.dialogEval('nP2'), 5000, 'dialogEval nP2');
|
|
823
|
-
assert(val && typeof val.value === 'number' && val.value >= 1,
|
|
824
|
-
`expected nP2 >= 1, got ${JSON.stringify(val)}`);
|
|
825
|
-
|
|
826
|
-
await s.exitDialog();
|
|
827
|
-
await withTimeout(mainProm, 8_000, 'P2 main eval');
|
|
828
|
-
} finally {
|
|
829
|
-
try { s.abort(); } catch (_) {}
|
|
830
|
-
s.close();
|
|
831
|
-
}
|
|
832
|
-
}, 15_000);
|
|
833
|
-
|
|
834
|
-
// ── P3: dialogEval timeout — kernel state after (diagnostic) ───────────
|
|
835
|
-
// Simulates the extension failure: dialogEval times out without exitDialog.
|
|
836
|
-
// Verifies the kernel is NOT permanently broken by a timeout alone.
|
|
837
|
-
// Always passes — records the observed behaviour.
|
|
838
|
-
await run('P3: dialogEval timeout — kernel still recovers via exitDialog', async () => {
|
|
839
|
-
const s = mkSession();
|
|
840
|
-
try {
|
|
841
|
-
await installHandler(s);
|
|
842
|
-
|
|
843
|
-
let evalDone = false;
|
|
844
|
-
const mainProm = s.evaluate(
|
|
845
|
-
'Do[nP3 = k; Pause[0.1], {k, 1, 15}]; "p3-done"',
|
|
846
|
-
{ onDialogBegin: () => {}, onDialogEnd: () => {} }
|
|
847
|
-
).then(() => { evalDone = true; });
|
|
848
|
-
|
|
849
|
-
await sleep(500);
|
|
850
|
-
|
|
851
|
-
s.interrupt();
|
|
852
|
-
let dlg1 = false;
|
|
853
|
-
try { await pollUntil(() => s.isDialogOpen, 3000); dlg1 = true; }
|
|
854
|
-
catch (_) {}
|
|
855
|
-
|
|
856
|
-
if (!dlg1) {
|
|
857
|
-
console.log(' P3 note: Dialog #1 never opened — interrupt may have been slow');
|
|
858
|
-
} else {
|
|
859
|
-
// Simulate a timed-out dialogEval (abandon it at 200ms)
|
|
860
|
-
try { await withTimeout(s.dialogEval('nP3'), 200, 'deliberate-timeout'); } catch (_) {}
|
|
861
|
-
|
|
862
|
-
// Attempt exitDialog — should succeed
|
|
863
|
-
let exitOk = false;
|
|
864
|
-
try { await withTimeout(s.exitDialog(), 2000, 'exitDialog after timeout'); exitOk = true; }
|
|
865
|
-
catch (_) {}
|
|
866
|
-
|
|
867
|
-
// Attempt a second interrupt to confirm kernel state
|
|
868
|
-
await sleep(400);
|
|
869
|
-
s.interrupt();
|
|
870
|
-
let dlg2 = false;
|
|
871
|
-
const t2 = Date.now();
|
|
872
|
-
while (!s.isDialogOpen && Date.now() - t2 < 3000) await sleep(30);
|
|
873
|
-
dlg2 = s.isDialogOpen;
|
|
874
|
-
|
|
875
|
-
if (dlg2) {
|
|
876
|
-
await withTimeout(s.dialogEval('nP3'), 4000, 'dialogEval #2').catch(() => {});
|
|
877
|
-
await s.exitDialog().catch(() => {});
|
|
878
|
-
}
|
|
879
|
-
|
|
880
|
-
if (!evalDone) { try { await withTimeout(mainProm, 6_000, 'P3 loop'); } catch (_) {} }
|
|
881
|
-
|
|
882
|
-
// Diagnostic: document observed outcome but do not hard-fail on dlg2
|
|
883
|
-
if (!exitOk) {
|
|
884
|
-
console.log(` P3 note: exitDialog failed, dlg2=${dlg2} (expected=false for unfixed build)`);
|
|
885
|
-
}
|
|
886
|
-
}
|
|
887
|
-
// Test passes unconditionally.
|
|
888
|
-
} finally {
|
|
889
|
-
try { s.abort(); } catch (_) {}
|
|
890
|
-
s.close();
|
|
891
|
-
}
|
|
892
|
-
}, 20_000);
|
|
893
|
-
|
|
894
|
-
// ── P4: abort() after stuck dialog — session stays alive ────────────────
|
|
895
|
-
// Note: abort() sends WSAbortMessage which resets the Wolfram kernel's
|
|
896
|
-
// Internal`AddHandler["Interrupt", ...] registration. Subsequent interrupts
|
|
897
|
-
// therefore do not open a new Dialog[]; that is expected, not a bug.
|
|
898
|
-
// What this test verifies: after abort() the session is NOT dead —
|
|
899
|
-
// evaluate() still works so the extension can queue more cells.
|
|
900
|
-
await run('P4: abort() after stuck dialog — session can still evaluate', async () => {
|
|
901
|
-
const s = mkSession();
|
|
902
|
-
try {
|
|
903
|
-
await installHandler(s);
|
|
904
|
-
|
|
905
|
-
const mainProm = s.evaluate(
|
|
906
|
-
'Do[nP4=k; Pause[0.1], {k,1,15}]; "p4-done"',
|
|
907
|
-
{ onDialogBegin: () => {}, onDialogEnd: () => {} }
|
|
908
|
-
).catch(() => {});
|
|
909
|
-
|
|
910
|
-
await sleep(500);
|
|
911
|
-
|
|
912
|
-
// Trigger a dialog, force dialogEval timeout, then abort
|
|
913
|
-
s.interrupt();
|
|
914
|
-
try { await pollUntil(() => s.isDialogOpen, 3000); }
|
|
915
|
-
catch (_) { throw new Error('Dialog #1 never opened'); }
|
|
916
|
-
|
|
917
|
-
try { await withTimeout(s.dialogEval('nP4'), 300, 'deliberate'); } catch (_) {}
|
|
918
|
-
try { s.abort(); } catch (_) {}
|
|
919
|
-
|
|
920
|
-
// Let execute() drain the abort response and OnOK fire
|
|
921
|
-
const tAbort = Date.now();
|
|
922
|
-
while (s.isDialogOpen && Date.now() - tAbort < 5000) await sleep(50);
|
|
923
|
-
try { await withTimeout(mainProm, 5000, 'P4 abort settle'); } catch (_) {}
|
|
924
|
-
await sleep(300);
|
|
925
|
-
|
|
926
|
-
// KEY assertion: the session is still functional for evaluate()
|
|
927
|
-
// (abort() must NOT permanently close the link)
|
|
928
|
-
const r = await withTimeout(
|
|
929
|
-
s.evaluate('"session-alive-after-abort"'),
|
|
930
|
-
8000, 'post-abort evaluate'
|
|
931
|
-
);
|
|
932
|
-
assert(r && r.result && r.result.value === 'session-alive-after-abort',
|
|
933
|
-
`evaluate() after abort returned unexpected result: ${JSON.stringify(r)}`);
|
|
934
|
-
} finally {
|
|
935
|
-
try { s.abort(); } catch (_) {}
|
|
936
|
-
s.close();
|
|
937
|
-
}
|
|
938
|
-
}, 15_000);
|
|
939
|
-
|
|
940
|
-
// ── P5: closeAllDialogs()+abort() recovery → reinstall handler → new dialog works
|
|
941
|
-
// closeAllDialogs() is designed to be paired with abort(). It rejects all
|
|
942
|
-
// pending dialogEval() promises (JS-side), while abort() signals the kernel.
|
|
943
|
-
// After recovery the interrupt handler must be reinstalled because abort()
|
|
944
|
-
// clears Wolfram's Internal`AddHandler["Interrupt",...] registration.
|
|
945
|
-
await run('P5: closeAllDialogs()+abort() recovery — new dialog works after reinstallHandler', async () => {
|
|
946
|
-
const s = mkSession();
|
|
947
|
-
try {
|
|
948
|
-
await installHandler(s);
|
|
949
|
-
|
|
950
|
-
const mainProm = s.evaluate(
|
|
951
|
-
'Do[nP5 = k; Pause[0.1], {k, 1, 15}]; "p5-done"',
|
|
952
|
-
{ onDialogBegin: () => {}, onDialogEnd: () => {} }
|
|
953
|
-
).catch(() => {});
|
|
954
|
-
|
|
955
|
-
await sleep(500);
|
|
956
|
-
|
|
957
|
-
// ── Phase 1: open dialog #1, call closeAllDialogs()+abort() ──────
|
|
958
|
-
s.interrupt();
|
|
959
|
-
try { await pollUntil(() => s.isDialogOpen, 3000); }
|
|
960
|
-
catch (_) { throw new Error('Dialog #1 never opened'); }
|
|
961
|
-
|
|
962
|
-
// Start a dialogEval — closeAllDialogs() will reject it synchronously.
|
|
963
|
-
const de1 = s.dialogEval('nP5').catch(() => {});
|
|
964
|
-
try { s.closeAllDialogs(); } catch (_) {}
|
|
965
|
-
await de1; // resolves immediately (rejected by closeAllDialogs)
|
|
966
|
-
await sleep(100);
|
|
967
|
-
try { s.abort(); } catch (_) {}
|
|
968
|
-
await mainProm; // should resolve promptly after abort()
|
|
969
|
-
|
|
970
|
-
// ── Phase 2: reinstall handler, start new loop, interrupt → dialog #2 ──
|
|
971
|
-
// abort() clears Internal`AddHandler["Interrupt",...] in the kernel,
|
|
972
|
-
// so we must reinstall before the next interrupt cycle.
|
|
973
|
-
await withTimeout(installHandler(s), 8000, 'reinstall handler after abort');
|
|
974
|
-
|
|
975
|
-
let dlg2 = false;
|
|
976
|
-
const mainProm2 = s.evaluate(
|
|
977
|
-
'Do[nP5b = k; Pause[0.1], {k, 1, 15}]; "p5b-done"',
|
|
978
|
-
{ onDialogBegin: () => {}, onDialogEnd: () => {} }
|
|
979
|
-
).catch(() => {});
|
|
980
|
-
|
|
981
|
-
await sleep(500);
|
|
982
|
-
s.interrupt();
|
|
983
|
-
try { await pollUntil(() => s.isDialogOpen, 3000); }
|
|
984
|
-
catch (_) { throw new Error('Dialog #2 never opened after closeAllDialogs()+abort() recovery'); }
|
|
985
|
-
dlg2 = true;
|
|
986
|
-
|
|
987
|
-
// Read a live variable inside the dialog.
|
|
988
|
-
const val2 = await withTimeout(s.dialogEval('nP5b'), 4000, 'dialogEval #2');
|
|
989
|
-
assert(val2 !== null && val2.value !== undefined, 'dialogEval #2 should return nP5b value');
|
|
990
|
-
|
|
991
|
-
assert(dlg2, 'after closeAllDialogs()+abort()+reinstallHandler, interrupt must open a new dialog');
|
|
992
|
-
// Abort the dialog and remaining Do loop promptly (no exitDialog needed).
|
|
993
|
-
try { s.abort(); } catch (_) {}
|
|
994
|
-
await mainProm2.catch(() => {}); // drains immediately after abort
|
|
995
|
-
} finally {
|
|
996
|
-
try { s.abort(); } catch (_) {}
|
|
997
|
-
s.close();
|
|
998
|
-
}
|
|
999
|
-
}, 25_000);
|
|
1000
|
-
|
|
1001
|
-
// ── P6: Simulate Dynamic + Pause[5] full scenario (diagnostic) ─────────
|
|
1002
|
-
// Reproduces: n=RandomInteger[100]; Pause[5] with interrupt every 2.5s.
|
|
1003
|
-
// Pause[5] is expected to block all interrupts. Test always passes —
|
|
1004
|
-
// it documents whether reads succeed despite long Pause.
|
|
1005
|
-
await run('P6: Pause[5] + interrupt cycle — dynamic read diagnostic', async () => {
|
|
1006
|
-
const s = mkSession();
|
|
1007
|
-
try {
|
|
1008
|
-
await installHandler(s);
|
|
1009
|
-
|
|
1010
|
-
let evalDone = false;
|
|
1011
|
-
const mainProm = s.evaluate(
|
|
1012
|
-
'n = RandomInteger[100]; Pause[3]; "p6-done"',
|
|
1013
|
-
{ onDialogBegin: () => {}, onDialogEnd: () => {} }
|
|
1014
|
-
).then(() => { evalDone = true; });
|
|
1015
|
-
|
|
1016
|
-
await sleep(200);
|
|
1017
|
-
|
|
1018
|
-
const INTERRUPT_WAIT_MS = 1500;
|
|
1019
|
-
const DIALOG_EVAL_TIMEOUT_MS = 8000;
|
|
1020
|
-
let dialogReadSucceeded = false;
|
|
1021
|
-
let pauseIgnoredInterrupt = false;
|
|
1022
|
-
|
|
1023
|
-
for (let cycle = 1; cycle <= 2 && !evalDone; cycle++) {
|
|
1024
|
-
const t0 = Date.now();
|
|
1025
|
-
s.interrupt();
|
|
1026
|
-
while (!s.isDialogOpen && Date.now() - t0 < INTERRUPT_WAIT_MS) await sleep(25);
|
|
1027
|
-
const dlg = s.isDialogOpen;
|
|
1028
|
-
|
|
1029
|
-
if (!dlg) {
|
|
1030
|
-
pauseIgnoredInterrupt = true;
|
|
1031
|
-
await sleep(300);
|
|
1032
|
-
continue;
|
|
1033
|
-
}
|
|
1034
|
-
|
|
1035
|
-
try {
|
|
1036
|
-
const val = await withTimeout(
|
|
1037
|
-
s.dialogEval('n'), DIALOG_EVAL_TIMEOUT_MS, `P6 dialogEval cycle ${cycle}`);
|
|
1038
|
-
dialogReadSucceeded = true;
|
|
1039
|
-
await s.exitDialog();
|
|
1040
|
-
break;
|
|
1041
|
-
} catch (e) {
|
|
1042
|
-
let exitOk = false;
|
|
1043
|
-
for (let a = 0; a < 3 && !exitOk; a++) {
|
|
1044
|
-
try { await withTimeout(s.exitDialog(), 2000, `exitDialog ${a+1}`); exitOk = true; }
|
|
1045
|
-
catch (_) {}
|
|
1046
|
-
}
|
|
1047
|
-
if (!exitOk) { try { s.abort(); } catch (_) {} await sleep(1000); break; }
|
|
1048
|
-
}
|
|
1049
|
-
}
|
|
1050
|
-
|
|
1051
|
-
try { await withTimeout(mainProm, 7_000, 'P6 main eval'); } catch (_) {}
|
|
1052
|
-
|
|
1053
|
-
// Diagnostic: always passes — log observed outcome
|
|
1054
|
-
if (pauseIgnoredInterrupt && !dialogReadSucceeded) {
|
|
1055
|
-
console.log(' P6 note: Pause[3] blocked all interrupts — expected behaviour');
|
|
1056
|
-
} else if (dialogReadSucceeded) {
|
|
1057
|
-
console.log(' P6 note: at least one read succeeded despite Pause');
|
|
1058
|
-
}
|
|
1059
|
-
} finally {
|
|
1060
|
-
try { s.abort(); } catch (_) {}
|
|
1061
|
-
s.close();
|
|
1062
|
-
}
|
|
1063
|
-
}, 20_000);
|
|
1064
|
-
|
|
1065
|
-
// ── 25. abort() while dialog is open ──────────────────────────────────
|
|
1066
|
-
// Must run AFTER all other dialog tests — abort() sends WSAbortMessage
|
|
1067
|
-
// which resets the WSTP link, leaving the session unusable for further
|
|
1068
|
-
// evaluations. Tests 26 and 27 need a clean session, so test 25 runs last
|
|
1069
|
-
// (just before test 18 which also corrupts the link via WSInterruptMessage).
|
|
1070
|
-
await run('25. abort while dialog is open', async () => {
|
|
1071
|
-
const p = session.evaluate('Dialog[]', { onDialogBegin: () => {} });
|
|
1072
|
-
await pollUntil(() => session.isDialogOpen);
|
|
1073
|
-
session.abort();
|
|
1074
|
-
const r = await p;
|
|
1075
|
-
assert(r.aborted === true, `aborted flag: ${r.aborted}`);
|
|
1076
|
-
assert(!session.isDialogOpen, 'isDialogOpen false after abort');
|
|
1077
|
-
// NOTE: session link is dead after abort() — no further evaluations.
|
|
1078
|
-
});
|
|
1079
|
-
|
|
1080
|
-
// ── 18. interrupt() is callable (best-effort, no hard assertion) ──────
|
|
1081
|
-
// interrupt() posts WSInterruptMessage. Without a Wolfram-side handler
|
|
1082
|
-
// the kernel ignores it. This test just verifies the method exists and
|
|
1083
|
-
// returns a boolean without throwing.
|
|
1084
|
-
// NOTE: must run LAST — WSInterruptMessage stays buffered on the link and
|
|
1085
|
-
// would abort the next Dialog[] evaluation, causing all dialog tests to hang.
|
|
1086
|
-
await run('18. interrupt() does not throw', async () => {
|
|
1087
|
-
const ok = session.interrupt();
|
|
1088
|
-
assert(typeof ok === 'boolean', `interrupt() should return boolean, got ${typeof ok}`);
|
|
1089
|
-
});
|
|
1090
|
-
|
|
1091
|
-
// ── 30. kernelPid is a positive integer ───────────────────────────────
|
|
1092
|
-
await run('30. kernelPid is a positive integer', async () => {
|
|
1093
|
-
const s = mkSession();
|
|
1094
|
-
try {
|
|
1095
|
-
const pid = s.kernelPid;
|
|
1096
|
-
assert(typeof pid === 'number', `kernelPid type: ${typeof pid}`);
|
|
1097
|
-
assert(Number.isInteger(pid), `kernelPid not integer: ${pid}`);
|
|
1098
|
-
assert(pid > 0, `kernelPid not positive: ${pid}`);
|
|
1099
|
-
// Verify it matches $ProcessID reported by the kernel itself.
|
|
1100
|
-
const r = await s.sub('$ProcessID');
|
|
1101
|
-
assert(r.type === 'integer' && r.value === pid,
|
|
1102
|
-
`kernelPid ${pid} vs kernel $ProcessID ${JSON.stringify(r)}`);
|
|
1103
|
-
} finally {
|
|
1104
|
-
s.close();
|
|
1105
|
-
}
|
|
1106
|
-
});
|
|
1107
|
-
|
|
1108
|
-
// ── 31. kernelPid is distinct for main + subsessions ──────────────────
|
|
1109
|
-
await run('31. kernelPid distinct across subsessions', async () => {
|
|
1110
|
-
const s = mkSession();
|
|
1111
|
-
try {
|
|
1112
|
-
const child1 = s.createSubsession();
|
|
1113
|
-
const child2 = s.createSubsession();
|
|
1114
|
-
try {
|
|
1115
|
-
const pids = [s.kernelPid, child1.kernelPid, child2.kernelPid];
|
|
1116
|
-
assert(pids.every(p => p > 0),
|
|
1117
|
-
`all PIDs must be positive: ${JSON.stringify(pids)}`);
|
|
1118
|
-
assert(new Set(pids).size === 3,
|
|
1119
|
-
`PIDs must be distinct: ${JSON.stringify(pids)}`);
|
|
1120
|
-
} finally {
|
|
1121
|
-
child1.close();
|
|
1122
|
-
child2.close();
|
|
1123
|
-
}
|
|
1124
|
-
} finally {
|
|
1125
|
-
s.close();
|
|
1126
|
-
}
|
|
1127
|
-
});
|
|
1128
|
-
|
|
1129
|
-
// ── 32. kernelPid survives close() ────────────────────────────────────
|
|
1130
|
-
await run('32. kernelPid readable after close()', async () => {
|
|
1131
|
-
const s = mkSession();
|
|
1132
|
-
const pid = s.kernelPid;
|
|
1133
|
-
assert(pid > 0, `pid before close: ${pid}`);
|
|
1134
|
-
s.close();
|
|
1135
|
-
assert(s.kernelPid === pid,
|
|
1136
|
-
`kernelPid changed after close: ${s.kernelPid} vs ${pid}`);
|
|
1137
|
-
});
|
|
1138
|
-
|
|
1139
|
-
// ── 33. subWhenIdle runs after all evaluate() calls ───────────────────
|
|
1140
|
-
await run('33. subWhenIdle runs after evaluate() queue drains', async () => {
|
|
1141
|
-
const s = mkSession();
|
|
1142
|
-
try {
|
|
1143
|
-
const order = [];
|
|
1144
|
-
const p1 = s.evaluate('Pause[0.3]; 1').then(() => order.push('eval1'));
|
|
1145
|
-
const p2 = s.evaluate('Pause[0.1]; 2').then(() => order.push('eval2'));
|
|
1146
|
-
const p3 = s.subWhenIdle('3').then(() => order.push('whenIdle'));
|
|
1147
|
-
await Promise.all([p1, p2, p3]);
|
|
1148
|
-
assert(order[0] === 'eval1', `order[0]: ${order[0]}`);
|
|
1149
|
-
assert(order[1] === 'eval2', `order[1]: ${order[1]}`);
|
|
1150
|
-
assert(order[2] === 'whenIdle', `order[2]: ${order[2]}`);
|
|
1151
|
-
} finally {
|
|
1152
|
-
s.close();
|
|
1153
|
-
}
|
|
1154
|
-
});
|
|
1155
|
-
|
|
1156
|
-
// ── 34. subWhenIdle resolves correctly when idle ───────────────────────
|
|
1157
|
-
await run('34. subWhenIdle resolves with correct WExpr when idle', async () => {
|
|
1158
|
-
const s = mkSession();
|
|
1159
|
-
try {
|
|
1160
|
-
const r = await s.subWhenIdle('2 + 2');
|
|
1161
|
-
assert(r.type === 'integer' && r.value === 4,
|
|
1162
|
-
`expected {type:"integer",value:4}, got ${JSON.stringify(r)}`);
|
|
1163
|
-
} finally {
|
|
1164
|
-
s.close();
|
|
1165
|
-
}
|
|
1166
|
-
});
|
|
1167
|
-
|
|
1168
|
-
// ── 35. subWhenIdle timeout rejects while kernel is busy ──────────────
|
|
1169
|
-
await run('35. subWhenIdle timeout rejects', async () => {
|
|
1170
|
-
const s = mkSession();
|
|
1171
|
-
try {
|
|
1172
|
-
s.evaluate('Pause[3]; 0'); // keep kernel busy for 3 s
|
|
1173
|
-
let threw = false;
|
|
1174
|
-
try {
|
|
1175
|
-
await s.subWhenIdle('1', { timeout: 400 });
|
|
1176
|
-
} catch (e) {
|
|
1177
|
-
threw = true;
|
|
1178
|
-
assert(e.message === 'subWhenIdle: timeout',
|
|
1179
|
-
`unexpected error: ${e.message}`);
|
|
1180
|
-
}
|
|
1181
|
-
assert(threw, 'subWhenIdle should have rejected with timeout');
|
|
1182
|
-
} finally {
|
|
1183
|
-
s.close();
|
|
1184
|
-
}
|
|
1185
|
-
});
|
|
1186
|
-
|
|
1187
|
-
// ── 36. subWhenIdle rejects when session is closed while queued ────────
|
|
1188
|
-
await run('36. subWhenIdle rejects on session close', async () => {
|
|
1189
|
-
const s = mkSession();
|
|
1190
|
-
s.evaluate('Pause[5]; 0'); // keep busy
|
|
1191
|
-
const p = s.subWhenIdle('1');
|
|
1192
|
-
let threw = false;
|
|
1193
|
-
try {
|
|
1194
|
-
setTimeout(() => s.close(), 200);
|
|
1195
|
-
await p;
|
|
1196
|
-
} catch (e) {
|
|
1197
|
-
threw = true;
|
|
1198
|
-
assert(e.message === 'Session is closed',
|
|
1199
|
-
`unexpected error: ${e.message}`);
|
|
1200
|
-
}
|
|
1201
|
-
assert(threw, 'subWhenIdle should reject when session is closed');
|
|
1202
|
-
});
|
|
1203
|
-
|
|
1204
|
-
// ── 37. sub() still prioritised over subWhenIdle() ────────────────────
|
|
1205
|
-
await run('37. sub() prioritised over subWhenIdle()', async () => {
|
|
1206
|
-
const s = mkSession();
|
|
1207
|
-
try {
|
|
1208
|
-
const order = [];
|
|
1209
|
-
s.evaluate('Pause[0.4]; 0'); // keep busy
|
|
1210
|
-
const wi = s.subWhenIdle('1').then(() => order.push('whenIdle'));
|
|
1211
|
-
const su = s.sub('2').then(() => order.push('sub'));
|
|
1212
|
-
await Promise.all([wi, su]);
|
|
1213
|
-
assert(order[0] === 'sub', `expected sub first, got: ${order[0]}`);
|
|
1214
|
-
assert(order[1] === 'whenIdle', `expected whenIdle second, got: ${order[1]}`);
|
|
1215
|
-
} finally {
|
|
1216
|
-
s.close();
|
|
1217
|
-
}
|
|
1218
|
-
});
|
|
1219
|
-
|
|
1220
|
-
// ══════════════════════════════════════════════════════════════════════
|
|
1221
|
-
// Dynamic eval API tests (v0.6.0)
|
|
1222
|
-
// ══════════════════════════════════════════════════════════════════════
|
|
1223
|
-
|
|
1224
|
-
// ── 38. registerDynamic + getDynamicResults basic ─────────────────────
|
|
1225
|
-
await run('38. registerDynamic / getDynamicResults basic', async () => {
|
|
1226
|
-
const s = mkSession();
|
|
1227
|
-
try {
|
|
1228
|
-
s.registerDynamic('sum', 'ToString[1+1]');
|
|
1229
|
-
s.setDynamicInterval(150);
|
|
1230
|
-
assert(s.dynamicActive, 'dynamicActive should be true after registration + interval');
|
|
1231
|
-
// Run a long-ish eval so the timer fires at least once
|
|
1232
|
-
await withTimeout(s.evaluate('Pause[0.8]; "done"'), 8000, 'test 38 eval');
|
|
1233
|
-
const results = s.getDynamicResults();
|
|
1234
|
-
assert(typeof results === 'object', 'getDynamicResults must return object');
|
|
1235
|
-
assert('sum' in results, 'result must have key "sum"');
|
|
1236
|
-
assert(results.sum.value === '2', `expected "2", got "${results.sum.value}"`);
|
|
1237
|
-
assert(typeof results.sum.timestamp === 'number' && results.sum.timestamp > 0,
|
|
1238
|
-
'timestamp must be positive number');
|
|
1239
|
-
} finally {
|
|
1240
|
-
s.close();
|
|
1241
|
-
}
|
|
1242
|
-
});
|
|
1243
|
-
|
|
1244
|
-
// ── 39. multiple registered expressions ───────────────────────────────
|
|
1245
|
-
await run('39. multiple Dynamic registrations', async () => {
|
|
1246
|
-
const s = mkSession();
|
|
1247
|
-
try {
|
|
1248
|
-
await s.evaluate('a = 10; b = 20; c = 30;');
|
|
1249
|
-
s.registerDynamic('a', 'ToString[a]');
|
|
1250
|
-
s.registerDynamic('b', 'ToString[b]');
|
|
1251
|
-
s.registerDynamic('c', 'ToString[c]');
|
|
1252
|
-
s.setDynamicInterval(150);
|
|
1253
|
-
await withTimeout(s.evaluate('Pause[0.8]; "done"'), 8000, 'test 39 eval');
|
|
1254
|
-
const res = s.getDynamicResults();
|
|
1255
|
-
assert(res.a && res.a.value === '10', `a: expected "10", got "${res.a && res.a.value}"`);
|
|
1256
|
-
assert(res.b && res.b.value === '20', `b: expected "20", got "${res.b && res.b.value}"`);
|
|
1257
|
-
assert(res.c && res.c.value === '30', `c: expected "30", got "${res.c && res.c.value}"`);
|
|
1258
|
-
} finally {
|
|
1259
|
-
s.close();
|
|
1260
|
-
}
|
|
1261
|
-
});
|
|
1262
|
-
|
|
1263
|
-
// ── 40. getDynamicResults clears on each call ─────────────────────────
|
|
1264
|
-
await run('40. getDynamicResults clears buffer on each call', async () => {
|
|
1265
|
-
const s = mkSession();
|
|
1266
|
-
try {
|
|
1267
|
-
s.registerDynamic('x', 'ToString[2+2]');
|
|
1268
|
-
s.setDynamicInterval(150);
|
|
1269
|
-
await withTimeout(s.evaluate('Pause[0.8]; "done"'), 8000, 'test 40 eval');
|
|
1270
|
-
const first = s.getDynamicResults();
|
|
1271
|
-
const second = s.getDynamicResults();
|
|
1272
|
-
assert('x' in first, 'first call should contain results');
|
|
1273
|
-
assert(Object.keys(second).length === 0, 'second call must return empty object (buffer cleared)');
|
|
1274
|
-
} finally {
|
|
1275
|
-
s.close();
|
|
1276
|
-
}
|
|
1277
|
-
});
|
|
1278
|
-
|
|
1279
|
-
// ── 41. rejectDialog:true prevents deadlock ───────────────────────────
|
|
1280
|
-
await run('41. rejectDialog:true option', async () => {
|
|
1281
|
-
const s = mkSession();
|
|
1282
|
-
try {
|
|
1283
|
-
// Install a handler so interrupt → Dialog[]
|
|
1284
|
-
await s.evaluate(
|
|
1285
|
-
'Quiet[Internal`AddHandler["Interrupt", Function[{}, Dialog[]]]]',
|
|
1286
|
-
{ rejectDialog: true }
|
|
1287
|
-
);
|
|
1288
|
-
// Evaluate with rejectDialog; any dialog gets silently closed
|
|
1289
|
-
const r = await withTimeout(
|
|
1290
|
-
s.evaluate('Pause[0.3]; "ok"', { rejectDialog: true }),
|
|
1291
|
-
5000, 'test 41 eval'
|
|
1292
|
-
);
|
|
1293
|
-
assert(r.result.value === 'ok', `expected "ok", got "${r.result.value}"`);
|
|
1294
|
-
} finally {
|
|
1295
|
-
s.close();
|
|
1296
|
-
}
|
|
1297
|
-
});
|
|
1298
|
-
|
|
1299
|
-
// ── 42. abort deduplication — multiple abort() calls don't corrupt ────
|
|
1300
|
-
await run('42. abort deduplication', async () => {
|
|
1301
|
-
const s = mkSession();
|
|
1302
|
-
try {
|
|
1303
|
-
const p = s.evaluate('Pause[5]; 42');
|
|
1304
|
-
await sleep(100);
|
|
1305
|
-
// Fire three rapid aborts — only first should take effect
|
|
1306
|
-
s.abort();
|
|
1307
|
-
s.abort();
|
|
1308
|
-
s.abort();
|
|
1309
|
-
const r = await withTimeout(p, 6000, 'test 42 abort settle');
|
|
1310
|
-
assert(r.aborted, 'evaluation should be aborted');
|
|
1311
|
-
// Kernel must still be usable
|
|
1312
|
-
const r2 = await withTimeout(s.evaluate('"alive"'), 5000, 'test 42 post-abort');
|
|
1313
|
-
assert(r2.result.value === 'alive', 'kernel must still work after multi-abort');
|
|
1314
|
-
} finally {
|
|
1315
|
-
s.close();
|
|
1316
|
-
}
|
|
1317
|
-
});
|
|
1318
|
-
|
|
1319
|
-
// ── 43. dynamicActive accessor ────────────────────────────────────────
|
|
1320
|
-
await run('43. dynamicActive accessor reflects state', async () => {
|
|
1321
|
-
const s = mkSession();
|
|
1322
|
-
try {
|
|
1323
|
-
assert(!s.dynamicActive, 'dynamicActive must be false initially');
|
|
1324
|
-
s.registerDynamic('v', 'ToString[1]');
|
|
1325
|
-
assert(!s.dynamicActive, 'dynamicActive must remain false until interval set');
|
|
1326
|
-
s.setDynamicInterval(200);
|
|
1327
|
-
assert(s.dynamicActive, 'dynamicActive must be true after registration + interval');
|
|
1328
|
-
s.clearDynamicRegistry();
|
|
1329
|
-
assert(!s.dynamicActive, 'dynamicActive must be false after clear');
|
|
1330
|
-
} finally {
|
|
1331
|
-
s.close();
|
|
1332
|
-
}
|
|
1333
|
-
});
|
|
1334
|
-
|
|
1335
|
-
// ── 44. clearDynamicRegistry empties getDynamicResults ────────────────
|
|
1336
|
-
await run('44. clearDynamicRegistry', async () => {
|
|
1337
|
-
const s = mkSession();
|
|
1338
|
-
try {
|
|
1339
|
-
s.registerDynamic('p', 'ToString[Pi, 5]');
|
|
1340
|
-
s.setDynamicInterval(150);
|
|
1341
|
-
await withTimeout(s.evaluate('Pause[0.5]; "done"'), 6000, 'test 44 eval');
|
|
1342
|
-
s.clearDynamicRegistry();
|
|
1343
|
-
// After clearing, a new eval should produce no dyn results
|
|
1344
|
-
await withTimeout(s.evaluate('"x"'), 5000, 'test 44 second eval');
|
|
1345
|
-
const res = s.getDynamicResults();
|
|
1346
|
-
assert(Object.keys(res).length === 0, 'no results expected after clearDynamicRegistry');
|
|
1347
|
-
} finally {
|
|
1348
|
-
s.close();
|
|
1349
|
-
}
|
|
1350
|
-
});
|
|
1351
|
-
|
|
1352
|
-
// ── 45. unregisterDynamic removes one entry ───────────────────────────
|
|
1353
|
-
await run('45. unregisterDynamic removes one entry', async () => {
|
|
1354
|
-
const s = mkSession();
|
|
1355
|
-
try {
|
|
1356
|
-
s.registerDynamic('keep', 'ToString[3+3]');
|
|
1357
|
-
s.registerDynamic('drop', 'ToString[4+4]');
|
|
1358
|
-
s.unregisterDynamic('drop');
|
|
1359
|
-
s.setDynamicInterval(150);
|
|
1360
|
-
await withTimeout(s.evaluate('Pause[0.8]; "done"'), 8000, 'test 45 eval');
|
|
1361
|
-
const res = s.getDynamicResults();
|
|
1362
|
-
assert('keep' in res, 'key "keep" must still be present');
|
|
1363
|
-
assert(!('drop' in res), 'key "drop" must be absent after unregister');
|
|
1364
|
-
} finally {
|
|
1365
|
-
s.close();
|
|
1366
|
-
}
|
|
1367
|
-
});
|
|
1368
|
-
|
|
1369
|
-
// ── 46. setDynamicInterval(0) stops timer ────────────────────────────
|
|
1370
|
-
await run('46. setDynamicInterval(0) disables timer', async () => {
|
|
1371
|
-
const s = mkSession();
|
|
1372
|
-
try {
|
|
1373
|
-
s.registerDynamic('z', 'ToString[7+7]');
|
|
1374
|
-
s.setDynamicInterval(100);
|
|
1375
|
-
assert(s.dynamicActive, 'dynamicActive should be true');
|
|
1376
|
-
s.setDynamicInterval(0);
|
|
1377
|
-
assert(!s.dynamicActive, 'dynamicActive must be false after interval set to 0');
|
|
1378
|
-
// Even with a long eval, no dynamic results should accumulate
|
|
1379
|
-
await withTimeout(s.evaluate('Pause[0.5]; "ok"'), 6000, 'test 46 eval');
|
|
1380
|
-
const res = s.getDynamicResults();
|
|
1381
|
-
assert(Object.keys(res).length === 0, 'no Dynamic results expected when timer is disabled');
|
|
1382
|
-
} finally {
|
|
1383
|
-
s.close();
|
|
1384
|
-
}
|
|
1385
|
-
});
|
|
1386
|
-
|
|
1387
|
-
// ── 47. setDynAutoMode(false) falls back to legacy JS dialog path ─────
|
|
1388
|
-
await run('47. setDynAutoMode(false) — legacy JS dialog path works', async () => {
|
|
1389
|
-
const s = mkSession();
|
|
1390
|
-
try {
|
|
1391
|
-
s.setDynAutoMode(false);
|
|
1392
|
-
let dialogOpened = false;
|
|
1393
|
-
// Install interrupt handler manually (required for legacy path)
|
|
1394
|
-
await installHandler(s);
|
|
1395
|
-
// Use a loop with short Pause so the interrupt fires between iterations
|
|
1396
|
-
// (single Pause[] absorbs interrupts until it completes).
|
|
1397
|
-
const p = s.evaluate('Do[Pause[0.15], {20}]; "done"', {
|
|
1398
|
-
onDialogBegin: async () => {
|
|
1399
|
-
dialogOpened = true;
|
|
1400
|
-
await s.exitDialog();
|
|
1401
|
-
},
|
|
1402
|
-
});
|
|
1403
|
-
await sleep(400);
|
|
1404
|
-
s.interrupt();
|
|
1405
|
-
const r = await withTimeout(p, 10000, 'test 47 legacy dialog eval');
|
|
1406
|
-
assert(dialogOpened, 'onDialogBegin should have fired in legacy mode');
|
|
1407
|
-
assert(!r.aborted, 'should not be aborted');
|
|
1408
|
-
} finally {
|
|
1409
|
-
s.close();
|
|
1410
|
-
}
|
|
1411
|
-
});
|
|
1412
|
-
|
|
1413
|
-
// ── 48. Dynamic: abort followed by new dynAutoMode eval works ─────────
|
|
1414
|
-
await run('48. abort then Dynamic eval recovers', async () => {
|
|
1415
|
-
const s = mkSession();
|
|
1416
|
-
try {
|
|
1417
|
-
s.registerDynamic('n', 'ToString[42]');
|
|
1418
|
-
s.setDynamicInterval(200);
|
|
1419
|
-
// Start a long eval, abort it
|
|
1420
|
-
const p = s.evaluate('Pause[10]; 0');
|
|
1421
|
-
await sleep(150);
|
|
1422
|
-
s.abort();
|
|
1423
|
-
const r = await withTimeout(p, 5000, 'test 48 abort wait');
|
|
1424
|
-
assert(r.aborted, 'first eval should be aborted');
|
|
1425
|
-
// Now a new eval must work normally with dyn interrupts
|
|
1426
|
-
const r2 = await withTimeout(s.evaluate('Pause[0.8]; "alive"'), 8000, 'test 48 second eval');
|
|
1427
|
-
assert(r2.result.value === 'alive', 'kernel alive after abort');
|
|
1428
|
-
// Dynamic results should be populated
|
|
1429
|
-
const res = s.getDynamicResults();
|
|
1430
|
-
assert('n' in res, 'dynamic result "n" expected after abort recovery');
|
|
1431
|
-
assert(res.n.value === '42', `expected "42", got "${res.n.value}"`);
|
|
1432
|
-
} finally {
|
|
1433
|
-
s.close();
|
|
1434
|
-
}
|
|
1435
|
-
});
|
|
1436
|
-
|
|
1437
|
-
// ── 49. Rapid cell transitions with Dynamic — no deadlock ─────────────
|
|
1438
|
-
// Tests rapid cell transitions with Dialog[] firing frequently. The C++
|
|
1439
|
-
// layer handles the case where EvaluatePacket gets processed inside a
|
|
1440
|
-
// Dialog[] context (between-eval ScheduledTask fire) by capturing the
|
|
1441
|
-
// outer RETURNPKT and returning it directly.
|
|
1442
|
-
await run('49. rapid cell transitions with Dynamic active', async () => {
|
|
1443
|
-
const s = mkSession();
|
|
1444
|
-
try {
|
|
1445
|
-
s.registerDynamic('tick', 'ToString[$Line]');
|
|
1446
|
-
s.setDynamicInterval(200);
|
|
1447
|
-
for (let i = 0; i < 5; i++) {
|
|
1448
|
-
const r = await withTimeout(
|
|
1449
|
-
s.evaluate(`Pause[0.3]; ${i}`),
|
|
1450
|
-
10000, `test 49 cell ${i}`
|
|
1451
|
-
);
|
|
1452
|
-
assert(!r.aborted, `cell ${i} must not be aborted`);
|
|
1453
|
-
assert(String(r.result.value) === String(i),
|
|
1454
|
-
`cell ${i}: expected "${i}", got "${r.result.value}"`);
|
|
1455
|
-
}
|
|
1456
|
-
} finally {
|
|
1457
|
-
s.close();
|
|
1458
|
-
}
|
|
1459
|
-
});
|
|
1460
|
-
|
|
1461
|
-
// ── 50. registerDynamic upsert — updates existing entry ───────────────
|
|
1462
|
-
await run('50. registerDynamic upsert updates existing entry', async () => {
|
|
1463
|
-
const s = mkSession();
|
|
1464
|
-
try {
|
|
1465
|
-
await s.evaluate('q = 5;');
|
|
1466
|
-
s.registerDynamic('q', 'ToString[q]'); // initial registration
|
|
1467
|
-
s.registerDynamic('q', 'ToString[q*2]'); // upsert — should override
|
|
1468
|
-
s.setDynamicInterval(150);
|
|
1469
|
-
await withTimeout(s.evaluate('Pause[0.8]; "done"'), 8000, 'test 50 eval');
|
|
1470
|
-
const res = s.getDynamicResults();
|
|
1471
|
-
assert('q' in res, 'key "q" must be present');
|
|
1472
|
-
assert(res.q.value === '10', `expected "10" (q*2), got "${res.q.value}"`);
|
|
1473
|
-
} finally {
|
|
1474
|
-
s.close();
|
|
1475
|
-
}
|
|
1476
|
-
});
|
|
1477
|
-
|
|
1478
|
-
// ── 51. empty registry + interval — eval completes normally ──────────
|
|
1479
|
-
await run('51. empty registry with interval set — eval completes', async () => {
|
|
1480
|
-
const s = mkSession();
|
|
1481
|
-
try {
|
|
1482
|
-
s.setDynamicInterval(100);
|
|
1483
|
-
// No registrations — dynamicActive must be false
|
|
1484
|
-
assert(!s.dynamicActive, 'dynamicActive must be false with empty registry');
|
|
1485
|
-
const r = await withTimeout(s.evaluate('"noblock"'), 5000, 'test 51 eval');
|
|
1486
|
-
assert(r.result.value === 'noblock', 'eval should complete normally');
|
|
1487
|
-
} finally {
|
|
1488
|
-
s.close();
|
|
1489
|
-
}
|
|
1490
|
-
});
|
|
1491
|
-
|
|
1492
|
-
// ── 52. drainStalePackets — eval after stale BEGINDLGPKT survives ─────
|
|
1493
|
-
await run('52. evaluate survives stale BEGINDLGPKT (Pattern D)', async () => {
|
|
1494
|
-
const s = mkSession();
|
|
1495
|
-
try {
|
|
1496
|
-
// Install handler so any interrupt produces Dialog[], then evalute with
|
|
1497
|
-
// rejectDialog so the stale packet is auto-drained in C++
|
|
1498
|
-
await s.evaluate(
|
|
1499
|
-
'Quiet[Internal`AddHandler["Interrupt", Function[{}, Dialog[]]]]',
|
|
1500
|
-
{ rejectDialog: true }
|
|
1501
|
-
);
|
|
1502
|
-
for (let i = 0; i < 5; i++) {
|
|
1503
|
-
const r = await withTimeout(
|
|
1504
|
-
s.evaluate(`Pause[0.2]; ${i}`, { rejectDialog: true }),
|
|
1505
|
-
6000, `test 52 iteration ${i}`
|
|
1506
|
-
);
|
|
1507
|
-
assert(!r.aborted, `iteration ${i} must not be aborted`);
|
|
1508
|
-
assert(String(r.result.value) === String(i),
|
|
1509
|
-
`iteration ${i}: expected "${i}", got "${r.result.value}"`);
|
|
1510
|
-
}
|
|
1511
|
-
} finally {
|
|
1512
|
-
s.close();
|
|
1513
|
-
}
|
|
1514
|
-
});
|
|
1515
|
-
|
|
1516
|
-
// ── 53. Bug 1A: stale ScheduledTask + non-Dynamic cell does not hang ───
|
|
1517
|
-
// Simulates the subsession.js teardown: Dynamic cell runs → cleanup calls
|
|
1518
|
-
// setDynAutoMode(false) but the kernel-side ScheduledTask may still fire
|
|
1519
|
-
// one more Dialog[]. The next plain cell (no onDialogBegin) must not hang.
|
|
1520
|
-
await run('53. stale ScheduledTask Dialog[] after setDynAutoMode(false) — no hang (Bug 1A)', async () => {
|
|
1521
|
-
const s = mkSession();
|
|
1522
|
-
try {
|
|
1523
|
-
// Step 1: register a Dynamic expr and start the interval.
|
|
1524
|
-
s.registerDynamic('dyn0', 'ToString[AbsoluteTime[], InputForm]');
|
|
1525
|
-
s.setDynamicInterval(300);
|
|
1526
|
-
|
|
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[].
|
|
1530
|
-
const r1 = await withTimeout(
|
|
1531
|
-
s.evaluate('Do[Pause[0.1], {20}]; "cell1"', { interactive: true }),
|
|
1532
|
-
12000, '53 cell1'
|
|
1533
|
-
);
|
|
1534
|
-
assert(!r1.aborted && r1.result.value === 'cell1',
|
|
1535
|
-
`cell1 result: ${JSON.stringify(r1.result)}`);
|
|
1536
|
-
|
|
1537
|
-
// Step 3: simulate subsession.js cleanup.
|
|
1538
|
-
// Fix 1B: setDynAutoMode(false) now also sets dynIntervalMs_=0.
|
|
1539
|
-
s.unregisterDynamic('dyn0');
|
|
1540
|
-
s.setDynAutoMode(false);
|
|
1541
|
-
|
|
1542
|
-
// Step 4: plain cell with no onDialogBegin — must not hang.
|
|
1543
|
-
const r2 = await withTimeout(
|
|
1544
|
-
s.evaluate('n = 1'),
|
|
1545
|
-
10000, '53 cell2 — would hang without Fix 1A'
|
|
1546
|
-
);
|
|
1547
|
-
assert(!r2.aborted, 'cell2 must not be aborted');
|
|
1548
|
-
|
|
1549
|
-
// Step 5: follow-up eval also works.
|
|
1550
|
-
const r3 = await withTimeout(s.evaluate('1 + 1'), 6000, '53 cell3');
|
|
1551
|
-
assert(r3.result.value === 2, `cell3 expected 2, got ${r3.result.value}`);
|
|
1552
|
-
} finally {
|
|
1553
|
-
s.close();
|
|
1554
|
-
}
|
|
1555
|
-
}, 45000);
|
|
1556
|
-
|
|
1557
|
-
// ── 54. Bug 1A: BEGINDLGPKT with dynAutoMode=false, no JS callback ────
|
|
1558
|
-
// With dynAutoMode=false and no onDialogBegin registered, any BEGINDLGPKT
|
|
1559
|
-
// must be auto-closed by the safety fallback rather than entering the
|
|
1560
|
-
// legacy loop where nobody will ever call exitDialog().
|
|
1561
|
-
await run('54. BEGINDLGPKT safety fallback — auto-close when no onDialogBegin (Bug 1A)', async () => {
|
|
1562
|
-
const s = mkSession();
|
|
1563
|
-
try {
|
|
1564
|
-
s.setDynAutoMode(false);
|
|
1565
|
-
// Eval runs for ~2s; no onDialogBegin registered. Any BEGINDLGPKT
|
|
1566
|
-
// that arrives (from a stale ScheduledTask or concurrent interrupt)
|
|
1567
|
-
// must be auto-closed by the safety fallback.
|
|
1568
|
-
const r = await withTimeout(
|
|
1569
|
-
s.evaluate('Do[Pause[0.2], {10}]; "done"'),
|
|
1570
|
-
15000, '54 eval — would hang without Fix 1A'
|
|
1571
|
-
);
|
|
1572
|
-
assert(!r.aborted, 'eval must not be aborted');
|
|
1573
|
-
assert(r.result.value === 'done', `expected "done", got "${r.result.value}"`);
|
|
1574
|
-
|
|
1575
|
-
// Follow-up eval must work — no leftover packets.
|
|
1576
|
-
const r2 = await withTimeout(s.evaluate('2 + 2'), 6000, '54 follow-up');
|
|
1577
|
-
assert(r2.result.value === 4, `follow-up expected 4, got ${r2.result.value}`);
|
|
1578
|
-
} finally {
|
|
1579
|
-
s.close();
|
|
1580
|
-
}
|
|
1581
|
-
}, 30000);
|
|
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
|
-
|
|
1671
|
-
// ── Teardown ──────────────────────────────────────────────────────────
|
|
1672
|
-
_mainSession = null;
|
|
1673
|
-
session.close();
|
|
1674
|
-
assert(!session.isOpen, 'main session not closed');
|
|
1675
|
-
|
|
1676
|
-
// Kill the watchdog process — suite completed in time.
|
|
1677
|
-
try { _watchdogProc.kill('SIGKILL'); } catch (_) {}
|
|
1678
|
-
|
|
1679
|
-
console.log();
|
|
1680
|
-
if (failed === 0) {
|
|
1681
|
-
console.log(`All ${passed} tests passed${skipped ? ` (${skipped} skipped)` : ''}.`);
|
|
1682
|
-
// Force-exit: WSTP library may keep libuv handles alive after WSClose,
|
|
1683
|
-
// preventing the event loop from draining naturally. All assertions are
|
|
1684
|
-
// done; a clean exit(0) is safe.
|
|
1685
|
-
process.exit(0);
|
|
1686
|
-
} else {
|
|
1687
|
-
console.log(`${passed} passed, ${failed} failed${skipped ? `, ${skipped} skipped` : ''}.`);
|
|
1688
|
-
process.exit(1);
|
|
1689
|
-
}
|
|
1690
|
-
}
|
|
1691
|
-
|
|
1692
|
-
main().catch(e => {
|
|
1693
|
-
console.error('Fatal:', e);
|
|
1694
|
-
process.exit(1);
|
|
1695
|
-
});
|