@@ -172,6 +172,11 @@ pub const MarkSweepGc = struct {
172172 const MAX_FREE_POOLS = 16 ; // Up to 16 distinct (size, alignment) pairs
173173 const MAX_FREE_PER_POOL = 4096 ; // Max cached blocks per pool
174174
175+ pub const FinalizerEntry = struct {
176+ marked : bool = false ,
177+ deinit_fn : * const fn (usize ) void ,
178+ };
179+
175180 pub const AllocInfo = struct {
176181 len : usize ,
177182 alignment : Alignment ,
@@ -213,6 +218,16 @@ pub const MarkSweepGc = struct {
213218 free_pools : [MAX_FREE_POOLS ]FreePool = [_ ]FreePool {.{}} ** MAX_FREE_POOLS ,
214219 free_pool_count : u8 = 0 ,
215220
221+ // --- Finalizer registry for non-GC-managed objects (e.g. WasmModule) ---
222+ //
223+ // Objects allocated outside the GC (via wasm_alloc, page_allocator, etc.)
224+ // cannot be tracked by the normal mark-sweep allocations map. To ensure
225+ // their deinit() is called when no GC roots reference them, we maintain
226+ // a separate finalizer registry. Each entry stores a pointer and a
227+ // deinit callback. During traceValue, live finalizable objects are marked
228+ // via markFinalizer(). After sweep, unmarked finalizers are invoked.
229+ finalizers : std .AutoArrayHashMapUnmanaged (usize , FinalizerEntry ) = .empty ,
230+
216231 // --- Allocation profiling (37.1) ---
217232 alloc_buckets : if (profile_alloc ) [MAX_ALLOC_BUCKETS ]AllocBucket else void =
218233 if (profile_alloc ) .{AllocBucket { .size = 0 , .count = 0 , .total_bytes = 0 }} ** MAX_ALLOC_BUCKETS else {},
@@ -239,6 +254,11 @@ pub const MarkSweepGc = struct {
239254 }
240255
241256 pub fn deinit (self : * MarkSweepGc ) void {
257+ // Run finalizers for all remaining finalizable objects
258+ for (self .finalizers .keys (), self .finalizers .values ()) | addr , entry | {
259+ entry .deinit_fn (addr );
260+ }
261+ self .finalizers .deinit (self .backing );
242262 // Free all tracked allocations through backing
243263 const keys = self .allocations .keys ();
244264 const vals = self .allocations .values ();
@@ -297,6 +317,41 @@ pub const MarkSweepGc = struct {
297317 }
298318 }
299319
320+ /// Register a non-GC-managed object for finalizer-based cleanup.
321+ /// When the object is no longer reachable (not marked during trace),
322+ /// deinit_fn is called with the pointer address, then the entry is removed.
323+ pub fn registerFinalizer (self : * MarkSweepGc , ptr : anytype , deinit_fn : * const fn (usize ) void ) void {
324+ const addr = @intFromPtr (ptr );
325+ self .finalizers .put (self .backing , addr , .{ .deinit_fn = deinit_fn }) catch {};
326+ }
327+
328+ /// Mark a finalizable object as live during the trace phase.
329+ pub fn markFinalizer (self : * MarkSweepGc , ptr : anytype ) void {
330+ const addr = @intFromPtr (ptr );
331+ if (self .finalizers .getPtr (addr )) | entry | {
332+ entry .marked = true ;
333+ }
334+ }
335+
336+ /// Sweep finalizers: invoke deinit on unmarked entries, reset marks on live ones.
337+ fn sweepFinalizers (self : * MarkSweepGc ) void {
338+ var i : usize = 0 ;
339+ while (i < self .finalizers .count ()) {
340+ const vals = self .finalizers .values ();
341+ if (! vals [i ].marked ) {
342+ const keys = self .finalizers .keys ();
343+ const addr = keys [i ];
344+ const entry = vals [i ];
345+ self .finalizers .swapRemoveAt (i );
346+ entry .deinit_fn (addr );
347+ // Don't increment — swapRemove moved last element to i
348+ } else {
349+ vals [i ].marked = false ;
350+ i += 1 ;
351+ }
352+ }
353+ }
354+
300355 /// Sweep all unmarked allocations, recycling to free pools or freeing.
301356 /// Marked allocations have their mark bit reset for the next cycle.
302357 ///
@@ -493,6 +548,7 @@ pub const MarkSweepGc = struct {
493548 defer self .gc_mutex .unlock ();
494549 traceRoots (self , roots );
495550 self .sweep ();
551+ self .sweepFinalizers ();
496552 }
497553
498554 fn gcShouldCollect (ptr : * anyopaque ) bool {
@@ -519,6 +575,7 @@ pub const MarkSweepGc = struct {
519575 if (self .bytes_allocated < self .threshold ) return ;
520576 traceRoots (self , roots );
521577 self .sweep ();
578+ self .sweepFinalizers ();
522579 // Grow threshold if live set is still above it (avoid re-triggering every cycle)
523580 if (self .bytes_allocated >= self .threshold ) {
524581 self .threshold = self .bytes_allocated * 2 ;
@@ -1032,9 +1089,10 @@ pub fn traceValue(gc: *MarkSweepGc, val: Value) void {
10321089 }
10331090 },
10341091
1035- // Wasm InterOp — opaque native objects, not GC-traced
1036- .wasm_module = > gc .markPtr (val .asWasmModule ()),
1037- .wasm_fn = > gc .markPtr (val .asWasmFn ()),
1092+ // Wasm InterOp — WasmModule allocated via wasm_alloc (non-GC), tracked by finalizer registry.
1093+ // WasmFn is a view into WasmModule — keep module alive when fn is reachable.
1094+ .wasm_module = > gc .markFinalizer (val .asWasmModule ()),
1095+ .wasm_fn = > gc .markFinalizer (val .asWasmFn ().module ),
10381096
10391097 .matcher = > {
10401098 const m = val .asMatcher ();
0 commit comments