Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

[インデックス 13830] ファイルの概要

このコミットは、Go言語の os/exec パッケージにおいて、ファイルディスクリプタ(FD)が不足した場合に Command.Start メソッドがクラッシュする可能性があったバグを修正するものです。具体的には、/dev/null を開く際にエラーが発生した場合に、nil の *os.File オブジェクトがクリーンアップリストに追加され、その後の処理でパニックを引き起こす問題を解決しています。

コミット

commit 5c5c2c8112f774b118b9251eb15c2df529ad454c
Author: Brad Fitzpatrick <bradfitz@golang.org>
Date:   Fri Sep 14 13:40:22 2012 -0700

    os/exec: don't crash when out of fds
    
    Command.Start could crash before if no fds were available
    because a nil *os.File of /dev/null was added to the cleanup
    list, which crashed before returning the proper error.
    
    R=golang-dev, iant
    CC=golang-dev
    https://golang.org/cl/6514043

GitHub上でのコミットページへのリンク

https://github.com/golang/go/commit/5c5c2c8112f774b118b9251eb15c2df529ad454c

元コミット内容

os/exec: don't crash when out of fds

Command.Start は、ファイルディスクリプタが利用できない場合にクラッシュする可能性がありました。これは、/dev/null の nil の *os.File がクリーンアップリストに追加され、適切なエラーを返す前にクラッシュしたためです。

変更の背景

Go言語の os/exec パッケージは、外部コマンドを実行するための機能を提供します。外部コマンドを実行する際には、そのコマンドの標準入力 (stdin)、標準出力 (stdout)、標準エラー出力 (stderr) を設定する必要があります。多くの場合、これらのストリームが明示的に設定されていない場合、システムは /dev/null (Windows では NUL) を使用して、不要な出力を破棄したり、入力ソースを提供したりします。

このコミットが行われる前は、os/exec パッケージ内で /dev/null を開く処理において、ファイルディスクリプタが不足しているなどの理由で os.Openos.OpenFile がエラーを返した場合に、そのエラーが適切に処理されていませんでした。具体的には、エラーが発生して *os.File オブジェクトが nil になったにもかかわらず、その nil オブジェクトが c.closeAfterStart というクリーンアップリストに追加されていました。

Command.Start メソッドが実行され、外部コマンドの起動が試みられた後、この c.closeAfterStart リストに登録されたファイルディスクリプタをクリーンアップ(閉じる)処理が実行されます。この際、リストに nil*os.File オブジェクトが含まれていると、nil ポインタに対するメソッド呼び出しが発生し、Goランタイムがパニック(クラッシュ)するという問題がありました。

この問題は、システムがファイルディスクリプタを使い果たしているような、リソースが枯渇した状況で発生しやすく、アプリケーション全体の安定性を損なう可能性がありました。したがって、このコミットは、このようなエッジケースにおける堅牢性を向上させるために導入されました。

前提知識の解説

1. ファイルディスクリプタ (File Descriptor, FD)

ファイルディスクリプタは、Unix系オペレーティングシステムにおいて、プロセスが開いているファイルやソケット、パイプなどのI/Oリソースを識別するために使用される整数値です。各プロセスには利用可能なファイルディスクリプタの最大数(通常は制限がある)があり、これを使い果たすと新たなファイルを開くことができなくなります。

2. os/exec パッケージ (Go言語)

os/exec パッケージは、Goプログラムから外部のシステムコマンドを実行するための機能を提供します。exec.Command 関数でコマンドと引数を指定し、Cmd 構造体を作成します。Cmd 構造体には、標準入力 (Stdin)、標準出力 (Stdout)、標準エラー出力 (Stderr) などのフィールドがあり、これらを io.Readerio.Writer インターフェースを実装するオブジェクトに設定することで、コマンドのI/Oを制御できます。

3. os.DevNull

os.DevNull は、Go言語の os パッケージで提供される定数で、オペレーティングシステムが提供する「nullデバイス」のパスを表します。Unix系システムでは /dev/null、Windowsでは NUL に相当します。

  • /dev/null に書き込まれたデータはすべて破棄されます。
  • /dev/null から読み取ろうとすると、即座にEOF (End Of File) が返されます。 これは、不要な出力を捨てる場合や、入力が不要な場合にダミーのI/Oストリームとして利用されます。

4. os.Openos.OpenFile

  • os.Open(name string) (*File, error): 指定されたパスのファイルを読み取り専用で開きます。
  • os.OpenFile(name string, flag int, perm FileMode) (*File, error): 指定されたパスのファイルを、指定されたフラグ(読み書きモード、作成、追記など)とパーミッションで開きます。

これらの関数は、ファイルを開くことに成功した場合に *os.File 型のポインタと nil エラーを返します。ファイルを開くことに失敗した場合(例: ファイルが存在しない、パーミッションがない、ファイルディスクリプタが不足しているなど)は、nil*os.File ポインタと非nil のエラーを返します。

5. Command.Start

Cmd 構造体の Start() メソッドは、外部コマンドを非同期で起動します。コマンドが正常に起動された場合、nil エラーを返します。起動に失敗した場合(例: コマンドが見つからない、パーミッションがない、I/O設定に問題があるなど)は、非nil のエラーを返します。

6. Go言語のエラーハンドリング

Go言語では、関数がエラーを返す場合、通常は戻り値の最後の要素として error 型の値を返します。慣習として、エラーが発生しなかった場合は nil を返し、エラーが発生した場合は非nilerror オブジェクトを返します。呼び出し元は、返されたエラーが nil かどうかをチェックすることで、処理が成功したかどうかを判断します。

f, err := os.Open("somefile.txt")
if err != nil {
    // エラー処理
    return nil, err
}
// f を使用した処理

