fix: harden browser launch relay handoff
- commit
- 7b85d0717cc00e1117af836f4c61a193acfb3009
- author
- brice · · 2026-04-15 05:50 UTC
- parent
- af0abd1
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 81 | 81 | return false, ctxErr | |
| 82 | 82 | } | |
| 83 | 83 | if _, ok := ctx.Deadline(); !ok { | |
| 84 | - | return false, decodeErr | |
| 84 | + | return false, nil | |
| 85 | 85 | } | |
| 86 | 86 | if deadlineErr := setBrowserLaunchConnDeadline(ctx, conn); deadlineErr != nil { | |
| 87 | 87 | return false, deadlineErr | |
| @@ -108,6 +108,20 @@ func isBrowserLaunchReadTimeout(err error) bool { | |||
| 108 | 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 | 125 | func setBrowserLaunchConnDeadline(ctx context.Context, conn net.Conn) error { | |
| 112 | 126 | deadline := time.Now().Add(browserLaunchIOTimeout) | |
| 113 | 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 | 140 | if mkdirErr := os.MkdirAll(filepath.Dir(socketPath), browserLaunchDirPerm); mkdirErr != nil { | |
| 127 | 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 | 143 | listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: socketPath, Net: "unix"}) | |
| 134 | 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 | 167 | relayListener := &browserLaunchRelayListener{listener: listener, socketPath: socketPath} | |
| @@ -213,22 +242,20 @@ func (*browserLaunchRelayListener) handleConnection(ctx context.Context, conn *n | |||
| 213 | 242 | 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 | 245 | if err := conn.SetDeadline(time.Now().Add(browserLaunchIOTimeout)); err != nil { | |
| 222 | 246 | return | |
| 223 | 247 | } | |
| 224 | - | if err := json.NewEncoder(conn).Encode(response); err != nil { | |
| 248 | + | if err := json.NewEncoder(conn).Encode(browserLaunchResponse{}); err != nil { | |
| 225 | 249 | 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 | |
| 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 | 261 | var _ port.BrowserLaunchRelay = (*browserLaunchRelay)(nil) | |
| @@ -81,7 +81,7 @@ func (r *browserLaunchRelay) DeliverOpenFreshWindow(ctx context.Context, url str | |||||
| 81 | return false, ctxErr | 81 | return false, ctxErr | ||
| 82 | } | 82 | } | ||
| 83 | if _, ok := ctx.Deadline(); !ok { | 83 | if _, ok := ctx.Deadline(); !ok { | ||
| 84 | - | return false, decodeErr | 84 | + | return false, nil |
| 85 | } | 85 | } | ||
| 86 | if deadlineErr := setBrowserLaunchConnDeadline(ctx, conn); deadlineErr != nil { | 86 | if deadlineErr := setBrowserLaunchConnDeadline(ctx, conn); deadlineErr != nil { | ||
| 87 | return false, deadlineErr | 87 | 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 | return | 242 | 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 | return | 246 | 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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "encoding/json" | |
| 5 | 6 | "net" | |
| 6 | 7 | "os" | |
| 7 | 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 | 206 | func TestBrowserLaunchRelay_RejectsMalformedPayloads(t *testing.T) { | |
| 141 | 207 | runtimeDir := filepath.Join(shortTempDir(t), "runtime") | |
| 142 | 208 | relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir}) | |
| @@ -246,7 +312,7 @@ func TestBrowserLaunchRelay_DeliverOpenFreshWindow_RespectsResponseReadTimeout(t | |||
| 246 | 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 | 316 | runtimeDir := shortTempDir(t) | |
| 251 | 317 | socketPath := filepath.Join(runtimeDir, "browser-launch.sock") | |
| 252 | 318 | require.NoError(t, os.MkdirAll(runtimeDir, 0o700)) | |
| @@ -272,25 +338,66 @@ func TestBrowserLaunchRelay_DeliverOpenFreshWindow_ReturnsWithoutCallerDeadline( | |||
| 272 | 338 | ||
| 273 | 339 | relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir}) | |
| 274 | 340 | ||
| 341 | + | started := make(chan struct{}) | |
| 342 | + | ||
| 275 | 343 | type deliverResult struct { | |
| 276 | 344 | delivered bool | |
| 277 | 345 | err error | |
| 278 | 346 | } | |
| 279 | 347 | result := make(chan deliverResult, 1) | |
| 280 | 348 | go func() { | |
| 349 | + | close(started) | |
| 281 | 350 | delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/slow") | |
| 282 | 351 | result <- deliverResult{delivered: delivered, err: err} | |
| 283 | 352 | }() | |
| 353 | + | <-started | |
| 284 | 354 | ||
| 285 | 355 | select { | |
| 286 | 356 | case got := <-result: | |
| 287 | 357 | assert.False(t, got.delivered) | |
| 288 | - | require.Error(t, got.err) | |
| 358 | + | require.NoError(t, got.err) | |
| 289 | 359 | case <-time.After(300 * time.Millisecond): | |
| 290 | 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 | 401 | func TestBrowserLaunchRelay_SilentClientDoesNotStallListener(t *testing.T) { | |
| 295 | 402 | runtimeDir := filepath.Join(shortTempDir(t), "runtime") | |
| 296 | 403 | 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 bool | 344 | delivered bool | ||
| 277 | err error | 345 | 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}) | ||