From a0c534f4d6dd5385a6477778f8560efc64368d51 Mon Sep 17 00:00:00 2001
From: Ayke van Laethem <aykevanlaethem@gmail.com>
Date: Mon, 17 Mar 2025 13:37:44 +0100
Subject: [PATCH] wasm: add Boehm GC support

This adds support for `-gc=boehm` on `-target=wasip1` and `-target=wasm`
(in a browser or NodeJS). Notably it does *not* add Boehm GC support for
`-target=wasip2`, since that target doesn't have a real libc.
---
 builder/bdwgc.go                      |  3 ++-
 builder/builtins.go                   |  2 +-
 builder/library.go                    |  4 ++--
 builder/mingw-w64.go                  |  2 +-
 builder/musl.go                       |  2 +-
 builder/picolibc.go                   |  2 +-
 builder/wasilibc.go                   |  7 ++++++-
 builder/wasmbuiltins.go               |  2 +-
 compileopts/config.go                 | 18 +++++++++++++++--
 compileopts/target.go                 |  3 +++
 go.mod                                |  2 +-
 go.sum                                |  4 ++--
 lib/bdwgc                             |  2 +-
 main_test.go                          | 18 ++++++++++++++++-
 src/runtime/arch_tinygowasm_malloc.go |  2 +-
 src/runtime/gc_boehm.c                | 17 ++++++++++++++++
 src/runtime/gc_boehm.go               |  9 ++++++---
 src/runtime/gc_stack_portable.go      |  2 +-
 targets/wasip1.json                   |  3 ++-
 targets/wasm.json                     |  3 ++-
 tests/runtime_wasi/malloc_test.go     | 29 ---------------------------
 21 files changed, 84 insertions(+), 52 deletions(-)
 create mode 100644 src/runtime/gc_boehm.c

