diff --git a/.github/workflows/llgo.yml b/.github/workflows/llgo.yml index 40eff89107..af30af60ca 100644 --- a/.github/workflows/llgo.yml +++ b/.github/workflows/llgo.yml @@ -374,6 +374,11 @@ jobs: with: version: "4.0.21" + - name: Set up Node.js + uses: actions/setup-node@v7 + with: + node-version: "25" + - name: Set up Go for building llgo uses: ./.github/actions/setup-go @@ -390,6 +395,21 @@ 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" + } + 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 + 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" file "$RUNNER_TEMP/runtime-js.wasm" "$RUNNER_TEMP/runtime-wasip1.wasm" diff --git a/.github/workflows/targets.yml b/.github/workflows/targets.yml index 49ce34aa6c..d08d96bd7d 100644 --- a/.github/workflows/targets.yml +++ b/.github/workflows/targets.yml @@ -27,6 +27,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/internal/build/build.go b/internal/build/build.go index 6db6ae9085..65f742d52c 100644 --- a/internal/build/build.go +++ b/internal/build/build.go @@ -251,9 +251,6 @@ func resolveBuildConfig(input *Config) (*Config, error) { if conf.Goarch == "" { conf.Goarch = runtime.GOARCH } - if conf.AppExt == "" { - conf.AppExt = defaultAppExt(conf) - } if conf.BuildMode == "" { conf.BuildMode = BuildModeExe } @@ -399,6 +396,9 @@ func Build(inv Invocation) ([]Package, error) { if conf.Target != "" && export.GOARCH != "" { conf.Goarch = export.GOARCH } + if conf.AppExt == "" { + conf.AppExt = defaultAppExt(conf) + } if err := validateLinkOptions(conf, &export); err != nil { return nil, err } @@ -479,10 +479,7 @@ func Build(inv Invocation) ([]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 @@ -793,16 +790,22 @@ 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 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 diff --git a/internal/build/build_test.go b/internal/build/build_test.go index 74951d5cd1..fcdbbabd2b 100644 --- a/internal/build/build_test.go +++ b/internal/build/build_test.go @@ -332,7 +332,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 { @@ -342,6 +342,30 @@ 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 TestWasmRuntimeAvoidsNativeHostDependencies(t *testing.T) { runtimeDir := filepath.Join(env.LLGoRuntimeDir(), "internal", "lib", "runtime") for _, goos := range []string{"js", "wasip1"} { 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..84d214f759 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") } }) } 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..12fd8c81ae --- /dev/null +++ b/internal/build/testdata/wasm-scheduler/main.go @@ -0,0 +1,144 @@ +package main + +import ( + "runtime" + "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 +) + +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") + } + println("wasm scheduler ok") +} + +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..73781131c4 --- /dev/null +++ b/internal/build/testdata/wasm-scheduler/model_go.go @@ -0,0 +1,14 @@ +//go:build !tinygo.wasm + +package main + +import "unsafe" + +func checkWasmModel() { + 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/crosscompile/crosscompile.go b/internal/crosscompile/crosscompile.go index c017c7e5f4..8e2c6943c0 100644 --- a/internal/crosscompile/crosscompile.go +++ b/internal/crosscompile/crosscompile.go @@ -218,6 +218,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() @@ -402,6 +406,7 @@ func use(goos, goarch string, wasiThreads, forceEspClang bool, level optlevel.Le "-fwasm-exceptions", "-mllvm", "-wasm-enable-sjlj", }...) + export.LLVMTarget = "wasm32-unknown-wasip1" // Add thread support if enabled if wasiThreads { export.CCFLAGS = append( @@ -417,7 +422,13 @@ func use(goos, goarch string, wasiThreads, forceEspClang bool, level optlevel.Le } 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" @@ -445,7 +456,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", @@ -457,6 +468,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) @@ -721,5 +735,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..66be579201 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" @@ -172,6 +173,88 @@ func TestUseCrossCompileSDK(t *testing.T) { } } +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/runtime/internal/clite/c.go b/runtime/internal/clite/c.go index 78a9897339..088991cb5b 100644 --- a/runtime/internal/clite/c.go +++ b/runtime/internal/clite/c.go @@ -51,7 +51,9 @@ type integer interface { } type SizeT = uintptr -type SsizeT = Long + +// ssize_t is the pointer-sized signed integer in every supported C data model. +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..00a4fe87f7 --- /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 (*Fiber).Init C.emscripten_fiber_init +func (fiber *Fiber) Init(entry FiberEntry, arg, stack c.Pointer, stackSize uintptr, asyncifyStack c.Pointer, asyncifyStackSize uintptr) { +} + +// llgo:link (*Fiber).InitCurrent C.emscripten_fiber_init_from_current_context +func (fiber *Fiber) InitCurrent(asyncifyStack c.Pointer, asyncifyStackSize uintptr) { +} + +// llgo:link (*Fiber).Swap C.emscripten_fiber_swap +func (fiber *Fiber) Swap(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..da2456dbc1 --- /dev/null +++ b/runtime/internal/clite/emscripten/fiber_test.go @@ -0,0 +1,12 @@ +package emscripten + +import ( + "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) + } +} 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/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/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/fatal_default.go b/runtime/internal/runtime/fatal_default.go new file mode 100644 index 0000000000..0046a9ec36 --- /dev/null +++ b/runtime/internal/runtime/fatal_default.go @@ -0,0 +1,7 @@ +//go:build !llgo || !js || !wasm + +package runtime + +func fatal(s string) { + print("fatal error: ", s, "\n") +} diff --git a/runtime/internal/runtime/fatal_wasm.go b/runtime/internal/runtime/fatal_wasm.go new file mode 100644 index 0000000000..3482947aab --- /dev/null +++ b/runtime/internal/runtime/fatal_wasm.go @@ -0,0 +1,10 @@ +//go:build llgo && js && wasm + +package runtime + +import c "github.com/goplus/llgo/runtime/internal/clite" + +func fatal(s string) { + print("fatal error: ", s, "\n") + c.Exit(2) +} diff --git a/runtime/internal/runtime/g_tls.go b/runtime/internal/runtime/g_tls.go index 9a8bcc5262..7300e4be6e 100644 --- a/runtime/internal/runtime/g_tls.go +++ b/runtime/internal/runtime/g_tls.go @@ -1,4 +1,4 @@ -//go:build llgo && !baremetal +//go:build llgo && !baremetal && (!js || !wasm) /* * 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..9ec5a4f221 --- /dev/null +++ b/runtime/internal/runtime/g_wasm.go @@ -0,0 +1,32 @@ +//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 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/os_pthread.go b/runtime/internal/runtime/os_pthread.go index 4a7447fda4..28121f8d60 100644 --- a/runtime/internal/runtime/os_pthread.go +++ b/runtime/internal/runtime/os_pthread.go @@ -1,3 +1,5 @@ +//go:build !llgo || !js || !wasm + /* * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. * @@ -63,8 +65,8 @@ func initThreadAttr(attr *pthread.Attr, stackSize uintptr) c.Int { return 0 } -func exitCurrentM() { - mp := getg().m - mexit(mp) +func goexitBackend(gp *g) { + 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..06cb527227 --- /dev/null +++ b/runtime/internal/runtime/os_wasm.go @@ -0,0 +1,23 @@ +//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 runtime + +// mOS is empty for the single-worker WebAssembly backend. The host owns the +// physical worker instead of creating one for each M. +type mOS struct{} diff --git a/runtime/internal/runtime/proc.go b/runtime/internal/runtime/proc.go index 12dc6169c2..57234487d2 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 { @@ -58,19 +55,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 - releaseG() - 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") @@ -84,7 +73,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") @@ -95,63 +86,25 @@ 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 - ownedByLifecycle := currentGUsesLifecycle() - if !ownedByLifecycle { - releaseGAndCheckDeadlock() - } - - casgstatus(gp, _Grunning, _Gdead) - setpstatus(pp, _Pdead) - - pp.m = nil - mp.p = nil - mp.curg = nil - gp.m = nil + ctx.root = nil + FreeRoot(root) +} - setg(nil) - if !ownedByLifecycle && root != nil { - ctx.root = nil - FreeRoot(root) +func initG(ctx *runtimeContext, callergp *g, status uint32) *g { + gp := &ctx.g + gp.atomicstatus = status + gp.goid = nextGoid(gp) + if callergp != nil { + gp.parentGoid = callergp.goid } + gp.context = ctx + retainG() + return gp } // releaseGAndCheckDeadlock is the sole last-goroutine decision. Main marks its @@ -165,47 +118,10 @@ func releaseGAndCheckDeadlock() { } } -func initRuntimeContext(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 - retainG() - 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. WebAssembly backends re-queue the +// current G and yield to their scheduler; pthread Gs rely on the host scheduler. +func Gosched() { + goschedBackend() } // GStateForTesting reports the packed scheduler state without changing it. diff --git a/runtime/internal/runtime/proc_atomic.go b/runtime/internal/runtime/proc_atomic.go index 5db811ab3e..c0652f926a 100644 --- a/runtime/internal/runtime/proc_atomic.go +++ b/runtime/internal/runtime/proc_atomic.go @@ -25,16 +25,18 @@ const ( gCountMask = mainExitedBit - 1 ) +// 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 retainG() { diff --git a/runtime/internal/runtime/proc_pthread.go b/runtime/internal/runtime/proc_pthread.go new file mode 100644 index 0000000000..5aba69c90b --- /dev/null +++ b/runtime/internal/runtime/proc_pthread.go @@ -0,0 +1,127 @@ +//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 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 { + releaseG() + 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 + ownedByLifecycle := currentGUsesLifecycle() + if !ownedByLifecycle { + releaseGAndCheckDeadlock() + } + + casgstatus(gp, _Grunning, _Gdead) + setpstatus(pp, _Pdead) + + pp.m = nil + mp.p = nil + mp.curg = nil + gp.m = nil + + setg(nil) + if !ownedByLifecycle { + 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_wasm.go b/runtime/internal/runtime/proc_wasm.go new file mode 100644 index 0000000000..f5ae5d71b5 --- /dev/null +++ b/runtime/internal/runtime/proc_wasm.go @@ -0,0 +1,316 @@ +//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 runtime + +import ( + "unsafe" + + "github.com/goplus/llgo/runtime/internal/clite/emscripten" + "github.com/goplus/llgo/runtime/internal/runqueue" +) + +const ( + defaultWasmGStackSize = 64 << 10 + defaultWasmAsyncifyStackSize = 64 << 10 +) + +type runtimeContextPlatform struct { + fiber emscripten.Fiber + stack unsafe.Pointer + asyncifyStack unsafe.Pointer + runqNext *g + runqQueued bool +} + +var wasmSched struct { + m m + p p + runq runqueue.Queue[*g] + retired *runtimeContext + started bool +} + +func (gp *g) RunqueueNext() *g { + return gp.context.platform.runqNext +} + +func (gp *g) SetRunqueueNext(next *g) { + gp.context.platform.runqNext = next +} + +func (gp *g) RunqueueQueued() bool { + return gp.context.platform.runqQueued +} + +func (gp *g) SetRunqueueQueued(queued bool) { + gp.context.platform.runqQueued = queued +} + +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 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) + if !initWasmFiber(gp, stackSize) { + releaseG() + FreeRoot(arg) + freeRuntimeContext(gp.context) + panic("runtime: failed to allocate WebAssembly goroutine stack") + } + if !wasmSched.runq.Push(gp) { + fatal("runtime: invalid run queue insertion") + } +} + +func initWasmFiber(gp *g, stackSize uintptr) bool { + if stackSize == 0 { + stackSize = defaultWasmGStackSize + } + stackSize = alignWasmStackSize(stackSize) + asyncifySize := uintptr(defaultWasmAsyncifyStackSize) + if stackSize > asyncifySize { + asyncifySize = stackSize + } + + platform := &gp.context.platform + platform.stack = AllocRoot(stackSize) + if platform.stack == nil { + return false + } + platform.asyncifyStack = AllocRoot(asyncifySize) + if platform.asyncifyStack == nil { + FreeRoot(platform.stack) + platform.stack = nil + return false + } + platform.fiber.Init( + emscripten.FiberEntry(wasmGStart), + unsafe.Pointer(gp), + platform.stack, + stackSize, + platform.asyncifyStack, + asyncifySize, + ) + return true +} + +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.fiber.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 := popWasmRunq() + if next == nil { + fatal("all goroutines are asleep - deadlock!") + 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.fiber.Swap(&next.context.platform.fiber) + reapRetiredWasmG() +} + +func goexitBackend(gp *g) { + casgstatus(gp, _Grunning, _Gdead) + if wasmSched.retired != nil { + fatal("runtime: unreaped WebAssembly goroutine") + return + } + releaseGAndCheckDeadlock() + + next := popWasmRunq() + 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.fiber.Swap(&next.context.platform.fiber) + fatal("runtime: resumed dead WebAssembly goroutine") +} + +func reapRetiredWasmG() { + ctx := wasmSched.retired + if ctx == nil { + return + } + wasmSched.retired = nil + 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 popWasmRunq() *g { + return wasmSched.runq.Pop() +} + +// 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/runtime2.go b/runtime/internal/runtime/runtime2.go index 7787c8e225..fc8a559221 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 @@ -57,9 +58,7 @@ type g struct { paniconfault bool } -// 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 @@ -67,9 +66,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/stubs.go b/runtime/internal/runtime/stubs.go index 61d9013b89..6e50b2518d 100644 --- a/runtime/internal/runtime/stubs.go +++ b/runtime/internal/runtime/stubs.go @@ -116,10 +116,6 @@ func memclrHasPointers(ptr unsafe.Pointer, n uintptr) { func memclrNoHeapPointers(ptr unsafe.Pointer, n uintptr) { } -func fatal(s string) { - print("fatal error: ", s, "\n") -} - func throw(s string) { print("fatal error: ", s, "\n") } diff --git a/runtime/internal/runtime/z_default.go b/runtime/internal/runtime/z_default.go index e73aeff206..b76139b86a 100644 --- a/runtime/internal/runtime/z_default.go +++ b/runtime/internal/runtime/z_default.go @@ -28,18 +28,14 @@ func Rethrow(link *Defer) { 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. + // Goexit runs deferred functions before the selected scheduler removes + // the current goroutine. if link != nil { c.Siglongjmp(link.Addr, 1) } if gp.isMain { markMainExited() } - leaveCurrentLocalContext() - exitCurrentM() + goexitBackend(gp) } } diff --git a/ssa/ssa_test.go b/ssa/ssa_test.go index 05f998fae0..31c9b567c3 100644 --- a/ssa/ssa_test.go +++ b/ssa/ssa_test.go @@ -2628,6 +2628,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/target.go b/ssa/target.go index e9317f669c..c63a5855e0 100644 --- a/ssa/target.go +++ b/ssa/target.go @@ -147,6 +147,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 }