Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 65 additions & 3 deletions dev/test_wasm_single_worker.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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');
Comment thread
cpunion marked this conversation as resolved.
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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions internal/build/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
60 changes: 60 additions & 0 deletions internal/build/wasm_memory.go
Original file line number Diff line number Diff line change
@@ -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"
Comment thread
cpunion marked this conversation as resolved.

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}
}
134 changes: 134 additions & 0 deletions internal/build/wasm_memory_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
14 changes: 12 additions & 2 deletions internal/clang/clang.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions internal/clang/clang_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion internal/crosscompile/crosscompile.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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",
Expand Down
9 changes: 9 additions & 0 deletions internal/crosscompile/crosscompile_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -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)
}
Expand Down
Loading
Loading