Skip to content

previewBatch intermittently never returns under Node after many previews (same GC collapse as #696/#779, past the first shadow) #811

Description

@ifeulner

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

  1. 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.
  2. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions