Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions internal/devconfig/configfile/packages.go
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,11 @@ func NewVersionOnlyPackage(name, version string) Package {
// If the package has a list of excluded platforms, it is enabled on all platforms
// except those.
func (p *Package) IsEnabledOnPlatform() bool {
// Avoid looking up the Nix system, which can run nix, in the common
// case where the package doesn't restrict its platforms.
if len(p.Platforms) == 0 && len(p.ExcludedPlatforms) == 0 {
return true
}
platform := nix.System()
if len(p.Platforms) > 0 {
for _, plt := range p.Platforms {
Expand Down
130 changes: 130 additions & 0 deletions nix/infocache.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
package nix

import (
"os"
"path/filepath"
"slices"
"strings"
"time"

"go.jetify.com/devbox/internal/xdg"
"go.jetify.com/pkg/filecache"
)

// infoCacheTTL bounds how long a cached [Info] is trusted. The cache key and
// file stamps catch the common changes (a new Nix binary or an edited
// nix.conf), but not everything that can affect the output, such as a file
// pulled in by an include directive in nix.conf.
const infoCacheTTL = 24 * time.Hour

// infoCacheEnv are the environment variables that can change the output of
// "nix --version --debug" without changing the Nix binary or its config files.
var infoCacheEnv = []string{
"HOME",
"NIX_CONF_DIR",
"NIX_CONFIG",
"NIX_DATA_DIR",
"NIX_STATE_DIR",
"NIX_STORE_DIR",
"NIX_USER_CONF_FILES",
"XDG_CONFIG_DIRS",
"XDG_CONFIG_HOME",
}

// cachedInfo is an [Info] stored on disk along with stamps of the files it
// was derived from.
type cachedInfo struct {
Info Info
Files []fileStamp
}

// fileStamp records the state of a file at the time [Info] was cached. A
// missing file has a zero ModTime and Size so that creating it later
// invalidates the cache.
type fileStamp struct {
Path string
ModTime time.Time
Size int64
}

func newFileStamp(path string) fileStamp {
stamp := fileStamp{Path: path}
if fi, err := os.Stat(path); err == nil {
stamp.ModTime = fi.ModTime()
stamp.Size = fi.Size()
}
return stamp
}

func (s fileStamp) changed() bool {
current := newFileStamp(s.Path)
return !current.ModTime.Equal(s.ModTime) || current.Size != s.Size
}

func newInfoCache() *filecache.Cache[cachedInfo] {
return filecache.New("devbox/nix-info", filecache.WithCacheDir[cachedInfo](xdg.CacheSubpath("")))
}

// infoCacheKey returns the key for caching the output of cmd. It identifies the
// Nix binary by its resolved path (usually an immutable /nix/store path) and
// includes the command arguments and any environment variables that affect the
// output.
func infoCacheKey(cmd *Cmd) string {
bin := cmd.Path
if resolved, err := filepath.EvalSymlinks(bin); err == nil {
bin = resolved
}

var key strings.Builder
key.WriteString(bin)
for _, arg := range cmd.Args.StringSlice()[1:] {
key.WriteString("\x00")
key.WriteString(arg)
}
for _, env := range infoCacheEnv {
key.WriteString("\x00")
key.WriteString(env)
key.WriteString("=")
key.WriteString(os.Getenv(env))
}
return key.String()
}

// infoFiles returns the files whose changes should invalidate a cached info:
// the Nix binary itself and the Nix config files that it reported.
func infoFiles(cmd *Cmd, info Info) []string {
files := []string{cmd.Path}
if info.SystemConfig != "" {
files = append(files, info.SystemConfig)
}
for _, path := range info.UserConfigs {
if path != "" {
files = append(files, path)
}
}
return files
}

// loadCachedInfo returns the cached info for key, or false if there's no
// cached info or if any of the files it was derived from have changed.
func loadCachedInfo(cache *filecache.Cache[cachedInfo], key string) (Info, bool) {
cached, err := cache.Get(key)
if err != nil || cached.Info.Version == "" {
return Info{}, false
}
if slices.ContainsFunc(cached.Files, fileStamp.changed) {
return Info{}, false
}
return cached.Info, true
}

// storeCachedInfo saves info to the cache, stamping the files in paths so that
// later changes to them invalidate it.
func storeCachedInfo(cache *filecache.Cache[cachedInfo], key string, info Info, paths []string) {
cached := cachedInfo{Info: info, Files: make([]fileStamp, len(paths))}
for i, path := range paths {
cached.Files[i] = newFileStamp(path)
}
// Caching is best-effort. A failure only means running nix next time.
_ = cache.Set(key, cached, infoCacheTTL)
}
135 changes: 135 additions & 0 deletions nix/infocache_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
package nix

import (
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
)

// fakeNixVersion writes a fake nix executable that prints "nix --version --debug"
// output referencing confPath and records each invocation in a log file. It
// returns the path to the executable and a function that returns how many
// times it has run.
func fakeNixVersion(t *testing.T, confPath string, exitCode int) (path string, runs func() int) {
t.Helper()

dir := t.TempDir()
logPath := filepath.Join(dir, "runs.log")
path = filepath.Join(dir, "nix")
script := `#!/bin/sh
echo run >> '` + logPath + `'
cat <<EOF
nix (Nix) 2.21.2
System type: aarch64-darwin
System configuration file: ` + confPath + `
User configuration files: ` + filepath.Join(dir, "missing.conf") + `
Store directory: /nix/store
EOF
exit ` + strconv.Itoa(exitCode) + `
`
if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
t.Fatal(err)
}
return path, func() int {
data, _ := os.ReadFile(logPath)
return strings.Count(string(data), "run")
}
}

func setupInfoCacheTest(t *testing.T) (confPath string) {
t.Helper()
t.Setenv("XDG_CACHE_HOME", t.TempDir())
for _, env := range infoCacheEnv {
if env != "HOME" {
t.Setenv(env, "")
}
}

confPath = filepath.Join(t.TempDir(), "nix.conf")
if err := os.WriteFile(confPath, []byte("experimental-features = nix-command\n"), 0o644); err != nil {
t.Fatal(err)
}
return confPath
}

func TestInfoDiskCache(t *testing.T) {
confPath := setupInfoCacheTest(t)
nixPath, runs := fakeNixVersion(t, confPath, 0)

info := func() Info {
t.Helper()
// Use a new Nix each time to bypass the in-process cache.
info, err := (&Nix{Path: nixPath}).Info()
if err != nil {
t.Fatal("got Info error:", err)
}
return info
}

got := info()
if got.Version != "2.21.2" || got.System != "aarch64-darwin" {
t.Errorf("got Info = %+v, want version 2.21.2 and system aarch64-darwin", got)
}
if runs() != 1 {
t.Fatalf("got %d nix runs, want 1", runs())
}

got = info()
if got.Version != "2.21.2" || got.System != "aarch64-darwin" {
t.Errorf("got cached Info = %+v, want version 2.21.2 and system aarch64-darwin", got)
}
if runs() != 1 {
t.Errorf("got %d nix runs after a cache hit, want 1", runs())
}

// Editing nix.conf invalidates the cache.
if err := os.WriteFile(confPath, []byte("system = x86_64-darwin\n"), 0o644); err != nil {
t.Fatal(err)
}
future := time.Now().Add(time.Minute)
if err := os.Chtimes(confPath, future, future); err != nil {
t.Fatal(err)
}
info()
if runs() != 2 {
t.Errorf("got %d nix runs after editing nix.conf, want 2", runs())
}

// Creating a user config file that didn't exist invalidates the cache.
if err := os.WriteFile(filepath.Join(filepath.Dir(nixPath), "missing.conf"), nil, 0o644); err != nil {
t.Fatal(err)
}
info()
if runs() != 3 {
t.Errorf("got %d nix runs after creating a user nix.conf, want 3", runs())
}

// Changing an environment variable that affects nix's config changes
// the cache key.
t.Setenv("NIX_CONFIG", "system = x86_64-darwin")
info()
if runs() != 4 {
t.Errorf("got %d nix runs after setting NIX_CONFIG, want 4", runs())
}
info()
if runs() != 4 {
t.Errorf("got %d nix runs after a cache hit, want 4", runs())
}
}

func TestInfoDiskCacheSkipsErrors(t *testing.T) {
confPath := setupInfoCacheTest(t)
nixPath, runs := fakeNixVersion(t, confPath, 1)

for range 2 {
if _, err := (&Nix{Path: nixPath}).Info(); err == nil {
t.Fatal("got nil Info error for a failing nix command")
}
}
if runs() != 2 {
t.Errorf("got %d nix runs, want 2 (failures shouldn't be cached)", runs())
}
}
16 changes: 16 additions & 0 deletions nix/nix.go
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,10 @@ func (n *Nix) Version() string {
// Info returns Nix version information. It caches the result after the first
// call, which means it won't reflect any configuration changes to Nix. Create a
// new Nix instance to retrieve uncached information.
//
// Info also caches the result on disk across processes. The disk cache is
// invalidated when the Nix binary, its config files, or relevant environment
// variables change.
func (n *Nix) Info() (Info, error) {
// Create the command first, which will catch any errors finding the Nix
// executable outside of the once. This allows us to retry after
Expand All @@ -171,6 +175,18 @@ func (n *Nix) Info() (Info, error) {
}

n.infoOnce.Do(func() {
cache := newInfoCache()
cacheKey := infoCacheKey(cmd)
if info, ok := loadCachedInfo(cache, cacheKey); ok {
n.info = info
return
}
defer func() {
if n.infoErr == nil {
storeCachedInfo(cache, cacheKey, n.info, infoFiles(cmd, n.info))
}
}()

out, err := cmd.Output(context.Background())
if err != nil {
var exitErr *exec.ExitError
Expand Down
2 changes: 1 addition & 1 deletion specs/perf-improvements.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ direnv `cd` about 5–10× faster when nothing has changed.

## Tier 1: fixed cost on every command

### 1. Cache the nix version info on disk
### 1. Cache the nix version info on disk (done in #3020)

**Win:** ~90ms on every command. Could take `shellenv` from ~100ms to ~10–20ms.
**Effort:** ~80–120 LOC.
Expand Down
Loading