Skip to content

Commit e9f9be7

Browse files
Merge pull request #1264 from CircleCI-Public/fix-spinner-output
fix(spinner): drain TTY line discipline buffer on stop
2 parents 54a2f1e + 019ebc7 commit e9f9be7

5 files changed

Lines changed: 124 additions & 1 deletion

File tree

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ require (
2323
github.com/spf13/cobra v1.10.2
2424
github.com/zalando/go-keyring v0.2.8
2525
golang.org/x/oauth2 v0.35.0
26+
golang.org/x/sys v0.43.0
2627
gopkg.in/yaml.v3 v3.0.1
2728
gotest.tools/v3 v3.5.2
2829
)
@@ -253,7 +254,6 @@ require (
253254
golang.org/x/mod v0.34.0 // indirect
254255
golang.org/x/net v0.52.0 // indirect
255256
golang.org/x/sync v0.20.0 // indirect
256-
golang.org/x/sys v0.43.0 // indirect
257257
golang.org/x/term v0.42.0 // indirect
258258
golang.org/x/text v0.35.0 // indirect
259259
golang.org/x/tools v0.43.0 // indirect

internal/iostream/drain_bsd.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
// Copyright (c) 2026 Circle Internet Services, Inc.
2+
//
3+
// Permission is hereby granted, free of charge, to any person obtaining a copy
4+
// of this software and associated documentation files (the "Software"), to deal
5+
// in the Software without restriction, including without limitation the rights
6+
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7+
// copies of the Software, and to permit persons to whom the Software is
8+
// furnished to do so, subject to the following conditions:
9+
//
10+
// The above copyright notice and this permission notice shall be included in
11+
// all copies or substantial portions of the Software.
12+
//
13+
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14+
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15+
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16+
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17+
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18+
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19+
// SOFTWARE.
20+
//
21+
// SPDX-License-Identifier: MIT
22+
23+
//go:build darwin || dragonfly || freebsd || netbsd || openbsd
24+
25+
package iostream
26+
27+
import (
28+
"os"
29+
30+
"golang.org/x/sys/unix"
31+
)
32+
33+
// drainStdin discards bytes pending in stdin's line discipline buffer.
34+
//
35+
// Bubbletea v2 writes capability queries (mode 2026 synchronized output, mode
36+
// 2027 unicode core) to the output at startup. The terminal responds on stdin.
37+
// Because the spinner uses WithInput(nil) the input loop never runs, so those
38+
// responses accumulate in the TTY line discipline buffer. A plain read() cannot
39+
// drain them in canonical mode (no newline, so the line discipline won't
40+
// deliver the data). TIOCFLUSH with FREAD=1 discards the buffer directly.
41+
// Without this, the responses appear as garbage in the shell prompt after exit.
42+
// See: charmbracelet/bubbletea#1590.
43+
func drainStdin() {
44+
_ = unix.IoctlSetPointerInt(int(os.Stdin.Fd()), unix.TIOCFLUSH, 1)
45+
}

internal/iostream/drain_linux.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
// Copyright (c) 2026 Circle Internet Services, Inc.
2+
//
3+
// Permission is hereby granted, free of charge, to any person obtaining a copy
4+
// of this software and associated documentation files (the "Software"), to deal
5+
// in the Software without restriction, including without limitation the rights
6+
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7+
// copies of the Software, and to permit persons to whom the Software is
8+
// furnished to do so, subject to the following conditions:
9+
//
10+
// The above copyright notice and this permission notice shall be included in
11+
// all copies or substantial portions of the Software.
12+
//
13+
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14+
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15+
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16+
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17+
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18+
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19+
// SOFTWARE.
20+
//
21+
// SPDX-License-Identifier: MIT
22+
23+
//go:build linux
24+
25+
package iostream
26+
27+
import "golang.org/x/sys/unix"
28+
29+
// drainStdin discards bytes pending in stdin's line discipline buffer.
30+
//
31+
// Bubbletea v2 writes capability queries (mode 2026 synchronized output, mode
32+
// 2027 unicode core) to the output at startup. The terminal responds on stdin.
33+
// Because the spinner uses WithInput(nil) the input loop never runs, so those
34+
// responses accumulate in the TTY line discipline buffer. A plain read() cannot
35+
// drain them in canonical mode (no newline, so the line discipline won't
36+
// deliver the data). TCFLSH/TCIFLUSH discards the buffer directly.
37+
//
38+
// We loop — flush then poll — to handle SSH round-trip latency where responses
39+
// may arrive after the first flush. On local terminals the poll returns
40+
// immediately with no data. See: charmbracelet/bubbletea#1590.
41+
func drainStdin() {
42+
fds := []unix.PollFd{{Fd: 0, Events: unix.POLLIN}} // stdin is always fd 0
43+
for {
44+
_ = unix.IoctlSetInt(0, unix.TCFLSH, 0) // 0 = TCIFLUSH: discard received, unread data
45+
n, _ := unix.Poll(fds, 200)
46+
if n <= 0 {
47+
return
48+
}
49+
}
50+
}

internal/iostream/drain_other.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
// Copyright (c) 2026 Circle Internet Services, Inc.
2+
//
3+
// Permission is hereby granted, free of charge, to any person obtaining a copy
4+
// of this software and associated documentation files (the "Software"), to deal
5+
// in the Software without restriction, including without limitation the rights
6+
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7+
// copies of the Software, and to permit persons to whom the Software is
8+
// furnished to do so, subject to the following conditions:
9+
//
10+
// The above copyright notice and this permission notice shall be included in
11+
// all copies or substantial portions of the Software.
12+
//
13+
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14+
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15+
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16+
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17+
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18+
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19+
// SOFTWARE.
20+
//
21+
// SPDX-License-Identifier: MIT
22+
23+
//go:build !darwin && !dragonfly && !freebsd && !netbsd && !openbsd && !linux
24+
25+
package iostream
26+
27+
func drainStdin() {}

internal/iostream/spinner.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ func (sp *Spin) Stop() {
8282
sp.once.Do(func() {
8383
sp.program.Quit()
8484
sp.program.Wait()
85+
drainStdin()
8586
})
8687
}
8788

0 commit comments

Comments
 (0)