1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
|
package tui
import (
"context"
"errors"
"fmt"
"time"
"ior/internal/globalfilter"
"ior/internal/parquet"
tea "charm.land/bubbletea/v2"
)
// traceLifecycle manages trace start/stop, recording start/stop, and the
// auto-reset interval cycle. It owns the context.CancelFunc for the running
// trace so the Model can stop tracing without understanding the context
// machinery.
type traceLifecycle struct {
startTrace TraceStarter
traceStop context.CancelFunc
}
// newTraceLifecycle creates a traceLifecycle bound to the given starter.
// If starter is nil, a no-op default is used so the model can operate
// in tests without a real BPF trace.
func newTraceLifecycle(starter TraceStarter) traceLifecycle {
if starter == nil {
starter = defaultTraceStarter
}
return traceLifecycle{startTrace: starter}
}
// beginCmd creates a tea.Cmd that runs the trace starter in a goroutine and
// returns a TracingStartedMsg or TracingErrorMsg. It also cancels any
// previously running trace and stores the new cancel function.
func (t *traceLifecycle) beginCmd(runtime *runtimeBindings, filter globalfilter.Filter) tea.Cmd {
ctx, cancel := context.WithCancel(context.Background())
t.traceStop = cancel
ctx = ContextWithRuntimeBindings(ctx, runtime)
ctx = ContextWithTraceFilters(ctx, filter)
return startTraceCmd(ctx, t.startTrace)
}
// stop cancels the running trace and clears the cancel function. Safe to call
// multiple times or when no trace is running.
func (t *traceLifecycle) stop() {
if t.traceStop != nil {
t.traceStop()
t.traceStop = nil
}
}
// defaultStartupTimeout is the maximum time allowed for BPF probe attachment.
// If the trace starter does not return within this window the TUI surfaces
// a TracingErrorMsg instead of spinning in the "Attaching tracepoints..."
// state indefinitely. The stuck goroutine is left running until the caller
// cancels the trace context (e.g. via traceLifecycle.stop on the next
// user action) so no goroutine is leaked permanently.
const defaultStartupTimeout = 30 * time.Second
// startTraceCmd wraps a TraceStarter in a tea.Cmd that handles context
// cancellation gracefully (returns nil so the caller does not treat a
// user-initiated stop as an error). It uses defaultStartupTimeout to
// prevent the TUI from hanging indefinitely when BPF probe attachment stalls.
// ctx is first per Go convention (context.Context always leads the parameter list).
func startTraceCmd(ctx context.Context, starter TraceStarter) tea.Cmd {
return startTraceCmdWithTimeout(ctx, starter, defaultStartupTimeout)
}
// startTraceCmdWithTimeout is the testable core of startTraceCmd. It races
// the starter goroutine against a caller-supplied timeout so that tests can
// use a short deadline without waiting 30 seconds.
// ctx is first per Go convention (context.Context always leads the parameter list).
func startTraceCmdWithTimeout(ctx context.Context, starter TraceStarter, timeout time.Duration) tea.Cmd {
return func() tea.Msg {
type starterResult struct{ err error }
ch := make(chan starterResult, 1)
go func() {
err := starter(ctx)
ch <- starterResult{err: err}
}()
select {
case res := <-ch:
if res.err != nil {
if errors.Is(res.err, context.Canceled) {
return nil
}
return TracingErrorMsg{Err: res.err}
}
return TracingStartedMsg{}
case <-time.After(timeout):
// BPF probe attachment did not complete in time. The stuck
// goroutine will be cleaned up when the caller cancels ctx
// (e.g. on the next traceLifecycle.stop call).
return TracingErrorMsg{Err: fmt.Errorf(
"trace startup timed out after %s: BPF probe attachment did not complete",
timeout,
)}
}
}
}
func defaultTraceStarter(context.Context) error {
return nil
}
// recorderStart opens the parquet recorder at the given path.
// It calls syncFn (typically syncDashboardFilterState) after the attempt
// (success or failure) so the status bar stays in sync.
func recorderStart(recorder *parquet.Recorder, path string, syncFn func()) error {
if recorder == nil {
return errors.New("recording runtime is unavailable")
}
err := recorder.Start(path, parquet.StartOptions{Metadata: tuiParquetMetadata()})
syncFn()
return err
}
// recorderStop closes the active parquet recorder.
// Returns nil without error when no recording is active.
// Calls syncFn after the attempt so the status bar stays in sync.
func recorderStop(recorder *parquet.Recorder, syncFn func()) error {
if recorder == nil {
return nil
}
if !recorder.Status().Active {
syncFn()
return nil
}
err := recorder.Stop()
syncFn()
return err
}
// recorderActive returns true when the recorder is currently recording.
func recorderActive(recorder *parquet.Recorder) bool {
if recorder == nil {
return false
}
return recorder.Status().Active
}
// recorderStatus returns the human-readable recording status string shown
// in the status bar.
func recorderStatus(recorder *parquet.Recorder) string {
if recorder == nil {
return "rec: unavailable"
}
status := recorder.Status()
if status.Active {
return "rec: " + shortenRecordingPath(status.Path)
}
if status.LastError != nil {
return "rec err: " + status.LastError.Error()
}
return "rec: off"
}
func defaultParquetRecordingFilename() string {
return fmt.Sprintf("ior-recording-%s.parquet", time.Now().Format("20060102-150405"))
}
// tuiParquetMetadata delegates to the canonical parquet.NewFileMetadata.
func tuiParquetMetadata() parquet.FileMetadata {
return parquet.NewFileMetadata("tui")
}
func shortenRecordingPath(path string) string {
const maxLen = 36
if len(path) <= maxLen {
return path
}
return "..." + path[len(path)-maxLen+3:]
}
// autoResetCycle is the ordered set of cadences exposed via the `I` hotkey.
// The first entry (0) disables the timer; the rest are progressively longer
// to give users a quick way to slow auto-resets down on long traces or turn
// them off entirely. The cycle wraps so pressing `I` past the last preset
// returns to off.
var autoResetCycle = []time.Duration{
0,
10 * time.Second,
30 * time.Second,
60 * time.Second,
2 * time.Minute,
5 * time.Minute,
}
// nextAutoResetInterval returns the next entry in autoResetCycle after
// current. If current is not in the cycle (e.g. a custom -resetTimer like
// 47s), the next entry is the first cycle value strictly greater than current;
// if there is none, we wrap to 0 (off).
func nextAutoResetInterval(current time.Duration) time.Duration {
for i, d := range autoResetCycle {
if d == current {
return autoResetCycle[(i+1)%len(autoResetCycle)]
}
}
for _, d := range autoResetCycle {
if d > current {
return d
}
}
return autoResetCycle[0]
}
|