detached 6168668

fix(xwm): harden selection transfers on close, late replies and theme truncation

commit
61686680ec5d9fcc62e6c54439c67b98ddc7972c
author
brice · · 2026-10-22 16:55 UTC
committer
brice · · 2026-10-22 17:55 UTC
parent
8fb84bf
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +74 -3
M internal/adapters/wayland/xwm/selection.go +8 -2
@@ -119,6 +119,7 @@ type selBridge struct {
119119 // active is the transfer that owns the conversion property, -1 none.
120120 active int
121121 timerOn bool
122+ closing bool // selClose ran: no transfer may start
122123 cancel func()
123124 tickFn func()
124125 }
@@ -320,7 +321,7 @@ func (x *XWM) SelectionReceive(kind int, mime string, fd int) {
320321 // inStartNext starts the oldest queued transfer when none is converting.
321322 func (x *XWM) inStartNext() {
322323 b := &x.sel
323- if b.active >= 0 {
324+ if b.active >= 0 || b.closing {
324325 return
325326 }
326327 best := -1
@@ -384,7 +385,10 @@ func (x *XWM) onDataNotify(e xproto.SelectionNotifyEvent) {
384385 return
385386 }
386387 t := &x.sel.in[i]
387- if t.state != inConverting || uint32(e.Target()) != t.target {
388+ if t.state != inConverting || uint32(e.Target()) != t.target || uint32(e.Selection()) != x.sel.st[t.kind].atom {
389+ return
390+ }
391+ if p := uint32(e.Property()); p != 0 && p != x.atoms[atomNeferwlData] {
388392 return
389393 }
390394 if e.Property() == 0 {
@@ -629,6 +633,7 @@ func (x *XWM) selTick() {
629633
630634 // selClose aborts every transfer.
631635 func (x *XWM) selClose() {
636+ x.sel.closing = true
632637 for i := range x.sel.out {
633638 if x.sel.out[i].state != outFree {
634639 x.outAbort(i)
@@ -643,6 +648,7 @@ func (x *XWM) selClose() {
643648 x.sel.cancel()
644649 x.sel.cancel = nil
645650 }
651+ x.sel.timerOn = false
646652 }
647653
648654 // ---- Wayland -> X ----
@@ -119,6 +119,7 @@ type selBridge struct {
119 // active is the transfer that owns the conversion property, -1 none.119 // active is the transfer that owns the conversion property, -1 none.
120 active int120 active int
121 timerOn bool121 timerOn bool
122+ closing bool // selClose ran: no transfer may start
122 cancel func()123 cancel func()
123 tickFn func()124 tickFn func()
124 }125 }
@@ -320,7 +321,7 @@ func (x *XWM) SelectionReceive(kind int, mime string, fd int) {
320 // inStartNext starts the oldest queued transfer when none is converting.321 // inStartNext starts the oldest queued transfer when none is converting.
321 func (x *XWM) inStartNext() {322 func (x *XWM) inStartNext() {
322 b := &x.sel323 b := &x.sel
323- if b.active >= 0 {324+ if b.active >= 0 || b.closing {
324 return325 return
325 }326 }
326 best := -1327 best := -1
@@ -384,7 +385,10 @@ func (x *XWM) onDataNotify(e xproto.SelectionNotifyEvent) {
384 return385 return
385 }386 }
386 t := &x.sel.in[i]387 t := &x.sel.in[i]
387- if t.state != inConverting || uint32(e.Target()) != t.target {388+ if t.state != inConverting || uint32(e.Target()) != t.target || uint32(e.Selection()) != x.sel.st[t.kind].atom {
389+ return
390+ }
391+ if p := uint32(e.Property()); p != 0 && p != x.atoms[atomNeferwlData] {
388 return392 return
389 }393 }
390 if e.Property() == 0 {394 if e.Property() == 0 {
@@ -629,6 +633,7 @@ func (x *XWM) selTick() {
629 633
630 // selClose aborts every transfer.634 // selClose aborts every transfer.
631 func (x *XWM) selClose() {635 func (x *XWM) selClose() {
636+ x.sel.closing = true
632 for i := range x.sel.out {637 for i := range x.sel.out {
633 if x.sel.out[i].state != outFree {638 if x.sel.out[i].state != outFree {
634 x.outAbort(i)639 x.outAbort(i)
@@ -643,6 +648,7 @@ func (x *XWM) selClose() {
643 x.sel.cancel()648 x.sel.cancel()
644 x.sel.cancel = nil649 x.sel.cancel = nil
645 }650 }
651+ x.sel.timerOn = false
646 }652 }
647 653
648 // ---- Wayland -> X ----654 // ---- Wayland -> X ----
M internal/adapters/wayland/xwm/selection_test.go +49 -0
@@ -735,3 +735,52 @@ func newTimerLoop(t *testing.T) *mockloop {
735735 func(time.Duration, func()) func() { return func() {} }).Maybe()
736736 return l
737737 }
738+
739+// A late SelectionNotify of an aborted transfer (other selection or property)
740+// is not taken by the next transfer.
741+func TestSelectionLateReplyIgnored(t *testing.T) {
742+ r := ownedRig(t)
743+ _ = r.receive(t, 0)
744+ _, n := pumpReq(t, r.x, r.p, r.skip, func(q xpeerReq) bool { return q.major == 24 })
745+ r.skip = n
746+ for i := 0; i < xIdleMax; i++ {
747+ r.timer()
748+ }
749+ require.Equal(t, inFree, r.x.sel.in[0].state)
750+ _ = r.receive(t, 0)
751+ require.Equal(t, inConverting, r.x.sel.in[0].state)
752+ prim := r.p.atom("PRIMARY")
753+ r.p.selectionNotify(r.x.selWin, prim, r.utf8, r.data)
754+ r.p.selectionNotify(r.x.selWin, r.clip, r.utf8, r.p.atom("_NEFERWL_TARGETS"))
755+ ev := r.x.Stats().Events
756+ readUntil(t, r.x, func() bool { return r.x.Stats().Events >= ev+2 })
757+ require.Equal(t, inConverting, r.x.sel.in[0].state)
758+}
759+
760+// CloseConn closes the fds of in-flight transfers.
761+func TestSelectionCloseConnClosesFDs(t *testing.T) {
762+ r := ownedRig(t)
763+ f := r.receive(t, 0)
764+ r.x.CloseConn()
765+ _, err := f.Read(make([]byte, 8))
766+ require.ErrorIs(t, err, io.EOF)
767+}
768+
769+// Closing never starts a queued transfer's ConvertSelection.
770+func TestSelectionCloseDoesNotStartQueued(t *testing.T) {
771+ r := ownedRig(t)
772+ _ = r.receive(t, 0)
773+ _ = r.receive(t, 0)
774+ _, n := pumpReq(t, r.x, r.p, r.skip, func(q xpeerReq) bool { return q.major == 24 })
775+ r.x.selClose()
776+ r.x.flush()
777+ time.Sleep(50 * time.Millisecond)
778+ cs := 0
779+ for _, q := range since(r.p, r.skip, 24) {
780+ cs++
781+ _ = q
782+ }
783+ _ = n
784+ require.Equal(t, 1, cs)
785+ require.False(t, r.x.sel.timerOn)
786+}
@@ -735,3 +735,52 @@ func newTimerLoop(t *testing.T) *mockloop {
735 func(time.Duration, func()) func() { return func() {} }).Maybe()735 func(time.Duration, func()) func() { return func() {} }).Maybe()
736 return l736 return l
737 }737 }
738+
739+// A late SelectionNotify of an aborted transfer (other selection or property)
740+// is not taken by the next transfer.
741+func TestSelectionLateReplyIgnored(t *testing.T) {
742+ r := ownedRig(t)
743+ _ = r.receive(t, 0)
744+ _, n := pumpReq(t, r.x, r.p, r.skip, func(q xpeerReq) bool { return q.major == 24 })
745+ r.skip = n
746+ for i := 0; i < xIdleMax; i++ {
747+ r.timer()
748+ }
749+ require.Equal(t, inFree, r.x.sel.in[0].state)
750+ _ = r.receive(t, 0)
751+ require.Equal(t, inConverting, r.x.sel.in[0].state)
752+ prim := r.p.atom("PRIMARY")
753+ r.p.selectionNotify(r.x.selWin, prim, r.utf8, r.data)
754+ r.p.selectionNotify(r.x.selWin, r.clip, r.utf8, r.p.atom("_NEFERWL_TARGETS"))
755+ ev := r.x.Stats().Events
756+ readUntil(t, r.x, func() bool { return r.x.Stats().Events >= ev+2 })
757+ require.Equal(t, inConverting, r.x.sel.in[0].state)
758+}
759+
760+// CloseConn closes the fds of in-flight transfers.
761+func TestSelectionCloseConnClosesFDs(t *testing.T) {
762+ r := ownedRig(t)
763+ f := r.receive(t, 0)
764+ r.x.CloseConn()
765+ _, err := f.Read(make([]byte, 8))
766+ require.ErrorIs(t, err, io.EOF)
767+}
768+
769+// Closing never starts a queued transfer's ConvertSelection.
770+func TestSelectionCloseDoesNotStartQueued(t *testing.T) {
771+ r := ownedRig(t)
772+ _ = r.receive(t, 0)
773+ _ = r.receive(t, 0)
774+ _, n := pumpReq(t, r.x, r.p, r.skip, func(q xpeerReq) bool { return q.major == 24 })
775+ r.x.selClose()
776+ r.x.flush()
777+ time.Sleep(50 * time.Millisecond)
778+ cs := 0
779+ for _, q := range since(r.p, r.skip, 24) {
780+ cs++
781+ _ = q
782+ }
783+ _ = n
784+ require.Equal(t, 1, cs)
785+ require.False(t, r.x.sel.timerOn)
786+}
M internal/adapters/wayland/xwm/selection_xsettings.go +6 -1
@@ -3,6 +3,7 @@ package xwm
33 import (
44 "encoding/binary"
55 "math"
6+ "unicode/utf8"
67
78 "bnema.dev/xturbo"
89 "bnema.dev/xturbo/xproto"
@@ -53,7 +54,11 @@ func (x *XWM) cursorThemeName() string {
5354 t = "default"
5455 }
5556 if len(t) > xsMaxString {
56- t = t[:xsMaxString]
57+ n := xsMaxString
58+ for n > 0 && !utf8.RuneStart(t[n]) { // back off to a rune boundary
59+ n--
60+ }
61+ t = t[:n]
5762 }
5863 return t
5964 }
@@ -3,6 +3,7 @@ package xwm
3 import (3 import (
4 "encoding/binary"4 "encoding/binary"
5 "math"5 "math"
6+ "unicode/utf8"
6 7
7 "bnema.dev/xturbo"8 "bnema.dev/xturbo"
8 "bnema.dev/xturbo/xproto"9 "bnema.dev/xturbo/xproto"
@@ -53,7 +54,11 @@ func (x *XWM) cursorThemeName() string {
53 t = "default"54 t = "default"
54 }55 }
55 if len(t) > xsMaxString {56 if len(t) > xsMaxString {
56- t = t[:xsMaxString]57+ n := xsMaxString
58+ for n > 0 && !utf8.RuneStart(t[n]) { // back off to a rune boundary
59+ n--
60+ }
61+ t = t[:n]
57 }62 }
58 return t63 return t
59 }64 }
M internal/adapters/wayland/xwm/xsettings_test.go +10 -0
@@ -3,7 +3,9 @@ package xwm
33 import (
44 "context"
55 "encoding/binary"
6+ "strings"
67 "testing"
8+ "unicode/utf8"
79
810 "github.com/bnema/neferwl/internal/ports"
911 "github.com/stretchr/testify/require"
@@ -217,3 +219,11 @@ func TestXSettingsWindowNotAdopted(t *testing.T) {
217219 x.flush()
218220 require.Zero(t, x.tab.lookup(x.xs.win))
219221 }
222+
223+func TestCursorThemeNameRuneBoundary(t *testing.T) {
224+ x := &XWM{}
225+ x.cfg.CursorTheme = strings.Repeat("a", xsMaxString-1) + "é" // é straddles the limit
226+ got := x.cursorThemeName()
227+ require.True(t, utf8.ValidString(got))
228+ require.Equal(t, strings.Repeat("a", xsMaxString-1), got)
229+}
@@ -3,7 +3,9 @@ package xwm
3 import (3 import (
4 "context"4 "context"
5 "encoding/binary"5 "encoding/binary"
6+ "strings"
6 "testing"7 "testing"
8+ "unicode/utf8"
7 9
8 "github.com/bnema/neferwl/internal/ports"10 "github.com/bnema/neferwl/internal/ports"
9 "github.com/stretchr/testify/require"11 "github.com/stretchr/testify/require"
@@ -217,3 +219,11 @@ func TestXSettingsWindowNotAdopted(t *testing.T) {
217 x.flush()219 x.flush()
218 require.Zero(t, x.tab.lookup(x.xs.win))220 require.Zero(t, x.tab.lookup(x.xs.win))
219 }221 }
222+
223+func TestCursorThemeNameRuneBoundary(t *testing.T) {
224+ x := &XWM{}
225+ x.cfg.CursorTheme = strings.Repeat("a", xsMaxString-1) + "é" // é straddles the limit
226+ got := x.cursorThemeName()
227+ require.True(t, utf8.ValidString(got))
228+ require.Equal(t, strings.Repeat("a", xsMaxString-1), got)
229+}
M internal/adapters/wayland/xwm/xwm.go +1 -0
@@ -427,6 +427,7 @@ func (x *XWM) Close() {
427427 // CloseConn closes the connection of an XWM that was never attached.
428428 func (x *XWM) CloseConn() {
429429 if !x.closed {
430+ x.selClose()
430431 x.closed = true
431432 _ = x.conn.Close()
432433 }
@@ -427,6 +427,7 @@ func (x *XWM) Close() {
427 // CloseConn closes the connection of an XWM that was never attached.427 // CloseConn closes the connection of an XWM that was never attached.
428 func (x *XWM) CloseConn() {428 func (x *XWM) CloseConn() {
429 if !x.closed {429 if !x.closed {
430+ x.selClose()
430 x.closed = true431 x.closed = true
431 _ = x.conn.Close()432 _ = x.conn.Close()
432 }433 }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 26

Tags 10

↑↓ moveenter openesc close