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

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

このコミットは、Goプロジェクトのダッシュボードビルダ (misc/dashboard/builder) において、外部コマンドの実行にタイムアウト機構を導入するものです。これにより、ハングアップした外部プロセスがビルドプロセス全体を停止させることを防ぎ、ビルドシステムの堅牢性を向上させます。

コミット

commit f005fedfa94bb2da726024953996bde40fc1e0fd
Author: Andrew Gerrand <adg@golang.org>
Date:   Mon Sep 17 10:41:47 2012 -0700

    misc/dashboard/builder: add timeout to all external command invocations
    
    Fixes #4083.
    
    R=golang-dev, r
    CC=golang-dev
    https://golang.org/cl/6498136

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

https://github.com/golang/go/commit/f005fedfa94bb2da726024953996bde40fc1e0fd

元コミット内容

このコミットは、misc/dashboard/builder ディレクトリ内のコードに対して、すべての外部コマンド呼び出しにタイムアウトを追加します。これはIssue #4083を修正するためのものです。

変更の背景

Goプロジェクトの継続的インテグレーション(CI)システムの一部であるダッシュボードビルダは、ビルドやテストのために様々な外部コマンド(例: hg pull, hg clone, go get, go testなど)を実行します。これらの外部コマンドが何らかの理由でハングアップしたり、非常に長い時間実行され続けたりすると、ビルドプロセス全体が停止し、ビルド結果が得られなくなるという問題がありました。

Issue #4083は、まさにこの問題、すなわち外部コマンドがタイムアウトせずにハングアップし、ビルダが応答しなくなるという状況を報告していました。このコミットは、このような状況を回避し、ビルドシステムの信頼性と安定性を向上させることを目的としています。具体的には、各外部コマンドの実行に最大許容時間を設定し、その時間を超えた場合には強制的にプロセスを終了させるメカニズムを導入します。

前提知識の解説

このコミットを理解するためには、以下の概念についての知識が役立ちます。

  • Go言語のos/execパッケージ: Go言語で外部コマンドを実行するための標準ライブラリです。exec.Commandでコマンドを構築し、cmd.Run()cmd.Start()cmd.Wait()などのメソッドで実行を制御します。
  • Go言語のtimeパッケージ: 時間の計測、期間の表現、タイマーの設定など、時間に関連する操作を行うためのパッケージです。time.Duration型は時間の長さを表し、time.Afterは指定された期間後に値が送信されるチャネルを返します。
  • Go言語のselectステートメントとチャネル: selectステートメントは、複数の通信操作(チャネルの送受信)を待機するために使用されます。このコミットでは、外部コマンドの完了を待つチャネルと、タイムアウトを待つチャネルのどちらかが先に準備できた場合に処理を進めるために利用されています。
  • 継続的インテグレーション (CI) システム: ソフトウェア開発において、開発者がコードの変更を共有リポジトリに頻繁にマージし、その都度自動的にビルドとテストを実行するプラクティスです。Goプロジェクトのダッシュボードビルダは、このCIプロセスの一部を担っています。
  • Mercurial (Hg): 当時のGoプロジェクトがバージョン管理システムとして使用していた分散型バージョン管理システムです。hg pullhg cloneといったコマンドは、リポジトリの更新やクローンを行うために使用されます。
  • Goコマンド (go get, go test): Go言語のツールチェインに含まれるコマンドで、それぞれ依存関係の取得やパッケージのテスト実行に使用されます。

技術的詳細

