Skip to content

Commit ef4877e

Browse files
authored
Merge pull request #1516 from cpunion/feature/compiler-hash-metadata
Record compiler hash in build fingerprints
2 parents 3e72be7 + 046c0d9 commit ef4877e

File tree

9 files changed

+368
-9
lines changed

9 files changed

+368
-9
lines changed
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
package compilerhash
2+
3+
import (
4+
"crypto/sha256"
5+
"encoding/binary"
6+
"encoding/hex"
7+
"fmt"
8+
"io"
9+
"os"
10+
"sync"
11+
12+
"github.com/goplus/llgo/internal/env"
13+
)
14+
15+
var (
16+
// LinkedValue may be set at link time for release builds using -X.
17+
LinkedValue string
18+
19+
once sync.Once
20+
cached string
21+
onceErr error
22+
23+
writeUint64LE = func(w io.Writer, v uint64) error { return binary.Write(w, binary.LittleEndian, v) }
24+
executablePath = os.Executable
25+
compilerHashFromPathFunc = compilerHashFromPath
26+
isDevel = env.Devel
27+
)
28+
29+
// Value returns the compiler hash used for cache invalidation. Release builds
30+
// leave it empty (unless preset via -X), while development builds hash the
31+
// running executable's metadata.
32+
func Value() string {
33+
ensure()
34+
return cached
35+
}
36+
37+
func ensure() {
38+
once.Do(func() {
39+
switch {
40+
case LinkedValue != "":
41+
cached = LinkedValue
42+
return
43+
case !isDevel():
44+
cached = ""
45+
return
46+
}
47+
48+
exe, err := executablePath()
49+
if err != nil {
50+
onceErr = fmt.Errorf("llgo: determine executable path: %w", err)
51+
return
52+
}
53+
hash, err := compilerHashFromPathFunc(exe)
54+
if err != nil {
55+
onceErr = fmt.Errorf("llgo: compute compiler hash: %w", err)
56+
return
57+
}
58+
cached = hash
59+
})
60+
if onceErr != nil {
61+
panic(onceErr)
62+
}
63+
}
64+
65+
func compilerHashFromPath(path string) (string, error) {
66+
info, err := os.Stat(path)
67+
if err != nil {
68+
return "", fmt.Errorf("llgo: stat executable: %w", err)
69+
}
70+
return hashMetadata(info.ModTime().UTC().UnixNano(), info.Size())
71+
}
72+
73+
func hashMetadata(modTimeNano int64, size int64) (string, error) {
74+
if size < 0 {
75+
return "", fmt.Errorf("llgo: invalid executable size: %d", size)
76+
}
77+
h := sha256.New()
78+
if err := writeUint64LE(h, uint64(modTimeNano)); err != nil {
79+
return "", fmt.Errorf("llgo: hash metadata timestamp: %w", err)
80+
}
81+
if err := writeUint64LE(h, uint64(size)); err != nil {
82+
return "", fmt.Errorf("llgo: hash metadata size: %w", err)
83+
}
84+
return hex.EncodeToString(h.Sum(nil)), nil
85+
}
86+
87+
func resetForTesting() {
88+
once = sync.Once{}
89+
cached = ""
90+
onceErr = nil
91+
LinkedValue = ""
92+
executablePath = os.Executable
93+
compilerHashFromPathFunc = compilerHashFromPath
94+
writeUint64LE = func(w io.Writer, v uint64) error { return binary.Write(w, binary.LittleEndian, v) }
95+
isDevel = env.Devel
96+
}
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
//go:build !llgo
2+
3+
package compilerhash
4+
5+
import (
6+
"errors"
7+
"io"
8+
"os"
9+
"path/filepath"
10+
"testing"
11+
"time"
12+
)
13+
14+
func TestHashMetadataDeterminism(t *testing.T) {
15+
resetForTesting()
16+
17+
const (
18+
modTime = int64(1700000000000000000)
19+
size = int64(4096)
20+
)
21+
22+
first, err := hashMetadata(modTime, size)
23+
if err != nil {
24+
t.Fatalf("hashMetadata returned error: %v", err)
25+
}
26+
second, err := hashMetadata(modTime, size)
27+
if err != nil {
28+
t.Fatalf("hashMetadata repeated call error: %v", err)
29+
}
30+
if first != second {
31+
t.Fatalf("hashMetadata is not deterministic: %q vs %q", first, second)
32+
}
33+
34+
zeroSize, err := hashMetadata(modTime, 0)
35+
if err != nil {
36+
t.Fatalf("hashMetadata zero size error: %v", err)
37+
}
38+
if zeroSize == "" {
39+
t.Fatal("hashMetadata zero size returned empty hash")
40+
}
41+
42+
modTimeChanged, err := hashMetadata(modTime+1, size)
43+
if err != nil {
44+
t.Fatalf("hashMetadata modTimeChanged error: %v", err)
45+
}
46+
if modTimeChanged == first {
47+
t.Fatalf("hashMetadata should change when mod time changes")
48+
}
49+
50+
sizeChanged, err := hashMetadata(modTime, size+1)
51+
if err != nil {
52+
t.Fatalf("hashMetadata sizeChanged error: %v", err)
53+
}
54+
if sizeChanged == first {
55+
t.Fatalf("hashMetadata should change when size changes")
56+
}
57+
}
58+
59+
func TestHashMetadataNegativeSize(t *testing.T) {
60+
resetForTesting()
61+
if _, err := hashMetadata(0, -1); err == nil {
62+
t.Fatal("hashMetadata should error for negative size")
63+
}
64+
}
65+
66+
func TestHashMetadataTimestampWriteError(t *testing.T) {
67+
resetForTesting()
68+
wantErr := errors.New("write timestamp")
69+
writeUint64LE = func(ioWriter io.Writer, v uint64) error {
70+
return wantErr
71+
}
72+
73+
if _, err := hashMetadata(1, 1); !errors.Is(err, wantErr) {
74+
t.Fatalf("hashMetadata error = %v, want %v", err, wantErr)
75+
}
76+
}
77+
78+
func TestHashMetadataSizeWriteError(t *testing.T) {
79+
resetForTesting()
80+
wantErr := errors.New("write size")
81+
call := 0
82+
writeUint64LE = func(ioWriter io.Writer, v uint64) error {
83+
call++
84+
if call == 2 {
85+
return wantErr
86+
}
87+
return nil
88+
}
89+
90+
if _, err := hashMetadata(1, 1); !errors.Is(err, wantErr) {
91+
t.Fatalf("hashMetadata error = %v, want %v", err, wantErr)
92+
}
93+
}
94+
95+
func TestCompilerHashFromPath(t *testing.T) {
96+
resetForTesting()
97+
98+
dir := t.TempDir()
99+
path := filepath.Join(dir, "llgo-bin")
100+
data := []byte("fake binary data")
101+
if err := os.WriteFile(path, data, 0o755); err != nil {
102+
t.Fatalf("WriteFile: %v", err)
103+
}
104+
105+
modTime := time.Unix(1735584000, 123_000_000)
106+
if err := os.Chtimes(path, modTime, modTime); err != nil {
107+
t.Fatalf("Chtimes: %v", err)
108+
}
109+
110+
got, err := compilerHashFromPath(path)
111+
if err != nil {
112+
t.Fatalf("compilerHashFromPath: %v", err)
113+
}
114+
info, err := os.Stat(path)
115+
if err != nil {
116+
t.Fatalf("Stat: %v", err)
117+
}
118+
want, err := hashMetadata(info.ModTime().UTC().UnixNano(), info.Size())
119+
if err != nil {
120+
t.Fatalf("hashMetadata: %v", err)
121+
}
122+
if got != want {
123+
t.Fatalf("compilerHashFromPath = %q, want %q", got, want)
124+
}
125+
126+
missingPath := filepath.Join(dir, "missing")
127+
if _, err := compilerHashFromPath(missingPath); err == nil {
128+
t.Fatal("compilerHashFromPath should error for missing file")
129+
}
130+
}
131+
132+
func TestValueUsesLinkedValue(t *testing.T) {
133+
resetForTesting()
134+
LinkedValue = "linked"
135+
if got := Value(); got != "linked" {
136+
t.Fatalf("Value() = %q, want %q", got, "linked")
137+
}
138+
}
139+
140+
func TestValueSkipsHashForRelease(t *testing.T) {
141+
resetForTesting()
142+
isDevel = func() bool { return false }
143+
executablePath = func() (string, error) {
144+
t.Fatal("executablePath should not be called for release builds")
145+
return "", nil
146+
}
147+
if got := Value(); got != "" {
148+
t.Fatalf("Value() = %q, want empty", got)
149+
}
150+
}
151+
152+
func TestValueExecutableErrorPanics(t *testing.T) {
153+
resetForTesting()
154+
isDevel = func() bool { return true }
155+
executablePath = func() (string, error) { return "", errors.New("boom") }
156+
157+
defer func() {
158+
if r := recover(); r == nil {
159+
t.Fatal("Value should panic when executable lookup fails")
160+
}
161+
}()
162+
_ = Value()
163+
}
164+
165+
func TestValueHashErrorPanics(t *testing.T) {
166+
resetForTesting()
167+
isDevel = func() bool { return true }
168+
executablePath = func() (string, error) { return "/fake", nil }
169+
compilerHashFromPathFunc = func(string) (string, error) { return "", errors.New("hash fail") }
170+
171+
defer func() {
172+
if r := recover(); r == nil {
173+
t.Fatal("Value should panic when hashing fails")
174+
}
175+
}()
176+
_ = Value()
177+
}
178+
179+
func TestValueComputesHashOnce(t *testing.T) {
180+
resetForTesting()
181+
isDevel = func() bool { return true }
182+
executablePath = func() (string, error) { return "/fake", nil }
183+
calls := 0
184+
compilerHashFromPathFunc = func(string) (string, error) {
185+
calls++
186+
return "computed", nil
187+
}
188+
189+
if got := Value(); got != "computed" {
190+
t.Fatalf("Value() = %q, want %q", got, "computed")
191+
}
192+
if got := Value(); got != "computed" {
193+
t.Fatalf("second Value() = %q, want %q", got, "computed")
194+
}
195+
if calls != 1 {
196+
t.Fatalf("compilerHashFromPathFunc called %d times, want 1", calls)
197+
}
198+
}

cmd/internal/flags/flags.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package flags
33
import (
44
"flag"
55

6+
"github.com/goplus/llgo/cmd/internal/compilerhash"
67
"github.com/goplus/llgo/internal/build"
78
"github.com/goplus/llgo/internal/buildenv"
89
)
@@ -170,6 +171,7 @@ func AddCmpTestFlags(fs *flag.FlagSet) {
170171
}
171172

172173
func UpdateConfig(conf *build.Config) error {
174+
conf.CompilerHash = compilerhash.Value()
173175
conf.Tags = Tags
174176
conf.Verbose = Verbose
175177
conf.Target = Target

internal/build/build.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@ type Config struct {
138138
SizeReport bool // print size report after successful build
139139
SizeFormat string // size report format: text,json (default text)
140140
SizeLevel string // size aggregation level: full,module,package (default module)
141+
CompilerHash string // metadata hash for the running compiler (development builds only)
141142
// GlobalRewrites specifies compile-time overrides for global string variables.
142143
// Keys are fully qualified package paths (e.g. "main" or "github.com/user/pkg").
143144
// Each Rewrites entry maps variable names to replacement string values. Only

internal/build/collect.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ func (c *context) collectEnvInputs(m *manifestBuilder) {
7373
m.env.Goarch = c.buildConf.Goarch
7474
m.env.LlvmTriple = c.crossCompile.LLVMTarget
7575
m.env.LlgoVersion = env.Version()
76+
m.env.LlgoCompilerHash = c.buildConf.CompilerHash
7677
m.env.GoVersion = runtime.Version()
7778
m.env.LlvmVersion = c.getLLVMVersion()
7879

internal/build/fingerprint.go

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -97,17 +97,18 @@ func (m orderedStringMap) MarshalYAML() (interface{}, error) {
9797

9898
// envSection holds fixed environment fields and optional vars.
9999
type envSection struct {
100-
Goos string `yaml:"GOOS,omitempty"`
101-
Goarch string `yaml:"GOARCH,omitempty"`
102-
GoVersion string `yaml:"GO_VERSION,omitempty"`
103-
LlgoVersion string `yaml:"LLGO_VERSION,omitempty"`
104-
LlvmTriple string `yaml:"LLVM_TRIPLE,omitempty"`
105-
LlvmVersion string `yaml:"LLVM_VERSION,omitempty"`
106-
Vars orderedStringMap `yaml:"VARS,omitempty"`
100+
Goos string `yaml:"GOOS,omitempty"`
101+
Goarch string `yaml:"GOARCH,omitempty"`
102+
GoVersion string `yaml:"GO_VERSION,omitempty"`
103+
LlgoVersion string `yaml:"LLGO_VERSION,omitempty"`
104+
LlgoCompilerHash string `yaml:"LLGO_COMPILER_HASH,omitempty"`
105+
LlvmTriple string `yaml:"LLVM_TRIPLE,omitempty"`
106+
LlvmVersion string `yaml:"LLVM_VERSION,omitempty"`
107+
Vars orderedStringMap `yaml:"VARS,omitempty"`
107108
}
108109

109110
func (s *envSection) empty() bool {
110-
return s.Goos == "" && s.Goarch == "" && s.LlvmTriple == "" && s.LlgoVersion == "" && s.GoVersion == "" && s.LlvmVersion == "" && len(s.Vars) == 0
111+
return s.Goos == "" && s.Goarch == "" && s.LlvmTriple == "" && s.LlgoVersion == "" && s.LlgoCompilerHash == "" && s.GoVersion == "" && s.LlvmVersion == "" && len(s.Vars) == 0
111112
}
112113

113114
type commonSection struct {

internal/env/utils_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
//go:build !llgo
2+
13
package env
24

35
import "testing"

internal/env/version.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ func Version() string {
3434
if buildVersion != "" {
3535
return buildVersion
3636
}
37-
info, ok := debug.ReadBuildInfo()
37+
info, ok := readBuildInfo()
3838
if ok && info.Main.Version != "" && !strings.HasSuffix(info.Main.Version, "+dirty") {
3939
return info.Main.Version
4040
}
@@ -44,3 +44,7 @@ func Version() string {
4444
func Devel() bool {
4545
return Version() == devel
4646
}
47+
48+
var (
49+
readBuildInfo = debug.ReadBuildInfo
50+
)

0 commit comments

Comments
 (0)