Skip to content

Commit 47f40db

Browse files
cmdio: Fix segfault in pager (#5815)
## Changes Fix segfault in pager (Claude generated based on the provided segfault stack trace) ## Why Third time I've encountered a random segfault during pagination of an API response. This time `databricks service-principals list` ## Tests Difficult to test, since this was intermittent. Ran the command a few times and didn't see it come up.
1 parent 6fb5c5c commit 47f40db

2 files changed

Lines changed: 57 additions & 16 deletions

File tree

libs/cmdio/pager.go

Lines changed: 30 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,11 @@ type pagerModel[T any] struct {
3838
spinner bubblespinner.Model
3939
// fetch is bound at construction with the caller's context captured
4040
// so we don't have to stash ctx on the struct (tea.Cmd has no ctx
41-
// parameter of its own).
42-
fetch func() tea.Msg
41+
// parameter of its own). It takes the paging state as arguments and
42+
// returns a pure Cmd: bubbletea runs Cmds on their own goroutine
43+
// concurrently with Update, so the returned closure must not read or
44+
// write the model.
45+
fetch func(pageSize, limit, total int) tea.Cmd
4346
pageSize int
4447
limit int
4548
total int
@@ -70,7 +73,9 @@ func newPagerModel[T any](
7073
pageSize: pageSize,
7174
limit: limit,
7275
}
73-
m.fetch = func() tea.Msg { return m.doFetch(ctx) }
76+
m.fetch = func(pageSize, limit, total int) tea.Cmd {
77+
return func() tea.Msg { return m.doFetch(ctx, pageSize, limit, total) }
78+
}
7479
return m
7580
}
7681

@@ -86,27 +91,32 @@ func newPagerSpinner() bubblespinner.Model {
8691
return s
8792
}
8893

89-
// batchMsg carries the rendered lines from one fetch. done is true when
90-
// the iterator is exhausted or the limit is reached.
94+
// batchMsg carries the rendered lines from one fetch. count is the number
95+
// of rows fetched, added to m.total in Update. done is true when the
96+
// iterator is exhausted or the limit is reached.
9197
type batchMsg struct {
9298
lines []string
99+
count int
93100
done bool
94101
err error
95102
}
96103

97104
func (m *pagerModel[T]) Init() tea.Cmd {
98105
m.fetching = true
99-
return tea.Batch(m.fetch, m.spinner.Tick)
106+
return tea.Batch(m.fetch(m.pageSize, m.limit, m.total), m.spinner.Tick)
100107
}
101108

102109
// doFetch reads one page from the iterator and renders it into lines.
103110
// It runs off the update loop so a slow network fetch doesn't stall
104-
// key handling.
105-
func (m *pagerModel[T]) doFetch(ctx context.Context) tea.Msg {
106-
buf := make([]any, 0, m.pageSize)
111+
// key handling. The paging state is passed in rather than read off the
112+
// model: bubbletea runs this on its own goroutine concurrently with
113+
// Update, so it must not touch shared model fields. m.iter and m.pager
114+
// are exempt because only one fetch is ever in flight at a time.
115+
func (m *pagerModel[T]) doFetch(ctx context.Context, pageSize, limit, total int) tea.Msg {
116+
buf := make([]any, 0, pageSize)
107117
done := false
108-
for len(buf) < m.pageSize {
109-
if m.limit > 0 && m.total+len(buf) >= m.limit {
118+
for len(buf) < pageSize {
119+
if limit > 0 && total+len(buf) >= limit {
110120
done = true
111121
break
112122
}
@@ -124,8 +134,11 @@ func (m *pagerModel[T]) doFetch(ctx context.Context) tea.Msg {
124134
if err != nil {
125135
return batchMsg{err: err}
126136
}
127-
m.total += len(buf)
128-
return batchMsg{lines: lines, done: done}
137+
// The loop exits on len(buf) == pageSize before re-checking the limit,
138+
// so a page that lands exactly on the limit leaves done false. Re-check
139+
// here to avoid scheduling one more (empty) fetch on drain/advance.
140+
done = done || (limit > 0 && total+len(buf) >= limit)
141+
return batchMsg{lines: lines, count: len(buf), done: done}
129142
}
130143

131144
func (m *pagerModel[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -145,6 +158,7 @@ func (m *pagerModel[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
145158
m.err = msg.err
146159
return m, tea.Quit
147160
}
161+
m.total += msg.count
148162
m.hasPrinted = true
149163
// One Println cmd (not N) keeps the batch ordered even though
150164
// tea.Sequence dispatches each cmd on its own goroutine.
@@ -158,7 +172,7 @@ func (m *pagerModel[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
158172
return m, tea.Sequence(printCmd, tea.Quit)
159173
case m.drainAll:
160174
m.fetching = true
161-
return m, tea.Sequence(printCmd, m.fetch)
175+
return m, tea.Sequence(printCmd, m.fetch(m.pageSize, m.limit, m.total))
162176
default:
163177
return m, printCmd
164178
}
@@ -196,7 +210,7 @@ func (m *pagerModel[T]) startAdvance() tea.Cmd {
196210
return nil
197211
}
198212
m.fetching = true
199-
return m.fetch
213+
return m.fetch(m.pageSize, m.limit, m.total)
200214
}
201215

202216
func (m *pagerModel[T]) startDrain() tea.Cmd {
@@ -210,7 +224,7 @@ func (m *pagerModel[T]) startDrain() tea.Cmd {
210224
return nil
211225
}
212226
m.fetching = true
213-
return m.fetch
227+
return m.fetch(m.pageSize, m.limit, m.total)
214228
}
215229

216230
func (m *pagerModel[T]) View() string {

libs/cmdio/pager_test.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,33 @@ func TestPagerModelInitFetchesFirstBatch(t *testing.T) {
8181
assert.True(t, m.fetching, "Init must mark the model as fetching")
8282
}
8383

84+
func TestPagerModelDoFetchLimit(t *testing.T) {
85+
cases := []struct {
86+
name string
87+
pageSize int
88+
limit int
89+
total int
90+
wantCount int
91+
wantDone bool
92+
}{
93+
{"exact boundary marks done", 5, 5, 0, 5, true},
94+
{"limit reached mid-page", 5, 12, 10, 2, true},
95+
{"page boundary below limit not done", 5, 100, 0, 5, false},
96+
{"no limit not done", 5, 0, 0, 5, false},
97+
}
98+
for _, tc := range cases {
99+
t.Run(tc.name, func(t *testing.T) {
100+
m := newTestPager(t, &numberIterator{n: 100}, tc.pageSize)
101+
msg := m.doFetch(t.Context(), tc.pageSize, tc.limit, tc.total)
102+
b, ok := msg.(batchMsg)
103+
require.True(t, ok, "expected batchMsg, got %T", msg)
104+
require.NoError(t, b.err)
105+
assert.Equal(t, tc.wantCount, b.count)
106+
assert.Equal(t, tc.wantDone, b.done)
107+
})
108+
}
109+
}
110+
84111
func TestPagerModelBatchPrintsAndQuitsWhenDone(t *testing.T) {
85112
m := newTestPager(t, &numberIterator{n: 3}, 10)
86113
_, cmd := m.Update(batchMsg{lines: []string{"1", "2", "3"}, done: true})

0 commit comments

Comments
 (0)