Skip to content

fix: dispose orphaned sibling handle when re-marshalling the same host object#83

Merged
rot1024 merged 1 commit into
mainfrom
fix/double-marshal-handle-leak
Jun 9, 2026
Merged

fix: dispose orphaned sibling handle when re-marshalling the same host object#83
rot1024 merged 1 commit into
mainfrom
fix/double-marshal-handle-leak

Conversation

@rot1024

@rot1024 rot1024 commented Jun 9, 2026

Copy link
Copy Markdown
Member

Problem

Marshalling the same host object into the VM more than once leaks a QuickJS handle, which aborts the debug-sync runtime on dispose() with:

Aborted(Assertion failed: list_empty(&rt->gc_obj_list), at: quickjs.c,2036,JS_FreeRuntime)

Minimal repro:

const shared = { k: 1 };
arena.expose({ get: () => shared });
arena.evalCode(`get(); get();`); // <-- second marshal leaks

One get() is fine; the second leaks. Same for get() === get().

Root cause

The first registration stores both a wrapped (proxy) handle and its unwrapped sibling in VMMap. Once the VM frees the proxy, the host-side handle goes dead and the map entry becomes stale. The lazy eviction in VMMap.get then dropped the entry via delete(key) without disposing the still-alive sibling, orphaning it.

(Tracked this down with the debug-sync runtime: a single host-fn return registers the global graph (~420 entries) which dispose cleanly; only the re-marshalled object's sibling leaked. The leak was confirmed via VMMap.set/delete tracing showing the stale entry being deleted with dispose=undefined then re-set.)

Fix

Evict the stale entry with delete(key, true) so the orphaned sibling is disposed. The dead primary is skipped by delete's own .alive guard, so this only frees the leaked sibling.

Tests

Added src/remarshalleak.test.ts — fails (runtime abort) without the fix, passes with it. Full suite: 163 passing, typecheck + lint clean.

Scope note

This fixes the handle leak (relates to the dispose-leak family in #4 / #41). It does not change reference-identity behavior across the host boundary (get() === get() is still false) — that is a separate, deeper lifetime concern; marshalByReference remains the supported path for guaranteed identity.

When the same host object is marshalled into the VM more than once, the
first registration stores both a wrapped (proxy) handle and its unwrapped
sibling. Once the VM frees the proxy, the map entry goes stale and the
lazy eviction in VMMap.get dropped it without disposing the still-alive
sibling, leaking a handle (and aborting the debug-sync runtime on dispose).

Evict with dispose so the orphaned sibling is released.
@github-actions

github-actions Bot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 97.48% 699 / 717
🔵 Statements 93.42% 781 / 836
🔵 Functions 95.13% 176 / 185
🔵 Branches 84.62% 490 / 579
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
src/vmmap.ts 95.83% 83.07% 100% 100% 74, 109, 111, 233, 236
Generated in workflow #543 for commit a6efc39 by the Vitest Coverage Report Action

@rot1024 rot1024 merged commit be52a3f into main Jun 9, 2026
2 checks passed
@rot1024 rot1024 deleted the fix/double-marshal-handle-leak branch June 9, 2026 10:31
@reearth-app reearth-app Bot mentioned this pull request Jun 9, 2026
4 tasks
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