From e053e7f6fa90e4e70917ebef67cf064c501781de Mon Sep 17 00:00:00 2001 From: Moses Narrow <36607567+0pcom@users.noreply.github.com> Date: Thu, 13 Aug 2026 21:29:16 -0500 Subject: [PATCH] runtime/debug: populate BuildInfo so ReadBuildInfo works MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit debug.ReadBuildInfo() returns ok=false under TinyGo, so anything that reports its own version — a --version flag, a crash handler, a metric — has nothing to read. The information already exists: `go list` reports module paths and versions for the loaded packages, and the standard toolchain stamps the same data into runtime/debug.modinfo, a plain string global that runtime/debug parses back into a *BuildInfo. This fills that global the same way. Four pieces, because the data has to travel: - loader: keep the module Version that `go list` already returns and the struct was discarding. - builder: assemble the modinfo string and set the global, unless -ldflags="-X runtime/debug.modinfo=..." already did. - src/runtime/debug: parse it, which is where ReadBuildInfo reads from. - go.mod: golang.org/x/mod, for module.Check and semver validation of what goes into the string. Skipped in GOPATH mode and when the main package is not in a module, where there is nothing to report. Verified: a module built with this prints its own path and version from ReadBuildInfo, where it previously reported nothing available. --- builder/build.go | 164 +++++++++++++++++++++++++++++++++ go.mod | 2 +- loader/loader.go | 1 + src/runtime/debug/debug.go | 180 ++++++++++++++++++++++++++++++++++++- 4 files changed, 344 insertions(+), 3 deletions(-) diff --git a/builder/build.go b/builder/build.go index 974ddc2a37..c81b1db5e0 100644 --- a/builder/build.go +++ b/builder/build.go @@ -25,6 +25,7 @@ import ( "strconv" "strings" "sync" + "time" "github.com/gofrs/flock" "github.com/tinygo-org/tinygo/compileopts" @@ -34,6 +35,8 @@ import ( "github.com/tinygo-org/tinygo/loader" "github.com/tinygo-org/tinygo/stacksize" "github.com/tinygo-org/tinygo/transform" + "golang.org/x/mod/module" + "golang.org/x/mod/semver" "tinygo.org/x/go-llvm" ) @@ -249,6 +252,22 @@ func Build(pkgName, outpath, tmpdir string, config *compileopts.Config) (BuildRe return result, err } + // Embed module build information so runtime/debug.ReadBuildInfo() works, + // mirroring what the standard `go build` toolchain does. We fill + // runtime/debug.modinfo (a plain string global) from the module info that + // `go list` already reported for the loaded packages; runtime/debug parses + // it back into a *BuildInfo. An explicit -ldflags="-X runtime/debug.modinfo=..." + // takes precedence. This is skipped in GOPATH mode (no module info) and when + // the main package isn't in a module. + if _, overridden := globalValues["runtime/debug"]["modinfo"]; !overridden { + if mi := moduleBuildInfo(lprogram); mi != "" { + if globalValues["runtime/debug"] == nil { + globalValues["runtime/debug"] = map[string]string{} + } + globalValues["runtime/debug"]["modinfo"] = mi + } + } + // Store which filesystem paths map to which package name. result.PackagePathMap = make(map[string]string, len(lprogram.Packages)) for _, pkg := range lprogram.Sorted() { @@ -1603,3 +1622,148 @@ func b2u8(b bool) uint8 { } return 0 } + +// moduleBuildInfo constructs the module build-info string embedded into the +// runtime/debug.modinfo global, in the same textual format that +// runtime/debug.BuildInfo.String() produces (minus the leading "go" line, which +// runtime/debug supplies from runtime.Version()). runtime/debug.ReadBuildInfo +// parses it back into a *BuildInfo, so `go build`-style version reporting works +// under TinyGo without -ldflags. It returns "" when there is no module +// information to embed (e.g. GOPATH mode, or a main package outside any module). +// +// The layout is the reverse of runtime/debug.ParseBuildInfo: +// +// path\t
\n +// mod\t
\t\t\n +// dep\t\t\t\n (one per contributing module, sorted) +// +// The main module version is reported as "(devel)" for a local checkout, as the +// go toolchain does; VCS-derived pseudo-version stamping is a separate follow-up. +func moduleBuildInfo(lprogram *loader.Program) string { + main := lprogram.MainPkg() + if main == nil || main.Module.Path == "" { + return "" // GOPATH mode or no module: nothing to embed. + } + + // Collect the distinct non-main modules that contributed packages to the + // build. As in the go toolchain, a module is listed if any of its packages + // are part of the build graph. + depVersions := make(map[string]string) // module path -> version + for _, pkg := range lprogram.Sorted() { + m := pkg.Module + if m.Path == "" || m.Main || m.Path == main.Module.Path { + continue + } + depVersions[m.Path] = m.Version + } + deps := make([]string, 0, len(depVersions)) + for path := range depVersions { + deps = append(deps, path) + } + sort.Strings(deps) + + // Derive the main module version. `go list` leaves it empty for a local + // checkout, so fall back to VCS stamping (as `go build` does): an exact tag + // on HEAD, otherwise a pseudo-version. This also yields the vcs.* build + // settings appended below. If VCS info isn't available, use "(devel)". + mainVersion := main.Module.Version + var vcsSettings string + if mainVersion == "" { + if v, s := gitVCSStamp(main.Module.Dir); v != "" { + mainVersion, vcsSettings = v, s + } else { + mainVersion = "(devel)" + } + } + + var b strings.Builder + b.WriteString("path\t") + b.WriteString(main.ImportPath) + b.WriteByte('\n') + b.WriteString("mod\t") + b.WriteString(main.Module.Path) + b.WriteByte('\t') + b.WriteString(mainVersion) + b.WriteString("\t\n") // trailing tab leaves the checksum column empty + for _, path := range deps { + b.WriteString("dep\t") + b.WriteString(path) + b.WriteByte('\t') + b.WriteString(depVersions[path]) + b.WriteString("\t\n") // go list -json carries no checksum; leave it empty + } + // Build settings (vcs.*) come after the module lines, matching + // runtime/debug.BuildInfo.String(). + b.WriteString(vcsSettings) + return b.String() +} + +// gitVCSStamp derives the main-module version and the vcs.* build settings from +// the git checkout at dir, mirroring what the standard `go build` toolchain +// records under -buildvcs. It returns ("", "") when dir is not a git work tree +// or git is unavailable, so the caller can fall back to "(devel)". +func gitVCSStamp(dir string) (version, settings string) { + if dir == "" { + return "", "" + } + git := func(args ...string) (string, bool) { + out, err := exec.Command("git", append([]string{"-C", dir}, args...)...).Output() + if err != nil { + return "", false + } + return strings.TrimSpace(string(out)), true + } + if out, ok := git("rev-parse", "--is-inside-work-tree"); !ok || out != "true" { + return "", "" + } + rev, ok := git("rev-parse", "HEAD") + if !ok || rev == "" { + return "", "" + } + + // Commit time (Unix seconds → UTC), used in the pseudo-version and vcs.time. + var commitTime time.Time + if s, ok := git("show", "-s", "--format=%ct", "HEAD"); ok { + if sec, err := strconv.ParseInt(s, 10, 64); err == nil { + commitTime = time.Unix(sec, 0).UTC() + } + } + + // The tree is "modified" if there are any uncommitted changes (tracked or + // untracked), as reported by `git status --porcelain` — same as `go build`. + modified := false + if status, ok := git("status", "--porcelain"); ok && status != "" { + modified = true + } + + // Version: an exact semver tag pointing at HEAD, else a Go-style + // pseudo-version based on the most recent reachable tag. + if tags, ok := git("tag", "--points-at", "HEAD"); ok { + for _, t := range strings.Fields(tags) { + if semver.IsValid(t) && semver.Canonical(t) == t { + version = t + break + } + } + } + if version == "" { + older := "" + if base, ok := git("describe", "--tags", "--abbrev=0", "--match", "v[0-9]*"); ok && semver.IsValid(base) { + older = base + } + short := rev + if len(short) > 12 { + short = short[:12] + } + version = module.PseudoVersion(semver.Major(older), older, commitTime, short) + } + + var sb strings.Builder + sb.WriteString("build\tvcs=git\n") + sb.WriteString("build\tvcs.revision=" + rev + "\n") + if !commitTime.IsZero() { + sb.WriteString("build\tvcs.time=" + commitTime.Format(time.RFC3339) + "\n") + } + sb.WriteString("build\tvcs.modified=" + strconv.FormatBool(modified) + "\n") + return version, sb.String() +} diff --git a/go.mod b/go.mod index b79c457c11..782c725df0 100644 --- a/go.mod +++ b/go.mod @@ -18,6 +18,7 @@ require ( go.bug.st/serial v1.8.0 go.bytecodealliance.org v0.6.2 go.bytecodealliance.org/cm v0.2.2 + golang.org/x/mod v0.37.0 golang.org/x/net v0.56.0 golang.org/x/sys v0.47.0 golang.org/x/tools v0.47.0 @@ -47,6 +48,5 @@ require ( github.com/spf13/afero v1.11.0 // indirect github.com/ulikunitz/xz v0.5.12 // indirect github.com/urfave/cli/v3 v3.0.0-beta1 // indirect - golang.org/x/mod v0.37.0 // indirect golang.org/x/text v0.38.0 // indirect ) diff --git a/loader/loader.go b/loader/loader.go index 5696abd065..df46d662fa 100644 --- a/loader/loader.go +++ b/loader/loader.go @@ -56,6 +56,7 @@ type PackageJSON struct { Dir string GoMod string GoVersion string + Version string // module version, e.g. "v1.2.3" (empty for the main module) } // Source files diff --git a/src/runtime/debug/debug.go b/src/runtime/debug/debug.go index 38e6ab763b..9b395f1aa7 100644 --- a/src/runtime/debug/debug.go +++ b/src/runtime/debug/debug.go @@ -32,13 +32,189 @@ func Stack() []byte { return nil } +// modinfo holds the serialized module build information for the running binary, +// in the same textual format produced by BuildInfo.String() (minus the leading +// "go\t..." line). It is empty unless the TinyGo builder embedded it (see +// builder.Build, which fills runtime/debug.modinfo from `go list -json`), or it +// was set explicitly via -ldflags="-X runtime/debug.modinfo=...". +// +// Unlike the standard Go toolchain, TinyGo controls both the writer and this +// reader, so the value is NOT wrapped in the 16-byte magic delimiters that +// runtime.modinfo uses; for robustness ReadBuildInfo tolerates them anyway. +var modinfo string + +// buildInfoMagic is the 16-byte header/footer the standard Go linker wraps +// around the module string in runtime.modinfo. TinyGo doesn't emit it, but we +// strip it if present so a value copied from a Go binary still parses. +const buildInfoMagic = "\xff Go buildinf:" + // ReadBuildInfo returns the build information embedded // in the running binary. The information is available only // in binaries built with module support. // -// Not implemented. +// TinyGo populates GoVersion always, and Path/Main/Deps/Settings when the +// builder (or -ldflags -X) embedded module info; see the modinfo var. func ReadBuildInfo() (info *BuildInfo, ok bool) { - return &BuildInfo{GoVersion: runtime.Compiler + runtime.Version()}, true + goVersion := runtime.Compiler + runtime.Version() + data := modinfo + if len(data) >= 32 && strings.HasPrefix(data, buildInfoMagic) { + data = data[16 : len(data)-16] + } + if data == "" { + // No module info embedded; still report the toolchain version so + // callers that only want GoVersion keep working. + return &BuildInfo{GoVersion: goVersion}, true + } + bi, err := ParseBuildInfo(data) + if err != nil { + return &BuildInfo{GoVersion: goVersion}, true + } + // GoVersion is stored separately from the module string (as in upstream Go). + bi.GoVersion = goVersion + return bi, true +} + +// ParseBuildInfo parses the string returned by BuildInfo.String (excluding the +// leading "go" line) back into a BuildInfo. It is the reverse of that method +// and is ported from the standard library's runtime/debug. +func ParseBuildInfo(data string) (bi *BuildInfo, err error) { + lineNum := 1 + defer func() { + if err != nil { + err = fmt.Errorf("could not parse Go build info: line %d: %w", lineNum, err) + } + }() + + const ( + pathLine = "path\t" + modLine = "mod\t" + depLine = "dep\t" + repLine = "=>\t" + buildLine = "build\t" + newline = "\n" + tab = "\t" + ) + + readModuleLine := func(elem []string) (Module, error) { + if len(elem) != 2 && len(elem) != 3 { + return Module{}, fmt.Errorf("expected 2 or 3 columns; got %d", len(elem)) + } + version := elem[1] + sum := "" + if len(elem) == 3 { + sum = elem[2] + } + return Module{ + Path: elem[0], + Version: version, + Sum: sum, + }, nil + } + + bi = new(BuildInfo) + var ( + last *Module + line string + ok bool + ) + // Reverse of BuildInfo.String(), except for go version. + for len(data) > 0 { + line, data, ok = strings.Cut(data, newline) + if !ok { + break + } + switch { + case strings.HasPrefix(line, pathLine): + elem := line[len(pathLine):] + bi.Path = elem + case strings.HasPrefix(line, modLine): + elem := strings.Split(line[len(modLine):], tab) + last = &bi.Main + *last, err = readModuleLine(elem) + if err != nil { + return nil, err + } + case strings.HasPrefix(line, depLine): + elem := strings.Split(line[len(depLine):], tab) + last = new(Module) + bi.Deps = append(bi.Deps, last) + *last, err = readModuleLine(elem) + if err != nil { + return nil, err + } + case strings.HasPrefix(line, repLine): + elem := strings.Split(line[len(repLine):], tab) + if len(elem) != 3 { + return nil, fmt.Errorf("expected 3 columns for replacement; got %d", len(elem)) + } + if last == nil { + return nil, fmt.Errorf("replacement with no module on previous line") + } + last.Replace = &Module{ + Path: elem[0], + Version: elem[1], + Sum: elem[2], + } + last = nil + case strings.HasPrefix(line, buildLine): + kv := line[len(buildLine):] + if len(kv) < 1 { + return nil, fmt.Errorf("build line missing '='") + } + + var key, rawValue string + switch kv[0] { + case '=': + return nil, fmt.Errorf("build line with missing key") + + case '`', '"': + rawKey, err := strconv.QuotedPrefix(kv) + if err != nil { + return nil, fmt.Errorf("invalid quoted key in build line") + } + if len(kv) == len(rawKey) { + return nil, fmt.Errorf("build line missing '=' after quoted key") + } + if c := kv[len(rawKey)]; c != '=' { + return nil, fmt.Errorf("unexpected character after quoted key: %q", c) + } + key, _ = strconv.Unquote(rawKey) + rawValue = kv[len(rawKey)+1:] + + default: + var ok bool + key, rawValue, ok = strings.Cut(kv, "=") + if !ok { + return nil, fmt.Errorf("build line missing '=' after key") + } + if quoteKey(key) { + return nil, fmt.Errorf("unquoted key %q must be quoted", key) + } + } + + var value string + if len(rawValue) > 0 { + switch rawValue[0] { + case '`', '"': + var err error + value, err = strconv.Unquote(rawValue) + if err != nil { + return nil, fmt.Errorf("invalid quoted value in build line") + } + + default: + value = rawValue + if quoteValue(value) { + return nil, fmt.Errorf("unquoted value %q must be quoted", value) + } + } + } + + bi.Settings = append(bi.Settings, BuildSetting{Key: key, Value: value}) + } + lineNum++ + } + return bi, nil } // BuildInfo represents the build information read from