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

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

このコミットは、Go言語の実験的なSSHパッケージ(exp/ssh)におけるセッションの終了処理とエラー報告のメカニズムを改善するものです。特に、リモートコマンドの終了ステータスやシグナルをより堅牢かつGoの標準ライブラリ(os/exec)に準拠した形で扱うために、Wait()メソッドの戻り値を変更し、*ExitError型を導入しています。

コミット

commit 50c24bf6ec4d05148012cbd010476f7151627424
Author: Gustav Paul <gustav.paul@gmail.com>
Date:   Wed Dec 7 09:58:22 2011 -0500

    exp/ssh: Have Wait() return an *ExitError
    
    I added the clientChan's msg channel to the list of channels that are closed in mainloop when the server sends a channelCloseMsg.
    
    I added an ExitError type that wraps a Waitmsg similar to that of os/exec. I fill ExitStatus with the data returned in the 'exit-status' channel message and Msg with the data returned in the 'exit-signal' channel message.
    
    Instead of having Wait() return on the first 'exit-status'/'exit-signal' I have it return an ExitError containing the status and signal when the clientChan's msg channel is closed.
    
    I added two tests cases to session_test.go that test for exit status 0 (in which case Wait() returns nil) and exit status 1 (in which case Wait() returns an ExitError with ExitStatus 1)
    
    R=dave, agl, rsc, golang-dev, bradfitz
    CC=golang-dev
    https://golang.org/cl/5452051

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

https://github.com/golang/go/commit/50c24bf6ec4d05148012cbd010476f7151627424

元コミット内容

このコミットの目的は、Goの実験的なSSHパッケージにおいて、リモートで実行されたコマンドの終了ステータスやシグナルをより適切に処理し、Goの標準ライブラリであるos/execパッケージの挙動に合わせることです。具体的には、Session.Wait()メソッドが、コマンドが正常終了した場合はnilを返し、異常終了した場合は*ExitError型のエラーを返すように変更されました。これにより、SSHセッションの終了処理がより予測可能で、Goのエラーハンドリングの慣習に沿ったものになります。

変更の背景

以前の実装では、Session.Wait()はリモートコマンドの終了ステータスやシグナルを直接エラー文字列として返していました。例えば、非ゼロの終了ステータスの場合、fmt.Errorf("remote process exited with %d", status)のようなエラーが返されていました。これは、Goの標準的なプロセス実行(os/exec)が提供する*ExitErrorとは異なる挙動であり、一貫性に欠けていました。

また、clientChanmsgチャネルが、サーバーからchannelCloseMsgが送信された際に適切に閉じられていなかったため、mainLoopがブロックする可能性がありました。これは、チャネルのコンシューマがメッセージを処理しない場合に無限にブロックする可能性があるという既存のTODOコメントにも示されています。

このコミットは、これらの問題を解決し、SSHパッケージの堅牢性と使いやすさを向上させることを目的としています。特に、os/execとの互換性を持たせることで、Go開発者がSSH経由でリモートコマンドを扱う際の学習コストを削減し、より自然なエラーハンドリングを可能にします。

前提知識の解説

このコミットを理解するためには、以下の概念に関する知識が必要です。

  • SSHプロトコル(RFC 4254):
    • チャネル (Channels): SSHプロトコルにおける論理的な通信路であり、セッション、X11転送、ポート転送など、様々なサービスを提供するために使用されます。各チャネルは一意のIDを持ち、独立したデータストリームとして機能します。
    • チャネルメッセージ: SSHチャネル上でやり取りされるメッセージには、データの送受信(SSH_MSG_CHANNEL_DATA)、ウィンドウサイズの調整(SSH_MSG_CHANNEL_WINDOW_ADJUST)、チャネルの開閉(SSH_MSG_CHANNEL_OPEN, SSH_MSG_CHANNEL_CLOSE)、EOFの通知(SSH_MSG_CHANNEL_EOF)、チャネル固有のリクエスト(SSH_MSG_CHANNEL_REQUEST)などがあります。
    • exit-statusリクエスト: RFC 4254のセクション 6.10 "Exiting Shells" で定義されており、リモートプロセスが正常に終了した場合の終了コード(整数値)を通知するために使用されます。
    • exit-signalリクエスト: RFC 4254のセクション 6.10 "Exiting Shells" で定義されており、リモートプロセスがシグナルによって終了した場合のシグナル名、コアダンプの有無、エラーメッセージ、言語タグなどを通知するために使用されます。
  • Go言語のチャネル: Goにおける並行処理の基本的な要素であり、ゴルーチン間で値を安全に送受信するための通信メカニズムです。チャネルが閉じられると、それ以降の受信操作はゼロ値を即座に返します。
  • Go言語のエラーハンドリング: Goでは、関数は通常、最後の戻り値としてerrorインターフェースを返します。エラーがない場合はnilを返します。特定の種類のエラーを区別するために、カスタムエラー型を定義し、型アサーション(err.(*MyErrorType))を使用してその型をチェックすることが一般的です。
  • os/exec.ExitError: Goの標準ライブラリos/execパッケージで定義されているエラー型で、外部コマンドが非ゼロの終了ステータスで終了した場合に返されます。この型は、終了ステータスや標準エラー出力などの詳細情報を含みます。

技術的詳細

このコミットの主要な変更点は以下の通りです。

  1. clientChanmsgチャネルのクローズ:

    • ClientConn.mainLoop()関数内で、サーバーからchannelCloseMsgを受信した際に、該当するclientChanmsgチャネルも閉じるように変更されました。これにより、チャネルがブロックする可能性が低減され、Wait()メソッドがチャネルのクローズをトリガーとして終了処理を行えるようになります。
    • 以前はchannelEOFMsgを受信した際にc.getChan(msg.PeersId).msg <- msgとしていましたが、これをc.getChan(msg.PeersId).sendEOF()に変更し、sendEOF関数がmsgChannelEOFパケットを送信するようにしました。これは、EOFメッセージをmsgチャネルに直接送るのではなく、プロトコルメッセージとして送信する方が適切であるためです。
  2. ExitErrorWaitmsg型の導入:

    • session.goExitErrorWaitmsgという新しい型が定義されました。
    • Waitmsgは、リモートコマンドの終了ステータス(status)、シグナル(signal)、メッセージ(msg)、言語タグ(lang)を保持する構造体です。これはos/exec.Waitmsgに似た役割を果たします。
    • ExitErrorWaitmsgをラップする型で、errorインターフェースを実装しています。これにより、Session.Wait()がエラーを返す際に、終了に関する詳細情報を構造化された形で提供できるようになります。
  3. Session.Wait()のロジック変更:

    • Session.wait()関数(Session.Wait()から呼ばれる内部関数)のロジックが大幅に変更されました。
    • 以前はexit-statusまたはexit-signalメッセージを受信した時点で即座にエラーを返すかnilを返していましたが、新しい実装ではs.msgチャネルが閉じられるまでメッセージを処理し続けます。
    • exit-statusメッセージを受信すると、Waitmsgstatusフィールドが更新されます。
    • exit-signalメッセージを受信すると、Waitmsgsignalmsglangフィールドが更新されます。exit-signalメッセージのデータは、RFC 4254で定義されている形式に従ってパースされます。
    • s.msgチャネルが閉じられた後、Waitmsgの内容に基づいて最終的なエラーが決定されます。
      • status0の場合はnilが返されます(正常終了)。
      • status-1exit-statusが送信されなかった場合)かつsignalが空でない場合、status128に設定され、既知のシグナルであればその値が加算されます。これは、シグナルによる終了を反映した終了ステータスを生成するためです。
      • それ以外の場合、*ExitErrorが返されます。
  4. テストケースの追加:

    • session_test.goに、Wait()メソッドの新しい挙動を検証するための複数のテストケースが追加されました。
    • TestExitStatusNonZero: 非ゼロの終了ステータス(15)をテストし、*ExitErrorが返され、ExitStatus()が正しい値を返すことを確認します。
    • TestExitStatusZero: ゼロの終了ステータスをテストし、Wait()nilを返すことを確認します。
    • TestExitSignalAndStatus: シグナルと終了ステータスの両方が送信された場合をテストし、*ExitErrorが両方の情報を含むことを確認します。
    • TestKnownExitSignalOnly: 既知のシグナルのみが送信された場合をテストし、ExitStatus()128 + signal_valueとなることを確認します。
    • TestUnknownExitSignal: 未知のシグナルが送信された場合をテストし、ExitStatus()128となることを確認します。
    • TestExitWithoutStatusOrSignal: 終了ステータスもシグナルも送信されずにチャネルが閉じられた場合をテストし、適切なエラーが返されることを確認します。
    • これらのテストは、テスト用のSSHサーバーハンドラ(exitStatusZeroHandler, exitSignalHandlerなど)を導入し、様々な終了シナリオをシミュレートしています。

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

src/pkg/exp/ssh/client.go

