Text file busy

Replacing a program while a copy of it is running

Updated 2026-09-13

Copying a new build over a program that is currently running gets you this:

cp /srv/tips/releases/tips-agent /srv/tips/bin/tips-agent
cp: cannot create regular file '/srv/tips/bin/tips-agent': Text file busy

ETXTBSY, errno 26. The "text" is machine code rather than anything readable: a running process has its executable mapped into memory and pages of it are read from the file on demand, so writing into that file would change the instructions under a process already part-way through them. The kernel refuses instead.

The refusal is on opening the file for writing, so it is not particular to cp:

truncate -s 0 /srv/tips/bin/tips-agent
truncate: cannot open '/srv/tips/bin/tips-agent' for writing: Text file busy

Which process is holding it

fuser with -v names the process and says how it is using the file:

fuser -v /srv/tips/bin/tips-agent
                     USER        PID ACCESS COMMAND
/srv/tips/bin/tips-agent:
                     root        280 ...e. tips-agent

The e in the ACCESS column is the answer: this process is executing the file, which is the only access that produces ETXTBSY. A process that merely has the file open shows f, and one that has it open for writing shows F. Neither of those stops you writing to it.

Where the program is running several times over, every copy has to go before the file can be written, and fuser lists them all.

Replacing it anyway

Deleting the file first works, because rm removes the name and leaves the inode alone. The running process is mapped to the inode and never had a claim on the name:

rm /srv/tips/bin/tips-agent
cp /srv/tips/releases/tips-agent /srv/tips/bin/tips-agent
cmp -s /srv/tips/releases/tips-agent /srv/tips/bin/tips-agent && echo "new build installed"
new build installed

That is the fix most people arrive at, and it has a hole in it. Between the rm and the cp there is a window, however brief, in which the path does not exist, so anything trying to start the program gets "No such file or directory". On a large binary over a slow filesystem that window is long enough to hit. A cp that fails halfway leaves a truncated file that will not run at all.

Writing the new build beside the old one and renaming it over the top closes both:

cp /srv/tips/releases/tips-agent /srv/tips/bin/tips-agent.new
mv /srv/tips/bin/tips-agent.new /srv/tips/bin/tips-agent
cmp -s /srv/tips/releases/tips-agent /srv/tips/bin/tips-agent && echo "new build installed"
new build installed

mv within one filesystem is a rename call, which the kernel performs atomically: the path points at the old inode or the new one, never at nothing and never at half a file. This is the same reason sed -i and any editor worth using write a temporary file and rename it.

The rename has to be on the same filesystem as the target. Across one, mv falls back to copying and deleting, so the window comes back.

install does the whole thing for you, which is why packages use it:

install -m 755 /srv/tips/releases/tips-agent /srv/tips/bin/tips-agent
cmp -s /srv/tips/releases/tips-agent /srv/tips/bin/tips-agent && echo "new build installed"
new build installed

The running process keeps the build it started with

Replacing the file changes what starts next. It does not touch what is already running:

pid=$(pgrep -x tips-agent)
readlink /proc/$pid/exe
cp /srv/tips/releases/tips-agent /srv/tips/bin/tips-agent.new
mv /srv/tips/bin/tips-agent.new /srv/tips/bin/tips-agent
readlink /proc/$pid/exe
/srv/tips/bin/tips-agent
/srv/tips/bin/tips-agent (deleted)

(deleted) on a path that plainly exists is the old inode, kept alive by the process still mapped to it, with no directory entry left pointing at it. The blocks it occupies do not come back until the process exits, which is the same accounting behind No space left on device.

So restart the service after deploying, and be careful about reading anything into a (deleted) executable on a machine you did not just deploy to: it means the running process is a version nobody can now inspect from the filesystem.

The refusal in the other direction

A file somebody has open for writing cannot be executed, which is ETXTBSY again from the other end:

env /srv/tips/bin/tips-stage 1
echo "exit status: $?"
env: '/srv/tips/bin/tips-stage': Text file busy
exit status: 126

The upload is still in progress and the kernel will not run a program somebody is in the middle of writing. A deployment that copies straight to the path it then starts hits this whenever the two overlap, and the same rename fixes it. Exit status 126 means found and not executable, which is worth telling apart from 127, meaning not found.

Shell scripts get no such protection

The kernel protects the interpreter, not the file the interpreter is reading, so a running script is an ordinary open file:

fuser -v /srv/tips/bin/tips-job
                     USER        PID ACCESS COMMAND
/srv/tips/bin/tips-job:
                     root        540 f.... tips-job

f rather than e, and the kernel does not refuse a write:

cp /srv/tips/releases/tips-agent /srv/tips/bin/tips-job
echo "overwrote a running script, exit status: $?"
overwrote a running script, exit status: 0

That permission is worth less than it looks. bash reads a script as it goes, keeping a byte offset into the file rather than a copy of it, so a script rewritten underneath a running instance carries on at the offset it had reached and executes whatever now happens to be there. The output is a run of syntax errors mentioning lines that were never in either version, and the tail of the old script never runs.

Editing a long-running script in place is the everyday version of this. An editor configured to write a new file and rename it over the original is safe; one configured to write back into the same inode, and a shell redirect into the path, are not. Deploy a script the way you would deploy a binary, since ETXTBSY is not going to stop you here.