Cut interactive shell startup from 680 ms to 180 ms - #93
Merged
Conversation
Interactive shell startup is ~680 ms, and ~530 ms of that is ~/.localrc.
Profiling it line by line shows why: ten `export VAR=$(secret get item)`
lines, each a full Keychain round trip at ~48 ms, for ~480 ms on every
single shell. zprof does not surface this, because the cost is forks from
a sourced file rather than time spent in shell functions.
Lazy-loading is not an option here: these variables have to exist in the
environment for MCP servers and other child processes that never go
through a shell wrapper. So the fix is to keep eager loading but stop
paying per variable.
`secret` gains an env bundle: one Keychain item holding a base64-encoded
block of `export VAR='value'` lines. ~/.localrc then does a single read:
eval "$(secret env)"
Measured over 10 variables: ~590 ms -> ~90 ms.
- bin/secret: add `env`, `env-edit` and `env-import`. Values are quoted
with a POSIX-safe single-quote escape rather than bash's %q, whose
$'...' output is not portable to every shell that might source the
bundle. Verified round-tripping values containing single quotes, double
quotes, dollar signs and backslashes. Plaintext touches disk only as a
mode-0600 mktemp during env-edit, removed via trap on EXIT/INT/TERM.
- CLAUDE.md: document the bundle, the measurement behind it, and two
rules for ~/.localrc -- no literal secrets, and watch per-line forks
(the three ssh-add calls there cost another ~60 ms).
base64 is used because `security add-generic-password` takes the value as
an argument, not on stdin, so the stored blob has to be single-line.
No secrets were migrated by this commit; ~/.localrc is untracked and
unchanged. Migration is a separate, deliberate step.
Follow-on to the env bundle. The remaining per-shell fork cost in ~/.localrc was three eager `ssh-add --apple-use-keychain` calls at ~47 ms total, re-adding keys the agent already held. Those are now handled by ~/.ssh/config (AddKeysToAgent + UseKeychain under Host *, plus a per-host IdentityFile), so keys load on first use. That file is machine-local and untracked, so only the documentation lands here. Worth writing down because the migration nearly broke SSH: `Host *.github.com` does not match `github.com` -- the `*.` requires a subdomain. That block had been inert, and GitHub auth only worked because the eager ssh-add had already pushed the key into the agent. gitlab.com had no IdentityFile at all for the same hidden reason. Deleting the ssh-add lines without first fixing the patterns would have removed push access. - CLAUDE.md: record the gotcha and the two commands that catch it, notably `SSH_AUTH_SOCK=/dev/null ssh -T ...`, which tests what a new shell gets rather than what a long-lived agent happens to hold.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Picks up the startup-performance item deferred during the audit. Measured median: 680 ms → 180 ms, a 74% reduction.
Where the time was
Profiled line by line on a real machine.
zprofdoes not surface any of this, because the cost is forks from a sourced file rather than time spent in shell functions:~/.localrcssh-add× 31. One Keychain read instead of ten
~/.localrchad tenexport VAR=$(secret get item)lines, each a full Keychain round trip at ~48 ms.Lazy loading was not an option: these variables must exist in the environment for MCP servers and other child processes that never pass through a shell wrapper. So the fix keeps eager loading but stops paying per variable.
bin/secretgains an env bundle — one Keychain item holding a base64-encoded block ofexport VAR='value'lines:New subcommands:
env(print, for eval),env-edit($EDITOR round-trip),env-import(build from existing items,VAR=itemon stdin).Values are quoted with a POSIX-safe single-quote escape rather than bash's
%q, whose$'...'output is not portable to every shell that might source the bundle. Verified round-tripping values containing single quotes, double quotes, dollar signs and backslashes. Plaintext touches disk only as a mode-0600mktempduringenv-edit, removed by trap on EXIT/INT/TERM. base64 is required becausesecurity add-generic-passwordtakes the value as an argument, not on stdin.2. SSH keys load lazily — and a latent bug this exposed
Three eager
ssh-add --apple-use-keychaincalls re-added keys the agent already held, every shell. Now handled by~/.ssh/config(AddKeysToAgent+UseKeychainunderHost *, plus per-hostIdentityFile).This nearly broke SSH.
Host *.github.comdoes not matchgithub.com— the*.requires a subdomain. That block had been inert, and GitHub auth worked only because the eagerssh-addhad already pushed the key into the agent.gitlab.comhad noIdentityFileat all, hidden the same way. Deleting thessh-addlines without first fixing the patterns would have removed push access.Caught it before making the change by checking
ssh -G github.com, which showed it falling back toid_rsa/id_ed25519— neither of which exists.Verification
Migration is provably lossless. Captured every exported variable's name plus a SHA-256 of its value before the change, in a clean
env -ishell, and compared after:(The first attempt at that baseline was contaminated by inherited environment;
env -iwas needed to make it trustworthy.)SSH verified with the agent bypassed —
SSH_AUTH_SOCK=/dev/null, which tests what a brand-new shell actually gets rather than what a long-running agent happens to hold:git@github.com→ authenticatedgit@ssh.dev.azure.com→ "Shell access is not supported" (its success response)git ls-remote origin→ OKgitlab.comstill failspublickey, but it fails identically with and without the agent while the key is loaded — pre-existing, not caused by this change. That key was never registered with GitLab; the eagerssh-addwas never making it work.10/10 tokens confirmed present in a real interactive shell afterwards.
What is in this PR vs. what is machine-local
Only
bin/secretandCLAUDE.mdare tracked.~/.localrcand~/.ssh/configare machine-local and untracked, so the actual migration is not in the diff — the tooling and the documented procedure are. Backups left at~/.localrc.pre-bundle.bakand~/.ssh/config.pre-identityfile.bak; both verified to contain no literal secrets.CLAUDE.md documents the bundle, the measurements behind it, and the SSH host-pattern trap with the two commands that catch it.
Reproducing the measurement