okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

dawnnix shim: carry every dev shell export into the cached shell74cf623

main
9.7 KiB164 linesraw

shims: nix and cargo

the model's jobs (run(), job.pipe(), and remote kernels' alike) find two shims first on PATH, each under $ALBEDO_HOME/shims/<needs>/ and prepended only when the program it needs is on the job's PATH (albedo_shims.prepend): shims/nix/nix when nix is, shims/mbx/cargo when mbx is. the kernel writes them where it runs (only .py files travel to a remote kernel), and every nix call a job makes goes through the shim, however deep in a script it is. Before lookup, prepend removes inherited */shims/nix and */shims/mbx directories, then installs only this kernel's shims for real tools on PATH. Nested daemon jobs must not resolve one shim generation through another. there is no nix API for the model to learn: it types nix develop -c cargo test or nix build .#x and the shim makes that safe and fast.

a daemon started with a thin PATH (a GUI or launchd start never ran the user's profile) would see no nix at all, so prepend first looks for each program of FALLBACKS that PATH lacks and appends the first directory holding it (/nix/var/nix/profiles/default/bin, then ~/.nix-profile/bin). it goes last, so it never shadows the user's PATH, and it joins before the shim check so the shim still goes in front. it covers job environments only: shutil.which in a cell reads the kernel process's own PATH.

nix

why

a jj workspace other than the colocated one has .jj/ but no .git, so nix reads . as a plain path: flake and copies the whole directory into the store on every evaluation: build output, target/, .jj and all (140 MB per eval measured on a small checkout, ~850 MB for a full albedo one). a git checkout or worktree is fine: nix's git fetcher copies tracked files only.

the program

shims/nix/nix is sh: with ALBEDO_SHIMS_PYTHON and ALBEDO_SHIMS_LIB set (by prepend, the kernel's python and its priv/python) it runs albedo_nix.py with python -E -s, else, and with ALBEDO_PLAIN_NIX=1, the next nix on PATH. albedo_nix.py is stdlib only and never imported by the kernel. what it does with a command line:

  1. reads it with nix's own flag table, nix __dump-cli (50 ms, cached in $ALBEDO_HOME/cache/nix-cli/ per nix binary), so every flag's arity is known. commands it cannot read (a flag or command not in the table) go to nix unchanged, which refuses them itself. ALIASES covers nix's renamed commands the table lists only under their new name (nix shell is env shell, profile install is profile add, dev-shell is develop).
  2. nix develop [installable] -c program ... with only output flags (QUIET) runs the program in the cached dev shell (below), with the shims kept first on PATH. any other develop flag (-i, --phase, ...) goes to step 3.
  3. every word that names a local flake of a jj workspace without .git is replaced by the workspace's commit ref, following the subcommand's argument labels in the table (installables, installable, flake-url, package, dependency), plus --inputs-from and flake update --flake. a command that names none defaults to . as nix does (not nix eval, not with --expr/--file/--stdin/--all). nix fmt and nix formatter run|build become nix run|build <ref>#formatter.<system> with PRJ_ROOT set to the flake's directory, as nix fmt sets it. the rewritten command is printed to stderr after albedo:, then exec'd.
  4. anything else is exec'd unchanged.

refs and lock files

flake_ref: jj log -r @ snapshots the working copy, so commit @ holds every edit, and jj git root names the git store. the ref is git+file://<store>?rev=<commit> (&dir= for a flake below the workspace root); a colocated .git is named by its work tree (nix reads it in place; naming the .git directory makes nix clone it into its cache), a bare store as itself. jj keeps every commit under refs/jj/keep/*, so nix finds it. git checkouts and directories outside version control are never rewritten.

nix writes a lock file it had to create or update into the repository a git+file ref names: for a colocated store that is the main checkout's work tree (measured: nix build <ref> created main/flake.lock). so a rewritten command gets --output-lock-file <workspace flake>/flake.lock, which writes where nix would have in a git checkout. that flag covers every flake the command locks (--inputs-from . nixpkgs#hello wrote nixpkgs' empty lock over the workspace's), so a command that locks any other flake gets --no-write-lock-file instead. --commit-lock-file would commit in the main checkout and is refused. nix rewrites the output lock file even when it is unchanged; nix-formatted locks keep their content.

dev shells

nix print-dev-env --profile <cache>/<key> <ref> writes the same script nix develop sources; the shim sources it with the shell's own bash (the shell variable from the profile's JSON: macOS's /bin/bash 3.2 cannot run it), which runs the shellHook (its output goes to stderr), removes $NIX_BUILD_TOP, and dumps env -0. what is kept is the difference from the job's environment (delta): variables set, prefixes put before a list the shell kept (PATH, XDG_DATA_DIRS) and variables unset. every variable the script exports is kept even where the capture's own environment already held its value (mkShell env.* values, CC, NIX_CFLAGS_COMPILE, ...): what nix develop -c gives a program does not depend on who ran it, and a capture run inside the very shell it was capturing kept nothing of it once (nix develop -c env showed no CGO_ENABLED, and a cgo link failed). the script's function bodies do not run, so their exports do not count; the paths it composes from the caller's own (it saves them as nix_saved_<name>) stay prefixes; and what the script leaves alone (HOME, TERM) is the caller's. TMPDIR, NIX_BUILD_TOP and the like (VOLATILE) are dropped. applied lays it over each later job's environment. a build prints albedo: building the dev shell for <dir> first; a cached run prints nothing of its own.

the cache is $ALBEDO_HOME/cache/nix-develop/<key>.json, and the profile <key> beside it is a garbage-collector root for the shell's closure. the key hashes the system, the attribute, the flake's place in its checkout, and the content of every tracked SHELL_FILES file (*.nix, flake.lock, rust-toolchain, rust-toolchain.toml; jj file list --ignore-working-copy or git ls-files, a walk outside version control). so an unchanged flake costs one jj file list and no nix call (60 ms), and every checkout of the same repository at the same shell files shares one entry. a build that wrote the lock file is stored under the key after it too. a record another capture shape wrote (it may carry too little) is rebuilt when next wanted, not replayed. the 32 most recently used entries are kept; a flake elsewhere (nixpkgs#hello) is keyed by its name and kept a day. shims building the same key at once wait on one flock.

limits: a dev shell made of other files (built from Cargo.lock or pyproject.toml) is not rebuilt when only those change; a shellHook with side effects (starting a service) runs once per build, not per command; a stale jj workspace (jj workspace update-stale) fails with jj's message. nix-shell and nix-build are other binaries and are not shimmed.

cargo

shims/mbx/cargo runs mbx (jdx's mr-boxington) in its shim mode, so every checkout shares compiled work through mbx's cache. ALBEDO_NO_MBX=1, or mbx gone from PATH, runs the next cargo. mbx's own mbx setup is not used: it edits the user's rust-analyzer config, and its shim exits 127 when mbx is missing.

what the shim changes for mbx's process, all measured on native/render (a second checkout: 32 s with 55 links bypassed, 2.1 s with 201 hits after):

  • SDKROOT is unset. mbx names the macOS linker with xcrun --sdk "$SDKROOT" --show-sdk-version, and xcrun rejects a path such as a nix shell's store SDK, which leaves the linker unnamed and every native link uncached: build scripts, proc macros, binaries, tests, and so every crate depending on a proc macro or a cc-built -sys crate. DEVELOPER_DIR stays, so the same SDK is linked.
  • inside a nix shell (ALBEDO_NIX_ENV, which the dev-shell capture sets), CC and CXX are unset when they resolve to the same file as cc and c++ on PATH (the nix cc-wrapper's cc is a symlink to its clang), so a build script's C compiles through mbx and stays portable: an explicit CC makes mbx leave that C alone (cc-compiler-override), and a dev build's objects then record the checkout's paths (synthetic workspace, second checkout: 31 hits and 3 misses with CXX set, 34 and 0 without). a shell whose CC is another compiler (gcc, a cross compiler) keeps it. and HOST_CFLAGS/HOST_CXXFLAGS gain -DALBEDO_NIX_ENV_<digest>. the digest is of the shell's compiler-wrapper variables (NIX_CFLAGS*, NIX_LDFLAGS*, NIX_CC*, NIX_HARDENING_ENABLE), which change C output but are in no mbx key: without it a changed shell was served another checkout's stale object.

never set for cargo here: CARGO_INCREMENTAL, RUSTC_WRAPPER, MBX_CACHE_DIR (one cache is the point), a --target equal to the host, or -C link-arg. what stays per checkout: crates whose build-script output or source names the checkout or target path and their dependents (aws-lc-sys's memcmp probe leaves a path-bearing .dSYM in OUT_DIR under -g; tikv-jemalloc-sys runs autotools there unless JEMALLOC_OVERRIDE names a built library), and a workspace crate being edited. a new toolchain or dev shell is a new key, as it should be.

mbx replaces a checkout's target/ with a symlink into its cache. an ignore rule with a trailing slash (target/) matches directories only, so jj would track that symlink: albedo's own rule is /native/render/target.

1# shims: nix and cargo
2
3the model's jobs (`run()`, `job.pipe()`, and remote kernels' alike) find two
4shims first on PATH, each under `$ALBEDO_HOME/shims/<needs>/` and prepended
5only when the program it needs is on the job's PATH (`albedo_shims.prepend`):
6`shims/nix/nix` when nix is, `shims/mbx/cargo` when mbx is. the kernel writes
7them where it runs (only `.py` files travel to a remote kernel), and every
8nix call a job makes goes through the shim, however deep in a script it is.
9Before lookup, `prepend` removes inherited `*/shims/nix` and `*/shims/mbx`
10directories, then installs only this kernel's shims for real tools on PATH.
11Nested daemon jobs must not resolve one shim generation through another.
12there is no nix API for the model to learn: it types `nix develop -c cargo
13test` or `nix build .#x` and the shim makes that safe and fast.
14
15a daemon started with a thin PATH (a GUI or launchd start never ran the user's
16profile) would see no nix at all, so `prepend` first looks for each program of
17`FALLBACKS` that PATH lacks and appends the first directory holding it
18(`/nix/var/nix/profiles/default/bin`, then `~/.nix-profile/bin`). it goes last,
19so it never shadows the user's PATH, and it joins before the shim check so the
20shim still goes in front. it covers job environments only: `shutil.which` in a
21cell reads the kernel process's own PATH.
22
23## nix
24
25### why
26
27a jj workspace other than the colocated one has `.jj/` but no `.git`, so nix
28reads `.` as a plain `path:` flake and copies the whole directory into the
29store on every evaluation: build output, `target/`, `.jj` and all (140 MB per
30eval measured on a small checkout, ~850 MB for a full albedo one). a git
31checkout or worktree is fine: nix's git fetcher copies tracked files only.
32
33### the program
34
35`shims/nix/nix` is sh: with `ALBEDO_SHIMS_PYTHON` and `ALBEDO_SHIMS_LIB` set
36(by `prepend`, the kernel's python and its `priv/python`) it runs
37`albedo_nix.py` with `python -E -s`, else, and with `ALBEDO_PLAIN_NIX=1`, the
38next nix on PATH. `albedo_nix.py` is stdlib only and never imported by the
39kernel. what it does with a command line:
40
411. reads it with nix's own flag table, `nix __dump-cli` (50 ms, cached in
42   `$ALBEDO_HOME/cache/nix-cli/` per nix binary), so every flag's arity is
43   known. commands it cannot read (a flag or command not in the table) go to
44   nix unchanged, which refuses them itself. `ALIASES` covers nix's renamed
45   commands the table lists only under their new name (`nix shell` is
46   `env shell`, `profile install` is `profile add`, `dev-shell` is `develop`).
472. `nix develop [installable] -c program ...` with only output flags (`QUIET`)
48   runs the program in the cached dev shell (below), with the shims kept first
49   on PATH. any other develop flag (`-i`, `--phase`, ...) goes to step 3.
503. every word that names a local flake of a jj workspace without `.git` is
51   replaced by the workspace's commit ref, following the subcommand's argument
52   labels in the table (`installables`, `installable`, `flake-url`, `package`,
53   `dependency`), plus `--inputs-from` and `flake update --flake`. a command
54   that names none defaults to `.` as nix does (not `nix eval`, not with
55   `--expr`/`--file`/`--stdin`/`--all`). `nix fmt` and `nix formatter run|build`
56   become `nix run|build <ref>#formatter.<system>` with `PRJ_ROOT` set to the
57   flake's directory, as nix fmt sets it. the rewritten command is printed to
58   stderr after `albedo:`, then exec'd.
594. anything else is exec'd unchanged.
60
61### refs and lock files
62
63`flake_ref`: `jj log -r @` snapshots the working copy, so commit `@` holds every
64edit, and `jj git root` names the git store. the ref is
65`git+file://<store>?rev=<commit>` (`&dir=` for a flake below the workspace
66root); a colocated `.git` is named by its work tree (nix reads it in place;
67naming the `.git` directory makes nix clone it into its cache), a bare store as
68itself. jj keeps every commit under `refs/jj/keep/*`, so nix finds it. git
69checkouts and directories outside version control are never rewritten.
70
71nix writes a lock file it had to create or update into the repository a
72`git+file` ref names: for a colocated store that is the main checkout's work
73tree (measured: `nix build <ref>` created `main/flake.lock`). so a rewritten
74command gets `--output-lock-file <workspace flake>/flake.lock`, which writes
75where nix would have in a git checkout. that flag covers every flake the
76command locks (`--inputs-from . nixpkgs#hello` wrote nixpkgs' empty lock over
77the workspace's), so a command that locks any other flake gets
78`--no-write-lock-file` instead. `--commit-lock-file` would commit in the main
79checkout and is refused. nix rewrites the output lock file even when it is
80unchanged; nix-formatted locks keep their content.
81
82### dev shells
83
84`nix print-dev-env --profile <cache>/<key> <ref>` writes the same script
85`nix develop` sources; the shim sources it with the shell's own bash (the
86`shell` variable from the profile's JSON: macOS's /bin/bash 3.2 cannot run it),
87which runs the shellHook (its output goes to stderr), removes `$NIX_BUILD_TOP`,
88and dumps `env -0`. what is kept is the difference from the job's environment
89(`delta`): variables set, prefixes put before a list the shell kept (PATH,
90XDG_DATA_DIRS) and variables unset. every variable the script exports is kept
91even where the capture's own environment already held its value (mkShell
92`env.*` values, `CC`, `NIX_CFLAGS_COMPILE`, ...): what `nix develop -c` gives a
93program does not depend on who ran it, and a capture run inside the very shell
94it was capturing kept nothing of it once (`nix develop -c env` showed no
95`CGO_ENABLED`, and a cgo link failed). the script's function bodies do not run,
96so their exports do not count; the paths it composes from the caller's own (it
97saves them as `nix_saved_<name>`) stay prefixes; and what the script leaves
98alone (`HOME`, `TERM`) is the caller's. `TMPDIR`, `NIX_BUILD_TOP` and the like
99(`VOLATILE`) are dropped. `applied` lays it over each later job's environment.
100a build prints `albedo: building the dev shell for <dir>` first; a cached run
101prints nothing of its own.
102
103the cache is `$ALBEDO_HOME/cache/nix-develop/<key>.json`, and the profile
104`<key>` beside it is a garbage-collector root for the shell's closure. the key
105hashes the system, the attribute, the flake's place in its checkout, and the
106content of every tracked `SHELL_FILES` file (`*.nix`, `flake.lock`,
107`rust-toolchain`, `rust-toolchain.toml`; `jj file list --ignore-working-copy`
108or `git ls-files`, a walk outside version control). so an unchanged flake
109costs one `jj file list` and no nix call (60 ms), and every checkout of the
110same repository at the same shell files shares one entry. a build that wrote
111the lock file is stored under the key after it too. a record another capture
112shape wrote (it may carry too little) is rebuilt when next wanted, not
113replayed. the 32 most recently used entries are kept; a flake elsewhere
114(`nixpkgs#hello`) is keyed by its name and kept a day. shims building the same
115key at once wait on one `flock`.
116
117limits: a dev shell made of other files (built from Cargo.lock or
118pyproject.toml) is not rebuilt when only those change; a shellHook with side
119effects (starting a service) runs once per build, not per command; a stale jj
120workspace (`jj workspace update-stale`) fails with jj's message. `nix-shell`
121and `nix-build` are other binaries and are not shimmed.
122
123## cargo
124
125`shims/mbx/cargo` runs mbx (jdx's mr-boxington) in its shim mode, so every
126checkout shares compiled work through mbx's cache. `ALBEDO_NO_MBX=1`, or mbx
127gone from PATH, runs the next cargo. mbx's own `mbx setup` is not used: it
128edits the user's rust-analyzer config, and its shim exits 127 when mbx is
129missing.
130
131what the shim changes for mbx's process, all measured on native/render (a
132second checkout: 32 s with 55 links bypassed, 2.1 s with 201 hits after):
133
134- `SDKROOT` is unset. mbx names the macOS linker with
135  `xcrun --sdk "$SDKROOT" --show-sdk-version`, and xcrun rejects a path such as
136  a nix shell's store SDK, which leaves the linker unnamed and every native
137  link uncached: build scripts, proc macros, binaries, tests, and so every
138  crate depending on a proc macro or a cc-built `-sys` crate. `DEVELOPER_DIR`
139  stays, so the same SDK is linked.
140- inside a nix shell (`ALBEDO_NIX_ENV`, which the dev-shell capture sets), `CC`
141  and `CXX` are unset when they resolve to the same file as `cc` and `c++` on
142  PATH (the nix cc-wrapper's `cc` is a symlink to its `clang`), so a build
143  script's C compiles through mbx and stays portable: an explicit `CC` makes
144  mbx leave that C alone (`cc-compiler-override`), and a dev build's objects
145  then record the checkout's paths (synthetic workspace, second checkout: 31
146  hits and 3 misses with `CXX` set, 34 and 0 without). a shell whose `CC` is
147  another compiler (gcc, a cross compiler) keeps it. and `HOST_CFLAGS`/`HOST_CXXFLAGS` gain
148  `-DALBEDO_NIX_ENV_<digest>`. the digest is of the shell's compiler-wrapper
149  variables (`NIX_CFLAGS*`, `NIX_LDFLAGS*`, `NIX_CC*`,
150  `NIX_HARDENING_ENABLE`), which change C output but are in no mbx key:
151  without it a changed shell was served another checkout's stale object.
152
153never set for cargo here: `CARGO_INCREMENTAL`, `RUSTC_WRAPPER`, `MBX_CACHE_DIR`
154(one cache is the point), a `--target` equal to the host, or `-C link-arg`.
155what stays per checkout: crates whose build-script output or source names the
156checkout or target path and their dependents (aws-lc-sys's memcmp probe leaves
157a path-bearing `.dSYM` in `OUT_DIR` under `-g`; tikv-jemalloc-sys runs
158autotools there unless `JEMALLOC_OVERRIDE` names a built library), and a
159workspace crate being edited. a new toolchain or dev shell is a new key, as it
160should be.
161
162mbx replaces a checkout's `target/` with a symlink into its cache. an ignore
163rule with a trailing slash (`target/`) matches directories only, so jj would
164track that symlink: albedo's own rule is `/native/render/target`.