Skip to content

runtime: implement SetFinalizer to fix the syscall/js reference leak#5521

Merged
deadprogram merged 1 commit into
tinygo-org:devfrom
felipegenef:fix-syscall-js-finalizeref-leak
Jul 17, 2026
Merged

runtime: implement SetFinalizer to fix the syscall/js reference leak#5521
deadprogram merged 1 commit into
tinygo-org:devfrom
felipegenef:fix-syscall-js-finalizeref-leak

Conversation

@felipegenef

@felipegenef felipegenef commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Implement runtime.SetFinalizer to fix the syscall/js reference leak

Fixes #1652 (also reported in #1140).

The leak

runtime.SetFinalizer is a no-op on every built-in GC. TinyGo compiles Go's syscall/js unchanged, and makeValue registers a finalizer on every js.Value to call finalizeRef, which frees the value's slot in the wasm_exec.js bridge tables.

Since the finalizer never runs, those tables grow without bound. Every GOOS=js program leaks roughly 2 to 4 slots per DOM event until it OOMs. The same program under standard Go does not leak.

The fix

Implement SetFinalizer in the block GC (gc_blocks.go, used by precise and conservative):

  • A table records (object, finalizer). The object address is stored encoded so the conservative scanner can't pin it; the finalizer value is kept live by the table.
  • During GC, after mark and before sweep, unreachable registered objects are resurrected for one cycle and their finalizers queued.
  • The queue drains on a dedicated goroutine, spawned lazily on the first SetFinalizer call and woken after gcLock is released, so a finalizer can allocate safely. Under scheduler=none they drain inline instead.
  • reflect.Value.Call is unavailable, so finalizers use the same closure-ABI call the runtime already uses for timer callbacks.

Because the runner is spawned lazily, a program that never calls SetFinalizer links none of this in: the linker drops the whole subsystem, so there is no size or RAM cost. In practice this only affects GOOS=js (wasm), where syscall/js registers these finalizers; TestBinarySize for the baremetal targets is unchanged.

Scope

The common case only: SetFinalizer(ptr, func(ptrType)), run-once, and nil to clear. Ordering, cycles, and AddCleanup are out of scope. wasm_exec.js and the other GCs' stubs are untouched.

Testing

New testdata/finalizer.go golden test covers fire, run-once, clear, and replace. It runs on -target=wasm (GOOS=js), where collecting a specific dropped object is deterministic; on the boehm host and the conservative emulated targets collection is not deterministic, so it is scoped out there. gc.go still passes on host, wasm, wasip1, and wasip2, make fmt is clean, and TestBuild / TestBinarySize are green.

A js.FuncOf harness confirms the bridge table returns to baseline after runtime.GC() instead of growing without bound:

js.Value reads stock _values high-water this change
1,000 3,012 ~18
40,000 120,012 ~54

@b0ch3nski

Copy link
Copy Markdown
Contributor

@felipegenef mind the CI errors

@felipegenef
felipegenef force-pushed the fix-syscall-js-finalizeref-leak branch from 735e164 to 9796326 Compare July 15, 2026 03:13
@felipegenef

Copy link
Copy Markdown
Contributor Author

@b0ch3nski all green and should be passing now. Made the finalizer runner spawn lazily so it adds no size on embedded, and scoped the firing test to wasm.

Sorry for the delay, was enjoying my birthday with family and friends! 🙂

}

// callFinalizer invokes a finalizer func value on the given object pointer.
func callFinalizer(objPtr unsafe.Pointer, fn interface{}) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This assumes that any func(*T) has the same calling convention as func(unsafe.Pointer). In TinyGo this is currently true (all pointer types are one word), but there is no compile-time validation that fn is actually func(*T) for the correct T.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just made SetFinalizer validate its args up front like the stdlib (panics if obj isn't a pointer or finalizer isn't a func).

Reflectlite can't check the exact func(*T) signature, but TinyGo's one-word pointer ABI keeps the reinterpret safe once it's confirmed to be a func.

@deadprogram

Also checked the goroutines.go failure and it looks unrelated to my change: it's gated to the conservative/precise GCs, so it isn't even compiled on the boehm host, and the goroutines.go host binary comes out byte-identical with and without my change. Looks like a pre-existing timing flake in that test, so a re-run should be green.

@felipegenef
felipegenef force-pushed the fix-syscall-js-finalizeref-leak branch from 9796326 to 354d4ab Compare July 16, 2026 14:14
@deadprogram

Copy link
Copy Markdown
Member

Thank you for the fix @felipegenef and to @b0ch3nski for helping review. Now merging.

@deadprogram
deadprogram merged commit 7994d2e into tinygo-org:dev Jul 17, 2026
20 of 21 checks passed
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.

WASM: syscall/js.finalizeRef not implemented

3 participants