detached a50e31e

fix(xwm): drop stale TARGETS replies after Wayland takes ownership

commit
a50e31e5c9b5f4f2d66a0b2237cb33a3c1c84096
author
brice · · 2026-10-22 16:50 UTC
committer
brice · · 2026-10-22 17:55 UTC
parent
4afca7a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +33 -4
M internal/adapters/wayland/xwm/selection.go +8 -4
@@ -32,6 +32,7 @@ var (
3232 type selState struct {
3333 atom uint32
3434 gen uint32 // bumped on every ownership change; stales TARGETS probes
35+ probe uint32 // gen when the TARGETS ConvertSelection was issued
3536 owner uint32 // X owner window other than selWin, 0 none
3637 ours bool // selWin owns the selection (Wayland -> X)
3738 // wl is what the Wayland side offers while ours: raw text availability.
@@ -206,6 +207,7 @@ func (x *XWM) onXFixesSelection(ev *xturbo.Event) {
206207 x.host.SelectionOwned(k, nil)
207208 return
208209 }
210+ st.probe = st.gen
209211 x.log.Debug().Int("kind", k).Uint32("owner", owner).Msg("selection: X owner")
210212 xproto.ConvertSelection(x.conn.Buf(), xproto.Window(x.selWin), xproto.Atom(st.atom),
211213 xproto.Atom(x.atoms[atomTargets]), xproto.Atom(x.atoms[atomNeferwlTargets]), 0)
@@ -227,10 +229,12 @@ func (x *XWM) onSelectionNotify(ev *xturbo.Event) {
227229 return
228230 }
229231 {
232+ st := &x.sel.st[k]
233+ if st.ours || st.owner == 0 || st.probe != st.gen { // stale probe
234+ return
235+ }
230236 if e.Property() == 0 {
231- if x.sel.st[k].owner != 0 {
232- x.host.SelectionOwned(k, nil)
233- }
237+ x.host.SelectionOwned(k, nil)
234238 return
235239 }
236240 seq := xproto.GetProperty(x.conn.Buf(), true, xproto.Window(x.selWin), xproto.Atom(x.atoms[atomNeferwlTargets]), 0, 0, 256)
@@ -244,7 +248,7 @@ func (x *XWM) onSelectionNotify(ev *xturbo.Event) {
244248 // to Wayland: the text subset, empty for images, files and the like.
245249 func (x *XWM) onTargetsReply(r *xturbo.Reply) {
246250 k := int(r.Args[0])
247- if k < 0 || k >= selKinds || r.Args[1] != x.sel.st[k].gen {
251+ if k < 0 || k >= selKinds || r.Args[1] != x.sel.st[k].gen || x.sel.st[k].ours || x.sel.st[k].owner == 0 || x.sel.st[k].probe != r.Args[1] {
248252 return
249253 }
250254 g, ok := xproto.ParseGetPropertyReply(r.Raw)
@@ -32,6 +32,7 @@ var (
32 type selState struct {32 type selState struct {
33 atom uint3233 atom uint32
34 gen uint32 // bumped on every ownership change; stales TARGETS probes34 gen uint32 // bumped on every ownership change; stales TARGETS probes
35+ probe uint32 // gen when the TARGETS ConvertSelection was issued
35 owner uint32 // X owner window other than selWin, 0 none36 owner uint32 // X owner window other than selWin, 0 none
36 ours bool // selWin owns the selection (Wayland -> X)37 ours bool // selWin owns the selection (Wayland -> X)
37 // wl is what the Wayland side offers while ours: raw text availability.38 // wl is what the Wayland side offers while ours: raw text availability.
@@ -206,6 +207,7 @@ func (x *XWM) onXFixesSelection(ev *xturbo.Event) {
206 x.host.SelectionOwned(k, nil)207 x.host.SelectionOwned(k, nil)
207 return208 return
208 }209 }
210+ st.probe = st.gen
209 x.log.Debug().Int("kind", k).Uint32("owner", owner).Msg("selection: X owner")211 x.log.Debug().Int("kind", k).Uint32("owner", owner).Msg("selection: X owner")
210 xproto.ConvertSelection(x.conn.Buf(), xproto.Window(x.selWin), xproto.Atom(st.atom),212 xproto.ConvertSelection(x.conn.Buf(), xproto.Window(x.selWin), xproto.Atom(st.atom),
211 xproto.Atom(x.atoms[atomTargets]), xproto.Atom(x.atoms[atomNeferwlTargets]), 0)213 xproto.Atom(x.atoms[atomTargets]), xproto.Atom(x.atoms[atomNeferwlTargets]), 0)
@@ -227,10 +229,12 @@ func (x *XWM) onSelectionNotify(ev *xturbo.Event) {
227 return229 return
228 }230 }
229 {231 {
232+ st := &x.sel.st[k]
233+ if st.ours || st.owner == 0 || st.probe != st.gen { // stale probe
234+ return
235+ }
230 if e.Property() == 0 {236 if e.Property() == 0 {
231- if x.sel.st[k].owner != 0 {237+ x.host.SelectionOwned(k, nil)
232- x.host.SelectionOwned(k, nil)
233- }
234 return238 return
235 }239 }
236 seq := xproto.GetProperty(x.conn.Buf(), true, xproto.Window(x.selWin), xproto.Atom(x.atoms[atomNeferwlTargets]), 0, 0, 256)240 seq := xproto.GetProperty(x.conn.Buf(), true, xproto.Window(x.selWin), xproto.Atom(x.atoms[atomNeferwlTargets]), 0, 0, 256)
@@ -244,7 +248,7 @@ func (x *XWM) onSelectionNotify(ev *xturbo.Event) {
244 // to Wayland: the text subset, empty for images, files and the like.248 // to Wayland: the text subset, empty for images, files and the like.
245 func (x *XWM) onTargetsReply(r *xturbo.Reply) {249 func (x *XWM) onTargetsReply(r *xturbo.Reply) {
246 k := int(r.Args[0])250 k := int(r.Args[0])
247- if k < 0 || k >= selKinds || r.Args[1] != x.sel.st[k].gen {251+ if k < 0 || k >= selKinds || r.Args[1] != x.sel.st[k].gen || x.sel.st[k].ours || x.sel.st[k].owner == 0 || x.sel.st[k].probe != r.Args[1] {
248 return252 return
249 }253 }
250 g, ok := xproto.ParseGetPropertyReply(r.Raw)254 g, ok := xproto.ParseGetPropertyReply(r.Raw)
M internal/adapters/wayland/xwm/selection_test.go +25 -0
@@ -683,3 +683,28 @@ func TestSelectionCopyAllocationsWaylandToX(t *testing.T) {
683683 require.Zero(t, allocs)
684684 require.Equal(t, outFree, x.sel.out[0].state)
685685 }
686+
687+// A TARGETS reply that arrives after Wayland took the selection is dropped.
688+func TestSelectionStaleTargetsAfterWaylandOwner(t *testing.T) {
689+ x, p, h, _, _ := attached(t)
690+ const owner = 0x500010
691+ clip := p.atom("CLIPBOARD")
692+ tgt := p.atom("TARGETS")
693+ p.xfixesSelection(owner, clip)
694+ req, _ := pumpReq(t, x, p, 0, func(r xpeerReq) bool { return r.major == 24 })
695+ prop := req.u32(16)
696+ x.WaylandSelection(SelClipboard, []string{"text/plain"})
697+ p.setProp(x.selWin, prop, 4, 32, le32(tgt, p.atom("UTF8_STRING")))
698+ p.selectionNotify(x.selWin, clip, tgt, prop) // the mock fails on SelectionOwned
699+ ev := x.Stats().Events
700+ readUntil(t, x, func() bool { return x.Stats().Events > ev })
701+ x.flush()
702+ require.True(t, x.sel.st[SelClipboard].ours)
703+ for i := 0; i < 20; i++ { // let any (wrong) GetProperty round trip finish
704+ x.onReadable()
705+ x.flush()
706+ time.Sleep(time.Millisecond)
707+ }
708+ require.Zero(t, getProps(p, 0, x.selWin), "the stale TARGETS reply is not fetched")
709+ _ = h
710+}
@@ -683,3 +683,28 @@ func TestSelectionCopyAllocationsWaylandToX(t *testing.T) {
683 require.Zero(t, allocs)683 require.Zero(t, allocs)
684 require.Equal(t, outFree, x.sel.out[0].state)684 require.Equal(t, outFree, x.sel.out[0].state)
685 }685 }
686+
687+// A TARGETS reply that arrives after Wayland took the selection is dropped.
688+func TestSelectionStaleTargetsAfterWaylandOwner(t *testing.T) {
689+ x, p, h, _, _ := attached(t)
690+ const owner = 0x500010
691+ clip := p.atom("CLIPBOARD")
692+ tgt := p.atom("TARGETS")
693+ p.xfixesSelection(owner, clip)
694+ req, _ := pumpReq(t, x, p, 0, func(r xpeerReq) bool { return r.major == 24 })
695+ prop := req.u32(16)
696+ x.WaylandSelection(SelClipboard, []string{"text/plain"})
697+ p.setProp(x.selWin, prop, 4, 32, le32(tgt, p.atom("UTF8_STRING")))
698+ p.selectionNotify(x.selWin, clip, tgt, prop) // the mock fails on SelectionOwned
699+ ev := x.Stats().Events
700+ readUntil(t, x, func() bool { return x.Stats().Events > ev })
701+ x.flush()
702+ require.True(t, x.sel.st[SelClipboard].ours)
703+ for i := 0; i < 20; i++ { // let any (wrong) GetProperty round trip finish
704+ x.onReadable()
705+ x.flush()
706+ time.Sleep(time.Millisecond)
707+ }
708+ require.Zero(t, getProps(p, 0, x.selWin), "the stale TARGETS reply is not fetched")
709+ _ = h
710+}

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 23

Tags 10

↑↓ moveenter openesc close