feat: add browser launch relay transport
- commit
- 1095483f2822b695f7ee0414f220bf8de89aaac9
- author
- brice · · 2026-04-14 12:24 UTC
- parent
- 264138f
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 ( | |||
| 11 | 11 | "path/filepath" | |
| 12 | 12 | "sync" | |
| 13 | 13 | "syscall" | |
| 14 | + | "time" | |
| 14 | 15 | ||
| 15 | 16 | "github.com/bnema/dumber/internal/application/port" | |
| 16 | 17 | ) | |
| 17 | 18 | ||
| 18 | 19 | const browserLaunchSocketName = "browser-launch.sock" | |
| 19 | 20 | ||
| 21 | + | const browserLaunchIOTimeout = 50 * time.Millisecond | |
| 22 | + | ||
| 20 | 23 | type browserLaunchRelay struct { | |
| 21 | 24 | xdg port.XDGPaths | |
| 22 | 25 | } | |
| @@ -63,14 +66,34 @@ func (r *browserLaunchRelay) DeliverOpenFreshWindow(ctx context.Context, url str | |||
| 63 | 66 | } | |
| 64 | 67 | defer conn.Close() | |
| 65 | 68 | ||
| 69 | + | if err := setBrowserLaunchConnDeadline(ctx, conn); err != nil { | |
| 70 | + | return false, err | |
| 71 | + | } | |
| 66 | 72 | if err := json.NewEncoder(conn).Encode(browserLaunchRequest{URL: url}); err != nil { | |
| 67 | 73 | return false, err | |
| 68 | 74 | } | |
| 69 | 75 | ||
| 70 | - | var response browserLaunchResponse | |
| 71 | - | if err := json.NewDecoder(conn).Decode(&response); err != nil { | |
| 76 | + | if err := setBrowserLaunchConnDeadline(ctx, conn); err != nil { | |
| 72 | 77 | 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 | 97 | if response.Error != "" { | |
| 75 | 98 | return true, errors.New(response.Error) | |
| 76 | 99 | } | |
| @@ -82,6 +105,20 @@ func isMissingRelayListener(err error) bool { | |||
| 82 | 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 | 122 | func (r *browserLaunchRelay) Listen(ctx context.Context, opener port.BrowserWindowOpener) (io.Closer, error) { | |
| 86 | 123 | socketPath, err := r.socketPath() | |
| 87 | 124 | if err != nil { | |
| @@ -102,10 +139,6 @@ func (r *browserLaunchRelay) Listen(ctx context.Context, opener port.BrowserWind | |||
| 102 | 139 | ||
| 103 | 140 | relayListener := &browserLaunchRelayListener{listener: listener, socketPath: socketPath} | |
| 104 | 141 | go relayListener.serve(ctx, opener) | |
| 105 | - | go func() { | |
| 106 | - | <-ctx.Done() | |
| 107 | - | _ = relayListener.Close() | |
| 108 | - | }() | |
| 109 | 142 | ||
| 110 | 143 | return relayListener, nil | |
| 111 | 144 | } | |
| @@ -149,11 +182,21 @@ func (l *browserLaunchRelayListener) serve(ctx context.Context, opener port.Brow | |||
| 149 | 182 | defer l.Close() | |
| 150 | 183 | ||
| 151 | 184 | for { | |
| 185 | + | if err := l.listener.SetDeadline(time.Now().Add(browserLaunchIOTimeout)); err != nil { | |
| 186 | + | return | |
| 187 | + | } | |
| 152 | 188 | conn, err := l.listener.AcceptUnix() | |
| 153 | 189 | if err != nil { | |
| 154 | 190 | if ctx.Err() != nil || errors.Is(err, net.ErrClosed) { | |
| 155 | 191 | 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 | 200 | continue | |
| 158 | 201 | } | |
| 159 | 202 | ||
| @@ -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.XDGPaths | 24 | 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, err | 73 | return false, err | ||
| 68 | } | 74 | } | ||
| 69 | 75 | ||||
| 70 | - | var response browserLaunchResponse | 76 | + | if err := setBrowserLaunchConnDeadline(ctx, conn); err != nil { |
| 71 | - | if err := json.NewDecoder(conn).Decode(&response); err != nil { | |||
| 72 | return false, err | 77 | 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, nil | 143 | 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 | return | 191 | 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 | continue | 200 | 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) { | |||
| 46 | 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 | 58 | func shortTempDir(t *testing.T) string { | |
| 50 | 59 | t.Helper() | |
| 51 | 60 | ||
| @@ -165,3 +174,74 @@ func TestBrowserLaunchRelay_RejectsMalformedPayloads(t *testing.T) { | |||
| 165 | 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 | + | } | |
| @@ -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 | + | } | |||