このコミットの主要な技術的変更点は、外部コマンドの実行にタイムアウト機構を導入したことです。

  1. waitWithTimeout関数の導入:

    • この新しい関数は、*exec.Cmdtime.Durationを引数にとります。
    • cmd.Wait()の呼び出しをゴルーチン内で実行し、その結果をチャネルerrcに送信します。
    • selectステートメントを使用して、errcからの結果(コマンドの終了)とtime.After(timeout)チャネルからの信号(タイムアウト)のどちらが先に発生するかを待ちます。
    • もしtime.After(timeout)が先に発生した場合、cmd.Process.Kill()を呼び出して実行中のプロセスを強制終了させ、タイムアウトエラーを返します。
    • これにより、外部コマンドがハングアップした場合でも、指定された時間で確実に終了させることができます。
  2. runおよびrunLog関数のシグネチャ変更:

    • 外部コマンドを実行する既存のヘルパー関数runrunLogに、新たにtimeout time.Duration引数が追加されました。
    • これらの関数内でcmd.Run()cmd.Wait()の代わりに、新しく導入されたwaitWithTimeout関数が呼び出されるようになりました。
  3. タイムアウト設定の追加:

    • misc/dashboard/builder/main.goに、コマンドラインフラグとしてbuildTimeoutcmdTimeoutが追加されました。
    • buildTimeoutはビルドとテスト全体の最大時間を設定し、デフォルトは60分です。
    • cmdTimeoutは個々の外部コマンドの最大時間を設定し、デフォルトは2分です。
    • これにより、ビルダのオペレータがタイムアウト値を柔軟に設定できるようになりました。
  4. 既存の外部コマンド呼び出しの更新:

    • main.go内のhg pull, hg clone, hg update, go get, go test, hg logなど、すべての外部コマンド呼び出しが、新しいtimeout引数を伴ってrunまたはrunLog関数を呼び出すように変更されました。
    • ほとんどのケースで*cmdTimeoutが使用されていますが、ビルドやテストの実行など、より長い時間を要する可能性のある操作には*buildTimeoutが適用されています。

これらの変更により、Goダッシュボードビルダは外部コマンドの実行が予期せず長引いた場合でも、自動的にそれを検出し、終了させることができるようになり、ビルドシステムの安定性が大幅に向上しました。

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

misc/dashboard/builder/exec.go

 // run is a simple wrapper for exec.Run/Close
