Skip to content

Cut interactive shell startup from 680 ms to 180 ms - #93

Merged
jfmercer merged 2 commits into
masterfrom
speed-up-localrc-secrets
Jul 27, 2026
Merged

Cut interactive shell startup from 680 ms to 180 ms#93
jfmercer merged 2 commits into
masterfrom
speed-up-localrc-secrets

Conversation

@jfmercer

Copy link
Copy Markdown
Owner

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. zprof does not surface any of this, because the cost is forks from a sourced file rather than time spent in shell functions:

Before After
Full interactive startup (median, n=20) 680 ms 180 ms
~/.localrc 530 ms 74 ms
— Keychain reads 480 ms (10 × ~48 ms) 52 ms (1 read)
— eager ssh-add × 3 47 ms 0
Everything else (prompt, plugins, compinit, topic files) ~150 ms ~105 ms

1. One Keychain read instead of ten

~/.localrc had ten export 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/secret gains an env bundle — one Keychain item holding a base64-encoded block of export VAR='value' lines:

eval "$(secret env)"          # in ~/.localrc, replacing 10 lookups

New subcommands: env (print, for eval), env-edit ($EDITOR round-trip), env-import (build from existing items, VAR=item on 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-0600 mktemp during env-edit, removed by trap on EXIT/INT/TERM. base64 is required because security add-generic-password takes 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-keychain calls re-added keys the agent already held, every shell. Now handled by ~/.ssh/config (AddKeysToAgent + UseKeychain under Host *, plus per-host IdentityFile).

This nearly broke SSH. Host *.github.com does not match github.com — the *. requires a subdomain. That block had been inert, and GitHub auth worked only because the eager ssh-add had already pushed the key into the agent. gitlab.com had no IdentityFile at all, hidden the same way. Deleting the ssh-add lines 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 to id_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 -i shell, and compared after:

IDENTICAL — all 16 variables, same names, same values

(The first attempt at that baseline was contaminated by inherited environment; env -i was needed to make it trustworthy.)

SSH verified with the agent bypassedSSH_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 → authenticated
  • git@ssh.dev.azure.com → "Shell access is not supported" (its success response)
  • git ls-remote origin → OK

gitlab.com still fails publickey, 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 eager ssh-add was 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/secret and CLAUDE.md are tracked. ~/.localrc and ~/.ssh/config are 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.bak and ~/.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

for i in $(seq 1 20); do /usr/bin/time zsh -i -c exit; done 2>&1 | awk '/real/{print $1}' | sort -n

jfmercer added 2 commits July 27, 2026 18:56
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.
@jfmercer
jfmercer merged commit 80adfb5 into master Jul 27, 2026
7 checks passed
@jfmercer
jfmercer deleted the speed-up-localrc-secrets branch July 27, 2026 23:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant