From 2e80ae27de4dc573979dc3711243db550c1d4e67 Mon Sep 17 00:00:00 2001 From: Li Jie Date: Wed, 9 Sep 2026 00:17:17 +0800 Subject: [PATCH 1/4] fix(wasi): size initial memory from static data and heap budget --- internal/build/build.go | 1 + internal/build/wasm_memory.go | 47 ++++++++ internal/build/wasm_memory_test.go | 123 +++++++++++++++++++++ internal/clang/clang.go | 14 ++- internal/clang/clang_test.go | 18 +++ internal/crosscompile/crosscompile.go | 2 +- internal/crosscompile/crosscompile_test.go | 9 ++ 7 files changed, 211 insertions(+), 3 deletions(-) create mode 100644 internal/build/wasm_memory.go create mode 100644 internal/build/wasm_memory_test.go diff --git a/internal/build/build.go b/internal/build/build.go index 6719bde096..ef5c5049ef 100644 --- a/internal/build/build.go +++ b/internal/build/build.go @@ -2492,6 +2492,7 @@ func linkObjFiles(ctx *context, app string, objFiles, linkArgs []string, verbose } buildArgs = append(buildArgs, objFiles...) + buildArgs = append(buildArgs, defaultWASIHeapArgs(ctx, buildArgs)...) cmd := ctx.linker() cmd.Verbose = printCmds diff --git a/internal/build/wasm_memory.go b/internal/build/wasm_memory.go new file mode 100644 index 0000000000..c2ff3650dc --- /dev/null +++ b/internal/build/wasm_memory.go @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package build + +import "strings" + +// Retain the previous 64 MiB total minus 10 MiB process-stack heap budget, +// without capping static data at the remainder of that total. wasm-ld adds this +// heap reservation after static data and the process stack, so a normal module +// initially has 64 MiB plus its static data (rounded to Wasm pages). This does +// not change the process stack or impose a maximum linear-memory size. +const defaultWASIHeapFlag = "-Wl,--initial-heap=56623104" + +func defaultWASIHeapArgs(ctx *context, args []string) []string { + if ctx == nil || ctx.buildConf == nil || ctx.buildConf.Goos != "wasip1" || ctx.buildConf.Goarch != "wasm" { + return nil + } + // Wait until all flags, including package link directives and the linker + // command prefix, are available. An explicit initial-memory or initial-heap + // option owns the complete policy; adding a default heap alongside an exact + // initial-memory size could make an otherwise valid user setting too small. + // User-supplied response-file contents are left to the external driver and + // are not expanded by this argument inspection. + for _, arg := range ctx.linker().LinkArguments(args...) { + for _, option := range strings.Split(strings.TrimPrefix(arg, "-Wl,"), ",") { + if option == "--initial-memory" || strings.HasPrefix(option, "--initial-memory=") || + option == "--initial-heap" || strings.HasPrefix(option, "--initial-heap=") { + return nil + } + } + } + return []string{defaultWASIHeapFlag} +} diff --git a/internal/build/wasm_memory_test.go b/internal/build/wasm_memory_test.go new file mode 100644 index 0000000000..ab12393401 --- /dev/null +++ b/internal/build/wasm_memory_test.go @@ -0,0 +1,123 @@ +//go:build !llgo + +package build + +import ( + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/xgo-dev/llgo/internal/crosscompile" +) + +func TestWASIHeapReachesLinker(t *testing.T) { + t.Setenv("CCFLAGS", "") + t.Setenv("LDFLAGS", "") + t.Setenv("LLGO_TEST_LINKER_HELPER", "write") + for _, test := range []struct { + name string + goos string + config []string + args []string + wantHeap bool + }{ + {name: "single worker default", goos: "wasip1", wantHeap: true}, + {name: "explicit initial memory", goos: "wasip1", args: []string{"-Wl,--initial-memory=33554432"}}, + {name: "explicit initial heap", goos: "wasip1", args: []string{"-Wl,--initial-heap=1048576"}}, + {name: "shared memory contract", goos: "wasip1", config: []string{"-Wl,--initial-memory=67108864", "-Wl,--import-memory"}}, + {name: "emscripten unchanged", goos: "js"}, + } { + t.Run(test.name, func(t *testing.T) { + dir := t.TempDir() + argsFile := filepath.Join(dir, "link-args.txt") + t.Setenv("LINK_ARGS_FILE", argsFile) + ctx := &context{ + buildConf: &Config{ + Goos: test.goos, Goarch: "wasm", BuildMode: BuildModeExe, + LinkOptions: LinkOptions{DWARF: DWARFOmit}, + }, + crossCompile: crosscompile.Export{Linker: os.Args[0], LDFLAGS: test.config}, + } + if err := linkObjFiles(ctx, filepath.Join(dir, "app.wasm"), nil, test.args, false); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(argsFile) + if err != nil { + t.Fatal(err) + } + args := strings.Split(strings.TrimSpace(string(data)), "\n") + if got := slices.Contains(args, defaultWASIHeapFlag); got != test.wantHeap { + t.Fatalf("default heap in linker arguments = %v, want %v: %q", got, test.wantHeap, args) + } + for _, flag := range slices.Concat(test.config, test.args) { + if !slices.Contains(args, flag) { + t.Errorf("explicit linker option %q was lost: %q", flag, args) + } + } + }) + } +} + +func TestDefaultWASIHeapArgs(t *testing.T) { + for _, test := range []struct { + name string + goos string + goarch string + args []string + config []string + prefix []string + ccflags string + ldflags string + wantHeap bool + }{ + {name: "default", goos: "wasip1", goarch: "wasm", wantHeap: true}, + {name: "named WASI flags", goos: "wasip1", goarch: "wasm", config: []string{"-target", "wasm32-unknown-wasip1", "-Wl,--stack-first"}, wantHeap: true}, + {name: "native", goos: "linux", goarch: "amd64"}, + {name: "non-wasm architecture", goos: "wasip1", goarch: "amd64"}, + {name: "emscripten", goos: "js", goarch: "wasm"}, + {name: "package memory", goos: "wasip1", goarch: "wasm", args: []string{"-Wl,--initial-memory=33554432"}}, + {name: "package heap", goos: "wasip1", goarch: "wasm", args: []string{"-Wl,--initial-heap=1048576"}}, + {name: "separate driver option", goos: "wasip1", goarch: "wasm", args: []string{"-Wl,--initial-memory,33554432"}}, + {name: "xlinker separate", goos: "wasip1", goarch: "wasm", args: []string{"-Xlinker", "--initial-heap", "-Xlinker", "0"}}, + {name: "xlinker equals", goos: "wasip1", goarch: "wasm", args: []string{"-Xlinker", "--initial-memory=33554432"}}, + {name: "direct linker option", goos: "wasip1", goarch: "wasm", args: []string{"--initial-memory", "33554432"}}, + {name: "config or extldflags", goos: "wasip1", goarch: "wasm", config: []string{"-Wl,--initial-memory=33554432"}}, + {name: "linker prefix", goos: "wasip1", goarch: "wasm", prefix: []string{"-Wl,--initial-heap=1048576"}}, + {name: "CCFLAGS", goos: "wasip1", goarch: "wasm", ccflags: "-Wl,--initial-memory=33554432"}, + {name: "LDFLAGS", goos: "wasip1", goarch: "wasm", ldflags: "-Xlinker --initial-heap=1048576"}, + {name: "maximum unchanged", goos: "wasip1", goarch: "wasm", args: []string{"-Wl,--max-memory=268435456"}, wantHeap: true}, + {name: "unrelated names", goos: "wasip1", goarch: "wasm", args: []string{"data-initial-memory.o", "-Wl,-Map,initial-heap.map"}, wantHeap: true}, + {name: "response contents not expanded", goos: "wasip1", goarch: "wasm", args: []string{"@initial-memory.rsp"}, wantHeap: true}, + } { + t.Run(test.name, func(t *testing.T) { + t.Setenv("CCFLAGS", test.ccflags) + t.Setenv("LDFLAGS", test.ldflags) + ctx := &context{ + buildConf: &Config{Goos: test.goos, Goarch: test.goarch}, + crossCompile: crosscompile.Export{ + Linker: "clang", LinkerArgs: test.prefix, LDFLAGS: test.config, + }, + } + before := ctx.linker().LinkArguments(test.args...) + got := defaultWASIHeapArgs(ctx, test.args) + var want []string + if test.wantHeap { + want = []string{defaultWASIHeapFlag} + } + if !slices.Equal(got, want) { + t.Fatalf("flags = %q, want %q", got, want) + } + if after := ctx.linker().LinkArguments(test.args...); !slices.Equal(before, after) { + t.Fatalf("explicit arguments changed: before %q, after %q", before, after) + } + }) + } + if got := defaultWASIHeapArgs(nil, nil); len(got) != 0 { + t.Fatalf("nil context: %q", got) + } + if got := defaultWASIHeapArgs(&context{}, nil); len(got) != 0 { + t.Fatalf("missing config: %q", got) + } +} diff --git a/internal/clang/clang.go b/internal/clang/clang.go index 2f6e669456..729c41cafc 100644 --- a/internal/clang/clang.go +++ b/internal/clang/clang.go @@ -135,12 +135,22 @@ func (c *Cmd) Compile(args ...string) error { // Link executes a linking command with merged flags. func (c *Cmd) Link(args ...string) error { + return c.exec(c.linkArguments(args...)...) +} + +// LinkArguments returns the effective driver arguments, including its command +// prefix and environment flags, without executing the linker. Inspecting these +// arguments preserves explicit user options when selecting linker defaults. +func (c *Cmd) LinkArguments(args ...string) []string { + return slices.Concat(c.prefixArgs, c.linkArguments(args...)) +} + +func (c *Cmd) linkArguments(args ...string) []string { flags := c.mergeLinkerFlags() allArgs := make([]string, 0, len(flags)+len(args)) allArgs = append(allArgs, flags...) allArgs = append(allArgs, args...) - allArgs = resolveMSVCImportLibraries(c.Dir, allArgs) - return c.exec(allArgs...) + return resolveMSVCImportLibraries(c.Dir, allArgs) } // resolveMSVCImportLibraries lets clang's MSVC driver consume library names diff --git a/internal/clang/clang_test.go b/internal/clang/clang_test.go index 23c37ffddf..188bcaeb44 100644 --- a/internal/clang/clang_test.go +++ b/internal/clang/clang_test.go @@ -35,6 +35,24 @@ import ( const clangTestHelperEnv = "GO_WANT_LLGO_CLANG_TEST_HELPER" +func TestLinkArgumentsIncludesEffectiveDriverFlags(t *testing.T) { + t.Setenv("CCFLAGS", "-Wl,--initial-memory=33554432") + t.Setenv("LDFLAGS", "-Wl,--initial-heap=1048576") + config := Config{Linker: "clang", LinkerArgs: []string{"--driver-mode=g++"}, LDFLAGS: []string{"-Wl,--max-memory=268435456"}} + cmd := NewLinker(config) + args := []string{"-Wl,--export-memory", "input.o"} + want := []string{"--driver-mode=g++", "-Wl,--initial-memory=33554432", "-Wl,--initial-heap=1048576", "-Wl,--max-memory=268435456", "-Wl,--export-memory", "input.o"} + got := cmd.LinkArguments(args...) + if !slices.Equal(got, want) { + t.Fatalf("effective driver arguments = %q, want %q", got, want) + } + got[0] = "changed" + got[len(got)-1] = "changed.o" + if args[1] != "input.o" || config.LinkerArgs[0] != "--driver-mode=g++" { + t.Fatal("effective arguments alias the caller's configuration") + } +} + func init() { if os.Getenv(clangTestHelperEnv) != "1" { return diff --git a/internal/crosscompile/crosscompile.go b/internal/crosscompile/crosscompile.go index d3fc2c1c01..10a06bec50 100644 --- a/internal/crosscompile/crosscompile.go +++ b/internal/crosscompile/crosscompile.go @@ -737,7 +737,6 @@ func useWithGOARMAndToolchain(goos, goarch, goarm string, wasiThreads, forceEspC "-L" + libDir, "-Wl,--allow-undefined", "-Wl,--export-memory", - "-Wl,--initial-memory=67108864", // 64MB // Some LLVM 19 wasm-ld distributions place static data before the // process stack by default. The single-worker runtime and Binaryen // Asyncify switch __stack_pointer; that host-dependent layout traps @@ -764,6 +763,7 @@ func useWithGOARMAndToolchain(goos, goarch, goarm string, wasiThreads, forceEspC export.BuildTags = append(export.BuildTags, "llgo.wasi_threads") export.LDFLAGS = append( export.LDFLAGS, + "-Wl,--initial-memory=67108864", // Preserve the shared-memory backend's host contract. "-Wl,--import-memory", "-lwasi-emulated-pthread", "-lpthread", diff --git a/internal/crosscompile/crosscompile_test.go b/internal/crosscompile/crosscompile_test.go index a4db4636da..e5539e7c33 100644 --- a/internal/crosscompile/crosscompile_test.go +++ b/internal/crosscompile/crosscompile_test.go @@ -290,6 +290,9 @@ func TestUseWASIThreadsImportsMemory(t *testing.T) { if !slices.Contains(export.LDFLAGS, "-Wl,--import-memory") { t.Fatalf("LDFLAGS do not import shared host memory: %v", export.LDFLAGS) } + if !slices.Contains(export.LDFLAGS, "-Wl,--initial-memory=67108864") { + t.Fatalf("WASI pthread initial-memory contract changed: %v", export.LDFLAGS) + } if export.WasmPostLink.Asyncify { t.Fatal("WASI pthread mode requests single-worker Asyncify processing") } @@ -561,6 +564,9 @@ func TestWASIProfileTarget(t *testing.T) { if !export.WasmPostLink.Asyncify { t.Fatal("single-worker WASI does not request Asyncify post-link processing") } + if slices.Contains(export.LDFLAGS, "-Wl,--initial-memory=67108864") { + t.Fatalf("WASI C profile caps initial memory independently of static data: %v", export.LDFLAGS) + } if !strings.Contains(export.Emulator, "--dir=/") || !strings.Contains(export.Emulator, "--env PWD") || !strings.Contains(export.Emulator, "--env PATH") { @@ -684,6 +690,9 @@ func TestRawWasmProfiles(t *testing.T) { if !wasi.WasmPostLink.Asyncify { t.Fatal("raw single-worker WASI does not request Asyncify post-link processing") } + if slices.Contains(wasi.LDFLAGS, "-Wl,--initial-memory=67108864") { + t.Fatalf("raw Go WASI profile caps initial memory independently of static data: %v", wasi.LDFLAGS) + } if !slices.Contains(wasi.BuildTags, "llgo.wasm.wasi") { t.Fatalf("raw wasip1/wasm did not select the WASI provider source tag: %v", wasi.BuildTags) } From f4e374281a01bd306a489e35785a98e832b2fa92 Mon Sep 17 00:00:00 2001 From: Li Jie Date: Wed, 9 Sep 2026 00:40:21 +0800 Subject: [PATCH 2/4] refactor(build): inspect WASI defaults with the active linker command --- internal/build/build.go | 2 +- internal/build/wasm_memory.go | 12 +++++++++--- internal/build/wasm_memory_test.go | 11 ++++++----- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/internal/build/build.go b/internal/build/build.go index ef5c5049ef..c349356b3b 100644 --- a/internal/build/build.go +++ b/internal/build/build.go @@ -2492,9 +2492,9 @@ func linkObjFiles(ctx *context, app string, objFiles, linkArgs []string, verbose } buildArgs = append(buildArgs, objFiles...) - buildArgs = append(buildArgs, defaultWASIHeapArgs(ctx, buildArgs)...) cmd := ctx.linker() + buildArgs = append(buildArgs, defaultWASIHeapArgs(ctx, cmd, buildArgs)...) cmd.Verbose = printCmds if err := cmd.Link(buildArgs...); err != nil { return err diff --git a/internal/build/wasm_memory.go b/internal/build/wasm_memory.go index c2ff3650dc..359346fe61 100644 --- a/internal/build/wasm_memory.go +++ b/internal/build/wasm_memory.go @@ -16,7 +16,11 @@ package build -import "strings" +import ( + "strings" + + "github.com/xgo-dev/llgo/internal/clang" +) // Retain the previous 64 MiB total minus 10 MiB process-stack heap budget, // without capping static data at the remainder of that total. wasm-ld adds this @@ -25,7 +29,7 @@ import "strings" // not change the process stack or impose a maximum linear-memory size. const defaultWASIHeapFlag = "-Wl,--initial-heap=56623104" -func defaultWASIHeapArgs(ctx *context, args []string) []string { +func defaultWASIHeapArgs(ctx *context, linker *clang.Cmd, args []string) []string { if ctx == nil || ctx.buildConf == nil || ctx.buildConf.Goos != "wasip1" || ctx.buildConf.Goarch != "wasm" { return nil } @@ -35,7 +39,9 @@ func defaultWASIHeapArgs(ctx *context, args []string) []string { // initial-memory size could make an otherwise valid user setting too small. // User-supplied response-file contents are left to the external driver and // are not expanded by this argument inspection. - for _, arg := range ctx.linker().LinkArguments(args...) { + // Reuse the command that will perform the link. Its MSVC library resolution + // is a no-op for WASI targets, so inspecting these flags performs no file I/O. + for _, arg := range linker.LinkArguments(args...) { for _, option := range strings.Split(strings.TrimPrefix(arg, "-Wl,"), ",") { if option == "--initial-memory" || strings.HasPrefix(option, "--initial-memory=") || option == "--initial-heap" || strings.HasPrefix(option, "--initial-heap=") { diff --git a/internal/build/wasm_memory_test.go b/internal/build/wasm_memory_test.go index ab12393401..c38c03e567 100644 --- a/internal/build/wasm_memory_test.go +++ b/internal/build/wasm_memory_test.go @@ -100,8 +100,9 @@ func TestDefaultWASIHeapArgs(t *testing.T) { Linker: "clang", LinkerArgs: test.prefix, LDFLAGS: test.config, }, } - before := ctx.linker().LinkArguments(test.args...) - got := defaultWASIHeapArgs(ctx, test.args) + linker := ctx.linker() + before := linker.LinkArguments(test.args...) + got := defaultWASIHeapArgs(ctx, linker, test.args) var want []string if test.wantHeap { want = []string{defaultWASIHeapFlag} @@ -109,15 +110,15 @@ func TestDefaultWASIHeapArgs(t *testing.T) { if !slices.Equal(got, want) { t.Fatalf("flags = %q, want %q", got, want) } - if after := ctx.linker().LinkArguments(test.args...); !slices.Equal(before, after) { + if after := linker.LinkArguments(test.args...); !slices.Equal(before, after) { t.Fatalf("explicit arguments changed: before %q, after %q", before, after) } }) } - if got := defaultWASIHeapArgs(nil, nil); len(got) != 0 { + if got := defaultWASIHeapArgs(nil, nil, nil); len(got) != 0 { t.Fatalf("nil context: %q", got) } - if got := defaultWASIHeapArgs(&context{}, nil); len(got) != 0 { + if got := defaultWASIHeapArgs(&context{}, nil, nil); len(got) != 0 { t.Fatalf("missing config: %q", got) } } From 1a2614cf5e28e7068609bad3257f7d25f0e975bd Mon Sep 17 00:00:00 2001 From: Li Jie Date: Wed, 9 Sep 2026 01:05:00 +0800 Subject: [PATCH 3/4] fix(wasm): bootstrap empty heaps and honor response-file policy --- dev/test_wasm_single_worker.sh | 67 +++++++++++++++++++- internal/build/wasm_memory.go | 14 ++-- internal/build/wasm_memory_test.go | 14 +++- runtime/internal/runtime/tinygogc/gc_wasm.go | 8 +++ 4 files changed, 94 insertions(+), 9 deletions(-) diff --git a/dev/test_wasm_single_worker.sh b/dev/test_wasm_single_worker.sh index 02f7c8d8d1..ba4ba5be4b 100755 --- a/dev/test_wasm_single_worker.sh +++ b/dev/test_wasm_single_worker.sh @@ -20,7 +20,7 @@ trap 'rm -rf "${work_dir}"' EXIT export LLGO_WASM_TEST_ENV=wasm-env-ok case "${suite}" in -all | runtime | test-command) ;; +all | runtime | test-command | gc-heap) ;; *) echo "unknown single-worker WebAssembly suite: ${suite}" >&2 exit 2 @@ -131,6 +131,63 @@ run_host_call_boundaries() { done } +wasi_heap_layout() { + # Instantiate without starting Go: startup itself needs the GC heap. These + # linker exports let the test derive the boundary from the actual module. + "${node_cmd}" - "$1" <<'JS' +const fs = require('node:fs'); +const {WASI} = require('node:wasi'); +const wasi = new WASI({version: 'preview1', args: ['heap-layout'], env: {}, preopens: {}}); +const module = new WebAssembly.Module(fs.readFileSync(process.argv[2])); +const instance = new WebAssembly.Instance(module, {wasi_snapshot_preview1: wasi.wasiImport}); +console.log(instance.exports.__global_base.value, instance.exports.__heap_base.value, + instance.exports.memory.buffer.byteLength); +JS +} + +run_wasi_empty_heap() { + local response="${work_dir}/heap-objects.rsp" + local module="${work_dir}/gc-wasi-objects.wasm" + local heap_flags base heap memory aligned_base boundary_heap boundary_memory + # An object-only user response file must not acquire an implicit 54 MiB + # heap. No response-file parsing is needed to preserve user memory policy. + clang --target=wasm32-unknown-unknown -c -x c /dev/null -o "${work_dir}/heap-empty.o" + printf '"%s"\n' "${work_dir}/heap-empty.o" > "${response}" + heap_flags="${LDFLAGS:-} -Wl,@${response} -Wl,--export=__heap_base,--export=__global_base" + # Reuse package archives between these links. Changing only linker options + # must not require rebuilding the runtime; the rest of the suite keeps its + # caller-selected cache policy. + LLGO_BUILD_CACHE=on LDFLAGS="${heap_flags}" \ + run_wasi wasi "${gc_fixture}" "wasm gc ok" "gc-wasi-objects" + read -r base heap memory < <(wasi_heap_layout "${module}") + echo "WASI object response: heap base=${heap}, initial memory=${memory}" + if (( heap > memory || memory - heap >= 65536 )); then + echo "unexpected initial heap for object response: base=${heap}, memory=${memory}" >&2 + exit 1 + fi + + # Shift static data by its page-rounding slack to create an exact, real + # __heap_base == memory.size boundary, independent of fixture/code size. + aligned_base=$((base + memory - heap)) + heap_flags+=" -Wl,--initial-heap=0,--global-base=${aligned_base}" + LLGO_BUILD_CACHE=on LDFLAGS="${heap_flags}" \ + run_wasi wasi "${gc_fixture}" "wasm gc ok" "gc-wasi-empty" + read -r base boundary_heap boundary_memory < <(wasi_heap_layout "${work_dir}/gc-wasi-empty.wasm") + echo "WASI zero heap: heap base=${boundary_heap}, initial memory=${boundary_memory}" + if (( boundary_heap != boundary_memory )); then + echo "zero-heap test missed boundary: base=${boundary_heap}, memory=${boundary_memory}" >&2 + exit 1 + fi + + # The same valid module must fail clearly if the host cannot grow its empty + # heap. Neither an arbitrary trap nor a link failure satisfies this check. + module="${work_dir}/gc-wasi-empty-limited.wasm" + LLGO_BUILD_CACHE=on LDFLAGS="${heap_flags} -Wl,--max-memory=${boundary_memory}" \ + "${llgo_cmd}" build -target wasi -o "${module}" "${gc_fixture}" + wasm-tools validate --features all "${module}" + expect_failure "gc: invalid heap range" "${wasmtime_cmd}" run -W exceptions=y "${module}" +} + run_llgo_test() { local target="$1" local name="$2" @@ -208,7 +265,7 @@ run_llgo_go_profile_run() { grep -Fq "raw wasm run ok" "${output}" } -if [[ "${suite}" != "test-command" ]]; then +if [[ "${suite}" == "all" || "${suite}" == "runtime" ]]; then # Check finalizer registry scaling and queue removal without timing or # conservative-root assumptions, alongside the real Wasm lifecycle fixtures. go -C "${repo_root}" test ./internal/build -run '^TestWasmFinalizerCandidates$' -count=1 @@ -289,7 +346,11 @@ run_host_call_boundaries "${work_dir}/callback-memory64.mjs" fi -if [[ "${suite}" != "runtime" ]]; then +if [[ "${suite}" != "test-command" ]]; then +run_wasi_empty_heap +fi + +if [[ "${suite}" == "all" || "${suite}" == "test-command" ]]; then # Exercise test-main generation, process exit, verbose output, and host runners # through the public test command. The JS-specific callback case also verifies # that host readiness interrupts a longer Go timer wait without re-entering an diff --git a/internal/build/wasm_memory.go b/internal/build/wasm_memory.go index 359346fe61..f23aeb4c6c 100644 --- a/internal/build/wasm_memory.go +++ b/internal/build/wasm_memory.go @@ -37,13 +37,19 @@ func defaultWASIHeapArgs(ctx *context, linker *clang.Cmd, args []string) []strin // command prefix, are available. An explicit initial-memory or initial-heap // option owns the complete policy; adding a default heap alongside an exact // initial-memory size could make an otherwise valid user setting too small. - // User-supplied response-file contents are left to the external driver and - // are not expanded by this argument inspection. + // A user response file also owns the policy: it may contain either option, + // including in a nested file. Leave its contents and quoting to the driver. + // The driver's automatic response-file creation happens after inspection. // Reuse the command that will perform the link. Its MSVC library resolution // is a no-op for WASI targets, so inspecting these flags performs no file I/O. for _, arg := range linker.LinkArguments(args...) { - for _, option := range strings.Split(strings.TrimPrefix(arg, "-Wl,"), ",") { - if option == "--initial-memory" || strings.HasPrefix(option, "--initial-memory=") || + options := []string{arg} + if strings.HasPrefix(arg, "-Wl,") { + options = strings.Split(strings.TrimPrefix(arg, "-Wl,"), ",") + } + for _, option := range options { + if strings.HasPrefix(option, "@") || + option == "--initial-memory" || strings.HasPrefix(option, "--initial-memory=") || option == "--initial-heap" || strings.HasPrefix(option, "--initial-heap=") { return nil } diff --git a/internal/build/wasm_memory_test.go b/internal/build/wasm_memory_test.go index c38c03e567..a7920239b6 100644 --- a/internal/build/wasm_memory_test.go +++ b/internal/build/wasm_memory_test.go @@ -26,6 +26,9 @@ func TestWASIHeapReachesLinker(t *testing.T) { {name: "single worker default", goos: "wasip1", wantHeap: true}, {name: "explicit initial memory", goos: "wasip1", args: []string{"-Wl,--initial-memory=33554432"}}, {name: "explicit initial heap", goos: "wasip1", args: []string{"-Wl,--initial-heap=1048576"}}, + {name: "driver response", goos: "wasip1", args: []string{"@user flags.rsp"}}, + {name: "linker response", goos: "wasip1", args: []string{"-Wl,@user flags.rsp"}}, + {name: "configured response", goos: "wasip1", config: []string{"-Xlinker", "@user flags.rsp"}}, {name: "shared memory contract", goos: "wasip1", config: []string{"-Wl,--initial-memory=67108864", "-Wl,--import-memory"}}, {name: "emscripten unchanged", goos: "js"}, } { @@ -88,8 +91,15 @@ func TestDefaultWASIHeapArgs(t *testing.T) { {name: "CCFLAGS", goos: "wasip1", goarch: "wasm", ccflags: "-Wl,--initial-memory=33554432"}, {name: "LDFLAGS", goos: "wasip1", goarch: "wasm", ldflags: "-Xlinker --initial-heap=1048576"}, {name: "maximum unchanged", goos: "wasip1", goarch: "wasm", args: []string{"-Wl,--max-memory=268435456"}, wantHeap: true}, - {name: "unrelated names", goos: "wasip1", goarch: "wasm", args: []string{"data-initial-memory.o", "-Wl,-Map,initial-heap.map"}, wantHeap: true}, - {name: "response contents not expanded", goos: "wasip1", goarch: "wasm", args: []string{"@initial-memory.rsp"}, wantHeap: true}, + {name: "unrelated names", goos: "wasip1", goarch: "wasm", args: []string{"data-initial-memory.o", "objects@user.o", "objects,@user.o", "-Wl,-Map,initial-heap.map"}, wantHeap: true}, + {name: "driver response", goos: "wasip1", goarch: "wasm", args: []string{"@user flags.rsp"}}, + {name: "linker response", goos: "wasip1", goarch: "wasm", args: []string{"-Wl,@user flags.rsp"}}, + {name: "combined linker response", goos: "wasip1", goarch: "wasm", args: []string{"-Wl,--export=main,@objects.rsp"}}, + {name: "xlinker response", goos: "wasip1", goarch: "wasm", args: []string{"-Xlinker", "@user flags.rsp"}}, + {name: "config response", goos: "wasip1", goarch: "wasm", config: []string{"@user flags.rsp"}}, + {name: "linker prefix response", goos: "wasip1", goarch: "wasm", prefix: []string{"@user flags.rsp"}}, + {name: "CCFLAGS response", goos: "wasip1", goarch: "wasm", ccflags: "-O2 -Wl,@user flags.rsp"}, + {name: "LDFLAGS response", goos: "wasip1", goarch: "wasm", ldflags: "-O2 -Wl,@user flags.rsp"}, } { t.Run(test.name, func(t *testing.T) { t.Setenv("CCFLAGS", test.ccflags) diff --git a/runtime/internal/runtime/tinygogc/gc_wasm.go b/runtime/internal/runtime/tinygogc/gc_wasm.go index 43da0adc39..decbd20d14 100644 --- a/runtime/internal/runtime/tinygogc/gc_wasm.go +++ b/runtime/internal/runtime/tinygogc/gc_wasm.go @@ -16,6 +16,14 @@ const wasmPageSize = uintptr(64 << 10) func gcMemoryLayout() (heapStart, heapEnd, globalsStart, globalsEnd, stackTop uintptr) { heapStart = alignUp(gcWasmHeapBase(), bytesPerBlock) heapEnd = alignDown(gcWasmMemorySize(), bytesPerBlock) + // A zero initial heap can leave no page-rounding slack after static data. + // Bootstrap the metadata before the first allocation can use growHeap. + // Do not use gcGrowMemory here: the package heapStart is not initialized yet. + if heapStart == heapEnd && heapEnd <= ^uintptr(0)-wasmPageSize { + if gcWasmGrowMemory(heapEnd+wasmPageSize) != 0 { + heapEnd = alignDown(gcWasmMemorySize(), bytesPerBlock) + } + } globalsStart = gcWasmGlobalsStart() globalsEnd = gcWasmGlobalsEnd() stackTop = gcWasmStackTop() From ea838b5761dd616d663e28716e13ddbe40b311bc Mon Sep 17 00:00:00 2001 From: Li Jie Date: Mon, 14 Sep 2026 13:40:22 +0800 Subject: [PATCH 4/4] test(wasm): name coupled memory constants --- dev/test_wasm_single_worker.sh | 3 ++- internal/build/wasm_memory.go | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/dev/test_wasm_single_worker.sh b/dev/test_wasm_single_worker.sh index ba4ba5be4b..4bb2a59a4e 100755 --- a/dev/test_wasm_single_worker.sh +++ b/dev/test_wasm_single_worker.sh @@ -149,6 +149,7 @@ run_wasi_empty_heap() { local response="${work_dir}/heap-objects.rsp" local module="${work_dir}/gc-wasi-objects.wasm" local heap_flags base heap memory aligned_base boundary_heap boundary_memory + local wasm_page_size=65536 # An object-only user response file must not acquire an implicit 54 MiB # heap. No response-file parsing is needed to preserve user memory policy. clang --target=wasm32-unknown-unknown -c -x c /dev/null -o "${work_dir}/heap-empty.o" @@ -161,7 +162,7 @@ run_wasi_empty_heap() { run_wasi wasi "${gc_fixture}" "wasm gc ok" "gc-wasi-objects" read -r base heap memory < <(wasi_heap_layout "${module}") echo "WASI object response: heap base=${heap}, initial memory=${memory}" - if (( heap > memory || memory - heap >= 65536 )); then + if (( heap > memory || memory - heap >= wasm_page_size )); then echo "unexpected initial heap for object response: base=${heap}, memory=${memory}" >&2 exit 1 fi diff --git a/internal/build/wasm_memory.go b/internal/build/wasm_memory.go index f23aeb4c6c..c18b8541ef 100644 --- a/internal/build/wasm_memory.go +++ b/internal/build/wasm_memory.go @@ -27,6 +27,7 @@ import ( // heap reservation after static data and the process stack, so a normal module // initially has 64 MiB plus its static data (rounded to Wasm pages). This does // not change the process stack or impose a maximum linear-memory size. +// The 10 MiB value matches -z stack-size=10485760 in crosscompile.go. const defaultWASIHeapFlag = "-Wl,--initial-heap=56623104" func defaultWASIHeapArgs(ctx *context, linker *clang.Cmd, args []string) []string {