From dd1782c5383b6482d9bced0b98c10b86b4ddabfc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Balv=C3=ADn?= Date: Fri, 2 Oct 2026 00:40:54 +0200 Subject: [PATCH] test(verify): gate GOOBJ link parity with cmd/link Assisted-by: GLM 5.3 --- .gitea/workflows/test.yml | 12 ++ verify/goobj_link_test.go | 326 ++++++++++++++++++++++++++++++ verify/testdata/linkreg_amd64.s | 55 +++++ verify/testdata/linkreg_arm64.s | 57 ++++++ verify/testdata/linkreg_loong64.s | 56 +++++ verify/testdata/linkreg_riscv64.s | 56 +++++ 6 files changed, 562 insertions(+) create mode 100644 verify/goobj_link_test.go create mode 100644 verify/testdata/linkreg_amd64.s create mode 100644 verify/testdata/linkreg_arm64.s create mode 100644 verify/testdata/linkreg_loong64.s create mode 100644 verify/testdata/linkreg_riscv64.s diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index 33968ba..f392e83 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -119,6 +119,18 @@ jobs: # regression names the gate that failed instead of hiding inside the suite. run: go test -count=1 -timeout 10m -run 'TestGroundTruth' ./verify/... + - name: Install qemu-user + # The GOOBJ link regression runs the arm64 and riscv64 binaries it links + # under qemu-user; without the emulator those two degrade to link-only. + # The install is a no-op where it is already present. + run: dnf install -y qemu-user + + - name: GOOBJ link parity + # The cmd/link parity gate: gasm objects substituted into a real go build + # must re-link on all four architectures, and the binaries must reproduce + # the toolchain baseline's output (native on amd64, qemu-user elsewhere). + run: go test -count=1 -timeout 10m -run 'TestGOOBJLinkRegression' ./verify/... + - name: Coverage floor run: | perl -e ' diff --git a/verify/goobj_link_test.go b/verify/goobj_link_test.go new file mode 100644 index 0000000..ad829f6 --- /dev/null +++ b/verify/goobj_link_test.go @@ -0,0 +1,326 @@ +// Copyright (c) 2026 Petr Balvín (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_.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) + } +} diff --git a/verify/testdata/linkreg_amd64.s b/verify/testdata/linkreg_amd64.s new file mode 100644 index 0000000..502ceac --- /dev/null +++ b/verify/testdata/linkreg_amd64.s @@ -0,0 +1,55 @@ +// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) +// SPDX-License-Identifier: BSD-3-Clause + +#include "textflag.h" + +// The link-regression kernel for amd64. One file exercises every GOOBJ +// record a small assembly contribution to a program needs: plain stack +// discipline (add), an intra-file call (probe calling seven, R_CALL), a +// code-to-data reference (word<>, R_PCREL), a symbol-valued DATA field +// (entry holding seven's address, R_ADDR), NOPTR data outside the GC's +// type scan (counter) and the data section itself. + +// func add(a, b int64) int64 +TEXT ·add(SB), NOSPLIT, $0-24 + MOVQ a+0(FP), AX + MOVQ b+8(FP), CX + ADDQ CX, AX + MOVQ AX, ret+16(FP) + RET + +// func seven() int64 +TEXT ·seven(SB), NOSPLIT, $0-8 + MOVQ $7, AX + MOVQ AX, ret+0(FP) + RET + +// func probe() int64 = seven() + word<>(SB) +TEXT ·probe(SB), NOSPLIT, $8-8 + CALL ·seven(SB) + MOVQ (SP), AX + MOVQ word<>(SB), CX + ADDQ CX, AX + MOVQ AX, ret+0(FP) + RET + +// func entryAddr() uintptr +TEXT ·entryAddr(SB), NOSPLIT, $0-8 + MOVQ entry(SB), AX + MOVQ AX, ret+0(FP) + RET + +// func counterVal() int64 +TEXT ·counterVal(SB), NOSPLIT, $0-8 + MOVQ counter(SB), AX + MOVQ AX, ret+0(FP) + RET + +GLOBL word<>(SB), RODATA, $8 +DATA word<>+0(SB)/8, $0x0fedcba987654321 + +GLOBL entry(SB), RODATA, $8 +DATA entry+0(SB)/8, $·seven(SB) + +GLOBL counter(SB), NOPTR, $8 +DATA counter+0(SB)/8, $1234 diff --git a/verify/testdata/linkreg_arm64.s b/verify/testdata/linkreg_arm64.s new file mode 100644 index 0000000..caefbe5 --- /dev/null +++ b/verify/testdata/linkreg_arm64.s @@ -0,0 +1,57 @@ +// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) +// SPDX-License-Identifier: BSD-3-Clause + +#include "textflag.h" + +// The link-regression kernel for arm64, the amd64 one's sibling: stack +// discipline (add), an intra-file branch with link (probe calling seven, +// R_CALLARM64), an ADRP+ADD pair against static data (word<>, +// R_ADDRARM64), a symbol-valued DATA field (entry holding seven's +// address, R_ADDR), NOPTR data outside the GC's type scan (counter) and +// the data section itself. The callee's result slot sits at the caller's +// SP+8, the arm64 ABI0 frame convention. + +// func add(a, b int64) int64 +TEXT ·add(SB), NOSPLIT, $0-24 + MOVD a+0(FP), R0 + MOVD b+8(FP), R1 + ADD R1, R0, R0 + MOVD R0, ret+16(FP) + RET + +// func seven() int64 +TEXT ·seven(SB), NOSPLIT, $0-8 + MOVD $7, R0 + MOVD R0, ret+0(FP) + RET + +// func probe() int64 = seven() + word<>(SB) +TEXT ·probe(SB), NOSPLIT, $8-8 + BL ·seven(SB) + MOVD 8(RSP), R0 + MOVD $word<>(SB), R4 + MOVD (R4), R5 + ADD R5, R0, R0 + MOVD R0, ret+0(FP) + RET + +// func entryAddr() uintptr +TEXT ·entryAddr(SB), NOSPLIT, $0-8 + MOVD entry(SB), R4 + MOVD R4, ret+0(FP) + RET + +// func counterVal() int64 +TEXT ·counterVal(SB), NOSPLIT, $0-8 + MOVD counter(SB), R4 + MOVD R4, ret+0(FP) + RET + +GLOBL word<>(SB), RODATA, $8 +DATA word<>+0(SB)/8, $0x0fedcba987654321 + +GLOBL entry(SB), RODATA, $8 +DATA entry+0(SB)/8, $·seven(SB) + +GLOBL counter(SB), NOPTR, $8 +DATA counter+0(SB)/8, $1234 diff --git a/verify/testdata/linkreg_loong64.s b/verify/testdata/linkreg_loong64.s new file mode 100644 index 0000000..2eb988b --- /dev/null +++ b/verify/testdata/linkreg_loong64.s @@ -0,0 +1,56 @@ +// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) +// SPDX-License-Identifier: BSD-3-Clause + +#include "textflag.h" + +// The link-regression kernel for loong64, the amd64 one's sibling: +// stack discipline (add), an intra-file call (probe calling seven, +// R_CALLLOONG64), a PCALAU12I pair against static data (word<>, +// R_LOONG64_ADDR_HI/LO), a symbol-valued DATA field (entry holding +// seven's address, R_ADDR), NOPTR data outside the GC's type scan +// (counter) and the data section itself. The callee's result slot sits +// at the caller's SP+8 (R3), the loong64 ABI0 frame convention. + +// func add(a, b int64) int64 +TEXT ·add(SB), NOSPLIT, $0-24 + MOVV a+0(FP), R4 + MOVV b+8(FP), R5 + ADDV R5, R4, R4 + MOVV R4, ret+16(FP) + RET + +// func seven() int64 +TEXT ·seven(SB), NOSPLIT, $0-8 + MOVV $7, R4 + MOVV R4, ret+0(FP) + RET + +// func probe() int64 = seven() + word<>(SB) +TEXT ·probe(SB), NOSPLIT, $8-8 + CALL ·seven(SB) + MOVV 8(R3), R4 + MOVV word<>(SB), R5 + ADDV R5, R4, R4 + MOVV R4, ret+0(FP) + RET + +// func entryAddr() uintptr +TEXT ·entryAddr(SB), NOSPLIT, $0-8 + MOVV entry(SB), R4 + MOVV R4, ret+0(FP) + RET + +// func counterVal() int64 +TEXT ·counterVal(SB), NOSPLIT, $0-8 + MOVV counter(SB), R4 + MOVV R4, ret+0(FP) + RET + +GLOBL word<>(SB), RODATA, $8 +DATA word<>+0(SB)/8, $0x0fedcba987654321 + +GLOBL entry(SB), RODATA, $8 +DATA entry+0(SB)/8, $·seven(SB) + +GLOBL counter(SB), NOPTR, $8 +DATA counter+0(SB)/8, $1234 diff --git a/verify/testdata/linkreg_riscv64.s b/verify/testdata/linkreg_riscv64.s new file mode 100644 index 0000000..89efa8e --- /dev/null +++ b/verify/testdata/linkreg_riscv64.s @@ -0,0 +1,56 @@ +// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) +// SPDX-License-Identifier: BSD-3-Clause + +#include "textflag.h" + +// The link-regression kernel for riscv64, the amd64 one's sibling: stack +// discipline (add), an intra-file call (probe calling seven, the +// R_RISCV_CALL pair), an AUIPC+I-type pair against static data (word<>, +// R_RISCV_PCREL_ITYPE), a symbol-valued DATA field (entry holding +// seven's address, R_ADDR), NOPTR data outside the GC's type scan +// (counter) and the data section itself. The callee's result slot sits +// at the caller's SP+8, the riscv64 ABI0 frame convention. + +// func add(a, b int64) int64 +TEXT ·add(SB), NOSPLIT, $0-24 + MOV a+0(FP), X10 + MOV b+8(FP), X11 + ADD X11, X10, X10 + MOV X10, ret+16(FP) + RET + +// func seven() int64 +TEXT ·seven(SB), NOSPLIT, $0-8 + MOV $7, X10 + MOV X10, ret+0(FP) + RET + +// func probe() int64 = seven() + word<>(SB) +TEXT ·probe(SB), NOSPLIT, $8-8 + CALL ·seven(SB) + MOV 8(SP), X10 + MOV word<>(SB), X11 + ADD X11, X10, X10 + MOV X10, ret+0(FP) + RET + +// func entryAddr() uintptr +TEXT ·entryAddr(SB), NOSPLIT, $0-8 + MOV entry(SB), X10 + MOV X10, ret+0(FP) + RET + +// func counterVal() int64 +TEXT ·counterVal(SB), NOSPLIT, $0-8 + MOV counter(SB), X10 + MOV X10, ret+0(FP) + RET + +GLOBL word<>(SB), RODATA, $8 +DATA word<>+0(SB)/8, $0x0fedcba987654321 + +GLOBL entry(SB), RODATA, $8 +DATA entry+0(SB)/8, $·seven(SB) + +GLOBL counter(SB), NOPTR, $8 +DATA counter+0(SB)/8, $1234