From f476b7bd8bd6383490bb3650e887cf5fa9c3e0f5 Mon Sep 17 00:00:00 2001 From: Daniel Arroyo Date: Mon, 13 Jul 2026 15:20:13 -0400 Subject: [PATCH] Fix shutdown UX: nohup wrapper prevents SSH session drop during host shutdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the host shuts down, sshd is killed before sending exit-status, causing Go ssh library to return "wait: remote command exited without exit status" — treated as failure even though the shutdown worked. Changes: - shutdown.go: wrap shutdown-like commands with nohup so the SSH session exits cleanly before sshd is killed by shutdown - IsShutdownCommand() helper detects shutdown/poweroff/halt/reboot commands - handlers_machines.go: classify expected shutdown-side-effect errors (no exit status, connection refused/reset) as success so the UI shows a green toast instead of a false error --- internal/api/handlers_machines.go | 32 ++++++++++++++++++++++++++++++- internal/sshmanager/shutdown.go | 27 ++++++++++++++++++++++++-- 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/internal/api/handlers_machines.go b/internal/api/handlers_machines.go index 2288e2f..e0cd302 100644 --- a/internal/api/handlers_machines.go +++ b/internal/api/handlers_machines.go @@ -7,6 +7,7 @@ import ( "fmt" "log/slog" "net/http" + "strings" "path/filepath" "regexp" "strconv" @@ -282,7 +283,36 @@ func (h *MachineHandler) Shutdown(w http.ResponseWriter, r *http.Request) { writeJSON(w, ShutdownResponse{Success: false, Error: err.Error()}) return } - writeJSON(w, ShutdownResponse{Success: result.Success, Output: result.Output, Error: result.Error}) + + responseSuccess := result.Success + responseOutput := result.Output + responseError := result.Error + + if !responseSuccess && m.ShutdownCommand != "" && sshmanager.IsShutdownCommand(m.ShutdownCommand) { + if isExpectedShutdownError(result.Error) { + responseSuccess = true + responseError = "" + if responseOutput == "" { + responseOutput = "shutdown command sent (host session terminated as expected)" + } + } + } + + writeJSON(w, ShutdownResponse{ + Success: responseSuccess, + Output: responseOutput, + Error: responseError, + }) +} + +func isExpectedShutdownError(errMsg string) bool { + if errMsg == "" { + return false + } + return strings.Contains(errMsg, "remote command exited without exit status") || + strings.Contains(errMsg, "connection refused") || + strings.Contains(errMsg, "connection reset by peer") || + strings.Contains(errMsg, "use of closed network connection") } func (h *MachineHandler) TestConnection(w http.ResponseWriter, r *http.Request) { diff --git a/internal/sshmanager/shutdown.go b/internal/sshmanager/shutdown.go index 72a99d2..4de6419 100644 --- a/internal/sshmanager/shutdown.go +++ b/internal/sshmanager/shutdown.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "fmt" + "strings" "time" ) @@ -13,6 +14,20 @@ type ShutdownResult struct { Error string } +func IsShutdownCommand(cmd string) bool { + c := strings.TrimSpace(strings.ToLower(cmd)) + for _, p := range []string{ + "shutdown", "poweroff", "halt", "reboot", + "sudo shutdown", "sudo poweroff", "sudo halt", "sudo reboot", + "systemctl poweroff", "systemctl halt", "systemctl reboot", + } { + if strings.HasPrefix(c, p) { + return true + } + } + return false +} + func RunRemoteCommand(ctx context.Context, host string, port int, user, privKeyPath, knownHostsPath string, strictHostKeyChecking bool, command string) (*ShutdownResult, error) { conn, _, _, err := dialSSH(ctx, host, port, user, privKeyPath, knownHostsPath, strictHostKeyChecking) if err != nil { @@ -30,12 +45,20 @@ func RunRemoteCommand(ctx context.Context, host string, port int, user, privKeyP session.Stdout = &stdout session.Stderr = &stderr + effectiveCmd := command + if IsShutdownCommand(command) { + effectiveCmd = fmt.Sprintf( + "nohup %s >/dev/null 2>&1