The Symptom
Two annoyances showed up on the same machine — WSL Ubuntu running off an external USB hard drive that occasionally has a loose connection.
Annoyance #1: Every time I started a fresh WSL session, opencode wasn't found:
opencode
# zsh: command not found: opencode
The fix that "worked" was running this every single time:
source ~/.zshrc
After that, opencode ran fine. This never used to happen.
Annoyance #2: After I did a wsl --shutdown in Windows (or the drive lost connection) and restarted, the first WSL session of the day took noticeably long to come up.
Two separate problems — one shared root cause hiding underneath.
The Discovery
First I confirmed the basics. My login shell was zsh, and opencode did exist:
getent passwd "$USER" | cut -d: -f7
# /usr/bin/zsh
ls -la ~/.opencode/bin/
# -rwxr-xr-x ... opencode
So the binary was there and the shell was right. Then I timed how long an interactive zsh actually took to start — even with a warm disk:
/usr/bin/time -v zsh -i -c 'true' 2>&1 | grep "wall clock"
# Elapsed (wall clock) time: 0:02.70
2.7 seconds just to open a shell. On a cold external disk, that balloons. So I profiled it with zsh's built-in profiler:
zsh -i -c 'zmodload zsh/zprof; source ~/.zshrc >/dev/null 2>&1; zprof | head'
num calls time self name
1) 2 956.59ms 478.29ms nvm
2) 2 618.70ms 309.35ms compdump
3) 24 439.80ms 18.33ms _omz_source
...
6) 1 1115.05ms ... nvm_auto
nvm alone was eating ~1.1 seconds of disk reads on every startup. compinit/compdump added another ~1 second. All of it reading hundreds of small files off a slow, sometimes-flaky disk.
Then the real "aha" — I checked the kernel log:
dmesg | grep -iE "ext4|recovery|orphan"
EXT4-fs (sdd): 1 orphan inode deleted
EXT4-fs (sdd): recovery complete
EXT4-fs (sdd): mounted filesystem ... r/w with ordered data mode.
The root filesystem (/dev/sdd, the external HDD) had to replay its journal and recover an orphaned inode before it could even mount read-write — and that happened ~19 seconds into boot.
The Root Cause
Why opencode kept vanishing from PATH
My .zshrc exported opencode's directory near the very bottom of the file:
# ...line 202 of ~/.zshrc...
export PATH=/home/ultimatum/.opencode/bin:$PATH
That line sits after all the slow, disk-heavy work (nvm, compinit, asdf, plugin sourcing). On a flaky external disk, .zshrc runs slowly — and if the connection hiccups mid-load, zsh never reaches the bottom of the file. The shell comes up "working" (the prompt appears) but the last PATH exports never ran.
Re-running source ~/.zshrc once the disk had settled simply finished the job. The file was never broken — it just wasn't finishing.
Why the first boot was slow
This one was textbook ext4. When a disk is pulled or disconnected mid-write, the filesystem is left in an inconsistent state. On the next mount, ext4 replays its journal to recover. Because my drive sometimes loses connection (and because I don't always shut WSL down cleanly), nearly every cold start triggered that recovery pass — hence the slow first boot. It's inherent to unclean shutdowns, not a misconfiguration.
The junk files nobody asked for
My .zshrc writes a small per-session file to remember the last directory:
export WSL_LAST_DIR_FILE="$HOME/.wsl_last_dir_$$" # $$ = PID
On a clean exit these get cleaned up. But on an abrupt crash, they're orphaned — and the old cleanup only deleted files older than 2 days. So they accumulated. I found 71 stale tracker files sitting in $HOME:
ls ~/.wsl_last_dir_* | wc -l # 32 (dead sessions)
ls ~/.last_dir* | wc -l # 39 (an obsolete July-2025 mechanism)
The Fix
Step 1: Make critical tools survive an interrupted load
Move opencode (and bun) onto PATH at the top of ~/.zshrc, before anything slow runs — so even a half-loaded .zshrc still has them:
# Set EARLY so a slow/flaky external-disk load can't strip these from PATH.
export PATH=$HOME/.opencode/bin:$HOME/.bun/bin:$HOME/bin:$HOME/.local/bin:/usr/local/bin:$PATH
Step 2: Lazy-load nvm (the biggest win)
node and npm already resolve through symlinks in ~/.local/bin, so there's no reason to source the heavy nvm.sh on every startup. Load it only when nvm is actually called:
export NVM_DIR="$HOME/.nvm"
nvm() {
unset -f nvm
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
[ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion"
nvm "$@"
}
That reclaims ~1.1s of disk I/O from every single shell.
Step 3: Cache compinit
The completion system runs a full security audit on every start. Do it at most once per day:
autoload -Uz compinit
if [[ -n ~/.zcompdump(#qNmh-24) ]]; then
compinit -C # dump is fresh (<24h): skip the slow security audit
else
compinit # regenerate + audit at most once per day
fi
Step 4: Clean up crash-orphaned files automatically
Teach the cleanup function to remove session files whose PID is no longer running — not just old ones:
cleanup_wsl_dirs() {
local f pid
for f in "$HOME"/.wsl_last_dir_<->; do
[[ -e "$f" ]] || continue
pid=${f##*_}
kill -0 "$pid" 2>/dev/null || rm -f "$f" 2>/dev/null
done
find "$HOME" -maxdepth 1 -name ".wsl_last_dir_*" -type f -mtime +2 -delete 2>/dev/null
}
Then I swept out the existing mess (71 files → 5: only live sessions plus the shared file).
Step 5: Stop creating the recovery in the first place
The slow boot is a consequence of unclean unmounts. The real fix lives in the habit, not the config:
- Always
wsl --shutdownin Windows before unplugging the drive. This flushes the ext4 journal so the next mount skips recovery entirely. - Treat the loose connection as the actual bug — a flaky cable mid-write risks far more than a slow boot. A stable port/cable (or moving the distro to internal storage) is the durable fix.
The Result
Shell startup dropped from ~2.7s to ~0.7s on a warm disk — and proportionally far more on a cold one, since the worst offenders were exactly the disk-heavy steps:
/usr/bin/time -v zsh -i -c 'true' 2>&1 | grep "wall clock"
# Elapsed (wall clock) time: 0:00.72
opencode now resolves from the first prompt, node/npm/nvm all still work, and $HOME stays tidy on its own.
The Lesson
Two takeaways, both about fragile environments:
- Order matters when your config can be interrupted. Anything you must have — PATH entries for your daily tools — belongs at the top of your shell rc, before slow or failure-prone work. Don't bury essentials behind a second of disk I/O on a disk that might blink.
- An unclean unmount is never free. Journaling filesystems pay for it with a recovery pass on the next boot, and any process that writes per-session state will leak files when it's killed instead of exited. Build the cleanup into startup, and cultivate the habit of shutting down cleanly.
Commands Reference
# Confirm your login shell
getent passwd "$USER" | cut -d: -f7
# Time an interactive shell startup
/usr/bin/time -v zsh -i -c 'true' 2>&1 | grep "wall clock"
# Profile what's slow in zsh startup
zsh -i -c 'zmodload zsh/zprof; source ~/.zshrc >/dev/null 2>&1; zprof | head'
# Check for ext4 journal recovery (the slow-first-boot tell)
dmesg | grep -iE "ext4|recovery|orphan"
# Find crash-orphaned per-session files
ls ~/.wsl_last_dir_* | wc -l
# Always shut WSL down cleanly before unplugging an external disk (run in Windows)
wsl --shutdown