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 1/6] 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 From 822d1741404cb6efb4165b68b4532c8210dd3dc3 Mon Sep 17 00:00:00 2001 From: Moses Narrow <36607567+0pcom@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:58:35 -0500 Subject: [PATCH 2/6] runtime/debug: test ReadBuildInfo and the module string parser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds testdata/buildinfo.go to the compiler test list. The output has to be identical on every machine, so nothing here prints a version, a module path or a checksum the build happens to have. What is checked is either derived — that ReadBuildInfo always succeeds, that it reports a toolchain version even with no module information embedded, and that the version names the compiler — or comes from a fixed module string parsed in the test. That string covers the four line kinds and a replacement, so the parse, the round trip back through BuildInfo.String, and the shapes that must be rejected are all exercised. The distinction the last group draws is the one worth having: a line whose prefix is not a known kind is skipped, which is what upstream does and what lets an older parser read a newer module string, while a known kind with the wrong number of columns is an error — ReadBuildInfo falls back to reporting just the toolchain version when that happens. --- main_test.go | 1 + testdata/buildinfo.go | 119 +++++++++++++++++++++++++++++++++++++++++ testdata/buildinfo.txt | 25 +++++++++ 3 files changed, 145 insertions(+) create mode 100644 testdata/buildinfo.go create mode 100644 testdata/buildinfo.txt diff --git a/main_test.go b/main_test.go index 2dfd1683f6..30dcd6ca89 100644 --- a/main_test.go +++ b/main_test.go @@ -55,6 +55,7 @@ func TestBuild(t *testing.T) { "alias.go", "atomic.go", "binop.go", + "buildinfo.go", "calls.go", "cgo/", "channel.go", diff --git a/testdata/buildinfo.go b/testdata/buildinfo.go new file mode 100644 index 0000000000..f8687e5aa8 --- /dev/null +++ b/testdata/buildinfo.go @@ -0,0 +1,119 @@ +package main + +// Tests runtime/debug.ReadBuildInfo and the parser behind it. +// +// The output has to be the same on every machine, so nothing here prints a +// version, a module path or a checksum that the build happens to have. What is +// printed is either derived (does the version exist, does it name the +// compiler) or comes from the fixed string parsed below. + +import ( + "runtime" + "runtime/debug" + "strings" +) + +// A module string in the format BuildInfo.String writes, minus the leading go +// line — which is what ParseBuildInfo takes. Covers the four line kinds: path, +// mod, dep and build, plus a replaced dependency. +const sample = "path\texample.com/prog\n" + + "mod\texample.com/prog\t(devel)\t\n" + + "dep\texample.com/a\tv1.2.3\th1:aaa=\n" + + "dep\texample.com/b\tv0.1.0\t\n" + + "=>\texample.com/b-fork\tv0.2.0\th1:bbb=\n" + + "build\t-tags=sample\n" + + "build\tCGO_ENABLED=0\n" + +func main() { + readBuildInfo() + parse() + roundTrip() + malformed() +} + +func readBuildInfo() { + info, ok := debug.ReadBuildInfo() + // ReadBuildInfo always succeeds: with no module information embedded it + // still reports the toolchain version, so callers that only want + // GoVersion keep working. + println("read ok:", ok) + println("info not nil:", info != nil) + if info == nil { + return + } + println("go version set:", info.GoVersion != "") + println("names the compiler:", strings.HasPrefix(info.GoVersion, runtime.Compiler)) +} + +func parse() { + bi, err := debug.ParseBuildInfo(sample) + if err != nil { + println("parse error:", err.Error()) + return + } + println("path:", bi.Path) + println("main path:", bi.Main.Path) + println("main version:", bi.Main.Version) + println("deps:", len(bi.Deps)) + for _, d := range bi.Deps { + println("dep:", d.Path, d.Version, d.Sum) + if d.Replace != nil { + println(" replaced by:", d.Replace.Path, d.Replace.Version, d.Replace.Sum) + } + } + println("settings:", len(bi.Settings)) + for _, s := range bi.Settings { + println("setting:", s.Key, s.Value) + } +} + +func roundTrip() { + bi, err := debug.ParseBuildInfo(sample) + if err != nil { + println("round trip parse error:", err.Error()) + return + } + // String writes a leading go line that ParseBuildInfo does not read, so it + // is dropped before parsing again. Everything else must survive. + out := bi.String() + if i := strings.Index(out, "\n"); i >= 0 && strings.HasPrefix(out, "go\t") { + out = out[i+1:] + } + again, err := debug.ParseBuildInfo(out) + if err != nil { + println("round trip reparse error:", err.Error()) + return + } + println("round trip path:", again.Path == bi.Path) + println("round trip main:", again.Main == bi.Main) + println("round trip deps:", len(again.Deps) == len(bi.Deps)) + println("round trip settings:", len(again.Settings) == len(bi.Settings)) +} + +func malformed() { + // A line whose prefix is not one of the known kinds is skipped rather than + // rejected, which is what upstream does and what keeps an older parser + // reading a newer module string. + for _, in := range []string{ + "path\n", // "path" without the tab is not the path line + "build\n", // likewise + "future\tsomething\n", // a line kind this version does not know + } { + _, err := debug.ParseBuildInfo(in) + println("unknown line skipped:", err == nil) + } + + // A line that is one of the known kinds but the wrong shape is an error, + // because that is a module string this parser has misread rather than one + // it does not recognize. ReadBuildInfo falls back to the toolchain version + // when that happens. + for _, in := range []string{ + "mod\texample.com/prog\n", // a module needs 2 or 3 columns + "dep\texample.com/a\n", // likewise + "=>\texample.com/x\tv1.0.0\th1:x=\n", // a replacement with nothing to replace + "mod\ta\tb\tc\td\n", // too many columns + } { + _, err := debug.ParseBuildInfo(in) + println("malformed rejected:", err != nil) + } +} diff --git a/testdata/buildinfo.txt b/testdata/buildinfo.txt new file mode 100644 index 0000000000..f2bb702781 --- /dev/null +++ b/testdata/buildinfo.txt @@ -0,0 +1,25 @@ +read ok: true +info not nil: true +go version set: true +names the compiler: true +path: example.com/prog +main path: example.com/prog +main version: (devel) +deps: 2 +dep: example.com/a v1.2.3 h1:aaa= +dep: example.com/b v0.1.0 + replaced by: example.com/b-fork v0.2.0 h1:bbb= +settings: 2 +setting: -tags sample +setting: CGO_ENABLED 0 +round trip path: true +round trip main: true +round trip deps: true +round trip settings: true +unknown line skipped: true +unknown line skipped: true +unknown line skipped: true +malformed rejected: true +malformed rejected: true +malformed rejected: true +malformed rejected: true From 445d4fba9a7f4d7adfcd3b8407129f6a0bc2e740 Mon Sep 17 00:00:00 2001 From: Moses Narrow <36607567+0pcom@users.noreply.github.com> Date: Thu, 20 Aug 2026 07:20:48 -0500 Subject: [PATCH 3/6] main_test: skip buildinfo.go on AVR It overflows the ATmega by about 4.8 KiB of flash and 5.3 KiB of RAM: the parser builds a BuildInfo and formats errors, which pulls in fmt and strings. The same reason json.go, stdlib.go and testing.go are skipped there. AVR was the only target that failed; every other one in the matrix ran it. --- main_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main_test.go b/main_test.go index 30dcd6ca89..1ff437fcf1 100644 --- a/main_test.go +++ b/main_test.go @@ -330,7 +330,7 @@ func runPlatTests(options compileopts.Options, tests []string, t *testing.T) { // Does not pass due to high mark false positive rate. continue - case "json.go", "stdlib.go", "testing.go": + case "buildinfo.go", "json.go", "stdlib.go", "testing.go": // Too big for AVR. Doesn't fit in flash/RAM. continue From 3ade2ea4e4d356650251561b8393daf087fbc461 Mon Sep 17 00:00:00 2001 From: Moses Narrow <36607567+0pcom@users.noreply.github.com> Date: Thu, 20 Aug 2026 19:59:41 -0500 Subject: [PATCH 4/6] runtime/debug: drop fmt from the build info parser The parser and BuildInfo.String used fmt.Errorf and fmt.Fprintf, which links fmt into any binary that calls ReadBuildInfo. That is what made testdata/buildinfo.go overflow the ATmega and need an AVR skip. Replacing those with errors.New, strconv and strings.Builder writes cuts a wasip1 binary that calls ReadBuildInfo from 677,404 to 293,340 bytes, 56.7% smaller. The testdata program itself goes from 685,718 to 303,901. A binary that never calls ReadBuildInfo is unaffected either way -- the linker already drops the parser and the embedded string when nothing references them. Error messages are unchanged. The line-number wrap keeps %w semantics via a parseError type with an Unwrap method rather than fmt.Errorf, so errors.Is and errors.As behave as they did. %q on a byte formats a single-quoted rune, so that one site uses strconv.QuoteRune, not Quote. --- src/runtime/debug/debug.go | 61 +++++++++++++++++++++++++++----------- 1 file changed, 44 insertions(+), 17 deletions(-) diff --git a/src/runtime/debug/debug.go b/src/runtime/debug/debug.go index 9b395f1aa7..6905a23901 100644 --- a/src/runtime/debug/debug.go +++ b/src/runtime/debug/debug.go @@ -6,7 +6,7 @@ package debug import ( - "fmt" + "errors" "runtime" "strconv" "strings" @@ -74,6 +74,24 @@ func ReadBuildInfo() (info *BuildInfo, ok bool) { return bi, true } +// parseError reports which line of the module string failed to parse. +// +// Upstream spells this fmt.Errorf("...: line %d: %w", ...). Doing the same +// here would link fmt into every binary that calls ReadBuildInfo, which on a +// small target costs more flash than the feature is worth, so the wrapping is +// written out by hand. The message is identical and Unwrap keeps errors.Is +// and errors.As working as they do upstream. +type parseError struct { + line int + err error +} + +func (e *parseError) Error() string { + return "could not parse Go build info: line " + strconv.Itoa(e.line) + ": " + e.err.Error() +} + +func (e *parseError) Unwrap() error { return e.err } + // 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. @@ -81,7 +99,7 @@ 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) + err = &parseError{line: lineNum, err: err} } }() @@ -97,7 +115,7 @@ func ParseBuildInfo(data string) (bi *BuildInfo, err error) { 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)) + return Module{}, errors.New("expected 2 or 3 columns; got " + strconv.Itoa(len(elem))) } version := elem[1] sum := "" @@ -145,10 +163,10 @@ func ParseBuildInfo(data string) (bi *BuildInfo, err error) { 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)) + return nil, errors.New("expected 3 columns for replacement; got " + strconv.Itoa(len(elem))) } if last == nil { - return nil, fmt.Errorf("replacement with no module on previous line") + return nil, errors.New("replacement with no module on previous line") } last.Replace = &Module{ Path: elem[0], @@ -159,24 +177,25 @@ func ParseBuildInfo(data string) (bi *BuildInfo, err error) { case strings.HasPrefix(line, buildLine): kv := line[len(buildLine):] if len(kv) < 1 { - return nil, fmt.Errorf("build line missing '='") + return nil, errors.New("build line missing '='") } var key, rawValue string switch kv[0] { case '=': - return nil, fmt.Errorf("build line with missing key") + return nil, errors.New("build line with missing key") case '`', '"': rawKey, err := strconv.QuotedPrefix(kv) if err != nil { - return nil, fmt.Errorf("invalid quoted key in build line") + return nil, errors.New("invalid quoted key in build line") } if len(kv) == len(rawKey) { - return nil, fmt.Errorf("build line missing '=' after quoted key") + return nil, errors.New("build line missing '=' after quoted key") } if c := kv[len(rawKey)]; c != '=' { - return nil, fmt.Errorf("unexpected character after quoted key: %q", c) + // %q on a byte formats a single-quoted rune, not a string. + return nil, errors.New("unexpected character after quoted key: " + strconv.QuoteRune(rune(c))) } key, _ = strconv.Unquote(rawKey) rawValue = kv[len(rawKey)+1:] @@ -185,10 +204,10 @@ func ParseBuildInfo(data string) (bi *BuildInfo, err error) { var ok bool key, rawValue, ok = strings.Cut(kv, "=") if !ok { - return nil, fmt.Errorf("build line missing '=' after key") + return nil, errors.New("build line missing '=' after key") } if quoteKey(key) { - return nil, fmt.Errorf("unquoted key %q must be quoted", key) + return nil, errors.New("unquoted key " + strconv.Quote(key) + " must be quoted") } } @@ -199,13 +218,13 @@ func ParseBuildInfo(data string) (bi *BuildInfo, err error) { var err error value, err = strconv.Unquote(rawValue) if err != nil { - return nil, fmt.Errorf("invalid quoted value in build line") + return nil, errors.New("invalid quoted value in build line") } default: value = rawValue if quoteValue(value) { - return nil, fmt.Errorf("unquoted value %q must be quoted", value) + return nil, errors.New("unquoted value " + strconv.Quote(value) + " must be quoted") } } } @@ -262,10 +281,14 @@ func quoteValue(value string) bool { func (bi *BuildInfo) String() string { buf := new(strings.Builder) if bi.GoVersion != "" { - fmt.Fprintf(buf, "go\t%s\n", bi.GoVersion) + buf.WriteString("go\t") + buf.WriteString(bi.GoVersion) + buf.WriteByte('\n') } if bi.Path != "" { - fmt.Fprintf(buf, "path\t%s\n", bi.Path) + buf.WriteString("path\t") + buf.WriteString(bi.Path) + buf.WriteByte('\n') } var formatMod func(string, Module) formatMod = func(word string, m Module) { @@ -298,7 +321,11 @@ func (bi *BuildInfo) String() string { if quoteValue(value) { value = strconv.Quote(value) } - fmt.Fprintf(buf, "build\t%s=%s\n", key, value) + buf.WriteString("build\t") + buf.WriteString(key) + buf.WriteByte('=') + buf.WriteString(value) + buf.WriteByte('\n') } return buf.String() From 2457360276c3a74484f1dc54dce183c941c218a7 Mon Sep 17 00:00:00 2001 From: Moses Narrow <36607567+0pcom@users.noreply.github.com> Date: Thu, 20 Aug 2026 20:04:47 -0500 Subject: [PATCH 5/6] builder: add -buildvcs to control VCS stamping Stamping runs six git subprocesses on every build, one of which is `git status --porcelain` over the whole work tree. That happens whether or not the program ever calls ReadBuildInfo, and the go toolchain has -buildvcs for exactly this reason, so mirror it rather than inventing something: "auto" (the default, current behaviour), "false" to skip it, "true" to require it. As in the go toolchain, "true" is an error when no stamp can be produced, rather than silently falling back to "(devel)". The wall-clock saving on a small module is inside the noise; what -buildvcs=false removes is the repository access itself (five git subprocesses), which matters on a large work tree, on a network filesystem, and for reproducible or sandboxed builds where the tree is not a git checkout at all. --- builder/build.go | 37 ++++++++++++++++++++++++++++++------- compileopts/options.go | 10 +++++++++- compileopts/options_test.go | 26 ++++++++++++++++++++++++++ main.go | 2 ++ 4 files changed, 67 insertions(+), 8 deletions(-) diff --git a/builder/build.go b/builder/build.go index c81b1db5e0..3971a07452 100644 --- a/builder/build.go +++ b/builder/build.go @@ -260,7 +260,11 @@ func Build(pkgName, outpath, tmpdir string, config *compileopts.Config) (BuildRe // 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 != "" { + mi, err := moduleBuildInfo(lprogram, config.Options.BuildVCS) + if err != nil { + return BuildResult{}, err + } + if mi != "" { if globalValues["runtime/debug"] == nil { globalValues["runtime/debug"] = map[string]string{} } @@ -1639,10 +1643,16 @@ func b2u8(b bool) uint8 { // // 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 { +// buildVCS selects whether the vcs.* settings and a VCS-derived version are +// stamped in: "false" skips it, "true" and "auto" (and "", the zero value, for +// callers that don't set it) stamp when a usable repository is found. This +// mirrors the go toolchain's -buildvcs, including its reason for existing: +// stamping shells out to git on every build, which a caller may not want to +// pay for. +func moduleBuildInfo(lprogram *loader.Program, buildVCS string) (string, error) { main := lprogram.MainPkg() if main == nil || main.Module.Path == "" { - return "" // GOPATH mode or no module: nothing to embed. + return "", nil // GOPATH mode or no module: nothing to embed. } // Collect the distinct non-main modules that contributed packages to the @@ -1669,10 +1679,23 @@ func moduleBuildInfo(lprogram *loader.Program) string { mainVersion := main.Module.Version var vcsSettings string if mainVersion == "" { - if v, s := gitVCSStamp(main.Module.Dir); v != "" { - mainVersion, vcsSettings = v, s - } else { + switch { + case buildVCS == "false": + // Don't touch the repository at all: no git subprocesses run. mainVersion = "(devel)" + default: + if v, s := gitVCSStamp(main.Module.Dir); v != "" { + mainVersion, vcsSettings = v, s + } else if buildVCS == "true" { + // -buildvcs=true means the stamp was demanded, so failing to + // produce one is an error rather than a silent fallback. This + // matches the go toolchain. + return "", fmt.Errorf("error obtaining VCS status for %s: "+ + "not a git work tree, or git is unavailable\n"+ + "\tUse -buildvcs=false to disable VCS stamping.", main.Module.Dir) + } else { + mainVersion = "(devel)" + } } } @@ -1695,7 +1718,7 @@ func moduleBuildInfo(lprogram *loader.Program) string { // Build settings (vcs.*) come after the module lines, matching // runtime/debug.BuildInfo.String(). b.WriteString(vcsSettings) - return b.String() + return b.String(), nil } // gitVCSStamp derives the main-module version and the vcs.* build settings from diff --git a/compileopts/options.go b/compileopts/options.go index cb5f24d5c0..69bb6b54e5 100644 --- a/compileopts/options.go +++ b/compileopts/options.go @@ -16,6 +16,7 @@ var ( validPrintSizeOptions = []string{"none", "short", "full", "html"} validPanicStrategyOptions = []string{"print", "trap"} validOptOptions = []string{"none", "0", "1", "2", "s", "z"} + validBuildVCSOptions = []string{"auto", "true", "false"} ) // Options contains extra options to give to the compiler. These options are @@ -62,7 +63,8 @@ type Options struct { WITPackage string // pass through to wasm-tools component embed invocation WITWorld string // pass through to wasm-tools component embed -w option ExtLDFlags []string - GoCompatibility bool // enable to check for Go version compatibility + GoCompatibility bool // enable to check for Go version compatibility + BuildVCS string // -buildvcs: "auto" (default), "true" or "false" } // Verify performs a validation on the given options, raising an error if options are not valid. @@ -126,5 +128,11 @@ func (o *Options) Verify() error { } } + if o.BuildVCS != "" { + if !slices.Contains(validBuildVCSOptions, o.BuildVCS) { + return fmt.Errorf("invalid -buildvcs=%s: valid values are %s", o.BuildVCS, strings.Join(validBuildVCSOptions, ", ")) + } + } + return nil } diff --git a/compileopts/options_test.go b/compileopts/options_test.go index dd098e6c4a..b694bfc023 100644 --- a/compileopts/options_test.go +++ b/compileopts/options_test.go @@ -13,6 +13,7 @@ func TestVerifyOptions(t *testing.T) { expectedSchedulerError := errors.New(`invalid scheduler option 'incorrect': valid values are none, tasks, asyncify, threads, cores`) expectedPrintSizeError := errors.New(`invalid size option 'incorrect': valid values are none, short, full, html`) expectedPanicStrategyError := errors.New(`invalid panic option 'incorrect': valid values are print, trap`) + expectedBuildVCSError := errors.New(`invalid -buildvcs=incorrect: valid values are auto, true, false`) testCases := []struct { name string @@ -117,6 +118,31 @@ func TestVerifyOptions(t *testing.T) { PanicStrategy: "trap", }, }, + { + name: "InvalidBuildVCSOption", + opts: compileopts.Options{ + BuildVCS: "incorrect", + }, + expectedError: expectedBuildVCSError, + }, + { + name: "BuildVCSOptionAuto", + opts: compileopts.Options{ + BuildVCS: "auto", + }, + }, + { + name: "BuildVCSOptionTrue", + opts: compileopts.Options{ + BuildVCS: "true", + }, + }, + { + name: "BuildVCSOptionFalse", + opts: compileopts.Options{ + BuildVCS: "false", + }, + }, } for _, tc := range testCases { diff --git a/main.go b/main.go index 986e8c176a..757b3c5cf4 100644 --- a/main.go +++ b/main.go @@ -1775,6 +1775,7 @@ func main() { flag.Var(&tags, "tags", "a space-separated list of extra build tags") target := flag.String("target", "", "chip/board name or JSON target specification file") buildMode := flag.String("buildmode", "", "build mode to use (default, c-shared, wasi-legacy)") + buildVCS := flag.String("buildvcs", "auto", "whether to stamp version control information (true, false, auto)") var stackSize uint64 flag.Func("stack-size", "goroutine stack size (if unknown at compile time)", func(s string) error { size, err := bytesize.Parse(s) @@ -1901,6 +1902,7 @@ func main() { GOMIPS: goenv.Get("GOMIPS"), Target: *target, BuildMode: *buildMode, + BuildVCS: *buildVCS, StackSize: stackSize, Opt: *opt, GC: *gc, From e4a80e9886cf95ca7790253e620fcf4a8dbf36cb Mon Sep 17 00:00:00 2001 From: Moses Narrow <36607567+0pcom@users.noreply.github.com> Date: Sat, 22 Aug 2026 09:06:53 -0500 Subject: [PATCH 6/6] runtime/debug, builder: address review buildInfoMagic was wrong and is removed. The constant held the 14-byte ".go.buildinfo" section magic, not the 16-byte modinfo wrapper cmd/go uses (infoStart in modload/build.go), while the code stripped 16 bytes and the comment claimed the constant was 16 bytes. The branch could never fire on a real modinfo string. TinyGo controls the writer, so nothing needs stripping at all. gitVCSStamp: a modified work tree now yields "+dirty", as go build does; previously "modified" was recorded in vcs.modified but ignored for the version, so a dirty tree reported a clean tag. gitVCSStamp: refuse to stamp when the module is not at the repository root. The tag searches run in the module directory and so could pick up a parent repository's tags, versioning a nested module with something that is not its own. Also check the major version against any /vN suffix on the module path. That check is deliberately CheckPathMajor and not module.Check, because module.Check additionally rejects a path whose first element has no dot, which is normal for a module that is never published and would lose those their stamp. gitVCSStamp: an unparsable commit time is now a stamp failure rather than a zero time, which module.PseudoVersion would have encoded as 00010101000000. gitVCSStamp: keep the first git failure and report it, so -buildvcs=true distinguishes a refusal over safe.directory or permissions from "not a git work tree". vcs.time now uses RFC3339Nano, matching cmd/go. Documented what the writer deliberately omits. Dropped a single-case switch, and matched the surrounding return style. --- builder/build.go | 113 +++++++++++++++++++++++++++++-------- src/runtime/debug/debug.go | 17 ++---- 2 files changed, 94 insertions(+), 36 deletions(-) diff --git a/builder/build.go b/builder/build.go index 3971a07452..637978d3f0 100644 --- a/builder/build.go +++ b/builder/build.go @@ -262,7 +262,7 @@ func Build(pkgName, outpath, tmpdir string, config *compileopts.Config) (BuildRe if _, overridden := globalValues["runtime/debug"]["modinfo"]; !overridden { mi, err := moduleBuildInfo(lprogram, config.Options.BuildVCS) if err != nil { - return BuildResult{}, err + return result, err } if mi != "" { if globalValues["runtime/debug"] == nil { @@ -1679,21 +1679,25 @@ func moduleBuildInfo(lprogram *loader.Program, buildVCS string) (string, error) mainVersion := main.Module.Version var vcsSettings string if mainVersion == "" { - switch { - case buildVCS == "false": + if buildVCS == "false" { // Don't touch the repository at all: no git subprocesses run. mainVersion = "(devel)" - default: - if v, s := gitVCSStamp(main.Module.Dir); v != "" { + } else { + v, s, gitErr := gitVCSStamp(main.Module.Dir, main.Module.Path) + switch { + case v != "": mainVersion, vcsSettings = v, s - } else if buildVCS == "true" { + case buildVCS == "true": // -buildvcs=true means the stamp was demanded, so failing to // produce one is an error rather than a silent fallback. This // matches the go toolchain. - return "", fmt.Errorf("error obtaining VCS status for %s: "+ - "not a git work tree, or git is unavailable\n"+ - "\tUse -buildvcs=false to disable VCS stamping.", main.Module.Dir) - } else { + reason := "not a git work tree, the module is not at the repository root, or git is unavailable" + if gitErr != nil { + reason = gitErr.Error() + } + return "", fmt.Errorf("error obtaining VCS status for %s: %s\n"+ + "\tUse -buildvcs=false to disable VCS stamping.", main.Module.Dir, reason) + default: mainVersion = "(devel)" } } @@ -1723,34 +1727,73 @@ func moduleBuildInfo(lprogram *loader.Program, buildVCS string) (string, error) // 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) { +// records under -buildvcs. modPath is the main module's path, used to reject a +// version that belongs to some other module. +// +// It returns ("", "", nil) when there is simply no stamp to make — dir is not a +// git work tree, or git is unavailable — so the caller falls back to "(devel)". +// The error is non-nil only when git itself failed in a way worth reporting, +// which -buildvcs=true turns into a hard failure. +func gitVCSStamp(dir, modPath string) (version, settings string, gitErr error) { if dir == "" { - return "", "" + return "", "", nil } + // Keep the first real git failure, so -buildvcs=true can say what actually + // went wrong. "not a work tree" and a refusal over safe.directory or file + // permissions are very different problems and should not share a message. + var firstErr error git := func(args ...string) (string, bool) { - out, err := exec.Command("git", append([]string{"-C", dir}, args...)...).Output() + cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) + var stderr strings.Builder + cmd.Stderr = &stderr + out, err := cmd.Output() if err != nil { + if firstErr == nil { + msg := strings.TrimSpace(stderr.String()) + if msg == "" { + msg = err.Error() + } + firstErr = fmt.Errorf("git %s: %s", strings.Join(args, " "), msg) + } return "", false } return strings.TrimSpace(string(out)), true } if out, ok := git("rev-parse", "--is-inside-work-tree"); !ok || out != "true" { - return "", "" + return "", "", firstErr + } + // The tags below are searched from the module directory, so they can belong + // to a parent repository. Only stamp when the module is itself the thing the + // repository is versioning; otherwise a monorepo, or a nested module, gets a + // version that is not its own. The go toolchain makes the same check. + root, ok := git("rev-parse", "--show-toplevel") + if !ok { + return "", "", firstErr + } + if absDir, err := filepath.Abs(dir); err == nil { + if absRoot, err := filepath.Abs(root); err == nil && absDir != absRoot { + return "", "", nil // module is not at the repository root; no stamp + } } rev, ok := git("rev-parse", "HEAD") if !ok || rev == "" { - return "", "" + return "", "", firstErr } // Commit time (Unix seconds → UTC), used in the pseudo-version and vcs.time. + // A missing or unparsable time is a stamp failure rather than something to + // paper over: module.PseudoVersion would otherwise encode the zero time as + // 00010101000000 and produce a version that looks real but is not. 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() - } + s, ok := git("show", "-s", "--format=%ct", "HEAD") + if !ok { + return "", "", firstErr + } + sec, err := strconv.ParseInt(s, 10, 64) + if err != nil { + return "", "", fmt.Errorf("git show -s --format=%%ct: unparsable commit time %q", s) } + 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`. @@ -1781,12 +1824,32 @@ func gitVCSStamp(dir string) (version, settings string) { version = module.PseudoVersion(semver.Major(older), older, commitTime, short) } + // The version came from whatever tags git found, which says nothing about + // the module path. Reject the mismatch that produces: a v1 tag on a module + // whose path ends in /v2, and the reverse. + // + // This deliberately checks only the major version, not the whole path. + // module.Check would also reject a path with no dot in its first element, + // which is a perfectly ordinary thing for a module that is never published, + // and stripping the stamp from those would be a regression. + if _, pathMajor, ok := module.SplitPathVersion(modPath); ok { + if err := module.CheckPathMajor(version, pathMajor); err != nil { + return "", "", nil // not our version to claim; fall back to (devel) + } + } + + // A modified work tree does not describe the tagged commit any more, so say + // so, exactly as `go build` does. + if modified { + version += "+dirty" + } + 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") - } + // RFC3339Nano, matching cmd/go. %ct only gives whole seconds, so nothing is + // lost either way, but the format should be the one Go readers expect. + sb.WriteString("build\tvcs.time=" + commitTime.Format(time.RFC3339Nano) + "\n") sb.WriteString("build\tvcs.modified=" + strconv.FormatBool(modified) + "\n") - return version, sb.String() + return version, sb.String(), nil } diff --git a/src/runtime/debug/debug.go b/src/runtime/debug/debug.go index 6905a23901..ae9a0ea8ba 100644 --- a/src/runtime/debug/debug.go +++ b/src/runtime/debug/debug.go @@ -38,16 +38,14 @@ func Stack() []byte { // 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. +// TinyGo controls both the writer and this reader, so the value is stored bare: +// it carries none of the delimiters cmd/go wraps around runtime.modinfo. +// +// What TinyGo writes is deliberately a subset. There are no "=>" replacement +// lines and no checksums, and none of the build settings the go toolchain +// records (-tags, GOOS, GOARCH, CGO_ENABLED and so on) beyond the vcs.* ones. 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. @@ -57,9 +55,6 @@ const buildInfoMagic = "\xff Go buildinf:" func ReadBuildInfo() (info *BuildInfo, ok bool) { 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.