diff --git a/dev/test_wasm_single_worker.sh b/dev/test_wasm_single_worker.sh index 02f7c8d8d1..4bb2a59a4e 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,64 @@ 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 + 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" + 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 >= wasm_page_size )); 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 +266,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 +347,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/build.go b/internal/build/build.go index 6719bde096..c349356b3b 100644 --- a/internal/build/build.go +++ b/internal/build/build.go @@ -2494,6 +2494,7 @@ func linkObjFiles(ctx *context, app string, objFiles, linkArgs []string, verbose buildArgs = append(buildArgs, objFiles...) 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 new file mode 100644 index 0000000000..c18b8541ef --- /dev/null +++ b/internal/build/wasm_memory.go @@ -0,0 +1,60 @@ +/* + * 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" + + "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 +// 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 { + 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. + // 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...) { + 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 + } + } + } + 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..a7920239b6 --- /dev/null +++ b/internal/build/wasm_memory_test.go @@ -0,0 +1,134 @@ +//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: "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"}, + } { + 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", "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) + 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, + }, + } + linker := ctx.linker() + before := linker.LinkArguments(test.args...) + got := defaultWASIHeapArgs(ctx, linker, 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 := linker.LinkArguments(test.args...); !slices.Equal(before, after) { + t.Fatalf("explicit arguments changed: before %q, after %q", before, after) + } + }) + } + if got := defaultWASIHeapArgs(nil, nil, nil); len(got) != 0 { + t.Fatalf("nil context: %q", got) + } + if got := defaultWASIHeapArgs(&context{}, nil, 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) } 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()