--- a/src/pkg/exp/ssh/client.go
+++ b/src/pkg/exp/ssh/client.go
@@ -231,9 +231,10 @@ func (c *ClientConn) mainLoop() {
 			close(ch.stdin.win)
 			close(ch.stdout.data)
 			close(ch.stderr.data)
+			close(ch.msg) // 追加: channelCloseMsg受信時にch.msgを閉じる
 			c.chanlist.remove(msg.PeersId)
 		case *channelEOFMsg:
-			c.getChan(msg.PeersId).msg <- msg
+			c.getChan(msg.PeersId).sendEOF() // 変更: EOFメッセージの処理
 		case *channelRequestSuccessMsg:
 			c.getChan(msg.PeersId).msg <- msg
 		case *channelRequestFailureMsg:
@@ -335,6 +336,13 @@ func (c *clientChan) waitForChannelOpenResponse() error {
 	return errors.New("unexpected packet")
 }
 
+// sendEOF Sends EOF to the server. RFC 4254 Section 5.3
+func (c *clientChan) sendEOF() error {
+	return c.writePacket(marshal(msgChannelEOF, channelEOFMsg{
+		PeersId: c.peersId,
+	}))
+}
+
 // Close closes the channel. This does not close the underlying connection.
 func (c *clientChan) Close() error {
 	return c.writePacket(marshal(msgChannelClose, channelCloseMsg{

src/pkg/exp/ssh/session.go

--- a/src/pkg/exp/ssh/session.go
+++ b/src/pkg/exp/ssh/session.go
@@ -34,6 +34,20 @@ const (
 	SIGUSR2 Signal = "USR2"
 )
 
+var signals = map[Signal]int{ // 追加: シグナル名と対応する数値のマップ
+	SIGABRT: 6,
+	SIGALRM: 14,
+	SIGFPE:  8,
+	SIGHUP:  1,
+	SIGILL:  4,
+	SIGINT:  2,
+	SIGKILL: 9,
+	SIGPIPE: 13,
+	SIGQUIT: 3,
+	SIGSEGV: 11,
+	SIGTERM: 15,
+}
+
 // A Session represents a connection to a remote command or shell.
 type Session struct {
 	// Stdin specifies the remote process's standard input.
@@ -170,10 +184,17 @@ func (s *Session) Start(cmd string) error {
 	return s.start()
 }
 
-// Run runs cmd on the remote host and waits for it to terminate.
-// Typically, the remote server passes cmd to the shell for
-// interpretation. A Session only accepts one call to Run,
-// Start or Shell.
+// Run runs cmd on the remote host. Typically, the remote
+// server passes cmd to the shell for interpretation.
+// A Session only accepts one call to Run, Start or Shell.
+//
+// The returned error is nil if the command runs, has no problems
+// copying stdin, stdout, and stderr, and exits with a zero exit
+// status.
+//
+// If the command fails to run or doesn't complete successfully, the
+// error is of type *ExitError. Other error types may be
+// returned for I/O problems. // ドキュメント更新
 func (s *Session) Run(cmd string) error {
 	err := s.Start(cmd)
 	if err != nil {
@@ -233,6 +254,14 @@ func (s *Session) start() error {
 }
 
 // Wait waits for the remote command to exit.
+//
+// The returned error is nil if the command runs, has no problems
+// copying stdin, stdout, and stderr, and exits with a zero exit
+// status.
+//
+// If the command fails to run or doesn't complete successfully, the
+// error is of type *ExitError. Other error types may be
+// returned for I/O problems. // ドキュメント更新
 func (s *Session) Wait() error {
 	if !s.started {
 		return errors.New("ssh: session not started")
@@ -255,21 +284,40 @@ func (s *Session) Wait() error {
 }
 
 func (s *Session) wait() error {
-\tfor {
-\t\tswitch msg := (<-s.msg).(type) {
+\twm := Waitmsg{status: -1} // Waitmsgを初期化
+
+\t// Wait for msg channel to be closed before returning.
+\tfor msg := range s.msg { // チャネルが閉じられるまでループ
+\t\tswitch msg := msg.(type) {
 \t\tcase *channelRequestMsg:
-\t\t\t// TODO(dfc) improve this behavior to match os.Waitmsg
 \t\t\tswitch msg.Request {
 \t\t\tcase "exit-status":
 \t\t\t\td := msg.RequestSpecificData
-\t\t\t\tstatus := int(d[0])<<24 | int(d[1])<<16 | int(d[2])<<8 | int(d[3])
-\t\t\t\tif status > 0 {\n-\t\t\t\t\treturn fmt.Errorf("remote process exited with %d", status)\n-\t\t\t\t}\n-\t\t\t\treturn nil
+\t\t\t\twm.status = int(d[0])<<24 | int(d[1])<<16 | int(d[2])<<8 | int(d[3]) // ステータスを保存
 \t\t\tcase "exit-signal":
-\t\t\t\t// TODO(dfc) make a more readable error message
-\t\t\t\treturn fmt.Errorf("%v", msg.RequestSpecificData)
+\t\t\t\tsignal, rest, ok := parseString(msg.RequestSpecificData) // シグナルをパース
+\t\t\t\tif !ok {
+\t\t\t\t\treturn fmt.Errorf("wait: could not parse request data: %v", msg.RequestSpecificData)
+\t\t\t\t}
+\t\t\t\twm.signal = safeString(string(signal))
+
+\t\t\t\t// skip coreDumped bool
+\t\t\t\tif len(rest) == 0 {
+\t\t\t\t\treturn fmt.Errorf("wait: could not parse request data: %v", msg.RequestSpecificData)
+\t\t\t\t}
+\t\t\t\trest = rest[1:]
+
+\t\t\t\terrmsg, rest, ok := parseString(rest) // エラーメッセージをパース
+\t\t\t\tif !ok {
+\t\t\t\t\treturn fmt.Errorf("wait: could not parse request data: %v", msg.RequestSpecificData)
+\t\t\t\t}
+\t\t\t\twm.msg = safeString(string(errmsg))
+
+\t\t\t\tlang, _, ok := parseString(rest) // 言語タグをパース
+\t\t\t\tif !ok {
+\t\t\t\t\treturn fmt.Errorf("wait: could not parse request data: %v", msg.RequestSpecificData)
+\t\t\t\t}
+\t\t\t\twm.lang = safeString(string(lang))
 \t\t\tdefault:
 \t\t\t\treturn fmt.Errorf("wait: unexpected channel request: %v", msg)
 \t\t\t}\
@@ -277,7 +325,20 @@ func (s *Session) wait() error {
 \t\t\treturn fmt.Errorf("wait: unexpected packet %T received: %v", msg, msg)
 \t\t}\n \t}\n-\tpanic("unreachable")
+\tif wm.status == 0 { // 正常終了の場合
+\t\treturn nil
+\t}
+\tif wm.status == -1 { // exit-statusが送信されなかった場合
+\t\t// exit-status was never sent from server
+\t\tif wm.signal == "" { // シグナルもなければエラー
+\t\t\treturn errors.New("wait: remote command exited without exit status or exit signal")
+\t\t}
+\t\twm.status = 128 // シグナル終了の一般的なステータス
+\t\tif _, ok := signals[Signal(wm.signal)]; ok { // 既知のシグナルであれば値を加算
+\t\t\twm.status += signals[Signal(wm.signal)]
+\t\t}
+\t}
+\treturn &ExitError{wm} // ExitErrorを返す
 }\n \n func (s *Session) stdin() error {\
@@ -391,3 +452,46 @@ func (c *ClientConn) NewSession() (*Session, error) {
 \t\tclientChan: ch,\n \t}, nil\n }\n+\n+// An ExitError reports unsuccessful completion of a remote command. // 追加: ExitError型
+type ExitError struct {
+\tWaitmsg
+}\n+\n+func (e *ExitError) Error() string {
+\treturn e.Waitmsg.String()
+}\n+\n+// Waitmsg stores the information about an exited remote command
+// as reported by Wait. // 追加: Waitmsg型
+type Waitmsg struct {
+\tstatus int
+\tsignal string
+\tmsg    string
+\tlang   string
+}\n+\n+// ExitStatus returns the exit status of the remote command.
+func (w Waitmsg) ExitStatus() int {
+\treturn w.status
+}\n+\n+// Signal returns the exit signal of the remote command if
+// it was terminated violently.
+func (w Waitmsg) Signal() string {
+\treturn w.signal
+}\n+\n+// Msg returns the exit message given by the remote command
+func (w Waitmsg) Msg() string {
+\treturn w.msg
+}\n+\n+// Lang returns the language tag. See RFC 3066
+func (w Waitmsg) Lang() string {
+\treturn w.lang
+}\n+\n+func (w Waitmsg) String() string {
+\treturn fmt.Sprintf("Process exited with: %v. Reason was: %v (%v)", w.status, w.msg, w.signal)
+}\n```

### `src/pkg/exp/ssh/session_test.go`

```diff
--- a/src/pkg/exp/ssh/session_test.go
+++ b/src/pkg/exp/ssh/session_test.go
@@ -12,8 +12,10 @@ import (
 	"testing"
 )
 
+type serverType func(*channel) // 追加: テスト用サーバーハンドラの型定義
+
 // dial constructs a new test server and returns a *ClientConn.
-func dial(t *testing.T) *ClientConn {
+func dial(handler serverType, t *testing.T) *ClientConn { // 変更: ハンドラを引数に追加
 	pw := password("tiger")
 	serverConfig.PasswordCallback = func(user, pass string) bool {
 		return user == "testuser" && pass == string(pw)
@@ -50,27 +52,7 @@ func dial(t *testing.T) *ClientConn {
 			\t\t\t\tcontinue
 			\t\t\t}
 			\t\t\tch.Accept()\n-\t\t\t\tgo func() {\n-\t\t\t\t\tdefer ch.Close()\n-\t\t\t\t\t// this string is returned to stdout\n-\t\t\t\t\tshell := NewServerShell(ch, "golang")\n-\t\t\t\t\tshell.ReadLine()\n-\t\t\t\t\ttype exitMsg struct {\n-\t\t\t\t\t\tPeersId   uint32\n-\t\t\t\t\t\tRequest   string\n-\t\t\t\t\t\tWantReply bool\n-\t\t\t\t\t\tStatus    uint32\n-\t\t\t\t\t}\n-\t\t\t\t\t// TODO(dfc) converting to the concrete type should not be\n-\t\t\t\t\t// necessary to send a packet.\n-\t\t\t\t\tmsg := exitMsg{\n-\t\t\t\t\t\tPeersId:   ch.(*channel).theirId,\n-\t\t\t\t\t\tRequest:   "exit-status",\n-\t\t\t\t\t\tWantReply: false,\n-\t\t\t\t\t\tStatus:    0,\n-\t\t\t\t\t}\n-\t\t\t\t\tch.(*channel).serverConn.writePacket(marshal(msgChannelRequest, msg))\n-\t\t\t\t}()\n+\t\t\t\tgo handler(ch.(*channel)) // 変更: ハンドラを呼び出す
 		\t\t}\n \t\t\tt.Log("done")
 	\t}()
@@ -91,7 +73,7 @@ func dial(t *testing.T) *ClientConn {
 
 // Test a simple string is returned to session.Stdout.
 func TestSessionShell(t *testing.T) {
-\tconn := dial(t)
+\tconn := dial(shellHandler, t) // 変更: shellHandlerを使用
 	defer conn.Close()
 	session, err := conn.NewSession()
 	if err != nil {
@@ -116,7 +98,7 @@ func TestSessionShell(t *testing.T) {
 
 // Test a simple string is returned via StdoutPipe.
 func TestSessionStdoutPipe(t *testing.T) {
-\tconn := dial(t)
+\tconn := dial(shellHandler, t) // 変更: shellHandlerを使用
 	defer conn.Close()
 	session, err := conn.NewSession()
 	if err != nil {
@@ -147,3 +129,237 @@ func TestSessionStdoutPipe(t *testing.T) {
 	\t\tt.Fatalf("Remote shell did not return expected string: expected=golang, actual=%s", actual)\n \t}\n }\n+\n+// Test non-0 exit status is returned correctly. // 追加: 非ゼロ終了ステータスのテスト
+func TestExitStatusNonZero(t *testing.T) {
+\tconn := dial(exitStatusNonZeroHandler, t)
+\tdefer conn.Close()
+\tsession, err := conn.NewSession()
+\tif err != nil {
+\t\tt.Fatalf("Unable to request new session: %s", err)
+\t}\n+\tdefer session.Close()
+\tif err := session.Shell(); err != nil {
+\t\tt.Fatalf("Unable to execute command: %s", err)
+\t}\n+\terr = session.Wait()
+\tif err == nil {
+\t\tt.Fatalf("expected command to fail but it didn't")
+\t}\n+\te, ok := err.(*ExitError)
+\tif !ok {
+\t\tt.Fatalf("expected *ExitError but got %T", err)
+\t}\n+\tif e.ExitStatus() != 15 {
+\t\tt.Fatalf("expected command to exit with 15 but got %s", e.ExitStatus())
+\t}\n+}\n+\n+// Test 0 exit status is returned correctly. // 追加: ゼロ終了ステータスのテスト
+func TestExitStatusZero(t *testing.T) {
+\tconn := dial(exitStatusZeroHandler, t)
+\tdefer conn.Close()
+\tsession, err := conn.NewSession()
+\tif err != nil {
+\t\tt.Fatalf("Unable to request new session: %s", err)
+\t}\n+\tdefer session.Close()
+\n+\tif err := session.Shell(); err != nil {
+\t\tt.Fatalf("Unable to execute command: %s", err)
+\t}\n+\terr = session.Wait()
+\tif err != nil {
+\t\tt.Fatalf("expected nil but got %s", err)
+\t}\n+}\n+\n+// Test exit signal and status are both returned correctly. // 追加: シグナルとステータスの両方のテスト
+func TestExitSignalAndStatus(t *testing.T) {
+\tconn := dial(exitSignalAndStatusHandler, t)
+\tdefer conn.Close()
+\tsession, err := conn.NewSession()
+\tif err != nil {
+\t\tt.Fatalf("Unable to request new session: %s", err)
+\t}\n+\tdefer session.Close()
+\tif err := session.Shell(); err != nil {
+\t\tt.Fatalf("Unable to execute command: %s", err)
+\t}\n+\terr = session.Wait()
+\tif err == nil {
+\t\tt.Fatalf("expected command to fail but it didn't")
+\t}\n+\te, ok := err.(*ExitError)
+\tif !ok {
+\t\tt.Fatalf("expected *ExitError but got %T", err)
+\t}\n+\tif e.Signal() != "TERM" || e.ExitStatus() != 15 {
+\t\tt.Fatalf("expected command to exit with signal TERM and status 15 but got signal %s and status %v", e.Signal(), e.ExitStatus())
+\t}\n+}\n+\n+// Test exit signal and status are both returned correctly. // 追加: 既知のシグナルのみのテスト
+func TestKnownExitSignalOnly(t *testing.T) {
+\tconn := dial(exitSignalHandler, t)
+\tdefer conn.Close()
+\tsession, err := conn.NewSession()
+\tif err != nil {
+\t\tt.Fatalf("Unable to request new session: %s", err)
+\t}\n+\tdefer session.Close()
+\tif err := session.Shell(); err != nil {
+\t\tt.Fatalf("Unable to execute command: %s", err)
+\t}\n+\terr = session.Wait()
+\tif err == nil {
+\t\tt.Fatalf("expected command to fail but it didn't")
+\t}\n+\te, ok := err.(*ExitError)
+\tif !ok {
+\t\tt.Fatalf("expected *ExitError but got %T", err)
+\t}\n+\tif e.Signal() != "TERM" || e.ExitStatus() != 143 {
+\t\tt.Fatalf("expected command to exit with signal TERM and status 143 but got signal %s and status %v", e.Signal(), e.ExitStatus())
+\t}\n+}\n+\n+// Test exit signal and status are both returned correctly. // 追加: 未知のシグナルのみのテスト
+func TestUnknownExitSignal(t *testing.T) {
+\tconn := dial(exitSignalUnknownHandler, t)
+\tdefer conn.Close()
+\tsession, err := conn.NewSession()
+\tif err != nil {
+\t\tt.Fatalf("Unable to request new session: %s", err)
+\t}\n+\tdefer session.Close()
+\tif err := session.Shell(); err != nil {
+\t\tt.Fatalf("Unable to execute command: %s", err)
+\t}\n+\terr = session.Wait()
+\tif err == nil {
+\t\tt.Fatalf("expected command to fail but it didn't")
+\t}\n+\te, ok := err.(*ExitError)
+\tif !ok {
+\t\tt.Fatalf("expected *ExitError but got %T", err)
+\t}\n+\tif e.Signal() != "SYS" || e.ExitStatus() != 128 {
+\t\tt.Fatalf("expected command to exit with signal SYS and status 128 but got signal %s and status %v", e.Signal(), e.ExitStatus())
+\t}\n+}\n+\n+// Test WaitMsg is not returned if the channel closes abruptly. // 追加: ステータス/シグナルなしでチャネルが閉じた場合のテスト
+func TestExitWithoutStatusOrSignal(t *testing.T) {
+\tconn := dial(exitWithoutSignalOrStatus, t)
+\tdefer conn.Close()
+\tsession, err := conn.NewSession()
+\tif err != nil {
+\t\tt.Fatalf("Unable to request new session: %s", err)
+\t}\n+\tdefer session.Close()
+\tif err := session.Shell(); err != nil {
+\t\tt.Fatalf("Unable to execute command: %s", err)
+\t}\n+\terr = session.Wait()
+\tif err == nil {
+\t\tt.Fatalf("expected command to fail but it didn't")
+\t}\n+\t_, ok := err.(*ExitError)
+\tif ok {
+\t\t// you can't actually test for errors.errorString
+\t\t// because it's not exported.
+\t\tt.Fatalf("expected *errorString but got %T", err)
+\t}\n+}\n+\n+type exitStatusMsg struct { // 追加: exit-statusメッセージ構造体
+\tPeersId   uint32
+\tRequest   string
+\tWantReply bool
+\tStatus    uint32
+}\n+\n+type exitSignalMsg struct { // 追加: exit-signalメッセージ構造体
+\tPeersId    uint32
+\tRequest    string
+\tWantReply  bool
+\tSignal     string
+\tCoreDumped bool
+\tErrmsg     string
+\tLang       string
+}\n+\n+func exitStatusZeroHandler(ch *channel) { // 追加: ゼロ終了ステータスハンドラ
+\tdefer ch.Close()
+\t// this string is returned to stdout
+\tshell := NewServerShell(ch, "> ")
+\tshell.ReadLine()
+\tsendStatus(0, ch)
+}\n+\n+func exitStatusNonZeroHandler(ch *channel) { // 追加: 非ゼロ終了ステータスハンドラ
+\tdefer ch.Close()
+\tshell := NewServerShell(ch, "> ")
+\tshell.ReadLine()
+\tsendStatus(15, ch)
+}\n+\n+func exitSignalAndStatusHandler(ch *channel) { // 追加: シグナルとステータスハンドラ
+\tdefer ch.Close()
+\tshell := NewServerShell(ch, "> ")
+\tshell.ReadLine()
+\tsendStatus(15, ch)
+\tsendSignal("TERM", ch)
+}\n+\n+func exitSignalHandler(ch *channel) { // 追加: シグナルのみハンドラ
+\tdefer ch.Close()
+\tshell := NewServerShell(ch, "> ")
+\tshell.ReadLine()
+\tsendSignal("TERM", ch)
+}\n+\n+func exitSignalUnknownHandler(ch *channel) { // 追加: 未知のシグナルハンドラ
+\tdefer ch.Close()
+\tshell := NewServerShell(ch, "> ")
+\tshell.ReadLine()
+\tsendSignal("SYS", ch)
+}\n+\n+func exitWithoutSignalOrStatus(ch *channel) { // 追加: ステータス/シグナルなしハンドラ
+\tdefer ch.Close()
+\tshell := NewServerShell(ch, "> ")
+\tshell.ReadLine()
+}\n+\n+func shellHandler(ch *channel) { // 追加: シェルハンドラ
+\tdefer ch.Close()
+\t// this string is returned to stdout
+\tshell := NewServerShell(ch, "golang")
+\tshell.ReadLine()
+\tsendStatus(0, ch)
+}\n+\n+func sendStatus(status uint32, ch *channel) { // 追加: ステータス送信ヘルパー
+\tmsg := exitStatusMsg{
+\t\tPeersId:   ch.theirId,
+\t\tRequest:   "exit-status",
+\t\tWantReply: false,
+\t\tStatus:    status,
+\t}\n+\tch.serverConn.writePacket(marshal(msgChannelRequest, msg))
+}\n+\n+func sendSignal(signal string, ch *channel) { // 追加: シグナル送信ヘルパー
+\tsig := exitSignalMsg{
+\t\tPeersId:    ch.theirId,
+\t\tRequest:    "exit-signal",
+\t\tWantReply:  false,
+\t\tSignal:     signal,
+\t\tCoreDumped: false,
+\t\tErrmsg:     "Process terminated",
+\t\tLang:       "en-GB-oed",
+\t}\n+\tch.serverConn.writePacket(marshal(msgChannelRequest, sig))
+}\n```

## コアとなるコードの解説

このコミットの核心は、`Session.Wait()`メソッドの挙動を、リモートコマンドの終了ステータスやシグナルをよりGoらしい方法で報告するように変更した点にあります。

1.  **`client.go`の変更**:
    *   `mainLoop`における`channelCloseMsg`の処理で、`ch.msg`チャネルを明示的に閉じるようにしたことで、`Session.wait()`がこのチャネルのクローズを検知して終了処理を開始できるようになりました。これにより、`Wait()`が無限にブロックする可能性が解消されます。
    *   `channelEOFMsg`の処理が`sendEOF()`関数に置き換えられたのは、EOFメッセージを内部チャネルに送るのではなく、SSHプロトコルに従って`SSH_MSG_CHANNEL_EOF`パケットとして送信することが正しい挙動であるためです。

2.  **`session.go`の変更**:
    *   **`signals`マップ**: POSIXシグナル名とその数値表現をマッピングする`signals`マップが導入されました。これは、`exit-signal`メッセージでシグナル名が提供された場合に、対応する終了ステータス(通常は`128 + signal_value`)を計算するために使用されます。
    *   **`Run`と`Wait`のドキュメント更新**: これらの関数のドキュメントが更新され、`*ExitError`が返される可能性があることが明記されました。これにより、ユーザーはこれらの関数が返すエラーの型を期待できるようになります。
    *   **`Waitmsg`構造体**: リモートコマンドの終了に関する詳細情報(ステータス、シグナル、メッセージ、言語)をカプセル化するための新しい構造体です。これは、`os/exec`パッケージの`Waitmsg`と同様の目的を持ちます。
    *   **`ExitError`構造体**: `Waitmsg`を埋め込み、`error`インターフェースを実装するカスタムエラー型です。これにより、`Session.Wait()`が非ゼロの終了ステータスやシグナルによる終了を報告する際に、単なる文字列エラーではなく、構造化されたエラーオブジェクトを返すことができます。ユーザーは型アサーション(`err.(*ssh.ExitError)`)を使用して、エラーが`ExitError`であるかどうかをチェックし、詳細情報にアクセスできます。
    *   **`Session.wait()`のロジック**:
        *   `for msg := range s.msg`ループは、`s.msg`チャネルが閉じられるまで、受信したメッセージを処理し続けます。これにより、`exit-status`と`exit-signal`の両方の情報が利用可能になった場合に、それらを統合して`Waitmsg`に格納できます。
        *   `exit-status`メッセージを受信すると、そのデータから終了ステータスを抽出し、`wm.status`に設定します。
        *   `exit-signal`メッセージを受信すると、そのデータからシグナル名、エラーメッセージ、言語タグをパースし、それぞれ`wm.signal`, `wm.msg`, `wm.lang`に設定します。`parseString`関数は、SSHプロトコルで文字列がどのようにエンコードされているかを処理します。
        *   ループが終了(`s.msg`チャネルがクローズ)した後、`wm.status`の値に基づいて最終的な戻り値が決定されます。
            *   `wm.status == 0`の場合、コマンドは正常終了と見なされ、`nil`が返されます。
            *   `wm.status == -1`(`exit-status`メッセージが受信されなかったことを示す初期値)の場合、シグナルによる終了が試みられます。`wm.signal`が空でなければ、`wm.status`は`128`に設定され、`signals`マップにシグナル名が存在すれば、そのシグナル値が加算されます。これは、Unix系システムにおけるシグナル終了の慣習的な終了コードを模倣しています。
            *   それ以外の場合(非ゼロの終了ステータスまたはシグナルによる終了)、`&ExitError{wm}`が返され、呼び出し元は詳細な終了情報を取得できます。

3.  **`session_test.go`の変更**:
    *   テストフレームワークが拡張され、`dial`関数が`serverType`ハンドラを受け取るようになりました。これにより、テストケースごとに異なるSSHサーバーの挙動(特定の終了ステータスやシグナルを送信するなど)を簡単にシミュレートできるようになりました。
    *   追加された多数のテストケースは、`Session.Wait()`の新しい挙動が様々なシナリオ(正常終了、非ゼロ終了ステータス、シグナル終了、ステータスとシグナルの両方、ステータスもシグナルもない場合など)で正しく機能することを保証します。これにより、変更の堅牢性が高められています。

これらの変更により、GoのSSHパッケージは、リモートコマンドの終了処理において、より標準的で予測可能なエラーハンドリングを提供できるようになりました。

## 関連リンク

*   [RFC 4254 - The Secure Shell (SSH) Connection Protocol](https://datatracker.ietf.org/doc/html/rfc4254)
    *   特にセクション 6.10 "Exiting Shells" は、`exit-status`と`exit-signal`リクエストについて詳述しています。
*   [Go Documentation: os/exec package](https://pkg.go.dev/os/exec)
    *   `ExitError`と`Waitmsg`の概念について理解を深めることができます。

## 参考にした情報源リンク

*   上記のGitHubコミットページ
*   Go言語の公式ドキュメント
*   RFC 4254 (The Secure Shell (SSH) Connection Protocol)
*   Go言語の`os/exec`パッケージのソースコードとドキュメントI have provided the detailed explanation in Markdown format as requested. I have covered all the sections specified in the prompt.
```markdown
# [インデックス 10640] ファイルの概要

このコミットは、Go言語の実験的なSSHパッケージ(`exp/ssh`)におけるセッションの終了処理とエラー報告のメカニズムを改善するものです。特に、リモートコマンドの終了ステータスやシグナルをより堅牢かつGoの標準ライブラリ(`os/exec`)に準拠した形で扱うために、`Wait()`メソッドの戻り値を変更し、`*ExitError`型を導入しています。

## コミット

commit 50c24bf6ec4d05148012cbd010476f7151627424 Author: Gustav Paul gustav.paul@gmail.com Date: Wed Dec 7 09:58:22 2011 -0500

exp/ssh: Have Wait() return an *ExitError

I added the clientChan's msg channel to the list of channels that are closed in mainloop when the server sends a channelCloseMsg.

I added an ExitError type that wraps a Waitmsg similar to that of os/exec. I fill ExitStatus with the data returned in the 'exit-status' channel message and Msg with the data returned in the 'exit-signal' channel message.

Instead of having Wait() return on the first 'exit-status'/'exit-signal' I have it return an ExitError containing the status and signal when the clientChan's msg channel is closed.

I added two tests cases to session_test.go that test for exit status 0 (in which case Wait() returns nil) and exit status 1 (in which case Wait() returns an ExitError with ExitStatus 1)

R=dave, agl, rsc, golang-dev, bradfitz
CC=golang-dev
https://golang.org/cl/5452051

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

[https://github.com/golang/go/commit/50c24bf6ec4d05148012cbd010476f7151627424](https://github.com/golang/go/commit/50c24bf6ec4d05148012cbd010476f7151627424)

## 元コミット内容

このコミットの目的は、Goの実験的なSSHパッケージにおいて、リモートで実行されたコマンドの終了ステータスやシグナルをより適切に処理し、Goの標準ライブラリである`os/exec`パッケージの挙動に合わせることです。具体的には、`Session.Wait()`メソッドが、コマンドが正常終了した場合は`nil`を返し、異常終了した場合は`*ExitError`型のエラーを返すように変更されました。これにより、SSHセッションの終了処理がより予測可能で、Goのエラーハンドリングの慣習に沿ったものになります。

## 変更の背景

以前の実装では、`Session.Wait()`はリモートコマンドの終了ステータスやシグナルを直接エラー文字列として返していました。例えば、非ゼロの終了ステータスの場合、`fmt.Errorf("remote process exited with %d", status)`のようなエラーが返されていました。これは、Goの標準的なプロセス実行(`os/exec`)が提供する`*ExitError`とは異なる挙動であり、一貫性に欠けていました。

また、`clientChan`の`msg`チャネルが、サーバーから`channelCloseMsg`が送信された際に適切に閉じられていなかったため、`mainLoop`がブロックする可能性がありました。これは、チャネルのコンシューマがメッセージを処理しない場合に無限にブロックする可能性があるという既存の`TODO`コメントにも示されています。

このコミットは、これらの問題を解決し、SSHパッケージの堅牢性と使いやすさを向上させることを目的としています。特に、`os/exec`との互換性を持たせることで、Go開発者がSSH経由でリモートコマンドを扱う際の学習コストを削減し、より自然なエラーハンドリングを可能にします。

## 前提知識の解説

このコミットを理解するためには、以下の概念に関する知識が必要です。

*   **SSHプロトコル(RFC 4254)**:
    *   **チャネル (Channels)**: SSHプロトコルにおける論理的な通信路であり、セッション、X11転送、ポート転送など、様々なサービスを提供するために使用されます。各チャネルは一意のIDを持ち、独立したデータストリームとして機能します。
    *   **チャネルメッセージ**: SSHチャネル上でやり取りされるメッセージには、データの送受信(`SSH_MSG_CHANNEL_DATA`)、ウィンドウサイズの調整(`SSH_MSG_CHANNEL_WINDOW_ADJUST`)、チャネルの開閉(`SSH_MSG_CHANNEL_OPEN`, `SSH_MSG_CHANNEL_CLOSE`)、EOFの通知(`SSH_MSG_CHANNEL_EOF`)、チャネル固有のリクエスト(`SSH_MSG_CHANNEL_REQUEST`)などがあります。
    *   **`exit-status`リクエスト**: RFC 4254のセクション 6.10 "Exiting Shells" で定義されており、リモートプロセスが正常に終了した場合の終了コード(整数値)を通知するために使用されます。
    *   **`exit-signal`リクエスト**: RFC 4254のセクション 6.10 "Exiting Shells" で定義されており、リモートプロセスがシグナルによって終了した場合のシグナル名、コアダンプの有無、エラーメッセージ、言語タグなどを通知するために使用されます。
*   **Go言語のチャネル**: Goにおける並行処理の基本的な要素であり、ゴルーチン間で値を安全に送受信するための通信メカニズムです。チャネルが閉じられると、それ以降の受信操作はゼロ値を即座に返します。
*   **Go言語のエラーハンドリング**: Goでは、関数は通常、最後の戻り値として`error`インターフェースを返します。エラーがない場合は`nil`を返します。特定の種類のエラーを区別するために、カスタムエラー型を定義し、型アサーション(`err.(*MyErrorType)`)を使用してその型をチェックすることが一般的です。
*   **`os/exec.ExitError`**: Goの標準ライブラリ`os/exec`パッケージで定義されているエラー型で、外部コマンドが非ゼロの終了ステータスで終了した場合に返されます。この型は、終了ステータスや標準エラー出力などの詳細情報を含みます。

## 技術的詳細

このコミットの主要な変更点は以下の通りです。

1.  **`clientChan`の`msg`チャネルのクローズ**:
    *   `ClientConn.mainLoop()`関数内で、サーバーから`channelCloseMsg`を受信した際に、該当する`clientChan`の`msg`チャネルも閉じるように変更されました。これにより、チャネルがブロックする可能性が低減され、`Wait()`メソッドがチャネルのクローズをトリガーとして終了処理を行えるようになります。
    *   以前は`channelEOFMsg`を受信した際に`c.getChan(msg.PeersId).msg <- msg`としていましたが、これを`c.getChan(msg.PeersId).sendEOF()`に変更し、`sendEOF`関数が`msgChannelEOF`パケットを送信するようにしました。これは、EOFメッセージを`msg`チャネルに直接送るのではなく、プロトコルメッセージとして送信する方が適切であるためです。

2.  **`ExitError`と`Waitmsg`型の導入**:
    *   `session.go`に`ExitError`と`Waitmsg`という新しい型が定義されました。
    *   `Waitmsg`は、リモートコマンドの終了ステータス(`status`)、シグナル(`signal`)、メッセージ(`msg`)、言語タグ(`lang`)を保持する構造体です。これは`os/exec.Waitmsg`に似た役割を果たします。
    *   `ExitError`は`Waitmsg`をラップする型で、`error`インターフェースを実装しています。これにより、`Session.Wait()`がエラーを返す際に、終了に関する詳細情報を構造化された形で提供できるようになります。

3.  **`Session.Wait()`のロジック変更**:
    *   `Session.wait()`関数(`Session.Wait()`から呼ばれる内部関数)のロジックが大幅に変更されました。
    *   以前は`exit-status`または`exit-signal`メッセージを受信した時点で即座にエラーを返すか`nil`を返していましたが、新しい実装では`s.msg`チャネルが閉じられるまでメッセージを処理し続けます。
    *   `exit-status`メッセージを受信すると、`Waitmsg`の`status`フィールドが更新されます。
    *   `exit-signal`メッセージを受信すると、`Waitmsg`の`signal`、`msg`、`lang`フィールドが更新されます。`exit-signal`メッセージのデータは、RFC 4254で定義されている形式に従ってパースされます。
    *   `s.msg`チャネルが閉じられた後、`Waitmsg`の内容に基づいて最終的なエラーが決定されます。
        *   `status`が`0`の場合は`nil`が返されます(正常終了)。
        *   `status`が`-1`(`exit-status`が送信されなかった場合)かつ`signal`が空でない場合、`status`は`128`に設定され、既知のシグナルであればその値が加算されます。これは、シグナルによる終了を反映した終了ステータスを生成するためです。
        *   それ以外の場合、`*ExitError`が返されます。

4.  **テストケースの追加**:
    *   `session_test.go`に、`Wait()`メソッドの新しい挙動を検証するための複数のテストケースが追加されました。
    *   `TestExitStatusNonZero`: 非ゼロの終了ステータス(15)をテストし、`*ExitError`が返され、`ExitStatus()`が正しい値を返すことを確認します。
    *   `TestExitStatusZero`: ゼロの終了ステータスをテストし、`Wait()`が`nil`を返すことを確認します。
    *   `TestExitSignalAndStatus`: シグナルと終了ステータスの両方が送信された場合をテストし、`*ExitError`が両方の情報を含むことを確認します。
    *   `TestKnownExitSignalOnly`: 既知のシグナルのみが送信された場合をテストし、`ExitStatus()`が`128 + signal_value`となることを確認します。
    *   `TestUnknownExitSignal`: 未知のシグナルが送信された場合をテストし、`ExitStatus()`が`128`となることを確認します。
    *   `TestExitWithoutStatusOrSignal`: 終了ステータスもシグナルも送信されずにチャネルが閉じられた場合をテストし、適切なエラーが返されることを確認します。
    *   これらのテストは、テスト用のSSHサーバーハンドラ(`exitStatusZeroHandler`, `exitSignalHandler`など)を導入し、様々な終了シナリオをシミュレートしています。

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

### `src/pkg/exp/ssh/client.go`

```diff
--- a/src/pkg/exp/ssh/client.go
+++ b/src/pkg/exp/ssh/client.go
@@ -231,9 +231,10 @@ func (c *ClientConn) mainLoop() {
 			close(ch.stdin.win)
 			close(ch.stdout.data)
 			close(ch.stderr.data)
+			close(ch.msg) // 追加: channelCloseMsg受信時にch.msgを閉じる
 			c.chanlist.remove(msg.PeersId)
 		case *channelEOFMsg:
-			c.getChan(msg.PeersId).msg <- msg
+			c.getChan(msg.PeersId).sendEOF() // 変更: EOFメッセージの処理
 		case *channelRequestSuccessMsg:
 			c.getChan(msg.PeersId).msg <- msg
 		case *channelRequestFailureMsg:
@@ -335,6 +336,13 @@ func (c *clientChan) waitForChannelOpenResponse() error {
 	return errors.New("unexpected packet")
 }
 
+// sendEOF Sends EOF to the server. RFC 4254 Section 5.3
+func (c *clientChan) sendEOF() error {
+	return c.writePacket(marshal(msgChannelEOF, channelEOFMsg{
+		PeersId: c.peersId,
+	}))
+}
+
 // Close closes the channel. This does not close the underlying connection.
 func (c *clientChan) Close() error {
 	return c.writePacket(marshal(msgChannelClose, channelCloseMsg{

src/pkg/exp/ssh/session.go

--- a/src/pkg/exp/ssh/session.go
+++ b/src/pkg/exp/ssh/session.go
@@ -34,6 +34,20 @@ const (
 	SIGUSR2 Signal = "USR2"
 )
 
+var signals = map[Signal]int{ // 追加: シグナル名と対応する数値のマップ
+	SIGABRT: 6,
+	SIGALRM: 14,
+	SIGFPE:  8,
+	SIGHUP:  1,
+	SIGILL:  4,
+	SIGINT:  2,
+	SIGKILL: 9,
+	SIGPIPE: 13,
+	SIGQUIT: 3,
+	SIGSEGV: 11,
+	SIGTERM: 15,
+}
+
 // A Session represents a connection to a remote command or shell.
 type Session struct {
 	// Stdin specifies the remote process's standard input.
@@ -170,10 +184,17 @@ func (s *Session) Start(cmd string) error {
 	return s.start()
 }
 
-// Run runs cmd on the remote host and waits for it to terminate.
-// Typically, the remote server passes cmd to the shell for
-// interpretation. A Session only accepts one call to Run,
-// Start or Shell.
+// Run runs cmd on the remote host. Typically, the remote
+// server passes cmd to the shell for interpretation.
+// A Session only accepts one call to Run, Start or Shell.
+//
+// The returned error is nil if the command runs, has no problems
+// copying stdin, stdout, and stderr, and exits with a zero exit
+// status.
+//
+// If the command fails to run or doesn't complete successfully, the
+// error is of type *ExitError. Other error types may be
+// returned for I/O problems. // ドキュメント更新
 func (s *Session) Run(cmd string) error {
 	err := s.Start(cmd)
 	if err != nil {
@@ -233,6 +254,14 @@ func (s *Session) start() error {
 }
 
 // Wait waits for the remote command to exit.
+//
+// The returned error is nil if the command runs, has no problems
+// copying stdin, stdout, and stderr, and exits with a zero exit
+// status.
+//
+// If the command fails to run or doesn't complete successfully, the
+// error is of type *ExitError. Other error types may be
+// returned for I/O problems. // ドキュメント更新
 func (s *Session) Wait() error {
 	if !s.started {
 		return errors.New("ssh: session not started")
@@ -255,21 +284,40 @@ func (s *Session) Wait() error {
 }
 
 func (s *Session) wait() error {
-\tfor {
-\t\tswitch msg := (<-s.msg).(type) {
+\twm := Waitmsg{status: -1} // Waitmsgを初期化
+
+\t// Wait for msg channel to be closed before returning.
+\tfor msg := range s.msg { // チャネルが閉じられるまでループ
+\t\tswitch msg := msg.(type) {
 \t\tcase *channelRequestMsg:
-\t\t\t// TODO(dfc) improve this behavior to match os.Waitmsg
 \t\t\tswitch msg.Request {
 \t\t\tcase "exit-status":
 \t\t\t\td := msg.RequestSpecificData
-\t\t\t\tstatus := int(d[0])<<24 | int(d[1])<<16 | int(d[2])<<8 | int(d[3])
-\t\t\t\tif status > 0 {\n-\t\t\t\t\treturn fmt.Errorf("remote process exited with %d", status)\n-\t\t\t\t}\n-\t\t\t\treturn nil
+\t\t\t\twm.status = int(d[0])<<24 | int(d[1])<<16 | int(d[2])<<8 | int(d[3]) // ステータスを保存
 \t\t\tcase "exit-signal":
-\t\t\t\t// TODO(dfc) make a more readable error message
-\t\t\t\treturn fmt.Errorf("%v", msg.RequestSpecificData)
+\t\t\t\tsignal, rest, ok := parseString(msg.RequestSpecificData) // シグナルをパース
+\t\t\t\tif !ok {
+\t\t\t\t\treturn fmt.Errorf("wait: could not parse request data: %v", msg.RequestSpecificData)
+\t\t\t\t}
+\t\t\t\twm.signal = safeString(string(signal))
+
+\t\t\t\t// skip coreDumped bool
+\t\t\t\tif len(rest) == 0 {
+\t\t\t\t\treturn fmt.Errorf("wait: could not parse request data: %v", msg.RequestSpecificData)
+\t\t\t\t}
+\t\t\t\trest = rest[1:]
+
+\t\t\t\terrmsg, rest, ok := parseString(rest) // エラーメッセージをパース
+\t\t\t\tif !ok {
+\t\t\t\t\treturn fmt.Errorf("wait: could not parse request data: %v", msg.RequestSpecificData)
+\t\t\t\t}
+\t\t\t\twm.msg = safeString(string(errmsg))
+
+\t\t\t\tlang, _, ok := parseString(rest) // 言語タグをパース
+\t\t\t\tif !ok {
+\t\t\t\t\treturn fmt.Errorf("wait: could not parse request data: %v", msg.RequestSpecificData)
+\t\t\t\t}
+\t\t\t\twm.lang = safeString(string(lang))
 \t\t\tdefault:
 \t\t\t\treturn fmt.Errorf("wait: unexpected channel request: %v", msg)
 \t\t\t}\
@@ -277,7 +325,20 @@ func (s *Session) wait() error {
 \t\t\treturn fmt.Errorf("wait: unexpected packet %T received: %v", msg, msg)
 \t\t}\n \t}\n-\tpanic("unreachable")
+\tif wm.status == 0 { // 正常終了の場合
+\t\treturn nil
+\t}
+\tif wm.status == -1 { // exit-statusが送信されなかった場合
+\t\t// exit-status was never sent from server
+\t\tif wm.signal == "" { // シグナルもなければエラー
+\t\t\treturn errors.New("wait: remote command exited without exit status or exit signal")
+\t\t}
+\t\twm.status = 128 // シグナル終了の一般的なステータス
+\t\tif _, ok := signals[Signal(wm.signal)]; ok { // 既知のシグナルであれば値を加算
+\t\t\twm.status += signals[Signal(wm.signal)]
+\t\t}
+\t}
+\treturn &ExitError{wm} // ExitErrorを返す
 }\n \n func (s *Session) stdin() error {\
@@ -391,3 +452,46 @@ func (c *ClientConn) NewSession() (*Session, error) {
 \t\tclientChan: ch,\n \t}, nil\n }\n+\n+// An ExitError reports unsuccessful completion of a remote command. // 追加: ExitError型
+type ExitError struct {
+\tWaitmsg
+}\n+\n+func (e *ExitError) Error() string {
+\treturn e.Waitmsg.String()
+}\n+\n+// Waitmsg stores the information about an exited remote command
+// as reported by Wait. // 追加: Waitmsg型
+type Waitmsg struct {
+\tstatus int
+\tsignal string
+\tmsg    string
+\tlang   string
+}\n+\n+// ExitStatus returns the exit status of the remote command.
+func (w Waitmsg) ExitStatus() int {
+\treturn w.status
+}\n+\n+// Signal returns the exit signal of the remote command if
+// it was terminated violently.
+func (w Waitmsg) Signal() string {
+\treturn w.signal
+}\n+\n+// Msg returns the exit message given by the remote command
+func (w Waitmsg) Msg() string {
+\treturn w.msg
+}\n+\n+// Lang returns the language tag. See RFC 3066
+func (w Waitmsg) Lang() string {
+\treturn w.lang
+}\n+\n+func (w Waitmsg) String() string {
+\treturn fmt.Sprintf("Process exited with: %v. Reason was: %v (%v)", w.status, w.msg, w.signal)
+}\n```

### `src/pkg/exp/ssh/session_test.go`

```diff
--- a/src/pkg/exp/ssh/session_test.go
+++ b/src/pkg/exp/ssh/session_test.go
@@ -12,8 +12,10 @@ import (
 	"testing"
 )
 
+type serverType func(*channel) // 追加: テスト用サーバーハンドラの型定義
+
 // dial constructs a new test server and returns a *ClientConn.
-func dial(t *testing.T) *ClientConn {
+func dial(handler serverType, t *testing.T) *ClientConn { // 変更: ハンドラを引数に追加
 	pw := password("tiger")
 	serverConfig.PasswordCallback = func(user, pass string) bool {
 		return user == "testuser" && pass == string(pw)
@@ -50,27 +52,7 @@ func dial(t *testing.T) *ClientConn {
 			\t\t\t\tcontinue
 			\t\t\t}
 			\t\t\tch.Accept()\n-\t\t\t\tgo func() {\n-\t\t\t\t\tdefer ch.Close()\n-\t\t\t\t\t// this string is returned to stdout\n-\t\t\t\t\tshell := NewServerShell(ch, "golang")\n-\t\t\t\t\tshell.ReadLine()\n-\t\t\t\t\ttype exitMsg struct {\n-\t\t\t\t\t\tPeersId   uint32\n-\t\t\t\t\t\tRequest   string\n-\t\t\t\t\t\tWantReply bool\n-\t\t\t\t\t\tStatus    uint32\n-\t\t\t\t\t}\n-\t\t\t\t\t// TODO(dfc) converting to the concrete type should not be\n-\t\t\t\t\t// necessary to send a packet.\n-\t\t\t\t\tmsg := exitMsg{\n-\t\t\t\t\t\tPeersId:   ch.(*channel).theirId,\n-\t\t\t\t\t\tRequest:   "exit-status",\n-\t\t\t\t\t\tWantReply: false,\n-\t\t\t\t\t\tStatus:    0,\n-\t\t\t\t\t}\n-\t\t\t\t\tch.(*channel).serverConn.writePacket(marshal(msgChannelRequest, msg))\n-\t\t\t\t}()\n+\t\t\t\tgo handler(ch.(*channel)) // 変更: ハンドラを呼び出す
 		\t\t}\n \t\t\tt.Log("done")
 	\t}()
@@ -91,7 +73,7 @@ func dial(t *testing.T) *ClientConn {
 
 // Test a simple string is returned to session.Stdout.
 func TestSessionShell(t *testing.T) {
-\tconn := dial(t)
+\tconn := dial(shellHandler, t) // 変更: shellHandlerを使用
 	defer conn.Close()
 	session, err := conn.NewSession()
 	if err != nil {
@@ -116,7 +98,7 @@ func TestSessionShell(t *testing.T) {
 
 // Test a simple string is returned via StdoutPipe.
 func TestSessionStdoutPipe(t *testing.T) {
-\tconn := dial(t)
+\tconn := dial(shellHandler, t) // 変更: shellHandlerを使用
 	defer conn.Close()
 	session, err := conn.NewSession()
 	if err != nil {
@@ -147,3 +129,237 @@ func TestSessionStdoutPipe(t *testing.T) {
 	\t\tt.Fatalf("Remote shell did not return expected string: expected=golang, actual=%s", actual)\n \t}\n }\n+\n+// Test non-0 exit status is returned correctly. // 追加: 非ゼロ終了ステータスのテスト
+func TestExitStatusNonZero(t *testing.T) {
+\tconn := dial(exitStatusNonZeroHandler, t)
+\tdefer conn.Close()
+\tsession, err := conn.NewSession()
+\tif err != nil {
+\t\tt.Fatalf("Unable to request new session: %s", err)
+\t}\n+\tdefer session.Close()
+\tif err := session.Shell(); err != nil {
+\t\tt.Fatalf("Unable to execute command: %s", err)
+\t}\n+\terr = session.Wait()
+\tif err == nil {
+\t\tt.Fatalf("expected command to fail but it didn't")
+\t}\n+\te, ok := err.(*ExitError)
+\tif !ok {
+\t\tt.Fatalf("expected *ExitError but got %T", err)
+\t}\n+\tif e.ExitStatus() != 15 {
+\t\tt.Fatalf("expected command to exit with 15 but got %s", e.ExitStatus())
+\t}\n+}\n+\n+// Test 0 exit status is returned correctly. // 追加: ゼロ終了ステータスのテスト
+func TestExitStatusZero(t *testing.T) {
+\tconn := dial(exitStatusZeroHandler, t)
+\tdefer conn.Close()
+\tsession, err := conn.NewSession()
+\tif err != nil {
+\t\tt.Fatalf("Unable to request new session: %s", err)
+\t}\n+\tdefer session.Close()
+\n+\tif err := session.Shell(); err != nil {
+\t\tt.Fatalf("Unable to execute command: %s", err)
+\t}\n+\terr = session.Wait()
+\tif err != nil {
+\t\tt.Fatalf("expected nil but got %s", err)
+\t}\n+}\n+\n+// Test exit signal and status are both returned correctly. // 追加: シグナルとステータスの両方のテスト
+func TestExitSignalAndStatus(t *testing.T) {
+\tconn := dial(exitSignalAndStatusHandler, t)
+\tdefer conn.Close()
+\tsession, err := conn.NewSession()
+\tif err != nil {
+\t\tt.Fatalf("Unable to request new session: %s", err)
+\t}\n+\tdefer session.Close()
+\tif err := session.Shell(); err != nil {
+\t\tt.Fatalf("Unable to execute command: %s", err)
+\t}\n+\terr = session.Wait()
+\tif err == nil {
+\t\tt.Fatalf("expected command to fail but it didn't")
+\t}\n+\te, ok := err.(*ExitError)
+\tif !ok {
+\t\tt.Fatalf("expected *ExitError but got %T", err)
+\t}\n+\tif e.Signal() != "TERM" || e.ExitStatus() != 15 {
+\t\tt.Fatalf("expected command to exit with signal TERM and status 15 but got signal %s and status %v", e.Signal(), e.ExitStatus())
+\t}\n+}\n+\n+// Test exit signal and status are both returned correctly. // 追加: 既知のシグナルのみのテスト
+func TestKnownExitSignalOnly(t *testing.T) {
+\tconn := dial(exitSignalHandler, t)
+\tdefer conn.Close()
+\tsession, err := conn.NewSession()
+\tif err != nil {
+\t\tt.Fatalf("Unable to request new session: %s", err)
+\t}\n+\tdefer session.Close()
+\tif err := session.Shell(); err != nil {
+\t\tt.Fatalf("Unable to execute command: %s", err)
+\t}\n+\terr = session.Wait()
+\tif err == nil {
+\t\tt.Fatalf("expected command to fail but it didn't")
+\t}\n+\te, ok := err.(*ExitError)
+\tif !ok {
+\t\tt.Fatalf("expected *ExitError but got %T", err)
+\t}\n+\tif e.Signal() != "TERM" || e.ExitStatus() != 143 {
+\t\tt.Fatalf("expected command to exit with signal TERM and status 143 but got signal %s and status %v", e.Signal(), e.ExitStatus())
+\t}\n+}\n+\n+// Test exit signal and status are both returned correctly. // 追加: 未知のシグナルのみのテスト
+func TestUnknownExitSignal(t *testing.T) {
+\tconn := dial(exitSignalUnknownHandler, t)
+\tdefer conn.Close()
+\tsession, err := conn.NewSession()
+\tif err != nil {
+\t\tt.Fatalf("Unable to request new session: %s", err)
+\t}\n+\tdefer session.Close()
+\tif err := session.Shell(); err != nil {
+\t\tt.Fatalf("Unable to execute command: %s", err)
+\t}\n+\terr = session.Wait()
+\tif err == nil {
+\t\tt.Fatalf("expected command to fail but it didn't")
+\t}\n+\te, ok := err.(*ExitError)
+\tif !ok {
+\t\tt.Fatalf("expected *ExitError but got %T", err)
+\t}\n+\tif e.Signal() != "SYS" || e.ExitStatus() != 128 {
+\t\tt.Fatalf("expected command to exit with signal SYS and status 128 but got signal %s and status %v", e.Signal(), e.ExitStatus())
+\t}\n+}\n+\n+// Test WaitMsg is not returned if the channel closes abruptly. // 追加: ステータス/シグナルなしでチャネルが閉じた場合のテスト
+func TestExitWithoutStatusOrSignal(t *testing.T) {
+\tconn := dial(exitWithoutSignalOrStatus, t)
+\tdefer conn.Close()
+\tsession, err := conn.NewSession()
+\tif err != nil {
+\t\tt.Fatalf("Unable to request new session: %s", err)
+\t}\n+\tdefer session.Close()
+\tif err := session.Shell(); err != nil {
+\t\tt.Fatalf("Unable to execute command: %s", err)
+\t}\n+\terr = session.Wait()
+\tif err == nil {
+\t\tt.Fatalf("expected command to fail but it didn't")
+\t}\n+\t_, ok := err.(*ExitError)
+\tif ok {
+\t\t// you can't actually test for errors.errorString
+\t\t// because it's not exported.
+\t\tt.Fatalf("expected *errorString but got %T", err)
+\t}\n+}\n+\n+type exitStatusMsg struct { // 追加: exit-statusメッセージ構造体
+\tPeersId   uint32
+\tRequest   string
+\tWantReply bool
+\tStatus    uint32
+}\n+\n+type exitSignalMsg struct { // 追加: exit-signalメッセージ構造体
+\tPeersId    uint32
+\tRequest    string
+\tWantReply  bool
+\tSignal     string
+\tCoreDumped bool
+\tErrmsg     string
+\tLang       string
+}\n+\n+func exitStatusZeroHandler(ch *channel) { // 追加: ゼロ終了ステータスハンドラ
+\tdefer ch.Close()
+\t// this string is returned to stdout
+\tshell := NewServerShell(ch, "> ")
+\tshell.ReadLine()
+\tsendStatus(0, ch)
+}\n+\n+func exitStatusNonZeroHandler(ch *channel) { // 追加: 非ゼロ終了ステータスハンドラ
+\tdefer ch.Close()
+\tshell := NewServerShell(ch, "> ")
+\tshell.ReadLine()
+\tsendStatus(15, ch)
+}\n+\n+func exitSignalAndStatusHandler(ch *channel) { // 追加: シグナルとステータスハンドラ
+\tdefer ch.Close()
+\tshell := NewServerShell(ch, "> ")
+\tshell.ReadLine()
+\tsendStatus(15, ch)
+\tsendSignal("TERM", ch)
+}\n+\n+func exitSignalHandler(ch *channel) { // 追加: シグナルのみハンドラ
+\tdefer ch.Close()
+\tshell := NewServerShell(ch, "> ")
+\tshell.ReadLine()
+\tsendSignal("TERM", ch)
+}\n+\n+func exitSignalUnknownHandler(ch *channel) { // 追加: 未知のシグナルハンドラ
+\tdefer ch.Close()
+\tshell := NewServerShell(ch, "> ")
+\tshell.ReadLine()
+\tsendSignal("SYS", ch)
+}\n+\n+func exitWithoutSignalOrStatus(ch *channel) { // 追加: ステータス/シグナルなしハンドラ
+\tdefer ch.Close()
+\tshell := NewServerShell(ch, "> ")
+\tshell.ReadLine()
+}\n+\n+func shellHandler(ch *channel) { // 追加: シェルハンドラ
+\tdefer ch.Close()
+\t// this string is returned to stdout
+\tshell := NewServerShell(ch, "golang")
+\tshell.ReadLine()
+\tsendStatus(0, ch)
+}\n+\n+func sendStatus(status uint32, ch *channel) { // 追加: ステータス送信ヘルパー
+\tmsg := exitStatusMsg{
+\t\tPeersId:   ch.theirId,
+\t\tRequest:   "exit-status",
+\t\tWantReply: false,
+\t\tStatus:    status,
+\t}\n+\tch.serverConn.writePacket(marshal(msgChannelRequest, msg))
+}\n+\n+func sendSignal(signal string, ch *channel) { // 追加: シグナル送信ヘルパー
+\tsig := exitSignalMsg{
+\t\tPeersId:    ch.theirId,
+\t\tRequest:    "exit-signal",
+\t\tWantReply:  false,
+\t\tSignal:     signal,
+\t\tCoreDumped: false,
+\t\tErrmsg:     "Process terminated",
+\t\tLang:       "en-GB-oed",
+\t}\n+\tch.serverConn.writePacket(marshal(msgChannelRequest, sig))
+}\n```

## コアとなるコードの解説

このコミットの核心は、`Session.Wait()`メソッドの挙動を、リモートコマンドの終了ステータスやシグナルをよりGoらしい方法で報告するように変更した点にあります。

1.  **`client.go`の変更**:
    *   `mainLoop`における`channelCloseMsg`の処理で、`ch.msg`チャネルを明示的に閉じるようにしたことで、`Session.wait()`がこのチャネルのクローズを検知して終了処理を開始できるようになりました。これにより、`Wait()`が無限にブロックする可能性が解消されます。
    *   `channelEOFMsg`の処理が`sendEOF()`関数に置き換えられたのは、EOFメッセージを内部チャネルに送るのではなく、SSHプロトコルに従って`SSH_MSG_CHANNEL_EOF`パケットとして送信することが正しい挙動であるためです。

2.  **`session.go`の変更**:
    *   **`signals`マップ**: POSIXシグナル名とその数値表現をマッピングする`signals`マップが導入されました。これは、`exit-signal`メッセージでシグナル名が提供された場合に、対応する終了ステータス(通常は`128 + signal_value`)を計算するために使用されます。
    *   **`Run`と`Wait`のドキュメント更新**: これらの関数のドキュメントが更新され、`*ExitError`が返される可能性があることが明記されました。これにより、ユーザーはこれらの関数が返すエラーの型を期待できるようになります。
    *   **`Waitmsg`構造体**: リモートコマンドの終了に関する詳細情報(ステータス、シグナル、メッセージ、言語)をカプセル化するための新しい構造体です。これは、`os/exec`パッケージの`Waitmsg`と同様の目的を持ちます。
    *   **`ExitError`構造体**: `Waitmsg`を埋め込み、`error`インターフェースを実装するカスタムエラー型です。これにより、`Session.Wait()`が非ゼロの終了ステータスやシグナルによる終了を報告する際に、単なる文字列エラーではなく、構造化されたエラーオブジェクトを返すことができます。ユーザーは型アサーション(`err.(*ssh.ExitError)`)を使用して、エラーが`ExitError`であるかどうかをチェックし、詳細情報にアクセスできます。
    *   **`Session.wait()`のロジック**:
        *   `for msg := range s.msg`ループは、`s.msg`チャネルが閉じられるまで、受信したメッセージを処理し続けます。これにより、`exit-status`と`exit-signal`の両方の情報が利用可能になった場合に、それらを統合して`Waitmsg`に格納できます。
        *   `exit-status`メッセージを受信すると、そのデータから終了ステータスを抽出し、`wm.status`に設定します。
        *   `exit-signal`メッセージを受信すると、そのデータからシグナル名、エラーメッセージ、言語タグをパースし、それぞれ`wm.signal`, `wm.msg`, `wm.lang`に設定します。`parseString`関数は、SSHプロトコルで文字列がどのようにエンコードされているかを処理します。
        *   ループが終了(`s.msg`チャネルがクローズ)した後、`wm.status`の値に基づいて最終的な戻り値が決定されます。
            *   `wm.status == 0`の場合、コマンドは正常終了と見なされ、`nil`が返されます。
            *   `wm.status == -1`(`exit-status`メッセージが受信されなかったことを示す初期値)の場合、シグナルによる終了が試みられます。`wm.signal`が空でなければ、`wm.status`は`128`に設定され、`signals`マップにシグナル名が存在すれば、そのシグナル値が加算されます。これは、Unix系システムにおけるシグナル終了の慣習的な終了コードを模倣しています。
            *   それ以外の場合(非ゼロの終了ステータスまたはシグナルによる終了)、`&ExitError{wm}`が返され、呼び出し元は詳細な終了情報を取得できます。

3.  **`session_test.go`の変更**:
    *   テストフレームワークが拡張され、`dial`関数が`serverType`ハンドラを受け取るようになりました。これにより、テストケースごとに異なるSSHサーバーの挙動(特定の終了ステータスやシグナルを送信するなど)を簡単にシミュレートできるようになりました。
    *   追加された多数のテストケースは、`Session.Wait()`の新しい挙動が様々なシナリオ(正常終了、非ゼロ終了ステータス、シグナル終了、ステータスとシグナルの両方、ステータスもシグナルもない場合など)で正しく機能することを保証します。これにより、変更の堅牢性が高められています。

これらの変更により、GoのSSHパッケージは、リモートコマンドの終了処理において、より標準的で予測可能なエラーハンドリングを提供できるようになりました。

## 関連リンク

*   [RFC 4254 - The Secure Shell (SSH) Connection Protocol](https://datatracker.ietf.org/doc/html/rfc4254)
    *   特にセクション 6.10 "Exiting Shells" は、`exit-status`と`exit-signal`リクエストについて詳述しています。
*   [Go Documentation: os/exec package](https://pkg.go.dev/os/exec)
    *   `ExitError`と`Waitmsg`の概念について理解を深めることができます。

## 参考にした情報源リンク

*   上記のGitHubコミットページ
*   Go言語の公式ドキュメント
*   RFC 4254 (The Secure Shell (SSH) Connection Protocol)
*   Go言語の`os/exec`パッケージのソースコードとドキュメント