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