wstp-node 0.3.0 → 0.4.1

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.
@@ -0,0 +1,171 @@
1
+ # Building wstp-node on Windows
2
+
3
+ This guide walks through compiling the native WSTP addon (`wstp.node`) on a
4
+ Windows 10 / 11 x64 machine so that the
5
+ [Wolfbook VS Code extension](https://github.com/vanbaalon/wolfbook) can use it
6
+ without any build step on the end-user's machine.
7
+
8
+ ---
9
+
10
+ ## Prerequisites
11
+
12
+ ### 1. Wolfram Engine (or Mathematica)
13
+
14
+ Download the free **Wolfram Engine** from:
15
+ https://wolfram.com/engine/
16
+
17
+ Default installation path after setup:
18
+ ```
19
+ C:\Program Files\Wolfram Research\Wolfram Engine\14.x\
20
+ ```
21
+
22
+ Verify the WSTP DeveloperKit is present:
23
+ ```
24
+ C:\Program Files\Wolfram Research\Wolfram Engine\14.x\SystemFiles\Links\WSTP\DeveloperKit\Windows-x86-64\CompilerAdditions\
25
+ ```
26
+ The key files needed are `wstp64i4s.lib` and `wstp.h` inside that folder.
27
+
28
+ > If your Wolfram version or install path differs, set the `WSTP_DIR` environment
29
+ > variable to the `CompilerAdditions` folder before building:
30
+ > ```powershell
31
+ > $env:WSTP_DIR = "C:\Program Files\Wolfram Research\Wolfram Engine\14.x\SystemFiles\Links\WSTP\DeveloperKit\Windows-x86-64\CompilerAdditions"
32
+ > ```
33
+
34
+ ---
35
+
36
+ ### 2. Visual Studio Build Tools (C++ compiler)
37
+
38
+ Run the following in **PowerShell as Administrator**:
39
+
40
+ ```powershell
41
+ winget install Microsoft.VisualStudio.2022.BuildTools
42
+ ```
43
+
44
+ Then open **Visual Studio Installer** → **Modify** → select the
45
+ **"Desktop development with C++"** workload. Make sure these components
46
+ are checked:
47
+
48
+ - ✅ MSVC v143 – VS 2022 C++ x64/x86 build tools
49
+ - ✅ Windows 11 SDK (or Windows 10 SDK)
50
+ - ✅ C++ CMake tools (optional but useful)
51
+
52
+ ---
53
+
54
+ ### 3. Node.js ≥ 18 (LTS)
55
+
56
+ ```powershell
57
+ winget install OpenJS.NodeJS.LTS
58
+ ```
59
+
60
+ Restart your shell after installation, then verify:
61
+
62
+ ```powershell
63
+ node --version # 20.x or 22.x
64
+ npm --version
65
+ ```
66
+
67
+ ---
68
+
69
+ ### 4. Git
70
+
71
+ ```powershell
72
+ winget install Git.Git
73
+ ```
74
+
75
+ ---
76
+
77
+ ## Build
78
+
79
+ Open a **Developer PowerShell for VS 2022** (or a regular PowerShell — `node-gyp`
80
+ finds MSVC automatically if the Build Tools are installed correctly).
81
+
82
+ ```powershell
83
+ git clone https://github.com/vanbaalon/mathematica-wstp-node.git
84
+ cd mathematica-wstp-node
85
+
86
+ npm install
87
+ npm run build
88
+ ```
89
+
90
+ A successful build prints:
91
+ ```
92
+ Build succeeded: build\Release\wstp.node
93
+ ```
94
+
95
+ ### Verify the build
96
+
97
+ ```powershell
98
+ node -e "const {WstpSession}=require('./build/Release/wstp.node'); const s=new WstpSession(); s.evaluate('1+1').then(r=>{console.log(r.result.value); s.close()})"
99
+ ```
100
+ Expected output: `2`
101
+
102
+ ---
103
+
104
+ ## Contributing the binary to the Wolfbook extension
105
+
106
+ After building, copy the binary to the extension's `wstp/prebuilt/` folder and
107
+ commit it so CI can package the Windows VSIX:
108
+
109
+ ```powershell
110
+ # from inside the mathematica-wstp-node directory:
111
+ copy build\Release\wstp.node ..\wolfbook\wstp\prebuilt\wstp-win32-x64.node
112
+ ```
113
+
114
+ Then on macOS / Linux, run the deploy script to build both platform VSIXs:
115
+
116
+ ```bash
117
+ bash deploy-extension.sh package
118
+ ```
119
+
120
+ ---
121
+
122
+ ## Setting up a self-hosted GitHub Actions runner (optional)
123
+
124
+ If you want CI to build the Windows binary automatically on every release tag:
125
+
126
+ **In your browser:** go to
127
+ `https://github.com/vanbaalon/mathematica-wstp-node` →
128
+ Settings → Actions → Runners → **New self-hosted runner** → Windows x64
129
+
130
+ GitHub shows you a unique token and the exact download URL. Run those commands
131
+ in **PowerShell as Administrator**:
132
+
133
+ ```powershell
134
+ mkdir C:\actions-runner; cd C:\actions-runner
135
+
136
+ # Replace the URL and token with what GitHub shows you:
137
+ Invoke-WebRequest -Uri https://github.com/actions/runner/releases/download/v2.x.x/actions-runner-win-x64-2.x.x.zip -OutFile runner.zip
138
+ Add-Type -AssemblyName System.IO.Compression.FileSystem
139
+ [System.IO.Compression.ZipFile]::ExtractToDirectory("$PWD\runner.zip", "$PWD")
140
+
141
+ .\config.cmd --url https://github.com/vanbaalon/mathematica-wstp-node --token YOUR_TOKEN_HERE
142
+
143
+ # Install as a Windows service so it runs even when you are logged out:
144
+ .\svc.cmd install
145
+ .\svc.cmd start
146
+ ```
147
+
148
+ Verify the runner is online: GitHub → Settings → Actions → Runners → green dot.
149
+
150
+ Once both the macOS and Windows runners are registered, push a version tag from
151
+ your Mac to trigger a full build:
152
+
153
+ ```bash
154
+ git tag v0.5.0 && git push origin v0.5.0
155
+ ```
156
+
157
+ The workflow builds both binaries and publishes them as assets on the
158
+ `mathematica-wstp-node` GitHub Release. The `wolfbook` workflow then downloads
159
+ those assets and packages the platform-specific VSIXs automatically.
160
+
161
+ ---
162
+
163
+ ## Troubleshooting
164
+
165
+ | Problem | Solution |
166
+ |---------|----------|
167
+ | `gyp ERR! find VS` | Open Developer PowerShell for VS 2022, or run `npm config set msvs_version 2022` |
168
+ | `LINK : fatal error LNK1181: cannot open input file 'wstp64i4s.lib'` | WSTP DeveloperKit not found — set `WSTP_DIR` (see Prerequisites) |
169
+ | `node.exe` crashes on `require('./build/Release/wstp.node')` | Node.js architecture mismatch — ensure you installed 64-bit Node |
170
+ | Runner shows offline | `cd C:\actions-runner && .\svc.cmd status`, then `.\svc.cmd start` |
171
+ | `error MSB8036: The Windows SDK version X was not found` | Install the matching Windows SDK via Visual Studio Installer |
package/README.md CHANGED
@@ -23,6 +23,7 @@ const { WstpSession, WstpReader, setDiagHandler } = require('./build/Release/wst
23
23
  - [`evaluate(expr, opts?)`](#evaluateexpr-opts) — queue an expression for evaluation; supports streaming `Print` callbacks
24
24
  - [`sub(expr)`](#subexpr) — priority evaluation that jumps ahead of the queue, for quick queries during a long computation
25
25
  - [`abort()`](#abort) — interrupt the currently running evaluation
26
+ - [`closeAllDialogs()`](#closealldialogs) — immediately reject all pending dialog promises and reset dialog state
26
27
  - [`dialogEval(expr)`](#dialogevalexpr) — evaluate inside an active `Dialog[]` subsession
27
28
  - [`exitDialog(retVal?)`](#exitdialogretval) — exit the current dialog and resume the main evaluation
28
29
  - [`interrupt()`](#interrupt) — send a low-level interrupt signal to the kernel
@@ -53,11 +54,16 @@ const { WstpSession, WstpReader, setDiagHandler } = require('./build/Release/wst
53
54
 
54
55
  | Requirement | Notes |
55
56
  |-------------|-------|
56
- | macOS | Tested on macOS 13+; Linux should work with minor path changes |
57
+ | macOS | Tested on macOS 13+ (ARM64 and x86-64) |
58
+ | Windows 10/11 x64 | See [InstallationWindows.md](InstallationWindows.md) for the full Windows guide |
59
+ | Linux x86-64 / ARM64 | Should work with standard `node-gyp` toolchain |
57
60
  | Node.js ≥ 18 | Earlier versions may work but are untested |
58
- | Clang / Xcode Command Line Tools | `xcode-select --install` |
61
+ | Clang / Xcode Command Line Tools (macOS) | `xcode-select --install` |
62
+ | MSVC Build Tools with C++ workload (Windows) | Visual Studio 2019+ or Build Tools |
59
63
  | Wolfram Mathematica or Wolfram Engine | Provides `WolframKernel` and the WSTP SDK headers/libraries |
60
64
 
65
+ > **Windows users:** follow [InstallationWindows.md](InstallationWindows.md) instead of the steps below.
66
+
61
67
  ### 1. Clone
62
68
 
63
69
  ```bash
@@ -91,7 +97,7 @@ The script automatically locates the WSTP SDK inside the default Wolfram install
91
97
  node test.js
92
98
  ```
93
99
 
94
- Expected last line: `All 28 tests passed.`
100
+ Expected last line: `All 36 tests passed.`
95
101
 
96
102
  A more comprehensive suite (both modes + In/Out + comparison) lives in `tmp/tests_all.js`:
97
103
 
@@ -317,6 +323,43 @@ const r = await p;
317
323
 
318
324
  ---
319
325
 
326
+ ### `closeAllDialogs()`
327
+
328
+ ```ts
329
+ session.closeAllDialogs(): boolean
330
+ ```
331
+
332
+ Unconditionally reset all dialog state on the Node.js side.
333
+
334
+ - Drains the internal dialog queue, immediately **rejecting** every pending `dialogEval()` and
335
+ `exitDialog()` promise with an error — no callers are left waiting forever.
336
+ - Clears `isDialogOpen` to `false`.
337
+
338
+ This does **not** send any packet to the kernel — it only fixes Node-side bookkeeping. Use
339
+ it in error-recovery paths, before `abort()`, or whenever you need to guarantee clean dialog
340
+ state without knowing whether a dialog is actually still running.
341
+
342
+ Returns `true` if `isDialogOpen` was `true` before the call (something was cleaned up),
343
+ `false` if it was already clear.
344
+
345
+ ```js
346
+ // Safe no-op when there is no open dialog:
347
+ const cleaned = session.closeAllDialogs(); // false
348
+
349
+ // Reliable recovery pattern before abort:
350
+ session.closeAllDialogs(); // reject any hanging dialog promises immediately
351
+ session.abort();
352
+
353
+ // Queued dialogEval() promises reject with a descriptive error:
354
+ const p = session.evaluate('Dialog[]');
355
+ await pollUntil(() => session.isDialogOpen);
356
+ const pe = session.dialogEval('1 + 1').catch(e => e.message);
357
+ session.closeAllDialogs(); // pe rejects → "dialog closed by closeAllDialogs"
358
+ session.abort();
359
+ ```
360
+
361
+ ---
362
+
320
363
  ### `dialogEval(expr)`
321
364
 
322
365
  ```ts
@@ -990,6 +1033,8 @@ kb.close();
990
1033
  | Abort | `evaluate()` **resolves** with `aborted: true`, `result.value === '$Aborted'` |
991
1034
  | Kernel crashes | `evaluate()` rejects with a link error message — create a new `WstpSession` |
992
1035
  | `dialogEval()` / `exitDialog()` when no dialog open | Rejects with `"no dialog subsession is open"` |
1036
+ | `dialogEval()` / `exitDialog()` when flushed by `closeAllDialogs()` | Rejects with `"dialog closed by closeAllDialogs"` |
1037
+ | `abort()` / `closeAllDialogs()` flushes dialog queue | Pending `dialogEval()`/`exitDialog()` promises reject immediately |
993
1038
  | `evaluate()` after `close()` | Rejects with `"Session is closed"` |
994
1039
  | `WstpReader.readNext()` after link closes | Rejects with a link-closed error |
995
1040
 
package/binding.gyp CHANGED
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "variables": {
3
- # Detect arch: arm64 → MacOSX-ARM64, x86_64 MacOSX-x86-64
4
- # Override the whole path by setting WSTP_DIR in the environment.
5
- "wstp_dir%": "<!(echo \"${WSTP_DIR:-/Applications/Wolfram 3.app/Contents/SystemFiles/Links/WSTP/DeveloperKit/$(uname -m | sed 's/x86_64/MacOSX-x86-64/;s/arm64/MacOSX-ARM64/')/CompilerAdditions}\")"
3
+ # Cross-platform WSTP DeveloperKit path.
4
+ # Override by setting WSTP_DIR in the environment.
5
+ # See scripts/wstp_dir.js for auto-detection logic.
6
+ "wstp_dir%": "<!@(node scripts/wstp_dir.js)"
6
7
  },
7
8
  "targets": [
8
9
  {
@@ -16,13 +17,9 @@
16
17
 
17
18
  "defines": ["NAPI_DISABLE_CPP_EXCEPTIONS"],
18
19
 
19
- "libraries": [
20
- # The static library ships as libWSTPi4.a in the macOS DeveloperKit.
21
- "<(wstp_dir)/libWSTPi4.a"
22
- ],
23
-
24
20
  "conditions": [
25
21
  ["OS=='mac'", {
22
+ "libraries": ["<(wstp_dir)/libWSTPi4.a"],
26
23
  "xcode_settings": {
27
24
  "GCC_ENABLE_CPP_EXCEPTIONS": "YES",
28
25
  "MACOSX_DEPLOYMENT_TARGET": "11.0",
@@ -37,8 +34,9 @@
37
34
  }
38
35
  }],
39
36
  ["OS=='linux'", {
37
+ "libraries": ["<(wstp_dir)/libWSTPi4.a"],
40
38
  "cflags_cc": ["-std=c++17", "-Wall", "-Wextra"],
41
- "libraries": ["-lrt", "-lpthread", "-ldl", "-lm"]
39
+ "link_settings": { "libraries": ["-lrt", "-lpthread", "-ldl", "-lm"] }
42
40
  }],
43
41
  ["OS=='win'", {
44
42
  "msvs_settings": {
Binary file
package/index.d.ts CHANGED
@@ -158,6 +158,19 @@ export class WstpSession {
158
158
  /** True while a Dialog[] subsession is open on this link. */
159
159
  readonly isDialogOpen: boolean;
160
160
 
161
+ /**
162
+ * True when the session is fully ready to accept a new evaluation:
163
+ * the kernel is running (`isOpen`), no evaluation is currently executing
164
+ * or queued, and no Dialog[] subsession is open.
165
+ *
166
+ * Equivalent to:
167
+ * `isOpen && !busy && !isDialogOpen && queue.empty() && subQueue.empty()`
168
+ *
169
+ * This is a synchronous snapshot; the value may change on the next tick
170
+ * if an async operation starts or finishes concurrently.
171
+ */
172
+ readonly isReady: boolean;
173
+
161
174
  /**
162
175
  * Interrupt the currently running evaluate() call.
163
176
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wstp-node",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
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",
@@ -20,16 +20,19 @@
20
20
  "license": "MIT",
21
21
  "os": [
22
22
  "darwin",
23
- "linux"
23
+ "linux",
24
+ "win32"
24
25
  ],
25
26
  "engines": {
26
27
  "node": ">=18"
27
28
  },
28
29
  "files": [
29
30
  "src/addon.cc",
31
+ "scripts/wstp_dir.js",
30
32
  "build.sh",
31
33
  "binding.gyp",
32
34
  "index.d.ts",
35
+ "InstallationWindows.md",
33
36
  "examples/",
34
37
  "test.js",
35
38
  "test_interrupt_dialog.js"
@@ -43,9 +46,9 @@
43
46
  "demo": "node examples/demo.js"
44
47
  },
45
48
  "dependencies": {
46
- "node-addon-api": "^8.0.0"
49
+ "node-addon-api": "^8.6.0"
47
50
  },
48
51
  "devDependencies": {
49
- "node-gyp": "^10.0.0"
52
+ "node-gyp": "^12.2.0"
50
53
  }
51
54
  }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * wstp_dir.js
3
+ * Cross-platform helper used by binding.gyp to locate the WSTP DeveloperKit.
4
+ *
5
+ * Usage in binding.gyp: "wstp_dir%": "<!@(node scripts/wstp_dir.js)"
6
+ *
7
+ * Precedence:
8
+ * 1. WSTP_DIR environment variable (always wins)
9
+ * 2. Platform-specific auto-detect below
10
+ */
11
+
12
+ 'use strict';
13
+
14
+ const path = require('path');
15
+ const fs = require('fs');
16
+
17
+ function resolve () {
18
+ // ── 1. Explicit override ────────────────────────────────────────────────
19
+ if (process.env.WSTP_DIR) {
20
+ return process.env.WSTP_DIR;
21
+ }
22
+
23
+ // ── 2. Windows ──────────────────────────────────────────────────────────
24
+ if (process.platform === 'win32') {
25
+ const base = 'C:\\Program Files\\Wolfram Research\\Wolfram Engine';
26
+ let versions;
27
+ try {
28
+ versions = fs.readdirSync(base)
29
+ .filter(d => /^\d/.test(d))
30
+ .sort()
31
+ .reverse();
32
+ } catch (e) {
33
+ throw new Error(
34
+ `WSTP DeveloperKit not found at "${base}".\n` +
35
+ `Install Wolfram Engine or set WSTP_DIR to the CompilerAdditions folder.`
36
+ );
37
+ }
38
+ if (!versions.length) throw new Error(`No Wolfram Engine version found under "${base}"`);
39
+ return path.join(
40
+ base, versions[0],
41
+ 'SystemFiles', 'Links', 'WSTP', 'DeveloperKit',
42
+ 'Windows-x86-64', 'CompilerAdditions'
43
+ );
44
+ }
45
+
46
+ // ── 3. macOS ────────────────────────────────────────────────────────────
47
+ if (process.platform === 'darwin') {
48
+ const { execSync } = require('child_process');
49
+ const machine = execSync('uname -m').toString().trim();
50
+ const archDir = machine === 'arm64' ? 'MacOSX-ARM64' : 'MacOSX-x86-64';
51
+ return path.join(
52
+ '/Applications/Wolfram 3.app/Contents/SystemFiles/Links/WSTP/DeveloperKit',
53
+ archDir, 'CompilerAdditions'
54
+ );
55
+ }
56
+
57
+ // ── 4. Linux ────────────────────────────────────────────────────────────
58
+ {
59
+ const { execSync } = require('child_process');
60
+ const machine = execSync('uname -m').toString().trim();
61
+ const archDir = machine === 'aarch64' ? 'Linux-ARM64' : 'Linux-x86-64';
62
+ const candidates = [
63
+ path.join(process.env.HOME || '/root', 'Wolfram', 'WolframEngine',
64
+ 'SystemFiles', 'Links', 'WSTP', 'DeveloperKit', archDir, 'CompilerAdditions'),
65
+ path.join('/usr/local/Wolfram/WolframEngine',
66
+ 'SystemFiles', 'Links', 'WSTP', 'DeveloperKit', archDir, 'CompilerAdditions'),
67
+ ];
68
+ for (const c of candidates) {
69
+ if (fs.existsSync(c)) return c;
70
+ }
71
+ throw new Error(
72
+ 'WSTP DeveloperKit not found for Linux.\n' +
73
+ 'Set WSTP_DIR to the CompilerAdditions directory.'
74
+ );
75
+ }
76
+ }
77
+
78
+ process.stdout.write(resolve());
package/src/addon.cc CHANGED
@@ -222,6 +222,46 @@ static WExpr ReadExprRaw(WSLINK lp, int depth = 0) {
222
222
  // Forward declaration — WExprToNapi is defined after WstpSession.
223
223
  static Napi::Value WExprToNapi(Napi::Env env, const WExpr& e);
224
224
 
225
+ // ---------------------------------------------------------------------------
226
+ // drainDialogAbortResponse — drain the WSTP link after aborting out of a
227
+ // dialog inner loop.
228
+ //
229
+ // When abort() fires proactively (abortFlag_ is true before the kernel has
230
+ // sent its response), WSAbortMessage has already been posted but the kernel's
231
+ // abort response — RETURNPKT[$Aborted] or ILLEGALPKT — has not yet been read.
232
+ // Leaving it on the link corrupts the next evaluation: it becomes the first
233
+ // packet seen by the next DrainToEvalResult call, which resolves immediately
234
+ // with $Aborted and leaves the real response on the link — permanently
235
+ // degrading the session and disabling subsequent interrupts.
236
+ //
237
+ // Reads and discards packets until RETURNPKT, RETURNEXPRPKT, ILLEGALPKT, or a
238
+ // 10-second wall-clock deadline. Intermediate packets (ENDDLGPKT, TEXTPKT,
239
+ // MESSAGEPKT, MENUPKT, etc.) are silently consumed.
240
+ // ---------------------------------------------------------------------------
241
+ static void drainDialogAbortResponse(WSLINK lp) {
242
+ const auto deadline =
243
+ std::chrono::steady_clock::now() + std::chrono::seconds(10);
244
+ while (std::chrono::steady_clock::now() < deadline) {
245
+ // Poll until data is available or the deadline passes.
246
+ while (std::chrono::steady_clock::now() < deadline) {
247
+ if (WSReady(lp)) break;
248
+ std::this_thread::sleep_for(std::chrono::milliseconds(10));
249
+ }
250
+ if (!WSReady(lp)) return; // timed out — give up
251
+ int pkt = WSNextPacket(lp);
252
+ if (pkt == RETURNPKT || pkt == RETURNEXPRPKT) {
253
+ WSNewPacket(lp);
254
+ return; // outer-eval abort response consumed — link is clean
255
+ }
256
+ if (pkt == ILLEGALPKT || pkt == 0) {
257
+ WSClearError(lp);
258
+ WSNewPacket(lp);
259
+ return; // link reset — clean
260
+ }
261
+ WSNewPacket(lp); // discard intermediate packet (ENDDLGPKT, MENUPKT, …)
262
+ }
263
+ }
264
+
225
265
  // ---------------------------------------------------------------------------
226
266
  // DrainToEvalResult — consume all packets for one cell, capturing Print[]
227
267
  // output and messages. Blocks until RETURNPKT. Thread-pool thread only.
@@ -273,19 +313,32 @@ static EvalResult DrainToEvalResult(WSLINK lp, EvalOptions* opts = nullptr) {
273
313
  WSReleaseString(lp, s);
274
314
  static const std::string NL = "\\012";
275
315
  std::string msg;
316
+ // Extract the message starting from the Symbol::tag position.
317
+ // Wolfram's TEXTPKT for MESSAGEPKT may contain multiple \012-separated
318
+ // sections (e.g. the tag on one line, the body on subsequent lines).
319
+ // Old code stopped at the first \012 after ::, which truncated long
320
+ // messages like NIntegrate::ncvb to just their tag.
321
+ // Fixed: take the whole text from the start of the Symbol name,
322
+ // stripping only leading/trailing \012 groups, and replacing
323
+ // internal \012 with spaces so the full message is one readable line.
276
324
  auto dc = text.find("::");
325
+ size_t raw_start = 0;
277
326
  if (dc != std::string::npos) {
278
327
  auto nl_before = text.rfind(NL, dc);
279
- size_t start = (nl_before != std::string::npos) ? nl_before + 4 : 0;
280
- auto nl_after = text.find(NL, dc);
281
- size_t end = (nl_after != std::string::npos) ? nl_after : text.size();
282
- while (start < end && text[start] == ' ') ++start;
283
- msg = text.substr(start, end - start);
284
- } else {
285
- for (size_t i = 0; i < text.size(); ) {
286
- if (text.compare(i, 4, NL) == 0) { msg += ' '; i += 4; }
287
- else { msg += text[i++]; }
288
- }
328
+ raw_start = (nl_before != std::string::npos) ? nl_before + 4 : 0;
329
+ }
330
+ // Strip trailing \012 sequences from the raw text
331
+ std::string raw = text.substr(raw_start);
332
+ while (raw.size() >= 4 && raw.compare(raw.size() - 4, 4, NL) == 0)
333
+ raw.resize(raw.size() - 4);
334
+ // Strip leading spaces
335
+ size_t sp = 0;
336
+ while (sp < raw.size() && raw[sp] == ' ') ++sp;
337
+ raw = raw.substr(sp);
338
+ // Replace all remaining \012 with a single space
339
+ for (size_t i = 0; i < raw.size(); ) {
340
+ if (raw.compare(i, 4, NL) == 0) { msg += ' '; i += 4; }
341
+ else { msg += raw[i++]; }
289
342
  }
290
343
  if (!forDialog) {
291
344
  r.messages.push_back(msg);
@@ -680,10 +733,14 @@ static EvalResult DrainToEvalResult(WSLINK lp, EvalOptions* opts = nullptr) {
680
733
  // The kernel may be slow to respond (or send RETURNPKT[$Aborted]
681
734
  // without a preceding ENDDLGPKT); bail out proactively to avoid
682
735
  // spinning forever.
736
+ // CRITICAL: always drain the link before returning. If we exit
737
+ // here before the kernel sends its RETURNPKT[$Aborted], that
738
+ // response stays on the link and corrupts the next evaluation.
683
739
  if (opts && opts->abortFlag && opts->abortFlag->load()) {
684
740
  if (opts->dialogOpen) opts->dialogOpen->store(false);
685
741
  r.result = WExpr::mkSymbol("System`$Aborted");
686
742
  r.aborted = true;
743
+ drainDialogAbortResponse(lp); // consume pending abort response
687
744
  return r;
688
745
  }
689
746
 
@@ -821,6 +878,7 @@ static EvalResult DrainToEvalResult(WSLINK lp, EvalOptions* opts = nullptr) {
821
878
  if (opts->dialogOpen) opts->dialogOpen->store(false);
822
879
  r.result = WExpr::mkSymbol("System`$Aborted");
823
880
  r.aborted = true;
881
+ drainDialogAbortResponse(lp); // consume pending abort response
824
882
  return r;
825
883
  }
826
884
  if (opts && opts->dialogPending && opts->dialogPending->load()) {
@@ -1037,6 +1095,7 @@ public:
1037
1095
  std::string expr,
1038
1096
  EvalOptions opts,
1039
1097
  std::atomic<bool>& abortFlag,
1098
+ std::atomic<bool>& workerReadingLink,
1040
1099
  std::function<void()> completionCb,
1041
1100
  int64_t cellIndex,
1042
1101
  bool interactive = false)
@@ -1047,6 +1106,7 @@ public:
1047
1106
  interactive_(interactive),
1048
1107
  opts_(std::move(opts)),
1049
1108
  abortFlag_(abortFlag),
1109
+ workerReadingLink_(workerReadingLink),
1050
1110
  completionCb_(std::move(completionCb)),
1051
1111
  cellIndex_(cellIndex)
1052
1112
  {}
@@ -1072,10 +1132,12 @@ public:
1072
1132
  WSFlush (lp_);
1073
1133
  }
1074
1134
  if (!sent) {
1135
+ workerReadingLink_.store(false, std::memory_order_release);
1075
1136
  SetError("Failed to send packet to kernel");
1076
1137
  } else {
1077
1138
  opts_.interactive = interactive_;
1078
1139
  result_ = DrainToEvalResult(lp_, &opts_);
1140
+ workerReadingLink_.store(false, std::memory_order_release); // lp_ no longer in use
1079
1141
  if (!interactive_) {
1080
1142
  // EvaluatePacket mode: kernel never sends INPUTNAMEPKT/OUTPUTNAMEPKT,
1081
1143
  // so stamp the pre-captured counter and derive outputName manually.
@@ -1160,6 +1222,7 @@ private:
1160
1222
  std::string expr_;
1161
1223
  EvalOptions opts_;
1162
1224
  std::atomic<bool>& abortFlag_;
1225
+ std::atomic<bool>& workerReadingLink_;
1163
1226
  std::function<void()> completionCb_;
1164
1227
  int64_t cellIndex_;
1165
1228
  bool interactive_;
@@ -1182,10 +1245,12 @@ public:
1182
1245
  InstanceMethod<&WstpSession::ExitDialog> ("exitDialog"),
1183
1246
  InstanceMethod<&WstpSession::Interrupt> ("interrupt"),
1184
1247
  InstanceMethod<&WstpSession::Abort> ("abort"),
1248
+ InstanceMethod<&WstpSession::CloseAllDialogs> ("closeAllDialogs"),
1185
1249
  InstanceMethod<&WstpSession::CreateSubsession>("createSubsession"),
1186
1250
  InstanceMethod<&WstpSession::Close> ("close"),
1187
1251
  InstanceAccessor<&WstpSession::IsOpen> ("isOpen"),
1188
1252
  InstanceAccessor<&WstpSession::IsDialogOpen> ("isDialogOpen"),
1253
+ InstanceAccessor<&WstpSession::IsReady> ("isReady"),
1189
1254
  });
1190
1255
 
1191
1256
  Napi::FunctionReference* ctor = new Napi::FunctionReference();
@@ -1401,12 +1466,14 @@ public:
1401
1466
  bool evalInteractive = (item.interactiveOverride == -1)
1402
1467
  ? interactiveMode_
1403
1468
  : (item.interactiveOverride == 1);
1469
+ workerReadingLink_.store(true, std::memory_order_release);
1404
1470
  auto* worker = new EvaluateWorker(
1405
1471
  std::move(item.deferred),
1406
1472
  lp_,
1407
1473
  std::move(item.expr),
1408
1474
  std::move(item.opts),
1409
1475
  abortFlag_,
1476
+ workerReadingLink_,
1410
1477
  [this]() { busy_.store(false); MaybeStartNext(); },
1411
1478
  nextLine_.fetch_add(1),
1412
1479
  evalInteractive
@@ -1419,10 +1486,11 @@ public:
1419
1486
  void StartSubIdleWorker(QueuedSubIdle item) {
1420
1487
  struct SubIdleWorker : public Napi::AsyncWorker {
1421
1488
  SubIdleWorker(Napi::Promise::Deferred d, WSLINK lp, std::string expr,
1489
+ std::atomic<bool>& workerReadingLink,
1422
1490
  std::function<void()> done)
1423
1491
  : Napi::AsyncWorker(d.Env()),
1424
1492
  deferred_(std::move(d)), lp_(lp), expr_(std::move(expr)),
1425
- done_(std::move(done)) {}
1493
+ workerReadingLink_(workerReadingLink), done_(std::move(done)) {}
1426
1494
 
1427
1495
  void Execute() override {
1428
1496
  if (!WSPutFunction(lp_, "EvaluatePacket", 1) ||
@@ -1430,10 +1498,12 @@ public:
1430
1498
  !WSPutUTF8String(lp_, (const unsigned char *)expr_.c_str(), (int)expr_.size()) ||
1431
1499
  !WSEndPacket(lp_) ||
1432
1500
  !WSFlush(lp_)) {
1501
+ workerReadingLink_.store(false, std::memory_order_release);
1433
1502
  SetError("sub (idle): failed to send EvaluatePacket");
1434
1503
  return;
1435
1504
  }
1436
1505
  result_ = DrainToEvalResult(lp_);
1506
+ workerReadingLink_.store(false, std::memory_order_release); // lp_ no longer in use
1437
1507
  }
1438
1508
  void OnOK() override {
1439
1509
  Napi::Env env = Env();
@@ -1456,11 +1526,14 @@ public:
1456
1526
  Napi::Promise::Deferred deferred_;
1457
1527
  WSLINK lp_;
1458
1528
  std::string expr_;
1529
+ std::atomic<bool>& workerReadingLink_;
1459
1530
  std::function<void()> done_;
1460
1531
  EvalResult result_;
1461
1532
  };
1462
1533
 
1534
+ workerReadingLink_.store(true, std::memory_order_release);
1463
1535
  (new SubIdleWorker(std::move(item.deferred), lp_, std::move(item.expr),
1536
+ workerReadingLink_,
1464
1537
  [this]() { busy_.store(false); MaybeStartNext(); }))->Queue();
1465
1538
  }
1466
1539
 
@@ -1521,6 +1594,15 @@ public:
1521
1594
  "no dialog subsession is open").Value());
1522
1595
  return promise;
1523
1596
  }
1597
+ // Stale-state guard: dialogOpen_=true but the drain loop has already
1598
+ // exited (busy_=false). Nobody will service the queue, so resolve
1599
+ // immediately and clean up rather than enqueuing a hanging request.
1600
+ if (!busy_.load()) {
1601
+ FlushDialogQueueWithError("dialog closed: session idle");
1602
+ dialogOpen_.store(false);
1603
+ deferred.Resolve(env.Null());
1604
+ return promise;
1605
+ }
1524
1606
  // Build "Return[]" or "Return[retVal]" as the exit expression.
1525
1607
  std::string exitExpr = "Return[]";
1526
1608
  if (info.Length() >= 1 && info[0].IsString())
@@ -1649,10 +1731,37 @@ public:
1649
1731
  // spurious RETURNPKT[$Aborted] that would corrupt the next evaluation.
1650
1732
  if (!busy_.load()) return Napi::Boolean::New(env, false);
1651
1733
  abortFlag_.store(true);
1734
+ // Flush any queued dialogEval/exitDialog requests so their promises
1735
+ // reject immediately instead of hanging forever.
1736
+ FlushDialogQueueWithError("abort");
1737
+ dialogOpen_.store(false);
1652
1738
  int ok = WSPutMessage(lp_, WSAbortMessage);
1653
1739
  return Napi::Boolean::New(env, ok != 0);
1654
1740
  }
1655
1741
 
1742
+ // -----------------------------------------------------------------------
1743
+ // closeAllDialogs() → boolean
1744
+ //
1745
+ // Unconditionally resets all dialog state on the JS side:
1746
+ // • Drains dialogQueue_, rejecting every pending dialogEval/exitDialog
1747
+ // promise with an error (so callers don't hang).
1748
+ // • Clears dialogOpen_ so isDialogOpen returns false.
1749
+ //
1750
+ // This does NOT send any packet to the kernel — it only fixes the Node
1751
+ // side bookkeeping. Use it in error-recovery paths (before abort() or
1752
+ // after an unexpected kernel state change) to guarantee clean state.
1753
+ //
1754
+ // Returns true if dialogOpen_ was set before the call (i.e. something
1755
+ // was actually cleaned up), false if it was already clear.
1756
+ // -----------------------------------------------------------------------
1757
+ Napi::Value CloseAllDialogs(const Napi::CallbackInfo& info) {
1758
+ Napi::Env env = info.Env();
1759
+ bool wasOpen = dialogOpen_.load();
1760
+ FlushDialogQueueWithError("dialog closed by closeAllDialogs");
1761
+ dialogOpen_.store(false);
1762
+ return Napi::Boolean::New(env, wasOpen);
1763
+ }
1764
+
1656
1765
  // -----------------------------------------------------------------------
1657
1766
  // createSubsession(kernelPath?) → WstpSession
1658
1767
  //
@@ -1690,6 +1799,23 @@ public:
1690
1799
  return Napi::Boolean::New(info.Env(), dialogOpen_.load());
1691
1800
  }
1692
1801
 
1802
+ // -----------------------------------------------------------------------
1803
+ // isReady (read-only accessor)
1804
+ // true iff the session is open, the kernel has no active evaluation
1805
+ // (busy_ is false), no dialog is open, and the eval + sub queues are
1806
+ // both empty — i.e. the next evaluate() will start immediately.
1807
+ // All reads are on the JS main thread (same thread that writes open_ and
1808
+ // the queues), so no extra locking is needed.
1809
+ // -----------------------------------------------------------------------
1810
+ Napi::Value IsReady(const Napi::CallbackInfo& info) {
1811
+ return Napi::Boolean::New(info.Env(),
1812
+ open_
1813
+ && !busy_.load(std::memory_order_relaxed)
1814
+ && !dialogOpen_.load(std::memory_order_relaxed)
1815
+ && queue_.empty()
1816
+ && subIdleQueue_.empty());
1817
+ }
1818
+
1693
1819
  private:
1694
1820
  // Queue entry — one pending evaluate() call.
1695
1821
  // interactiveOverride: -1 = use session default, 0 = force batch, 1 = force interactive
@@ -1700,14 +1826,58 @@ private:
1700
1826
  int interactiveOverride = -1;
1701
1827
  };
1702
1828
 
1829
+ // -----------------------------------------------------------------------
1830
+ // FlushDialogQueueWithError — drain dialogQueue_, rejecting every pending
1831
+ // promise with errMsg. Caller must hold no locks; this acquires
1832
+ // dialogMutex_ internally. Resets dialogPending_.
1833
+ // -----------------------------------------------------------------------
1834
+ void FlushDialogQueueWithError(const std::string& errMsg) {
1835
+ std::queue<DialogRequest> toFlush;
1836
+ {
1837
+ std::lock_guard<std::mutex> lk(dialogMutex_);
1838
+ std::swap(toFlush, dialogQueue_);
1839
+ dialogPending_.store(false);
1840
+ }
1841
+ while (!toFlush.empty()) {
1842
+ DialogRequest req = std::move(toFlush.front());
1843
+ toFlush.pop();
1844
+ std::string msg = errMsg;
1845
+ req.resolve.NonBlockingCall([msg](Napi::Env e, Napi::Function cb) {
1846
+ auto obj = Napi::Object::New(e);
1847
+ obj.Set("error", Napi::String::New(e, msg));
1848
+ cb.Call({obj});
1849
+ });
1850
+ req.resolve.Release();
1851
+ }
1852
+ }
1853
+
1703
1854
  void CleanUp() {
1855
+ // If a worker thread is currently reading from lp_, calling WSClose()
1856
+ // on it from the JS main thread causes a concurrent-access crash
1857
+ // (heap-use-after-free / SIGSEGV).
1858
+ //
1859
+ // We spin on workerReadingLink_ (set false by Execute() on the background
1860
+ // thread) rather than busy_ (set false by OnOK/OnError on the main thread).
1861
+ // Spinning on busy_ from the main thread would deadlock because the main
1862
+ // thread's event loop is blocked — NAPI never gets to call OnOK.
1863
+ open_ = false; // prevent new evals from queuing during shutdown
1864
+ if (workerReadingLink_.load(std::memory_order_acquire) && lp_) {
1865
+ abortFlag_.store(true);
1866
+ FlushDialogQueueWithError("session closed");
1867
+ dialogOpen_.store(false);
1868
+ WSPutMessage(lp_, WSAbortMessage);
1869
+ const auto deadline =
1870
+ std::chrono::steady_clock::now() + std::chrono::seconds(10);
1871
+ while (workerReadingLink_.load(std::memory_order_acquire) &&
1872
+ std::chrono::steady_clock::now() < deadline)
1873
+ std::this_thread::sleep_for(std::chrono::milliseconds(5));
1874
+ }
1704
1875
  if (lp_) { WSClose(lp_); lp_ = nullptr; }
1705
1876
  if (wsEnv_) { WSDeinitialize(wsEnv_); wsEnv_ = nullptr; }
1706
1877
  // Kill the child kernel process so it doesn't become a zombie.
1707
1878
  // WSClose() closes the link but does not terminate the WolframKernel
1708
1879
  // child process — without this, each session leaks a kernel.
1709
1880
  if (kernelPid_ > 0) { kill(kernelPid_, SIGTERM); kernelPid_ = 0; }
1710
- open_ = false;
1711
1881
  }
1712
1882
 
1713
1883
 
@@ -1805,6 +1975,11 @@ private:
1805
1975
  std::atomic<int64_t> nextLine_{1}; // 1-based In[n] counter for EvalResult.cellIndex
1806
1976
  std::atomic<bool> abortFlag_{false};
1807
1977
  std::atomic<bool> busy_{false};
1978
+ // Set true before queuing a worker, set false from within Execute() (background
1979
+ // thread) right after DrainToEvalResult returns. CleanUp() spins on this flag
1980
+ // rather than busy_ (which is cleared on the main thread and cannot be polled
1981
+ // from a main-thread spin loop).
1982
+ std::atomic<bool> workerReadingLink_{false};
1808
1983
  std::mutex queueMutex_;
1809
1984
  std::queue<QueuedEval> queue_;
1810
1985
  std::queue<QueuedSubIdle> subIdleQueue_; // sub() — runs before queue_ items
package/test.js CHANGED
@@ -39,6 +39,30 @@ function pollUntil(condition, timeoutMs = 3000, intervalMs = 50) {
39
39
  });
40
40
  }
41
41
 
42
+ // withTimeout — race a promise against a named deadline.
43
+ function withTimeout(p, ms, label) {
44
+ return Promise.race([
45
+ p,
46
+ new Promise((_, rej) =>
47
+ setTimeout(() => rej(new Error(`TIMEOUT(${ms}ms): ${label}`)), ms)),
48
+ ]);
49
+ }
50
+
51
+ // mkSession — open a fresh WstpSession.
52
+ function mkSession() {
53
+ return new WstpSession(KERNEL_PATH);
54
+ }
55
+
56
+ // installHandler — install Interrupt→Dialog[] handler on a session.
57
+ // Must be done via evaluate() (EnterExpressionPacket context) so the handler
58
+ // fires on WSInterruptMessage; EvaluatePacket context does not receive it.
59
+ async function installHandler(s) {
60
+ await s.evaluate(
61
+ 'Quiet[Internal`AddHandler["Interrupt", Function[{}, Dialog[]]]]',
62
+ { onDialogBegin: () => {}, onDialogEnd: () => {} }
63
+ );
64
+ }
65
+
42
66
  // Per-test timeout (ms). Any test that does not complete within this window
43
67
  // is failed immediately with a "TIMED OUT" error. Prevents indefinite hangs.
44
68
  const TEST_TIMEOUT_MS = 30_000;
@@ -56,11 +80,11 @@ suiteWatchdog.unref(); // does not prevent normal exit
56
80
  let passed = 0;
57
81
  let failed = 0;
58
82
 
59
- async function run(name, fn) {
83
+ async function run(name, fn, timeoutMs = TEST_TIMEOUT_MS) {
60
84
  // Race the test body against a per-test timeout.
61
85
  const timeout = new Promise((_, reject) =>
62
- setTimeout(() => reject(new Error(`TIMED OUT after ${TEST_TIMEOUT_MS} ms`)),
63
- TEST_TIMEOUT_MS));
86
+ setTimeout(() => reject(new Error(`TIMED OUT after ${timeoutMs} ms`)),
87
+ timeoutMs));
64
88
  try {
65
89
  await Promise.race([fn(), timeout]);
66
90
  console.log(` ✓ ${name}`);
@@ -616,6 +640,353 @@ async function main() {
616
640
  );
617
641
  });
618
642
 
643
+ // ── 28. closeAllDialogs() is a no-op when no dialog is open ───────────
644
+ await run('28. closeAllDialogs() no-op when idle', async () => {
645
+ assert(!session.isDialogOpen, 'precondition: no dialog open');
646
+ const closed = session.closeAllDialogs();
647
+ assert(closed === false,
648
+ `closeAllDialogs() should return false when idle, got: ${closed}`);
649
+ assert(!session.isDialogOpen, 'isDialogOpen should stay false');
650
+ });
651
+
652
+ // ── 29. closeAllDialogs() rejects all queued dialogEval promises ───────
653
+ // Uses a subsession so the main session stays intact for tests 25 and 18.
654
+ // Verifies: return value = true, isDialogOpen cleared, queued promises rejected.
655
+ await run('29. closeAllDialogs() rejects queued dialogEval promises', async () => {
656
+ const sub = session.createSubsession();
657
+ try {
658
+ // Open a Dialog[] on the subsession.
659
+ const pEval = sub.evaluate('Dialog[]');
660
+ await pollUntil(() => sub.isDialogOpen);
661
+ assert(sub.isDialogOpen, 'dialog should be open');
662
+
663
+ // Queue two dialogEval() calls — neither will be serviced before
664
+ // closeAllDialogs() runs (the JS event loop hasn't yielded yet).
665
+ const pe1 = sub.dialogEval('"expr-1"')
666
+ .then(() => 'resolved').catch(e => 'rejected:' + e.message);
667
+ const pe2 = sub.dialogEval('"expr-2"')
668
+ .then(() => 'resolved').catch(e => 'rejected:' + e.message);
669
+
670
+ // closeAllDialogs() should flush both and return true.
671
+ const closed = sub.closeAllDialogs();
672
+ assert(closed === true,
673
+ `closeAllDialogs() should return true when dialog was open, got: ${closed}`);
674
+ assert(!sub.isDialogOpen,
675
+ 'isDialogOpen should be false after closeAllDialogs()');
676
+
677
+ // Both queued promises must reject immediately.
678
+ const [r1, r2] = await Promise.all([pe1, pe2]);
679
+ assert(r1.startsWith('rejected:'),
680
+ `pe1 should have rejected, got: ${r1}`);
681
+ assert(r2.startsWith('rejected:'),
682
+ `pe2 should have rejected, got: ${r2}`);
683
+
684
+ // Abort the subsession to unstick the kernel (still inside Dialog[]).
685
+ sub.abort();
686
+ const ra = await pEval;
687
+ assert(ra.aborted === true,
688
+ `subsession evaluate should resolve with aborted=true, got: ${JSON.stringify(ra.aborted)}`);
689
+ } finally {
690
+ sub.close();
691
+ }
692
+ });
693
+
694
+ // ── P1: Pause[8] ignores interrupt ────────────────────────────────────
695
+ // Expected: interrupt() returns true but isDialogOpen stays false within
696
+ // 2500ms because Pause[] ignores WSInterruptMessage during a sleep.
697
+ // This test documents the fundamental limitation: Dynamic cannot read a
698
+ // live variable while Pause[N] is running.
699
+ await run('P1: Pause[8] ignores interrupt within 2500ms', async () => {
700
+ const s = mkSession();
701
+ try {
702
+ await installHandler(s);
703
+
704
+ let evalDone = false;
705
+ const mainProm = s.evaluate(
706
+ 'pP1 = 0; Pause[8]; pP1 = 1; "p1-done"',
707
+ { onDialogBegin: () => {}, onDialogEnd: () => {} }
708
+ ).then(() => { evalDone = true; });
709
+
710
+ await sleep(300);
711
+
712
+ const sent = s.interrupt();
713
+ assert(sent === true, 'interrupt() should return true mid-eval');
714
+
715
+ const t0 = Date.now();
716
+ while (!s.isDialogOpen && Date.now() - t0 < 2500) await sleep(25);
717
+
718
+ const dlgOpened = s.isDialogOpen;
719
+ assert(!dlgOpened, 'Pause[8] should NOT open a dialog within 2500ms');
720
+
721
+ // After the 2500ms window the interrupt may still be queued — the kernel
722
+ // will fire Dialog[] once Pause[] releases. Abort to unstick the eval
723
+ // rather than waiting for it to return on its own (which could take forever
724
+ // if Dialog[] opens and nobody services it).
725
+ s.abort();
726
+ await mainProm; // resolves immediately after abort()
727
+ } finally {
728
+ s.close();
729
+ }
730
+ }, 20_000);
731
+
732
+ // ── P2: Pause[0.3] loop — interrupt opens Dialog and dialogEval works ──
733
+ // Expected: interrupt during a short Pause[] loop opens a Dialog[],
734
+ // dialogEval can read the live variable, and exitDialog resumes the loop.
735
+ await run('P2: Pause[0.3] loop — interrupt opens Dialog and dialogEval succeeds', async () => {
736
+ const s = mkSession();
737
+ try {
738
+ await installHandler(s);
739
+
740
+ let evalDone = false;
741
+ const mainProm = s.evaluate(
742
+ 'Do[nP2 = k; Pause[0.3], {k, 1, 30}]; "p2-done"',
743
+ { onDialogBegin: () => {}, onDialogEnd: () => {} }
744
+ ).then(() => { evalDone = true; });
745
+
746
+ await sleep(500);
747
+
748
+ s.interrupt();
749
+ try { await pollUntil(() => s.isDialogOpen, 3000); }
750
+ catch (_) { throw new Error('Dialog never opened — interrupt not working with Pause[0.3]'); }
751
+
752
+ const val = await withTimeout(s.dialogEval('nP2'), 5000, 'dialogEval nP2');
753
+ assert(val && typeof val.value === 'number' && val.value >= 1,
754
+ `expected nP2 >= 1, got ${JSON.stringify(val)}`);
755
+
756
+ await s.exitDialog();
757
+ await withTimeout(mainProm, 15_000, 'P2 main eval');
758
+ } finally {
759
+ try { s.abort(); } catch (_) {}
760
+ s.close();
761
+ }
762
+ }, 30_000);
763
+
764
+ // ── P3: dialogEval timeout — kernel state after (diagnostic) ───────────
765
+ // Simulates the extension failure: dialogEval times out without exitDialog.
766
+ // Verifies the kernel is NOT permanently broken by a timeout alone.
767
+ // Always passes — records the observed behaviour.
768
+ await run('P3: dialogEval timeout — kernel still recovers via exitDialog', async () => {
769
+ const s = mkSession();
770
+ try {
771
+ await installHandler(s);
772
+
773
+ let evalDone = false;
774
+ const mainProm = s.evaluate(
775
+ 'Do[nP3 = k; Pause[0.3], {k, 1, 200}]; "p3-done"',
776
+ { onDialogBegin: () => {}, onDialogEnd: () => {} }
777
+ ).then(() => { evalDone = true; });
778
+
779
+ await sleep(500);
780
+
781
+ s.interrupt();
782
+ try { await pollUntil(() => s.isDialogOpen, 3000); }
783
+ catch (_) { throw new Error('Dialog #1 never opened'); }
784
+
785
+ // Simulate a timed-out dialogEval (abandon it at 200ms)
786
+ try { await withTimeout(s.dialogEval('nP3'), 200, 'deliberate-timeout'); } catch (_) {}
787
+
788
+ // Attempt exitDialog — should succeed
789
+ let exitOk = false;
790
+ try { await withTimeout(s.exitDialog(), 2000, 'exitDialog after timeout'); exitOk = true; }
791
+ catch (_) {}
792
+
793
+ // Attempt a second interrupt to confirm kernel state
794
+ await sleep(400);
795
+ s.interrupt();
796
+ let dlg2 = false;
797
+ const t2 = Date.now();
798
+ while (!s.isDialogOpen && Date.now() - t2 < 3000) await sleep(30);
799
+ dlg2 = s.isDialogOpen;
800
+
801
+ if (dlg2) {
802
+ await withTimeout(s.dialogEval('nP3'), 4000, 'dialogEval #2').catch(() => {});
803
+ await s.exitDialog().catch(() => {});
804
+ }
805
+
806
+ if (!evalDone) { try { await withTimeout(mainProm, 20_000, 'P3 loop'); } catch (_) {} }
807
+
808
+ // Diagnostic: document observed outcome but do not hard-fail on dlg2
809
+ if (!exitOk) {
810
+ console.log(` P3 note: exitDialog failed, dlg2=${dlg2} (expected=false for unfixed build)`);
811
+ }
812
+ // Test passes unconditionally.
813
+ } finally {
814
+ try { s.abort(); } catch (_) {}
815
+ s.close();
816
+ }
817
+ }, 45_000);
818
+
819
+ // ── P4: abort() after stuck dialog — session stays alive ────────────────
820
+ // Note: abort() sends WSAbortMessage which resets the Wolfram kernel's
821
+ // Internal`AddHandler["Interrupt", ...] registration. Subsequent interrupts
822
+ // therefore do not open a new Dialog[]; that is expected, not a bug.
823
+ // What this test verifies: after abort() the session is NOT dead —
824
+ // evaluate() still works so the extension can queue more cells.
825
+ await run('P4: abort() after stuck dialog — session can still evaluate', async () => {
826
+ const s = mkSession();
827
+ try {
828
+ await installHandler(s);
829
+
830
+ const mainProm = s.evaluate(
831
+ 'Do[nP4=k; Pause[0.3], {k,1,200}]; "p4-done"',
832
+ { onDialogBegin: () => {}, onDialogEnd: () => {} }
833
+ ).catch(() => {});
834
+
835
+ await sleep(500);
836
+
837
+ // Trigger a dialog, force dialogEval timeout, then abort
838
+ s.interrupt();
839
+ try { await pollUntil(() => s.isDialogOpen, 3000); }
840
+ catch (_) { throw new Error('Dialog #1 never opened'); }
841
+
842
+ try { await withTimeout(s.dialogEval('nP4'), 300, 'deliberate'); } catch (_) {}
843
+ try { s.abort(); } catch (_) {}
844
+
845
+ // Let execute() drain the abort response and OnOK fire
846
+ const tAbort = Date.now();
847
+ while (s.isDialogOpen && Date.now() - tAbort < 5000) await sleep(50);
848
+ try { await withTimeout(mainProm, 5000, 'P4 abort settle'); } catch (_) {}
849
+ await sleep(300);
850
+
851
+ // KEY assertion: the session is still functional for evaluate()
852
+ // (abort() must NOT permanently close the link)
853
+ const r = await withTimeout(
854
+ s.evaluate('"session-alive-after-abort"'),
855
+ 8000, 'post-abort evaluate'
856
+ );
857
+ assert(r && r.result && r.result.value === 'session-alive-after-abort',
858
+ `evaluate() after abort returned unexpected result: ${JSON.stringify(r)}`);
859
+ } finally {
860
+ try { s.abort(); } catch (_) {}
861
+ s.close();
862
+ }
863
+ }, 30_000);
864
+
865
+ // ── P5: closeAllDialogs()+abort() recovery → reinstall handler → new dialog works
866
+ // closeAllDialogs() is designed to be paired with abort(). It rejects all
867
+ // pending dialogEval() promises (JS-side), while abort() signals the kernel.
868
+ // After recovery the interrupt handler must be reinstalled because abort()
869
+ // clears Wolfram's Internal`AddHandler["Interrupt",...] registration.
870
+ await run('P5: closeAllDialogs()+abort() recovery — new dialog works after reinstallHandler', async () => {
871
+ const s = mkSession();
872
+ try {
873
+ await installHandler(s);
874
+
875
+ const mainProm = s.evaluate(
876
+ 'Do[nP5 = k; Pause[0.3], {k, 1, 200}]; "p5-done"',
877
+ { onDialogBegin: () => {}, onDialogEnd: () => {} }
878
+ ).catch(() => {});
879
+
880
+ await sleep(500);
881
+
882
+ // ── Phase 1: open dialog #1, call closeAllDialogs()+abort() ──────
883
+ s.interrupt();
884
+ try { await pollUntil(() => s.isDialogOpen, 3000); }
885
+ catch (_) { throw new Error('Dialog #1 never opened'); }
886
+
887
+ // Start a dialogEval — closeAllDialogs() will reject it synchronously.
888
+ const de1 = s.dialogEval('nP5').catch(() => {});
889
+ try { s.closeAllDialogs(); } catch (_) {}
890
+ await de1; // resolves immediately (rejected by closeAllDialogs)
891
+ await sleep(100);
892
+ try { s.abort(); } catch (_) {}
893
+ await mainProm; // should resolve promptly after abort()
894
+
895
+ // ── Phase 2: reinstall handler, start new loop, interrupt → dialog #2 ──
896
+ // abort() clears Internal`AddHandler["Interrupt",...] in the kernel,
897
+ // so we must reinstall before the next interrupt cycle.
898
+ await withTimeout(installHandler(s), 8000, 'reinstall handler after abort');
899
+
900
+ let dlg2 = false;
901
+ const mainProm2 = s.evaluate(
902
+ 'Do[nP5b = k; Pause[0.3], {k, 1, 200}]; "p5b-done"',
903
+ { onDialogBegin: () => {}, onDialogEnd: () => {} }
904
+ ).catch(() => {});
905
+
906
+ await sleep(500);
907
+ s.interrupt();
908
+ try { await pollUntil(() => s.isDialogOpen, 3000); }
909
+ catch (_) { throw new Error('Dialog #2 never opened after closeAllDialogs()+abort() recovery'); }
910
+ dlg2 = true;
911
+
912
+ // Read a live variable inside the dialog.
913
+ const val2 = await withTimeout(s.dialogEval('nP5b'), 4000, 'dialogEval #2');
914
+ assert(val2 !== null && val2.value !== undefined, 'dialogEval #2 should return nP5b value');
915
+
916
+ assert(dlg2, 'after closeAllDialogs()+abort()+reinstallHandler, interrupt must open a new dialog');
917
+ // Abort the dialog and remaining Do loop promptly (no exitDialog needed).
918
+ try { s.abort(); } catch (_) {}
919
+ await mainProm2.catch(() => {}); // drains immediately after abort
920
+ } finally {
921
+ try { s.abort(); } catch (_) {}
922
+ s.close();
923
+ }
924
+ }, 60_000);
925
+
926
+ // ── P6: Simulate Dynamic + Pause[5] full scenario (diagnostic) ─────────
927
+ // Reproduces: n=RandomInteger[100]; Pause[5] with interrupt every 2.5s.
928
+ // Pause[5] is expected to block all interrupts. Test always passes —
929
+ // it documents whether reads succeed despite long Pause.
930
+ await run('P6: Pause[5] + interrupt cycle — dynamic read diagnostic', async () => {
931
+ const s = mkSession();
932
+ try {
933
+ await installHandler(s);
934
+
935
+ let evalDone = false;
936
+ const mainProm = s.evaluate(
937
+ 'n = RandomInteger[100]; Pause[5]; "p6-done"',
938
+ { onDialogBegin: () => {}, onDialogEnd: () => {} }
939
+ ).then(() => { evalDone = true; });
940
+
941
+ await sleep(200);
942
+
943
+ const INTERRUPT_WAIT_MS = 2500;
944
+ const DIALOG_EVAL_TIMEOUT_MS = 8000;
945
+ let dialogReadSucceeded = false;
946
+ let pauseIgnoredInterrupt = false;
947
+
948
+ for (let cycle = 1; cycle <= 5 && !evalDone; cycle++) {
949
+ const t0 = Date.now();
950
+ s.interrupt();
951
+ while (!s.isDialogOpen && Date.now() - t0 < INTERRUPT_WAIT_MS) await sleep(25);
952
+ const dlg = s.isDialogOpen;
953
+
954
+ if (!dlg) {
955
+ pauseIgnoredInterrupt = true;
956
+ await sleep(300);
957
+ continue;
958
+ }
959
+
960
+ try {
961
+ const val = await withTimeout(
962
+ s.dialogEval('n'), DIALOG_EVAL_TIMEOUT_MS, `P6 dialogEval cycle ${cycle}`);
963
+ dialogReadSucceeded = true;
964
+ await s.exitDialog();
965
+ break;
966
+ } catch (e) {
967
+ let exitOk = false;
968
+ for (let a = 0; a < 3 && !exitOk; a++) {
969
+ try { await withTimeout(s.exitDialog(), 2000, `exitDialog ${a+1}`); exitOk = true; }
970
+ catch (_) {}
971
+ }
972
+ if (!exitOk) { try { s.abort(); } catch (_) {} await sleep(1000); break; }
973
+ }
974
+ }
975
+
976
+ try { await withTimeout(mainProm, 12_000, 'P6 main eval'); } catch (_) {}
977
+
978
+ // Diagnostic: always passes — log observed outcome
979
+ if (pauseIgnoredInterrupt && !dialogReadSucceeded) {
980
+ console.log(' P6 note: Pause[5] blocked all interrupts — expected behaviour');
981
+ } else if (dialogReadSucceeded) {
982
+ console.log(' P6 note: at least one read succeeded despite Pause');
983
+ }
984
+ } finally {
985
+ try { s.abort(); } catch (_) {}
986
+ s.close();
987
+ }
988
+ }, 60_000);
989
+
619
990
  // ── 25. abort() while dialog is open ──────────────────────────────────
620
991
  // Must run AFTER all other dialog tests — abort() sends WSAbortMessage
621
992
  // which resets the WSTP link, leaving the session unusable for further
@@ -649,6 +1020,10 @@ async function main() {
649
1020
  console.log();
650
1021
  if (failed === 0) {
651
1022
  console.log(`All ${passed} tests passed.`);
1023
+ // Force-exit: WSTP library may keep libuv handles alive after WSClose,
1024
+ // preventing the event loop from draining naturally. All assertions are
1025
+ // done; a clean exit(0) is safe.
1026
+ process.exit(0);
652
1027
  } else {
653
1028
  console.log(`${passed} passed, ${failed} failed.`);
654
1029
  }