detached 7b85d07

fix: harden browser launch relay handoff

commit
7b85d0717cc00e1117af836f4c61a193acfb3009
author
brice · · 2026-04-15 05:50 UTC
parent
af0abd1
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +153 -19
M internal/infrastructure/desktop/browser_launch_relay.go +44 -17
@@ -81,7 +81,7 @@ func (r *browserLaunchRelay) DeliverOpenFreshWindow(ctx context.Context, url str
8181 return false, ctxErr
8282 }
8383 if _, ok := ctx.Deadline(); !ok {
84- return false, decodeErr
84+ return false, nil
8585 }
8686 if deadlineErr := setBrowserLaunchConnDeadline(ctx, conn); deadlineErr != nil {
8787 return false, deadlineErr
@@ -108,6 +108,20 @@ func isBrowserLaunchReadTimeout(err error) bool {
108108 return errors.As(err, &netErr) && netErr.Timeout()
109109 }
110110
111+func browserLaunchSocketHasLiveListener(socketPath string) (bool, error) {
112+ conn, err := net.DialTimeout("unix", socketPath, browserLaunchIOTimeout)
113+ if err == nil {
114+ _ = conn.Close()
115+ return true, nil
116+ }
117+
118+ if isMissingRelayListener(err) {
119+ return false, nil
120+ }
121+
122+ return false, fmt.Errorf("check browser launch socket: %w", err)
123+}
124+
111125 func setBrowserLaunchConnDeadline(ctx context.Context, conn net.Conn) error {
112126 deadline := time.Now().Add(browserLaunchIOTimeout)
113127 if ctxDeadline, ok := ctx.Deadline(); ok && ctxDeadline.Before(deadline) {
@@ -126,13 +140,28 @@ func (r *browserLaunchRelay) Listen(ctx context.Context, opener port.BrowserWind
126140 if mkdirErr := os.MkdirAll(filepath.Dir(socketPath), browserLaunchDirPerm); mkdirErr != nil {
127141 return nil, fmt.Errorf("create browser launch dir: %w", mkdirErr)
128142 }
129- if removeErr := os.Remove(socketPath); removeErr != nil && !os.IsNotExist(removeErr) {
130- return nil, fmt.Errorf("remove stale browser launch socket: %w", removeErr)
131- }
132-
133143 listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: socketPath, Net: "unix"})
134144 if err != nil {
135- return nil, fmt.Errorf("listen browser launch socket: %w", err)
145+ if !errors.Is(err, syscall.EADDRINUSE) {
146+ return nil, fmt.Errorf("listen browser launch socket: %w", err)
147+ }
148+
149+ live, liveErr := browserLaunchSocketHasLiveListener(socketPath)
150+ if liveErr != nil {
151+ return nil, liveErr
152+ }
153+ if live {
154+ return nil, errors.New("browser launch relay already running")
155+ }
156+
157+ if removeErr := os.Remove(socketPath); removeErr != nil && !os.IsNotExist(removeErr) {
158+ return nil, fmt.Errorf("remove stale browser launch socket: %w", removeErr)
159+ }
160+
161+ listener, err = net.ListenUnix("unix", &net.UnixAddr{Name: socketPath, Net: "unix"})
162+ if err != nil {
163+ return nil, fmt.Errorf("listen browser launch socket: %w", err)
164+ }
136165 }
137166
138167 relayListener := &browserLaunchRelayListener{listener: listener, socketPath: socketPath}
@@ -213,22 +242,20 @@ func (*browserLaunchRelayListener) handleConnection(ctx context.Context, conn *n
213242 return
214243 }
215244
216- response := browserLaunchResponse{}
217- if err := opener.OpenFreshWindow(ctx, request.URL); err != nil {
218- response.Error = err.Error()
219- }
220-
221245 if err := conn.SetDeadline(time.Now().Add(browserLaunchIOTimeout)); err != nil {
222246 return
223247 }
224- if err := json.NewEncoder(conn).Encode(response); err != nil {
248+ if err := json.NewEncoder(conn).Encode(browserLaunchResponse{}); err != nil {
225249 log := logging.FromContext(ctx)
226- entry := log.Warn().Err(err).Str("url", request.URL)
227- if response.Error != "" {
228- entry = entry.Str("response_error", response.Error)
229- }
230- entry.Msg("failed to encode browser launch response")
250+ log.Warn().Err(err).Str("url", request.URL).Msg("failed to encode browser launch response")
251+ return
231252 }
253+
254+ go func() {
255+ if err := opener.OpenFreshWindow(ctx, request.URL); err != nil {
256+ logging.FromContext(ctx).Warn().Err(err).Str("url", request.URL).Msg("failed to open fresh browser window")
257+ }
258+ }()
232259 }
233260
234261 var _ port.BrowserLaunchRelay = (*browserLaunchRelay)(nil)
@@ -81,7 +81,7 @@ func (r *browserLaunchRelay) DeliverOpenFreshWindow(ctx context.Context, url str
81 return false, ctxErr81 return false, ctxErr
82 }82 }
83 if _, ok := ctx.Deadline(); !ok {83 if _, ok := ctx.Deadline(); !ok {
84- return false, decodeErr84+ return false, nil
85 }85 }
86 if deadlineErr := setBrowserLaunchConnDeadline(ctx, conn); deadlineErr != nil {86 if deadlineErr := setBrowserLaunchConnDeadline(ctx, conn); deadlineErr != nil {
87 return false, deadlineErr87 return false, deadlineErr
@@ -108,6 +108,20 @@ func isBrowserLaunchReadTimeout(err error) bool {
108 return errors.As(err, &netErr) && netErr.Timeout()108 return errors.As(err, &netErr) && netErr.Timeout()
109 }109 }
110 110
111+func browserLaunchSocketHasLiveListener(socketPath string) (bool, error) {
112+ conn, err := net.DialTimeout("unix", socketPath, browserLaunchIOTimeout)
113+ if err == nil {
114+ _ = conn.Close()
115+ return true, nil
116+ }
117+
118+ if isMissingRelayListener(err) {
119+ return false, nil
120+ }
121+
122+ return false, fmt.Errorf("check browser launch socket: %w", err)
123+}
124+
111 func setBrowserLaunchConnDeadline(ctx context.Context, conn net.Conn) error {125 func setBrowserLaunchConnDeadline(ctx context.Context, conn net.Conn) error {
112 deadline := time.Now().Add(browserLaunchIOTimeout)126 deadline := time.Now().Add(browserLaunchIOTimeout)
113 if ctxDeadline, ok := ctx.Deadline(); ok && ctxDeadline.Before(deadline) {127 if ctxDeadline, ok := ctx.Deadline(); ok && ctxDeadline.Before(deadline) {
@@ -126,13 +140,28 @@ func (r *browserLaunchRelay) Listen(ctx context.Context, opener port.BrowserWind
126 if mkdirErr := os.MkdirAll(filepath.Dir(socketPath), browserLaunchDirPerm); mkdirErr != nil {140 if mkdirErr := os.MkdirAll(filepath.Dir(socketPath), browserLaunchDirPerm); mkdirErr != nil {
127 return nil, fmt.Errorf("create browser launch dir: %w", mkdirErr)141 return nil, fmt.Errorf("create browser launch dir: %w", mkdirErr)
128 }142 }
129- if removeErr := os.Remove(socketPath); removeErr != nil && !os.IsNotExist(removeErr) {
130- return nil, fmt.Errorf("remove stale browser launch socket: %w", removeErr)
131- }
132-
133 listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: socketPath, Net: "unix"})143 listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: socketPath, Net: "unix"})
134 if err != nil {144 if err != nil {
135- return nil, fmt.Errorf("listen browser launch socket: %w", err)145+ if !errors.Is(err, syscall.EADDRINUSE) {
146+ return nil, fmt.Errorf("listen browser launch socket: %w", err)
147+ }
148+
149+ live, liveErr := browserLaunchSocketHasLiveListener(socketPath)
150+ if liveErr != nil {
151+ return nil, liveErr
152+ }
153+ if live {
154+ return nil, errors.New("browser launch relay already running")
155+ }
156+
157+ if removeErr := os.Remove(socketPath); removeErr != nil && !os.IsNotExist(removeErr) {
158+ return nil, fmt.Errorf("remove stale browser launch socket: %w", removeErr)
159+ }
160+
161+ listener, err = net.ListenUnix("unix", &net.UnixAddr{Name: socketPath, Net: "unix"})
162+ if err != nil {
163+ return nil, fmt.Errorf("listen browser launch socket: %w", err)
164+ }
136 }165 }
137 166
138 relayListener := &browserLaunchRelayListener{listener: listener, socketPath: socketPath}167 relayListener := &browserLaunchRelayListener{listener: listener, socketPath: socketPath}
@@ -213,22 +242,20 @@ func (*browserLaunchRelayListener) handleConnection(ctx context.Context, conn *n
213 return242 return
214 }243 }
215 244
216- response := browserLaunchResponse{}
217- if err := opener.OpenFreshWindow(ctx, request.URL); err != nil {
218- response.Error = err.Error()
219- }
220-
221 if err := conn.SetDeadline(time.Now().Add(browserLaunchIOTimeout)); err != nil {245 if err := conn.SetDeadline(time.Now().Add(browserLaunchIOTimeout)); err != nil {
222 return246 return
223 }247 }
224- if err := json.NewEncoder(conn).Encode(response); err != nil {248+ if err := json.NewEncoder(conn).Encode(browserLaunchResponse{}); err != nil {
225 log := logging.FromContext(ctx)249 log := logging.FromContext(ctx)
226- entry := log.Warn().Err(err).Str("url", request.URL)250+ log.Warn().Err(err).Str("url", request.URL).Msg("failed to encode browser launch response")
227- if response.Error != "" {251+ return
228- entry = entry.Str("response_error", response.Error)
229- }
230- entry.Msg("failed to encode browser launch response")
231 }252 }
253+
254+ go func() {
255+ if err := opener.OpenFreshWindow(ctx, request.URL); err != nil {
256+ logging.FromContext(ctx).Warn().Err(err).Str("url", request.URL).Msg("failed to open fresh browser window")
257+ }
258+ }()
232 }259 }
233 260
234 var _ port.BrowserLaunchRelay = (*browserLaunchRelay)(nil)261 var _ port.BrowserLaunchRelay = (*browserLaunchRelay)(nil)
M internal/infrastructure/desktop/browser_launch_relay_test.go +109 -2
@@ -2,6 +2,7 @@ package desktop
22
33 import (
44 "context"
5+ "encoding/json"
56 "net"
67 "os"
78 "path/filepath"
@@ -137,6 +138,71 @@ func TestBrowserLaunchRelay_UsesStateFallbackWhenRuntimeDirMissing(t *testing.T)
137138 }
138139 }
139140
141+func TestBrowserLaunchRelay_SecondListenWhileLiveFailsWithoutReplacingListener(t *testing.T) {
142+ runtimeDir := filepath.Join(shortTempDir(t), "runtime")
143+ relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir})
144+
145+ firstReceived := make(chan string, 1)
146+ ctx, cancel := context.WithCancel(context.Background())
147+ defer cancel()
148+
149+ closer, err := relay.Listen(ctx, browserWindowOpenerFunc(func(_ context.Context, url string) error {
150+ firstReceived <- url
151+ return nil
152+ }))
153+ require.NoError(t, err)
154+ defer closer.Close()
155+
156+ waitForSocket(t, filepath.Join(runtimeDir, "browser-launch.sock"))
157+
158+ _, err = relay.Listen(ctx, browserWindowOpenerFunc(func(context.Context, string) error { return nil }))
159+ require.Error(t, err)
160+
161+ delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/live")
162+ require.NoError(t, err)
163+ require.True(t, delivered)
164+
165+ select {
166+ case got := <-firstReceived:
167+ assert.Equal(t, "https://example.com/live", got)
168+ case <-time.After(time.Second):
169+ t.Fatal("expected the original listener to remain active")
170+ }
171+}
172+
173+func TestBrowserLaunchRelay_RebindsStaleSocketPath(t *testing.T) {
174+ runtimeDir := filepath.Join(shortTempDir(t), "runtime")
175+ relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir})
176+
177+ socketPath := filepath.Join(runtimeDir, "browser-launch.sock")
178+ require.NoError(t, os.MkdirAll(runtimeDir, 0o700))
179+ require.NoError(t, os.WriteFile(socketPath, []byte("stale"), 0o600))
180+
181+ received := make(chan string, 1)
182+ ctx, cancel := context.WithCancel(context.Background())
183+ defer cancel()
184+
185+ closer, err := relay.Listen(ctx, browserWindowOpenerFunc(func(_ context.Context, url string) error {
186+ received <- url
187+ return nil
188+ }))
189+ require.NoError(t, err)
190+ defer closer.Close()
191+
192+ waitForSocket(t, socketPath)
193+
194+ delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/stale")
195+ require.NoError(t, err)
196+ require.True(t, delivered)
197+
198+ select {
199+ case got := <-received:
200+ assert.Equal(t, "https://example.com/stale", got)
201+ case <-time.After(time.Second):
202+ t.Fatal("expected stale socket path to be rebound")
203+ }
204+}
205+
140206 func TestBrowserLaunchRelay_RejectsMalformedPayloads(t *testing.T) {
141207 runtimeDir := filepath.Join(shortTempDir(t), "runtime")
142208 relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir})
@@ -246,7 +312,7 @@ func TestBrowserLaunchRelay_DeliverOpenFreshWindow_RespectsResponseReadTimeout(t
246312 assert.False(t, delivered)
247313 }
248314
249-func TestBrowserLaunchRelay_DeliverOpenFreshWindow_ReturnsWithoutCallerDeadline(t *testing.T) {
315+func TestBrowserLaunchRelay_DeliverOpenFreshWindow_ReturnsFalseOnNoDeadlineTimeout(t *testing.T) {
250316 runtimeDir := shortTempDir(t)
251317 socketPath := filepath.Join(runtimeDir, "browser-launch.sock")
252318 require.NoError(t, os.MkdirAll(runtimeDir, 0o700))
@@ -272,25 +338,66 @@ func TestBrowserLaunchRelay_DeliverOpenFreshWindow_ReturnsWithoutCallerDeadline(
272338
273339 relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir})
274340
341+ started := make(chan struct{})
342+
275343 type deliverResult struct {
276344 delivered bool
277345 err error
278346 }
279347 result := make(chan deliverResult, 1)
280348 go func() {
349+ close(started)
281350 delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/slow")
282351 result <- deliverResult{delivered: delivered, err: err}
283352 }()
353+ <-started
284354
285355 select {
286356 case got := <-result:
287357 assert.False(t, got.delivered)
288- require.Error(t, got.err)
358+ require.NoError(t, got.err)
289359 case <-time.After(300 * time.Millisecond):
290360 t.Fatal("DeliverOpenFreshWindow blocked without a caller deadline")
291361 }
292362 }
293363
364+func TestBrowserLaunchRelay_AcknowledgesBeforeOpenerReturns(t *testing.T) {
365+ runtimeDir := filepath.Join(shortTempDir(t), "runtime")
366+ relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir})
367+
368+ started := make(chan struct{})
369+ release := make(chan struct{})
370+ ctx, cancel := context.WithCancel(context.Background())
371+ defer cancel()
372+
373+ closer, err := relay.Listen(ctx, browserWindowOpenerFunc(func(_ context.Context, url string) error {
374+ close(started)
375+ <-release
376+ return nil
377+ }))
378+ require.NoError(t, err)
379+ defer closer.Close()
380+
381+ waitForSocket(t, filepath.Join(runtimeDir, "browser-launch.sock"))
382+
383+ conn, err := net.Dial("unix", filepath.Join(runtimeDir, "browser-launch.sock"))
384+ require.NoError(t, err)
385+ defer conn.Close()
386+ require.NoError(t, conn.SetDeadline(time.Now().Add(300*time.Millisecond)))
387+ require.NoError(t, json.NewEncoder(conn).Encode(browserLaunchRequest{URL: "https://example.com/slow-but-healthy"}))
388+
389+ var response browserLaunchResponse
390+ require.NoError(t, json.NewDecoder(conn).Decode(&response))
391+ assert.Empty(t, response.Error)
392+
393+ select {
394+ case <-started:
395+ case <-time.After(time.Second):
396+ t.Fatal("expected opener to be invoked after acknowledgement")
397+ }
398+ close(release)
399+}
400+
294401 func TestBrowserLaunchRelay_SilentClientDoesNotStallListener(t *testing.T) {
295402 runtimeDir := filepath.Join(shortTempDir(t), "runtime")
296403 relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir})
@@ -2,6 +2,7 @@ package desktop
2 2
3 import (3 import (
4 "context"4 "context"
5+ "encoding/json"
5 "net"6 "net"
6 "os"7 "os"
7 "path/filepath"8 "path/filepath"
@@ -137,6 +138,71 @@ func TestBrowserLaunchRelay_UsesStateFallbackWhenRuntimeDirMissing(t *testing.T)
137 }138 }
138 }139 }
139 140
141+func TestBrowserLaunchRelay_SecondListenWhileLiveFailsWithoutReplacingListener(t *testing.T) {
142+ runtimeDir := filepath.Join(shortTempDir(t), "runtime")
143+ relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir})
144+
145+ firstReceived := make(chan string, 1)
146+ ctx, cancel := context.WithCancel(context.Background())
147+ defer cancel()
148+
149+ closer, err := relay.Listen(ctx, browserWindowOpenerFunc(func(_ context.Context, url string) error {
150+ firstReceived <- url
151+ return nil
152+ }))
153+ require.NoError(t, err)
154+ defer closer.Close()
155+
156+ waitForSocket(t, filepath.Join(runtimeDir, "browser-launch.sock"))
157+
158+ _, err = relay.Listen(ctx, browserWindowOpenerFunc(func(context.Context, string) error { return nil }))
159+ require.Error(t, err)
160+
161+ delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/live")
162+ require.NoError(t, err)
163+ require.True(t, delivered)
164+
165+ select {
166+ case got := <-firstReceived:
167+ assert.Equal(t, "https://example.com/live", got)
168+ case <-time.After(time.Second):
169+ t.Fatal("expected the original listener to remain active")
170+ }
171+}
172+
173+func TestBrowserLaunchRelay_RebindsStaleSocketPath(t *testing.T) {
174+ runtimeDir := filepath.Join(shortTempDir(t), "runtime")
175+ relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir})
176+
177+ socketPath := filepath.Join(runtimeDir, "browser-launch.sock")
178+ require.NoError(t, os.MkdirAll(runtimeDir, 0o700))
179+ require.NoError(t, os.WriteFile(socketPath, []byte("stale"), 0o600))
180+
181+ received := make(chan string, 1)
182+ ctx, cancel := context.WithCancel(context.Background())
183+ defer cancel()
184+
185+ closer, err := relay.Listen(ctx, browserWindowOpenerFunc(func(_ context.Context, url string) error {
186+ received <- url
187+ return nil
188+ }))
189+ require.NoError(t, err)
190+ defer closer.Close()
191+
192+ waitForSocket(t, socketPath)
193+
194+ delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/stale")
195+ require.NoError(t, err)
196+ require.True(t, delivered)
197+
198+ select {
199+ case got := <-received:
200+ assert.Equal(t, "https://example.com/stale", got)
201+ case <-time.After(time.Second):
202+ t.Fatal("expected stale socket path to be rebound")
203+ }
204+}
205+
140 func TestBrowserLaunchRelay_RejectsMalformedPayloads(t *testing.T) {206 func TestBrowserLaunchRelay_RejectsMalformedPayloads(t *testing.T) {
141 runtimeDir := filepath.Join(shortTempDir(t), "runtime")207 runtimeDir := filepath.Join(shortTempDir(t), "runtime")
142 relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir})208 relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir})
@@ -246,7 +312,7 @@ func TestBrowserLaunchRelay_DeliverOpenFreshWindow_RespectsResponseReadTimeout(t
246 assert.False(t, delivered)312 assert.False(t, delivered)
247 }313 }
248 314
249-func TestBrowserLaunchRelay_DeliverOpenFreshWindow_ReturnsWithoutCallerDeadline(t *testing.T) {315+func TestBrowserLaunchRelay_DeliverOpenFreshWindow_ReturnsFalseOnNoDeadlineTimeout(t *testing.T) {
250 runtimeDir := shortTempDir(t)316 runtimeDir := shortTempDir(t)
251 socketPath := filepath.Join(runtimeDir, "browser-launch.sock")317 socketPath := filepath.Join(runtimeDir, "browser-launch.sock")
252 require.NoError(t, os.MkdirAll(runtimeDir, 0o700))318 require.NoError(t, os.MkdirAll(runtimeDir, 0o700))
@@ -272,25 +338,66 @@ func TestBrowserLaunchRelay_DeliverOpenFreshWindow_ReturnsWithoutCallerDeadline(
272 338
273 relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir})339 relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir})
274 340
341+ started := make(chan struct{})
342+
275 type deliverResult struct {343 type deliverResult struct {
276 delivered bool344 delivered bool
277 err error345 err error
278 }346 }
279 result := make(chan deliverResult, 1)347 result := make(chan deliverResult, 1)
280 go func() {348 go func() {
349+ close(started)
281 delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/slow")350 delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/slow")
282 result <- deliverResult{delivered: delivered, err: err}351 result <- deliverResult{delivered: delivered, err: err}
283 }()352 }()
353+ <-started
284 354
285 select {355 select {
286 case got := <-result:356 case got := <-result:
287 assert.False(t, got.delivered)357 assert.False(t, got.delivered)
288- require.Error(t, got.err)358+ require.NoError(t, got.err)
289 case <-time.After(300 * time.Millisecond):359 case <-time.After(300 * time.Millisecond):
290 t.Fatal("DeliverOpenFreshWindow blocked without a caller deadline")360 t.Fatal("DeliverOpenFreshWindow blocked without a caller deadline")
291 }361 }
292 }362 }
293 363
364+func TestBrowserLaunchRelay_AcknowledgesBeforeOpenerReturns(t *testing.T) {
365+ runtimeDir := filepath.Join(shortTempDir(t), "runtime")
366+ relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir})
367+
368+ started := make(chan struct{})
369+ release := make(chan struct{})
370+ ctx, cancel := context.WithCancel(context.Background())
371+ defer cancel()
372+
373+ closer, err := relay.Listen(ctx, browserWindowOpenerFunc(func(_ context.Context, url string) error {
374+ close(started)
375+ <-release
376+ return nil
377+ }))
378+ require.NoError(t, err)
379+ defer closer.Close()
380+
381+ waitForSocket(t, filepath.Join(runtimeDir, "browser-launch.sock"))
382+
383+ conn, err := net.Dial("unix", filepath.Join(runtimeDir, "browser-launch.sock"))
384+ require.NoError(t, err)
385+ defer conn.Close()
386+ require.NoError(t, conn.SetDeadline(time.Now().Add(300*time.Millisecond)))
387+ require.NoError(t, json.NewEncoder(conn).Encode(browserLaunchRequest{URL: "https://example.com/slow-but-healthy"}))
388+
389+ var response browserLaunchResponse
390+ require.NoError(t, json.NewDecoder(conn).Decode(&response))
391+ assert.Empty(t, response.Error)
392+
393+ select {
394+ case <-started:
395+ case <-time.After(time.Second):
396+ t.Fatal("expected opener to be invoked after acknowledgement")
397+ }
398+ close(release)
399+}
400+
294 func TestBrowserLaunchRelay_SilentClientDoesNotStallListener(t *testing.T) {401 func TestBrowserLaunchRelay_SilentClientDoesNotStallListener(t *testing.T) {
295 runtimeDir := filepath.Join(shortTempDir(t), "runtime")402 runtimeDir := filepath.Join(shortTempDir(t), "runtime")
296 relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir})403 relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir})

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