Solarisの/bin/shは標準入力をクローズしてシェルスクリプトを起動してはいけない

ちょっとしたデーモンプロセスを 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 に変更すると起こらない。

状況をシンプルにしてみるために次のようなラッパープログラムを使ってスクリプトを起動すると、問題が再現する。


```cexec.c 
#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";
~~~