From ff8880c66791967b3713b2f9eb66d7132997f041 Mon Sep 17 00:00:00 2001 From: Li Jie Date: Sun, 2 Aug 2026 17:56:06 +0800 Subject: [PATCH 1/3] debug: add Chrome WebAssembly source sessions --- .github/workflows/browser-debug.yml | 126 +++ cmd/internal/browser/browser.go | 477 +++++++++++ cmd/internal/browser/browser_test.go | 370 +++++++++ cmd/internal/browser/extension/devtools.html | 3 + cmd/internal/browser/extension/manifest.json | 11 + cmd/internal/browser/extension/plugin.js | 628 +++++++++++++++ cmd/internal/browser/extension/plugin_test.js | 205 +++++ cmd/internal/debug/debug.go | 34 +- cmd/internal/debug/session.go | 42 +- cmd/llgo/debugtest/README.md | 47 +- internal/browserdebug/index.go | 746 ++++++++++++++++++ internal/browserdebug/index_test.go | 213 +++++ internal/build/debug_artifact_external.go | 8 + .../build/debug_artifact_external_test.go | 15 + internal/wasmdebug/wasmdebug.go | 95 ++- internal/wasmdebug/wasmdebug_test.go | 50 ++ 16 files changed, 3051 insertions(+), 19 deletions(-) create mode 100644 .github/workflows/browser-debug.yml create mode 100644 cmd/internal/browser/browser.go create mode 100644 cmd/internal/browser/browser_test.go create mode 100644 cmd/internal/browser/extension/devtools.html create mode 100644 cmd/internal/browser/extension/manifest.json create mode 100644 cmd/internal/browser/extension/plugin.js create mode 100644 cmd/internal/browser/extension/plugin_test.js create mode 100644 internal/browserdebug/index.go create mode 100644 internal/browserdebug/index_test.go diff --git a/.github/workflows/browser-debug.yml b/.github/workflows/browser-debug.yml new file mode 100644 index 0000000000..efd475b642 --- /dev/null +++ b/.github/workflows/browser-debug.yml @@ -0,0 +1,126 @@ +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}" + + - 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/build/testdata/wasm-runtime + 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" \ + go test -timeout 5m ./internal/browserdebug \ + -run '^(TestLoadLLGoArtifact|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..2b68453956 --- /dev/null +++ b/cmd/internal/browser/browser_test.go @@ -0,0 +1,370 @@ +//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_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...) + } + 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..c008377519 --- /dev/null +++ b/cmd/internal/browser/extension/plugin.js @@ -0,0 +1,628 @@ +// 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; + + 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 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, 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); + return this.activeVariables(module, location.codeOffset).map(variable => ({ + scope: variable.scope, + name: variable.name, + type: module.types.get(variable.type)?.name || '', + })); + } + + 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 path = expression.trim().split('.').filter(Boolean); + if (path.length === 0) return null; + const module = this.module(context.rawModuleId); + const variable = this.activeVariables(module, context.codeOffset).find(item => item.name === path[0]); + if (!variable) return null; + let type = module.types.get(variable.type); + if (!type) return null; + let located; + if (variable.constant) { + located = {kind: 'value', value: constantToValue(variable.constant)}; + } else { + const location = variable.locations.find(item => inRanges(context.codeOffset, [item])); + if (!location) return null; + located = await this.evaluateDWARF(bytesFromHex(location.expression), module, stopId); + if (!located) return null; + } + for (const fieldName of path.slice(1)) { + 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 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); + } + 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: type.name, description: pointer === 0n ? 'nil' : `0x${pointer.toString(16)}`, + objectId: object, hasChildren: pointer !== 0n, + linearMemoryAddress: numberAddress(pointer), linearMemorySize: 0, + }; + } + const stringSpec = module.layout?.string; + if (stringSpec && type.name === stringSpec.type_name) { + return this.stringRemote(module, type, address, stopId, stringSpec); + } + const sliceSpec = module.layout?.slice; + if (sliceSpec && new RegExp(sliceSpec.type_pattern).test(type.name)) { + return this.sliceRemote(module, type, address, stopId, sliceSpec); + } + const objectId = this.storeObject(module, type, address, stopId, 'aggregate'); + return { + type: type.kind === 'array' ? 'array' : 'object', className: type.name, + description: 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, + }; + } + + 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 (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 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 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..d27780be94 --- /dev/null +++ b/cmd/internal/browser/extension/plugin_test.js @@ -0,0 +1,205 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +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/); +}); 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..312cb853fe --- /dev/null +++ b/internal/browserdebug/index_test.go @@ -0,0 +1,213 @@ +//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 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 +} 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))) From 28f43eed8bc1bab9754f1c16b55b9dbaa66f0926 Mon Sep 17 00:00:00 2001 From: Li Jie Date: Sun, 2 Aug 2026 18:09:55 +0800 Subject: [PATCH 2/3] debug: complete browser runtime presentation --- .github/workflows/browser-debug.yml | 5 +- cmd/internal/browser/extension/plugin.js | 449 +++++++++++++++++- cmd/internal/browser/extension/plugin_test.js | 125 +++++ internal/browserdebug/index_test.go | 39 ++ 4 files changed, 590 insertions(+), 28 deletions(-) diff --git a/.github/workflows/browser-debug.yml b/.github/workflows/browser-debug.yml index efd475b642..2042a3f057 100644 --- a/.github/workflows/browser-debug.yml +++ b/.github/workflows/browser-debug.yml @@ -89,7 +89,7 @@ jobs: shell: bash run: | set -euo pipefail - package=./internal/build/testdata/wasm-runtime + 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 \ @@ -119,8 +119,9 @@ jobs: 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|TestLoadUsesLongestSourcePathMapping)$' \ + -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/extension/plugin.js b/cmd/internal/browser/extension/plugin.js index c008377519..1b67934307 100644 --- a/cmd/internal/browser/extension/plugin.js +++ b/cmd/internal/browser/extension/plugin.js @@ -9,6 +9,7 @@ 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; @@ -191,10 +192,14 @@ 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, layout, + 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', @@ -277,11 +282,15 @@ async listVariablesInScope(location) { const module = this.module(location.rawModuleId); - return this.activeVariables(module, location.codeOffset).map(variable => ({ + 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) { @@ -295,23 +304,23 @@ async getInlinedCalleesRanges() { return []; } async evaluate(expression, context, stopId) { - const path = expression.trim().split('.').filter(Boolean); - if (path.length === 0) return null; + const trimmed = expression.trim(); + if (!trimmed) return null; const module = this.module(context.rawModuleId); - const variable = this.activeVariables(module, context.codeOffset).find(item => item.name === path[0]); + 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; - if (variable.constant) { - located = {kind: 'value', value: constantToValue(variable.constant)}; - } else { - const location = variable.locations.find(item => inRanges(context.codeOffset, [item])); - if (!location) return null; - located = await this.evaluateDWARF(bytesFromHex(location.expression), module, stopId); - if (!located) return null; - } - for (const fieldName of path.slice(1)) { + 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); @@ -323,6 +332,19 @@ 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 = []; @@ -399,27 +421,46 @@ 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: type.name, description: pointer === 0n ? 'nil' : `0x${pointer.toString(16)}`, + 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 stringSpec = module.layout?.string; - if (stringSpec && type.name === stringSpec.type_name) { - return this.stringRemote(module, type, address, stopId, stringSpec); - } - const sliceSpec = module.layout?.slice; - if (sliceSpec && new RegExp(sliceSpec.type_pattern).test(type.name)) { - return this.sliceRemote(module, type, address, stopId, sliceSpec); - } const objectId = this.storeObject(module, type, address, stopId, 'aggregate'); return { - type: type.kind === 'array' ? 'array' : 'object', className: type.name, - description: type.name, objectId, hasChildren: true, + 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), }; } @@ -457,6 +498,195 @@ }; } + 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}); @@ -490,6 +720,21 @@ } 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 []; @@ -513,6 +758,117 @@ 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) { @@ -534,6 +890,47 @@ } } + 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')) { diff --git a/cmd/internal/browser/extension/plugin_test.js b/cmd/internal/browser/extension/plugin_test.js index d27780be94..c095e5fb88 100644 --- a/cmd/internal/browser/extension/plugin_test.js +++ b/cmd/internal/browser/extension/plugin_test.js @@ -1,5 +1,6 @@ const assert = require('node:assert/strict'); const test = require('node:test'); +const debuggerSchema = require('../../../../internal/debugabi/schema_v1.json'); require('./plugin.js'); @@ -203,3 +204,127 @@ test('LLGo language extension rejects stale browser indexes', async () => { 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/internal/browserdebug/index_test.go b/internal/browserdebug/index_test.go index 312cb853fe..75802ce68b 100644 --- a/internal/browserdebug/index_test.go +++ b/internal/browserdebug/index_test.go @@ -179,6 +179,27 @@ func TestLoadLLGoArtifact(t *testing.T) { } } +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") @@ -211,3 +232,21 @@ func hasFunction(functions []Function, name string) bool { } 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 +} From 0c046904739eb024fd3c76cbd5962a484844408a Mon Sep 17 00:00:00 2001 From: Li Jie Date: Sun, 2 Aug 2026 18:14:25 +0800 Subject: [PATCH 3/3] ci: allow pinned Chrome on restricted runners --- .github/workflows/browser-debug.yml | 1 + cmd/internal/browser/browser_test.go | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/.github/workflows/browser-debug.yml b/.github/workflows/browser-debug.yml index 2042a3f057..fecada2510 100644 --- a/.github/workflows/browser-debug.yml +++ b/.github/workflows/browser-debug.yml @@ -72,6 +72,7 @@ jobs: [[ "${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 diff --git a/cmd/internal/browser/browser_test.go b/cmd/internal/browser/browser_test.go index 2b68453956..9a534930a6 100644 --- a/cmd/internal/browser/browser_test.go +++ b/cmd/internal/browser/browser_test.go @@ -192,6 +192,9 @@ func TestChromeLanguageExtension(t *testing.T) { 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...) } @@ -258,6 +261,9 @@ func TestChromeWithoutLanguageExtension(t *testing.T) { 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