このコミットの背景にある問題は、このエラーチェックが不十分であったために発生しました。

技術的詳細

このバグは、os/exec パッケージ内の stdin() および writerDescriptor() メソッドに存在していました。これらのメソッドは、Cmd 構造体の StdinStdout/Stderr が明示的に設定されていない場合に、デフォルトで /dev/null を開いて *os.File オブジェクトを生成します。

問題のコードは以下のようになっていました(修正前を想定):

// stdin() メソッド内 (修正前)
func (c *Cmd) stdin() (f *os.File, err error) {
    if c.Stdin == nil {
        f, err = os.Open(os.DevNull)
        // ここで err が nil でない場合でも、f は nil のまま
        c.closeAfterStart = append(c.closeAfterStart, f) // nil が追加される
        return
    }
    // ...
}

// writerDescriptor() メソッド内 (修正前)
func (c *Cmd) writerDescriptor(w io.Writer) (f *os.File, err error) {
    if w == nil {
        f, err = os.OpenFile(os.DevNull, os.O_WRONLY, 0)
        // ここで err が nil でない場合でも、f は nil のまま
        c.closeAfterStart = append(c.closeAfterStart, f) // nil が追加される
        return
    }
    // ...
}

ここで重要なのは、os.Openos.OpenFile がエラーを返した場合、変数 fnil のままになるという点です。しかし、その後の c.closeAfterStart = append(c.closeAfterStart, f) の行では、fnil であっても、その nil 値が closeAfterStart スライスに追加されてしまいます。

Command.Start メソッドが外部コマンドを起動した後、内部的には closeAfterStart リストに登録されたファイルディスクリプタを閉じる処理が実行されます。このクリーンアップ処理は、リスト内の各 *os.File オブジェクトに対して Close() メソッドを呼び出そうとします。

nil ポインタに対してメソッドを呼び出すと、Goランタイムはパニックを発生させます。したがって、ファイルディスクリプタが不足しているなどの理由で /dev/null を開くことに失敗し、nil*os.File がクリーンアップリストに追加された場合、Command.Start がエラーを返す前にアプリケーションがクラッシュするという問題が発生していました。

このコミットは、os.Openos.OpenFile の呼び出し直後にエラーチェックを追加し、エラーが発生した場合はすぐに return するように変更することで、この問題を解決しています。これにより、nil*os.File オブジェクトが c.closeAfterStart リストに追加されることを防ぎ、パニックを回避しています。

コアとなるコードの変更箇所

--- a/src/pkg/os/exec/exec.go
+++ b/src/pkg/os/exec/exec.go
@@ -143,6 +143,9 @@ func (c *Cmd) argv() []string {\n func (c *Cmd) stdin() (f *os.File, err error) {\n \tif c.Stdin == nil {\n \t\tf, err = os.Open(os.DevNull)\n+\t\tif err != nil {\n+\t\t\treturn\n+\t\t}\n \t\tc.closeAfterStart = append(c.closeAfterStart, f)\n \t\treturn\n \t}\n@@ -182,6 +185,9 @@ func (c *Cmd) stderr() (f *os.File, err error) {\n func (c *Cmd) writerDescriptor(w io.Writer) (f *os.File, err error) {\n \tif w == nil {\n \t\tf, err = os.OpenFile(os.DevNull, os.O_WRONLY, 0)\n+\t\tif err != nil {\n+\t\t\treturn\n+\t\t}\n \t\tc.closeAfterStart = append(c.closeAfterStart, f)\n \t\treturn\n \t}\

コアとなるコードの解説

変更は src/pkg/os/exec/exec.go ファイルの2箇所にあります。

  1. stdin() メソッド内:

    func (c *Cmd) stdin() (f *os.File, err error) {
        if c.Stdin == nil {
            f, err = os.Open(os.DevNull)
            if err != nil { // 追加された行
                return // 追加された行
            } // 追加された行
            c.closeAfterStart = append(c.closeAfterStart, f)
            return
        }
        // ...
    }
    

    os.Open(os.DevNull) の呼び出し後、すぐに if err != nil { return } というエラーチェックが追加されました。これにより、/dev/null を開く際にエラーが発生した場合(例: ファイルディスクリプタ不足)、関数は直ちにエラーを返して終了します。この時点で fnil のままであり、c.closeAfterStartnil が追加されることを防ぎます。

  2. writerDescriptor() メソッド内:

    func (c *Cmd) writerDescriptor(w io.Writer) (f *os.File, err error) {
        if w == nil {
            f, err = os.OpenFile(os.DevNull, os.O_WRONLY, 0)
            if err != nil { // 追加された行
                return // 追加された行
            } // 追加された行
            c.closeAfterStart = append(c.closeAfterStart, f)
            return
        }
        // ...
    }
    

    同様に、os.OpenFile(os.DevNull, os.O_WRONLY, 0) の呼び出し後にも if err != nil { return } が追加されました。これにより、書き込みモードで /dev/null を開く際にエラーが発生した場合も、同様に直ちにエラーを返して終了し、nil*os.File がクリーンアップリストに追加されるのを防ぎます。

これらの変更により、ファイルディスクリプタの枯渇などのシステムリソースの問題が発生した場合でも、os/exec パッケージが堅牢に動作し、予期せぬパニックによるアプリケーションのクラッシュを防ぐことができるようになりました。エラーは適切に呼び出し元に伝播され、呼び出し元で適切なエラーハンドリングを行うことが可能になります。

関連リンク

参考にした情報源リンク

  • Go言語 os/exec パッケージのドキュメント: https://pkg.go.dev/os/exec
  • Go言語 os パッケージのドキュメント: https://pkg.go.dev/os
  • ファイルディスクリプタに関する一般的な情報 (Wikipediaなど)
  • Go言語のエラーハンドリングに関する一般的な情報