Skip to content

Commit 7cd8b14

Browse files
committed
Recursively discover new dependencies in code generation
Because we're building each package in isolation and go.sum doesn't hold the full dependency graph, only the bits you're currently building. Without including deeply nested indirect dependencies an application build will fail because the Go compiler needs to be able to look up metadata about it's dependencies, even if those are not required to actually build.
1 parent 3bfcd30 commit 7cd8b14

12 files changed

Lines changed: 722 additions & 482 deletions

File tree

doc/src/architecture.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ In practice however many Go packages do not do this and have direct dependencies
3535

3636
This means we always have to write out all dependencies to the lock.
3737

38-
### Symlink farming of `GOMODCACHE`
38+
### Patching of `go.mod` & Symlink farming of `GOMODCACHE`
3939

4040
When creating the module cache directory all `*.mod` files have to be patched & all `go.sum` files have to be omitted from the file tree.
4141

@@ -45,3 +45,10 @@ So one symlink per input file is created when unpacking the module cache.
4545
This approach causes a downstream knock-on effect which is that Go embed statements don't consider files that are symlinks.
4646

4747
That's why `gobuild.nix` unpacks the module cache using a hybrid approach: create one symlink per source file & copy every non-Go file in full.
48+
49+
### Deeply nested indirect dependencies
50+
51+
Because we're building each package in isolation and go.sum doesn't hold the full dependency graph, only the that your application/library cares about.
52+
Without including deeply nested indirect dependencies an application build will fail because the Go compiler needs to be able to look up metadata about it's dependencies, even if those are not required to actually perform the build.
53+
54+
Therefore the lock file generator discovers additional deeply nested dependencies not present in `go.sum` during generation.
Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
package main
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"fmt"
8+
"io"
9+
"log"
10+
"os"
11+
"os/exec"
12+
"slices"
13+
"strings"
14+
"sync"
15+
16+
"golang.org/x/mod/modfile"
17+
"golang.org/x/mod/module"
18+
"golang.org/x/mod/semver"
19+
"golang.org/x/sync/errgroup"
20+
)
21+
22+
type goModDownload struct {
23+
Path string
24+
Version string
25+
Info string
26+
GoMod string
27+
Zip string
28+
Dir string
29+
Sum string
30+
GoModSum string
31+
}
32+
33+
func downloadModules(directory string, packages []string) ([]*goModDownload, error) {
34+
var downloads []*goModDownload
35+
36+
cmd := exec.Command("go", append([]string{"mod", "download", "--json"}, packages...)...)
37+
cmd.Dir = directory
38+
stdout, err := cmd.Output()
39+
if err != nil {
40+
if exiterr, ok := err.(*exec.ExitError); ok {
41+
return nil, fmt.Errorf("failed to run 'go mod download --json: %s\n%s", exiterr, exiterr.Stderr)
42+
} else {
43+
return nil, fmt.Errorf("failed to run 'go mod download --json': %s", err)
44+
}
45+
}
46+
47+
dec := json.NewDecoder(bytes.NewReader(stdout))
48+
for {
49+
var dl *goModDownload
50+
err := dec.Decode(&dl)
51+
if err == io.EOF {
52+
break
53+
} else {
54+
downloads = append(downloads, dl)
55+
}
56+
}
57+
58+
return downloads, nil
59+
}
60+
61+
func downloadModule(directory string, packagePath string, version string) (*goModDownload, error) {
62+
log.Printf("Downloading %s@%s", packagePath, version)
63+
64+
cmd := exec.Command("go", "mod", "download", "--json", fmt.Sprintf("%s@%s", packagePath, version))
65+
cmd.Dir = directory
66+
stdout, err := cmd.Output()
67+
if err != nil {
68+
if exiterr, ok := err.(*exec.ExitError); ok {
69+
return nil, fmt.Errorf("failed to run 'go mod download --json: %s\n%s", exiterr, exiterr.Stderr)
70+
} else {
71+
return nil, fmt.Errorf("failed to run 'go mod download --json': %s", err)
72+
}
73+
}
74+
75+
dec := json.NewDecoder(bytes.NewReader(stdout))
76+
for {
77+
var dl *goModDownload
78+
err := dec.Decode(&dl)
79+
if err == io.EOF {
80+
break
81+
} else {
82+
return dl, err
83+
}
84+
}
85+
86+
return nil, fmt.Errorf("error downloading %s@%s: no module download returned", packagePath, version)
87+
}
88+
89+
func discoverDependencies(directory string, workers int, sumVersions map[string]string) ([]*goModDownload, error) {
90+
var downloadMod func(context.Context, module.Version) error
91+
var discoverMod func(module.Version)
92+
var wg sync.WaitGroup
93+
94+
downloads := map[string]*goModDownload{}
95+
var downloadsMu sync.RWMutex
96+
97+
eg := errgroup.Group{}
98+
eg.SetLimit(workers)
99+
100+
discoveredVersions := map[string]string{}
101+
var discoveredMu sync.RWMutex
102+
discoverSem := make(chan struct{}, workers)
103+
errChan := make(chan error, workers)
104+
105+
ctx, cancel := context.WithCancel(context.Background())
106+
defer cancel()
107+
108+
discoverMod = func(mod module.Version) {
109+
if mod.Path[0] == '.' { // Disregard local replacements
110+
return
111+
}
112+
113+
wg.Add(1)
114+
115+
doWork := func() error {
116+
select {
117+
case <-ctx.Done():
118+
return ctx.Err()
119+
case discoverSem <- struct{}{}:
120+
}
121+
122+
defer func() {
123+
<-discoverSem
124+
wg.Done()
125+
}()
126+
127+
// If package was found through go.sum it's always a fixed version
128+
if prevVersion, ok := sumVersions[mod.Path]; ok {
129+
discoveredMu.Lock()
130+
discoveredVersions[mod.Path] = prevVersion
131+
discoveredMu.Unlock()
132+
133+
return downloadMod(ctx, module.Version{
134+
Path: mod.Path,
135+
Version: prevVersion,
136+
})
137+
}
138+
139+
discoveredMu.RLock()
140+
prevVersion, ok := discoveredVersions[mod.Path]
141+
discoveredMu.RUnlock()
142+
143+
if ok {
144+
if semver.Compare(mod.Version, prevVersion) == 1 {
145+
discoveredMu.Lock()
146+
discoveredVersions[mod.Path] = mod.Version
147+
discoveredMu.Unlock()
148+
return downloadMod(ctx, mod)
149+
} else {
150+
return nil
151+
}
152+
} else {
153+
discoveredMu.Lock()
154+
discoveredVersions[mod.Path] = mod.Version
155+
discoveredMu.Unlock()
156+
return downloadMod(ctx, mod)
157+
}
158+
}
159+
160+
go func() {
161+
if err := doWork(); err != nil {
162+
select {
163+
case errChan <- err:
164+
default:
165+
// Channel full, error already reported
166+
}
167+
}
168+
}()
169+
}
170+
171+
downloadMod = func(ctx context.Context, goModule module.Version) error {
172+
// Check for cancellation
173+
select {
174+
case <-ctx.Done():
175+
return ctx.Err()
176+
default:
177+
}
178+
179+
key := fmt.Sprintf("%s@%s", goModule.Path, goModule.Version)
180+
181+
// Check if already being handled
182+
downloadsMu.Lock()
183+
_, ok := downloads[key]
184+
185+
if ok {
186+
downloadsMu.Unlock()
187+
return nil
188+
}
189+
190+
// Set a nil value to indicate it is _being_ handled, but not handled yet
191+
_, exists := downloads[key]
192+
if exists {
193+
downloadsMu.Unlock()
194+
return nil
195+
}
196+
downloads[key] = nil
197+
downloadsMu.Unlock()
198+
199+
download, err := downloadModule(directory, goModule.Path, goModule.Version)
200+
if err != nil {
201+
return fmt.Errorf("error downloading module %s@%s: %w", goModule.Path, goModule.Version, err)
202+
}
203+
204+
contents, err := os.ReadFile(download.GoMod)
205+
if err != nil {
206+
return fmt.Errorf("error reading %s: %v", download.GoMod, err)
207+
}
208+
209+
mod, err := modfile.Parse(download.GoMod, contents, nil)
210+
if err != nil {
211+
return fmt.Errorf("error parsing %s: %v", download.GoMod, err)
212+
}
213+
214+
requireLoop:
215+
for _, require := range mod.Require {
216+
// Check for cancellation in loop
217+
select {
218+
case <-ctx.Done():
219+
return ctx.Err()
220+
default:
221+
}
222+
223+
for _, replacement := range mod.Replace {
224+
if require.Mod.Path == replacement.Old.Path {
225+
discoverMod(replacement.New)
226+
continue requireLoop
227+
}
228+
}
229+
230+
discoverMod(require.Mod)
231+
}
232+
233+
downloadsMu.Lock()
234+
downloads[key] = download
235+
downloadsMu.Unlock()
236+
237+
return nil
238+
}
239+
240+
// Start a goroutine to monitor for errors and cancel on first error
241+
done := make(chan struct{})
242+
var firstErr error
243+
go func() {
244+
for err := range errChan {
245+
if firstErr == nil {
246+
firstErr = err
247+
cancel() // Cancel all ongoing work on first error
248+
}
249+
}
250+
close(done)
251+
}()
252+
253+
for packagePath, version := range sumVersions {
254+
discoverMod(module.Version{
255+
Path: packagePath,
256+
Version: version,
257+
})
258+
}
259+
260+
wg.Wait()
261+
close(errChan)
262+
<-done
263+
264+
if firstErr != nil {
265+
return nil, firstErr
266+
}
267+
268+
modDownloads := make([]*goModDownload, len(discoveredVersions))
269+
i := 0
270+
for packagePath, version := range discoveredVersions {
271+
modDownloads[i] = downloads[fmt.Sprintf("%s@%s", packagePath, version)]
272+
i++
273+
}
274+
275+
slices.SortFunc(modDownloads, func(a, b *goModDownload) int {
276+
return strings.Compare(a.Path, b.Path)
277+
})
278+
279+
return modDownloads, nil
280+
}

go/gobuild-nix-generate/go.mod

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ module github.com/adisbladis/gobuild.nix/go/gobuild-nix-generate
33
go 1.25.4
44

55
require (
6-
github.com/BurntSushi/toml v1.5.0 // indirect
7-
golang.org/x/mod v0.30.0 // indirect
8-
golang.org/x/sync v0.18.0 // indirect
6+
github.com/BurntSushi/toml v1.5.0
7+
golang.org/x/mod v0.30.0
8+
golang.org/x/sync v0.18.0
99
)
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,55 @@
11
schema = 1
22
require = ["github.com/BurntSushi/toml", "golang.org/x/mod", "golang.org/x/sync"]
33

4+
[cycles]
5+
"golang.org/x/crypto" = 0
6+
"golang.org/x/mod" = 0
7+
"golang.org/x/net" = 0
8+
"golang.org/x/telemetry" = 0
9+
"golang.org/x/text" = 0
10+
"golang.org/x/tools" = 0
11+
412
[locked]
513
[locked."github.com/BurntSushi/toml"]
614
version = "v1.5.0"
715
hash = "sha256-SyKVqkZQopsYprjWCVssulTGQ/NUrrztdzsl02Bsg7Q="
16+
[locked."github.com/google/go-cmp"]
17+
version = "v0.6.0"
18+
hash = "sha256-cTZvg6svBSFLdmNinaT4HPL9cfPeeDSPItHIpK8sijo="
19+
[locked."github.com/yuin/goldmark"]
20+
version = "v1.4.13"
21+
hash = "sha256-gbmz7XQm9GgNDmC7SSPd9QAdKI3fGHQNWto3WR4pWlI="
22+
[locked."golang.org/x/crypto"]
23+
version = "v0.43.0"
24+
hash = "sha256-ZBMachasMBLHjsU4HPy68wvHO6Y7hV5BnvTSMBY+VDM="
25+
require = ["golang.org/x/net", "golang.org/x/sys", "golang.org/x/term", "golang.org/x/text"]
826
[locked."golang.org/x/mod"]
927
version = "v0.30.0"
1028
hash = "sha256-dEjRvA/ak+JgGyfQ3jzMc/uiznogPtqv2j+C6xJASqU="
29+
require = ["golang.org/x/tools"]
30+
[locked."golang.org/x/net"]
31+
version = "v0.46.0"
32+
hash = "sha256-htixoCv4MuxZFRz5nCnDbDfEo1oVoWze0fYDji213cs="
33+
require = ["golang.org/x/crypto", "golang.org/x/sys", "golang.org/x/term", "golang.org/x/text"]
1134
[locked."golang.org/x/sync"]
1235
version = "v0.18.0"
1336
hash = "sha256-Zm4eHAVpxplyeLW55l6JYcHFEEZgfp8WMQt5+Q7LF8o="
37+
[locked."golang.org/x/sys"]
38+
version = "v0.37.0"
39+
hash = "sha256-/MX5ilknF6lyaRMO5hxSvr9J2bBhNP7D+3dzzfXbx0M="
40+
[locked."golang.org/x/telemetry"]
41+
version = "v0.0.0-20251008203120-078029d740a8"
42+
hash = "sha256-Yxd8cbhA2ckEe0+DspQFS9iW0WpXEfr2wjn4lzk33Ok="
43+
require = ["golang.org/x/mod", "golang.org/x/sync", "golang.org/x/sys"]
44+
[locked."golang.org/x/term"]
45+
version = "v0.36.0"
46+
hash = "sha256-ca10B3VAScoKzxPLtacQpkkpve8KEznjqMsBY1qqVKg="
47+
require = ["golang.org/x/sys"]
48+
[locked."golang.org/x/text"]
49+
version = "v0.30.0"
50+
hash = "sha256-Z7udpwFbQF1nDGY892lP/XdKRq8JtBQeYpGXNMs85S4="
51+
require = ["golang.org/x/tools", "golang.org/x/mod", "golang.org/x/sync"]
52+
[locked."golang.org/x/tools"]
53+
version = "v0.38.0"
54+
hash = "sha256-AHKhWy0pE37H9myBZc/JjZpyMSEy7/9XcL/40it+C+Q="
55+
require = ["github.com/google/go-cmp", "github.com/yuin/goldmark", "golang.org/x/mod", "golang.org/x/net", "golang.org/x/sync", "golang.org/x/telemetry", "golang.org/x/sys"]

0 commit comments

Comments
 (0)