detached f4ebc85

Merge pull request 'fix: release review findings since v0.8.0' (#50) from fix/release-cleanup into main

Reviewed-on: https://git.bnema.dev/bnema/neferwl/pulls/50
commit
f4ebc8579851667f0010607c7cf617882eb3bed1
author
bnema · · 2026-10-14 13:41 UTC
merge of
877c74b + 5be01e9 · diff against the first parent
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
7 files · +72 -31
M internal/adapters/wayland/acquire_failure_test.go +29 -0
@@ -207,6 +207,35 @@ func TestAcquirePollTerminalFailureRetiresActiveAndAdded(t *testing.T) {
207207 }
208208 }
209209
210+// A waiter whose pipe cannot be pinned fails its waits and still closes
211+// their descriptors.
212+func TestSyncWaiterPinFailureClosesWaits(t *testing.T) {
213+ sw, err := newSyncWaiter(func() {})
214+ if err != nil {
215+ t.Fatal(err)
216+ }
217+ defer sw.close()
218+ // Pinned already: run's own Pin fails with EEXIST.
219+ if err := sw.waiter.Pin(int32(sw.pipeR.Fd())); err != nil {
220+ t.Fatal(err)
221+ }
222+ // A pipe stands in for the eventfd: once run closes the read end, a
223+ // write fails with EPIPE, whatever reuses the descriptor number.
224+ var p [2]int
225+ if err := unix.Pipe2(p[:], unix.O_CLOEXEC|unix.O_NONBLOCK); err != nil {
226+ t.Fatal(err)
227+ }
228+ defer unix.Close(p[1])
229+ w := sw.add(p[0])
230+ sw.run(context.Background())
231+ if !sw.fired(w) || !w.failed {
232+ t.Fatal("wait not failed after pin failure")
233+ }
234+ if _, err := unix.Write(p[1], []byte{0}); err != unix.EPIPE {
235+ t.Fatalf("wait descriptor still open: write %v", err)
236+ }
237+}
238+
210239 func TestFailedRootRetiresEveryChildPredecessorGraph(t *testing.T) {
211240 h := newSyncHarness(t)
212241 sw := h.s.syncWait
@@ -207,6 +207,35 @@ func TestAcquirePollTerminalFailureRetiresActiveAndAdded(t *testing.T) {
207 }207 }
208 }208 }
209 209
210+// A waiter whose pipe cannot be pinned fails its waits and still closes
211+// their descriptors.
212+func TestSyncWaiterPinFailureClosesWaits(t *testing.T) {
213+ sw, err := newSyncWaiter(func() {})
214+ if err != nil {
215+ t.Fatal(err)
216+ }
217+ defer sw.close()
218+ // Pinned already: run's own Pin fails with EEXIST.
219+ if err := sw.waiter.Pin(int32(sw.pipeR.Fd())); err != nil {
220+ t.Fatal(err)
221+ }
222+ // A pipe stands in for the eventfd: once run closes the read end, a
223+ // write fails with EPIPE, whatever reuses the descriptor number.
224+ var p [2]int
225+ if err := unix.Pipe2(p[:], unix.O_CLOEXEC|unix.O_NONBLOCK); err != nil {
226+ t.Fatal(err)
227+ }
228+ defer unix.Close(p[1])
229+ w := sw.add(p[0])
230+ sw.run(context.Background())
231+ if !sw.fired(w) || !w.failed {
232+ t.Fatal("wait not failed after pin failure")
233+ }
234+ if _, err := unix.Write(p[1], []byte{0}); err != unix.EPIPE {
235+ t.Fatalf("wait descriptor still open: write %v", err)
236+ }
237+}
238+
210 func TestFailedRootRetiresEveryChildPredecessorGraph(t *testing.T) {239 func TestFailedRootRetiresEveryChildPredecessorGraph(t *testing.T) {
211 h := newSyncHarness(t)240 h := newSyncHarness(t)
212 sw := h.s.syncWait241 sw := h.s.syncWait
M internal/adapters/wayland/alloc_hotpath_test.go +1 -2
@@ -64,8 +64,7 @@ func TestCommitSyncStorageIsPerUpdate(t *testing.T) {
6464 if err != nil {
6565 t.Fatal(err)
6666 }
67- defer sw.pipeR.Close()
68- defer sw.waiter.Close()
67+ defer sw.discard()
6968 defer sw.close()
7069 srv.syncWait = sw
7170 tl := &timeline{dev: dev, handle: 7, alive: true}
@@ -64,8 +64,7 @@ func TestCommitSyncStorageIsPerUpdate(t *testing.T) {
64 if err != nil {64 if err != nil {
65 t.Fatal(err)65 t.Fatal(err)
66 }66 }
67- defer sw.pipeR.Close()67+ defer sw.discard()
68- defer sw.waiter.Close()
69 defer sw.close()68 defer sw.close()
70 srv.syncWait = sw69 srv.syncWait = sw
71 tl := &timeline{dev: dev, handle: 7, alive: true}70 tl := &timeline{dev: dev, handle: 7, alive: true}
M internal/adapters/wayland/server.go +15 -16
@@ -420,15 +420,6 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
420420 s.colorFeedbacks = map[*colorFeedback]struct{}{}
421421 s.tokens = map[string]activationToken{}
422422 s.foreignExports = map[string]*foreignExport{}
423- if opts.Keymap != "" {
424- fd, size, e := keymapFile(opts.Keymap)
425- if e != nil {
426- cleanup()
427- d.Close()
428- return nil, e
429- }
430- s.seat.keymapFD, s.seat.keymapSize, s.seat.keymapText = fd, size, opts.Keymap
431- }
432423 s.syncWait.log = log
433424 s.cleanup = func() {
434425 if s.paceReady {
@@ -449,20 +440,28 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
449440 }
450441 cleanup()
451442 }
452- if s.securityStop, err = unix.Eventfd(0, unix.EFD_CLOEXEC|unix.EFD_NONBLOCK); err != nil {
443+ // Run never starts after a failure here: release what it would close.
444+ abort := func(err error) (*Server, error) {
453445 s.cleanup()
446+ s.syncWait.discard()
454447 d.Close()
455448 return nil, err
456449 }
450+ if opts.Keymap != "" {
451+ fd, size, e := keymapFile(opts.Keymap)
452+ if e != nil {
453+ return abort(e)
454+ }
455+ s.seat.keymapFD, s.seat.keymapSize, s.seat.keymapText = fd, size, opts.Keymap
456+ }
457+ if s.securityStop, err = unix.Eventfd(0, unix.EFD_CLOEXEC|unix.EFD_NONBLOCK); err != nil {
458+ return abort(err)
459+ }
457460 if err = s.openPaceTimer(); err != nil {
458- s.cleanup()
459- d.Close()
460- return nil, err
461+ return abort(err)
461462 }
462463 if err = registerGlobals(d, opts, s); err != nil {
463- s.cleanup()
464- d.Close()
465- return nil, err
464+ return abort(err)
466465 }
467466 for _, p := range opts.Outputs {
468467 s.addOutput(p)
@@ -420,15 +420,6 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
420 s.colorFeedbacks = map[*colorFeedback]struct{}{}420 s.colorFeedbacks = map[*colorFeedback]struct{}{}
421 s.tokens = map[string]activationToken{}421 s.tokens = map[string]activationToken{}
422 s.foreignExports = map[string]*foreignExport{}422 s.foreignExports = map[string]*foreignExport{}
423- if opts.Keymap != "" {
424- fd, size, e := keymapFile(opts.Keymap)
425- if e != nil {
426- cleanup()
427- d.Close()
428- return nil, e
429- }
430- s.seat.keymapFD, s.seat.keymapSize, s.seat.keymapText = fd, size, opts.Keymap
431- }
432 s.syncWait.log = log423 s.syncWait.log = log
433 s.cleanup = func() {424 s.cleanup = func() {
434 if s.paceReady {425 if s.paceReady {
@@ -449,20 +440,28 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
449 }440 }
450 cleanup()441 cleanup()
451 }442 }
452- if s.securityStop, err = unix.Eventfd(0, unix.EFD_CLOEXEC|unix.EFD_NONBLOCK); err != nil {443+ // Run never starts after a failure here: release what it would close.
444+ abort := func(err error) (*Server, error) {
453 s.cleanup()445 s.cleanup()
446+ s.syncWait.discard()
454 d.Close()447 d.Close()
455 return nil, err448 return nil, err
456 }449 }
450+ if opts.Keymap != "" {
451+ fd, size, e := keymapFile(opts.Keymap)
452+ if e != nil {
453+ return abort(e)
454+ }
455+ s.seat.keymapFD, s.seat.keymapSize, s.seat.keymapText = fd, size, opts.Keymap
456+ }
457+ if s.securityStop, err = unix.Eventfd(0, unix.EFD_CLOEXEC|unix.EFD_NONBLOCK); err != nil {
458+ return abort(err)
459+ }
457 if err = s.openPaceTimer(); err != nil {460 if err = s.openPaceTimer(); err != nil {
458- s.cleanup()461+ return abort(err)
459- d.Close()
460- return nil, err
461 }462 }
462 if err = registerGlobals(d, opts, s); err != nil {463 if err = registerGlobals(d, opts, s); err != nil {
463- s.cleanup()464+ return abort(err)
464- d.Close()
465- return nil, err
466 }465 }
467 for _, p := range opts.Outputs {466 for _, p := range opts.Outputs {
468 s.addOutput(p)467 s.addOutput(p)
M internal/adapters/wayland/syncobj.go +13 -6
@@ -388,12 +388,6 @@ func (sw *syncWaiter) run(ctx context.Context) {
388388 // The pipe lives as long as the poller; the eventfds come and go. The
389389 // poll sleeps until a fence, a new or cancelled wait (the pipe), or the
390390 // end of ctx.
391- sw.waiter.StopOn(ctx)
392- if err := sw.waiter.Pin(int32(pipeFD)); err != nil {
393- sw.log.Warn().Err(err).Msg("sync waiter")
394- sw.fail(nil)
395- return
396- }
397391 var waits []*syncWait
398392 defer func() {
399393 sw.mu.Lock()
@@ -404,6 +398,12 @@ func (sw *syncWaiter) run(ctx context.Context) {
404398 unix.Close(w.efd)
405399 }
406400 }()
401+ sw.waiter.StopOn(ctx)
402+ if err := sw.waiter.Pin(int32(pipeFD)); err != nil {
403+ sw.log.Warn().Err(err).Msg("sync waiter")
404+ sw.fail(nil)
405+ return
406+ }
407407 for ctx.Err() == nil {
408408 live := waits[:0]
409409 for _, w := range waits {
@@ -468,6 +468,13 @@ func (sw *syncWaiter) run(ctx context.Context) {
468468 // close stops the waiter's input side.
469469 func (sw *syncWaiter) close() { sw.pipeW.Close() }
470470
471+// discard releases what run would have closed, for a waiter whose run
472+// never starts.
473+func (sw *syncWaiter) discard() {
474+ sw.pipeR.Close()
475+ sw.waiter.Close()
476+}
477+
471478 // syncHold is a buffer's explicit-sync state while it is on screen: the
472479 // acquire fence handed to readers and the release point to signal.
473480 type syncHold struct {
@@ -388,12 +388,6 @@ func (sw *syncWaiter) run(ctx context.Context) {
388 // The pipe lives as long as the poller; the eventfds come and go. The388 // The pipe lives as long as the poller; the eventfds come and go. The
389 // poll sleeps until a fence, a new or cancelled wait (the pipe), or the389 // poll sleeps until a fence, a new or cancelled wait (the pipe), or the
390 // end of ctx.390 // end of ctx.
391- sw.waiter.StopOn(ctx)
392- if err := sw.waiter.Pin(int32(pipeFD)); err != nil {
393- sw.log.Warn().Err(err).Msg("sync waiter")
394- sw.fail(nil)
395- return
396- }
397 var waits []*syncWait391 var waits []*syncWait
398 defer func() {392 defer func() {
399 sw.mu.Lock()393 sw.mu.Lock()
@@ -404,6 +398,12 @@ func (sw *syncWaiter) run(ctx context.Context) {
404 unix.Close(w.efd)398 unix.Close(w.efd)
405 }399 }
406 }()400 }()
401+ sw.waiter.StopOn(ctx)
402+ if err := sw.waiter.Pin(int32(pipeFD)); err != nil {
403+ sw.log.Warn().Err(err).Msg("sync waiter")
404+ sw.fail(nil)
405+ return
406+ }
407 for ctx.Err() == nil {407 for ctx.Err() == nil {
408 live := waits[:0]408 live := waits[:0]
409 for _, w := range waits {409 for _, w := range waits {
@@ -468,6 +468,13 @@ func (sw *syncWaiter) run(ctx context.Context) {
468 // close stops the waiter's input side.468 // close stops the waiter's input side.
469 func (sw *syncWaiter) close() { sw.pipeW.Close() }469 func (sw *syncWaiter) close() { sw.pipeW.Close() }
470 470
471+// discard releases what run would have closed, for a waiter whose run
472+// never starts.
473+func (sw *syncWaiter) discard() {
474+ sw.pipeR.Close()
475+ sw.waiter.Close()
476+}
477+
471 // syncHold is a buffer's explicit-sync state while it is on screen: the478 // syncHold is a buffer's explicit-sync state while it is on screen: the
472 // acquire fence handed to readers and the release point to signal.479 // acquire fence handed to readers and the release point to signal.
473 type syncHold struct {480 type syncHold struct {
M internal/adapters/wayland/syncwait_test.go +2 -4
@@ -19,8 +19,7 @@ func TestSyncWaitAlreadySignalled(t *testing.T) {
1919 if err != nil {
2020 t.Fatal(err)
2121 }
22- defer sw.pipeR.Close()
23- defer sw.waiter.Close()
22+ defer sw.discard()
2423 defer sw.close()
2524 w, err := sw.watch(syncPoint{tl: &timeline{dev: dev, handle: 7}, point: 4})
2625 if err != nil || !sw.fired(w) {
@@ -34,8 +33,7 @@ func TestImplicitPollErrorRejectedAndWarnOnce(t *testing.T) {
3433 if err != nil {
3534 t.Fatal(err)
3635 }
37- defer sw.pipeR.Close()
38- defer sw.waiter.Close()
36+ defer sw.discard()
3937 defer sw.close()
4038 var output bytes.Buffer
4139 sw.log = zerowrap.New(zerowrap.Config{Output: &output})
@@ -19,8 +19,7 @@ func TestSyncWaitAlreadySignalled(t *testing.T) {
19 if err != nil {19 if err != nil {
20 t.Fatal(err)20 t.Fatal(err)
21 }21 }
22- defer sw.pipeR.Close()22+ defer sw.discard()
23- defer sw.waiter.Close()
24 defer sw.close()23 defer sw.close()
25 w, err := sw.watch(syncPoint{tl: &timeline{dev: dev, handle: 7}, point: 4})24 w, err := sw.watch(syncPoint{tl: &timeline{dev: dev, handle: 7}, point: 4})
26 if err != nil || !sw.fired(w) {25 if err != nil || !sw.fired(w) {
@@ -34,8 +33,7 @@ func TestImplicitPollErrorRejectedAndWarnOnce(t *testing.T) {
34 if err != nil {33 if err != nil {
35 t.Fatal(err)34 t.Fatal(err)
36 }35 }
37- defer sw.pipeR.Close()36+ defer sw.discard()
38- defer sw.waiter.Close()
39 defer sw.close()37 defer sw.close()
40 var output bytes.Buffer38 var output bytes.Buffer
41 sw.log = zerowrap.New(zerowrap.Config{Output: &output})39 sw.log = zerowrap.New(zerowrap.Config{Output: &output})
M internal/core/layout.go +6 -3
@@ -1832,13 +1832,16 @@ func (w *Workspace) appendLayout(dst []Placement) []Placement {
18321832 )
18331833 appendFloats := func(group int) {
18341834 for _, f := range w.Floats {
1835- below := f.below && w.coversFloat(f)
1835+ // A dialog shown over the cover stays a dialog whatever its
1836+ // size, so a full-size one is not drawn under the stash.
1837+ dialog := cover != 0 && w.overCover(f)
1838+ below := f.below && w.coversFloat(f) && !dialog
18361839 // Dialogs stay over covering floats even when the latter is
18371840 // selected. Fullscreen floats remain in the upper group.
18381841 order := dialogs
18391842 if below {
18401843 order = belowTiles
1841- } else if w.coversFloat(f) || w.fullscreen == f.ID {
1844+ } else if !dialog && (w.coversFloat(f) || w.fullscreen == f.ID) {
18421845 order = coveringFloats
18431846 }
18441847 if order != group {
@@ -1847,7 +1850,7 @@ func (w *Workspace) appendLayout(dst []Placement) []Placement {
18471850 // A covering fullscreen window hides every float but the
18481851 // dialogs over it (overCover): the others show again when it
18491852 // leaves fullscreen.
1850- p := Placement{ID: f.ID, Rect: w.floatRect(f), Floating: true, Below: below, Focused: f.ID == focusedID, Inset: ports.SideAll, Hidden: cover != 0 && f.ID != cover && !w.overCover(f)}
1853+ p := Placement{ID: f.ID, Rect: w.floatRect(f), Floating: true, Below: below, Focused: f.ID == focusedID, Inset: ports.SideAll, Hidden: cover != 0 && f.ID != cover && !dialog}
18511854 if p.Hidden {
18521855 p.Rect = Rect{}
18531856 result = append(result, p)
@@ -1832,13 +1832,16 @@ func (w *Workspace) appendLayout(dst []Placement) []Placement {
1832 )1832 )
1833 appendFloats := func(group int) {1833 appendFloats := func(group int) {
1834 for _, f := range w.Floats {1834 for _, f := range w.Floats {
1835- below := f.below && w.coversFloat(f)1835+ // A dialog shown over the cover stays a dialog whatever its
1836+ // size, so a full-size one is not drawn under the stash.
1837+ dialog := cover != 0 && w.overCover(f)
1838+ below := f.below && w.coversFloat(f) && !dialog
1836 // Dialogs stay over covering floats even when the latter is1839 // Dialogs stay over covering floats even when the latter is
1837 // selected. Fullscreen floats remain in the upper group.1840 // selected. Fullscreen floats remain in the upper group.
1838 order := dialogs1841 order := dialogs
1839 if below {1842 if below {
1840 order = belowTiles1843 order = belowTiles
1841- } else if w.coversFloat(f) || w.fullscreen == f.ID {1844+ } else if !dialog && (w.coversFloat(f) || w.fullscreen == f.ID) {
1842 order = coveringFloats1845 order = coveringFloats
1843 }1846 }
1844 if order != group {1847 if order != group {
@@ -1847,7 +1850,7 @@ func (w *Workspace) appendLayout(dst []Placement) []Placement {
1847 // A covering fullscreen window hides every float but the1850 // A covering fullscreen window hides every float but the
1848 // dialogs over it (overCover): the others show again when it1851 // dialogs over it (overCover): the others show again when it
1849 // leaves fullscreen.1852 // leaves fullscreen.
1850- p := Placement{ID: f.ID, Rect: w.floatRect(f), Floating: true, Below: below, Focused: f.ID == focusedID, Inset: ports.SideAll, Hidden: cover != 0 && f.ID != cover && !w.overCover(f)}1853+ p := Placement{ID: f.ID, Rect: w.floatRect(f), Floating: true, Below: below, Focused: f.ID == focusedID, Inset: ports.SideAll, Hidden: cover != 0 && f.ID != cover && !dialog}
1851 if p.Hidden {1854 if p.Hidden {
1852 p.Rect = Rect{}1855 p.Rect = Rect{}
1853 result = append(result, p)1856 result = append(result, p)
M internal/core/stash_cover_test.go +6 -0
@@ -203,6 +203,12 @@ func TestActivateOverCover(t *testing.T) {
203203 w.AddDialog(9, 2, 20, 10)
204204 return 9
205205 }, 9, true},
206+ // A dialog filling the usable area is a covering float: it stays
207+ // over the stash all the same.
208+ {"full-size dialog of stashed", func(w *Workspace, _ WindowID) WindowID {
209+ w.AddDialog(9, 2, 100, 80)
210+ return 9
211+ }, 9, true},
206212 {"dialog of cover", func(w *Workspace, cover WindowID) WindowID {
207213 w.AddDialog(9, cover, 20, 10)
208214 w.FocusID(cover)
@@ -203,6 +203,12 @@ func TestActivateOverCover(t *testing.T) {
203 w.AddDialog(9, 2, 20, 10)203 w.AddDialog(9, 2, 20, 10)
204 return 9204 return 9
205 }, 9, true},205 }, 9, true},
206+ // A dialog filling the usable area is a covering float: it stays
207+ // over the stash all the same.
208+ {"full-size dialog of stashed", func(w *Workspace, _ WindowID) WindowID {
209+ w.AddDialog(9, 2, 100, 80)
210+ return 9
211+ }, 9, true},
206 {"dialog of cover", func(w *Workspace, cover WindowID) WindowID {212 {"dialog of cover", func(w *Workspace, cover WindowID) WindowID {
207 w.AddDialog(9, cover, 20, 10)213 w.AddDialog(9, cover, 20, 10)
208 w.FocusID(cover)214 w.FocusID(cover)

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