detached 4035b00

feat(xwm): X to Wayland text selection transfers with INCR, buffer pool and timeouts

commit
4035b0078c07189194f282cc1e947f5a4ef64b87
author
brice · · 2026-10-22 16:15 UTC
parent
889daca
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +774 -7
M Makefile +1 -1
@@ -70,7 +70,7 @@ adapter-imports:
7070 [ -z "$$bad" ] || { echo "$$bad"; echo 'adapter imports another adapter: use ports or a shared library (Makefile ADAPTER_LIBS)' >&2; exit 1; }
7171 # Fast, mandatory guards for tiled content publications and SHM copies.
7272 perf-check:
73- CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/headless ./internal/adapters/vulkan ./internal/adapters/wayland ./internal/adapters/outputkit/capture ./internal/adapters/screencast ./internal/adapters/wayland/xwm -run '^(TestAnimateAllocations|TestPublishAllocations|TestPublishSubscribersAllocations|TestPublishStashSlideAllocations|TestPublishOverviewAllocations|TestPublishLeavingAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestFlipShowsAllocations|TestMRUNoteFocusDoesNotAllocateWhenSettled|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestRenderSteadyStateDMABufAllocations|TestSceneOpaqueRegionAllocations|TestSceneDeltaAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget|TestReleaseHeldAllocations|TestCommitDamageAllocations|TestEmitContentAllocations|TestPresentationPushAllocations|TestAnimatingFrameSendAllocations|TestContentBatchCycleAllocations|TestWriteMappedAllocations|TestCapturedForwardAllocations|TestCommandForwardAllocations|TestOutboxQueuedDeliveryAllocations|TestCastFrameAllocations|TestCastProducerAllocations|TestCaptureCycleAllocations|TestLogScenesAllocations|TestOverviewNamedRowsAllocations|TestBufferContentAllocations|TestDueFramesFireScratch|TestRetireSemaphoreAllocatesNothing|TestTableLookupAllocations|TestTableInsertRemoveAllocations|TestPropertyUnchangedAllocations|TestDispatchAllocations|TestConfigureFlushAllocations|TestFocusAllocations|TestSpaceAllocations|TestUnmanagedGeometryAllocations|TestFocusInUnmanagedAllocations|TestUnmanagedPublishAllocations|TestUnmanagedHitAllocations|TestOverrideMovedUnchangedAllocations|TestFullscreenConfigureAllocations|TestKeyboardGrabSyncAllocations)$$' -count=1
73+ CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/headless ./internal/adapters/vulkan ./internal/adapters/wayland ./internal/adapters/outputkit/capture ./internal/adapters/screencast ./internal/adapters/wayland/xwm -run '^(TestAnimateAllocations|TestPublishAllocations|TestPublishSubscribersAllocations|TestPublishStashSlideAllocations|TestPublishOverviewAllocations|TestPublishLeavingAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestFlipShowsAllocations|TestMRUNoteFocusDoesNotAllocateWhenSettled|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestRenderSteadyStateDMABufAllocations|TestSceneOpaqueRegionAllocations|TestSceneDeltaAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget|TestReleaseHeldAllocations|TestCommitDamageAllocations|TestEmitContentAllocations|TestPresentationPushAllocations|TestAnimatingFrameSendAllocations|TestContentBatchCycleAllocations|TestWriteMappedAllocations|TestCapturedForwardAllocations|TestCommandForwardAllocations|TestOutboxQueuedDeliveryAllocations|TestCastFrameAllocations|TestCastProducerAllocations|TestCaptureCycleAllocations|TestLogScenesAllocations|TestOverviewNamedRowsAllocations|TestBufferContentAllocations|TestDueFramesFireScratch|TestRetireSemaphoreAllocatesNothing|TestTableLookupAllocations|TestTableInsertRemoveAllocations|TestPropertyUnchangedAllocations|TestDispatchAllocations|TestConfigureFlushAllocations|TestFocusAllocations|TestSpaceAllocations|TestUnmanagedGeometryAllocations|TestFocusInUnmanagedAllocations|TestUnmanagedPublishAllocations|TestUnmanagedHitAllocations|TestOverrideMovedUnchangedAllocations|TestFullscreenConfigureAllocations|TestKeyboardGrabSyncAllocations|TestSelectionCopyAllocations|TestSelectionINCRChunkAllocations)$$' -count=1
7474 CGO_ENABLED=0 go test -tags xturbo_poison ./internal/adapters/wayland/xwm -count=1
7575 check: vet test arch fakes-check log-check perf-check
7676
@@ -70,7 +70,7 @@ adapter-imports:
70 [ -z "$$bad" ] || { echo "$$bad"; echo 'adapter imports another adapter: use ports or a shared library (Makefile ADAPTER_LIBS)' >&2; exit 1; }70 [ -z "$$bad" ] || { echo "$$bad"; echo 'adapter imports another adapter: use ports or a shared library (Makefile ADAPTER_LIBS)' >&2; exit 1; }
71 # Fast, mandatory guards for tiled content publications and SHM copies.71 # Fast, mandatory guards for tiled content publications and SHM copies.
72 perf-check:72 perf-check:
73- CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/headless ./internal/adapters/vulkan ./internal/adapters/wayland ./internal/adapters/outputkit/capture ./internal/adapters/screencast ./internal/adapters/wayland/xwm -run '^(TestAnimateAllocations|TestPublishAllocations|TestPublishSubscribersAllocations|TestPublishStashSlideAllocations|TestPublishOverviewAllocations|TestPublishLeavingAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestFlipShowsAllocations|TestMRUNoteFocusDoesNotAllocateWhenSettled|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestRenderSteadyStateDMABufAllocations|TestSceneOpaqueRegionAllocations|TestSceneDeltaAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget|TestReleaseHeldAllocations|TestCommitDamageAllocations|TestEmitContentAllocations|TestPresentationPushAllocations|TestAnimatingFrameSendAllocations|TestContentBatchCycleAllocations|TestWriteMappedAllocations|TestCapturedForwardAllocations|TestCommandForwardAllocations|TestOutboxQueuedDeliveryAllocations|TestCastFrameAllocations|TestCastProducerAllocations|TestCaptureCycleAllocations|TestLogScenesAllocations|TestOverviewNamedRowsAllocations|TestBufferContentAllocations|TestDueFramesFireScratch|TestRetireSemaphoreAllocatesNothing|TestTableLookupAllocations|TestTableInsertRemoveAllocations|TestPropertyUnchangedAllocations|TestDispatchAllocations|TestConfigureFlushAllocations|TestFocusAllocations|TestSpaceAllocations|TestUnmanagedGeometryAllocations|TestFocusInUnmanagedAllocations|TestUnmanagedPublishAllocations|TestUnmanagedHitAllocations|TestOverrideMovedUnchangedAllocations|TestFullscreenConfigureAllocations|TestKeyboardGrabSyncAllocations)$$' -count=173+ CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/headless ./internal/adapters/vulkan ./internal/adapters/wayland ./internal/adapters/outputkit/capture ./internal/adapters/screencast ./internal/adapters/wayland/xwm -run '^(TestAnimateAllocations|TestPublishAllocations|TestPublishSubscribersAllocations|TestPublishStashSlideAllocations|TestPublishOverviewAllocations|TestPublishLeavingAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestFlipShowsAllocations|TestMRUNoteFocusDoesNotAllocateWhenSettled|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestRenderSteadyStateDMABufAllocations|TestSceneOpaqueRegionAllocations|TestSceneDeltaAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget|TestReleaseHeldAllocations|TestCommitDamageAllocations|TestEmitContentAllocations|TestPresentationPushAllocations|TestAnimatingFrameSendAllocations|TestContentBatchCycleAllocations|TestWriteMappedAllocations|TestCapturedForwardAllocations|TestCommandForwardAllocations|TestOutboxQueuedDeliveryAllocations|TestCastFrameAllocations|TestCastProducerAllocations|TestCaptureCycleAllocations|TestLogScenesAllocations|TestOverviewNamedRowsAllocations|TestBufferContentAllocations|TestDueFramesFireScratch|TestRetireSemaphoreAllocatesNothing|TestTableLookupAllocations|TestTableInsertRemoveAllocations|TestPropertyUnchangedAllocations|TestDispatchAllocations|TestConfigureFlushAllocations|TestFocusAllocations|TestSpaceAllocations|TestUnmanagedGeometryAllocations|TestFocusInUnmanagedAllocations|TestUnmanagedPublishAllocations|TestUnmanagedHitAllocations|TestOverrideMovedUnchangedAllocations|TestFullscreenConfigureAllocations|TestKeyboardGrabSyncAllocations|TestSelectionCopyAllocations|TestSelectionINCRChunkAllocations)$$' -count=1
74 CGO_ENABLED=0 go test -tags xturbo_poison ./internal/adapters/wayland/xwm -count=174 CGO_ENABLED=0 go test -tags xturbo_poison ./internal/adapters/wayland/xwm -count=1
75 check: vet test arch fakes-check log-check perf-check75 check: vet test arch fakes-check log-check perf-check
76 76
M internal/adapters/wayland/xwm/props.go +4 -0
@@ -113,6 +113,10 @@ func (x *XWM) requestProp(s Slot, idx int) {
113113 // being mapped or is mapped; unwatched atoms cost nothing.
114114 func (x *XWM) onPropertyNotify(ev *xturbo.Event) {
115115 e := xproto.PropertyNotifyOf(ev)
116+ if uint32(e.Window()) == x.selWin && x.selWin != 0 {
117+ x.onSelProperty(ev)
118+ return
119+ }
116120 s := x.tab.lookup(uint32(e.Window()))
117121 if s == 0 {
118122 return
@@ -113,6 +113,10 @@ func (x *XWM) requestProp(s Slot, idx int) {
113 // being mapped or is mapped; unwatched atoms cost nothing.113 // being mapped or is mapped; unwatched atoms cost nothing.
114 func (x *XWM) onPropertyNotify(ev *xturbo.Event) {114 func (x *XWM) onPropertyNotify(ev *xturbo.Event) {
115 e := xproto.PropertyNotifyOf(ev)115 e := xproto.PropertyNotifyOf(ev)
116+ if uint32(e.Window()) == x.selWin && x.selWin != 0 {
117+ x.onSelProperty(ev)
118+ return
119+ }
116 s := x.tab.lookup(uint32(e.Window()))120 s := x.tab.lookup(uint32(e.Window()))
117 if s == 0 {121 if s == 0 {
118 return122 return
M internal/adapters/wayland/xwm/selection.go +429 -3
@@ -2,10 +2,13 @@ package xwm
22
33 import (
44 "encoding/binary"
5+ "errors"
6+ "time"
57
68 "bnema.dev/xturbo"
79 "bnema.dev/xturbo/xfixes"
810 "bnema.dev/xturbo/xproto"
11+ "golang.org/x/sys/unix"
912 )
1013
1114 // The selection bridge: text-only CLIPBOARD and PRIMARY between X clients and
@@ -32,10 +35,99 @@ type selState struct {
3235 owner uint32 // X owner window other than selWin, 0 none
3336 }
3437
38+const (
39+ chunk = 65536 // every transfer step moves at most this many bytes
40+ nXfer = 4 // concurrent transfers per direction, and pool buffers
41+ tickEvery = time.Second
42+ xIdleMax = 10 // ticks without X-side progress
43+ rIdleMax = 30 // ticks of a reader that does not drain its pipe
44+)
45+
46+// bufPool is the fixed set of transfer buffers.
47+type bufPool struct {
48+ bufs [nXfer][chunk]byte
49+ free [nXfer]int8
50+ n int
51+}
52+
53+func (p *bufPool) init() {
54+ for i := range p.free {
55+ p.free[i] = int8(i)
56+ }
57+ p.n = nXfer
58+}
59+
60+// get returns a free buffer index, -1 when the pool is exhausted.
61+func (p *bufPool) get() int {
62+ if p.n == 0 {
63+ return -1
64+ }
65+ p.n--
66+ return int(p.free[p.n])
67+}
68+
69+func (p *bufPool) put(i int) {
70+ p.free[p.n] = int8(i)
71+ p.n++
72+}
73+
74+// X -> Wayland transfer states.
75+const (
76+ inFree uint8 = iota
77+ inQueued // waiting for the conversion property to be free
78+ inConverting
79+ inWaitIncr // waiting for the next INCR chunk (PropertyNotify)
80+ inWriting // a buffer is pending on the Wayland reader's pipe
81+ inReading // a GetProperty is outstanding
82+)
83+
84+// xin is one X -> Wayland transfer.
85+type xin struct {
86+ x *XWM
87+ idx int
88+ state uint8
89+ kind int
90+ fd int
91+ target uint32
92+ order uint64
93+ gen uint32 // stales replies of an aborted transfer
94+ incr bool
95+ buf int
96+ off, n int
97+ words uint32 // long offset of the next piece of a long property
98+ more bool // the last piece left bytes in the property
99+ ready bool // the owner wrote the next INCR chunk while a buffer was pending
100+ idle int
101+ src fdSource
102+ onR func() // pipe hangup
103+ onW func() // pipe writable
104+}
105+
35106 // selBridge is the XWM's selection state.
36107 type selBridge struct {
37108 st [selKinds]selState
38109 mimes [len(textMimes)]string // scratch handed to SelectionOwned
110+ pool bufPool
111+ in [nXfer]xin
112+ order uint64
113+ // active is the transfer that owns the conversion property, -1 none.
114+ active int
115+ timerOn bool
116+ cancel func()
117+ tickFn func()
118+}
119+
120+func (x *XWM) initSelection() {
121+ b := &x.sel
122+ b.pool.init()
123+ b.active = -1
124+ b.tickFn = x.selTick
125+ for i := range b.in {
126+ t := &b.in[i]
127+ t.x, t.idx, t.buf = x, i, -1
128+ t.onR = func() { t.x.inAbort(t.idx, true) }
129+ t.onW = func() { t.x.inWritable(t.idx) }
130+ }
39131 }
40132
41133 // bootSelection creates the selection window and asks for ownership events.
@@ -116,7 +208,11 @@ func (x *XWM) onSelectionNotify(ev *xturbo.Event) {
116208 if k < 0 {
117209 return
118210 }
119- if uint32(e.Target()) == x.atoms[atomTargets] {
211+ if uint32(e.Target()) != x.atoms[atomTargets] {
212+ x.onDataNotify(e)
213+ return
214+ }
215+ {
120216 if e.Property() == 0 {
121217 if x.sel.st[k].owner != 0 {
122218 x.host.SelectionOwned(k, nil)
@@ -167,7 +263,337 @@ func (x *XWM) onTargetsReply(r *xturbo.Reply) {
167263 x.host.SelectionOwned(k, x.sel.mimes[:n])
168264 }
169265
266+// ---- X -> Wayland ----
267+
268+// SelectionReceive: a Wayland reader wants the X selection kind as mime. The
269+// XWM takes fd (a write end it now owns) and closes it when done.
270+func (x *XWM) SelectionReceive(kind int, mime string, fd int) {
271+ target := uint32(0)
272+ for i, m := range textMimes {
273+ if m == mime {
274+ target = x.atoms[mimeTarget[i]]
275+ break
276+ }
277+ }
278+ if x.selWin == 0 || x.closed || kind < 0 || kind >= selKinds || target == 0 || x.sel.st[kind].owner == 0 {
279+ _ = unix.Close(fd)
280+ return
281+ }
282+ if unix.SetNonblock(fd, true) != nil {
283+ _ = unix.Close(fd)
284+ return
285+ }
286+ for i := range x.sel.in {
287+ t := &x.sel.in[i]
288+ if t.state != inFree {
289+ continue
290+ }
291+ x.sel.order++
292+ t.state, t.kind, t.fd, t.target, t.order = inQueued, kind, fd, target, x.sel.order
293+ t.incr, t.buf, t.off, t.n, t.words, t.more, t.ready, t.idle, t.src = false, -1, 0, 0, 0, false, false, 0, nil
294+ x.selEnsureTimer()
295+ x.inStartNext()
296+ return
297+ }
298+ x.log.Warn().Msg("selection: too many transfers, paste refused")
299+ _ = unix.Close(fd)
300+}
301+
302+// inStartNext starts the oldest queued transfer when none is converting.
303+func (x *XWM) inStartNext() {
304+ b := &x.sel
305+ if b.active >= 0 {
306+ return
307+ }
308+ best := -1
309+ for i := range b.in {
310+ if b.in[i].state == inQueued && (best < 0 || b.in[i].order < b.in[best].order) {
311+ best = i
312+ }
313+ }
314+ if best < 0 {
315+ return
316+ }
317+ t := &b.in[best]
318+ t.state, t.idle = inConverting, 0
319+ b.active = best
320+ xproto.ConvertSelection(x.conn.Buf(), xproto.Window(x.selWin), xproto.Atom(b.st[t.kind].atom),
321+ xproto.Atom(t.target), xproto.Atom(x.atoms[atomNeferwlData]), 0)
322+ x.wake()
323+}
324+
325+// inFinish ends transfer i and starts the next one.
326+func (x *XWM) inFinish(i int) {
327+ b := &x.sel
328+ t := &b.in[i]
329+ if t.src != nil {
330+ t.src.Remove()
331+ t.src = nil
332+ }
333+ if t.fd >= 0 {
334+ _ = unix.Close(t.fd)
335+ }
336+ if t.buf >= 0 {
337+ b.pool.put(t.buf)
338+ t.buf = -1
339+ }
340+ t.gen++
341+ t.state, t.fd = inFree, -1
342+ if b.active == i {
343+ b.active = -1
344+ }
345+ x.inStartNext()
346+}
347+
348+// inAbort drops transfer i, deleting the conversion property when X may still
349+// be writing it.
350+func (x *XWM) inAbort(i int, del bool) {
351+ t := &x.sel.in[i]
352+ if t.state == inFree {
353+ return
354+ }
355+ if del && x.sel.active == i && !x.closed {
356+ xproto.DeleteProperty(x.conn.Buf(), xproto.Window(x.selWin), xproto.Atom(x.atoms[atomNeferwlData]))
357+ x.wake()
358+ }
359+ x.inFinish(i)
360+}
361+
362+// onDataNotify: the owner answered our data ConvertSelection.
363+func (x *XWM) onDataNotify(e xproto.SelectionNotifyEvent) {
364+ i := x.sel.active
365+ if i < 0 {
366+ return
367+ }
368+ t := &x.sel.in[i]
369+ if t.state != inConverting || uint32(e.Target()) != t.target {
370+ return
371+ }
372+ if e.Property() == 0 {
373+ x.inFinish(i)
374+ return
375+ }
376+ t.words = 0
377+ x.inRequest(i)
378+}
379+
380+// inRequest asks for the next piece of the property; delete makes INCR and
381+// whole-value reads advance.
382+func (x *XWM) inRequest(i int) {
383+ t := &x.sel.in[i]
384+ t.state = inReading
385+ seq := xproto.GetProperty(x.conn.Buf(), true, xproto.Window(x.selWin), xproto.Atom(x.atoms[atomNeferwlData]),
386+ 0, t.words, chunk/4)
387+ if seq != 0 {
388+ if err := x.conn.Expect(seq, kindSelData, uint32(i), t.gen, 0, 0); err != nil {
389+ x.stats.RingFull++
390+ }
391+ }
392+ x.wake()
393+}
394+
395+// onSelDataReply handles one piece of the converted property.
396+func (x *XWM) onSelDataReply(r *xturbo.Reply) {
397+ i := int(r.Args[0])
398+ if i < 0 || i >= nXfer {
399+ return
400+ }
401+ t := &x.sel.in[i]
402+ if t.state != inReading || t.gen != r.Args[1] {
403+ return
404+ }
405+ g, ok := xproto.ParseGetPropertyReply(r.Raw)
406+ if !ok || g.Format() == 0 {
407+ x.inAbort(i, false)
408+ return
409+ }
410+ t.idle = 0
411+ if uint32(g.Type()) == x.atoms[atomIncr] {
412+ t.incr, t.state, t.words, t.more = true, inWaitIncr, 0, false
413+ return
414+ }
415+ v := g.Value()
416+ if g.Format() != 8 || len(v) > chunk {
417+ x.inAbort(i, true)
418+ return
419+ }
420+ t.more = g.BytesAfter() > 0
421+ if len(v) == 0 {
422+ if t.more { // cannot happen: bytes remain only after a non-empty read
423+ x.inAbort(i, true)
424+ return
425+ }
426+ x.inNext(i)
427+ return
428+ }
429+ b := x.sel.pool.get()
430+ if b < 0 {
431+ x.log.Warn().Msg("selection: no free buffer, paste aborted")
432+ x.inAbort(i, true)
433+ return
434+ }
435+ t.buf = b
436+ t.n = copy(x.sel.pool.bufs[b][:], v)
437+ t.off = 0
438+ t.words += uint32(t.n) / 4
439+ t.state = inWriting
440+ x.inDrain(i)
441+}
442+
443+// inDrain writes the pending buffer to the reader's pipe.
444+func (x *XWM) inDrain(i int) {
445+ t := &x.sel.in[i]
446+ buf := &x.sel.pool.bufs[t.buf]
447+ for t.off < t.n {
448+ n, err := unix.Write(t.fd, buf[t.off:t.n])
449+ switch {
450+ case err == nil:
451+ t.off += n
452+ t.idle = 0
453+ case errors.Is(err, unix.EINTR):
454+ case errors.Is(err, unix.EAGAIN):
455+ x.inWatch(i, true)
456+ return
457+ default: // EPIPE: the reader went away
458+ x.inAbort(i, true)
459+ return
460+ }
461+ }
462+ x.sel.pool.put(t.buf)
463+ t.buf = -1
464+ if t.src != nil {
465+ x.inWatch(i, false)
466+ }
467+ x.inNext(i)
468+}
469+
470+// inWatch arms or disarms writable interest on the reader's pipe.
471+func (x *XWM) inWatch(i int, on bool) {
472+ t := &x.sel.in[i]
473+ if t.src == nil {
474+ if !on || x.addFD == nil {
475+ if on {
476+ x.inAbort(i, true)
477+ }
478+ return
479+ }
480+ src, err := x.addFD(t.fd, t.onR, t.onW)
481+ if err != nil {
482+ x.inAbort(i, true)
483+ return
484+ }
485+ t.src = src
486+ }
487+ if err := t.src.SetWritable(on); err != nil {
488+ x.inAbort(i, true)
489+ }
490+}
491+
492+// inWritable: the pipe has room again.
493+func (x *XWM) inWritable(i int) {
494+ t := &x.sel.in[i]
495+ if t.state == inWriting && t.buf >= 0 {
496+ x.inDrain(i)
497+ }
498+}
499+
500+// inNext decides what follows a drained (or empty) piece.
501+func (x *XWM) inNext(i int) {
502+ t := &x.sel.in[i]
503+ switch {
504+ case t.more: // the rest of this property
505+ x.inRequest(i)
506+ case t.incr && t.n > 0:
507+ t.state, t.n, t.words = inWaitIncr, 0, 0
508+ if t.ready {
509+ t.ready = false
510+ x.inRequest(i)
511+ }
512+ default: // single value done, or the empty INCR terminator
513+ x.inFinish(i)
514+ }
515+}
516+
517+// onSelProperty: PropertyNotify on the selection window.
518+func (x *XWM) onSelProperty(ev *xturbo.Event) {
519+ e := xproto.PropertyNotifyOf(ev)
520+ if e.State() != xproto.PropertyNewValue || uint32(e.Atom()) != x.atoms[atomNeferwlData] {
521+ return
522+ }
523+ i := x.sel.active
524+ if i < 0 || !x.sel.in[i].incr {
525+ return
526+ }
527+ t := &x.sel.in[i]
528+ switch t.state {
529+ case inWaitIncr:
530+ t.idle = 0
531+ t.n = 0
532+ x.inRequest(i)
533+ case inWriting, inReading:
534+ t.ready = true // no request while a buffer is pending: backpressure
535+ }
536+}
537+
170538 // onSelError: a selection request failed; the awaited reply never comes.
171-func (x *XWM) onSelError(e *xturbo.XError) {}
539+func (x *XWM) onSelError(e *xturbo.XError) {
540+ if e.Kind == kindSelData {
541+ if i := int(e.Args[0]); i >= 0 && i < nXfer && x.sel.in[i].gen == e.Args[1] && x.sel.in[i].state == inReading {
542+ x.inAbort(i, false)
543+ }
544+ }
545+}
546+
547+// ---- timeouts ----
172548
173-func (x *XWM) onSelDataReply(r *xturbo.Reply) {}
549+func (x *XWM) selEnsureTimer() {
550+ b := &x.sel
551+ if b.timerOn || x.after == nil {
552+ return
553+ }
554+ b.timerOn = true
555+ b.cancel = x.after(tickEvery, b.tickFn)
556+}
557+
558+// selTick ages every transfer by one second and aborts the stalled ones.
559+func (x *XWM) selTick() {
560+ b := &x.sel
561+ b.timerOn, b.cancel = false, nil
562+ if x.closed {
563+ return
564+ }
565+ live := false
566+ for i := range b.in {
567+ t := &b.in[i]
568+ if t.state == inFree {
569+ continue
570+ }
571+ t.idle++
572+ limit := xIdleMax
573+ if t.state == inWriting {
574+ limit = rIdleMax
575+ }
576+ if t.state != inQueued && t.idle >= limit {
577+ x.log.Warn().Int("kind", t.kind).Msg("selection: transfer timed out")
578+ x.inAbort(i, true)
579+ continue
580+ }
581+ live = true
582+ }
583+ if live {
584+ x.selEnsureTimer()
585+ }
586+}
587+
588+// selClose aborts every transfer.
589+func (x *XWM) selClose() {
590+ for i := range x.sel.in {
591+ if x.sel.in[i].state != inFree {
592+ x.inAbort(i, false)
593+ }
594+ }
595+ if x.sel.cancel != nil {
596+ x.sel.cancel()
597+ x.sel.cancel = nil
598+ }
599+}
@@ -2,10 +2,13 @@ package xwm
2 2
3 import (3 import (
4 "encoding/binary"4 "encoding/binary"
5+ "errors"
6+ "time"
5 7
6 "bnema.dev/xturbo"8 "bnema.dev/xturbo"
7 "bnema.dev/xturbo/xfixes"9 "bnema.dev/xturbo/xfixes"
8 "bnema.dev/xturbo/xproto"10 "bnema.dev/xturbo/xproto"
11+ "golang.org/x/sys/unix"
9 )12 )
10 13
11 // The selection bridge: text-only CLIPBOARD and PRIMARY between X clients and14 // The selection bridge: text-only CLIPBOARD and PRIMARY between X clients and
@@ -32,10 +35,99 @@ type selState struct {
32 owner uint32 // X owner window other than selWin, 0 none35 owner uint32 // X owner window other than selWin, 0 none
33 }36 }
34 37
38+const (
39+ chunk = 65536 // every transfer step moves at most this many bytes
40+ nXfer = 4 // concurrent transfers per direction, and pool buffers
41+ tickEvery = time.Second
42+ xIdleMax = 10 // ticks without X-side progress
43+ rIdleMax = 30 // ticks of a reader that does not drain its pipe
44+)
45+
46+// bufPool is the fixed set of transfer buffers.
47+type bufPool struct {
48+ bufs [nXfer][chunk]byte
49+ free [nXfer]int8
50+ n int
51+}
52+
53+func (p *bufPool) init() {
54+ for i := range p.free {
55+ p.free[i] = int8(i)
56+ }
57+ p.n = nXfer
58+}
59+
60+// get returns a free buffer index, -1 when the pool is exhausted.
61+func (p *bufPool) get() int {
62+ if p.n == 0 {
63+ return -1
64+ }
65+ p.n--
66+ return int(p.free[p.n])
67+}
68+
69+func (p *bufPool) put(i int) {
70+ p.free[p.n] = int8(i)
71+ p.n++
72+}
73+
74+// X -> Wayland transfer states.
75+const (
76+ inFree uint8 = iota
77+ inQueued // waiting for the conversion property to be free
78+ inConverting
79+ inWaitIncr // waiting for the next INCR chunk (PropertyNotify)
80+ inWriting // a buffer is pending on the Wayland reader's pipe
81+ inReading // a GetProperty is outstanding
82+)
83+
84+// xin is one X -> Wayland transfer.
85+type xin struct {
86+ x *XWM
87+ idx int
88+ state uint8
89+ kind int
90+ fd int
91+ target uint32
92+ order uint64
93+ gen uint32 // stales replies of an aborted transfer
94+ incr bool
95+ buf int
96+ off, n int
97+ words uint32 // long offset of the next piece of a long property
98+ more bool // the last piece left bytes in the property
99+ ready bool // the owner wrote the next INCR chunk while a buffer was pending
100+ idle int
101+ src fdSource
102+ onR func() // pipe hangup
103+ onW func() // pipe writable
104+}
105+
35 // selBridge is the XWM's selection state.106 // selBridge is the XWM's selection state.
36 type selBridge struct {107 type selBridge struct {
37 st [selKinds]selState108 st [selKinds]selState
38 mimes [len(textMimes)]string // scratch handed to SelectionOwned109 mimes [len(textMimes)]string // scratch handed to SelectionOwned
110+ pool bufPool
111+ in [nXfer]xin
112+ order uint64
113+ // active is the transfer that owns the conversion property, -1 none.
114+ active int
115+ timerOn bool
116+ cancel func()
117+ tickFn func()
118+}
119+
120+func (x *XWM) initSelection() {
121+ b := &x.sel
122+ b.pool.init()
123+ b.active = -1
124+ b.tickFn = x.selTick
125+ for i := range b.in {
126+ t := &b.in[i]
127+ t.x, t.idx, t.buf = x, i, -1
128+ t.onR = func() { t.x.inAbort(t.idx, true) }
129+ t.onW = func() { t.x.inWritable(t.idx) }
130+ }
39 }131 }
40 132
41 // bootSelection creates the selection window and asks for ownership events.133 // bootSelection creates the selection window and asks for ownership events.
@@ -116,7 +208,11 @@ func (x *XWM) onSelectionNotify(ev *xturbo.Event) {
116 if k < 0 {208 if k < 0 {
117 return209 return
118 }210 }
119- if uint32(e.Target()) == x.atoms[atomTargets] {211+ if uint32(e.Target()) != x.atoms[atomTargets] {
212+ x.onDataNotify(e)
213+ return
214+ }
215+ {
120 if e.Property() == 0 {216 if e.Property() == 0 {
121 if x.sel.st[k].owner != 0 {217 if x.sel.st[k].owner != 0 {
122 x.host.SelectionOwned(k, nil)218 x.host.SelectionOwned(k, nil)
@@ -167,7 +263,337 @@ func (x *XWM) onTargetsReply(r *xturbo.Reply) {
167 x.host.SelectionOwned(k, x.sel.mimes[:n])263 x.host.SelectionOwned(k, x.sel.mimes[:n])
168 }264 }
169 265
266+// ---- X -> Wayland ----
267+
268+// SelectionReceive: a Wayland reader wants the X selection kind as mime. The
269+// XWM takes fd (a write end it now owns) and closes it when done.
270+func (x *XWM) SelectionReceive(kind int, mime string, fd int) {
271+ target := uint32(0)
272+ for i, m := range textMimes {
273+ if m == mime {
274+ target = x.atoms[mimeTarget[i]]
275+ break
276+ }
277+ }
278+ if x.selWin == 0 || x.closed || kind < 0 || kind >= selKinds || target == 0 || x.sel.st[kind].owner == 0 {
279+ _ = unix.Close(fd)
280+ return
281+ }
282+ if unix.SetNonblock(fd, true) != nil {
283+ _ = unix.Close(fd)
284+ return
285+ }
286+ for i := range x.sel.in {
287+ t := &x.sel.in[i]
288+ if t.state != inFree {
289+ continue
290+ }
291+ x.sel.order++
292+ t.state, t.kind, t.fd, t.target, t.order = inQueued, kind, fd, target, x.sel.order
293+ t.incr, t.buf, t.off, t.n, t.words, t.more, t.ready, t.idle, t.src = false, -1, 0, 0, 0, false, false, 0, nil
294+ x.selEnsureTimer()
295+ x.inStartNext()
296+ return
297+ }
298+ x.log.Warn().Msg("selection: too many transfers, paste refused")
299+ _ = unix.Close(fd)
300+}
301+
302+// inStartNext starts the oldest queued transfer when none is converting.
303+func (x *XWM) inStartNext() {
304+ b := &x.sel
305+ if b.active >= 0 {
306+ return
307+ }
308+ best := -1
309+ for i := range b.in {
310+ if b.in[i].state == inQueued && (best < 0 || b.in[i].order < b.in[best].order) {
311+ best = i
312+ }
313+ }
314+ if best < 0 {
315+ return
316+ }
317+ t := &b.in[best]
318+ t.state, t.idle = inConverting, 0
319+ b.active = best
320+ xproto.ConvertSelection(x.conn.Buf(), xproto.Window(x.selWin), xproto.Atom(b.st[t.kind].atom),
321+ xproto.Atom(t.target), xproto.Atom(x.atoms[atomNeferwlData]), 0)
322+ x.wake()
323+}
324+
325+// inFinish ends transfer i and starts the next one.
326+func (x *XWM) inFinish(i int) {
327+ b := &x.sel
328+ t := &b.in[i]
329+ if t.src != nil {
330+ t.src.Remove()
331+ t.src = nil
332+ }
333+ if t.fd >= 0 {
334+ _ = unix.Close(t.fd)
335+ }
336+ if t.buf >= 0 {
337+ b.pool.put(t.buf)
338+ t.buf = -1
339+ }
340+ t.gen++
341+ t.state, t.fd = inFree, -1
342+ if b.active == i {
343+ b.active = -1
344+ }
345+ x.inStartNext()
346+}
347+
348+// inAbort drops transfer i, deleting the conversion property when X may still
349+// be writing it.
350+func (x *XWM) inAbort(i int, del bool) {
351+ t := &x.sel.in[i]
352+ if t.state == inFree {
353+ return
354+ }
355+ if del && x.sel.active == i && !x.closed {
356+ xproto.DeleteProperty(x.conn.Buf(), xproto.Window(x.selWin), xproto.Atom(x.atoms[atomNeferwlData]))
357+ x.wake()
358+ }
359+ x.inFinish(i)
360+}
361+
362+// onDataNotify: the owner answered our data ConvertSelection.
363+func (x *XWM) onDataNotify(e xproto.SelectionNotifyEvent) {
364+ i := x.sel.active
365+ if i < 0 {
366+ return
367+ }
368+ t := &x.sel.in[i]
369+ if t.state != inConverting || uint32(e.Target()) != t.target {
370+ return
371+ }
372+ if e.Property() == 0 {
373+ x.inFinish(i)
374+ return
375+ }
376+ t.words = 0
377+ x.inRequest(i)
378+}
379+
380+// inRequest asks for the next piece of the property; delete makes INCR and
381+// whole-value reads advance.
382+func (x *XWM) inRequest(i int) {
383+ t := &x.sel.in[i]
384+ t.state = inReading
385+ seq := xproto.GetProperty(x.conn.Buf(), true, xproto.Window(x.selWin), xproto.Atom(x.atoms[atomNeferwlData]),
386+ 0, t.words, chunk/4)
387+ if seq != 0 {
388+ if err := x.conn.Expect(seq, kindSelData, uint32(i), t.gen, 0, 0); err != nil {
389+ x.stats.RingFull++
390+ }
391+ }
392+ x.wake()
393+}
394+
395+// onSelDataReply handles one piece of the converted property.
396+func (x *XWM) onSelDataReply(r *xturbo.Reply) {
397+ i := int(r.Args[0])
398+ if i < 0 || i >= nXfer {
399+ return
400+ }
401+ t := &x.sel.in[i]
402+ if t.state != inReading || t.gen != r.Args[1] {
403+ return
404+ }
405+ g, ok := xproto.ParseGetPropertyReply(r.Raw)
406+ if !ok || g.Format() == 0 {
407+ x.inAbort(i, false)
408+ return
409+ }
410+ t.idle = 0
411+ if uint32(g.Type()) == x.atoms[atomIncr] {
412+ t.incr, t.state, t.words, t.more = true, inWaitIncr, 0, false
413+ return
414+ }
415+ v := g.Value()
416+ if g.Format() != 8 || len(v) > chunk {
417+ x.inAbort(i, true)
418+ return
419+ }
420+ t.more = g.BytesAfter() > 0
421+ if len(v) == 0 {
422+ if t.more { // cannot happen: bytes remain only after a non-empty read
423+ x.inAbort(i, true)
424+ return
425+ }
426+ x.inNext(i)
427+ return
428+ }
429+ b := x.sel.pool.get()
430+ if b < 0 {
431+ x.log.Warn().Msg("selection: no free buffer, paste aborted")
432+ x.inAbort(i, true)
433+ return
434+ }
435+ t.buf = b
436+ t.n = copy(x.sel.pool.bufs[b][:], v)
437+ t.off = 0
438+ t.words += uint32(t.n) / 4
439+ t.state = inWriting
440+ x.inDrain(i)
441+}
442+
443+// inDrain writes the pending buffer to the reader's pipe.
444+func (x *XWM) inDrain(i int) {
445+ t := &x.sel.in[i]
446+ buf := &x.sel.pool.bufs[t.buf]
447+ for t.off < t.n {
448+ n, err := unix.Write(t.fd, buf[t.off:t.n])
449+ switch {
450+ case err == nil:
451+ t.off += n
452+ t.idle = 0
453+ case errors.Is(err, unix.EINTR):
454+ case errors.Is(err, unix.EAGAIN):
455+ x.inWatch(i, true)
456+ return
457+ default: // EPIPE: the reader went away
458+ x.inAbort(i, true)
459+ return
460+ }
461+ }
462+ x.sel.pool.put(t.buf)
463+ t.buf = -1
464+ if t.src != nil {
465+ x.inWatch(i, false)
466+ }
467+ x.inNext(i)
468+}
469+
470+// inWatch arms or disarms writable interest on the reader's pipe.
471+func (x *XWM) inWatch(i int, on bool) {
472+ t := &x.sel.in[i]
473+ if t.src == nil {
474+ if !on || x.addFD == nil {
475+ if on {
476+ x.inAbort(i, true)
477+ }
478+ return
479+ }
480+ src, err := x.addFD(t.fd, t.onR, t.onW)
481+ if err != nil {
482+ x.inAbort(i, true)
483+ return
484+ }
485+ t.src = src
486+ }
487+ if err := t.src.SetWritable(on); err != nil {
488+ x.inAbort(i, true)
489+ }
490+}
491+
492+// inWritable: the pipe has room again.
493+func (x *XWM) inWritable(i int) {
494+ t := &x.sel.in[i]
495+ if t.state == inWriting && t.buf >= 0 {
496+ x.inDrain(i)
497+ }
498+}
499+
500+// inNext decides what follows a drained (or empty) piece.
501+func (x *XWM) inNext(i int) {
502+ t := &x.sel.in[i]
503+ switch {
504+ case t.more: // the rest of this property
505+ x.inRequest(i)
506+ case t.incr && t.n > 0:
507+ t.state, t.n, t.words = inWaitIncr, 0, 0
508+ if t.ready {
509+ t.ready = false
510+ x.inRequest(i)
511+ }
512+ default: // single value done, or the empty INCR terminator
513+ x.inFinish(i)
514+ }
515+}
516+
517+// onSelProperty: PropertyNotify on the selection window.
518+func (x *XWM) onSelProperty(ev *xturbo.Event) {
519+ e := xproto.PropertyNotifyOf(ev)
520+ if e.State() != xproto.PropertyNewValue || uint32(e.Atom()) != x.atoms[atomNeferwlData] {
521+ return
522+ }
523+ i := x.sel.active
524+ if i < 0 || !x.sel.in[i].incr {
525+ return
526+ }
527+ t := &x.sel.in[i]
528+ switch t.state {
529+ case inWaitIncr:
530+ t.idle = 0
531+ t.n = 0
532+ x.inRequest(i)
533+ case inWriting, inReading:
534+ t.ready = true // no request while a buffer is pending: backpressure
535+ }
536+}
537+
170 // onSelError: a selection request failed; the awaited reply never comes.538 // onSelError: a selection request failed; the awaited reply never comes.
171-func (x *XWM) onSelError(e *xturbo.XError) {}539+func (x *XWM) onSelError(e *xturbo.XError) {
540+ if e.Kind == kindSelData {
541+ if i := int(e.Args[0]); i >= 0 && i < nXfer && x.sel.in[i].gen == e.Args[1] && x.sel.in[i].state == inReading {
542+ x.inAbort(i, false)
543+ }
544+ }
545+}
546+
547+// ---- timeouts ----
172 548
173-func (x *XWM) onSelDataReply(r *xturbo.Reply) {}549+func (x *XWM) selEnsureTimer() {
550+ b := &x.sel
551+ if b.timerOn || x.after == nil {
552+ return
553+ }
554+ b.timerOn = true
555+ b.cancel = x.after(tickEvery, b.tickFn)
556+}
557+
558+// selTick ages every transfer by one second and aborts the stalled ones.
559+func (x *XWM) selTick() {
560+ b := &x.sel
561+ b.timerOn, b.cancel = false, nil
562+ if x.closed {
563+ return
564+ }
565+ live := false
566+ for i := range b.in {
567+ t := &b.in[i]
568+ if t.state == inFree {
569+ continue
570+ }
571+ t.idle++
572+ limit := xIdleMax
573+ if t.state == inWriting {
574+ limit = rIdleMax
575+ }
576+ if t.state != inQueued && t.idle >= limit {
577+ x.log.Warn().Int("kind", t.kind).Msg("selection: transfer timed out")
578+ x.inAbort(i, true)
579+ continue
580+ }
581+ live = true
582+ }
583+ if live {
584+ x.selEnsureTimer()
585+ }
586+}
587+
588+// selClose aborts every transfer.
589+func (x *XWM) selClose() {
590+ for i := range x.sel.in {
591+ if x.sel.in[i].state != inFree {
592+ x.inAbort(i, false)
593+ }
594+ }
595+ if x.sel.cancel != nil {
596+ x.sel.cancel()
597+ x.sel.cancel = nil
598+ }
599+}
M internal/adapters/wayland/xwm/selection_test.go +311 -0
@@ -1,10 +1,18 @@
11 package xwm
22
33 import (
4+ "bytes"
45 "encoding/binary"
6+ "io"
7+ "os"
58 "testing"
9+ "time"
610
11+ "bnema.dev/xturbo"
12+ "bnema.dev/xturbo/xproto"
13+ "github.com/stretchr/testify/mock"
714 "github.com/stretchr/testify/require"
15+ "golang.org/x/sys/unix"
816 )
917
1018 const peerXFixesFirstEvent = 87
@@ -111,3 +119,306 @@ func TestSelectionXOwnsAnnounces(t *testing.T) {
111119 x.flush()
112120 require.Equal(t, before, len(p.requests()))
113121 }
122+
123+// ---- X -> Wayland ----
124+
125+type selRig struct {
126+ x *XWM
127+ p *xpeer
128+ h *mockhost
129+ clip uint32
130+ utf8 uint32
131+ data uint32
132+ skip int
133+ timer func()
134+}
135+
136+// ownedRig attaches an XWM whose CLIPBOARD is owned by an X client offering text.
137+func ownedRig(t *testing.T) *selRig {
138+ t.Helper()
139+ x, p, h, _, _ := attached(t)
140+ r := &selRig{x: x, p: p, h: h, clip: p.atom("CLIPBOARD"), utf8: p.atom("UTF8_STRING"), data: p.atom("_NEFERWL_DATA")}
141+ h.EXPECT().After(mock.Anything, mock.Anything).Run(func(_ time.Duration, fn func()) { r.timer = fn }).Return(func() {}).Maybe()
142+ got := 0
143+ h.EXPECT().SelectionOwned(SelClipboard, mock.Anything).Run(func(int, []string) { got++ }).Once()
144+ p.xfixesSelection(0x500010, r.clip)
145+ _, n := pumpReq(t, x, p, 0, func(q xpeerReq) bool { return q.major == 24 })
146+ tgt := p.atom("TARGETS")
147+ p.setProp(x.selWin, p.atom("_NEFERWL_TARGETS"), 4, 32, le32(tgt, r.utf8))
148+ p.selectionNotify(x.selWin, r.clip, tgt, p.atom("_NEFERWL_TARGETS"))
149+ readUntil(t, x, func() bool { return got == 1 })
150+ r.skip = len(p.requests())
151+ _ = n
152+ return r
153+}
154+
155+// receive starts a paste and returns the read end of its pipe.
156+func (r *selRig) receive(t *testing.T, size int) *os.File {
157+ t.Helper()
158+ fds := make([]int, 2)
159+ require.NoError(t, unix.Pipe2(fds, unix.O_CLOEXEC))
160+ if size > 0 {
161+ _, err := unix.FcntlInt(uintptr(fds[0]), unix.F_SETPIPE_SZ, size)
162+ require.NoError(t, err)
163+ }
164+ r.x.SelectionReceive(SelClipboard, "text/plain;charset=utf-8", fds[1])
165+ r.x.flush()
166+ f := os.NewFile(uintptr(fds[0]), "pipe-r")
167+ t.Cleanup(func() { f.Close() })
168+ return f
169+}
170+
171+func (r *selRig) waitGet(t *testing.T) {
172+ t.Helper()
173+ _, n := pumpReq(t, r.x, r.p, r.skip, func(q xpeerReq) bool { return q.major == opGetProperty && q.u32(8) == r.data })
174+ r.skip = n
175+}
176+
177+func pattern(n int) []byte {
178+ b := make([]byte, n)
179+ for i := range b {
180+ b[i] = byte(i*7 + i/251)
181+ }
182+ return b
183+}
184+
185+func TestSelectionXToWaylandSmall(t *testing.T) {
186+ r := ownedRig(t)
187+ f := r.receive(t, 0)
188+ req, n := pumpReq(t, r.x, r.p, r.skip, func(q xpeerReq) bool { return q.major == 24 })
189+ require.Equal(t, r.x.selWin, req.u32(4))
190+ require.Equal(t, r.clip, req.u32(8))
191+ require.Equal(t, r.utf8, req.u32(12))
192+ require.Equal(t, r.data, req.u32(16))
193+ r.skip = n
194+ r.p.setProp(r.x.selWin, r.data, r.utf8, 8, []byte("hello clipboard"))
195+ r.p.selectionNotify(r.x.selWin, r.clip, r.utf8, r.data)
196+ r.waitGet(t)
197+ buf := make([]byte, 64)
198+ var got []byte
199+ readUntil(t, r.x, func() bool {
200+ m, _ := unix.Read(int(f.Fd()), buf[:0:0])
201+ _ = m
202+ return r.x.sel.in[0].state == inFree
203+ })
204+ m, err := f.Read(buf)
205+ require.NoError(t, err)
206+ got = buf[:m]
207+ require.Equal(t, "hello clipboard", string(got))
208+ _, ok := r.p.prop(r.x.selWin, "_NEFERWL_DATA")
209+ require.False(t, ok, "property deleted by the read")
210+ _, err = f.Read(buf)
211+ require.ErrorIs(t, err, io.EOF, "write end closed")
212+}
213+
214+func TestSelectionXToWaylandRefused(t *testing.T) {
215+ r := ownedRig(t)
216+ f := r.receive(t, 0)
217+ _, n := pumpReq(t, r.x, r.p, r.skip, func(q xpeerReq) bool { return q.major == 24 })
218+ r.skip = n
219+ r.p.selectionNotify(r.x.selWin, r.clip, r.utf8, 0)
220+ readUntil(t, r.x, func() bool { return r.x.sel.in[0].state == inFree })
221+ _, err := f.Read(make([]byte, 8))
222+ require.ErrorIs(t, err, io.EOF)
223+}
224+
225+func TestSelectionXToWaylandINCR(t *testing.T) {
226+ r := ownedRig(t)
227+ const total = 1 << 20
228+ want := pattern(total)
229+ f := r.receive(t, 1<<20)
230+ _, n := pumpReq(t, r.x, r.p, r.skip, func(q xpeerReq) bool { return q.major == 24 })
231+ r.skip = n
232+ r.p.setProp(r.x.selWin, r.data, r.p.atom("INCR"), 32, le32(total))
233+ r.p.selectionNotify(r.x.selWin, r.clip, r.utf8, r.data)
234+ r.waitGet(t) // reads and deletes the INCR marker
235+ gets := 1
236+ for off := 0; off <= total; off += chunk {
237+ end := min(off+chunk, total)
238+ r.p.setProp(r.x.selWin, r.data, r.utf8, 8, want[off:end])
239+ r.p.propertyNotify(r.x.selWin, r.data, false)
240+ r.waitGet(t)
241+ gets++
242+ }
243+ readUntil(t, r.x, func() bool { return r.x.sel.in[0].state == inFree })
244+ got, err := io.ReadAll(f)
245+ require.NoError(t, err)
246+ require.True(t, bytes.Equal(want, got), "byte-exact, got %d", len(got))
247+ require.Equal(t, 1+total/chunk+1, gets, "marker, 16 chunks, empty terminator")
248+}
249+
250+// A reader that does not drain: no further GetProperty until the pipe has room.
251+func TestSelectionXToWaylandBackpressure(t *testing.T) {
252+ r := ownedRig(t)
253+ src := newMockfdSource(t)
254+ var onW func()
255+ r.x.addFD = func(_ int, _, w func()) (fdSource, error) { onW = w; return src, nil }
256+ src.EXPECT().SetWritable(true).Return(nil).Once()
257+ src.EXPECT().SetWritable(false).Return(nil).Once()
258+ src.EXPECT().Remove().Return().Once()
259+ f := r.receive(t, 0) // default 64 KiB pipe
260+ _, n := pumpReq(t, r.x, r.p, r.skip, func(q xpeerReq) bool { return q.major == 24 })
261+ r.skip = n
262+ r.p.setProp(r.x.selWin, r.data, r.p.atom("INCR"), 32, le32(3*chunk))
263+ r.p.selectionNotify(r.x.selWin, r.clip, r.utf8, r.data)
264+ r.waitGet(t)
265+ want := pattern(3 * chunk)
266+ chunkN := func(k int) {
267+ r.p.setProp(r.x.selWin, r.data, r.utf8, 8, want[k*chunk:(k+1)*chunk])
268+ r.p.propertyNotify(r.x.selWin, r.data, false)
269+ }
270+ chunkN(0) // fills the pipe exactly
271+ r.waitGet(t)
272+ readUntil(t, r.x, func() bool { return r.x.sel.in[0].state == inWaitIncr })
273+ chunkN(1) // nobody reads: EAGAIN, the buffer stays pending
274+ r.waitGet(t)
275+ readUntil(t, r.x, func() bool { return r.x.sel.in[0].state == inWriting })
276+ require.NotNil(t, onW)
277+
278+ gets := getProps(r.p, 0, r.x.selWin)
279+ chunkN(2) // announced while a buffer is pending
280+ ev := r.x.Stats().Events
281+ readUntil(t, r.x, func() bool { return r.x.Stats().Events > ev })
282+ r.x.flush()
283+ require.Equal(t, gets, getProps(r.p, 0, r.x.selWin), "no GetProperty while a buffer is pending")
284+
285+ got := make([]byte, 0, 3*chunk)
286+ buf := make([]byte, chunk)
287+ m, err := f.Read(buf) // the reader wakes: room again
288+ require.NoError(t, err)
289+ got = append(got, buf[:m]...)
290+ onW()
291+ require.Equal(t, inReading, r.x.sel.in[0].state, "the pending announcement is served once drained")
292+ r.waitGet(t)
293+ _ = got
294+ r.x.selClose()
295+}
296+
297+func TestSelectionTimeouts(t *testing.T) {
298+ r := ownedRig(t)
299+ f := r.receive(t, 0)
300+ _, n := pumpReq(t, r.x, r.p, r.skip, func(q xpeerReq) bool { return q.major == 24 })
301+ r.skip = n
302+ require.NotNil(t, r.timer)
303+ // X stays silent after ConvertSelection: nine ticks keep it alive, the tenth aborts.
304+ for i := 0; i < xIdleMax-1; i++ {
305+ fn := r.timer
306+ fn()
307+ require.Equal(t, inConverting, r.x.sel.in[0].state, i)
308+ }
309+ r.timer()
310+ require.Equal(t, inFree, r.x.sel.in[0].state)
311+ _, err := f.Read(make([]byte, 8))
312+ require.ErrorIs(t, err, io.EOF, "fd closed")
313+ pumpReq(t, r.x, r.p, r.skip, func(q xpeerReq) bool { return q.major == opDeleteProperty && q.u32(8) == r.data })
314+ require.Equal(t, nXfer, r.x.sel.pool.n)
315+}
316+
317+func TestSelectionPoolExhausted(t *testing.T) {
318+ r := ownedRig(t)
319+ var files []*os.File
320+ for i := 0; i < nXfer; i++ {
321+ files = append(files, r.receive(t, 0))
322+ }
323+ fifth := r.receive(t, 0)
324+ _, err := fifth.Read(make([]byte, 8))
325+ require.ErrorIs(t, err, io.EOF, "the fifth paste is refused with its pipe closed")
326+ for i := range r.x.sel.in {
327+ require.NotEqual(t, inFree, r.x.sel.in[i].state, i)
328+ }
329+ _ = files
330+ r.x.selClose()
331+ for i := range r.x.sel.in {
332+ require.Equal(t, inFree, r.x.sel.in[i].state, i)
333+ }
334+}
335+
336+// allocRig is a rig whose peer sinks requests and whose timer seam is inert.
337+func allocRig(t *testing.T) *selRig {
338+ r := ownedRig(t)
339+ sinkPeer(t, r.x, r.p)
340+ r.x.after = func(time.Duration, func()) func() { return func() {} }
341+ r.x.sel.timerOn = true // no timer re-arming in the measured loop
342+ return r
343+}
344+
345+func dataReply(r *selRig, typ uint32, payload []byte) *xturbo.Reply {
346+ raw := make([]byte, 32+(len(payload)+3)&^3)
347+ raw[0], raw[1] = 1, 8
348+ binary.LittleEndian.PutUint32(raw[8:], typ)
349+ binary.LittleEndian.PutUint32(raw[16:], uint32(len(payload)))
350+ copy(raw[32:], payload)
351+ return &xturbo.Reply{Kind: kindSelData, Raw: raw}
352+}
353+
354+func TestSelectionCopyAllocations(t *testing.T) {
355+ r := allocRig(t)
356+ x := r.x
357+ fds := make([]int, 2)
358+ require.NoError(t, unix.Pipe2(fds, unix.O_CLOEXEC))
359+ t.Cleanup(func() { unix.Close(fds[0]); unix.Close(fds[1]) })
360+ payload := pattern(4096)
361+ rep := dataReply(r, r.utf8, payload)
362+ var note xturbo.Event
363+ note.Kind = xproto.KindSelectionNotify
364+ binary.LittleEndian.PutUint32(note.Raw[8:], x.selWin)
365+ binary.LittleEndian.PutUint32(note.Raw[12:], r.clip)
366+ binary.LittleEndian.PutUint32(note.Raw[16:], r.utf8)
367+ binary.LittleEndian.PutUint32(note.Raw[20:], r.data)
368+ sink := make([]byte, 8192)
369+ mime := textMimes[0]
370+ round := func() {
371+ fd, err := unix.FcntlInt(uintptr(fds[1]), unix.F_DUPFD_CLOEXEC, 0)
372+ if err != nil {
373+ t.Fatal("dup")
374+ }
375+ x.SelectionReceive(SelClipboard, mime, fd)
376+ x.onSelectionNotify(&note)
377+ rep.Args = [4]uint32{0, x.sel.in[0].gen}
378+ x.onSelDataReply(rep)
379+ if m, err := unix.Read(fds[0], sink); err != nil || m != 4096 {
380+ t.Fatal("read")
381+ }
382+ x.posted = false
383+ x.flush()
384+ }
385+ round()
386+ require.Zero(t, testing.AllocsPerRun(100, round))
387+}
388+
389+func TestSelectionINCRChunkAllocations(t *testing.T) {
390+ r := allocRig(t)
391+ x := r.x
392+ fds := make([]int, 2)
393+ require.NoError(t, unix.Pipe2(fds, unix.O_CLOEXEC))
394+ t.Cleanup(func() { unix.Close(fds[0]); unix.Close(fds[1]) })
395+ x.SelectionReceive(SelClipboard, textMimes[0], fds[1])
396+ var note xturbo.Event
397+ note.Kind = xproto.KindSelectionNotify
398+ binary.LittleEndian.PutUint32(note.Raw[8:], x.selWin)
399+ binary.LittleEndian.PutUint32(note.Raw[12:], r.clip)
400+ binary.LittleEndian.PutUint32(note.Raw[16:], r.utf8)
401+ binary.LittleEndian.PutUint32(note.Raw[20:], r.data)
402+ x.onSelectionNotify(&note)
403+ x.onSelDataReply(dataReply(r, r.p.atom("INCR"), le32(1<<20)))
404+ require.Equal(t, inWaitIncr, x.sel.in[0].state)
405+ rep := dataReply(r, r.utf8, pattern(chunk))
406+ var pn xturbo.Event
407+ pn.Kind = xproto.KindPropertyNotify
408+ binary.LittleEndian.PutUint32(pn.Raw[4:], x.selWin)
409+ binary.LittleEndian.PutUint32(pn.Raw[8:], r.data)
410+ sink := make([]byte, chunk)
411+ step := func() {
412+ x.onSelProperty(&pn)
413+ rep.Args = [4]uint32{0, x.sel.in[0].gen}
414+ x.onSelDataReply(rep)
415+ if m, err := unix.Read(fds[0], sink); err != nil || m != chunk {
416+ t.Fatal("read")
417+ }
418+ x.posted = false
419+ x.flush()
420+ }
421+ step()
422+ require.Zero(t, testing.AllocsPerRun(100, step))
423+ require.Equal(t, inWaitIncr, x.sel.in[0].state)
424+}
@@ -1,10 +1,18 @@
1 package xwm1 package xwm
2 2
3 import (3 import (
4+ "bytes"
4 "encoding/binary"5 "encoding/binary"
6+ "io"
7+ "os"
5 "testing"8 "testing"
9+ "time"
6 10
11+ "bnema.dev/xturbo"
12+ "bnema.dev/xturbo/xproto"
13+ "github.com/stretchr/testify/mock"
7 "github.com/stretchr/testify/require"14 "github.com/stretchr/testify/require"
15+ "golang.org/x/sys/unix"
8 )16 )
9 17
10 const peerXFixesFirstEvent = 8718 const peerXFixesFirstEvent = 87
@@ -111,3 +119,306 @@ func TestSelectionXOwnsAnnounces(t *testing.T) {
111 x.flush()119 x.flush()
112 require.Equal(t, before, len(p.requests()))120 require.Equal(t, before, len(p.requests()))
113 }121 }
122+
123+// ---- X -> Wayland ----
124+
125+type selRig struct {
126+ x *XWM
127+ p *xpeer
128+ h *mockhost
129+ clip uint32
130+ utf8 uint32
131+ data uint32
132+ skip int
133+ timer func()
134+}
135+
136+// ownedRig attaches an XWM whose CLIPBOARD is owned by an X client offering text.
137+func ownedRig(t *testing.T) *selRig {
138+ t.Helper()
139+ x, p, h, _, _ := attached(t)
140+ r := &selRig{x: x, p: p, h: h, clip: p.atom("CLIPBOARD"), utf8: p.atom("UTF8_STRING"), data: p.atom("_NEFERWL_DATA")}
141+ h.EXPECT().After(mock.Anything, mock.Anything).Run(func(_ time.Duration, fn func()) { r.timer = fn }).Return(func() {}).Maybe()
142+ got := 0
143+ h.EXPECT().SelectionOwned(SelClipboard, mock.Anything).Run(func(int, []string) { got++ }).Once()
144+ p.xfixesSelection(0x500010, r.clip)
145+ _, n := pumpReq(t, x, p, 0, func(q xpeerReq) bool { return q.major == 24 })
146+ tgt := p.atom("TARGETS")
147+ p.setProp(x.selWin, p.atom("_NEFERWL_TARGETS"), 4, 32, le32(tgt, r.utf8))
148+ p.selectionNotify(x.selWin, r.clip, tgt, p.atom("_NEFERWL_TARGETS"))
149+ readUntil(t, x, func() bool { return got == 1 })
150+ r.skip = len(p.requests())
151+ _ = n
152+ return r
153+}
154+
155+// receive starts a paste and returns the read end of its pipe.
156+func (r *selRig) receive(t *testing.T, size int) *os.File {
157+ t.Helper()
158+ fds := make([]int, 2)
159+ require.NoError(t, unix.Pipe2(fds, unix.O_CLOEXEC))
160+ if size > 0 {
161+ _, err := unix.FcntlInt(uintptr(fds[0]), unix.F_SETPIPE_SZ, size)
162+ require.NoError(t, err)
163+ }
164+ r.x.SelectionReceive(SelClipboard, "text/plain;charset=utf-8", fds[1])
165+ r.x.flush()
166+ f := os.NewFile(uintptr(fds[0]), "pipe-r")
167+ t.Cleanup(func() { f.Close() })
168+ return f
169+}
170+
171+func (r *selRig) waitGet(t *testing.T) {
172+ t.Helper()
173+ _, n := pumpReq(t, r.x, r.p, r.skip, func(q xpeerReq) bool { return q.major == opGetProperty && q.u32(8) == r.data })
174+ r.skip = n
175+}
176+
177+func pattern(n int) []byte {
178+ b := make([]byte, n)
179+ for i := range b {
180+ b[i] = byte(i*7 + i/251)
181+ }
182+ return b
183+}
184+
185+func TestSelectionXToWaylandSmall(t *testing.T) {
186+ r := ownedRig(t)
187+ f := r.receive(t, 0)
188+ req, n := pumpReq(t, r.x, r.p, r.skip, func(q xpeerReq) bool { return q.major == 24 })
189+ require.Equal(t, r.x.selWin, req.u32(4))
190+ require.Equal(t, r.clip, req.u32(8))
191+ require.Equal(t, r.utf8, req.u32(12))
192+ require.Equal(t, r.data, req.u32(16))
193+ r.skip = n
194+ r.p.setProp(r.x.selWin, r.data, r.utf8, 8, []byte("hello clipboard"))
195+ r.p.selectionNotify(r.x.selWin, r.clip, r.utf8, r.data)
196+ r.waitGet(t)
197+ buf := make([]byte, 64)
198+ var got []byte
199+ readUntil(t, r.x, func() bool {
200+ m, _ := unix.Read(int(f.Fd()), buf[:0:0])
201+ _ = m
202+ return r.x.sel.in[0].state == inFree
203+ })
204+ m, err := f.Read(buf)
205+ require.NoError(t, err)
206+ got = buf[:m]
207+ require.Equal(t, "hello clipboard", string(got))
208+ _, ok := r.p.prop(r.x.selWin, "_NEFERWL_DATA")
209+ require.False(t, ok, "property deleted by the read")
210+ _, err = f.Read(buf)
211+ require.ErrorIs(t, err, io.EOF, "write end closed")
212+}
213+
214+func TestSelectionXToWaylandRefused(t *testing.T) {
215+ r := ownedRig(t)
216+ f := r.receive(t, 0)
217+ _, n := pumpReq(t, r.x, r.p, r.skip, func(q xpeerReq) bool { return q.major == 24 })
218+ r.skip = n
219+ r.p.selectionNotify(r.x.selWin, r.clip, r.utf8, 0)
220+ readUntil(t, r.x, func() bool { return r.x.sel.in[0].state == inFree })
221+ _, err := f.Read(make([]byte, 8))
222+ require.ErrorIs(t, err, io.EOF)
223+}
224+
225+func TestSelectionXToWaylandINCR(t *testing.T) {
226+ r := ownedRig(t)
227+ const total = 1 << 20
228+ want := pattern(total)
229+ f := r.receive(t, 1<<20)
230+ _, n := pumpReq(t, r.x, r.p, r.skip, func(q xpeerReq) bool { return q.major == 24 })
231+ r.skip = n
232+ r.p.setProp(r.x.selWin, r.data, r.p.atom("INCR"), 32, le32(total))
233+ r.p.selectionNotify(r.x.selWin, r.clip, r.utf8, r.data)
234+ r.waitGet(t) // reads and deletes the INCR marker
235+ gets := 1
236+ for off := 0; off <= total; off += chunk {
237+ end := min(off+chunk, total)
238+ r.p.setProp(r.x.selWin, r.data, r.utf8, 8, want[off:end])
239+ r.p.propertyNotify(r.x.selWin, r.data, false)
240+ r.waitGet(t)
241+ gets++
242+ }
243+ readUntil(t, r.x, func() bool { return r.x.sel.in[0].state == inFree })
244+ got, err := io.ReadAll(f)
245+ require.NoError(t, err)
246+ require.True(t, bytes.Equal(want, got), "byte-exact, got %d", len(got))
247+ require.Equal(t, 1+total/chunk+1, gets, "marker, 16 chunks, empty terminator")
248+}
249+
250+// A reader that does not drain: no further GetProperty until the pipe has room.
251+func TestSelectionXToWaylandBackpressure(t *testing.T) {
252+ r := ownedRig(t)
253+ src := newMockfdSource(t)
254+ var onW func()
255+ r.x.addFD = func(_ int, _, w func()) (fdSource, error) { onW = w; return src, nil }
256+ src.EXPECT().SetWritable(true).Return(nil).Once()
257+ src.EXPECT().SetWritable(false).Return(nil).Once()
258+ src.EXPECT().Remove().Return().Once()
259+ f := r.receive(t, 0) // default 64 KiB pipe
260+ _, n := pumpReq(t, r.x, r.p, r.skip, func(q xpeerReq) bool { return q.major == 24 })
261+ r.skip = n
262+ r.p.setProp(r.x.selWin, r.data, r.p.atom("INCR"), 32, le32(3*chunk))
263+ r.p.selectionNotify(r.x.selWin, r.clip, r.utf8, r.data)
264+ r.waitGet(t)
265+ want := pattern(3 * chunk)
266+ chunkN := func(k int) {
267+ r.p.setProp(r.x.selWin, r.data, r.utf8, 8, want[k*chunk:(k+1)*chunk])
268+ r.p.propertyNotify(r.x.selWin, r.data, false)
269+ }
270+ chunkN(0) // fills the pipe exactly
271+ r.waitGet(t)
272+ readUntil(t, r.x, func() bool { return r.x.sel.in[0].state == inWaitIncr })
273+ chunkN(1) // nobody reads: EAGAIN, the buffer stays pending
274+ r.waitGet(t)
275+ readUntil(t, r.x, func() bool { return r.x.sel.in[0].state == inWriting })
276+ require.NotNil(t, onW)
277+
278+ gets := getProps(r.p, 0, r.x.selWin)
279+ chunkN(2) // announced while a buffer is pending
280+ ev := r.x.Stats().Events
281+ readUntil(t, r.x, func() bool { return r.x.Stats().Events > ev })
282+ r.x.flush()
283+ require.Equal(t, gets, getProps(r.p, 0, r.x.selWin), "no GetProperty while a buffer is pending")
284+
285+ got := make([]byte, 0, 3*chunk)
286+ buf := make([]byte, chunk)
287+ m, err := f.Read(buf) // the reader wakes: room again
288+ require.NoError(t, err)
289+ got = append(got, buf[:m]...)
290+ onW()
291+ require.Equal(t, inReading, r.x.sel.in[0].state, "the pending announcement is served once drained")
292+ r.waitGet(t)
293+ _ = got
294+ r.x.selClose()
295+}
296+
297+func TestSelectionTimeouts(t *testing.T) {
298+ r := ownedRig(t)
299+ f := r.receive(t, 0)
300+ _, n := pumpReq(t, r.x, r.p, r.skip, func(q xpeerReq) bool { return q.major == 24 })
301+ r.skip = n
302+ require.NotNil(t, r.timer)
303+ // X stays silent after ConvertSelection: nine ticks keep it alive, the tenth aborts.
304+ for i := 0; i < xIdleMax-1; i++ {
305+ fn := r.timer
306+ fn()
307+ require.Equal(t, inConverting, r.x.sel.in[0].state, i)
308+ }
309+ r.timer()
310+ require.Equal(t, inFree, r.x.sel.in[0].state)
311+ _, err := f.Read(make([]byte, 8))
312+ require.ErrorIs(t, err, io.EOF, "fd closed")
313+ pumpReq(t, r.x, r.p, r.skip, func(q xpeerReq) bool { return q.major == opDeleteProperty && q.u32(8) == r.data })
314+ require.Equal(t, nXfer, r.x.sel.pool.n)
315+}
316+
317+func TestSelectionPoolExhausted(t *testing.T) {
318+ r := ownedRig(t)
319+ var files []*os.File
320+ for i := 0; i < nXfer; i++ {
321+ files = append(files, r.receive(t, 0))
322+ }
323+ fifth := r.receive(t, 0)
324+ _, err := fifth.Read(make([]byte, 8))
325+ require.ErrorIs(t, err, io.EOF, "the fifth paste is refused with its pipe closed")
326+ for i := range r.x.sel.in {
327+ require.NotEqual(t, inFree, r.x.sel.in[i].state, i)
328+ }
329+ _ = files
330+ r.x.selClose()
331+ for i := range r.x.sel.in {
332+ require.Equal(t, inFree, r.x.sel.in[i].state, i)
333+ }
334+}
335+
336+// allocRig is a rig whose peer sinks requests and whose timer seam is inert.
337+func allocRig(t *testing.T) *selRig {
338+ r := ownedRig(t)
339+ sinkPeer(t, r.x, r.p)
340+ r.x.after = func(time.Duration, func()) func() { return func() {} }
341+ r.x.sel.timerOn = true // no timer re-arming in the measured loop
342+ return r
343+}
344+
345+func dataReply(r *selRig, typ uint32, payload []byte) *xturbo.Reply {
346+ raw := make([]byte, 32+(len(payload)+3)&^3)
347+ raw[0], raw[1] = 1, 8
348+ binary.LittleEndian.PutUint32(raw[8:], typ)
349+ binary.LittleEndian.PutUint32(raw[16:], uint32(len(payload)))
350+ copy(raw[32:], payload)
351+ return &xturbo.Reply{Kind: kindSelData, Raw: raw}
352+}
353+
354+func TestSelectionCopyAllocations(t *testing.T) {
355+ r := allocRig(t)
356+ x := r.x
357+ fds := make([]int, 2)
358+ require.NoError(t, unix.Pipe2(fds, unix.O_CLOEXEC))
359+ t.Cleanup(func() { unix.Close(fds[0]); unix.Close(fds[1]) })
360+ payload := pattern(4096)
361+ rep := dataReply(r, r.utf8, payload)
362+ var note xturbo.Event
363+ note.Kind = xproto.KindSelectionNotify
364+ binary.LittleEndian.PutUint32(note.Raw[8:], x.selWin)
365+ binary.LittleEndian.PutUint32(note.Raw[12:], r.clip)
366+ binary.LittleEndian.PutUint32(note.Raw[16:], r.utf8)
367+ binary.LittleEndian.PutUint32(note.Raw[20:], r.data)
368+ sink := make([]byte, 8192)
369+ mime := textMimes[0]
370+ round := func() {
371+ fd, err := unix.FcntlInt(uintptr(fds[1]), unix.F_DUPFD_CLOEXEC, 0)
372+ if err != nil {
373+ t.Fatal("dup")
374+ }
375+ x.SelectionReceive(SelClipboard, mime, fd)
376+ x.onSelectionNotify(&note)
377+ rep.Args = [4]uint32{0, x.sel.in[0].gen}
378+ x.onSelDataReply(rep)
379+ if m, err := unix.Read(fds[0], sink); err != nil || m != 4096 {
380+ t.Fatal("read")
381+ }
382+ x.posted = false
383+ x.flush()
384+ }
385+ round()
386+ require.Zero(t, testing.AllocsPerRun(100, round))
387+}
388+
389+func TestSelectionINCRChunkAllocations(t *testing.T) {
390+ r := allocRig(t)
391+ x := r.x
392+ fds := make([]int, 2)
393+ require.NoError(t, unix.Pipe2(fds, unix.O_CLOEXEC))
394+ t.Cleanup(func() { unix.Close(fds[0]); unix.Close(fds[1]) })
395+ x.SelectionReceive(SelClipboard, textMimes[0], fds[1])
396+ var note xturbo.Event
397+ note.Kind = xproto.KindSelectionNotify
398+ binary.LittleEndian.PutUint32(note.Raw[8:], x.selWin)
399+ binary.LittleEndian.PutUint32(note.Raw[12:], r.clip)
400+ binary.LittleEndian.PutUint32(note.Raw[16:], r.utf8)
401+ binary.LittleEndian.PutUint32(note.Raw[20:], r.data)
402+ x.onSelectionNotify(&note)
403+ x.onSelDataReply(dataReply(r, r.p.atom("INCR"), le32(1<<20)))
404+ require.Equal(t, inWaitIncr, x.sel.in[0].state)
405+ rep := dataReply(r, r.utf8, pattern(chunk))
406+ var pn xturbo.Event
407+ pn.Kind = xproto.KindPropertyNotify
408+ binary.LittleEndian.PutUint32(pn.Raw[4:], x.selWin)
409+ binary.LittleEndian.PutUint32(pn.Raw[8:], r.data)
410+ sink := make([]byte, chunk)
411+ step := func() {
412+ x.onSelProperty(&pn)
413+ rep.Args = [4]uint32{0, x.sel.in[0].gen}
414+ x.onSelDataReply(rep)
415+ if m, err := unix.Read(fds[0], sink); err != nil || m != chunk {
416+ t.Fatal("read")
417+ }
418+ x.posted = false
419+ x.flush()
420+ }
421+ step()
422+ require.Zero(t, testing.AllocsPerRun(100, step))
423+ require.Equal(t, inWaitIncr, x.sel.in[0].state)
424+}
M internal/adapters/wayland/xwm/xpeer_test.go +16 -3
@@ -365,12 +365,25 @@ func (p *xpeer) answer(r xpeerReq, body []byte) {
365365 delete(p.props[binary.LittleEndian.Uint32(body)], binary.LittleEndian.Uint32(body[4:]))
366366 case r.major == opGetProperty:
367367 win, prop := binary.LittleEndian.Uint32(body), binary.LittleEndian.Uint32(body[4:])
368+ off, length := int(binary.LittleEndian.Uint32(body[12:]))*4, int(binary.LittleEndian.Uint32(body[16:]))*4
368369 v, ok := p.props[win][prop]
369- b := make([]byte, 24+(len(v.data)+3)&^3)
370+ data, after := v.data, 0
371+ if off > len(data) {
372+ off = len(data)
373+ }
374+ data = data[off:]
375+ if len(data) > length {
376+ data, after = data[:length], len(data)-length
377+ }
378+ b := make([]byte, 24+(len(data)+3)&^3)
370379 if ok && v.format != 0 {
371380 binary.LittleEndian.PutUint32(b[0:], v.typ)
372- binary.LittleEndian.PutUint32(b[8:], uint32(len(v.data)/(int(v.format)/8)))
373- copy(b[24:], v.data)
381+ binary.LittleEndian.PutUint32(b[4:], uint32(after))
382+ binary.LittleEndian.PutUint32(b[8:], uint32(len(data)/(int(v.format)/8)))
383+ copy(b[24:], data)
384+ if r.raw[1] != 0 && after == 0 { // delete
385+ delete(p.props[win], prop)
386+ }
374387 }
375388 p.reply(v.format, b)
376389 case r.major == opChangeWindowAttributes:
@@ -365,12 +365,25 @@ func (p *xpeer) answer(r xpeerReq, body []byte) {
365 delete(p.props[binary.LittleEndian.Uint32(body)], binary.LittleEndian.Uint32(body[4:]))365 delete(p.props[binary.LittleEndian.Uint32(body)], binary.LittleEndian.Uint32(body[4:]))
366 case r.major == opGetProperty:366 case r.major == opGetProperty:
367 win, prop := binary.LittleEndian.Uint32(body), binary.LittleEndian.Uint32(body[4:])367 win, prop := binary.LittleEndian.Uint32(body), binary.LittleEndian.Uint32(body[4:])
368+ off, length := int(binary.LittleEndian.Uint32(body[12:]))*4, int(binary.LittleEndian.Uint32(body[16:]))*4
368 v, ok := p.props[win][prop]369 v, ok := p.props[win][prop]
369- b := make([]byte, 24+(len(v.data)+3)&^3)370+ data, after := v.data, 0
371+ if off > len(data) {
372+ off = len(data)
373+ }
374+ data = data[off:]
375+ if len(data) > length {
376+ data, after = data[:length], len(data)-length
377+ }
378+ b := make([]byte, 24+(len(data)+3)&^3)
370 if ok && v.format != 0 {379 if ok && v.format != 0 {
371 binary.LittleEndian.PutUint32(b[0:], v.typ)380 binary.LittleEndian.PutUint32(b[0:], v.typ)
372- binary.LittleEndian.PutUint32(b[8:], uint32(len(v.data)/(int(v.format)/8)))381+ binary.LittleEndian.PutUint32(b[4:], uint32(after))
373- copy(b[24:], v.data)382+ binary.LittleEndian.PutUint32(b[8:], uint32(len(data)/(int(v.format)/8)))
383+ copy(b[24:], data)
384+ if r.raw[1] != 0 && after == 0 { // delete
385+ delete(p.props[win], prop)
386+ }
374 }387 }
375 p.reply(v.format, b)388 p.reply(v.format, b)
376 case r.major == opChangeWindowAttributes:389 case r.major == opChangeWindowAttributes:
M internal/adapters/wayland/xwm/xwm.go +13 -0
@@ -14,6 +14,7 @@ import (
1414 "fmt"
1515 "io"
1616 "os"
17+ "time"
1718
1819 "bnema.dev/xturbo"
1920 "bnema.dev/xturbo/composite"
@@ -63,6 +64,8 @@ type XWM struct {
6364 // attachment
6465 host host
6566 src fdSource
67+ addFD func(fd int, onReadable, onWritable func()) (fdSource, error) // display fd source seam
68+ after func(d time.Duration, fn func()) (cancel func())
6669 post func(func())
6770 flushFn func()
6871
@@ -92,6 +95,7 @@ func newXWM(conn *xturbo.Conn, log zerowrap.Logger) *XWM {
9295 x := &XWM{conn: conn, log: log, root: conn.Root(), tab: newTable(initialSlots), space: new(Space)}
9396 x.h = xturbo.Handler{OnEvent: x.onEvent, OnReply: x.onReply, OnError: x.onError}
9497 x.fit()
98+ x.initSelection()
9599 return x
96100 }
97101
@@ -312,6 +316,13 @@ func (x *XWM) boot() error {
312316 // Attach registers the X socket with the display and hands the windows found
313317 // at boot to h. Display goroutine only.
314318 func (x *XWM) Attach(d *server.Display, h host) error {
319+ x.addFD = func(fd int, r, w func()) (fdSource, error) {
320+ s, err := d.AddFDRW(fd, r, w)
321+ if err != nil {
322+ return nil, err
323+ }
324+ return s, nil
325+ }
315326 src, err := d.AddFDRW(x.conn.Fd(), x.onReadable, x.onWritable)
316327 if err != nil {
317328 return fmt.Errorf("xwm: watch X socket: %w", err)
@@ -323,6 +334,7 @@ func (x *XWM) Attach(d *server.Display, h host) error {
323334 // attach is Attach over its seams: the event source and the display's Post.
324335 func (x *XWM) attach(src fdSource, h host, post func(func())) {
325336 x.src, x.host, x.post = src, h, post
337+ x.after = h.After
326338 x.flushFn = func() {
327339 x.posted = false
328340 x.flush()
@@ -352,6 +364,7 @@ func (x *XWM) Close() {
352364 return
353365 }
354366 x.closed = true
367+ x.selClose()
355368 s := x.stats
356369 x.log.Info().Uint64("events", s.Events).Uint64("replies", s.Replies).Uint64("errors", s.Errors).
357370 Uint64("unattributed", s.Unattributed).Uint64("stale", s.Stale).Uint64("ring_full", s.RingFull).
@@ -14,6 +14,7 @@ import (
14 "fmt"14 "fmt"
15 "io"15 "io"
16 "os"16 "os"
17+ "time"
17 18
18 "bnema.dev/xturbo"19 "bnema.dev/xturbo"
19 "bnema.dev/xturbo/composite"20 "bnema.dev/xturbo/composite"
@@ -63,6 +64,8 @@ type XWM struct {
63 // attachment64 // attachment
64 host host65 host host
65 src fdSource66 src fdSource
67+ addFD func(fd int, onReadable, onWritable func()) (fdSource, error) // display fd source seam
68+ after func(d time.Duration, fn func()) (cancel func())
66 post func(func())69 post func(func())
67 flushFn func()70 flushFn func()
68 71
@@ -92,6 +95,7 @@ func newXWM(conn *xturbo.Conn, log zerowrap.Logger) *XWM {
92 x := &XWM{conn: conn, log: log, root: conn.Root(), tab: newTable(initialSlots), space: new(Space)}95 x := &XWM{conn: conn, log: log, root: conn.Root(), tab: newTable(initialSlots), space: new(Space)}
93 x.h = xturbo.Handler{OnEvent: x.onEvent, OnReply: x.onReply, OnError: x.onError}96 x.h = xturbo.Handler{OnEvent: x.onEvent, OnReply: x.onReply, OnError: x.onError}
94 x.fit()97 x.fit()
98+ x.initSelection()
95 return x99 return x
96 }100 }
97 101
@@ -312,6 +316,13 @@ func (x *XWM) boot() error {
312 // Attach registers the X socket with the display and hands the windows found316 // Attach registers the X socket with the display and hands the windows found
313 // at boot to h. Display goroutine only.317 // at boot to h. Display goroutine only.
314 func (x *XWM) Attach(d *server.Display, h host) error {318 func (x *XWM) Attach(d *server.Display, h host) error {
319+ x.addFD = func(fd int, r, w func()) (fdSource, error) {
320+ s, err := d.AddFDRW(fd, r, w)
321+ if err != nil {
322+ return nil, err
323+ }
324+ return s, nil
325+ }
315 src, err := d.AddFDRW(x.conn.Fd(), x.onReadable, x.onWritable)326 src, err := d.AddFDRW(x.conn.Fd(), x.onReadable, x.onWritable)
316 if err != nil {327 if err != nil {
317 return fmt.Errorf("xwm: watch X socket: %w", err)328 return fmt.Errorf("xwm: watch X socket: %w", err)
@@ -323,6 +334,7 @@ func (x *XWM) Attach(d *server.Display, h host) error {
323 // attach is Attach over its seams: the event source and the display's Post.334 // attach is Attach over its seams: the event source and the display's Post.
324 func (x *XWM) attach(src fdSource, h host, post func(func())) {335 func (x *XWM) attach(src fdSource, h host, post func(func())) {
325 x.src, x.host, x.post = src, h, post336 x.src, x.host, x.post = src, h, post
337+ x.after = h.After
326 x.flushFn = func() {338 x.flushFn = func() {
327 x.posted = false339 x.posted = false
328 x.flush()340 x.flush()
@@ -352,6 +364,7 @@ func (x *XWM) Close() {
352 return364 return
353 }365 }
354 x.closed = true366 x.closed = true
367+ x.selClose()
355 s := x.stats368 s := x.stats
356 x.log.Info().Uint64("events", s.Events).Uint64("replies", s.Replies).Uint64("errors", s.Errors).369 x.log.Info().Uint64("events", s.Events).Uint64("replies", s.Replies).Uint64("errors", s.Errors).
357 Uint64("unattributed", s.Unattributed).Uint64("stale", s.Stale).Uint64("ring_full", s.RingFull).370 Uint64("unattributed", s.Unattributed).Uint64("stale", s.Stale).Uint64("ring_full", s.RingFull).

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