Bump tcell to an unreleased snapshot to fix a shutdown race

tcell's filterEvents goroutine sends events into eventQ with a plain
blocking send, while Fini (via finish/finalize) closes eventQ after
closing the quit channel. The goroutine can have already committed to
the ev = <-inQ select arm when quit is closed, so its send into eventQ
races with the close; the race detector flags this (send and close on
the same channel are unsynchronized), and if the close wins, the send
panics with "send on closed channel".

This was caught by the integration tests under the race detector,
where every test drives a real tScreen over a MockTerm and tears it
down via Fini, but it equally affects real-terminal shutdown.

Upstream fixed it in 243630d2 ("Fix screen Init/Fini races") by
tracking the filter goroutine in a WaitGroup that finalize waits for
before closing eventQ, and guarding the send with a select on quit.
That commit is not in a tagged release yet (latest is v3.4.0), so pin
the pseudo-version; the delta over v3.4.0 is just this fix, a Windows
key-release fix, a cell-rendering perf tweak, and dependency bumps.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Stefan Haller 2026-07-17 08:41:25 +02:00
parent 07745afc57
commit 14d717d77f
6 changed files with 97 additions and 38 deletions

10
go.mod
View file

@ -13,7 +13,7 @@ require (
github.com/cli/go-gh/v2 v2.13.0
github.com/cloudfoundry/jibber_jabber v0.0.0-20151120183258-bcc4c8345a21
github.com/creack/pty v1.1.24
github.com/gdamore/tcell/v3 v3.4.0
github.com/gdamore/tcell/v3 v3.4.1-0.20260703153331-243630d2fb59
github.com/go-errors/errors v1.5.1
github.com/gookit/color v1.6.1
github.com/integrii/flaggy v1.8.0
@ -65,11 +65,11 @@ require (
github.com/onsi/gomega v1.34.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
golang.org/x/mod v0.35.0 // indirect
golang.org/x/mod v0.36.0 // indirect
golang.org/x/net v0.55.0 // indirect
golang.org/x/term v0.43.0 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/tools v0.44.0 // indirect
golang.org/x/term v0.44.0 // indirect
golang.org/x/text v0.38.0 // indirect
golang.org/x/tools v0.45.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/fsnotify.v1 v1.4.7 // indirect
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect

20
go.sum
View file

@ -32,8 +32,8 @@ github.com/fatih/color v1.9.0 h1:8xPHl4/q1VyqGIPif1F+1V3Y3lSmrq01EabUW3CoW5s=
github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU=
github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=
github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=
github.com/gdamore/tcell/v3 v3.4.0 h1:VUym1HQZiYodA5PGQrqLxF7QwqQndcAUwQD7G7XUy5E=
github.com/gdamore/tcell/v3 v3.4.0/go.mod h1:fjKxNiIFwbzTxDU+i+AAMz+xPOgXVaZq5tbShsKseHc=
github.com/gdamore/tcell/v3 v3.4.1-0.20260703153331-243630d2fb59 h1:kUXexBZYoVdAJIOIuP6uLgK3k0G7ClDIRO27Z3epgtU=
github.com/gdamore/tcell/v3 v3.4.1-0.20260703153331-243630d2fb59/go.mod h1:Ev/2PFhL0QtVmu6XPZG9NEuITAZ6XH7i7/BF3wupBdw=
github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk=
github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
@ -139,8 +139,8 @@ golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
@ -168,21 +168,21 @@ golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=

View file

@ -72,12 +72,18 @@ func (cb *CellBuffer) put(x int, y int, str string, style Style) (string, int) {
if x >= 0 && y >= 0 && x < cb.w && y < cb.h {
var cl string
c := &cb.cells[(y*cb.w)+x]
g := textWidthOptions.StringGraphemes(str)
for width == 0 && g.Next() {
cluster := g.Value()
cl += cluster
width = g.Width()
str = str[len(cluster):]
if str == c.currStr && c.width > 0 {
// Identical re-Put (a full-screen redraw): the grapheme split is
// unchanged, so reuse the measured width instead of segmenting.
cl, width, str = str, c.width, ""
} else {
g := textWidthOptions.StringGraphemes(str)
for width == 0 && g.Next() {
cluster := g.Value()
cl += cluster
width = g.Width()
str = str[len(cluster):]
}
}
// Wide characters: we want to mark the "wide" cells

View file

@ -243,6 +243,7 @@ type tScreen struct {
legacy bool
hasClipboard bool // true if OSC 52 reported via DA1
finiOnce sync.Once
initFiniLock sync.Mutex
enterUrl string
exitUrl string
setWinSize string
@ -258,6 +259,7 @@ type tScreen struct {
running bool
startTime time.Time
wg sync.WaitGroup
eventWg sync.WaitGroup
mouseFlags MouseFlags
pasteEnabled bool
focusEnabled bool
@ -355,6 +357,20 @@ func (t *tScreen) applyEnvironmentOverrides() {
}
func (t *tScreen) Init() error {
t.initFiniLock.Lock()
defer t.initFiniLock.Unlock()
t.Lock()
if t.fini {
t.Unlock()
return errors.New("screen finalized")
}
if t.running {
t.Unlock()
return errors.New("already initialized")
}
t.Unlock()
if e := t.initialize(); e != nil {
return e
}
@ -525,7 +541,9 @@ func (t *tScreen) processInitQ() {
func (t *tScreen) filterEvents() chan Event {
inQ := make(chan Event, 128)
t.eventWg.Add(1)
go func() {
defer t.eventWg.Done()
for {
var ev Event
select {
@ -541,7 +559,11 @@ func (t *tScreen) filterEvents() chan Event {
}
default:
t.eventQ <- ev
select {
case t.eventQ <- ev:
case <-t.quit:
return
}
}
}
}()
@ -596,6 +618,9 @@ func (t *tScreen) prepareCursorStyles() {
}
func (t *tScreen) Fini() {
t.initFiniLock.Lock()
defer t.initFiniLock.Unlock()
// Ensure that enough time passes for terminals to finish sending
// their initial response (gnome-terminal sends terminal dimensions
// asynchronously later than the response to primary DA for some reason.)
@ -1659,6 +1684,7 @@ func (t *tScreen) Beep() error {
func (t *tScreen) finalize() {
t.disengage()
_ = t.tty.Close()
t.eventWg.Wait()
close(t.eventQ)
}

View file

@ -20,6 +20,7 @@ package tty
import (
"encoding/binary"
"errors"
"fmt"
"sync"
"syscall"
"time"
@ -98,6 +99,38 @@ type inputRecord struct {
data [16]byte
}
func encodeWinKeyRecord(data [16]byte, surrogate *rune) []byte {
keyDown := binary.LittleEndian.Uint32(data[0:]) != 0
repeat := binary.LittleEndian.Uint16(data[4:])
virtualKey := binary.LittleEndian.Uint16(data[6:])
scanCode := binary.LittleEndian.Uint16(data[8:])
// we normally only expect to see ascii, but paste data may come in as UTF-16.
wc := rune(binary.LittleEndian.Uint16(data[10:]))
controlState := binary.LittleEndian.Uint32(data[12:])
if virtualKey != 0 || scanCode != 0 {
kd := 0
if keyDown {
kd = 1
}
return fmt.Appendf(nil, "\x1b[%d;%d;%d;%d;%d;%d_",
virtualKey, scanCode, wc, kd, controlState, max(1, repeat))
}
if !keyDown {
return nil
}
var encoded []byte
decodedRunes := decodeUTF16Rune(surrogate, wc)
for range max(1, repeat) {
for _, decoded := range decodedRunes {
encoded = append(encoded, []byte(string(decoded))...)
}
}
return encoded
}
type winTty struct {
buf chan byte
out syscall.Handle
@ -207,17 +240,11 @@ func (w *winTty) getConsoleInput() error {
ir := rec[i]
switch ir.typ {
case keyEvent:
// we normally only expect to see ascii, but paste data may come in as UTF-16.
wc := rune(binary.LittleEndian.Uint16(ir.data[10:]))
for _, decoded := range decodeUTF16Rune(&w.surrogate, wc) {
for _, chr := range []byte(string(decoded)) {
// We normally expect only to see ASCII (win32-input-mode),
// but apparently pasted data can arrive in UTF-16 here.
select {
case w.buf <- chr:
case <-w.stopQ:
break loop
}
for _, chr := range encodeWinKeyRecord(ir.data, &w.surrogate) {
select {
case w.buf <- chr:
case <-w.stopQ:
break loop
}
}

10
vendor/modules.txt vendored
View file

@ -48,7 +48,7 @@ github.com/fatih/color
# github.com/gdamore/encoding v1.0.1
## explicit; go 1.9
github.com/gdamore/encoding
# github.com/gdamore/tcell/v3 v3.4.0
# github.com/gdamore/tcell/v3 v3.4.1-0.20260703153331-243630d2fb59
## explicit; go 1.25.0
github.com/gdamore/tcell/v3
github.com/gdamore/tcell/v3/color
@ -175,7 +175,7 @@ github.com/xo/terminfo
## explicit; go 1.20
golang.org/x/exp/constraints
golang.org/x/exp/slices
# golang.org/x/mod v0.35.0
# golang.org/x/mod v0.36.0
## explicit; go 1.25.0
golang.org/x/mod/internal/lazyregexp
golang.org/x/mod/modfile
@ -192,10 +192,10 @@ golang.org/x/sync/semaphore
golang.org/x/sys/plan9
golang.org/x/sys/unix
golang.org/x/sys/windows
# golang.org/x/term v0.43.0
# golang.org/x/term v0.44.0
## explicit; go 1.25.0
golang.org/x/term
# golang.org/x/text v0.37.0
# golang.org/x/text v0.38.0
## explicit; go 1.25.0
golang.org/x/text/cases
golang.org/x/text/encoding
@ -208,7 +208,7 @@ golang.org/x/text/language
golang.org/x/text/runes
golang.org/x/text/transform
golang.org/x/text/unicode/norm
# golang.org/x/tools v0.44.0
# golang.org/x/tools v0.45.0
## explicit; go 1.25.0
golang.org/x/tools/go/ast/astutil
# gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c