From e5fa165e28603e598277d5be5f9247eed0ce68f3 Mon Sep 17 00:00:00 2001 From: Li Jie Date: Sun, 2 Aug 2026 16:04:44 +0800 Subject: [PATCH] debug: add Wasmtime WASI source sessions --- .github/workflows/wasi-debug.yml | 77 ++++++++++ cmd/internal/debug/debug.go | 32 +++- cmd/internal/debug/debug_test.go | 127 +++++++++++++++ cmd/internal/debug/session.go | 112 +++++++++++--- cmd/internal/debug/wasi.go | 108 +++++++++++++ cmd/internal/lldb/lldb.go | 145 ++++++++++++++++-- cmd/internal/lldb/lldb_test.go | 137 +++++++++++++++++ cmd/internal/lldb/llgo_plugin.py | 89 ++++++++++- cmd/internal/wasmtime/wasmtime.go | 75 +++++++++ cmd/internal/wasmtime/wasmtime_test.go | 61 ++++++++ cmd/llgo/debugtest/README.md | 35 ++++- cmd/llgo/debugtest/wasi/go.mod | 3 + cmd/llgo/debugtest/wasi/main.go | 16 ++ cmd/llgo/debugtest/wasi/runtest.sh | 49 ++++++ internal/wasmdebug/wasmdebug.go | 204 ++++++++++++++++++++++++- internal/wasmdebug/wasmdebug_test.go | 71 +++++++++ 16 files changed, 1296 insertions(+), 45 deletions(-) create mode 100644 .github/workflows/wasi-debug.yml create mode 100644 cmd/internal/debug/wasi.go create mode 100644 cmd/internal/wasmtime/wasmtime.go create mode 100644 cmd/internal/wasmtime/wasmtime_test.go create mode 100644 cmd/llgo/debugtest/wasi/go.mod create mode 100644 cmd/llgo/debugtest/wasi/main.go create mode 100755 cmd/llgo/debugtest/wasi/runtest.sh diff --git a/.github/workflows/wasi-debug.yml b/.github/workflows/wasi-debug.yml new file mode 100644 index 0000000000..ccc659b0d8 --- /dev/null +++ b/.github/workflows/wasi-debug.yml @@ -0,0 +1,77 @@ +name: WASI Debug + +on: + push: + branches: [main] + paths: + - ".github/workflows/wasi-debug.yml" + - "cmd/internal/debug/**" + - "cmd/internal/lldb/**" + - "cmd/internal/wasmtime/**" + - "cmd/llgo/debugtest/wasi/**" + - "internal/debugabi/**" + - "internal/wasmdebug/**" + pull_request: + branches: ["**"] + paths: + - ".github/workflows/wasi-debug.yml" + - "cmd/internal/debug/**" + - "cmd/internal/lldb/**" + - "cmd/internal/wasmtime/**" + - "cmd/llgo/debugtest/wasi/**" + - "internal/debugabi/**" + - "internal/wasmdebug/**" + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + guest-debug: + name: Wasmtime 47 / LLDB 22 guest debug + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v7 + + - name: Install LLGo dependencies + uses: ./.github/actions/setup-deps + with: + llvm-version: 19 + + - name: Set up Go + uses: ./.github/actions/setup-go + + - name: Cache WASI crosscompile toolchain + uses: actions/cache@v5 + with: + path: ~/.cache/llgo/crosscompile + key: wasi-debug-${{ runner.os }}-${{ runner.arch }}-v25 + + - name: Install guest-debug tools + shell: bash + run: | + set -euo pipefail + mkdir -p .tools + curl -fL --retry 3 \ + https://github.com/bytecodealliance/wasmtime/releases/download/v47.0.3/wasmtime-v47.0.3-x86_64-linux.tar.xz \ + | tar -xJ -C .tools + curl -fL --retry 3 \ + https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-33/wasi-sdk-33.0-x86_64-linux.tar.gz \ + | tar -xz -C .tools + echo "LLGO_WASMTIME=${GITHUB_WORKSPACE}/.tools/wasmtime-v47.0.3-x86_64-linux/wasmtime" >> "${GITHUB_ENV}" + echo "LLGO_LLDB=${GITHUB_WORKSPACE}/.tools/wasi-sdk-33.0-x86_64-linux/bin/lldb" >> "${GITHUB_ENV}" + echo "LLGO_ROOT=${GITHUB_WORKSPACE}" >> "${GITHUB_ENV}" + + - name: Test session planning and debugger contracts + run: | + go test -timeout 10m \ + ./internal/debugabi \ + ./internal/wasmdebug \ + ./cmd/internal/wasmtime \ + ./cmd/internal/lldb \ + ./cmd/internal/debug + + - name: Test WASI source-debug session + run: bash cmd/llgo/debugtest/wasi/runtest.sh diff --git a/cmd/internal/debug/debug.go b/cmd/internal/debug/debug.go index b232f1d18e..2971e96378 100644 --- a/cmd/internal/debug/debug.go +++ b/cmd/internal/debug/debug.go @@ -45,6 +45,7 @@ var ( backendFlag string lldbPath string gdbPath string + wasmtimePath string remoteAddress string serverCommand string ) @@ -60,6 +61,7 @@ func init() { Cmd.Flag.StringVar(&backendFlag, "backend", string(backendAuto), "debug backend: auto, lldb, gdb, wasmtime, or browser") Cmd.Flag.StringVar(&lldbPath, "lldb", "", "path to LLDB (default $LLGO_LLDB or auto-detect)") Cmd.Flag.StringVar(&gdbPath, "gdb", "", "path to GDB (default $LLGO_GDB, target candidates, or auto-detect)") + Cmd.Flag.StringVar(&wasmtimePath, "wasmtime", "", "path to Wasmtime (default $LLGO_WASMTIME or auto-detect)") Cmd.Flag.StringVar(&remoteAddress, "remote", "", "connect to an existing debug server at host:port") Cmd.Flag.StringVar(&serverCommand, "server", "", "debug-server command template; {} is the artifact and {debug-port} is the allocated port") } @@ -71,11 +73,12 @@ func runCmd(cmd *base.Command, args []string) { return } if err := run(cmd.Flag.Args(), debuggerArgs, options{ - backend: backend(backendFlag), - lldb: lldbPath, - gdb: gdbPath, - remote: remoteAddress, - server: serverCommand, + backend: backend(backendFlag), + lldb: lldbPath, + gdb: gdbPath, + wasmtime: wasmtimePath, + remote: remoteAddress, + server: serverCommand, }, os.Stdin, os.Stdout, os.Stderr); err != nil { fmt.Fprintln(os.Stderr, err) mockable.Exit(1) @@ -123,13 +126,11 @@ func run(packageArgs, debuggerArgs []string, opts options, stdin io.Reader, stdo if err != nil { return err } + applyResolvedTarget(conf, target) selected, err := selectBackend(opts.backend, classifyTarget(conf, target)) if err != nil { return err } - if selected == backendWasmtime { - return errors.New("llgo debug: the WASI/Wasmtime backend is not available yet") - } if selected == backendBrowser { return errors.New("llgo debug: the browser DevTools backend is not available yet") } @@ -158,6 +159,21 @@ func run(packageArgs, debuggerArgs []string, opts options, stdin io.Reader, stdo }, stdin, stdout, stderr) } +func applyResolvedTarget(conf *build.Config, target *targets.Config) { + if conf == nil || target == nil { + return + } + // The existing wasm/wasi target names intentionally use the GOOS/GOARCH + // crosscompile path instead of the generic target-libc builder. Resolve + // those values before build.Do so debug-artifact selection and the + // crosscompiler agree on the target from the start. + if target.GOARCH == "wasm" { + conf.Goos = target.GOOS + conf.Goarch = target.GOARCH + conf.Target = "" + } +} + func resolveTarget(name string) (*targets.Config, error) { if name == "" { return nil, nil diff --git a/cmd/internal/debug/debug_test.go b/cmd/internal/debug/debug_test.go index 146edd2f49..7abea76a15 100644 --- a/cmd/internal/debug/debug_test.go +++ b/cmd/internal/debug/debug_test.go @@ -31,8 +31,10 @@ import ( "github.com/goplus/llgo/cmd/internal/flags" "github.com/goplus/llgo/internal/build" + "github.com/goplus/llgo/internal/debugabi" "github.com/goplus/llgo/internal/optlevel" "github.com/goplus/llgo/internal/targets" + "github.com/goplus/llgo/internal/wasmdebug" ) func TestBackendRouting(t *testing.T) { @@ -234,6 +236,131 @@ func TestArtifactAndArgumentHandling(t *testing.T) { } } +func TestResolvedWasmTargetConfig(t *testing.T) { + conf := &build.Config{Goos: runtime.GOOS, Goarch: runtime.GOARCH, Target: "wasip1"} + applyResolvedTarget(conf, &targets.Config{GOOS: "wasip1", GOARCH: "wasm"}) + if conf.Goos != "wasip1" || conf.Goarch != "wasm" || conf.Target != "" { + t.Fatalf("resolved target config = %s/%s target=%q, want wasip1/wasm GOOS path", conf.Goos, conf.Goarch, conf.Target) + } + + native := &build.Config{Goos: runtime.GOOS, Goarch: runtime.GOARCH, Target: "board"} + applyResolvedTarget(native, &targets.Config{GOOS: "none", GOARCH: "arm"}) + if native.Goos != runtime.GOOS || native.Goarch != runtime.GOARCH { + t.Fatalf("non-Wasm target unexpectedly changed config to %s/%s", native.Goos, native.Goarch) + } +} + +func TestWASIDebugArtifactAndEnvironment(t *testing.T) { + raw := wasiDebugFixture(t) + artifact := filepath.Join(t.TempDir(), "program.wasm") + if err := os.WriteFile(artifact, raw, 0600); err != nil { + t.Fatal(err) + } + memory, err := validateWASIDebugArtifact(artifact) + if err != nil { + t.Fatal(err) + } + if memory == nil || memory.Module != "env" || memory.Name != "memory" || memory.Minimum != 1024 || memory.Maximum != 1024 || !memory.HasMax || !memory.Shared { + t.Fatalf("validated memory = %+v", memory) + } + + environment, cleanup, err := writeWASIEnvironment(memory) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(environment) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{`(memory (export "memory") 1024 1024 shared)`, `export "longjmp"`, `export "pthread_exit"`} { + if !strings.Contains(string(data), want) { + t.Fatalf("environment WAT %q does not contain %q", string(data), want) + } + } + cleanup() + if _, err := os.Stat(environment); !os.IsNotExist(err) { + t.Fatalf("environment cleanup error = %v", err) + } + withoutMemory := []byte{0, 'a', 's', 'm', 1, 0, 0, 0} + debug := appendTestName(nil, ".debug_info") + debug = append(debug, 1) + withoutMemory = appendTestSection(withoutMemory, 0, debug) + withoutMemory, err = wasmdebug.SetDebuggerRecord(withoutMemory, debugabi.NewRecord(2, 4, debugabi.ByteOrderLittle)) + if err != nil { + t.Fatal(err) + } + withoutMemoryPath := filepath.Join(t.TempDir(), "self-contained.wasm") + if err := os.WriteFile(withoutMemoryPath, withoutMemory, 0600); err != nil { + t.Fatal(err) + } + if memory, err := validateWASIDebugArtifact(withoutMemoryPath); err != nil || memory != nil { + t.Fatalf("self-contained WASI artifact = (%+v, %v), want (nil, nil)", memory, err) + } + + plan, planCleanup, err := makeWASIServerPlan(artifact, options{remote: ":1234"}) + if err != nil { + t.Fatal(err) + } + defer planCleanup() + if plan.address != "127.0.0.1:1234" || len(plan.command) != 0 { + t.Fatalf("remote WASI plan = %+v", plan) + } + args, err := debuggerArguments(backendWasmtime, artifact, []string{"--batch"}, plan) + if err != nil { + t.Fatal(err) + } + joined := strings.Join(args, " ") + if !strings.Contains(joined, "process connect --plugin wasm connect://127.0.0.1:1234") || !strings.Contains(joined, "--batch") { + t.Fatalf("Wasmtime LLDB arguments = %q", joined) + } +} + +func wasiDebugFixture(t *testing.T) []byte { + t.Helper() + imports := appendTestULEB(nil, 1) + imports = appendTestName(imports, "env") + imports = appendTestName(imports, "memory") + imports = append(imports, 2) + imports = appendTestULEB(imports, 3) + imports = appendTestULEB(imports, 1024) + imports = appendTestULEB(imports, 1024) + raw := []byte{0, 'a', 's', 'm', 1, 0, 0, 0} + raw = appendTestSection(raw, 2, imports) + debug := appendTestName(nil, ".debug_info") + debug = append(debug, 1, 2, 3) + raw = appendTestSection(raw, 0, debug) + result, err := wasmdebug.SetDebuggerRecord(raw, debugabi.NewRecord(2, 4, debugabi.ByteOrderLittle)) + if err != nil { + t.Fatal(err) + } + return result +} + +func appendTestName(dst []byte, name string) []byte { + dst = appendTestULEB(dst, uint32(len(name))) + return append(dst, name...) +} + +func appendTestSection(dst []byte, id byte, payload []byte) []byte { + dst = append(dst, id) + dst = appendTestULEB(dst, uint32(len(payload))) + return append(dst, payload...) +} + +func appendTestULEB(dst []byte, value uint32) []byte { + for { + current := byte(value & 0x7f) + value >>= 7 + if value != 0 { + current |= 0x80 + } + dst = append(dst, current) + if value == 0 { + return dst + } + } +} + func TestRunBuildsAndLaunchesNativeDebugger(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("test helper uses a POSIX shell") diff --git a/cmd/internal/debug/session.go b/cmd/internal/debug/session.go index c22f310237..8d8655c3f9 100644 --- a/cmd/internal/debug/session.go +++ b/cmd/internal/debug/session.go @@ -30,6 +30,7 @@ import ( "github.com/goplus/llgo/cmd/internal/gdb" "github.com/goplus/llgo/cmd/internal/lldb" + wasmtimetool "github.com/goplus/llgo/cmd/internal/wasmtime" "github.com/goplus/llgo/internal/build" "github.com/goplus/llgo/internal/env" "github.com/goplus/llgo/internal/shellparse" @@ -56,11 +57,12 @@ const ( ) type options struct { - backend backend - lldb string - gdb string - remote string - server string + backend backend + lldb string + gdb string + wasmtime string + remote string + server string } func (o options) validate() error { @@ -128,10 +130,18 @@ type session struct { } func runSession(s session, stdin io.Reader, stdout, stderr io.Writer) error { - plan, err := makeServerPlan(s.target, s.artifact, s.options) + cleanup := func() {} + var plan *serverPlan + var err error + if s.backend == backendWasmtime { + plan, cleanup, err = makeWASIServerPlan(s.artifact, s.options) + } else { + plan, err = makeServerPlan(s.target, s.artifact, s.options) + } if err != nil { return err } + defer cleanup() args, err := debuggerArguments(s.backend, s.artifact, s.debuggerArgs, plan) if err != nil { return err @@ -159,6 +169,10 @@ func runSession(s session, stdin io.Reader, stdout, stderr io.Writer) error { if err := gdb.Run(s.options.gdb, candidates, args, stdin, stdout, stderr); err != nil { debugErr = err } + case backendWasmtime: + if err := lldb.RunWasm(s.options.lldb, args, stdin, stdout, stderr); err != nil { + debugErr = fmt.Errorf("llgo debug: %w", err) + } default: debugErr = fmt.Errorf("llgo debug: backend %s is not implemented", s.backend) } @@ -171,9 +185,58 @@ func runSession(s session, stdin io.Reader, stdout, stderr io.Writer) error { } type serverPlan struct { - command []string - address string - load bool + command []string + address string + load bool + readyLog string +} + +func makeWASIServerPlan(artifact string, opts options) (*serverPlan, func(), error) { + memory, err := validateWASIDebugArtifact(artifact) + if err != nil { + return nil, func() {}, err + } + if opts.remote != "" { + if opts.server != "" { + return nil, func() {}, errors.New("llgo debug: -remote and -server are mutually exclusive") + } + return &serverPlan{address: normalizeRemoteAddress(opts.remote)}, func() {}, nil + } + + port, err := freeTCPPort() + if err != nil { + return nil, func() {}, fmt.Errorf("llgo debug: allocate Wasmtime guest-debug port: %w", err) + } + address := net.JoinHostPort("127.0.0.1", strconv.Itoa(port)) + if opts.server != "" { + command, err := parseServerCommand(opts.server, artifact, port) + if err != nil { + return nil, func() {}, err + } + return &serverPlan{command: command, address: address}, func() {}, nil + } + + wasmtimePath, err := wasmtimetool.Find(opts.wasmtime) + if err != nil { + return nil, func() {}, err + } + environment, cleanup, err := writeWASIEnvironment(memory) + if err != nil { + return nil, func() {}, err + } + command := []string{ + wasmtimePath, + "run", + "-g", strconv.Itoa(port), + "-W", "threads=y,shared-memory=y", + "--preload", "env=" + environment, + artifact, + } + return &serverPlan{ + command: command, + address: address, + readyLog: "Debugger listening on", + }, cleanup, nil } func makeServerPlan(target *targets.Config, artifact string, opts options) (*serverPlan, error) { @@ -294,31 +357,38 @@ func startServer(plan serverPlan) (*debugServer, error) { done := make(chan error, 1) go func() { done <- command.Wait() }() server := &debugServer{cmd: command, done: done, log: log, logPath: log.Name()} - if err := server.waitReady(plan.address, 10*time.Second); err != nil { + if err := server.waitReady(plan, 10*time.Second); err != nil { server.stop() return nil, err } return server, nil } -func (s *debugServer) waitReady(address string, timeout time.Duration) error { +func (s *debugServer) waitReady(plan serverPlan, timeout time.Duration) error { deadline := time.Now().Add(timeout) for time.Now().Before(deadline) { select { case err := <-s.done: s.finished = true - return fmt.Errorf("llgo debug: debug server exited before listening at %s: %v%s", address, err, s.logSuffix()) + return fmt.Errorf("llgo debug: debug server exited before listening at %s: %v%s", plan.address, err, s.logSuffix()) default: } - connection, err := net.DialTimeout("tcp", address, 100*time.Millisecond) - if err == nil { - connection.Close() - time.Sleep(50 * time.Millisecond) - return nil + if plan.readyLog != "" { + data, _ := os.ReadFile(s.logPath) + if strings.Contains(string(data), plan.readyLog) { + return nil + } + } else { + connection, err := net.DialTimeout("tcp", plan.address, 100*time.Millisecond) + if err == nil { + connection.Close() + time.Sleep(50 * time.Millisecond) + return nil + } } time.Sleep(25 * time.Millisecond) } - return fmt.Errorf("llgo debug: timed out waiting for debug server at %s%s", address, s.logSuffix()) + return fmt.Errorf("llgo debug: timed out waiting for debug server at %s%s", plan.address, s.logSuffix()) } func (s *debugServer) stop() { @@ -383,6 +453,12 @@ func debuggerArguments(selected backend, artifact string, extra []string, server "-o", "target modules load --file " + quoteLLDBArgument(artifact) + " --slide 0", } return append(args, extra...), nil + case backendWasmtime: + args := []string{ + artifact, + "-o", "process connect --plugin wasm connect://" + server.address, + } + return append(args, extra...), nil default: return nil, fmt.Errorf("llgo debug: backend %s does not use GDB Remote", selected) } diff --git a/cmd/internal/debug/wasi.go b/cmd/internal/debug/wasi.go new file mode 100644 index 0000000000..78c4cd72b8 --- /dev/null +++ b/cmd/internal/debug/wasi.go @@ -0,0 +1,108 @@ +/* + * 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 debug + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/goplus/llgo/internal/debugabi" + "github.com/goplus/llgo/internal/wasmdebug" +) + +func validateWASIDebugArtifact(path string) (*wasmdebug.MemoryImport, error) { + raw, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("llgo debug: read WASI artifact %q: %w", path, err) + } + hasDWARF, err := wasmdebug.HasDWARF(raw) + if err != nil { + return nil, fmt.Errorf("llgo debug: validate WASI artifact %q: %w", path, err) + } + if !hasDWARF { + if externalURL, ok, urlErr := wasmdebug.ExternalURL(raw); urlErr != nil { + return nil, fmt.Errorf("llgo debug: validate external WASI DWARF reference: %w", urlErr) + } else if ok { + return nil, fmt.Errorf("llgo debug: Wasmtime guest debugging currently requires embedded DWARF; artifact references external debug file %q", externalURL) + } + return nil, errors.New("llgo debug: WASI artifact contains no DWARF sections") + } + + record, ok, err := wasmdebug.DebuggerRecord(raw) + if err != nil { + return nil, fmt.Errorf("llgo debug: validate WASI debugger ABI: %w", err) + } + if !ok { + return nil, errors.New("llgo debug: WASI artifact has no LLGo debugger ABI record") + } + if record.PointerSize != 4 || record.ByteOrder != debugabi.ByteOrderLittle { + return nil, fmt.Errorf("llgo debug: unsupported WASI debugger ABI target: pointer size %d, byte order %d", record.PointerSize, record.ByteOrder) + } + + memories, err := wasmdebug.ImportedMemories(raw) + if err != nil { + return nil, fmt.Errorf("llgo debug: inspect WASI memory imports: %w", err) + } + var environment *wasmdebug.MemoryImport + for index := range memories { + memory := &memories[index] + if memory.Module != "env" || memory.Name != "memory" { + continue + } + if environment != nil { + return nil, errors.New("llgo debug: WASI artifact imports env.memory more than once") + } + environment = memory + } + if environment != nil && environment.Memory64 { + return nil, errors.New("llgo debug: memory64 WASI guest debugging is not supported yet") + } + return environment, nil +} + +func writeWASIEnvironment(memory *wasmdebug.MemoryImport) (path string, cleanup func(), err error) { + cleanup = func() {} + dir, err := os.MkdirTemp("", "llgo-wasmtime-env-") + if err != nil { + return "", cleanup, fmt.Errorf("llgo debug: create Wasmtime environment directory: %w", err) + } + cleanup = func() { _ = os.RemoveAll(dir) } + + var declaration strings.Builder + if memory != nil { + fmt.Fprintf(&declaration, " (memory (export \"memory\") %d", memory.Minimum) + if memory.HasMax { + fmt.Fprintf(&declaration, " %d", memory.Maximum) + } + if memory.Shared { + declaration.WriteString(" shared") + } + declaration.WriteString(")\n") + } + wat := "(module\n" + declaration.String() + + " (func (export \"longjmp\") (param i32 i32) unreachable)\n" + + " (func (export \"pthread_exit\") (param i32) unreachable))\n" + path = filepath.Join(dir, "env.wat") + if err := os.WriteFile(path, []byte(wat), 0600); err != nil { + cleanup() + return "", func() {}, fmt.Errorf("llgo debug: write Wasmtime environment module: %w", err) + } + return path, cleanup, nil +} diff --git a/cmd/internal/lldb/lldb.go b/cmd/internal/lldb/lldb.go index ec07ea76d5..bd1e03735c 100644 --- a/cmd/internal/lldb/lldb.go +++ b/cmd/internal/lldb/lldb.go @@ -35,6 +35,7 @@ import ( ) const minimumUpstreamLLDBVersion = 18 +const minimumWasmLLDBVersion = 22 const debuggerSchemaFilename = "llgo_debugger_schema_v1.json" var ( @@ -51,6 +52,12 @@ type lldbVersion struct { apple bool } +type lldbCapabilities struct { + version lldbVersion + wasm bool + scripting bool +} + // Cmd is the llgo lldb command. var Cmd = &base.Command{ UsageLine: "llgo lldb [-lldb path] [--] executable [lldb arguments...]", @@ -82,26 +89,35 @@ func run(configuredPath string, args []string, stdin io.Reader, stdout, stderr i if err != nil { return err } + return runWithPath(path, args, true, stdin, stdout, stderr) +} - pluginDir, err := os.MkdirTemp("", "llgo-lldb-") - if err != nil { - return fmt.Errorf("llgo lldb: create plugin directory: %w", err) - } - defer os.RemoveAll(pluginDir) - - pluginPath := filepath.Join(pluginDir, "llgo_plugin.py") - if err := os.WriteFile(pluginPath, pluginSource, 0600); err != nil { - return fmt.Errorf("llgo lldb: write plugin: %w", err) - } - schemaPath := filepath.Join(pluginDir, debuggerSchemaFilename) - if err := os.WriteFile(schemaPath, debugabi.SchemaV1(), 0600); err != nil { - return fmt.Errorf("llgo lldb: write debugger schema: %w", err) +func runWithPath(path string, args []string, adapter bool, stdin io.Reader, stdout, stderr io.Writer) error { + if len(args) == 0 { + return errors.New("llgo lldb: no executable specified") } lldbArgs := make([]string, 0, len(args)+2) - // Import after LLDB creates the target so the plugin can enable runtime - // formatters only for binaries that advertise a supported LLGo schema. - lldbArgs = append(lldbArgs, "-o", lldbImportCommand(pluginPath)) + if adapter { + pluginDir, err := os.MkdirTemp("", "llgo-lldb-") + if err != nil { + return fmt.Errorf("llgo lldb: create plugin directory: %w", err) + } + defer os.RemoveAll(pluginDir) + + pluginPath := filepath.Join(pluginDir, "llgo_plugin.py") + if err := os.WriteFile(pluginPath, pluginSource, 0600); err != nil { + return fmt.Errorf("llgo lldb: write plugin: %w", err) + } + schemaPath := filepath.Join(pluginDir, debuggerSchemaFilename) + if err := os.WriteFile(schemaPath, debugabi.SchemaV1(), 0600); err != nil { + return fmt.Errorf("llgo lldb: write debugger schema: %w", err) + } + + // Import after LLDB creates the target so the plugin can enable runtime + // formatters only for binaries that advertise a supported LLGo schema. + lldbArgs = append(lldbArgs, "-o", lldbImportCommand(pluginPath)) + } lldbArgs = append(lldbArgs, args...) command := exec.Command(path, lldbArgs...) @@ -119,6 +135,24 @@ func Run(configuredPath string, args []string, stdin io.Reader, stdout, stderr i return run(configuredPath, args, stdin, stdout, stderr) } +// RunWasm starts a Wasm-aware LLDB. The LLGo Python adapter is enabled when +// the debugger embeds a scripting interpreter. Stock wasi-sdk LLDB builds +// without scripting remain useful for raw source debugging and receive a +// clear downgrade notice instead of failing the session. +func RunWasm(configuredPath string, args []string, stdin io.Reader, stdout, stderr io.Writer) error { + if len(args) == 0 { + return errors.New("llgo lldb: no WebAssembly executable specified") + } + path, capabilities, err := findWasmLLDB(configuredPath) + if err != nil { + return err + } + if !capabilities.scripting { + fmt.Fprintf(stderr, "llgo debug: LLDB %q has no Python scripting support; LLGo runtime presentation is disabled, but raw WebAssembly source debugging remains available\n", path) + } + return runWithPath(path, args, capabilities.scripting, stdin, stdout, stderr) +} + func findLLDB(configuredPath string) (string, error) { return findLLDBFrom(configuredPath, os.Getenv("LLGO_LLDB"), []string{ "/opt/homebrew/bin/lldb", @@ -128,6 +162,37 @@ func findLLDB(configuredPath string) (string, error) { }) } +func findWasmLLDB(configuredPath string) (string, lldbCapabilities, error) { + return findWasmLLDBFrom(configuredPath, os.Getenv("LLGO_LLDB"), []string{ + "/opt/homebrew/bin/lldb", + "/usr/local/bin/lldb", + "/usr/bin/lldb", + "lldb", + }) +} + +func findWasmLLDBFrom(configuredPath, environmentPath string, candidates []string) (string, lldbCapabilities, error) { + if configuredPath != "" { + return validateWasmLLDB(configuredPath) + } + if environmentPath != "" { + return validateWasmLLDB(environmentPath) + } + + seen := make(map[string]bool) + for _, candidate := range candidates { + path, err := exec.LookPath(candidate) + if err != nil || seen[path] { + continue + } + seen[path] = true + if path, capabilities, err := validateWasmLLDB(path); err == nil { + return path, capabilities, nil + } + } + return "", lldbCapabilities{}, fmt.Errorf("llgo debug: upstream LLDB %d or newer with the WebAssembly process plugin was not found; install a Wasm-enabled LLDB or set LLGO_LLDB", minimumWasmLLDBVersion) +} + func findLLDBFrom(configuredPath, environmentPath string, candidates []string) (string, error) { if configuredPath != "" { return validateLLDB(configuredPath) @@ -173,6 +238,54 @@ func validateLLDB(name string) (string, error) { return path, nil } +func validateWasmLLDB(name string) (string, lldbCapabilities, error) { + path, err := exec.LookPath(name) + if err != nil { + return "", lldbCapabilities{}, fmt.Errorf("llgo debug: find LLDB %q: %w", name, err) + } + output, err := exec.Command(path, "--version").CombinedOutput() + if err != nil { + return "", lldbCapabilities{}, fmt.Errorf("llgo debug: query LLDB %q version: %w", path, err) + } + version, ok := parseLLDBVersion(string(output)) + if !ok { + return "", lldbCapabilities{}, fmt.Errorf("llgo debug: cannot parse LLDB version from %q", strings.TrimSpace(string(output))) + } + if !version.apple && version.major < minimumWasmLLDBVersion { + return "", lldbCapabilities{}, fmt.Errorf("llgo debug: %q is upstream LLDB %d; version %d or newer with the WebAssembly process plugin is required", path, version.major, minimumWasmLLDBVersion) + } + + pluginOutput, err := exec.Command(path, "--batch", "-o", "plugin list").CombinedOutput() + if err != nil || !hasWasmProcessPlugin(string(pluginOutput)) { + return "", lldbCapabilities{}, fmt.Errorf("llgo debug: LLDB %q does not provide the WebAssembly process plugin", path) + } + scriptOutput, scriptErr := exec.Command(path, "--batch", "-o", "script print('LLGO_SCRIPT_OK')").CombinedOutput() + capabilities := lldbCapabilities{ + version: version, + wasm: true, + scripting: scriptErr == nil && strings.Contains(string(scriptOutput), "LLGO_SCRIPT_OK"), + } + return path, capabilities, nil +} + +func hasWasmProcessPlugin(output string) bool { + inProcess := false + for _, line := range strings.Split(output, "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == "process" { + inProcess = true + continue + } + if line != "" && line[0] != ' ' && line[0] != '\t' { + inProcess = false + } + if inProcess && strings.HasPrefix(trimmed, "[+] wasm ") { + return true + } + } + return false +} + func parseLLDBVersion(output string) (lldbVersion, bool) { pattern := upstreamLLDBVersionPattern apple := false diff --git a/cmd/internal/lldb/lldb_test.go b/cmd/internal/lldb/lldb_test.go index d4e365bfc1..3d83c86f3d 100644 --- a/cmd/internal/lldb/lldb_test.go +++ b/cmd/internal/lldb/lldb_test.go @@ -20,14 +20,18 @@ package lldb import ( "bytes" + "fmt" "os" + "os/exec" "path/filepath" "runtime" "strings" "testing" "github.com/goplus/llgo/cmd/internal/base" + "github.com/goplus/llgo/internal/debugabi" "github.com/goplus/llgo/internal/mockable" + "github.com/goplus/llgo/internal/wasmdebug" ) func TestParseLLDBVersion(t *testing.T) { @@ -107,6 +111,62 @@ func TestFindLLDBPrecedenceAndFallback(t *testing.T) { } } +func TestFindWasmLLDBCapabilities(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("test helper uses a POSIX shell") + } + wasmWithScript := writeFakeWasmLLDB(t, "lldb version 22.1.0", true, true, "") + wasmWithoutScript := writeFakeWasmLLDB(t, "lldb version 22.1.0-wasi-sdk", true, false, "") + withoutWasm := writeFakeWasmLLDB(t, "lldb version 22.1.0", false, true, "") + old := writeFakeWasmLLDB(t, "lldb version 21.1.0", true, true, "") + + path, capabilities, err := findWasmLLDBFrom(wasmWithScript, "", nil) + if err != nil || path != wasmWithScript || !capabilities.wasm || !capabilities.scripting { + t.Fatalf("scripted Wasm LLDB = (%q, %+v, %v)", path, capabilities, err) + } + path, capabilities, err = findWasmLLDBFrom("", wasmWithoutScript, nil) + if err != nil || path != wasmWithoutScript || !capabilities.wasm || capabilities.scripting { + t.Fatalf("non-scripted Wasm LLDB = (%q, %+v, %v)", path, capabilities, err) + } + if _, _, err := findWasmLLDBFrom(withoutWasm, "", nil); err == nil || !strings.Contains(err.Error(), "WebAssembly process plugin") { + t.Fatalf("non-Wasm LLDB error = %v", err) + } + if _, _, err := findWasmLLDBFrom(old, "", nil); err == nil || !strings.Contains(err.Error(), "version 22 or newer") { + t.Fatalf("old Wasm LLDB error = %v", err) + } + if !hasWasmProcessPlugin("process\n [+] wasm WebAssembly process\nplatform\n") { + t.Fatal("hasWasmProcessPlugin did not recognize the process plugin") + } + if hasWasmProcessPlugin("object-file\n [+] wasm WebAssembly object file\n") { + t.Fatal("hasWasmProcessPlugin confused the object-file plugin with the process plugin") + } +} + +func TestRunWasmAdapterDowngrade(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("test helper uses a POSIX shell") + } + capture := filepath.Join(t.TempDir(), "arguments") + t.Setenv("LLGO_LLDB_TEST_CAPTURE", capture) + fake := writeFakeWasmLLDB(t, "lldb version 22.1.0-wasi-sdk", true, false, + `printf '%s\n' "$@" > "$LLGO_LLDB_TEST_CAPTURE"`) + + var stdout, stderr bytes.Buffer + if err := RunWasm(fake, []string{"program.wasm", "-o", "process connect --plugin wasm connect://127.0.0.1:1234"}, strings.NewReader(""), &stdout, &stderr); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(capture) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(data), "command script import") { + t.Fatalf("non-scripted LLDB arguments unexpectedly import the adapter: %q", string(data)) + } + if !strings.Contains(stderr.String(), "runtime presentation is disabled") { + t.Fatalf("downgrade warning = %q", stderr.String()) + } +} + func TestRunImportsEmbeddedPluginAndPassesArguments(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("test helper uses a POSIX shell") @@ -215,6 +275,49 @@ func TestEmbeddedPluginIdentity(t *testing.T) { } } +func TestEmbeddedPluginReadsWasmDebuggerRecord(t *testing.T) { + python, err := exec.LookPath("python3") + if err != nil { + t.Skip("python3 is unavailable") + } + dir := t.TempDir() + pluginPath := filepath.Join(dir, "llgo_plugin.py") + if err := os.WriteFile(pluginPath, pluginSource, 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, debuggerSchemaFilename), debugabi.SchemaV1(), 0600); err != nil { + t.Fatal(err) + } + record := debugabi.NewRecord(2, 4, debugabi.ByteOrderLittle) + recordBytes, err := record.MarshalBinary() + if err != nil { + t.Fatal(err) + } + module, err := wasmdebug.SetDebuggerRecord([]byte{0, 'a', 's', 'm', 1, 0, 0, 0}, record) + if err != nil { + t.Fatal(err) + } + modulePath := filepath.Join(dir, "program.wasm") + if err := os.WriteFile(modulePath, module, 0600); err != nil { + t.Fatal(err) + } + script := fmt.Sprintf(` +import importlib.util +from pathlib import Path +import sys +import types +sys.modules["lldb"] = types.ModuleType("lldb") +spec = importlib.util.spec_from_file_location("llgo_plugin", %q) +plugin = importlib.util.module_from_spec(spec) +sys.modules["llgo_plugin"] = plugin +spec.loader.exec_module(plugin) +assert plugin._wasm_debugger_records(Path(%q)) == [bytes.fromhex(%q)] +`, pluginPath, modulePath, fmt.Sprintf("%x", recordBytes)) + if output, err := exec.Command(python, "-c", script).CombinedOutput(); err != nil { + t.Fatalf("Wasm record parser failed: %v\n%s", err, output) + } +} + func writeFakeLLDB(t *testing.T, version, body string) string { t.Helper() path := filepath.Join(t.TempDir(), "lldb") @@ -228,3 +331,37 @@ func writeFakeLLDB(t *testing.T, version, body string) string { } return path } + +func writeFakeWasmLLDB(t *testing.T, version string, wasm, scripting bool, body string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "lldb") + wasmPlugin := "" + if wasm { + wasmPlugin = " [+] wasm GDB Remote protocol based WebAssembly debugging plug-in." + } + scriptStatus := "exit 1" + if scripting { + scriptStatus = "echo LLGO_SCRIPT_OK; exit 0" + } + script := `#!/bin/sh +if [ "$1" = "--version" ]; then + printf '%s\n' '` + version + `' + exit 0 +fi +if [ "$1" = "--batch" ] && [ "$2" = "-o" ] && [ "$3" = "plugin list" ]; then + echo process + echo '` + wasmPlugin + `' + echo platform + exit 0 +fi +if [ "$1" = "--batch" ] && [ "$2" = "-o" ]; then + case "$3" in + script*) ` + scriptStatus + ` ;; + esac +fi +` + body + "\n" + if err := os.WriteFile(path, []byte(script), 0700); err != nil { + t.Fatal(err) + } + return path +} diff --git a/cmd/internal/lldb/llgo_plugin.py b/cmd/internal/lldb/llgo_plugin.py index de791d50c5..153199fceb 100644 --- a/cmd/internal/lldb/llgo_plugin.py +++ b/cmd/internal/lldb/llgo_plugin.py @@ -1,5 +1,7 @@ # pylint: disable=missing-module-docstring,missing-class-docstring,missing-function-docstring +from __future__ import annotations + from dataclasses import dataclass import json from pathlib import Path @@ -42,6 +44,7 @@ def _load_debugger_schema() -> Tuple[Dict[str, Any], Optional[str]]: LLGO_DEBUGGER_SCHEMA, LLGO_DEBUGGER_SCHEMA_ERROR = _load_debugger_schema() _RECORD_SCHEMA = LLGO_DEBUGGER_SCHEMA.get("record", {}) LLGO_DEBUGGER_RECORD_SYMBOL = _RECORD_SCHEMA.get("native_symbol", "") +LLGO_DEBUGGER_WASM_SECTION = _RECORD_SCHEMA.get("wasm_custom_section", "") LLGO_DEBUGGER_RECORD_SIZE = int(_RECORD_SCHEMA.get("size", 0)) try: LLGO_DEBUGGER_RECORD_MAGIC = bytes.fromhex( @@ -435,6 +438,76 @@ def _sbdata_bytes(data: lldb.SBData, size: int) -> Optional[bytes]: return raw if error.Success() else None +def _read_uleb(raw: bytes, offset: int, + maximum_bits: int = 32) -> Tuple[int, int]: + value = 0 + shift = 0 + maximum_bytes = (maximum_bits + 6) // 7 + for _ in range(maximum_bytes): + if offset >= len(raw): + raise ValueError("truncated WebAssembly unsigned LEB128") + byte = raw[offset] + offset += 1 + payload = byte & 0x7f + if shift + 7 > maximum_bits and payload >= (1 << (maximum_bits - shift)): + raise ValueError("WebAssembly unsigned LEB128 overflows") + value |= payload << shift + if byte & 0x80 == 0: + return value, offset + shift += 7 + raise ValueError("invalid WebAssembly unsigned LEB128") + + +def _wasm_debugger_records(path: Path) -> List[bytes]: + if not LLGO_DEBUGGER_WASM_SECTION: + return [] + try: + raw = path.read_bytes() + except OSError: + return [] + if len(raw) < 8 or raw[:8] != b"\x00asm\x01\x00\x00\x00": + return [] + + records = [] + offset = 8 + try: + while offset < len(raw): + section_id = raw[offset] + offset += 1 + size, offset = _read_uleb(raw, offset) + end = offset + size + if end > len(raw): + raise ValueError("truncated WebAssembly section") + if section_id == 0: + name_size, payload = _read_uleb(raw, offset) + name_end = payload + name_size + if name_end > end: + raise ValueError( + "truncated WebAssembly custom-section name") + name = raw[payload:name_end].decode("utf-8") + if name == LLGO_DEBUGGER_WASM_SECTION: + records.append(raw[name_end:end]) + offset = end + except (UnicodeDecodeError, ValueError): + return [b""] + if len(records) > 1: + return [b""] + return records + + +def _module_file_path(module: lldb.SBModule) -> Optional[Path]: + if not module or not module.IsValid(): + return None + file_spec = module.GetFileSpec() + if not file_spec or not file_spec.IsValid(): + return None + directory = file_spec.GetDirectory() or "" + filename = file_spec.GetFilename() or "" + if not filename: + return None + return Path(directory) / filename if directory else Path(filename) + + def _read_debugger_record(target: lldb.SBTarget) -> Optional[bytes]: if not LLGO_DEBUGGER_RECORD_SYMBOL or LLGO_DEBUGGER_RECORD_SIZE <= 0: return None @@ -462,6 +535,20 @@ def _read_debugger_record(target: lldb.SBTarget) -> Optional[bytes]: if error.Success() and raw is not None: records.append(bytes(raw)) + paths = set() + for module_index in range(target.GetNumModules()): + path = _module_file_path(target.GetModuleAtIndex(module_index)) + if path is None: + continue + try: + key = str(path.resolve()) + except OSError: + key = str(path) + if key in paths: + continue + paths.add(key) + records.extend(_wasm_debugger_records(path)) + if not records: return None first = records[0] @@ -485,7 +572,7 @@ def _record_field(raw: bytes, name: str) -> Optional[int]: def _decode_debugger_record( raw: bytes) -> Tuple[Optional[LLGoDebuggerRecord], Optional[str]]: if len(raw) != LLGO_DEBUGGER_RECORD_SIZE: - return None, "conflicting or incorrectly sized native records" + return None, "conflicting or incorrectly sized debugger records" if not LLGO_DEBUGGER_RECORD_MAGIC or not raw.startswith( LLGO_DEBUGGER_RECORD_MAGIC): return None, "invalid record magic" diff --git a/cmd/internal/wasmtime/wasmtime.go b/cmd/internal/wasmtime/wasmtime.go new file mode 100644 index 0000000000..79f19ba245 --- /dev/null +++ b/cmd/internal/wasmtime/wasmtime.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 wasmtime locates the Wasmtime guest-debug server used by llgo debug. +package wasmtime + +import ( + "fmt" + "os" + "os/exec" + "regexp" + "strconv" + "strings" +) + +// Version 44 is the first Wasmtime release containing the built-in gdbstub +// guest-debug frontend used by LLGo. +const MinimumGuestDebugVersion = 44 + +var versionPattern = regexp.MustCompile(`(?im)^wasmtime\s+([0-9]+)(?:\.[0-9]+){1,2}\b`) + +// Find returns a validated Wasmtime executable. An explicit path takes +// precedence over LLGO_WASMTIME and PATH. +func Find(configuredPath string) (string, error) { + return findFrom(configuredPath, os.Getenv("LLGO_WASMTIME"), []string{"wasmtime"}) +} + +func findFrom(configuredPath, environmentPath string, candidates []string) (string, error) { + if configuredPath != "" { + return validate(configuredPath) + } + if environmentPath != "" { + return validate(environmentPath) + } + for _, candidate := range candidates { + path, err := validate(candidate) + if err == nil { + return path, nil + } + } + return "", fmt.Errorf("llgo debug: Wasmtime %d or newer with the built-in gdbstub was not found; install Wasmtime or set LLGO_WASMTIME", MinimumGuestDebugVersion) +} + +func validate(name string) (string, error) { + path, err := exec.LookPath(name) + if err != nil { + return "", fmt.Errorf("llgo debug: find Wasmtime %q: %w", name, err) + } + output, err := exec.Command(path, "--version").CombinedOutput() + if err != nil { + return "", fmt.Errorf("llgo debug: query Wasmtime %q version: %w", path, err) + } + match := versionPattern.FindStringSubmatch(string(output)) + if len(match) != 2 { + return "", fmt.Errorf("llgo debug: cannot parse Wasmtime version from %q", strings.TrimSpace(string(output))) + } + major, err := strconv.Atoi(match[1]) + if err != nil || major < MinimumGuestDebugVersion { + return "", fmt.Errorf("llgo debug: %q is Wasmtime %s; version %d or newer with the built-in gdbstub is required", path, match[1], MinimumGuestDebugVersion) + } + return path, nil +} diff --git a/cmd/internal/wasmtime/wasmtime_test.go b/cmd/internal/wasmtime/wasmtime_test.go new file mode 100644 index 0000000000..b34310ee0a --- /dev/null +++ b/cmd/internal/wasmtime/wasmtime_test.go @@ -0,0 +1,61 @@ +//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 wasmtime + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestFindPrecedenceAndVersion(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("test helper uses a POSIX shell") + } + newWasmtime := writeFakeWasmtime(t, "wasmtime 44.0.0 (test)") + oldWasmtime := writeFakeWasmtime(t, "wasmtime 43.0.1 (test)") + + if got, err := findFrom(newWasmtime, oldWasmtime, nil); err != nil || got != newWasmtime { + t.Fatalf("configured Wasmtime = (%q, %v), want (%q, nil)", got, err, newWasmtime) + } + if got, err := findFrom("", newWasmtime, nil); err != nil || got != newWasmtime { + t.Fatalf("environment Wasmtime = (%q, %v), want (%q, nil)", got, err, newWasmtime) + } + if got, err := findFrom("", "", []string{oldWasmtime, newWasmtime}); err != nil || got != newWasmtime { + t.Fatalf("fallback Wasmtime = (%q, %v), want (%q, nil)", got, err, newWasmtime) + } + if _, err := findFrom(oldWasmtime, "", nil); err == nil || !strings.Contains(err.Error(), "version 44 or newer") { + t.Fatalf("old Wasmtime error = %v", err) + } + if _, err := findFrom(filepath.Join(t.TempDir(), "missing"), "", nil); err == nil || !strings.Contains(err.Error(), "find Wasmtime") { + t.Fatalf("missing Wasmtime error = %v", err) + } +} + +func writeFakeWasmtime(t *testing.T, version string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "wasmtime") + script := "#!/bin/sh\nprintf '%s\\n' '" + version + "'\n" + if err := os.WriteFile(path, []byte(script), 0700); err != nil { + t.Fatal(err) + } + return path +} diff --git a/cmd/llgo/debugtest/README.md b/cmd/llgo/debugtest/README.md index 21fa58284e..7ff74ee3f3 100644 --- a/cmd/llgo/debugtest/README.md +++ b/cmd/llgo/debugtest/README.md @@ -19,7 +19,7 @@ The automatic backend depends on the selected target: | --- | --- | --- | | Native Darwin/Linux | LLDB | Local process | | Non-Wasm embedded | GDB | Target `debug-server`, OpenOCD, or `-remote` | -| WASI | Wasmtime | Added by the WASI debugger task | +| WASI | Wasmtime guest-debug + Wasm-aware LLDB | Built in | | Browser Wasm | Browser DevTools | Added by the browser debugger task | Use `-backend=gdb` or `-backend=lldb` to override a native or GDB Remote @@ -31,3 +31,36 @@ OpenOCD interface/transport/target fields need no additional command. `llgo lldb` remains the explicit compatibility command for opening an existing artifact without building it. + +## WASI tool matrix + +WASI guest debugging uses Wasmtime's built-in gdbstub and LLDB's WebAssembly +process plugin: + +- Wasmtime 44 or newer (`-g`/`--gdbstub` support); +- upstream or wasi-sdk LLDB 22 or newer with the `process/wasm` plugin; +- Python scripting in LLDB for LLGo runtime formatters. + +The wasi-sdk 33 LLDB build supports source breakpoints, parameters, locals, +stepping, and Wasm call stacks, but is built without Python. `llgo debug` +detects that capability and continues in raw source-debug mode with a clear +notice. A scripting-capable Wasm LLDB additionally loads the shared LLGo +runtime adapter and reads the `llgo.debugger` custom-section record. + +The current LLGo WASI runtime imports a shared linear memory. Wasmtime 47 can +debug Wasm locals but does not yet expose `SharedMemory` through its guest-debug +RSP memory map, so globals and runtime-backed formatters remain gated by +[Wasmtime issue #14062](https://github.com/bytecodealliance/wasmtime/issues/14062). +This does not affect source breakpoints or stack/parameter/local inspection. + +An embedded-DWARF module is currently required for the automated Wasmtime +session. External Wasm DWARF remains a valid build artifact, but debugger-side +resolution is part of the external/browser acceptance work. + +Run the focused fixture with: + +```sh +LLGO_WASMTIME=/path/to/wasmtime \ +LLGO_LLDB=/path/to/wasm-aware/lldb \ +bash cmd/llgo/debugtest/wasi/runtest.sh +``` diff --git a/cmd/llgo/debugtest/wasi/go.mod b/cmd/llgo/debugtest/wasi/go.mod new file mode 100644 index 0000000000..d8170adedc --- /dev/null +++ b/cmd/llgo/debugtest/wasi/go.mod @@ -0,0 +1,3 @@ +module github.com/goplus/llgo/debugtest/wasi + +go 1.26 diff --git a/cmd/llgo/debugtest/wasi/main.go b/cmd/llgo/debugtest/wasi/main.go new file mode 100644 index 0000000000..980e144e53 --- /dev/null +++ b/cmd/llgo/debugtest/wasi/main.go @@ -0,0 +1,16 @@ +package main + +var sink int32 + +//go:noinline +func increment(value int32) int32 { + result := value + 1 + sink = result + return result +} + +func main() { + value := int32(41) + result := increment(value) + println(result) +} diff --git a/cmd/llgo/debugtest/wasi/runtest.sh b/cmd/llgo/debugtest/wasi/runtest.sh new file mode 100755 index 0000000000..29ad58df2b --- /dev/null +++ b/cmd/llgo/debugtest/wasi/runtest.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if [[ -z "${LLGO_WASMTIME:-}" ]]; then + echo "LLGO_WASMTIME must name Wasmtime 44 or newer" >&2 + exit 2 +fi +if [[ -z "${LLGO_LLDB:-}" ]]; then + echo "LLGO_LLDB must name LLDB 22 or newer with the Wasm process plugin" >&2 + exit 2 +fi + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "${script_dir}/../../../.." && pwd)" +tmp_dir="$(mktemp -d)" +trap 'rm -rf "${tmp_dir}"' EXIT + +export LLGO_ROOT="${repo_root}" +export GOMEMLIMIT="${GOMEMLIMIT:-4GiB}" +export GOMAXPROCS="${GOMAXPROCS:-4}" + +(cd "${repo_root}" && go build -o "${tmp_dir}/llgo" ./cmd/llgo) + +( + cd "${script_dir}" + "${tmp_dir}/llgo" debug \ + -target=wasip1 \ + -o="${tmp_dir}/program.wasm" \ + -lldb="${LLGO_LLDB}" \ + -wasmtime="${LLGO_WASMTIME}" \ + . -- \ + --batch \ + -o 'breakpoint set --file main.go --line 8' \ + -o continue \ + -o 'frame variable value result' \ + -o bt \ + -o continue +) 2>&1 | tee "${tmp_dir}/session.log" + +grep -F 'main.increment(value=41)' "${tmp_dir}/session.log" +grep -F '(int) result = 42' "${tmp_dir}/session.log" +grep -F 'main.main at main.go:' "${tmp_dir}/session.log" +grep -F 'Process 1 exited with status = 0' "${tmp_dir}/session.log" + +if command -v llvm-dwarfdump >/dev/null 2>&1; then + llvm-dwarfdump --verify --error-display=quiet "${tmp_dir}/program.wasm" | tee "${tmp_dir}/dwarf.log" + grep -F 'No errors.' "${tmp_dir}/dwarf.log" +fi diff --git a/internal/wasmdebug/wasmdebug.go b/internal/wasmdebug/wasmdebug.go index 16fc278525..8fea16b289 100644 --- a/internal/wasmdebug/wasmdebug.go +++ b/internal/wasmdebug/wasmdebug.go @@ -58,6 +58,25 @@ func readULEB32(raw []byte, off *int) (uint32, error) { return 0, errors.New("invalid WebAssembly varuint32") } +func readULEB64(raw []byte, off *int) (uint64, error) { + var value uint64 + for shift := uint(0); shift < 70; shift += 7 { + if *off >= len(raw) { + return 0, errors.New("truncated WebAssembly varuint64") + } + b := raw[*off] + (*off)++ + if shift == 63 && b > 0x01 { + return 0, errors.New("WebAssembly varuint64 overflows") + } + value |= uint64(b&0x7f) << shift + if b&0x80 == 0 { + return value, nil + } + } + return 0, errors.New("invalid WebAssembly varuint64") +} + func appendULEB32(dst []byte, value uint32) []byte { for { b := byte(value & 0x7f) @@ -105,7 +124,7 @@ func parse(raw []byte) ([]section, error) { return nil, errors.New("truncated WebAssembly section") } end := off + int(size) - entry := section{raw: raw[start:end], id: id} + entry := section{raw: raw[start:end], id: id, content: raw[off:end]} if id == 0 { payloadOff := off entry.name, err = readName(raw[:end], &payloadOff) @@ -120,6 +139,189 @@ func parse(raw []byte) ([]section, error) { return sections, nil } +// MemoryImport describes one memory in the WebAssembly import section. Page +// counts are expressed in the memory type's native 64-KiB WebAssembly pages. +type MemoryImport struct { + Module string + Name string + Minimum uint64 + Maximum uint64 + HasMax bool + Shared bool + Memory64 bool +} + +// ImportedMemories returns every memory import in declaration order. It is +// deliberately independent of a runtime so debug-session setup can construct +// an exact provider for an imported LLGo linear memory. +func ImportedMemories(module []byte) ([]MemoryImport, error) { + sections, err := parse(module) + if err != nil { + return nil, err + } + var importSection []byte + for _, section := range sections { + if section.id != 2 { + continue + } + if importSection != nil { + return nil, errors.New("multiple WebAssembly import sections") + } + importSection = section.content + } + if importSection == nil { + return nil, nil + } + + off := 0 + count, err := readULEB32(importSection, &off) + if err != nil { + return nil, fmt.Errorf("invalid WebAssembly import count: %w", err) + } + memories := make([]MemoryImport, 0, 1) + for index := uint32(0); index < count; index++ { + moduleName, err := readName(importSection, &off) + if err != nil { + return nil, fmt.Errorf("invalid WebAssembly import %d module: %w", index, err) + } + fieldName, err := readName(importSection, &off) + if err != nil { + return nil, fmt.Errorf("invalid WebAssembly import %d name: %w", index, err) + } + if off >= len(importSection) { + return nil, errors.New("truncated WebAssembly import descriptor") + } + kind := importSection[off] + off++ + switch kind { + case 0: // function type index + if _, err := readULEB32(importSection, &off); err != nil { + return nil, fmt.Errorf("invalid WebAssembly function import: %w", err) + } + case 1: // table type + if err := skipReferenceType(importSection, &off); err != nil { + return nil, fmt.Errorf("invalid WebAssembly table import: %w", err) + } + if _, err := readLimits(importSection, &off); err != nil { + return nil, fmt.Errorf("invalid WebAssembly table limits: %w", err) + } + case 2: // memory type + limits, err := readLimits(importSection, &off) + if err != nil { + return nil, fmt.Errorf("invalid WebAssembly memory import: %w", err) + } + memories = append(memories, MemoryImport{ + Module: moduleName, + Name: fieldName, + Minimum: limits.minimum, + Maximum: limits.maximum, + HasMax: limits.hasMax, + Shared: limits.shared, + Memory64: limits.memory64, + }) + case 3: // global type + if err := skipValueType(importSection, &off); err != nil { + return nil, fmt.Errorf("invalid WebAssembly global import: %w", err) + } + if off >= len(importSection) { + return nil, errors.New("truncated WebAssembly global mutability") + } + off++ + case 4: // tag attribute and function type index + if off >= len(importSection) { + return nil, errors.New("truncated WebAssembly tag attribute") + } + off++ + if _, err := readULEB32(importSection, &off); err != nil { + return nil, fmt.Errorf("invalid WebAssembly tag import: %w", err) + } + default: + return nil, fmt.Errorf("unsupported WebAssembly import kind %d", kind) + } + } + if off != len(importSection) { + return nil, errors.New("trailing data in WebAssembly import section") + } + return memories, nil +} + +type limits struct { + minimum uint64 + maximum uint64 + hasMax bool + shared bool + memory64 bool +} + +func readLimits(raw []byte, off *int) (limits, error) { + flags, err := readULEB32(raw, off) + if err != nil { + return limits{}, err + } + if flags&^uint32(7) != 0 { + return limits{}, fmt.Errorf("unsupported limits flags %#x", flags) + } + result := limits{ + hasMax: flags&1 != 0, + shared: flags&2 != 0, + memory64: flags&4 != 0, + } + if result.shared && !result.hasMax { + return limits{}, errors.New("shared memory limits have no maximum") + } + read := func() (uint64, error) { + if result.memory64 { + return readULEB64(raw, off) + } + value, err := readULEB32(raw, off) + return uint64(value), err + } + result.minimum, err = read() + if err != nil { + return limits{}, err + } + if result.hasMax { + result.maximum, err = read() + if err != nil { + return limits{}, err + } + if result.maximum < result.minimum { + return limits{}, errors.New("memory maximum is smaller than its minimum") + } + } + return result, nil +} + +func skipValueType(raw []byte, off *int) error { + if *off >= len(raw) { + return errors.New("truncated WebAssembly value type") + } + typeCode := raw[*off] + (*off)++ + if typeCode == 0x63 || typeCode == 0x64 { + return skipSignedLEB33(raw, off) + } + return nil +} + +func skipReferenceType(raw []byte, off *int) error { + return skipValueType(raw, off) +} + +func skipSignedLEB33(raw []byte, off *int) error { + for index := 0; index < 5; index++ { + if *off >= len(raw) { + return errors.New("truncated WebAssembly heap type") + } + b := raw[*off] + (*off)++ + if b&0x80 == 0 { + return nil + } + } + return errors.New("invalid WebAssembly heap type") +} + func isDWARFSection(name string) bool { return strings.HasPrefix(name, ".debug_") || strings.HasPrefix(name, ".zdebug_") || diff --git a/internal/wasmdebug/wasmdebug_test.go b/internal/wasmdebug/wasmdebug_test.go index 00622877de..81c774f62b 100644 --- a/internal/wasmdebug/wasmdebug_test.go +++ b/internal/wasmdebug/wasmdebug_test.go @@ -14,6 +14,11 @@ func appendSection(dst []byte, id byte, payload []byte) []byte { return append(dst, payload...) } +func appendName(dst []byte, name string) []byte { + dst = appendULEB32(dst, uint32(len(name))) + return append(dst, name...) +} + func debugFixture() []byte { module := append([]byte(nil), wasmHeader...) module = appendCustomSection(module, "producers", []byte("LLGo")) @@ -199,3 +204,69 @@ func TestDebuggerRecordValidation(t *testing.T) { t.Fatal("SetDebuggerRecord accepted an invalid record") } } + +func TestImportedMemories(t *testing.T) { + imports := appendULEB32(nil, 3) + imports = appendName(imports, "wasi_snapshot_preview1") + imports = appendName(imports, "fd_write") + imports = append(imports, 0) + imports = appendULEB32(imports, 7) + imports = appendName(imports, "env") + imports = appendName(imports, "memory") + imports = append(imports, 2) + imports = appendULEB32(imports, 3) // maximum + shared, wasm32 + imports = appendULEB32(imports, 1024) + imports = appendULEB32(imports, 1024) + imports = appendName(imports, "host") + imports = appendName(imports, "memory64") + imports = append(imports, 2) + imports = appendULEB32(imports, 5) // maximum + memory64 + imports = append(imports, 0x80, 0x80, 0x80, 0x80, 0x10) // 2^32 + imports = append(imports, 0x81, 0x80, 0x80, 0x80, 0x10) // 2^32 + 1 + + module := appendSection(append([]byte(nil), wasmHeader...), 2, imports) + got, err := ImportedMemories(module) + if err != nil { + t.Fatal(err) + } + want := []MemoryImport{ + {Module: "env", Name: "memory", Minimum: 1024, Maximum: 1024, HasMax: true, Shared: true}, + {Module: "host", Name: "memory64", Minimum: 1 << 32, Maximum: 1<<32 + 1, HasMax: true, Memory64: true}, + } + if len(got) != len(want) { + t.Fatalf("ImportedMemories() = %+v, want %+v", got, want) + } + for index := range want { + if got[index] != want[index] { + t.Errorf("ImportedMemories()[%d] = %+v, want %+v", index, got[index], want[index]) + } + } +} + +func TestImportedMemoriesValidation(t *testing.T) { + valid := appendULEB32(nil, 1) + valid = appendName(valid, "env") + valid = appendName(valid, "memory") + valid = append(valid, 2) + valid = appendULEB32(valid, 3) + valid = appendULEB32(valid, 1) + valid = appendULEB32(valid, 2) + + tests := [][]byte{ + append(valid, 0), + append(append([]byte(nil), valid[:len(valid)-1]...), 0), + append(append([]byte(nil), valid[:len(valid)-2]...), 1, 0), + } + for index, imports := range tests { + module := appendSection(append([]byte(nil), wasmHeader...), 2, imports) + if _, err := ImportedMemories(module); err == nil { + t.Errorf("ImportedMemories accepted malformed fixture %d", index) + } + } + + duplicate := appendSection(append([]byte(nil), wasmHeader...), 2, []byte{0}) + duplicate = appendSection(duplicate, 2, []byte{0}) + if _, err := ImportedMemories(duplicate); err == nil { + t.Fatal("ImportedMemories accepted duplicate import sections") + } +}