detached 1095483

feat: add browser launch relay transport

commit
1095483f2822b695f7ee0414f220bf8de89aaac9
author
brice · · 2026-04-14 12:24 UTC
parent
264138f
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +129 -6
M internal/infrastructure/desktop/browser_launch_relay.go +49 -6
@@ -11,12 +11,15 @@ import (
1111 "path/filepath"
1212 "sync"
1313 "syscall"
14+ "time"
1415
1516 "github.com/bnema/dumber/internal/application/port"
1617 )
1718
1819 const browserLaunchSocketName = "browser-launch.sock"
1920
21+const browserLaunchIOTimeout = 50 * time.Millisecond
22+
2023 type browserLaunchRelay struct {
2124 xdg port.XDGPaths
2225 }
@@ -63,14 +66,34 @@ func (r *browserLaunchRelay) DeliverOpenFreshWindow(ctx context.Context, url str
6366 }
6467 defer conn.Close()
6568
69+ if err := setBrowserLaunchConnDeadline(ctx, conn); err != nil {
70+ return false, err
71+ }
6672 if err := json.NewEncoder(conn).Encode(browserLaunchRequest{URL: url}); err != nil {
6773 return false, err
6874 }
6975
70- var response browserLaunchResponse
71- if err := json.NewDecoder(conn).Decode(&response); err != nil {
76+ if err := setBrowserLaunchConnDeadline(ctx, conn); err != nil {
7277 return false, err
7378 }
79+
80+ var response browserLaunchResponse
81+ decoder := json.NewDecoder(conn)
82+ for {
83+ if err := decoder.Decode(&response); err != nil {
84+ if isBrowserLaunchReadTimeout(err) {
85+ if ctxErr := ctx.Err(); ctxErr != nil {
86+ return false, ctxErr
87+ }
88+ if err := setBrowserLaunchConnDeadline(ctx, conn); err != nil {
89+ return false, err
90+ }
91+ continue
92+ }
93+ return false, err
94+ }
95+ break
96+ }
7497 if response.Error != "" {
7598 return true, errors.New(response.Error)
7699 }
@@ -82,6 +105,20 @@ func isMissingRelayListener(err error) bool {
82105 return os.IsNotExist(err) || errors.Is(err, syscall.ECONNREFUSED)
83106 }
84107
108+func isBrowserLaunchReadTimeout(err error) bool {
109+ var netErr net.Error
110+ return errors.As(err, &netErr) && netErr.Timeout()
111+}
112+
113+func setBrowserLaunchConnDeadline(ctx context.Context, conn net.Conn) error {
114+ deadline := time.Now().Add(browserLaunchIOTimeout)
115+ if ctxDeadline, ok := ctx.Deadline(); ok && ctxDeadline.Before(deadline) {
116+ deadline = ctxDeadline
117+ }
118+
119+ return conn.SetDeadline(deadline)
120+}
121+
85122 func (r *browserLaunchRelay) Listen(ctx context.Context, opener port.BrowserWindowOpener) (io.Closer, error) {
86123 socketPath, err := r.socketPath()
87124 if err != nil {
@@ -102,10 +139,6 @@ func (r *browserLaunchRelay) Listen(ctx context.Context, opener port.BrowserWind
102139
103140 relayListener := &browserLaunchRelayListener{listener: listener, socketPath: socketPath}
104141 go relayListener.serve(ctx, opener)
105- go func() {
106- <-ctx.Done()
107- _ = relayListener.Close()
108- }()
109142
110143 return relayListener, nil
111144 }
@@ -149,11 +182,21 @@ func (l *browserLaunchRelayListener) serve(ctx context.Context, opener port.Brow
149182 defer l.Close()
150183
151184 for {
185+ if err := l.listener.SetDeadline(time.Now().Add(browserLaunchIOTimeout)); err != nil {
186+ return
187+ }
152188 conn, err := l.listener.AcceptUnix()
153189 if err != nil {
154190 if ctx.Err() != nil || errors.Is(err, net.ErrClosed) {
155191 return
156192 }
193+ var netErr net.Error
194+ if errors.As(err, &netErr) && netErr.Timeout() {
195+ if ctx.Err() != nil {
196+ return
197+ }
198+ continue
199+ }
157200 continue
158201 }
159202
@@ -11,12 +11,15 @@ import (
11 "path/filepath"11 "path/filepath"
12 "sync"12 "sync"
13 "syscall"13 "syscall"
14+ "time"
14 15
15 "github.com/bnema/dumber/internal/application/port"16 "github.com/bnema/dumber/internal/application/port"
16 )17 )
17 18
18 const browserLaunchSocketName = "browser-launch.sock"19 const browserLaunchSocketName = "browser-launch.sock"
19 20
21+const browserLaunchIOTimeout = 50 * time.Millisecond
22+
20 type browserLaunchRelay struct {23 type browserLaunchRelay struct {
21 xdg port.XDGPaths24 xdg port.XDGPaths
22 }25 }
@@ -63,14 +66,34 @@ func (r *browserLaunchRelay) DeliverOpenFreshWindow(ctx context.Context, url str
63 }66 }
64 defer conn.Close()67 defer conn.Close()
65 68
69+ if err := setBrowserLaunchConnDeadline(ctx, conn); err != nil {
70+ return false, err
71+ }
66 if err := json.NewEncoder(conn).Encode(browserLaunchRequest{URL: url}); err != nil {72 if err := json.NewEncoder(conn).Encode(browserLaunchRequest{URL: url}); err != nil {
67 return false, err73 return false, err
68 }74 }
69 75
70- var response browserLaunchResponse76+ if err := setBrowserLaunchConnDeadline(ctx, conn); err != nil {
71- if err := json.NewDecoder(conn).Decode(&response); err != nil {
72 return false, err77 return false, err
73 }78 }
79+
80+ var response browserLaunchResponse
81+ decoder := json.NewDecoder(conn)
82+ for {
83+ if err := decoder.Decode(&response); err != nil {
84+ if isBrowserLaunchReadTimeout(err) {
85+ if ctxErr := ctx.Err(); ctxErr != nil {
86+ return false, ctxErr
87+ }
88+ if err := setBrowserLaunchConnDeadline(ctx, conn); err != nil {
89+ return false, err
90+ }
91+ continue
92+ }
93+ return false, err
94+ }
95+ break
96+ }
74 if response.Error != "" {97 if response.Error != "" {
75 return true, errors.New(response.Error)98 return true, errors.New(response.Error)
76 }99 }
@@ -82,6 +105,20 @@ func isMissingRelayListener(err error) bool {
82 return os.IsNotExist(err) || errors.Is(err, syscall.ECONNREFUSED)105 return os.IsNotExist(err) || errors.Is(err, syscall.ECONNREFUSED)
83 }106 }
84 107
108+func isBrowserLaunchReadTimeout(err error) bool {
109+ var netErr net.Error
110+ return errors.As(err, &netErr) && netErr.Timeout()
111+}
112+
113+func setBrowserLaunchConnDeadline(ctx context.Context, conn net.Conn) error {
114+ deadline := time.Now().Add(browserLaunchIOTimeout)
115+ if ctxDeadline, ok := ctx.Deadline(); ok && ctxDeadline.Before(deadline) {
116+ deadline = ctxDeadline
117+ }
118+
119+ return conn.SetDeadline(deadline)
120+}
121+
85 func (r *browserLaunchRelay) Listen(ctx context.Context, opener port.BrowserWindowOpener) (io.Closer, error) {122 func (r *browserLaunchRelay) Listen(ctx context.Context, opener port.BrowserWindowOpener) (io.Closer, error) {
86 socketPath, err := r.socketPath()123 socketPath, err := r.socketPath()
87 if err != nil {124 if err != nil {
@@ -102,10 +139,6 @@ func (r *browserLaunchRelay) Listen(ctx context.Context, opener port.BrowserWind
102 139
103 relayListener := &browserLaunchRelayListener{listener: listener, socketPath: socketPath}140 relayListener := &browserLaunchRelayListener{listener: listener, socketPath: socketPath}
104 go relayListener.serve(ctx, opener)141 go relayListener.serve(ctx, opener)
105- go func() {
106- <-ctx.Done()
107- _ = relayListener.Close()
108- }()
109 142
110 return relayListener, nil143 return relayListener, nil
111 }144 }
@@ -149,11 +182,21 @@ func (l *browserLaunchRelayListener) serve(ctx context.Context, opener port.Brow
149 defer l.Close()182 defer l.Close()
150 183
151 for {184 for {
185+ if err := l.listener.SetDeadline(time.Now().Add(browserLaunchIOTimeout)); err != nil {
186+ return
187+ }
152 conn, err := l.listener.AcceptUnix()188 conn, err := l.listener.AcceptUnix()
153 if err != nil {189 if err != nil {
154 if ctx.Err() != nil || errors.Is(err, net.ErrClosed) {190 if ctx.Err() != nil || errors.Is(err, net.ErrClosed) {
155 return191 return
156 }192 }
193+ var netErr net.Error
194+ if errors.As(err, &netErr) && netErr.Timeout() {
195+ if ctx.Err() != nil {
196+ return
197+ }
198+ continue
199+ }
157 continue200 continue
158 }201 }
159 202
M internal/infrastructure/desktop/browser_launch_relay_test.go +80 -0
@@ -46,6 +46,15 @@ func waitForSocket(t *testing.T, path string) {
4646 }, time.Second, 10*time.Millisecond)
4747 }
4848
49+func waitForSocketGone(t *testing.T, path string) {
50+ t.Helper()
51+
52+ require.Eventually(t, func() bool {
53+ _, err := os.Stat(path)
54+ return os.IsNotExist(err)
55+ }, time.Second, 10*time.Millisecond)
56+}
57+
4958 func shortTempDir(t *testing.T) string {
5059 t.Helper()
5160
@@ -165,3 +174,74 @@ func TestBrowserLaunchRelay_RejectsMalformedPayloads(t *testing.T) {
165174 t.Fatal("expected opener to receive the URL after malformed payload")
166175 }
167176 }
177+
178+func TestBrowserLaunchRelay_CloseRemovesSocketPath(t *testing.T) {
179+ runtimeDir := filepath.Join(shortTempDir(t), "runtime")
180+ relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir})
181+
182+ ctx, cancel := context.WithCancel(context.Background())
183+ defer cancel()
184+
185+ closer, err := relay.Listen(ctx, browserWindowOpenerFunc(func(context.Context, string) error { return nil }))
186+ require.NoError(t, err)
187+
188+ socketPath := filepath.Join(runtimeDir, "browser-launch.sock")
189+ waitForSocket(t, socketPath)
190+
191+ require.NoError(t, closer.Close())
192+ waitForSocketGone(t, socketPath)
193+}
194+
195+func TestBrowserLaunchRelay_ContextCancelStopsServing(t *testing.T) {
196+ runtimeDir := filepath.Join(shortTempDir(t), "runtime")
197+ relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir})
198+
199+ ctx, cancel := context.WithCancel(context.Background())
200+ closer, err := relay.Listen(ctx, browserWindowOpenerFunc(func(context.Context, string) error { return nil }))
201+ require.NoError(t, err)
202+
203+ socketPath := filepath.Join(runtimeDir, "browser-launch.sock")
204+ waitForSocket(t, socketPath)
205+
206+ cancel()
207+ waitForSocketGone(t, socketPath)
208+ require.NoError(t, closer.Close())
209+
210+ delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/after-cancel")
211+ require.NoError(t, err)
212+ assert.False(t, delivered)
213+}
214+
215+func TestBrowserLaunchRelay_DeliverOpenFreshWindow_RespectsResponseReadTimeout(t *testing.T) {
216+ runtimeDir := shortTempDir(t)
217+ socketPath := filepath.Join(runtimeDir, "browser-launch.sock")
218+ require.NoError(t, os.MkdirAll(runtimeDir, 0o700))
219+
220+ listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: socketPath, Net: "unix"})
221+ require.NoError(t, err)
222+ defer func() {
223+ _ = listener.Close()
224+ _ = os.Remove(socketPath)
225+ }()
226+
227+ ready := make(chan struct{})
228+ go func() {
229+ close(ready)
230+ conn, err := listener.AcceptUnix()
231+ if err != nil {
232+ return
233+ }
234+ defer conn.Close()
235+ <-time.After(time.Second)
236+ }()
237+ <-ready
238+
239+ relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir})
240+ ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
241+ defer cancel()
242+
243+ delivered, err := relay.DeliverOpenFreshWindow(ctx, "https://example.com/slow")
244+ require.Error(t, err)
245+ assert.ErrorIs(t, err, context.DeadlineExceeded)
246+ assert.False(t, delivered)
247+}
@@ -46,6 +46,15 @@ func waitForSocket(t *testing.T, path string) {
46 }, time.Second, 10*time.Millisecond)46 }, time.Second, 10*time.Millisecond)
47 }47 }
48 48
49+func waitForSocketGone(t *testing.T, path string) {
50+ t.Helper()
51+
52+ require.Eventually(t, func() bool {
53+ _, err := os.Stat(path)
54+ return os.IsNotExist(err)
55+ }, time.Second, 10*time.Millisecond)
56+}
57+
49 func shortTempDir(t *testing.T) string {58 func shortTempDir(t *testing.T) string {
50 t.Helper()59 t.Helper()
51 60
@@ -165,3 +174,74 @@ func TestBrowserLaunchRelay_RejectsMalformedPayloads(t *testing.T) {
165 t.Fatal("expected opener to receive the URL after malformed payload")174 t.Fatal("expected opener to receive the URL after malformed payload")
166 }175 }
167 }176 }
177+
178+func TestBrowserLaunchRelay_CloseRemovesSocketPath(t *testing.T) {
179+ runtimeDir := filepath.Join(shortTempDir(t), "runtime")
180+ relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir})
181+
182+ ctx, cancel := context.WithCancel(context.Background())
183+ defer cancel()
184+
185+ closer, err := relay.Listen(ctx, browserWindowOpenerFunc(func(context.Context, string) error { return nil }))
186+ require.NoError(t, err)
187+
188+ socketPath := filepath.Join(runtimeDir, "browser-launch.sock")
189+ waitForSocket(t, socketPath)
190+
191+ require.NoError(t, closer.Close())
192+ waitForSocketGone(t, socketPath)
193+}
194+
195+func TestBrowserLaunchRelay_ContextCancelStopsServing(t *testing.T) {
196+ runtimeDir := filepath.Join(shortTempDir(t), "runtime")
197+ relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir})
198+
199+ ctx, cancel := context.WithCancel(context.Background())
200+ closer, err := relay.Listen(ctx, browserWindowOpenerFunc(func(context.Context, string) error { return nil }))
201+ require.NoError(t, err)
202+
203+ socketPath := filepath.Join(runtimeDir, "browser-launch.sock")
204+ waitForSocket(t, socketPath)
205+
206+ cancel()
207+ waitForSocketGone(t, socketPath)
208+ require.NoError(t, closer.Close())
209+
210+ delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/after-cancel")
211+ require.NoError(t, err)
212+ assert.False(t, delivered)
213+}
214+
215+func TestBrowserLaunchRelay_DeliverOpenFreshWindow_RespectsResponseReadTimeout(t *testing.T) {
216+ runtimeDir := shortTempDir(t)
217+ socketPath := filepath.Join(runtimeDir, "browser-launch.sock")
218+ require.NoError(t, os.MkdirAll(runtimeDir, 0o700))
219+
220+ listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: socketPath, Net: "unix"})
221+ require.NoError(t, err)
222+ defer func() {
223+ _ = listener.Close()
224+ _ = os.Remove(socketPath)
225+ }()
226+
227+ ready := make(chan struct{})
228+ go func() {
229+ close(ready)
230+ conn, err := listener.AcceptUnix()
231+ if err != nil {
232+ return
233+ }
234+ defer conn.Close()
235+ <-time.After(time.Second)
236+ }()
237+ <-ready
238+
239+ relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir})
240+ ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
241+ defer cancel()
242+
243+ delivered, err := relay.DeliverOpenFreshWindow(ctx, "https://example.com/slow")
244+ require.Error(t, err)
245+ assert.ErrorIs(t, err, context.DeadlineExceeded)
246+ assert.False(t, delivered)
247+}

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 8

Tags 42

↑↓ moveenter openesc close