ちょっとしたデーモンプロセスを perl で書いて、シェルスクリプトを起動させようとしたら、みごとにはまったのその顛末。
魔物が住む cat
問題を切り分けるためにシンプルにしたスクリプトなのだが、このような何の変哲もないスクリプトが、perl で書いたバックグランドプロセスから起動すると、 cat >/dev/null
があると、その後の if
文 then
節で unexpected end of file
が出る。
#!/bin/sh
exec >>/dev/tty 2>&1
set -x
cat >/dev/null 2>/dev/tty
#############################
if [ -d / ]; then
echo $0
date
fi
exit 0
どんなに見直してもスクリプトに問題はなさそう。
スクリプトを手で実行すると起こらない。
perl
から fork() || exec(...)
で起動すると起こっていて、スクリプトを実行するシェルが bourne shell では起こって、korn shell に変更すると起こらない。
状況をシンプルにしてみるために次のようなラッパープログラムを使ってスクリプトを起動すると、問題が再現する。
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{
int stat;
if (fork()) {
wait(&stat);
} else {
close(0);
close(1);
close(2);
execl("/export/home/komeda/sh-bug.sh", "sh-bug.sh", 0);
}
}
再現した状況
% ./cexec
+ cat
/export/home/komeda/sh-bug.sh: syntax error at line 11: `end of file' unexpected
じつは標準入力をクローズして起動するだけで問題が再現できた。
% ./sh-bug.sh <&-
+ cat
./sh-bug.sh: syntax error at line 11: `end of file' unexpected
註: プロンプト記号は %
であるが、筆者が使っているシェルは zsh である。
魔物の正体
環境は Solaris だったのだが、よくよく調べてみると bourne shell のバグだった。
perl スクリプトは、バックグラウンドプロセスとして起動するため、標準入力、標準出力、標準エラー出力を close
している。
これが問題の引き金だった。
if 文のところでエラーが起きていたのは、シェルがスクリプトを 128 バイト単位で読み込むのが、たまたま読み込み境界が if 文のところにあったためである。
なにが起こっているのか
標準入力をクローズした状態でシェルスクリプトを起動すると、スクリプトファイルが、ファイルディスクリプタ 0 でオープンされる。
ここで子プロセスを起動すると、ファイルディスクリプタ 0 は標準入力であるため、そのまま子プロセスに引き継がれる。
子プロセスが標準入力を読む。
それはシェルがスクリプトファイルを読むために使っているファイルディスクリプタなので、この後実行するはずの部分を子プロセスが読んでしまうことになる。この結果ファイルオフセットがずれて、シェルは続きの実行に失敗する。
#!/bin/sh
exec >>/dev/tty 2>&1
set -x
cat >/dev/null 2>/dev/tty
#############################
if [ -d / ]; then
echo $0
date
fi
exit 0
trussで見ると、cat
がスクリプトの続き部分を読んでいることが見て取れる。
% truss -f ./cexec |& egrep 'open|dup|fork|read'
+ cat
/export/home/komeda/sh-bug.sh: syntax error at line 11: `end of file' unexpected
2705: open("/var/ld/ld.config", O_RDONLY) = 3
2705: open("/lib/libc.so.1", O_RDONLY) = 3
2705: fork1() = 2706
2706: fork1() (returning as child ...) = 2705
2706: open("/var/ld/ld.config", O_RDONLY) = 0
2706: open("/lib/libc.so.1", O_RDONLY) = 0
2706: open64("/export/home/komeda/sh-bug.sh", O_RDONLY) = 0
2706: read(0, " # ! / b i n / s h\n e x".., 128) = 128
2706: open64("/dev/tty", O_WRONLY) = 1
2706: dup(1) = 2
2706: fork1() = 2708
2708: fork1() (returning as child ...) = 2706
2708: open("/var/ld/ld.config", O_RDONLY) = 3
2708: open("/lib/libc.so.1", O_RDONLY) = 3
2708: read(0, " f i\n\n e x i t 0\n\n".., 8192) = 117
2708: read(0, 0x080621B8, 8192) = 0
2706: read(0, 0x08076358, 128) = 0
スクリプト中で標準入力を exec でつなぎ替えた場合でも、ファイルディスクリプタ 0 は確かに切り替わるが、ファイルディスクリプタ 0 は、そもそもシェル自身ががスクリプトファイルを読むために使っているものである。このファイルディスクリプタが close されてしまい、続きが読めず実行に失敗する。
#!/bin/sh
exec </dev/null >>/dev/tty 2>&1
set -x
cat >/dev/null 2>/dev/tty
#############################
if [ -d / ]; then
echo $0
date
fi
exit 0
実行結果
% ./sh-bug.sh <&-
+ cat
./sh-bug.sh: syntax error at line 10: `end of file' unexpected
truss で見たようす
% truss -f ./cexec |& egrep 'open|dup|fork|read'
2716: open("/var/ld/ld.config", O_RDONLY) = 3
2716: open("/lib/libc.so.1", O_RDONLY) = 3
2716: fork1() = 2717
2717: fork1() (returning as child ...) = 2716
2717: open("/var/ld/ld.config", O_RDONLY) = 0
2717: open("/lib/libc.so.1", O_RDONLY) = 0
2717: open64("/export/home/komeda/sh-bug.sh", O_RDONLY) = 0
2717: read(0, " # ! / b i n / s h\n e x".., 128) = 128
2717: open64("/dev/null", O_RDONLY) = 1
2717: open64("/dev/tty", O_WRONLY) = 1
2717: dup(1) = 2
+ cat
2717: fork1() = 2719
2719: fork1() (returning as child ...) = 2717
2719: open("/var/ld/ld.config", O_RDONLY) = 3
2719: open("/lib/libc.so.1", O_RDONLY) = 3
2719: read(0, 0x080621B8, 8192) = 0
2717: read(0, 0x08076358, 128) = 0
/export/home/komeda/sh-bug.sh: syntax error at line 10: `end of file' unexpected
Korn shell や POSIX シェルでは起こらない
これらのシェルでは、スクリプトがいったんファイルディスクリプタ 0 でオープンされたあと、もっと大きな番号のファイルディスクリプタが使われるように fcntl
で F_DUPFD
しているためである。
trussで見たようす
1956: fcntl(0, F_DUPFD, 0x0000000A) = 10
1956: close(0) = 0
1956: close(62) Err#9 EBADF
1956: fcntl(10, F_DUPFD, 0x0000003E) = 62
1956: close(10) = 0
1956: fcntl(62, F_SETFD, 0x00000001) = 0
1956: fcntl(62, F_GETFL) = 8192
1956: llseek(62, 0, SEEK_CUR) = 0
まとめ
Solaris では /bin/sh
を使用する場合、標準入力を /dev/null
につなぎ替えておくのはかまわないが、close
して起動してはいけない。
perlスクリプトの方は、exec
の前に /dev/null
を open
しておくことで問題を回避することにした。
POSIX::open("/dev/null", O_RDONLY, 0644);
exec($script) or die "exec failed $?\n";