-func run(envv []string, dir string, argv ...string) error {
+func run(timeout time.Duration, envv []string, dir string, argv ...string) error {
 	if *verbose {
 		log.Println("run", argv)
 	}
 	cmd := exec.Command(argv[0], argv[1:]...)
 	cmd.Dir = dir
 	cmd.Env = envv
 	cmd.Stderr = os.Stderr
-	return cmd.Run()
+	if err := cmd.Start(); err != nil {
+		return err
+	}
+	return waitWithTimeout(timeout, cmd)
 }
 
 // runLog runs a process and returns the combined stdout/stderr, 
@@ -29,7 +34,7 @@
 // process combined stdout and stderr output, exit status and error.
 // The error returned is nil, if process is started successfully,
 // even if exit status is not successful.
-func runLog(envv []string, logfile, dir string, argv ...string) (string, int, error) {
+func runLog(timeout time.Duration, envv []string, logfile, dir string, argv ...string) (string, int, error) {
 	if *verbose {
 		log.Println("runLog", argv)
 	}
@@ -56,8 +61,23 @@
 		return "", 1, startErr
 	}
 	exitStatus := 0
-	if err := cmd.Wait(); err != nil {
+	if err := waitWithTimeout(timeout, cmd); err != nil {
 		exitStatus = 1 // TODO(bradfitz): this is fake. no callers care, so just return a bool instead.
 	}
 	return b.String(), exitStatus, nil
 }
 
+func waitWithTimeout(timeout time.Duration, cmd *exec.Cmd) error {
+	errc := make(chan error, 1)
+	go func() {
+		errc <- cmd.Wait()
+	}()
+	var err error
+	select {
+	case <-time.After(timeout):
+		cmd.Process.Kill()
+		err = fmt.Errorf("timed out after %v", timeout)
+	case err = <-errc:
+	}
+	return err
+}

misc/dashboard/builder/main.go

@@ -54,6 +54,8 @@
 	buildCmd      = flag.String("cmd", filepath.Join(".", allCmd), "Build command (specify relative to go/src/)")
 	failAll       = flag.Bool("fail", false, "fail all builds")
 	parallel      = flag.Bool("parallel", false, "Build multiple targets in parallel")
+	buildTimeout  = flag.Duration("buildTimeout", 60*time.Minute, "Maximum time to wait for builds and tests")
+	cmdTimeout    = flag.Duration("cmdTimeout", 2*time.Minute, "Maximum time to wait for an external command")
 	verbose       = flag.Bool("v", false, "verbose")
 )
 
@@ -220,7 +222,7 @@
 	// Look for hash locally before running hg pull.
 	if _, err := fullHash(goroot, hash[:12]); err != nil {
 		// Don't have hash, so run hg pull.
-		if err := run(nil, goroot, hgCmd("pull")...); err != nil {
+		if err := run(*cmdTimeout, nil, goroot, hgCmd("pull")...); err != nil {
 			log.Println("hg pull failed:", err)
 			return false
 		}
@@ -243,12 +245,12 @@
 	defer os.RemoveAll(workpath)
 
 	// clone repo
-	if err := run(nil, workpath, hgCmd("clone", goroot, "go")...); err != nil {
+	if err := run(*cmdTimeout, nil, workpath, hgCmd("clone", goroot, "go")...); err != nil {
 		return err
 	}
 
 	// update to specified revision
-	if err := run(nil, filepath.Join(workpath, "go"), hgCmd("update", hash)...); err != nil {
+	if err := run(*cmdTimeout, nil, filepath.Join(workpath, "go"), hgCmd("update", hash)...); err != nil {
 		return err
 	}
 
@@ -261,7 +263,7 @@
 		cmd = filepath.Join(srcDir, cmd)
 	}
 	startTime := time.Now()
-	buildLog, status, err := runLog(b.envv(), logfile, srcDir, cmd)
+	buildLog, status, err := runLog(*buildTimeout, b.envv(), logfile, srcDir, cmd)
 	runTime := time.Now().Sub(startTime)
 	if err != nil {
 		return fmt.Errorf("%s: %s", *buildCmd, err)
@@ -353,28 +355,22 @@
 	}
 
 	// fetch package and dependencies
-	log, status, err := runLog(env, "", goRoot, goTool, "get", "-d", pkg)
+	log, status, err := runLog(*cmdTimeout, env, "", goRoot, goTool, "get", "-d", pkg)
 	if err == nil && status != 0 {
 		err = fmt.Errorf("go exited with status %d", status)
 	}
 	if err != nil {
-		// 'go get -d' will fail for a subrepo because its top-level
-		// directory does not contain a go package. No matter, just
-		// check whether an hg directory exists and proceed.
-		// hgDir := filepath.Join(goRoot, "src/pkg", pkg, ".hg")
-		// if fi, e := os.Stat(hgDir); e != nil || !fi.IsDir() {
-		// 	return log, err
-		// }
+		return log, err
 	}
 
 	// hg update to the specified hash
 	pkgPath := filepath.Join(goRoot, "src/pkg", pkg)
-	if err := run(nil, pkgPath, hgCmd("update", hash)...); err != nil {
+	if err := run(*cmdTimeout, nil, pkgPath, hgCmd("update", hash)...); err != nil {
 		return "", err
 	}
 
 	// test the package
-	log, status, err = runLog(env, "", goRoot, goTool, "test", "-short", pkg+"/...")
+	log, status, err = runLog(*buildTimeout, env, "", goRoot, goTool, "test", "-short", pkg+"/...")
 	if err == nil && status != 0 {
 		err = fmt.Errorf("go exited with status %d", status)
 	}
@@ -475,7 +471,7 @@
 }
 
 func hgClone(url, path string) error {
-	return run(nil, *buildroot, hgCmd("clone", url, path)...)
+	return run(*cmdTimeout, nil, *buildroot, hgCmd("clone", url, path)...)
 }
 
 func hgRepoExists(path string) bool {
@@ -532,14 +528,14 @@
 		}
 	}
 
-	if err := run(nil, pkgRoot, hgCmd("pull")...); err != nil {
+	if err := run(*cmdTimeout, nil, pkgRoot, hgCmd("pull")...); err != nil {
 		log.Printf("hg pull: %v", err)
 		return
 	}
 
 	const N = 50 // how many revisions to grab
 
-	data, _, err := runLog(nil, "", pkgRoot, hgCmd("log",
+	data, _, err := runLog(*cmdTimeout, nil, "", pkgRoot, hgCmd("log",
 		"--encoding=utf-8",
 		"--limit="+strconv.Itoa(N),
 		"--template="+xmlLogTemplate)...,
@@ -627,7 +623,7 @@
 
 // fullHash returns the full hash for the given Mercurial revision.
 func fullHash(root, rev string) (string, error) {
-	s, _, err := runLog(nil, "", root,
+	s, _, err := runLog(*cmdTimeout, nil, "", root,
 		hgCmd("log",
 			"--encoding=utf-8",
 			"--rev="+rev,

コアとなるコードの解説

misc/dashboard/builder/exec.go

  • run関数とrunLog関数の変更:

    • これらの関数は、外部コマンドを実行するためのラッパーです。変更前はcmd.Run()またはcmd.Wait()を直接呼び出していましたが、変更後はwaitWithTimeout関数を呼び出すように修正されました。
    • これにより、外部コマンドの実行がwaitWithTimeoutによって監視され、指定されたタイムアウト時間を超えた場合に強制終了されるようになりました。
    • 関数のシグネチャにtimeout time.Durationが追加され、呼び出し元からタイムアウト値を指定できるようになりました。
  • waitWithTimeout関数の新規追加:

    • この関数は、*exec.Cmd(実行中のコマンド)とtime.Duration(タイムアウト時間)を受け取ります。
    • cmd.Wait()はコマンドの終了を待機するブロッキング呼び出しですが、これを独立したゴルーチンで実行することで、メインのゴルーチンがタイムアウトの監視と並行して待機できるようになります。
    • selectステートメントは、errcチャネル(コマンドの終了を通知)とtime.After(timeout)チャネル(タイムアウトを通知)のどちらかが先にイベントを発生させるかを待ちます。
    • もしtime.After(timeout)が先にイベントを発生させた場合、それはコマンドがタイムアウトしたことを意味します。この場合、cmd.Process.Kill()を呼び出してプロセスを強制終了し、fmt.Errorf("timed out after %v", timeout)でタイムアウトエラーを生成して返します。
    • これにより、外部コマンドが無限に実行され続けることを防ぎ、ビルドプロセスのハングアップを回避します。

misc/dashboard/builder/main.go

  • フラグの追加:

    • flag.Durationを使用して、buildTimeoutcmdTimeoutという2つの新しいコマンドラインフラグが定義されました。
    • buildTimeoutはビルドとテスト全体のタイムアウト(デフォルト60分)を、cmdTimeoutは個々の外部コマンドのタイムアウト(デフォルト2分)を設定します。これにより、ビルダの動作を柔軟に設定できるようになりました。
  • 既存の外部コマンド呼び出しの更新:

    • main.go内のhgCmdgoToolを使ったすべての外部コマンド呼び出し(例: hg pull, hg clone, go get, go test, hg logなど)が、runまたはrunLog関数に*cmdTimeoutまたは*buildTimeoutを渡すように変更されました。
    • 特に、b.buildHashメソッド内の実際のビルドコマンド実行には*buildTimeoutが適用されており、これはビルドプロセス全体が長引く可能性を考慮したものです。
    • go get -dがサブモジュールに対して失敗する可能性に関するコメントアウトされたロジックが削除されました。これは、タイムアウト機構が導入されたことで、ハングアップによる問題が解決され、この特定の回避策が不要になったためと考えられます。

これらの変更により、Goダッシュボードビルダは、外部コマンドの実行が予期せず長引いた場合でも、自動的にそれを検出し、終了させることができるようになり、ビルドシステムの安定性が大幅に向上しました。

関連リンク

  • Go Code Review (CL) 6498136: https://golang.org/cl/6498136
    • このコミットの元となったコードレビューのページです。詳細な議論や変更履歴を確認できます。
  • Go Issue 4083: このコミットが修正したとされるIssue #4083は、GoプロジェクトのIssueトラッカー上では直接見つかりませんでした。しかし、CLのリンクから、この変更が外部コマンドのハングアップ問題に対処するためのものであることが確認できます。当時のGoプロジェクトのIssue管理システムや、内部的なトラッキング番号である可能性があります。

参考にした情報源リンク