diff --git a/builder/bdwgc.go b/builder/bdwgc.go
index 8341005d2e..d5f99bbbeb 100644
--- a/builder/bdwgc.go
+++ b/builder/bdwgc.go
@@ -30,6 +30,7 @@ var BoehmGC = Library{
 			// Use a minimal environment.
 			"-DNO_MSGBOX_ON_ERROR", // don't call MessageBoxA on Windows
 			"-DDONT_USE_ATEXIT",
+			"-DNO_GETENV",
 
 			// Special flag to work around the lack of __data_start in ld.lld.
 			// TODO: try to fix this in LLVM/lld directly so we don't have to
@@ -53,7 +54,7 @@ var BoehmGC = Library{
 	sourceDir: func() string {
 		return filepath.Join(goenv.Get("TINYGOROOT"), "lib/bdwgc")
 	},
-	librarySources: func(target string) ([]string, error) {
+	librarySources: func(target string, _ bool) ([]string, error) {
 		sources := []string{
 			"allchblk.c",
 			"alloc.c",
diff --git a/builder/builtins.go b/builder/builtins.go
index b493b6680a..008f02a55b 100644
--- a/builder/builtins.go
+++ b/builder/builtins.go
@@ -220,7 +220,7 @@ var libCompilerRT = Library{
 		// Development build.
 		return filepath.Join(goenv.Get("TINYGOROOT"), "lib/compiler-rt-builtins")
 	},
-	librarySources: func(target string) ([]string, error) {
+	librarySources: func(target string, _ bool) ([]string, error) {
 		builtins := append([]string{}, genericBuiltins...) // copy genericBuiltins
 		switch compileopts.CanonicalArchName(target) {
 		case "arm":
diff --git a/builder/library.go b/builder/library.go
index 53ae5d9d60..dd002e0364 100644
--- a/builder/library.go
+++ b/builder/library.go
@@ -35,7 +35,7 @@ type Library struct {
 	sourceDir func() string
 
 	// The source files, relative to sourceDir.
-	librarySources func(target string) ([]string, error)
+	librarySources func(target string, libcNeedsMalloc bool) ([]string, error)
 
 	// The source code for the crt1.o file, relative to sourceDir.
 	crt1Source string
@@ -223,7 +223,7 @@ func (l *Library) load(config *compileopts.Config, tmpdir string) (job *compileJ
 
 	// Create jobs to compile all sources. These jobs are depended upon by the
 	// archive job above, so must be run first.
-	paths, err := l.librarySources(target)
+	paths, err := l.librarySources(target, config.LibcNeedsMalloc())
 	if err != nil {
 		return nil, nil, err
 	}
diff --git a/builder/mingw-w64.go b/builder/mingw-w64.go
index 32cf58f531..33e7f1cfa3 100644
--- a/builder/mingw-w64.go
+++ b/builder/mingw-w64.go
@@ -37,7 +37,7 @@ var libMinGW = Library{
 			"-I" + headerPath,
 		}
 	},
-	librarySources: func(target string) ([]string, error) {
+	librarySources: func(target string, _ bool) ([]string, error) {
 		// These files are needed so that printf and the like are supported.
 		sources := []string{
 			"mingw-w64-crt/stdio/ucrt_fprintf.c",
diff --git a/builder/musl.go b/builder/musl.go
index 4195332550..ce39634946 100644
--- a/builder/musl.go
+++ b/builder/musl.go
@@ -121,7 +121,7 @@ var libMusl = Library{
 		return cflags
 	},
 	sourceDir: func() string { return filepath.Join(goenv.Get("TINYGOROOT"), "lib/musl/src") },
-	librarySources: func(target string) ([]string, error) {
+	librarySources: func(target string, _ bool) ([]string, error) {
 		arch := compileopts.MuslArchitecture(target)
 		globs := []string{
 			"ctype/*.c",
diff --git a/builder/picolibc.go b/builder/picolibc.go
index 9026b99ee4..43837fa1dc 100644
--- a/builder/picolibc.go
+++ b/builder/picolibc.go
@@ -43,7 +43,7 @@ var libPicolibc = Library{
 		}
 	},
 	sourceDir: func() string { return filepath.Join(goenv.Get("TINYGOROOT"), "lib/picolibc/newlib") },
-	librarySources: func(target string) ([]string, error) {
+	librarySources: func(target string, _ bool) ([]string, error) {
 		sources := append([]string(nil), picolibcSources...)
 		if !strings.HasPrefix(target, "avr") {
 			// Small chips without long jumps can't compile many files (printf,
diff --git a/builder/wasilibc.go b/builder/wasilibc.go
index 9b8ad7a5e2..ebfc8a72fa 100644
--- a/builder/wasilibc.go
+++ b/builder/wasilibc.go
@@ -119,7 +119,7 @@ var libWasiLibc = Library{
 		return nil
 	},
 	sourceDir: func() string { return filepath.Join(goenv.Get("TINYGOROOT"), "lib/wasi-libc") },
-	librarySources: func(target string) ([]string, error) {
+	librarySources: func(target string, libcNeedsMalloc bool) ([]string, error) {
 		type filePattern struct {
 			glob    string
 			exclude []string
@@ -168,6 +168,11 @@ var libWasiLibc = Library{
 			{glob: "libc-bottom-half/sources/*.c"},
 		}
 
+		// We're using the Boehm GC, so we need a heap implementation in the libc.
+		if libcNeedsMalloc {
+			globs = append(globs, filePattern{glob: "dlmalloc/src/dlmalloc.c"})
+		}
+
 		// See: LIBC_TOP_HALF_MUSL_SOURCES in the Makefile
 		sources := []string{
 			"libc-top-half/musl/src/misc/a64l.c",
diff --git a/builder/wasmbuiltins.go b/builder/wasmbuiltins.go
index 4c158f2337..031dd372d3 100644
--- a/builder/wasmbuiltins.go
+++ b/builder/wasmbuiltins.go
@@ -39,7 +39,7 @@ var libWasmBuiltins = Library{
 		}
 	},
 	sourceDir: func() string { return filepath.Join(goenv.Get("TINYGOROOT"), "lib/wasi-libc") },
-	librarySources: func(target string) ([]string, error) {
+	librarySources: func(target string, _ bool) ([]string, error) {
 		return []string{
 			// memory builtins needed for llvm.memcpy.*, llvm.memmove.*, and
 			// llvm.memset.* LLVM intrinsics.
diff --git a/compileopts/config.go b/compileopts/config.go
index 9fa482952f..e4515eacce 100644
--- a/compileopts/config.go
+++ b/compileopts/config.go
@@ -122,7 +122,7 @@ func (c *Config) GC() string {
 // that can be traced by the garbage collector.
 func (c *Config) NeedsStackObjects() bool {
 	switch c.GC() {
-	case "conservative", "custom", "precise":
+	case "conservative", "custom", "precise", "boehm":
 		for _, tag := range c.BuildTags() {
 			if tag == "tinygo.wasm" {
 				return true
@@ -247,6 +247,15 @@ func MuslArchitecture(triple string) string {
 	return CanonicalArchName(triple)
 }
 
+// Returns true if the libc needs to include malloc, for the libcs where this
+// matters.
+func (c *Config) LibcNeedsMalloc() bool {
+	if c.GC() == "boehm" && c.Target.Libc == "wasi-libc" {
+		return true
+	}
+	return false
+}
+
 // LibcPath returns the path to the libc directory. The libc path will be a libc
 // path in the cache directory (which might not yet be built).
 func (c *Config) LibcPath(name string) string {
@@ -265,9 +274,14 @@ func (c *Config) LibcPath(name string) string {
 		archname += "-" + c.Target.Libc
 	}
 
+	options := ""
+	if c.LibcNeedsMalloc() {
+		options += "+malloc"
+	}
+
 	// No precompiled library found. Determine the path name that will be used
 	// in the build cache.
-	return filepath.Join(goenv.Get("GOCACHE"), name+"-"+archname)
+	return filepath.Join(goenv.Get("GOCACHE"), name+options+"-"+archname)
 }
 
 // DefaultBinaryExtension returns the default extension for binaries, such as
diff --git a/compileopts/target.go b/compileopts/target.go
index baf6c1214a..2f22a2b133 100644
--- a/compileopts/target.go
+++ b/compileopts/target.go
@@ -267,6 +267,9 @@ func defaultTarget(options *Options) (*TargetSpec, error) {
 		DefaultStackSize: 1024 * 64, // 64kB
 		GDB:              []string{"gdb"},
 		PortReset:        "false",
+		ExtraFiles: []string{
+			"src/runtime/gc_boehm.c",
+		},
 	}
 
 	// Configure target based on GOARCH.
diff --git a/go.mod b/go.mod
index ec52702d53..c877d18bee 100644
--- a/go.mod
+++ b/go.mod
@@ -3,7 +3,7 @@ module github.com/tinygo-org/tinygo
 go 1.19
 
 require (
-	github.com/aykevl/go-wasm v0.0.2-0.20240825160117-b76c3f9f0982
+	github.com/aykevl/go-wasm v0.0.2-0.20250317121156-42b86c494139
 	github.com/blakesmith/ar v0.0.0-20150311145944-8bd4349a67f2
 	github.com/chromedp/cdproto v0.0.0-20220113222801-0725d94bb6ee
 	github.com/chromedp/chromedp v0.7.6
diff --git a/go.sum b/go.sum
index f8cef17c11..e59b071e6b 100644
--- a/go.sum
+++ b/go.sum
@@ -1,5 +1,5 @@
-github.com/aykevl/go-wasm v0.0.2-0.20240825160117-b76c3f9f0982 h1:cD7QfvrJdYmBw2tFP/VyKPT8ZESlcrwSwo7SvH9Y4dc=
-github.com/aykevl/go-wasm v0.0.2-0.20240825160117-b76c3f9f0982/go.mod h1:7sXyiaA0WtSogCu67R2252fQpVmJMh9JWJ9ddtGkpWw=
+github.com/aykevl/go-wasm v0.0.2-0.20250317121156-42b86c494139 h1:2O/WuAt8J5id3khcAtVB90czG80m+v0sfkLE07GrCVg=
+github.com/aykevl/go-wasm v0.0.2-0.20250317121156-42b86c494139/go.mod h1:7sXyiaA0WtSogCu67R2252fQpVmJMh9JWJ9ddtGkpWw=
 github.com/blakesmith/ar v0.0.0-20150311145944-8bd4349a67f2 h1:oMCHnXa6CCCafdPDbMh/lWRhRByN0VFLvv+g+ayx1SI=
 github.com/blakesmith/ar v0.0.0-20150311145944-8bd4349a67f2/go.mod h1:PkYb9DJNAwrSvRx5DYA+gUcOIgTGVMNkfSCbZM8cWpI=
 github.com/chromedp/cdproto v0.0.0-20211126220118-81fa0469ad77/go.mod h1:At5TxYYdxkbQL0TSefRjhLE3Q0lgvqKKMSFUglJ7i1U=
diff --git a/lib/bdwgc b/lib/bdwgc
index d1ff06cc50..b435e76287 160000
--- a/lib/bdwgc
+++ b/lib/bdwgc
@@ -1 +1 @@
-Subproject commit d1ff06cc503a74dca0150d5e988f2c93158b0cdf
+Subproject commit b435e76287dbf275d922a0a3c432efc913c8460e
diff --git a/main_test.go b/main_test.go
index f193f46799..475264fc4a 100644
--- a/main_test.go
+++ b/main_test.go
@@ -194,10 +194,26 @@ func TestBuild(t *testing.T) {
 		t.Run("WebAssembly", func(t *testing.T) {
 			t.Parallel()
 			runPlatTests(optionsFromTarget("wasm", sema), tests, t)
+
+			// Test with -gc=boehm.
+			t.Run("gc.go-boehm", func(t *testing.T) {
+				t.Parallel()
+				optionsBoehm := optionsFromTarget("wasm", sema)
+				optionsBoehm.GC = "boehm"
+				runTest("gc.go", optionsBoehm, t, nil, nil)
+			})
 		})
-		t.Run("WASI", func(t *testing.T) {
+		t.Run("WASIp1", func(t *testing.T) {
 			t.Parallel()
 			runPlatTests(optionsFromTarget("wasip1", sema), tests, t)
+
+			// Test with -gc=boehm.
+			t.Run("gc.go-boehm", func(t *testing.T) {
+				t.Parallel()
+				optionsBoehm := optionsFromTarget("wasip1", sema)
+				optionsBoehm.GC = "boehm"
+				runTest("gc.go", optionsBoehm, t, nil, nil)
+			})
 		})
 		t.Run("WASIp2", func(t *testing.T) {
 			t.Parallel()
diff --git a/src/runtime/arch_tinygowasm_malloc.go b/src/runtime/arch_tinygowasm_malloc.go
index 239f7c73eb..8726328025 100644
--- a/src/runtime/arch_tinygowasm_malloc.go
+++ b/src/runtime/arch_tinygowasm_malloc.go
@@ -1,4 +1,4 @@
-//go:build tinygo.wasm && !(custommalloc || wasm_unknown)
+//go:build tinygo.wasm && !(custommalloc || wasm_unknown || gc.boehm)
 
 package runtime
 
diff --git a/src/runtime/gc_boehm.c b/src/runtime/gc_boehm.c
new file mode 100644
index 0000000000..846f31ceba
--- /dev/null
+++ b/src/runtime/gc_boehm.c
@@ -0,0 +1,17 @@
+//go:build none
+
+// This file is included in the build on systems that support the Boehm GC,
+// despite the //go:build line above.
+
+typedef void (* GC_push_other_roots_proc)(void);
+void GC_set_push_other_roots(GC_push_other_roots_proc);
+
+void tinygo_runtime_bdwgc_callback(void);
+
+static void callback(void) {
+    tinygo_runtime_bdwgc_callback();
+}
+
+void tinygo_runtime_bdwgc_init(void) {
+    GC_set_push_other_roots(callback);
+}
diff --git a/src/runtime/gc_boehm.go b/src/runtime/gc_boehm.go
index 0955f3c224..c4aff86d6b 100644
--- a/src/runtime/gc_boehm.go
+++ b/src/runtime/gc_boehm.go
@@ -4,7 +4,6 @@ package runtime
 
 import (
 	"internal/gclayout"
-	"internal/reflectlite"
 	"internal/task"
 	"unsafe"
 )
@@ -19,11 +18,15 @@ var gcLock task.PMutex
 func initHeap() {
 	libgc_init()
 
-	libgc_set_push_other_roots(gcCallbackPtr)
+	// Call GC_set_push_other_roots(gcCallback) in C because of function
+	// signature differences that do matter in WebAssembly.
+	gcInit()
 }
 
-var gcCallbackPtr = reflectlite.ValueOf(gcCallback).UnsafePointer()
+//export tinygo_runtime_bdwgc_init
+func gcInit()
 
+//export tinygo_runtime_bdwgc_callback
 func gcCallback() {
 	// Mark the system stack and (if we're on a goroutine stack) also the
 	// current goroutine stack.
diff --git a/src/runtime/gc_stack_portable.go b/src/runtime/gc_stack_portable.go
index d35e16e30c..7daab606f0 100644
--- a/src/runtime/gc_stack_portable.go
+++ b/src/runtime/gc_stack_portable.go
@@ -1,4 +1,4 @@
-//go:build (gc.conservative || gc.custom || gc.precise) && tinygo.wasm
+//go:build (gc.conservative || gc.custom || gc.precise || gc.boehm) && tinygo.wasm
 
 package runtime
 
diff --git a/targets/wasip1.json b/targets/wasip1.json
index 25ac7c3a6c..8abc65e1e3 100644
--- a/targets/wasip1.json
+++ b/targets/wasip1.json
@@ -23,7 +23,8 @@
 		"--no-demangle"
 	],
 	"extra-files": [
-		"src/runtime/asm_tinygowasm.S"
+		"src/runtime/asm_tinygowasm.S",
+		"src/runtime/gc_boehm.c"
 	],
 	"emulator":      "wasmtime run --dir={tmpDir}::/tmp {}"
 }
diff --git a/targets/wasm.json b/targets/wasm.json
index 1333647e98..a8641636ea 100644
--- a/targets/wasm.json
+++ b/targets/wasm.json
@@ -24,7 +24,8 @@
 		"--no-demangle"
 	],
 	"extra-files": [
-		"src/runtime/asm_tinygowasm.S"
+		"src/runtime/asm_tinygowasm.S",
+		"src/runtime/gc_boehm.c"
 	],
 	"emulator":      "node {root}/targets/wasm_exec.js {}"
 }
diff --git a/tests/runtime_wasi/malloc_test.go b/tests/runtime_wasi/malloc_test.go
index 465e662a45..fed461cb19 100644
--- a/tests/runtime_wasi/malloc_test.go
+++ b/tests/runtime_wasi/malloc_test.go
@@ -124,32 +124,3 @@ func TestMallocFree(t *testing.T) {
 		})
 	}
 }
-
-func TestMallocEmpty(t *testing.T) {
-	ptr := libc_malloc(0)
-	if ptr != nil {
-		t.Errorf("expected nil pointer, got %p", ptr)
-	}
-}
-
-func TestCallocEmpty(t *testing.T) {
-	ptr := libc_calloc(0, 1)
-	if ptr != nil {
-		t.Errorf("expected nil pointer, got %p", ptr)
-	}
-	ptr = libc_calloc(1, 0)
-	if ptr != nil {
-		t.Errorf("expected nil pointer, got %p", ptr)
-	}
-}
-
-func TestReallocEmpty(t *testing.T) {
-	ptr := libc_malloc(1)
-	if ptr == nil {
-		t.Error("expected pointer but was nil")
-	}
-	ptr = libc_realloc(ptr, 0)
-	if ptr != nil {
-		t.Errorf("expected nil pointer, got %p", ptr)
-	}
-}