test(verify): gate GOOBJ link parity with cmd/link
Assisted-by: GLM 5.3
This commit is contained in:
@@ -0,0 +1,326 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package verify
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The GOOBJ link regression: for every supported architecture, a small
|
||||
// assembly kernel is assembled by the gasm CLI into a GOOBJ object, the
|
||||
// object is substituted for the toolchain's inside a real `go build`'s
|
||||
// package archive, and cmd/link is re-run on it. This is the parity
|
||||
// gate the roadmap demands: what `gasm asm --format goobj` emits must be
|
||||
// consumed by the toolchain's own linker, not only inspected by hand.
|
||||
//
|
||||
// The hard claims, per architecture:
|
||||
//
|
||||
// - the re-link with the gasm object succeeds (all four architectures);
|
||||
// - the linked binary produces exactly the baseline binary's output,
|
||||
// run natively on amd64 and under qemu-user on arm64 and riscv64
|
||||
// where the emulator is installed;
|
||||
// - loong64 is link-only: the run claim is waived for it, because a
|
||||
// Go runtime coming up under qemu-user is not something this
|
||||
// architecture's emulator fleet can be assumed to provide.
|
||||
//
|
||||
// The archive member is byte-compared against the gasm object after the
|
||||
// repack, so a build-layout change that silently skipped the substitution
|
||||
// fails the test instead of passing it vacuously.
|
||||
|
||||
// goobjLinkModule is the Go side of the regression module. The assembly
|
||||
// declares the five functions; main verifies their semantics and prints a
|
||||
// deterministic line the linked binaries must agree on.
|
||||
const goobjLinkMainSrc = `package main
|
||||
|
||||
func add(a, b int64) int64
|
||||
func seven() int64
|
||||
func probe() int64
|
||||
func entryAddr() uintptr
|
||||
func counterVal() int64
|
||||
|
||||
func main() {
|
||||
const word = 0x0fedcba987654321
|
||||
if add(20, 22) != 42 {
|
||||
panic("add")
|
||||
}
|
||||
if probe() != 7+word {
|
||||
panic("probe")
|
||||
}
|
||||
if entryAddr() == 0 {
|
||||
panic("entry")
|
||||
}
|
||||
if counterVal() != 1234 {
|
||||
panic("counter")
|
||||
}
|
||||
println("ok", add(1, 2), probe(), entryAddr() != 0, counterVal())
|
||||
}
|
||||
`
|
||||
|
||||
// qemuEmulator maps a target GOARCH to its qemu-user program name.
|
||||
var qemuEmulator = map[string]string{
|
||||
"amd64": "qemu-x86_64",
|
||||
"arm64": "qemu-aarch64",
|
||||
"riscv64": "qemu-riscv64",
|
||||
}
|
||||
|
||||
// goobjLinkRunner returns the command prefix that executes a binary of
|
||||
// the target architecture on this machine, and whether the run claim
|
||||
// applies at all. A native host runs natively; otherwise qemu-user is
|
||||
// required, and loong64 never runs (the runtime does not start under
|
||||
// qemu-user there, the documented decision).
|
||||
func goobjLinkRunner(t *testing.T, goarch string) ([]string, bool) {
|
||||
t.Helper()
|
||||
if runtime.GOARCH == goarch {
|
||||
return nil, true
|
||||
}
|
||||
if goarch == "loong64" {
|
||||
return nil, false
|
||||
}
|
||||
qemu, err := exec.LookPath(qemuEmulator[goarch])
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return []string{qemu}, true
|
||||
}
|
||||
|
||||
// runGoobjBinary executes the linked binary under the given prefix (empty
|
||||
// for a native run) and returns its combined output.
|
||||
func runGoobjBinary(prefix []string, path string) ([]byte, error) {
|
||||
args := append(prefix, path)
|
||||
cmd := exec.Command(args[0], args[1:]...)
|
||||
return cmd.CombinedOutput()
|
||||
}
|
||||
|
||||
// fieldAfterLinkLog returns the whitespace-delimited field following the
|
||||
// first occurrence of flag in line.
|
||||
func fieldAfterLinkLog(line, flag string) string {
|
||||
fields := strings.Fields(line)
|
||||
for i, f := range fields {
|
||||
if f == flag && i+1 < len(fields) {
|
||||
return fields[i+1]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// TestGOOBJLinkRegression is the per-architecture end-to-end gate
|
||||
// described at the top of this file.
|
||||
func TestGOOBJLinkRegression(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("builds the gasm binary and links Go programs")
|
||||
}
|
||||
goBin, err := exec.LookPath("go")
|
||||
if err != nil {
|
||||
t.Skip("no Go toolchain available")
|
||||
}
|
||||
|
||||
// One gasm binary for every architecture, built from the tree under
|
||||
// test: the regression exercises the shipped CLI path, not the library.
|
||||
// The build runs from the module root, one level above this package.
|
||||
gasmBin := filepath.Join(t.TempDir(), "gasm")
|
||||
gasmBuild := exec.Command(goBin, "build", "-o", gasmBin, "./cmd/gasm")
|
||||
gasmBuild.Dir = ".."
|
||||
if out, err := gasmBuild.CombinedOutput(); err != nil {
|
||||
t.Fatalf("build gasm: %v\n%s", err, out)
|
||||
}
|
||||
|
||||
for _, goarch := range []string{"amd64", "arm64", "riscv64", "loong64"} {
|
||||
t.Run(goarch, func(t *testing.T) {
|
||||
testGOOBJLinkArch(t, goBin, gasmBin, goarch)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func testGOOBJLinkArch(t *testing.T, goBin, gasmBin, goarch string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
|
||||
// The module: main.go, go.mod and the kernel as main_<goarch>.s, the
|
||||
// name the build system selects the assembly by.
|
||||
kernel, err := os.ReadFile(filepath.Join("testdata", "linkreg_"+goarch+".s"))
|
||||
if err != nil {
|
||||
t.Fatalf("read kernel: %v", err)
|
||||
}
|
||||
asmPath := filepath.Join(dir, "main_"+goarch+".s")
|
||||
if err := os.WriteFile(asmPath, kernel, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "main.go"), []byte(goobjLinkMainSrc), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module linkreg\n\ngo 1.27\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// The baseline build, with the steps logged so the package archive,
|
||||
// the assembler's object and the link line can be captured. A fresh
|
||||
// directory every run keeps the build cache out of the way.
|
||||
buildEnv := append(os.Environ(), "GOARCH="+goarch)
|
||||
baseline := filepath.Join(dir, "baseline")
|
||||
build := exec.Command(goBin, "build", "-x", "-work", "-o", baseline, ".")
|
||||
build.Dir = dir
|
||||
build.Env = buildEnv
|
||||
buildLog, err := build.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("baseline build: %v\n%s", err, buildLog)
|
||||
}
|
||||
var work, asmObj, pkgArch, linkLine string
|
||||
for line := range strings.SplitSeq(string(buildLog), "\n") {
|
||||
switch {
|
||||
case strings.HasPrefix(line, "WORK="):
|
||||
work = strings.TrimPrefix(line, "WORK=")
|
||||
case strings.Contains(line, "/asm ") && strings.Contains(line, "main_"+goarch+".s") && !strings.Contains(line, "-gensymabis"):
|
||||
asmObj = fieldAfterLinkLog(line, "-o")
|
||||
case strings.Contains(line, "pack r") && strings.Contains(line, "_pkg_.a"):
|
||||
rest := strings.TrimSpace(strings.SplitN(line, "pack r", 2)[1])
|
||||
pkgArch = strings.Fields(strings.SplitN(rest, "#", 2)[0])[0]
|
||||
case strings.Contains(line, "/link ") && strings.Contains(line, "-importcfg"):
|
||||
linkLine = line
|
||||
}
|
||||
}
|
||||
if work == "" || asmObj == "" || pkgArch == "" || linkLine == "" {
|
||||
t.Fatalf("could not locate the build steps in the -x log (work=%q asmObj=%q pkgArch=%q link=%v):\n%s",
|
||||
work, asmObj, pkgArch, linkLine, buildLog)
|
||||
}
|
||||
defer os.RemoveAll(work)
|
||||
asmObj = strings.ReplaceAll(asmObj, "$WORK", work)
|
||||
pkgArch = strings.ReplaceAll(pkgArch, "$WORK", work)
|
||||
member := filepath.Base(asmObj)
|
||||
|
||||
// The reference output, before any substitution: the run claim
|
||||
// compares against what the toolchain's own object produces.
|
||||
prefix, canRun := goobjLinkRunner(t, goarch)
|
||||
var baseOut []byte
|
||||
if canRun {
|
||||
var runErr error
|
||||
baseOut, runErr = runGoobjBinary(prefix, baseline)
|
||||
if runErr != nil {
|
||||
// The environment cannot execute this architecture at all
|
||||
// (an emulator that cannot host the runtime): the run claim
|
||||
// degrades, the link claim below still applies in full.
|
||||
t.Logf("baseline binary does not run here (%v); degrading to link-only", runErr)
|
||||
canRun = false
|
||||
}
|
||||
}
|
||||
|
||||
// Assemble the same source with the gasm CLI.
|
||||
gasmObjPath := filepath.Join(dir, "gasm.o")
|
||||
asmCmd := exec.Command(gasmBin, "asm", "--format", "goobj", "-p", "main",
|
||||
"-GOARCH", goarch, "-o", gasmObjPath, asmPath)
|
||||
if out, err := asmCmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("gasm asm --format goobj: %v\n%s", err, out)
|
||||
}
|
||||
gasmObj, err := os.ReadFile(gasmObjPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Rebuild the package archive with the gasm object in place of the
|
||||
// toolchain's: extract, substitute, repack.
|
||||
members := filepath.Join(dir, "members")
|
||||
if err := os.MkdirAll(members, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
extract := exec.Command(goBin, "tool", "pack", "x", pkgArch)
|
||||
extract.Dir = members
|
||||
extract.Env = buildEnv
|
||||
if out, err := extract.CombinedOutput(); err != nil {
|
||||
t.Fatalf("pack x: %v\n%s", err, out)
|
||||
}
|
||||
memberPath := filepath.Join(members, member)
|
||||
if _, err := os.Stat(memberPath); err != nil {
|
||||
t.Fatalf("the assembler's archive member %s was not extracted: %v", member, err)
|
||||
}
|
||||
if err := os.Chmod(memberPath, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(memberPath, gasmObj, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
listCmd := exec.Command(goBin, "tool", "pack", "t", pkgArch)
|
||||
listCmd.Env = buildEnv
|
||||
listOut, err := listCmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("pack t: %v\n%s", err, listOut)
|
||||
}
|
||||
newArch := filepath.Join(dir, "pkg.a")
|
||||
packArgs := []string{"tool", "pack", "c", newArch}
|
||||
seen := map[string]bool{}
|
||||
for m := range strings.FieldsSeq(string(listOut)) {
|
||||
if seen[m] {
|
||||
continue
|
||||
}
|
||||
seen[m] = true
|
||||
if err := os.Chmod(filepath.Join(members, m), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
packArgs = append(packArgs, m)
|
||||
}
|
||||
pack := exec.Command(goBin, packArgs...)
|
||||
pack.Dir = members
|
||||
pack.Env = buildEnv
|
||||
if out, err := pack.CombinedOutput(); err != nil {
|
||||
t.Fatalf("pack c: %v\n%s", err, out)
|
||||
}
|
||||
|
||||
// Prove the substitution: the archive handed to the linker holds the
|
||||
// gasm object, byte for byte.
|
||||
checkDir := filepath.Join(dir, "check")
|
||||
if err := os.MkdirAll(checkDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
check := exec.Command(goBin, "tool", "pack", "x", newArch)
|
||||
check.Dir = checkDir
|
||||
check.Env = buildEnv
|
||||
if out, err := check.CombinedOutput(); err != nil {
|
||||
t.Fatalf("pack x (verification): %v\n%s", err, out)
|
||||
}
|
||||
got, err := os.ReadFile(filepath.Join(checkDir, member))
|
||||
if err != nil {
|
||||
t.Fatalf("read the substituted member back: %v", err)
|
||||
}
|
||||
if !bytes.Equal(got, gasmObj) {
|
||||
t.Fatal("the repacked archive does not carry the gasm object")
|
||||
}
|
||||
|
||||
// Re-link with the substituted archive. The line carries the
|
||||
// GOROOT assignment and $WORK placeholders; GOARCH selects the
|
||||
// linker's target configuration and GOEXPERIMENT the header it
|
||||
// expects in the objects.
|
||||
goExp, _ := exec.Command(goBin, "env", "GOEXPERIMENT").Output()
|
||||
link := strings.ReplaceAll(linkLine, "$WORK", work)
|
||||
link = strings.ReplaceAll(link, filepath.Join(work, "b001", "exe", "a.out"), filepath.Join(dir, "gasm-linked"))
|
||||
link = strings.ReplaceAll(link, filepath.Join(work, "b001", "_pkg_.a"), newArch)
|
||||
linkCmd := exec.Command("sh", "-c", "cd "+dir+" && "+link)
|
||||
linkCmd.Env = append(buildEnv, "GOEXPERIMENT="+strings.TrimSpace(string(goExp)))
|
||||
linkOut, err := linkCmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("link with the gasm object: %v\n%s", err, linkOut)
|
||||
}
|
||||
|
||||
// The run claim: identical output to the baseline.
|
||||
if !canRun {
|
||||
if runtime.GOARCH == goarch {
|
||||
t.Fatalf("the native run claim is mandatory on %s hosts", goarch)
|
||||
}
|
||||
if goarch == "loong64" {
|
||||
t.Skipf("loong64 is link-only: a Go runtime under qemu-user is not assumed for it")
|
||||
}
|
||||
t.Skipf("%s is not installed and no %s host: the run claim degrades to link-only",
|
||||
qemuEmulator[goarch], goarch)
|
||||
}
|
||||
gasmOut, err := runGoobjBinary(prefix, filepath.Join(dir, "gasm-linked"))
|
||||
if err != nil {
|
||||
t.Fatalf("the gasm-linked binary failed: %v\n%s", err, gasmOut)
|
||||
}
|
||||
if !bytes.Equal(gasmOut, baseOut) {
|
||||
t.Errorf("gasm-linked output %q, want the baseline %q", gasmOut, baseOut)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user