diff --git a/.github/workflows/browser-debug.yml b/.github/workflows/browser-debug.yml
new file mode 100644
index 0000000000..fecada2510
--- /dev/null
+++ b/.github/workflows/browser-debug.yml
@@ -0,0 +1,128 @@
+name: Browser Debug
+
+on:
+ push:
+ branches: [main]
+ paths:
+ - ".github/workflows/browser-debug.yml"
+ - "cmd/internal/browser/**"
+ - "cmd/internal/debug/**"
+ - "cmd/llgo/debugtest/**"
+ - "internal/browserdebug/**"
+ - "internal/build/debug_artifact*"
+ - "internal/debugabi/**"
+ - "internal/wasmdebug/**"
+ pull_request:
+ branches: ["**"]
+ paths:
+ - ".github/workflows/browser-debug.yml"
+ - "cmd/internal/browser/**"
+ - "cmd/internal/debug/**"
+ - "cmd/llgo/debugtest/**"
+ - "internal/browserdebug/**"
+ - "internal/build/debug_artifact*"
+ - "internal/debugabi/**"
+ - "internal/wasmdebug/**"
+ workflow_dispatch:
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ language-extension:
+ name: Chrome 151 / embedded and external DWARF
+ 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 Emscripten
+ uses: emscripten-core/setup-emsdk@v15
+ with:
+ version: "4.0.21"
+
+ - name: Set up Go
+ uses: ./.github/actions/setup-go
+
+ - name: Install LLGo
+ shell: bash
+ run: |
+ set -euo pipefail
+ go install ./...
+ echo "LLGO_ROOT=${GITHUB_WORKSPACE}" >> "${GITHUB_ENV}"
+
+ - name: Install pinned Chrome for Testing
+ shell: bash
+ run: |
+ set -euo pipefail
+ chrome_version=151.0.7922.71
+ mkdir -p .tools/chrome
+ curl -fL --retry 3 \
+ "https://storage.googleapis.com/chrome-for-testing-public/${chrome_version}/linux64/chrome-linux64.zip" \
+ -o .tools/chrome/chrome.zip
+ unzip -q .tools/chrome/chrome.zip -d .tools/chrome
+ chrome="${GITHUB_WORKSPACE}/.tools/chrome/chrome-linux64/chrome"
+ version_output="$("${chrome}" --version)"
+ [[ "${version_output}" == *"${chrome_version}"* ]]
+ echo "${version_output}"
+ echo "LLGO_BROWSER_CHROME=${chrome}" >> "${GITHUB_ENV}"
+ echo "LLGO_BROWSER_CHROME_NO_SANDBOX=1" >> "${GITHUB_ENV}"
+
+ - name: Test browser debugger contracts
+ shell: bash
+ run: |
+ set -euo pipefail
+ node --test cmd/internal/browser/extension/plugin_test.js
+ go test -timeout 10m \
+ ./internal/debugabi \
+ ./internal/wasmdebug \
+ ./internal/browserdebug \
+ ./cmd/internal/browser \
+ ./cmd/internal/debug
+
+ - name: Build real LLGo browser artifacts
+ shell: bash
+ run: |
+ set -euo pipefail
+ package=./internal/debugabi/testdata/fixture
+ GOOS=js GOARCH=wasm llgo build -debug-artifact=embedded \
+ -o "${RUNNER_TEMP}/browser-embedded.wasm" "${package}"
+ GOOS=js GOARCH=wasm llgo build -debug-artifact=external \
+ -o "${RUNNER_TEMP}/browser-external.wasm" "${package}"
+ test -s "${RUNNER_TEMP}/browser-embedded.wasm"
+ test -s "${RUNNER_TEMP}/browser-external.wasm"
+ test -s "${RUNNER_TEMP}/browser-external.debug.wasm"
+ ls -lh "${RUNNER_TEMP}"/browser-*.wasm
+
+ - name: Test headless Chrome Language Extension
+ shell: bash
+ run: |
+ set -euo pipefail
+ for artifact in \
+ fixture \
+ fixture-external \
+ "${RUNNER_TEMP}/browser-embedded.wasm" \
+ "${RUNNER_TEMP}/browser-external.wasm"
+ do
+ LLGO_BROWSER_DEBUG_ARTIFACT="${artifact}" \
+ go test -timeout 5m ./cmd/internal/browser \
+ -run '^TestChromeLanguageExtension$' -count=1 -v
+ done
+
+ - name: Test source remapping and browser fallback
+ shell: bash
+ run: |
+ set -euo pipefail
+ LLGO_BROWSER_DEBUG_ARTIFACT="${RUNNER_TEMP}/browser-embedded.wasm" \
+ LLGO_BROWSER_DEBUG_RUNTIME_ARTIFACT="${RUNNER_TEMP}/browser-embedded.wasm" \
+ go test -timeout 5m ./internal/browserdebug \
+ -run '^(TestLoadLLGoArtifact|TestLoadLLGoRuntimeFixture|TestLoadUsesLongestSourcePathMapping)$' \
+ -count=1 -v
+ go test -timeout 5m ./cmd/internal/browser \
+ -run '^TestChromeWithoutLanguageExtension$' -count=1 -v
diff --git a/cmd/internal/browser/browser.go b/cmd/internal/browser/browser.go
new file mode 100644
index 0000000000..8b18e1ae57
--- /dev/null
+++ b/cmd/internal/browser/browser.go
@@ -0,0 +1,477 @@
+// 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 browser launches LLGo WebAssembly debug sessions in Chromium.
+package browser
+
+import (
+ "context"
+ _ "embed"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+ "net/url"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "regexp"
+ "runtime"
+ "strconv"
+ "strings"
+ "sync/atomic"
+ "time"
+
+ "github.com/goplus/llgo/internal/browserdebug"
+ "github.com/goplus/llgo/internal/debugabi"
+ "github.com/goplus/llgo/internal/env"
+ "github.com/goplus/llgo/internal/wasmdebug"
+)
+
+const MinimumChromeMajor = 123
+
+//go:embed extension/manifest.json
+var extensionManifest []byte
+
+//go:embed extension/devtools.html
+var extensionPage []byte
+
+//go:embed extension/plugin.js
+var extensionPlugin []byte
+
+var chromeVersionPattern = regexp.MustCompile(`(?:Chrome(?: for Testing| Canary)?|Chromium)\s+(\d+)\.`)
+
+type Options struct {
+ Chrome string
+ ChromeArgs []string
+ SourceMaps []browserdebug.PathMapping
+ KeepProfile bool
+ ProfilePath string
+ DisableTools bool
+}
+
+// Run validates artifact, starts a loopback-only debug server, installs the
+// LLGo extension in an isolated profile, and waits for Chromium to exit.
+func Run(artifact string, options Options, stdin io.Reader, stdout, stderr io.Writer) error {
+ path, version, err := Find(options.Chrome)
+ if err != nil {
+ return err
+ }
+ session, err := StartSession(artifact, options.SourceMaps)
+ if err != nil {
+ return fmt.Errorf("llgo debug: %w", err)
+ }
+ defer session.Close()
+
+ profile, profileCleanup, err := prepareProfile(options)
+ if err != nil {
+ return err
+ }
+ defer profileCleanup()
+ extensionPath := filepath.Join(profile, "llgo-extension")
+ if err := WriteExtension(extensionPath); err != nil {
+ return fmt.Errorf("llgo debug: prepare browser extension: %w", err)
+ }
+
+ args := []string{
+ "--user-data-dir=" + profile,
+ "--no-first-run",
+ "--no-default-browser-check",
+ "--disable-background-networking",
+ "--disable-breakpad",
+ "--disable-default-apps",
+ "--password-store=basic",
+ "--disable-extensions-except=" + extensionPath,
+ "--load-extension=" + extensionPath,
+ }
+ if runtime.GOOS == "darwin" {
+ // An isolated profile must not wait for a system Keychain prompt before
+ // loading its command-line extension and first navigation.
+ args = append(args, "--use-mock-keychain")
+ }
+ if !options.DisableTools {
+ args = append(args, "--auto-open-devtools-for-tabs")
+ }
+ args = append(args, options.ChromeArgs...)
+ sessionURL := session.URL
+ if options.DisableTools {
+ sessionURL += "?llgo-devtools=disabled"
+ }
+ args = append(args, sessionURL)
+ fmt.Fprintf(stderr, "llgo debug: Chromium %d; browser session %s\n", version, session.URL)
+ command := exec.Command(path, args...)
+ command.Stdin = stdin
+ command.Stdout = stdout
+ command.Stderr = stderr
+ if err := command.Run(); err != nil {
+ return fmt.Errorf("llgo debug: Chromium session: %w", err)
+ }
+ return nil
+}
+
+func prepareProfile(options Options) (string, func(), error) {
+ if options.ProfilePath != "" {
+ path, err := filepath.Abs(options.ProfilePath)
+ if err != nil {
+ return "", func() {}, err
+ }
+ if err := os.MkdirAll(path, 0o700); err != nil {
+ return "", func() {}, err
+ }
+ return path, func() {}, nil
+ }
+ path, err := os.MkdirTemp("", "llgo-browser-debug-")
+ if err != nil {
+ return "", func() {}, fmt.Errorf("llgo debug: create Chromium profile: %w", err)
+ }
+ cleanup := func() { _ = os.RemoveAll(path) }
+ if options.KeepProfile {
+ cleanup = func() {}
+ }
+ return path, cleanup, nil
+}
+
+// Find resolves and validates a Chromium-family executable.
+func Find(configured string) (string, int, error) {
+ candidates := []string{configured, os.Getenv("LLGO_CHROME")}
+ switch runtime.GOOS {
+ case "darwin":
+ candidates = append(candidates,
+ "/Applications/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing",
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
+ "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
+ )
+ case "windows":
+ candidates = append(candidates, "chrome.exe", "chromium.exe")
+ default:
+ candidates = append(candidates, "chromium", "chromium-browser", "google-chrome", "google-chrome-stable")
+ }
+ seen := make(map[string]bool)
+ var failures []string
+ for _, candidate := range candidates {
+ if candidate == "" || seen[candidate] {
+ continue
+ }
+ seen[candidate] = true
+ path, err := exec.LookPath(candidate)
+ if err != nil {
+ continue
+ }
+ output, err := exec.Command(path, "--version").CombinedOutput()
+ if err != nil {
+ failures = append(failures, fmt.Sprintf("%s: %v", path, err))
+ continue
+ }
+ match := chromeVersionPattern.FindStringSubmatch(string(output))
+ if len(match) != 2 {
+ failures = append(failures, fmt.Sprintf("%s: cannot parse %q", path, strings.TrimSpace(string(output))))
+ continue
+ }
+ major, _ := strconv.Atoi(match[1])
+ if major < MinimumChromeMajor {
+ failures = append(failures, fmt.Sprintf("%s: version %d is older than %d", path, major, MinimumChromeMajor))
+ continue
+ }
+ return path, major, nil
+ }
+ detail := ""
+ if len(failures) != 0 {
+ detail = ": " + strings.Join(failures, "; ")
+ }
+ return "", 0, fmt.Errorf("llgo debug: Chromium %d or newer is required; use -chrome or LLGO_CHROME%s", MinimumChromeMajor, detail)
+}
+
+// WriteExtension materializes the embedded unpacked extension.
+func WriteExtension(directory string) error {
+ if err := os.MkdirAll(directory, 0o755); err != nil {
+ return err
+ }
+ for name, data := range map[string][]byte{
+ "manifest.json": extensionManifest,
+ "devtools.html": extensionPage,
+ "plugin.js": extensionPlugin,
+ } {
+ if err := os.WriteFile(filepath.Join(directory, name), data, 0o644); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+type Session struct {
+ URL string
+ Listener net.Listener
+ Server *http.Server
+ Bundle *browserdebug.Bundle
+ pluginRequests atomic.Uint64
+ pluginReady atomic.Uint64
+ runtimeReady atomic.Uint64
+}
+
+// StartSession starts the loopback HTTP portion of a browser debug session.
+// It is exported so headless acceptance tests can exercise exactly the same
+// artifact, sidecar, source, schema, and page routes as the interactive path.
+func StartSession(artifact string, mappings []browserdebug.PathMapping) (*Session, error) {
+ bundle, err := browserdebug.Load(artifact, mappings)
+ if err != nil {
+ return nil, err
+ }
+ wasmExec, err := os.ReadFile(filepath.Join(env.LLGoROOT(), "targets", "wasm_exec.js"))
+ if err != nil {
+ return nil, fmt.Errorf("read browser runtime: %w", err)
+ }
+ main, err := os.ReadFile(bundle.MainPath)
+ if err != nil {
+ return nil, err
+ }
+ indexJSON, err := json.Marshal(bundle.Index)
+ if err != nil {
+ return nil, err
+ }
+ listener, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ return nil, fmt.Errorf("start browser debug server: %w", err)
+ }
+ origin := "http://" + listener.Addr().String()
+ mainRoute := "/" + filepath.Base(bundle.MainPath)
+ mainURLPath := "/" + url.PathEscape(filepath.Base(bundle.MainPath))
+ session := &Session{URL: origin + "/", Listener: listener, Bundle: bundle}
+
+ files := map[string]servedFile{
+ mainRoute: {data: main, contentType: "application/wasm"},
+ "/wasm_exec.js": {data: wasmExec, contentType: "text/javascript; charset=utf-8"},
+ "/favicon.ico": {data: nil, contentType: "image/x-icon"},
+ }
+ if bundle.SymbolsPath != bundle.MainPath {
+ reference, ok, err := wasmdebug.ExternalURL(main)
+ if err != nil || !ok {
+ listener.Close()
+ return nil, fmt.Errorf("read external WebAssembly DWARF URL: %w", err)
+ }
+ parsed, _ := url.Parse(reference)
+ sidecarRoute, err := url.PathUnescape("/" + parsed.EscapedPath())
+ if err != nil {
+ listener.Close()
+ return nil, err
+ }
+ sidecar, err := os.ReadFile(bundle.SymbolsPath)
+ if err != nil {
+ listener.Close()
+ return nil, err
+ }
+ files[sidecarRoute] = servedFile{data: sidecar, contentType: "application/wasm"}
+ }
+
+ mux := http.NewServeMux()
+ mux.HandleFunc("/", func(response http.ResponseWriter, request *http.Request) {
+ setDebugHeaders(response)
+ if request.URL.Path == "/" {
+ response.Header().Set("Content-Type", "text/html; charset=utf-8")
+ _, _ = io.WriteString(response, debugPage(mainURLPath))
+ return
+ }
+ if file, ok := files[request.URL.Path]; ok {
+ response.Header().Set("Content-Type", file.contentType)
+ response.Header().Set("Content-Length", strconv.Itoa(len(file.data)))
+ _, _ = response.Write(file.data)
+ return
+ }
+ http.NotFound(response, request)
+ })
+ mux.HandleFunc("/__llgo/debug-index.json", func(response http.ResponseWriter, _ *http.Request) {
+ session.pluginRequests.Add(1)
+ setDebugHeaders(response)
+ response.Header().Set("Content-Type", "application/json")
+ _, _ = response.Write(indexJSON)
+ })
+ mux.HandleFunc("/__llgo/debug-schema.json", func(response http.ResponseWriter, _ *http.Request) {
+ setDebugHeaders(response)
+ response.Header().Set("Content-Type", "application/json")
+ _, _ = response.Write(debugabi.SchemaV1())
+ })
+ mux.HandleFunc("/__llgo/plugin-ready", func(response http.ResponseWriter, request *http.Request) {
+ setDebugHeaders(response)
+ if request.Method != http.MethodGet {
+ response.Header().Set("Allow", http.MethodGet)
+ http.Error(response, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ session.pluginReady.Add(1)
+ response.WriteHeader(http.StatusNoContent)
+ })
+ mux.HandleFunc("/__llgo/runtime-ready", func(response http.ResponseWriter, request *http.Request) {
+ setDebugHeaders(response)
+ if request.Method != http.MethodGet {
+ response.Header().Set("Allow", http.MethodGet)
+ http.Error(response, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ session.runtimeReady.Add(1)
+ response.WriteHeader(http.StatusNoContent)
+ })
+ mux.HandleFunc("/__llgo/source/", func(response http.ResponseWriter, request *http.Request) {
+ setDebugHeaders(response)
+ id := strings.TrimPrefix(request.URL.Path, "/__llgo/source/")
+ path, ok := bundle.SourceFiles[id]
+ if !ok {
+ http.NotFound(response, request)
+ return
+ }
+ response.Header().Set("Content-Type", "text/plain; charset=utf-8")
+ http.ServeFile(response, request, path)
+ })
+
+ server := &http.Server{
+ Handler: mux,
+ ReadHeaderTimeout: 5 * time.Second,
+ IdleTimeout: 30 * time.Second,
+ }
+ session.Server = server
+ go func() {
+ _ = server.Serve(listener)
+ }()
+ return session, nil
+}
+
+// PluginRequests reports how often Chrome's Language Extension requested the
+// session index. PluginReady is the stronger end-to-end readiness signal.
+func (s *Session) PluginRequests() uint64 {
+ if s == nil {
+ return 0
+ }
+ return s.pluginRequests.Load()
+}
+
+// PluginReady reports how often Chrome's Language Extension completed all
+// module, index, build-identity, and debugger-schema validation.
+func (s *Session) PluginReady() uint64 {
+ if s == nil {
+ return 0
+ }
+ return s.pluginReady.Load()
+}
+
+// RuntimeReady reports how often the inspected page completed WebAssembly
+// instantiation. It is independent of whether DevTools or the extension ran.
+func (s *Session) RuntimeReady() uint64 {
+ if s == nil {
+ return 0
+ }
+ return s.runtimeReady.Load()
+}
+
+type servedFile struct {
+ data []byte
+ contentType string
+}
+
+func setDebugHeaders(response http.ResponseWriter) {
+ response.Header().Set("Cache-Control", "no-store")
+ response.Header().Set("Cross-Origin-Opener-Policy", "same-origin")
+ response.Header().Set("Cross-Origin-Embedder-Policy", "require-corp")
+ response.Header().Set("Access-Control-Allow-Origin", "*")
+}
+
+func debugPage(modulePath string) string {
+ quoted, _ := json.Marshal(modulePath)
+ return `
+
+
LLGo WebAssembly Debug Session
+loading
+
+
+
+`
+}
+
+func (s *Session) Close() error {
+ if s == nil || s.Server == nil {
+ return nil
+ }
+ context, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+ err := s.Server.Shutdown(context)
+ if errors.Is(err, http.ErrServerClosed) {
+ return nil
+ }
+ return err
+}
diff --git a/cmd/internal/browser/browser_test.go b/cmd/internal/browser/browser_test.go
new file mode 100644
index 0000000000..9a534930a6
--- /dev/null
+++ b/cmd/internal/browser/browser_test.go
@@ -0,0 +1,376 @@
+//go:build !llgo
+
+package browser
+
+import (
+ "bytes"
+ "encoding/json"
+ "io"
+ "net/http"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/goplus/llgo/internal/debugabi"
+ "github.com/goplus/llgo/internal/wasmdebug"
+)
+
+func TestExtensionJavaScript(t *testing.T) {
+ node, err := exec.LookPath("node")
+ if err != nil {
+ t.Skip("node is unavailable")
+ }
+ command := exec.Command(node, "--test", filepath.Join("extension", "plugin_test.js"))
+ if output, err := command.CombinedOutput(); err != nil {
+ t.Fatalf("language extension tests: %v\n%s", err, output)
+ }
+}
+
+func TestSessionServesArtifactIndexSchemaAndSources(t *testing.T) {
+ repoRoot, err := filepath.Abs(filepath.Join("..", "..", ".."))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("LLGO_ROOT", repoRoot)
+ dir := t.TempDir()
+ source := filepath.Join(dir, "fixture.c")
+ artifact := filepath.Join(dir, "fixture.wasm")
+ if err := os.WriteFile(source, []byte("int answer(void) { return 42; }\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ compileBrowserFixture(t, source, artifact)
+ raw, err := os.ReadFile(artifact)
+ if err != nil {
+ t.Fatal(err)
+ }
+ raw, err = wasmdebug.SetDebuggerRecord(raw, debugabi.NewRecord(2, 4, debugabi.ByteOrderLittle))
+ if err != nil {
+ t.Fatal(err)
+ }
+ raw, _, err = wasmdebug.EnsureBuildID(raw)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(artifact, raw, 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ session, err := StartSession(artifact, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer session.Close()
+ for _, route := range []string{"/", "/fixture.wasm", "/wasm_exec.js", "/__llgo/debug-index.json", "/__llgo/debug-schema.json"} {
+ response, err := http.Get(strings.TrimSuffix(session.URL, "/") + route)
+ if err != nil {
+ t.Fatal(err)
+ }
+ body, readErr := io.ReadAll(response.Body)
+ response.Body.Close()
+ if readErr != nil || response.StatusCode != http.StatusOK || len(body) == 0 {
+ t.Fatalf("GET %s = status %d bytes %d, %v", route, response.StatusCode, len(body), readErr)
+ }
+ }
+ response, err := http.Get(strings.TrimSuffix(session.URL, "/") + "/__llgo/plugin-ready")
+ if err != nil {
+ t.Fatal(err)
+ }
+ response.Body.Close()
+ if response.StatusCode != http.StatusNoContent || session.PluginReady() != 1 {
+ t.Fatalf("plugin-ready = status %d, count %d", response.StatusCode, session.PluginReady())
+ }
+ response, err = http.Get(strings.TrimSuffix(session.URL, "/") + "/__llgo/debug-index.json")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer response.Body.Close()
+ var index map[string]any
+ if err := json.NewDecoder(response.Body).Decode(&index); err != nil {
+ t.Fatal(err)
+ }
+ if index["contract"] != "llgo.browser.debug" {
+ t.Fatalf("browser index contract = %v", index["contract"])
+ }
+ for _, field := range []string{"sources", "lines", "functions", "variables", "types"} {
+ if _, ok := index[field].([]any); !ok {
+ t.Fatalf("browser index field %q is %T, want array", field, index[field])
+ }
+ }
+ for id, sourcePath := range session.Bundle.SourceFiles {
+ response, err := http.Get(strings.TrimSuffix(session.URL, "/") + "/__llgo/source/" + id)
+ if err != nil {
+ t.Fatal(err)
+ }
+ contents, readErr := io.ReadAll(response.Body)
+ response.Body.Close()
+ if readErr != nil || response.StatusCode != http.StatusOK {
+ t.Fatalf("GET source %q (%q) = %d, %v", id, sourcePath, response.StatusCode, readErr)
+ }
+ if sourcePath == source && !bytes.Contains(contents, []byte("return 42")) {
+ t.Fatalf("served fixture source = %q", contents)
+ }
+ }
+}
+
+func TestWriteExtensionAndChromeVersion(t *testing.T) {
+ directory := filepath.Join(t.TempDir(), "extension")
+ if err := WriteExtension(directory); err != nil {
+ t.Fatal(err)
+ }
+ for _, name := range []string{"manifest.json", "devtools.html", "plugin.js"} {
+ if info, err := os.Stat(filepath.Join(directory, name)); err != nil || info.Size() == 0 {
+ t.Fatalf("extension file %s: %v, %+v", name, err, info)
+ }
+ }
+ for input, want := range map[string]string{
+ "Google Chrome 150.0.1": "150",
+ "Google Chrome for Testing 151.0.1": "151",
+ "Google Chrome Canary 152.0.1": "152",
+ "Chromium 153.0.1": "153",
+ } {
+ if match := chromeVersionPattern.FindStringSubmatch(input); len(match) != 2 || match[1] != want {
+ t.Fatalf("Chrome version match for %q = %v, want %s", input, match, want)
+ }
+ }
+}
+
+func TestDebugPageCanSkipDevToolsHandshake(t *testing.T) {
+ page := debugPage("/fixture.wasm")
+ for _, want := range []string{
+ "__llgoLanguageExtensionReady",
+ "llgo-devtools",
+ "setTimeout(resolve, 5000)",
+ } {
+ if !strings.Contains(page, want) {
+ t.Fatalf("debug page does not contain %q", want)
+ }
+ }
+}
+
+func TestChromeLanguageExtension(t *testing.T) {
+ chrome := os.Getenv("LLGO_BROWSER_CHROME")
+ requestedArtifact := os.Getenv("LLGO_BROWSER_DEBUG_ARTIFACT")
+ if chrome == "" || requestedArtifact == "" {
+ t.Skip("LLGO_BROWSER_CHROME and LLGO_BROWSER_DEBUG_ARTIFACT are required")
+ }
+ if _, _, err := Find(chrome); err != nil {
+ t.Fatal(err)
+ }
+ repoRoot, err := filepath.Abs(filepath.Join("..", "..", ".."))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("LLGO_ROOT", repoRoot)
+ artifact := prepareBrowserArtifact(t, requestedArtifact)
+ session, err := StartSession(artifact, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer session.Close()
+ profile := t.TempDir()
+ profileData := filepath.Join(profile, "profile")
+ extension := filepath.Join(profile, "extension")
+ if err := WriteExtension(extension); err != nil {
+ t.Fatal(err)
+ }
+ var output bytes.Buffer
+ chromeArgs := []string{
+ "--remote-debugging-port=0",
+ "--user-data-dir=" + profileData,
+ "--no-first-run", "--no-default-browser-check",
+ "--disable-background-networking", "--disable-breakpad", "--disable-default-apps",
+ "--password-store=basic",
+ "--disable-extensions-except=" + extension,
+ "--load-extension=" + extension,
+ "--auto-open-devtools-for-tabs",
+ session.URL,
+ }
+ if runtime.GOOS == "darwin" {
+ chromeArgs = append([]string{"--use-mock-keychain"}, chromeArgs...)
+ }
+ if os.Getenv("LLGO_BROWSER_CHROME_NO_SANDBOX") == "1" {
+ chromeArgs = append([]string{"--no-sandbox"}, chromeArgs...)
+ }
+ if os.Getenv("LLGO_BROWSER_CHROME_GUI") == "" {
+ chromeArgs = append([]string{"--headless=new"}, chromeArgs...)
+ }
+ command := exec.Command(chrome, chromeArgs...)
+ command.Stdout = &output
+ command.Stderr = &output
+ if err := command.Start(); err != nil {
+ t.Fatal(err)
+ }
+ done := make(chan error, 1)
+ go func() { done <- command.Wait() }()
+ defer func() {
+ if command.Process != nil {
+ _ = command.Process.Kill()
+ }
+ select {
+ case <-done:
+ case <-time.After(5 * time.Second):
+ }
+ }()
+ deadline := time.Now().Add(30 * time.Second)
+ for (session.PluginReady() == 0 || session.RuntimeReady() == 0) && time.Now().Before(deadline) {
+ select {
+ case err := <-done:
+ t.Fatalf("Chromium exited before the language extension associated the module: %v\n%s", err, output.String())
+ case <-time.After(100 * time.Millisecond):
+ }
+ }
+ if session.PluginReady() == 0 || session.RuntimeReady() == 0 {
+ t.Fatalf("Chrome did not complete LLGo module registration/instantiation (index=%d plugin-ready=%d runtime-ready=%d)\ntargets: %s\n%s",
+ session.PluginRequests(), session.PluginReady(), session.RuntimeReady(),
+ chromeTargets(profileData), output.String())
+ }
+}
+
+func TestChromeWithoutLanguageExtension(t *testing.T) {
+ chrome := os.Getenv("LLGO_BROWSER_CHROME")
+ if chrome == "" {
+ t.Skip("LLGO_BROWSER_CHROME is required")
+ }
+ if _, _, err := Find(chrome); err != nil {
+ t.Fatal(err)
+ }
+ repoRoot, err := filepath.Abs(filepath.Join("..", "..", ".."))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("LLGO_ROOT", repoRoot)
+ artifact := prepareBrowserArtifact(t, "fixture")
+ session, err := StartSession(artifact, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer session.Close()
+
+ args := []string{
+ "--headless=new", "--remote-debugging-port=0",
+ "--user-data-dir=" + filepath.Join(t.TempDir(), "profile"),
+ "--no-first-run", "--no-default-browser-check", "--disable-default-apps",
+ "--disable-background-networking", "--disable-breakpad",
+ "--disable-extensions", "--password-store=basic",
+ session.URL + "?llgo-devtools=disabled",
+ }
+ if runtime.GOOS == "darwin" {
+ args = append([]string{"--use-mock-keychain"}, args...)
+ }
+ if os.Getenv("LLGO_BROWSER_CHROME_NO_SANDBOX") == "1" {
+ args = append([]string{"--no-sandbox"}, args...)
+ }
+ var output bytes.Buffer
+ command := exec.Command(chrome, args...)
+ command.Stdout = &output
+ command.Stderr = &output
+ if err := command.Start(); err != nil {
+ t.Fatal(err)
+ }
+ done := make(chan error, 1)
+ go func() { done <- command.Wait() }()
+ defer func() {
+ if command.Process != nil {
+ _ = command.Process.Kill()
+ }
+ select {
+ case <-done:
+ case <-time.After(5 * time.Second):
+ }
+ }()
+ deadline := time.Now().Add(30 * time.Second)
+ for session.RuntimeReady() == 0 && time.Now().Before(deadline) {
+ select {
+ case err := <-done:
+ t.Fatalf("Chromium exited before fallback WebAssembly instantiation: %v\n%s", err, output.String())
+ case <-time.After(100 * time.Millisecond):
+ }
+ }
+ if session.RuntimeReady() == 0 {
+ t.Fatalf("fallback page did not instantiate WebAssembly without the extension:\n%s", output.String())
+ }
+ if session.PluginRequests() != 0 || session.PluginReady() != 0 {
+ t.Fatalf("fallback unexpectedly used the LLGo extension: index=%d ready=%d",
+ session.PluginRequests(), session.PluginReady())
+ }
+}
+
+func prepareBrowserArtifact(t *testing.T, requested string) string {
+ t.Helper()
+ if requested != "fixture" && requested != "fixture-external" {
+ return requested
+ }
+ external := requested == "fixture-external"
+ dir := t.TempDir()
+ source := filepath.Join(dir, "fixture.c")
+ artifact := filepath.Join(dir, "fixture.wasm")
+ if err := os.WriteFile(source, []byte("int answer(void) { return 42; }\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ compileBrowserFixture(t, source, artifact)
+ raw, err := os.ReadFile(artifact)
+ if err != nil {
+ t.Fatal(err)
+ }
+ raw, err = wasmdebug.SetDebuggerRecord(raw, debugabi.NewRecord(2, 4, debugabi.ByteOrderLittle))
+ if err != nil {
+ t.Fatal(err)
+ }
+ raw, _, err = wasmdebug.EnsureBuildID(raw)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if external {
+ if err := os.WriteFile(filepath.Join(dir, "fixture debug.wasm"), raw, 0o644); err != nil {
+ t.Fatal(err)
+ }
+ raw, err = wasmdebug.Externalize(raw, "fixture%20debug.wasm")
+ if err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := os.WriteFile(artifact, raw, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ return artifact
+}
+
+func chromeTargets(profile string) string {
+ data, err := os.ReadFile(filepath.Join(profile, "DevToolsActivePort"))
+ if err != nil {
+ return err.Error()
+ }
+ fields := strings.Fields(string(data))
+ if len(fields) == 0 {
+ return "DevToolsActivePort is empty"
+ }
+ response, err := http.Get("http://127.0.0.1:" + fields[0] + "/json/list")
+ if err != nil {
+ return err.Error()
+ }
+ defer response.Body.Close()
+ contents, err := io.ReadAll(response.Body)
+ if err != nil {
+ return err.Error()
+ }
+ return string(contents)
+}
+
+func compileBrowserFixture(t *testing.T, source, artifact string) {
+ t.Helper()
+ clang, err := exec.LookPath("clang")
+ if err != nil {
+ t.Skip("clang is unavailable")
+ }
+ command := exec.Command(clang,
+ "--target=wasm32-unknown-unknown", "-O0", "-g", "-nostdlib",
+ "-Wl,--no-entry", "-Wl,--export=answer", "-o", artifact, source,
+ )
+ if output, err := command.CombinedOutput(); err != nil {
+ t.Fatalf("compile WebAssembly fixture on %s/%s: %v\n%s", runtime.GOOS, runtime.GOARCH, err, output)
+ }
+}
diff --git a/cmd/internal/browser/extension/devtools.html b/cmd/internal/browser/extension/devtools.html
new file mode 100644
index 0000000000..79cf9895f0
--- /dev/null
+++ b/cmd/internal/browser/extension/devtools.html
@@ -0,0 +1,3 @@
+
+
+
diff --git a/cmd/internal/browser/extension/manifest.json b/cmd/internal/browser/extension/manifest.json
new file mode 100644
index 0000000000..da3912098a
--- /dev/null
+++ b/cmd/internal/browser/extension/manifest.json
@@ -0,0 +1,11 @@
+{
+ "manifest_version": 3,
+ "name": "LLGo WebAssembly Debugger",
+ "version": "0.1.0",
+ "description": "Source and runtime presentation for LLGo WebAssembly DWARF",
+ "devtools_page": "devtools.html",
+ "host_permissions": [
+ "http://127.0.0.1/*",
+ "http://localhost/*"
+ ]
+}
diff --git a/cmd/internal/browser/extension/plugin.js b/cmd/internal/browser/extension/plugin.js
new file mode 100644
index 0000000000..1b67934307
--- /dev/null
+++ b/cmd/internal/browser/extension/plugin.js
@@ -0,0 +1,1025 @@
+// Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved.
+// Licensed under the Apache License, Version 2.0.
+
+(() => {
+ 'use strict';
+
+ const INDEX_CONTRACT = 'llgo.browser.debug';
+ const INDEX_VERSION = 1;
+ const RECORD_MAGIC = [0x4c, 0x4c, 0x47, 0x4f, 0x44, 0x42, 0x47, 0x00];
+ const RECORD_SIZE = 16;
+ const MAX_CHILDREN = 100;
+ const MAX_CONTAINER_SCAN_BUCKETS = 4096;
+
+ function readULEB(bytes, cursor) {
+ let value = 0n;
+ let shift = 0n;
+ for (let count = 0; count < 10; ++count) {
+ if (cursor.offset >= bytes.length) throw new Error('truncated varuint');
+ const current = bytes[cursor.offset++];
+ value |= BigInt(current & 0x7f) << shift;
+ if ((current & 0x80) === 0) return value;
+ shift += 7n;
+ }
+ throw new Error('invalid varuint');
+ }
+
+ function readSLEB(bytes, cursor) {
+ let value = 0n;
+ let shift = 0n;
+ let current = 0;
+ for (let count = 0; count < 10; ++count) {
+ if (cursor.offset >= bytes.length) throw new Error('truncated varint');
+ current = bytes[cursor.offset++];
+ value |= BigInt(current & 0x7f) << shift;
+ shift += 7n;
+ if ((current & 0x80) === 0) {
+ if ((current & 0x40) !== 0) value |= (-1n) << shift;
+ return value;
+ }
+ }
+ throw new Error('invalid varint');
+ }
+
+ function readName(bytes, cursor) {
+ const size = Number(readULEB(bytes, cursor));
+ if (cursor.offset + size > bytes.length) throw new Error('truncated WebAssembly name');
+ const value = new TextDecoder().decode(bytes.subarray(cursor.offset, cursor.offset + size));
+ cursor.offset += size;
+ return value;
+ }
+
+ function customSections(moduleBytes) {
+ const bytes = moduleBytes instanceof Uint8Array ? moduleBytes : new Uint8Array(moduleBytes);
+ if (bytes.length < 8 || bytes[0] !== 0 || bytes[1] !== 0x61 || bytes[2] !== 0x73 || bytes[3] !== 0x6d) {
+ throw new Error('invalid WebAssembly module');
+ }
+ const result = new Map();
+ const cursor = {offset: 8};
+ while (cursor.offset < bytes.length) {
+ const id = bytes[cursor.offset++];
+ const size = Number(readULEB(bytes, cursor));
+ const end = cursor.offset + size;
+ if (end > bytes.length) throw new Error('truncated WebAssembly section');
+ if (id === 0) {
+ const name = readName(bytes.subarray(0, end), cursor);
+ if (result.has(name)) throw new Error(`multiple ${name} custom sections`);
+ result.set(name, bytes.slice(cursor.offset, end));
+ }
+ cursor.offset = end;
+ }
+ return result;
+ }
+
+ function debuggerRecord(sections) {
+ const bytes = sections.get('llgo.debugger');
+ if (!bytes) return null;
+ if (bytes.length !== RECORD_SIZE) throw new Error('invalid LLGo debugger record size');
+ for (let index = 0; index < RECORD_MAGIC.length; ++index) {
+ if (bytes[index] !== RECORD_MAGIC[index]) throw new Error('invalid LLGo debugger record magic');
+ }
+ if (bytes[15] !== 0) throw new Error('invalid LLGo debugger record reserved byte');
+ return {
+ record_version: bytes[8],
+ schema_version: bytes[9],
+ runtime_layout_version: bytes[10],
+ llgo_abi_version: bytes[11],
+ cabi_mode: bytes[12],
+ pointer_size: bytes[13],
+ byte_order: bytes[14],
+ };
+ }
+
+ function buildID(sections) {
+ const bytes = sections.get('build_id');
+ if (!bytes) return null;
+ const cursor = {offset: 0};
+ const size = Number(readULEB(bytes, cursor));
+ if (size !== bytes.length - cursor.offset) throw new Error('invalid WebAssembly build_id');
+ return [...bytes.subarray(cursor.offset)].map(value => value.toString(16).padStart(2, '0')).join('');
+ }
+
+ function inRanges(offset, ranges) {
+ return !ranges || ranges.length === 0 || ranges.some(range =>
+ ((!range.start && !range.end) || (offset >= range.start && offset < range.end)));
+ }
+
+ function bytesFromHex(value) {
+ if (value.length % 2 !== 0) throw new Error('invalid hexadecimal DWARF expression');
+ const result = new Uint8Array(value.length / 2);
+ for (let index = 0; index < result.length; ++index) {
+ result[index] = Number.parseInt(value.slice(index * 2, index * 2 + 2), 16);
+ }
+ return result;
+ }
+
+ function sameRecord(left, right) {
+ return left.record_version === right.record_version &&
+ left.schema_version === right.schema_version &&
+ left.runtime_layout_version === right.runtime_layout_version &&
+ left.llgo_abi_version === right.llgo_abi_version &&
+ left.cabi_mode === right.cabi_mode &&
+ left.pointer_size === right.pointer_size && left.byte_order === right.byte_order;
+ }
+
+ class LLGoLanguageExtensionPlugin {
+ constructor(languageServices, fetcher = globalThis.fetch.bind(globalThis)) {
+ this.languageServices = languageServices;
+ this.fetcher = fetcher;
+ this.modules = new Map();
+ this.objects = new Map();
+ this.nextObject = 1;
+ this.schema = null;
+ this.addRawModuleCalls = 0;
+ this.lastAddRawModuleError = null;
+ }
+
+ async addRawModule(rawModuleId, symbolsURL, rawModule) {
+ ++this.addRawModuleCalls;
+ try {
+ return await this.loadRawModule(rawModuleId, symbolsURL, rawModule);
+ } catch (error) {
+ this.lastAddRawModuleError = String(error && error.stack || error);
+ throw error;
+ }
+ }
+
+ async loadRawModule(rawModuleId, symbolsURL, rawModule) {
+ let code = rawModule.code;
+ if (!code) {
+ const response = await this.fetcher(rawModule.url);
+ if (!response.ok) throw new Error(`load WebAssembly module: HTTP ${response.status}`);
+ code = await response.arrayBuffer();
+ }
+ const sections = customSections(new Uint8Array(code));
+ const record = debuggerRecord(sections);
+ // A dedicated LLGo session must leave non-LLGo modules usable as raw
+ // WebAssembly instead of claiming source presentation for them.
+ if (!record) return [];
+ const id = buildID(sections);
+ if (!id) throw new Error('LLGo WebAssembly module has no build_id');
+
+ const indexURL = new URL('/__llgo/debug-index.json', rawModule.url).href;
+ const response = await this.fetcher(indexURL, {cache: 'no-store'});
+ if (response.status === 424 || response.status === 404) {
+ let missing = symbolsURL ? [symbolsURL] : [];
+ try {
+ const details = await response.json();
+ if (Array.isArray(details.missing_symbol_files)) missing = details.missing_symbol_files;
+ } catch (_) {
+ }
+ return {missingSymbolFiles: missing};
+ }
+ if (!response.ok) throw new Error(`load LLGo browser debug index: HTTP ${response.status}`);
+ const index = await response.json();
+ if (index.contract !== INDEX_CONTRACT || index.version !== INDEX_VERSION) {
+ throw new Error(`unsupported LLGo browser debug index ${index.contract || ''} v${index.version}`);
+ }
+ if (index.build_id !== id) throw new Error('LLGo browser debug index build_id mismatch');
+ if (!sameRecord(record, index.record)) throw new Error('LLGo browser debug index record mismatch');
+ const schema = await this.loadSchema(rawModule.url);
+ if (record.schema_version !== schema.schema_version ||
+ record.runtime_layout_version !== schema.runtime_layout_version ||
+ record.llgo_abi_version !== schema.llgo_abi_version) {
+ throw new Error(`unsupported LLGo debugger schema/runtime/ABI ${record.schema_version}/${record.runtime_layout_version}/${record.llgo_abi_version}`);
+ }
+
+ const sources = new Map();
+ const sourceByURL = new Map();
+ for (const source of index.sources) {
+ const resolved = {...source, resolvedURL: new URL(source.url, rawModule.url).href};
+ sources.set(source.id, resolved);
+ sourceByURL.set(resolved.resolvedURL, resolved);
+ }
+ const types = new Map(index.types.map(type => [type.id, type]));
+ const typesByName = new Map();
+ for (const type of index.types) {
+ if (type.name && !typesByName.has(type.name)) typesByName.set(type.name, type);
+ }
+ const runtimeLayouts = schema.runtime_layouts || {};
+ const layout = runtimeLayouts[String(record.runtime_layout_version)] || null;
+ this.modules.set(rawModuleId, {
+ rawModuleId, rawModule, symbolsURL, record, index, sources, sourceByURL, types, typesByName, layout,
+ });
+ const ready = await this.fetcher(new URL('/__llgo/plugin-ready', rawModule.url).href, {
+ cache: 'no-store',
+ });
+ if (!ready.ok) throw new Error(`report LLGo browser debugger readiness: HTTP ${ready.status}`);
+ return [...sources.values()].filter(source => source.local).map(source => source.resolvedURL);
+ }
+
+ async loadSchema(moduleURL) {
+ if (!this.schema) {
+ const schemaURL = new URL('/__llgo/debug-schema.json', moduleURL).href;
+ const response = await this.fetcher(schemaURL, {cache: 'no-store'});
+ if (!response.ok) throw new Error(`load LLGo debugger schema: HTTP ${response.status}`);
+ this.schema = await response.json();
+ }
+ return this.schema;
+ }
+
+ async removeRawModule(rawModuleId) {
+ this.modules.delete(rawModuleId);
+ for (const [id, object] of this.objects) {
+ if (object.rawModuleId === rawModuleId) this.objects.delete(id);
+ }
+ }
+
+ module(rawModuleId) {
+ const module = this.modules.get(rawModuleId);
+ if (!module) throw new Error(`unknown LLGo raw module ${rawModuleId}`);
+ return module;
+ }
+
+ async sourceLocationToRawLocation(location) {
+ const module = this.module(location.rawModuleId);
+ const source = module.sourceByURL.get(location.sourceFileURL);
+ if (!source) return [];
+ return module.index.lines
+ .filter(line => line.source === source.id && line.line === location.lineNumber)
+ .map(line => ({rawModuleId: location.rawModuleId, startOffset: line.start, endOffset: line.end}));
+ }
+
+ async rawLocationToSourceLocation(location) {
+ const module = this.module(location.rawModuleId);
+ return module.index.lines.filter(line => location.codeOffset >= line.start && location.codeOffset < line.end)
+ .map(line => {
+ const source = module.sources.get(line.source);
+ return source ? {
+ rawModuleId: location.rawModuleId,
+ sourceFileURL: source.resolvedURL,
+ lineNumber: line.line,
+ columnNumber: line.column,
+ } : null;
+ }).filter(Boolean);
+ }
+
+ async getMappedLines(rawModuleId, sourceFileURL) {
+ const module = this.module(rawModuleId);
+ const source = module.sourceByURL.get(sourceFileURL);
+ if (!source) return undefined;
+ return [...new Set(module.index.lines.filter(line => line.source === source.id).map(line => line.line))]
+ .sort((left, right) => left - right);
+ }
+
+ async getScopeInfo(type) {
+ const names = {GLOBAL: 'Global', LOCAL: 'Local', PARAMETER: 'Parameter'};
+ if (!names[type]) throw new Error(`unknown LLGo scope ${type}`);
+ return {type, typeName: names[type], icon: 'data:null'};
+ }
+
+ activeVariables(module, codeOffset) {
+ const active = module.index.variables.filter(variable => inRanges(codeOffset, variable.ranges) &&
+ (variable.constant || variable.locations.some(location => inRanges(codeOffset, [location]))));
+ active.sort((left, right) => right.depth - left.depth);
+ const names = new Set();
+ return active.filter(variable => {
+ if (names.has(variable.name)) return false;
+ names.add(variable.name);
+ return true;
+ });
+ }
+
+ async listVariablesInScope(location) {
+ const module = this.module(location.rawModuleId);
+ const result = this.activeVariables(module, location.codeOffset).map(variable => ({
+ scope: variable.scope,
+ name: variable.name,
+ type: module.types.get(variable.type)?.name || '',
+ }));
+ if (this.goroutineSeed(module, location.codeOffset)) {
+ result.push({scope: 'GLOBAL', name: '$goroutines', type: '[]goroutine'});
+ }
+ return result;
+ }
+
+ async getFunctionInfo(location) {
+ const module = this.module(location.rawModuleId);
+ const matches = module.index.functions.filter(fn => inRanges(location.codeOffset, fn.ranges));
+ matches.sort((left, right) => rangeWidth(left.ranges) - rangeWidth(right.ranges));
+ return {frames: matches.map(fn => ({name: fn.name})), missingSymbolFiles: []};
+ }
+
+ async getInlinedFunctionRanges() { return []; }
+ async getInlinedCalleesRanges() { return []; }
+
+ async evaluate(expression, context, stopId) {
+ const trimmed = expression.trim();
+ if (!trimmed) return null;
+ const module = this.module(context.rawModuleId);
+ if (trimmed === '$goroutines') {
+ return this.goroutinesRemote(module, context.codeOffset, stopId);
+ }
+ const variables = this.activeVariables(module, context.codeOffset);
+ const variable = variables.filter(item => trimmed === item.name || trimmed.startsWith(item.name + '.'))
+ .sort((left, right) => right.name.length - left.name.length)[0];
+ if (!variable) return null;
+ const suffix = trimmed.slice(variable.name.length);
+ const path = suffix ? suffix.slice(1).split('.').filter(Boolean) : [];
+ let type = module.types.get(variable.type);
+ if (!type) return null;
+ let located = await this.variableLocation(variable, context.codeOffset, module, stopId);
+ if (!located) return null;
+ for (const fieldName of path) {
+ const resolved = resolveType(module, type);
+ if (located.kind !== 'address' || !resolved.fields) return null;
+ const field = resolved.fields.find(item => item.name === fieldName);
+ if (!field) return null;
+ located = {kind: 'address', value: located.value + BigInt(field.offset)};
+ type = module.types.get(field.type);
+ if (!type) return null;
+ }
+ return this.remoteObject(module, type, located, stopId);
+ }
+
+ async variableLocation(variable, codeOffset, module, stopId) {
+ if (variable.constant) return {kind: 'value', value: constantToValue(variable.constant)};
+ const location = variable.locations.find(item => inRanges(codeOffset, [item]));
+ if (!location) return null;
+ return this.evaluateDWARF(bytesFromHex(location.expression), module, stopId);
+ }
+
+ goroutineSeed(module, codeOffset) {
+ const name = module.layout?.goroutine?.head_symbol;
+ if (!name) return null;
+ return this.activeVariables(module, codeOffset).find(variable => variable.name === name) || null;
+ }
+
+ async evaluateDWARF(bytes, module, stopId) {
+ const cursor = {offset: 0};
+ const stack = [];
+ let stackValue = false;
+ const pop = () => {
+ if (!stack.length) throw new Error('invalid empty DWARF expression stack');
+ return stack.pop();
+ };
+ while (cursor.offset < bytes.length) {
+ const op = bytes[cursor.offset++];
+ if (op >= 0x30 && op <= 0x4f) {
+ stack.push(BigInt(op - 0x30));
+ continue;
+ }
+ switch (op) {
+ case 0x03: stack.push(readFixed(bytes, cursor, module.record.pointer_size, false)); break; // DW_OP_addr
+ case 0x06: {
+ const address = pop();
+ stack.push(await this.readUnsigned(address, module.record.pointer_size, stopId));
+ break;
+ }
+ case 0x08: stack.push(readFixed(bytes, cursor, 1, false)); break;
+ case 0x09: stack.push(readFixed(bytes, cursor, 1, true)); break;
+ case 0x0a: stack.push(readFixed(bytes, cursor, 2, false)); break;
+ case 0x0b: stack.push(readFixed(bytes, cursor, 2, true)); break;
+ case 0x0c: stack.push(readFixed(bytes, cursor, 4, false)); break;
+ case 0x0d: stack.push(readFixed(bytes, cursor, 4, true)); break;
+ case 0x0e: stack.push(readFixed(bytes, cursor, 8, false)); break;
+ case 0x0f: stack.push(readFixed(bytes, cursor, 8, true)); break;
+ case 0x10: stack.push(readULEB(bytes, cursor)); break;
+ case 0x11: stack.push(readSLEB(bytes, cursor)); break;
+ case 0x12: stack.push(stack[stack.length - 1]); break;
+ case 0x13: pop(); break;
+ case 0x14: stack.push(stack[stack.length - 2]); break;
+ case 0x1a: { const right = pop(); stack.push(pop() & right); break; }
+ case 0x1b: { const right = pop(); stack.push(pop() / right); break; }
+ case 0x1c: { const right = pop(); stack.push(pop() - right); break; }
+ case 0x1d: { const right = pop(); stack.push(pop() % right); break; }
+ case 0x1e: { const right = pop(); stack.push(pop() * right); break; }
+ case 0x21: { const right = pop(); stack.push(pop() | right); break; }
+ case 0x22: { const right = pop(); stack.push(pop() + right); break; }
+ case 0x23: stack.push(pop() + readULEB(bytes, cursor)); break;
+ case 0x24: { const right = pop(); stack.push(pop() << right); break; }
+ case 0x25: { const right = pop(); stack.push(pop() >> right); break; }
+ case 0x27: { const right = pop(); stack.push(pop() ^ right); break; }
+ case 0x9f: stackValue = true; break;
+ case 0xed: {
+ if (cursor.offset >= bytes.length) throw new Error('truncated DW_OP_WASM_location');
+ const kind = bytes[cursor.offset++];
+ const index = kind === 3 ? Number(readFixed(bytes, cursor, 4, false)) : Number(readULEB(bytes, cursor));
+ let wasm;
+ if (kind === 0) wasm = await this.languageServices.getWasmLocal(index, stopId);
+ else if (kind === 1 || kind === 3) wasm = await this.languageServices.getWasmGlobal(index, stopId);
+ else if (kind === 2) wasm = await this.languageServices.getWasmOp(index, stopId);
+ else throw new Error(`unsupported DW_OP_WASM_location kind ${kind}`);
+ if (wasm.type === 'reftype') return null;
+ stack.push(typeof wasm.value === 'bigint' ? wasm.value : BigInt(Math.trunc(wasm.value)));
+ break;
+ }
+ default:
+ throw new Error(`unsupported DWARF expression opcode 0x${op.toString(16)}`);
+ }
+ }
+ if (stack.length !== 1) throw new Error('invalid DWARF expression result');
+ return {kind: stackValue ? 'value' : 'address', value: stack[0]};
+ }
+
+ async remoteObject(module, originalType, located, stopId) {
+ const type = resolveType(module, originalType);
+ if (!type) return null;
+ if (located.kind === 'value' && isScalar(type)) return scalarRemote(type, located.value);
+ const address = located.value;
+ if (isScalar(type)) {
+ const value = await this.readScalar(type, address, stopId);
+ return scalarRemote(type, value);
+ }
+ const stringSpec = module.layout?.string;
+ if (stringSpec && runtimeTypeMatches(originalType, type, stringSpec.type_name)) {
+ return this.stringRemote(module, type, address, stopId, stringSpec);
+ }
+ const sliceSpec = module.layout?.slice;
+ if (sliceSpec && runtimeTypeMatches(originalType, type, sliceSpec.type_pattern, true)) {
+ return this.sliceRemote(module, type, address, stopId, sliceSpec);
+ }
+ const interfaceSpec = module.layout?.interface;
+ if (interfaceSpec && runtimeTypeMatches(originalType, type, interfaceSpec.type_pattern, true)) {
+ return this.interfaceRemote(module, originalType, type, address, stopId, interfaceSpec);
+ }
+ const functionSpec = module.layout?.function;
+ if (functionSpec && runtimeTypeMatches(originalType, type, functionSpec.type_pattern, true)) {
+ return this.functionRemote(module, originalType, type, address, stopId, functionSpec);
+ }
+ const mapSpec = module.layout?.map;
+ if (mapSpec && (runtimeTypeMatches(originalType, type, mapSpec.type_pattern, true) ||
+ resolvePointee(module, type)?.name?.startsWith('hash<'))) {
+ return this.mapRemote(module, originalType, type, located, stopId, mapSpec);
+ }
+ const channelSpec = module.layout?.channel;
+ if (channelSpec && (runtimeTypeMatches(originalType, type, channelSpec.type_pattern, true) ||
+ resolvePointee(module, type)?.name?.startsWith('hchan<'))) {
+ return this.channelRemote(module, originalType, type, located, stopId, channelSpec);
+ }
+ if (type.kind === 'pointer') {
+ const pointer = located.kind === 'value' ? address : await this.readUnsigned(address, type.size, stopId);
+ const object = this.storeObject(module, type, pointer, stopId, 'pointer');
+ return {
+ type: 'object', className: originalType.name || type.name,
+ description: pointer === 0n ? 'nil' : `0x${pointer.toString(16)}`,
+ objectId: object, hasChildren: pointer !== 0n,
+ linearMemoryAddress: numberAddress(pointer), linearMemorySize: 0,
+ };
+ }
+ const objectId = this.storeObject(module, type, address, stopId, 'aggregate');
+ return {
+ type: type.kind === 'array' ? 'array' : 'object', className: originalType.name || type.name,
+ description: originalType.name || type.name, objectId, hasChildren: true,
+ linearMemoryAddress: numberAddress(address), linearMemorySize: Math.max(0, type.size),
+ };
+ }
+
+ async stringRemote(module, type, address, stopId, spec) {
+ const dataField = type.fields.find(field => field.name === spec.data);
+ const lengthField = type.fields.find(field => field.name === spec.length);
+ if (!dataField || !lengthField) return null;
+ const pointer = await this.readUnsigned(address + BigInt(dataField.offset), module.record.pointer_size, stopId);
+ const length = await this.readUnsigned(address + BigInt(lengthField.offset), module.record.pointer_size, stopId);
+ const size = Number(length > 4096n ? 4096n : length);
+ const raw = size ? await this.languageServices.getWasmLinearMemory(numberAddress(pointer), size, stopId) : new ArrayBuffer(0);
+ const text = new TextDecoder().decode(raw);
+ const truncated = length > 4096n ? '…' : '';
+ return {
+ type: 'string', className: type.name, value: text,
+ description: JSON.stringify(text + truncated), hasChildren: false,
+ linearMemoryAddress: numberAddress(pointer), linearMemorySize: Number(length),
+ };
+ }
+
+ async sliceRemote(module, type, address, stopId, spec) {
+ const dataField = type.fields.find(field => field.name === spec.data);
+ const lengthField = type.fields.find(field => field.name === spec.length);
+ const capacityField = type.fields.find(field => field.name === spec.capacity);
+ if (!dataField || !lengthField || !capacityField) return null;
+ const pointer = await this.readUnsigned(address + BigInt(dataField.offset), module.record.pointer_size, stopId);
+ const length = await this.readUnsigned(address + BigInt(lengthField.offset), module.record.pointer_size, stopId);
+ const capacity = await this.readUnsigned(address + BigInt(capacityField.offset), module.record.pointer_size, stopId);
+ const objectId = this.storeObject(module, type, address, stopId, 'slice', {pointer, length});
+ return {
+ type: 'array', className: type.name, description: `${type.name} len=${length} cap=${capacity}`,
+ objectId, hasChildren: length !== 0n,
+ linearMemoryAddress: numberAddress(pointer), linearMemorySize: 0,
+ };
+ }
+
+ async interfaceRemote(module, originalType, type, address, stopId, spec) {
+ const typeField = fieldByName(type, spec.type);
+ const dataField = fieldByName(type, spec.data);
+ if (!typeField || !dataField) return null;
+ let typePointer = await this.readUnsigned(
+ address + BigInt(typeField.offset), module.record.pointer_size, stopId);
+ const dataPointer = await this.readUnsigned(
+ address + BigInt(dataField.offset), module.record.pointer_size, stopId);
+ if (typePointer === 0n) {
+ return {type: 'null', value: null, description: 'nil', hasChildren: false};
+ }
+ const displayType = originalType.name || type.name;
+ if (displayType !== spec.empty_type) {
+ const itabType = lookupType(module, spec.itab_type);
+ const concreteField = fieldByName(resolveType(module, itabType), spec.itab_concrete_type);
+ if (!concreteField) return null;
+ typePointer = await this.readUnsigned(
+ typePointer + BigInt(concreteField.offset), module.record.pointer_size, stopId);
+ if (typePointer === 0n) return null;
+ }
+ const dynamicName = await this.runtimeTypeName(module, typePointer, stopId);
+ const dynamicType = dynamicName ? lookupType(module, dynamicName) : null;
+ const objectId = this.storeObject(module, type, address, stopId, 'interface', {
+ dataPointer, dynamicType,
+ });
+ return {
+ type: 'object', className: displayType,
+ description: `type=${dynamicName || `0x${typePointer.toString(16)}`}`,
+ objectId, hasChildren: dataPointer !== 0n,
+ linearMemoryAddress: numberAddress(address), linearMemorySize: Math.max(0, type.size),
+ };
+ }
+
+ async runtimeTypeName(module, address, stopId) {
+ if (!address) return null;
+ const spec = module.layout?.runtime_type;
+ if (!spec) return null;
+ const runtimeType = resolveType(module, lookupType(module, spec.type_name));
+ const stringField = fieldByName(runtimeType, spec.string);
+ if (!runtimeType || !stringField) return null;
+ const stringType = resolveType(module, module.types.get(stringField.type));
+ const name = await this.readGoString(module, stringType, address + BigInt(stringField.offset), stopId, 4096);
+ if (name === null) return null;
+ const flagField = fieldByName(runtimeType, spec.tflag);
+ if (!flagField) return name;
+ const flagType = resolveType(module, module.types.get(flagField.type));
+ const flags = await this.readUnsigned(
+ address + BigInt(flagField.offset), Math.max(1, flagType?.size || 1), stopId);
+ return (flags & BigInt(spec.extra_star_flag)) !== 0n ? `*${name}` : name;
+ }
+
+ async readGoString(module, type, address, stopId, limit) {
+ const spec = module.layout?.string;
+ const dataField = fieldByName(type, spec?.data);
+ const lengthField = fieldByName(type, spec?.length);
+ if (!spec || !dataField || !lengthField) return null;
+ const pointer = await this.readUnsigned(
+ address + BigInt(dataField.offset), module.record.pointer_size, stopId);
+ const length = await this.readUnsigned(
+ address + BigInt(lengthField.offset), module.record.pointer_size, stopId);
+ if (length > BigInt(limit) || (length !== 0n && pointer === 0n)) return null;
+ const raw = length === 0n ? new ArrayBuffer(0) : await this.languageServices.getWasmLinearMemory(
+ numberAddress(pointer), Number(length), stopId);
+ return new TextDecoder().decode(raw);
+ }
+
+ async functionRemote(module, originalType, type, address, stopId, spec) {
+ const codeField = fieldByName(type, spec.code);
+ const dataField = fieldByName(type, spec.data);
+ if (!codeField || !dataField) return null;
+ const code = await this.readUnsigned(
+ address + BigInt(codeField.offset), module.record.pointer_size, stopId);
+ const data = await this.readUnsigned(
+ address + BigInt(dataField.offset), module.record.pointer_size, stopId);
+ if (code === 0n) return {type: 'null', value: null, description: 'nil', hasChildren: false};
+ let name = functionNameAt(module, code);
+ if (!name) name = `func[${code}]`;
+ if (spec.bound_symbol_suffix && name.endsWith(spec.bound_symbol_suffix)) name += ' (bound method)';
+ else if (spec.closure_symbol_pattern && new RegExp(spec.closure_symbol_pattern).test(name)) name += ' (closure)';
+ else if (data !== 0n && name.startsWith('func[')) name += ` data=0x${data.toString(16)}`;
+ const objectId = this.storeObject(module, type, address, stopId, 'aggregate');
+ return {
+ type: 'object', className: originalType.name || type.name, description: name,
+ objectId, hasChildren: true,
+ linearMemoryAddress: numberAddress(address), linearMemorySize: Math.max(0, type.size),
+ };
+ }
+
+ async mapRemote(module, originalType, type, located, stopId, spec) {
+ const pointer = await this.runtimePointerValue(module, type, located, stopId);
+ if (pointer === null) return null;
+ if (pointer === 0n) return {type: 'null', value: null, description: 'nil', hasChildren: false};
+ const hashType = resolvePointee(module, type);
+ const count = await this.readNamedUnsigned(module, hashType, pointer, spec.count, stopId);
+ if (count === null) return null;
+ const objectId = this.storeObject(module, type, pointer, stopId, 'map', {
+ hashType, length: count, spec,
+ });
+ return {
+ type: 'object', className: originalType.name || type.name, description: `len=${count}`,
+ objectId, hasChildren: count !== 0n,
+ linearMemoryAddress: numberAddress(pointer), linearMemorySize: Math.max(0, hashType?.size || 0),
+ };
+ }
+
+ async channelRemote(module, originalType, type, located, stopId, spec) {
+ const pointer = await this.runtimePointerValue(module, type, located, stopId);
+ if (pointer === null) return null;
+ if (pointer === 0n) return {type: 'null', value: null, description: 'nil', hasChildren: false};
+ const channelType = resolvePointee(module, type);
+ const length = await this.readNamedUnsigned(module, channelType, pointer, spec.count, stopId);
+ const capacity = await this.readNamedUnsigned(module, channelType, pointer, spec.capacity, stopId);
+ const buffer = await this.readNamedUnsigned(module, channelType, pointer, spec.buffer, stopId);
+ const receiveIndex = await this.readNamedUnsigned(module, channelType, pointer, spec.receive_index, stopId);
+ const closed = await this.readNamedUnsigned(module, channelType, pointer, spec.closed, stopId);
+ if ([length, capacity, buffer, receiveIndex, closed].some(value => value === null)) return null;
+ const elementType = channelElementType(module, channelType, spec);
+ const objectId = this.storeObject(module, type, pointer, stopId, 'channel', {
+ length, capacity, buffer, receiveIndex, elementType,
+ });
+ return {
+ type: 'array', className: originalType.name || type.name,
+ description: `len=${length} cap=${capacity}${closed !== 0n ? ' closed' : ''}`,
+ objectId, hasChildren: length !== 0n && buffer !== 0n && !!elementType,
+ linearMemoryAddress: numberAddress(buffer), linearMemorySize: 0,
+ };
+ }
+
+ async runtimePointerValue(module, type, located, stopId) {
+ if (located.kind === 'value') return located.value;
+ return type.kind === 'pointer' ?
+ this.readUnsigned(located.value, Math.max(1, type.size || module.record.pointer_size), stopId) : located.value;
+ }
+
+ async readNamedUnsigned(module, type, address, name, stopId) {
+ const field = fieldByName(type, name);
+ if (!field) return null;
+ const fieldType = resolveType(module, module.types.get(field.type));
+ const size = fieldType?.kind === 'pointer' ? module.record.pointer_size : fieldType?.size;
+ if (!size || size < 1 || size > 8) return null;
+ return this.readUnsigned(address + BigInt(field.offset), size, stopId);
+ }
+
+ async goroutinesRemote(module, codeOffset, stopId) {
+ const spec = module.layout?.goroutine;
+ const seed = this.goroutineSeed(module, codeOffset);
+ if (!spec || !seed) return null;
+ const located = await this.variableLocation(seed, codeOffset, module, stopId);
+ const seedType = resolveType(module, module.types.get(seed.type));
+ if (!located || !seedType) return null;
+ let current = located.value;
+ if (located.kind === 'address' && seedType.kind === 'pointer') {
+ current = await this.readUnsigned(current, module.record.pointer_size, stopId);
+ }
+ const goroutineType = resolveType(module, lookupType(module, spec.goroutine_type)) ||
+ resolvePointee(module, seedType);
+ if (!goroutineType) return null;
+ const addresses = [];
+ const seen = new Set();
+ while (current !== 0n && addresses.length < MAX_CHILDREN && !seen.has(current.toString())) {
+ addresses.push(current);
+ seen.add(current.toString());
+ current = await this.readNamedUnsigned(module, goroutineType, current, spec.next, stopId) || 0n;
+ }
+ const objectId = this.storeObject(module, goroutineType, 0n, stopId, 'goroutines', {
+ goroutineType, addresses,
+ });
+ return {
+ type: 'array', className: '[]goroutine', description: `goroutines len=${addresses.length}`,
+ objectId, hasChildren: addresses.length !== 0,
+ };
+ }
+
+ async goroutineRemote(module, type, address, stopId) {
+ const spec = module.layout.goroutine;
+ const id = await this.readNamedUnsigned(module, type, address, spec.id, stopId);
+ const parent = await this.readNamedUnsigned(module, type, address, spec.parent_id, stopId);
+ const status = await this.readNamedUnsigned(module, type, address, spec.status, stopId);
+ const statusName = status === null ? 'unknown' :
+ (spec.status_names?.[String(status)] || `status=${status}`);
+ const objectId = this.storeObject(module, type, address, stopId, 'aggregate');
+ return {
+ type: 'object', className: 'goroutine',
+ description: `goroutine ${id ?? '?'} [${statusName}] parent=${parent ?? '?'}`,
+ objectId, hasChildren: true,
+ linearMemoryAddress: numberAddress(address), linearMemorySize: Math.max(0, type.size),
+ };
+ }
+
+ storeObject(module, type, address, stopId, kind, extra = {}) {
+ const id = `llgo:${this.nextObject++}`;
+ this.objects.set(id, {rawModuleId: module.rawModuleId, type, address, stopId, kind, ...extra});
+ return id;
+ }
+
+ async getProperties(objectId) {
+ const object = this.objects.get(objectId);
+ if (!object) return [];
+ const module = this.module(object.rawModuleId);
+ const type = resolveType(module, object.type);
+ if (object.kind === 'pointer') {
+ if (object.address === 0n) return [];
+ const elem = module.types.get(type.elem);
+ return [{name: '*', value: await this.remoteObject(module, elem, {kind: 'address', value: object.address}, object.stopId)}];
+ }
+ if (object.kind === 'slice') {
+ const dataField = type.fields.find(field => field.name === module.layout.slice.data);
+ const pointerType = dataField ? resolveType(module, module.types.get(dataField.type)) : null;
+ const elem = pointerType?.elem ? module.types.get(pointerType.elem) : null;
+ if (!elem || elem.size <= 0) return [];
+ const count = Number(object.length > BigInt(MAX_CHILDREN) ? BigInt(MAX_CHILDREN) : object.length);
+ const result = [];
+ for (let index = 0; index < count; ++index) {
+ result.push({
+ name: String(index),
+ value: await this.remoteObject(module, elem, {
+ kind: 'address', value: object.pointer + BigInt(index) * BigInt(elem.size),
+ }, object.stopId),
+ });
+ }
+ return result;
+ }
+ if (object.kind === 'interface') {
+ if (!object.dynamicType || object.dataPointer === 0n) return [];
+ return [{name: 'value', value: await this.remoteObject(
+ module, object.dynamicType, {kind: 'address', value: object.dataPointer}, object.stopId)}];
+ }
+ if (object.kind === 'map') return this.mapProperties(module, object);
+ if (object.kind === 'channel') return this.channelProperties(module, object);
+ if (object.kind === 'goroutines') {
+ const result = [];
+ for (let index = 0; index < object.addresses.length; ++index) {
+ result.push({name: String(index), value: await this.goroutineRemote(
+ module, object.goroutineType, object.addresses[index], object.stopId)});
+ }
+ return result;
+ }
+ if (type.kind === 'array') {
+ const elem = module.types.get(type.elem);
+ if (!elem || elem.size <= 0) return [];
+ const count = Math.min(type.count, MAX_CHILDREN);
+ const result = [];
+ for (let index = 0; index < count; ++index) {
+ result.push({name: String(index), value: await this.remoteObject(module, elem, {
+ kind: 'address', value: object.address + BigInt(index) * BigInt(elem.size),
+ }, object.stopId)});
+ }
+ return result;
+ }
+ const result = [];
+ for (const field of type.fields || []) {
+ const fieldType = module.types.get(field.type);
+ if (!fieldType) continue;
+ result.push({name: field.name, value: await this.remoteObject(module, fieldType, {
+ kind: 'address', value: object.address + BigInt(field.offset),
+ }, object.stopId)});
+ }
+ return result;
+ }
+
+ async channelProperties(module, object) {
+ const {length, capacity, buffer, receiveIndex, elementType} = object;
+ if (!elementType || elementType.size <= 0 || capacity === 0n || buffer === 0n) return [];
+ const count = Number(length > BigInt(MAX_CHILDREN) ? BigInt(MAX_CHILDREN) : length);
+ const result = [];
+ for (let index = 0; index < count; ++index) {
+ const slot = (receiveIndex + BigInt(index)) % capacity;
+ result.push({name: String(index), value: await this.remoteObject(module, elementType, {
+ kind: 'address', value: buffer + slot * BigInt(elementType.size),
+ }, object.stopId)});
+ }
+ return result;
+ }
+
+ async mapProperties(module, object) {
+ const {hashType, spec, length} = object;
+ if (!hashType || length === 0n) return [];
+ const flags = await this.readNamedUnsigned(module, hashType, object.address, spec.flags, object.stopId);
+ const bits = await this.readNamedUnsigned(module, hashType, object.address, spec.bucket_bits, object.stopId);
+ const buckets = await this.readNamedUnsigned(module, hashType, object.address, spec.buckets, object.stopId);
+ const oldBuckets = await this.readNamedUnsigned(module, hashType, object.address, spec.old_buckets, object.stopId);
+ if ([flags, bits, buckets, oldBuckets].some(value => value === null) || bits >= 63n || buckets === 0n) return [];
+ const bucketsField = fieldByName(hashType, spec.buckets);
+ const bucketsPointer = resolveType(module, module.types.get(bucketsField?.type));
+ const bucketType = resolvePointee(module, bucketsPointer);
+ if (!bucketType || bucketType.size <= 0) return [];
+ const logical = 1n << bits;
+ const scan = Number(logical > BigInt(MAX_CONTAINER_SCAN_BUCKETS) ?
+ BigInt(MAX_CONTAINER_SCAN_BUCKETS) : logical);
+ const oldCount = (flags & BigInt(spec.same_size_grow_flag)) !== 0n ? logical : logical >> 1n;
+ const result = [];
+ for (let bucketIndex = 0; bucketIndex < scan && result.length < MAX_CHILDREN * 2; ++bucketIndex) {
+ let bucketAddress = buckets + BigInt(bucketIndex) * BigInt(bucketType.size);
+ if (oldBuckets !== 0n && oldCount !== 0n) {
+ const oldIndex = BigInt(bucketIndex) & (oldCount - 1n);
+ const oldAddress = oldBuckets + oldIndex * BigInt(bucketType.size);
+ if (!(await this.bucketEvacuated(module, bucketType, oldAddress, object.stopId, spec))) {
+ if (BigInt(bucketIndex) >= oldCount) continue;
+ bucketAddress = oldAddress;
+ }
+ }
+ const visited = new Set();
+ while (bucketAddress !== 0n && !visited.has(bucketAddress.toString()) &&
+ result.length < MAX_CHILDREN * 2) {
+ visited.add(bucketAddress.toString());
+ const entries = await this.bucketEntries(module, bucketType, bucketAddress, object.stopId, spec);
+ if (!entries) return result;
+ for (const entry of entries) {
+ const index = result.length / 2;
+ result.push({name: `key[${index}]`, value: await this.remoteObject(
+ module, entry.keyType, {kind: 'address', value: entry.key}, object.stopId)});
+ result.push({name: `value[${index}]`, value: await this.remoteObject(
+ module, entry.valueType, {kind: 'address', value: entry.value}, object.stopId)});
+ if (result.length >= MAX_CHILDREN * 2 || BigInt(result.length / 2) >= length) return result;
+ }
+ bucketAddress = await this.readNamedUnsigned(
+ module, bucketType, bucketAddress, spec.bucket_overflow, object.stopId) || 0n;
+ }
+ }
+ return result;
+ }
+
+ async bucketEvacuated(module, bucketType, address, stopId, spec) {
+ const field = fieldByName(bucketType, spec.bucket_tophash);
+ const array = resolveType(module, module.types.get(field?.type));
+ const element = array?.kind === 'array' ? resolveType(module, module.types.get(array.elem)) : null;
+ if (!field || !element || element.size <= 0) return false;
+ const value = await this.readUnsigned(address + BigInt(field.offset), element.size, stopId);
+ return value >= BigInt(spec.evacuated_tophash_min) && value <= BigInt(spec.evacuated_tophash_max);
+ }
+
+ async bucketEntries(module, bucketType, address, stopId, spec) {
+ const topField = fieldByName(bucketType, spec.bucket_tophash);
+ const keyField = fieldByName(bucketType, spec.bucket_keys) ||
+ fieldByName(bucketType, spec.bucket_indirect_keys);
+ const valueField = fieldByName(bucketType, spec.bucket_values) ||
+ fieldByName(bucketType, spec.bucket_indirect_values);
+ if (!topField || !keyField || !valueField) return null;
+ const topArray = resolveType(module, module.types.get(topField.type));
+ const keyArray = resolveType(module, module.types.get(keyField.type));
+ const valueArray = resolveType(module, module.types.get(valueField.type));
+ if (topArray?.kind !== 'array' || keyArray?.kind !== 'array' || valueArray?.kind !== 'array') return null;
+ const topType = resolveType(module, module.types.get(topArray.elem));
+ const keyStorageType = resolveType(module, module.types.get(keyArray.elem));
+ const valueStorageType = resolveType(module, module.types.get(valueArray.elem));
+ if (!topType || !keyStorageType || !valueStorageType || topType.size <= 0 ||
+ keyStorageType.size <= 0 || valueStorageType.size <= 0) return null;
+ const indirectKey = keyField.name === spec.bucket_indirect_keys;
+ const indirectValue = valueField.name === spec.bucket_indirect_values;
+ const keyType = indirectKey ? resolvePointee(module, keyStorageType) : keyStorageType;
+ const valueType = indirectValue ? resolvePointee(module, valueStorageType) : valueStorageType;
+ if (!keyType || !valueType) return null;
+ const slots = Math.min(topArray.count, keyArray.count, valueArray.count);
+ const result = [];
+ for (let slot = 0; slot < slots; ++slot) {
+ const top = await this.readUnsigned(
+ address + BigInt(topField.offset + slot * topType.size), topType.size, stopId);
+ if (top < BigInt(spec.occupied_tophash_min)) continue;
+ let key = address + BigInt(keyField.offset + slot * keyStorageType.size);
+ let value = address + BigInt(valueField.offset + slot * valueStorageType.size);
+ if (indirectKey) {
+ key = await this.readUnsigned(key, module.record.pointer_size, stopId);
+ }
+ if (indirectValue) {
+ value = await this.readUnsigned(value, module.record.pointer_size, stopId);
+ }
+ if (key !== 0n && value !== 0n && keyType && valueType) result.push({key, value, keyType, valueType});
+ }
+ return result;
+ }
+
+ async releaseObject(objectId) { this.objects.delete(objectId); }
+
+ async readUnsigned(address, size, stopId) {
+ const raw = await this.languageServices.getWasmLinearMemory(numberAddress(address), size, stopId);
+ const bytes = new Uint8Array(raw);
+ let result = 0n;
+ for (let index = 0; index < bytes.length; ++index) result |= BigInt(bytes[index]) << BigInt(index * 8);
+ return result;
+ }
+
+ async readScalar(type, address, stopId) {
+ const raw = await this.languageServices.getWasmLinearMemory(numberAddress(address), type.size, stopId);
+ const view = new DataView(raw);
+ if (type.kind === 'float') return type.size === 4 ? view.getFloat32(0, true) : view.getFloat64(0, true);
+ let value = 0n;
+ const bytes = new Uint8Array(raw);
+ for (let index = 0; index < bytes.length; ++index) value |= BigInt(bytes[index]) << BigInt(index * 8);
+ return signedValue(type, value);
+ }
+ }
+
+ function runtimeTypeMatches(originalType, resolvedType, pattern, regexp = false) {
+ if (!pattern) return false;
+ const names = new Set([originalType?.name, resolvedType?.name].filter(Boolean));
+ if (!regexp) return names.has(pattern);
+ const expression = new RegExp(pattern);
+ return [...names].some(name => expression.test(name));
+ }
+
+ function lookupType(module, name) {
+ if (!name) return null;
+ return module.typesByName.get(name) || module.typesByName.get(`struct ${name}`) || null;
+ }
+
+ function resolvePointee(module, type) {
+ type = resolveType(module, type);
+ return type?.kind === 'pointer' ? resolveType(module, module.types.get(type.elem)) : null;
+ }
+
+ function fieldByName(type, name) {
+ return name && type?.fields ? type.fields.find(field => field.name === name) || null : null;
+ }
+
+ function channelElementType(module, channelType, spec) {
+ const queueField = fieldByName(channelType, spec.receive_queue);
+ const queueType = resolveType(module, module.types.get(queueField?.type));
+ const firstField = fieldByName(queueType, spec.queue_first);
+ const waiterPointer = resolveType(module, module.types.get(firstField?.type));
+ const waiterType = resolvePointee(module, waiterPointer);
+ const elementField = fieldByName(waiterType, spec.waiter_element);
+ const elementPointer = resolveType(module, module.types.get(elementField?.type));
+ return resolvePointee(module, elementPointer);
+ }
+
+ function functionNameAt(module, address) {
+ if (address < 0n || address > BigInt(Number.MAX_SAFE_INTEGER)) return null;
+ const offset = Number(address);
+ const match = module.index.functions.find(fn => (fn.ranges || []).some(range =>
+ offset >= range.start && offset < range.end));
+ return match?.name || null;
+ }
+
+ function resolveType(module, type) {
+ const seen = new Set();
+ while (type && (type.kind === 'typedef' || type.kind === 'qualified')) {
+ if (seen.has(type.id)) return null;
+ seen.add(type.id);
+ type = module.types.get(type.elem);
+ }
+ return type;
+ }
+
+ function isScalar(type) {
+ return ['bool', 'integer', 'float', 'enum'].includes(type.kind);
+ }
+
+ function signedValue(type, value) {
+ if (!type.signed || type.size <= 0) return value;
+ const bits = BigInt(type.size * 8);
+ const sign = 1n << (bits - 1n);
+ return (value & sign) !== 0n ? value - (1n << bits) : value;
+ }
+
+ function scalarRemote(type, raw) {
+ if (type.kind === 'bool') {
+ const value = raw !== 0n && raw !== 0;
+ return {type: 'boolean', value, description: String(value), hasChildren: false};
+ }
+ if (typeof raw === 'number') {
+ return {type: 'number', value: raw, description: String(raw), hasChildren: false};
+ }
+ const value = signedValue(type, raw);
+ if (value <= BigInt(Number.MAX_SAFE_INTEGER) && value >= BigInt(Number.MIN_SAFE_INTEGER)) {
+ return {type: 'number', value: Number(value), description: value.toString(), hasChildren: false};
+ }
+ return {type: 'bigint', value, description: `${value}n`, hasChildren: false};
+ }
+
+ function constantToValue(constant) {
+ if (constant.kind === 'signed' || constant.kind === 'unsigned') return BigInt(constant.value || '0');
+ return 0n;
+ }
+
+ function readFixed(bytes, cursor, size, signed) {
+ if (cursor.offset + size > bytes.length) throw new Error('truncated fixed-width DWARF operand');
+ let result = 0n;
+ for (let index = 0; index < size; ++index) result |= BigInt(bytes[cursor.offset++]) << BigInt(index * 8);
+ if (signed) {
+ const bits = BigInt(size * 8);
+ const sign = 1n << (bits - 1n);
+ if ((result & sign) !== 0n) result -= 1n << bits;
+ }
+ return result;
+ }
+
+ function numberAddress(value) {
+ if (value < 0n || value > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error(`invalid linear-memory address ${value}`);
+ return Number(value);
+ }
+
+ function rangeWidth(ranges) {
+ return (ranges || []).reduce((total, range) => total + Math.max(0, range.end - range.start), 0);
+ }
+
+ globalThis.LLGoLanguageExtension = {
+ LLGoLanguageExtensionPlugin,
+ customSections,
+ debuggerRecord,
+ buildID,
+ bytesFromHex,
+ };
+
+ if (globalThis.chrome?.devtools?.languageServices) {
+ const languageServices = globalThis.chrome.devtools.languageServices;
+ const plugin = new LLGoLanguageExtensionPlugin(languageServices);
+ globalThis.__llgoLanguageExtensionPlugin = plugin;
+ globalThis.__llgoLanguageExtensionRegistration = languageServices.registerLanguageExtensionPlugin(
+ plugin, 'LLGo WebAssembly Debugger',
+ {language: 'WebAssembly', symbol_types: ['EmbeddedDWARF', 'ExternalDWARF']})
+ .then(() => new Promise(resolve => {
+ // A Wasm module that is instantiated before DevTools has installed the
+ // language plugin may not be offered to the plugin. Tell the inspected
+ // LLGo launcher that registration is complete before it instantiates.
+ chrome.devtools.inspectedWindow.eval(
+ `globalThis.__llgoLanguageExtensionReady = true;
+ globalThis.dispatchEvent(new Event('__llgoLanguageExtensionReady'));`,
+ () => resolve());
+ }))
+ .catch(error => {
+ console.error('LLGo language extension registration failed', error);
+ throw error;
+ });
+ }
+})();
diff --git a/cmd/internal/browser/extension/plugin_test.js b/cmd/internal/browser/extension/plugin_test.js
new file mode 100644
index 0000000000..c095e5fb88
--- /dev/null
+++ b/cmd/internal/browser/extension/plugin_test.js
@@ -0,0 +1,330 @@
+const assert = require('node:assert/strict');
+const test = require('node:test');
+const debuggerSchema = require('../../../../internal/debugabi/schema_v1.json');
+
+require('./plugin.js');
+
+const {LLGoLanguageExtensionPlugin} = globalThis.LLGoLanguageExtension;
+
+function uleb(value) {
+ const result = [];
+ do {
+ let current = value & 0x7f;
+ value >>>= 7;
+ if (value) current |= 0x80;
+ result.push(current);
+ } while (value);
+ return result;
+}
+
+function custom(name, payload) {
+ const nameBytes = [...new TextEncoder().encode(name)];
+ const contents = [...uleb(nameBytes.length), ...nameBytes, ...payload];
+ return [0, ...uleb(contents.length), ...contents];
+}
+
+function moduleBytes({marker = true, id = [1, 2, 3, 4]} = {}) {
+ const result = [0, 0x61, 0x73, 0x6d, 1, 0, 0, 0];
+ if (marker) {
+ result.push(...custom('llgo.debugger', [
+ 0x4c, 0x4c, 0x47, 0x4f, 0x44, 0x42, 0x47, 0,
+ 1, 1, 1, 1, 2, 4, 1, 0,
+ ]));
+ }
+ result.push(...custom('build_id', [...uleb(id.length), ...id]));
+ return new Uint8Array(result);
+}
+
+function fixtureIndex() {
+ return {
+ contract: 'llgo.browser.debug', version: 1, build_id: '01020304', artifact: 'embedded',
+ record: {
+ record_version: 1, schema_version: 1, runtime_layout_version: 1,
+ llgo_abi_version: 1, cabi_mode: 2, pointer_size: 4, byte_order: 1,
+ },
+ sources: [{id: 'source', path: '/src/main.go', url: '/__llgo/source/source', local: true}],
+ lines: [{source: 'source', line: 7, column: 1, start: 10, end: 20}],
+ functions: [{name: 'main.main', ranges: [{start: 10, end: 20}]}],
+ variables: [
+ {name: 'constant', scope: 'LOCAL', type: 'int32', depth: 2, ranges: [{start: 10, end: 20}],
+ constant: {kind: 'signed', value: '41'}},
+ {name: 'local', scope: 'LOCAL', type: 'int32', depth: 2, ranges: [{start: 10, end: 20}],
+ locations: [{expression: 'ed00009f'}]},
+ ],
+ types: [{id: 'int32', name: 'int32', kind: 'integer', size: 4, signed: true, complete: true}],
+ };
+}
+
+const schema = {
+ schema_version: 1, runtime_layout_version: 1, llgo_abi_version: 1,
+ runtime_layouts: {'1': {
+ string: {type_name: 'string', data: 'data', length: 'len'},
+ slice: {type_pattern: '^\\[\\].+', data: 'data', length: 'len', capacity: 'cap'},
+ }},
+};
+
+function response(value, init) {
+ return new Response(typeof value === 'string' ? value : JSON.stringify(value), init);
+}
+
+test('LLGo language extension maps DWARF index and evaluates constants and Wasm locals', async () => {
+ const calls = [];
+ const services = {
+ getWasmLocal: async (index, stopId) => {
+ calls.push(['local', index, stopId]);
+ return {type: 'i32', value: 42};
+ },
+ getWasmGlobal: async () => { throw new Error('unexpected global'); },
+ getWasmOp: async () => { throw new Error('unexpected operand'); },
+ getWasmLinearMemory: async () => { throw new Error('unexpected memory'); },
+ };
+ const fetcher = async (url, options = {}) => {
+ if (String(url).endsWith('/__llgo/debug-index.json')) return response(fixtureIndex());
+ if (String(url).endsWith('/__llgo/debug-schema.json')) return response(schema);
+ if (String(url).endsWith('/__llgo/plugin-ready')) {
+ calls.push(['ready']);
+ return new Response(null, {status: 204});
+ }
+ throw new Error(`unexpected URL ${url}`);
+ };
+ const plugin = new LLGoLanguageExtensionPlugin(services, fetcher);
+ const rawModule = {url: 'http://127.0.0.1:1234/program.wasm', code: moduleBytes().buffer};
+ const sources = await plugin.addRawModule('module', undefined, rawModule);
+ assert.deepEqual(sources, ['http://127.0.0.1:1234/__llgo/source/source']);
+ assert.deepEqual(await plugin.sourceLocationToRawLocation({
+ rawModuleId: 'module', sourceFileURL: sources[0], lineNumber: 7, columnNumber: 0,
+ }), [{rawModuleId: 'module', startOffset: 10, endOffset: 20}]);
+ assert.deepEqual(await plugin.rawLocationToSourceLocation({
+ rawModuleId: 'module', codeOffset: 12, inlineFrameIndex: 0,
+ }), [{rawModuleId: 'module', sourceFileURL: sources[0], lineNumber: 7, columnNumber: 1}]);
+ assert.deepEqual(await plugin.getMappedLines('module', sources[0]), [7]);
+ assert.deepEqual(await plugin.getFunctionInfo({rawModuleId: 'module', codeOffset: 12, inlineFrameIndex: 0}), {
+ frames: [{name: 'main.main'}], missingSymbolFiles: [],
+ });
+ assert.deepEqual((await plugin.listVariablesInScope({rawModuleId: 'module', codeOffset: 12})).map(v => v.name),
+ ['constant', 'local']);
+ assert.deepEqual(await plugin.evaluate('constant', {rawModuleId: 'module', codeOffset: 12}, 'stop'), {
+ type: 'number', value: 41, description: '41', hasChildren: false,
+ });
+ assert.deepEqual(await plugin.evaluate('local', {rawModuleId: 'module', codeOffset: 12}, 'stop'), {
+ type: 'number', value: 42, description: '42', hasChildren: false,
+ });
+ assert.deepEqual(calls, [['ready'], ['local', 0, 'stop']]);
+});
+
+test('LLGo language extension reports missing symbols and ignores non-LLGo modules', async () => {
+ const services = {};
+ const fetcher = async url => {
+ if (String(url).endsWith('/__llgo/debug-index.json')) {
+ return response({missing_symbol_files: ['http://host/missing.wasm']}, {status: 424});
+ }
+ throw new Error(`unexpected URL ${url}`);
+ };
+ const plugin = new LLGoLanguageExtensionPlugin(services, fetcher);
+ const missing = await plugin.addRawModule('missing', 'http://host/missing.wasm', {
+ url: 'http://host/program.wasm', code: moduleBytes().buffer,
+ });
+ assert.deepEqual(missing, {missingSymbolFiles: ['http://host/missing.wasm']});
+
+ const ignored = await plugin.addRawModule('plain', undefined, {
+ url: 'http://host/plain.wasm', code: moduleBytes({marker: false}).buffer,
+ });
+ assert.deepEqual(ignored, []);
+});
+
+test('LLGo language extension presents strings, slices, aggregates, and children from linear memory', async () => {
+ const index = fixtureIndex();
+ index.types.push(
+ {id: 'uint32', name: 'uint32', kind: 'integer', size: 4, complete: true},
+ {id: 'int32ptr', name: '*int32', kind: 'pointer', size: 4, elem: 'int32', complete: true},
+ {id: 'string', name: 'string', kind: 'struct', size: 8, complete: true,
+ fields: [{name: 'data', type: 'int32ptr', offset: 0}, {name: 'len', type: 'uint32', offset: 4}]},
+ {id: 'slice', name: '[]int32', kind: 'struct', size: 12, complete: true,
+ fields: [{name: 'data', type: 'int32ptr', offset: 0}, {name: 'len', type: 'uint32', offset: 4},
+ {name: 'cap', type: 'uint32', offset: 8}]},
+ {id: 'pair', name: 'main.Pair', kind: 'struct', size: 8, complete: true,
+ fields: [{name: 'Left', type: 'int32', offset: 0}, {name: 'Right', type: 'int32', offset: 4}]},
+ );
+ index.variables.push(
+ {name: 'text', scope: 'LOCAL', type: 'string', depth: 2, ranges: [{start: 10, end: 20}],
+ locations: [{expression: '0310000000'}]},
+ {name: 'values', scope: 'LOCAL', type: 'slice', depth: 2, ranges: [{start: 10, end: 20}],
+ locations: [{expression: '0320000000'}]},
+ {name: 'pair', scope: 'LOCAL', type: 'pair', depth: 2, ranges: [{start: 10, end: 20}],
+ locations: [{expression: '0340000000'}]},
+ );
+ const memory = new Uint8Array(256);
+ const view = new DataView(memory.buffer);
+ view.setUint32(16, 100, true);
+ view.setUint32(20, 3, true);
+ memory.set(new TextEncoder().encode('abc'), 100);
+ view.setUint32(32, 120, true);
+ view.setUint32(36, 2, true);
+ view.setUint32(40, 3, true);
+ view.setInt32(64, 9, true);
+ view.setInt32(68, 10, true);
+ view.setInt32(120, 7, true);
+ view.setInt32(124, 8, true);
+ const services = {
+ getWasmLinearMemory: async (offset, length) => memory.slice(offset, offset + length).buffer,
+ };
+ const fetcher = async url => {
+ if (String(url).endsWith('/__llgo/debug-index.json')) return response(index);
+ if (String(url).endsWith('/__llgo/debug-schema.json')) return response(schema);
+ if (String(url).endsWith('/__llgo/plugin-ready')) return new Response(null, {status: 204});
+ throw new Error(`unexpected URL ${url}`);
+ };
+ const plugin = new LLGoLanguageExtensionPlugin(services, fetcher);
+ const context = {rawModuleId: 'module', codeOffset: 12};
+ await plugin.addRawModule('module', undefined, {
+ url: 'http://127.0.0.1:1234/program.wasm', code: moduleBytes().buffer,
+ });
+ assert.deepEqual(await plugin.evaluate('text', context, 'stop'), {
+ type: 'string', className: 'string', value: 'abc', description: '"abc"', hasChildren: false,
+ linearMemoryAddress: 100, linearMemorySize: 3,
+ });
+ const values = await plugin.evaluate('values', context, 'stop');
+ assert.equal(values.description, '[]int32 len=2 cap=3');
+ assert.deepEqual((await plugin.getProperties(values.objectId)).map(item => [item.name, item.value.value]),
+ [['0', 7], ['1', 8]]);
+ assert.deepEqual(await plugin.evaluate('pair.Right', context, 'stop'), {
+ type: 'number', value: 10, description: '10', hasChildren: false,
+ });
+});
+
+test('LLGo language extension rejects stale browser indexes', async () => {
+ const index = fixtureIndex();
+ index.build_id = 'ffffffff';
+ const fetcher = async url => {
+ if (String(url).endsWith('/__llgo/debug-index.json')) return response(index);
+ throw new Error(`unexpected URL ${url}`);
+ };
+ const plugin = new LLGoLanguageExtensionPlugin({}, fetcher);
+ await assert.rejects(plugin.addRawModule('stale', undefined, {
+ url: 'http://host/program.wasm', code: moduleBytes().buffer,
+ }), /build_id mismatch/);
+});
+
+test('LLGo language extension consumes common interface, function, map, channel, and goroutine layouts', async () => {
+ const index = fixtureIndex();
+ index.types.push(
+ {id: 'u8', name: 'uint8', kind: 'integer', size: 1, complete: true},
+ {id: 'u32', name: 'uint32', kind: 'integer', size: 4, complete: true},
+ {id: 'u64', name: 'uint64', kind: 'integer', size: 8, complete: true},
+ {id: 'ptrInt', name: '*int32', kind: 'pointer', size: 4, elem: 'int32', complete: true},
+ {id: 'string', name: 'string', kind: 'struct', size: 8, complete: true,
+ fields: [{name: 'data', type: 'ptrInt', offset: 0}, {name: 'len', type: 'u32', offset: 4}]},
+ {id: 'runtimeType', name: 'github.com/goplus/llgo/runtime/abi.Type', kind: 'struct', size: 12,
+ complete: true, fields: [{name: 'TFlag', type: 'u8', offset: 0}, {name: 'Str_', type: 'string', offset: 4}]},
+ {id: 'ptrRuntimeType', name: '*github.com/goplus/llgo/runtime/abi.Type', kind: 'pointer', size: 4,
+ elem: 'runtimeType', complete: true},
+ {id: 'itab', name: 'github.com/goplus/llgo/runtime/internal/runtime.itab', kind: 'struct', size: 4,
+ complete: true, fields: [{name: '_type', type: 'ptrRuntimeType', offset: 0}]},
+ {id: 'ptrItab', name: '*github.com/goplus/llgo/runtime/internal/runtime.itab', kind: 'pointer', size: 4,
+ elem: 'itab', complete: true},
+ {id: 'emptyInterface', name: 'interface{}', kind: 'struct', size: 8, complete: true,
+ fields: [{name: 'type', type: 'ptrRuntimeType', offset: 0}, {name: 'data', type: 'ptrInt', offset: 4}]},
+ {id: 'nonemptyInterface', name: 'interface{Value() int32}', kind: 'struct', size: 8, complete: true,
+ fields: [{name: 'type', type: 'ptrItab', offset: 0}, {name: 'data', type: 'ptrInt', offset: 4}]},
+ {id: 'funcval', name: 'struct{$f func(); $data unsafe.Pointer}', kind: 'struct', size: 8,
+ complete: true, fields: [{name: '$f', type: 'u32', offset: 0}, {name: '$data', type: 'ptrInt', offset: 4}]},
+ {id: 'topArray', name: '[8]uint8', kind: 'array', size: 8, elem: 'u8', count: 8, complete: true},
+ {id: 'keyArray', name: '[8]int32', kind: 'array', size: 32, elem: 'int32', count: 8, complete: true},
+ {id: 'valueArray', name: '[8]int32', kind: 'array', size: 32, elem: 'int32', count: 8, complete: true},
+ {id: 'bucket', name: 'bucket', kind: 'struct', size: 76, complete: true,
+ fields: [{name: 'tophash', type: 'topArray', offset: 0}, {name: 'keys', type: 'keyArray', offset: 8},
+ {name: 'values', type: 'valueArray', offset: 40}, {name: 'overflow', type: 'ptrBucket', offset: 72}]},
+ {id: 'ptrBucket', name: '*bucket', kind: 'pointer', size: 4, elem: 'bucket', complete: true},
+ {id: 'hash', name: 'hash', kind: 'struct', size: 16, complete: true,
+ fields: [{name: 'count', type: 'u32', offset: 0}, {name: 'flags', type: 'u8', offset: 4},
+ {name: 'B', type: 'u8', offset: 5}, {name: 'buckets', type: 'ptrBucket', offset: 8},
+ {name: 'oldbuckets', type: 'ptrBucket', offset: 12}]},
+ {id: 'map', name: 'map[int32]int32', kind: 'pointer', size: 4, elem: 'hash', complete: true},
+ {id: 'waiter', name: 'sudog', kind: 'struct', size: 4, complete: true,
+ fields: [{name: 'elem', type: 'ptrInt', offset: 0}]},
+ {id: 'ptrWaiter', name: '*sudog', kind: 'pointer', size: 4, elem: 'waiter', complete: true},
+ {id: 'waitq', name: 'waitq', kind: 'struct', size: 4, complete: true,
+ fields: [{name: 'first', type: 'ptrWaiter', offset: 0}]},
+ {id: 'hchan', name: 'hchan', kind: 'struct', size: 24, complete: true,
+ fields: [{name: 'qcount', type: 'u32', offset: 0}, {name: 'dataqsiz', type: 'u32', offset: 4},
+ {name: 'buf', type: 'ptrInt', offset: 8}, {name: 'closed', type: 'u32', offset: 12},
+ {name: 'recvx', type: 'u32', offset: 16}, {name: 'recvq', type: 'waitq', offset: 20}]},
+ {id: 'chan', name: 'chan int32', kind: 'pointer', size: 4, elem: 'hchan', complete: true},
+ {id: 'g', name: 'github.com/goplus/llgo/runtime/internal/runtime.g', kind: 'struct', size: 24,
+ complete: true, fields: [{name: 'alllink', type: 'ptrG', offset: 0},
+ {name: 'atomicstatus', type: 'u32', offset: 4},
+ {name: 'goid', type: 'u64', offset: 8},
+ {name: 'parentGoid', type: 'u64', offset: 16}]},
+ {id: 'ptrG', name: '*github.com/goplus/llgo/runtime/internal/runtime.g', kind: 'pointer', size: 4,
+ elem: 'g', complete: true},
+ );
+ index.variables.push(
+ {name: 'mapping', scope: 'LOCAL', type: 'map', depth: 2, ranges: [{start: 10, end: 20}],
+ locations: [{expression: '0350000000'}]},
+ {name: 'queue', scope: 'LOCAL', type: 'chan', depth: 2, ranges: [{start: 10, end: 20}],
+ locations: [{expression: '0354000000'}]},
+ {name: 'dynamic', scope: 'LOCAL', type: 'emptyInterface', depth: 2, ranges: [{start: 10, end: 20}],
+ locations: [{expression: '0358000000'}]},
+ {name: 'callback', scope: 'LOCAL', type: 'funcval', depth: 2, ranges: [{start: 10, end: 20}],
+ locations: [{expression: '0360000000'}]},
+ {name: 'nonempty', scope: 'LOCAL', type: 'nonemptyInterface', depth: 2, ranges: [{start: 10, end: 20}],
+ locations: [{expression: '0370000000'}]},
+ {name: 'github.com/goplus/llgo/runtime/internal/runtime.debuggerAllgV1', scope: 'GLOBAL', type: 'ptrG', depth: 0,
+ locations: [{expression: '0368000000'}]},
+ );
+
+ const memory = new Uint8Array(800);
+ const view = new DataView(memory.buffer);
+ const u32 = (address, value) => view.setUint32(address, value, true);
+ const u64 = (address, value) => view.setBigUint64(address, BigInt(value), true);
+ u32(80, 160); // map header pointer
+ u32(84, 300); // channel header pointer
+ u32(88, 500); u32(92, 520); // empty interface type/data
+ u32(96, 10); u32(100, 123); // function code/data
+ u32(104, 600); // debuggerAllgV1
+ u32(112, 560); u32(116, 520); // non-empty interface itab/data
+ u32(160, 2); u32(168, 200); // hmap count, buckets
+ memory[200] = 5; memory[201] = 6;
+ view.setInt32(208, 1, true); view.setInt32(212, 2, true);
+ view.setInt32(240, 11, true); view.setInt32(244, 22, true);
+ u32(300, 2); u32(304, 3); u32(308, 400); u32(312, 0); u32(316, 1);
+ view.setInt32(400, 10, true); view.setInt32(404, 11, true); view.setInt32(408, 12, true);
+ u32(504, 550); u32(508, 5); memory.set(new TextEncoder().encode('int32'), 550);
+ view.setInt32(520, 42, true);
+ u32(560, 500);
+ u32(600, 640); u32(604, 2); u64(608, 1); u64(616, 0);
+ u32(640, 0); u32(644, 1); u64(648, 2); u64(656, 1);
+
+ const services = {
+ getWasmLinearMemory: async (offset, length) => memory.slice(offset, offset + length).buffer,
+ };
+ const fetcher = async url => {
+ if (String(url).endsWith('/__llgo/debug-index.json')) return response(index);
+ if (String(url).endsWith('/__llgo/debug-schema.json')) return response(debuggerSchema);
+ if (String(url).endsWith('/__llgo/plugin-ready')) return new Response(null, {status: 204});
+ throw new Error(`unexpected URL ${url}`);
+ };
+ const plugin = new LLGoLanguageExtensionPlugin(services, fetcher);
+ const context = {rawModuleId: 'module', codeOffset: 12};
+ await plugin.addRawModule('module', undefined, {
+ url: 'http://127.0.0.1:1234/program.wasm', code: moduleBytes().buffer,
+ });
+
+ const dynamic = await plugin.evaluate('dynamic', context, 'stop');
+ assert.equal(dynamic.description, 'type=int32');
+ assert.equal((await plugin.getProperties(dynamic.objectId))[0].value.value, 42);
+ assert.equal((await plugin.evaluate('nonempty', context, 'stop')).description, 'type=int32');
+ assert.equal((await plugin.evaluate('callback', context, 'stop')).description, 'main.main');
+ const mapping = await plugin.evaluate('mapping', context, 'stop');
+ assert.equal(mapping.description, 'len=2');
+ assert.deepEqual((await plugin.getProperties(mapping.objectId)).map(item => [item.name, item.value.value]),
+ [['key[0]', 1], ['value[0]', 11], ['key[1]', 2], ['value[1]', 22]]);
+ const queue = await plugin.evaluate('queue', context, 'stop');
+ assert.equal(queue.description, 'len=2 cap=3');
+ assert.deepEqual((await plugin.getProperties(queue.objectId)).map(item => item.value.value), [11, 12]);
+ assert.ok((await plugin.listVariablesInScope(context)).some(variable => variable.name === '$goroutines'));
+ const goroutines = await plugin.evaluate('$goroutines', context, 'stop');
+ assert.equal(goroutines.description, 'goroutines len=2');
+ assert.deepEqual((await plugin.getProperties(goroutines.objectId)).map(item => item.value.description),
+ ['goroutine 1 [running] parent=0', 'goroutine 2 [runnable] parent=1']);
+});
diff --git a/cmd/internal/debug/debug.go b/cmd/internal/debug/debug.go
index 2971e96378..a7beb055f4 100644
--- a/cmd/internal/debug/debug.go
+++ b/cmd/internal/debug/debug.go
@@ -46,10 +46,22 @@ var (
lldbPath string
gdbPath string
wasmtimePath string
+ chromePath string
+ browserTools bool
remoteAddress string
serverCommand string
+ sourceMapFlag stringListFlag
)
+type stringListFlag []string
+
+func (values *stringListFlag) String() string { return strings.Join(*values, ",") }
+
+func (values *stringListFlag) Set(value string) error {
+ *values = append(*values, value)
+ return nil
+}
+
func init() {
Cmd.Run = runCmd
goBuildFlags = flags.CaptureGoBuildFlags(Cmd)
@@ -62,8 +74,11 @@ func init() {
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(&chromePath, "chrome", "", "path to Chromium (default $LLGO_CHROME or auto-detect)")
+ Cmd.Flag.BoolVar(&browserTools, "browser-devtools", true, "open Chrome DevTools for a browser debug session")
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")
+ Cmd.Flag.Var(&sourceMapFlag, "source-map", "browser source path mapping FROM=TO (repeatable)")
}
func runCmd(cmd *base.Command, args []string) {
@@ -73,12 +88,15 @@ func runCmd(cmd *base.Command, args []string) {
return
}
if err := run(cmd.Flag.Args(), debuggerArgs, options{
- backend: backend(backendFlag),
- lldb: lldbPath,
- gdb: gdbPath,
- wasmtime: wasmtimePath,
- remote: remoteAddress,
- server: serverCommand,
+ backend: backend(backendFlag),
+ lldb: lldbPath,
+ gdb: gdbPath,
+ wasmtime: wasmtimePath,
+ chrome: chromePath,
+ browserTools: browserTools,
+ remote: remoteAddress,
+ server: serverCommand,
+ sourceMap: append([]string(nil), sourceMapFlag...),
}, os.Stdin, os.Stdout, os.Stderr); err != nil {
fmt.Fprintln(os.Stderr, err)
mockable.Exit(1)
@@ -131,8 +149,8 @@ func run(packageArgs, debuggerArgs []string, opts options, stdin io.Reader, stdo
if err != nil {
return err
}
- if selected == backendBrowser {
- return errors.New("llgo debug: the browser DevTools backend is not available yet")
+ if selected == backendBrowser && (opts.remote != "" || opts.server != "") {
+ return errors.New("llgo debug: the browser backend does not use -remote or -server")
}
if target == nil && opts.remote == "" && (conf.Goos != runtime.GOOS || conf.Goarch != runtime.GOARCH) {
return fmt.Errorf("llgo debug: cannot launch a %s/%s program on %s/%s without -remote", conf.Goos, conf.Goarch, runtime.GOOS, runtime.GOARCH)
diff --git a/cmd/internal/debug/session.go b/cmd/internal/debug/session.go
index 8d8655c3f9..dccea72f21 100644
--- a/cmd/internal/debug/session.go
+++ b/cmd/internal/debug/session.go
@@ -28,9 +28,11 @@ import (
"strings"
"time"
+ browsertool "github.com/goplus/llgo/cmd/internal/browser"
"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/browserdebug"
"github.com/goplus/llgo/internal/build"
"github.com/goplus/llgo/internal/env"
"github.com/goplus/llgo/internal/shellparse"
@@ -57,12 +59,15 @@ const (
)
type options struct {
- backend backend
- lldb string
- gdb string
- wasmtime string
- remote string
- server string
+ backend backend
+ lldb string
+ gdb string
+ wasmtime string
+ chrome string
+ browserTools bool
+ remote string
+ server string
+ sourceMap []string
}
func (o options) validate() error {
@@ -133,7 +138,9 @@ func runSession(s session, stdin io.Reader, stdout, stderr io.Writer) error {
cleanup := func() {}
var plan *serverPlan
var err error
- if s.backend == backendWasmtime {
+ if s.backend == backendBrowser {
+ plan = nil
+ } else if s.backend == backendWasmtime {
plan, cleanup, err = makeWASIServerPlan(s.artifact, s.options)
} else {
plan, err = makeServerPlan(s.target, s.artifact, s.options)
@@ -173,6 +180,24 @@ func runSession(s session, stdin io.Reader, stdout, stderr io.Writer) error {
if err := lldb.RunWasm(s.options.lldb, args, stdin, stdout, stderr); err != nil {
debugErr = fmt.Errorf("llgo debug: %w", err)
}
+ case backendBrowser:
+ mappings := make([]browserdebug.PathMapping, 0, len(s.options.sourceMap))
+ for _, value := range s.options.sourceMap {
+ mapping, err := browserdebug.ParsePathMapping(value)
+ if err != nil {
+ debugErr = fmt.Errorf("llgo debug: %w", err)
+ break
+ }
+ mappings = append(mappings, mapping)
+ }
+ if debugErr == nil {
+ if err := browsertool.Run(s.artifact, browsertool.Options{
+ Chrome: s.options.chrome, ChromeArgs: s.debuggerArgs, SourceMaps: mappings,
+ DisableTools: !s.options.browserTools,
+ }, stdin, stdout, stderr); err != nil {
+ debugErr = err
+ }
+ }
default:
debugErr = fmt.Errorf("llgo debug: backend %s is not implemented", s.backend)
}
@@ -426,6 +451,9 @@ func (s *debugServer) logSuffix() string {
func debuggerArguments(selected backend, artifact string, extra []string, server *serverPlan) ([]string, error) {
if server == nil {
+ if selected == backendBrowser {
+ return append([]string(nil), extra...), nil
+ }
return append([]string{artifact}, extra...), nil
}
if server.address == "" {
diff --git a/cmd/llgo/debugtest/README.md b/cmd/llgo/debugtest/README.md
index 7ff74ee3f3..3fae9b34a5 100644
--- a/cmd/llgo/debugtest/README.md
+++ b/cmd/llgo/debugtest/README.md
@@ -20,7 +20,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 guest-debug + Wasm-aware LLDB | Built in |
-| Browser Wasm | Browser DevTools | Added by the browser debugger task |
+| Browser Wasm | Chrome DevTools + LLGo Language Extension | Built in |
Use `-backend=gdb` or `-backend=lldb` to override a native or GDB Remote
session, and `-gdb` or `-lldb` to select a debugger executable. For an already
@@ -54,8 +54,8 @@ RSP memory map, so globals and runtime-backed formatters remain gated by
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.
+session. Browser sessions support embedded DWARF and an adjacent external
+sidecar.
Run the focused fixture with:
@@ -64,3 +64,44 @@ LLGO_WASMTIME=/path/to/wasmtime \
LLGO_LLDB=/path/to/wasm-aware/lldb \
bash cmd/llgo/debugtest/wasi/runtest.sh
```
+
+## Browser WebAssembly
+
+Browser source debugging requires Chromium 123 or newer. The headless
+acceptance lane pins Chrome for Testing 151.0.7922.71 and validates embedded
+DWARF, external DWARF, and a real LLGo browser module. Select another Chromium
+binary with `-chrome` or `LLGO_CHROME`.
+
+Launch a development session with:
+
+```sh
+llgo debug -target=wasm ./path/to/main
+```
+
+`llgo debug` opens an isolated Chrome profile and DevTools, waits for the LLGo
+WebAssembly Language Extension to register, and leaves execution behind a Run
+button so source breakpoints can be set first. Use
+`-debug-artifact=external` to keep DWARF in the adjacent `.debug.wasm`
+sidecar. The main module and sidecar carry the same standard WebAssembly
+`build_id`; a missing or stale sidecar is rejected before launch.
+
+Build paths can be relocated without rewriting DWARF by repeating a
+longest-prefix mapping:
+
+```sh
+llgo debug -target=wasm \
+ -source-map=/build/checkout=/home/me/checkout \
+ ./path/to/main
+```
+
+Source paths that remain unavailable are retained for symbolication but are
+not advertised as local source files. Optimized builds can omit variable
+locations according to DWARF; use `-O0` (the `llgo debug` default) when stable
+local inspection is more important than optimized code shape.
+
+When the LLGo extension is unavailable, the launch page still instantiates
+and runs the module. DevTools or another consumer can then use the standard
+Wasm name section and any separately produced source map for lower-level
+symbolication, but Go expressions, scopes, and runtime-value presentation are
+unavailable. Pass `-browser-devtools=false` to exercise this fallback without
+opening DevTools.
diff --git a/internal/browserdebug/index.go b/internal/browserdebug/index.go
new file mode 100644
index 0000000000..ca7a09ca49
--- /dev/null
+++ b/internal/browserdebug/index.go
@@ -0,0 +1,746 @@
+// 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 browserdebug converts standards-based WebAssembly DWARF into the
+// compact query index used by LLGo's Chrome Language Extension. DWARF remains
+// the source of truth; the index is generated for a debug session and is not a
+// compiler-owned replacement debug format.
+package browserdebug
+
+import (
+ "bytes"
+ "debug/dwarf"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "io"
+ "net/url"
+ "os"
+ "path/filepath"
+ "reflect"
+ "sort"
+ "strings"
+
+ "github.com/goplus/llgo/internal/debugabi"
+ "github.com/goplus/llgo/internal/wasmdebug"
+)
+
+const IndexVersion = 1
+
+// PathMapping maps a source prefix recorded by the compiler to a local source
+// prefix. The longest matching From prefix wins.
+type PathMapping struct {
+ From string
+ To string
+}
+
+// ParsePathMapping parses the debugger spelling FROM=TO.
+func ParsePathMapping(value string) (PathMapping, error) {
+ from, to, ok := strings.Cut(value, "=")
+ if !ok || from == "" || to == "" {
+ return PathMapping{}, fmt.Errorf("source mapping %q must be FROM=TO", value)
+ }
+ return PathMapping{From: filepath.Clean(from), To: filepath.Clean(to)}, nil
+}
+
+// Index is the transport-neutral state consumed by the browser extension.
+type Index struct {
+ Contract string `json:"contract"`
+ Version int `json:"version"`
+ BuildID string `json:"build_id"`
+ Artifact string `json:"artifact"`
+ Record Record `json:"record"`
+ Sources []Source `json:"sources"`
+ Lines []LineRange `json:"lines"`
+ Functions []Function `json:"functions"`
+ Variables []Variable `json:"variables"`
+ Types []Type `json:"types"`
+ Diagnostics []string `json:"diagnostics,omitempty"`
+}
+
+type Record struct {
+ RecordVersion uint8 `json:"record_version"`
+ SchemaVersion uint8 `json:"schema_version"`
+ RuntimeLayoutVersion uint8 `json:"runtime_layout_version"`
+ LLGoABIVersion uint8 `json:"llgo_abi_version"`
+ CABIMode uint8 `json:"cabi_mode"`
+ PointerSize uint8 `json:"pointer_size"`
+ ByteOrder debugabi.ByteOrder `json:"byte_order"`
+}
+
+type Source struct {
+ ID string `json:"id"`
+ Path string `json:"path"`
+ URL string `json:"url"`
+ Local bool `json:"local"`
+}
+
+type AddressRange struct {
+ Start uint64 `json:"start"`
+ End uint64 `json:"end"`
+}
+
+type LineRange struct {
+ Source string `json:"source"`
+ Line int `json:"line"`
+ Column int `json:"column"`
+ Start uint64 `json:"start"`
+ End uint64 `json:"end"`
+}
+
+type Function struct {
+ Name string `json:"name"`
+ Ranges []AddressRange `json:"ranges"`
+}
+
+type Location struct {
+ Start uint64 `json:"start,omitempty"`
+ End uint64 `json:"end,omitempty"`
+ Expression string `json:"expression"`
+}
+
+type Constant struct {
+ Kind string `json:"kind"`
+ Value string `json:"value"`
+}
+
+type Variable struct {
+ Name string `json:"name"`
+ Scope string `json:"scope"`
+ Type string `json:"type"`
+ Ranges []AddressRange `json:"ranges,omitempty"`
+ Locations []Location `json:"locations,omitempty"`
+ Constant *Constant `json:"constant,omitempty"`
+ Depth int `json:"depth"`
+}
+
+type Type struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Kind string `json:"kind"`
+ Size int64 `json:"size"`
+ Signed bool `json:"signed,omitempty"`
+ Elem string `json:"elem,omitempty"`
+ Count int64 `json:"count,omitempty"`
+ Fields []TypeField `json:"fields,omitempty"`
+ Enum []EnumValue `json:"enum,omitempty"`
+ Complete bool `json:"complete"`
+}
+
+type TypeField struct {
+ Name string `json:"name"`
+ Type string `json:"type"`
+ Offset int64 `json:"offset"`
+}
+
+type EnumValue struct {
+ Name string `json:"name"`
+ Value int64 `json:"value"`
+}
+
+// Bundle retains the index and the local files needed to serve a browser
+// session. SourceFiles is keyed by Source.ID.
+type Bundle struct {
+ Index Index
+ MainPath string
+ SymbolsPath string
+ SourceFiles map[string]string
+}
+
+// MissingSymbolsError reports an unavailable external_debug_info target.
+type MissingSymbolsError struct {
+ URL string
+ Path string
+ Err error
+}
+
+func (e *MissingSymbolsError) Error() string {
+ return fmt.Sprintf("external WebAssembly DWARF %q is unavailable at %q: %v", e.URL, e.Path, e.Err)
+}
+
+func (e *MissingSymbolsError) Unwrap() error { return e.Err }
+
+// Load reads an embedded or external LLGo WebAssembly artifact and builds its
+// browser query index. External sidecars must carry the same standard build_id
+// as the main module.
+func Load(mainPath string, mappings []PathMapping) (*Bundle, error) {
+ mainPath, err := filepath.Abs(mainPath)
+ if err != nil {
+ return nil, err
+ }
+ main, err := os.ReadFile(mainPath)
+ if err != nil {
+ return nil, fmt.Errorf("read browser WebAssembly artifact: %w", err)
+ }
+ record, ok, err := wasmdebug.DebuggerRecord(main)
+ if err != nil {
+ return nil, err
+ }
+ if !ok {
+ return nil, errors.New("browser WebAssembly artifact has no LLGo debugger ABI record")
+ }
+ buildID, ok, err := wasmdebug.BuildID(main)
+ if err != nil {
+ return nil, err
+ }
+ if !ok || len(buildID) == 0 {
+ return nil, errors.New("browser WebAssembly artifact has no build_id")
+ }
+
+ symbolsPath := mainPath
+ symbols := main
+ hasDWARF, err := wasmdebug.HasDWARF(main)
+ if err != nil {
+ return nil, err
+ }
+ external, hasExternal, err := wasmdebug.ExternalURL(main)
+ if err != nil {
+ return nil, err
+ }
+ if hasDWARF && hasExternal {
+ return nil, errors.New("browser WebAssembly artifact contains both embedded and external DWARF")
+ }
+ artifactMode := "embedded"
+ if !hasDWARF {
+ if !hasExternal {
+ return nil, errors.New("browser WebAssembly artifact contains no DWARF")
+ }
+ artifactMode = "external"
+ symbolsPath, err = localExternalPath(mainPath, external)
+ if err != nil {
+ return nil, err
+ }
+ symbols, err = os.ReadFile(symbolsPath)
+ if err != nil {
+ return nil, &MissingSymbolsError{URL: external, Path: symbolsPath, Err: err}
+ }
+ sidecarID, sidecarHasID, err := wasmdebug.BuildID(symbols)
+ if err != nil {
+ return nil, fmt.Errorf("read external WebAssembly DWARF build ID: %w", err)
+ }
+ if !sidecarHasID {
+ return nil, errors.New("external WebAssembly DWARF has no build_id")
+ }
+ if !bytes.Equal(buildID, sidecarID) {
+ return nil, fmt.Errorf("stale external WebAssembly DWARF: main build_id %x does not match sidecar %x", buildID, sidecarID)
+ }
+ if sidecarHasDWARF, err := wasmdebug.HasDWARF(symbols); err != nil {
+ return nil, err
+ } else if !sidecarHasDWARF {
+ return nil, errors.New("external WebAssembly DWARF sidecar contains no DWARF")
+ }
+ }
+
+ sections, err := wasmdebug.DWARFSections(symbols)
+ if err != nil {
+ return nil, err
+ }
+ data, err := dwarfData(sections)
+ if err != nil {
+ return nil, fmt.Errorf("read WebAssembly DWARF: %w", err)
+ }
+ builder := indexBuilder{
+ data: data,
+ sections: sections,
+ mappings: normalizeMappings(mappings),
+ index: Index{
+ Sources: []Source{},
+ Lines: []LineRange{},
+ Functions: []Function{},
+ Variables: []Variable{},
+ Types: []Type{},
+ },
+ sourceByPath: make(map[string]string),
+ sourceFiles: make(map[string]string),
+ typeByKey: make(map[uintptr]string),
+ }
+ if err := builder.build(); err != nil {
+ return nil, err
+ }
+ builder.index.Contract = "llgo.browser.debug"
+ builder.index.Version = IndexVersion
+ builder.index.BuildID = hex.EncodeToString(buildID)
+ builder.index.Artifact = artifactMode
+ builder.index.Record = Record{
+ RecordVersion: record.RecordVersion,
+ SchemaVersion: record.SchemaVersion,
+ RuntimeLayoutVersion: record.RuntimeLayoutVersion,
+ LLGoABIVersion: record.LLGoABIVersion,
+ CABIMode: record.CABIMode,
+ PointerSize: record.PointerSize,
+ ByteOrder: record.ByteOrder,
+ }
+ builder.sort()
+ return &Bundle{
+ Index: builder.index,
+ MainPath: mainPath,
+ SymbolsPath: symbolsPath,
+ SourceFiles: builder.sourceFiles,
+ }, nil
+}
+
+func localExternalPath(mainPath, reference string) (string, error) {
+ parsed, err := url.Parse(reference)
+ if err != nil {
+ return "", fmt.Errorf("parse external WebAssembly DWARF URL: %w", err)
+ }
+ if parsed.IsAbs() || parsed.Host != "" || parsed.RawQuery != "" || parsed.Fragment != "" {
+ return "", fmt.Errorf("external WebAssembly DWARF URL %q is not a local relative URL", reference)
+ }
+ decoded, err := url.PathUnescape(parsed.EscapedPath())
+ if err != nil {
+ return "", fmt.Errorf("decode external WebAssembly DWARF URL: %w", err)
+ }
+ if decoded == "" {
+ return "", errors.New("external WebAssembly DWARF URL is empty")
+ }
+ return filepath.Clean(filepath.Join(filepath.Dir(mainPath), filepath.FromSlash(decoded))), nil
+}
+
+func dwarfData(sections map[string][]byte) (*dwarf.Data, error) {
+ data, err := dwarf.New(
+ sections[".debug_abbrev"], sections[".debug_aranges"],
+ sections[".debug_frame"], sections[".debug_info"],
+ sections[".debug_line"], sections[".debug_pubnames"],
+ sections[".debug_ranges"], sections[".debug_str"],
+ )
+ if err != nil {
+ return nil, err
+ }
+ for name, contents := range sections {
+ switch name {
+ case ".debug_abbrev", ".debug_aranges", ".debug_frame", ".debug_info",
+ ".debug_line", ".debug_pubnames", ".debug_ranges", ".debug_str":
+ continue
+ }
+ if err := data.AddSection(name, contents); err != nil {
+ return nil, err
+ }
+ }
+ return data, nil
+}
+
+type scopeState struct {
+ function bool
+ ranges []AddressRange
+}
+
+type indexBuilder struct {
+ data *dwarf.Data
+ sections map[string][]byte
+ mappings []PathMapping
+ index Index
+ sourceByPath map[string]string
+ sourceFiles map[string]string
+ typeByKey map[uintptr]string
+}
+
+func (b *indexBuilder) build() error {
+ reader := b.data.Reader()
+ stack := []scopeState{{}}
+ for {
+ entry, err := reader.Next()
+ if err != nil {
+ return fmt.Errorf("read WebAssembly DWARF entry: %w", err)
+ }
+ if entry == nil {
+ return nil
+ }
+ if entry.Tag == 0 {
+ if len(stack) > 1 {
+ stack = stack[:len(stack)-1]
+ }
+ continue
+ }
+ current := stack[len(stack)-1]
+ ranges := b.ranges(entry)
+ switch entry.Tag {
+ case dwarf.TagCompileUnit:
+ if err := b.addLines(entry); err != nil {
+ b.index.Diagnostics = append(b.index.Diagnostics, err.Error())
+ }
+ case dwarf.TagSubprogram, dwarf.TagInlinedSubroutine:
+ name, _ := entry.Val(dwarf.AttrName).(string)
+ if linkage, ok := entry.Val(dwarf.AttrLinkageName).(string); ok && linkage != "" {
+ name = linkage
+ }
+ if name != "" && len(ranges) != 0 {
+ b.index.Functions = append(b.index.Functions, Function{Name: name, Ranges: ranges})
+ }
+ current.function = true
+ if len(ranges) != 0 {
+ current.ranges = ranges
+ }
+ case dwarf.TagLexDwarfBlock, dwarf.TagTryDwarfBlock, dwarf.TagCatchDwarfBlock:
+ if len(ranges) != 0 {
+ current.ranges = ranges
+ }
+ case dwarf.TagVariable, dwarf.TagFormalParameter:
+ b.addVariable(entry, stack[len(stack)-1], len(stack)-1)
+ }
+ if entry.Children {
+ stack = append(stack, current)
+ }
+ }
+}
+
+func (b *indexBuilder) ranges(entry *dwarf.Entry) []AddressRange {
+ raw, err := b.data.Ranges(entry)
+ if err != nil {
+ b.index.Diagnostics = append(b.index.Diagnostics,
+ fmt.Sprintf("DWARF ranges at %#x: %v", entry.Offset, err))
+ return nil
+ }
+ result := make([]AddressRange, 0, len(raw))
+ for _, item := range raw {
+ if item[1] > item[0] {
+ result = append(result, AddressRange{Start: item[0], End: item[1]})
+ }
+ }
+ return result
+}
+
+func (b *indexBuilder) addLines(unit *dwarf.Entry) error {
+ reader, err := b.data.LineReader(unit)
+ if err != nil {
+ return fmt.Errorf("read line table at %#x: %w", unit.Offset, err)
+ }
+ if reader == nil {
+ return nil
+ }
+ var previous dwarf.LineEntry
+ havePrevious := false
+ for {
+ var current dwarf.LineEntry
+ err := reader.Next(¤t)
+ if errors.Is(err, io.EOF) {
+ break
+ }
+ if err != nil {
+ return fmt.Errorf("read line table at %#x: %w", unit.Offset, err)
+ }
+ if havePrevious && !previous.EndSequence && current.Address > previous.Address && previous.File != nil {
+ source := b.addSource(previous.File.Name)
+ if source != "" {
+ line, column := previous.Line-1, previous.Column
+ if line < 0 {
+ line = 0
+ }
+ if column > 0 {
+ column--
+ }
+ b.index.Lines = append(b.index.Lines, LineRange{
+ Source: source, Line: line, Column: column,
+ Start: previous.Address, End: current.Address,
+ })
+ }
+ }
+ previous = current
+ havePrevious = !current.EndSequence
+ }
+ return nil
+}
+
+func (b *indexBuilder) addSource(recorded string) string {
+ if recorded == "" {
+ return ""
+ }
+ recorded = filepath.Clean(recorded)
+ if id, ok := b.sourceByPath[recorded]; ok {
+ return id
+ }
+ idBytes := []byte(recorded)
+ // FNV-sized stable IDs are sufficient here; retain the complete path in the
+ // index and use a collision suffix if a future fixture ever needs one.
+ var hash uint64 = 1469598103934665603
+ for _, value := range idBytes {
+ hash ^= uint64(value)
+ hash *= 1099511628211
+ }
+ id := fmt.Sprintf("s%016x", hash)
+ for suffix := 1; ; suffix++ {
+ collision := false
+ for _, source := range b.index.Sources {
+ if source.ID == id && source.Path != recorded {
+ collision = true
+ break
+ }
+ }
+ if !collision {
+ break
+ }
+ id = fmt.Sprintf("s%016x-%d", hash, suffix)
+ }
+ local := b.localSourcePath(recorded)
+ _, statErr := os.Stat(local)
+ available := statErr == nil
+ b.index.Sources = append(b.index.Sources, Source{
+ ID: id, Path: recorded, URL: "/__llgo/source/" + id, Local: available,
+ })
+ b.sourceByPath[recorded] = id
+ if available {
+ b.sourceFiles[id] = local
+ }
+ return id
+}
+
+func (b *indexBuilder) localSourcePath(recorded string) string {
+ for _, mapping := range b.mappings {
+ if suffix, ok := pathPrefix(recorded, mapping.From); ok {
+ return filepath.Join(mapping.To, suffix)
+ }
+ }
+ return recorded
+}
+
+func normalizeMappings(mappings []PathMapping) []PathMapping {
+ result := append([]PathMapping(nil), mappings...)
+ for index := range result {
+ result[index].From = filepath.Clean(result[index].From)
+ result[index].To = filepath.Clean(result[index].To)
+ }
+ sort.SliceStable(result, func(i, j int) bool {
+ return len(result[i].From) > len(result[j].From)
+ })
+ return result
+}
+
+func pathPrefix(path, prefix string) (string, bool) {
+ path = filepath.Clean(path)
+ prefix = filepath.Clean(prefix)
+ if path == prefix {
+ return "", true
+ }
+ withSeparator := prefix + string(filepath.Separator)
+ if strings.HasPrefix(path, withSeparator) {
+ return strings.TrimPrefix(path, withSeparator), true
+ }
+ return "", false
+}
+
+func (b *indexBuilder) addVariable(entry *dwarf.Entry, state scopeState, depth int) {
+ name, _ := entry.Val(dwarf.AttrName).(string)
+ if name == "" {
+ return
+ }
+ value := Variable{Name: name, Depth: depth}
+ if entry.Tag == dwarf.TagFormalParameter {
+ value.Scope = "PARAMETER"
+ } else if state.function {
+ value.Scope = "LOCAL"
+ } else {
+ value.Scope = "GLOBAL"
+ }
+ value.Ranges = append(value.Ranges, state.ranges...)
+ if typeOffset, ok := entry.Val(dwarf.AttrType).(dwarf.Offset); ok {
+ if dwarfType, err := b.data.Type(typeOffset); err == nil {
+ value.Type = b.addType(dwarfType)
+ } else {
+ b.index.Diagnostics = append(b.index.Diagnostics,
+ fmt.Sprintf("DWARF type at %#x: %v", typeOffset, err))
+ }
+ }
+ value.Constant = constantValue(entry.Val(dwarf.AttrConstValue))
+ if raw := entry.Val(dwarf.AttrLocation); raw != nil {
+ switch location := raw.(type) {
+ case []byte:
+ value.Locations = []Location{{Expression: hex.EncodeToString(location)}}
+ case int64:
+ locations, err := parseDebugLoc(b.sections[".debug_loc"], uint64(location), 4)
+ if err != nil {
+ b.index.Diagnostics = append(b.index.Diagnostics,
+ fmt.Sprintf("DWARF location for %s at %#x: %v", name, entry.Offset, err))
+ } else {
+ value.Locations = locations
+ }
+ }
+ }
+ // Storage-free declarations are intentionally absent from the scope view;
+ // this matches the native adapters' optimized-out policy.
+ if value.Constant == nil && len(value.Locations) == 0 {
+ return
+ }
+ b.index.Variables = append(b.index.Variables, value)
+}
+
+func constantValue(raw any) *Constant {
+ switch value := raw.(type) {
+ case int64:
+ return &Constant{Kind: "signed", Value: fmt.Sprint(value)}
+ case uint64:
+ return &Constant{Kind: "unsigned", Value: fmt.Sprint(value)}
+ case string:
+ return &Constant{Kind: "string", Value: value}
+ case []byte:
+ return &Constant{Kind: "bytes", Value: hex.EncodeToString(value)}
+ default:
+ return nil
+ }
+}
+
+func parseDebugLoc(section []byte, offset uint64, addressSize int) ([]Location, error) {
+ if addressSize != 4 && addressSize != 8 {
+ return nil, fmt.Errorf("unsupported DWARF address size %d", addressSize)
+ }
+ if offset > uint64(len(section)) {
+ return nil, errors.New("location-list offset is outside .debug_loc")
+ }
+ position := int(offset)
+ var result []Location
+ var base uint64
+ maximum := ^uint64(0)
+ if addressSize == 4 {
+ maximum = uint64(^uint32(0))
+ }
+ readAddress := func() (uint64, error) {
+ if position+addressSize > len(section) {
+ return 0, io.ErrUnexpectedEOF
+ }
+ var value uint64
+ for index := 0; index < addressSize; index++ {
+ value |= uint64(section[position+index]) << (8 * index)
+ }
+ position += addressSize
+ return value, nil
+ }
+ for {
+ low, err := readAddress()
+ if err != nil {
+ return nil, err
+ }
+ high, err := readAddress()
+ if err != nil {
+ return nil, err
+ }
+ if low == 0 && high == 0 {
+ return result, nil
+ }
+ if low == maximum {
+ base = high
+ continue
+ }
+ if position+2 > len(section) {
+ return nil, io.ErrUnexpectedEOF
+ }
+ size := int(section[position]) | int(section[position+1])<<8
+ position += 2
+ if position+size > len(section) {
+ return nil, io.ErrUnexpectedEOF
+ }
+ expression := section[position : position+size]
+ position += size
+ if high > low {
+ result = append(result, Location{
+ Start: base + low, End: base + high,
+ Expression: hex.EncodeToString(expression),
+ })
+ }
+ }
+}
+
+func (b *indexBuilder) addType(value dwarf.Type) string {
+ if value == nil {
+ return ""
+ }
+ key := typeKey(value)
+ if id, ok := b.typeByKey[key]; ok {
+ return id
+ }
+ id := fmt.Sprintf("t%d", len(b.index.Types)+1)
+ b.typeByKey[key] = id
+ info := Type{ID: id, Name: value.String(), Size: value.Size(), Complete: true}
+ if name := value.Common().Name; name != "" {
+ info.Name = name
+ }
+ b.index.Types = append(b.index.Types, info)
+ index := len(b.index.Types) - 1
+ switch current := value.(type) {
+ case *dwarf.BoolType:
+ info.Kind = "bool"
+ case *dwarf.IntType, *dwarf.CharType:
+ info.Kind, info.Signed = "integer", true
+ case *dwarf.UintType, *dwarf.UcharType, *dwarf.AddrType:
+ info.Kind = "integer"
+ case *dwarf.FloatType:
+ info.Kind = "float"
+ case *dwarf.ComplexType:
+ info.Kind = "complex"
+ case *dwarf.PtrType:
+ info.Kind = "pointer"
+ info.Elem = b.addType(current.Type)
+ case *dwarf.ArrayType:
+ info.Kind = "array"
+ info.Elem = b.addType(current.Type)
+ info.Count = current.Count
+ case *dwarf.StructType:
+ info.Kind = current.Kind
+ info.Complete = !current.Incomplete
+ for _, field := range current.Field {
+ info.Fields = append(info.Fields, TypeField{
+ Name: field.Name, Type: b.addType(field.Type), Offset: field.ByteOffset,
+ })
+ }
+ case *dwarf.TypedefType:
+ info.Kind = "typedef"
+ info.Elem = b.addType(current.Type)
+ case *dwarf.QualType:
+ info.Kind = "qualified"
+ info.Elem = b.addType(current.Type)
+ case *dwarf.EnumType:
+ info.Kind = "enum"
+ for _, item := range current.Val {
+ info.Enum = append(info.Enum, EnumValue{Name: item.Name, Value: item.Val})
+ }
+ case *dwarf.FuncType:
+ info.Kind = "function"
+ info.Elem = b.addType(current.ReturnType)
+ default:
+ info.Kind = "unknown"
+ }
+ b.index.Types[index] = info
+ return id
+}
+
+func typeKey(value dwarf.Type) uintptr {
+ reflected := reflect.ValueOf(value)
+ if reflected.Kind() == reflect.Pointer && !reflected.IsNil() {
+ return reflected.Pointer()
+ }
+ // debug/dwarf currently returns pointer-backed concrete types. Keep a
+ // deterministic non-zero fallback if that ever changes.
+ return uintptr(len(value.String()) + 1)
+}
+
+func (b *indexBuilder) sort() {
+ sort.SliceStable(b.index.Sources, func(i, j int) bool {
+ return b.index.Sources[i].Path < b.index.Sources[j].Path
+ })
+ sort.SliceStable(b.index.Lines, func(i, j int) bool {
+ left, right := b.index.Lines[i], b.index.Lines[j]
+ if left.Start != right.Start {
+ return left.Start < right.Start
+ }
+ if left.Source != right.Source {
+ return left.Source < right.Source
+ }
+ return left.Line < right.Line
+ })
+ sort.SliceStable(b.index.Functions, func(i, j int) bool {
+ left, right := b.index.Functions[i], b.index.Functions[j]
+ if len(left.Ranges) != 0 && len(right.Ranges) != 0 && left.Ranges[0].Start != right.Ranges[0].Start {
+ return left.Ranges[0].Start < right.Ranges[0].Start
+ }
+ return left.Name < right.Name
+ })
+}
diff --git a/internal/browserdebug/index_test.go b/internal/browserdebug/index_test.go
new file mode 100644
index 0000000000..75802ce68b
--- /dev/null
+++ b/internal/browserdebug/index_test.go
@@ -0,0 +1,252 @@
+//go:build !llgo
+
+package browserdebug
+
+import (
+ "bytes"
+ "errors"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "testing"
+
+ "github.com/goplus/llgo/internal/debugabi"
+ "github.com/goplus/llgo/internal/wasmdebug"
+)
+
+func TestLoadEmbeddedAndExternal(t *testing.T) {
+ dir := t.TempDir()
+ sourceDir := filepath.Join(dir, "local-source")
+ if err := os.MkdirAll(sourceDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ source := filepath.Join(sourceDir, "fixture.c")
+ if err := os.WriteFile(source, []byte("int add(int a, int b) { int result = a + b; return result; }\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ embedded := filepath.Join(dir, "fixture.wasm")
+ compileWasmFixture(t, source, embedded)
+ raw, err := os.ReadFile(embedded)
+ if err != nil {
+ t.Fatal(err)
+ }
+ raw, err = wasmdebug.SetDebuggerRecord(raw, debugabi.NewRecord(2, 4, debugabi.ByteOrderLittle))
+ if err != nil {
+ t.Fatal(err)
+ }
+ raw, _, err = wasmdebug.EnsureBuildID(raw)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(embedded, raw, 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ bundle, err := Load(embedded, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if bundle.Index.Artifact != "embedded" || bundle.Index.Record.SchemaVersion != 1 || bundle.Index.BuildID == "" {
+ t.Fatalf("embedded index header = %+v", bundle.Index)
+ }
+ if !hasSourceSuffix(bundle.Index.Sources, "fixture.c") || !hasFunction(bundle.Index.Functions, "add") || len(bundle.Index.Lines) == 0 {
+ t.Fatalf("embedded index lacks source/function/lines: sources=%+v functions=%+v lines=%d diagnostics=%v",
+ bundle.Index.Sources, bundle.Index.Functions, len(bundle.Index.Lines), bundle.Index.Diagnostics)
+ }
+
+ sidecar := filepath.Join(dir, "fixture debug.wasm")
+ if err := os.WriteFile(sidecar, raw, 0o644); err != nil {
+ t.Fatal(err)
+ }
+ main, err := wasmdebug.Externalize(raw, "fixture%20debug.wasm")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(embedded, main, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ bundle, err = Load(embedded, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if bundle.Index.Artifact != "external" || bundle.SymbolsPath != sidecar {
+ t.Fatalf("external bundle = mode %q symbols %q", bundle.Index.Artifact, bundle.SymbolsPath)
+ }
+
+ if err := os.Remove(sidecar); err != nil {
+ t.Fatal(err)
+ }
+ _, err = Load(embedded, nil)
+ var missing *MissingSymbolsError
+ if !errors.As(err, &missing) || missing.URL != "fixture%20debug.wasm" {
+ t.Fatalf("missing sidecar error = %T %v", err, err)
+ }
+
+ stale, err := wasmdebug.SetBuildID(raw, bytes.Repeat([]byte{0xaa}, 32))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(sidecar, stale, 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := Load(embedded, nil); err == nil || !strings.Contains(err.Error(), "stale external") {
+ t.Fatalf("stale sidecar error = %v", err)
+ }
+}
+
+func TestPathMapping(t *testing.T) {
+ mapping, err := ParsePathMapping("/build/source=/local/source")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if suffix, ok := pathPrefix(filepath.Join("/build/source", "pkg/main.go"), mapping.From); !ok || suffix != filepath.Join("pkg", "main.go") {
+ t.Fatalf("pathPrefix = %q, %v", suffix, ok)
+ }
+ if _, err := ParsePathMapping("missing-separator"); err == nil {
+ t.Fatal("invalid source mapping was accepted")
+ }
+}
+
+func TestLoadUsesLongestSourcePathMapping(t *testing.T) {
+ dir := t.TempDir()
+ recordedRoot := filepath.Join(dir, "recorded")
+ recordedPackage := filepath.Join(recordedRoot, "pkg")
+ if err := os.MkdirAll(recordedPackage, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ source := filepath.Join(recordedPackage, "fixture.c")
+ artifact := filepath.Join(dir, "fixture.wasm")
+ if err := os.WriteFile(source, []byte("int add(int a, int b) { return a + b; }\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ compileWasmFixture(t, source, artifact)
+ raw, err := os.ReadFile(artifact)
+ if err != nil {
+ t.Fatal(err)
+ }
+ raw, err = wasmdebug.SetDebuggerRecord(raw, debugabi.NewRecord(2, 4, debugabi.ByteOrderLittle))
+ if err != nil {
+ t.Fatal(err)
+ }
+ raw, _, err = wasmdebug.EnsureBuildID(raw)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(artifact, raw, 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ relocatedRoot := filepath.Join(dir, "relocated")
+ if err := os.Rename(recordedRoot, relocatedRoot); err != nil {
+ t.Fatal(err)
+ }
+ bundle, err := Load(artifact, []PathMapping{
+ {From: recordedRoot, To: filepath.Join(dir, "wrong")},
+ {From: recordedPackage, To: filepath.Join(relocatedRoot, "pkg")},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ wantSource := filepath.Join(relocatedRoot, "pkg", "fixture.c")
+ for _, indexed := range bundle.Index.Sources {
+ if indexed.Path != source {
+ continue
+ }
+ if !indexed.Local || bundle.SourceFiles[indexed.ID] != wantSource {
+ t.Fatalf("mapped source = %+v, file %q, want %q", indexed, bundle.SourceFiles[indexed.ID], wantSource)
+ }
+ return
+ }
+ t.Fatalf("recorded source %q is absent from %+v", source, bundle.Index.Sources)
+}
+
+func TestLoadLLGoArtifact(t *testing.T) {
+ path := os.Getenv("LLGO_BROWSER_DEBUG_ARTIFACT")
+ if path == "" {
+ t.Skip("LLGO_BROWSER_DEBUG_ARTIFACT is unset")
+ }
+ bundle, err := Load(path, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Logf("sources=%d lines=%d functions=%d variables=%d types=%d diagnostics=%d",
+ len(bundle.Index.Sources), len(bundle.Index.Lines), len(bundle.Index.Functions),
+ len(bundle.Index.Variables), len(bundle.Index.Types), len(bundle.Index.Diagnostics))
+ if !hasFunction(bundle.Index.Functions, "main.main") {
+ t.Fatalf("LLGo index does not contain main.main")
+ }
+}
+
+func TestLoadLLGoRuntimeFixture(t *testing.T) {
+ path := os.Getenv("LLGO_BROWSER_DEBUG_RUNTIME_ARTIFACT")
+ if path == "" {
+ t.Skip("LLGO_BROWSER_DEBUG_RUNTIME_ARTIFACT is unset")
+ }
+ bundle, err := Load(path, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, name := range []string{"text", "values", "mapping", "queue", "greeter", "closure"} {
+ if !hasVariable(bundle.Index.Variables, name) {
+ t.Errorf("LLGo browser runtime fixture does not contain variable %q", name)
+ }
+ }
+ for _, pattern := range []string{"string", "[]", "map[", "chan ", "interface{"} {
+ if !hasTypePattern(bundle.Index.Types, pattern) {
+ t.Errorf("LLGo browser runtime fixture does not contain a type matching %q", pattern)
+ }
+ }
+}
+
+func compileWasmFixture(t *testing.T, source, output string) {
+ t.Helper()
+ clang, err := exec.LookPath("clang")
+ if err != nil {
+ t.Skip("clang is unavailable")
+ }
+ command := exec.Command(clang,
+ "--target=wasm32-unknown-unknown", "-O0", "-g", "-nostdlib",
+ "-Wl,--no-entry", "-Wl,--export=add", "-o", output, source,
+ )
+ if data, err := command.CombinedOutput(); err != nil {
+ t.Fatalf("compile WebAssembly fixture on %s/%s: %v\n%s", runtime.GOOS, runtime.GOARCH, err, data)
+ }
+}
+
+func hasSourceSuffix(sources []Source, suffix string) bool {
+ for _, source := range sources {
+ if strings.HasSuffix(source.Path, suffix) {
+ return true
+ }
+ }
+ return false
+}
+
+func hasFunction(functions []Function, name string) bool {
+ for _, function := range functions {
+ if function.Name == name {
+ return true
+ }
+ }
+ return false
+}
+
+func hasVariable(variables []Variable, name string) bool {
+ for _, variable := range variables {
+ if variable.Name == name {
+ return true
+ }
+ }
+ return false
+}
+
+func hasTypePattern(types []Type, pattern string) bool {
+ for _, item := range types {
+ if strings.Contains(item.Name, pattern) {
+ return true
+ }
+ }
+ return false
+}
diff --git a/internal/build/debug_artifact_external.go b/internal/build/debug_artifact_external.go
index 58953147d9..0f03e00e23 100644
--- a/internal/build/debug_artifact_external.go
+++ b/internal/build/debug_artifact_external.go
@@ -56,6 +56,10 @@ func finalizeDebugArtifact(conf *Config, out *OutFmtDetails, verbose bool) error
if err != nil {
return fmt.Errorf("add WebAssembly debugger ABI record: %w", err)
}
+ raw, _, err = wasmdebug.EnsureBuildID(raw)
+ if err != nil {
+ return fmt.Errorf("add WebAssembly build ID: %w", err)
+ }
}
// external_debug_info stores a URL, not a filesystem path. Keep the
// sidecar adjacent to the module and escape its filename for URL lookup.
@@ -92,6 +96,10 @@ func finalizeEmbeddedWasmDebuggerRecord(conf *Config, out *OutFmtDetails) error
if err != nil {
return fmt.Errorf("add WebAssembly debugger ABI record: %w", err)
}
+ raw, _, err = wasmdebug.EnsureBuildID(raw)
+ if err != nil {
+ return fmt.Errorf("add WebAssembly build ID: %w", err)
+ }
info, err := os.Stat(out.Out)
if err != nil {
return err
diff --git a/internal/build/debug_artifact_external_test.go b/internal/build/debug_artifact_external_test.go
index 022914a9a2..2e7354b7c6 100644
--- a/internal/build/debug_artifact_external_test.go
+++ b/internal/build/debug_artifact_external_test.go
@@ -51,6 +51,10 @@ func TestFinalizeExternalWasmDWARF(t *testing.T) {
if err != nil {
t.Fatal(err)
}
+ wantDebugModule, _, err = wasmdebug.EnsureBuildID(wantDebugModule)
+ if err != nil {
+ t.Fatal(err)
+ }
if !bytes.Equal(debugModule, wantDebugModule) {
t.Fatal("external DWARF sidecar differs from the recorded debug module")
}
@@ -73,6 +77,14 @@ func TestFinalizeExternalWasmDWARF(t *testing.T) {
t.Fatalf("%s DebuggerRecord = %+v, %v, %v", name, got, ok, err)
}
}
+ mainID, mainHasID, err := wasmdebug.BuildID(main)
+ if err != nil || !mainHasID {
+ t.Fatalf("main BuildID = %x, %v, %v", mainID, mainHasID, err)
+ }
+ sidecarID, sidecarHasID, err := wasmdebug.BuildID(debugModule)
+ if err != nil || !sidecarHasID || !bytes.Equal(mainID, sidecarID) {
+ t.Fatalf("sidecar BuildID = %x, %v, %v; main = %x", sidecarID, sidecarHasID, err, mainID)
+ }
embedded := filepath.Join(dir, "embedded.wasm")
if err := os.WriteFile(embedded, original, 0o755); err != nil {
@@ -92,6 +104,9 @@ func TestFinalizeExternalWasmDWARF(t *testing.T) {
if got, ok, err := wasmdebug.DebuggerRecord(embeddedModule); err != nil || !ok || got.CABIMode != 1 {
t.Fatalf("embedded DebuggerRecord = %+v, %v, %v", got, ok, err)
}
+ if id, ok, err := wasmdebug.BuildID(embeddedModule); err != nil || !ok || len(id) == 0 {
+ t.Fatalf("embedded BuildID = %x, %v, %v", id, ok, err)
+ }
}
func TestFinalizeDebugArtifactValidation(t *testing.T) {
diff --git a/internal/wasmdebug/wasmdebug.go b/internal/wasmdebug/wasmdebug.go
index 8fea16b289..2bd8f08cc1 100644
--- a/internal/wasmdebug/wasmdebug.go
+++ b/internal/wasmdebug/wasmdebug.go
@@ -20,6 +20,7 @@ package wasmdebug
import (
"bytes"
+ "crypto/sha256"
"errors"
"fmt"
"strings"
@@ -28,7 +29,10 @@ import (
"github.com/goplus/llgo/internal/debugabi"
)
-const externalDebugInfo = "external_debug_info"
+const (
+ externalDebugInfo = "external_debug_info"
+ buildIDSection = "build_id"
+)
var wasmHeader = []byte{0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00}
@@ -338,6 +342,95 @@ func appendCustomSection(dst []byte, name string, content []byte) []byte {
return append(dst, payload...)
}
+// DWARFSections returns the unique embedded DWARF custom sections keyed by
+// their standard section names. Returned contents do not alias module.
+func DWARFSections(module []byte) (map[string][]byte, error) {
+ sections, err := parse(module)
+ if err != nil {
+ return nil, err
+ }
+ result := make(map[string][]byte)
+ for _, section := range sections {
+ if section.id != 0 || !isDWARFSection(section.name) {
+ continue
+ }
+ if _, exists := result[section.name]; exists {
+ return nil, fmt.Errorf("multiple %s WebAssembly custom sections", section.name)
+ }
+ result[section.name] = bytes.Clone(section.content)
+ }
+ return result, nil
+}
+
+// BuildID returns the unique WebAssembly tool-conventions build_id, if
+// present. Build IDs are arbitrary bytes and are not interpreted as UTF-8.
+func BuildID(module []byte) ([]byte, bool, error) {
+ sections, err := parse(module)
+ if err != nil {
+ return nil, false, err
+ }
+ var id []byte
+ found := false
+ for _, section := range sections {
+ if section.id != 0 || section.name != buildIDSection {
+ continue
+ }
+ if found {
+ return nil, false, errors.New("multiple WebAssembly build_id sections")
+ }
+ off := 0
+ size, err := readULEB32(section.content, &off)
+ if err != nil {
+ return nil, false, fmt.Errorf("invalid WebAssembly build_id section: %w", err)
+ }
+ if int(size) != len(section.content)-off {
+ return nil, false, errors.New("invalid WebAssembly build_id length")
+ }
+ id = bytes.Clone(section.content[off:])
+ found = true
+ }
+ return id, found, nil
+}
+
+// SetBuildID replaces the WebAssembly tool-conventions build_id section.
+func SetBuildID(module, id []byte) ([]byte, error) {
+ if len(id) == 0 {
+ return nil, errors.New("WebAssembly build ID must not be empty")
+ }
+ sections, err := parse(module)
+ if err != nil {
+ return nil, err
+ }
+ out := append([]byte(nil), wasmHeader...)
+ for _, section := range sections {
+ if section.id == 0 && section.name == buildIDSection {
+ continue
+ }
+ out = append(out, section.raw...)
+ }
+ payload := appendULEB32(nil, uint32(len(id)))
+ payload = append(payload, id...)
+ return appendCustomSection(out, buildIDSection, payload), nil
+}
+
+// EnsureBuildID preserves an existing valid build ID or installs a
+// deterministic SHA-256 ID over the complete module without a build_id
+// section. Calling it before externalization gives the main module and DWARF
+// sidecar the same identity for stale-sidecar checks.
+func EnsureBuildID(module []byte) ([]byte, []byte, error) {
+ if id, ok, err := BuildID(module); err != nil {
+ return nil, nil, err
+ } else if ok {
+ return bytes.Clone(module), id, nil
+ }
+ digest := sha256.Sum256(module)
+ result, err := SetBuildID(module, digest[:])
+ if err != nil {
+ return nil, nil, err
+ }
+ return result, bytes.Clone(digest[:]), nil
+}
+
// HasDWARF reports whether module contains at least one DWARF custom section.
func HasDWARF(module []byte) (bool, error) {
sections, err := parse(module)
diff --git a/internal/wasmdebug/wasmdebug_test.go b/internal/wasmdebug/wasmdebug_test.go
index 81c774f62b..9bc569d01d 100644
--- a/internal/wasmdebug/wasmdebug_test.go
+++ b/internal/wasmdebug/wasmdebug_test.go
@@ -2,12 +2,62 @@ package wasmdebug
import (
"bytes"
+ "crypto/sha256"
"strings"
"testing"
"github.com/goplus/llgo/internal/debugabi"
)
+func TestBuildIDAndDWARFSections(t *testing.T) {
+ base := append([]byte(nil), wasmHeader...)
+ base = appendCustomSection(base, ".debug_info", []byte{1, 2, 3})
+ base = appendCustomSection(base, ".debug_line", []byte{4, 5})
+
+ sections, err := DWARFSections(base)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !bytes.Equal(sections[".debug_info"], []byte{1, 2, 3}) ||
+ !bytes.Equal(sections[".debug_line"], []byte{4, 5}) {
+ t.Fatalf("DWARF sections = %#v", sections)
+ }
+ sections[".debug_info"][0] = 9
+ again, err := DWARFSections(base)
+ if err != nil || again[".debug_info"][0] != 1 {
+ t.Fatalf("DWARFSections returned aliased data: %#v, %v", again, err)
+ }
+
+ withID, id, err := EnsureBuildID(base)
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := sha256.Sum256(base)
+ if !bytes.Equal(id, want[:]) {
+ t.Fatalf("generated build ID = %x, want %x", id, want)
+ }
+ got, ok, err := BuildID(withID)
+ if err != nil || !ok || !bytes.Equal(got, id) {
+ t.Fatalf("BuildID = %x, %v, %v; want %x, true, nil", got, ok, err, id)
+ }
+ againModule, againID, err := EnsureBuildID(withID)
+ if err != nil || !bytes.Equal(againModule, withID) || !bytes.Equal(againID, id) {
+ t.Fatalf("EnsureBuildID did not preserve the ID: %x, %x, %v", againModule, againID, err)
+ }
+
+ replaced, err := SetBuildID(withID, []byte{0, 0xff, 1})
+ if err != nil {
+ t.Fatal(err)
+ }
+ got, ok, err = BuildID(replaced)
+ if err != nil || !ok || !bytes.Equal(got, []byte{0, 0xff, 1}) {
+ t.Fatalf("replaced BuildID = %x, %v, %v", got, ok, err)
+ }
+ if _, err := SetBuildID(base, nil); err == nil {
+ t.Fatal("SetBuildID accepted an empty ID")
+ }
+}
+
func appendSection(dst []byte, id byte, payload []byte) []byte {
dst = append(dst, id)
dst = appendULEB32(dst, uint32(len(payload)))