From 0f05763129e400e4813142f3a8243d388b73389c Mon Sep 17 00:00:00 2001 From: Gurney Buchanan Date: Wed, 21 Oct 2026 12:57:38 -0400 Subject: [PATCH] Fix the detach hang: one ctrl+c releases the terminal immediately MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detaching held the terminal open. The attach monitor's relay goroutine waited for the journal tailer to close its feed channel — but a detached run is still going, so its tailer never stops. The tea program had already exited and restored the terminal, yet the process sat blocked on the relay, holding the session until a second ctrl+c killed it outright (losing the detach message and exit status). The relay now abandons the feed the instant the program exits: one ctrl+c detaches cleanly, prints the re-attach hint, and exits 0 while the run keeps going. The regression test drives the monitor with piped input and discarded output, quitting with ctrl+c while the feed channel stays open — previously that hung forever, now it returns promptly. Verified live in a PTY: attach exits 0 at the ctrl+c with the detach message, and the run shows still-running afterward. 💘 Generated with Crush Assisted-by: Crush:rits/zai-org/glm-5-3 --- CHANGELOG.md | 5 +++++ internal/tui/monitor.go | 29 +++++++++++++++++++++++------ internal/tui/width_test.go | 28 ++++++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b8017be..ae0500c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -87,3 +87,8 @@ Initial implementation. - The monitor now shows tool results (crush tool_result parts, truncated), which are the bulk of a run's output; journal raw lines no longer duplicate parsed events, so attach replays render once. +- Fixed the detach hang: the attach monitor's relay goroutine waited for + the journal tailer to finish, but a detached run keeps tailing — so the + process (and the terminal) stayed open until a second ctrl+c killed it. + The relay now abandons the feed the moment the monitor exits; one ctrl+c + detaches cleanly, printing the re-attach hint and exiting 0. diff --git a/internal/tui/monitor.go b/internal/tui/monitor.go index ebf5a31..b38a634 100644 --- a/internal/tui/monitor.go +++ b/internal/tui/monitor.go @@ -355,19 +355,36 @@ func (m monitorModel) budgetBar(out, cap int64, width int) string { // quits the monitor without touching the run; cancelling (x twice) invokes // stop. The returned bool reports whether the run completed while attached. func Attach(header MonitorHeader, follow <-chan MonitorItem, stop func()) (bool, error) { + return attachProgram(header, follow, stop, tea.WithAltScreen(), tea.WithMouseCellMotion()) +} + +// attachProgram is Attach with injectable program options (tests feed a +// pipe as input and discard output). +func attachProgram(header MonitorHeader, follow <-chan MonitorItem, stop func(), opts ...tea.ProgramOption) (bool, error) { m := newMonitor(header, stop, 80, 24) - prog := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion()) + prog := tea.NewProgram(m, opts...) - relayDone := make(chan struct{}) + // The relay must ABANDON the feed when the program exits, not wait for + // it to close: detaching means the run — and its journal tailer — keep + // going. Waiting here held the process (and the terminal) open until a + // second ctrl+c killed it. + progDone := make(chan struct{}) go func() { - defer close(relayDone) - for it := range follow { - prog.Send(it) + for { + select { + case it, ok := <-follow: + if !ok { + return + } + prog.Send(it) + case <-progDone: + return + } } }() out, err := prog.Run() - <-relayDone + close(progDone) if err != nil { return false, err } diff --git a/internal/tui/width_test.go b/internal/tui/width_test.go index cbff094..f2c417c 100644 --- a/internal/tui/width_test.go +++ b/internal/tui/width_test.go @@ -1,8 +1,10 @@ package tui import ( + "io" "strings" "testing" + "time" "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -11,6 +13,32 @@ import ( "github.com/traust-security/traust-cli/internal/views" ) +// TestAttachDetachReturnsWhileFeedOpen is the regression test for the +// detach hang: the monitor must exit when the user quits, even though the +// journal tailer keeps the feed channel open (the run continues). +func TestAttachDetachReturnsWhileFeedOpen(t *testing.T) { + follow := make(chan MonitorItem, 8) // never closed — the run is still going + + done := make(chan error, 1) + go func() { + _, err := attachProgram(MonitorHeader{Skill: "s", Harness: "crush"}, + follow, nil, + tea.WithInput(strings.NewReader("\x03")), // ctrl+c: detach + tea.WithOutput(io.Discard), + tea.WithoutSignalHandler()) + done <- err + }() + + select { + case err := <-done: + if err != nil { + t.Fatalf("attach: %v", err) + } + case <-time.After(10 * time.Second): + t.Fatal("attach did not return after ctrl+c while the feed stayed open — detach hang regression") + } +} + func TestWrapRespectsWidth(t *testing.T) { cases := []struct { width int