Skip to content

Commit 4a12e1d

Browse files
committed
Add GC finalizer for WasmModule to prevent memory leaks
- MarkSweepGc: add finalizer registry (FinalizerEntry map) - registerFinalizer(ptr, deinit_fn): register non-GC object for cleanup - markFinalizer(ptr): mark as live during trace - sweepFinalizers(): invoke deinit on unmarked, called after sweep - deinit: run all remaining finalizers on shutdown - wasm_types: register wasmModuleFinalizer in wrapInner() - gc_ref global set via setGc() from runner.zig/nrepl.zig - gc.zig traceValue: wasm_module uses markFinalizer instead of markPtr - wasm_fn marks parent module alive (view into module, not independent) Fixes: WasmModule allocated via wasm_alloc (non-GC) was never deinited when CW GC collected the wrapper Value, leaking zwasm internal memory.
1 parent 375c9a8 commit 4a12e1d

4 files changed

Lines changed: 83 additions & 3 deletions

File tree

‎src/app/repl/nrepl.zig‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ pub fn startServer(gpa_allocator: Allocator, port: u16) !void {
7676
var env = Env.init(gpa_allocator);
7777
defer env.deinit();
7878
env.gc = @ptrCast(&gc);
79+
@import("../../runtime/wasm_types.zig").setGc(&gc);
7980

8081
registry.registerBuiltins(&env) catch {
8182
std.debug.print("Error: failed to register builtins\n", .{});

‎src/app/runner.zig‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -517,6 +517,8 @@ pub fn bootstrapFromCache(gc_alloc: Allocator, env: *Env, gc: ?*gc_mod.MarkSweep
517517
if (gc) |g| {
518518
g.threshold = @max(g.bytes_allocated * 2, g.threshold);
519519
env.gc = @ptrCast(g);
520+
// Register GC reference for Wasm module finalizer support
521+
@import("../runtime/wasm_types.zig").setGc(g);
520522
}
521523
}
522524

‎src/runtime/gc.zig‎

Lines changed: 61 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -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();

‎src/runtime/wasm_types.zig‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,16 @@ const zwasm = if (enable_wasm) @import("zwasm") else struct {};
2525
/// Without this, GC sweeps the ~1MB zwasm VM causing segfaults.
2626
const wasm_alloc = if (enable_wasm) std.heap.smp_allocator else std.heap.page_allocator;
2727

28+
const gc_mod = @import("gc.zig");
29+
30+
/// GC reference for finalizer registration. Set by main.zig after bootstrap.
31+
var gc_ref: ?*gc_mod.MarkSweepGc = null;
32+
33+
/// Set the GC reference so WasmModule can register finalizers.
34+
pub fn setGc(gc: *gc_mod.MarkSweepGc) void {
35+
gc_ref = gc;
36+
}
37+
2838
// ============================================================
2939
// CW-specific types
3040
// ============================================================
@@ -117,9 +127,18 @@ pub const WasmModule = struct {
117127
self.export_fns = buildExportInfo(wasm_alloc, inner) catch &[_]ExportInfo{};
118128
self.cached_fns = buildCachedFns(wasm_alloc, self) catch &[_]WasmFn{};
119129
self.wit_funcs = &[_]wit_parser.WitFunc{};
130+
// Register GC finalizer so deinit() is called when no roots reference this module
131+
if (gc_ref) |gc| {
132+
gc.registerFinalizer(self, wasmModuleFinalizer);
133+
}
120134
return self;
121135
}
122136

137+
fn wasmModuleFinalizer(addr: usize) void {
138+
const self: *WasmModule = @ptrFromInt(addr);
139+
self.deinit();
140+
}
141+
123142
pub fn deinit(self: *WasmModule) void {
124143
if (comptime !enable_wasm) return;
125144
const allocator = self.allocator;

0 commit comments

Comments
 (0)