detached f4c0e07

perf(adapters): park descriptor waits in the Go netpoller

commit
f4c0e07210da36741ba9c679a8130d7c8d694538
author
brice · · 2026-10-13 19:49 UTC
committer
brice · · 2026-10-14 04:35 UTC
parent
35a45d5
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
10 files · +311 -11
M Makefile +1 -1
@@ -61,7 +61,7 @@ arch: adapter-imports
6161 # Adapters stay independent: production code may import only the shared
6262 # adapter libraries below. outputkit holds what both output backends (drm,
6363 # headless) run; the others are leaf helpers.
64-ADAPTER_LIBS := busretry|outputkit/capture|outputkit/corewake|outputkit/presented|outputkit/surfaces|captureallow|clock|logging|sessionsecurity|syncfile|workspaceid|xkb
64+ADAPTER_LIBS := fdwait|busretry|outputkit/capture|outputkit/corewake|outputkit/presented|outputkit/surfaces|captureallow|clock|logging|sessionsecurity|syncfile|workspaceid|xkb
6565 adapter-imports:
6666 @edges=$$(CGO_ENABLED=0 go list -f '{{$$p := .ImportPath}}{{range .Imports}}{{$$p}} {{.}}{{"\n"}}{{end}}' ./internal/adapters/...) || exit 1; \
6767 bad=$$(echo "$$edges" | grep -E ' \S+/internal/adapters/' | grep -vE '/internal/adapters/($(ADAPTER_LIBS))$$' \
@@ -61,7 +61,7 @@ arch: adapter-imports
61 # Adapters stay independent: production code may import only the shared61 # Adapters stay independent: production code may import only the shared
62 # adapter libraries below. outputkit holds what both output backends (drm,62 # adapter libraries below. outputkit holds what both output backends (drm,
63 # headless) run; the others are leaf helpers.63 # headless) run; the others are leaf helpers.
64-ADAPTER_LIBS := busretry|outputkit/capture|outputkit/corewake|outputkit/presented|outputkit/surfaces|captureallow|clock|logging|sessionsecurity|syncfile|workspaceid|xkb64+ADAPTER_LIBS := fdwait|busretry|outputkit/capture|outputkit/corewake|outputkit/presented|outputkit/surfaces|captureallow|clock|logging|sessionsecurity|syncfile|workspaceid|xkb
65 adapter-imports:65 adapter-imports:
66 @edges=$$(CGO_ENABLED=0 go list -f '{{$$p := .ImportPath}}{{range .Imports}}{{$$p}} {{.}}{{"\n"}}{{end}}' ./internal/adapters/...) || exit 1; \66 @edges=$$(CGO_ENABLED=0 go list -f '{{$$p := .ImportPath}}{{range .Imports}}{{$$p}} {{.}}{{"\n"}}{{end}}' ./internal/adapters/...) || exit 1; \
67 bad=$$(echo "$$edges" | grep -E ' \S+/internal/adapters/' | grep -vE '/internal/adapters/($(ADAPTER_LIBS))$$' \67 bad=$$(echo "$$edges" | grep -E ' \S+/internal/adapters/' | grep -vE '/internal/adapters/($(ADAPTER_LIBS))$$' \
M internal/adapters/captureallow/store.go +7 -1
@@ -13,6 +13,7 @@ import (
1313 "time"
1414 "unsafe"
1515
16+ "github.com/bnema/neferwl/internal/adapters/fdwait"
1617 "github.com/bnema/zerowrap"
1718 "golang.org/x/sys/unix"
1819 )
@@ -186,6 +187,11 @@ func (s *Store) Run(ctx context.Context) error {
186187 return err
187188 }
188189 defer unix.Close(fd)
190+ waiter, err := fdwait.New()
191+ if err != nil {
192+ return err
193+ }
194+ defer waiter.Close()
189195 dir, name := filepath.Dir(s.path), filepath.Base(s.path)
190196 wd := -1
191197 var retry, pending time.Time
@@ -224,7 +230,7 @@ func (s *Store) Run(ctx context.Context) error {
224230 timeout = min(timeout, int(remaining.Milliseconds())+1)
225231 }
226232 fds := []unix.PollFd{{Fd: int32(fd), Events: unix.POLLIN}}
227- if _, err := unix.Poll(fds, timeout); err != nil {
233+ if _, err := waiter.Poll(fds, timeout); err != nil {
228234 if err == unix.EINTR {
229235 continue
230236 }
@@ -13,6 +13,7 @@ import (
13 "time"13 "time"
14 "unsafe"14 "unsafe"
15 15
16+ "github.com/bnema/neferwl/internal/adapters/fdwait"
16 "github.com/bnema/zerowrap"17 "github.com/bnema/zerowrap"
17 "golang.org/x/sys/unix"18 "golang.org/x/sys/unix"
18 )19 )
@@ -186,6 +187,11 @@ func (s *Store) Run(ctx context.Context) error {
186 return err187 return err
187 }188 }
188 defer unix.Close(fd)189 defer unix.Close(fd)
190+ waiter, err := fdwait.New()
191+ if err != nil {
192+ return err
193+ }
194+ defer waiter.Close()
189 dir, name := filepath.Dir(s.path), filepath.Base(s.path)195 dir, name := filepath.Dir(s.path), filepath.Base(s.path)
190 wd := -1196 wd := -1
191 var retry, pending time.Time197 var retry, pending time.Time
@@ -224,7 +230,7 @@ func (s *Store) Run(ctx context.Context) error {
224 timeout = min(timeout, int(remaining.Milliseconds())+1)230 timeout = min(timeout, int(remaining.Milliseconds())+1)
225 }231 }
226 fds := []unix.PollFd{{Fd: int32(fd), Events: unix.POLLIN}}232 fds := []unix.PollFd{{Fd: int32(fd), Events: unix.POLLIN}}
227- if _, err := unix.Poll(fds, timeout); err != nil {233+ if _, err := waiter.Poll(fds, timeout); err != nil {
228 if err == unix.EINTR {234 if err == unix.EINTR {
229 continue235 continue
230 }236 }
M internal/adapters/drm/card.go +7 -1
@@ -8,6 +8,7 @@ import (
88 "sync"
99 "sync/atomic"
1010
11+ "github.com/bnema/neferwl/internal/adapters/fdwait"
1112 "github.com/bnema/neferwl/internal/ports"
1213 "github.com/bnema/zerowrap"
1314 "golang.org/x/sys/unix"
@@ -247,11 +248,16 @@ func (c *Card) open(conn connector, mode modeInfo) (*Output, error) {
247248 // output of their CRTC. Run it once per card. An event is never dropped:
248249 // the output owes it a pending commit.
249250 func (c *Card) ReadEvents(ctx context.Context) error {
251+ waiter, err := fdwait.New()
252+ if err != nil {
253+ return fmt.Errorf("drm event waiter: %w", err)
254+ }
255+ defer waiter.Close()
250256 buf := make([]byte, 1024)
251257 var events []flipEvent // reused by every read
252258 for ctx.Err() == nil {
253259 fds := []unix.PollFd{{Fd: int32(c.fd), Events: unix.POLLIN}}
254- n, err := unix.Poll(fds, 100)
260+ n, err := waiter.Poll(fds, 100)
255261 if err != nil && !errors.Is(err, unix.EINTR) {
256262 return fmt.Errorf("drm poll: %w", err)
257263 }
@@ -8,6 +8,7 @@ import (
8 "sync"8 "sync"
9 "sync/atomic"9 "sync/atomic"
10 10
11+ "github.com/bnema/neferwl/internal/adapters/fdwait"
11 "github.com/bnema/neferwl/internal/ports"12 "github.com/bnema/neferwl/internal/ports"
12 "github.com/bnema/zerowrap"13 "github.com/bnema/zerowrap"
13 "golang.org/x/sys/unix"14 "golang.org/x/sys/unix"
@@ -247,11 +248,16 @@ func (c *Card) open(conn connector, mode modeInfo) (*Output, error) {
247 // output of their CRTC. Run it once per card. An event is never dropped:248 // output of their CRTC. Run it once per card. An event is never dropped:
248 // the output owes it a pending commit.249 // the output owes it a pending commit.
249 func (c *Card) ReadEvents(ctx context.Context) error {250 func (c *Card) ReadEvents(ctx context.Context) error {
251+ waiter, err := fdwait.New()
252+ if err != nil {
253+ return fmt.Errorf("drm event waiter: %w", err)
254+ }
255+ defer waiter.Close()
250 buf := make([]byte, 1024)256 buf := make([]byte, 1024)
251 var events []flipEvent // reused by every read257 var events []flipEvent // reused by every read
252 for ctx.Err() == nil {258 for ctx.Err() == nil {
253 fds := []unix.PollFd{{Fd: int32(c.fd), Events: unix.POLLIN}}259 fds := []unix.PollFd{{Fd: int32(c.fd), Events: unix.POLLIN}}
254- n, err := unix.Poll(fds, 100)260+ n, err := waiter.Poll(fds, 100)
255 if err != nil && !errors.Is(err, unix.EINTR) {261 if err != nil && !errors.Is(err, unix.EINTR) {
256 return fmt.Errorf("drm poll: %w", err)262 return fmt.Errorf("drm poll: %w", err)
257 }263 }
M internal/adapters/drm/hotplug.go +7 -1
@@ -6,6 +6,7 @@ import (
66 "fmt"
77 "sync"
88
9+ "github.com/bnema/neferwl/internal/adapters/fdwait"
910 "github.com/bnema/purego"
1011 "golang.org/x/sys/unix"
1112 )
@@ -77,9 +78,14 @@ func WatchHotplug(ctx context.Context, changed chan<- struct{}) error {
7778 return errors.New("udev monitor setup failed")
7879 }
7980 fd := monitorFD(mon)
81+ waiter, err := fdwait.New()
82+ if err != nil {
83+ return fmt.Errorf("udev waiter: %w", err)
84+ }
85+ defer waiter.Close()
8086 for ctx.Err() == nil {
8187 fds := []unix.PollFd{{Fd: fd, Events: unix.POLLIN}}
82- n, err := unix.Poll(fds, 100)
88+ n, err := waiter.Poll(fds, 100)
8389 if err != nil && !errors.Is(err, unix.EINTR) {
8490 return fmt.Errorf("udev poll: %w", err)
8591 }
@@ -6,6 +6,7 @@ import (
6 "fmt"6 "fmt"
7 "sync"7 "sync"
8 8
9+ "github.com/bnema/neferwl/internal/adapters/fdwait"
9 "github.com/bnema/purego"10 "github.com/bnema/purego"
10 "golang.org/x/sys/unix"11 "golang.org/x/sys/unix"
11 )12 )
@@ -77,9 +78,14 @@ func WatchHotplug(ctx context.Context, changed chan<- struct{}) error {
77 return errors.New("udev monitor setup failed")78 return errors.New("udev monitor setup failed")
78 }79 }
79 fd := monitorFD(mon)80 fd := monitorFD(mon)
81+ waiter, err := fdwait.New()
82+ if err != nil {
83+ return fmt.Errorf("udev waiter: %w", err)
84+ }
85+ defer waiter.Close()
80 for ctx.Err() == nil {86 for ctx.Err() == nil {
81 fds := []unix.PollFd{{Fd: fd, Events: unix.POLLIN}}87 fds := []unix.PollFd{{Fd: fd, Events: unix.POLLIN}}
82- n, err := unix.Poll(fds, 100)88+ n, err := waiter.Poll(fds, 100)
83 if err != nil && !errors.Is(err, unix.EINTR) {89 if err != nil && !errors.Is(err, unix.EINTR) {
84 return fmt.Errorf("udev poll: %w", err)90 return fmt.Errorf("udev poll: %w", err)
85 }91 }
A internal/adapters/fdwait/poller.go +111 -0
@@ -0,0 +1,111 @@
1+// Package fdwait parks descriptor waits in Go's netpoller.
2+package fdwait
3+
4+import (
5+ "errors"
6+ "os"
7+ "syscall"
8+ "time"
9+
10+ "golang.org/x/sys/unix"
11+)
12+
13+// Poller owns an epoll descriptor and is used by one goroutine.
14+// Watched descriptors remain owned by the caller.
15+type Poller struct {
16+ file *os.File
17+ raw syscall.RawConn
18+ registered map[int]struct{}
19+}
20+
21+func New() (*Poller, error) {
22+ fd, err := unix.EpollCreate1(unix.EPOLL_CLOEXEC)
23+ if err != nil {
24+ return nil, err
25+ }
26+ if err = unix.SetNonblock(fd, true); err != nil {
27+ unix.Close(fd)
28+ return nil, err
29+ }
30+ f := os.NewFile(uintptr(fd), "fdwait")
31+ raw, err := f.SyscallConn()
32+ if err != nil {
33+ f.Close()
34+ return nil, err
35+ }
36+ return &Poller{file: f, raw: raw, registered: make(map[int]struct{})}, nil
37+}
38+
39+func (p *Poller) Close() error { return p.file.Close() }
40+
41+// Poll has the readable/error semantics of unix.Poll. A negative timeout
42+// waits indefinitely. Timeout expiration returns zero, not an error.
43+func (p *Poller) Poll(fds []unix.PollFd, timeout int) (int, error) {
44+ // Remove registrations no longer requested. Closing a watched fd already
45+ // removes it from epoll, so ENOENT and EBADF are harmless here.
46+ err := p.raw.Control(func(ep uintptr) {
47+ for fd := range p.registered {
48+ found := false
49+ for _, f := range fds {
50+ if int(f.Fd) == fd {
51+ found = true
52+ break
53+ }
54+ }
55+ if !found {
56+ unix.EpollCtl(int(ep), unix.EPOLL_CTL_DEL, fd, nil)
57+ delete(p.registered, fd)
58+ }
59+ }
60+ })
61+ if err != nil {
62+ return 0, err
63+ }
64+ var ctlErr error
65+ err = p.raw.Control(func(ep uintptr) {
66+ for _, f := range fds {
67+ fd := int(f.Fd)
68+ if fd < 0 {
69+ continue
70+ }
71+ // MOD detects a descriptor number recycled after its old file closed.
72+ ev := unix.EpollEvent{Events: unix.EPOLLIN, Fd: int32(fd)}
73+ e := unix.EpollCtl(int(ep), unix.EPOLL_CTL_MOD, fd, &ev)
74+ if errors.Is(e, unix.ENOENT) {
75+ e = unix.EpollCtl(int(ep), unix.EPOLL_CTL_ADD, fd, &ev)
76+ }
77+ if e != nil {
78+ ctlErr = e
79+ return
80+ }
81+ p.registered[fd] = struct{}{}
82+ }
83+ })
84+ if err != nil {
85+ return 0, err
86+ }
87+ if ctlErr != nil {
88+ return 0, ctlErr
89+ }
90+ if timeout >= 0 {
91+ err = p.file.SetReadDeadline(time.Now().Add(time.Duration(timeout) * time.Millisecond))
92+ } else {
93+ err = p.file.SetReadDeadline(time.Time{})
94+ }
95+ if err != nil {
96+ return 0, err
97+ }
98+ var n int
99+ var pollErr error
100+ err = p.raw.Read(func(_ uintptr) bool {
101+ n, pollErr = unix.Poll(fds, 0)
102+ return n > 0 || (pollErr != nil && !errors.Is(pollErr, unix.EINTR))
103+ })
104+ if errors.Is(err, os.ErrDeadlineExceeded) {
105+ return 0, nil
106+ }
107+ if err != nil {
108+ return 0, err
109+ }
110+ return n, pollErr
111+}
@@ -0,0 +1,111 @@
1+// Package fdwait parks descriptor waits in Go's netpoller.
2+package fdwait
3+
4+import (
5+ "errors"
6+ "os"
7+ "syscall"
8+ "time"
9+
10+ "golang.org/x/sys/unix"
11+)
12+
13+// Poller owns an epoll descriptor and is used by one goroutine.
14+// Watched descriptors remain owned by the caller.
15+type Poller struct {
16+ file *os.File
17+ raw syscall.RawConn
18+ registered map[int]struct{}
19+}
20+
21+func New() (*Poller, error) {
22+ fd, err := unix.EpollCreate1(unix.EPOLL_CLOEXEC)
23+ if err != nil {
24+ return nil, err
25+ }
26+ if err = unix.SetNonblock(fd, true); err != nil {
27+ unix.Close(fd)
28+ return nil, err
29+ }
30+ f := os.NewFile(uintptr(fd), "fdwait")
31+ raw, err := f.SyscallConn()
32+ if err != nil {
33+ f.Close()
34+ return nil, err
35+ }
36+ return &Poller{file: f, raw: raw, registered: make(map[int]struct{})}, nil
37+}
38+
39+func (p *Poller) Close() error { return p.file.Close() }
40+
41+// Poll has the readable/error semantics of unix.Poll. A negative timeout
42+// waits indefinitely. Timeout expiration returns zero, not an error.
43+func (p *Poller) Poll(fds []unix.PollFd, timeout int) (int, error) {
44+ // Remove registrations no longer requested. Closing a watched fd already
45+ // removes it from epoll, so ENOENT and EBADF are harmless here.
46+ err := p.raw.Control(func(ep uintptr) {
47+ for fd := range p.registered {
48+ found := false
49+ for _, f := range fds {
50+ if int(f.Fd) == fd {
51+ found = true
52+ break
53+ }
54+ }
55+ if !found {
56+ unix.EpollCtl(int(ep), unix.EPOLL_CTL_DEL, fd, nil)
57+ delete(p.registered, fd)
58+ }
59+ }
60+ })
61+ if err != nil {
62+ return 0, err
63+ }
64+ var ctlErr error
65+ err = p.raw.Control(func(ep uintptr) {
66+ for _, f := range fds {
67+ fd := int(f.Fd)
68+ if fd < 0 {
69+ continue
70+ }
71+ // MOD detects a descriptor number recycled after its old file closed.
72+ ev := unix.EpollEvent{Events: unix.EPOLLIN, Fd: int32(fd)}
73+ e := unix.EpollCtl(int(ep), unix.EPOLL_CTL_MOD, fd, &ev)
74+ if errors.Is(e, unix.ENOENT) {
75+ e = unix.EpollCtl(int(ep), unix.EPOLL_CTL_ADD, fd, &ev)
76+ }
77+ if e != nil {
78+ ctlErr = e
79+ return
80+ }
81+ p.registered[fd] = struct{}{}
82+ }
83+ })
84+ if err != nil {
85+ return 0, err
86+ }
87+ if ctlErr != nil {
88+ return 0, ctlErr
89+ }
90+ if timeout >= 0 {
91+ err = p.file.SetReadDeadline(time.Now().Add(time.Duration(timeout) * time.Millisecond))
92+ } else {
93+ err = p.file.SetReadDeadline(time.Time{})
94+ }
95+ if err != nil {
96+ return 0, err
97+ }
98+ var n int
99+ var pollErr error
100+ err = p.raw.Read(func(_ uintptr) bool {
101+ n, pollErr = unix.Poll(fds, 0)
102+ return n > 0 || (pollErr != nil && !errors.Is(pollErr, unix.EINTR))
103+ })
104+ if errors.Is(err, os.ErrDeadlineExceeded) {
105+ return 0, nil
106+ }
107+ if err != nil {
108+ return 0, err
109+ }
110+ return n, pollErr
111+}
A internal/adapters/fdwait/poller_test.go +85 -0
@@ -0,0 +1,85 @@
1+package fdwait
2+
3+import (
4+ "golang.org/x/sys/unix"
5+ "testing"
6+ "time"
7+)
8+
9+func TestPollReadinessTimeoutAndReplacement(t *testing.T) {
10+ p, err := New()
11+ if err != nil {
12+ t.Fatal(err)
13+ }
14+ defer p.Close()
15+ fd, err := unix.Eventfd(0, unix.EFD_NONBLOCK|unix.EFD_CLOEXEC)
16+ if err != nil {
17+ t.Fatal(err)
18+ }
19+ defer unix.Close(fd)
20+ fds := []unix.PollFd{{Fd: int32(fd), Events: unix.POLLIN}}
21+ if n, err := p.Poll(fds, 5); n != 0 || err != nil {
22+ t.Fatalf("timeout: %d %v", n, err)
23+ }
24+ done := make(chan error, 1)
25+ go func() { _, e := unix.Write(fd, []byte{1, 0, 0, 0, 0, 0, 0, 0}); done <- e }()
26+ if n, err := p.Poll(fds, 1000); n != 1 || err != nil || fds[0].Revents&unix.POLLIN == 0 {
27+ t.Fatalf("readiness: %d %v %+v", n, err, fds)
28+ }
29+ if err := <-done; err != nil {
30+ t.Fatal(err)
31+ }
32+ // Level readiness must remain visible before the caller consumes it.
33+ if n, err := p.Poll(fds, 1000); n != 1 || err != nil {
34+ t.Fatalf("level: %d %v", n, err)
35+ }
36+ var b [8]byte
37+ if _, err := unix.Read(fd, b[:]); err != nil {
38+ t.Fatal(err)
39+ }
40+ if n, err := p.Poll(fds, 5); n != 0 || err != nil {
41+ t.Fatalf("consumed: %d %v", n, err)
42+ }
43+ other, err := unix.Eventfd(0, unix.EFD_NONBLOCK|unix.EFD_CLOEXEC)
44+ if err != nil {
45+ t.Fatal(err)
46+ }
47+ defer unix.Close(other)
48+ fds[0].Fd = int32(other)
49+ if _, err := unix.Write(other, b[:]); err != nil {
50+ t.Fatal(err)
51+ }
52+ if n, err := p.Poll(fds, 1000); n != 1 || err != nil {
53+ t.Fatalf("replacement: %d %v", n, err)
54+ }
55+ if _, ok := p.registered[fd]; ok {
56+ t.Fatal("old registration retained")
57+ }
58+}
59+
60+func TestCloseUnblocksPoll(t *testing.T) {
61+ p, err := New()
62+ if err != nil {
63+ t.Fatal(err)
64+ }
65+ defer p.Close()
66+ fd, err := unix.Eventfd(0, unix.EFD_NONBLOCK|unix.EFD_CLOEXEC)
67+ if err != nil {
68+ t.Fatal(err)
69+ }
70+ defer unix.Close(fd)
71+ done := make(chan error, 1)
72+ go func() { _, e := p.Poll([]unix.PollFd{{Fd: int32(fd), Events: unix.POLLIN}}, -1); done <- e }()
73+ // Close is safe both before and during the netpoll wait.
74+ if err := p.Close(); err != nil {
75+ t.Fatal(err)
76+ }
77+ select {
78+ case err := <-done:
79+ if err == nil {
80+ t.Fatal("closed wait succeeded")
81+ }
82+ case <-time.After(time.Second):
83+ t.Fatal("close did not unblock wait")
84+ }
85+}
@@ -0,0 +1,85 @@
1+package fdwait
2+
3+import (
4+ "golang.org/x/sys/unix"
5+ "testing"
6+ "time"
7+)
8+
9+func TestPollReadinessTimeoutAndReplacement(t *testing.T) {
10+ p, err := New()
11+ if err != nil {
12+ t.Fatal(err)
13+ }
14+ defer p.Close()
15+ fd, err := unix.Eventfd(0, unix.EFD_NONBLOCK|unix.EFD_CLOEXEC)
16+ if err != nil {
17+ t.Fatal(err)
18+ }
19+ defer unix.Close(fd)
20+ fds := []unix.PollFd{{Fd: int32(fd), Events: unix.POLLIN}}
21+ if n, err := p.Poll(fds, 5); n != 0 || err != nil {
22+ t.Fatalf("timeout: %d %v", n, err)
23+ }
24+ done := make(chan error, 1)
25+ go func() { _, e := unix.Write(fd, []byte{1, 0, 0, 0, 0, 0, 0, 0}); done <- e }()
26+ if n, err := p.Poll(fds, 1000); n != 1 || err != nil || fds[0].Revents&unix.POLLIN == 0 {
27+ t.Fatalf("readiness: %d %v %+v", n, err, fds)
28+ }
29+ if err := <-done; err != nil {
30+ t.Fatal(err)
31+ }
32+ // Level readiness must remain visible before the caller consumes it.
33+ if n, err := p.Poll(fds, 1000); n != 1 || err != nil {
34+ t.Fatalf("level: %d %v", n, err)
35+ }
36+ var b [8]byte
37+ if _, err := unix.Read(fd, b[:]); err != nil {
38+ t.Fatal(err)
39+ }
40+ if n, err := p.Poll(fds, 5); n != 0 || err != nil {
41+ t.Fatalf("consumed: %d %v", n, err)
42+ }
43+ other, err := unix.Eventfd(0, unix.EFD_NONBLOCK|unix.EFD_CLOEXEC)
44+ if err != nil {
45+ t.Fatal(err)
46+ }
47+ defer unix.Close(other)
48+ fds[0].Fd = int32(other)
49+ if _, err := unix.Write(other, b[:]); err != nil {
50+ t.Fatal(err)
51+ }
52+ if n, err := p.Poll(fds, 1000); n != 1 || err != nil {
53+ t.Fatalf("replacement: %d %v", n, err)
54+ }
55+ if _, ok := p.registered[fd]; ok {
56+ t.Fatal("old registration retained")
57+ }
58+}
59+
60+func TestCloseUnblocksPoll(t *testing.T) {
61+ p, err := New()
62+ if err != nil {
63+ t.Fatal(err)
64+ }
65+ defer p.Close()
66+ fd, err := unix.Eventfd(0, unix.EFD_NONBLOCK|unix.EFD_CLOEXEC)
67+ if err != nil {
68+ t.Fatal(err)
69+ }
70+ defer unix.Close(fd)
71+ done := make(chan error, 1)
72+ go func() { _, e := p.Poll([]unix.PollFd{{Fd: int32(fd), Events: unix.POLLIN}}, -1); done <- e }()
73+ // Close is safe both before and during the netpoll wait.
74+ if err := p.Close(); err != nil {
75+ t.Fatal(err)
76+ }
77+ select {
78+ case err := <-done:
79+ if err == nil {
80+ t.Fatal("closed wait succeeded")
81+ }
82+ case <-time.After(time.Second):
83+ t.Fatal("close did not unblock wait")
84+ }
85+}
A internal/adapters/fdwait/reuse_test.go +58 -0
@@ -0,0 +1,58 @@
1+package fdwait
2+
3+import (
4+ "golang.org/x/sys/unix"
5+ "testing"
6+)
7+
8+// Closing a watched file removes it from epoll. Reusing its number must
9+// register the new file, not leave it waiting on the old registration.
10+func TestPollRecycledDescriptor(t *testing.T) {
11+ p, err := New()
12+ if err != nil {
13+ t.Fatal(err)
14+ }
15+ defer p.Close()
16+ fd, err := unix.Eventfd(0, unix.EFD_NONBLOCK|unix.EFD_CLOEXEC)
17+ if err != nil {
18+ t.Fatal(err)
19+ }
20+ fds := []unix.PollFd{{Fd: int32(fd), Events: unix.POLLIN}}
21+ if _, err := p.Poll(fds, 1); err != nil {
22+ unix.Close(fd)
23+ t.Fatal(err)
24+ }
25+ unix.Close(fd)
26+ replacement, err := unix.Eventfd(0, unix.EFD_NONBLOCK|unix.EFD_CLOEXEC)
27+ if err != nil {
28+ t.Fatal(err)
29+ }
30+ defer unix.Close(replacement)
31+ if replacement != fd {
32+ t.Skip("descriptor number not reused")
33+ }
34+ if _, err := unix.Write(replacement, []byte{1, 0, 0, 0, 0, 0, 0, 0}); err != nil {
35+ t.Fatal(err)
36+ }
37+ if n, err := p.Poll(fds, 1000); n != 1 || err != nil {
38+ t.Fatalf("reused descriptor: %d %v", n, err)
39+ }
40+}
41+
42+func TestPollHangup(t *testing.T) {
43+ p, err := New()
44+ if err != nil {
45+ t.Fatal(err)
46+ }
47+ defer p.Close()
48+ var pipe [2]int
49+ if err := unix.Pipe2(pipe[:], unix.O_NONBLOCK|unix.O_CLOEXEC); err != nil {
50+ t.Fatal(err)
51+ }
52+ defer unix.Close(pipe[0])
53+ unix.Close(pipe[1])
54+ fds := []unix.PollFd{{Fd: int32(pipe[0]), Events: unix.POLLIN}}
55+ if n, err := p.Poll(fds, 1000); n != 1 || err != nil || fds[0].Revents&unix.POLLHUP == 0 {
56+ t.Fatalf("hangup: %d %v %+v", n, err, fds)
57+ }
58+}
@@ -0,0 +1,58 @@
1+package fdwait
2+
3+import (
4+ "golang.org/x/sys/unix"
5+ "testing"
6+)
7+
8+// Closing a watched file removes it from epoll. Reusing its number must
9+// register the new file, not leave it waiting on the old registration.
10+func TestPollRecycledDescriptor(t *testing.T) {
11+ p, err := New()
12+ if err != nil {
13+ t.Fatal(err)
14+ }
15+ defer p.Close()
16+ fd, err := unix.Eventfd(0, unix.EFD_NONBLOCK|unix.EFD_CLOEXEC)
17+ if err != nil {
18+ t.Fatal(err)
19+ }
20+ fds := []unix.PollFd{{Fd: int32(fd), Events: unix.POLLIN}}
21+ if _, err := p.Poll(fds, 1); err != nil {
22+ unix.Close(fd)
23+ t.Fatal(err)
24+ }
25+ unix.Close(fd)
26+ replacement, err := unix.Eventfd(0, unix.EFD_NONBLOCK|unix.EFD_CLOEXEC)
27+ if err != nil {
28+ t.Fatal(err)
29+ }
30+ defer unix.Close(replacement)
31+ if replacement != fd {
32+ t.Skip("descriptor number not reused")
33+ }
34+ if _, err := unix.Write(replacement, []byte{1, 0, 0, 0, 0, 0, 0, 0}); err != nil {
35+ t.Fatal(err)
36+ }
37+ if n, err := p.Poll(fds, 1000); n != 1 || err != nil {
38+ t.Fatalf("reused descriptor: %d %v", n, err)
39+ }
40+}
41+
42+func TestPollHangup(t *testing.T) {
43+ p, err := New()
44+ if err != nil {
45+ t.Fatal(err)
46+ }
47+ defer p.Close()
48+ var pipe [2]int
49+ if err := unix.Pipe2(pipe[:], unix.O_NONBLOCK|unix.O_CLOEXEC); err != nil {
50+ t.Fatal(err)
51+ }
52+ defer unix.Close(pipe[0])
53+ unix.Close(pipe[1])
54+ fds := []unix.PollFd{{Fd: int32(pipe[0]), Events: unix.POLLIN}}
55+ if n, err := p.Poll(fds, 1000); n != 1 || err != nil || fds[0].Revents&unix.POLLHUP == 0 {
56+ t.Fatalf("hangup: %d %v %+v", n, err, fds)
57+ }
58+}
M internal/adapters/libinput/libinput.go +7 -1
@@ -10,6 +10,7 @@ import (
1010 "time"
1111 "unsafe"
1212
13+ "github.com/bnema/neferwl/internal/adapters/fdwait"
1314 "github.com/bnema/neferwl/internal/adapters/xkb"
1415 "github.com/bnema/neferwl/internal/ports"
1516 "github.com/bnema/purego"
@@ -267,6 +268,11 @@ func Run(ctx context.Context, opts Options, input chan<- ports.InputEvent) error
267268 p.moved(opts.MoveCursor, false)
268269 in := &inputState{cfg: opts.DeviceConfig, dev: libinputDevices{}, devices: map[uintptr]bool{}}
269270 defer in.release()
271+ waiter, err := fdwait.New()
272+ if err != nil {
273+ return fmt.Errorf("libinput waiter: %w", err)
274+ }
275+ defer waiter.Close()
270276 fd := getFD(li)
271277 fwd := newForwarder(opts.Log)
272278 fwdCtx, stopFwd := context.WithCancel(ctx)
@@ -313,7 +319,7 @@ func Run(ctx context.Context, opts Options, input chan<- ports.InputEvent) error
313319 default:
314320 }
315321 fds := []unix.PollFd{{Fd: fd, Events: unix.POLLIN}}
316- n, err := unix.Poll(fds, 100)
322+ n, err := waiter.Poll(fds, 100)
317323 if err != nil && !errors.Is(err, unix.EINTR) {
318324 return fmt.Errorf("libinput poll: %w", err)
319325 }
@@ -10,6 +10,7 @@ import (
10 "time"10 "time"
11 "unsafe"11 "unsafe"
12 12
13+ "github.com/bnema/neferwl/internal/adapters/fdwait"
13 "github.com/bnema/neferwl/internal/adapters/xkb"14 "github.com/bnema/neferwl/internal/adapters/xkb"
14 "github.com/bnema/neferwl/internal/ports"15 "github.com/bnema/neferwl/internal/ports"
15 "github.com/bnema/purego"16 "github.com/bnema/purego"
@@ -267,6 +268,11 @@ func Run(ctx context.Context, opts Options, input chan<- ports.InputEvent) error
267 p.moved(opts.MoveCursor, false)268 p.moved(opts.MoveCursor, false)
268 in := &inputState{cfg: opts.DeviceConfig, dev: libinputDevices{}, devices: map[uintptr]bool{}}269 in := &inputState{cfg: opts.DeviceConfig, dev: libinputDevices{}, devices: map[uintptr]bool{}}
269 defer in.release()270 defer in.release()
271+ waiter, err := fdwait.New()
272+ if err != nil {
273+ return fmt.Errorf("libinput waiter: %w", err)
274+ }
275+ defer waiter.Close()
270 fd := getFD(li)276 fd := getFD(li)
271 fwd := newForwarder(opts.Log)277 fwd := newForwarder(opts.Log)
272 fwdCtx, stopFwd := context.WithCancel(ctx)278 fwdCtx, stopFwd := context.WithCancel(ctx)
@@ -313,7 +319,7 @@ func Run(ctx context.Context, opts Options, input chan<- ports.InputEvent) error
313 default:319 default:
314 }320 }
315 fds := []unix.PollFd{{Fd: fd, Events: unix.POLLIN}}321 fds := []unix.PollFd{{Fd: fd, Events: unix.POLLIN}}
316- n, err := unix.Poll(fds, 100)322+ n, err := waiter.Poll(fds, 100)
317 if err != nil && !errors.Is(err, unix.EINTR) {323 if err != nil && !errors.Is(err, unix.EINTR) {
318 return fmt.Errorf("libinput poll: %w", err)324 return fmt.Errorf("libinput poll: %w", err)
319 }325 }
M internal/adapters/seat/seat.go +7 -1
@@ -10,6 +10,7 @@ import (
1010 "sync"
1111 "unsafe"
1212
13+ "github.com/bnema/neferwl/internal/adapters/fdwait"
1314 "github.com/bnema/purego"
1415 "github.com/bnema/zerowrap"
1516 "golang.org/x/sys/unix"
@@ -190,9 +191,14 @@ func (s *Seat) dispatchLocked(ctx context.Context, timeoutMs int) error {
190191 // Run dispatches seat events until ctx ends. The owner calls Close afterwards,
191192 // once outputs have restored their CRTC state.
192193 func (s *Seat) Run(ctx context.Context) error {
194+ waiter, err := fdwait.New()
195+ if err != nil {
196+ return fmt.Errorf("seat waiter: %w", err)
197+ }
198+ defer waiter.Close()
193199 for ctx.Err() == nil {
194200 fds := []unix.PollFd{{Fd: getFD(s.handle), Events: unix.POLLIN}}
195- if _, err := unix.Poll(fds, 100); err != nil && !errors.Is(err, unix.EINTR) {
201+ if _, err := waiter.Poll(fds, 100); err != nil && !errors.Is(err, unix.EINTR) {
196202 return fmt.Errorf("seat poll: %w", err)
197203 }
198204 // Dispatch every tick: libseat may queue events without the fd becoming readable.
@@ -10,6 +10,7 @@ import (
10 "sync"10 "sync"
11 "unsafe"11 "unsafe"
12 12
13+ "github.com/bnema/neferwl/internal/adapters/fdwait"
13 "github.com/bnema/purego"14 "github.com/bnema/purego"
14 "github.com/bnema/zerowrap"15 "github.com/bnema/zerowrap"
15 "golang.org/x/sys/unix"16 "golang.org/x/sys/unix"
@@ -190,9 +191,14 @@ func (s *Seat) dispatchLocked(ctx context.Context, timeoutMs int) error {
190 // Run dispatches seat events until ctx ends. The owner calls Close afterwards,191 // Run dispatches seat events until ctx ends. The owner calls Close afterwards,
191 // once outputs have restored their CRTC state.192 // once outputs have restored their CRTC state.
192 func (s *Seat) Run(ctx context.Context) error {193 func (s *Seat) Run(ctx context.Context) error {
194+ waiter, err := fdwait.New()
195+ if err != nil {
196+ return fmt.Errorf("seat waiter: %w", err)
197+ }
198+ defer waiter.Close()
193 for ctx.Err() == nil {199 for ctx.Err() == nil {
194 fds := []unix.PollFd{{Fd: getFD(s.handle), Events: unix.POLLIN}}200 fds := []unix.PollFd{{Fd: getFD(s.handle), Events: unix.POLLIN}}
195- if _, err := unix.Poll(fds, 100); err != nil && !errors.Is(err, unix.EINTR) {201+ if _, err := waiter.Poll(fds, 100); err != nil && !errors.Is(err, unix.EINTR) {
196 return fmt.Errorf("seat poll: %w", err)202 return fmt.Errorf("seat poll: %w", err)
197 }203 }
198 // Dispatch every tick: libseat may queue events without the fd becoming readable.204 // Dispatch every tick: libseat may queue events without the fd becoming readable.
M internal/adapters/wayland/syncobj.go +21 -5
@@ -9,6 +9,7 @@ import (
99
1010 "github.com/bnema/go-wayland-bindings/server/linuxdrmsyncobj"
1111 "github.com/bnema/go-wayland-bindings/server/wayland"
12+ "github.com/bnema/neferwl/internal/adapters/fdwait"
1213 "github.com/bnema/neferwl/internal/ports"
1314 "github.com/bnema/purego-libwayland/server"
1415 "github.com/bnema/zerowrap"
@@ -197,14 +198,16 @@ type acquirePoller interface {
197198 poll([]unix.PollFd, int) (int, error)
198199 }
199200
200-type unixAcquirePoller struct{}
201+type unixAcquirePoller struct{ waiter *fdwait.Poller }
201202
202-func (unixAcquirePoller) poll(fds []unix.PollFd, timeout int) (int, error) {
203- return unix.Poll(fds, timeout)
203+func (p unixAcquirePoller) poll(fds []unix.PollFd, timeout int) (int, error) {
204+ return p.waiter.Poll(fds, timeout)
204205 }
205206
206207 type syncWaiter struct {
207208 poller acquirePoller
209+ waiter *fdwait.Poller
210+ pipeFD int
208211 terminal bool // polling ended in failure; protected by mu
209212 mu sync.Mutex
210213 ready map[*syncWait]bool
@@ -229,7 +232,13 @@ func newSyncWaiter(wake func()) (*syncWaiter, error) {
229232 if err != nil {
230233 return nil, err
231234 }
232- return &syncWaiter{poller: unixAcquirePoller{}, ready: map[*syncWait]bool{}, wake: wake, pipeR: r, pipeW: w, log: zerowrap.Default()}, nil
235+ p, err := fdwait.New()
236+ if err != nil {
237+ r.Close()
238+ w.Close()
239+ return nil, err
240+ }
241+ return &syncWaiter{poller: unixAcquirePoller{p}, waiter: p, pipeFD: int(r.Fd()), ready: map[*syncWait]bool{}, wake: wake, pipeR: r, pipeW: w, log: zerowrap.Default()}, nil
233242 }
234243
235244 // watch registers an eventfd for p; the wait is ready once it fires.
@@ -375,6 +384,13 @@ func (sw *syncWaiter) fail(waits []*syncWait) {
375384 // run polls every watched eventfd; it owns and closes them.
376385 func (sw *syncWaiter) run(ctx context.Context) {
377386 defer sw.pipeR.Close()
387+ if sw.waiter != nil {
388+ defer sw.waiter.Close()
389+ }
390+ pipeFD := sw.pipeFD
391+ if sw.waiter == nil { // bare test fixtures own their pipe
392+ pipeFD = int(sw.pipeR.Fd())
393+ }
378394 var waits []*syncWait
379395 defer func() {
380396 sw.mu.Lock()
@@ -395,7 +411,7 @@ func (sw *syncWaiter) run(ctx context.Context) {
395411 live = append(live, w)
396412 }
397413 waits = live
398- fds := []unix.PollFd{{Fd: int32(sw.pipeR.Fd()), Events: unix.POLLIN}}
414+ fds := []unix.PollFd{{Fd: int32(pipeFD), Events: unix.POLLIN}}
399415 for _, w := range waits {
400416 fds = append(fds, unix.PollFd{Fd: int32(w.efd), Events: unix.POLLIN})
401417 }
@@ -9,6 +9,7 @@ import (
9 9
10 "github.com/bnema/go-wayland-bindings/server/linuxdrmsyncobj"10 "github.com/bnema/go-wayland-bindings/server/linuxdrmsyncobj"
11 "github.com/bnema/go-wayland-bindings/server/wayland"11 "github.com/bnema/go-wayland-bindings/server/wayland"
12+ "github.com/bnema/neferwl/internal/adapters/fdwait"
12 "github.com/bnema/neferwl/internal/ports"13 "github.com/bnema/neferwl/internal/ports"
13 "github.com/bnema/purego-libwayland/server"14 "github.com/bnema/purego-libwayland/server"
14 "github.com/bnema/zerowrap"15 "github.com/bnema/zerowrap"
@@ -197,14 +198,16 @@ type acquirePoller interface {
197 poll([]unix.PollFd, int) (int, error)198 poll([]unix.PollFd, int) (int, error)
198 }199 }
199 200
200-type unixAcquirePoller struct{}201+type unixAcquirePoller struct{ waiter *fdwait.Poller }
201 202
202-func (unixAcquirePoller) poll(fds []unix.PollFd, timeout int) (int, error) {203+func (p unixAcquirePoller) poll(fds []unix.PollFd, timeout int) (int, error) {
203- return unix.Poll(fds, timeout)204+ return p.waiter.Poll(fds, timeout)
204 }205 }
205 206
206 type syncWaiter struct {207 type syncWaiter struct {
207 poller acquirePoller208 poller acquirePoller
209+ waiter *fdwait.Poller
210+ pipeFD int
208 terminal bool // polling ended in failure; protected by mu211 terminal bool // polling ended in failure; protected by mu
209 mu sync.Mutex212 mu sync.Mutex
210 ready map[*syncWait]bool213 ready map[*syncWait]bool
@@ -229,7 +232,13 @@ func newSyncWaiter(wake func()) (*syncWaiter, error) {
229 if err != nil {232 if err != nil {
230 return nil, err233 return nil, err
231 }234 }
232- return &syncWaiter{poller: unixAcquirePoller{}, ready: map[*syncWait]bool{}, wake: wake, pipeR: r, pipeW: w, log: zerowrap.Default()}, nil235+ p, err := fdwait.New()
236+ if err != nil {
237+ r.Close()
238+ w.Close()
239+ return nil, err
240+ }
241+ return &syncWaiter{poller: unixAcquirePoller{p}, waiter: p, pipeFD: int(r.Fd()), ready: map[*syncWait]bool{}, wake: wake, pipeR: r, pipeW: w, log: zerowrap.Default()}, nil
233 }242 }
234 243
235 // watch registers an eventfd for p; the wait is ready once it fires.244 // watch registers an eventfd for p; the wait is ready once it fires.
@@ -375,6 +384,13 @@ func (sw *syncWaiter) fail(waits []*syncWait) {
375 // run polls every watched eventfd; it owns and closes them.384 // run polls every watched eventfd; it owns and closes them.
376 func (sw *syncWaiter) run(ctx context.Context) {385 func (sw *syncWaiter) run(ctx context.Context) {
377 defer sw.pipeR.Close()386 defer sw.pipeR.Close()
387+ if sw.waiter != nil {
388+ defer sw.waiter.Close()
389+ }
390+ pipeFD := sw.pipeFD
391+ if sw.waiter == nil { // bare test fixtures own their pipe
392+ pipeFD = int(sw.pipeR.Fd())
393+ }
378 var waits []*syncWait394 var waits []*syncWait
379 defer func() {395 defer func() {
380 sw.mu.Lock()396 sw.mu.Lock()
@@ -395,7 +411,7 @@ func (sw *syncWaiter) run(ctx context.Context) {
395 live = append(live, w)411 live = append(live, w)
396 }412 }
397 waits = live413 waits = live
398- fds := []unix.PollFd{{Fd: int32(sw.pipeR.Fd()), Events: unix.POLLIN}}414+ fds := []unix.PollFd{{Fd: int32(pipeFD), Events: unix.POLLIN}}
399 for _, w := range waits {415 for _, w := range waits {
400 fds = append(fds, unix.PollFd{Fd: int32(w.efd), Events: unix.POLLIN})416 fds = append(fds, unix.PollFd{Fd: int32(w.efd), Events: unix.POLLIN})
401 }417 }

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 25

Tags 10

↑↓ moveenter openesc close