Summary
session.previewBatch() intermittently never returns under Node (npm package, WASM runtime): one core pinned at 100%, no exception, no progress. The pattern matches the conservative-GC collapse diagnosed in #696/#697 and #779, but it happens on previews well after the first one, so PreviewEngine.EnsureWarm() does not cover it.
- Reproduced on 12.6.2 and 12.6.0 at the same rate (5/20 vs 6/20 runs under
node --test), so it is not a regression in the latest patch.
- Node v26.8.1, macOS 26 (Darwin 25.6.0, arm64). Plain
node --experimental-strip-types, no browser.
What the workload does
A test file of ~48 cases over one ~20-paragraph German contract fixture, all in one process after a single initialize(). Each case opens fresh sessions (openDocxSession(bytes, { revisionAuthor, trackedChanges: "render_inline" })), and most do the following:
project()
previewBatch(steps, "atomic", { retain: true }), whose mutation/preflight callbacks call replaceText / replaceTextRange / insertParagraph / deleteBlock / addCommentToRevision / listRevisions on the shadow session
commitPreview(previewId)
save() and close()
Some cases also run acceptAllRevisions / rejectAllRevisions and a docxDiff* comparison.
Observations
It stalls in a different case each run. Logging each test's start, the last case to start varied between runs: "#784 deleting a clause", "a negotiated redline proves reversible", "an insert after a clause the same plan rewrote", and others. Each ran ~1.5–3 s into the process, well after initialize() (~105 ms), which itself never hangs (10/10).
It needs accumulated state. No single describe block stalls on its own (0/10 for each of the 15). The two halves of the file stall at 3/10 and 2/10, and the whole file at 8/10. A standalone loop of openDocxSession → previewBatch({retain:true}) → commitPreview → save → close, run 400 times, never stalls (0/10). This matches the note in #779 that replaying the same export sequence by hand does not reproduce it.
Rate by runner. Running the file directly (node --experimental-strip-types file.test.ts) stalls 7–12 of every 15–20 runs. Under node --test it stalls roughly 1 in 4.
Stack of a stalled process. Native sample, symbolized with --perf-basic-prof. All 1615 samples show the same stack:
Builtins_AsyncFunctionAwaitResolveClosure
JS: session (test helper)
JS: previewBatch (docxodus npm wrapper)
Stub: js-to-wasm
wasm-function[21003] = mono_wasm_invoke_jsexport
wasm-function[19629] … [20058] (~22 frames, stripped names)
wasm-function[17530] <- all samples leaf here, looping
dotnet.native.wasm ships without a name section, so I could not name the inner frames. No JS frames appear below the JS-to-WASM boundary, so this is inside the runtime and not in one of our mutation callbacks. --report-on-signal never produced a report, meaning the isolate never services the interrupt.
JS GC settings have no effect. Stall rate with --max-semi-space-size=1 / default / =128: 7/15, 7/15, 5/15.
Mono nursery size has a large effect. I added "environmentVariables": {"MONO_GC_PARAMS": "nursery-size=…"} to dotnet.boot.js for this experiment only. Same test file, 15 direct runs each:
| nursery-size |
stalled |
| 4m |
6/15 |
| 8m |
0/15 |
| 16m |
0/15 |
| 32m |
0/15 |
This is the same non-monotone signature documented in docs/architecture/wasm-packaging.md (4m/6m/12m collapse, 8m/16m don't). I'm not proposing nursery tuning as a fix; the doc already explains why it isn't one. I'm including it as evidence that this is the same collapse in a path the warm-ups don't cover.
Why it matters for us
We run the engine in Node for an agent loop (parallel editing sessions merged through previewBatch/commitPreview). A spin there blocks the event loop, so no in-process timeout can fire. We only get out because a watchdog on a separate thread SIGTERMs the process.
Questions
- Does the warm-up invariant need to cover more than the first shadow? For example: the first
commitPreview, the first addCommentToRevision, or acceptAll/rejectAll on a redlined document, run inside a preview.
- Could a build with the WASM name section (or a
.symbols file) be published, or is there a switch for one, so downstream users can name the spinning frames themselves? I'm happy to collect a symbolized sample.
I can't share the test file publicly as-is, but I can shrink it to a self-contained repro against a synthetic fixture if that would help.
Summary
session.previewBatch()intermittently never returns under Node (npm package, WASM runtime): one core pinned at 100%, no exception, no progress. The pattern matches the conservative-GC collapse diagnosed in #696/#697 and #779, but it happens on previews well after the first one, soPreviewEngine.EnsureWarm()does not cover it.node --test), so it is not a regression in the latest patch.node --experimental-strip-types, no browser.What the workload does
A test file of ~48 cases over one ~20-paragraph German contract fixture, all in one process after a single
initialize(). Each case opens fresh sessions (openDocxSession(bytes, { revisionAuthor, trackedChanges: "render_inline" })), and most do the following:project()previewBatch(steps, "atomic", { retain: true }), whosemutation/preflightcallbacks callreplaceText/replaceTextRange/insertParagraph/deleteBlock/addCommentToRevision/listRevisionson the shadow sessioncommitPreview(previewId)save()andclose()Some cases also run
acceptAllRevisions/rejectAllRevisionsand adocxDiff*comparison.Observations
It stalls in a different case each run. Logging each test's start, the last case to start varied between runs: "#784 deleting a clause", "a negotiated redline proves reversible", "an insert after a clause the same plan rewrote", and others. Each ran ~1.5–3 s into the process, well after
initialize()(~105 ms), which itself never hangs (10/10).It needs accumulated state. No single
describeblock stalls on its own (0/10 for each of the 15). The two halves of the file stall at 3/10 and 2/10, and the whole file at 8/10. A standalone loop ofopenDocxSession→previewBatch({retain:true})→commitPreview→save→close, run 400 times, never stalls (0/10). This matches the note in #779 that replaying the same export sequence by hand does not reproduce it.Rate by runner. Running the file directly (
node --experimental-strip-types file.test.ts) stalls 7–12 of every 15–20 runs. Undernode --testit stalls roughly 1 in 4.Stack of a stalled process. Native sample, symbolized with
--perf-basic-prof. All 1615 samples show the same stack:dotnet.native.wasmships without a name section, so I could not name the inner frames. No JS frames appear below the JS-to-WASM boundary, so this is inside the runtime and not in one of our mutation callbacks.--report-on-signalnever produced a report, meaning the isolate never services the interrupt.JS GC settings have no effect. Stall rate with
--max-semi-space-size=1/ default /=128: 7/15, 7/15, 5/15.Mono nursery size has a large effect. I added
"environmentVariables": {"MONO_GC_PARAMS": "nursery-size=…"}todotnet.boot.jsfor this experiment only. Same test file, 15 direct runs each:This is the same non-monotone signature documented in
docs/architecture/wasm-packaging.md(4m/6m/12m collapse, 8m/16m don't). I'm not proposing nursery tuning as a fix; the doc already explains why it isn't one. I'm including it as evidence that this is the same collapse in a path the warm-ups don't cover.Why it matters for us
We run the engine in Node for an agent loop (parallel editing sessions merged through
previewBatch/commitPreview). A spin there blocks the event loop, so no in-process timeout can fire. We only get out because a watchdog on a separate thread SIGTERMs the process.Questions
commitPreview, the firstaddCommentToRevision, oracceptAll/rejectAllon a redlined document, run inside a preview..symbolsfile) be published, or is there a switch for one, so downstream users can name the spinning frames themselves? I'm happy to collect a symbolized sample.I can't share the test file publicly as-is, but I can shrink it to a self-contained repro against a synthetic fixture if that would help.