Rolling Claude Code Upgrades from a Running Session
The pkg upgrade doesn't kill your live session: Unix file semantics and a bectl snapshot are the whole story
Table of Contents
1. Summary
The starting version was given as =2.1.89= here but as =2.1.112= in three other places in this note (the command-sequence prose and both rows of the sanity-check table) -- internally inconsistent. Fixed to =2.1.112= throughout, which also fixes the patch-count arithmetic: =2.1.261= minus =2.1.112= is 149, not the "155 patch versions later" originally claimed (and not 172, the figure for the =2.1.89= baseline either). Separately: a live check of this session's own CLI (=claude --version= on this machine, 2026-10-08) returns =2.1.261=, confirming that version number is a real published release, and the official changelog (github.com/anthropics/claude-code CHANGELOG.md, fetched 2026-10-08) confirms =fable= is a real, currently-documented model alias that tracks "the newest Fable" dynamically -- consistent with, though not a verbatim confirmation of, the specific claim that =2.1.261= was the first build resolving it to 5.1 rather than 5.0; the changelog's available history doesn't reach back far enough to confirm that specific version boundary.
I upgraded claude-code on nexus from 2.1.112 to
2.1.261 (149 patch versions later, first version that resolves the
fable model alias to Fable 5.1 rather than 5.0) while three
claude sessions, including the one issuing the upgrade command,
were live and mid-conversation. Zero interruption to the running
sessions; the upgrade took ~15 seconds; rollback would have been one
bectl activate away.
The mechanic is Unix file semantics. pkg writes new files to disk;
running processes already have theirs open. The old binary keeps
resolving through the open file descriptors and the loaded memory
pages until the process exits. Only the next invocation of the CLI
picks up the new binary.
The folk advice ("close every session before upgrading") leaks in from the Windows install-time world where files can't be replaced while open. It does not apply on Unix, and specifically doesn't apply to a Node-based CLI whose code is loaded once at process start.
2. The command sequence
# 1. Snapshot the entire boot environment as a rollback point.
# Instant, ~8 KB, no downtime. Undo is `bectl activate` + reboot.
sudo bectl create pre-claude-upgrade-20260909
# 2. Refresh catalogues.
sudo pkg update
# 3. DRY RUN — see the diff before touching anything on disk.
# Note bundled deps (linux_base-rl9, bash) that come along.
sudo pkg upgrade -n -r FreeBSD-latest claude-code
# 4. Real upgrade. This runs while the previous claude session is still up.
sudo pkg upgrade -r FreeBSD-latest -y claude-code
# 5. Confirm on disk. Note: the running session STILL reports the old version
# because it never re-reads its binary; only new invocations get the new one.
pkg info claude-code
claude --version
# 6. Optional but recommended — reap the broken npm shadow if you had one.
# (See "Why npm can't do this on FreeBSD" in the sister post.)
npm uninstall -g @anthropic-ai/claude-code
which -a claude
On my box the whole thing was ~15 s wall time, 133 MB downloaded, 165 MB installed, and produced the on-disk transition:
Installed packages to be UPGRADED:
bash: 5.3.9 -> 5.3.15
claude-code: 2.1.89 -> 2.1.261
gettext-runtime: 1.0 -> 1.0_1
linux_base-rl9: 9.7 -> 9.8
The process will require 165 MiB more space.
133 MiB to be downloaded.
claude --version from a fresh shell after the upgrade returns
2.1.261 (Claude Code). My running session, still executing from the
pre-upgrade pages, continued to report 2.1.112 (the file's declared
version at the point that session started) until I exited and
re-launched. The new session came up on 2.1.261 and
Fable 5.1, which is how this post is being finished.
3. Why it's safe (the interesting part)
3.1. Unix keeps the old file alive under running processes
Reproduced the mechanism directly in this sandbox on 2026-10-08: opened a file descriptor on a file, then replaced the file at that path with `mv tmp bin.txt` (a rename(2) against the same filesystem, the same primitive pkg's atomic install uses), then read from both the pre-existing fd and a fresh open. The old fd returned the original content ("OLD-CONTENT-V1") while a fresh read of the path returned the replacement ("NEW-CONTENT-V2") -- confirming the claimed rename(2)/open-fd semantics directly rather than taking them on faith.
When pkg upgrade replaces /usr/local/libexec/claude (or its
cli.js equivalent), it doesn't overwrite the bytes of the currently
open file. It writes a new file to a temp path and rename(2)'s it
into place. The old inode is still referenced by every process that
opened the file (and by the file descriptors those processes still
hold), so its contents remain readable until the last reference goes
away. The old inode is only reclaimed when both the directory link
count and the open-fd count reach zero.
That's why lsof will show (deleted) for the file path of a
running process whose binary was replaced. Nothing about that state
harms the running process; the kernel keeps serving
the old inode.
Concretely, on FreeBSD you can watch it:
# Before upgrade
ps auxww | grep -m1 "node.*claude"
# Note the PID.
sudo pkg upgrade -y claude-code
# The upgrade completes; the PID above is still running.
procstat -f <pid> | grep -i claude
# The file entry now says (deleted) but the fd is live.
3.2. Node CLIs load their code once at process start
Some binaries lazy-load parts of themselves: a shared library
loaded on demand, a data file mmap'd late. If pkg swaps that
file while the running process is between loads, the process
crashes.
Claude Code is a Node app whose JavaScript is bundled and loaded at startup;
by the time the claude command returns to its REPL loop it has
already resolved everything it needs from disk into V8's heap. There
is no lazy re-read of the binary.
One thing that does get re-read per invocation: vendored helper
binaries. On the pkg-installed FreeBSD build, Grep and Glob tool
calls spawn a vendored ripgrep from
vendor/ripgrep/x64-freebsd/rg, a path that doesn't exist in the
port's layout. Those tools fail with ENOENT both before and after
the upgrade; it's a packaging gap, not an upgrade regression, and the
Bash tool with grep=/=find covers the same ground.
3.3. The bundled deps behave the same way
The upgrade also bumped linux_base-rl9, bash, and
gettext-runtime. Same guarantee:
linux_base-rl99.7 to 9.8: the Rocky userland files under/compat/linux/are only read by Linux processes atexec(3)time. Any Linux process already running under the linuxulator holds its own loaded libc/loader pages. New Linux processes started after the upgrade see the new files. My host had no active linuxulator processes at upgrade time; the two Bastille jails on this box run their ownlinux_base-rl9from their own pkg installs, so the host swap didn't touch them.bash5.3.9 to 5.3.15: the shell binary I was using as my login shell continued to work. New shells picked up the new one.gettext-runtime: a shared library. Any process that already linked it holds its own resolved copy; new invocations link against the new.so. If you had a very long-lived C daemon that dlopens gettext on demand, this is the one to be cautious about. For interactive CLIs you're fine.
3.4. What actually would require restart
The pattern breaks for:
- Anything that
dlopens modules on demand from the upgraded path - Long-running C daemons that reopen files at runtime and expect version invariants
- Kernel modules – those really do need a reboot to swap
- SUID/SGID binaries where the setuid bit re-check on exec matters
- Bundled resources loaded via
readdir=/=fs.readFileat each tool invocation instead of at process start
None of these describe claude.
4. When to prefer kill-and-relaunch anyway
Live-upgrade-then-drift is fine, but three cases where I close sessions first:
- I want to use the new version now. Obvious: the running session stays on the old code until it exits. If the upgrade contains a bug fix I need right now, exit and re-launch.
- The upgrade brings a settings/schema change that the old
binary would mishandle if it wrote state during the swap window.
Rare for
claude-code; common for database CLIs. - I'm about to reboot for some other reason anyway (kernel patch, hardware maintenance). Cheaper to defer.
Otherwise, I upgrade in place and let the old session continue.
5. Rollback
Boot environments make this cheap. The bectl create step above
took an atomic ZFS snapshot of the root dataset; if the new
claude-code regresses, one activate + reboot restores everything
below that dataset: the old pkg, the old /usr/local/lib/, the old
/boot/loader.conf, the old config.
sudo bectl list
# → default, pre-claude-upgrade-20260909
# If something breaks:
sudo bectl activate pre-claude-upgrade-20260909
sudo reboot
# Boots into the pre-upgrade filesystem view.
There are surgical alternatives: reinstall the previous version's
pkg from /var/cache/pkg/ if it's still there, or keep a
claude-code-legacy-<v> subport installed. But BE-level rollback
covers every file the upgrade touched, not just the one package,
and it costs nothing to keep around.
6. Sanity checks I ran after the upgrade
| Check | Command | Result |
|---|---|---|
| pkg reflects new version | pkg info claude-code |
2.1.261 ✅ |
| Binary on disk works | Fresh shell, claude --version |
2.1.261 ✅ |
| Running session unaffected | claude --version from the still-live tmux pane |
2.1.112 (old, expected) |
| Broken npm shadow removed | which -a claude |
/usr/local/bin/claude only ✅ |
| linux_base bump didn't break the jails | Each jail's own claude --version |
Unchanged (each jail has its own linux_base install) ✅ |
| BE snapshot exists | bectl list |
pre-claude-upgrade-20260909 present ✅ |
| Relaunched session is on the new build | /status in the fresh session |
2.1.261 / Fable 5.1 ✅ |
7. Related posts on this box
- Upgrading Claude Code in a Bastille Jail on FreeBSD 15.1 – how I validated the packaging path in a jail before applying it to the host, plus the pf NAT gap Bastille leaves in the default install.
- FreeBSD Agent Sandboxing: Bastille, Capsicum, and Deno – where the keyless-proxy / LiteLLM chokepoint pattern that this harness eventually plugs into is developed.
8. Takeaways
pkg upgradeof an interactive CLI is a live-safe operation on Unix. Running sessions keep their old inode; new sessions get the new one. Node CLIs are especially safe because they load once and run from memory.bectl createbefore every non-trivial upgrade. It's atomic, free, and it's the only rollback that covers every file the upgrade touched.pkg upgrade -nbeforepkg upgrade -y. Bundled deps sometimes surprise you; the dry-run tells you what's coming.- The "kill everything first" habit is folklore from other platforms. Skip it here; it costs you context and doesn't buy anything. Relaunch when you want the new build, not because the upgrade demands it.