From 4e8cba9ac7d115a3317d7311f3ef7a39c2e2eaff Mon Sep 17 00:00:00 2001 From: Mike Landau Date: Sun, 25 Oct 2026 14:34:26 -0700 Subject: [PATCH 1/2] perf(nix): cache nix version info on disk Every devbox command runs `nix --version --debug` (~90ms) to get the Nix version and system, and the result was only cached per process. Persist the parsed Info under the XDG cache dir so later commands can skip it. The cache key is the resolved nix binary path, the command arguments, and the environment variables that affect Nix's config. The entry also stamps (mtime + size) the nix binary and every nix.conf that --debug reports, including ones that don't exist yet, so editing or creating a config invalidates it. Entries expire after 24h as a backstop for changes the stamps can't see (e.g. nix.conf include directives). Failures aren't cached. Also skip the nix.System() lookup in IsEnabledOnPlatform when a package doesn't restrict its platforms. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/devconfig/configfile/packages.go | 5 + nix/infocache.go | 130 +++++++++++++++++++++ nix/infocache_test.go | 135 ++++++++++++++++++++++ nix/nix.go | 16 +++ 4 files changed, 286 insertions(+) create mode 100644 nix/infocache.go create mode 100644 nix/infocache_test.go diff --git a/internal/devconfig/configfile/packages.go b/internal/devconfig/configfile/packages.go index f545b38e57a..3f24f5e8b7c 100644 --- a/internal/devconfig/configfile/packages.go +++ b/internal/devconfig/configfile/packages.go @@ -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 { diff --git a/nix/infocache.go b/nix/infocache.go new file mode 100644 index 00000000000..06cfd6fa967 --- /dev/null +++ b/nix/infocache.go @@ -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) +} diff --git a/nix/infocache_test.go b/nix/infocache_test.go new file mode 100644 index 00000000000..da385701261 --- /dev/null +++ b/nix/infocache_test.go @@ -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 < Date: Sun, 25 Oct 2026 14:46:34 -0700 Subject: [PATCH 2/2] docs(specs): mark perf item(s) 1 done Co-Authored-By: Claude Opus 5.5 (1M context) --- specs/perf-improvements.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specs/perf-improvements.md b/specs/perf-improvements.md index 70ff55bfe03..4a656b09104 100644 --- a/specs/perf-improvements.md +++ b/specs/perf-improvements.md @@ -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.