Skip to content

perf(devbox): skip the nix recompute when only env, init_hook or scripts change - #3031

Merged
mikeland73 merged 2 commits into
mainfrom
mikeland73/perf-split-state-hash
Oct 29, 2026
Merged

mikeland73 merged 2 commits into
mainfrom
mikeland73/perf-split-state-hash

Conversation

@mikeland73

Copy link
Copy Markdown
Collaborator

Summary

Implements item 8 of specs/perf-improvements.md.

.devbox/state.json stored one hash of the whole config. Editing a script, an env var or the init hook made the next devbox run/shell/shellenv do a full recompute: package install checks, narinfo lookups, flake generation, nix print-dev-env and a nix profile sync. None of those outputs depend on these fields.

What counts as a nix input

  • state.json gets a second hash, nix_config_hash. It's computed like config_hash, but the root config's env, env_from, shell (init_hook and scripts), aliases, name, description, $schema and devbox_version are removed from the AST first. I checked each of these. None of them reach the flake template, nix print-dev-env or the profile. env/env_from are merged in computeEnv on every run, scripts and the init hook are written by shellgen.WriteScriptsToFiles, and aliases are read when a shell or command starts. devbox_version is only read by vercheck, and the state file already tracks the running version.
  • Everything else, including unknown fields, still counts as a nix input: packages, include, nixpkgs, the package hashes (which cover local flake contents), and all included plugin configs. Plugins are hashed in full, including their env and init hooks, because they can add packages and create files.

Up-to-date check

  • New lock.File.StateStatus returns up to date, non-nix stale, or stale. It returns non-nix stale only when config_hash is the only thing that changed. The lockfile, print-dev-env cache, profile manifest, Devbox version and shell must all still match. IsUpToDateAndInstalled now wraps it.
  • A state.json written by an older Devbox has no nix_config_hash, so it gets one full recompute.

Fast path

  • In ensure mode, a non-nix stale state skips installPackages and recomputeState. It rewrites the scripts and the .hooks file and saves the new hashes, and the env is then computed from the cached print-dev-env output. It doesn't print "Ensuring packages are installed". Inside a devbox shell it still prints the "environment may be out of date" warning, as before, because the env vars or init hook may have changed.
  • --recompute=false (shellenv, run) also does this refresh instead of calling OnStaleState, since no nix work is involved. Before, it printed the stale warning and kept the old .hooks file.
  • install, uninstall and update modes don't change. They always do the full work.

Testing

  • internal/devconfig/configfile: NixHash ignores each non-nix field and formatting/comments. It changes for packages, include, nixpkgs and unknown fields, and doesn't modify the config. There's also a test for the struct fallback used when there is no AST.
  • internal/lock: table test for compareStateHashes (each non-config field change → stale, old state file → stale), plus File.StateStatus against a real state.json on disk.
  • internal/devbox: after a script, env and init_hook edit, ensureStateIsUpToDate never calls uncached print-dev-env. It writes the new hooks and script files, deletes the removed script, and the computed env has the new value. A SkipRecompute test checks the same thing for --recompute=false. Package and include edits are still stale.
  • New testscript run/nonnix_state.test.txt covers the run → edit → run → edit packages flow. I also ran the existing run, add, rm, generate direnv and lockfile testscripts and they pass.
  • go test and golangci-lint run on the changed packages: pass, 0 issues.
  • I compared the fast path with a full recompute in a project with hello and jq. After an edit, the devbox shellenv --init-hook output, .devbox/gen/ and state.json were byte-for-byte the same as after deleting state.json and recomputing.
  • With DEVBOX_DEBUG=1 the fast path logs only non-nix inputs changed and runs no nix commands.
  • Timing on macOS (nix 2.30), same project, DO_NOT_TRACK=1, averaged over 10 edits of each kind. Each row is the first command after that edit:
Edit devbox run say
main
devbox run say
this PR
devbox shellenv
main
devbox shellenv
this PR
none (up to date) 22ms 25ms 16ms 16ms
script 919ms 25ms 976ms 18ms
env var 899ms 25ms 960ms 17ms
init_hook 917ms 25ms 954ms 17ms
packages (add/remove jq) 1099ms 1107ms 1140ms 1166ms

Most of the main-branch cost is nix print-dev-env (~830ms), followed by nix path-info for the install check (~120ms) and nix profile list (~80ms).

🤖 Generated with Claude Code

mikeland73 and others added 2 commits October 29, 2026 14:11
…pts change

Implements item 8 of `specs/perf-improvements.md`.

`.devbox/state.json` stored one hash of the whole config, so editing a
script, an env var or the init hook made the next command do a full
recompute: package install checks, narinfo lookups, flake generation,
`nix print-dev-env` and a nix profile sync. None of those outputs depend
on these fields.

