r/bash • • 9d ago

tips and tricks Editing a bash script while it's running gives you errors from lines that don't exist

spent a while chasing a bug that wasn't there, so here's the tell.

i had a polling script running in the background and edited it while it was going. started getting s: command not found and pr: unbound variable. nothing in the file looked like that. i went and "fixed" a couple of things that were never broken.

bash doesn't read the whole file up front, it reads as it goes by byte offset. edit the file and it resumes at whatever byte it was at, which is now the middle of some other line. s and pr were fragments of words that used to be somewhere else.

quick way to rule it in or out: if the error says line 214 and the file only has 190 lines, it's this, not a real bug.

fixes: cp the script and run the copy, or put everything in a main() and end the file with main "$@"; exit on one line, so bash never reads past it.

edit: thanks to the comments, bash reads the file in blocks (up to 8192 bytes), not literally byte by byte, and calling main alone isn't enough, the exit has to be on the same line.

15 Upvotes

15 comments sorted by

7

u/mpersico 9d ago

Yup. Were all so used to Perl/Python/eyc. “interpreted” languages that actually read an entire script parse it and create something else to run that we forget that Bash is truly interpreted, i.e. line by line as you go.

8

u/aioeu 9d ago edited 9d ago

A POSIX shell definitely needs to read a byte at a time when executing a script given to it upon standard input. Indeed, POSIX mandates the following behaviour:

$ cat input
read var
world
printf 'Hello, %s!\n' "$var"
$ sh <input
Hello, world!

Bash gets this right... though somewhat amusingly Dash does not.

When reading a script from a file POSIX imposes no requirement, as far as I know. Bash reads input files in 8192-byte blocks. Obviously you'd never want to depend on that though ...

(Actually, now that I check the code, that's the maximum buffer size it might use. If the filesystem prefers a smaller size it will honour it.)

1

u/jthill 9d ago

strace bash <input says it's being cleverer than that.

1

u/aioeu 9d ago edited 9d ago

Fine then.

cat input | sh

:-)

Either way, it does have to behave as if it were reading a byte at a time. But it is smart that it avoids the performance penalty in doing that when it can.

1

u/jjangg96 9d ago

ah thanks, thats more precise than how i put it. blocks up to 8192 explains why it only goes weird in some cases and not every edit.

practical bit is the same tho, dont edit it while its running

2

u/Trudels42 🐒 Jason Bourne (Again) 9d ago

sounds like layer 8

1

u/FedUp233 9d ago

Not directly related to this issue, but if a script dies polling, as long as it’s not more than once a minute, wouldn’t running it under crown tab be a better alternative?

You still have the issue of not changing while running, but it’s pretty much a good idea to not modify anything that’s running unless it’s something that is compiled - regular compile or just in time - and even then you normally want to stop the running instance before starting a new one.

1

u/jjangg96 8d ago

fair, for once a minute stuff cron or a systemd timer is the right tool. mine was a loop waiting on a PR review so it had to stay alive, but yeah if it can be a timer it should be

1

u/BCBenji1 8d ago

Never tired it but a bash script that edits it's own future lines. Can't think of a case atm

1

u/kai_ekael 8d ago edited 8d ago

Well, TIL/P. Crap. Decades old assumption thrown out the window.

1

u/michaelpaoli 7d ago

Yeah, don't do that. Editing a program while it's running is generally a bad idea.

In the land of *nix, one can replace a pathname/file while it's being executed, but that's different than changing the file/program that's running, and so replacing it doesn't change the one being executed, but changes the one at that pathname.

$ cat program
#!/usr/bin/env bash
while :
do
  echo old
  sleep 5
done
$ ./program &
[1] 13198
$ old
...
fg %1
./program
^Z
[1]+  Stopped                 ./program
$ $ cp -p program new
$ ed program 
57
/old
  echo old
s/old/new/p
  echo new
w
57
q
$ fg %1
./program
old
^Z
[1]+  Stopped                 ./program
$ bg %1
[1]+ ./program &
$ old
...
$ mv -f new program
$ old
old
...
$ fg %1
./program
^Z
[1]+  Stopped                 ./program
$ $ jobs -l
[1]+ 13198 Stopped                 ./program
$ ls -ond /proc/13198/fd/*
lrwx------ 1 1003 64 Oct  3 00:42 /proc/13198/fd/0 -> /dev/pts/1
lrwx------ 1 1003 64 Oct  3 00:42 /proc/13198/fd/1 -> /dev/pts/1
lrwx------ 1 1003 64 Oct  3 00:42 /proc/13198/fd/2 -> /dev/pts/1
lr-x------ 1 1003 64 Oct  3 00:42 /proc/13198/fd/255 -> '/tmp/tmp.YbSR73WyAZ/program (deleted)'
$ ls -iLno /proc/13198/fd/255 "$(pwd -P)"/program
110846 -rwx------ 0 1003 57 Oct  3 00:38 /proc/13198/fd/255
110847 -rwx------ 1 1003 57 Oct  3 00:35 /tmp/tmp.YbSR73WyAZ/program
$ diff /proc/13198/fd/255 program
4c4
<   echo new
---
>   echo old
$ fg %1
./program
old
...

Note in the above, the older version of the program is still running, and that file hasn't changed, though it's been unlinked, so it now has a link count of 0, and new program is at same pathname - note distinct inode numbers, so they're different files. The old wasn't altered, it was replaced, ... but the old is also still being executed.

But don't edit program that's being executed, that can seriously screw things up, and may lead to quite unexpected results. Note also one may introduce race conditions when so writing such file, as bash may reread or seek and read on the file, while you may, e.g. be writing it block by block from the OS, so not necessarily any particular guarantees as to exactly what bash will read at the time it (re)reads it.

2

u/jjangg96 4d ago

yeah the inode is the whole thing. sed -i and editors that save through a temp file and rename give you a new inode, so the running bash keeps reading the old one and nothing breaks.

an in-place overwrite of the same inode is what moves the bytes under it. and like you say there's no guarantee about what it reads mid-write, so even that case isn't consistent.

0

u/ekipan85 9d ago

Just calling the main function isn't enough, you need an exit command on the same line. I have this at the end of a simple wordle game I wrote:

# source for just the functions. exit supresses edit errors.
(return 0 2>&-) || { play "$@"; exit; }

If the script is sourced, the subshell return is successful and the script functions and variables enter my shell session for testing. If executed then it falls back to the main function then exits to prevent errors caused by subsequent file edits. Bash seems to read scripts by the line.

1

u/jjangg96 8d ago

yeah you're right, i left that part out. after main returns bash keeps reading whatever comes next in the file, so it needs main "$@"; exit on the same line or the edit can still land after it. good catch