From 14d717d77f7ac2e722656d5405cac61b29d54379 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 17 Jul 2026 08:41:25 +0200 Subject: [PATCH] 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 --- go.mod | 10 ++-- go.sum | 20 ++++---- vendor/github.com/gdamore/tcell/v3/cell.go | 18 ++++--- vendor/github.com/gdamore/tcell/v3/tscreen.go | 28 ++++++++++- .../gdamore/tcell/v3/tty/tty_win.go | 49 ++++++++++++++----- vendor/modules.txt | 10 ++-- 6 files changed, 97 insertions(+), 38 deletions(-) diff --git a/go.mod b/go.mod index c10004176..12af7493b 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 1ca3151b4..2f734de11 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/vendor/github.com/gdamore/tcell/v3/cell.go b/vendor/github.com/gdamore/tcell/v3/cell.go index cbe2732de..b3be03b13 100644 --- a/vendor/github.com/gdamore/tcell/v3/cell.go +++ b/vendor/github.com/gdamore/tcell/v3/cell.go @@ -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 diff --git a/vendor/github.com/gdamore/tcell/v3/tscreen.go b/vendor/github.com/gdamore/tcell/v3/tscreen.go index 2fa180f63..9fe3cf8fe 100644 --- a/vendor/github.com/gdamore/tcell/v3/tscreen.go +++ b/vendor/github.com/gdamore/tcell/v3/tscreen.go @@ -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) } diff --git a/vendor/github.com/gdamore/tcell/v3/tty/tty_win.go b/vendor/github.com/gdamore/tcell/v3/tty/tty_win.go index 853c1e136..e17cd7dab 100644 --- a/vendor/github.com/gdamore/tcell/v3/tty/tty_win.go +++ b/vendor/github.com/gdamore/tcell/v3/tty/tty_win.go @@ -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 } } diff --git a/vendor/modules.txt b/vendor/modules.txt index 3b50432bf..2c60d9282 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -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