- `state.json` gets a second hash, `nix_config_hash`. It's like
  `config_hash`, except the root config's `env`, `env_from`, `shell`
  (`init_hook` and `scripts`), `aliases`, `name`, `description`,
  `$schema` and `devbox_version` are removed from the AST before hashing.
  Everything else, including unknown fields, still counts as a nix input.
  Included plugins, package hashes and plugin config hashes are hashed in
  full, the same as before.
- New `lock.File.StateStatus` returns up to date, non-nix stale, or
  stale. It returns non-nix stale only when `config_hash` is the only field
  that changed: the lockfile, print-dev-env cache, profile manifest,
  Devbox version and shell must all still match. `IsUpToDateAndInstalled`
  wraps it.
- When the state is non-nix stale, `ensureStateIsUpToDate` (ensure mode)
  rewrites the scripts and the `.hooks` file and saves the new hashes. It
  doesn't print "Ensuring packages are installed". The env is then
  computed from the cached print-dev-env output. Env vars, `env_from` and
  aliases need no extra work because they're read from the config every
  time the env is computed.
- `--recompute=false` (shellenv, run) also does this refresh instead of
  printing the stale state warning, since it doesn't run nix.
- install, uninstall and update modes don't change. They always do the
  full work.
- A `state.json` written by an older Devbox has no `nix_config_hash`, so
  it gets one full recompute.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@mikeland73

Copy link
Copy Markdown
Collaborator Author

@claude ptal

@claude

claude Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

Claude finished @mikeland73's task in 38s —— View job


Review of #3031: skip the nix recompute when only env, init_hook or scripts change

I read the non-test diff and checked the PR's claims against the code. I didn't run the tests or linters. Overall the change looks correct and I found no blocking issues.

What I checked

  • The excluded fields don't reach the nix outputs. newFlakePlan and the flake templates only read packages and IncludedPluginConfigs(), not root env, shell, aliases or the metadata fields. Outside the nix-hash code, the root Env(), InitHook(), Scripts() and Aliases() are read only in computeEnv (devbox.go:1115), WriteScriptsToFiles and the shell/command start paths. That matches the PR's claim.
  • The fast path writes the same files as the full path. GenerateForPrintEnv ends with WriteScriptsToFiles, and refreshNonNixState calls it directly. So the .hooks file, the script files and the deletion of removed scripts match a full recompute. This agrees with your byte-for-byte comparison.
  • Fail-safe defaults hold. compareStateHashes returns non-nix stale only when NixConfigHash is non-empty and every other field matches. An old state.json, a lockfile change, a print-dev-env cache change, a version change or a shell change all fall back to a full recompute. An unknown top-level field is treated as a nix input (it isn't in nonNixFields). Plugin configs are hashed in full.
  • The stale warning is preserved. warnIfEnvIsStale is pulled out of the inline block with the same condition. warnIfEnvIsStale fires on the non-nix path in ensure mode. On the SkipRecompute path it does not, which matches how --recompute=false behaved before.

Minor, non-blocking

  1. Easy to break later. nonNixFields (configfile/file.go) and the struct fallback in NixHash (the c.ast == nil branch) list the same fields by hand. Adding a field to one and not the other would make the two paths hash differently. Both fail safe only if the new field is treated as a nix input. A test that checks every ConfigFile struct field against the fallback list would catch this. It's optional.
  2. Fallback shallow copy. nixOnly := *c copies the whole struct, including the ast pointer. That's fine today because the ast == nil branch only runs when ast is nil. A one-line comment would stop someone from reordering the branches by mistake.
  3. Rarely-seen error path. In refreshNonNixState, if WriteScriptsToFiles succeeds and saveStateHashFile then fails, the next run just redoes the cheap refresh. This is safe, so no change is needed.
  4. devbox_version in the exclusion list. This relies on vercheck handling version switches before the state check, and on state.json recording the running version separately. The PR description explains this. A short pointer to the vercheck code in the nonNixFields comment would help future readers.

Tests
Coverage looks thorough. It includes the compareStateHashes table, File.StateStatus against a real state.json, the "never calls uncached print-dev-env" assertion, the SkipRecompute case and the new testscript. The timing numbers are a clear win: roughly 920ms down to 25ms on script, env and init_hook edits, and no change for package edits.

LGTM from my side, with the nits above as optional follow-ups.
· branch mikeland73/perf-split-state-hash

@mikeland73
mikeland73 merged commit dc599b1 into main Oct 29, 2026
28 checks passed
@mikeland73
mikeland73 deleted the mikeland73/perf-split-state-hash branch October 29, 2026 22:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant