bash スクリプトの実行中上書き動作について
はじめに
本記事は、bash の実行中スクリプトファイル上書きでどの様に動作するのかを個人的な興味の上で確認するのが目的であり、下記の件について言及する物ではありません。
bash スクリプトファイル上書きの動作
上記の内容によると、実行中に echo foo から echo bar に変更すると bar が表示されるというもの。
#!/bin/sh
sleep 30
echo foo
手元で試した所、bar は表示されなかった。試しに SHEBANG を bash に変更してみたが再現しなかった。
ただし実行中に下記のファイルを cp で上書きすると再現した。
#!/bin/sh
sleep 30
echo bar
また、cp ではなく mv を使うと再現しなかった。さらに echo bar が書かれたスクリプトを tar でアーカイブしておき、実行中に x で上書きした所、再現しなかった。まとめると以下の通り。
| やった内容 | 再現ありなし |
|---|---|
| vim で上書き | 再現せず |
| cp で上書き | 再現した |
| tar で上書き | 再現せず |
この事から推測すると下記の事が言える。
Vim は保存時、一旦テンポラリファイルにファイルを書き出し、リネームする事で保存を行っているのが理由であろう。実際に Vim で
:set backupcopy=yes
を設定してから再度試した所 bar が表示された。backupcopy は編集中のファイルによって自動で判別する auto がデフォルトになっている為、試す際には明示的に yes に設定しないといけない。
bash の実装確認
evalstring.c の parse_and_execute でコマンドが処理されており、input.c の with_input_from_buffered_stream で読み込みの準備が行われている。バッファの読み込みの本体は y.tab.c つまりパーサから直接呼ばれており、このパーサは fgets(3) で読み込まれつつ実行される為、一括でファイルが読み込まれている訳ではない。
while/do でループ実行した際に、ファイルを書き換えられたら戻り先はどうなるか、についてはスクリプトはバッファ付きで読み込まれており、そのバッファがファイルシステムから読み直していなければ同じループが再現される。なので done を見つけた際の戻り先はメモリ上にある。
これらは bash がうんぬんというよりも、ファイルシステムを I/O する際にそれがアトミックであるかどうか、というだけの話。同じ様な事をしているのであれば、どのシェルや言語処理系でも起きうる。
昔の php はパースした結果を AST として保持していなかったので、bash と同じく、YACC のパーサに直接処理が実装されていたのを思い出した。
おわりに
bash のコードは何度か読んだ事があったが、スクリプトの読み込み周りは読んだ事が無かったので、ガードが掛かってない様を見れて良かったし、よりいっそうスクリプト処理系での実行スクリプトファイルの上書きが怖くなった。
繰り返すが、これは bash の問題ではなく、I/O がアトミックではない事で起きる問題であり、この問題を回避しつつスクリプトを入れ替えるのであれば、スクリプトが停止している事を確認するしかない。 mv を使うか、rm & cp を使うか、install コマンドを使う様にしましょう。
追記 2021/12/29
Twitter で daemon1995 さんからご指摘頂き、確認したところ、Linux の dash(/bin/sh)、zsh では再現、FreeBSD の sh/bash では再現せず。どうやら UNIX System-V だと再現する様です。
追記 2021/12/30 (1)
FreeBSD で再現しない件、Twitter で hdk_2 さんにご指摘頂き、FreeBSD は stdio のバッファリングの関係で 512 バイトぐらいで検証した方が良さそうでしたので確認しました。
#include <stdio.h>
int
main() {
char buf[512];
FILE* fp = fopen("test", "r");
fgets(buf, sizeof(buf), fp);
puts(buf);
getchar();
fgets(buf, sizeof(buf), fp);
puts(buf);
fclose(fp);
}
#include <stdio.h>
#include <memory.h>
int
main() {
char buf[512];
FILE* fp = fopen("test", "w");
memset(buf, '0', sizeof(buf));
fwrite(buf, sizeof(buf), 1, fp);
fflush(fp);
getchar();
memset(buf, '1', sizeof(buf));
fwrite(buf, sizeof(buf), 1, fp);
fclose(fp);
}
main1 を実行、停止中に main2 を実行し、main1 でキーを叩いて続行した所、512 バイト目以降が読めました。ですので FreeBSD でも再現します。
追記 2021/12/30 (2)
Twitter で gishicho さんにご指摘頂き、jobs.c の sync_buffered_stream で lseek している事が分かりました。dup する fd に同じオフセットを渡す為に、バッファで先読みしてしまった分を戻している様です。
/* Seek backwards on file BFD to synchronize what we've read so far
with the underlying file pointer. */
int
sync_buffered_stream (bfd)
int bfd;
{
BUFFERED_STREAM *bp;
off_t chars_left;
if (buffers == 0 || (bp = buffers[bfd]) == 0)
return (-1);
chars_left = bp->b_used - bp->b_inputp;
if (chars_left) {
lseek (bp->b_fd, -chars_left, SEEK_CUR);
}
bp->b_used = bp->b_inputp = 0;
return (0);
}
バッファの読み込み量が丁度の時にもこの事象は発生するのですが、この lseek がこの事象を起きやすくしている様です。check_bash_input のコメントを読むと、このケースへの対応の様です。

Discussion
Linux の場合
installコマンドを使用しても inode は変わらなかったためうまく行かないと思います。ただし WSL1 上と WSL2 の/mnt以下、つまりに NTFS 上に作成される場合は inode は変わりました。また macOS と FreeBSD では inode は変わりました。つまり
installコマンドで inode が変わるかどうかはカーネル、ファイルシステム、実装などの違いで異なるようです。ありがとうございます。参考になります。