diff --git a/.github/actions/setup-binaryen/action.yml b/.github/actions/setup-binaryen/action.yml new file mode 100644 index 0000000000..ed76713576 --- /dev/null +++ b/.github/actions/setup-binaryen/action.yml @@ -0,0 +1,40 @@ +name: "Setup Binaryen" +description: "Install a pinned Binaryen release" +inputs: + version: + description: "Binaryen release version" + required: false + default: "131" + +runs: + using: "composite" + steps: + - name: Install Binaryen + shell: bash + run: | + set -euo pipefail + + version="${{ inputs.version }}" + case "$(uname -s):$(uname -m)" in + Linux:x86_64) platform="x86_64-linux" ;; + Linux:aarch64|Linux:arm64) platform="aarch64-linux" ;; + Darwin:x86_64) platform="x86_64-macos" ;; + Darwin:arm64) platform="arm64-macos" ;; + *) + echo "Unsupported Binaryen host: $(uname -s) $(uname -m)" >&2 + exit 1 + ;; + esac + + archive="binaryen-version_${version}-${platform}.tar.gz" + base_url="https://github.com/WebAssembly/binaryen/releases/download/version_${version}" + cd "$RUNNER_TEMP" + curl --retry 3 --retry-all-errors -fsSLO "${base_url}/${archive}" + curl --retry 3 --retry-all-errors -fsSLO "${base_url}/${archive}.sha256" + if command -v sha256sum >/dev/null; then + sha256sum --check "${archive}.sha256" + else + shasum -a 256 --check "${archive}.sha256" + fi + tar -xzf "$archive" -C "$RUNNER_TEMP" + echo "$RUNNER_TEMP/binaryen-version_${version}/bin" >> "$GITHUB_PATH" diff --git a/.github/workflows/build-cache.yml b/.github/workflows/build-cache.yml index 5b3fb374ea..6663946239 100644 --- a/.github/workflows/build-cache.yml +++ b/.github/workflows/build-cache.yml @@ -35,6 +35,9 @@ jobs: - name: Set up Go uses: ./.github/actions/setup-go + - name: Set up Binaryen + uses: ./.github/actions/setup-binaryen + - name: Install wamr (for wasm tests) if: startsWith(matrix.os, 'macos') run: | diff --git a/.github/workflows/llgo.yml b/.github/workflows/llgo.yml index e8e34e6854..7fb6e50498 100644 --- a/.github/workflows/llgo.yml +++ b/.github/workflows/llgo.yml @@ -180,7 +180,9 @@ jobs: test: name: test (${{ matrix.lane }}, ${{ matrix.os }}, LLVM ${{ matrix.llvm }}, Go ${{ matrix.go }}, shard ${{ matrix.shard }}) continue-on-error: ${{ matrix.lane == 'compatibility' }} - timeout-minutes: 30 + # macOS runs the full package set in one shard and is normally just over + # 30 minutes once the wasm runtime sources are present. + timeout-minutes: 40 strategy: matrix: # Keep compatibility and primary toolchains pinned to exact patches. @@ -366,6 +368,9 @@ jobs: - name: Set up Go for building llgo uses: ./.github/actions/setup-go + - name: Set up Binaryen + uses: ./.github/actions/setup-binaryen + - name: Install wamr run: | git clone --branch WAMR-2.4.4 --depth 1 https://github.com/bytecodealliance/wasm-micro-runtime.git @@ -414,6 +419,24 @@ jobs: with: version: "4.0.21" + - name: Set up Node.js + uses: actions/setup-node@v7 + with: + node-version: "25" + + - name: Set up Binaryen + uses: ./.github/actions/setup-binaryen + + - name: Set up Wasmtime + uses: bytecodealliance/actions/wasmtime/setup@v1 + with: + version: "39.0.1" + + - name: Set up wasm-tools + uses: bytecodealliance/actions/wasm-tools/setup@v1 + with: + version: "1.243.0" + - name: Set up Go for building llgo uses: ./.github/actions/setup-go @@ -430,6 +453,103 @@ jobs: - name: Build standard runtime for wasm shell: bash run: | + run_wasm_scheduler() { + local module="$1" + local output + node --input-type=module -e "import Module from '$module'; await Module();" + if output=$(node --input-type=module -e "import Module from '$module'; await Module({preRun: [module => { module.ENV.LLGO_WASM_SCHEDULER_DEADLOCK = '1'; }]});" 2>&1); then + echo "deadlock scheduler fixture unexpectedly succeeded" + return 1 + fi + grep -Fq "fatal error: all goroutines are asleep - deadlock!" <<<"$output" + } + + run_wasi_scheduler() { + local module="$1" + local output + wasm-tools validate --features all "$module" + output=$(wasmtime run -W exceptions=y "$module" 2>&1) + grep -Fq "wasm scheduler ok" <<<"$output" + if output=$(wasmtime run -W exceptions=y \ + --env LLGO_WASM_SCHEDULER_DEADLOCK=1 "$module" 2>&1); then + echo "deadlock scheduler fixture unexpectedly succeeded" + return 1 + fi + grep -Fq "fatal error: all goroutines are asleep - deadlock!" <<<"$output" + } + + run_wasm_timers() { + local output + output=$(node --input-type=module -e "import Module from '$1'; await Module();" 2>&1) + test "$output" = "wasm timers ok" + } + + run_wasm_workers() { + local output + output=$(node -e "import('$1').then(module => module.default()).catch(error => { console.error(error); process.exit(1); });" 2>&1) + grep -Fxq "wasm workers ok" <<<"$output" + } + + run_wasi_timers() { + local output + wasm-tools validate --features all "$1" + output=$(wasmtime run -W exceptions=y "$1" 2>&1) + test "$output" = "wasm timers ok" + } + GOOS=js GOARCH=wasm llgo build -o "$RUNNER_TEMP/runtime-js.wasm" ./internal/build/testdata/wasm-runtime GOOS=wasip1 GOARCH=wasm llgo build -o "$RUNNER_TEMP/runtime-wasip1.wasm" ./internal/build/testdata/wasm-runtime - file "$RUNNER_TEMP/runtime-js.wasm" "$RUNNER_TEMP/runtime-wasip1.wasm" + LLGO_WASI_THREADS=1 GOOS=wasip1 GOARCH=wasm llgo build \ + -o "$RUNNER_TEMP/runtime-wasip1-threads.wasm" ./internal/build/testdata/wasm-blocking + test "$(wasmtime run -W exceptions=y "$RUNNER_TEMP/runtime-wasip1.wasm" 2>&1)" = "wasip1" + GOOS=js GOARCH=wasm llgo build -o "$RUNNER_TEMP/wasm-scheduler-go.mjs" ./internal/build/testdata/wasm-scheduler + run_wasm_scheduler "$RUNNER_TEMP/wasm-scheduler-go.mjs" + llgo build -target wasm -o "$RUNNER_TEMP/wasm-scheduler.mjs" ./internal/build/testdata/wasm-scheduler + run_wasm_scheduler "$RUNNER_TEMP/wasm-scheduler.mjs" + GOOS=wasip1 GOARCH=wasm llgo build -o "$RUNNER_TEMP/wasm-scheduler-wasip1.wasm" ./internal/build/testdata/wasm-scheduler + run_wasi_scheduler "$RUNNER_TEMP/wasm-scheduler-wasip1.wasm" + GOOS=js GOARCH=wasm llgo build -o "$RUNNER_TEMP/wasm-timers-go.mjs" ./internal/build/testdata/wasm-timers + run_wasm_timers "$RUNNER_TEMP/wasm-timers-go.mjs" + llgo build -target wasm -o "$RUNNER_TEMP/wasm-timers.mjs" ./internal/build/testdata/wasm-timers + run_wasm_timers "$RUNNER_TEMP/wasm-timers.mjs" + GOOS=wasip1 GOARCH=wasm llgo build -o "$RUNNER_TEMP/wasm-timers-wasip1.wasm" ./internal/build/testdata/wasm-timers + run_wasi_timers "$RUNNER_TEMP/wasm-timers-wasip1.wasm" + LLGO_WASM_WORKERS=2 GOOS=js GOARCH=wasm llgo build \ + -o "$RUNNER_TEMP/wasm-workers-go.mjs" ./internal/build/testdata/wasm-workers + run_wasm_workers "$RUNNER_TEMP/wasm-workers-go.mjs" + LLGO_WASM_WORKERS=2 llgo build -target wasm \ + -o "$RUNNER_TEMP/wasm-workers.mjs" ./internal/build/testdata/wasm-workers + run_wasm_workers "$RUNNER_TEMP/wasm-workers.mjs" + cp ./internal/build/testdata/wasm-workers/browser.html "$RUNNER_TEMP/browser.html" + node ./internal/build/testdata/wasm-workers/server.mjs "$RUNNER_TEMP" 8123 & + browser_server=$! + trap 'kill "$browser_server" 2>/dev/null || true' EXIT + for attempt in {1..50}; do + if curl -fsS http://127.0.0.1:8123/ >/dev/null; then + break + fi + sleep 0.1 + done + curl -fsS http://127.0.0.1:8123/ >/dev/null + browser="$(command -v google-chrome || command -v google-chrome-stable || command -v chromium || true)" + test -n "$browser" + for module in wasm-timers.mjs wasm-workers.mjs wasm-workers-go.mjs; do + html=$("$browser" --headless=new --no-sandbox --disable-gpu \ + --disable-dev-shm-usage --virtual-time-budget=15000 --dump-dom \ + "http://127.0.0.1:8123/browser.html?module=$module") + grep -Fq 'data-result="pass"' <<<"$html" + done + kill "$browser_server" + wait "$browser_server" || true + trap - EXIT + GOOS=js GOARCH=wasm llgo build -o "$RUNNER_TEMP/wasm-gc-go.mjs" ./internal/build/testdata/wasm-gc + node --input-type=module -e "import Module from '$RUNNER_TEMP/wasm-gc-go.mjs'; await Module();" + llgo build -target wasm -o "$RUNNER_TEMP/wasm-gc.mjs" ./internal/build/testdata/wasm-gc + node --input-type=module -e "import Module from '$RUNNER_TEMP/wasm-gc.mjs'; await Module();" + GOOS=wasip1 GOARCH=wasm LLGO_WASI_THREADS=0 llgo build -o "$RUNNER_TEMP/wasm-gc-wasip1.wasm" ./internal/build/testdata/wasm-gc + wasm-tools validate --features all "$RUNNER_TEMP/wasm-gc-wasip1.wasm" + test "$(wasmtime run -W exceptions=y "$RUNNER_TEMP/wasm-gc-wasip1.wasm" 2>&1)" = "wasm gc ok" + file "$RUNNER_TEMP/runtime-js.wasm" \ + "$RUNNER_TEMP/runtime-wasip1.wasm" \ + "$RUNNER_TEMP/runtime-wasip1-threads.wasm" \ + "$RUNNER_TEMP/wasm-gc-wasip1.wasm" diff --git a/.github/workflows/targets.yml b/.github/workflows/targets.yml index 6eed98a333..171eb78566 100644 --- a/.github/workflows/targets.yml +++ b/.github/workflows/targets.yml @@ -29,6 +29,11 @@ jobs: with: llvm-version: ${{matrix.llvm}} + - name: Set up Emscripten + uses: emscripten-core/setup-emsdk@v15 + with: + version: "4.0.21" + - name: Set up Go for build uses: ./.github/actions/setup-go diff --git a/benchmark/wasm_workers/main.go b/benchmark/wasm_workers/main.go new file mode 100644 index 0000000000..34209f68bc --- /dev/null +++ b/benchmark/wasm_workers/main.go @@ -0,0 +1,86 @@ +package main + +import ( + "sync" + "time" +) + +const ( + lifecycleIterations = 10_000 + channelIterations = 100_000 + cpuIterations = 20_000_000 +) + +var cpuResult uint64 + +func main() { + report("WasmGoroutineLifecycle", lifecycleIterations, benchmarkLifecycle()) + report("WasmChannelRoundTrip", channelIterations, benchmarkChannel()) + cpuResult = cpuWork(0) + report("WasmOneCPUJob", 1, benchmarkCPUJobs(1)) + report("WasmTwoCPUJobs", 2, benchmarkCPUJobs(2)) + if cpuResult == 0 { + panic("unexpected CPU benchmark result") + } +} + +func benchmarkLifecycle() time.Duration { + done := make(chan struct{}, 1) + start := time.Now() + for range lifecycleIterations { + go func() { + done <- struct{}{} + }() + <-done + } + return time.Since(start) +} + +func benchmarkChannel() time.Duration { + request := make(chan struct{}) + response := make(chan struct{}) + go func() { + for range channelIterations { + <-request + response <- struct{}{} + } + }() + + start := time.Now() + for range channelIterations { + request <- struct{}{} + <-response + } + return time.Since(start) +} + +func benchmarkCPUJobs(jobs int) time.Duration { + var wg sync.WaitGroup + results := make([]uint64, jobs) + wg.Add(jobs) + start := time.Now() + for i := range results { + go func() { + results[i] = cpuWork(uint64(i + 1)) + wg.Done() + }() + } + wg.Wait() + cpuResult = 0 + for _, result := range results { + cpuResult ^= result + } + return time.Since(start) +} + +//go:noinline +func cpuWork(value uint64) uint64 { + for i := uint64(0); i < cpuIterations; i++ { + value = value*1664525 + 1013904223 + i + } + return value +} + +func report(name string, iterations int, elapsed time.Duration) { + println("Benchmark"+name+"-1", iterations, elapsed.Nanoseconds()/int64(iterations), "ns/op") +} diff --git a/cl/compile.go b/cl/compile.go index 8f99b9a0f5..35f062d47d 100644 --- a/cl/compile.go +++ b/cl/compile.go @@ -32,6 +32,7 @@ import ( "github.com/goplus/llgo/cl/blocks" "github.com/goplus/llgo/cl/ssawrap" + "github.com/goplus/llgo/internal/directive" "github.com/goplus/llgo/internal/goembed" "github.com/goplus/llgo/internal/typepatch" "golang.org/x/tools/go/ssa" @@ -179,6 +180,10 @@ type context struct { debugDIVars map[*types.Var]llssa.DIVar debugAllocVars map[*ssa.Alloc]*types.Var runtimeCallerFuncs map[*ssa.Function]bool + gcRoots map[ssa.Value][]llssa.Expr + gcClosureRoot llssa.Expr + safepointEntry bool + safepoints map[ssa.Instruction]struct{} pcLineSeq uint64 patches Patches @@ -560,6 +565,13 @@ func (p *context) compileFuncDecl(pkg llssa.Package, f *ssa.Function) (llssa.Fun if fn == nil { fn = pkg.NewFuncEx(name, sig, llssa.Background(ftype), hasCtx, p.needsLinkOnce(f)) } + if target := p.prog.Target(); target.GOARCH == "wasm" { + if decl, ok := f.Syntax().(*ast.FuncDecl); ok { + if module, importName, ok := wasmImportByDoc(decl.Doc); ok { + fn.SetWasmImport(module, importName) + } + } + } noInlineDirective := hasNoInlineDirective(f) runtimeStackNoInline := needsRuntimeStackNoInline(pkgTypes, f) pcLineNoInline := p.needsPCLineNoInline(f) @@ -605,6 +617,8 @@ func (p *context) compileFuncDecl(pkg llssa.Package, f *ssa.Function) (llssa.Fun dbgSymsEnabled := enableDbgSyms && (f == nil || f.Origin() == nil) p.inits = append(p.inits, func() { oldFn, oldGoFn, oldMethodNilDerefChecks, oldCallerFrameMark := p.fn, p.goFn, p.methodNilDerefChecks, p.callerFrameMark + oldGCRoots, oldGCClosureRoot := p.gcRoots, p.gcClosureRoot + oldSafepointEntry, oldSafepoints := p.safepointEntry, p.safepoints oldLocalityFunction := p.locality.function p.fn = fn p.goFn = f @@ -613,6 +627,8 @@ func (p *context) compileFuncDecl(pkg llssa.Package, f *ssa.Function) (llssa.Fun p.state = state // restore pkgState when compiling funcBody defer func() { p.fn, p.goFn, p.methodNilDerefChecks, p.callerFrameMark = oldFn, oldGoFn, oldMethodNilDerefChecks, oldCallerFrameMark + p.gcRoots, p.gcClosureRoot = oldGCRoots, oldGCClosureRoot + p.safepointEntry, p.safepoints = oldSafepointEntry, oldSafepoints p.locality.function = oldLocalityFunction }() p.phis = nil @@ -634,6 +650,9 @@ func (p *context) compileFuncDecl(pkg llssa.Package, f *ssa.Function) (llssa.Fun p.prepareExportedLocalContext(f) p.bvals = make(map[ssa.Value]llssa.Expr) p.methodNilDerefChecks = collectMethodNilDerefChecks(f) + p.prepareCooperativeSafepoints(f, isCgo) + p.prepareGCRoots(f, hasCtx) + p.initGCRoots(b, f) off := make([]int, len(f.Blocks)) if isCgo { p.cgoArgs = make([]llssa.Expr, len(f.Params)) @@ -686,12 +705,16 @@ func funcInfoDisplayName(pkgTypes *types.Package, goName string) string { } func hasNoInlineDirective(f *ssa.Function) bool { + return hasFuncDirective(f, "go:noinline") +} + +func hasFuncDirective(f *ssa.Function, name string) bool { decl, _ := f.Syntax().(*ast.FuncDecl) if decl == nil || decl.Doc == nil { return false } - for _, c := range decl.Doc.List { - if c.Text == "//go:noinline" { + for _, item := range directive.ParseGroup(decl.Doc) { + if item.Name == name { return true } } @@ -864,6 +887,9 @@ func (p *context) compileBlock(b llssa.Builder, block *ssa.BasicBlock, n int, do if enableDbgSyms && block.Parent().Origin() == nil && block.Index == 0 { p.debugParams(b, block.Parent()) } + if block.Index == 0 && p.safepointEntry { + p.emitCooperativeSafepoint(b) + } if doModInit { p.initializeLocalGuards(b) @@ -887,6 +913,9 @@ func (p *context) compileBlock(b llssa.Builder, block *ssa.BasicBlock, n int, do isCgoC2 := isCgoC2func(fnName) isCgoCmacro := isCgoCmacro(fnName) for i, instr := range instrs { + if p.isCooperativeSafepoint(instr) { + p.emitCooperativeSafepoint(b) + } if i == 1 && doModInit && p.state == pkgInPatch { // in patch package but no pkgFNoOldInit initFnNameOld := initFnNameOfHasPatch(p.fn.Name()) fnOld := pkg.NewFunc(initFnNameOld, llssa.NoArgsNoRet, llssa.InC) @@ -1185,6 +1214,7 @@ func (p *context) compilePhis(b llssa.Builder, block *ssa.BasicBlock) int { for i := 0; i < n; i++ { iv := block.Instrs[i].(*ssa.Phi) p.bvals[iv] = rets[i] + p.publishGCRoot(b, iv, rets[i]) } return n } @@ -1217,6 +1247,9 @@ func (p *context) compileInstrOrValue(b llssa.Builder, iv instrOrValue, asValue } log.Panicln("unreachable:", iv) } + defer func() { + p.publishGCRoot(b, iv, ret) + }() switch v := iv.(type) { case *ssa.Call: ret = p.call(b, llssa.Call, &v.Call) diff --git a/cl/gcroot.go b/cl/gcroot.go new file mode 100644 index 0000000000..eec2c51377 --- /dev/null +++ b/cl/gcroot.go @@ -0,0 +1,216 @@ +/* + * 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 cl + +import ( + "go/token" + "go/types" + + "github.com/goplus/llgo/internal/gcrootplan" + llssa "github.com/goplus/llgo/ssa" + "golang.org/x/tools/go/ssa" +) + +func (p *context) prepareGCRoots(fn *ssa.Function, hasClosureContext bool) { + p.gcRoots = nil + p.gcClosureRoot = llssa.Nil + if !p.prog.GCRootsEnabled() { + return + } + + planned := gcrootplan.Plan(fn, func(value ssa.Value) bool { + switch value.(type) { + case *ssa.FreeVar: + return false + } + typ := p.type_(value.Type(), llssa.InGo) + return p.prog.GCRootCount(typ) != 0 + }, p.isGCSafepoint) + if p.safepointEntry { + for _, param := range fn.Params { + typ := p.type_(param.Type(), llssa.InGo) + if p.prog.GCRootCount(typ) != 0 { + planned[param] = struct{}{} + } + } + } + counts := make(map[ssa.Value]int, len(planned)) + total := 0 + count := func(value ssa.Value) { + if _, ok := planned[value]; !ok { + return + } + typ := p.type_(value.Type(), llssa.InGo) + if n := p.prog.GCRootCount(typ); n != 0 { + counts[value] = n + total += n + } + } + for _, param := range fn.Params { + count(param) + } + for _, block := range fn.Blocks { + for _, instr := range block.Instrs { + if value, ok := instr.(ssa.Value); ok { + count(value) + } + } + } + hasClosureRoot := hasClosureContext && p.functionHasGCSafepoint(fn) + if hasClosureRoot { + total++ + } + allSlots := p.fn.NewGCRoots(total) + next := 0 + roots := make(map[ssa.Value][]llssa.Expr, len(counts)) + assign := func(value ssa.Value) { + if n := counts[value]; n != 0 { + roots[value] = allSlots[next : next+n] + next += n + } + } + for _, param := range fn.Params { + assign(param) + } + for _, block := range fn.Blocks { + for _, instr := range block.Instrs { + if value, ok := instr.(ssa.Value); ok { + assign(value) + } + } + } + p.gcRoots = roots + if hasClosureRoot { + p.gcClosureRoot = allSlots[next] + } +} + +func (p *context) initGCRoots(b llssa.Builder, fn *ssa.Function) { + if len(p.gcRoots) == 0 && p.gcClosureRoot.IsNil() { + return + } + b.SetBlockEx(p.fn.Block(0), llssa.AtEnd, true) + for i, param := range fn.Params { + if _, ok := p.gcRoots[param]; ok { + p.publishGCRoot(b, param, b.Param(i)) + } + } + if !p.gcClosureRoot.IsNil() { + b.SetGCRoot(p.gcClosureRoot, p.fn.ClosureContextParam()) + } +} + +func functionHasGCSafepoint(fn *ssa.Function) bool { + for _, block := range fn.Blocks { + for _, instr := range block.Instrs { + if gcSafepoint(instr) { + return true + } + } + } + return false +} + +func (p *context) functionHasGCSafepoint(fn *ssa.Function) bool { + if p.safepointEntry { + return true + } + for _, block := range fn.Blocks { + for _, instr := range block.Instrs { + if p.isGCSafepoint(instr) { + return true + } + } + } + return false +} + +func (p *context) isGCSafepoint(instr ssa.Instruction) bool { + return gcSafepoint(instr) || p.isCooperativeSafepoint(instr) +} + +// gcSafepoint mirrors the operations whose LLGo lowering can call the runtime. +// Unknown instructions stay conservative. +func gcSafepoint(instr ssa.Instruction) bool { + switch instr := instr.(type) { + case *ssa.Phi, *ssa.DebugRef, *ssa.Extract, *ssa.Field, *ssa.FieldAddr, + *ssa.Index, *ssa.IndexAddr, *ssa.If, *ssa.Jump, *ssa.Return, + *ssa.Slice, *ssa.SliceToArrayPointer, *ssa.Store, *ssa.ChangeType: + return false + case *ssa.BinOp: + return gcBinOpSafepoint(instr) + case *ssa.UnOp: + return instr.Op == token.ARROW + case *ssa.Convert: + return gcConversionSafepoint(instr.X.Type(), instr.Type()) + case *ssa.Call: + if builtin, ok := instr.Call.Value.(*ssa.Builtin); ok { + switch builtin.Name() { + case "cap", "complex", "imag", "len", "real": + return false + } + } + return true + default: + return true + } +} + +func gcBinOpSafepoint(instr *ssa.BinOp) bool { + switch basicKind(instr.X.Type()) { + case types.String, types.UntypedString: + return true + } + _, isInterface := types.Unalias(instr.X.Type()).Underlying().(*types.Interface) + return isInterface +} + +func gcConversionSafepoint(src, dst types.Type) bool { + return isStringOrSlice(src) || isStringOrSlice(dst) +} + +func isStringOrSlice(typ types.Type) bool { + switch typ := types.Unalias(typ).Underlying().(type) { + case *types.Slice: + return true + case *types.Basic: + return typ.Info()&types.IsString != 0 + default: + return false + } +} + +func basicKind(typ types.Type) types.BasicKind { + if basic, ok := types.Unalias(typ).Underlying().(*types.Basic); ok { + return basic.Kind() + } + return types.Invalid +} + +func (p *context) publishGCRoot(b llssa.Builder, value ssa.Value, expr llssa.Expr) { + slots, ok := p.gcRoots[value] + if !ok || expr.IsNil() { + return + } + roots := b.GCRootPointers(expr) + if len(roots) != len(slots) { + panic("cl: inconsistent GC root layout") + } + for i, root := range roots { + b.SetGCRoot(slots[i], root) + } +} diff --git a/cl/gcroot_internal_test.go b/cl/gcroot_internal_test.go new file mode 100644 index 0000000000..fda4d0dd83 --- /dev/null +++ b/cl/gcroot_internal_test.go @@ -0,0 +1,153 @@ +//go:build !llgo + +package cl + +import ( + "go/ast" + "go/importer" + "go/parser" + "go/token" + "go/types" + "testing" + + "golang.org/x/tools/go/ssa" + "golang.org/x/tools/go/ssa/ssautil" +) + +func TestGCSafepointClassification(t *testing.T) { + fn := buildGCRootSSAFunction(t, `package p +func helper() +func classify(p *int, text string, bytes []byte, ch chan int, m map[string]int, value any) { + _ = *p + _ = len(bytes) + _ = text + text + _ = string(bytes) + _ = value == value + helper() + _ = <-ch + m[text] = 1 +}`) + seen := make(map[string]bool) + for _, block := range fn.Blocks { + for _, instr := range block.Instrs { + switch instr := instr.(type) { + case *ssa.UnOp: + switch instr.Op { + case token.MUL: + seen["deref"] = true + if gcSafepoint(instr) { + t.Error("pointer dereference classified as a safepoint") + } + case token.ARROW: + seen["receive"] = true + if !gcSafepoint(instr) { + t.Error("channel receive not classified as a safepoint") + } + } + case *ssa.BinOp: + switch basicKind(instr.X.Type()) { + case types.String: + seen["string operation"] = true + if !gcSafepoint(instr) { + t.Error("string operation not classified as a safepoint") + } + default: + if _, ok := instr.X.Type().Underlying().(*types.Interface); ok { + seen["interface comparison"] = true + if !gcSafepoint(instr) { + t.Error("interface comparison not classified as a safepoint") + } + } + } + case *ssa.Convert: + seen["string conversion"] = true + if !gcSafepoint(instr) { + t.Error("string conversion not classified as a safepoint") + } + case *ssa.Call: + if builtin, ok := instr.Call.Value.(*ssa.Builtin); ok && builtin.Name() == "len" { + seen["pure builtin"] = true + if gcSafepoint(instr) { + t.Error("len classified as a safepoint") + } + } else { + seen["call"] = true + if !gcSafepoint(instr) { + t.Error("call not classified as a safepoint") + } + } + case *ssa.MapUpdate: + seen["map update"] = true + if !gcSafepoint(instr) { + t.Error("map update not classified as a safepoint") + } + } + } + } + for _, want := range []string{ + "deref", "receive", "string operation", "string conversion", + "interface comparison", "pure builtin", "call", "map update", + } { + if !seen[want] { + t.Errorf("%s instruction was not generated", want) + } + } + if !functionHasGCSafepoint(fn) { + t.Error("function with runtime operations has no GC safepoint") + } +} + +func TestGCSafepointPureInstructions(t *testing.T) { + for _, instr := range []ssa.Instruction{ + new(ssa.DebugRef), + new(ssa.Extract), + new(ssa.Field), + new(ssa.FieldAddr), + new(ssa.If), + new(ssa.Index), + new(ssa.IndexAddr), + new(ssa.Jump), + new(ssa.Phi), + new(ssa.Return), + new(ssa.Slice), + new(ssa.SliceToArrayPointer), + new(ssa.Store), + new(ssa.ChangeType), + } { + if gcSafepoint(instr) { + t.Errorf("%T classified as a safepoint", instr) + } + } + if !gcSafepoint(new(ssa.MakeSlice)) { + t.Error("unknown runtime-lowered instruction must stay conservative") + } + if gcConversionSafepoint(types.Typ[types.Int], types.Typ[types.Uint]) { + t.Error("numeric conversion classified as a safepoint") + } + pure := buildGCRootSSAFunction(t, `package p +func classify(p *int) *int { return p } +`) + if functionHasGCSafepoint(pure) { + t.Error("pure function has a GC safepoint") + } +} + +func buildGCRootSSAFunction(t *testing.T, src string) *ssa.Function { + t.Helper() + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, "gcroot.go", src, 0) + if err != nil { + t.Fatal(err) + } + pkg, _, err := ssautil.BuildPackage( + &types.Config{Importer: importer.Default()}, + fset, + types.NewPackage("gcroot", "p"), + []*ast.File{file}, + ssa.InstantiateGenerics, + ) + if err != nil { + t.Fatal(err) + } + return pkg.Func("classify") +} diff --git a/cl/gcroot_test.go b/cl/gcroot_test.go new file mode 100644 index 0000000000..9d222d862b --- /dev/null +++ b/cl/gcroot_test.go @@ -0,0 +1,124 @@ +//go:build !llgo + +package cl_test + +import ( + "strings" + "testing" + + "github.com/goplus/llgo/cl/cltest" + llssa "github.com/goplus/llgo/ssa" +) + +func TestCompileDirectGCRoots(t *testing.T) { + const src = `package main + +func use(*int) + +func keep(p *int) *int { + use(p) + return p +} + +func choose(cond bool, a, b *int) *int { + var p *int + if cond { + p = a + } else { + p = b + } + use(p) + return p +} +` + ir := cltest.CompileIREx(t, src, "gcroot.go", false, func(prog llssa.Program) { + prog.EnableGCRoots(true) + }) + if !strings.Contains(ir, `@llvm_gc_root_chain`) { + t.Fatalf("compiler-maintained root is missing:\n%s", ir) + } + if strings.Contains(ir, `llvm.gcroot`) || strings.Contains(ir, `gc "shadow-stack"`) { + t.Fatalf("compiler emitted roots that still require backend lowering:\n%s", ir) + } + if !strings.Contains(ir, `store ptr %0`) { + t.Fatalf("pointer parameter was not published:\n%s", ir) + } +} + +func TestCompileAggregateGCRoots(t *testing.T) { + const src = `package main + +type holder struct { + p *int + s []byte + text string + any any + fn func() + array [2]*int +} + +func useHolder(holder) + +func keep(h holder) holder { + useHolder(h) + return h +} +` + ir := cltest.CompileIREx(t, src, "gcroot_aggregate.go", false, func(prog llssa.Program) { + prog.EnableGCRoots(true) + }) + if !strings.Contains(ir, `[7 x ptr]`) { + t.Fatalf("aggregate did not emit one seven-root frame:\n%s", ir) + } +} + +func TestCompileGCRootsDisabled(t *testing.T) { + const src = `package main + +func keep(p *int) *int { return p } +` + ir := cltest.CompileIREx(t, src, "gcroot_disabled.go", false, nil) + if strings.Contains(ir, `llvm_gc_root_chain`) || strings.Contains(ir, `llvm.gcroot`) || + strings.Contains(ir, `gc "shadow-stack"`) { + t.Fatalf("disabled GC roots changed ordinary code:\n%s", ir) + } +} + +func TestCompileGCRootPlanning(t *testing.T) { + const pure = `package main +func keep(p *int) *int { return p } +` + ir := cltest.CompileIREx(t, pure, "gcroot_pure.go", false, func(prog llssa.Program) { + prog.EnableGCRoots(true) + }) + if strings.Contains(ir, `llvm_gc_root_chain`) || strings.Contains(ir, `llvm.gcroot`) || + strings.Contains(ir, `gc "shadow-stack"`) { + t.Fatalf("function without a safepoint emitted roots:\n%s", ir) + } + + const allocating = `package main +func keep(p *int, n int) *int { + _ = make([]byte, n) + return p +} +` + ir = cltest.CompileIREx(t, allocating, "gcroot_allocating.go", false, func(prog llssa.Program) { + prog.EnableGCRoots(true) + }) + if !strings.Contains(ir, `[1 x ptr]`) { + t.Fatalf("pointer live across allocation did not emit one root:\n%s", ir) + } + + const closure = `package main +func use(*int) +func keep(p *int) func() { + return func() { use(p) } +} +` + ir = cltest.CompileIREx(t, closure, "gcroot_closure.go", false, func(prog llssa.Program) { + prog.EnableGCRoots(true) + }) + if !strings.Contains(ir, `@llvm_gc_root_chain`) { + t.Fatalf("closure context live across a call was not rooted:\n%s", ir) + } +} diff --git a/cl/import.go b/cl/import.go index 5b18496eac..6d3b0fbcf1 100644 --- a/cl/import.go +++ b/cl/import.go @@ -322,6 +322,27 @@ func (p *context) processNoInterfaceByDoc(doc *ast.CommentGroup, fullName string } } +func wasmImportByDoc(doc *ast.CommentGroup) (module, name string, ok bool) { + if doc == nil { + return + } + const prefix = "//go:wasmimport " + for n := len(doc.List) - 1; n >= 0; n-- { + line := doc.List[n].Text + if strings.HasPrefix(line, prefix) { + fields := strings.Fields(line[len(prefix):]) + if len(fields) == 2 { + return fields[0], fields[1], true + } + return + } + if !strings.HasPrefix(line, "//go:") { + return + } + } + return +} + const ( noDirective = iota hasLinkname diff --git a/cl/import_coverage_test.go b/cl/import_coverage_test.go index 4afa5b0110..487bfab6e4 100644 --- a/cl/import_coverage_test.go +++ b/cl/import_coverage_test.go @@ -127,6 +127,27 @@ func TestParsePkgSyntaxReportsLocalityErrors(t *testing.T) { } } +func TestWasmImportByDoc(t *testing.T) { + module, name, ok := wasmImportByDoc(&ast.CommentGroup{List: []*ast.Comment{ + {Text: "//go:noescape"}, + {Text: "//go:wasmimport wasi_snapshot_preview1 fd_read"}, + }}) + if !ok || module != "wasi_snapshot_preview1" || name != "fd_read" { + t.Fatalf("wasm import = (%q, %q, %v)", module, name, ok) + } + + for _, doc := range []*ast.CommentGroup{ + nil, + {List: []*ast.Comment{{Text: "// ordinary comment"}}}, + {List: []*ast.Comment{{Text: "//go:noescape"}}}, + {List: []*ast.Comment{{Text: "//go:wasmimport missing-name"}}}, + } { + if _, _, ok := wasmImportByDoc(doc); ok { + t.Fatalf("unexpected wasm import from %#v", doc) + } + } +} + func TestPkgSymInfoAddSymAndInitLinknamesCoverage(t *testing.T) { dir := t.TempDir() srcPath := filepath.Join(dir, "p.go") diff --git a/cl/safepoint.go b/cl/safepoint.go new file mode 100644 index 0000000000..baac7ab483 --- /dev/null +++ b/cl/safepoint.go @@ -0,0 +1,72 @@ +/* + * 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 cl + +import ( + "strings" + + "github.com/goplus/llgo/internal/safepointplan" + llssa "github.com/goplus/llgo/ssa" + "golang.org/x/tools/go/ssa" +) + +func (p *context) prepareCooperativeSafepoints(fn *ssa.Function, isCgo bool) { + p.safepointEntry = false + p.safepoints = nil + if !p.prog.CooperativeSafepointsEnabled() || fn == nil || len(fn.Blocks) == 0 || + isCgo || hasFuncDirective(fn, "go:nosplit") { + return + } + // Package-less SSA wrappers only forward into a declared function and may + // represent runtime helpers, so the declared function owns the poll. + if path := safepointPackagePath(fn); path == "" || excludeSafepointPackage(path) { + return + } + p.safepointEntry = true + p.safepoints = safepointplan.Backedges(fn) +} + +func safepointPackagePath(fn *ssa.Function) string { + for current := fn; current != nil; current = current.Parent() { + if pkg := current.Package(); pkg != nil { + return pkg.Pkg.Path() + } + if origin := current.Origin(); origin != nil { + if pkg := origin.Package(); pkg != nil { + return pkg.Pkg.Path() + } + } + } + return "" +} + +func excludeSafepointPackage(path string) bool { + if path == "runtime" || strings.HasPrefix(path, "internal/runtime/") { + return true + } + runtimeModule := strings.TrimSuffix(llssa.PkgRuntime, "/internal/runtime") + return path == runtimeModule || strings.HasPrefix(path, runtimeModule+"/") +} + +func (p *context) isCooperativeSafepoint(instr ssa.Instruction) bool { + _, ok := p.safepoints[instr] + return ok +} + +func (p *context) emitCooperativeSafepoint(b llssa.Builder) { + b.Call(p.pkg.RuntimeFunc("CooperativeSafepoint")) +} diff --git a/cl/safepoint_internal_test.go b/cl/safepoint_internal_test.go new file mode 100644 index 0000000000..7bb45e2e53 --- /dev/null +++ b/cl/safepoint_internal_test.go @@ -0,0 +1,78 @@ +//go:build !llgo + +/* + * 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 cl + +import ( + "go/types" + "testing" +) + +func TestExcludeSafepointPackage(t *testing.T) { + tests := []struct { + path string + want bool + }{ + {path: "runtime", want: true}, + {path: "internal/runtime/atomic", want: true}, + {path: "github.com/goplus/llgo/runtime", want: true}, + {path: "github.com/goplus/llgo/runtime/internal/wasmevent", want: true}, + {path: "github.com/goplus/llgo/runtimeextra"}, + {path: "example.com/app/runtime"}, + } + for _, test := range tests { + if got := excludeSafepointPackage(test.path); got != test.want { + t.Errorf("excludeSafepointPackage(%q) = %v, want %v", test.path, got, test.want) + } + } +} + +func TestSafepointPackagePathUsesGenericOrigin(t *testing.T) { + pkg := buildLinkOnceSSAPackage(t, `package p +type Box[T any] struct{} +func (Box[T]) M() {} +`) + box := pkg.Pkg.Scope().Lookup("Box").(*types.TypeName).Type().(*types.Named) + boxInt, err := types.Instantiate(nil, box, []types.Type{types.Typ[types.Int]}, true) + if err != nil { + t.Fatal(err) + } + _, fn := linkOnceTestMethodValue(t, pkg, boxInt, "M") + if fn.Package() != nil || fn.Origin() == nil { + t.Fatalf("expected a package-less generic instance, got package=%v origin=%v", fn.Package(), fn.Origin()) + } + if got := safepointPackagePath(fn); got != "p" { + t.Fatalf("safepointPackagePath(%s) = %q, want p", fn, got) + } +} + +func TestSafepointPackagePathLeavesSyntheticWrapperUnowned(t *testing.T) { + pkg := buildLinkOnceSSAPackage(t, `package p +type Inner struct{} +func (Inner) M() {} +type Outer struct{ Inner } +`) + outer := pkg.Pkg.Scope().Lookup("Outer").(*types.TypeName).Type() + _, fn := linkOnceTestMethodValue(t, pkg, types.NewPointer(outer), "M") + if fn.Package() != nil || fn.Origin() != nil { + t.Fatalf("expected an unowned synthetic wrapper, got package=%v origin=%v", fn.Package(), fn.Origin()) + } + if got := safepointPackagePath(fn); got != "" { + t.Fatalf("safepointPackagePath(%s) = %q, want empty", fn, got) + } +} diff --git a/cl/safepoint_test.go b/cl/safepoint_test.go new file mode 100644 index 0000000000..012f8e57e4 --- /dev/null +++ b/cl/safepoint_test.go @@ -0,0 +1,83 @@ +//go:build !llgo + +package cl_test + +import ( + "regexp" + "strings" + "testing" + + "github.com/goplus/llgo/cl/cltest" + llssa "github.com/goplus/llgo/ssa" +) + +func TestCompileCooperativeSafepoints(t *testing.T) { + const src = `package main + +func leaf(p *int) *int { + return p +} + +func loop(p *int, n int) *int { + for n > 0 { + n-- + } + return p +} + +//go:nosplit +func noPoll(p *int) *int { + return p +} +` + ir := cltest.CompileIREx(t, src, "safepoint.go", false, func(prog llssa.Program) { + prog.EnableGCRoots(true) + prog.EnableCooperativeSafepoints(true) + }) + + leaf := findLLVMFunction(t, ir, "leaf") + if got := strings.Count(leaf, "CooperativeSafepoint"); got != 1 { + t.Fatalf("leaf has %d safepoints, want 1:\n%s", got, leaf) + } + if !strings.Contains(leaf, `[1 x ptr]`) { + t.Fatalf("leaf parameter is not rooted across the entry safepoint:\n%s", leaf) + } + + loop := findLLVMFunction(t, ir, "loop") + if got := strings.Count(loop, "CooperativeSafepoint"); got != 2 { + t.Fatalf("loop has %d safepoints, want entry plus backedge:\n%s", got, loop) + } + if !strings.Contains(loop, `[1 x ptr]`) { + t.Fatalf("loop parameter is not rooted across safepoints:\n%s", loop) + } + + noPoll := findLLVMFunction(t, ir, "noPoll") + if strings.Contains(noPoll, "CooperativeSafepoint") || + strings.Contains(noPoll, "llvm_gc_root_chain") { + t.Fatalf("//go:nosplit function contains a safepoint or root frame:\n%s", noPoll) + } +} + +func TestCompileCooperativeSafepointsDisabled(t *testing.T) { + const src = `package main +func loop(n int) { + for n > 0 { + n-- + } +} +` + ir := cltest.CompileIREx(t, src, "safepoint_disabled.go", false, nil) + if strings.Contains(ir, "CooperativeSafepoint") { + t.Fatalf("disabled cooperative safepoints changed ordinary code:\n%s", ir) + } +} + +func findLLVMFunction(t *testing.T, ir, name string) string { + t.Helper() + pattern := regexp.MustCompile(`(?ms)^define [^{]*\.` + regexp.QuoteMeta(name) + `"?\([^)]*\).*?^\}`) + body := pattern.FindString(ir) + if body == "" { + t.Fatalf("LLVM function %s not found:\n%s", name, ir) + } + return body +} diff --git a/cl/wasm_import_test.go b/cl/wasm_import_test.go new file mode 100644 index 0000000000..f4765bd39c --- /dev/null +++ b/cl/wasm_import_test.go @@ -0,0 +1,35 @@ +//go:build !llgo + +package cl_test + +import ( + "strings" + "testing" + + "github.com/goplus/llgo/cl/cltest" + llssa "github.com/goplus/llgo/ssa" +) + +func TestWasmImportDirective(t *testing.T) { + const src = `package foo + +//go:wasmimport wasi_snapshot_preview1 fd_read +func fdRead(fd int32, buf *byte, size uint32) uint32 + +func read(buf *byte) uint32 { + return fdRead(0, buf, 1) +} +` + ir := cltest.CompileIREx(t, src, "foo.go", false, func(prog llssa.Program) { + prog.Target().GOOS = "wasip1" + prog.Target().GOARCH = "wasm" + }) + for _, want := range []string{ + `"wasm-import-module"="wasi_snapshot_preview1"`, + `"wasm-import-name"="fd_read"`, + } { + if !strings.Contains(ir, want) { + t.Fatalf("missing %s in wasm import IR:\n%s", want, ir) + } + } +} diff --git a/internal/build/build.go b/internal/build/build.go index c2f80c4136..fa209054f9 100644 --- a/internal/build/build.go +++ b/internal/build/build.go @@ -36,6 +36,7 @@ import ( "strings" "sync" "sync/atomic" + "unicode" "golang.org/x/tools/go/ssa" @@ -59,6 +60,7 @@ import ( "github.com/goplus/llgo/internal/pclnmap" "github.com/goplus/llgo/internal/pclnpost" "github.com/goplus/llgo/internal/typepatch" + "github.com/goplus/llgo/internal/wasmworkers" "github.com/goplus/llgo/ssa/abi" xenv "github.com/goplus/llgo/xtool/env" "github.com/goplus/llgo/xtool/env/llvm" @@ -298,9 +300,6 @@ func Do(args []string, conf *Config) ([]Package, error) { if conf.Goarch == "" { conf.Goarch = runtime.GOARCH } - if conf.AppExt == "" { - conf.AppExt = defaultAppExt(conf) - } if conf.BuildMode == "" { conf.BuildMode = BuildModeExe } @@ -339,6 +338,17 @@ func Do(args []string, conf *Config) ([]Package, error) { if conf.Target != "" && export.GOARCH != "" { conf.Goarch = export.GOARCH } + wasmWorkers, err := configureWasmWorkers(conf, &export) + if err != nil { + return nil, err + } + wasmGC, err := configureWasmGC(conf, &export, wasmWorkers.Enabled()) + if err != nil { + return nil, err + } + if conf.AppExt == "" { + conf.AppExt = defaultAppExt(conf) + } if err := validateLinkOptions(conf, &export); err != nil { return nil, err } @@ -405,6 +415,8 @@ func Do(args []string, conf *Config) ([]Package, error) { } prog.EnableGoGlobalDCE(conf.goGlobalDCEEnabled()) prog.EnableDeadcodeDrop(conf.deadcodeDropEnabled()) + prog.EnableGCRoots(wasmGC) + prog.EnableCooperativeSafepoints(wasmGC || wasmWorkers.Enabled()) if conf.PthreadStackSize > 0 { prog.SetPthreadStackSize(uint64(conf.PthreadStackSize)) } @@ -418,10 +430,7 @@ func Do(args []string, conf *Config) ([]Package, error) { // final-PC sites for sidecar construction. prog.EnableFuncInfoSites(shouldEnablePCLNSites(conf, funcInfo, emitDebugInfo)) sizes := func(sizes types.Sizes, compiler, arch string) types.Sizes { - if arch == "wasm" { - sizes = &types.StdSizes{WordSize: 4, MaxAlign: 4} - } - return prog.TypeSizes(sizes) + return prog.TypeSizes(effectiveTypeSizes(sizes, conf.Goos, arch, conf.Target)) } dedup := packages.NewDeduper() var syntaxErr error @@ -727,16 +736,104 @@ func DefaultBuildTags(goarch, target string) string { func defaultBuildTags(goarch, target string) string { tags := "llgo,math_big_pure_go,purego" - // Raw GOOS/GOARCH wasm builds do not have a target configuration that - // selects a collector. BDWGC is not available in either wasm host, so use - // the supported collector-free runtime unless a named target supplies its - // own runtime configuration. - if goarch == "wasm" && target == "" { + // BDWGC is unavailable in both wasm hosts. + if goarch == "wasm" { tags += ",nogc" } return tags } +func configureWasmWorkers(conf *Config, export *crosscompile.Export) (wasmworkers.Config, error) { + config, err := wasmworkers.Parse(os.Getenv(llgoWasmWorkers)) + if err != nil { + return config, err + } + if err := config.ValidateTarget(conf.Goos, conf.Goarch); err != nil { + return config, err + } + if !config.Enabled() { + return config, nil + } + workers := strconv.Itoa(config.Count) + poolSize := strconv.Itoa(config.Count) + preJS := wasmworkers.PreJSPath(env.LLGoROOT()) + if _, err := os.Stat(preJS); err != nil { + return config, fmt.Errorf("locate WebAssembly worker host shim: %w", err) + } + export.BuildTags = append(export.BuildTags, "llgo.wasm_workers") + export.CCFLAGS = append(export.CCFLAGS, "-pthread", "-DLLGO_WASM_WORKERS="+workers) + export.LDFLAGS = append(export.LDFLAGS, + "--pre-js", preJS, + "-pthread", + "-sPTHREAD_POOL_SIZE="+poolSize, + "-sPROXY_TO_PTHREAD=1", + "-sEXIT_RUNTIME=1", + ) + export.WasmRuntime.RunMainTask = true + return config, nil +} + +func configureWasmGC(conf *Config, export *crosscompile.Export, wasmWorkers bool) (bool, error) { + explicit := hasBuildTag(conf.Tags, "llgo_wasm_gc") + if conf.Goarch != "wasm" { + if explicit { + return false, fmt.Errorf("llgo_wasm_gc does not support GOARCH=%s", conf.Goarch) + } + return false, nil + } + switch conf.Goos { + case "js": + if wasmWorkers { + if explicit { + return false, errors.New("llgo_wasm_gc does not yet support multiple WebAssembly workers") + } + return false, nil + } + if !slices.Contains(export.LDFLAGS, "-sMALLOC=none") { + export.LDFLAGS = append(export.LDFLAGS, "-sMALLOC=none") + } + case "wasip1": + if IsWasiThreadsEnabled() { + if explicit { + return false, errors.New("llgo_wasm_gc requires single-worker WASI (set LLGO_WASI_THREADS=0)") + } + return false, nil + } + default: + if explicit { + return false, fmt.Errorf("llgo_wasm_gc does not support GOOS=%s", conf.Goos) + } + return false, nil + } + if !explicit { + if conf.Tags != "" { + conf.Tags += "," + } + conf.Tags += "llgo_wasm_gc" + } + return true, nil +} + +func hasBuildTag(tags, want string) bool { + for _, tag := range strings.FieldsFunc(tags, func(r rune) bool { + return r == ',' || unicode.IsSpace(r) + }) { + if tag == want { + return true + } + } + return false +} + +func effectiveTypeSizes(sizes types.Sizes, goos, goarch, target string) types.Sizes { + // Named wasm targets use the native wasm32 data model. The raw js/wasm + // entry point keeps Go's 64-bit word model and is emitted as Memory64. + if goarch == "wasm" && (target != "" || goos != "js") { + return &types.StdSizes{WordSize: 4, MaxAlign: 4} + } + return sizes +} + func allowMissingFunctionBodies(initial []*packages.Package) { for _, pkg := range initial { hasMissingBody := false @@ -1359,12 +1456,15 @@ func linkMainPkg(ctx *context, pkg *packages.Package, pkgs []*aPackage, outputPa } linkArgs = append(linkArgs, cSharedExportArgs(ctx, linkedOrder)...) - err = linkObjFiles(ctx, outputPath, linkInputs, linkArgs, verbose) + linkOutput, err := prepareWasmLinkOutput(ctx.buildConf, &ctx.crossCompile, outputPath) if err != nil { return err } - - return nil + defer cleanupWasmLinkOutput(linkOutput, outputPath) + if err := linkObjFiles(ctx, linkOutput, linkInputs, linkArgs, verbose); err != nil { + return err + } + return publishWasmLinkOutput(ctx, linkOutput, outputPath, verbose) } func linkedModuleGlobals(pkgs []Package) map[string]none { @@ -2283,6 +2383,7 @@ const llgoTrace = "LLGO_TRACE" const llgoOptimize = "LLGO_OPTIMIZE" const llgoWasmRuntime = "LLGO_WASM_RUNTIME" const llgoWasiThreads = "LLGO_WASI_THREADS" +const llgoWasmWorkers = "LLGO_WASM_WORKERS" const llgoStdioNobuf = "LLGO_STDIO_NOBUF" const llgoFullRpath = "LLGO_FULL_RPATH" const llgoBuildCache = "LLGO_BUILD_CACHE" @@ -2360,7 +2461,7 @@ func llvmPassPipeline(level optlevel.Level, ltoMode lto.Mode) string { } func IsWasiThreadsEnabled() bool { - return isEnvOn(llgoWasiThreads, true) + return isEnvOn(llgoWasiThreads, false) } func IsFullRpathEnabled() bool { diff --git a/internal/build/build_test.go b/internal/build/build_test.go index cf3054c1e9..5a8c8404fc 100644 --- a/internal/build/build_test.go +++ b/internal/build/build_test.go @@ -30,6 +30,7 @@ import ( "github.com/goplus/llgo/internal/meta" "github.com/goplus/llgo/internal/mockable" "github.com/goplus/llgo/internal/packages" + "github.com/goplus/llgo/internal/wasmworkers" llssa "github.com/goplus/llgo/ssa" "github.com/xgo-dev/llvm" ) @@ -109,7 +110,7 @@ func TestDefaultBuildTags(t *testing.T) { }{ {name: "native", goarch: "arm64", want: base}, {name: "raw wasm", goarch: "wasm", want: base + ",nogc"}, - {name: "configured wasm target", goarch: "wasm", target: "wasip1", want: base}, + {name: "configured wasm target", goarch: "wasm", target: "wasip1", want: base + ",nogc"}, } { t.Run(test.name, func(t *testing.T) { if got := defaultBuildTags(test.goarch, test.target); got != test.want { @@ -119,6 +120,149 @@ func TestDefaultBuildTags(t *testing.T) { } } +func TestEffectiveWasmTypeSizes(t *testing.T) { + goSizes := types.SizesFor("gc", "wasm") + for _, test := range []struct { + name string + goos string + target string + want int64 + }{ + {name: "Go js wasm", goos: "js", want: 8}, + {name: "configured wasm", goos: "js", target: "wasm", want: 4}, + {name: "WASI compatibility", goos: "wasip1", want: 4}, + } { + t.Run(test.name, func(t *testing.T) { + got := effectiveTypeSizes(goSizes, test.goos, "wasm", test.target) + if size := got.Sizeof(types.Typ[types.Uintptr]); size != test.want { + t.Fatalf("uintptr size = %d, want %d", size, test.want) + } + }) + } + if got := effectiveTypeSizes(goSizes, "linux", "amd64", ""); got != goSizes { + t.Fatal("native type sizes changed") + } +} + +func TestConfigureWasmGC(t *testing.T) { + t.Setenv("LLGO_WASI_THREADS", "0") + tests := []struct { + name string + conf Config + wantGC bool + err bool + }{ + {name: "wasm32", conf: Config{Goos: "js", Goarch: "wasm", Tags: "llgo_wasm_gc"}, wantGC: true}, + {name: "comma separated tags", conf: Config{Goos: "js", Goarch: "wasm", Tags: "other,llgo_wasm_gc"}, wantGC: true}, + {name: "default wasm", conf: Config{Goos: "js", Goarch: "wasm"}, wantGC: true}, + {name: "WASI", conf: Config{Goos: "wasip1", Goarch: "wasm", Tags: "llgo_wasm_gc"}, wantGC: true}, + {name: "default WASI", conf: Config{Goos: "wasip1", Goarch: "wasm"}, wantGC: true}, + {name: "default with custom tag", conf: Config{Goos: "js", Goarch: "wasm", Tags: "custom"}, wantGC: true}, + {name: "native", conf: Config{Goos: "linux", Goarch: "amd64"}}, + {name: "native explicit", conf: Config{Goos: "linux", Goarch: "amd64", Tags: "llgo_wasm_gc"}, err: true}, + {name: "unsupported host default", conf: Config{Goos: "linux", Goarch: "wasm"}}, + {name: "unsupported host", conf: Config{Goos: "linux", Goarch: "wasm", Tags: "llgo_wasm_gc"}, err: true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + export := crosscompile.Export{} + enabled, err := configureWasmGC(&test.conf, &export, false) + if (err != nil) != test.err { + t.Fatalf("configureWasmGC error = %v, want error %v", err, test.err) + } + if enabled != test.wantGC { + t.Fatalf("configureWasmGC enabled = %v, want %v", enabled, test.wantGC) + } + if got := slices.Contains(export.LDFLAGS, "-sMALLOC=none"); got != (test.wantGC && test.conf.Goos == "js") { + t.Fatalf("MALLOC=none present = %v", got) + } + if test.wantGC && !hasBuildTag(test.conf.Tags, "llgo_wasm_gc") { + t.Fatalf("internal GC tag missing from %q", test.conf.Tags) + } + }) + } +} + +func TestConfigureWasmGCRejectsWASIThreads(t *testing.T) { + t.Setenv("LLGO_WASI_THREADS", "1") + conf := Config{Goos: "wasip1", Goarch: "wasm", Tags: "llgo_wasm_gc"} + if _, err := configureWasmGC(&conf, &crosscompile.Export{}, false); err == nil { + t.Fatal("expected llgo_wasm_gc with WASI threads to fail") + } +} + +func TestConfigureWasmGCLeavesWASIThreadsDisabled(t *testing.T) { + t.Setenv("LLGO_WASI_THREADS", "1") + conf := Config{Goos: "wasip1", Goarch: "wasm"} + enabled, err := configureWasmGC(&conf, &crosscompile.Export{}, false) + if err != nil { + t.Fatal(err) + } + if enabled || hasBuildTag(conf.Tags, "llgo_wasm_gc") { + t.Fatalf("threaded WASI selected wasm GC: enabled=%v tags=%q", enabled, conf.Tags) + } +} + +func TestConfigureWasmWorkers(t *testing.T) { + t.Setenv(llgoWasmWorkers, "4") + conf := Config{Goos: "js", Goarch: "wasm"} + export := crosscompile.Export{} + config, err := configureWasmWorkers(&conf, &export) + if err != nil { + t.Fatal(err) + } + if config.Count != 4 || !config.Enabled() { + t.Fatalf("worker config = %+v, want four enabled workers", config) + } + for _, flag := range []string{"-pthread", "-DLLGO_WASM_WORKERS=4"} { + if !slices.Contains(export.CCFLAGS, flag) { + t.Fatalf("CCFLAGS do not contain %q: %v", flag, export.CCFLAGS) + } + } + for _, flag := range []string{"-pthread", "-sPTHREAD_POOL_SIZE=4", "-sPROXY_TO_PTHREAD=1", "-sEXIT_RUNTIME=1"} { + if !slices.Contains(export.LDFLAGS, flag) { + t.Fatalf("LDFLAGS do not contain %q: %v", flag, export.LDFLAGS) + } + } + preJS := wasmworkers.PreJSPath(env.LLGoROOT()) + if i := slices.Index(export.LDFLAGS, "--pre-js"); i < 0 || i+1 == len(export.LDFLAGS) || export.LDFLAGS[i+1] != preJS { + t.Fatalf("LDFLAGS do not select worker host shim %q: %v", preJS, export.LDFLAGS) + } + if !slices.Contains(export.BuildTags, "llgo.wasm_workers") { + t.Fatalf("BuildTags do not select the worker runtime: %v", export.BuildTags) + } + if !export.WasmRuntime.RunMainTask { + t.Fatal("worker runtime does not run main as a schedulable task") + } +} + +func TestConfigureWasmWorkersDefaultIsInert(t *testing.T) { + conf := Config{Goos: "linux", Goarch: "amd64"} + export := crosscompile.Export{} + config, err := configureWasmWorkers(&conf, &export) + if err != nil { + t.Fatal(err) + } + if config.Enabled() || len(export.CCFLAGS) != 0 || len(export.LDFLAGS) != 0 || len(export.BuildTags) != 0 { + t.Fatalf("default worker config changed native build: config=%+v export=%+v", config, export) + } +} + +func TestConfigureWasmWorkersRejectsUnsupportedTarget(t *testing.T) { + t.Setenv(llgoWasmWorkers, "2") + conf := Config{Goos: "wasip1", Goarch: "wasm"} + if _, err := configureWasmWorkers(&conf, &crosscompile.Export{}); err == nil { + t.Fatal("WASI worker configuration succeeded") + } +} + +func TestConfigureWasmWorkersRejectsCurrentGC(t *testing.T) { + conf := Config{Goos: "js", Goarch: "wasm", Tags: "llgo_wasm_gc"} + if _, err := configureWasmGC(&conf, &crosscompile.Export{}, true); err == nil { + t.Fatal("multi-worker wasm GC configuration succeeded before M2") + } +} + func TestWasmRuntimeAvoidsNativeHostDependencies(t *testing.T) { runtimeDir := filepath.Join(env.LLGoRuntimeDir(), "internal", "lib", "runtime") for _, goos := range []string{"js", "wasip1"} { @@ -126,7 +270,7 @@ func TestWasmRuntimeAvoidsNativeHostDependencies(t *testing.T) { ctx := gobuild.Default ctx.GOOS = goos ctx.GOARCH = "wasm" - ctx.BuildTags = []string{"llgo", "nogc"} + ctx.BuildTags = []string{"llgo", "nogc", "llgo_wasm_gc"} pkg, err := ctx.ImportDir(runtimeDir, 0) if err != nil { t.Fatal(err) @@ -153,8 +297,9 @@ func TestWasmRuntimeAvoidsNativeHostDependencies(t *testing.T) { } for _, name := range []string{ - "mfinal_nogc.go", + "mfinal_wasm.go", "runtime_baremetal.go", + "runtime_gc_nonmoving.go", "signal_baremetal_llgo.go", "time_wasm_llgo.go", "unwind_wasm_llgo.go", @@ -836,6 +981,17 @@ func TestApplyBuildModeCompileFlags(t *testing.T) { applyBuildModeCompileFlags(BuildModeCShared, nil) } +func TestWASIThreadsAreOptIn(t *testing.T) { + t.Setenv(llgoWasiThreads, "") + if IsWasiThreadsEnabled() { + t.Fatal("WASI threads are enabled by default") + } + t.Setenv(llgoWasiThreads, "1") + if !IsWasiThreadsEnabled() { + t.Fatal("WASI threads opt-in was ignored") + } +} + func TestCHeaderPackagesExcludesStandardRuntime(t *testing.T) { prog := llssa.NewProgram(nil) defer prog.Dispose() diff --git a/internal/build/collect.go b/internal/build/collect.go index 11735574da..a165c4d0ea 100644 --- a/internal/build/collect.go +++ b/internal/build/collect.go @@ -86,6 +86,7 @@ func (c *context) collectEnvInputs(m *manifestBuilder) { llgoOptimize, llgoWasmRuntime, llgoWasiThreads, + llgoWasmWorkers, llgoStdioNobuf, llgoFullRpath, } diff --git a/internal/build/main_module.go b/internal/build/main_module.go index cb0c2a418b..60faa9d463 100644 --- a/internal/build/main_module.go +++ b/internal/build/main_module.go @@ -88,7 +88,7 @@ func genMainModule(ctx *context, rtPkgPath string, pkg *packages.Package, cfg *g } var rtInit llssa.Function - if cfg.rtInit { + if cfg.rtInit || ctx.crossCompile.WasmPostLink.Asyncify { rtInit = declareNoArgFunc(mainPkg, rtPkgPath+".init") } @@ -127,17 +127,23 @@ func genMainModule(ctx *context, rtPkgPath string, pkg *packages.Package, cfg *g return mainAPkg } + var wasmRunMain llssa.Function + if ctx.crossCompile.WasmPostLink.Asyncify || ctx.crossCompile.WasmRuntime.RunMainTask { + defineWasmMainTask(mainPkg, mainInit, mainMain) + wasmRunMain = declareNoArgFunc(mainPkg, rtPkgPath+".RunWasmMain") + } entryFn := defineEntryFunction(ctx, mainPkg, argcVar, argvVar, argvValueType, entryFunctions{ runtimeStub: runtimeStub, mainInit: mainInit, mainMain: mainMain, + wasmRunMain: wasmRunMain, pyInit: pyInit, pyFinalize: pyFinalize, rtInit: rtInit, abiInit: abiInit, }) - if needStart(ctx) { + if needStart(ctx) && !ctx.crossCompile.WasmRuntime.RunMainTask { defineStart(mainPkg, entryFn, argvValueType) } @@ -225,6 +231,7 @@ type entryFunctions struct { runtimeStub llssa.Function mainInit llssa.Function mainMain llssa.Function + wasmRunMain llssa.Function pyInit llssa.Function pyFinalize llssa.Function rtInit llssa.Function @@ -241,7 +248,8 @@ type entryFunctions struct { func defineEntryFunction(ctx *context, pkg llssa.Package, argcVar, argvVar llssa.Global, argvType llssa.Type, fns entryFunctions) llssa.Function { prog := pkg.Prog entryName := "main" - if !needStart(ctx) && isWasmTarget(ctx.buildConf.Goos) { + if isWasmTarget(ctx.buildConf.Goos) && + (!needStart(ctx) || ctx.crossCompile.WasmRuntime.RunMainTask) { entryName = "__main_argc_argv" } sig := newEntrySignature(argvType.RawType()) @@ -272,8 +280,12 @@ func defineEntryFunction(ctx *context, pkg llssa.Package, argcVar, argvVar llssa b.Call(fns.abiInit.Expr) } b.Call(fns.runtimeStub.Expr) - b.Call(fns.mainInit.Expr) - b.Call(fns.mainMain.Expr) + if fns.wasmRunMain != nil { + b.Call(fns.wasmRunMain.Expr) + } else { + b.Call(fns.mainInit.Expr) + b.Call(fns.mainMain.Expr) + } if fns.pyFinalize != nil { b.Call(fns.pyFinalize.Expr) } @@ -284,6 +296,21 @@ func defineEntryFunction(ctx *context, pkg llssa.Package, argcVar, argvVar llssa return fn } +func defineWasmMainTask(pkg llssa.Package, mainInit, mainMain llssa.Function) { + prog := pkg.Prog + sig := newSignature( + []types.Type{types.Typ[types.UnsafePointer]}, + []types.Type{types.Typ[types.UnsafePointer]}, + ) + fn := pkg.NewFunc("__llgo_wasm_main", sig, llssa.InC) + fnVal := pkg.Module().NamedFunction("__llgo_wasm_main") + fnVal.SetVisibility(llvm.HiddenVisibility) + b := fn.MakeBody(1) + b.Call(mainInit.Expr) + b.Call(mainMain.Expr) + b.Return(prog.Nil(prog.VoidPtr())) +} + func defineStart(pkg llssa.Package, entry llssa.Function, argvType llssa.Type) { fn := pkg.NewFunc("_start", llssa.NoArgsNoRet, llssa.InC) pkg.Module().NamedFunction("_start").SetLinkage(llvm.WeakAnyLinkage) diff --git a/internal/build/main_module_test.go b/internal/build/main_module_test.go index 4e0b16c907..97b081261a 100644 --- a/internal/build/main_module_test.go +++ b/internal/build/main_module_test.go @@ -9,6 +9,7 @@ import ( "strings" "testing" + "github.com/goplus/llgo/internal/crosscompile" "github.com/xgo-dev/llvm" "github.com/goplus/llgo/internal/packages" @@ -57,6 +58,77 @@ func TestGenMainModuleExecutable(t *testing.T) { ) } +func TestGenMainModuleWASIAsyncifyEntry(t *testing.T) { + llvm.InitializeAllTargets() + t.Setenv(llgoStdioNobuf, "") + ctx := &context{ + prog: llssa.NewProgram(nil), + buildConf: &Config{ + BuildMode: BuildModeExe, + Goos: "wasip1", + Goarch: "wasm", + }, + crossCompile: crosscompile.Export{ + WasmPostLink: crosscompile.WasmPostLink{Asyncify: true}, + }, + } + pkg := &packages.Package{PkgPath: "example.com/foo", ExportFile: "foo.a"} + mod := genMainModule(ctx, llssa.PkgRuntime, pkg, &genConfig{}) + ir := mod.LPkg.String() + checks := []string{ + `define hidden ptr @__llgo_wasm_main(ptr %0)`, + `call void @"github.com/goplus/llgo/runtime/internal/runtime.init"()`, + `call void @"example.com/foo.init"()`, + `call void @"example.com/foo.main"()`, + `call void @"github.com/goplus/llgo/runtime/internal/runtime.RunWasmMain"()`, + } + for _, want := range checks { + if !strings.Contains(ir, want) { + t.Fatalf("WASI main module IR missing %q:\n%s", want, ir) + } + } + entryStart := strings.Index(ir, "define hidden i32 @__main_argc_argv(") + if entryStart < 0 { + t.Fatalf("WASI main module missing host entry:\n%s", ir) + } + entry := ir[entryStart:] + entry = entry[:strings.Index(entry, "}\n")+2] + if strings.Contains(entry, `call void @"example.com/foo.init"()`) || + strings.Contains(entry, `call void @"example.com/foo.main"()`) { + t.Fatalf("WASI system-stack entry calls package main directly:\n%s", entry) + } +} + +func TestGenMainModuleWasmWorkerEntry(t *testing.T) { + llvm.InitializeAllTargets() + t.Setenv(llgoStdioNobuf, "") + ctx := &context{ + prog: llssa.NewProgram(nil), + buildConf: &Config{ + BuildMode: BuildModeExe, + Goos: "js", + Goarch: "wasm", + }, + crossCompile: crosscompile.Export{ + WasmRuntime: crosscompile.WasmRuntime{RunMainTask: true}, + }, + } + pkg := &packages.Package{PkgPath: "example.com/foo", ExportFile: "foo.a"} + ir := genMainModule(ctx, llssa.PkgRuntime, pkg, &genConfig{}).LPkg.String() + if !strings.Contains(ir, "define hidden i32 @__main_argc_argv(") { + t.Fatalf("worker module missing Emscripten host entry:\n%s", ir) + } + if !strings.Contains(ir, `call void @"github.com/goplus/llgo/runtime/internal/runtime.RunWasmMain"()`) { + t.Fatalf("worker host entry does not start the runtime main task:\n%s", ir) + } + if strings.Contains(ir, "define i32 @main(") { + t.Fatalf("worker module should let Emscripten provide main:\n%s", ir) + } + if strings.Contains(ir, "define weak void @_start()") { + t.Fatalf("worker module should let Emscripten provide _start:\n%s", ir) + } +} + func TestGenMainModuleLibrary(t *testing.T) { llvm.InitializeAllTargets() t.Setenv(llgoStdioNobuf, "") diff --git a/internal/build/outputs.go b/internal/build/outputs.go index 4ab63186e7..3553fbb049 100644 --- a/internal/build/outputs.go +++ b/internal/build/outputs.go @@ -279,6 +279,12 @@ func defaultAppExt(conf *Config) string { return ".so" } case BuildModeExe: + if conf.Goos == "js" && conf.OutFile != "" { + switch ext := filepath.Ext(conf.OutFile); ext { + case ".js", ".mjs": + return ext + } + } // For executable mode, handle target-specific logic if conf.Target != "" { if strings.HasPrefix(conf.Target, "wasi") || strings.HasPrefix(conf.Target, "wasm") { diff --git a/internal/build/outputs_test.go b/internal/build/outputs_test.go index db2667b290..61c17e1c58 100644 --- a/internal/build/outputs_test.go +++ b/internal/build/outputs_test.go @@ -185,6 +185,29 @@ func TestBuildOutFmtsWithTarget(t *testing.T) { } } +func TestDefaultAppExtJSExplicitGlueOutput(t *testing.T) { + tests := []struct { + out string + want string + }{ + {out: "app.mjs", want: ".mjs"}, + {out: "app.js", want: ".js"}, + {out: "app.wasm", want: ".wasm"}, + {want: ".wasm"}, + } + for _, tt := range tests { + conf := &Config{ + Goos: "js", + Goarch: "wasm", + BuildMode: BuildModeExe, + OutFile: tt.out, + } + if got := defaultAppExt(conf); got != tt.want { + t.Errorf("defaultAppExt(%q) = %q, want %q", tt.out, got, tt.want) + } + } +} + func TestBuildOutFmtsNativeTarget(t *testing.T) { tests := []struct { name string diff --git a/internal/build/source_patch_test.go b/internal/build/source_patch_test.go index a4654cc752..9aa0b9c1b4 100644 --- a/internal/build/source_patch_test.go +++ b/internal/build/source_patch_test.go @@ -19,29 +19,40 @@ import ( ) func TestWasmRuntimeSourcePatchTypeChecks(t *testing.T) { - for _, goos := range []string{"js", "wasip1"} { - t.Run(goos, func(t *testing.T) { - cfgEnv := append(os.Environ(), "GOOS="+goos, "GOARCH=wasm") + for _, test := range []struct { + name string + goos string + target string + buildFlags []string + }{ + {name: "js Memory64", goos: "js"}, + {name: "js wasm32 target", goos: "js", target: "wasm", buildFlags: []string{"-tags=tinygo.wasm"}}, + {name: "WASI wasm32", goos: "wasip1"}, + } { + t.Run(test.name, func(t *testing.T) { + cfgEnv := append(os.Environ(), "GOOS="+test.goos, "GOARCH=wasm") goroot, goversion, err := env.GOROOTAndGOVERSIONWithEnv(cfgEnv) if err != nil { t.Fatal(err) } overlay, _, err := buildSourcePatchOverlayForGOROOT(nil, env.LLGoRuntimeDir(), goroot, sourcePatchBuildContext{ - goos: goos, - goarch: "wasm", - goversion: goversion, + goos: test.goos, + goarch: "wasm", + goversion: goversion, + buildFlags: test.buildFlags, }) if err != nil { t.Fatal(err) } - pkgs, err := packages.LoadEx(nil, func(types.Sizes, string, string) types.Sizes { - return &types.StdSizes{WordSize: 4, MaxAlign: 4} + pkgs, err := packages.LoadEx(nil, func(sizes types.Sizes, _ string, arch string) types.Sizes { + return effectiveTypeSizes(sizes, test.goos, arch, test.target) }, &packages.Config{ - Mode: loadSyntax | packages.NeedDeps | packages.NeedModule | packages.NeedExportFile, - Env: cfgEnv, - Fset: token.NewFileSet(), - Overlay: overlay, + Mode: loadSyntax | packages.NeedDeps | packages.NeedModule | packages.NeedExportFile, + Env: cfgEnv, + Fset: token.NewFileSet(), + Overlay: overlay, + BuildFlags: test.buildFlags, }, "runtime") if err != nil { t.Fatal(err) @@ -51,7 +62,7 @@ func TestWasmRuntimeSourcePatchTypeChecks(t *testing.T) { } if pkgs[0].IllTyped { logPackageErrors(t, pkgs[0], make(map[string]bool)) - t.Fatal("runtime did not type-check with wasm32 sizes") + t.Fatal("runtime did not type-check") } }) } @@ -234,6 +245,39 @@ func boolToUint8(bool) uint8 } } +func TestBuildSourcePatchOverlayForGo124HashTrieMap(t *testing.T) { + goroot := t.TempDir() + syncDir := filepath.Join(goroot, "src", "internal", "sync") + hashTrieMap := filepath.Join(syncDir, "hashtriemap.go") + mustWriteFile(t, hashTrieMap, `package sync + +type HashTrieMap[K comparable, V any] struct{} + +func (ht *HashTrieMap[K, V]) CompareAndSwap(key K, old, new V) bool { + return false +} +`) + + overlay, _, err := buildSourcePatchOverlayForGOROOT(nil, env.LLGoRuntimeDir(), goroot, sourcePatchBuildContext{ + goos: runtime.GOOS, + goarch: runtime.GOARCH, + goversion: "go1.24.2", + }) + if err != nil { + t.Fatal(err) + } + + patch := filepath.Join(syncDir, "z_llgo_patch_hashtriemap.go") + if src, ok := overlay[patch]; !ok { + t.Fatalf("missing source patch file %s", patch) + } else if !strings.Contains(string(src), "type HashTrieMap") { + t.Fatalf("source patch file %s does not contain HashTrieMap replacement", patch) + } + if stdSrc := string(overlay[hashTrieMap]); strings.Contains(stdSrc, "type HashTrieMap") { + t.Fatalf("stub overlay for internal/sync still contains HashTrieMap: %s", stdSrc) + } +} + func TestGo126PayloadsUseSourcePatchInsteadOfAltPkg(t *testing.T) { for _, pkgPath := range []string{"internal/sync", "crypto/internal/constanttime"} { if !llruntime.HasSourcePatchPkg(pkgPath) { diff --git a/internal/build/testdata/wasm-blocking/main.go b/internal/build/testdata/wasm-blocking/main.go new file mode 100644 index 0000000000..0a8a7fae5d --- /dev/null +++ b/internal/build/testdata/wasm-blocking/main.go @@ -0,0 +1,63 @@ +package main + +import ( + "sync" + "sync/atomic" +) + +func main() { + values := make(chan int) + var wg sync.WaitGroup + var mu sync.Mutex + wg.Add(1) + go func() { + mu.Lock() + values <- 42 + mu.Unlock() + wg.Done() + }() + if value := <-values; value != 42 { + panic("channel value mismatch") + } + wg.Wait() + + var rw sync.RWMutex + rw.RLock() + rw.RUnlock() + rw.Lock() + rw.Unlock() + + cond := sync.NewCond(&mu) + started := make(chan struct{}) + ready := false + wg.Add(1) + go func() { + mu.Lock() + close(started) + for !ready { + cond.Wait() + } + mu.Unlock() + wg.Done() + }() + <-started + mu.Lock() + ready = true + cond.Signal() + mu.Unlock() + wg.Wait() + + var once sync.Once + once.Do(func() {}) + var pool sync.Pool + pool.Put("value") + if pool.Get() != "value" { + panic("sync.Pool value mismatch") + } + var atomicValue atomic.Value + atomicValue.Store("value") + if atomicValue.Load() != "value" { + panic("atomic.Value mismatch") + } + println("wasm blocking primitives ok") +} diff --git a/internal/build/testdata/wasm-gc/abi.c b/internal/build/testdata/wasm-gc/abi.c new file mode 100644 index 0000000000..979c9af679 --- /dev/null +++ b/internal/build/testdata/wasm-gc/abi.c @@ -0,0 +1,31 @@ +#include +#include +#include + +#if defined(__EMSCRIPTEN__) +#include +#endif + +int llgo_test_gc_aligned_alloc(void) { +#if defined(__EMSCRIPTEN__) + void *ptr = emscripten_builtin_memalign(65536, 257); + if (ptr == NULL || (uintptr_t)ptr % 65536 != 0) { + return 0; + } + unsigned char *bytes = ptr; + bytes[0] = 0x5a; + bytes[256] = 0xa5; + if (bytes[0] != 0x5a || bytes[256] != 0xa5) { + return 0; + } + emscripten_builtin_free(ptr); + + ptr = NULL; + if (posix_memalign(&ptr, 65536, 257) != 0 || ptr == NULL || + (uintptr_t)ptr % 65536 != 0) { + return 0; + } + free(ptr); +#endif + return 1; +} diff --git a/internal/build/testdata/wasm-gc/abi.go b/internal/build/testdata/wasm-gc/abi.go new file mode 100644 index 0000000000..4eb18638a2 --- /dev/null +++ b/internal/build/testdata/wasm-gc/abi.go @@ -0,0 +1,8 @@ +package main + +import _ "unsafe" + +const LLGoFiles = "abi.c" + +//go:linkname testAlignedAlloc C.llgo_test_gc_aligned_alloc +func testAlignedAlloc() int32 diff --git a/internal/build/testdata/wasm-gc/main.go b/internal/build/testdata/wasm-gc/main.go new file mode 100644 index 0000000000..f701f0aa06 --- /dev/null +++ b/internal/build/testdata/wasm-gc/main.go @@ -0,0 +1,212 @@ +package main + +import ( + "runtime" + "sync/atomic" + "time" +) + +type payload struct { + value uint64 +} + +var ( + globalRoot *payload + garbage *payload + liveChunks [][]byte + stopLoop atomic.Bool +) + +func main() { + if testAlignedAlloc() == 0 { + panic("aligned allocation failed") + } + testRoots() + testCooperativeSafepoint() + testSuspendedGRoots() + testRecoveredRootChain() + testReclamation() + testHeapGrowth() + println("wasm gc ok") +} + +//go:noinline +func cooperativeLoopWorker(ready chan<- struct{}, done chan<- uint64) { + live := &payload{value: 0x31415926} + ready <- struct{}{} + for !stopLoop.Load() { + } + done <- live.value +} + +func testCooperativeSafepoint() { + stopLoop.Store(false) + ready := make(chan struct{}) + done := make(chan uint64) + go cooperativeLoopWorker(ready, done) + <-ready + + time.AfterFunc(10*time.Millisecond, func() { + runtime.GC() + stopLoop.Store(true) + }) + if value := <-done; value != 0x31415926 { + panic("cooperative safepoint lost a live root") + } +} + +func testRoots() { + globalRoot = &payload{value: 0x12345678} + runtime.GC() + if globalRoot.value != 0x12345678 { + panic("global root was not retained") + } + globalRoot = nil +} + +type suspendedRoots struct { + p *payload + slice []byte + text string + any any + fn func() uint64 + array [2]*payload +} + +//go:noinline +func suspendedRootWorker(ready chan<- struct{}, resume <-chan struct{}, done chan<- struct{}) { + closurePayload := &payload{value: 0x11223344} + bytes := []byte{'w', 'a', 's', 'm'} + roots := suspendedRoots{ + p: &payload{value: 0x55667788}, + slice: []byte{1, 2, 3, 4}, + text: string(bytes), + any: &payload{value: 0x99aabbcc}, + fn: func() uint64 { return closurePayload.value }, + array: [2]*payload{{value: 0xddeeff00}, {value: 0x10203040}}, + } + ready <- struct{}{} + <-resume + + if roots.p.value != 0x55667788 || + len(roots.slice) != 4 || roots.slice[0] != 1 || roots.slice[3] != 4 || + roots.text != "wasm" || + roots.any.(*payload).value != 0x99aabbcc || + roots.fn() != 0x11223344 || + roots.array[0].value != 0xddeeff00 || roots.array[1].value != 0x10203040 { + panic("suspended goroutine roots were not retained") + } + done <- struct{}{} +} + +func testSuspendedGRoots() { + ready := make(chan struct{}) + resume := make(chan struct{}) + done := make(chan struct{}) + go suspendedRootWorker(ready, resume, done) + <-ready + + runtime.GC() + for i := 0; i < 4096; i++ { + garbage = &payload{value: uint64(i)} + } + garbage = nil + close(resume) + <-done +} + +//go:noinline +func usePayload(*payload) {} + +//go:noinline +func panicWithRoot(value *payload) { + usePayload(value) + panic("root-chain unwind") +} + +//go:noinline +func clobberStack(depth int, value uint64) uint64 { + var words [32]uint64 + for i := range words { + words[i] = value + uint64(i) + } + if depth != 0 { + return words[depth%len(words)] + clobberStack(depth-1, value+1) + } + return words[0] +} + +func testRecoveredRootChain() { + live := &payload{value: 0xabcdef01} + func() { + defer func() { + if recover() == nil { + panic("panic was not recovered") + } + }() + panicWithRoot(live) + }() + + _ = clobberStack(32, 1) + runtime.GC() + if live.value != 0xabcdef01 { + panic("root chain was not restored after recover") + } +} + +//go:noinline +func allocateGarbage() { + for i := 0; i < 1024; i++ { + garbage = &payload{value: uint64(i)} + } + garbage = nil +} + +func testReclamation() { + runtime.GC() + var before runtime.MemStats + runtime.ReadMemStats(&before) + + allocateGarbage() + runtime.GC() + + var after runtime.MemStats + runtime.ReadMemStats(&after) + if after.TotalAlloc <= before.TotalAlloc || after.Mallocs <= before.Mallocs { + panic("allocation statistics did not advance") + } + if after.Frees <= before.Frees { + panic("unreachable objects were not reclaimed") + } +} + +func testHeapGrowth() { + var before runtime.MemStats + runtime.ReadMemStats(&before) + + const chunkSize = 1 << 20 + chunkCount := int(before.HeapSys/chunkSize) + 8 + if chunkCount > 128 { + panic("initial heap is too large for the bounded growth test") + } + liveChunks = make([][]byte, 0, chunkCount) + for i := 0; i < chunkCount; i++ { + chunk := make([]byte, chunkSize) + chunk[0] = byte(i + 1) + chunk[len(chunk)-1] = byte(i + 2) + liveChunks = append(liveChunks, chunk) + } + + var after runtime.MemStats + runtime.ReadMemStats(&after) + if after.HeapSys <= before.HeapSys { + panic("heap did not grow") + } + runtime.GC() + for i, chunk := range liveChunks { + if chunk[0] != byte(i+1) || chunk[len(chunk)-1] != byte(i+2) { + panic("live object was corrupted during heap growth") + } + } + liveChunks = nil +} diff --git a/internal/build/testdata/wasm-scheduler/abi.c b/internal/build/testdata/wasm-scheduler/abi.c new file mode 100644 index 0000000000..b648edf3ec --- /dev/null +++ b/internal/build/testdata/wasm-scheduler/abi.c @@ -0,0 +1,10 @@ +#include +#include + +size_t llgo_test_sizeof_long(void) { + return sizeof(long); +} + +int llgo_test_scheduler_deadlock(void) { + return getenv("LLGO_WASM_SCHEDULER_DEADLOCK") != NULL; +} diff --git a/internal/build/testdata/wasm-scheduler/abi.go b/internal/build/testdata/wasm-scheduler/abi.go new file mode 100644 index 0000000000..caa04596fc --- /dev/null +++ b/internal/build/testdata/wasm-scheduler/abi.go @@ -0,0 +1,11 @@ +package main + +import _ "unsafe" + +const LLGoFiles = "abi.c" + +//go:linkname cLongSize C.llgo_test_sizeof_long +func cLongSize() uintptr + +//go:linkname schedulerDeadlockMode C.llgo_test_scheduler_deadlock +func schedulerDeadlockMode() int32 diff --git a/internal/build/testdata/wasm-scheduler/main.go b/internal/build/testdata/wasm-scheduler/main.go new file mode 100644 index 0000000000..6927a5e70e --- /dev/null +++ b/internal/build/testdata/wasm-scheduler/main.go @@ -0,0 +1,404 @@ +package main + +import ( + "runtime" + "sync" + "sync/atomic" + "unsafe" +) + +//go:linkname currentGForTesting github.com/goplus/llgo/runtime/internal/runtime.CurrentGForTesting +func currentGForTesting() unsafe.Pointer + +//go:linkname parkForTesting github.com/goplus/llgo/runtime/internal/runtime.ParkForTesting +func parkForTesting() + +//go:linkname readyForTesting github.com/goplus/llgo/runtime/internal/runtime.ReadyForTesting +func readyForTesting(unsafe.Pointer) + +//go:linkname schedulerStateForTesting github.com/goplus/llgo/runtime/internal/runtime.SchedulerStateForTesting +func schedulerStateForTesting() (runq uintptr, mid int64, pid int32) + +//go:linkname gmpForTesting github.com/goplus/llgo/runtime/internal/runtime.GMPForTesting +func gmpForTesting() (goid, parentGoid uint64, mid int64, pid int32, gstatus, pstatus uint32, linked bool) + +var ( + parked unsafe.Pointer + mainMID int64 + mainPID int32 + mainGID uint64 + seenG [4]uint64 + seenGCount int + eventLog [8]int + eventCount int + done int + lifecycle int +) + +func event(value int) { + eventLog[eventCount] = value + eventCount++ +} + +func checkCurrentG() { + goid, parent, mid, pid, gstatus, pstatus, linked := gmpForTesting() + if goid == 0 || goid == mainGID || parent != mainGID { + panic("invalid goroutine identity") + } + if mid != mainMID || pid != mainPID { + panic("goroutine did not reuse the single worker M/P") + } + if gstatus != 2 || pstatus != 1 || !linked { + panic("invalid running G/M/P state") + } + for i := 0; i < seenGCount; i++ { + if seenG[i] == goid { + panic("duplicate goroutine identity") + } + } + seenG[seenGCount] = goid + seenGCount++ +} + +func main() { + checkWasmModel() + if schedulerDeadlockMode() != 0 { + testParkedMainDeadlock() + return + } + var ( + gstatus uint32 + pstatus uint32 + linked bool + ) + mainGID, _, mainMID, mainPID, gstatus, pstatus, linked = gmpForTesting() + if mainGID != 1 || mainMID != 1 || mainPID != 0 || gstatus != 2 || pstatus != 1 || !linked { + panic("invalid main G/M/P state") + } + + go func() { + checkCurrentG() + event(1) + parked = currentGForTesting() + parkForTesting() + event(8) + done++ + }() + + go func() { + checkCurrentG() + event(2) + runtime.Gosched() + event(6) + readyForTesting(parked) + event(7) + done++ + }() + + go func() { + checkCurrentG() + defer func() { + if recover() != "expected panic" { + panic("unexpected recover value") + } + event(3) + done++ + }() + panic("expected panic") + }() + + go func() { + checkCurrentG() + defer func() { + event(4) + done++ + }() + runtime.Goexit() + panic("Goexit returned") + }() + + if runq, mid, pid := schedulerStateForTesting(); runq != 4 || mid != mainMID || pid != mainPID { + panic("invalid initial scheduler state") + } + event(0) + for done != 4 { + runtime.Gosched() + } + + want := [...]int{0, 1, 2, 3, 4, 6, 7, 8} + if eventCount != len(want) { + panic("unexpected event count") + } + for i, value := range want { + if eventLog[i] != value { + panic("unexpected scheduler order") + } + } + if seenGCount != len(seenG) { + panic("not all goroutines ran") + } + testGoroutineLifecycle() + testBlockingPrimitives() + println("wasm scheduler ok") +} + +func testBlockingPrimitives() { + testChannelsAndSelect() + testWaitGroup() + testMutexes() + testCond() + testSyncHelpers() + testSyncMap() +} + +func testChannelsAndSelect() { + values := make(chan int) + ack := make(chan struct{}) + go func() { + values <- 41 + close(ack) + }() + if value := <-values; value != 41 { + panic("unexpected channel value") + } + <-ack + + buffered := make(chan int, 2) + buffered <- 1 + buffered <- 2 + if len(buffered) != 2 || cap(buffered) != 2 { + panic("buffered channel size mismatch") + } + if <-buffered != 1 || <-buffered != 2 { + panic("buffered channel order mismatch") + } + + left := make(chan int) + right := make(chan int) + go func() { + right <- 42 + }() + select { + case <-left: + panic("select chose a blocked channel") + case value := <-right: + if value != 42 { + panic("unexpected select value") + } + } + + selected := make(chan int) + go func() { + select { + case value := <-left: + selected <- value + case value := <-right: + selected <- value + } + }() + runtime.Gosched() + left <- 43 + if value := <-selected; value != 43 { + panic("blocked select chose the wrong channel") + } + + select { + case <-right: + panic("non-blocking select chose a blocked channel") + default: + } + + close(right) + if value, ok := <-right; value != 0 || ok { + panic("closed channel receive mismatch") + } +} + +func testWaitGroup() { + var wg sync.WaitGroup + count := 0 + wg.Add(2) + go func() { + count++ + wg.Done() + }() + go func() { + runtime.Gosched() + count++ + wg.Done() + }() + wg.Wait() + if count != 2 { + panic("WaitGroup returned too early") + } + + wg.Add(1) + go wg.Done() + wg.Wait() +} + +func testMutexes() { + var mu sync.Mutex + started := make(chan struct{}) + finished := make(chan struct{}) + value := 0 + mu.Lock() + go func() { + close(started) + mu.Lock() + value = 1 + mu.Unlock() + close(finished) + }() + <-started + mu.Unlock() + <-finished + if value != 1 { + panic("Mutex waiter did not run") + } + if !mu.TryLock() { + panic("Mutex.TryLock failed") + } + mu.Unlock() + + var rw sync.RWMutex + rw.RLock() + finished = make(chan struct{}) + go func() { + rw.Lock() + value = 2 + rw.Unlock() + close(finished) + }() + runtime.Gosched() + rw.RUnlock() + <-finished + if value != 2 { + panic("RWMutex waiter did not run") + } +} + +func testCond() { + var mu sync.Mutex + cond := sync.NewCond(&mu) + arrived := make(chan struct{}, 2) + done := make(chan struct{}, 2) + ready := false + for i := 0; i < 2; i++ { + go func() { + mu.Lock() + arrived <- struct{}{} + for !ready { + cond.Wait() + } + mu.Unlock() + done <- struct{}{} + }() + } + <-arrived + <-arrived + + mu.Lock() + ready = true + cond.Signal() + mu.Unlock() + <-done + select { + case <-done: + panic("Cond.Signal woke more than one waiter") + default: + } + + mu.Lock() + cond.Broadcast() + mu.Unlock() + <-done +} + +func testSyncHelpers() { + var once sync.Once + var wg sync.WaitGroup + count := 0 + wg.Add(2) + for i := 0; i < 2; i++ { + go func() { + once.Do(func() { + count++ + }) + wg.Done() + }() + } + wg.Wait() + if count != 1 { + panic("sync.Once ran more than once") + } + + var pool sync.Pool + pool.Put("pooled") + if value := pool.Get(); value != "pooled" { + panic("sync.Pool value mismatch") + } + + var value atomic.Value + value.Store("before") + if old := value.Swap("after"); old != "before" || value.Load() != "after" { + panic("atomic.Value swap mismatch") + } +} + +func testSyncMap() { + var m sync.Map + if _, loaded := m.LoadOrStore("key", 1); loaded { + panic("sync.Map unexpectedly loaded a missing key") + } + if value, loaded := m.Load("key"); !loaded || value != 1 { + panic("sync.Map load mismatch") + } + if !m.CompareAndSwap("key", 1, 2) { + panic("sync.Map compare-and-swap failed") + } + if previous, loaded := m.Swap("key", 3); !loaded || previous != 2 { + panic("sync.Map swap mismatch") + } + count := 0 + m.Range(func(key, value any) bool { + if key != "key" || value != 3 { + panic("sync.Map range mismatch") + } + count++ + return true + }) + if count != 1 { + panic("sync.Map range count mismatch") + } + if !m.CompareAndDelete("key", 3) { + panic("sync.Map compare-and-delete failed") + } + if _, loaded := m.LoadAndDelete("key"); loaded { + panic("sync.Map retained a deleted key") + } + m.Store("clear", 4) + m.Clear() + if _, loaded := m.Load("clear"); loaded { + panic("sync.Map clear failed") + } +} + +func testGoroutineLifecycle() { + const count = 5000 + for i := 1; i <= count; i++ { + want := i + go func() { + lifecycle = want + }() + for lifecycle != want { + runtime.Gosched() + } + } +} + +func testParkedMainDeadlock() { + go func() {}() + parkForTesting() + panic("park returned after scheduler deadlock") +} diff --git a/internal/build/testdata/wasm-scheduler/model_go.go b/internal/build/testdata/wasm-scheduler/model_go.go new file mode 100644 index 0000000000..9cde6cc3f5 --- /dev/null +++ b/internal/build/testdata/wasm-scheduler/model_go.go @@ -0,0 +1,26 @@ +//go:build !tinygo.wasm + +package main + +import ( + "runtime" + "unsafe" +) + +func checkWasmModel() { + if runtime.GOOS == "wasip1" { + if unsafe.Sizeof(uintptr(0)) != 4 { + panic("GOOS=wasip1 GOARCH=wasm must use 32-bit words") + } + if cLongSize() != 4 { + panic("GOOS=wasip1 GOARCH=wasm must use the wasm32 C data model") + } + return + } + if unsafe.Sizeof(uintptr(0)) != 8 { + panic("GOOS/GOARCH wasm must use 64-bit words") + } + if cLongSize() != 8 { + panic("GOOS/GOARCH wasm must use the LP64 C data model") + } +} diff --git a/internal/build/testdata/wasm-scheduler/model_target.go b/internal/build/testdata/wasm-scheduler/model_target.go new file mode 100644 index 0000000000..1fadc4efc1 --- /dev/null +++ b/internal/build/testdata/wasm-scheduler/model_target.go @@ -0,0 +1,14 @@ +//go:build tinygo.wasm + +package main + +import "unsafe" + +func checkWasmModel() { + if unsafe.Sizeof(uintptr(0)) != 4 { + panic("-target wasm must use 32-bit words") + } + if cLongSize() != 4 { + panic("-target wasm must use the wasm32 C data model") + } +} diff --git a/internal/build/testdata/wasm-timers/main.go b/internal/build/testdata/wasm-timers/main.go new file mode 100644 index 0000000000..00915534ee --- /dev/null +++ b/internal/build/testdata/wasm-timers/main.go @@ -0,0 +1,81 @@ +package main + +import "time" + +func main() { + testClock() + testSleep() + testTimerStopAndReset() + testTicker() + testAfterFunc() + testTimeoutSelect() + println("wasm timers ok") +} + +func testClock() { + name, offset := time.Now().Zone() + if name == "" || offset < -24*60*60 || offset > 24*60*60 { + panic("invalid local time zone") + } +} + +func testSleep() { + start := time.Now() + time.Sleep(5 * time.Millisecond) + if time.Since(start) < 4*time.Millisecond { + panic("Sleep returned early") + } +} + +func testTimerStopAndReset() { + timer := time.NewTimer(100 * time.Millisecond) + if !timer.Stop() { + panic("active timer Stop returned false") + } + if timer.Stop() { + panic("stopped timer Stop returned true") + } + if timer.Reset(5 * time.Millisecond) { + panic("stopped timer Reset returned true") + } + <-timer.C + if timer.Stop() { + panic("expired timer Stop returned true") + } + + timer = time.NewTimer(100 * time.Millisecond) + if !timer.Reset(5 * time.Millisecond) { + panic("active timer Reset returned false") + } + <-timer.C + if timer.Stop() { + panic("reset timer Stop returned true after expiry") + } +} + +func testTicker() { + ticker := time.NewTicker(3 * time.Millisecond) + for i := 0; i < 3; i++ { + <-ticker.C + } + ticker.Stop() +} + +func testAfterFunc() { + done := make(chan int) + time.AfterFunc(5*time.Millisecond, func() { + done <- 42 + }) + if value := <-done; value != 42 { + panic("AfterFunc result mismatch") + } +} + +func testTimeoutSelect() { + blocked := make(chan struct{}) + select { + case <-blocked: + panic("blocked channel became ready") + case <-time.After(5 * time.Millisecond): + } +} diff --git a/internal/build/testdata/wasm-workers/abi.go b/internal/build/testdata/wasm-workers/abi.go new file mode 100644 index 0000000000..0cba615e3d --- /dev/null +++ b/internal/build/testdata/wasm-workers/abi.go @@ -0,0 +1,17 @@ +package main + +import _ "unsafe" + +const LLGoFiles = "workers.c" + +//go:linkname parallelWorkerBarrier C.llgo_test_parallel_worker_barrier +func parallelWorkerBarrier() int32 + +//go:linkname parallelWorkerThread C.llgo_test_parallel_worker_thread +func parallelWorkerThread(slot int32) uintptr + +//go:linkname currentWorkerThread C.llgo_test_current_worker_thread +func currentWorkerThread() uintptr + +//go:linkname configuredWorkerCount C.llgo_test_worker_count +func configuredWorkerCount() int32 diff --git a/internal/build/testdata/wasm-workers/browser.html b/internal/build/testdata/wasm-workers/browser.html new file mode 100644 index 0000000000..a892d2e887 --- /dev/null +++ b/internal/build/testdata/wasm-workers/browser.html @@ -0,0 +1,25 @@ + + + +LLGo wasm runtime test +running + + diff --git a/internal/build/testdata/wasm-workers/main.go b/internal/build/testdata/wasm-workers/main.go new file mode 100644 index 0000000000..6d8a1e68e1 --- /dev/null +++ b/internal/build/testdata/wasm-workers/main.go @@ -0,0 +1,189 @@ +package main + +import ( + "runtime" + "sync" + "sync/atomic" + "time" + _ "unsafe" +) + +//go:linkname gmpForTesting github.com/goplus/llgo/runtime/internal/runtime.GMPForTesting +func gmpForTesting() (goid, parentGoid uint64, mid int64, pid int32, gstatus, pstatus uint32, linked bool) + +type workerIdentity struct { + mid int64 + pid int32 + thread uintptr +} + +func main() { + testParallelWorkers() + testPinnedGoroutine() + testBoundedWorkerLifecycle() + testCrossWorkerChannelHandoffs() + testCrossWorkerSynchronization() + testCrossWorkerTimerWake() + println("wasm workers ok") +} + +func testParallelWorkers() { + var identities [2]workerIdentity + record := func() { + _, _, mid, pid, gstatus, pstatus, linked := gmpForTesting() + if gstatus != 2 || pstatus != 1 || !linked { + panic("invalid worker G/M/P state") + } + slot := parallelWorkerBarrier() + if slot < 0 || int(slot) >= len(identities) { + panic("parallel worker barrier timed out") + } + identities[slot] = workerIdentity{ + mid: mid, + pid: pid, + thread: parallelWorkerThread(slot), + } + } + + done := make(chan struct{}) + go func() { + record() + close(done) + }() + record() + <-done + + left, right := identities[0], identities[1] + if left.mid == right.mid || left.pid == right.pid { + panic("goroutines did not run on distinct scheduler workers") + } + if left.thread == 0 || right.thread == 0 || left.thread == right.thread { + panic("goroutines did not overlap on distinct pthreads") + } +} + +func testPinnedGoroutine() { + done := make(chan struct{}) + go func() { + _, _, mid, pid, _, _, linked := gmpForTesting() + thread := currentWorkerThread() + if !linked || thread == 0 { + panic("invalid initial worker identity") + } + for range 32 { + runtime.Gosched() + } + time.Sleep(time.Millisecond) + _, _, currentMid, currentPid, _, _, currentLinked := gmpForTesting() + if !currentLinked || currentMid != mid || currentPid != pid || currentWorkerThread() != thread { + panic("started goroutine migrated between workers") + } + close(done) + }() + <-done +} + +func testBoundedWorkerLifecycle() { + const goroutines = 5000 + workerCount := int(configuredWorkerCount()) + var ( + done atomic.Uint32 + mid int64 + thread uintptr + ) + mids := make(map[int64]struct{}) + threads := make(map[uintptr]struct{}) + for i := uint32(1); i <= goroutines; i++ { + go func() { + _, _, currentMID, _, _, _, linked := gmpForTesting() + currentThread := currentWorkerThread() + if !linked || currentThread == 0 { + panic("invalid lifecycle worker identity") + } + mid = currentMID + thread = currentThread + done.Store(i) + }() + for done.Load() != i { + runtime.Gosched() + } + mids[mid] = struct{}{} + threads[thread] = struct{}{} + } + if len(mids) != workerCount || len(threads) != workerCount { + panic("goroutine lifecycle escaped the bounded worker pool") + } +} + +func testCrossWorkerChannelHandoffs() { + const handoffs = 100_000 + values := make(chan int) + done := make(chan struct{}) + go func() { + for i := range handoffs { + values <- i + } + close(done) + }() + for i := range handoffs { + if value := <-values; value != i { + panic("cross-worker channel handoff lost ordering") + } + } + <-done +} + +func testCrossWorkerSynchronization() { + workerCount := int(configuredWorkerCount()) + goroutines := workerCount * 2 + var ( + counter atomic.Uint32 + mu sync.Mutex + wg sync.WaitGroup + ) + values := make(chan int, goroutines) + workers := make(map[int64]int) + wg.Add(goroutines) + for i := range goroutines { + go func() { + _, _, mid, _, _, _, _ := gmpForTesting() + mu.Lock() + counter.Add(1) + workers[mid]++ + mu.Unlock() + values <- i + wg.Done() + }() + } + wg.Wait() + close(values) + + seen := 0 + for range values { + seen++ + } + if seen != goroutines || counter.Load() != uint32(goroutines) { + panic("cross-worker synchronization lost work") + } + if len(workers) != workerCount { + panic("goroutines did not use the bounded worker pool") + } + for _, count := range workers { + if count < 2 { + panic("worker did not execute multiple goroutines") + } + } +} + +func testCrossWorkerTimerWake() { + done := make(chan struct{}) + go func() { + time.Sleep(5 * time.Millisecond) + close(done) + }() + select { + case <-done: + case <-time.After(time.Second): + panic("timer did not wake a worker") + } +} diff --git a/internal/build/testdata/wasm-workers/server.mjs b/internal/build/testdata/wasm-workers/server.mjs new file mode 100644 index 0000000000..78f9480b2b --- /dev/null +++ b/internal/build/testdata/wasm-workers/server.mjs @@ -0,0 +1,35 @@ +import { createReadStream, statSync } from "node:fs"; +import { createServer } from "node:http"; +import { extname, resolve, sep } from "node:path"; + +const root = resolve(process.argv[2]); +const port = Number(process.argv[3]); +const types = new Map([ + [".html", "text/html; charset=utf-8"], + [".js", "text/javascript; charset=utf-8"], + [".mjs", "text/javascript; charset=utf-8"], + [".wasm", "application/wasm"], +]); + +createServer((request, response) => { + const pathname = decodeURIComponent(new URL(request.url, "http://localhost").pathname); + const file = resolve(root, pathname.replace(/^\/+/, "") || "browser.html"); + if (file !== root && !file.startsWith(root + sep)) { + response.writeHead(403).end(); + return; + } + try { + if (!statSync(file).isFile()) { + throw new Error("not a file"); + } + response.writeHead(200, { + "Content-Type": types.get(extname(file)) || "application/octet-stream", + "Cross-Origin-Embedder-Policy": "require-corp", + "Cross-Origin-Opener-Policy": "same-origin", + "Cross-Origin-Resource-Policy": "same-origin", + }); + createReadStream(file).pipe(response); + } catch { + response.writeHead(404).end(); + } +}).listen(port, "127.0.0.1"); diff --git a/internal/build/testdata/wasm-workers/workers.c b/internal/build/testdata/wasm-workers/workers.c new file mode 100644 index 0000000000..d46158fb71 --- /dev/null +++ b/internal/build/testdata/wasm-workers/workers.c @@ -0,0 +1,50 @@ +#include +#include +#include +#include + +#ifndef LLGO_WASM_WORKERS +#define LLGO_WASM_WORKERS 1 +#endif + +static _Atomic uint32_t llgo_test_parallel_workers; +static uintptr_t llgo_test_parallel_threads[2]; + +int32_t llgo_test_parallel_worker_barrier(void) { + uint32_t slot = atomic_fetch_add_explicit( + &llgo_test_parallel_workers, 1, memory_order_acq_rel); + if (slot >= 2) { + return -1; + } + llgo_test_parallel_threads[slot] = (uintptr_t)pthread_self(); + if (slot == 0) { + uint32_t attempts = 0; + while (atomic_load_explicit( + &llgo_test_parallel_workers, memory_order_acquire) != 2) { + if (attempts++ == 10) { + return -1; + } + emscripten_futex_wait( + (volatile void *)&llgo_test_parallel_workers, 1, 100.0); + } + } else { + emscripten_futex_wake( + (volatile void *)&llgo_test_parallel_workers, 1); + } + return (int32_t)slot; +} + +uintptr_t llgo_test_parallel_worker_thread(int32_t slot) { + if (slot < 0 || slot >= 2) { + return 0; + } + return llgo_test_parallel_threads[slot]; +} + +uintptr_t llgo_test_current_worker_thread(void) { + return (uintptr_t)pthread_self(); +} + +int32_t llgo_test_worker_count(void) { + return LLGO_WASM_WORKERS; +} diff --git a/internal/build/wasm_postlink.go b/internal/build/wasm_postlink.go new file mode 100644 index 0000000000..b460f153b1 --- /dev/null +++ b/internal/build/wasm_postlink.go @@ -0,0 +1,123 @@ +//go:build !llgo + +/* + * 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 ( + "fmt" + "os" + "os/exec" + "path/filepath" + + "github.com/goplus/llgo/internal/crosscompile" +) + +func needsWasmPostLink(conf *Config, target *crosscompile.Export) bool { + return conf != nil && conf.BuildMode == BuildModeExe && + target != nil && target.WasmPostLink.Asyncify +} + +func wasmPostLinkArgs(target *crosscompile.Export, input, output string, debug bool) []string { + if target == nil || !target.WasmPostLink.Asyncify { + return nil + } + // LLVM 19 lowers Wasm SjLj through the legacy EH encoding. Asyncify + // understands that form; translate it only after instrumentation so the + // final module uses the standardized exnref-based EH instructions. + args := []string{"--asyncify", "--translate-to-exnref"} + if debug { + args = append(args, "-g") + } + return append(args, input, "-o", output) +} + +func prepareWasmLinkOutput(conf *Config, target *crosscompile.Export, output string) (string, error) { + if !needsWasmPostLink(conf, target) { + return output, nil + } + return createClosedTemp( + filepath.Dir(output), + "."+filepath.Base(output)+".linked-*", + ) +} + +func cleanupWasmLinkOutput(input, output string) { + if input != output { + os.Remove(input) + } +} + +func publishWasmLinkOutput(ctx *context, input, output string, verbose bool) error { + if input == output { + return nil + } + return postLinkWasm(ctx, input, output, verbose) +} + +func createClosedTemp(dir, pattern string) (string, error) { + tmp, err := os.CreateTemp(dir, pattern) + if err != nil { + return "", err + } + name := tmp.Name() + if err := tmp.Close(); err != nil { + os.Remove(name) + return "", err + } + return name, nil +} + +func postLinkWasm(ctx *context, input, output string, verbose bool) error { + wasmOpt := os.Getenv("WASMOPT") + if wasmOpt == "" { + wasmOpt = "wasm-opt" + } + resolved, err := exec.LookPath(wasmOpt) + if err != nil { + return fmt.Errorf("WebAssembly Asyncify requires wasm-opt; install Binaryen or set WASMOPT: %w", err) + } + + tmpName, err := createClosedTemp( + filepath.Dir(output), + "."+filepath.Base(output)+".wasm-opt-*", + ) + if err != nil { + return err + } + defer os.Remove(tmpName) + + args := wasmPostLinkArgs( + &ctx.crossCompile, + input, + tmpName, + shouldEmitDebugInfo(ctx.buildConf, &ctx.crossCompile), + ) + if ctx.shouldPrintCommands(verbose) { + fmt.Fprintln(os.Stderr, resolved, args) + } + cmd := exec.Command(resolved, args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("wasm-opt Asyncify failed: %w", err) + } + if err := os.Rename(tmpName, output); err != nil { + return err + } + return nil +} diff --git a/internal/build/wasm_postlink_test.go b/internal/build/wasm_postlink_test.go new file mode 100644 index 0000000000..0a00327425 --- /dev/null +++ b/internal/build/wasm_postlink_test.go @@ -0,0 +1,249 @@ +//go:build !llgo + +/* + * 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 ( + "os" + "path/filepath" + "reflect" + "runtime" + "strings" + "testing" + + "github.com/goplus/llgo/internal/crosscompile" +) + +func wasmPostLinkTestContext() *context { + return &context{ + buildConf: &Config{LinkOptions: LinkOptions{DWARF: DWARFOmit}}, + crossCompile: crosscompile.Export{ + WasmPostLink: crosscompile.WasmPostLink{Asyncify: true}, + }, + } +} + +func writeWasmOptTestTool(t *testing.T, dir, script string) string { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("test helper uses a POSIX shell") + } + tool := filepath.Join(dir, "wasm-opt") + if err := os.WriteFile(tool, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + return tool +} + +func TestWasmPostLinkArgs(t *testing.T) { + target := &crosscompile.Export{WasmPostLink: crosscompile.WasmPostLink{Asyncify: true}} + if got, want := wasmPostLinkArgs(target, "in.wasm", "out.wasm", false), + []string{"--asyncify", "--translate-to-exnref", "in.wasm", "-o", "out.wasm"}; !reflect.DeepEqual(got, want) { + t.Fatalf("wasmPostLinkArgs() = %v, want %v", got, want) + } + if got, want := wasmPostLinkArgs(target, "in.wasm", "out.wasm", true), + []string{"--asyncify", "--translate-to-exnref", "-g", "in.wasm", "-o", "out.wasm"}; !reflect.DeepEqual(got, want) { + t.Fatalf("wasmPostLinkArgs(debug) = %v, want %v", got, want) + } + if got := wasmPostLinkArgs(&crosscompile.Export{}, "in", "out", false); got != nil { + t.Fatalf("wasmPostLinkArgs(disabled) = %v, want nil", got) + } +} + +func TestNeedsWasmPostLink(t *testing.T) { + target := &crosscompile.Export{WasmPostLink: crosscompile.WasmPostLink{Asyncify: true}} + tests := []struct { + name string + conf *Config + want bool + }{ + {name: "executable", conf: &Config{BuildMode: BuildModeExe}, want: true}, + {name: "archive", conf: &Config{BuildMode: BuildModeCArchive}}, + {name: "shared", conf: &Config{BuildMode: BuildModeCShared}}, + {name: "nil config"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := needsWasmPostLink(test.conf, target); got != test.want { + t.Fatalf("needsWasmPostLink() = %v, want %v", got, test.want) + } + }) + } + if needsWasmPostLink(&Config{BuildMode: BuildModeExe}, nil) { + t.Fatal("needsWasmPostLink() enabled for a nil target") + } +} + +func TestPrepareWasmLinkOutput(t *testing.T) { + dir := t.TempDir() + output := filepath.Join(dir, "app.wasm") + target := &crosscompile.Export{WasmPostLink: crosscompile.WasmPostLink{Asyncify: true}} + + input, err := prepareWasmLinkOutput(&Config{BuildMode: BuildModeExe}, target, output) + if err != nil { + t.Fatal(err) + } + if input == output || filepath.Dir(input) != dir { + t.Fatalf("temporary link output = %q, want a distinct file in %q", input, dir) + } + if _, err := os.Stat(input); err != nil { + t.Fatalf("temporary link output was not created: %v", err) + } + cleanupWasmLinkOutput(input, output) + if _, err := os.Stat(input); !os.IsNotExist(err) { + t.Fatalf("temporary link output remains after cleanup: %v", err) + } + + if err := os.WriteFile(output, []byte("final"), 0o644); err != nil { + t.Fatal(err) + } + input, err = prepareWasmLinkOutput(&Config{BuildMode: BuildModeCArchive}, target, output) + if err != nil || input != output { + t.Fatalf("disabled post-link output = %q, %v; want %q, nil", input, err, output) + } + cleanupWasmLinkOutput(input, output) + if data, err := os.ReadFile(output); err != nil || string(data) != "final" { + t.Fatalf("cleanup removed final output: %q, %v", data, err) + } + if err := publishWasmLinkOutput(nil, output, output, false); err != nil { + t.Fatalf("disabled publish failed: %v", err) + } + + missingOutput := filepath.Join(dir, "missing", "app.wasm") + if _, err := prepareWasmLinkOutput(&Config{BuildMode: BuildModeExe}, target, missingOutput); err == nil { + t.Fatal("prepareWasmLinkOutput succeeded with a missing output directory") + } +} + +func TestPostLinkWasmPublishesOutput(t *testing.T) { + dir := t.TempDir() + input := filepath.Join(dir, "linked.wasm") + output := filepath.Join(dir, "app.wasm") + argsFile := filepath.Join(dir, "args") + if err := os.WriteFile(input, []byte("core module"), 0o644); err != nil { + t.Fatal(err) + } + + script := `#!/bin/sh +printf '%s\n' "$@" > "$ARGS_FILE" +cp "$3" "$5" +` + tool := writeWasmOptTestTool(t, dir, script) + t.Setenv("WASMOPT", "") + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("ARGS_FILE", argsFile) + + ctx := wasmPostLinkTestContext() + stderr, err := os.CreateTemp(dir, "stderr") + if err != nil { + t.Fatal(err) + } + oldStderr := os.Stderr + os.Stderr = stderr + t.Cleanup(func() { os.Stderr = oldStderr }) + + if err := publishWasmLinkOutput(ctx, input, output, true); err != nil { + t.Fatal(err) + } + if err := stderr.Close(); err != nil { + t.Fatal(err) + } + if got, err := os.ReadFile(stderr.Name()); err != nil || + !strings.Contains(string(got), tool) || + !strings.Contains(string(got), "--asyncify") { + t.Fatalf("verbose command = %q, %v", got, err) + } + if data, err := os.ReadFile(output); err != nil || string(data) != "core module" { + t.Fatalf("published output = %q, %v", data, err) + } + args, err := os.ReadFile(argsFile) + if err != nil { + t.Fatal(err) + } + if got := string(args); !strings.Contains(got, "--asyncify\n--translate-to-exnref\n") || + !strings.Contains(got, input+"\n-o\n") { + t.Fatalf("wasm-opt args = %q", got) + } +} + +func TestPostLinkWasmReportsToolFailure(t *testing.T) { + dir := t.TempDir() + input := filepath.Join(dir, "linked.wasm") + output := filepath.Join(dir, "app.wasm") + if err := os.WriteFile(input, []byte("new"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(output, []byte("old"), 0o644); err != nil { + t.Fatal(err) + } + tool := writeWasmOptTestTool(t, dir, "#!/bin/sh\nexit 7\n") + t.Setenv("WASMOPT", tool) + + ctx := wasmPostLinkTestContext() + err := postLinkWasm(ctx, input, output, false) + if err == nil || !strings.Contains(err.Error(), "wasm-opt Asyncify failed") { + t.Fatalf("postLinkWasm() error = %v", err) + } + if data, err := os.ReadFile(output); err != nil || string(data) != "old" { + t.Fatalf("failed post-link changed final output: %q, %v", data, err) + } +} + +func TestPostLinkWasmReportsPublishFailure(t *testing.T) { + dir := t.TempDir() + input := filepath.Join(dir, "linked.wasm") + output := filepath.Join(dir, "existing-directory") + if err := os.WriteFile(input, []byte("core module"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(output, 0o755); err != nil { + t.Fatal(err) + } + script := "#!/bin/sh\ncp \"$3\" \"$5\"\n" + tool := writeWasmOptTestTool(t, dir, script) + t.Setenv("WASMOPT", tool) + + ctx := wasmPostLinkTestContext() + err := postLinkWasm(ctx, input, output, false) + if err == nil { + t.Fatal("postLinkWasm succeeded when the final output was a directory") + } + if strings.Contains(err.Error(), "wasm-opt Asyncify failed") { + t.Fatalf("postLinkWasm failed before publishing output: %v", err) + } +} + +func TestPostLinkWasmReportsMissingTool(t *testing.T) { + t.Setenv("WASMOPT", filepath.Join(t.TempDir(), "missing-wasm-opt")) + ctx := wasmPostLinkTestContext() + err := postLinkWasm(ctx, "input", filepath.Join(t.TempDir(), "output"), false) + if err == nil || !strings.Contains(err.Error(), "install Binaryen or set WASMOPT") { + t.Fatalf("postLinkWasm() error = %v", err) + } +} + +func TestPostLinkWasmReportsInvalidOutputDirectory(t *testing.T) { + dir := t.TempDir() + tool := writeWasmOptTestTool(t, dir, "") + t.Setenv("WASMOPT", tool) + ctx := wasmPostLinkTestContext() + err := postLinkWasm(ctx, "input", filepath.Join(dir, "missing", "output"), false) + if err == nil { + t.Fatal("postLinkWasm succeeded with a missing output directory") + } +} diff --git a/internal/crosscompile/crosscompile.go b/internal/crosscompile/crosscompile.go index e41c3e811f..e73e383f0d 100644 --- a/internal/crosscompile/crosscompile.go +++ b/internal/crosscompile/crosscompile.go @@ -42,11 +42,24 @@ type Export struct { FormatDetail string // For uf2, it's uf2FamilyID Emulator string // Emulator command template (e.g., "qemu-system-arm -M {} -kernel {}") DebugInfo DebugInfoPolicy + WasmPostLink WasmPostLink + WasmRuntime WasmRuntime // Flashing/Debugging configuration Device flash.Device // Device configuration for flashing/debugging } +// WasmRuntime describes entry behavior implemented by the selected runtime. +type WasmRuntime struct { + RunMainTask bool +} + +// WasmPostLink describes transformations required after the core module is +// linked. Build orchestration owns tool discovery and atomic output handling. +type WasmPostLink struct { + Asyncify bool +} + // DebugInfoPolicy describes how a selected linker handles debug information. // Build orchestration consumes this typed capability instead of inferring it // from a target name or linker executable. @@ -218,6 +231,10 @@ func compileWithConfig( } func use(goos, goarch string, wasiThreads, forceEspClang bool, level optlevel.Level, ltoMode lto.Mode, goGlobalDCE bool) (export Export, err error) { + return useWithJSWasm32(goos, goarch, wasiThreads, forceEspClang, level, ltoMode, goGlobalDCE, false) +} + +func useWithJSWasm32(goos, goarch string, wasiThreads, forceEspClang bool, level optlevel.Level, ltoMode lto.Mode, goGlobalDCE, jsWasm32 bool) (export Export, err error) { targetTriple := llvm.GetTargetTriple(goos, goarch) llgoRoot := env.LLGoROOT() @@ -365,6 +382,9 @@ func use(goos, goarch string, wasiThreads, forceEspClang bool, level optlevel.Le "-matomics", "-mbulk-memory", } + if wasiThreads { + export.CCFLAGS = append(export.CCFLAGS, "-pthread") + } export.CFLAGS = []string{ "-I" + includeDir, "-Qunused-arguments", @@ -372,12 +392,20 @@ func use(goos, goarch string, wasiThreads, forceEspClang bool, level optlevel.Le } // Add WebAssembly linker flags export.LDFLAGS = append(export.LDFLAGS, export.CCFLAGS...) + export.LDFLAGS = append(export.LDFLAGS, "-fwasm-exceptions") + if ltoMode.Enabled() { + export.LDFLAGS = append(export.LDFLAGS, "-Wl,--mllvm=-wasm-enable-sjlj") + } + export.CCFLAGS = append( + export.CCFLAGS, + "-fwasm-exceptions", + "-mllvm", "-wasm-enable-sjlj", + ) export.LDFLAGS = append(export.LDFLAGS, []string{ "-Wno-override-module", "-Wl,--error-limit=0", "-L" + libDir, "-Wl,--allow-undefined", - "-Wl,--import-memory,", // unknown import: `env::memory` has not been defined "-Wl,--export-memory", "-Wl,--initial-memory=67108864", // 64MB "-mbulk-memory", @@ -394,25 +422,29 @@ func use(goos, goarch string, wasiThreads, forceEspClang bool, level optlevel.Le "-lwasi-emulated-getpid", "-lwasi-emulated-process-clocks", "-lwasi-emulated-signal", - "-fwasm-exceptions", - "-mllvm", "-wasm-enable-sjlj", }...) + export.LLVMTarget = "wasm32-unknown-wasip1" // Add thread support if enabled if wasiThreads { - export.CCFLAGS = append( - export.CCFLAGS, - "-pthread", - ) - export.LDFLAGS = append(export.LDFLAGS, export.CCFLAGS...) + export.BuildTags = append(export.BuildTags, "llgo.wasi_threads") export.LDFLAGS = append( export.LDFLAGS, + "-Wl,--import-memory", "-lwasi-emulated-pthread", "-lpthread", ) + } else { + export.WasmPostLink.Asyncify = true } case "js": - targetTriple := "wasm32-unknown-emscripten" + // The Go wasm type model uses 64-bit words. Use Memory64 so LLVM + // pointers have the same width; named wasm targets retain wasm32. + targetTriple := "wasm64-unknown-emscripten" + if jsWasm32 { + targetTriple = "wasm32-unknown-emscripten" + } + export.LLVMTarget = targetTriple // Emscripten configuration using system installation // Specify emcc as the compiler export.CC = "emcc" @@ -440,7 +472,7 @@ func use(goos, goarch string, wasiThreads, forceEspClang bool, level optlevel.Le // "-Wl,--export=malloc", "-Wl,--export=free", } export.LDFLAGS = append(export.LDFLAGS, []string{ - "-sENVIRONMENT=web,worker", + "-sENVIRONMENT=web,worker,node", "-DPLATFORM_WEB", "-sEXPORT_KEEPALIVE=1", "-sEXPORT_ES6=1", @@ -452,6 +484,9 @@ func use(goos, goarch string, wasiThreads, forceEspClang bool, level optlevel.Le "-sASYNCIFY=1", "-sSTACK_SIZE=5242880", // 50MB }...) + if !jsWasm32 { + export.LDFLAGS = append(export.LDFLAGS, "-sMEMORY64=1") + } default: err = errors.New("unsupported GOOS for WebAssembly: " + goos) @@ -716,5 +751,19 @@ func Use(goos, goarch, targetName string, wasiThreads, forceEspClang bool, level if targetName != "" && !strings.HasPrefix(targetName, "wasm") && !strings.HasPrefix(targetName, "wasi") { return UseTarget(targetName, level, ltoMode) } + if targetName == "wasm" { + config, err := targets.NewDefaultResolver().Resolve(targetName) + if err != nil { + return export, err + } + export, err = useWithJSWasm32(config.GOOS, config.GOARCH, wasiThreads, forceEspClang, level, ltoMode, goGlobalDCE, true) + if err != nil { + return export, err + } + export.BuildTags = config.BuildTags + export.GOOS = config.GOOS + export.GOARCH = config.GOARCH + return export, nil + } return use(goos, goarch, wasiThreads, forceEspClang, level, ltoMode, goGlobalDCE) } diff --git a/internal/crosscompile/crosscompile_test.go b/internal/crosscompile/crosscompile_test.go index ea89a9596a..f811bf3e9b 100644 --- a/internal/crosscompile/crosscompile_test.go +++ b/internal/crosscompile/crosscompile_test.go @@ -5,6 +5,7 @@ package crosscompile import ( "os" + "path/filepath" "runtime" "slices" "strings" @@ -124,6 +125,16 @@ func TestUseCrossCompileSDK(t *testing.T) { if !hasResourceDir { t.Error("Missing -resource-dir flag in CCFLAGS") } + if !slices.Contains(export.CCFLAGS, "-fwasm-exceptions") || + !hasMllvmOption(export.CCFLAGS, "-wasm-enable-sjlj") { + t.Errorf("CCFLAGS do not enable WebAssembly SjLj lowering: %v", export.CCFLAGS) + } + if !export.WasmPostLink.Asyncify { + t.Error("WASI target does not request Asyncify post-link processing") + } + if slices.Contains(export.LDFLAGS, "-Wl,--import-memory") { + t.Errorf("single-worker WASI imports host memory: %v", export.LDFLAGS) + } } else if tc.name == "Same Platform" { // For same platform, we expect sysroot only on macOS if runtime.GOOS == "darwin" && !hasSysroot { @@ -172,6 +183,123 @@ func TestUseCrossCompileSDK(t *testing.T) { } } +func TestUseWASIThreadsImportsMemory(t *testing.T) { + if testing.Short() { + t.Skip("requires WASI SDK") + } + export, err := use("wasip1", "wasm", true, false, optlevel.O2, lto.Off, false) + if err != nil { + t.Fatal(err) + } + if !slices.Contains(export.CCFLAGS, "-pthread") { + t.Fatalf("CCFLAGS do not enable WASI threads: %v", export.CCFLAGS) + } + if !slices.Contains(export.BuildTags, "llgo.wasi_threads") { + t.Fatalf("BuildTags do not select the WASI pthread backend: %v", export.BuildTags) + } + if !slices.Contains(export.LDFLAGS, "-Wl,--import-memory") { + t.Fatalf("LDFLAGS do not import shared host memory: %v", export.LDFLAGS) + } + if export.WasmPostLink.Asyncify { + t.Fatal("WASI pthread mode requests single-worker Asyncify processing") + } +} + +func TestUseWASILTOEnablesSjLjAtLink(t *testing.T) { + if testing.Short() { + t.Skip("requires WASI SDK") + } + export, err := use("wasip1", "wasm", false, false, optlevel.O2, lto.Thin, false) + if err != nil { + t.Fatal(err) + } + if !slices.Contains(export.LDFLAGS, "-Wl,--mllvm=-wasm-enable-sjlj") { + t.Fatalf("LDFLAGS do not enable Wasm SjLj for LTO: %v", export.LDFLAGS) + } +} + +func TestUseJSSupportsNode(t *testing.T) { + export, err := use("js", "wasm", false, false, optlevel.Oz, lto.Off, false) + if err != nil { + t.Fatal(err) + } + if export.LLVMTarget != "wasm64-unknown-emscripten" { + t.Fatalf("LLVMTarget = %q, want wasm64-unknown-emscripten", export.LLVMTarget) + } + if !slices.Contains(export.LDFLAGS, "-sENVIRONMENT=web,worker,node") { + t.Fatalf("LDFLAGS do not enable Node: %v", export.LDFLAGS) + } + if !slices.Contains(export.LDFLAGS, "-sMEMORY64=1") { + t.Fatalf("LDFLAGS do not enable Memory64: %v", export.LDFLAGS) + } +} + +func TestUseWasmTargetSelectsGoPlatform(t *testing.T) { + export, err := Use(runtime.GOOS, runtime.GOARCH, "wasm", false, false, optlevel.Oz, lto.Off, false) + if err != nil { + t.Fatal(err) + } + if export.GOOS != "js" || export.GOARCH != "wasm" { + t.Fatalf("GOOS/GOARCH = %s/%s, want js/wasm", export.GOOS, export.GOARCH) + } + if export.CC != "emcc" { + t.Fatalf("CC = %q, want emcc", export.CC) + } + if export.LLVMTarget != "wasm32-unknown-emscripten" { + t.Fatalf("LLVMTarget = %q, want wasm32-unknown-emscripten", export.LLVMTarget) + } + if !slices.Contains(export.BuildTags, "tinygo.wasm") { + t.Fatalf("BuildTags do not identify the wasm32 target: %v", export.BuildTags) + } + if slices.Contains(export.LDFLAGS, "-sMEMORY64=1") { + t.Fatalf("wasm32 LDFLAGS enable Memory64: %v", export.LDFLAGS) + } +} + +func TestUseWasmTargetErrors(t *testing.T) { + newLLGoRoot := func(t *testing.T, wasmConfig string) { + t.Helper() + root := t.TempDir() + runtimeDir := filepath.Join(root, "runtime") + if err := os.MkdirAll(runtimeDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(runtimeDir, "go.mod"), []byte("module github.com/goplus/llgo/runtime\n"), 0o644); err != nil { + t.Fatal(err) + } + if wasmConfig != "" { + targetsDir := filepath.Join(root, "targets") + if err := os.MkdirAll(targetsDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(targetsDir, "wasm.json"), []byte(wasmConfig), 0o644); err != nil { + t.Fatal(err) + } + } + t.Setenv("LLGO_ROOT", root) + } + + t.Run("resolve", func(t *testing.T) { + newLLGoRoot(t, "") + _, err := Use(runtime.GOOS, runtime.GOARCH, "wasm", false, false, optlevel.Oz, lto.Off, false) + if err == nil || !strings.Contains(err.Error(), "failed to resolve target wasm") { + t.Fatalf("Use error = %v, want target resolution error", err) + } + }) + + t.Run("toolchain setup", func(t *testing.T) { + newLLGoRoot(t, `{"goos":"js","goarch":"wasm"}`) + oldCacheRoot := cacheRoot + cacheRoot = func() string { return "\x00" } + defer func() { cacheRoot = oldCacheRoot }() + + _, err := Use(runtime.GOOS, runtime.GOARCH, "wasm", false, true, optlevel.Oz, lto.Off, false) + if err == nil { + t.Fatal("Use succeeded with an invalid toolchain cache path") + } + }) +} + func TestUseTarget(t *testing.T) { // Test cases for target-based configuration testCases := []struct { diff --git a/internal/gcrootplan/plan.go b/internal/gcrootplan/plan.go new file mode 100644 index 0000000000..226cd77864 --- /dev/null +++ b/internal/gcrootplan/plan.go @@ -0,0 +1,175 @@ +/* + * 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 gcrootplan computes the Go SSA values that must remain visible to a +// tracing collector while a function is stopped at a safepoint. +package gcrootplan + +import "golang.org/x/tools/go/ssa" + +// Plan returns values accepted by needsRoot that are live immediately before +// an instruction accepted by isSafepoint. +func Plan(fn *ssa.Function, needsRoot func(ssa.Value) bool, isSafepoint func(ssa.Instruction) bool) map[ssa.Value]struct{} { + if fn == nil || len(fn.Blocks) == 0 { + return nil + } + + blocks := make([]blockInfo, len(fn.Blocks)) + for _, block := range fn.Blocks { + info := &blocks[block.Index] + info.def = make(valueSet) + info.use = make(valueSet) + info.phiDef = make(valueSet) + info.edgeUse = make(map[int]valueSet) + + for _, instr := range block.Instrs { + if phi, ok := instr.(*ssa.Phi); ok { + info.def.add(phi) + info.phiDef.add(phi) + for i, pred := range block.Preds { + edge := info.edgeUse[pred.Index] + if edge == nil { + edge = make(valueSet) + info.edgeUse[pred.Index] = edge + } + addOperand(edge, phi.Edges[i]) + } + continue + } + for _, operand := range instr.Operands(nil) { + if operand != nil && *operand != nil { + value := *operand + if _, defined := info.def[value]; !defined { + addOperand(info.use, value) + } + } + } + if value, ok := instr.(ssa.Value); ok { + info.def.add(value) + } + } + } + + liveIn := make([]valueSet, len(blocks)) + liveOut := make([]valueSet, len(blocks)) + changed := true + for changed { + changed = false + for i := len(fn.Blocks) - 1; i >= 0; i-- { + block := fn.Blocks[i] + out := make(valueSet) + for _, succ := range block.Succs { + for value := range liveIn[succ.Index] { + if _, isPhi := blocks[succ.Index].phiDef[value]; !isPhi { + out.add(value) + } + } + for value := range blocks[succ.Index].edgeUse[block.Index] { + out.add(value) + } + } + in := out.clone() + in.removeAll(blocks[block.Index].def) + in.addAll(blocks[block.Index].use) + if !out.equal(liveOut[block.Index]) || !in.equal(liveIn[block.Index]) { + liveOut[block.Index] = out + liveIn[block.Index] = in + changed = true + } + } + } + + roots := make(map[ssa.Value]struct{}) + for _, block := range fn.Blocks { + live := liveOut[block.Index].clone() + for i := len(block.Instrs) - 1; i >= 0; i-- { + instr := block.Instrs[i] + if _, ok := instr.(*ssa.Phi); ok { + continue + } + if value, ok := instr.(ssa.Value); ok { + delete(live, value) + } + for _, operand := range instr.Operands(nil) { + if operand != nil && *operand != nil { + addOperand(live, *operand) + } + } + if isSafepoint(instr) { + for value := range live { + if needsRoot(value) { + roots[value] = struct{}{} + } + } + } + } + } + return roots +} + +type blockInfo struct { + def valueSet + use valueSet + phiDef valueSet + edgeUse map[int]valueSet +} + +type valueSet map[ssa.Value]struct{} + +func (s valueSet) add(value ssa.Value) { + if value != nil { + s[value] = struct{}{} + } +} + +func (s valueSet) addAll(other valueSet) { + for value := range other { + s.add(value) + } +} + +func (s valueSet) removeAll(other valueSet) { + for value := range other { + delete(s, value) + } +} + +func (s valueSet) clone() valueSet { + clone := make(valueSet, len(s)) + clone.addAll(s) + return clone +} + +func (s valueSet) equal(other valueSet) bool { + if len(s) != len(other) { + return false + } + for value := range s { + if _, ok := other[value]; !ok { + return false + } + } + return true +} + +func addOperand(set valueSet, value ssa.Value) { + switch value.(type) { + case nil, *ssa.Builtin, *ssa.Const, *ssa.Function, *ssa.Global: + return + default: + set.add(value) + } +} diff --git a/internal/gcrootplan/plan_test.go b/internal/gcrootplan/plan_test.go new file mode 100644 index 0000000000..8d6ef79e3c --- /dev/null +++ b/internal/gcrootplan/plan_test.go @@ -0,0 +1,136 @@ +package gcrootplan + +import ( + "go/ast" + "go/importer" + "go/parser" + "go/token" + "go/types" + "testing" + + "golang.org/x/tools/go/ssa" + "golang.org/x/tools/go/ssa/ssautil" +) + +func TestPlanStraightLine(t *testing.T) { + fn := buildFunction(t, `package p +func keep(*int) +func f(live, dead *int) *int { + _ = dead + keep(live) + return live +}`) + roots := Plan(fn, pointerValue, isCall) + assertRootNames(t, roots, "live") +} + +func TestPlanPhiEdges(t *testing.T) { + fn := buildFunction(t, `package p +func keep(*int) +func f(cond bool, left, right *int) *int { + var value *int + if cond { + value = left + } else { + value = right + } + keep(value) + return value + }`) + roots := Plan(fn, pointerValue, isCall) + var foundPhi bool + for value := range roots { + if _, ok := value.(*ssa.Phi); ok { + foundPhi = true + } + } + if !foundPhi { + t.Fatal("merged pointer is not rooted at the call") + } +} + +func TestPlanPhiEdgeUse(t *testing.T) { + fn := buildFunction(t, `package p +func keep(*int) +func f(cond bool, left, right *int) *int { + var value *int + if cond { + keep(nil) + value = left + } else { + keep(nil) + value = right + } + return value +}`) + roots := Plan(fn, pointerValue, isCall) + assertRootNames(t, roots, "left", "right") +} + +func TestPlanLoop(t *testing.T) { + fn := buildFunction(t, `package p +func keep(*int) +func f(head *int, n int) *int { + for n > 0 { + keep(head) + n-- + } + return head +}`) + roots := Plan(fn, pointerValue, isCall) + assertRootNames(t, roots, "head") +} + +func TestPlanNoSafepoint(t *testing.T) { + fn := buildFunction(t, `package p +func f(value *int) *int { return value }`) + if roots := Plan(fn, pointerValue, isCall); len(roots) != 0 { + t.Fatalf("Plan returned %d roots without a safepoint", len(roots)) + } +} + +func buildFunction(t *testing.T, src string) *ssa.Function { + t.Helper() + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, "p.go", src, 0) + if err != nil { + t.Fatal(err) + } + ssaPkg, _, err := ssautil.BuildPackage( + &types.Config{Importer: importer.Default()}, + fset, + types.NewPackage("p", "p"), + []*ast.File{file}, + ssa.InstantiateGenerics, + ) + if err != nil { + t.Fatal(err) + } + return ssaPkg.Func("f") +} + +func pointerValue(value ssa.Value) bool { + _, ok := value.Type().Underlying().(*types.Pointer) + return ok +} + +func isCall(instr ssa.Instruction) bool { + _, ok := instr.(*ssa.Call) + return ok +} + +func assertRootNames(t *testing.T, roots map[ssa.Value]struct{}, names ...string) { + t.Helper() + for _, name := range names { + var found bool + for value := range roots { + if value.Name() == name { + found = true + break + } + } + if !found { + t.Errorf("root %q not found", name) + } + } +} diff --git a/internal/safepointplan/plan.go b/internal/safepointplan/plan.go new file mode 100644 index 0000000000..19891b19da --- /dev/null +++ b/internal/safepointplan/plan.go @@ -0,0 +1,63 @@ +/* + * 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 safepointplan identifies control-flow edges that must poll for +// cooperative scheduling. +package safepointplan + +import "golang.org/x/tools/go/ssa" + +// Backedges returns block terminators that close a DFS cycle. Polling before +// these instructions intersects every cycle, including irreducible control +// flow, without adding a poll to every block in a loop. +func Backedges(fn *ssa.Function) map[ssa.Instruction]struct{} { + if fn == nil || len(fn.Blocks) == 0 { + return nil + } + + const ( + unvisited uint8 = iota + visiting + visited + ) + state := make([]uint8, len(fn.Blocks)) + polls := make(map[ssa.Instruction]struct{}) + var visit func(*ssa.BasicBlock) + visit = func(block *ssa.BasicBlock) { + state[block.Index] = visiting + for _, succ := range block.Succs { + switch state[succ.Index] { + case unvisited: + visit(succ) + case visiting: + if n := len(block.Instrs); n != 0 { + polls[block.Instrs[n-1]] = struct{}{} + } + } + } + state[block.Index] = visited + } + + for _, block := range fn.Blocks { + if state[block.Index] == unvisited { + visit(block) + } + } + if len(polls) == 0 { + return nil + } + return polls +} diff --git a/internal/safepointplan/plan_test.go b/internal/safepointplan/plan_test.go new file mode 100644 index 0000000000..91087a9754 --- /dev/null +++ b/internal/safepointplan/plan_test.go @@ -0,0 +1,93 @@ +package safepointplan + +import ( + "go/ast" + "go/importer" + "go/parser" + "go/token" + "go/types" + "testing" + + "golang.org/x/tools/go/ssa" + "golang.org/x/tools/go/ssa/ssautil" +) + +func TestBackedges(t *testing.T) { + tests := []struct { + name string + src string + want int + }{ + { + name: "straight line", + src: `package p; func f(n int) int { return n + 1 }`, + }, + { + name: "loop", + src: `package p; func f(n int) { for n > 0 { n-- } }`, + want: 1, + }, + { + name: "nested loops", + src: `package p; func f(n int) { for i := 0; i < n; i++ { for j := 0; j < n; j++ {} } }`, + want: 2, + }, + { + name: "irreducible loop", + src: `package p +func f(n int) { + if n > 0 { goto left } +right: + n-- + if n > 0 { goto left } + return +left: + n-- + if n > 0 { goto right } +}`, + want: 1, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + fn := buildFunction(t, test.src) + polls := Backedges(fn) + if len(polls) != test.want { + t.Fatalf("Backedges returned %d polls, want %d", len(polls), test.want) + } + for instr := range polls { + switch instr.(type) { + case *ssa.If, *ssa.Jump: + default: + t.Errorf("poll instruction is %T, want a block terminator", instr) + } + } + }) + } +} + +func TestBackedgesNil(t *testing.T) { + if got := Backedges(nil); got != nil { + t.Fatalf("Backedges(nil) = %v, want nil", got) + } +} + +func buildFunction(t *testing.T, src string) *ssa.Function { + t.Helper() + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, "p.go", src, 0) + if err != nil { + t.Fatal(err) + } + pkg, _, err := ssautil.BuildPackage( + &types.Config{Importer: importer.Default()}, + fset, + types.NewPackage("p", "p"), + []*ast.File{file}, + ssa.InstantiateGenerics, + ) + if err != nil { + t.Fatal(err) + } + return pkg.Func("f") +} diff --git a/internal/wasmworkers/config.go b/internal/wasmworkers/config.go new file mode 100644 index 0000000000..a26dd6f5f8 --- /dev/null +++ b/internal/wasmworkers/config.go @@ -0,0 +1,62 @@ +/* + * 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 wasmworkers validates the bounded WebAssembly worker-pool setting. +package wasmworkers + +import ( + "fmt" + "path/filepath" + "strconv" +) + +const ( + DefaultCount = 1 + MaxCount = 16 +) + +type Config struct { + Count int +} + +func Parse(value string) (Config, error) { + if value == "" { + return Config{Count: DefaultCount}, nil + } + count, err := strconv.Atoi(value) + if err != nil || count < 1 || count > MaxCount { + return Config{}, fmt.Errorf("LLGO_WASM_WORKERS must be an integer from 1 through %d", MaxCount) + } + return Config{Count: count}, nil +} + +func (c Config) Enabled() bool { + return c.Count > DefaultCount +} + +func (c Config) ValidateTarget(goos, goarch string) error { + if !c.Enabled() { + return nil + } + if goos != "js" || goarch != "wasm" { + return fmt.Errorf("LLGO_WASM_WORKERS requires GOOS=js GOARCH=wasm") + } + return nil +} + +func PreJSPath(llgoRoot string) string { + return filepath.Join(llgoRoot, "internal", "wasmworkers", "worker_pre.js") +} diff --git a/internal/wasmworkers/config_test.go b/internal/wasmworkers/config_test.go new file mode 100644 index 0000000000..65e31e978d --- /dev/null +++ b/internal/wasmworkers/config_test.go @@ -0,0 +1,51 @@ +package wasmworkers + +import ( + "path/filepath" + "testing" +) + +func TestParse(t *testing.T) { + for _, test := range []struct { + value string + count int + err bool + }{ + {count: 1}, + {value: "1", count: 1}, + {value: "2", count: 2}, + {value: "16", count: 16}, + {value: "0", err: true}, + {value: "17", err: true}, + {value: "two", err: true}, + } { + got, err := Parse(test.value) + if (err != nil) != test.err { + t.Fatalf("Parse(%q) error = %v, want error %v", test.value, err, test.err) + } + if !test.err && got.Count != test.count { + t.Fatalf("Parse(%q).Count = %d, want %d", test.value, got.Count, test.count) + } + } +} + +func TestValidateTarget(t *testing.T) { + if err := (Config{Count: 2}).ValidateTarget("js", "wasm"); err != nil { + t.Fatal(err) + } + for _, target := range [][2]string{{"wasip1", "wasm"}, {"linux", "amd64"}} { + if err := (Config{Count: 2}).ValidateTarget(target[0], target[1]); err == nil { + t.Fatalf("ValidateTarget(%q, %q) succeeded", target[0], target[1]) + } + } + if err := (Config{Count: 1}).ValidateTarget("linux", "amd64"); err != nil { + t.Fatalf("disabled config rejected native target: %v", err) + } +} + +func TestPreJSPath(t *testing.T) { + want := filepath.Join("llgo", "internal", "wasmworkers", "worker_pre.js") + if got := PreJSPath("llgo"); got != want { + t.Fatalf("PreJSPath() = %q, want %q", got, want) + } +} diff --git a/internal/wasmworkers/worker_pre.js b/internal/wasmworkers/worker_pre.js new file mode 100644 index 0000000000..d9f6f9417c --- /dev/null +++ b/internal/wasmworkers/worker_pre.js @@ -0,0 +1,87 @@ +(() => { + const host = globalThis; + host.global ||= host; + host.require ||= typeof require !== "undefined" ? require : undefined; + + if (host.require) { + host.fs ||= host.require("node:fs"); + host.path ||= host.require("node:path"); + } + + const enosys = () => { + const err = new Error("not implemented"); + err.code = "ENOSYS"; + return err; + }; + + if (!host.fs) { + let outputBuf = ""; + const decoder = new TextDecoder("utf-8"); + host.fs = { + constants: { + O_WRONLY: -1, + O_RDWR: -1, + O_CREAT: -1, + O_TRUNC: -1, + O_APPEND: -1, + O_EXCL: -1, + }, + writeSync(fd, buf) { + outputBuf += decoder.decode(buf); + const newline = outputBuf.lastIndexOf("\n"); + if (newline !== -1) { + console.log(outputBuf.slice(0, newline)); + outputBuf = outputBuf.slice(newline + 1); + } + return buf.length; + }, + write(fd, buf, offset, length, position, callback) { + if (offset !== 0 || length !== buf.length || position !== null) { + callback(enosys()); + return; + } + callback(null, this.writeSync(fd, buf)); + }, + chmod(path, mode, callback) { callback(enosys()); }, + chown(path, uid, gid, callback) { callback(enosys()); }, + close(fd, callback) { callback(enosys()); }, + fchmod(fd, mode, callback) { callback(enosys()); }, + fchown(fd, uid, gid, callback) { callback(enosys()); }, + fstat(fd, callback) { callback(enosys()); }, + fsync(fd, callback) { callback(null); }, + ftruncate(fd, length, callback) { callback(enosys()); }, + lchown(path, uid, gid, callback) { callback(enosys()); }, + link(path, link, callback) { callback(enosys()); }, + lstat(path, callback) { callback(enosys()); }, + mkdir(path, perm, callback) { callback(enosys()); }, + open(path, flags, mode, callback) { callback(enosys()); }, + read(fd, buffer, offset, length, position, callback) { callback(enosys()); }, + readdir(path, callback) { callback(enosys()); }, + readlink(path, callback) { callback(enosys()); }, + rename(from, to, callback) { callback(enosys()); }, + rmdir(path, callback) { callback(enosys()); }, + stat(path, callback) { callback(enosys()); }, + symlink(path, link, callback) { callback(enosys()); }, + truncate(path, length, callback) { callback(enosys()); }, + unlink(path, callback) { callback(enosys()); }, + utimes(path, atime, mtime, callback) { callback(enosys()); }, + }; + } + + host.path ||= { + resolve(path) { return path; }, + }; + + host.process ||= { + getuid() { return -1; }, + getgid() { return -1; }, + geteuid() { return -1; }, + getegid() { return -1; }, + getgroups() { throw enosys(); }, + pid: -1, + ppid: -1, + umask() { throw enosys(); }, + cwd() { return "/"; }, + chdir() { throw enosys(); }, + }; +})(); diff --git a/runtime/_patch/internal/sync/hashtriemap.go b/runtime/_patch/internal/sync/hashtriemap.go index c6a2f12918..22ba1e736e 100644 --- a/runtime/_patch/internal/sync/hashtriemap.go +++ b/runtime/_patch/internal/sync/hashtriemap.go @@ -2,7 +2,7 @@ // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. -//go:build go1.26 +//go:build go1.24 //llgo:skipall package sync @@ -108,15 +108,17 @@ func (ht *HashTrieMap[K, V]) Swap(key K, new V) (previous V, loaded bool) { } func (ht *HashTrieMap[K, V]) CompareAndSwap(key K, old, new V) bool { + var swapped bool + ht.compareAndSwap(&swapped, key, old, new) + return swapped +} + +func (ht *HashTrieMap[K, V]) compareAndSwap(swapped *bool, key K, old, new V) { ht.mu.Lock() defer ht.mu.Unlock() - if i := ht.findIndex(key); i < 0 { - return false - } else if !hashTrieValueEqual(ht.m[i].value, old) { - return false - } else { + if i := ht.findIndex(key); i >= 0 && hashTrieValueEqual(ht.m[i].value, old) { ht.m[i].value = new - return true + *swapped = true } } @@ -135,15 +137,17 @@ func (ht *HashTrieMap[K, V]) Delete(key K) { } func (ht *HashTrieMap[K, V]) CompareAndDelete(key K, old V) bool { + var deleted bool + ht.compareAndDelete(&deleted, key, old) + return deleted +} + +func (ht *HashTrieMap[K, V]) compareAndDelete(deleted *bool, key K, old V) { ht.mu.Lock() defer ht.mu.Unlock() - if i := ht.findIndex(key); i < 0 { - return false - } else if !hashTrieValueEqual(ht.m[i].value, old) { - return false - } else { + if i := ht.findIndex(key); i >= 0 && hashTrieValueEqual(ht.m[i].value, old) { ht.deleteIndex(i) - return true + *deleted = true } } @@ -169,14 +173,18 @@ func (ht *HashTrieMap[K, V]) Clear() { } func (ht *HashTrieMap[K, V]) snapshot() []hashTrieEntry[K, V] { + var entries []hashTrieEntry[K, V] + ht.snapshotInto(&entries) + return entries +} + +func (ht *HashTrieMap[K, V]) snapshotInto(entries *[]hashTrieEntry[K, V]) { ht.mu.Lock() defer ht.mu.Unlock() - if len(ht.m) == 0 { - return nil + if len(ht.m) != 0 { + *entries = make([]hashTrieEntry[K, V], len(ht.m)) + copy(*entries, ht.m) } - entries := make([]hashTrieEntry[K, V], len(ht.m)) - copy(entries, ht.m) - return entries } func hashTrieValueEqual[V any](a, b V) bool { diff --git a/runtime/_patch/internal/sync/mutex.go b/runtime/_patch/internal/sync/mutex.go index 0cca2f2a56..4e00a88cd1 100644 --- a/runtime/_patch/internal/sync/mutex.go +++ b/runtime/_patch/internal/sync/mutex.go @@ -2,7 +2,7 @@ // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. -//go:build go1.26 +//go:build go1.24 package sync diff --git a/runtime/_patch/internal/sync/runtime.go b/runtime/_patch/internal/sync/runtime.go index f2e0a85c39..8abbfdd714 100644 --- a/runtime/_patch/internal/sync/runtime.go +++ b/runtime/_patch/internal/sync/runtime.go @@ -2,7 +2,7 @@ // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. -//go:build go1.26 +//go:build go1.24 package sync diff --git a/runtime/internal/clite/c.go b/runtime/internal/clite/c.go index 78a9897339..aa6f7a6fef 100644 --- a/runtime/internal/clite/c.go +++ b/runtime/internal/clite/c.go @@ -51,7 +51,7 @@ type integer interface { } type SizeT = uintptr -type SsizeT = Long +type SsizeT = int type IntptrT = uintptr type UintptrT = uintptr diff --git a/runtime/internal/clite/ctypes_selection_test.go b/runtime/internal/clite/ctypes_selection_test.go new file mode 100644 index 0000000000..1db4dc4c20 --- /dev/null +++ b/runtime/internal/clite/ctypes_selection_test.go @@ -0,0 +1,34 @@ +package c + +import ( + "go/build" + "slices" + "testing" +) + +func TestWasmCTypeFileSelection(t *testing.T) { + for _, test := range []struct { + name string + goos string + tags []string + want string + }{ + {name: "js Memory64", goos: "js", want: "ctypes_wasm64.go"}, + {name: "js wasm32 target", goos: "js", tags: []string{"tinygo.wasm"}, want: "ctypes_wasm.go"}, + {name: "WASI wasm32", goos: "wasip1", want: "ctypes_wasm.go"}, + } { + t.Run(test.name, func(t *testing.T) { + ctx := build.Default + ctx.GOOS = test.goos + ctx.GOARCH = "wasm" + ctx.BuildTags = test.tags + pkg, err := ctx.ImportDir(".", 0) + if err != nil { + t.Fatal(err) + } + if !slices.Contains(pkg.GoFiles, test.want) { + t.Fatalf("GoFiles = %v, want %s", pkg.GoFiles, test.want) + } + }) + } +} diff --git a/runtime/internal/clite/ctypes_wasm.go b/runtime/internal/clite/ctypes_wasm.go index 9b68f43a68..a580ccb6e5 100644 --- a/runtime/internal/clite/ctypes_wasm.go +++ b/runtime/internal/clite/ctypes_wasm.go @@ -1,5 +1,4 @@ -//go:build wasip1 || js -// +build wasip1 js +//go:build wasip1 || (js && tinygo.wasm) /* * Copyright (c) 2024 The XGo Authors (xgo.dev). All rights reserved. @@ -19,7 +18,7 @@ package c -// For WebAssembly targets, Long is 32-bit per the spec +// WASI and configured js/wasm targets use the wasm32 C data model. type ( Long = int32 Ulong = uint32 diff --git a/runtime/internal/clite/ctypes_wasm64.go b/runtime/internal/clite/ctypes_wasm64.go new file mode 100644 index 0000000000..1f6b409b79 --- /dev/null +++ b/runtime/internal/clite/ctypes_wasm64.go @@ -0,0 +1,25 @@ +//go:build js && !tinygo.wasm + +/* + * 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 c + +// Emscripten Memory64 uses the LP64 C data model. +type ( + Long = int64 + Ulong = uint64 +) diff --git a/runtime/internal/clite/emscripten/_wrap/fiber.c b/runtime/internal/clite/emscripten/_wrap/fiber.c new file mode 100644 index 0000000000..43a99f8fb5 --- /dev/null +++ b/runtime/internal/clite/emscripten/_wrap/fiber.c @@ -0,0 +1,5 @@ +#include + +_Static_assert( + sizeof(emscripten_fiber_t) == 8 * sizeof(void *), + "LLGo Fiber storage does not match emscripten_fiber_t"); diff --git a/runtime/internal/clite/emscripten/fiber.go b/runtime/internal/clite/emscripten/fiber.go new file mode 100644 index 0000000000..95e6ae2410 --- /dev/null +++ b/runtime/internal/clite/emscripten/fiber.go @@ -0,0 +1,42 @@ +/* + * 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 emscripten exposes the small host ABI needed by the WebAssembly +// execution-context backend. +package emscripten + +import c "github.com/goplus/llgo/runtime/internal/clite" + +// Fiber is the opaque emscripten_fiber_t storage. The C layout consists of +// eight pointer-sized fields. +type Fiber struct { + _ [8]uintptr +} + +//llgo:type C +type FiberEntry func(c.Pointer) + +// llgo:link FiberInit C.emscripten_fiber_init +func FiberInit(fiber *Fiber, entry FiberEntry, arg, stack c.Pointer, stackSize uintptr, asyncifyStack c.Pointer, asyncifyStackSize uintptr) { +} + +// llgo:link FiberInitCurrent C.emscripten_fiber_init_from_current_context +func FiberInitCurrent(fiber *Fiber, asyncifyStack c.Pointer, asyncifyStackSize uintptr) { +} + +// llgo:link FiberSwap C.emscripten_fiber_swap +func FiberSwap(fiber, next *Fiber) { +} diff --git a/runtime/internal/clite/emscripten/fiber_test.go b/runtime/internal/clite/emscripten/fiber_test.go new file mode 100644 index 0000000000..42c41eb5bc --- /dev/null +++ b/runtime/internal/clite/emscripten/fiber_test.go @@ -0,0 +1,19 @@ +package emscripten + +import ( + "reflect" + "testing" + "unsafe" +) + +func TestFiberStorageUsesEightWords(t *testing.T) { + if got, want := unsafe.Sizeof(Fiber{}), uintptr(8)*unsafe.Sizeof(uintptr(0)); got != want { + t.Fatalf("Fiber size = %d, want %d", got, want) + } +} + +func TestFiberHasNoReflectableHostMethods(t *testing.T) { + if got := reflect.TypeOf(Fiber{}).NumMethod(); got != 0 { + t.Fatalf("Fiber has %d reflectable methods, want 0", got) + } +} diff --git a/runtime/internal/clite/emscripten/fiber_wasm.go b/runtime/internal/clite/emscripten/fiber_wasm.go new file mode 100644 index 0000000000..2a7060a4a1 --- /dev/null +++ b/runtime/internal/clite/emscripten/fiber_wasm.go @@ -0,0 +1,21 @@ +//go:build js && wasm + +/* + * 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 emscripten + +const LLGoFiles = "_wrap/fiber.c" diff --git a/runtime/internal/clite/pthread/pthread_gc.go b/runtime/internal/clite/pthread/pthread_gc.go index 88409bc1c3..fdc067d2d2 100644 --- a/runtime/internal/clite/pthread/pthread_gc.go +++ b/runtime/internal/clite/pthread/pthread_gc.go @@ -1,4 +1,4 @@ -//go:build !nogc && !baremetal +//go:build !nogc && !baremetal && !wasm /* * Copyright (c) 2024 The XGo Authors (xgo.dev). All rights reserved. diff --git a/runtime/internal/clite/pthread/pthread_nogc.go b/runtime/internal/clite/pthread/pthread_nogc.go index d61d39a3be..c72f1d597e 100644 --- a/runtime/internal/clite/pthread/pthread_nogc.go +++ b/runtime/internal/clite/pthread/pthread_nogc.go @@ -1,4 +1,4 @@ -//go:build nogc || baremetal +//go:build nogc || baremetal || wasm /* * Copyright (c) 2024 The XGo Authors (xgo.dev). All rights reserved. diff --git a/runtime/internal/clite/pthread/sync/sync.go b/runtime/internal/clite/pthread/sync/sync.go index 688c88303a..9074c5d5cb 100644 --- a/runtime/internal/clite/pthread/sync/sync.go +++ b/runtime/internal/clite/pthread/sync/sync.go @@ -80,10 +80,10 @@ type MutexAttr struct { } // llgo:link (*MutexAttr).Init C.pthread_mutexattr_init -func (a *MutexAttr) Init(attr *MutexAttr) c.Int { return 0 } +func (a *MutexAttr) Init() c.Int { return 0 } // llgo:link (*MutexAttr).Destroy C.pthread_mutexattr_destroy -func (a *MutexAttr) Destroy() {} +func (a *MutexAttr) Destroy() c.Int { return 0 } // llgo:link (*MutexAttr).SetType C.pthread_mutexattr_settype func (a *MutexAttr) SetType(typ MutexType) c.Int { return 0 } @@ -142,10 +142,10 @@ type RWLockAttr struct { } // llgo:link (*RWLockAttr).Init C.pthread_rwlockattr_init -func (a *RWLockAttr) Init(attr *RWLockAttr) c.Int { return 0 } +func (a *RWLockAttr) Init() c.Int { return 0 } // llgo:link (*RWLockAttr).Destroy C.pthread_rwlockattr_destroy -func (a *RWLockAttr) Destroy() {} +func (a *RWLockAttr) Destroy() c.Int { return 0 } // llgo:link (*RWLockAttr).SetPShared C.pthread_rwlockattr_setpshared func (a *RWLockAttr) SetPShared(pshared c.Int) c.Int { return 0 } @@ -222,10 +222,10 @@ type CondAttr struct { } // llgo:link (*CondAttr).Init C.pthread_condattr_init -func (a *CondAttr) Init(attr *CondAttr) c.Int { return 0 } +func (a *CondAttr) Init() c.Int { return 0 } // llgo:link (*CondAttr).Destroy C.pthread_condattr_destroy -func (a *CondAttr) Destroy() {} +func (a *CondAttr) Destroy() c.Int { return 0 } // // llgo:link (*CondAttr).SetClock C.pthread_condattr_setclock // func (a *CondAttr) SetClock(clock time.ClockidT) c.Int { return 0 } diff --git a/runtime/internal/clite/pthread/sync/sync_test.go b/runtime/internal/clite/pthread/sync/sync_test.go new file mode 100644 index 0000000000..697508244e --- /dev/null +++ b/runtime/internal/clite/pthread/sync/sync_test.go @@ -0,0 +1,25 @@ +package sync + +import "testing" + +func TestAttrMethods(t *testing.T) { + for name, initDestroy := range map[string]func() (int32, int32){ + "mutex": func() (int32, int32) { + var attr MutexAttr + return int32(attr.Init()), int32(attr.Destroy()) + }, + "rwlock": func() (int32, int32) { + var attr RWLockAttr + return int32(attr.Init()), int32(attr.Destroy()) + }, + "cond": func() (int32, int32) { + var attr CondAttr + return int32(attr.Init()), int32(attr.Destroy()) + }, + } { + initResult, destroyResult := initDestroy() + if initResult != 0 || destroyResult != 0 { + t.Errorf("%s attribute lifecycle returned (%d, %d)", name, initResult, destroyResult) + } + } +} diff --git a/runtime/internal/clite/tls/tls_gc.go b/runtime/internal/clite/tls/tls_gc.go index afd10494de..dee28069a9 100644 --- a/runtime/internal/clite/tls/tls_gc.go +++ b/runtime/internal/clite/tls/tls_gc.go @@ -1,4 +1,4 @@ -//go:build llgo && !baremetal && !nogc +//go:build llgo && !baremetal && !wasm && !nogc /* * Copyright (c) 2025 The XGo Authors (xgo.dev). All rights reserved. diff --git a/runtime/internal/clite/tls/tls_nogc.go b/runtime/internal/clite/tls/tls_nogc.go index 00923d4105..4e7283b41f 100644 --- a/runtime/internal/clite/tls/tls_nogc.go +++ b/runtime/internal/clite/tls/tls_nogc.go @@ -1,4 +1,4 @@ -//go:build llgo && (nogc || baremetal) +//go:build llgo && (nogc || baremetal || wasm) /* * Copyright (c) 2025 The XGo Authors (xgo.dev). All rights reserved. diff --git a/runtime/internal/embind/_wrap/emval.cpp b/runtime/internal/embind/_wrap/emval.cpp index 44a7eda18c..da16b4da51 100644 --- a/runtime/internal/embind/_wrap/emval.cpp +++ b/runtime/internal/embind/_wrap/emval.cpp @@ -2,10 +2,15 @@ #include #include #include +#include using namespace emscripten; using namespace emscripten::internal; +#define LLGO_EMVAL_INVOKER_API \ + (__EMSCRIPTEN_major__ > 4 || \ + (__EMSCRIPTEN_major__ == 4 && (__EMSCRIPTEN_minor__ > 0 || __EMSCRIPTEN_tiny__ >= 11))) + template TYPEID take_typeid() { typename WithPolicies<>::template ArgTypeList targetType; @@ -14,13 +19,21 @@ TYPEID take_typeid() { template EM_VAL take_value(T&& value, Policies...) { +#if LLGO_EMVAL_INVOKER_API + return val(std::forward(value)).release_ownership(); +#else typename WithPolicies::template ArgTypeList valueType; WireTypePack argv(std::forward(value)); return _emval_take_value(valueType.getTypes()[0], argv); +#endif } template T as_value(EM_VAL val, Policies...) { +#if LLGO_EMVAL_INVOKER_API + _emval_incref(val); + return emscripten::val::take_ownership(val).as(); +#else typedef BindingType BT; typename WithPolicies::template ArgTypeList targetType; @@ -31,6 +44,7 @@ T as_value(EM_VAL val, Policies...) { &destructors); DestructorsRunner dr(destructors); return fromGenericWireType(result); +#endif } struct GoString { @@ -40,6 +54,27 @@ struct GoString { static TYPEID typeid_val = take_typeid(); +#if LLGO_EMVAL_INVOKER_API +EM_INVOKER take_invoker(int nargs, EM_INVOKER_KIND kind, const TYPEID *types) { + static thread_local std::vector invokers[3]; + std::vector& byArity = invokers[static_cast(kind)]; + if (byArity.size() <= static_cast(nargs)) { + byArity.resize(nargs + 1, nullptr); + } + EM_INVOKER& invoker = byArity[nargs]; + if (invoker == nullptr) { + invoker = _emval_create_invoker(nargs + 1, types, kind); + } + return invoker; +} + +EM_VAL take_val_result(EM_GENERIC_WIRE_TYPE result) { + using WireType = BindingType::WireType; + WireType wire = GenericWireTypeConverter::from(result); + return BindingType::fromWireType(wire).release_ownership(); +} +#endif + extern "C" { // export from llgo @@ -131,17 +166,30 @@ EM_VAL llgo_emval_method_call(EM_VAL object, const char* name, EM_VAL args[], in _emval_incref(args[i]); writeGenericWireTypes(cursor, args[i]); } +#if LLGO_EMVAL_INVOKER_API + EM_INVOKER caller = take_invoker(nargs, EM_INVOKER_KIND::METHOD, arr.data()); +#else EM_METHOD_CALLER caller = _emval_get_method_caller(nargs+1,&arr[0],EM_METHOD_CALLER_KIND::FUNCTION); +#endif EM_GENERIC_WIRE_TYPE ret; try { EM_DESTRUCTORS destructors = nullptr; +#if LLGO_EMVAL_INVOKER_API + ret = _emval_invoke(caller, object, name, &destructors, elements.data()); + DestructorsRunner dr(destructors); +#else ret = _emval_call_method(caller, object, name, &destructors, elements.data()); +#endif } catch(const emscripten::val& jsErr) { printf("error\n"); *error = 1; return EM_VAL(internal::_EMVAL_UNDEFINED); } +#if LLGO_EMVAL_INVOKER_API + return take_val_result(ret); +#else return fromGenericWireType(ret).release_ownership(); +#endif } /* @@ -161,16 +209,32 @@ EM_VAL llgo_emval_call(EM_VAL fn, EM_VAL args[], int nargs, int kind, int *error _emval_incref(args[i]); writeGenericWireTypes(cursor, args[i]); } +#if LLGO_EMVAL_INVOKER_API + EM_INVOKER_KIND invokerKind = kind == 0 + ? EM_INVOKER_KIND::FUNCTION + : EM_INVOKER_KIND::CONSTRUCTOR; + EM_INVOKER caller = take_invoker(nargs, invokerKind, arr.data()); +#else EM_METHOD_CALLER caller = _emval_get_method_caller(nargs+1,&arr[0],EM_METHOD_CALLER_KIND(kind)); +#endif EM_GENERIC_WIRE_TYPE ret; try { EM_DESTRUCTORS destructors = nullptr; +#if LLGO_EMVAL_INVOKER_API + ret = _emval_invoke(caller, fn, nullptr, &destructors, elements.data()); + DestructorsRunner dr(destructors); +#else ret = _emval_call(caller, fn, &destructors, elements.data()); +#endif } catch(const emscripten::val& jsErr) { *error = 1; return EM_VAL(internal::_EMVAL_UNDEFINED); } +#if LLGO_EMVAL_INVOKER_API + return take_val_result(ret); +#else return fromGenericWireType(ret).release_ownership(); +#endif } EM_VAL llgo_emval_memory_view_uint8(size_t length, uint8_t *data) { @@ -192,4 +256,4 @@ bool invoke(val args) { EMSCRIPTEN_BINDINGS(my_module) { function("_llgo_invoke", &invoke); -} \ No newline at end of file +} diff --git a/runtime/internal/gcroot/current_stub.go b/runtime/internal/gcroot/current_stub.go new file mode 100644 index 0000000000..54b0ca040c --- /dev/null +++ b/runtime/internal/gcroot/current_stub.go @@ -0,0 +1,7 @@ +//go:build !llgo || !wasm || !llgo_wasm_gc + +package gcroot + +import "unsafe" + +var currentRootChain unsafe.Pointer diff --git a/runtime/internal/gcroot/current_wasm.go b/runtime/internal/gcroot/current_wasm.go new file mode 100644 index 0000000000..576994966a --- /dev/null +++ b/runtime/internal/gcroot/current_wasm.go @@ -0,0 +1,8 @@ +//go:build llgo && wasm && llgo_wasm_gc + +package gcroot + +import "unsafe" + +//go:linkname currentRootChain llvm_gc_root_chain +var currentRootChain unsafe.Pointer diff --git a/runtime/internal/gcroot/gcroot.go b/runtime/internal/gcroot/gcroot.go new file mode 100644 index 0000000000..c0bc40ecc6 --- /dev/null +++ b/runtime/internal/gcroot/gcroot.go @@ -0,0 +1,158 @@ +/* + * 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 gcroot owns LLGo's per-G compiler root chains. +package gcroot + +import "unsafe" + +// Context stores one suspended execution owner's compiler root chain. +type Context struct { + next *Context + chain unsafe.Pointer +} + +type frameMap struct { + numRoots uint32 + numMeta uint32 +} + +type stackEntry struct { + next *stackEntry + m *frameMap +} + +var ( + contexts *Context + active *Context +) + +// CurrentChain returns the active execution owner's compiler root chain. +func CurrentChain() unsafe.Pointer { + return currentRootChain +} + +// RestoreChain installs a chain captured before a non-local control transfer. +func RestoreChain(chain unsafe.Pointer) { + currentRootChain = chain +} + +// Register adds a suspended context to root enumeration. +func Register(ctx *Context) { + if ctx == nil || registered(ctx) { + panic("gcroot: invalid context registration") + } + ctx.next = contexts + contexts = ctx +} + +// RegisterActive adds ctx and assigns the existing LLVM root chain to it. +func RegisterActive(ctx *Context) { + if active != nil { + panic("gcroot: active context already registered") + } + Register(ctx) + active = ctx +} + +// Switch saves the active chain and installs next's chain. +func Switch(next *Context) { + if next == nil { + panic("gcroot: switch to nil context") + } + SwitchAtBoundary(next) +} + +// SwitchAtBoundary saves the active chain and installs next's chain. +// +// This function is called between a context wrapper's root-frame setup and +// the target-specific stack switch. Keep it free of calls and allocations so +// it cannot acquire a compiler-maintained root frame of its own. +func SwitchAtBoundary(next *Context) { + if active == next { + return + } + if active != nil { + active.chain = currentRootChain + } + active = next + currentRootChain = next.chain +} + +// AdoptCurrent marks next active after a target-specific stack switch has +// already restored currentRootChain. +func AdoptCurrent(next *Context) { + active = next +} + +// Unregister removes a suspended context from root enumeration. +func Unregister(ctx *Context) { + if ctx == nil || ctx == active { + panic("gcroot: invalid context unregistration") + } + link := &contexts + for *link != nil && *link != ctx { + link = &(*link).next + } + if *link == nil { + panic("gcroot: context is not registered") + } + *link = ctx.next + ctx.next = nil + ctx.chain = nil +} + +// Visit calls visitor for every root slot in every registered context. +func Visit(visitor func(root *unsafe.Pointer, metadata unsafe.Pointer)) { + if visitor == nil { + return + } + for ctx := contexts; ctx != nil; ctx = ctx.next { + chain := ctx.chain + if ctx == active { + chain = currentRootChain + } + visitChain(chain, visitor) + } +} + +func registered(want *Context) bool { + for ctx := contexts; ctx != nil; ctx = ctx.next { + if ctx == want { + return true + } + } + return false +} + +func visitChain(chain unsafe.Pointer, visitor func(*unsafe.Pointer, unsafe.Pointer)) { + const pointerSize = unsafe.Sizeof(uintptr(0)) + for entry := (*stackEntry)(chain); entry != nil; entry = entry.next { + if entry.m == nil || entry.m.numMeta > entry.m.numRoots { + panic("gcroot: invalid compiler root frame") + } + roots := unsafe.Add(unsafe.Pointer(entry), unsafe.Sizeof(stackEntry{})) + metadata := unsafe.Add(unsafe.Pointer(entry.m), unsafe.Sizeof(frameMap{})) + for i := uint32(0); i < entry.m.numRoots; i++ { + var meta unsafe.Pointer + if i < entry.m.numMeta { + meta = *(*unsafe.Pointer)(unsafe.Add(metadata, uintptr(i)*pointerSize)) + } + root := (*unsafe.Pointer)(unsafe.Add(roots, uintptr(i)*pointerSize)) + visitor(root, meta) + } + } +} diff --git a/runtime/internal/gcroot/gcroot_test.go b/runtime/internal/gcroot/gcroot_test.go new file mode 100644 index 0000000000..44e30672c8 --- /dev/null +++ b/runtime/internal/gcroot/gcroot_test.go @@ -0,0 +1,130 @@ +package gcroot + +import ( + "testing" + "unsafe" +) + +type testFrameMap struct { + frameMap + meta [1]unsafe.Pointer +} + +type testStackEntry struct { + stackEntry + roots [2]unsafe.Pointer +} + +func TestVisitAndSwitchContexts(t *testing.T) { + resetForTest() + t.Cleanup(resetForTest) + + meta := unsafe.Pointer(uintptr(0x33)) + firstValue := unsafe.Pointer(uintptr(0x11)) + secondValue := unsafe.Pointer(uintptr(0x22)) + m := testFrameMap{ + frameMap: frameMap{numRoots: 2, numMeta: 1}, + meta: [1]unsafe.Pointer{meta}, + } + entry := testStackEntry{ + stackEntry: stackEntry{m: &m.frameMap}, + roots: [2]unsafe.Pointer{firstValue, secondValue}, + } + currentRootChain = unsafe.Pointer(&entry.stackEntry) + + var first, second Context + RegisterActive(&first) + Register(&second) + + var values, metadata []unsafe.Pointer + Visit(func(root *unsafe.Pointer, meta unsafe.Pointer) { + values = append(values, *root) + metadata = append(metadata, meta) + }) + if len(values) != 2 || values[0] != firstValue || values[1] != secondValue { + t.Fatalf("Visit values = %v, want [%p %p]", values, firstValue, secondValue) + } + if metadata[0] != meta || metadata[1] != nil { + t.Fatalf("Visit metadata = %v, want [%p nil]", metadata, meta) + } + + Switch(&second) + if first.chain != unsafe.Pointer(&entry.stackEntry) || currentRootChain != nil { + t.Fatal("Switch did not save the active chain and restore the next chain") + } + Unregister(&first) + if contexts != &second || second.next != nil { + t.Fatal("Unregister did not unlink the suspended context") + } +} + +func TestRejectsInvalidContextOperations(t *testing.T) { + resetForTest() + t.Cleanup(resetForTest) + + var ctx Context + assertPanics(t, func() { Register(nil) }) + RegisterActive(&ctx) + assertPanics(t, func() { Register(&ctx) }) + assertPanics(t, func() { RegisterActive(new(Context)) }) + assertPanics(t, func() { Switch(nil) }) + assertPanics(t, func() { Unregister(&ctx) }) +} + +func TestRejectsInvalidFrameMap(t *testing.T) { + m := frameMap{numRoots: 1, numMeta: 2} + entry := stackEntry{m: &m} + assertPanics(t, func() { + visitChain(unsafe.Pointer(&entry), func(*unsafe.Pointer, unsafe.Pointer) {}) + }) +} + +func TestRestoreChain(t *testing.T) { + resetForTest() + t.Cleanup(resetForTest) + + first := unsafe.Pointer(uintptr(0x11)) + second := unsafe.Pointer(uintptr(0x22)) + currentRootChain = first + if got := CurrentChain(); got != first { + t.Fatalf("CurrentChain() = %p, want %p", got, first) + } + RestoreChain(second) + if got := CurrentChain(); got != second { + t.Fatalf("CurrentChain() after restore = %p, want %p", got, second) + } +} + +func TestAdoptCurrent(t *testing.T) { + resetForTest() + t.Cleanup(resetForTest) + + var first, second Context + RegisterActive(&first) + Register(&second) + currentRootChain = unsafe.Pointer(uintptr(0x11)) + + AdoptCurrent(&second) + if active != &second { + t.Fatal("AdoptCurrent did not replace the active context") + } + if currentRootChain != unsafe.Pointer(uintptr(0x11)) { + t.Fatal("AdoptCurrent changed the chain restored by the stack switch") + } +} + +func assertPanics(t *testing.T, fn func()) { + t.Helper() + defer func() { + if recover() == nil { + t.Fatal("operation did not panic") + } + }() + fn() +} + +func resetForTest() { + contexts = nil + active = nil + currentRootChain = nil +} diff --git a/runtime/internal/lib/runtime/debug.go b/runtime/internal/lib/runtime/debug.go index b19cb2b9d2..f8d832d59b 100644 --- a/runtime/internal/lib/runtime/debug.go +++ b/runtime/internal/lib/runtime/debug.go @@ -1,5 +1,7 @@ package runtime +import llruntime "github.com/goplus/llgo/runtime/internal/runtime" + func NumCPU() int { return int(c_maxprocs()) } @@ -9,6 +11,7 @@ func Breakpoint() { } func Gosched() { + llruntime.Gosched() } func NumCgoCall() int64 { diff --git a/runtime/internal/lib/runtime/link_wasm_llgo.go b/runtime/internal/lib/runtime/link_wasm_llgo.go new file mode 100644 index 0000000000..4f4ce8c04f --- /dev/null +++ b/runtime/internal/lib/runtime/link_wasm_llgo.go @@ -0,0 +1,21 @@ +//go:build wasm + +package runtime + +import ( + _ "unsafe" + + c "github.com/goplus/llgo/runtime/internal/clite" +) + +//go:linkname c_environ environ +var c_environ **c.Char + +//go:linkname syscall_runtime_envs syscall.runtime_envs +func syscall_runtime_envs() []string { + var out []string + for p := c_environ; p != nil && *p != nil; p = c.Advance(p, 1) { + out = append(out, c.GoString(*p)) + } + return out +} diff --git a/runtime/internal/lib/runtime/mfinal.go b/runtime/internal/lib/runtime/mfinal.go index a62305c8a2..70d43c918e 100644 --- a/runtime/internal/lib/runtime/mfinal.go +++ b/runtime/internal/lib/runtime/mfinal.go @@ -1,4 +1,4 @@ -//go:build !nogc +//go:build !nogc && !wasm // Copyright 2009 The Go Authors. All rights reserved. // Use of this source code is governed by a BSD-style diff --git a/runtime/internal/lib/runtime/mfinal_nogc.go b/runtime/internal/lib/runtime/mfinal_nogc.go index c85111ea65..b4e55a948d 100644 --- a/runtime/internal/lib/runtime/mfinal_nogc.go +++ b/runtime/internal/lib/runtime/mfinal_nogc.go @@ -1,4 +1,4 @@ -//go:build nogc +//go:build nogc && (!wasm || !llgo_wasm_gc) package runtime diff --git a/runtime/internal/lib/runtime/mfinal_wasm.go b/runtime/internal/lib/runtime/mfinal_wasm.go new file mode 100644 index 0000000000..e18f6f6b7f --- /dev/null +++ b/runtime/internal/lib/runtime/mfinal_wasm.go @@ -0,0 +1,8 @@ +//go:build wasm && llgo_wasm_gc + +package runtime + +// SetFinalizer is not implemented by the initial WebAssembly collector. +func SetFinalizer(obj any, finalizer any) { + _, _ = obj, finalizer +} diff --git a/runtime/internal/lib/runtime/nanotime_other_llgo.go b/runtime/internal/lib/runtime/nanotime_other_llgo.go index 8478b3ec8f..4d71cbba9b 100644 --- a/runtime/internal/lib/runtime/nanotime_other_llgo.go +++ b/runtime/internal/lib/runtime/nanotime_other_llgo.go @@ -1,4 +1,4 @@ -//go:build !darwin && !linux && !baremetal +//go:build !darwin && !linux && !baremetal && (!wasm || (wasip1 && llgo.wasi_threads)) /* * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. diff --git a/runtime/internal/lib/runtime/nanotime_wasm_llgo.go b/runtime/internal/lib/runtime/nanotime_wasm_llgo.go new file mode 100644 index 0000000000..f8cd09ce16 --- /dev/null +++ b/runtime/internal/lib/runtime/nanotime_wasm_llgo.go @@ -0,0 +1,25 @@ +//go:build wasm && !(wasip1 && llgo.wasi_threads) + +/* + * 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 runtime + +import "github.com/goplus/llgo/runtime/internal/wasmevent" + +func nanotime1() int64 { + return wasmevent.Now() +} diff --git a/runtime/internal/lib/runtime/runtime_gc.go b/runtime/internal/lib/runtime/runtime_gc.go index d8656f93a4..7de1695009 100644 --- a/runtime/internal/lib/runtime/runtime_gc.go +++ b/runtime/internal/lib/runtime/runtime_gc.go @@ -1,4 +1,4 @@ -//go:build !nogc && !baremetal +//go:build !nogc && !baremetal && !wasm package runtime diff --git a/runtime/internal/lib/runtime/runtime_gc_baremetal.go b/runtime/internal/lib/runtime/runtime_gc_nonmoving.go similarity index 90% rename from runtime/internal/lib/runtime/runtime_gc_baremetal.go rename to runtime/internal/lib/runtime/runtime_gc_nonmoving.go index f384b6d0f2..e91c06c75b 100644 --- a/runtime/internal/lib/runtime/runtime_gc_baremetal.go +++ b/runtime/internal/lib/runtime/runtime_gc_nonmoving.go @@ -1,4 +1,4 @@ -//go:build !nogc && baremetal +//go:build (baremetal && !nogc) || (wasm && llgo_wasm_gc) package runtime diff --git a/runtime/internal/lib/runtime/runtime_nogc.go b/runtime/internal/lib/runtime/runtime_nogc.go index 3f11426023..50c039b10f 100644 --- a/runtime/internal/lib/runtime/runtime_nogc.go +++ b/runtime/internal/lib/runtime/runtime_nogc.go @@ -1,4 +1,4 @@ -//go:build nogc +//go:build nogc && (!wasm || !llgo_wasm_gc) package runtime diff --git a/runtime/internal/lib/runtime/sema_llgo.go b/runtime/internal/lib/runtime/sema_llgo.go index f7ab6c3434..0bb1fb2667 100644 --- a/runtime/internal/lib/runtime/sema_llgo.go +++ b/runtime/internal/lib/runtime/sema_llgo.go @@ -1,4 +1,4 @@ -//go:build darwin || linux +//go:build darwin || linux || (llgo && wasip1 && wasm && llgo.wasi_threads) package runtime @@ -99,8 +99,7 @@ func sync_runtime_SemacquireRWMutex(addr *uint32, _ bool, _ int) { semaAcquire(addr) } -//go:linkname sync_runtime_SemacquireWaitGroup sync.runtime_SemacquireWaitGroup -func sync_runtime_SemacquireWaitGroup(addr *uint32, _ bool) { +func syncWaitGroupAcquire(addr *uint32) { semaAcquire(addr) } diff --git a/runtime/internal/lib/runtime/sema_waitgroup_go124_llgo.go b/runtime/internal/lib/runtime/sema_waitgroup_go124_llgo.go new file mode 100644 index 0000000000..6765f7e863 --- /dev/null +++ b/runtime/internal/lib/runtime/sema_waitgroup_go124_llgo.go @@ -0,0 +1,10 @@ +//go:build (darwin || linux || (llgo && wasm)) && !go1.25 + +package runtime + +import _ "unsafe" + +//go:linkname sync_runtime_SemacquireWaitGroup sync.runtime_SemacquireWaitGroup +func sync_runtime_SemacquireWaitGroup(addr *uint32) { + syncWaitGroupAcquire(addr) +} diff --git a/runtime/internal/lib/runtime/sema_waitgroup_go125_llgo.go b/runtime/internal/lib/runtime/sema_waitgroup_go125_llgo.go new file mode 100644 index 0000000000..d019ea83b0 --- /dev/null +++ b/runtime/internal/lib/runtime/sema_waitgroup_go125_llgo.go @@ -0,0 +1,10 @@ +//go:build (darwin || linux || (llgo && wasm)) && go1.25 + +package runtime + +import _ "unsafe" + +//go:linkname sync_runtime_SemacquireWaitGroup sync.runtime_SemacquireWaitGroup +func sync_runtime_SemacquireWaitGroup(addr *uint32, _ bool) { + syncWaitGroupAcquire(addr) +} diff --git a/runtime/internal/lib/runtime/sema_wasm_llgo.go b/runtime/internal/lib/runtime/sema_wasm_llgo.go new file mode 100644 index 0000000000..a18130ec39 --- /dev/null +++ b/runtime/internal/lib/runtime/sema_wasm_llgo.go @@ -0,0 +1,217 @@ +//go:build llgo && wasm && !(wasip1 && llgo.wasi_threads) + +package runtime + +import ( + "unsafe" + + latomic "github.com/goplus/llgo/runtime/internal/lib/sync/atomic" + llruntime "github.com/goplus/llgo/runtime/internal/runtime" +) + +type wasmWaiter struct { + next *wasmWaiter + waiter llruntime.SchedulerWaiter + ticket uint32 +} + +type wasmWaitQueue struct { + head *wasmWaiter + tail *wasmWaiter +} + +func (q *wasmWaitQueue) push(w *wasmWaiter, lifo bool) { + if lifo { + w.next = q.head + q.head = w + if q.tail == nil { + q.tail = w + } + return + } + if q.tail == nil { + q.head = w + } else { + q.tail.next = w + } + q.tail = w +} + +func (q *wasmWaitQueue) pop() *wasmWaiter { + w := q.head + if w == nil { + return nil + } + q.head = w.next + if q.head == nil { + q.tail = nil + } + w.next = nil + return w +} + +func (q *wasmWaitQueue) removeTicket(ticket uint32) *wasmWaiter { + var prev *wasmWaiter + for w := q.head; w != nil; w = w.next { + if w.ticket == ticket { + if prev == nil { + q.head = w.next + } else { + prev.next = w.next + } + if q.tail == w { + q.tail = prev + } + w.next = nil + return w + } + prev = w + } + return nil +} + +var semaQueues map[uintptr]*wasmWaitQueue + +func semaQueue(addr *uint32) *wasmWaitQueue { + if semaQueues == nil { + semaQueues = make(map[uintptr]*wasmWaitQueue) + } + key := uintptr(unsafe.Pointer(addr)) + q := semaQueues[key] + if q == nil { + q = new(wasmWaitQueue) + semaQueues[key] = q + } + return q +} + +//go:linkname sync_runtime_Semacquire sync.runtime_Semacquire +func sync_runtime_Semacquire(addr *uint32) { + semaAcquire(addr, false) +} + +//go:linkname poll_runtime_Semacquire internal/poll.runtime_Semacquire +func poll_runtime_Semacquire(addr *uint32) { + semaAcquire(addr, false) +} + +//go:linkname sync_runtime_Semrelease sync.runtime_Semrelease +func sync_runtime_Semrelease(addr *uint32, handoff bool, _ int) { + semaRelease(addr, handoff) +} + +//go:linkname sync_runtime_SemacquireRWMutexR sync.runtime_SemacquireRWMutexR +func sync_runtime_SemacquireRWMutexR(addr *uint32, lifo bool, _ int) { + semaAcquire(addr, lifo) +} + +//go:linkname sync_runtime_SemacquireRWMutex sync.runtime_SemacquireRWMutex +func sync_runtime_SemacquireRWMutex(addr *uint32, lifo bool, _ int) { + semaAcquire(addr, lifo) +} + +func syncWaitGroupAcquire(addr *uint32) { + semaAcquire(addr, false) +} + +func runtime_SemacquireMutex(addr *uint32, lifo bool, _ int) { + semaAcquire(addr, lifo) +} + +//go:linkname sync_runtime_SemacquireMutex sync.runtime_SemacquireMutex +func sync_runtime_SemacquireMutex(addr *uint32, lifo bool, skipframes int) { + runtime_SemacquireMutex(addr, lifo, skipframes) +} + +func runtime_Semrelease(addr *uint32, handoff bool, _ int) { + semaRelease(addr, handoff) +} + +//go:linkname poll_runtime_Semrelease internal/poll.runtime_Semrelease +func poll_runtime_Semrelease(addr *uint32) { + semaRelease(addr, false) +} + +func runtime_canSpin(int) bool { return false } +func runtime_doSpin() {} +func runtime_nanotime() int64 { return runtimeNano() } + +//go:linkname sync_runtime_canSpin sync.runtime_canSpin +func sync_runtime_canSpin(i int) bool { return runtime_canSpin(i) } + +//go:linkname sync_runtime_doSpin sync.runtime_doSpin +func sync_runtime_doSpin() { runtime_doSpin() } + +//go:linkname sync_runtime_nanotime sync.runtime_nanotime +func sync_runtime_nanotime() int64 { return runtime_nanotime() } + +//go:linkname internal_sync_runtime_canSpin internal/sync.runtime_canSpin +func internal_sync_runtime_canSpin(i int) bool { return runtime_canSpin(i) } + +//go:linkname internal_sync_runtime_doSpin internal/sync.runtime_doSpin +func internal_sync_runtime_doSpin() { runtime_doSpin() } + +//go:linkname internal_sync_runtime_nanotime internal/sync.runtime_nanotime +func internal_sync_runtime_nanotime() int64 { return runtime_nanotime() } + +//go:linkname internal_sync_runtime_SemacquireMutex internal/sync.runtime_SemacquireMutex +func internal_sync_runtime_SemacquireMutex(addr *uint32, lifo bool, skipframes int) { + runtime_SemacquireMutex(addr, lifo, skipframes) +} + +//go:linkname internal_sync_runtime_Semrelease internal/sync.runtime_Semrelease +func internal_sync_runtime_Semrelease(addr *uint32, handoff bool, skipframes int) { + runtime_Semrelease(addr, handoff, skipframes) +} + +//go:linkname internal_sync_throw internal/sync.throw +func internal_sync_throw(s string) { throw(s) } + +//go:linkname internal_sync_fatal internal/sync.fatal +func internal_sync_fatal(s string) { fatal(s) } + +type notifyList struct { + wait uint32 + notify uint32 + lock uintptr + head unsafe.Pointer + tail unsafe.Pointer +} + +var notifyQueues map[uintptr]*wasmWaitQueue + +func notifyQueue(l *notifyList) *wasmWaitQueue { + if notifyQueues == nil { + notifyQueues = make(map[uintptr]*wasmWaitQueue) + } + key := uintptr(unsafe.Pointer(l)) + q := notifyQueues[key] + if q == nil { + q = new(wasmWaitQueue) + notifyQueues[key] = q + } + return q +} + +func ticketLess(a, b uint32) bool { + return int32(a-b) < 0 +} + +//go:linkname sync_runtime_notifyListAdd sync.runtime_notifyListAdd +func sync_runtime_notifyListAdd(l *notifyList) uint32 { + return latomic.AddUint32(&l.wait, 1) - 1 +} + +//go:linkname sync_runtime_notifyListCheck sync.runtime_notifyListCheck +func sync_runtime_notifyListCheck(size uintptr) { + if size != unsafe.Sizeof(notifyList{}) { + panic("sync.notifyList size mismatch") + } +} + +var poolCleanup func() + +//go:linkname sync_runtime_registerPoolCleanup sync.runtime_registerPoolCleanup +func sync_runtime_registerPoolCleanup(cleanup func()) { + poolCleanup = cleanup +} diff --git a/runtime/internal/lib/runtime/sema_wasm_single_llgo.go b/runtime/internal/lib/runtime/sema_wasm_single_llgo.go new file mode 100644 index 0000000000..4a189e25e8 --- /dev/null +++ b/runtime/internal/lib/runtime/sema_wasm_single_llgo.go @@ -0,0 +1,108 @@ +//go:build llgo && wasm && !(wasip1 && llgo.wasi_threads) && !llgo.wasm_workers + +package runtime + +import ( + "unsafe" + + latomic "github.com/goplus/llgo/runtime/internal/lib/sync/atomic" + llruntime "github.com/goplus/llgo/runtime/internal/runtime" +) + +func semaAcquire(addr *uint32, lifo bool) { + value := latomic.LoadUint32(addr) + if value != 0 && latomic.CompareAndSwapUint32(addr, value, value-1) { + return + } + w := &wasmWaiter{waiter: llruntime.CurrentSchedulerWaiter()} + semaQueue(addr).push(w, lifo) + w.waiter.Park() +} + +func semaRelease(addr *uint32, handoff bool) { + key := uintptr(unsafe.Pointer(addr)) + if q := semaQueues[key]; q != nil { + if w := q.pop(); w != nil { + if q.head == nil { + delete(semaQueues, key) + } + w.waiter.Ready() + if handoff { + llruntime.Gosched() + } + return + } + } + latomic.AddUint32(addr, 1) +} + +//go:linkname sync_runtime_notifyListWait sync.runtime_notifyListWait +func sync_runtime_notifyListWait(l *notifyList, ticket uint32) { + if ticketLess(ticket, latomic.LoadUint32(&l.notify)) { + return + } + w := &wasmWaiter{ + waiter: llruntime.CurrentSchedulerWaiter(), + ticket: ticket, + } + notifyQueue(l).push(w, false) + w.waiter.Park() +} + +//go:linkname sync_runtime_notifyListNotifyAll sync.runtime_notifyListNotifyAll +func sync_runtime_notifyListNotifyAll(l *notifyList) { + wait := latomic.LoadUint32(&l.wait) + if latomic.LoadUint32(&l.notify) == wait { + return + } + latomic.StoreUint32(&l.notify, wait) + key := uintptr(unsafe.Pointer(l)) + q := notifyQueues[key] + if q == nil { + return + } + delete(notifyQueues, key) + for { + w := q.pop() + if w == nil { + return + } + w.waiter.Ready() + } +} + +//go:linkname sync_runtime_notifyListNotifyOne sync.runtime_notifyListNotifyOne +func sync_runtime_notifyListNotifyOne(l *notifyList) { + notify := latomic.LoadUint32(&l.notify) + if notify == latomic.LoadUint32(&l.wait) { + return + } + latomic.StoreUint32(&l.notify, notify+1) + key := uintptr(unsafe.Pointer(l)) + q := notifyQueues[key] + if q == nil { + return + } + if w := q.removeTicket(notify); w != nil { + if q.head == nil { + delete(notifyQueues, key) + } + w.waiter.Ready() + } +} + +//go:linkname sync_runtime_procPin sync.runtime_procPin +func sync_runtime_procPin() int { + return 0 +} + +//go:linkname sync_runtime_procUnpin sync.runtime_procUnpin +func sync_runtime_procUnpin() {} + +//go:linkname atomic_runtime_procPin sync/atomic.runtime_procPin +func atomic_runtime_procPin() int { + return 0 +} + +//go:linkname atomic_runtime_procUnpin sync/atomic.runtime_procUnpin +func atomic_runtime_procUnpin() {} diff --git a/runtime/internal/lib/runtime/sema_wasm_workers_llgo.go b/runtime/internal/lib/runtime/sema_wasm_workers_llgo.go new file mode 100644 index 0000000000..1bc68a69ed --- /dev/null +++ b/runtime/internal/lib/runtime/sema_wasm_workers_llgo.go @@ -0,0 +1,157 @@ +//go:build llgo && js && wasm && llgo.wasm_workers + +package runtime + +import ( + "unsafe" + + psync "github.com/goplus/llgo/runtime/internal/clite/pthread/sync" + latomic "github.com/goplus/llgo/runtime/internal/lib/sync/atomic" + llruntime "github.com/goplus/llgo/runtime/internal/runtime" +) + +var semaQueuesLock = newWasmSemaMutex() +var notifyQueuesLock = newWasmSemaMutex() + +type wasmSemaMutex struct { + mutex psync.Mutex +} + +func newWasmSemaMutex() wasmSemaMutex { + var result wasmSemaMutex + if result.mutex.Init(nil) != 0 { + panic("runtime: failed to initialize WebAssembly semaphore mutex") + } + return result +} + +func (m *wasmSemaMutex) Lock() { + m.mutex.Lock() +} + +func (m *wasmSemaMutex) Unlock() { + m.mutex.Unlock() +} + +func semaAcquire(addr *uint32, lifo bool) { + value := latomic.LoadUint32(addr) + if value != 0 && latomic.CompareAndSwapUint32(addr, value, value-1) { + return + } + semaQueuesLock.Lock() + value = latomic.LoadUint32(addr) + if value != 0 && latomic.CompareAndSwapUint32(addr, value, value-1) { + semaQueuesLock.Unlock() + return + } + w := &wasmWaiter{waiter: llruntime.CurrentSchedulerWaiter()} + semaQueue(addr).push(w, lifo) + semaQueuesLock.Unlock() + w.waiter.Park() +} + +func semaRelease(addr *uint32, handoff bool) { + key := uintptr(unsafe.Pointer(addr)) + semaQueuesLock.Lock() + if q := semaQueues[key]; q != nil { + if w := q.pop(); w != nil { + if q.head == nil { + delete(semaQueues, key) + } + semaQueuesLock.Unlock() + w.waiter.Ready() + if handoff { + llruntime.Gosched() + } + return + } + } + latomic.AddUint32(addr, 1) + semaQueuesLock.Unlock() +} + +//go:linkname sync_runtime_notifyListWait sync.runtime_notifyListWait +func sync_runtime_notifyListWait(l *notifyList, ticket uint32) { + if ticketLess(ticket, latomic.LoadUint32(&l.notify)) { + return + } + w := &wasmWaiter{ + waiter: llruntime.CurrentSchedulerWaiter(), + ticket: ticket, + } + notifyQueuesLock.Lock() + if ticketLess(ticket, latomic.LoadUint32(&l.notify)) { + notifyQueuesLock.Unlock() + return + } + notifyQueue(l).push(w, false) + notifyQueuesLock.Unlock() + w.waiter.Park() +} + +//go:linkname sync_runtime_notifyListNotifyAll sync.runtime_notifyListNotifyAll +func sync_runtime_notifyListNotifyAll(l *notifyList) { + notifyQueuesLock.Lock() + wait := latomic.LoadUint32(&l.wait) + if latomic.LoadUint32(&l.notify) == wait { + notifyQueuesLock.Unlock() + return + } + latomic.StoreUint32(&l.notify, wait) + key := uintptr(unsafe.Pointer(l)) + q := notifyQueues[key] + if q == nil { + notifyQueuesLock.Unlock() + return + } + delete(notifyQueues, key) + notifyQueuesLock.Unlock() + for { + w := q.pop() + if w == nil { + return + } + w.waiter.Ready() + } +} + +//go:linkname sync_runtime_notifyListNotifyOne sync.runtime_notifyListNotifyOne +func sync_runtime_notifyListNotifyOne(l *notifyList) { + notifyQueuesLock.Lock() + notify := latomic.LoadUint32(&l.notify) + if notify == latomic.LoadUint32(&l.wait) { + notifyQueuesLock.Unlock() + return + } + latomic.StoreUint32(&l.notify, notify+1) + key := uintptr(unsafe.Pointer(l)) + q := notifyQueues[key] + if q == nil { + notifyQueuesLock.Unlock() + return + } + w := q.removeTicket(notify) + if q.head == nil { + delete(notifyQueues, key) + } + notifyQueuesLock.Unlock() + if w != nil { + w.waiter.Ready() + } +} + +//go:linkname sync_runtime_procPin sync.runtime_procPin +func sync_runtime_procPin() int { + return llruntime.SchedulerProcID() +} + +//go:linkname sync_runtime_procUnpin sync.runtime_procUnpin +func sync_runtime_procUnpin() {} + +//go:linkname atomic_runtime_procPin sync/atomic.runtime_procPin +func atomic_runtime_procPin() int { + return llruntime.SchedulerProcID() +} + +//go:linkname atomic_runtime_procUnpin sync/atomic.runtime_procUnpin +func atomic_runtime_procUnpin() {} diff --git a/runtime/internal/lib/runtime/sync_runtime_llgo.go b/runtime/internal/lib/runtime/sync_runtime_llgo.go index 800ccab4bb..2b4e245912 100644 --- a/runtime/internal/lib/runtime/sync_runtime_llgo.go +++ b/runtime/internal/lib/runtime/sync_runtime_llgo.go @@ -1,4 +1,4 @@ -//go:build darwin || linux +//go:build darwin || linux || (llgo && wasip1 && wasm && llgo.wasi_threads) package runtime diff --git a/runtime/internal/lib/runtime/synctest_llgo.go b/runtime/internal/lib/runtime/synctest_llgo.go index df08c497f4..277686154a 100644 --- a/runtime/internal/lib/runtime/synctest_llgo.go +++ b/runtime/internal/lib/runtime/synctest_llgo.go @@ -1,4 +1,4 @@ -//go:build darwin || linux +//go:build darwin || linux || (llgo && wasm) package runtime diff --git a/runtime/internal/lib/runtime/time_wasm_event_llgo.go b/runtime/internal/lib/runtime/time_wasm_event_llgo.go new file mode 100644 index 0000000000..d7ba99c7d3 --- /dev/null +++ b/runtime/internal/lib/runtime/time_wasm_event_llgo.go @@ -0,0 +1,66 @@ +//go:build wasm && go1.23 && !(wasip1 && llgo.wasi_threads) + +package runtime + +import "github.com/goplus/llgo/runtime/internal/wasmevent" + +type runtimeTimerPlatform struct { + event wasmevent.Timer +} + +func startRuntimeTimer(r *runtimeTimer) { + if r == nil || r.f == nil { + return + } + wasmevent.Reset(&r.platform.event, r.when, r.period, fireWasmRuntimeTimer, r) +} + +func stopRuntimeTimer(r *runtimeTimer) bool { + return r != nil && wasmevent.Stop(&r.platform.event) +} + +func resetRuntimeTimer(r *runtimeTimer, when, period int64, f func(any, uintptr, int64), arg any, seq uintptr) bool { + if r == nil { + return false + } + r.when = when + r.period = period + r.f = f + r.arg = arg + r.seq = seq + return wasmevent.Reset(&r.platform.event, when, period, fireWasmRuntimeTimer, r) +} + +func fireWasmRuntimeTimer(arg any, timer *wasmevent.Timer, scheduled, now int64) { + r := arg.(*runtimeTimer) + if deadline, active := timer.Deadline(); active { + r.when = deadline + } + f, farg, seq := r.f, r.arg, r.seq + delta := now - scheduled + if delta < 0 { + delta = 0 + } + if f != nil { + f(farg, seq, delta) + } +} + +func sleepRuntime(ns int64) { + if ns <= 0 { + return + } + done := make(chan struct{}, 1) + r := &runtimeTimer{ + when: runtimeNano() + ns, + f: timeSleepWake, + arg: done, + } + startRuntimeTimer(r) + <-done + stopRuntimeTimer(r) +} + +func timeSleepWake(arg any, _ uintptr, _ int64) { + arg.(chan struct{}) <- struct{}{} +} diff --git a/runtime/internal/lib/runtime/time_wasm_llgo.go b/runtime/internal/lib/runtime/time_wasm_llgo.go index 500e7e0c99..712b838be5 100644 --- a/runtime/internal/lib/runtime/time_wasm_llgo.go +++ b/runtime/internal/lib/runtime/time_wasm_llgo.go @@ -9,10 +9,6 @@ import ( ct "github.com/goplus/llgo/runtime/internal/clite/time" ) -// Minimal timer hooks for wasm builds. Host-backed asynchronous timers need -// scheduler integration; until that is available, keep runtime and time -// linkable without pulling the native libuv event loop into wasm binaries. - type runtimeTimer struct { pp uintptr when int64 @@ -22,6 +18,7 @@ type runtimeTimer struct { seq uintptr nextwhen int64 status uint32 + platform runtimeTimerPlatform } type timeTimer struct { @@ -30,32 +27,6 @@ type timeTimer struct { r runtimeTimer } -func startRuntimeTimer(r *runtimeTimer) { - if r == nil || r.f == nil { - return - } - if r.period == 0 && r.when <= runtimeNano() { - r.f(r.arg, r.seq, runtimeNano()) - } -} - -func stopRuntimeTimer(r *runtimeTimer) bool { - return r != nil -} - -func resetRuntimeTimer(r *runtimeTimer, when, period int64, f func(any, uintptr, int64), arg any, seq uintptr) bool { - if r == nil { - return false - } - r.when = when - r.period = period - r.f = f - r.arg = arg - r.seq = seq - startRuntimeTimer(r) - return true -} - //go:linkname time_now time.now func time_now() (sec int64, nsec int32, mono int64) { tv := (*ct.Timespec)(c.Alloca(unsafe.Sizeof(ct.Timespec{}))) @@ -80,12 +51,7 @@ func time_runtimeIsBubbled() bool { //go:linkname timeSleep time.Sleep func timeSleep(ns int64) { - if ns <= 0 { - return - } - deadline := runtimeNano() + ns - for runtimeNano() < deadline { - } + sleepRuntime(ns) } //go:linkname newTimer time.newTimer diff --git a/runtime/internal/lib/runtime/time_wasm_threads_llgo.go b/runtime/internal/lib/runtime/time_wasm_threads_llgo.go new file mode 100644 index 0000000000..34e286c81d --- /dev/null +++ b/runtime/internal/lib/runtime/time_wasm_threads_llgo.go @@ -0,0 +1,40 @@ +//go:build wasip1 && wasm && go1.23 && llgo.wasi_threads + +package runtime + +type runtimeTimerPlatform struct{} + +func startRuntimeTimer(r *runtimeTimer) { + if r == nil || r.f == nil { + return + } + if r.period == 0 && r.when <= runtimeNano() { + r.f(r.arg, r.seq, runtimeNano()) + } +} + +func stopRuntimeTimer(r *runtimeTimer) bool { + return r != nil +} + +func resetRuntimeTimer(r *runtimeTimer, when, period int64, f func(any, uintptr, int64), arg any, seq uintptr) bool { + if r == nil { + return false + } + r.when = when + r.period = period + r.f = f + r.arg = arg + r.seq = seq + startRuntimeTimer(r) + return true +} + +func sleepRuntime(ns int64) { + if ns <= 0 { + return + } + deadline := runtimeNano() + ns + for runtimeNano() < deadline { + } +} diff --git a/runtime/internal/pollbudget/budget.go b/runtime/internal/pollbudget/budget.go new file mode 100644 index 0000000000..e9248ca1cf --- /dev/null +++ b/runtime/internal/pollbudget/budget.go @@ -0,0 +1,42 @@ +/* + * 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 pollbudget implements a fixed cooperative polling budget. +package pollbudget + +// Budget reports every quantum-th call to Poll. +type Budget struct { + remaining uint32 + quantum uint32 +} + +// New returns a budget with the requested non-zero quantum. +func New(quantum uint32) Budget { + if quantum == 0 { + panic("pollbudget: zero quantum") + } + return Budget{remaining: quantum, quantum: quantum} +} + +// Poll consumes one unit and reports whether the slow path should run. +func (b *Budget) Poll() bool { + if b.remaining > 1 { + b.remaining-- + return false + } + b.remaining = b.quantum + return true +} diff --git a/runtime/internal/pollbudget/budget_test.go b/runtime/internal/pollbudget/budget_test.go new file mode 100644 index 0000000000..acaa3d7d56 --- /dev/null +++ b/runtime/internal/pollbudget/budget_test.go @@ -0,0 +1,35 @@ +package pollbudget + +import "testing" + +func TestBudget(t *testing.T) { + budget := New(3) + if budget.Poll() { + t.Fatal("first poll reached the slow path") + } + if budget.Poll() { + t.Fatal("second poll reached the slow path") + } + if !budget.Poll() { + t.Fatal("third poll did not reach the slow path") + } + if budget.Poll() { + t.Fatal("budget did not reset") + } +} + +func TestZeroQuantum(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("New(0) did not panic") + } + }() + New(0) +} + +func BenchmarkBudgetPoll(b *testing.B) { + budget := New(1024) + for b.Loop() { + budget.Poll() + } +} diff --git a/runtime/internal/runqueue/runqueue.go b/runtime/internal/runqueue/runqueue.go new file mode 100644 index 0000000000..1e6f32c57c --- /dev/null +++ b/runtime/internal/runqueue/runqueue.go @@ -0,0 +1,75 @@ +/* + * 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 runqueue provides an allocation-free intrusive FIFO for scheduler +// backends with one queue owner. +package runqueue + +// Node is the intrusive link contract implemented by scheduler-owned values. +type Node[T comparable] interface { + RunqueueNext() T + SetRunqueueNext(T) + RunqueueQueued() bool + SetRunqueueQueued(bool) +} + +type Queue[T interface { + comparable + Node[T] +}] struct { + head T + tail T + size uintptr +} + +// Push appends node and reports whether it was non-zero and not queued. +func (q *Queue[T]) Push(node T) bool { + var zero T + if node == zero || node.RunqueueQueued() { + return false + } + node.SetRunqueueNext(zero) + node.SetRunqueueQueued(true) + if q.tail == zero { + q.head = node + } else { + q.tail.SetRunqueueNext(node) + } + q.tail = node + q.size++ + return true +} + +// Pop removes and returns the oldest node, or its zero value when empty. +func (q *Queue[T]) Pop() T { + var zero T + node := q.head + if node == zero { + return zero + } + q.head = node.RunqueueNext() + if q.head == zero { + q.tail = zero + } + node.SetRunqueueNext(zero) + node.SetRunqueueQueued(false) + q.size-- + return node +} + +func (q *Queue[T]) Len() uintptr { + return q.size +} diff --git a/runtime/internal/runqueue/runqueue_test.go b/runtime/internal/runqueue/runqueue_test.go new file mode 100644 index 0000000000..38a9fd5fb7 --- /dev/null +++ b/runtime/internal/runqueue/runqueue_test.go @@ -0,0 +1,60 @@ +package runqueue + +import "testing" + +type testNode struct { + value int + queued bool + next *testNode +} + +func (node *testNode) RunqueueNext() *testNode { + return node.next +} + +func (node *testNode) SetRunqueueNext(next *testNode) { + node.next = next +} + +func (node *testNode) RunqueueQueued() bool { + return node.queued +} + +func (node *testNode) SetRunqueueQueued(queued bool) { + node.queued = queued +} + +func TestQueueFIFOAndReuse(t *testing.T) { + first := &testNode{value: 1} + second := &testNode{value: 2} + var q Queue[*testNode] + + if !q.Push(first) || !q.Push(second) { + t.Fatal("Push rejected initialized nodes") + } + if q.Push(first) { + t.Fatal("Push accepted a queued node") + } + if got := q.Len(); got != 2 { + t.Fatalf("Len = %d, want 2", got) + } + if got := q.Pop(); got != first || got.value != 1 { + t.Fatalf("first Pop = %p, want %p", got, first) + } + if got := q.Pop(); got != second || got.value != 2 { + t.Fatalf("second Pop = %p, want %p", got, second) + } + if got := q.Pop(); got != nil { + t.Fatalf("empty Pop = %p, want nil", got) + } + if !q.Push(first) || q.Pop() != first { + t.Fatal("queue did not accept a reused node") + } +} + +func TestQueueRejectsInvalidNodes(t *testing.T) { + var q Queue[*testNode] + if q.Push(nil) { + t.Fatal("Push accepted nil") + } +} diff --git a/runtime/internal/runtime/chan_sync_pthread.go b/runtime/internal/runtime/chan_sync_pthread.go new file mode 100644 index 0000000000..c37740d341 --- /dev/null +++ b/runtime/internal/runtime/chan_sync_pthread.go @@ -0,0 +1,61 @@ +//go:build !llgo || !wasm || (wasip1 && llgo.wasi_threads) + +package runtime + +import "github.com/goplus/llgo/runtime/internal/clite/pthread/sync" + +type chanMutex struct { + mutex sync.Mutex +} + +func (m *chanMutex) init() { + m.mutex.Init(nil) +} + +func (m *chanMutex) Lock() { + m.mutex.Lock() +} + +func (m *chanMutex) Unlock() { + m.mutex.Unlock() +} + +type chanSignal struct { + mutex sync.Mutex + cond sync.Cond +} + +func (s *chanSignal) init() { + s.mutex.Init(nil) + s.cond.Init(nil) +} + +func (s *chanSignal) lock() { + s.mutex.Lock() +} + +func (s *chanSignal) unlock() { + s.mutex.Unlock() +} + +func (s *chanSignal) park() { + s.cond.Wait(&s.mutex) +} + +func (s *chanSignal) ready() { + s.cond.Signal() +} + +func (s *chanSignal) destroy() { + s.cond.Destroy() + s.mutex.Destroy() +} + +func chanBlockForever() { + var signal chanSignal + signal.init() + signal.lock() + for { + signal.park() + } +} diff --git a/runtime/internal/runtime/chan_sync_wasm.go b/runtime/internal/runtime/chan_sync_wasm.go new file mode 100644 index 0000000000..fd59d389d9 --- /dev/null +++ b/runtime/internal/runtime/chan_sync_wasm.go @@ -0,0 +1,35 @@ +//go:build llgo && wasm && !(wasip1 && llgo.wasi_threads) && !llgo.wasm_workers + +package runtime + +type chanMutex struct{} + +func (*chanMutex) init() {} +func (*chanMutex) Lock() {} +func (*chanMutex) Unlock() {} + +type chanSignal struct { + waiter SchedulerWaiter +} + +func (s *chanSignal) init() { + s.waiter = CurrentSchedulerWaiter() +} + +func (*chanSignal) lock() {} +func (*chanSignal) unlock() {} + +func (s *chanSignal) park() { + s.waiter.Park() +} + +func (s *chanSignal) ready() { + s.waiter.Ready() +} + +func (*chanSignal) destroy() {} + +func chanBlockForever() { + CurrentSchedulerWaiter().Park() + fatal("runtime: permanently parked goroutine was resumed") +} diff --git a/runtime/internal/runtime/chan_sync_wasm_workers.go b/runtime/internal/runtime/chan_sync_wasm_workers.go new file mode 100644 index 0000000000..9c00ad5747 --- /dev/null +++ b/runtime/internal/runtime/chan_sync_wasm_workers.go @@ -0,0 +1,68 @@ +//go:build llgo && js && wasm && llgo.wasm_workers + +package runtime + +import ( + "github.com/goplus/llgo/runtime/internal/clite/pthread/sync" + "github.com/goplus/llgo/runtime/internal/clite/sync/atomic" + "github.com/goplus/llgo/runtime/internal/wasmworkers" +) + +type chanMutex struct { + mutex sync.Mutex +} + +func (m *chanMutex) init() { + if m.mutex.Init(nil) != 0 { + fatal("runtime: failed to initialize channel mutex") + } +} + +func (m *chanMutex) Lock() { + m.mutex.Lock() +} + +func (m *chanMutex) Unlock() { + m.mutex.Unlock() +} + +type chanSignal struct { + lockWord uint32 + waiter SchedulerWaiter +} + +func (s *chanSignal) init() { + s.waiter = CurrentSchedulerWaiter() +} + +func (s *chanSignal) lock() { + for { + if _, ok := atomic.CompareAndExchange(&s.lockWord, uint32(0), uint32(1)); ok { + return + } + wasmworkers.Wait(&s.lockWord, 1, -1) + } +} + +func (s *chanSignal) unlock() { + atomic.Store(&s.lockWord, uint32(0)) + wasmworkers.Wake(&s.lockWord) +} + +func (s *chanSignal) park() { + s.unlock() + s.waiter.Park() + s.lock() +} + +func (s *chanSignal) ready() { + s.waiter.Ready() +} + +func (*chanSignal) destroy() {} + +func chanBlockForever() { + waiter := CurrentSchedulerWaiter() + waiter.Park() + fatal("runtime: permanently parked goroutine was resumed") +} diff --git a/runtime/internal/runtime/defer_gcroot_default.go b/runtime/internal/runtime/defer_gcroot_default.go new file mode 100644 index 0000000000..e451d8f381 --- /dev/null +++ b/runtime/internal/runtime/defer_gcroot_default.go @@ -0,0 +1,18 @@ +//go:build !wasm || !llgo_wasm_gc + +package runtime + +import "unsafe" + +// Defer presents defer statements in a function. +type Defer struct { + Addr unsafe.Pointer // sigjmpbuf + Bits uintptr + Link *Defer + Reth unsafe.Pointer // block address after Rethrow + Rund unsafe.Pointer // block address after RunDefers + Args unsafe.Pointer // defer func and args links +} + +// SetDeferGCRoot is omitted by the compiler when root publication is disabled. +func SetDeferGCRoot(*Defer) {} diff --git a/runtime/internal/runtime/defer_gcroot_wasm.go b/runtime/internal/runtime/defer_gcroot_wasm.go new file mode 100644 index 0000000000..0bf2eefe97 --- /dev/null +++ b/runtime/internal/runtime/defer_gcroot_wasm.go @@ -0,0 +1,25 @@ +//go:build wasm && llgo_wasm_gc + +package runtime + +import ( + "unsafe" + + "github.com/goplus/llgo/runtime/internal/gcroot" +) + +// Defer presents defer statements in a function. +type Defer struct { + Addr unsafe.Pointer // sigjmpbuf + Bits uintptr + Link *Defer + Reth unsafe.Pointer // block address after Rethrow + Rund unsafe.Pointer // block address after RunDefers + Args unsafe.Pointer // defer func and args links + gcRoot unsafe.Pointer // root chain at the owning function's setjmp +} + +// SetDeferGCRoot records the chain that longjmp must restore. +func SetDeferGCRoot(frame *Defer) { + frame.gcRoot = gcroot.CurrentChain() +} diff --git a/runtime/internal/runtime/g_pthread.go b/runtime/internal/runtime/g_pthread.go index 83aebf0127..9023bc50bb 100644 --- a/runtime/internal/runtime/g_pthread.go +++ b/runtime/internal/runtime/g_pthread.go @@ -1,4 +1,4 @@ -//go:build llgo && !baremetal +//go:build llgo && !baremetal && (!wasm || (wasip1 && llgo.wasi_threads)) /* * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. diff --git a/runtime/internal/runtime/g_wasm.go b/runtime/internal/runtime/g_wasm.go new file mode 100644 index 0000000000..0dc11d214e --- /dev/null +++ b/runtime/internal/runtime/g_wasm.go @@ -0,0 +1,32 @@ +//go:build llgo && wasm && !(wasip1 && llgo.wasi_threads) && !llgo.wasm_workers + +/* + * 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 runtime + +var currentG *g + +func getg() *g { + if currentG == nil { + currentG = initRuntimeContext(allocRuntimeContext(), nil, _Grunning) + } + return currentG +} + +func setg(gp *g) { + currentG = gp +} diff --git a/runtime/internal/runtime/g_wasm_workers.go b/runtime/internal/runtime/g_wasm_workers.go new file mode 100644 index 0000000000..350fd91893 --- /dev/null +++ b/runtime/internal/runtime/g_wasm_workers.go @@ -0,0 +1,36 @@ +//go:build llgo && js && wasm && llgo.wasm_workers + +package runtime + +import ( + "unsafe" + + "github.com/goplus/llgo/runtime/internal/wasmworkers" +) + +func getg() *g { + if worker := currentWasmWorker(); worker != nil { + return worker.m.curg + } + if wasmMultiSched.started { + return nil + } + return initRuntimeContext(allocRuntimeContext(), nil, _Grunning) +} + +func setg(gp *g) { + worker := currentWasmWorker() + if worker == nil { + fatal("runtime: setg without a WebAssembly worker") + return + } + worker.m.curg = gp +} + +func currentWasmWorker() *wasmWorker { + return (*wasmWorker)(wasmworkers.Current()) +} + +func setCurrentWasmWorker(worker *wasmWorker) { + wasmworkers.SetCurrent(unsafe.Pointer(worker)) +} diff --git a/runtime/internal/runtime/os_pthread.go b/runtime/internal/runtime/os_pthread.go index 4a7447fda4..3f99bf0f45 100644 --- a/runtime/internal/runtime/os_pthread.go +++ b/runtime/internal/runtime/os_pthread.go @@ -1,3 +1,5 @@ +//go:build !llgo || !wasm || (wasip1 && llgo.wasi_threads) + /* * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. * @@ -63,8 +65,12 @@ func initThreadAttr(attr *pthread.Attr, stackSize uintptr) c.Int { return 0 } -func exitCurrentM() { - mp := getg().m - mexit(mp) +func goexitBackend(gp *g) { + if gp.isMain { + fatal("no goroutines (main called runtime.Goexit) - deadlock!") + c.Exit(2) + } + leaveCurrentLocalContext() + mexit(gp.m) pthread.Exit(nil) } diff --git a/runtime/internal/runtime/os_wasm.go b/runtime/internal/runtime/os_wasm.go new file mode 100644 index 0000000000..4851564c6a --- /dev/null +++ b/runtime/internal/runtime/os_wasm.go @@ -0,0 +1,23 @@ +//go:build llgo && wasm && !(wasip1 && llgo.wasi_threads) && !llgo.wasm_workers + +/* + * 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 runtime + +// mOS is empty for the single-worker WebAssembly backend. The host Worker is +// owned by Emscripten rather than created for an individual M. +type mOS struct{} diff --git a/runtime/internal/runtime/os_wasm_workers.go b/runtime/internal/runtime/os_wasm_workers.go new file mode 100644 index 0000000000..10314aab35 --- /dev/null +++ b/runtime/internal/runtime/os_wasm_workers.go @@ -0,0 +1,7 @@ +//go:build llgo && js && wasm && llgo.wasm_workers + +package runtime + +// Worker threads are owned by the scheduler pool rather than individual M +// records, so the host-specific M payload stays empty. +type mOS struct{} diff --git a/runtime/internal/runtime/proc.go b/runtime/internal/runtime/proc.go index b9e35ef954..ae66de6057 100644 --- a/runtime/internal/runtime/proc.go +++ b/runtime/internal/runtime/proc.go @@ -28,17 +28,14 @@ import ( //llgo:type C type goroutineFunc func(unsafe.Pointer) unsafe.Pointer -// runtimeContext keeps the G, M, and P for the current 1:1 backend in one -// allocation. Keeping their ownership together makes mexit deterministic while -// leaving the individual objects and links compatible with a later M:N backend. +// runtimeContext owns one G and its target-specific suspended execution state. +// M and P ownership belongs to the selected scheduler backend and can outlive, +// or be shared by, multiple runtime contexts. type runtimeContext struct { g g - m m - p p - // root is non-nil for contexts passed through a host-thread API. Such - // contexts must remain visible to the collector until mexit. - root unsafe.Pointer + root unsafe.Pointer + platform runtimeContextPlatform } var sched struct { @@ -53,18 +50,11 @@ var sched struct { // lowering, this ABI contains no pthread types: the selected runtime backend // decides how to provide an M and execute the G. func NewProc(fn goroutineFunc, arg unsafe.Pointer, stackSize uintptr) { - gp := newproc1(fn, arg, getg()) - if errno := newm(gp.m, stackSize); errno != 0 { - ctx := gp.context - FreeRoot(arg) - FreeRoot(ctx.root) - panic("runtime: failed to create new OS thread") - } + newprocBackend(fn, arg, stackSize, getg()) } -// newproc1 creates a runnable G and its initial M/P ownership. The pthread -// backend starts that G immediately; a future scheduler can enqueue the same G -// without changing the compiler ABI. +// newproc1 creates target-independent runnable G state. The selected backend +// attaches execution resources and either starts or queues it. func newproc1(fn goroutineFunc, arg unsafe.Pointer, callergp *g) *g { if fn == nil { panic("go of nil func value") @@ -78,7 +68,9 @@ func newproc1(fn goroutineFunc, arg unsafe.Pointer, callergp *g) *g { } func allocRuntimeContext() *runtimeContext { - size := unsafe.Sizeof(runtimeContext{}) + // LLVM rounds contexts containing 64-bit IDs to this boundary on wasm. + const contextAlignment = uintptr(unsafe.Sizeof(uint64(0))) + size := (unsafe.Sizeof(runtimeContext{}) + contextAlignment - 1) &^ (contextAlignment - 1) root := AllocRoot(size) if root == nil { panic("runtime: failed to allocate goroutine context") @@ -89,99 +81,28 @@ func allocRuntimeContext() *runtimeContext { return ctx } -// newm starts the platform execution resource for mp. -func newm(mp *m, stackSize uintptr) int { - return newosproc(mp, stackSize) -} - -// mstart is the first LLGo runtime function executed on a new M. -func mstart(arg unsafe.Pointer) unsafe.Pointer { - mp := (*m)(arg) - if mp == nil || mp.curg == nil || mp.p == nil { - fatal("runtime: invalid mstart context") - return nil - } - gp := mp.curg - pp := mp.p - - setg(gp) - casgstatus(gp, _Grunnable, _Grunning) - setpstatus(pp, _Prunning) - - fn, arg := gp.startfn, gp.startarg - gp.startfn = nil - gp.startarg = nil - ret := fn(arg) - mexit(mp) - return ret -} - -// mexit tears down the current 1:1 G/M/P context. It does not terminate the -// host thread so both a returning start routine and runtime.Goexit can share -// the same ownership cleanup. -func mexit(mp *m) { - if mp == nil || mp.curg == nil || mp.p == nil { - fatal("runtime: invalid mexit context") +func freeRuntimeContext(ctx *runtimeContext) { + if ctx == nil || ctx.root == nil { return } - gp := mp.curg - pp := mp.p - ctx := gp.context root := ctx.root - - casgstatus(gp, _Grunning, _Gdead) - setpstatus(pp, _Pdead) - - pp.m = nil - mp.p = nil - mp.curg = nil - gp.m = nil - - setg(nil) - if root != nil { - ctx.root = nil - FreeRoot(root) - } + ctx.root = nil + FreeRoot(root) } -func initRuntimeContext(ctx *runtimeContext, callergp *g, status uint32) *g { +func initG(ctx *runtimeContext, callergp *g, status uint32) *g { gp := &ctx.g - mp := &ctx.m - pp := &ctx.p - - gp.m = mp gp.atomicstatus = status gp.goid = nextGoid(gp) if callergp != nil { gp.parentGoid = callergp.goid } gp.context = ctx - - mp.curg = gp - mp.p = pp - mp.id = nextMid(mp) - - pp.id = nextPid(pp) - pstatus := uint32(_Pidle) - if status == _Grunning { - pstatus = _Prunning - } - setpstatus(pp, pstatus) - pp.m = mp return gp } -// GMPForTesting reports the current runtime ownership graph. It is kept -// internal to the compiler runtime and linked only by LLGo execution tests. -func GMPForTesting() (goid, parentGoid uint64, mid int64, pid int32, gstatus, pstatus uint32, linked bool) { - gp := getg() - if gp == nil || gp.m == nil || gp.m.p == nil { - return - } - mp := gp.m - pp := mp.p - ctx := gp.context - return gp.goid, gp.parentGoid, mp.id, pp.id, readgstatus(gp), readpstatus(pp), - mp.curg == gp && pp.m == mp && ctx != nil && - &ctx.g == gp && &ctx.m == mp && &ctx.p == pp +// Gosched asks the active backend to yield. The WebAssembly fiber backend +// switches to another runnable G; pthread Gs rely on the host thread scheduler. +func Gosched() { + goschedBackend() } diff --git a/runtime/internal/runtime/proc_atomic.go b/runtime/internal/runtime/proc_atomic.go index eaec33b38f..0920cfb7e7 100644 --- a/runtime/internal/runtime/proc_atomic.go +++ b/runtime/internal/runtime/proc_atomic.go @@ -20,16 +20,18 @@ package runtime import "github.com/goplus/llgo/runtime/internal/clite/sync/atomic" +// LLGo's atomic.Add returns the value before the addition. G and M reserve ID +// zero, while P IDs are zero-based like the Go runtime. func nextGoid(gp *g) uint64 { - return atomic.Add(&sched.goidgen, uint64(1)) + return atomic.Add(&sched.goidgen, uint64(1)) + 1 } func nextMid(mp *m) int64 { - return atomic.Add(&sched.midgen, int64(1)) + return atomic.Add(&sched.midgen, int64(1)) + 1 } func nextPid(pp *p) int32 { - return atomic.Add(&sched.pidgen, int32(1)) - 1 + return atomic.Add(&sched.pidgen, int32(1)) } func readgstatus(gp *g) uint32 { diff --git a/runtime/internal/runtime/proc_pthread.go b/runtime/internal/runtime/proc_pthread.go new file mode 100644 index 0000000000..bebc2f7740 --- /dev/null +++ b/runtime/internal/runtime/proc_pthread.go @@ -0,0 +1,120 @@ +//go:build !llgo || !wasm || (wasip1 && llgo.wasi_threads) + +/* + * 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 runtime + +import "unsafe" + +// The pthread backend keeps its one-to-one M/P pair in the G context without +// exposing those fields to other execution-context backends. +type runtimeContextPlatform struct { + m m + p p +} + +func newprocBackend(fn goroutineFunc, arg unsafe.Pointer, stackSize uintptr, callergp *g) { + gp := newproc1(fn, arg, callergp) + if errno := newm(gp.m, stackSize); errno != 0 { + FreeRoot(arg) + freeRuntimeContext(gp.context) + panic("runtime: failed to create new OS thread") + } +} + +func initRuntimeContext(ctx *runtimeContext, callergp *g, status uint32) *g { + gp := initG(ctx, callergp, status) + mp := &ctx.platform.m + pp := &ctx.platform.p + + gp.m = mp + mp.curg = gp + mp.p = pp + mp.id = nextMid(mp) + + pp.id = nextPid(pp) + pstatus := uint32(_Pidle) + if status == _Grunning { + pstatus = _Prunning + } + setpstatus(pp, pstatus) + pp.m = mp + return gp +} + +func newm(mp *m, stackSize uintptr) int { + return newosproc(mp, stackSize) +} + +func mstart(arg unsafe.Pointer) unsafe.Pointer { + mp := (*m)(arg) + if mp == nil || mp.curg == nil || mp.p == nil { + fatal("runtime: invalid mstart context") + return nil + } + gp := mp.curg + pp := mp.p + + setg(gp) + casgstatus(gp, _Grunnable, _Grunning) + setpstatus(pp, _Prunning) + + fn, arg := gp.startfn, gp.startarg + gp.startfn = nil + gp.startarg = nil + ret := fn(arg) + mexit(mp) + return ret +} + +func mexit(mp *m) { + if mp == nil || mp.curg == nil || mp.p == nil { + fatal("runtime: invalid mexit context") + return + } + gp := mp.curg + pp := mp.p + ctx := gp.context + + casgstatus(gp, _Grunning, _Gdead) + setpstatus(pp, _Pdead) + + pp.m = nil + mp.p = nil + mp.curg = nil + gp.m = nil + + setg(nil) + freeRuntimeContext(ctx) +} + +func goschedBackend() { +} + +// GMPForTesting reports the current runtime ownership graph. +func GMPForTesting() (goid, parentGoid uint64, mid int64, pid int32, gstatus, pstatus uint32, linked bool) { + gp := getg() + if gp == nil || gp.m == nil || gp.m.p == nil { + return + } + mp := gp.m + pp := mp.p + ctx := gp.context + return gp.goid, gp.parentGoid, mp.id, pp.id, readgstatus(gp), readpstatus(pp), + mp.curg == gp && pp.m == mp && ctx != nil && + &ctx.g == gp && &ctx.platform.m == mp && &ctx.platform.p == pp +} diff --git a/runtime/internal/runtime/proc_wasip1.go b/runtime/internal/runtime/proc_wasip1.go new file mode 100644 index 0000000000..b991039f42 --- /dev/null +++ b/runtime/internal/runtime/proc_wasip1.go @@ -0,0 +1,289 @@ +//go:build llgo && wasip1 && wasm && !llgo.wasi_threads + +/* + * 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 runtime + +import ( + "unsafe" + + "github.com/goplus/llgo/runtime/internal/runqueue" + "github.com/goplus/llgo/runtime/internal/wasmcontext" +) + +const ( + defaultWasmGStackSize = 64 << 10 + defaultWasmAsyncifyStackSize = 64 << 10 +) + +type runtimeContextPlatform struct { + context wasmcontext.Context + gcRoot wasmGCRootContext + stack unsafe.Pointer + asyncifyStack unsafe.Pointer +} + +var wasmSched struct { + m m + p p + runq runqueue.Queue[*g] + started bool + mainExited bool +} + +var wasmSystemGCRoot wasmGCRootContext + +func initRuntimeContext(ctx *runtimeContext, callergp *g, status uint32) *g { + gp := initG(ctx, callergp, status) + if wasmGCRootEnabled { + registerWasmGCRoot(&ctx.platform.gcRoot, false) + } + if status == _Grunning { + initWasmScheduler(gp) + } + return gp +} + +func initWasmScheduler(gp *g) { + if wasmSched.started { + fatal("runtime: WebAssembly scheduler initialized twice") + return + } + wasmSched.started = true + if wasmGCRootEnabled { + registerWasmGCRoot(&wasmSystemGCRoot, true) + } + mp := &wasmSched.m + pp := &wasmSched.p + mp.curg = gp + mp.p = pp + mp.id = nextMid(mp) + pp.id = nextPid(pp) + setpstatus(pp, _Prunning) + pp.m = mp + gp.m = mp +} + +//go:linkname wasmMainTask __llgo_wasm_main +func wasmMainTask(unsafe.Pointer) unsafe.Pointer + +// RunWasmMain runs package initialization and main.main as the first +// Asyncify task. It remains on the system stack and dispatches one G at a time. +func RunWasmMain() { + gp := getg() + if gp == nil || !gp.isMain { + fatal("runtime: invalid WebAssembly main goroutine") + return + } + initWasmContext(gp, wasmcontext.Entry(wasmMainTask), nil, 0) + + for { + runWasmContext(gp) + status := readgstatus(gp) + if gp.isMain && status == _Grunning { + casgstatus(gp, _Grunning, _Gdead) + releaseWasmContext(gp) + return + } + releaseWasmOwnership(gp) + if status == _Gdead { + releaseWasmContext(gp) + } + + gp = waitWasmRunq() + if gp == nil { + if wasmSched.mainExited { + fatal("no goroutines (main called runtime.Goexit) - deadlock!") + } else { + fatal("all goroutines are asleep - deadlock!") + } + return + } + } +} + +func runWasmContext(gp *g) { + if readgstatus(gp) == _Grunnable { + casgstatus(gp, _Grunnable, _Grunning) + } + mp := &wasmSched.m + pp := &wasmSched.p + mp.curg = gp + pp.m = mp + gp.m = mp + setg(gp) + gp.context.platform.context.Resume( + wasmGCRootPointer(&gp.context.platform.gcRoot), + ) + if wasmGCRootEnabled { + adoptWasmGCRoot(&wasmSystemGCRoot) + } +} + +func releaseWasmOwnership(gp *g) { + if gp != nil { + gp.m = nil + } + wasmSched.m.curg = nil +} + +func newprocBackend(fn goroutineFunc, arg unsafe.Pointer, stackSize uintptr, callergp *g) { + gp := newproc1(fn, arg, callergp) + initWasmContext(gp, wasmcontext.Entry(wasmGStart), unsafe.Pointer(gp), stackSize) + if !wasmSched.runq.Push(gp) { + fatal("runtime: invalid run queue insertion") + } +} + +func initWasmContext(gp *g, entry wasmcontext.Entry, arg unsafe.Pointer, stackSize uintptr) { + if stackSize == 0 { + stackSize = defaultWasmGStackSize + } + stackSize = alignWasmStackSize(stackSize) + asyncifySize := uintptr(defaultWasmAsyncifyStackSize) + if stackSize > asyncifySize { + asyncifySize = stackSize + } + + platform := &gp.context.platform + platform.stack = allocWasmStack(stackSize) + platform.asyncifyStack = allocWasmStack(asyncifySize) + platform.context.Init( + entry, + arg, + platform.stack, + stackSize, + platform.asyncifyStack, + asyncifySize, + ) +} + +func alignWasmStackSize(size uintptr) uintptr { + const alignment = uintptr(16) + return (size + alignment - 1) &^ (alignment - 1) +} + +func allocWasmStack(size uintptr) unsafe.Pointer { + stack := AllocRoot(size) + if stack == nil { + panic("runtime: failed to allocate WebAssembly goroutine stack") + } + return stack +} + +func releaseWasmContext(gp *g) { + if gp == nil || gp.context == nil { + return + } + ctx := gp.context + platform := &ctx.platform + if wasmGCRootEnabled { + unregisterWasmGCRoot(&platform.gcRoot) + } + if platform.stack != nil { + FreeRoot(platform.stack) + platform.stack = nil + } + if platform.asyncifyStack != nil { + FreeRoot(platform.asyncifyStack) + platform.asyncifyStack = nil + } + freeRuntimeContext(ctx) +} + +func wasmGStart(arg unsafe.Pointer) unsafe.Pointer { + gp := (*g)(arg) + if gp == nil || getg() != gp { + fatal("runtime: invalid WebAssembly goroutine entry") + return nil + } + fn, fnarg := gp.startfn, gp.startarg + gp.startfn = nil + gp.startarg = nil + ret := fn(fnarg) + goexitBackend(gp) + return ret +} + +func goschedBackend() { + gp := getg() + casgstatus(gp, _Grunning, _Grunnable) + if !wasmSched.runq.Push(gp) { + fatal("runtime: invalid run queue insertion") + return + } + gp.context.platform.context.Suspend(wasmGCRootPointer(&wasmSystemGCRoot)) +} + +func gopark() { + gp := getg() + casgstatus(gp, _Grunning, _Gwaiting) + gp.context.platform.context.Suspend(wasmGCRootPointer(&wasmSystemGCRoot)) +} + +func goready(gp *g) { + if gp == nil { + fatal("runtime: ready of nil goroutine") + return + } + casgstatus(gp, _Gwaiting, _Grunnable) + if !wasmSched.runq.Push(gp) { + fatal("runtime: invalid run queue insertion") + } +} + +func goexitBackend(gp *g) { + casgstatus(gp, _Grunning, _Gdead) + if gp.isMain { + wasmSched.mainExited = true + } + gp.context.platform.context.Suspend(wasmGCRootPointer(&wasmSystemGCRoot)) + fatal("runtime: resumed dead WebAssembly goroutine") +} + +// CurrentGForTesting returns an opaque handle suitable for ReadyForTesting. +func CurrentGForTesting() unsafe.Pointer { + return unsafe.Pointer(getg()) +} + +// ParkForTesting parks the current G until another G marks it runnable. +func ParkForTesting() { + gopark() +} + +// ReadyForTesting makes a G previously parked by ParkForTesting runnable. +func ReadyForTesting(handle unsafe.Pointer) { + goready((*g)(handle)) +} + +// SchedulerStateForTesting reports single-worker queue and ownership state. +func SchedulerStateForTesting() (runq uintptr, mid int64, pid int32) { + return wasmSched.runq.Len(), wasmSched.m.id, wasmSched.p.id +} + +func GMPForTesting() (goid, parentGoid uint64, mid int64, pid int32, gstatus, pstatus uint32, linked bool) { + gp := getg() + if gp == nil || gp.m == nil || gp.m.p == nil { + return + } + mp := gp.m + pp := mp.p + ctx := gp.context + return gp.goid, gp.parentGoid, mp.id, pp.id, readgstatus(gp), readpstatus(pp), + mp == &wasmSched.m && pp == &wasmSched.p && + mp.curg == gp && pp.m == mp && ctx != nil && &ctx.g == gp +} diff --git a/runtime/internal/runtime/proc_wasm.go b/runtime/internal/runtime/proc_wasm.go new file mode 100644 index 0000000000..f3151fc95f --- /dev/null +++ b/runtime/internal/runtime/proc_wasm.go @@ -0,0 +1,296 @@ +//go:build llgo && js && wasm && !llgo.wasm_workers + +/* + * 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 runtime + +import ( + "unsafe" + + "github.com/goplus/llgo/runtime/internal/runqueue" + "github.com/goplus/llgo/runtime/internal/wasmcontext" +) + +const ( + defaultWasmGStackSize = 64 << 10 + defaultWasmAsyncifyStackSize = 64 << 10 +) + +type runtimeContextPlatform struct { + context wasmcontext.Context + gcRoot wasmGCRootContext + stack unsafe.Pointer + asyncifyStack unsafe.Pointer +} + +var wasmSched struct { + m m + p p + runq runqueue.Queue[*g] + retired *runtimeContext + started bool +} + +func initRuntimeContext(ctx *runtimeContext, callergp *g, status uint32) *g { + gp := initG(ctx, callergp, status) + if wasmGCRootEnabled { + registerWasmGCRoot(&ctx.platform.gcRoot, status == _Grunning) + } + if status == _Grunning { + initWasmScheduler(gp) + } + return gp +} + +func initWasmScheduler(gp *g) { + if wasmSched.started { + fatal("runtime: WebAssembly scheduler initialized twice") + return + } + wasmSched.started = true + mp := &wasmSched.m + pp := &wasmSched.p + mp.curg = gp + mp.p = pp + mp.id = nextMid(mp) + pp.id = nextPid(pp) + setpstatus(pp, _Prunning) + pp.m = mp + gp.m = mp +} + +func newprocBackend(fn goroutineFunc, arg unsafe.Pointer, stackSize uintptr, callergp *g) { + gp := newproc1(fn, arg, callergp) + initWasmFiber(gp, stackSize) + if !wasmSched.runq.Push(gp) { + fatal("runtime: invalid run queue insertion") + } +} + +func initWasmFiber(gp *g, stackSize uintptr) { + if stackSize == 0 { + stackSize = defaultWasmGStackSize + } + stackSize = alignWasmStackSize(stackSize) + asyncifySize := uintptr(defaultWasmAsyncifyStackSize) + if stackSize > asyncifySize { + asyncifySize = stackSize + } + + platform := &gp.context.platform + platform.stack = allocWasmStack(stackSize) + platform.asyncifyStack = allocWasmStack(asyncifySize) + platform.context.Init( + wasmcontext.Entry(wasmGStart), + unsafe.Pointer(gp), + platform.stack, + stackSize, + platform.asyncifyStack, + asyncifySize, + ) +} + +func alignWasmStackSize(size uintptr) uintptr { + const alignment = uintptr(16) + return (size + alignment - 1) &^ (alignment - 1) +} + +func allocWasmStack(size uintptr) unsafe.Pointer { + stack := AllocRoot(size) + if stack == nil { + panic("runtime: failed to allocate WebAssembly goroutine stack") + } + return stack +} + +func ensureCurrentWasmFiber(gp *g) { + platform := &gp.context.platform + if platform.asyncifyStack != nil { + return + } + platform.asyncifyStack = allocWasmStack(defaultWasmAsyncifyStackSize) + platform.context.InitCurrent(platform.asyncifyStack, defaultWasmAsyncifyStackSize) +} + +func wasmGStart(arg unsafe.Pointer) { + gp := (*g)(arg) + if gp == nil || getg() != gp { + fatal("runtime: invalid WebAssembly goroutine entry") + return + } + reapRetiredWasmG() + fn, fnarg := gp.startfn, gp.startarg + gp.startfn = nil + gp.startarg = nil + fn(fnarg) + goexitBackend(gp) +} + +func goschedBackend() { + gp := getg() + casgstatus(gp, _Grunning, _Grunnable) + if !wasmSched.runq.Push(gp) { + fatal("runtime: invalid run queue insertion") + return + } + next := popWasmRunq() + if next == gp { + casgstatus(gp, _Grunnable, _Grunning) + return + } + resumeWasmG(gp, next) +} + +func gopark() { + gp := getg() + casgstatus(gp, _Grunning, _Gwaiting) + next := waitWasmRunq() + if next == nil { + fatal("all goroutines are asleep - deadlock!") + return + } + if next == gp { + casgstatus(gp, _Grunnable, _Grunning) + return + } + resumeWasmG(gp, next) +} + +func goready(gp *g) { + if gp == nil { + fatal("runtime: ready of nil goroutine") + return + } + casgstatus(gp, _Gwaiting, _Grunnable) + if !wasmSched.runq.Push(gp) { + fatal("runtime: invalid run queue insertion") + } +} + +func resumeWasmG(old, next *g) { + if old == nil || next == nil || next.context == nil { + fatal("runtime: invalid WebAssembly context switch") + return + } + ensureCurrentWasmFiber(old) + if next.context.platform.asyncifyStack == nil { + fatal("runtime: uninitialized WebAssembly goroutine context") + return + } + + casgstatus(next, _Grunnable, _Grunning) + mp := &wasmSched.m + old.m = nil + next.m = mp + mp.curg = next + setg(next) + old.context.platform.context.Swap( + &next.context.platform.context, + wasmGCRootPointer(&next.context.platform.gcRoot), + ) + reapRetiredWasmG() +} + +func goexitBackend(gp *g) { + casgstatus(gp, _Grunning, _Gdead) + if wasmSched.retired != nil { + fatal("runtime: unreaped WebAssembly goroutine") + return + } + + next := waitWasmRunq() + if next == nil { + if gp.isMain { + fatal("no goroutines (main called runtime.Goexit) - deadlock!") + } else { + fatal("all goroutines are asleep - deadlock!") + } + return + } + + wasmSched.retired = gp.context + resumeDeadWasmG(gp, next) +} + +func resumeDeadWasmG(old, next *g) { + ensureCurrentWasmFiber(old) + casgstatus(next, _Grunnable, _Grunning) + mp := &wasmSched.m + old.m = nil + next.m = mp + mp.curg = next + setg(next) + old.context.platform.context.Swap( + &next.context.platform.context, + wasmGCRootPointer(&next.context.platform.gcRoot), + ) + fatal("runtime: resumed dead WebAssembly goroutine") +} + +func reapRetiredWasmG() { + ctx := wasmSched.retired + if ctx == nil { + return + } + wasmSched.retired = nil + platform := &ctx.platform + if wasmGCRootEnabled { + unregisterWasmGCRoot(&platform.gcRoot) + } + if platform.stack != nil { + FreeRoot(platform.stack) + platform.stack = nil + } + if platform.asyncifyStack != nil { + FreeRoot(platform.asyncifyStack) + platform.asyncifyStack = nil + } + freeRuntimeContext(ctx) +} + +// CurrentGForTesting returns an opaque handle suitable for ReadyForTesting. +func CurrentGForTesting() unsafe.Pointer { + return unsafe.Pointer(getg()) +} + +// ParkForTesting parks the current G until another G marks it runnable. +func ParkForTesting() { + gopark() +} + +// ReadyForTesting makes a G previously parked by ParkForTesting runnable. +func ReadyForTesting(handle unsafe.Pointer) { + goready((*g)(handle)) +} + +// SchedulerStateForTesting reports single-worker queue and ownership state. +func SchedulerStateForTesting() (runq uintptr, mid int64, pid int32) { + return wasmSched.runq.Len(), wasmSched.m.id, wasmSched.p.id +} + +func GMPForTesting() (goid, parentGoid uint64, mid int64, pid int32, gstatus, pstatus uint32, linked bool) { + gp := getg() + if gp == nil || gp.m == nil || gp.m.p == nil { + return + } + mp := gp.m + pp := mp.p + ctx := gp.context + return gp.goid, gp.parentGoid, mp.id, pp.id, readgstatus(gp), readpstatus(pp), + mp == &wasmSched.m && pp == &wasmSched.p && + mp.curg == gp && pp.m == mp && ctx != nil && &ctx.g == gp +} diff --git a/runtime/internal/runtime/proc_wasm_workers.go b/runtime/internal/runtime/proc_wasm_workers.go new file mode 100644 index 0000000000..b5c488d115 --- /dev/null +++ b/runtime/internal/runtime/proc_wasm_workers.go @@ -0,0 +1,464 @@ +//go:build llgo && js && wasm && llgo.wasm_workers + +/* + * 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 runtime + +import ( + "unsafe" + + c "github.com/goplus/llgo/runtime/internal/clite" + "github.com/goplus/llgo/runtime/internal/clite/pthread/sync" + "github.com/goplus/llgo/runtime/internal/clite/sync/atomic" + "github.com/goplus/llgo/runtime/internal/pollbudget" + "github.com/goplus/llgo/runtime/internal/runqueue" + "github.com/goplus/llgo/runtime/internal/wasmcontext" + "github.com/goplus/llgo/runtime/internal/wasmevent" + "github.com/goplus/llgo/runtime/internal/wasmworkers" +) + +const ( + defaultWasmGStackSize = 64 << 10 + defaultWasmAsyncifyStackSize = 64 << 10 + maxWasmWorkers = 16 +) + +type runtimeContextPlatform struct { + context wasmcontext.Context + gcRoot wasmGCRootContext + stack unsafe.Pointer + asyncifyStack unsafe.Pointer + owner *wasmWorker +} + +type wasmWorker struct { + m m + p p + + lock sync.Mutex + runq runqueue.Queue[*g] + wake uint32 + + system wasmcontext.Context + systemAsyncifyStack unsafe.Pointer + index int + safepointBudget pollbudget.Budget +} + +var wasmMultiSched struct { + workers [maxWasmWorkers]wasmWorker + count int + + nextWorker uint32 + active uint32 + started bool + + mainReturned bool + mainGoexit bool +} + +func initRuntimeContext(ctx *runtimeContext, callergp *g, status uint32) *g { + gp := initG(ctx, callergp, status) + if status == _Grunning { + initWasmScheduler(gp) + } + return gp +} + +func initWasmScheduler(gp *g) { + if wasmMultiSched.started { + fatal("runtime: WebAssembly scheduler initialized twice") + return + } + count := wasmworkers.Count() + if count < 2 || count > maxWasmWorkers { + fatal("runtime: invalid WebAssembly worker count") + return + } + wasmMultiSched.count = count + // atomic.Add returns the pre-increment value, so the first child starts + // away from the main worker. + wasmMultiSched.nextWorker = 1 + wasmMultiSched.active = 1 + + for i := 0; i < count; i++ { + worker := &wasmMultiSched.workers[i] + worker.index = i + worker.safepointBudget = pollbudget.New(wasmSafepointQuantum) + if worker.lock.Init(nil) != 0 { + fatal("runtime: failed to initialize WebAssembly worker queue") + return + } + worker.m.id = nextMid(&worker.m) + worker.m.p = &worker.p + worker.p.id = nextPid(&worker.p) + worker.p.m = &worker.m + setpstatus(&worker.p, _Prunning) + } + + mainWorker := &wasmMultiSched.workers[0] + setCurrentWasmWorker(mainWorker) + bindWasmWorkerG(mainWorker, gp) + gp.context.platform.owner = mainWorker + wasmMultiSched.started = true + + for i := 1; i < count; i++ { + worker := &wasmMultiSched.workers[i] + if errno := wasmworkers.Start(wasmworkers.Entry(wasmWorkerStart), unsafe.Pointer(worker), 0); errno != 0 { + fatal("runtime: failed to start WebAssembly worker") + return + } + } + wasmevent.InstallWake(wakeWasmEventWorker) +} + +//go:linkname wasmMainTask __llgo_wasm_main +func wasmMainTask(unsafe.Pointer) unsafe.Pointer + +func RunWasmMain() { + gp := getg() + worker := currentWasmWorker() + if gp == nil || !gp.isMain || worker == nil || worker.index != 0 { + fatal("runtime: invalid WebAssembly main goroutine") + return + } + initWasmFiber(gp, wasmcontext.Entry(wasmMainStart), unsafe.Pointer(gp), 0) + initWasmWorkerSystem(worker) + releaseWasmWorkerG(worker, gp) + casgstatus(gp, _Grunning, _Grunnable) + enqueueWasmG(worker, gp) + runWasmWorker(worker, true) + c.Exit(0) +} + +func wasmMainStart(arg unsafe.Pointer) { + gp := (*g)(arg) + if gp == nil || getg() != gp { + fatal("runtime: invalid WebAssembly main entry") + return + } + wasmMainTask(nil) + wasmMultiSched.mainReturned = true + finishWasmG(gp) +} + +func wasmWorkerStart(arg unsafe.Pointer) unsafe.Pointer { + worker := (*wasmWorker)(arg) + if worker == nil || worker.index == 0 { + fatal("runtime: invalid WebAssembly worker entry") + return nil + } + setCurrentWasmWorker(worker) + setg(nil) + initWasmWorkerSystem(worker) + runWasmWorker(worker, false) + return nil +} + +func initWasmWorkerSystem(worker *wasmWorker) { + if worker.systemAsyncifyStack != nil { + return + } + worker.systemAsyncifyStack = allocWasmStack(defaultWasmAsyncifyStackSize) + worker.system.InitCurrent(worker.systemAsyncifyStack, defaultWasmAsyncifyStackSize) +} + +func runWasmWorker(worker *wasmWorker, stopAtMain bool) { + for { + gp := waitWasmWorkerRunq(worker) + if gp == nil { + continue + } + casgstatus(gp, _Grunnable, _Grunning) + bindWasmWorkerG(worker, gp) + setg(gp) + worker.system.Swap( + &gp.context.platform.context, + wasmGCRootPointer(&gp.context.platform.gcRoot), + ) + setg(nil) + releaseWasmWorkerG(worker, gp) + + if readgstatus(gp) != _Gdead { + continue + } + isMain := gp.isMain + releaseWasmContext(gp) + if isMain && stopAtMain { + if wasmMultiSched.mainReturned { + return + } + if wasmMultiSched.mainGoexit && atomic.Load(&wasmMultiSched.active) == 0 { + fatal("no goroutines (main called runtime.Goexit) - deadlock!") + return + } + } + } +} + +func bindWasmWorkerG(worker *wasmWorker, gp *g) { + worker.m.curg = gp + gp.m = &worker.m +} + +func releaseWasmWorkerG(worker *wasmWorker, gp *g) { + if gp != nil { + gp.m = nil + } + worker.m.curg = nil +} + +func newprocBackend(fn goroutineFunc, arg unsafe.Pointer, stackSize uintptr, callergp *g) { + gp := newproc1(fn, arg, callergp) + worker := nextWasmWorker() + gp.context.platform.owner = worker + initWasmFiber(gp, wasmcontext.Entry(wasmGStart), unsafe.Pointer(gp), stackSize) + atomic.Add(&wasmMultiSched.active, uint32(1)) + enqueueWasmG(worker, gp) +} + +func nextWasmWorker() *wasmWorker { + index := atomic.Add(&wasmMultiSched.nextWorker, uint32(1)) + return &wasmMultiSched.workers[int(index%uint32(wasmMultiSched.count))] +} + +func initWasmFiber(gp *g, entry wasmcontext.Entry, arg unsafe.Pointer, stackSize uintptr) { + if stackSize == 0 { + stackSize = defaultWasmGStackSize + } + stackSize = alignWasmStackSize(stackSize) + asyncifySize := uintptr(defaultWasmAsyncifyStackSize) + if stackSize > asyncifySize { + asyncifySize = stackSize + } + + platform := &gp.context.platform + platform.stack = allocWasmStack(stackSize) + platform.asyncifyStack = allocWasmStack(asyncifySize) + platform.context.Init( + entry, + arg, + platform.stack, + stackSize, + platform.asyncifyStack, + asyncifySize, + ) +} + +func alignWasmStackSize(size uintptr) uintptr { + const alignment = uintptr(16) + return (size + alignment - 1) &^ (alignment - 1) +} + +func allocWasmStack(size uintptr) unsafe.Pointer { + stack := AllocRoot(size) + if stack == nil { + panic("runtime: failed to allocate WebAssembly goroutine stack") + } + return stack +} + +func releaseWasmContext(gp *g) { + if gp == nil || gp.context == nil { + return + } + ctx := gp.context + platform := &ctx.platform + if platform.stack != nil { + FreeRoot(platform.stack) + platform.stack = nil + } + if platform.asyncifyStack != nil { + FreeRoot(platform.asyncifyStack) + platform.asyncifyStack = nil + } + freeRuntimeContext(ctx) +} + +func wasmGStart(arg unsafe.Pointer) { + gp := (*g)(arg) + if gp == nil || getg() != gp { + fatal("runtime: invalid WebAssembly goroutine entry") + return + } + fn, fnarg := gp.startfn, gp.startarg + gp.startfn = nil + gp.startarg = nil + fn(fnarg) + finishWasmG(gp) +} + +func finishWasmG(gp *g) { + casgstatus(gp, _Grunning, _Gdead) + atomic.Add(&wasmMultiSched.active, ^uint32(0)) + wakeWasmEventWorker() + worker := gp.context.platform.owner + gp.context.platform.context.Swap(&worker.system, nil) + fatal("runtime: resumed dead WebAssembly goroutine") +} + +func goschedBackend() { + gp := getg() + worker := currentWasmWorker() + casgstatus(gp, _Grunning, _Grunnable) + enqueueWasmG(worker, gp) + gp.context.platform.context.Swap(&worker.system, nil) +} + +func gopark() { + gp := getg() + parkWasmG(gp) +} + +func parkWasmG(gp *g) { + casgstatus(gp, _Grunning, _Gwaiting) + atomic.Add(&wasmMultiSched.active, ^uint32(0)) + wakeWasmEventWorker() + worker := gp.context.platform.owner + gp.context.platform.context.Swap(&worker.system, nil) +} + +func goready(gp *g) { + if gp == nil { + fatal("runtime: ready of nil goroutine") + return + } + casgstatus(gp, _Gwaiting, _Grunnable) + atomic.Add(&wasmMultiSched.active, uint32(1)) + enqueueWasmG(gp.context.platform.owner, gp) +} + +func goexitBackend(gp *g) { + if gp.isMain { + wasmMultiSched.mainGoexit = true + } + finishWasmG(gp) +} + +func enqueueWasmG(worker *wasmWorker, gp *g) { + if worker == nil { + fatal("runtime: enqueue on nil WebAssembly worker") + return + } + worker.lock.Lock() + ok := worker.runq.Push(gp) + worker.lock.Unlock() + if !ok { + fatal("runtime: invalid WebAssembly run queue insertion") + return + } + wakeWasmWorker(worker) +} + +func popWasmWorkerRunq(worker *wasmWorker) *g { + worker.lock.Lock() + gp := worker.runq.Pop() + worker.lock.Unlock() + return gp +} + +func wasmWorkerRunqLen(worker *wasmWorker) uintptr { + worker.lock.Lock() + size := worker.runq.Len() + worker.lock.Unlock() + return size +} + +func wakeWasmWorker(worker *wasmWorker) { + atomic.Add(&worker.wake, uint32(1)) + wasmworkers.Wake(&worker.wake) +} + +func wakeWasmEventWorker() { + if wasmMultiSched.count != 0 { + wakeWasmWorker(&wasmMultiSched.workers[0]) + } +} + +func waitWasmWorkerRunq(worker *wasmWorker) *g { + for { + if gp := popWasmWorkerRunq(worker); gp != nil { + return gp + } + timeout := int64(-1) + if worker.index == 0 { + wasmevent.Poll() + if gp := popWasmWorkerRunq(worker); gp != nil { + return gp + } + now := wasmevent.Now() + if deadline, ok := wasmevent.NextDeadline(); ok { + timeout = deadline - now + if timeout < 0 { + timeout = 0 + } + } else if atomic.Load(&wasmMultiSched.active) == 0 { + if wasmMultiSched.mainGoexit { + fatal("no goroutines (main called runtime.Goexit) - deadlock!") + } else { + fatal("all goroutines are asleep - deadlock!") + } + return nil + } + } + + sequence := atomic.Load(&worker.wake) + if gp := popWasmWorkerRunq(worker); gp != nil { + return gp + } + wasmworkers.Wait(&worker.wake, sequence, timeout) + } +} + +// CurrentGForTesting returns an opaque handle suitable for ReadyForTesting. +func CurrentGForTesting() unsafe.Pointer { + return unsafe.Pointer(getg()) +} + +func ParkForTesting() { + gopark() +} + +func ReadyForTesting(handle unsafe.Pointer) { + goready((*g)(handle)) +} + +func SchedulerStateForTesting() (runq uintptr, mid int64, pid int32) { + worker := currentWasmWorker() + if worker == nil { + return + } + for i := 0; i < wasmMultiSched.count; i++ { + runq += wasmWorkerRunqLen(&wasmMultiSched.workers[i]) + } + return runq, worker.m.id, worker.p.id +} + +func GMPForTesting() (goid, parentGoid uint64, mid int64, pid int32, gstatus, pstatus uint32, linked bool) { + gp := getg() + worker := currentWasmWorker() + if gp == nil || worker == nil || gp.m == nil || gp.m.p == nil { + return + } + mp := gp.m + pp := mp.p + ctx := gp.context + return gp.goid, gp.parentGoid, mp.id, pp.id, readgstatus(gp), readpstatus(pp), + mp == &worker.m && pp == &worker.p && mp.curg == gp && + pp.m == mp && ctx != nil && &ctx.g == gp && + ctx.platform.owner == worker +} diff --git a/runtime/internal/runtime/rethrow_default.go b/runtime/internal/runtime/rethrow_default.go new file mode 100644 index 0000000000..9f46735fc3 --- /dev/null +++ b/runtime/internal/runtime/rethrow_default.go @@ -0,0 +1,32 @@ +//go:build !baremetal && (!wasm || !llgo_wasm_gc) + +package runtime + +import ( + c "github.com/goplus/llgo/runtime/internal/clite" + "github.com/goplus/llgo/runtime/internal/clite/debug" +) + +// Rethrow rethrows a panic. +func Rethrow(link *Defer) { + gp := getg() + if ptr := gp.panic_; ptr != nil { + if link == nil { + TracePanic(*(*any)(ptr)) + if PanicTraceback == nil || !PanicTraceback(2) { + debug.PrintStack(2) + } + c.Free(ptr) + c.Exit(2) + } else { + c.Siglongjmp(link.Addr, 1) + } + } else if gp.goexit { + // Goexit runs deferred functions before the selected scheduler removes + // the current goroutine. + if link != nil { + c.Siglongjmp(link.Addr, 1) + } + goexitBackend(gp) + } +} diff --git a/runtime/internal/runtime/rethrow_wasm_gc.go b/runtime/internal/runtime/rethrow_wasm_gc.go new file mode 100644 index 0000000000..2120700239 --- /dev/null +++ b/runtime/internal/runtime/rethrow_wasm_gc.go @@ -0,0 +1,33 @@ +//go:build wasm && llgo_wasm_gc + +package runtime + +import ( + c "github.com/goplus/llgo/runtime/internal/clite" + "github.com/goplus/llgo/runtime/internal/clite/debug" + "github.com/goplus/llgo/runtime/internal/gcroot" +) + +// Rethrow rethrows a panic after discarding roots owned by skipped frames. +func Rethrow(link *Defer) { + gp := getg() + if ptr := gp.panic_; ptr != nil { + if link == nil { + TracePanic(*(*any)(ptr)) + if PanicTraceback == nil || !PanicTraceback(2) { + debug.PrintStack(2) + } + c.Free(ptr) + c.Exit(2) + } else { + gcroot.RestoreChain(link.gcRoot) + c.Siglongjmp(link.Addr, 1) + } + } else if gp.goexit { + if link != nil { + gcroot.RestoreChain(link.gcRoot) + c.Siglongjmp(link.Addr, 1) + } + goexitBackend(gp) + } +} diff --git a/runtime/internal/runtime/runtime2.go b/runtime/internal/runtime/runtime2.go index 8e0fe9dc7a..e617ca3cef 100644 --- a/runtime/internal/runtime/runtime2.go +++ b/runtime/internal/runtime/runtime2.go @@ -19,10 +19,11 @@ package runtime import "unsafe" // These G and P states intentionally keep the values used by the Go runtime. -// Only states reachable by the current 1:1 backend are defined here. +// Only states reachable by the current backends are defined here. const ( _Grunnable = 1 _Grunning = 2 + _Gwaiting = 4 _Gdead = 6 ) @@ -34,9 +35,9 @@ const ( // g holds state owned by one LLGo goroutine. // -// The current pthread backend gives every G its own M and P. Fields that only -// make sense once LLGo can suspend and resume a G (saved registers, wait state, -// and stack roots) belong here when those facilities are added. +// A backend decides the M/P ownership model: pthread gives every G its own M/P, +// while the WebAssembly fiber scheduler shares one M/P across its Gs. Suspended +// execution state is held by the backend-specific runtimeContext. type g struct { defer_ *Defer panic_ unsafe.Pointer @@ -54,11 +55,32 @@ type g struct { goexit bool isMain bool paniconfault bool + + runqQueued uint32 + runqNext *g +} + +func (gp *g) RunqueueNext() *g { + return gp.runqNext +} + +func (gp *g) SetRunqueueNext(next *g) { + gp.runqNext = next +} + +func (gp *g) RunqueueQueued() bool { + return gp.runqQueued != 0 +} + +func (gp *g) SetRunqueueQueued(queued bool) { + if queued { + gp.runqQueued = 1 + } else { + gp.runqQueued = 0 + } } -// m represents the host execution resource running Go code. The platform -// thread handle is deliberately confined to mOS so other backends do not leak -// pthread types into the scheduler core. +// m represents the host execution resource running Go code. type m struct { curg *g p *p @@ -66,9 +88,7 @@ type m struct { os mOS } -// p represents the scheduling resources attached to an M. The pthread backend -// currently binds one P to one M; a later M:N scheduler can retain this object -// while replacing that fixed binding with a P pool and run queues. +// p represents the scheduling resources attached to an M. type p struct { id int32 status uint32 diff --git a/runtime/internal/runtime/safepoint_stub.go b/runtime/internal/runtime/safepoint_stub.go new file mode 100644 index 0000000000..4ba143e39f --- /dev/null +++ b/runtime/internal/runtime/safepoint_stub.go @@ -0,0 +1,23 @@ +//go:build !llgo || !wasm || (!llgo_wasm_gc && !llgo.wasm_workers) || (wasip1 && llgo.wasi_threads) + +/* + * 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 runtime + +// CooperativeSafepoint is inactive on runtimes without wasm cooperative +// scheduling. +func CooperativeSafepoint() {} diff --git a/runtime/internal/runtime/safepoint_wasm.go b/runtime/internal/runtime/safepoint_wasm.go new file mode 100644 index 0000000000..8b9defd84d --- /dev/null +++ b/runtime/internal/runtime/safepoint_wasm.go @@ -0,0 +1,48 @@ +//go:build llgo && wasm && llgo_wasm_gc && !(wasip1 && llgo.wasi_threads) && !llgo.wasm_workers + +/* + * 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 runtime + +import ( + "github.com/goplus/llgo/runtime/internal/pollbudget" + "github.com/goplus/llgo/runtime/internal/wasmevent" +) + +const wasmSafepointQuantum = uint32(1024) + +var wasmSafepointBudget = pollbudget.New(wasmSafepointQuantum) + +// CooperativeSafepoint gives the single wasm worker a bounded opportunity to +// run host events and another runnable goroutine. +func CooperativeSafepoint() { + if !wasmSafepointBudget.Poll() { + return + } + cooperativeSafepointSlow() +} + +//go:noinline +func cooperativeSafepointSlow() { + if !wasmSched.started { + return + } + wasmevent.Poll() + if wasmSched.runq.Len() != 0 { + goschedBackend() + } +} diff --git a/runtime/internal/runtime/safepoint_wasm_workers.go b/runtime/internal/runtime/safepoint_wasm_workers.go new file mode 100644 index 0000000000..9c3b6db065 --- /dev/null +++ b/runtime/internal/runtime/safepoint_wasm_workers.go @@ -0,0 +1,29 @@ +//go:build llgo && js && wasm && llgo.wasm_workers + +package runtime + +import "github.com/goplus/llgo/runtime/internal/wasmevent" + +const wasmSafepointQuantum = uint32(1024) + +func CooperativeSafepoint() { + worker := currentWasmWorker() + if worker == nil || !worker.safepointBudget.Poll() { + return + } + cooperativeSafepointSlow() +} + +//go:noinline +func cooperativeSafepointSlow() { + worker := currentWasmWorker() + if worker == nil { + return + } + if worker.index == 0 { + wasmevent.Poll() + } + if wasmWorkerRunqLen(worker) != 0 { + goschedBackend() + } +} diff --git a/runtime/internal/runtime/scheduler_events_wasm.go b/runtime/internal/runtime/scheduler_events_wasm.go new file mode 100644 index 0000000000..abc44c940c --- /dev/null +++ b/runtime/internal/runtime/scheduler_events_wasm.go @@ -0,0 +1,37 @@ +//go:build llgo && wasm && !(wasip1 && llgo.wasi_threads) && !llgo.wasm_workers + +/* + * 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 runtime + +import "github.com/goplus/llgo/runtime/internal/wasmevent" + +func popWasmRunq() *g { + wasmevent.Poll() + return wasmSched.runq.Pop() +} + +func waitWasmRunq() *g { + for { + if gp := popWasmRunq(); gp != nil { + return gp + } + if !wasmevent.Wait() { + return nil + } + } +} diff --git a/runtime/internal/runtime/scheduler_waiter_wasm.go b/runtime/internal/runtime/scheduler_waiter_wasm.go new file mode 100644 index 0000000000..fe68b8d2ba --- /dev/null +++ b/runtime/internal/runtime/scheduler_waiter_wasm.go @@ -0,0 +1,32 @@ +//go:build llgo && wasm && !(wasip1 && llgo.wasi_threads) && !llgo.wasm_workers + +package runtime + +// SchedulerWaiter is an opaque handle used by runtime primitives that park +// the current G without exposing scheduler-owned state. +type SchedulerWaiter struct { + gp *g +} + +// CurrentSchedulerWaiter returns a handle for the current G. +func CurrentSchedulerWaiter() SchedulerWaiter { + return SchedulerWaiter{gp: getg()} +} + +// Park suspends the waiter until Ready makes it runnable. +func (w SchedulerWaiter) Park() { + if w.gp == nil || getg() != w.gp { + fatal("runtime: invalid WebAssembly scheduler waiter") + return + } + gopark() +} + +// Ready makes a previously parked waiter runnable. +func (w SchedulerWaiter) Ready() { + if w.gp == nil { + fatal("runtime: ready of invalid WebAssembly scheduler waiter") + return + } + goready(w.gp) +} diff --git a/runtime/internal/runtime/scheduler_waiter_wasm_workers.go b/runtime/internal/runtime/scheduler_waiter_wasm_workers.go new file mode 100644 index 0000000000..f8ff38c69c --- /dev/null +++ b/runtime/internal/runtime/scheduler_waiter_wasm_workers.go @@ -0,0 +1,71 @@ +//go:build llgo && js && wasm && llgo.wasm_workers + +package runtime + +import "github.com/goplus/llgo/runtime/internal/clite/sync/atomic" + +func tryCasgstatus(gp *g, oldval, newval uint32) bool { + _, ok := atomic.CompareAndExchange(&gp.atomicstatus, oldval, newval) + return ok +} + +// SchedulerWaiter is an opaque one-shot notification owned by a waiting G. +// The notification closes the Ready-before-Park race between Web workers. +type SchedulerWaiter struct { + gp *g + notified uint32 +} + +func CurrentSchedulerWaiter() SchedulerWaiter { + return SchedulerWaiter{gp: getg()} +} + +func (w *SchedulerWaiter) Park() { + gp := w.gp + if gp == nil || getg() != gp { + fatal("runtime: invalid WebAssembly scheduler waiter") + return + } + if _, ok := atomic.CompareAndExchange(&w.notified, uint32(1), uint32(0)); ok { + return + } + + casgstatus(gp, _Grunning, _Gwaiting) + // Keep the G active until an early notification is either consumed here + // or has made the G runnable. Otherwise worker 0 can observe a transient + // zero active count and report a false deadlock. + if _, ok := atomic.CompareAndExchange(&w.notified, uint32(1), uint32(0)); ok { + if tryCasgstatus(gp, _Gwaiting, _Grunning) { + return + } + } + + atomic.Add(&wasmMultiSched.active, ^uint32(0)) + wakeWasmEventWorker() + worker := gp.context.platform.owner + gp.context.platform.context.Swap(&worker.system, nil) + atomic.Store(&w.notified, uint32(0)) +} + +func (w *SchedulerWaiter) Ready() { + gp := w.gp + if gp == nil { + fatal("runtime: ready of invalid WebAssembly scheduler waiter") + return + } + if _, ok := atomic.CompareAndExchange(&w.notified, uint32(0), uint32(1)); !ok { + return + } + if tryCasgstatus(gp, _Gwaiting, _Grunnable) { + atomic.Add(&wasmMultiSched.active, uint32(1)) + enqueueWasmG(gp.context.platform.owner, gp) + } +} + +func SchedulerProcID() int { + worker := currentWasmWorker() + if worker == nil { + return 0 + } + return worker.index +} diff --git a/runtime/internal/runtime/stubs.go b/runtime/internal/runtime/stubs.go index 61d9013b89..9c164d3ffb 100644 --- a/runtime/internal/runtime/stubs.go +++ b/runtime/internal/runtime/stubs.go @@ -7,6 +7,7 @@ package runtime import ( "unsafe" + c "github.com/goplus/llgo/runtime/internal/clite" "github.com/goplus/llgo/runtime/internal/clite/sync/atomic" "github.com/goplus/llgo/runtime/internal/clite/time" "github.com/goplus/llgo/runtime/internal/runtime/math" @@ -118,6 +119,7 @@ func memclrNoHeapPointers(ptr unsafe.Pointer, n uintptr) { func fatal(s string) { print("fatal error: ", s, "\n") + c.Exit(2) } func throw(s string) { diff --git a/runtime/internal/runtime/tinygogc/_wrap/gc_wasm.c b/runtime/internal/runtime/tinygogc/_wrap/gc_wasm.c new file mode 100644 index 0000000000..d6ae8fd3d4 --- /dev/null +++ b/runtime/internal/runtime/tinygogc/_wrap/gc_wasm.c @@ -0,0 +1,53 @@ +#include +#include + +#if defined(__EMSCRIPTEN__) +#include +#include +#else +extern unsigned char __stack_high; +#endif + +extern unsigned char __data_end; +extern unsigned char __global_base; +extern unsigned char __heap_base; + +#define LLGO_WASM_PAGE_SIZE 65536 + +uintptr_t llgo_gc_globals_start(void) { + return (uintptr_t)&__global_base; +} + +uintptr_t llgo_gc_globals_end(void) { + return (uintptr_t)&__data_end; +} + +uintptr_t llgo_gc_heap_base(void) { + return (uintptr_t)&__heap_base; +} + +uintptr_t llgo_gc_stack_top(void) { +#if defined(__EMSCRIPTEN__) + return (uintptr_t)emscripten_stack_get_base(); +#else + return (uintptr_t)&__stack_high; +#endif +} + +uintptr_t llgo_gc_memory_size(void) { + return (uintptr_t)__builtin_wasm_memory_size(0) * LLGO_WASM_PAGE_SIZE; +} + +int llgo_gc_grow_memory(uintptr_t required) { +#if defined(__EMSCRIPTEN__) + return emscripten_resize_heap(required); +#else + uintptr_t current = llgo_gc_memory_size(); + if (required <= current) { + return 1; + } + uintptr_t pages = (required - current + LLGO_WASM_PAGE_SIZE - 1) / + LLGO_WASM_PAGE_SIZE; + return __builtin_wasm_memory_grow(0, pages) != (size_t)-1; +#endif +} diff --git a/runtime/internal/runtime/tinygogc/gc.go b/runtime/internal/runtime/tinygogc/gc.go index b83b8569d6..8163f7ff10 100644 --- a/runtime/internal/runtime/tinygogc/gc.go +++ b/runtime/internal/runtime/tinygogc/gc.go @@ -1,9 +1,7 @@ -//go:build baremetal +//go:build (baremetal && !nogc) || (wasm && llgo_wasm_gc) package tinygogc -import "unsafe" - type GCStats struct { // General statistics. @@ -150,6 +148,7 @@ func ReadGCStats() GCStats { var heapInuse, heapIdle uint64 lock(&gcMutex) + lazyInit() for block := uintptr(0); block < endBlock; block++ { bstate := gcStateOf(block) @@ -160,8 +159,7 @@ func ReadGCStats() GCStats { } } - stackEnd := uintptr(unsafe.Pointer(&_stackEnd)) - stackSys := stackTop - stackEnd + stackInuse, stackSys := gcStackStats() stats := GCStats{ Alloc: (gcTotalBlocks - gcFreedBlocks) * uint64(bytesPerBlock), @@ -173,7 +171,7 @@ func ReadGCStats() GCStats { HeapSys: heapInuse + heapIdle, HeapIdle: heapIdle, HeapInuse: heapInuse, - StackInuse: uint64(stackTop - uintptr(getsp())), + StackInuse: uint64(stackInuse), StackSys: uint64(stackSys), GCSys: uint64(heapEnd - uintptr(metadataStart)), } diff --git a/runtime/internal/runtime/tinygogc/gc_link.go b/runtime/internal/runtime/tinygogc/gc_link.go index 9921cd5c7b..550341e897 100644 --- a/runtime/internal/runtime/tinygogc/gc_link.go +++ b/runtime/internal/runtime/tinygogc/gc_link.go @@ -1,4 +1,4 @@ -//go:build baremetal +//go:build baremetal && !nogc package tinygogc @@ -32,9 +32,6 @@ func __wrap_realloc(ptr unsafe.Pointer, size uintptr) unsafe.Pointer { return Realloc(ptr, size) } -//go:linkname getsp llgo.stackSave -func getsp() unsafe.Pointer - //go:linkname _heapStart _heapStart var _heapStart [0]byte @@ -52,3 +49,38 @@ var _globals_start [0]byte //go:linkname _globals_end _globals_end var _globals_end [0]byte + +func gcMemoryLayout() (heapStart, heapEnd, globalsStart, globalsEnd, stackTop uintptr) { + // Reserve 2 KiB for libc internal allocation that cannot be wrapped. + return uintptr(unsafe.Pointer(&_heapStart)) + 2048, + uintptr(unsafe.Pointer(&_heapEnd)), + uintptr(unsafe.Pointer(&_globals_start)), + uintptr(unsafe.Pointer(&_globals_end)), + uintptr(unsafe.Pointer(&_stackStart)) +} + +func gcGrowMemory(oldHeapEnd uintptr) uintptr { + return oldHeapEnd +} + +func gcMarkReachable() { + sp := uintptr(getsp()) + if sp < stackTop { + markRoots(sp, stackTop) + } + if globalsStart < globalsEnd { + markRoots(globalsStart, globalsEnd) + } +} + +func gcStackStats() (inuse, sys uintptr) { + sp := uintptr(getsp()) + if sp < stackTop { + inuse = stackTop - sp + } + stackEnd := uintptr(unsafe.Pointer(&_stackEnd)) + if stackEnd < stackTop { + sys = stackTop - stackEnd + } + return +} diff --git a/runtime/internal/runtime/tinygogc/gc_tinygo.go b/runtime/internal/runtime/tinygogc/gc_tinygo.go index cca6f9fccd..65aceb822e 100644 --- a/runtime/internal/runtime/tinygogc/gc_tinygo.go +++ b/runtime/internal/runtime/tinygogc/gc_tinygo.go @@ -1,4 +1,4 @@ -//go:build baremetal +//go:build (baremetal && !nogc) || (wasm && llgo_wasm_gc) /* * Copyright (c) 2018-2025 The TinyGo Authors. All rights reserved. @@ -18,14 +18,11 @@ */ // Package tinygogc implements a conservative mark-and-sweep garbage collector -// for baremetal environments where the standard Go runtime and bdwgc are unavailable. +// for targets where the standard Go runtime and bdwgc are unavailable. // // This implementation is based on TinyGo's GC and is designed for resource-constrained // embedded systems. It uses a block-based allocator with conservative pointer scanning. // -// Build tags: -// - baremetal: Enables this GC for baremetal targets -// // Memory Layout: // The heap is divided into fixed-size blocks (32 bytes on 64-bit). Metadata is stored // at the end of the heap, using 2 bits per block to track state (free/head/tail/mark). @@ -95,18 +92,20 @@ const ( // this function MUST be initalized first, which means it's required to be initalized before runtime func initGC() { - // reserve 2K blocks for libc internal malloc, we cannot wrap those internal functions - heapStart = uintptr(unsafe.Pointer(&_heapStart)) + 2048 - heapEnd = uintptr(unsafe.Pointer(&_heapEnd)) - globalsStart = uintptr(unsafe.Pointer(&_globals_start)) - globalsEnd = uintptr(unsafe.Pointer(&_globals_end)) + heapStart, heapEnd, globalsStart, globalsEnd, stackTop = gcMemoryLayout() + if heapStart >= heapEnd { + gcPanic(c.Str("gc: invalid heap range")) + } + configureHeap() + metadataSize := heapEnd - uintptr(metadataStart) + c.Memset(metadataStart, 0, metadataSize) +} + +func configureHeap() { totalSize := heapEnd - heapStart metadataSize := (totalSize + blocksPerStateByte*bytesPerBlock) / (1 + blocksPerStateByte*bytesPerBlock) metadataStart = unsafe.Pointer(heapEnd - metadataSize) endBlock = (uintptr(metadataStart) - heapStart) / bytesPerBlock - stackTop = uintptr(unsafe.Pointer(&_stackStart)) - - c.Memset(metadataStart, 0, metadataSize) } func lazyInit() { @@ -362,12 +361,12 @@ func Realloc(ptr unsafe.Pointer, size uintptr) unsafe.Pointer { newAlloc := Alloc(size) c.Memcpy(newAlloc, ptr, oldSize) - free(ptr) + freeObject(ptr) return newAlloc } -func free(ptr unsafe.Pointer) { +func freeObject(ptr unsafe.Pointer) { // TODO: free blocks on request, when the compiler knows they're unused. } @@ -555,15 +554,28 @@ func sweep() (freeBytes uintptr) { // growHeap tries to grow the heap size. It returns true if it succeeds, false // otherwise. func growHeap() bool { - // On baremetal, there is no way the heap can be grown. - return false -} + oldHeapEnd := heapEnd + oldMetadataStart := metadataStart + oldMetadataSize := oldHeapEnd - uintptr(oldMetadataStart) + newHeapEnd := gcGrowMemory(oldHeapEnd) + if newHeapEnd <= oldHeapEnd { + return false + } -func gcMarkReachable() { - markRoots(uintptr(getsp()), stackTop) - markRoots(globalsStart, globalsEnd) + heapEnd = newHeapEnd + configureHeap() + newMetadataSize := heapEnd - uintptr(metadataStart) + if newMetadataSize < oldMetadataSize { + gcPanic(c.Str("gc: metadata shrank while growing heap")) + } + c.Memmove(metadataStart, oldMetadataStart, oldMetadataSize) + c.Memset(unsafe.Add(metadataStart, oldMetadataSize), 0, newMetadataSize-oldMetadataSize) + return true } func gcResumeWorld() { // Nothing to do here (single threaded). } + +//go:linkname getsp llgo.stackSave +func getsp() unsafe.Pointer diff --git a/runtime/internal/runtime/tinygogc/gc_wasm.go b/runtime/internal/runtime/tinygogc/gc_wasm.go new file mode 100644 index 0000000000..87f4ac25ac --- /dev/null +++ b/runtime/internal/runtime/tinygogc/gc_wasm.go @@ -0,0 +1,94 @@ +//go:build wasm && llgo_wasm_gc + +package tinygogc + +import ( + "unsafe" + + c "github.com/goplus/llgo/runtime/internal/clite" + "github.com/goplus/llgo/runtime/internal/gcroot" +) + +const LLGoFiles = "_wrap/gc_wasm.c" + +const wasmPageSize = uintptr(64 << 10) + +func gcMemoryLayout() (heapStart, heapEnd, globalsStart, globalsEnd, stackTop uintptr) { + heapStart = alignUp(gcWasmHeapBase(), bytesPerBlock) + heapEnd = alignDown(gcWasmMemorySize(), bytesPerBlock) + globalsStart = gcWasmGlobalsStart() + globalsEnd = gcWasmGlobalsEnd() + stackTop = gcWasmStackTop() + return +} + +func gcGrowMemory(oldHeapEnd uintptr) uintptr { + current := gcWasmMemorySize() + if current < oldHeapEnd { + return oldHeapEnd + } + growth := oldHeapEnd - heapStart + if growth < 1<<20 { + growth = 1 << 20 + } + if current > ^uintptr(0)-growth { + return oldHeapEnd + } + required := alignUp(current+growth, wasmPageSize) + if gcWasmGrowMemory(required) == 0 { + return oldHeapEnd + } + return alignDown(gcWasmMemorySize(), bytesPerBlock) +} + +func gcMarkReachable() { + sp := uintptr(getsp()) + top := gcWasmStackTop() + if sp < top { + markRoots(sp, top) + } + if globalsStart < globalsEnd { + markRoots(globalsStart, globalsEnd) + } + gcroot.Visit(markWasmGCRoot) +} + +func markWasmGCRoot(root *unsafe.Pointer, _ unsafe.Pointer) { + markRoot(uintptr(unsafe.Pointer(root)), uintptr(*root)) +} + +func gcStackStats() (inuse, sys uintptr) { + sp := uintptr(getsp()) + top := gcWasmStackTop() + if sp < top { + inuse = top - sp + sys = inuse + } + return +} + +func alignUp(value, alignment uintptr) uintptr { + return (value + alignment - 1) &^ (alignment - 1) +} + +func alignDown(value, alignment uintptr) uintptr { + return value &^ (alignment - 1) +} + +//go:linkname gcWasmGlobalsStart C.llgo_gc_globals_start +func gcWasmGlobalsStart() uintptr + +//go:linkname gcWasmGlobalsEnd C.llgo_gc_globals_end +func gcWasmGlobalsEnd() uintptr + +//go:linkname gcWasmHeapBase C.llgo_gc_heap_base +func gcWasmHeapBase() uintptr + +//go:linkname gcWasmStackTop C.llgo_gc_stack_top +func gcWasmStackTop() uintptr + +//go:linkname gcWasmMemorySize C.llgo_gc_memory_size +func gcWasmMemorySize() uintptr + +//go:linkname gcWasmGrowMemory C.llgo_gc_grow_memory +func gcWasmGrowMemory(required uintptr) c.Int diff --git a/runtime/internal/runtime/tinygogc/gc_wasm_js.go b/runtime/internal/runtime/tinygogc/gc_wasm_js.go new file mode 100644 index 0000000000..537111b395 --- /dev/null +++ b/runtime/internal/runtime/tinygogc/gc_wasm_js.go @@ -0,0 +1,91 @@ +//go:build js && wasm && llgo_wasm_gc + +package tinygogc + +import "unsafe" + +func wasmCalloc(nmemb, size uintptr) unsafe.Pointer { + totalSize := nmemb * size + if nmemb != 0 && totalSize/nmemb != size { + return nil + } + return Alloc(totalSize) +} + +func wasmMemalign(alignment, size uintptr) unsafe.Pointer { + if !wasmValidMemalign(alignment) { + return nil + } + if alignment <= bytesPerBlock { + return Alloc(size) + } + if size > ^uintptr(0)-(alignment-1) { + return nil + } + return unsafe.Pointer(alignUp(uintptr(Alloc(size+alignment-1)), alignment)) +} + +func wasmValidMemalign(alignment uintptr) bool { + return alignment >= unsafe.Sizeof(uintptr(0)) && alignment&(alignment-1) == 0 +} + +//export malloc +func malloc(size uintptr) unsafe.Pointer { + return Alloc(size) +} + +//export free +func free(ptr unsafe.Pointer) { +} + +//export calloc +func calloc(nmemb, size uintptr) unsafe.Pointer { + return wasmCalloc(nmemb, size) +} + +//export realloc +func realloc(ptr unsafe.Pointer, size uintptr) unsafe.Pointer { + return Realloc(ptr, size) +} + +//export memalign +func memalign(alignment, size uintptr) unsafe.Pointer { + return wasmMemalign(alignment, size) +} + +//export posix_memalign +func posix_memalign(result *unsafe.Pointer, alignment, size uintptr) int32 { + if !wasmValidMemalign(alignment) { + return 22 + } + ptr := wasmMemalign(alignment, size) + if ptr == nil { + return 12 + } + *result = ptr + return 0 +} + +//export emscripten_builtin_malloc +func emscripten_builtin_malloc(size uintptr) unsafe.Pointer { + return Alloc(size) +} + +//export emscripten_builtin_free +func emscripten_builtin_free(ptr unsafe.Pointer) { +} + +//export emscripten_builtin_calloc +func emscripten_builtin_calloc(nmemb, size uintptr) unsafe.Pointer { + return wasmCalloc(nmemb, size) +} + +//export emscripten_builtin_realloc +func emscripten_builtin_realloc(ptr unsafe.Pointer, size uintptr) unsafe.Pointer { + return Realloc(ptr, size) +} + +//export emscripten_builtin_memalign +func emscripten_builtin_memalign(alignment, size uintptr) unsafe.Pointer { + return wasmMemalign(alignment, size) +} diff --git a/runtime/internal/runtime/tinygogc/gc_wasm_wasip1.go b/runtime/internal/runtime/tinygogc/gc_wasm_wasip1.go new file mode 100644 index 0000000000..9dd5db8874 --- /dev/null +++ b/runtime/internal/runtime/tinygogc/gc_wasm_wasip1.go @@ -0,0 +1,30 @@ +//go:build wasip1 && wasm && llgo_wasm_gc + +package tinygogc + +import "unsafe" + +const LLGoPackage = "link: -Wl,--wrap=malloc -Wl,--wrap=free -Wl,--wrap=realloc -Wl,--wrap=calloc" + +//export __wrap_malloc +func __wrap_malloc(size uintptr) unsafe.Pointer { + return Alloc(size) +} + +//export __wrap_free +func __wrap_free(ptr unsafe.Pointer) { +} + +//export __wrap_calloc +func __wrap_calloc(nmemb, size uintptr) unsafe.Pointer { + totalSize := nmemb * size + if nmemb != 0 && totalSize/nmemb != size { + return nil + } + return Alloc(totalSize) +} + +//export __wrap_realloc +func __wrap_realloc(ptr unsafe.Pointer, size uintptr) unsafe.Pointer { + return Realloc(ptr, size) +} diff --git a/runtime/internal/runtime/wasm_gcroot.go b/runtime/internal/runtime/wasm_gcroot.go new file mode 100644 index 0000000000..0b83b67862 --- /dev/null +++ b/runtime/internal/runtime/wasm_gcroot.go @@ -0,0 +1,33 @@ +//go:build llgo && wasm && llgo_wasm_gc + +package runtime + +import ( + "unsafe" + + "github.com/goplus/llgo/runtime/internal/gcroot" +) + +const wasmGCRootEnabled = true + +type wasmGCRootContext = gcroot.Context + +func registerWasmGCRoot(ctx *wasmGCRootContext, active bool) { + if active { + gcroot.RegisterActive(ctx) + } else { + gcroot.Register(ctx) + } +} + +func wasmGCRootPointer(ctx *wasmGCRootContext) unsafe.Pointer { + return unsafe.Pointer(ctx) +} + +func adoptWasmGCRoot(ctx *wasmGCRootContext) { + gcroot.AdoptCurrent(ctx) +} + +func unregisterWasmGCRoot(ctx *wasmGCRootContext) { + gcroot.Unregister(ctx) +} diff --git a/runtime/internal/runtime/wasm_gcroot_stub.go b/runtime/internal/runtime/wasm_gcroot_stub.go new file mode 100644 index 0000000000..701be4278e --- /dev/null +++ b/runtime/internal/runtime/wasm_gcroot_stub.go @@ -0,0 +1,17 @@ +//go:build llgo && wasm && !llgo_wasm_gc + +package runtime + +import "unsafe" + +const wasmGCRootEnabled = false + +type wasmGCRootContext struct{} + +func registerWasmGCRoot(*wasmGCRootContext, bool) {} + +func wasmGCRootPointer(*wasmGCRootContext) unsafe.Pointer { return nil } + +func adoptWasmGCRoot(*wasmGCRootContext) {} + +func unregisterWasmGCRoot(*wasmGCRootContext) {} diff --git a/runtime/internal/runtime/z_chan.go b/runtime/internal/runtime/z_chan.go index eeac62eebf..45b46f840e 100644 --- a/runtime/internal/runtime/z_chan.go +++ b/runtime/internal/runtime/z_chan.go @@ -20,14 +20,13 @@ import ( "unsafe" c "github.com/goplus/llgo/runtime/internal/clite" - "github.com/goplus/llgo/runtime/internal/clite/pthread/sync" "github.com/goplus/llgo/runtime/internal/runtime/math" ) // ----------------------------------------------------------------------------- type Chan struct { - mutex sync.Mutex + mutex chanMutex qcount int dataqsiz int @@ -59,16 +58,14 @@ type chanWaiter struct { queued bool status waitStatus - mutex sync.Mutex - cond sync.Cond + signal chanSignal sel *selectState caseIndex int } type selectState struct { - mutex sync.Mutex - cond sync.Cond + signal chanSignal status waitStatus chosen int @@ -150,7 +147,7 @@ func NewChan(eltSize, cap int) *Chan { if cap > 0 { ret.buf = AllocU(mem) } - ret.mutex.Init(nil) + ret.mutex.init() return ret } @@ -201,9 +198,8 @@ func newChanWaiter(ch *Chan, elem unsafe.Pointer, eltSize int, send bool) *chanW w.elem = elem w.size = eltSize w.send = send - w.mutex.Init(nil) - w.cond.Init(nil) - w.mutex.Lock() + w.signal.init() + w.signal.lock() return w } @@ -214,12 +210,12 @@ func newSelectState() *selectState { } c.Memset(unsafe.Pointer(state), 0, unsafe.Sizeof(selectState{})) state.chosen = -1 - state.mutex.Init(nil) - state.cond.Init(nil) + state.signal.init() return state } func freeSelectState(state *selectState) { + state.signal.destroy() c.Free(unsafe.Pointer(state)) } @@ -248,37 +244,36 @@ func freeSelectWaiters(w *chanWaiter) { func (w *chanWaiter) wait() { for !w.status.done() { - w.cond.Wait(&w.mutex) + w.signal.park() } - w.mutex.Unlock() - w.cond.Destroy() - w.mutex.Destroy() + w.signal.unlock() + w.signal.destroy() } func (w *chanWaiter) finish(status waitStatus) { if w.sel != nil { - w.sel.mutex.Lock() + w.sel.signal.lock() w.sel.status = status - w.sel.mutex.Unlock() - w.sel.cond.Signal() + w.sel.signal.unlock() + w.sel.signal.ready() return } - w.mutex.Lock() + w.signal.lock() w.status = status - w.mutex.Unlock() - w.cond.Signal() + w.signal.unlock() + w.signal.ready() } func claimWaiter(w *chanWaiter) bool { if w.sel != nil { - w.sel.mutex.Lock() + w.sel.signal.lock() if w.sel.status != waitPending { - w.sel.mutex.Unlock() + w.sel.signal.unlock() return false } w.sel.status = waitClaimed w.sel.chosen = w.caseIndex - w.sel.mutex.Unlock() + w.sel.signal.unlock() return true } return true @@ -497,14 +492,7 @@ func ChanClose(p *Chan) { } func blockForever() { - var mutex sync.Mutex - var cond sync.Cond - mutex.Init(nil) - cond.Init(nil) - mutex.Lock() - for { - cond.Wait(&mutex) - } + chanBlockForever() } // ----------------------------------------------------------------------------- @@ -688,20 +676,18 @@ func Select(ops ...ChanOp) (isel int, recvOK bool) { } unlockSelectChannels(&chans) - state.mutex.Lock() + state.signal.lock() for !state.status.done() { - state.cond.Wait(&state.mutex) + state.signal.park() } isel = state.chosen status := state.status recvOK = status.recvOK() - state.mutex.Unlock() + state.signal.unlock() for w := waiters; w != nil; w = w.all { cleanupSelectWaiter(w) } - state.cond.Destroy() - state.mutex.Destroy() freeSelectState(state) freeSelectWaiters(waiters) if status.panicOnWake() { diff --git a/runtime/internal/runtime/z_default.go b/runtime/internal/runtime/z_default.go index 401f8aaa17..284b2301d2 100644 --- a/runtime/internal/runtime/z_default.go +++ b/runtime/internal/runtime/z_default.go @@ -2,45 +2,10 @@ package runtime -import ( - c "github.com/goplus/llgo/runtime/internal/clite" - "github.com/goplus/llgo/runtime/internal/clite/debug" -) +import c "github.com/goplus/llgo/runtime/internal/clite" var ( printFormatPrefixInt = c.Str("%lld") printFormatPrefixUInt = c.Str("%llu") printFormatPrefixHex = c.Str("%llx") ) - -// Rethrow rethrows a panic. -func Rethrow(link *Defer) { - gp := getg() - if ptr := gp.panic_; ptr != nil { - if link == nil { - TracePanic(*(*any)(ptr)) - if PanicTraceback == nil || !PanicTraceback(2) { - debug.PrintStack(2) - } - c.Free(ptr) - c.Exit(2) - } else { - c.Siglongjmp(link.Addr, 1) - } - } else if gp.goexit { - // Goexit must run deferred functions before terminating the current - // goroutine. Reuse the longjmp-based defer unwinding: - // 1) If we have a defer frame, longjmp to it so it can execute defers. - // 2) Once we've unwound past the last frame (link==nil), terminate the - // current pthread. - if link != nil { - c.Siglongjmp(link.Addr, 1) - } - if gp.isMain { - fatal("no goroutines (main called runtime.Goexit) - deadlock!") - c.Exit(2) - } - leaveCurrentLocalContext() - exitCurrentM() - } -} diff --git a/runtime/internal/runtime/z_defer_gc.go b/runtime/internal/runtime/z_defer_gc.go index 88ee5eeae2..2739768e08 100644 --- a/runtime/internal/runtime/z_defer_gc.go +++ b/runtime/internal/runtime/z_defer_gc.go @@ -1,4 +1,4 @@ -//go:build !nogc && !baremetal +//go:build !nogc && !baremetal && !wasm /* * Copyright (c) 2025 The XGo Authors (xgo.dev). All rights reserved. diff --git a/runtime/internal/runtime/z_defer_nogc.go b/runtime/internal/runtime/z_defer_nogc.go index e7acb7bc6d..b16c883dce 100644 --- a/runtime/internal/runtime/z_defer_nogc.go +++ b/runtime/internal/runtime/z_defer_nogc.go @@ -1,4 +1,4 @@ -//go:build nogc && !baremetal +//go:build nogc && !baremetal && (!wasm || !llgo_wasm_gc) /* * Copyright (c) 2025 The XGo Authors (xgo.dev). All rights reserved. diff --git a/runtime/internal/runtime/z_defer_baremetal.go b/runtime/internal/runtime/z_defer_nonmoving.go similarity index 74% rename from runtime/internal/runtime/z_defer_baremetal.go rename to runtime/internal/runtime/z_defer_nonmoving.go index 0af31efef4..3142e3250c 100644 --- a/runtime/internal/runtime/z_defer_baremetal.go +++ b/runtime/internal/runtime/z_defer_nonmoving.go @@ -1,4 +1,4 @@ -//go:build baremetal && !nogc +//go:build (baremetal && !nogc) || (wasm && llgo_wasm_gc) /* * Copyright (c) 2025 The XGo Authors (xgo.dev). All rights reserved. @@ -20,9 +20,6 @@ package runtime import "unsafe" -// FreeDeferNode is a no-op in baremetal environment. -// Defer nodes become unreachable after being unlinked from the chain, -// and tinygogc will reclaim them in the next GC cycle. +// FreeDeferNode leaves unreachable nodes for the collector. func FreeDeferNode(ptr unsafe.Pointer) { - // no-op: let tinygogc collect } diff --git a/runtime/internal/runtime/z_gc.go b/runtime/internal/runtime/z_gc.go index 4b4d144678..32ba207edc 100644 --- a/runtime/internal/runtime/z_gc.go +++ b/runtime/internal/runtime/z_gc.go @@ -1,4 +1,4 @@ -//go:build !nogc && !baremetal +//go:build !nogc && !baremetal && !wasm /* * Copyright (c) 2024 The XGo Authors (xgo.dev). All rights reserved. diff --git a/runtime/internal/runtime/z_gc_baremetal.go b/runtime/internal/runtime/z_gc_nonmoving.go similarity index 74% rename from runtime/internal/runtime/z_gc_baremetal.go rename to runtime/internal/runtime/z_gc_nonmoving.go index 09fdb2a69d..5186cb7b62 100644 --- a/runtime/internal/runtime/z_gc_baremetal.go +++ b/runtime/internal/runtime/z_gc_nonmoving.go @@ -1,4 +1,4 @@ -//go:build !nogc && baremetal +//go:build (baremetal && !nogc) || (wasm && llgo_wasm_gc) /* * Copyright (c) 2024 The XGo Authors (xgo.dev). All rights reserved. @@ -24,14 +24,12 @@ import ( "github.com/goplus/llgo/runtime/internal/runtime/tinygogc" ) -// AllocU allocates uninitialized memory. func AllocU(size uintptr) unsafe.Pointer { ret := tinygogc.Alloc(size) recordMemProfileAlloc(size) return ret } -// AllocZ allocates zero-initialized memory. func AllocZ(size uintptr) unsafe.Pointer { ret := tinygogc.Alloc(size) recordMemProfileAlloc(size) @@ -45,11 +43,7 @@ func AllocRoot(size uintptr) unsafe.Pointer { func FreeRoot(ptr unsafe.Pointer) { } -// AddCleanupPtr is not implemented in baremetal builds because tinygogc -// does not support finalizers. Cleanup functions will never be called. -// -// Returns: a no-op cancel function +// AddCleanupPtr is not implemented by the non-moving collector. func AddCleanupPtr(ptr unsafe.Pointer, cleanup func()) (cancel func()) { - // Not implemented: tinygogc does not support finalizers - return func() {} // no-op cancel + return func() {} } diff --git a/runtime/internal/runtime/z_nogc.go b/runtime/internal/runtime/z_nogc.go index e716ec1e94..2300034d4b 100644 --- a/runtime/internal/runtime/z_nogc.go +++ b/runtime/internal/runtime/z_nogc.go @@ -1,5 +1,4 @@ -//go:build nogc -// +build nogc +//go:build nogc && (!wasm || !llgo_wasm_gc) /* * Copyright (c) 2024 The XGo Authors (xgo.dev). All rights reserved. diff --git a/runtime/internal/runtime/z_rt.go b/runtime/internal/runtime/z_rt.go index d771ab94cf..baa9dd655c 100644 --- a/runtime/internal/runtime/z_rt.go +++ b/runtime/internal/runtime/z_rt.go @@ -23,18 +23,6 @@ import ( "github.com/goplus/llgo/runtime/internal/clite/setjmp" ) -// ----------------------------------------------------------------------------- - -// Defer presents defer statements in a function. -type Defer struct { - Addr unsafe.Pointer // sigjmpbuf - Bits uintptr - Link *Defer - Reth unsafe.Pointer // block address after Rethrow - Rund unsafe.Pointer // block address after RunDefers - Args unsafe.Pointer // defer func and args links -} - // Recover recovers a panic. func Recover() (ret any) { gp := getg() diff --git a/runtime/internal/wasmcontext/_asm/context_wasm.S b/runtime/internal/wasmcontext/_asm/context_wasm.S new file mode 100644 index 0000000000..bc3b33034c --- /dev/null +++ b/runtime/internal/wasmcontext/_asm/context_wasm.S @@ -0,0 +1,121 @@ +// Copyright (c) 2018-2026 The TinyGo Authors. All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are met: +// +// * Redistributions of source code must retain the above copyright notice, +// this list of conditions and the following disclaimer. +// * Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation +// and/or other materials provided with the distribution. +// * Neither the name of the copyright holder nor the names of its contributors +// may be used to endorse or promote products derived from this software +// without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +// LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +// POSSIBILITY OF SUCH DAMAGE. +// +// This file was adapted for LLGo's wasmcontext ABI and wasm32 WASI scheduler. + +.globaltype __stack_pointer, i32 + +.functype start_unwind (i32) -> () +.import_module start_unwind, asyncify +.import_name start_unwind, start_unwind +.functype stop_unwind () -> () +.import_module stop_unwind, asyncify +.import_name stop_unwind, stop_unwind +.functype start_rewind (i32) -> () +.import_module start_rewind, asyncify +.import_name start_rewind, start_rewind +.functype stop_rewind () -> () +.import_module stop_rewind, asyncify +.import_name stop_rewind, stop_rewind + +.global __llgo_wasm_context_unwind +.hidden __llgo_wasm_context_unwind +.type __llgo_wasm_context_unwind,@function +__llgo_wasm_context_unwind: + .functype __llgo_wasm_context_unwind (i32) -> () + i32.const 0 + i32.load8_u __llgo_wasm_context_rewinding + if + call stop_rewind + i32.const 0 + i32.const 0 + i32.store8 __llgo_wasm_context_rewinding + else + local.get 0 + global.get __stack_pointer + i32.store 16 + local.get 0 + i32.const 8 + i32.add + call start_unwind + end_if + return + end_function + +.global __llgo_wasm_context_launch +.hidden __llgo_wasm_context_launch +.type __llgo_wasm_context_launch,@function +__llgo_wasm_context_launch: + .functype __llgo_wasm_context_launch (i32) -> () + global.get __stack_pointer + local.get 0 + i32.load 16 + global.set __stack_pointer + local.get 0 + i32.load 4 + local.get 0 + i32.load 0 + call_indirect (i32) -> (i32) + drop + call stop_unwind + global.set __stack_pointer + return + end_function + +.global __llgo_wasm_context_rewind +.hidden __llgo_wasm_context_rewind +.type __llgo_wasm_context_rewind,@function +__llgo_wasm_context_rewind: + .functype __llgo_wasm_context_rewind (i32) -> () + global.get __stack_pointer + local.get 0 + i32.load 16 + global.set __stack_pointer + local.get 0 + i32.load 4 + local.get 0 + i32.load 0 + i32.const 0 + i32.const 1 + i32.store8 __llgo_wasm_context_rewinding + local.get 0 + i32.const 8 + i32.add + call start_rewind + call_indirect (i32) -> (i32) + drop + call stop_unwind + global.set __stack_pointer + return + end_function + +.hidden __llgo_wasm_context_rewinding +.type __llgo_wasm_context_rewinding,@object +.section .bss.__llgo_wasm_context_rewinding,"",@ +.globl __llgo_wasm_context_rewinding +__llgo_wasm_context_rewinding: + .int8 0 + .size __llgo_wasm_context_rewinding, 1 diff --git a/runtime/internal/wasmcontext/_asm/context_wasm_gcroot.S b/runtime/internal/wasmcontext/_asm/context_wasm_gcroot.S new file mode 100644 index 0000000000..879aa36654 --- /dev/null +++ b/runtime/internal/wasmcontext/_asm/context_wasm_gcroot.S @@ -0,0 +1,9 @@ +.global __llgo_wasm_context_rewinding_state +.hidden __llgo_wasm_context_rewinding_state +.type __llgo_wasm_context_rewinding_state,@function +__llgo_wasm_context_rewinding_state: + .functype __llgo_wasm_context_rewinding_state () -> (i32) + i32.const 0 + i32.load8_u __llgo_wasm_context_rewinding + return + end_function diff --git a/runtime/internal/wasmcontext/context_js.go b/runtime/internal/wasmcontext/context_js.go new file mode 100644 index 0000000000..d1764a6cb2 --- /dev/null +++ b/runtime/internal/wasmcontext/context_js.go @@ -0,0 +1,48 @@ +//go:build llgo && js && wasm + +/* + * 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 wasmcontext + +import ( + "unsafe" + + "github.com/goplus/llgo/runtime/internal/clite/emscripten" +) + +type Entry = emscripten.FiberEntry + +// Context wraps the Emscripten Fiber ABI used by JavaScript hosts. +type Context struct { + fiber emscripten.Fiber +} + +func (ctx *Context) Init(entry Entry, arg, stack unsafe.Pointer, stackSize uintptr, asyncifyStack unsafe.Pointer, asyncifyStackSize uintptr) { + emscripten.FiberInit( + &ctx.fiber, + entry, + arg, + stack, + stackSize, + asyncifyStack, + asyncifyStackSize, + ) +} + +func (ctx *Context) InitCurrent(asyncifyStack unsafe.Pointer, asyncifyStackSize uintptr) { + emscripten.FiberInitCurrent(&ctx.fiber, asyncifyStack, asyncifyStackSize) +} diff --git a/runtime/internal/wasmcontext/context_js_gcroot.go b/runtime/internal/wasmcontext/context_js_gcroot.go new file mode 100644 index 0000000000..245a03d726 --- /dev/null +++ b/runtime/internal/wasmcontext/context_js_gcroot.go @@ -0,0 +1,15 @@ +//go:build llgo && js && wasm && llgo_wasm_gc + +package wasmcontext + +import ( + "unsafe" + + "github.com/goplus/llgo/runtime/internal/clite/emscripten" + "github.com/goplus/llgo/runtime/internal/gcroot" +) + +func (ctx *Context) Swap(next *Context, nextRoots unsafe.Pointer) { + gcroot.SwitchAtBoundary((*gcroot.Context)(nextRoots)) + emscripten.FiberSwap(&ctx.fiber, &next.fiber) +} diff --git a/runtime/internal/wasmcontext/context_js_nogcroot.go b/runtime/internal/wasmcontext/context_js_nogcroot.go new file mode 100644 index 0000000000..db05227638 --- /dev/null +++ b/runtime/internal/wasmcontext/context_js_nogcroot.go @@ -0,0 +1,13 @@ +//go:build llgo && js && wasm && !llgo_wasm_gc + +package wasmcontext + +import ( + "unsafe" + + "github.com/goplus/llgo/runtime/internal/clite/emscripten" +) + +func (ctx *Context) Swap(next *Context, _ unsafe.Pointer) { + emscripten.FiberSwap(&ctx.fiber, &next.fiber) +} diff --git a/runtime/internal/wasmcontext/context_wasip1.go b/runtime/internal/wasmcontext/context_wasip1.go new file mode 100644 index 0000000000..5f2b6e22f5 --- /dev/null +++ b/runtime/internal/wasmcontext/context_wasip1.go @@ -0,0 +1,57 @@ +//go:build llgo && wasip1 && wasm && !llgo.wasi_threads + +/* + * 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 wasmcontext + +import ( + "unsafe" + + c "github.com/goplus/llgo/runtime/internal/clite" +) + +//llgo:type C +type Entry func(unsafe.Pointer) unsafe.Pointer + +// Context is the state consumed by Binaryen Asyncify. The first five fields +// have fixed wasm32 offsets shared with context_wasm.S. +type Context struct { + entry unsafe.Pointer + arg unsafe.Pointer + asyncifyStack unsafe.Pointer + asyncifyEnd unsafe.Pointer + stackPointer unsafe.Pointer + launched bool +} + +func (ctx *Context) Init(entry Entry, arg, stack unsafe.Pointer, stackSize uintptr, asyncifyStack unsafe.Pointer, asyncifyStackSize uintptr) { + ctx.entry = c.Func(entry) + ctx.arg = arg + ctx.asyncifyStack = asyncifyStack + ctx.asyncifyEnd = unsafe.Add(asyncifyStack, asyncifyStackSize) + ctx.stackPointer = unsafe.Add(stack, stackSize) + ctx.launched = false +} + +//go:linkname contextLaunch C.__llgo_wasm_context_launch +func contextLaunch(*Context) + +//go:linkname contextRewind C.__llgo_wasm_context_rewind +func contextRewind(*Context) + +//go:linkname contextUnwind C.__llgo_wasm_context_unwind +func contextUnwind(*Context) diff --git a/runtime/internal/wasmcontext/context_wasip1_gcroot.go b/runtime/internal/wasmcontext/context_wasip1_gcroot.go new file mode 100644 index 0000000000..16d62ba4cf --- /dev/null +++ b/runtime/internal/wasmcontext/context_wasip1_gcroot.go @@ -0,0 +1,33 @@ +//go:build llgo && wasip1 && wasm && !llgo.wasi_threads && llgo_wasm_gc + +package wasmcontext + +import ( + "unsafe" + + "github.com/goplus/llgo/runtime/internal/gcroot" +) + +func (ctx *Context) Resume(nextRoots unsafe.Pointer) { + gcroot.SwitchAtBoundary((*gcroot.Context)(nextRoots)) + if !ctx.launched { + contextLaunch(ctx) + ctx.launched = true + return + } + contextRewind(ctx) +} + +func (ctx *Context) Suspend(nextRoots unsafe.Pointer) { + // Asyncify replays this call stack while rewinding. The owner transition + // belongs only to the original unwind, not to that replay. + if contextRewinding() == 0 { + gcroot.SwitchAtBoundary((*gcroot.Context)(nextRoots)) + } + contextUnwind(ctx) +} + +//go:linkname contextRewinding C.__llgo_wasm_context_rewinding_state +func contextRewinding() uint32 + +const LLGoFiles = "_asm/context_wasm.S; _asm/context_wasm_gcroot.S" diff --git a/runtime/internal/wasmcontext/context_wasip1_nogcroot.go b/runtime/internal/wasmcontext/context_wasip1_nogcroot.go new file mode 100644 index 0000000000..5f6c6e76c8 --- /dev/null +++ b/runtime/internal/wasmcontext/context_wasip1_nogcroot.go @@ -0,0 +1,20 @@ +//go:build llgo && wasip1 && wasm && !llgo.wasi_threads && !llgo_wasm_gc + +package wasmcontext + +import "unsafe" + +func (ctx *Context) Resume(_ unsafe.Pointer) { + if !ctx.launched { + contextLaunch(ctx) + ctx.launched = true + return + } + contextRewind(ctx) +} + +func (ctx *Context) Suspend(_ unsafe.Pointer) { + contextUnwind(ctx) +} + +const LLGoFiles = "_asm/context_wasm.S" diff --git a/runtime/internal/wasmcontext/doc.go b/runtime/internal/wasmcontext/doc.go new file mode 100644 index 0000000000..688f6da9b7 --- /dev/null +++ b/runtime/internal/wasmcontext/doc.go @@ -0,0 +1,19 @@ +/* + * 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 wasmcontext provides suspended execution contexts for WebAssembly +// runtime schedulers. +package wasmcontext diff --git a/runtime/internal/wasmevent/_wrap/event_wasm.c b/runtime/internal/wasmevent/_wrap/event_wasm.c new file mode 100644 index 0000000000..90e73d8bcd --- /dev/null +++ b/runtime/internal/wasmevent/_wrap/event_wasm.c @@ -0,0 +1,43 @@ +#include +#include +#include + +#if defined(__EMSCRIPTEN__) +#include +#elif defined(__wasi__) +#include +#else +#error "unsupported WebAssembly host" +#endif + +#define LLGO_NANOSECONDS_PER_MILLISECOND UINT64_C(1000000) + +int64_t llgo_wasm_event_now(void) { + struct timespec now; + if (clock_gettime(CLOCK_MONOTONIC, &now) != 0) { + return 0; + } + return (int64_t)now.tv_sec * INT64_C(1000000000) + now.tv_nsec; +} + +void llgo_wasm_event_wait(uint64_t nanoseconds) { + uint64_t milliseconds = nanoseconds / LLGO_NANOSECONDS_PER_MILLISECOND; + if (nanoseconds % LLGO_NANOSECONDS_PER_MILLISECOND != 0) { + milliseconds++; + } +#if defined(__EMSCRIPTEN__) + if (milliseconds > UINT32_MAX) { + milliseconds = UINT32_MAX; + } + /* + * emscripten_sleep returns to JavaScript and resumes this Asyncify context + * later. The host callback does not call back into Go synchronously. + */ + emscripten_sleep((unsigned int)milliseconds); +#else + if (milliseconds > INT_MAX) { + milliseconds = INT_MAX; + } + (void)poll(NULL, 0, (int)milliseconds); +#endif +} diff --git a/runtime/internal/wasmevent/dispatch.go b/runtime/internal/wasmevent/dispatch.go new file mode 100644 index 0000000000..255c884a0e --- /dev/null +++ b/runtime/internal/wasmevent/dispatch.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 wasmevent + +var ( + pollEvents func() int + waitEvent func() bool +) + +// Poll runs host events that are ready without blocking. +func Poll() int { + if pollEvents == nil { + return 0 + } + return pollEvents() +} + +// Wait blocks until a host event is ready. It reports false when no event +// source has been activated. +func Wait() bool { + if waitEvent == nil { + return false + } + return waitEvent() +} + +func installEventLoop(poll func() int, wait func() bool) { + if pollEvents != nil { + return + } + pollEvents = poll + waitEvent = wait +} diff --git a/runtime/internal/wasmevent/dispatch_test.go b/runtime/internal/wasmevent/dispatch_test.go new file mode 100644 index 0000000000..7e4601c0bf --- /dev/null +++ b/runtime/internal/wasmevent/dispatch_test.go @@ -0,0 +1,54 @@ +/* + * 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 wasmevent + +import "testing" + +func TestEventLoopDispatch(t *testing.T) { + oldPoll, oldWait := pollEvents, waitEvent + defer func() { + pollEvents, waitEvent = oldPoll, oldWait + }() + pollEvents, waitEvent = nil, nil + + if got := Poll(); got != 0 { + t.Fatalf("Poll without an event loop = %d, want 0", got) + } + if Wait() { + t.Fatal("Wait without an event loop returned true") + } + + polls, waits := 0, 0 + installEventLoop( + func() int { + polls++ + return 3 + }, + func() bool { + waits++ + return true + }, + ) + installEventLoop(func() int { return -1 }, func() bool { return false }) + + if got := Poll(); got != 3 || polls != 1 { + t.Fatalf("Poll = %d, calls = %d, want 3, 1", got, polls) + } + if !Wait() || waits != 1 { + t.Fatalf("Wait calls = %d, want true, 1", waits) + } +} diff --git a/runtime/internal/wasmevent/dispatch_wasm_workers.go b/runtime/internal/wasmevent/dispatch_wasm_workers.go new file mode 100644 index 0000000000..5657e13a52 --- /dev/null +++ b/runtime/internal/wasmevent/dispatch_wasm_workers.go @@ -0,0 +1,17 @@ +//go:build llgo && js && wasm && llgo.wasm_workers + +package wasmevent + +var wakeEvent func() + +// InstallWake registers the scheduler wakeup used when another worker changes +// the earliest host-event deadline. +func InstallWake(wake func()) { + wakeEvent = wake +} + +func notifyWake() { + if wakeEvent != nil { + wakeEvent() + } +} diff --git a/runtime/internal/wasmevent/queue.go b/runtime/internal/wasmevent/queue.go new file mode 100644 index 0000000000..2b3a1761b7 --- /dev/null +++ b/runtime/internal/wasmevent/queue.go @@ -0,0 +1,218 @@ +/* + * 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 wasmevent owns host-driven events for the single-worker WebAssembly +// scheduler. The queue is platform independent so timer semantics can be tested +// without a WebAssembly host. +package wasmevent + +const maxInt64 = int64(^uint64(0) >> 1) + +type Callback func(arg any, timer *Timer, scheduled, now int64) + +type Timer struct { + queue *queue + callback Callback + arg any + when int64 + period int64 + sequence uint64 + index int + active bool +} + +func (t *Timer) Deadline() (int64, bool) { + return t.when, t.active +} + +type queue struct { + timers []*Timer + nextSequence uint64 +} + +func (q *queue) len() int { + return len(q.timers) +} + +func (q *queue) deadline() (int64, bool) { + if len(q.timers) == 0 { + return 0, false + } + return q.timers[0].when, true +} + +// Reset schedules timer and reports whether it was active before the reset. +func (q *queue) reset(timer *Timer, when, period int64, callback Callback, arg any) bool { + if timer == nil { + return false + } + wasActive := timer.active + if timer.active { + timer.queue.remove(timer.index) + } + timer.queue = q + timer.callback = callback + timer.arg = arg + timer.when = when + timer.period = period + timer.sequence = q.nextSequence + q.nextSequence++ + timer.index = len(q.timers) + timer.active = true + q.timers = append(q.timers, timer) + q.siftUp(timer.index) + return wasActive +} + +// Stop removes timer and reports whether it was active. +func (q *queue) stop(timer *Timer) bool { + if timer == nil || !timer.active || timer.queue != q { + return false + } + q.remove(timer.index) + timer.callback = nil + timer.arg = nil + return true +} + +// RunDue runs each timer due at now once. Periodic timers skip missed periods +// and are reinserted before their callback, so a callback can Stop or Reset +// itself with the same semantics as any other active timer. +func (q *queue) runDue(now int64) int { + ran := 0 + for len(q.timers) != 0 { + timer := q.timers[0] + if timer.when > now { + break + } + + scheduled := timer.when + period := timer.period + callback := timer.callback + arg := timer.arg + q.remove(0) + + if period > 0 { + next := nextPeriodicDeadline(scheduled, period, now) + q.reset(timer, next, period, callback, arg) + } + if callback != nil { + callback(arg, timer, scheduled, now) + } + if !timer.active { + timer.callback = nil + timer.arg = nil + } + ran++ + } + return ran +} + +func waitForEvent(q *queue, now func() int64, wait func(uint64)) bool { + for { + current := now() + if q.runDue(current) != 0 { + return true + } + deadline, ok := q.deadline() + if !ok { + return false + } + if deadline > current { + wait(uint64(deadline - current)) + } + } +} + +func nextPeriodicDeadline(when, period, now int64) int64 { + if when > maxInt64-period { + return maxInt64 + } + next := when + period + if next > now { + return next + } + steps := (now-when)/period + 1 + if steps > (maxInt64-when)/period { + return maxInt64 + } + return when + steps*period +} + +func (q *queue) less(i, j int) bool { + left, right := q.timers[i], q.timers[j] + if left.when != right.when { + return left.when < right.when + } + return left.sequence < right.sequence +} + +func (q *queue) swap(i, j int) { + q.timers[i], q.timers[j] = q.timers[j], q.timers[i] + q.timers[i].index = i + q.timers[j].index = j +} + +func (q *queue) siftUp(index int) { + for index > 0 { + parent := (index - 1) / 2 + if !q.less(index, parent) { + return + } + q.swap(index, parent) + index = parent + } +} + +func (q *queue) siftDown(index int) { + for { + left := index*2 + 1 + if left >= len(q.timers) { + return + } + child := left + if right := left + 1; right < len(q.timers) && q.less(right, left) { + child = right + } + if !q.less(child, index) { + return + } + q.swap(index, child) + index = child + } +} + +func (q *queue) remove(index int) { + timer := q.timers[index] + last := len(q.timers) - 1 + if index != last { + q.timers[index] = q.timers[last] + q.timers[index].index = index + } + q.timers[last] = nil + q.timers = q.timers[:last] + if index < len(q.timers) { + parent := (index - 1) / 2 + if index > 0 && q.less(index, parent) { + q.siftUp(index) + } else { + q.siftDown(index) + } + } + timer.queue = nil + timer.index = -1 + timer.active = false +} diff --git a/runtime/internal/wasmevent/queue_test.go b/runtime/internal/wasmevent/queue_test.go new file mode 100644 index 0000000000..849941285a --- /dev/null +++ b/runtime/internal/wasmevent/queue_test.go @@ -0,0 +1,147 @@ +package wasmevent + +import ( + "reflect" + "testing" +) + +func appendEvent(arg any, _ *Timer, scheduled, now int64) { + events := arg.(*[]int64) + *events = append(*events, scheduled, now) +} + +type orderedEvent struct { + id int64 + events *[]int64 +} + +func appendOrderedEvent(arg any, _ *Timer, _, _ int64) { + event := arg.(orderedEvent) + *event.events = append(*event.events, event.id) +} + +func TestQueueOrdersDeadlinesAndTies(t *testing.T) { + var q queue + var events []int64 + var first, second, third Timer + q.reset(&first, 30, 0, appendOrderedEvent, orderedEvent{1, &events}) + q.reset(&second, 10, 0, appendOrderedEvent, orderedEvent{2, &events}) + q.reset(&third, 10, 0, appendOrderedEvent, orderedEvent{3, &events}) + + if deadline, ok := q.deadline(); !ok || deadline != 10 { + t.Fatalf("Deadline = %d, %v; want 10, true", deadline, ok) + } + if got := q.runDue(9); got != 0 { + t.Fatalf("RunDue(9) = %d, want 0", got) + } + if got := q.runDue(10); got != 2 { + t.Fatalf("RunDue(10) = %d, want 2", got) + } + if want := []int64{2, 3}; !reflect.DeepEqual(events, want) { + t.Fatalf("events = %v, want %v", events, want) + } + if got := q.runDue(30); got != 1 || q.len() != 0 { + t.Fatalf("runDue(30) = %d, Len = %d; want 1, 0", got, q.len()) + } +} + +func TestQueueResetAndStop(t *testing.T) { + var q queue + var timer Timer + if active := q.reset(&timer, 30, 0, nil, nil); active { + t.Fatal("initial Reset reported an active timer") + } + if active := q.reset(&timer, 20, 0, nil, nil); !active { + t.Fatal("second Reset reported an inactive timer") + } + if deadline, ok := timer.Deadline(); !ok || deadline != 20 { + t.Fatalf("timer deadline = %d, %v; want 20, true", deadline, ok) + } + if !q.stop(&timer) { + t.Fatal("Stop reported an inactive timer") + } + if q.stop(&timer) { + t.Fatal("second Stop reported an active timer") + } + if _, ok := q.deadline(); ok { + t.Fatal("stopped timer remained in the queue") + } +} + +func TestQueuePeriodicTimerSkipsMissedPeriods(t *testing.T) { + var q queue + var timer Timer + var events []int64 + q.reset(&timer, 10, 10, appendEvent, &events) + + if got := q.runDue(35); got != 1 { + t.Fatalf("RunDue(35) = %d, want 1", got) + } + if want := []int64{10, 35}; !reflect.DeepEqual(events, want) { + t.Fatalf("events = %v, want %v", events, want) + } + if deadline, ok := timer.Deadline(); !ok || deadline != 40 { + t.Fatalf("timer deadline = %d, %v; want 40, true", deadline, ok) + } +} + +func TestQueueCallbackCanResetAndStop(t *testing.T) { + var q queue + var resetTimer, stoppedTimer Timer + resetCount := 0 + q.reset(&resetTimer, 5, 0, func(_ any, timer *Timer, _, _ int64) { + resetCount++ + q.reset(timer, 20, 0, func(any, *Timer, int64, int64) { + resetCount++ + }, nil) + }, nil) + q.reset(&stoppedTimer, 5, 5, func(_ any, timer *Timer, _, _ int64) { + q.stop(timer) + }, nil) + + if got := q.runDue(5); got != 2 { + t.Fatalf("RunDue(5) = %d, want 2", got) + } + if q.len() != 1 { + t.Fatalf("Len = %d, want 1", q.len()) + } + if got := q.runDue(20); got != 1 || resetCount != 2 { + t.Fatalf("RunDue(20) = %d, reset callbacks = %d; want 1, 2", got, resetCount) + } +} + +func TestWaitForEventRechecksAfterEarlyWake(t *testing.T) { + var q queue + var timer Timer + now := int64(0) + waits := 0 + q.reset(&timer, 10, 0, func(any, *Timer, int64, int64) {}, nil) + + if !waitForEvent(&q, func() int64 { return now }, func(delay uint64) { + waits++ + if delay != uint64(10-now) { + t.Fatalf("wait delay = %d, want %d", delay, 10-now) + } + now += 5 + }) { + t.Fatal("waitForEvent reported no event") + } + if waits != 2 { + t.Fatalf("host waits = %d, want 2", waits) + } + if waitForEvent(&q, func() int64 { return now }, func(uint64) {}) { + t.Fatal("empty queue reported an event") + } +} + +func BenchmarkQueueResetAndRunDue(b *testing.B) { + var q queue + var timer Timer + callback := func(any, *Timer, int64, int64) {} + + b.ReportAllocs() + for i := 0; i < b.N; i++ { + q.reset(&timer, int64(i), 0, callback, nil) + q.runDue(int64(i)) + } +} diff --git a/runtime/internal/wasmevent/runtime_mutex_workers.go b/runtime/internal/wasmevent/runtime_mutex_workers.go new file mode 100644 index 0000000000..128c6b8e3a --- /dev/null +++ b/runtime/internal/wasmevent/runtime_mutex_workers.go @@ -0,0 +1,25 @@ +//go:build llgo && js && wasm && llgo.wasm_workers + +package wasmevent + +import "github.com/goplus/llgo/runtime/internal/clite/pthread/sync" + +type runtimeMutex struct { + mutex sync.Mutex +} + +func newRuntimeMutex() runtimeMutex { + var result runtimeMutex + if result.mutex.Init(nil) != 0 { + panic("wasmevent: failed to initialize timer mutex") + } + return result +} + +func (m *runtimeMutex) Lock() { + m.mutex.Lock() +} + +func (m *runtimeMutex) Unlock() { + m.mutex.Unlock() +} diff --git a/runtime/internal/wasmevent/runtime_wasm.go b/runtime/internal/wasmevent/runtime_wasm.go new file mode 100644 index 0000000000..f3fa8bb433 --- /dev/null +++ b/runtime/internal/wasmevent/runtime_wasm.go @@ -0,0 +1,55 @@ +//go:build llgo && wasm && !(wasip1 && llgo.wasi_threads) && !llgo.wasm_workers + +/* + * 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 wasmevent + +import _ "unsafe" + +const LLGoFiles = "_wrap/event_wasm.c" + +var runtimeQueue queue + +func Reset(timer *Timer, when, period int64, callback Callback, arg any) bool { + if timer == nil { + return false + } + installEventLoop(pollRuntimeQueue, waitRuntimeQueue) + return runtimeQueue.reset(timer, when, period, callback, arg) +} + +func Stop(timer *Timer) bool { + return runtimeQueue.stop(timer) +} + +func pollRuntimeQueue() int { + return runtimeQueue.runDue(Now()) +} + +func waitRuntimeQueue() bool { + return waitForEvent(&runtimeQueue, Now, hostWait) +} + +func Now() int64 { + return hostNow() +} + +//go:linkname hostNow C.llgo_wasm_event_now +func hostNow() int64 + +//go:linkname hostWait C.llgo_wasm_event_wait +func hostWait(nanoseconds uint64) diff --git a/runtime/internal/wasmevent/runtime_wasm_workers.go b/runtime/internal/wasmevent/runtime_wasm_workers.go new file mode 100644 index 0000000000..e57f107fa6 --- /dev/null +++ b/runtime/internal/wasmevent/runtime_wasm_workers.go @@ -0,0 +1,119 @@ +//go:build llgo && js && wasm && llgo.wasm_workers + +package wasmevent + +import _ "unsafe" + +const LLGoFiles = "_wrap/event_wasm.c" + +type dueTimer struct { + timer *Timer + callback Callback + arg any + scheduled int64 +} + +var runtimeQueue queue +var runtimeQueueLock = newRuntimeMutex() + +func Reset(timer *Timer, when, period int64, callback Callback, arg any) bool { + if timer == nil { + return false + } + runtimeQueueLock.Lock() + installEventLoop(pollRuntimeQueue, waitRuntimeQueue) + active := runtimeQueue.reset(timer, when, period, callback, arg) + runtimeQueueLock.Unlock() + notifyWake() + return active +} + +func Stop(timer *Timer) bool { + runtimeQueueLock.Lock() + active := runtimeQueue.stop(timer) + runtimeQueueLock.Unlock() + if active { + notifyWake() + } + return active +} + +func pollRuntimeQueue() int { + now := Now() + ran := 0 + for { + runtimeQueueLock.Lock() + due, ok := popRuntimeDue(now) + runtimeQueueLock.Unlock() + if !ok { + return ran + } + if due.callback != nil { + due.callback(due.arg, due.timer, due.scheduled, now) + } + runtimeQueueLock.Lock() + if !due.timer.active { + due.timer.callback = nil + due.timer.arg = nil + } + runtimeQueueLock.Unlock() + ran++ + } +} + +func popRuntimeDue(now int64) (due dueTimer, ok bool) { + if len(runtimeQueue.timers) == 0 { + return due, false + } + timer := runtimeQueue.timers[0] + if timer.when > now { + return due, false + } + due = dueTimer{ + timer: timer, + callback: timer.callback, + arg: timer.arg, + scheduled: timer.when, + } + period := timer.period + runtimeQueue.remove(0) + if period > 0 { + next := nextPeriodicDeadline(due.scheduled, period, now) + runtimeQueue.reset(timer, next, period, due.callback, due.arg) + } + return due, true +} + +func waitRuntimeQueue() bool { + for { + if pollRuntimeQueue() != 0 { + return true + } + now := Now() + deadline, ok := NextDeadline() + if !ok { + return false + } + if deadline > now { + hostWait(uint64(deadline - now)) + } + } +} + +// NextDeadline reports the earliest active host-event deadline. +func NextDeadline() (int64, bool) { + runtimeQueueLock.Lock() + deadline, ok := runtimeQueue.deadline() + runtimeQueueLock.Unlock() + return deadline, ok +} + +func Now() int64 { + return hostNow() +} + +//go:linkname hostNow C.llgo_wasm_event_now +func hostNow() int64 + +//go:linkname hostWait C.llgo_wasm_event_wait +func hostWait(nanoseconds uint64) diff --git a/runtime/internal/wasmworkers/_wrap/workers.c b/runtime/internal/wasmworkers/_wrap/workers.c new file mode 100644 index 0000000000..4c2e6a958d --- /dev/null +++ b/runtime/internal/wasmworkers/_wrap/workers.c @@ -0,0 +1,36 @@ +#include +#include +#include +#include + +#ifndef LLGO_WASM_WORKERS +#define LLGO_WASM_WORKERS 1 +#endif + +static _Thread_local void *llgo_wasm_current_worker; + +int llgo_wasm_worker_count(void) { + return LLGO_WASM_WORKERS; +} + +void *llgo_wasm_worker_current(void) { + return llgo_wasm_current_worker; +} + +void llgo_wasm_worker_set_current(void *worker) { + llgo_wasm_current_worker = worker; +} + +int llgo_wasm_worker_wait( + uint32_t *address, uint32_t expected, int64_t timeout_nanoseconds) { + double timeout_milliseconds = INFINITY; + if (timeout_nanoseconds >= 0) { + timeout_milliseconds = (double)timeout_nanoseconds / 1000000.0; + } + return emscripten_futex_wait( + (volatile void *)address, expected, timeout_milliseconds); +} + +int llgo_wasm_worker_wake(uint32_t *address) { + return emscripten_futex_wake((volatile void *)address, INT_MAX); +} diff --git a/runtime/internal/wasmworkers/workers.go b/runtime/internal/wasmworkers/workers.go new file mode 100644 index 0000000000..049070fb80 --- /dev/null +++ b/runtime/internal/wasmworkers/workers.go @@ -0,0 +1,94 @@ +//go:build llgo && js && wasm && llgo.wasm_workers + +/* + * 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 wasmworkers contains the Emscripten host boundary for the bounded +// WebAssembly M/P worker pool. +package wasmworkers + +import ( + "unsafe" + + c "github.com/goplus/llgo/runtime/internal/clite" + "github.com/goplus/llgo/runtime/internal/clite/pthread" +) + +const LLGoFiles = "_wrap/workers.c" + +//llgo:type C +type Entry func(unsafe.Pointer) unsafe.Pointer + +func Count() int { + return int(workerCount()) +} + +func Current() unsafe.Pointer { + return workerCurrent() +} + +func SetCurrent(worker unsafe.Pointer) { + workerSetCurrent(worker) +} + +func Start(entry Entry, arg unsafe.Pointer, stackSize uintptr) int { + var attr pthread.Attr + if ret := attr.Init(); ret != 0 { + return int(ret) + } + if ret := attr.SetDetached(pthread.CreateDetached); ret != 0 { + _ = attr.Destroy() + return int(ret) + } + if stackSize != 0 { + if ret := attr.SetStackSize(stackSize); ret != 0 { + _ = attr.Destroy() + return int(ret) + } + } + var thread pthread.Thread + ret := pthread.Create( + &thread, + &attr, + pthread.RoutineFunc(entry), + c.Pointer(arg), + ) + _ = attr.Destroy() + return int(ret) +} + +func Wait(addr *uint32, expected uint32, timeoutNanoseconds int64) { + workerWait(addr, expected, timeoutNanoseconds) +} + +func Wake(addr *uint32) { + workerWake(addr) +} + +//go:linkname workerCount C.llgo_wasm_worker_count +func workerCount() c.Int + +//go:linkname workerCurrent C.llgo_wasm_worker_current +func workerCurrent() unsafe.Pointer + +//go:linkname workerSetCurrent C.llgo_wasm_worker_set_current +func workerSetCurrent(unsafe.Pointer) + +//go:linkname workerWait C.llgo_wasm_worker_wait +func workerWait(addr *uint32, expected uint32, timeoutNanoseconds int64) c.Int + +//go:linkname workerWake C.llgo_wasm_worker_wake +func workerWake(addr *uint32) c.Int diff --git a/ssa/decl.go b/ssa/decl.go index a575dd1bee..ed45b58ae0 100644 --- a/ssa/decl.go +++ b/ssa/decl.go @@ -254,6 +254,8 @@ type aFunction struct { fakeUses []llvm.Value fakeUseSet map[llvm.Value]struct{} + gcRootPrev Expr + diFunc DIFunction } @@ -458,4 +460,12 @@ func (p Function) DisableTailCalls() { p.impl.AddFunctionAttr(attr) } +// SetWasmImport maps an external function declaration to a WebAssembly host +// import. +func (p Function) SetWasmImport(module, name string) { + ctx := p.Pkg.mod.Context() + p.impl.AddFunctionAttr(ctx.CreateStringAttribute("wasm-import-module", module)) + p.impl.AddFunctionAttr(ctx.CreateStringAttribute("wasm-import-name", name)) +} + // ----------------------------------------------------------------------------- diff --git a/ssa/eh.go b/ssa/eh.go index b8ead4eb64..3cc2a2b787 100644 --- a/ssa/eh.go +++ b/ssa/eh.go @@ -270,6 +270,9 @@ func (b Builder) initDeferState(procBlk, rethrowBlk BasicBlock) (*aDefer, Expr, ptr := b.aggregateAllocU(prog.Defer(), jb.impl, zero.impl, link.impl, procBlk.Addr().impl) deferData := Expr{ptr, prog.DeferPtr()} b.Call(b.Pkg.rtFunc("SetThreadDefer"), deferData) + if prog.GCRootsEnabled() { + b.Call(b.Pkg.rtFunc("SetDeferGCRoot"), deferData) + } bitsPtr := b.FieldAddr(deferData, deferBits) rethPtr := b.FieldAddr(deferData, deferRethrow) rundPtr := b.FieldAddr(deferData, deferRunDefers) @@ -548,15 +551,41 @@ func (b Builder) RunDefers() { return } blk := b.Func.MakeBlock() + next := len(self.rundsNext) self.rundsNext = append(self.rundsNext, blk) - b.Store(self.rundPtr, blk.Addr()) + b.storeRunDefersTarget(self.rundPtr, next, blk) b.Jump(self.procBlk) b.SetBlockEx(blk, AtEnd, false) b.blk.last = blk.last } +func (b Builder) storeRunDefersTarget(ptr Expr, index int, target BasicBlock) { + value := target.Addr() + if b.Prog.target.GOARCH == "wasm" { + value = b.PtrCast(b.Prog.VoidPtr(), b.Prog.Val(uintptr(index))) + } + b.Store(ptr, value) +} + +func (b Builder) jumpRunDefersTarget(ptr Expr, targets []BasicBlock) { + target := b.Load(ptr) + if b.Prog.target.GOARCH != "wasm" { + b.IndirectJump(target, targets) + return + } + + index := b.Convert(b.Prog.Uintptr(), target) + invalid := b.Func.MakeBlock() + sw := b.impl.CreateSwitch(index.impl, invalid.first, len(targets)) + for i, target := range targets { + sw.AddCase(b.Prog.Val(uintptr(i)).impl, target.first) + } + b.SetBlockEx(invalid, AtEnd, false) + b.Unreachable() +} + func (p Function) endDefer(b Builder) { self := p.defer_ if self == nil { @@ -593,10 +622,10 @@ func (p Function) endDefer(b Builder) { } link := b.getField(b.Load(self.data), deferLink) b.Call(b.Pkg.rtFunc("SetThreadDefer"), link) - b.IndirectJump(b.Load(rundPtr), nexts) + b.jumpRunDefersTarget(rundPtr, nexts) b.SetBlockEx(panicBlk, AtEnd, false) // panicBlk: exec runDefers and rethrow - b.Store(rundPtr, rethrowBlk.Addr()) + b.storeRunDefersTarget(rundPtr, 0, rethrowBlk) b.IndirectJump(b.Load(rethPtr), rethsNext) } diff --git a/ssa/eh_defer_test.go b/ssa/eh_defer_test.go index 5f99729b1e..c48b447033 100644 --- a/ssa/eh_defer_test.go +++ b/ssa/eh_defer_test.go @@ -40,6 +40,31 @@ func TestExplicitDeferStackIR(t *testing.T) { if !strings.Contains(ir, "sigsetjmp") && !strings.Contains(ir, "setjmp") { t.Fatalf("expected explicit defer stack setup in IR, got:\n%s", ir) } + if strings.Contains(ir, "SetDeferGCRoot") { + t.Fatalf("disabled root publication changed defer setup:\n%s", ir) + } +} + +func TestDeferCapturesGCRootChain(t *testing.T) { + prog := ssatest.NewProgram(t, nil) + prog.EnableGCRoots(true) + pkg := prog.NewPackage("foo", "foo") + + callee := pkg.NewFunc("callee", ssa.NoArgsNoRet, ssa.InGo) + cb := callee.MakeBody(1) + cb.Return() + cb.EndBuild() + + fn := pkg.NewFunc("main", ssa.NoArgsNoRet, ssa.InGo) + b := fn.MakeBody(1) + fn.SetRecover(fn.MakeBlock()) + b.Defer(ssa.DeferAlways, callee.Expr, ssa.Builder.Call) + b.Return() + b.EndBuild() + + if ir := pkg.Module().String(); !strings.Contains(ir, "SetDeferGCRoot") { + t.Fatalf("root-enabled defer did not capture its root chain:\n%s", ir) + } } func TestExplicitDeferStackFallbackAndNilBuiltin(t *testing.T) { @@ -156,3 +181,31 @@ func TestConditionalDeferIR(t *testing.T) { t.Fatalf("expected conditional defer bitmask operations in IR, got:\n%s", ir) } } + +func TestWasmRunDefersUsesStaticDispatch(t *testing.T) { + prog := ssatest.NewProgram(t, nil) + prog.Target().GOOS = "js" + prog.Target().GOARCH = "wasm" + pkg := prog.NewPackage("foo", "foo") + + callee := pkg.NewFunc("callee", ssa.NoArgsNoRet, ssa.InGo) + cb := callee.MakeBody(1) + cb.Return() + cb.EndBuild() + + fn := pkg.NewFunc("main", ssa.NoArgsNoRet, ssa.InGo) + b := fn.MakeBody(1) + fn.SetRecover(fn.MakeBlock()) + b.Defer(ssa.DeferAlways, callee.Expr, ssa.Builder.Call) + b.RunDefers() + b.Return() + b.EndBuild() + + ir := pkg.Module().String() + if !strings.Contains(ir, "switch i64") { + t.Fatalf("expected wasm RunDefers selector dispatch in IR, got:\n%s", ir) + } + if got := strings.Count(ir, "indirectbr"); got != 1 { + t.Fatalf("got %d indirect branches, want only the rethrow dispatch:\n%s", got, ir) + } +} diff --git a/ssa/gcroot.go b/ssa/gcroot.go new file mode 100644 index 0000000000..011614a1d9 --- /dev/null +++ b/ssa/gcroot.go @@ -0,0 +1,202 @@ +/* + * 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 ssa + +import ( + "go/types" + + "github.com/xgo-dev/llvm" +) + +const gcRootChainName = "llvm_gc_root_chain" + +// EnableGCRoots controls compiler-maintained GC roots. +func (p Program) EnableGCRoots(enable bool) { + p.enableGCRoots = enable +} + +// GCRootsEnabled reports whether compiler-maintained GC roots are enabled. +func (p Program) GCRootsEnabled() bool { + return p.enableGCRoots +} + +// NewGCRoots reserves count pointer roots in one compiler-maintained frame. +// It must be called once, before the function emits a return. +func (p Function) NewGCRoots(count int) []Expr { + if count <= 0 { + return nil + } + if !p.gcRootPrev.IsNil() { + panic("ssa: GC roots already reserved") + } + b := p.NewBuilder() + defer b.Dispose() + entry := p.Block(0) + if entry.first.FirstInstruction().IsNil() { + b.SetBlockEx(entry, AtEnd, false) + } else { + b.SetBlockEx(entry, AtStart, false) + } + + prog := p.Prog + voidPtr := prog.tyVoidPtr() + rootArrayType := llvm.ArrayType(voidPtr, count) + frameType := prog.ctx.StructType([]llvm.Type{voidPtr, voidPtr, rootArrayType}, false) + frame := llvm.CreateAlloca(b.impl, frameType) + + chain := p.gcRootChain() + prev := llvm.CreateLoad(b.impl, voidPtr, chain) + b.impl.CreateStore(prev, llvm.CreateStructGEP(b.impl, frameType, frame, 0)) + + frameMap := p.newGCRootMap(count) + b.impl.CreateStore(frameMap, llvm.CreateStructGEP(b.impl, frameType, frame, 1)) + + roots := make([]Expr, count) + rootArray := llvm.CreateStructGEP(b.impl, frameType, frame, 2) + zero := llvm.ConstInt(prog.tyInt32(), 0, false) + for i := range roots { + index := llvm.ConstInt(prog.tyInt32(), uint64(i), false) + root := llvm.CreateInBoundsGEP(b.impl, rootArrayType, rootArray, []llvm.Value{zero, index}) + b.impl.CreateStore(llvm.ConstNull(voidPtr), root) + roots[i] = Expr{root, prog.Pointer(prog.VoidPtr())} + } + b.impl.CreateStore(frame, chain) + p.gcRootPrev = Expr{prev, prog.VoidPtr()} + return roots +} + +// SetGCRoot publishes value through a root created by NewGCRoots. +func (b Builder) SetGCRoot(root, value Expr) { + b.Store(root, b.Convert(b.Prog.VoidPtr(), value)) +} + +func (p Function) gcRootChain() llvm.Value { + global := p.Pkg.mod.NamedGlobal(gcRootChainName) + if global.IsNil() { + global = llvm.AddGlobal(p.Pkg.mod, p.Prog.tyVoidPtr(), gcRootChainName) + } + global.SetInitializer(llvm.ConstNull(p.Prog.tyVoidPtr())) + global.SetLinkage(llvm.LinkOnceAnyLinkage) + global.SetAlignment(p.Prog.PointerSize()) + return global +} + +func (p Function) newGCRootMap(count int) llvm.Value { + prog := p.Prog + mapType := prog.ctx.StructType([]llvm.Type{prog.tyInt32(), prog.tyInt32()}, false) + name := p.Name() + "$gcmap" + global := llvm.AddGlobal(p.Pkg.mod, mapType, name) + global.SetInitializer(llvm.ConstNamedStruct(mapType, []llvm.Value{ + llvm.ConstInt(prog.tyInt32(), uint64(count), false), + llvm.ConstInt(prog.tyInt32(), 0, false), + })) + global.SetGlobalConstant(true) + global.SetLinkage(llvm.InternalLinkage) + global.SetAlignment(4) + return global +} + +func (p Function) endGCRoots(b Builder) { + if p.gcRootPrev.IsNil() { + return + } + chain := p.gcRootChain() + for block := p.impl.FirstBasicBlock(); !block.IsNil(); block = llvm.NextBasicBlock(block) { + term := block.LastInstruction() + if term.IsNil() || term.InstructionOpcode() != llvm.Ret { + continue + } + b.impl.SetInsertPointBefore(term) + b.impl.CreateStore(p.gcRootPrev.impl, chain) + } +} + +// GCRootCount reports how many heap pointers typ contributes to a root frame. +func (p Program) GCRootCount(typ Type) int { + switch typ.kind { + case vkPtr, vkString, vkSlice, vkMap, vkEface, vkIface, vkClosure, vkChan: + return 1 + case vkStruct: + raw := typ.raw.Type.Underlying().(*types.Struct) + count := 0 + for i := 0; i < raw.NumFields(); i++ { + count += p.GCRootCount(p.Field(typ, i)) + } + return count + case vkArray: + raw := typ.raw.Type.Underlying().(*types.Array) + return int(raw.Len()) * p.GCRootCount(p.Index(typ)) + case vkTuple: + raw := typ.raw.Type.Underlying().(*types.Tuple) + count := 0 + for i := 0; i < raw.Len(); i++ { + count += p.GCRootCount(p.Field(typ, i)) + } + return count + default: + return 0 + } +} + +// GCRootPointers extracts the heap pointers represented by value. +func (b Builder) GCRootPointers(value Expr) []Expr { + var roots []Expr + b.appendGCRootPointers(&roots, value) + return roots +} + +func (b Builder) appendGCRootPointers(roots *[]Expr, value Expr) { + switch value.Type.kind { + case vkPtr, vkMap, vkChan: + *roots = append(*roots, b.Convert(b.Prog.VoidPtr(), value)) + case vkString: + *roots = append(*roots, b.Convert(b.Prog.VoidPtr(), b.StringData(value))) + case vkSlice: + *roots = append(*roots, b.Convert(b.Prog.VoidPtr(), b.SliceData(value))) + case vkEface, vkIface: + *roots = append(*roots, b.InterfaceData(value)) + case vkClosure: + data := llvm.CreateExtractValue(b.impl, value.impl, 1) + *roots = append(*roots, Expr{data, b.Prog.VoidPtr()}) + case vkStruct, vkTuple: + var count int + switch raw := value.Type.raw.Type.Underlying().(type) { + case *types.Struct: + count = raw.NumFields() + case *types.Tuple: + count = raw.Len() + } + for i := 0; i < count; i++ { + b.appendGCRootPointers(roots, b.Field(value, i)) + } + case vkArray: + raw := value.Type.raw.Type.Underlying().(*types.Array) + elem := b.Prog.Index(value.Type) + for i := 0; i < int(raw.Len()); i++ { + part := llvm.CreateExtractValue(b.impl, value.impl, i) + b.appendGCRootPointers(roots, Expr{part, elem}) + } + } +} + +// ClosureContextParam returns the hidden closure context parameter. +func (p Function) ClosureContextParam() Expr { + if p.base == 0 { + return Nil + } + return Expr{p.impl.Param(0), p.params[0]} +} diff --git a/ssa/gcroot_test.go b/ssa/gcroot_test.go new file mode 100644 index 0000000000..44d4e269b2 --- /dev/null +++ b/ssa/gcroot_test.go @@ -0,0 +1,186 @@ +//go:build !llgo + +package ssa_test + +import ( + "go/token" + "go/types" + "strings" + "testing" + + "github.com/goplus/llgo/ssa" + "github.com/goplus/llgo/ssa/ssatest" + "github.com/xgo-dev/llvm" +) + +func TestGCRootFrameIR(t *testing.T) { + prog := ssatest.NewProgram(t, &ssa.Target{GOOS: "js", GOARCH: "wasm"}) + pkg := prog.NewPackage("main", "main") + + param := types.NewParam(token.NoPos, nil, "p", types.NewPointer(types.Typ[types.Int])) + sig := types.NewSignatureType(nil, nil, nil, types.NewTuple(param), nil, false) + fn := pkg.NewFunc("main.keep", sig, ssa.InGo) + b := fn.MakeBody(1) + mayGC := pkg.NewFunc("runtime.mayGC", ssa.NoArgsNoRet, ssa.InGo) + b.Call(mayGC.Expr) + root := fn.NewGCRoots(1)[0] + assertPanics(t, func() { + fn.NewGCRoots(1) + }) + b.SetGCRoot(root, b.Param(0)) + b.Return() + b.EndBuild() + + if err := llvm.VerifyModule(pkg.Module(), llvm.ReturnStatusAction); err != nil { + t.Fatal(err) + } + ir := pkg.String() + for _, want := range []string{ + `define void @main.keep(ptr %0)`, + `@llvm_gc_root_chain`, + `[1 x ptr]`, + `store ptr %0`, + } { + if !strings.Contains(ir, want) { + t.Fatalf("missing %q in GC root IR:\n%s", want, ir) + } + } + if strings.Contains(ir, `llvm.gcroot`) || strings.Contains(ir, `gc "shadow-stack"`) { + t.Fatalf("GC roots must be lowered before optimization:\n%s", ir) + } + if push, call := strings.Index(ir, `store ptr %`), strings.Index(ir, `call void @runtime.mayGC`); push < 0 || call < 0 || push > call { + t.Fatalf("GC root frame must be linked before a safepoint:\n%s", ir) + } + + mod := pkg.Module() + mod.SetDataLayout(prog.DataLayout()) + mod.SetTarget(prog.Target().Spec().Triple) + pbo := llvm.NewPassBuilderOptions() + defer pbo.Dispose() + if err := mod.RunPasses("default", prog.TargetMachine(), pbo); err != nil { + t.Fatalf("optimize GC root frame: %v", err) + } + if err := llvm.VerifyModule(mod, llvm.ReturnStatusAction); err != nil { + t.Fatalf("verify optimized GC root frame: %v", err) + } + optimized := mod.String() + if !strings.Contains(optimized, `@llvm_gc_root_chain`) || + strings.Contains(optimized, `llvm.gcroot`) || + strings.Contains(optimized, `gc "shadow-stack"`) { + t.Fatalf("optimization changed the lowered GC root ABI:\n%s", optimized) + } +} + +func TestGCRootReservationAndClosureContext(t *testing.T) { + prog := ssatest.NewProgram(t, &ssa.Target{GOOS: "js", GOARCH: "wasm"}) + pkg := prog.NewPackage("main", "main") + + fn := pkg.NewFunc("main.empty", ssa.NoArgsNoRet, ssa.InGo) + if roots := fn.NewGCRoots(0); roots != nil { + t.Fatalf("NewGCRoots(0) = %v, want nil", roots) + } + if context := fn.ClosureContextParam(); !context.IsNil() { + t.Fatal("ordinary function unexpectedly has a closure context") + } + + context := types.NewParam(token.NoPos, nil, "__llgo_ctx", types.Typ[types.UnsafePointer]) + closureSig := ssa.FuncAddCtx(context, ssa.NoArgsNoRet) + closure := pkg.NewFuncEx("main.closure", closureSig, ssa.InGo, true, false) + if context := closure.ClosureContextParam(); context.IsNil() { + t.Fatal("closure function is missing its hidden context") + } +} + +func TestAggregateGCRootPointers(t *testing.T) { + prog := ssatest.NewProgram(t, &ssa.Target{GOOS: "js", GOARCH: "wasm"}) + pkg := prog.NewPackage("main", "main") + + ptr := types.NewPointer(types.Typ[types.Int]) + fnType := types.NewSignatureType(nil, nil, nil, nil, nil, false) + fields := []*types.Var{ + types.NewField(token.NoPos, nil, "p", ptr, false), + types.NewField(token.NoPos, nil, "s", types.NewSlice(types.Typ[types.Byte]), false), + types.NewField(token.NoPos, nil, "text", types.Typ[types.String], false), + types.NewField(token.NoPos, nil, "any", types.NewInterfaceType(nil, nil).Complete(), false), + types.NewField(token.NoPos, nil, "fn", fnType, false), + types.NewField(token.NoPos, nil, "array", types.NewArray(ptr, 2), false), + } + holder := types.NewStruct(fields, nil) + param := types.NewParam(token.NoPos, nil, "holder", holder) + sig := types.NewSignatureType(nil, nil, nil, types.NewTuple(param), nil, false) + fn := pkg.NewFunc("main.aggregate", sig, ssa.InGo) + b := fn.MakeBody(1) + + value := b.Param(0) + if got := prog.GCRootCount(value.Type); got != 7 { + t.Fatalf("GCRootCount(holder) = %d, want 7", got) + } + roots := b.GCRootPointers(value) + if len(roots) != 7 { + t.Fatalf("GCRootPointers(holder) returned %d roots, want 7", len(roots)) + } + slots := fn.NewGCRoots(len(roots)) + for i, value := range roots { + b.SetGCRoot(slots[i], value) + } + b.Return() + b.EndBuild() + + if err := llvm.VerifyModule(pkg.Module(), llvm.ReturnStatusAction); err != nil { + t.Fatal(err) + } + if ir := pkg.String(); !strings.Contains(ir, `[7 x ptr]`) { + t.Fatalf("aggregate did not emit one seven-root frame:\n%s", ir) + } +} + +func TestPatchedNestedGCRootPointers(t *testing.T) { + prog := ssatest.NewProgram(t, &ssa.Target{GOOS: "js", GOARCH: "wasm"}) + pkg := prog.NewPackage("main", "main") + + originalPkg := types.NewPackage("syscall/js", "js") + originalName := types.NewTypeName(token.NoPos, originalPkg, "Value", nil) + original := types.NewNamed(originalName, types.NewStruct([]*types.Var{ + types.NewField(token.NoPos, originalPkg, "pointer", types.NewPointer(types.Typ[types.Int]), false), + types.NewField(token.NoPos, originalPkg, "data", types.Typ[types.UnsafePointer], false), + }, nil), nil) + patchedName := types.NewTypeName(token.NoPos, originalPkg, "Value", nil) + patched := types.NewNamed(patchedName, types.NewStruct([]*types.Var{ + types.NewField(token.NoPos, originalPkg, "ref", types.Typ[types.Int32], false), + }, nil), nil) + prog.SetPatch(func(typ types.Type) types.Type { + if typ == original { + return patched + } + return typ + }) + + outer := types.NewStruct([]*types.Var{ + types.NewField(token.NoPos, nil, "value", original, false), + types.NewField(token.NoPos, nil, "err", types.NewInterfaceType(nil, nil).Complete(), false), + }, nil) + param := types.NewParam(token.NoPos, nil, "result", outer) + sig := types.NewSignatureType(nil, nil, nil, types.NewTuple(param), nil, false) + fn := pkg.NewFunc("main.patched", sig, ssa.InGo) + b := fn.MakeBody(1) + + roots := b.GCRootPointers(b.Param(0)) + if len(roots) != 1 { + t.Fatalf("GCRootPointers(patched nested struct) returned %d roots, want 1", len(roots)) + } + b.Return() + b.EndBuild() + if err := llvm.VerifyModule(pkg.Module(), llvm.ReturnStatusAction); err != nil { + t.Fatal(err) + } +} + +func assertPanics(t *testing.T, fn func()) { + t.Helper() + defer func() { + if recover() == nil { + t.Fatal("operation did not panic") + } + }() + fn() +} diff --git a/ssa/package.go b/ssa/package.go index 64263ea9fc..d821790a60 100644 --- a/ssa/package.go +++ b/ssa/package.go @@ -241,6 +241,8 @@ type aProgram struct { enableGoGlobalDCE bool enableDeadcodeDrop bool + enableGCRoots bool + enableSafepoints bool disableBoundsChecks bool pthreadStackSize uint64 enableLTOPluginMarker bool diff --git a/ssa/safepoint.go b/ssa/safepoint.go new file mode 100644 index 0000000000..f456c942a9 --- /dev/null +++ b/ssa/safepoint.go @@ -0,0 +1,27 @@ +/* + * 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 ssa + +// EnableCooperativeSafepoints controls compiler-inserted scheduling polls. +func (p Program) EnableCooperativeSafepoints(enable bool) { + p.enableSafepoints = enable +} + +// CooperativeSafepointsEnabled reports whether scheduling polls are enabled. +func (p Program) CooperativeSafepointsEnabled() bool { + return p.enableSafepoints +} diff --git a/ssa/ssa_test.go b/ssa/ssa_test.go index 38085a627b..179dee909f 100644 --- a/ssa/ssa_test.go +++ b/ssa/ssa_test.go @@ -2713,6 +2713,24 @@ func TestTargetMachineAndDataLayout(t *testing.T) { } } +func TestWasmTargetSpec(t *testing.T) { + for _, test := range []struct { + name string + target string + want string + }{ + {name: "Go environment", want: "wasm64-unknown-js"}, + {name: "configured target", target: "wasm", want: "wasm32-unknown-js"}, + } { + t.Run(test.name, func(t *testing.T) { + got := (&Target{GOOS: "js", GOARCH: "wasm", Target: test.target}).Spec().Triple + if got != test.want { + t.Fatalf("triple = %q, want %q", got, test.want) + } + }) + } +} + func TestAbiTables(t *testing.T) { prog := NewProgram(nil) prog.sizes = types.SizesFor("gc", runtime.GOARCH) diff --git a/ssa/stmt_builder.go b/ssa/stmt_builder.go index e9dd136f63..e4936f8f13 100644 --- a/ssa/stmt_builder.go +++ b/ssa/stmt_builder.go @@ -77,6 +77,7 @@ func (b Builder) EndBuild() { b.Func.emitFakeUsesInlineAsm(b) } b.Func.endDefer(b) + b.Func.endGCRoots(b) } // Dispose disposes of the builder. diff --git a/ssa/target.go b/ssa/target.go index a352b477fd..fbe8cf38ac 100644 --- a/ssa/target.go +++ b/ssa/target.go @@ -146,6 +146,11 @@ func (p *Target) Spec() (spec TargetSpec) { } case "wasm": llvmarch = "wasm32" + // Keep raw js/wasm consistent with Go's 64-bit word model. Named + // targets use their existing wasm32 ABI. + if goos == "js" && p.Target == "" { + llvmarch = "wasm64" + } default: llvmarch = goarch } diff --git a/ssa/type.go b/ssa/type.go index 98c9fc4848..de4309b2cd 100644 --- a/ssa/type.go +++ b/ssa/type.go @@ -261,7 +261,7 @@ func (p Program) Field(typ Type, i int) Type { } fld = st.Field(i) } - return p.rawType(fld.Type()) + return p.rawType(p.patch(fld.Type())) } func typeStringWithPkg(t types.Type) string { diff --git a/ssa/wasm_import_test.go b/ssa/wasm_import_test.go new file mode 100644 index 0000000000..9cf7d76d55 --- /dev/null +++ b/ssa/wasm_import_test.go @@ -0,0 +1,28 @@ +//go:build !llgo + +package ssa_test + +import ( + "strings" + "testing" + + "github.com/goplus/llgo/ssa" + "github.com/goplus/llgo/ssa/ssatest" +) + +func TestWasmImportAttributes(t *testing.T) { + prog := ssatest.NewProgram(t, nil) + pkg := prog.NewPackage("foo", "foo") + fn := pkg.NewFunc("fdRead", ssa.NoArgsNoRet, ssa.InGo) + fn.SetWasmImport("wasi_snapshot_preview1", "fd_read") + + ir := pkg.Module().String() + for _, want := range []string{ + `"wasm-import-module"="wasi_snapshot_preview1"`, + `"wasm-import-name"="fd_read"`, + } { + if !strings.Contains(ir, want) { + t.Fatalf("missing %s in wasm import IR:\n%s", want, ir) + } + } +}