Skip to content

Commit 2b56368

Browse files
committed
builder: rescan C dependencies for cache keys
Stop caching C dependency lists as reusable inputs to object cache lookups. Instead, ask Clang for the current dependency list before each lookup and key the object by the current dependencies, compiler flags, and clang/LLVM identity. This avoids stale object hits when include path resolution changes, such as when a new header is added earlier in an include path. It also avoids using dependency data produced by a different clang binary.
1 parent 3a43fbb commit 2b56368

2 files changed

Lines changed: 131 additions & 111 deletions

File tree

builder/cc.go

Lines changed: 79 additions & 111 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import (
1010
"errors"
1111
"fmt"
1212
"io"
13-
"io/fs"
1413
"os"
1514
"path/filepath"
1615
"sort"
@@ -25,37 +24,21 @@ import (
2524
// Compiling the same file again (if nothing changed, including included header
2625
// files) the output is loaded from the build cache instead.
2726
//
28-
// Its operation is a bit complex (more complex than Go package build caching)
29-
// because the list of file dependencies is only known after the file is
30-
// compiled. However, luckily compilers have a flag to write a list of file
31-
// dependencies in Makefile syntax which can be used for caching.
27+
// Its operation is a bit complex (more complex than Go package build caching),
28+
// because the list of file dependencies depends on C include path resolution.
29+
// TinyGo asks Clang for the current dependency list before looking for an object
30+
// cache hit, then uses the hashes of those dependencies in the object key.
3231
//
33-
// Because of this complexity, every file has in fact two cached build outputs:
34-
// the file itself, and the list of dependencies. Its operation is as follows:
35-
//
36-
// depfile = hash(path, compiler, cflags, ...)
37-
// if depfile exists:
38-
// outfile = hash of all files and depfile name
39-
// if outfile exists:
40-
// # cache hit
41-
// return outfile
42-
// # cache miss
32+
// dependencies = clang -M source
33+
// outfile = hash(path, compiler, cflags, dependencies, ...)
34+
// if outfile exists:
35+
// # cache hit
36+
// return outfile
4337
// tmpfile = compile file
44-
// read dependencies (side effect of compile)
45-
// write depfile
46-
// outfile = hash of all files and depfile name
4738
// rename tmpfile to outfile
4839
//
49-
// There are a few edge cases that are not handled:
50-
// - If a file is added to an include path, that file may be included instead of
51-
// some other file. This would be fixed by also including lookup failures in the
52-
// dependencies file, but I'm not aware of a compiler which does that.
53-
// - The Makefile syntax that compilers output has issues, see readDepFile for
54-
// details.
55-
// - A header file may be changed to add/remove an include. This invalidates the
56-
// depfile but without invalidating its name. For this reason, the depfile is
57-
// written on each new compilation (even when it seems unnecessary). However, it
58-
// could in rare cases lead to a stale file fetched from the cache.
40+
// The Makefile syntax that compilers output has issues, see readDepFile for
41+
// details.
5942
func compileAndCacheCFile(abspath, tmpdir string, cflags []string, printCommands func(string, ...string)) (string, error) {
6043
// Hash input file.
6144
fileHash, err := hashFile(abspath)
@@ -67,65 +50,47 @@ func compileAndCacheCFile(abspath, tmpdir string, cflags []string, printCommands
6750
unlock := lock(filepath.Join(goenv.Get("GOCACHE"), fileHash+".c.lock"))
6851
defer unlock()
6952

70-
// Create cache key for the dependencies file.
71-
buf, err := json.Marshal(struct {
72-
Path string
73-
Hash string
74-
Flags []string
75-
LLVMVersion string
76-
}{
77-
Path: abspath,
78-
Hash: fileHash,
79-
Flags: cflags,
80-
LLVMVersion: llvm.Version,
81-
})
53+
compilerID, err := clangCompilerIdentity()
8254
if err != nil {
83-
panic(err) // shouldn't happen
55+
return "", err
8456
}
85-
depfileNameHashBuf := sha512.Sum512_224(buf)
86-
depfileNameHash := hex.EncodeToString(depfileNameHashBuf[:])
87-
88-
// Load dependencies file, if possible.
89-
depfileName := "dep-" + depfileNameHash + ".json"
90-
depfileCachePath := filepath.Join(goenv.Get("GOCACHE"), depfileName)
91-
depfileBuf, err := os.ReadFile(depfileCachePath)
92-
var dependencies []string // sorted list of dependency paths
93-
if err == nil {
94-
// There is a dependency file, that's great!
95-
// Parse it first.
96-
err := json.Unmarshal(depfileBuf, &dependencies)
97-
if err != nil {
98-
return "", fmt.Errorf("could not parse dependencies JSON: %w", err)
99-
}
10057

101-
// Obtain hashes of all the files listed as a dependency.
102-
outpath, err := makeCFileCachePath(dependencies, depfileNameHash)
103-
if err == nil {
104-
if _, err := os.Stat(outpath); err == nil {
105-
return outpath, nil
106-
} else if !errors.Is(err, fs.ErrNotExist) {
107-
return "", err
108-
}
109-
}
110-
} else if !errors.Is(err, fs.ErrNotExist) {
111-
// expected either nil or IsNotExist
58+
dependencies, err := scanCFileDependencies(abspath, tmpdir, cflags, printCommands)
59+
if err != nil {
60+
return "", err
61+
}
62+
outpath, err := makeCFileCachePath(abspath, cFileCompileArgs(abspath, "$OBJ", cflags), compilerID, dependencies)
63+
if err != nil {
64+
return "", err
65+
}
66+
if _, err := os.Stat(outpath); err == nil {
67+
return outpath, nil
68+
} else if !errors.Is(err, os.ErrNotExist) {
11269
return "", err
11370
}
11471

115-
objTmpFile, err := os.CreateTemp(goenv.Get("GOCACHE"), "tmp-*.bc")
72+
objTmpFile, err := compileCFile(goenv.Get("GOCACHE"), abspath, cflags, printCommands)
11673
if err != nil {
11774
return "", err
11875
}
119-
objTmpFile.Close()
120-
depTmpFile, err := os.CreateTemp(tmpdir, "dep-*.d")
76+
err = os.Rename(objTmpFile, outpath)
12177
if err != nil {
12278
return "", err
12379
}
80+
return outpath, nil
81+
}
82+
83+
func scanCFileDependencies(abspath, tmpdir string, cflags []string, printCommands func(string, ...string)) ([]string, error) {
84+
depTmpFile, err := os.CreateTemp(tmpdir, "dep-*.d")
85+
if err != nil {
86+
return nil, err
87+
}
12488
depTmpFile.Close()
125-
flags := append([]string{}, cflags...) // copy cflags
126-
flags = append(flags, "-MD", "-MV", "-MTdeps", "-MF", depTmpFile.Name(), "-flto=thin") // autogenerate dependencies
127-
flags = append(flags, "-c", "-o", objTmpFile.Name(), abspath)
128-
if strings.ToLower(filepath.Ext(abspath)) == ".s" {
89+
defer os.Remove(depTmpFile.Name())
90+
91+
flags := append([]string{}, cflags...)
92+
flags = append(flags, "-M", "-MV", "-MTdeps", "-MF", depTmpFile.Name(), abspath)
93+
if isAssemblyFile(abspath) {
12994
// If this is an assembly file (.s or .S, lowercase or uppercase), then
13095
// we'll need to add -Qunused-arguments because many parameters are
13196
// relevant to C, not assembly. And with -Werror, having meaningless
@@ -137,13 +102,12 @@ func compileAndCacheCFile(abspath, tmpdir string, cflags []string, printCommands
137102
}
138103
err = runCCompiler(flags...)
139104
if err != nil {
140-
return "", &commandError{"failed to build", abspath, err}
105+
return nil, &commandError{"failed to scan dependencies", abspath, err}
141106
}
142107

143-
// Create sorted and uniqued slice of dependencies.
144108
dependencyPaths, err := readDepFile(depTmpFile.Name())
145109
if err != nil {
146-
return "", err
110+
return nil, err
147111
}
148112
dependencyPaths = append(dependencyPaths, abspath) // necessary for .s files
149113
dependencySet := make(map[string]struct{}, len(dependencyPaths))
@@ -156,50 +120,44 @@ func compileAndCacheCFile(abspath, tmpdir string, cflags []string, printCommands
156120
dependencySlice = append(dependencySlice, path)
157121
}
158122
sort.Strings(dependencySlice)
123+
return dependencySlice, nil
124+
}
159125

160-
// Write dependencies file.
161-
f, err := os.CreateTemp(filepath.Dir(depfileCachePath), depfileName)
162-
if err != nil {
163-
return "", err
126+
func cFileCompileArgs(abspath, objpath string, cflags []string) []string {
127+
flags := append([]string{}, cflags...)
128+
flags = append(flags, "-flto=thin")
129+
flags = append(flags, "-c", "-o", objpath, abspath)
130+
if isAssemblyFile(abspath) {
131+
flags = append(flags, "-Qunused-arguments")
164132
}
133+
return flags
134+
}
165135

166-
buf, err = json.MarshalIndent(dependencySlice, "", "\t")
167-
if err != nil {
168-
panic(err) // shouldn't happen
169-
}
170-
_, err = f.Write(buf)
171-
if err != nil {
172-
return "", err
173-
}
174-
err = f.Close()
175-
if err != nil {
176-
return "", err
177-
}
178-
err = os.Rename(f.Name(), depfileCachePath)
136+
func compileCFile(cacheDir, abspath string, cflags []string, printCommands func(string, ...string)) (string, error) {
137+
objTmpFile, err := os.CreateTemp(cacheDir, "tmp-*.bc")
179138
if err != nil {
180139
return "", err
181140
}
141+
objTmpFile.Close()
182142

183-
// Move temporary object file to final location.
184-
outpath, err := makeCFileCachePath(dependencySlice, depfileNameHash)
185-
if err != nil {
186-
return "", err
143+
flags := cFileCompileArgs(abspath, objTmpFile.Name(), cflags)
144+
if printCommands != nil {
145+
printCommands("clang", flags...)
187146
}
188-
err = os.Rename(objTmpFile.Name(), outpath)
147+
err = runCCompiler(flags...)
189148
if err != nil {
190-
return "", err
149+
return "", &commandError{"failed to build", abspath, err}
191150
}
192-
193-
return outpath, nil
151+
return objTmpFile.Name(), nil
194152
}
195153

196154
// Create a cache path (a path in GOCACHE) to store the output of a compiler
197-
// job. This path is based on the dep file name (which is a hash of metadata
198-
// including compiler flags) and the hash of all input files in the paths slice.
199-
func makeCFileCachePath(paths []string, depfileNameHash string) (string, error) {
155+
// job. This path is based on the compiler identity, compiler flags, and the
156+
// hash of all dependency files.
157+
func makeCFileCachePath(path string, flags []string, compilerID string, dependencies []string) (string, error) {
200158
// Hash all input files.
201-
fileHashes := make(map[string]string, len(paths))
202-
for _, path := range paths {
159+
fileHashes := make(map[string]string, len(dependencies))
160+
for _, path := range dependencies {
203161
hash, err := hashFile(path)
204162
if err != nil {
205163
return "", err
@@ -209,11 +167,17 @@ func makeCFileCachePath(paths []string, depfileNameHash string) (string, error)
209167

210168
// Calculate a cache key based on the above hashes.
211169
buf, err := json.Marshal(struct {
212-
DepfileHash string
213-
FileHashes map[string]string
170+
Path string
171+
Flags []string
172+
LLVMVersion string
173+
CompilerIdentity string
174+
FileHashes map[string]string
214175
}{
215-
DepfileHash: depfileNameHash,
216-
FileHashes: fileHashes,
176+
Path: path,
177+
Flags: flags,
178+
LLVMVersion: llvm.Version,
179+
CompilerIdentity: compilerID,
180+
FileHashes: fileHashes,
217181
})
218182
if err != nil {
219183
panic(err) // shouldn't happen
@@ -225,6 +189,10 @@ func makeCFileCachePath(paths []string, depfileNameHash string) (string, error)
225189
return outpath, nil
226190
}
227191

192+
func isAssemblyFile(path string) bool {
193+
return strings.ToLower(filepath.Ext(path)) == ".s"
194+
}
195+
228196
// hashFile hashes the given file path and returns the hash as a hex string.
229197
func hashFile(path string) (string, error) {
230198
f, err := os.Open(path)

builder/cc_test.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package builder
22

33
import (
4+
"os"
5+
"path/filepath"
46
"reflect"
57
"testing"
68
)
@@ -31,3 +33,53 @@ func TestSplitDepFile(t *testing.T) {
3133
}
3234
}
3335
}
36+
37+
func TestCFileCacheIncludePathShadowing(t *testing.T) {
38+
t.Setenv("GOCACHEPROG", "")
39+
40+
dir := t.TempDir()
41+
42+
include1 := filepath.Join(dir, "include1")
43+
include2 := filepath.Join(dir, "include2")
44+
if err := os.Mkdir(include1, 0o777); err != nil {
45+
t.Fatal(err)
46+
}
47+
if err := os.Mkdir(include2, 0o777); err != nil {
48+
t.Fatal(err)
49+
}
50+
if err := os.WriteFile(filepath.Join(include2, "value.h"), []byte("#define VALUE 1\n"), 0o666); err != nil {
51+
t.Fatal(err)
52+
}
53+
source := filepath.Join(dir, "test.c")
54+
if err := os.WriteFile(source, []byte("#include \"value.h\"\nint value(void) { return VALUE; }\n"), 0o666); err != nil {
55+
t.Fatal(err)
56+
}
57+
58+
flags := []string{
59+
"-I", include1,
60+
"-I", include2,
61+
"--target=x86_64-unknown-linux-gnu",
62+
}
63+
first, err := compileAndCacheCFile(source, dir, flags, nil)
64+
if err != nil {
65+
t.Fatal(err)
66+
}
67+
second, err := compileAndCacheCFile(source, dir, flags, nil)
68+
if err != nil {
69+
t.Fatal(err)
70+
}
71+
if first != second {
72+
t.Fatalf("unchanged compile did not hit cache: %s != %s", first, second)
73+
}
74+
75+
if err := os.WriteFile(filepath.Join(include1, "value.h"), []byte("#define VALUE 2\n"), 0o666); err != nil {
76+
t.Fatal(err)
77+
}
78+
shadowed, err := compileAndCacheCFile(source, dir, flags, nil)
79+
if err != nil {
80+
t.Fatal(err)
81+
}
82+
if shadowed == first {
83+
t.Fatal("include path shadowing reused stale cached object")
84+
}
85+
}

0 commit comments

Comments
 (0)