Skip to content

fix(docx): close display-list handles instead of leaving them to finalizers - #1323

Merged
eliahilse merged 19 commits into
mainfrom
fix/docx-dispose-display-list-handles
Oct 14, 2026
Merged

eliahilse merged 19 commits into
mainfrom
fix/docx-dispose-display-list-handles

Conversation

@eliahilse

Copy link
Copy Markdown
Member

TL;DR: the docx editor closes its display-list store handles when they stop being used, so bun test --isolate no longer leaves hundreds of finalizer registrations behind per test file.

Why

CI's Test step crashes now and then on Bun 1.4.2 with a segfault in JSFinalizationRegistry::runFinalizationCleanup (bun.report traces of three CI crashes). Cleanup jobs for registrations that are still live when an --isolate file's realm is torn down run against a freed callback. A standalone repro on Bun 1.4.2 crashed 5 of 5 runs when registrations were left live as their context died, and 0 of 5 when they were unregistered first. The upstream fix (oven-sh/bun#44279) landed after 1.4.2. The crash depends on timing and never reproduced on our test VMs, so this PR measures live finalizer registrations at the end of each file instead.

The largest source was the docx display-list query facade. Every facade registered with a FinalizationRegistry when it was created, even if it never opened a store handle. Nothing disposed facades when the editor unmounted.

What changed

  • displayListQueries.ts: a facade registers for finalization only while it holds a store handle, and the handle cell is the unregister token. endDisplayListQueriesLine closes every handle held by that line's facades. useDisplayList already ends its open lines on unmount, release, layout reset and worker fallback, so those paths now close their handles. A query method called after its facade has been garbage collected answers over the JSON path and opens no handle.
  • residentEngineWorker.ts / residentCommandSelection.ts: the worker releases its selection queries and per-engine selection reads when the command host is rebound and when the session is destroyed.
  • Tests that created facades, sessions, hosts or workbooks without releasing them now release them in teardown. No assertion was removed or weakened.

Tests

New (red on main, green here):

  • displayListQueries.finalizer.test.ts: only a facade that holds a handle is registered; adoption moves the registration; a failed by-handle query drops it; ending a line closes and unregisters every handle; a method kept past its collected facade opens no handle.
  • sessionLifetime.test.tsx: unmounting the editor closes every display-list store handle it opened.
  • residentEngineWorker.navigation.test.ts: rebinding the command host disposes its selection queries.

Changed (teardown only): displayListQueries.test.ts, frameDelta.test.ts, showHiddenText.test.ts, useYrsCoreSession.workerOpen.test.tsx, residentCommandSelection.test.ts, residentEngineWorker.navigation.test.ts, XlsxEditorPlugins.test.tsx, commandSwap.test.tsx, commandHost.shared.test.ts, commandSwap.test.ts, recovery.test.ts.

Verification

Measured in one run on an x86_64 Linux VM with Bun 1.4.2, at d70fe7d. That VM was loaded: CI's test command took 2765 s there. Pure main 7166688 took 1001 s on a quiet VM.

  • CI's typecheck passes. The docx, docx-react, xlsx and xlsx-react builds pass.
  • displayListQueries.finalizer.test.ts, 3 runs per source:
    • main's displayListQueries.ts: 0 pass / 5 fail
    • this branch before the collected-facade guard: 4 / 1 (only "a query method kept past its collected facade opens no handle" fails)
    • head: 5 / 0
  • residentEngineWorker.navigation.test.ts with main's worker sources: 11 pass / 1 fail ("rebinding_command_host_disposes_selection_queries"). Head: 12 / 0.
  • CI's bun run test: 9336 pass / 65 fail / 120 skip / 10 todo across 658 files, and no segfault. Pure main on the quiet VM: 9391 pass / 3 fail, with no unhandled errors.
    • 3 failures also fail on main on these VMs: native package public names, standalone Cargo workspaces and the S3 PNG upload.
    • The other 62 match the pattern main shows on loaded VMs. They are:
      • 52 in DocxEditorPlugins.test.tsx
      • 4 "the editor Save writes ..." cases in useFileIO.comments.test.tsx
      • 2 reply-mode cases in useDisplayList.workerLayout.test.ts
      • 1 each in viewportFrames.parity.test.ts, previewChain.parity.test.ts, useYrsCoreSession.workerOpen.test.tsx ("a successful worker-recovery main open ...") and VsdxEditor.lifecycle.test.tsx (font face restore, which takes 2.4 to 3.1 s even on GitHub's runners)
    • Run alone on the same VM, the plugins file gives 1 pass / 51 fail in 164.84 s. Pure main cd042ba gave the same on a loaded VM: 1 / 51 in 164.91 s. In both runs the first test times out at 5 s, and every later mount times out at about 3.1 s.
    • The run reports 3 unhandled errors between tests. Each comes from a test body that kept running after its test had failed: a wasm version() call on a freed session in DocxEditorPlugins.test.tsx, window is not defined in a React act flush in useFileIO.comments.test.tsx, and a waitFor in useDisplayList.workerLayout.test.ts. None comes from a finalization callback.
  • On one VM, main's versions of the three changed source files against this head, 2 rounds of the six docx files above: only DocxEditorPlugins.test.tsx differed. Main's sources gave 52 / 0 twice. Head gave 1 / 51, then 51 / 1. In both head runs the in-thread first test timed out on its own, at 5147 and 5050 ms, and in the 1 / 51 run every later mount timed out after it (see Deferred). In the other five files head had no more failures than main's sources in either round.
  • That first test runs close to Bun's 5 s default on both sides. Two more runs counted the display-list query paths, with the same three files swapped:
    • A VM at load average about 4, test 1 alone with a 60 s timeout, 3 runs per side: main's sources took 5082 to 5804 ms on the in-thread engine, head 4599 to 5152 ms.
    • A VM at load average about 2.5, sides interleaved, 4 runs per side: the whole file at the default timeout passed 52 / 0 in all 8 runs, with test 1 at 3383 to 4527 ms on main's sources and 2344 to 4784 ms on head. Test 1 alone with a 60 s timeout took 2462 to 2915 ms on main's sources and 2357 to 3156 ms on head.
    • In all 22 counted runs, no query used the JSON path on either side. Test 1, run on both engines, opened 7 store handles on both sides, and a whole-file run opened 62 to 66 on both sides.

Live finalizer registrations at file end (measured)

Each test file runs on its own, with a preload that counts live FinalizationRegistry registrations when the file ends. "Handles" counts display-list store handle registrations. "Other" counts edit sessions and xlsx documents. Before = pure main 7166688; after = this head, from the run above. The crash never reproduced on our VMs (no segfault in either run), so this count is the measure.

File Handles before Other before Handles after Other after
useYrsCoreSession.workerOpen.test.tsx * 461 0 1 0
useDisplayList.lazyPages.test.ts 108 0 0 0
DocxEditorPlugins.test.tsx * 72 0 0 0
useDisplayList.workerLayout.test.ts * 61 0 0 1
useFileIO.untouchedParts.test.tsx 58 0 1 0
useDisplayList.provenance.test.tsx 33 0 0 0
previewFailure.test.tsx 31 0 0 0
useDisplayList.outOfMemory.test.ts 30 0 0 0
displayListQueries.test.ts 29 0 1 0
residentEngineWorker.navigation.test.ts 28 0 0 0
whenLayoutComplete.test.tsx 24 0 0 0
useDisplayList.handoff.test.ts 22 0 0 0
useDisplayList.fallback.test.ts 20 0 0 0
geometry.stories.test.ts 15 0 0 0
firstPagePainted.test.tsx * 13 0 0 0
EditorToolbarProp.test.tsx * 12 0 0 0
hostProposals.test.tsx 10 0 0 0
saveCycles.test.tsx * 10 0 0 0
sessionLifetime.test.tsx 8 0 0 0
viewerSelectionPublication.test.tsx 8 0 0 0
frameDelta.test.ts 1 5 0 0
residentCommandSelection.test.ts 6 0 0 0
proposalToggle.geometry.test.tsx * 5 0 0 0
useDocxPluginHost.lazyPages.test.tsx 4 0 0 0
useDocxEditorRefApi.worker.test.tsx 3 0 0 0
useFileIO.pickedImage.test.tsx 3 0 0 0
loadingIndicator.test.tsx 2 0 0 0
residentCommandWorker.test.ts 2 0 2 0
XlsxEditorPlugins.test.tsx 0 1 0 0
commandHost.shared.test.ts 0 1 0 0
commandSwap.test.ts 0 1 0 0
commandSwap.test.tsx 0 1 0 0
recovery.test.ts 0 1 0 0
showHiddenText.test.ts 0 1 0 0
Total 1079 11 5 1

Files at 0 on both sides are left out. A file marked * had failures in the after run, so its count only covers the tests that completed. With this branch merged into #1318's head, every marked file passes and holds 0 handles. Across 147 files, #1318's head alone holds 1119 + 11 and the merge holds 13 + 0; 9 of the 13 are in workerPluginGeometry.test.ts (see Deferred).

Deferred

  • workerPluginGeometry.test.ts (from feat(docx-react): editing in the worker-only docx editor #1319) never disposes its plugin harness and leaves 9 handle registrations live. feat(docx): plugin layout reservations #1318's next head fixes it.
  • 5 handle registrations stay live at file end on this head: 2 in residentCommandWorker.test.ts from its fixture, and 1 each in useYrsCoreSession.workerOpen.test.tsx, useFileIO.untouchedParts.test.tsx and displayListQueries.test.ts. One edit session also stays live in useDisplayList.workerLayout.test.ts. That count comes from a run where two of the file's tests failed; in the merge with feat(docx): plugin layout reservations #1318 the file passes and holds 0.
  • Upgrade Bun once a release includes Cancel a dead realm's deferred work at the end of the collection that found it dead oven-sh/bun#44279. Until then, any registration still live when a file's realm is torn down can still hit the crash, including the ones above and those other libraries create. A handle's close callback was also seen once running against a torn-down realm, as deadSources.has is not a function at displayListQueries.ts:339, on a main-based branch.
  • Pre-existing flakes on main, found while measuring this (follow-ups):
    • saveCycles.test.tsx: on loaded VMs, all 4 tests fail together in about 1 of 3 runs of the file on main, and they failed on this head too. Each is a mount or startup timeout: the in-thread arm hits the 5 s test timeout while it waits for Save to enable. No comment-range or paragraph assertion fails.
    • useYrsCoreSession.workerOpen.test.tsx, "the editor state prefetch waits for a sliced background page-build reply with metadata bootstrap": run alone with a name filter, it fails 3 of 3 runs on main 89a5631. It passes in full-file runs.
    • DocxEditorPlugins.test.tsx on loaded VMs: the first test times out at 5 s and its body keeps running. Every later mount in the file then times out, giving 1 pass / 51 fail on main and on this head.

🤖 Generated with Claude Code

Co-authored-by: Claude Opus 5.5 noreply@anthropic.com
Co-authored-by: GPT 6.1 Sol codex@openai.com

@openooxml-bot

openooxml-bot Bot commented Oct 14, 2026

Copy link
Copy Markdown
Contributor

All contributors have signed the CLA — thank you! ✍️ ✅

Posted by the CLA bot.

@kyora

kyora Bot commented Oct 14, 2026

Copy link
Copy Markdown

kyora · 5/5 · low risk

Closes docx display-list store handles and worker selection reads explicitly instead of relying on finalizers.
No blocking issues found; the handle ownership, rebind, and teardown paths are consistent and verified against callers.

Summary:

  • displayListQueries facades register for finalization only while holding a handle and track handles per line for bulk close on line end, with a guard against opening handles from collected facades.
  • Resident worker rebind and session destroy now dispose selection queries and per-engine selection reads via a stored release hook.
  • Adds finalizer/lifetime/rebind tests and adds dispose teardown to existing tests that created facades, sessions, or hosts.
Reviewer models
model status findings time
muse-spark-1.3-contributor · high ok 0 43s

Review 1 · d70fe7d · comment @kyora review to re-run

@eliahilse
eliahilse merged commit c602f99 into main Oct 14, 2026
15 of 16 checks passed
@eliahilse
eliahilse deleted the fix/docx-dispose-display-list-handles branch October 14, 2026 21:35
@eliahilse eliahilse mentioned this pull request Oct 15, 2026
eliahilse added a commit that referenced this pull request Oct 15, 2026
TL;DR: every workflow runs Bun 1.4.3 instead of 1.4.2.

- 1.4.3 fixes the `bun test --isolate` segfault when a finished test file's global is collected while FinalizationRegistry callbacks are still queued (oven-sh/bun#44279). Our CI crash has the same stack; #1323 avoids it on 1.4.2 by disposing wasm handles eagerly.
- The node:vm repro from oven-sh/bun#44279 segfaults 10/10 on 1.4.2 and 0/10 on 1.4.3 (Linux x64).
- CI's test step on main, 4 vCPU Linux VMs, arms interleaved: no segfault on either; failed tests per run 16, 4, 7, 1, 5, 6 on 1.4.2 vs 7, 2, 0, 1, 0, 0, 0 on 1.4.3; median wall time 1189 s vs 1157 s.
- `scripts/native-bindings.test.ts` expects the new pin.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant