Text file busy (ETXTBSY): fix writes to a running binary
cp: cannot create regular file './svc': Text file busy means the destination inode has an executable mapping right now — a process is running that binary, so the kernel refuses every write open on it. It is not a permission problem, not a full disk, and not a locked file in the Windows sense: it is ETXTBSY, errno 26, and it fires the moment a deploy tool tries to write in place over a file the kernel has mapped as text. The same errno surfaces with dd: failed to open './runner': Text file busy, truncate: cannot open './svc' for writing: Text file busy, tee: ./svc: Text file busy, > ./svc: Text file busy from a shell redirect, and OSError: [Errno 26] Text file busy from Python.
Every one of those strings was reproduced for this article on Linux 6.17 (bash 5.2.37, Python 3.13.5) with a non-root user and a five-line lab. The good news: once you know which side of the line a tool sits on — in-place write versus unlink-and-replace — the error becomes entirely predictable, and the fix is a rename, not a retry.
TL;DR — ETXTBSY in one screen
- Cause: the target file is the executable image of a running process (an active text mapping exists on that inode).
- Kernel rule:
open(path, O_WRONLY | O_RDWR)on such an inode fails withETXTBSY;open(path, O_RDONLY)succeeds. Every write path is refused, includingO_APPEND. - Tools that fail:
cp(default),dd of=,truncate,tee, shell>and>>, Pythonopen(..., "wb"),shutil.copyfile(). - Tools that succeed:
mv -f,cp -f/cp --remove-destination,install(1),tar -xf,sed -i,perl -i— because they replace the directory entry or unlink first. - Fastest fix: stage the new artifact next to the target,
sync, thenmv -f(rename). Zero downtime, no restart of the old process. - Fastest diagnosis: walk
/proc/<pid>/mapsfor the path — nolsof, no root.
Measured matrix from the lab (Linux 6.17, non-root, /usr/bin/sleep running as ./svc):
| Attempt | Result | Stderr / errno |
|---|---|---|
cp /usr/bin/sleep ./svc | fails | Text file busy |
cp -f /usr/bin/sleep ./svc | succeeds | exit 0 |
cp --remove-destination ... | succeeds | exit 0 |
echo x > ./svc | fails | Text file busy |
echo x >> ./svc | fails | Text file busy |
tee ./svc | fails | Text file busy |
truncate -s 4096 ./svc | fails | Text file busy |
dd ... of=./svc | fails | Text file busy |
install -m 0755 art ./svc | succeeds | exit 0 |
tar -xf payload.tar (over svc) | succeeds | exit 0 |
sed -i / perl -i | succeeds | exit 0 |
mv -f new ./svc | succeeds | exit 0 |
What the kernel actually refuses
Linux denies the write at the inode, before the filesystem or the permission bits get a vote. When a process executes a binary, the kernel maps its text segments; while that mapping is live, the inode carries a flag that makes open() for writing fail with ETXTBSY. The check is on the inode, not on the path you typed, which explains the two results people find most confusing: a hard link to the running binary fails the same way, and a rename over it does not fail at all.
You can read the errno values straight out of the platform — useful, because ETXTBSY (26) is easy to confuse with EBUSY (16) and with the -EBUSY you see when kernel block devices or mount points are busy:
import errno
print(errno.ETXTBSY, errno.errorcode[errno.ETXTBSY]) # 26 ETXTBSY
print(errno.EBUSY, errno.errorcode[errno.EBUSY]) # 16 EBUSY
Measured against a live process, the whole rule collapses into one comparison. Reading is always allowed; the first byte of any write attempt is refused:
import errno, shutil, sys
dst = sys.argv[1] if len(sys.argv) > 1 else "./svc"
def attempt(label, fn):
try:
fn()
print(f"{label}: ok")
except OSError as exc:
print(f"{label}: {errno.errorcode.get(exc.errno, exc.errno)} "
f"({exc.errno}) {exc.strerror}")
attempt("PY_OPEN_WRITE", lambda: open(dst, "wb").write(b"x"))
attempt("PY_COPYFILE", lambda: shutil.copyfile("/usr/bin/sleep", dst))
attempt("PY_READ_ONLY", lambda: open(dst, "rb").read(4))
Run it against a binary that is executing right now (the full sequence, with the shell output included, is in the lab notes below):
$ cp /usr/bin/sleep ./svc && ./svc 30 & # ./svc is now mapped as executable text
$ python3 probe_etxtbsy.py ./svc
PY_OPEN_WRITE: ETXTBSY (26) Text file busy
PY_COPYFILE: ETXTBSY (26) Text file busy
PY_READ_ONLY: ok
Three details in that output matter for production work. First, shutil.copyfile fails exactly like a shell redirect — most Python "atomic write" helpers that do open(dst, "wb") are not atomic at all, they are in-place writers that happen to work until the file is executable. Second, read-only opens are fine, so a monitoring agent can still hash the artifact while the service runs. Third, the errno is stable across tools: the message text differs (cp:, dd:, tee:, OSError), the number does not.
One consequence is easy to miss: this is not file locking. There is no daemon to ask and nothing to wait for — the condition clears only when every process executing that inode exits or re-execs.
Cause 1: the tool writes in place instead of replacing
The default cp is the poster child, because cp src dst opens dst with O_WRONLY|O_TRUNC and streams the bytes over the existing inode. If dst is the currently running binary, the very first step fails and the copy never starts:
$ cp /usr/bin/sleep ./svc
cp: cannot create regular file './svc': Text file busy
$ cp -f /usr/bin/sleep ./svc # unlink-then-create, exit 0
$ cp --remove-destination /usr/bin/sleep ./svc # explicit, exit 0
That one flag is the difference between a failed deploy and a successful one, and it is the reason two engineers on the same team can disagree about whether "cp works". It also explains the shell-redirect variant: cat new_binary > ./svc in a Makefile or a CI step is an in-place write and dies the same way, with the shell itself reporting the failure rather than the tool you thought you ran.
Append mode is not a loophole. O_APPEND is still an open-for-write, so echo x >> ./svc gives the same result — measured, not assumed:
$ ./svc 30 &
$ echo x >> ./svc
bash: ./svc: Text file busy
$ truncate -s 4096 ./svc
truncate: cannot open './svc' for writing: Text file busy
$ echo x | tee ./svc >/dev/null
tee: ./svc: Text file busy
Log-rotation scripts built on tee, size-capping scripts built on truncate, and "touch up the binary in place" scripts built on dd conv=notrunc all belong in this family. dd is the most deceptive of the three because it accepts the path and only fails at open time, so a loop that batches several images together will report the failure on a line that looks unrelated to the file you actually broke.
Cause 2: the deploy path chosen by your installer
This is where the error stops being a curiosity and starts costing release windows. Whether Text file busy appears depends on the syscall sequence the installer picks, and the same family of tools includes both flavours:
| Tool / mode | Syscall shape | Over a running binary |
|---|---|---|
install -m 0755 | creates, unlinks destination first | succeeds |
tar -xf | unlinks existing members by default | succeeds |
sed -i / perl -i | writes a temp file, renames it over the target | succeeds |
cp (default) | open(O_WRONLY|O_TRUNC) in place | fails |
cp -a with an existing target | depends on flags; test it, do not assume | verify |
dd, tee, truncate, redirects | open for write, in place | fails |
Two operational conclusions follow. Push your release tooling toward "write next to the target, then rename" — it is both faster and immune to this errno. And when a vendor's installer fails on a host but not on a laptop, check whether the difference is that the service happened to be running there.
The rename family is not a hack; it is how package managers and container runtimes install files in the first place. A rename changes only the directory entry, so the running process keeps its old inode and its old code, while new execs pick up the new file. If you need the swap to be safe for concurrent readers, do it in the same filesystem — a cross-filesystem mv degrades into copy-then-unlink, which is why the atomic property depends on staging inside the destination directory. Both variants exit 0 in the lab, but only the same-filesystem one is atomic:
$ stat -f -c %T /tmp
overlayfs
$ stat -f -c %T .
ext2/ext3
$ mv /tmp/artifact ./svc # cross-device: copy + unlink, exit 0, not atomic
crossfs_mv_exit=0
Cause 3: another path to the same inode
Because the check lives on the inode, any name that resolves to the running image is equally frozen. A hard link is the cleanest demonstration: two names, one inode, same refusal.
$ cp /usr/bin/sleep ./svc && ./svc 30 &
$ ln -f ./svc ./svc-link
$ ls -l ./svc ./svc-link | awk '{print $2, $5, $9}'
2 68048 ./svc
2 68048 ./svc-link
$ cp /usr/bin/sleep ./svc-link
cp: cannot create regular file './svc-link': Text file busy
Symlinks behave differently in an important way: writing "through" a symlink with an in-place tool fails exactly like writing the target directly, but replacing the symlink itself is a rename and succeeds. That asymmetry is why "deploy by re-pointing the current symlink" is a classic pattern — and why a script that resolves readlink -f first and writes the resolved path throws away that safety.
Mounted filesystems have their own semantics, and this is where I refuse to guess: if your artifact lives on a network or FUSE-backed mount (NFS, SMB/CIFS, 9p from a hypervisor, virtiofs from Docker Desktop, vboxsf from VirtualBox), the errno you get on an in-place write may differ from the local case, and rename-over-open-file on NFS may produce very different errors when the server is involved. Test the specific mount type you deploy to; do not port a local-only fix into a network filesystem without re-running the failure case.
Cause 4: scripts are not protected — the silent corruption case
Here is the result that surprised me most in the lab, and it is the most dangerous one in practice. ETXTBSY protects executables with a text mapping. A shell script executed through its shebang is not mapped as text: the kernel hands the interpreter an open file descriptor and bash reads it incrementally. So writes to a running script succeed.
$ printf '#!/usr/bin/env bash\nsleep 10\n' > run.sh && chmod 0755 run.sh
$ ./run.sh &
$ ls -l /proc/$!/fd/255 | sed 's#.*-> ##'
/opt/data/c2cz/lab/etxt5/run.sh
$ printf '#!/usr/bin/env bash\nsleep 11\n' > run.sh
$ echo "write exit=$?"
write exit=0
$ grep -q "run.sh" /proc/$!/maps && echo "maps hits: 1" || echo "maps hits: 0"
maps hits: 0
Read that sequence carefully: the interpreter holds run.sh open on fd 255, the file has no mapping entry, and the overwrite returns success. Nothing warned us. The failure mode is not an error message — it is a long-running script that suddenly executes a half-written line, or a step that disappears because the new file is shorter than the old one and bash's read position is already past the end. If you have ever had a "perfectly good" deploy script fail with a syntax error only while it was running, this is the mechanism.
Python gives the same green light:
# r+b on a script that is currently executing under bash
with open("./run2.sh", "r+b") as fh:
fh.write(b"\n# touched\n")
print("SCRIPT_WRITE(r+b): ok")
The rule that falls out of this for anyone shipping operational scripts: never edit a running script in place, either. The same stage-then-rename pattern that fixes binaries is also the correct thing for scripts, and for the same reason — one directory-entry swap instead of a partial write that some interpreter may read halfway through. Keep binaries on rename-based installs and scripts on rename-based installs, and this whole class disappears.
Cause 5: containers change the namespace, not the errno
The refusal itself is identical inside a container: a process executing the file, an in-place write, errno 26. What changes is which processes count and where you have to look for them. A binary that is idle on the host can have three live exec mappings inside a container that shares the same bind-mounted path, and /proc inside the container only shows its own PID namespace — so a holder scan run inside the wrong namespace reports "no holder" while the write still fails.
Two habits make container deploys predictable:
- Stage and rename inside the container's own writable layer or a shared volume, never rely on
docker cpsemantics for a file that is executing — the copy runs as a write against the same inode and inherits the same errno. - When you must debug from the host, resolve the path through
/proc/<host-pid>/root/for the container's mount namespace, and scan that namespace's processes rather than the host's.
I could not reproduce the container case in this lab: the test host has a Docker client but no reachable daemon, so treat those two bullets as guidance derived from the measured kernel rule rather than pasted output. The rule itself does not change when a namespace is added. Our earlier post on the four branches behind Cannot connect to the Docker daemon covers that particular message.
Find the holder without lsof
Most hosts that throw this error are minimal images with no lsof — and installing one during an incident is a bad plan. The information is in /proc/<pid>/maps; you just need to read it for the whole process table. This small script does exactly that, and it also resolves the (deleted) suffix you get after an atomic swap, so it keeps pointing at the process that is still running the old inode:
#!/usr/bin/env python3
"""find-text-holders.py - list processes whose address space maps a given file."""
import os
import sys
def holders(target: str) -> list[tuple[int, str]]:
real = os.path.realpath(target)
found: list[tuple[int, str]] = []
for entry in os.scandir("/proc"):
if not entry.name.isdigit():
continue
pid = int(entry.name)
try:
with open(f"/proc/{pid}/maps", "rb") as maps:
lines = maps.read().splitlines()
except OSError: # exited, or not ours to inspect
continue
for line in lines:
fields = line.split(None, 5)
if len(fields) < 6: # anonymous mapping, no pathname
continue
path = fields[5].decode("utf-8", "replace")
if path.endswith(" (deleted)"):
path = path[: -len(" (deleted)")]
if os.path.realpath(path) != real:
continue
try:
with open(f"/proc/{pid}/cmdline", "rb") as cmdline:
cmd = cmdline.read().replace(b"\0", b" ").decode().strip()
except OSError:
cmd = "?"
found.append((pid, cmd))
break
return found
if __name__ == "__main__":
if len(sys.argv) != 2:
sys.exit("usage: find-text-holders.py /path/to/binary")
result = holders(sys.argv[1])
if not result:
print("no holder: no process has an executable mapping of " + sys.argv[1])
for holder_pid, holder_cmd in result:
print(f"pid={holder_pid} cmd={holder_cmd}")
Run it against a live service and you get the culprit, complete with the command line, in milliseconds:
$ python3 find-text-holders.py /srv/app/svc
pid=1655230 cmd=./svc 30
Two limits are worth knowing. Other users' maps are unreadable when ptrace_scope or hidepid is enforced, so run the scan as root if the service is not yours. And a process that mapped the file and then exited leaves nothing behind — if the scan is empty and the write still fails, the holder is in another PID namespace, which takes you back to the container section above.
The fix that always works: stage, sync, rename
The deploy pattern below is the whole answer: copy the new artifact into the destination directory under a temporary name, flush it, then rename it over the target. The directory entry swap is atomic, the running process never sees a partial file, and no write is ever attempted on the mapped inode. It also takes a lock, because two concurrent deploys racing on the same path is the other half of "the binary is broken".
#!/usr/bin/env bash
# atomic-install.sh - replace a running binary/script without ETXTBSY and without
# a window where consumers see a half-written file.
# usage: atomic-install.sh <new-artifact> <target-path> [mode]
set -Eeuo pipefail
SRC=${1:?usage: atomic-install.sh <new-artifact> <target-path> [mode]}
DEST=${2:?usage: atomic-install.sh <new-artifact> <target-path> [mode]}
MODE=${3:-0755}
DEST_DIR=$(cd -- "$(dirname -- "$DEST")" && pwd)
DEST="$DEST_DIR/$(basename -- "$DEST")"
LOCK="$DEST_DIR/.$(basename -- "$DEST").install.lock"
[ -f "$SRC" ] || { echo "FATAL: source artifact $SRC not found" >&2; exit 1; }
# 1. one deployer at a time - concurrent installs corrupt just as effectively
exec 9>"$LOCK"
flock -n 9 || { echo "FATAL: another install holds $LOCK" >&2; exit 1; }
# 2. stage inside the destination directory: rename() only works in one filesystem
TMP=$(mktemp "$DEST_DIR/.$(basename -- "$DEST").staging.XXXXXX")
trap 'rm -f -- "$TMP"' EXIT
install -m "$MODE" -- "$SRC" "$TMP"
sync -f "$TMP" 2>/dev/null || sync "$TMP" # flush before the rename
# 3. the swap: rename() never returns ETXTBSY, it re-points the directory entry
mv -f -- "$TMP" "$DEST"
trap - EXIT
# 4. prove the swap: new inode, same path, old processes untouched
printf 'target=%s inode=%s sha256=%s\n' \
"$DEST" "$(stat -c %i -- "$DEST")" "$(sha256sum -- "$DEST" | cut -c1-16)"
Executed against a live process, the swap is silent and verifiable — note the inode changing while the running PID keeps its old, now-deleted image:
$ ./svc 30 &
$ stat -c '%i %s' ./svc
8570664 68048
$ bash atomic-install.sh ./artifact ./svc 0755
target=/opt/data/c2cz/lab/etxt3/svc inode=8570666 sha256=f42cf7276848ac3d
$ stat -c '%i %s' ./svc
8570666 68048
$ readlink /proc/$!/exe
/opt/data/c2cz/lab/etxt3/svc (deleted)
If you cannot change the deploy tool, the fallback is one flag: cp -f or cp --remove-destination, or letting install(1) do the work. What you should never do is wrap the failing write in a retry loop. Retrying an in-place write cannot succeed while the service is up: the condition is structural, not transient, so the loop only converts a fast, honest failure into a slow one. The only legitimate retry here is "wait for the holder to exit", and if you truly need that, back it with a holder scan and a timeout rather than a fixed sleep.
Two systemd-era details complete the picture. If your unit uses ExecStart=/srv/app/svc with Restart=always, a crashed process can be restarted at the exact moment you swap — it will exec the new file, which is fine, but your inode bookkeeping in the deploy log will look odd. And if you replaced the binary while the service kept running, the old image stays on disk until the last holder exits: the space is not visible to du on the path, only to df on the filesystem. That is the same "deleted but open" accounting that shows up in file-descriptor incidents, and it is how a 40 MB binary turns into four gigabytes of unaccounted disk after a week of deploys.
Verify the fix instead of trusting it
Three checks separate "the command stopped failing" from "the deploy actually worked".
- The inode changed.
stat -c '%i' ./svcbefore and after must differ; if it is identical, the tool wrote in place and quietly succeeded because no process was running — fine, but you have not tested the interesting path. - The hash matches the artifact. Compare
sha256sumof the target against the built artifact, per host. A truncated rename is rare but a wrong artifact is common. - The new exec works. Run the target with
--versionor an exec probe in a subshell so you exercise the kernel's exec path, not just a read.
Then re-run the whole thing while the service is up. A deploy script that only ever gets tested against a stopped service will pass every dry run you give it and fail on the first real release — which is precisely how this errno gets discovered at 3 a.m. rather than in a pipeline.
Pitfalls that keep people stuck
- ⚠️
EBUSYis notETXTBSY. 16 versus 26.EBUSYfrom a mount or a block device means something else entirely; do not apply this fix there. - ⚠️
cp -ais not a synonym for "safe". Archive mode controls metadata, not the open flags; test the exact command line you ship. - ⚠️ A cross-filesystem
mvis a copy. Stage in the destination directory, or you lose atomicity silently. - ⚠️ Do not chase permissions.
chmod,chown, and running as root do not change the answer; the refusal is not a permission check. - ⚠️ Running scripts are unprotected. No error, no warning — if you edit a running script in place, the interpreter may read your half-written file.
- 💡 The message is honest. "Text file busy" names the real condition: the file's text is in use. The fix is always "stop writing that inode, replace the name".
- 💡 Related process-table work: when the holder is a stuck child rather than a running service, the diagnosis is different — see our notes on zombie processes in Docker.
Related on C2CZ
- [Errno 24] Too many open files — the other errno that punishes the wrong limit.
- <defunct> zombie processes: find the leak — reading
/procto find the process nobody else can see. - Cannot connect to the Docker daemon — four branches behind one message.
- ECONNREFUSED from another host — the 127.0.0.1 bind trap.
Lab environment: Linux 6.17.0-1019-oracle (aarch64), bash 5.2.37, Python 3.13.5, non-root user, target binary /usr/bin/sleep. Every command and every quoted output in this article was executed on 2026-09-29 and pasted from that run.