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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
29 changes: 23 additions & 6 deletions internal/tui/monitor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
28 changes: 28 additions & 0 deletions internal/tui/width_test.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package tui

import (
"io"
"strings"
"testing"
"time"

"github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
Expand All @@ -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
Expand Down
Loading