Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions cmd/api/api/exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -199,4 +203,3 @@ func (w *wsReadWriter) Write(p []byte) (n int, err error) {
}
return len(p), nil
}

17 changes: 10 additions & 7 deletions cmd/exec/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down
40 changes: 40 additions & 0 deletions lib/instances/exec_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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")
}