From 890b2c5bef6ba6ad2d6c92ac28c3662ed8cbae6d Mon Sep 17 00:00:00 2001 From: Christian Praiss Date: Tue, 28 Jul 2026 15:53:13 +0200 Subject: [PATCH] refactor(process): Improve subprocess signaling and lifecycle management Implements `runWithSignalForwarding` across exec, main, and TUI components. This enhances graceful shutdown by attempting SIGINT on the first interrupt (Ctrl+C), followed by a hard kill() for subsequent signals, aligning with standard shell conventions. The pattern is extended to manage subprocess lifecycles within the TUI component gracefully. --- exec.go | 46 +++++++++++++++++++++++++++++++++++++++++-- main.go | 2 +- tui.go | 61 ++++++++++++++++++++++++++++++++++++++++++++++++--------- 3 files changed, 97 insertions(+), 12 deletions(-) diff --git a/exec.go b/exec.go index df92aa2..cd23c2f 100644 --- a/exec.go +++ b/exec.go @@ -4,7 +4,9 @@ import ( "fmt" "os" "os/exec" + "os/signal" "strings" + "syscall" "golang.org/x/term" ) @@ -63,7 +65,7 @@ func runScript(container, script string, extraArgs []string) error { cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr - return cmd.Run() + return runWithSignalForwarding(cmd) } // runExec opens an interactive shell (or runs a command) in a module's container. @@ -92,9 +94,49 @@ func runExec(m *Module, extraArgs []string) error { cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr - return cmd.Run() + return runWithSignalForwarding(cmd) } func isTTY() bool { return term.IsTerminal(int(os.Stdin.Fd())) } + +// runWithSignalForwarding starts cmd and blocks until it exits, forwarding +// SIGINT/SIGTERM to it instead of letting Go's default handling kill this +// process immediately. Without this, a single Ctrl+C would kill the devops-cli +// wrapper right away while a child like `docker compose up` kept running its +// own graceful shutdown in the background, printing to the shared terminal +// after control had already returned to the shell. A second signal escalates +// to a hard kill, matching the usual "one Ctrl+C to stop gracefully, two to +// force it" shell convention. +func runWithSignalForwarding(cmd *exec.Cmd) error { + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + defer signal.Stop(sigCh) + + if err := cmd.Start(); err != nil { + return err + } + + done := make(chan struct{}) + go func() { + forwarded := false + for { + select { + case sig := <-sigCh: + if !forwarded { + forwarded = true + _ = cmd.Process.Signal(sig) + } else { + _ = cmd.Process.Kill() + } + case <-done: + return + } + } + }() + + err := cmd.Wait() + close(done) + return err +} diff --git a/main.go b/main.go index f9701c3..3a26452 100644 --- a/main.go +++ b/main.go @@ -72,7 +72,7 @@ func runAllCommand(command string, extraArgs []string) int { cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { + if err := runWithSignalForwarding(cmd); err != nil { return 1 } return 0 diff --git a/tui.go b/tui.go index 39c5658..b1327fc 100644 --- a/tui.go +++ b/tui.go @@ -54,6 +54,20 @@ type panel struct { autoScroll bool status string // "running" | "done" | "failed" exitCode int + proc *os.Process +} + +// signal delivers sig to the panel's process if it's still running. Used to +// stop a panel's subprocess (e.g. `docker compose up`) instead of leaving it +// running in the background after the TUI quits. +func (p *panel) signal(sig os.Signal) { + p.mu.RLock() + proc := p.proc + running := p.status == "running" + p.mu.RUnlock() + if proc != nil && running { + _ = proc.Signal(sig) + } } func newPanel(label, cmd string) *panel { @@ -91,6 +105,9 @@ func runPanel(idx int, p *panel, ch chan<- tea.Msg) { ch <- exitMsg{idx, 1} return } + p.mu.Lock() + p.proc = cmd.Process + p.mu.Unlock() _ = w.Close() sc := bufio.NewScanner(r) @@ -143,13 +160,14 @@ func statusFrame(p *panel, tick int) string { // ── Model ──────────────────────────────────────────────────────────────────── type model struct { - panels []*panel - focus int - width int - height int - tick int - ch chan tea.Msg - running int + panels []*panel + focus int + width int + height int + tick int + ch chan tea.Msg + running int + stopping bool } func newModel(panels []*panel) model { @@ -207,9 +225,15 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } p.mu.Unlock() m.running-- + if m.stopping && m.running == 0 { + return m, tea.Quit + } return m, listenCh(m.ch) case allDoneMsg: + if m.stopping { + return m, tea.Quit + } return m, nil case tea.KeyMsg: @@ -217,6 +241,22 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { contentH := m.height - 2 switch msg.String() { case "q", "Q", "ctrl+c": + if m.running == 0 { + return m, tea.Quit + } + if !m.stopping { + // First press: ask running panels to stop gracefully and + // wait for them to actually exit before quitting the TUI. + m.stopping = true + for _, panel := range m.panels { + panel.signal(os.Interrupt) + } + return m, nil + } + // Second press: force-kill anything still running and quit now. + for _, panel := range m.panels { + panel.signal(os.Kill) + } return m, tea.Quit case "tab", "right", "l": m.focus = (m.focus + 1) % n @@ -328,9 +368,12 @@ func (m model) View() string { } } var barText string - if m.running == 0 { + switch { + case m.running == 0: barText = fmt.Sprintf(" Done: %d/%d succeeded [q]uit [tab/←→/h/l]focus [↑↓/PgUp/PgDn/j/k]scroll [g/G]top/end", okCount, n) - } else { + case m.stopping: + barText = fmt.Sprintf(" Stopping (%d active)… press again to force quit", m.running) + default: barText = fmt.Sprintf(" Running (%d active) [q]uit [tab/←→/h/l]focus [↑↓/PgUp/PgDn/j/k]scroll [g/G]top/end", m.running) } sb.WriteString(barStyle.Render(fitWidth(barText, m.width)))