diff --git a/cmd/api/api/exec.go b/cmd/api/api/exec.go index 26a3745d..39e823c5 100644 --- a/cmd/api/api/exec.go +++ b/cmd/api/api/exec.go @@ -138,7 +138,11 @@ func (s *ApiService) ExecHandler(w http.ResponseWriter, r *http.Request) { "duration_ms", duration.Milliseconds(), ) // Send error message over WebSocket before closing - ws.WriteMessage(websocket.TextMessage, []byte(fmt.Sprintf("Error: %v", err))) + // Use BinaryMessage so the CLI writes it to stdout (it ignores TextMessage for output) + // Use \r\n so it displays properly when client terminal is in raw mode + ws.WriteMessage(websocket.BinaryMessage, []byte(fmt.Sprintf("Error: %v\r\n", err))) + // Send exit code 127 (command not found - standard Unix convention) + ws.WriteMessage(websocket.TextMessage, []byte(`{"exitCode":127}`)) return } @@ -199,4 +203,3 @@ func (w *wsReadWriter) Write(p []byte) (n int, err error) { } return len(p), nil } - diff --git a/cmd/exec/main.go b/cmd/exec/main.go index fac83a85..592caf36 100644 --- a/cmd/exec/main.go +++ b/cmd/exec/main.go @@ -143,22 +143,22 @@ func main() { // Use custom WebSocket dialer dialer := &websocket.Dialer{} - + // Set up headers for WebSocket connection (body was already sent) headers := http.Header{} headers.Set("Authorization", fmt.Sprintf("Bearer %s", jwtToken)) // Make HTTP POST with body client := &http.Client{} - + // Actually, we need a custom approach. Let me use a modified request // that sends body AND upgrades to WebSocket. // For simplicity, let's POST the JSON as the Sec-WebSocket-Protocol header value (hacky but works) // OR we can encode params in URL query string - + // Actually, the simplest approach: POST the body first, get a session ID, then connect WebSocket // But that requires server changes. - + // Let's use the approach where we send JSON as first WebSocket message after connect ws, resp, err := dialer.Dial(wsURL.String(), headers) if err != nil { @@ -240,7 +240,10 @@ func runInteractive(ws *websocket.Conn) (int, error) { for { msgType, message, err := ws.ReadMessage() if err != nil { - if !websocket.IsUnexpectedCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) { + if websocket.IsUnexpectedCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) { + // Unexpected close - report as error to avoid hanging + errCh <- fmt.Errorf("connection closed unexpectedly: %w", err) + } else { // Normal close, default exit code 0 exitCodeCh <- 0 } @@ -310,8 +313,8 @@ func runNonInteractive(ws *websocket.Conn) (int, error) { msgType, message, err := ws.ReadMessage() if err != nil { // Connection closed is normal - default exit code 0 - if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) || - err == io.EOF { + if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) || + err == io.EOF { exitCodeCh <- 0 return } diff --git a/lib/instances/exec_test.go b/lib/instances/exec_test.go index cee4e71b..f0e69663 100644 --- a/lib/instances/exec_test.go +++ b/lib/instances/exec_test.go @@ -9,6 +9,7 @@ import ( "testing" "time" + "github.com/onkernel/hypeman/lib/exec" "github.com/onkernel/hypeman/lib/images" "github.com/onkernel/hypeman/lib/paths" "github.com/onkernel/hypeman/lib/system" @@ -214,4 +215,43 @@ func TestExecConcurrent(t *testing.T) { "streams appear serialized - took %v, expected < %v", streamElapsed, maxExpected) t.Logf("Long-running streams completed in %v (concurrent OK)", streamElapsed) + + // Phase 3: Test command not found returns quickly (no hang) + // Regression test for a hang that occurred when the command wasn't found. + t.Log("Phase 3: Testing exec with non-existent command...") + + // Test without TTY + start := time.Now() + var stdout, stderr strings.Builder + _, err = exec.ExecIntoInstance(ctx, inst.VsockSocket, exec.ExecOptions{ + Command: []string{"nonexistent_command_asdfasdf"}, + Stdout: &stdout, + Stderr: &stderr, + TTY: false, + }) + elapsed := time.Since(start) + t.Logf("Exec (no TTY) completed in %v (error: %v)", elapsed, err) + + require.Error(t, err, "exec should fail for non-existent command") + require.Contains(t, err.Error(), "executable file not found", "error should mention command not found") + require.Less(t, elapsed, 5*time.Second, "exec should not hang, took %v", elapsed) + + // Test with TTY + start = time.Now() + stdout.Reset() + stderr.Reset() + _, err = exec.ExecIntoInstance(ctx, inst.VsockSocket, exec.ExecOptions{ + Command: []string{"nonexistent_command_xyz123"}, + Stdout: &stdout, + Stderr: &stderr, + TTY: true, + }) + elapsed = time.Since(start) + t.Logf("Exec (with TTY) completed in %v (error: %v)", elapsed, err) + + require.Error(t, err, "exec with TTY should fail for non-existent command") + require.Contains(t, err.Error(), "executable file not found", "error should mention command not found") + require.Less(t, elapsed, 5*time.Second, "exec with TTY should not hang, took %v", elapsed) + + t.Log("Command not found tests passed - exec does not hang") }