feat(runtime): add named CEF browser instances (#444)
* fix(systemviews): wait for bridge before loading wasm * feat(runtime): share CEF profiles across named instances * fix(runtime): lock the default CEF profile host * fix(runtime): harden shared CEF host lifecycle * fix(runtime): preserve browser launch intent
- commit
- df4f3adac82be391057b1b5dacf84633b646fa97
- author
- bnema · · 2026-09-21 17:51 UTC
- committer
- GitHub · · 2026-09-21 17:51 UTC
- parent
- fdc7e51
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
32 files · +1511 -116
M CHANGELOG.md +1 -0
| @@ -6,6 +6,7 @@ All notable changes to this project will be documented in this file. | |||
| 6 | 6 | ||
| 7 | 7 | ### Added | |
| 8 | 8 | ||
| 9 | + | - **Named browser windows**: Added `dumber browse --instance <name>` to open or focus a stable named window in the current CEF profile host. Named windows share cookies, tokens, local storage, configuration, history, and favorites; use `--profile` for a separate persistent browser profile/host or `--ephemeral` for a temporary isolated profile/host. | |
| 9 | 10 | - **Omnibox visual styles**: Added configurable `command`, `multiplexer`, and `minimal` GTK treatments. The terminal-inspired `command` palette is the default. | |
| 10 | 11 | - **Vim mode for keyboard-driven page navigation**: Added configurable multi-key sequences for page scrolling, fast scrolling, page-input focus, and live heading navigation. Pressing `Enter` on a selected CEF heading opens its associated link before leaving Vim Mode. CEF accessibility data also provides the foundation for navigating structured page content such as headings, lists, paragraphs, and sections. | |
| 11 | 12 | - **Native favorites sidebar**: Added a keyboard-driven GTK sidebar toggled with `Ctrl+B`, including search, tag filtering, tag creation, favorite editing, shortcut assignment, deletion, and opening favorites in the current pane or a new split. | |
| @@ -6,6 +6,7 @@ All notable changes to this project will be documented in this file. | |||||
| 6 | 6 | ||||
| 7 | ### Added | 7 | ### Added | ||
| 8 | 8 | ||||
| 9 | + | - **Named browser windows**: Added `dumber browse --instance <name>` to open or focus a stable named window in the current CEF profile host. Named windows share cookies, tokens, local storage, configuration, history, and favorites; use `--profile` for a separate persistent browser profile/host or `--ephemeral` for a temporary isolated profile/host. | |||
| 9 | - **Omnibox visual styles**: Added configurable `command`, `multiplexer`, and `minimal` GTK treatments. The terminal-inspired `command` palette is the default. | 10 | - **Omnibox visual styles**: Added configurable `command`, `multiplexer`, and `minimal` GTK treatments. The terminal-inspired `command` palette is the default. | ||
| 10 | - **Vim mode for keyboard-driven page navigation**: Added configurable multi-key sequences for page scrolling, fast scrolling, page-input focus, and live heading navigation. Pressing `Enter` on a selected CEF heading opens its associated link before leaving Vim Mode. CEF accessibility data also provides the foundation for navigating structured page content such as headings, lists, paragraphs, and sections. | 11 | - **Vim mode for keyboard-driven page navigation**: Added configurable multi-key sequences for page scrolling, fast scrolling, page-input focus, and live heading navigation. Pressing `Enter` on a selected CEF heading opens its associated link before leaving Vim Mode. CEF accessibility data also provides the foundation for navigating structured page content such as headings, lists, paragraphs, and sections. | ||
| 11 | - **Native favorites sidebar**: Added a keyboard-driven GTK sidebar toggled with `Ctrl+B`, including search, tag filtering, tag creation, favorite editing, shortcut assignment, deletion, and opening favorites in the current pane or a new split. | 12 | - **Native favorites sidebar**: Added a keyboard-driven GTK sidebar toggled with `Ctrl+B`, including search, tag filtering, tag creation, favorite editing, shortcut assignment, deletion, and opening favorites in the current pane or a new split. | ||
M assets/systemviews/index.html +17 -1
| @@ -71,6 +71,21 @@ | |||
| 71 | 71 | }); | |
| 72 | 72 | } | |
| 73 | 73 | ||
| 74 | + | function waitForBridge() { | |
| 75 | + | if (window.dumber && typeof window.dumber.postMessage === 'function') return Promise.resolve(); | |
| 76 | + | if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.dumber) return Promise.resolve(); | |
| 77 | + | return new Promise((resolve, reject) => { | |
| 78 | + | const deadline = Date.now() + 5000; | |
| 79 | + | const check = () => { | |
| 80 | + | if (window.dumber && typeof window.dumber.postMessage === 'function') return resolve(); | |
| 81 | + | if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.dumber) return resolve(); | |
| 82 | + | if (Date.now() >= deadline) return reject(new Error('no bridge transport available')); | |
| 83 | + | setTimeout(check, 10); | |
| 84 | + | }; | |
| 85 | + | check(); | |
| 86 | + | }); | |
| 87 | + | } | |
| 88 | + | ||
| 74 | 89 | if (typeof WebAssembly !== 'object') { | |
| 75 | 90 | renderWasmError('WebAssembly is not supported in this browser: '); | |
| 76 | 91 | } else if (typeof Go !== 'function') { | |
| @@ -86,7 +101,8 @@ | |||
| 86 | 101 | goExitCode = code; | |
| 87 | 102 | goExit(code); | |
| 88 | 103 | }; | |
| 89 | - | instantiateWasm(go, './systemviews.wasm') | |
| 104 | + | waitForBridge() | |
| 105 | + | .then(() => instantiateWasm(go, './systemviews.wasm')) | |
| 90 | 106 | // The runtime stays alive: a resolved go.run means main returned | |
| 91 | 107 | // instead of serving, which is itself a terminal failure. | |
| 92 | 108 | .then((result) => go.run(result.instance)) | |
| @@ -71,6 +71,21 @@ | |||||
| 71 | }); | 71 | }); | ||
| 72 | } | 72 | } | ||
| 73 | 73 | ||||
| 74 | + | function waitForBridge() { | |||
| 75 | + | if (window.dumber && typeof window.dumber.postMessage === 'function') return Promise.resolve(); | |||
| 76 | + | if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.dumber) return Promise.resolve(); | |||
| 77 | + | return new Promise((resolve, reject) => { | |||
| 78 | + | const deadline = Date.now() + 5000; | |||
| 79 | + | const check = () => { | |||
| 80 | + | if (window.dumber && typeof window.dumber.postMessage === 'function') return resolve(); | |||
| 81 | + | if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.dumber) return resolve(); | |||
| 82 | + | if (Date.now() >= deadline) return reject(new Error('no bridge transport available')); | |||
| 83 | + | setTimeout(check, 10); | |||
| 84 | + | }; | |||
| 85 | + | check(); | |||
| 86 | + | }); | |||
| 87 | + | } | |||
| 88 | + | ||||
| 74 | if (typeof WebAssembly !== 'object') { | 89 | if (typeof WebAssembly !== 'object') { | ||
| 75 | renderWasmError('WebAssembly is not supported in this browser: '); | 90 | renderWasmError('WebAssembly is not supported in this browser: '); | ||
| 76 | } else if (typeof Go !== 'function') { | 91 | } else if (typeof Go !== 'function') { | ||
| @@ -86,7 +101,8 @@ | |||||
| 86 | goExitCode = code; | 101 | goExitCode = code; | ||
| 87 | goExit(code); | 102 | goExit(code); | ||
| 88 | }; | 103 | }; | ||
| 89 | - | instantiateWasm(go, './systemviews.wasm') | 104 | + | waitForBridge() |
| 105 | + | .then(() => instantiateWasm(go, './systemviews.wasm')) | |||
| 90 | // The runtime stays alive: a resolved go.run means main returned | 106 | // The runtime stays alive: a resolved go.run means main returned | ||
| 91 | // instead of serving, which is itself a terminal failure. | 107 | // instead of serving, which is itself a terminal failure. | ||
| 92 | .then((result) => go.run(result.instance)) | 108 | .then((result) => go.run(result.instance)) | ||
M cmd/dumber/main.go +109 -25
| @@ -75,16 +75,11 @@ func launchModeFromArgs(args []string) (launchMode, string) { | |||
| 75 | 75 | if len(args) > 1 { | |
| 76 | 76 | switch args[1] { | |
| 77 | 77 | case "browse": | |
| 78 | - | if len(args) > 3 { | |
| 78 | + | url, _, ok := cmd.ParseBrowseArgs(args[2:]) | |
| 79 | + | if !ok { | |
| 79 | 80 | return launchModeCLI, "" | |
| 80 | 81 | } | |
| 81 | - | if len(args) > 2 { | |
| 82 | - | if strings.HasPrefix(args[2], "-") { | |
| 83 | - | return launchModeCLI, "" | |
| 84 | - | } | |
| 85 | - | return launchModeBrowse, args[2] | |
| 86 | - | } | |
| 87 | - | return launchModeBrowse, "" | |
| 82 | + | return launchModeBrowse, url | |
| 88 | 83 | case "omnibox": | |
| 89 | 84 | if len(args) > 2 { | |
| 90 | 85 | return launchModeCLI, "" | |
| @@ -221,6 +216,16 @@ func main() { | |||
| 221 | 216 | if mode == launchModeBrowse { | |
| 222 | 217 | cfg := initConfig() | |
| 223 | 218 | timing.configComplete = time.Now() | |
| 219 | + | browseArgs, ok := cmd.ParseBrowseLaunchArgs(os.Args[2:]) | |
| 220 | + | if !ok { | |
| 221 | + | cmd.Execute() | |
| 222 | + | return | |
| 223 | + | } | |
| 224 | + | if cfg.Engine.ResolveEngineType() == config.EngineTypeCEF || browseArgs.Instance != "" || | |
| 225 | + | browseArgs.Profile != "" || browseArgs.Ephemeral { | |
| 226 | + | os.Exit(runInstanceGUI(cfg, timing, browseArgs)) | |
| 227 | + | return | |
| 228 | + | } | |
| 224 | 229 | configureBrowserLaunchRelay(cfg) | |
| 225 | 230 | startupURL := domainurl.ResolveBrowserStartupURL(browseURL) | |
| 226 | 231 | freshWindow := os.Getenv(desktop.FreshWindowLaunchEnvVar) == "1" | |
| @@ -243,7 +248,7 @@ func main() { | |||
| 243 | 248 | } | |
| 244 | 249 | ||
| 245 | 250 | initialURL = startupURL | |
| 246 | - | restoreSessionID = os.Getenv("DUMBER_RESTORE_SESSION") | |
| 251 | + | restoreSessionID = os.Getenv(desktop.RestoreSessionEnvVar) | |
| 247 | 252 | os.Args = os.Args[:1] | |
| 248 | 253 | os.Exit(runGUI(cfg, timing)) | |
| 249 | 254 | return | |
| @@ -277,6 +282,70 @@ func setAlreadyRunningRelaunchHandler(engine port.Engine, app *ui.App, ctx conte | |||
| 277 | 282 | } | |
| 278 | 283 | ||
| 279 | 284 | func runGUI(cfg *config.Config, timing startupTiming) int { | |
| 285 | + | return runGUIWithProfile(cfg, timing, runtimeprofile.Profile{}) | |
| 286 | + | } | |
| 287 | + | ||
| 288 | + | func runInstanceGUI(cfg *config.Config, timing startupTiming, args cmd.BrowseArgs) int { | |
| 289 | + | profile, err := bootstrap.ResolveRuntimeProfile(cfg) | |
| 290 | + | if err != nil { | |
| 291 | + | fmt.Fprintf(os.Stderr, "dumber: resolve runtime profile: %v\n", err) | |
| 292 | + | return 1 | |
| 293 | + | } | |
| 294 | + | if args.Profile != "" { | |
| 295 | + | profile, err = bootstrap.NewBrowserProfile(cfg, args.Profile) | |
| 296 | + | } else if args.Ephemeral { | |
| 297 | + | profile, err = bootstrap.NewEphemeralBrowserProfile(cfg) | |
| 298 | + | } | |
| 299 | + | if err != nil { | |
| 300 | + | fmt.Fprintf(os.Stderr, "dumber: %v\n", err) | |
| 301 | + | return 1 | |
| 302 | + | } | |
| 303 | + | if profile.Engine != config.EngineTypeCEF { | |
| 304 | + | fmt.Fprintln(os.Stderr, "dumber: --instance requires the CEF engine") | |
| 305 | + | return 1 | |
| 306 | + | } | |
| 307 | + | browserLaunchRelay = desktop.NewBrowserLaunchRelay(profile.IPC) | |
| 308 | + | startupURL := domainurl.ResolveBrowserStartupURL(args.URL) | |
| 309 | + | freshWindow := os.Getenv(desktop.FreshWindowLaunchEnvVar) == "1" | |
| 310 | + | if freshWindow { | |
| 311 | + | _ = os.Unsetenv(desktop.FreshWindowLaunchEnvVar) | |
| 312 | + | } | |
| 313 | + | instanceName := args.Instance | |
| 314 | + | // The elected first launch owns the shared profile host. Its initial browser | |
| 315 | + | // window is registered for the instance after GTK activation below. | |
| 316 | + | initialURL = startupURL | |
| 317 | + | restoreSessionID = os.Getenv(desktop.RestoreSessionEnvVar) | |
| 318 | + | if err := os.Setenv("DUMBER_INITIAL_INSTANCE", instanceName); err != nil { | |
| 319 | + | fmt.Fprintf(os.Stderr, "dumber: set initial instance: %v\n", err) | |
| 320 | + | return 1 | |
| 321 | + | } | |
| 322 | + | defer func() { _ = os.Unsetenv("DUMBER_INITIAL_INSTANCE") }() | |
| 323 | + | os.Args = os.Args[:1] | |
| 324 | + | code, launchErr := bootstrap.DeliverOrRunWithInstanceRelay( | |
| 325 | + | context.Background(), profile, browserLaunchRelay, instanceName, startupURL, freshWindow, | |
| 326 | + | func() int { | |
| 327 | + | return runGUIWithProfile(cfg, timing, profile) | |
| 328 | + | }, | |
| 329 | + | ) | |
| 330 | + | if launchErr != nil && !errors.Is(launchErr, desktop.ErrBrowserLaunchRelayUnconfirmed) { | |
| 331 | + | fmt.Fprintf(os.Stderr, "dumber: start or deliver to CEF profile host: %v\n", launchErr) | |
| 332 | + | } | |
| 333 | + | if args.Ephemeral { | |
| 334 | + | if removeErr := os.RemoveAll(profile.InstanceRoot); removeErr != nil { | |
| 335 | + | fmt.Fprintf(os.Stderr, "warning: failed to remove ephemeral browser profile: %v\n", removeErr) | |
| 336 | + | } | |
| 337 | + | } | |
| 338 | + | return code | |
| 339 | + | } | |
| 340 | + | ||
| 341 | + | // Return rather than os.Exit so the named IPC reservation is released on failure. | |
| 342 | + | func reportGUIStackError(log *zerolog.Logger, err error) int { | |
| 343 | + | log.Error().Err(err).Msg("failed to initialize database/webkit") | |
| 344 | + | return 1 | |
| 345 | + | } | |
| 346 | + | ||
| 347 | + | //nolint:funlen // Startup orchestration is intentionally linear to preserve cleanup ordering. | |
| 348 | + | func runGUIWithProfile(cfg *config.Config, timing startupTiming, profile runtimeprofile.Profile) int { | |
| 280 | 349 | bootstrap.ApplyGTKIMModuleFallbackDefault(os.Stderr) | |
| 281 | 350 | ||
| 282 | 351 | runtime.LockOSThread() | |
| @@ -296,7 +365,7 @@ func runGUI(cfg *config.Config, timing startupTiming) int { | |||
| 296 | 365 | timer.Mark("logger") | |
| 297 | 366 | bootstrapLog := logging.FromContext(ctx) | |
| 298 | 367 | ||
| 299 | - | initResult, err := runParallelInitPhase(ctx, cfg) | |
| 368 | + | initResult, err := runParallelInitPhase(ctx, cfg, profile) | |
| 300 | 369 | if err != nil { | |
| 301 | 370 | return 1 | |
| 302 | 371 | } | |
| @@ -311,14 +380,17 @@ func runGUI(cfg *config.Config, timing startupTiming) int { | |||
| 311 | 380 | ||
| 312 | 381 | engine, repos, dbCleanup, err := initStackAndRepos(ctx, cfg, initResult, needsEagerDB) | |
| 313 | 382 | if err != nil { | |
| 314 | - | bootstrapLog.Fatal().Err(err).Msg("failed to initialize database/webkit") | |
| 383 | + | return reportGUIStackError(bootstrapLog, err) | |
| 315 | 384 | } | |
| 316 | 385 | if dbCleanup != nil { | |
| 317 | 386 | defer dbCleanup() | |
| 318 | 387 | } | |
| 319 | 388 | timer.Mark("db_webkit_parallel") | |
| 320 | 389 | ||
| 321 | - | ctx, browserSession, sessionCleanup := initBrowserSession(ctx, cfg, repos, bootstrapLog) | |
| 390 | + | ctx, browserSession, sessionCleanup, err := initBrowserSession(ctx, cfg, repos) | |
| 391 | + | if err != nil { | |
| 392 | + | return reportBrowserSessionStartupError(bootstrapLog, err) | |
| 393 | + | } | |
| 322 | 394 | defer sessionCleanup() | |
| 323 | 395 | bootstrap.LogResolvedTheme(ctx, initResult.ResolvedTheme) | |
| 324 | 396 | timer.Mark("session") | |
| @@ -366,7 +438,7 @@ func runStandaloneOmnibox() int { | |||
| 366 | 438 | ctx := initStartupContext(cfg) | |
| 367 | 439 | applyCEFRenderStackDefault(ctx, cfg) | |
| 368 | 440 | ||
| 369 | - | initResult, err := runParallelInitPhase(ctx, cfg) | |
| 441 | + | initResult, err := runParallelInitPhase(ctx, cfg, runtimeprofile.Profile{}) | |
| 370 | 442 | if err != nil { | |
| 371 | 443 | return 1 | |
| 372 | 444 | } | |
| @@ -567,28 +639,35 @@ func activateCEFStartupTraceForGUI( | |||
| 567 | 639 | activate(timing.processEntry, timing.configComplete, logger) | |
| 568 | 640 | } | |
| 569 | 641 | ||
| 570 | - | func runParallelInitPhase(ctx context.Context, cfg *config.Config) (*bootstrap.ParallelInitResult, error) { | |
| 642 | + | func runParallelInitPhase(ctx context.Context, cfg *config.Config, profile runtimeprofile.Profile) (*bootstrap.ParallelInitResult, error) { | |
| 571 | 643 | initResult, err := bootstrap.RunParallelInit(bootstrap.ParallelInitInput{ | |
| 572 | - | Ctx: ctx, | |
| 573 | - | Config: cfg, | |
| 644 | + | Ctx: ctx, | |
| 645 | + | Config: cfg, | |
| 646 | + | RuntimeProfile: profile, | |
| 574 | 647 | }) | |
| 575 | 648 | if err != nil { | |
| 576 | - | handleParallelInitError(ctx, err) | |
| 577 | - | return nil, err | |
| 649 | + | return nil, reportParallelInitError(ctx, err) | |
| 578 | 650 | } | |
| 579 | 651 | return initResult, nil | |
| 580 | 652 | } | |
| 581 | 653 | ||
| 654 | + | func reportBrowserSessionStartupError(log *zerolog.Logger, err error) int { | |
| 655 | + | if err == nil { | |
| 656 | + | return 0 | |
| 657 | + | } | |
| 658 | + | log.Error().Err(err).Msg("failed to start session") | |
| 659 | + | return 1 | |
| 660 | + | } | |
| 661 | + | ||
| 582 | 662 | func initBrowserSession( | |
| 583 | 663 | ctx context.Context, | |
| 584 | 664 | cfg *config.Config, | |
| 585 | 665 | repos *repositories, | |
| 586 | - | bootstrapLog *zerolog.Logger, | |
| 587 | - | ) (context.Context, *bootstrap.BrowserSession, func()) { | |
| 666 | + | ) (context.Context, *bootstrap.BrowserSession, func(), error) { | |
| 588 | 667 | deferPersist := restoreSessionID == "" && !cfg.Session.AutoRestore | |
| 589 | 668 | browserSession, sessionCtx, err := bootstrap.StartBrowserSession(ctx, cfg, repos.session, deferPersist) | |
| 590 | 669 | if err != nil { | |
| 591 | - | bootstrapLog.Fatal().Err(err).Msg("failed to start session") | |
| 670 | + | return ctx, nil, nil, err | |
| 592 | 671 | } | |
| 593 | 672 | cleanup := func() { | |
| 594 | 673 | _ = browserSession.End(sessionCtx) | |
| @@ -596,7 +675,7 @@ func initBrowserSession( | |||
| 596 | 675 | browserSession.LogCleanup() | |
| 597 | 676 | } | |
| 598 | 677 | } | |
| 599 | - | return sessionCtx, browserSession, cleanup | |
| 678 | + | return sessionCtx, browserSession, cleanup, nil | |
| 600 | 679 | } | |
| 601 | 680 | ||
| 602 | 681 | func closeIdleInhibitor(inhibitor port.IdleInhibitor) { | |
| @@ -625,6 +704,9 @@ func buildAndConfigureApp( | |||
| 625 | 704 | return nil, err | |
| 626 | 705 | } | |
| 627 | 706 | configureDeferredInit(uiDeps, cfg, browserSession) | |
| 707 | + | if initResult.RuntimeProfile.InstanceRoot != "" { | |
| 708 | + | uiDeps.ApplicationID = ui.AppID + ".instance." + initResult.RuntimeProfile.InstanceAppIDSuffix | |
| 709 | + | } | |
| 628 | 710 | return ui.New(uiDeps) | |
| 629 | 711 | } | |
| 630 | 712 | ||
| @@ -765,15 +847,17 @@ func initConfig() *config.Config { | |||
| 765 | 847 | return config.Get() | |
| 766 | 848 | } | |
| 767 | 849 | ||
| 768 | - | func handleParallelInitError(ctx context.Context, err error) { | |
| 850 | + | func reportParallelInitError(ctx context.Context, err error) error { | |
| 769 | 851 | log := logging.FromContext(ctx) | |
| 770 | 852 | if runtimeErr, ok := err.(*bootstrap.RuntimeRequirementsError); ok { | |
| 771 | 853 | runtimeErr.LogDetails(ctx) | |
| 772 | - | log.Fatal().Err(runtimeErr). | |
| 854 | + | log.Error().Err(runtimeErr). | |
| 773 | 855 | Str("hint", "Run: dumber doctor (and set runtime.prefix for /opt installs)"). | |
| 774 | 856 | Msg("runtime requirements not met") | |
| 857 | + | return err | |
| 775 | 858 | } | |
| 776 | - | log.Fatal().Err(err).Msg("initialization failed") | |
| 859 | + | log.Error().Err(err).Msg("initialization failed") | |
| 860 | + | return err | |
| 777 | 861 | } | |
| 778 | 862 | ||
| 779 | 863 | func logDeferredInitResults(ctx context.Context, result bootstrap.DeferredInitResult) { | |
| @@ -75,16 +75,11 @@ func launchModeFromArgs(args []string) (launchMode, string) { | |||||
| 75 | if len(args) > 1 { | 75 | if len(args) > 1 { | ||
| 76 | switch args[1] { | 76 | switch args[1] { | ||
| 77 | case "browse": | 77 | case "browse": | ||
| 78 | - | if len(args) > 3 { | 78 | + | url, _, ok := cmd.ParseBrowseArgs(args[2:]) |
| 79 | + | if !ok { | |||
| 79 | return launchModeCLI, "" | 80 | return launchModeCLI, "" | ||
| 80 | } | 81 | } | ||
| 81 | - | if len(args) > 2 { | 82 | + | return launchModeBrowse, url |
| 82 | - | if strings.HasPrefix(args[2], "-") { | |||
| 83 | - | return launchModeCLI, "" | |||
| 84 | - | } | |||
| 85 | - | return launchModeBrowse, args[2] | |||
| 86 | - | } | |||
| 87 | - | return launchModeBrowse, "" | |||
| 88 | case "omnibox": | 83 | case "omnibox": | ||
| 89 | if len(args) > 2 { | 84 | if len(args) > 2 { | ||
| 90 | return launchModeCLI, "" | 85 | return launchModeCLI, "" | ||
| @@ -221,6 +216,16 @@ func main() { | |||||
| 221 | if mode == launchModeBrowse { | 216 | if mode == launchModeBrowse { | ||
| 222 | cfg := initConfig() | 217 | cfg := initConfig() | ||
| 223 | timing.configComplete = time.Now() | 218 | timing.configComplete = time.Now() | ||
| 219 | + | browseArgs, ok := cmd.ParseBrowseLaunchArgs(os.Args[2:]) | |||
| 220 | + | if !ok { | |||
| 221 | + | cmd.Execute() | |||
| 222 | + | return | |||
| 223 | + | } | |||
| 224 | + | if cfg.Engine.ResolveEngineType() == config.EngineTypeCEF || browseArgs.Instance != "" || | |||
| 225 | + | browseArgs.Profile != "" || browseArgs.Ephemeral { | |||
| 226 | + | os.Exit(runInstanceGUI(cfg, timing, browseArgs)) | |||
| 227 | + | return | |||
| 228 | + | } | |||
| 224 | configureBrowserLaunchRelay(cfg) | 229 | configureBrowserLaunchRelay(cfg) | ||
| 225 | startupURL := domainurl.ResolveBrowserStartupURL(browseURL) | 230 | startupURL := domainurl.ResolveBrowserStartupURL(browseURL) | ||
| 226 | freshWindow := os.Getenv(desktop.FreshWindowLaunchEnvVar) == "1" | 231 | freshWindow := os.Getenv(desktop.FreshWindowLaunchEnvVar) == "1" | ||
| @@ -243,7 +248,7 @@ func main() { | |||||
| 243 | } | 248 | } | ||
| 244 | 249 | ||||
| 245 | initialURL = startupURL | 250 | initialURL = startupURL | ||
| 246 | - | restoreSessionID = os.Getenv("DUMBER_RESTORE_SESSION") | 251 | + | restoreSessionID = os.Getenv(desktop.RestoreSessionEnvVar) |
| 247 | os.Args = os.Args[:1] | 252 | os.Args = os.Args[:1] | ||
| 248 | os.Exit(runGUI(cfg, timing)) | 253 | os.Exit(runGUI(cfg, timing)) | ||
| 249 | return | 254 | return | ||
| @@ -277,6 +282,70 @@ func setAlreadyRunningRelaunchHandler(engine port.Engine, app *ui.App, ctx conte | |||||
| 277 | } | 282 | } | ||
| 278 | 283 | ||||
| 279 | func runGUI(cfg *config.Config, timing startupTiming) int { | 284 | func runGUI(cfg *config.Config, timing startupTiming) int { | ||
| 285 | + | return runGUIWithProfile(cfg, timing, runtimeprofile.Profile{}) | |||
| 286 | + | } | |||
| 287 | + | ||||
| 288 | + | func runInstanceGUI(cfg *config.Config, timing startupTiming, args cmd.BrowseArgs) int { | |||
| 289 | + | profile, err := bootstrap.ResolveRuntimeProfile(cfg) | |||
| 290 | + | if err != nil { | |||
| 291 | + | fmt.Fprintf(os.Stderr, "dumber: resolve runtime profile: %v\n", err) | |||
| 292 | + | return 1 | |||
| 293 | + | } | |||
| 294 | + | if args.Profile != "" { | |||
| 295 | + | profile, err = bootstrap.NewBrowserProfile(cfg, args.Profile) | |||
| 296 | + | } else if args.Ephemeral { | |||
| 297 | + | profile, err = bootstrap.NewEphemeralBrowserProfile(cfg) | |||
| 298 | + | } | |||
| 299 | + | if err != nil { | |||
| 300 | + | fmt.Fprintf(os.Stderr, "dumber: %v\n", err) | |||
| 301 | + | return 1 | |||
| 302 | + | } | |||
| 303 | + | if profile.Engine != config.EngineTypeCEF { | |||
| 304 | + | fmt.Fprintln(os.Stderr, "dumber: --instance requires the CEF engine") | |||
| 305 | + | return 1 | |||
| 306 | + | } | |||
| 307 | + | browserLaunchRelay = desktop.NewBrowserLaunchRelay(profile.IPC) | |||
| 308 | + | startupURL := domainurl.ResolveBrowserStartupURL(args.URL) | |||
| 309 | + | freshWindow := os.Getenv(desktop.FreshWindowLaunchEnvVar) == "1" | |||
| 310 | + | if freshWindow { | |||
| 311 | + | _ = os.Unsetenv(desktop.FreshWindowLaunchEnvVar) | |||
| 312 | + | } | |||
| 313 | + | instanceName := args.Instance | |||
| 314 | + | // The elected first launch owns the shared profile host. Its initial browser | |||
| 315 | + | // window is registered for the instance after GTK activation below. | |||
| 316 | + | initialURL = startupURL | |||
| 317 | + | restoreSessionID = os.Getenv(desktop.RestoreSessionEnvVar) | |||
| 318 | + | if err := os.Setenv("DUMBER_INITIAL_INSTANCE", instanceName); err != nil { | |||
| 319 | + | fmt.Fprintf(os.Stderr, "dumber: set initial instance: %v\n", err) | |||
| 320 | + | return 1 | |||
| 321 | + | } | |||
| 322 | + | defer func() { _ = os.Unsetenv("DUMBER_INITIAL_INSTANCE") }() | |||
| 323 | + | os.Args = os.Args[:1] | |||
| 324 | + | code, launchErr := bootstrap.DeliverOrRunWithInstanceRelay( | |||
| 325 | + | context.Background(), profile, browserLaunchRelay, instanceName, startupURL, freshWindow, | |||
| 326 | + | func() int { | |||
| 327 | + | return runGUIWithProfile(cfg, timing, profile) | |||
| 328 | + | }, | |||
| 329 | + | ) | |||
| 330 | + | if launchErr != nil && !errors.Is(launchErr, desktop.ErrBrowserLaunchRelayUnconfirmed) { | |||
| 331 | + | fmt.Fprintf(os.Stderr, "dumber: start or deliver to CEF profile host: %v\n", launchErr) | |||
| 332 | + | } | |||
| 333 | + | if args.Ephemeral { | |||
| 334 | + | if removeErr := os.RemoveAll(profile.InstanceRoot); removeErr != nil { | |||
| 335 | + | fmt.Fprintf(os.Stderr, "warning: failed to remove ephemeral browser profile: %v\n", removeErr) | |||
| 336 | + | } | |||
| 337 | + | } | |||
| 338 | + | return code | |||
| 339 | + | } | |||
| 340 | + | ||||
| 341 | + | // Return rather than os.Exit so the named IPC reservation is released on failure. | |||
| 342 | + | func reportGUIStackError(log *zerolog.Logger, err error) int { | |||
| 343 | + | log.Error().Err(err).Msg("failed to initialize database/webkit") | |||
| 344 | + | return 1 | |||
| 345 | + | } | |||
| 346 | + | ||||
| 347 | + | //nolint:funlen // Startup orchestration is intentionally linear to preserve cleanup ordering. | |||
| 348 | + | func runGUIWithProfile(cfg *config.Config, timing startupTiming, profile runtimeprofile.Profile) int { | |||
| 280 | bootstrap.ApplyGTKIMModuleFallbackDefault(os.Stderr) | 349 | bootstrap.ApplyGTKIMModuleFallbackDefault(os.Stderr) | ||
| 281 | 350 | ||||
| 282 | runtime.LockOSThread() | 351 | runtime.LockOSThread() | ||
| @@ -296,7 +365,7 @@ func runGUI(cfg *config.Config, timing startupTiming) int { | |||||
| 296 | timer.Mark("logger") | 365 | timer.Mark("logger") | ||
| 297 | bootstrapLog := logging.FromContext(ctx) | 366 | bootstrapLog := logging.FromContext(ctx) | ||
| 298 | 367 | ||||
| 299 | - | initResult, err := runParallelInitPhase(ctx, cfg) | 368 | + | initResult, err := runParallelInitPhase(ctx, cfg, profile) |
| 300 | if err != nil { | 369 | if err != nil { | ||
| 301 | return 1 | 370 | return 1 | ||
| 302 | } | 371 | } | ||
| @@ -311,14 +380,17 @@ func runGUI(cfg *config.Config, timing startupTiming) int { | |||||
| 311 | 380 | ||||
| 312 | engine, repos, dbCleanup, err := initStackAndRepos(ctx, cfg, initResult, needsEagerDB) | 381 | engine, repos, dbCleanup, err := initStackAndRepos(ctx, cfg, initResult, needsEagerDB) | ||
| 313 | if err != nil { | 382 | if err != nil { | ||
| 314 | - | bootstrapLog.Fatal().Err(err).Msg("failed to initialize database/webkit") | 383 | + | return reportGUIStackError(bootstrapLog, err) |
| 315 | } | 384 | } | ||
| 316 | if dbCleanup != nil { | 385 | if dbCleanup != nil { | ||
| 317 | defer dbCleanup() | 386 | defer dbCleanup() | ||
| 318 | } | 387 | } | ||
| 319 | timer.Mark("db_webkit_parallel") | 388 | timer.Mark("db_webkit_parallel") | ||
| 320 | 389 | ||||
| 321 | - | ctx, browserSession, sessionCleanup := initBrowserSession(ctx, cfg, repos, bootstrapLog) | 390 | + | ctx, browserSession, sessionCleanup, err := initBrowserSession(ctx, cfg, repos) |
| 391 | + | if err != nil { | |||
| 392 | + | return reportBrowserSessionStartupError(bootstrapLog, err) | |||
| 393 | + | } | |||
| 322 | defer sessionCleanup() | 394 | defer sessionCleanup() | ||
| 323 | bootstrap.LogResolvedTheme(ctx, initResult.ResolvedTheme) | 395 | bootstrap.LogResolvedTheme(ctx, initResult.ResolvedTheme) | ||
| 324 | timer.Mark("session") | 396 | timer.Mark("session") | ||
| @@ -366,7 +438,7 @@ func runStandaloneOmnibox() int { | |||||
| 366 | ctx := initStartupContext(cfg) | 438 | ctx := initStartupContext(cfg) | ||
| 367 | applyCEFRenderStackDefault(ctx, cfg) | 439 | applyCEFRenderStackDefault(ctx, cfg) | ||
| 368 | 440 | ||||
| 369 | - | initResult, err := runParallelInitPhase(ctx, cfg) | 441 | + | initResult, err := runParallelInitPhase(ctx, cfg, runtimeprofile.Profile{}) |
| 370 | if err != nil { | 442 | if err != nil { | ||
| 371 | return 1 | 443 | return 1 | ||
| 372 | } | 444 | } | ||
| @@ -567,28 +639,35 @@ func activateCEFStartupTraceForGUI( | |||||
| 567 | activate(timing.processEntry, timing.configComplete, logger) | 639 | activate(timing.processEntry, timing.configComplete, logger) | ||
| 568 | } | 640 | } | ||
| 569 | 641 | ||||
| 570 | - | func runParallelInitPhase(ctx context.Context, cfg *config.Config) (*bootstrap.ParallelInitResult, error) { | 642 | + | func runParallelInitPhase(ctx context.Context, cfg *config.Config, profile runtimeprofile.Profile) (*bootstrap.ParallelInitResult, error) { |
| 571 | initResult, err := bootstrap.RunParallelInit(bootstrap.ParallelInitInput{ | 643 | initResult, err := bootstrap.RunParallelInit(bootstrap.ParallelInitInput{ | ||
| 572 | - | Ctx: ctx, | 644 | + | Ctx: ctx, |
| 573 | - | Config: cfg, | 645 | + | Config: cfg, |
| 646 | + | RuntimeProfile: profile, | |||
| 574 | }) | 647 | }) | ||
| 575 | if err != nil { | 648 | if err != nil { | ||
| 576 | - | handleParallelInitError(ctx, err) | 649 | + | return nil, reportParallelInitError(ctx, err) |
| 577 | - | return nil, err | |||
| 578 | } | 650 | } | ||
| 579 | return initResult, nil | 651 | return initResult, nil | ||
| 580 | } | 652 | } | ||
| 581 | 653 | ||||
| 654 | + | func reportBrowserSessionStartupError(log *zerolog.Logger, err error) int { | |||
| 655 | + | if err == nil { | |||
| 656 | + | return 0 | |||
| 657 | + | } | |||
| 658 | + | log.Error().Err(err).Msg("failed to start session") | |||
| 659 | + | return 1 | |||
| 660 | + | } | |||
| 661 | + | ||||
| 582 | func initBrowserSession( | 662 | func initBrowserSession( | ||
| 583 | ctx context.Context, | 663 | ctx context.Context, | ||
| 584 | cfg *config.Config, | 664 | cfg *config.Config, | ||
| 585 | repos *repositories, | 665 | repos *repositories, | ||
| 586 | - | bootstrapLog *zerolog.Logger, | 666 | + | ) (context.Context, *bootstrap.BrowserSession, func(), error) { |
| 587 | - | ) (context.Context, *bootstrap.BrowserSession, func()) { | |||
| 588 | deferPersist := restoreSessionID == "" && !cfg.Session.AutoRestore | 667 | deferPersist := restoreSessionID == "" && !cfg.Session.AutoRestore | ||
| 589 | browserSession, sessionCtx, err := bootstrap.StartBrowserSession(ctx, cfg, repos.session, deferPersist) | 668 | browserSession, sessionCtx, err := bootstrap.StartBrowserSession(ctx, cfg, repos.session, deferPersist) | ||
| 590 | if err != nil { | 669 | if err != nil { | ||
| 591 | - | bootstrapLog.Fatal().Err(err).Msg("failed to start session") | 670 | + | return ctx, nil, nil, err |
| 592 | } | 671 | } | ||
| 593 | cleanup := func() { | 672 | cleanup := func() { | ||
| 594 | _ = browserSession.End(sessionCtx) | 673 | _ = browserSession.End(sessionCtx) | ||
| @@ -596,7 +675,7 @@ func initBrowserSession( | |||||
| 596 | browserSession.LogCleanup() | 675 | browserSession.LogCleanup() | ||
| 597 | } | 676 | } | ||
| 598 | } | 677 | } | ||
| 599 | - | return sessionCtx, browserSession, cleanup | 678 | + | return sessionCtx, browserSession, cleanup, nil |
| 600 | } | 679 | } | ||
| 601 | 680 | ||||
| 602 | func closeIdleInhibitor(inhibitor port.IdleInhibitor) { | 681 | func closeIdleInhibitor(inhibitor port.IdleInhibitor) { | ||
| @@ -625,6 +704,9 @@ func buildAndConfigureApp( | |||||
| 625 | return nil, err | 704 | return nil, err | ||
| 626 | } | 705 | } | ||
| 627 | configureDeferredInit(uiDeps, cfg, browserSession) | 706 | configureDeferredInit(uiDeps, cfg, browserSession) | ||
| 707 | + | if initResult.RuntimeProfile.InstanceRoot != "" { | |||
| 708 | + | uiDeps.ApplicationID = ui.AppID + ".instance." + initResult.RuntimeProfile.InstanceAppIDSuffix | |||
| 709 | + | } | |||
| 628 | return ui.New(uiDeps) | 710 | return ui.New(uiDeps) | ||
| 629 | } | 711 | } | ||
| 630 | 712 | ||||
| @@ -765,15 +847,17 @@ func initConfig() *config.Config { | |||||
| 765 | return config.Get() | 847 | return config.Get() | ||
| 766 | } | 848 | } | ||
| 767 | 849 | ||||
| 768 | - | func handleParallelInitError(ctx context.Context, err error) { | 850 | + | func reportParallelInitError(ctx context.Context, err error) error { |
| 769 | log := logging.FromContext(ctx) | 851 | log := logging.FromContext(ctx) | ||
| 770 | if runtimeErr, ok := err.(*bootstrap.RuntimeRequirementsError); ok { | 852 | if runtimeErr, ok := err.(*bootstrap.RuntimeRequirementsError); ok { | ||
| 771 | runtimeErr.LogDetails(ctx) | 853 | runtimeErr.LogDetails(ctx) | ||
| 772 | - | log.Fatal().Err(runtimeErr). | 854 | + | log.Error().Err(runtimeErr). |
| 773 | Str("hint", "Run: dumber doctor (and set runtime.prefix for /opt installs)"). | 855 | Str("hint", "Run: dumber doctor (and set runtime.prefix for /opt installs)"). | ||
| 774 | Msg("runtime requirements not met") | 856 | Msg("runtime requirements not met") | ||
| 857 | + | return err | |||
| 775 | } | 858 | } | ||
| 776 | - | log.Fatal().Err(err).Msg("initialization failed") | 859 | + | log.Error().Err(err).Msg("initialization failed") |
| 860 | + | return err | |||
| 777 | } | 861 | } | ||
| 778 | 862 | ||||
| 779 | func logDeferredInitResults(ctx context.Context, result bootstrap.DeferredInitResult) { | 863 | func logDeferredInitResults(ctx context.Context, result bootstrap.DeferredInitResult) { | ||
M cmd/dumber/main_test.go +23 -0
| @@ -14,6 +14,29 @@ import ( | |||
| 14 | 14 | "github.com/rs/zerolog" | |
| 15 | 15 | ) | |
| 16 | 16 | ||
| 17 | + | func TestReportParallelInitErrorReturnsFailureToCaller(t *testing.T) { | |
| 18 | + | failure := errors.New("parallel init failed") | |
| 19 | + | if got := reportParallelInitError(context.Background(), failure); !errors.Is(got, failure) { | |
| 20 | + | t.Fatalf("reportParallelInitError() = %v, want original failure", got) | |
| 21 | + | } | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | func TestLaunchModeFromArgs_InstanceBrowse(t *testing.T) { | |
| 25 | + | for _, args := range [][]string{ | |
| 26 | + | {"dumber", "browse", "--instance", "work", "https://example.com"}, | |
| 27 | + | {"dumber", "browse", "https://example.com", "--instance", "work"}, | |
| 28 | + | } { | |
| 29 | + | mode, url := launchModeFromArgs(args) | |
| 30 | + | if mode != launchModeBrowse || url != "https://example.com" { | |
| 31 | + | t.Fatalf("unexpected instance launch: %s %q", mode, url) | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | mode, _ := launchModeFromArgs([]string{"dumber", "browse", "--instance", "work", "--help"}) | |
| 35 | + | if mode != launchModeCLI { | |
| 36 | + | t.Fatal("instance help must go through Cobra") | |
| 37 | + | } | |
| 38 | + | } | |
| 39 | + | ||
| 17 | 40 | func TestLaunchModeFromArgs_DetectsStandaloneOmnibox(t *testing.T) { | |
| 18 | 41 | mode, _ := launchModeFromArgs([]string{"dumber", "omnibox"}) | |
| 19 | 42 | if mode != launchModeStandaloneOmnibox { | |
| @@ -14,6 +14,29 @@ import ( | |||||
| 14 | "github.com/rs/zerolog" | 14 | "github.com/rs/zerolog" | ||
| 15 | ) | 15 | ) | ||
| 16 | 16 | ||||
| 17 | + | func TestReportParallelInitErrorReturnsFailureToCaller(t *testing.T) { | |||
| 18 | + | failure := errors.New("parallel init failed") | |||
| 19 | + | if got := reportParallelInitError(context.Background(), failure); !errors.Is(got, failure) { | |||
| 20 | + | t.Fatalf("reportParallelInitError() = %v, want original failure", got) | |||
| 21 | + | } | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | func TestLaunchModeFromArgs_InstanceBrowse(t *testing.T) { | |||
| 25 | + | for _, args := range [][]string{ | |||
| 26 | + | {"dumber", "browse", "--instance", "work", "https://example.com"}, | |||
| 27 | + | {"dumber", "browse", "https://example.com", "--instance", "work"}, | |||
| 28 | + | } { | |||
| 29 | + | mode, url := launchModeFromArgs(args) | |||
| 30 | + | if mode != launchModeBrowse || url != "https://example.com" { | |||
| 31 | + | t.Fatalf("unexpected instance launch: %s %q", mode, url) | |||
| 32 | + | } | |||
| 33 | + | } | |||
| 34 | + | mode, _ := launchModeFromArgs([]string{"dumber", "browse", "--instance", "work", "--help"}) | |||
| 35 | + | if mode != launchModeCLI { | |||
| 36 | + | t.Fatal("instance help must go through Cobra") | |||
| 37 | + | } | |||
| 38 | + | } | |||
| 39 | + | ||||
| 17 | func TestLaunchModeFromArgs_DetectsStandaloneOmnibox(t *testing.T) { | 40 | func TestLaunchModeFromArgs_DetectsStandaloneOmnibox(t *testing.T) { | ||
| 18 | mode, _ := launchModeFromArgs([]string{"dumber", "omnibox"}) | 41 | mode, _ := launchModeFromArgs([]string{"dumber", "omnibox"}) | ||
| 19 | if mode != launchModeStandaloneOmnibox { | 42 | if mode != launchModeStandaloneOmnibox { | ||
M go.mod +1 -1
| @@ -21,6 +21,7 @@ require ( | |||
| 21 | 21 | github.com/pressly/goose/v3 v3.28.0 | |
| 22 | 22 | github.com/rs/zerolog v1.35.1 | |
| 23 | 23 | github.com/spf13/cobra v1.10.2 | |
| 24 | + | github.com/spf13/pflag v1.0.10 | |
| 24 | 25 | github.com/spf13/viper v1.21.0 | |
| 25 | 26 | github.com/stretchr/testify v1.12.1 | |
| 26 | 27 | golang.org/x/image v0.46.0 | |
| @@ -65,7 +66,6 @@ require ( | |||
| 65 | 66 | github.com/sethvargo/go-retry v0.4.0 // indirect | |
| 66 | 67 | github.com/spf13/afero v1.15.0 // indirect | |
| 67 | 68 | github.com/spf13/cast v1.10.0 // indirect | |
| 68 | - | github.com/spf13/pflag v1.0.10 // indirect | |
| 69 | 69 | github.com/stretchr/objx v0.5.3 // indirect | |
| 70 | 70 | github.com/subosito/gotenv v1.6.0 // indirect | |
| 71 | 71 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect | |
| @@ -21,6 +21,7 @@ require ( | |||||
| 21 | github.com/pressly/goose/v3 v3.28.0 | 21 | github.com/pressly/goose/v3 v3.28.0 | ||
| 22 | github.com/rs/zerolog v1.35.1 | 22 | github.com/rs/zerolog v1.35.1 | ||
| 23 | github.com/spf13/cobra v1.10.2 | 23 | github.com/spf13/cobra v1.10.2 | ||
| 24 | + | github.com/spf13/pflag v1.0.10 | |||
| 24 | github.com/spf13/viper v1.21.0 | 25 | github.com/spf13/viper v1.21.0 | ||
| 25 | github.com/stretchr/testify v1.12.1 | 26 | github.com/stretchr/testify v1.12.1 | ||
| 26 | golang.org/x/image v0.46.0 | 27 | golang.org/x/image v0.46.0 | ||
| @@ -65,7 +66,6 @@ require ( | |||||
| 65 | github.com/sethvargo/go-retry v0.4.0 // indirect | 66 | github.com/sethvargo/go-retry v0.4.0 // indirect | ||
| 66 | github.com/spf13/afero v1.15.0 // indirect | 67 | github.com/spf13/afero v1.15.0 // indirect | ||
| 67 | github.com/spf13/cast v1.10.0 // indirect | 68 | github.com/spf13/cast v1.10.0 // indirect | ||
| 68 | - | github.com/spf13/pflag v1.0.10 // indirect | |||
| 69 | github.com/stretchr/objx v0.5.3 // indirect | 69 | github.com/stretchr/objx v0.5.3 // indirect | ||
| 70 | github.com/subosito/gotenv v1.6.0 // indirect | 70 | github.com/subosito/gotenv v1.6.0 // indirect | ||
| 71 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect | 71 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect | ||
M internal/application/port/browser_launch.go +20 -0
| @@ -11,6 +11,19 @@ type BrowserWindowOpener interface { | |||
| 11 | 11 | OpenFreshWindow(ctx context.Context, url string) error | |
| 12 | 12 | } | |
| 13 | 13 | ||
| 14 | + | // BrowserInstanceOpener opens or focuses a logical UI instance inside a shared | |
| 15 | + | // browser runtime. Implementations must keep one dedicated window per name. | |
| 16 | + | type BrowserInstanceOpener interface { | |
| 17 | + | OpenInstance(ctx context.Context, name, url string) error | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | // BrowserInstanceLaunchRelay delivers launches for logical UI instances. | |
| 21 | + | // It is separate from BrowserLaunchRelay so other engine adapters do not need | |
| 22 | + | // to implement CEF-specific multi-instance behavior. | |
| 23 | + | type BrowserInstanceLaunchRelay interface { | |
| 24 | + | DeliverOpenInstance(ctx context.Context, name, url string) (bool, error) | |
| 25 | + | } | |
| 26 | + | ||
| 14 | 27 | // BrowserLaunchRelay delivers browser URL launch requests. | |
| 15 | 28 | type BrowserLaunchRelay interface { | |
| 16 | 29 | // DeliverOpenExternalURL attempts to deliver a request to open an external URL. | |
| @@ -25,6 +38,13 @@ type BrowserLaunchRelay interface { | |||
| 25 | 38 | Listen(ctx context.Context, opener BrowserWindowOpener) (io.Closer, error) | |
| 26 | 39 | } | |
| 27 | 40 | ||
| 41 | + | // BrowserLaunchRelayPreparer binds the socket while the caller holds the | |
| 42 | + | // separate namespace lease. Listen starts serving it and returns a closer that | |
| 43 | + | // stops serving without releasing namespace ownership. | |
| 44 | + | type BrowserLaunchRelayPreparer interface { | |
| 45 | + | Prepare(namespaceLease io.Closer) error | |
| 46 | + | } | |
| 47 | + | ||
| 28 | 48 | // AlreadyRunningAppRelaunchHandlerSetter configures a handler for CEF relaunches. | |
| 29 | 49 | type AlreadyRunningAppRelaunchHandlerSetter interface { | |
| 30 | 50 | SetAlreadyRunningAppRelaunchHandler(func(string)) | |
| @@ -11,6 +11,19 @@ type BrowserWindowOpener interface { | |||||
| 11 | OpenFreshWindow(ctx context.Context, url string) error | 11 | OpenFreshWindow(ctx context.Context, url string) error | ||
| 12 | } | 12 | } | ||
| 13 | 13 | ||||
| 14 | + | // BrowserInstanceOpener opens or focuses a logical UI instance inside a shared | |||
| 15 | + | // browser runtime. Implementations must keep one dedicated window per name. | |||
| 16 | + | type BrowserInstanceOpener interface { | |||
| 17 | + | OpenInstance(ctx context.Context, name, url string) error | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | // BrowserInstanceLaunchRelay delivers launches for logical UI instances. | |||
| 21 | + | // It is separate from BrowserLaunchRelay so other engine adapters do not need | |||
| 22 | + | // to implement CEF-specific multi-instance behavior. | |||
| 23 | + | type BrowserInstanceLaunchRelay interface { | |||
| 24 | + | DeliverOpenInstance(ctx context.Context, name, url string) (bool, error) | |||
| 25 | + | } | |||
| 26 | + | ||||
| 14 | // BrowserLaunchRelay delivers browser URL launch requests. | 27 | // BrowserLaunchRelay delivers browser URL launch requests. | ||
| 15 | type BrowserLaunchRelay interface { | 28 | type BrowserLaunchRelay interface { | ||
| 16 | // DeliverOpenExternalURL attempts to deliver a request to open an external URL. | 29 | // DeliverOpenExternalURL attempts to deliver a request to open an external URL. | ||
| @@ -25,6 +38,13 @@ type BrowserLaunchRelay interface { | |||||
| 25 | Listen(ctx context.Context, opener BrowserWindowOpener) (io.Closer, error) | 38 | Listen(ctx context.Context, opener BrowserWindowOpener) (io.Closer, error) | ||
| 26 | } | 39 | } | ||
| 27 | 40 | ||||
| 41 | + | // BrowserLaunchRelayPreparer binds the socket while the caller holds the | |||
| 42 | + | // separate namespace lease. Listen starts serving it and returns a closer that | |||
| 43 | + | // stops serving without releasing namespace ownership. | |||
| 44 | + | type BrowserLaunchRelayPreparer interface { | |||
| 45 | + | Prepare(namespaceLease io.Closer) error | |||
| 46 | + | } | |||
| 47 | + | ||||
| 28 | // AlreadyRunningAppRelaunchHandlerSetter configures a handler for CEF relaunches. | 48 | // AlreadyRunningAppRelaunchHandlerSetter configures a handler for CEF relaunches. | ||
| 29 | type AlreadyRunningAppRelaunchHandlerSetter interface { | 49 | type AlreadyRunningAppRelaunchHandlerSetter interface { | ||
| 30 | SetAlreadyRunningAppRelaunchHandler(func(string)) | 50 | SetAlreadyRunningAppRelaunchHandler(func(string)) | ||
M internal/bootstrap/engine.go +6 -2
| @@ -121,11 +121,15 @@ func BuildEngine(input EngineInput) (port.Engine, error) { | |||
| 121 | 121 | ImageDataResolver: webkit.NewContextMenuResolver(), | |
| 122 | 122 | } | |
| 123 | 123 | audioFactory := audiofactory.NewAudioOutputFactory() | |
| 124 | - | return cef.NewEngine(input.Ctx, opts, cef.RuntimePaths{ | |
| 124 | + | paths := cef.RuntimePaths{ | |
| 125 | 125 | StateRoot: profile.CEFUserDataDir(), | |
| 126 | 126 | LogFile: profile.CEFLogFile(), | |
| 127 | 127 | ProfileLogDir: profile.Shared.LogDir, | |
| 128 | - | }, cefCfg, audioFactory, deps) | |
| 128 | + | } | |
| 129 | + | if profile.InstanceRoot != "" { | |
| 130 | + | paths.IsolatedRoot = profile.CEFUserDataDir() | |
| 131 | + | } | |
| 132 | + | return cef.NewEngine(input.Ctx, opts, paths, cefCfg, audioFactory, deps) | |
| 129 | 133 | default: | |
| 130 | 134 | return nil, fmt.Errorf("unknown engine type: %q", engineType) | |
| 131 | 135 | } | |
| @@ -121,11 +121,15 @@ func BuildEngine(input EngineInput) (port.Engine, error) { | |||||
| 121 | ImageDataResolver: webkit.NewContextMenuResolver(), | 121 | ImageDataResolver: webkit.NewContextMenuResolver(), | ||
| 122 | } | 122 | } | ||
| 123 | audioFactory := audiofactory.NewAudioOutputFactory() | 123 | audioFactory := audiofactory.NewAudioOutputFactory() | ||
| 124 | - | return cef.NewEngine(input.Ctx, opts, cef.RuntimePaths{ | 124 | + | paths := cef.RuntimePaths{ |
| 125 | StateRoot: profile.CEFUserDataDir(), | 125 | StateRoot: profile.CEFUserDataDir(), | ||
| 126 | LogFile: profile.CEFLogFile(), | 126 | LogFile: profile.CEFLogFile(), | ||
| 127 | ProfileLogDir: profile.Shared.LogDir, | 127 | ProfileLogDir: profile.Shared.LogDir, | ||
| 128 | - | }, cefCfg, audioFactory, deps) | 128 | + | } |
| 129 | + | if profile.InstanceRoot != "" { | |||
| 130 | + | paths.IsolatedRoot = profile.CEFUserDataDir() | |||
| 131 | + | } | |||
| 132 | + | return cef.NewEngine(input.Ctx, opts, paths, cefCfg, audioFactory, deps) | |||
| 129 | default: | 133 | default: | ||
| 130 | return nil, fmt.Errorf("unknown engine type: %q", engineType) | 134 | return nil, fmt.Errorf("unknown engine type: %q", engineType) | ||
| 131 | } | 135 | } | ||
M internal/bootstrap/gui.go +10 -5
| @@ -52,8 +52,9 @@ type DeferredInitResult struct { | |||
| 52 | 52 | ||
| 53 | 53 | // ParallelInitInput holds the input for parallel initialization. | |
| 54 | 54 | type ParallelInitInput struct { | |
| 55 | - | Ctx context.Context | |
| 56 | - | Config *config.Config | |
| 55 | + | RuntimeProfile runtimeprofile.Profile | |
| 56 | + | Ctx context.Context | |
| 57 | + | Config *config.Config | |
| 57 | 58 | } | |
| 58 | 59 | ||
| 59 | 60 | // DeferredInitInput holds the input for deferred initialization. | |
| @@ -101,9 +102,13 @@ func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) { | |||
| 101 | 102 | wg sync.WaitGroup | |
| 102 | 103 | ) | |
| 103 | 104 | ||
| 104 | - | profile, err := ResolveRuntimeProfile(input.Config) | |
| 105 | - | if err != nil { | |
| 106 | - | return nil, fmt.Errorf("resolve runtime profile: %w", err) | |
| 105 | + | profile := input.RuntimeProfile | |
| 106 | + | if profile.Engine == "" { | |
| 107 | + | var err error | |
| 108 | + | profile, err = ResolveRuntimeProfile(input.Config) | |
| 109 | + | if err != nil { | |
| 110 | + | return nil, fmt.Errorf("resolve runtime profile: %w", err) | |
| 111 | + | } | |
| 107 | 112 | } | |
| 108 | 113 | ||
| 109 | 114 | start := time.Now() | |
| @@ -52,8 +52,9 @@ type DeferredInitResult struct { | |||||
| 52 | 52 | ||||
| 53 | // ParallelInitInput holds the input for parallel initialization. | 53 | // ParallelInitInput holds the input for parallel initialization. | ||
| 54 | type ParallelInitInput struct { | 54 | type ParallelInitInput struct { | ||
| 55 | - | Ctx context.Context | 55 | + | RuntimeProfile runtimeprofile.Profile |
| 56 | - | Config *config.Config | 56 | + | Ctx context.Context |
| 57 | + | Config *config.Config | |||
| 57 | } | 58 | } | ||
| 58 | 59 | ||||
| 59 | // DeferredInitInput holds the input for deferred initialization. | 60 | // DeferredInitInput holds the input for deferred initialization. | ||
| @@ -101,9 +102,13 @@ func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) { | |||||
| 101 | wg sync.WaitGroup | 102 | wg sync.WaitGroup | ||
| 102 | ) | 103 | ) | ||
| 103 | 104 | ||||
| 104 | - | profile, err := ResolveRuntimeProfile(input.Config) | 105 | + | profile := input.RuntimeProfile |
| 105 | - | if err != nil { | 106 | + | if profile.Engine == "" { |
| 106 | - | return nil, fmt.Errorf("resolve runtime profile: %w", err) | 107 | + | var err error |
| 108 | + | profile, err = ResolveRuntimeProfile(input.Config) | |||
| 109 | + | if err != nil { | |||
| 110 | + | return nil, fmt.Errorf("resolve runtime profile: %w", err) | |||
| 111 | + | } | |||
| 107 | } | 112 | } | ||
| 108 | 113 | ||||
| 109 | start := time.Now() | 114 | start := time.Now() | ||
A internal/bootstrap/instance.go +32 -0
| @@ -0,0 +1,32 @@ | |||
| 1 | + | package bootstrap | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 7 | + | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func NewBrowserProfile(cfg *config.Config, name string) (runtimeprofile.Profile, error) { | |
| 11 | + | profile, err := ResolveRuntimeProfile(cfg) | |
| 12 | + | if err != nil { | |
| 13 | + | return runtimeprofile.Profile{}, err | |
| 14 | + | } | |
| 15 | + | profile, err = profile.WithBrowserProfile(name, ResolveXDGRuntimeDir(profile)) | |
| 16 | + | if err != nil { | |
| 17 | + | return runtimeprofile.Profile{}, fmt.Errorf("resolve browser profile: %w", err) | |
| 18 | + | } | |
| 19 | + | return profile, nil | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | func NewEphemeralBrowserProfile(cfg *config.Config) (runtimeprofile.Profile, error) { | |
| 23 | + | profile, err := ResolveRuntimeProfile(cfg) | |
| 24 | + | if err != nil { | |
| 25 | + | return runtimeprofile.Profile{}, err | |
| 26 | + | } | |
| 27 | + | profile, err = profile.WithEphemeralBrowserProfile(ResolveXDGRuntimeDir(profile)) | |
| 28 | + | if err != nil { | |
| 29 | + | return runtimeprofile.Profile{}, fmt.Errorf("resolve ephemeral browser profile: %w", err) | |
| 30 | + | } | |
| 31 | + | return profile, nil | |
| 32 | + | } | |
| @@ -0,0 +1,32 @@ | |||||
| 1 | + | package bootstrap | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/dumber/internal/infrastructure/config" | |||
| 7 | + | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func NewBrowserProfile(cfg *config.Config, name string) (runtimeprofile.Profile, error) { | |||
| 11 | + | profile, err := ResolveRuntimeProfile(cfg) | |||
| 12 | + | if err != nil { | |||
| 13 | + | return runtimeprofile.Profile{}, err | |||
| 14 | + | } | |||
| 15 | + | profile, err = profile.WithBrowserProfile(name, ResolveXDGRuntimeDir(profile)) | |||
| 16 | + | if err != nil { | |||
| 17 | + | return runtimeprofile.Profile{}, fmt.Errorf("resolve browser profile: %w", err) | |||
| 18 | + | } | |||
| 19 | + | return profile, nil | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | func NewEphemeralBrowserProfile(cfg *config.Config) (runtimeprofile.Profile, error) { | |||
| 23 | + | profile, err := ResolveRuntimeProfile(cfg) | |||
| 24 | + | if err != nil { | |||
| 25 | + | return runtimeprofile.Profile{}, err | |||
| 26 | + | } | |||
| 27 | + | profile, err = profile.WithEphemeralBrowserProfile(ResolveXDGRuntimeDir(profile)) | |||
| 28 | + | if err != nil { | |||
| 29 | + | return runtimeprofile.Profile{}, fmt.Errorf("resolve ephemeral browser profile: %w", err) | |||
| 30 | + | } | |||
| 31 | + | return profile, nil | |||
| 32 | + | } | |||
A internal/bootstrap/instance_launch.go +149 -0
| @@ -0,0 +1,149 @@ | |||
| 1 | + | package bootstrap | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "fmt" | |
| 7 | + | "io" | |
| 8 | + | "os" | |
| 9 | + | "path/filepath" | |
| 10 | + | "time" | |
| 11 | + | ||
| 12 | + | "golang.org/x/sys/unix" | |
| 13 | + | ||
| 14 | + | "github.com/bnema/dumber/internal/application/port" | |
| 15 | + | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | const ( | |
| 19 | + | instanceRuntimeDirPerm = 0o700 | |
| 20 | + | instanceLockFilePerm = 0o600 | |
| 21 | + | instanceLockRetry = 25 * time.Millisecond | |
| 22 | + | instanceElectionSlice = 150 * time.Millisecond | |
| 23 | + | ) | |
| 24 | + | ||
| 25 | + | // errInstanceNamespaceContended marks a lease acquisition that failed because | |
| 26 | + | // another process holds the namespace lock. It is the only acquisition failure | |
| 27 | + | // callers may retry; permanent failures (mkdir/open/flock) must surface at once. | |
| 28 | + | var errInstanceNamespaceContended = errors.New("named instance namespace contended") | |
| 29 | + | ||
| 30 | + | type namespaceLease struct{ file *os.File } | |
| 31 | + | ||
| 32 | + | func (l *namespaceLease) Close() error { | |
| 33 | + | if l == nil || l.file == nil { | |
| 34 | + | return nil | |
| 35 | + | } | |
| 36 | + | err := unix.Flock(int(l.file.Fd()), unix.LOCK_UN) | |
| 37 | + | closeErr := l.file.Close() | |
| 38 | + | l.file = nil | |
| 39 | + | return errors.Join(err, closeErr) | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func acquireInstanceNamespace(ctx context.Context, profile runtimeprofile.Profile) (io.Closer, error) { | |
| 43 | + | if err := os.MkdirAll(profile.InstanceRoot, instanceRuntimeDirPerm); err != nil { | |
| 44 | + | return nil, fmt.Errorf("create named instance runtime directory: %w", err) | |
| 45 | + | } | |
| 46 | + | path := filepath.Join(profile.InstanceRoot, "namespace.lock") | |
| 47 | + | file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, instanceLockFilePerm) | |
| 48 | + | if err != nil { | |
| 49 | + | return nil, fmt.Errorf("open named instance namespace lock: %w", err) | |
| 50 | + | } | |
| 51 | + | for { | |
| 52 | + | if err = unix.Flock(int(file.Fd()), unix.LOCK_EX|unix.LOCK_NB); err == nil { | |
| 53 | + | return &namespaceLease{file: file}, nil | |
| 54 | + | } | |
| 55 | + | if !errors.Is(err, unix.EWOULDBLOCK) { | |
| 56 | + | _ = file.Close() | |
| 57 | + | return nil, fmt.Errorf("lock named instance namespace: %w", err) | |
| 58 | + | } | |
| 59 | + | select { | |
| 60 | + | case <-ctx.Done(): | |
| 61 | + | _ = file.Close() | |
| 62 | + | return nil, fmt.Errorf("wait for named instance namespace: %w: %w", errInstanceNamespaceContended, ctx.Err()) | |
| 63 | + | case <-time.After(instanceLockRetry): | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | // DeliverOrRunWithInstanceRelay retries delivery while another process owns the | |
| 69 | + | // namespace, and only the process that acquires the lease starts the host. | |
| 70 | + | func DeliverOrRunWithInstanceRelay( | |
| 71 | + | ctx context.Context, | |
| 72 | + | profile runtimeprofile.Profile, | |
| 73 | + | relay port.BrowserLaunchRelay, | |
| 74 | + | name, url string, | |
| 75 | + | freshWindow bool, | |
| 76 | + | run func() int, | |
| 77 | + | ) (int, error) { | |
| 78 | + | deliver := relay.DeliverOpenExternalURL | |
| 79 | + | if freshWindow { | |
| 80 | + | deliver = relay.DeliverOpenFreshWindow | |
| 81 | + | } else if name != "" { | |
| 82 | + | instanceRelay, ok := relay.(port.BrowserInstanceLaunchRelay) | |
| 83 | + | if !ok { | |
| 84 | + | return 1, fmt.Errorf("named instance requires an instance launch relay") | |
| 85 | + | } | |
| 86 | + | deliver = func(ctx context.Context, url string) (bool, error) { | |
| 87 | + | return instanceRelay.DeliverOpenInstance(ctx, name, url) | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | // Normalize before any acquisition attempt so a cold default profile locks | |
| 91 | + | // the shared IPC runtime directory instead of retrying a permanent mkdir/open | |
| 92 | + | // failure on an empty path. | |
| 93 | + | if profile.InstanceRoot == "" { | |
| 94 | + | profile.InstanceRoot = profile.IPC.RuntimeDir | |
| 95 | + | } | |
| 96 | + | for { | |
| 97 | + | deliveryCtx, cancel := context.WithTimeout(ctx, instanceElectionSlice) | |
| 98 | + | delivered, err := deliver(deliveryCtx, url) | |
| 99 | + | cancel() | |
| 100 | + | if delivered { | |
| 101 | + | // An unconfirmed ACK still means ownership may have transferred; retrying | |
| 102 | + | // the same request would rely on relay request-ID deduplication. | |
| 103 | + | return 0, err | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | acquireCtx, cancel := context.WithTimeout(ctx, instanceElectionSlice) | |
| 107 | + | lease, acquireErr := acquireInstanceNamespace(acquireCtx, profile) | |
| 108 | + | cancel() | |
| 109 | + | if acquireErr == nil { | |
| 110 | + | return runWithInstanceLease(profile, relay, lease, run) | |
| 111 | + | } | |
| 112 | + | if !errors.Is(acquireErr, errInstanceNamespaceContended) { | |
| 113 | + | // Permanent failure (mkdir, open, unexpected flock error): retrying | |
| 114 | + | // cannot succeed, so surface it instead of spinning. | |
| 115 | + | return 1, fmt.Errorf("elect named instance host: %w", acquireErr) | |
| 116 | + | } | |
| 117 | + | if ctx.Err() != nil { | |
| 118 | + | return 1, fmt.Errorf("elect named instance host: %w", ctx.Err()) | |
| 119 | + | } | |
| 120 | + | } | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | // RunWithInstanceRelay owns the namespace lease through full GUI and engine teardown. | |
| 124 | + | func RunWithInstanceRelay(ctx context.Context, profile runtimeprofile.Profile, relay port.BrowserLaunchRelay, run func() int) (int, error) { | |
| 125 | + | lease, err := acquireInstanceNamespace(ctx, profile) | |
| 126 | + | if err != nil { | |
| 127 | + | return 1, err | |
| 128 | + | } | |
| 129 | + | return runWithInstanceLease(profile, relay, lease, run) | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | func runWithInstanceLease(profile runtimeprofile.Profile, relay port.BrowserLaunchRelay, lease io.Closer, run func() int) (int, error) { | |
| 133 | + | preparer, ok := relay.(port.BrowserLaunchRelayPreparer) | |
| 134 | + | if !ok { | |
| 135 | + | return 1, fmt.Errorf("named instance requires a preparable browser launch relay") | |
| 136 | + | } | |
| 137 | + | if profile.InstanceRoot == "" { | |
| 138 | + | profile.InstanceRoot = profile.IPC.RuntimeDir | |
| 139 | + | } | |
| 140 | + | defer func() { _ = lease.Close() }() | |
| 141 | + | if err := preparer.Prepare(lease); err != nil { | |
| 142 | + | return 1, fmt.Errorf( | |
| 143 | + | "prepare named instance IPC; remove %q only after verifying no Dumber instance is running: %w", | |
| 144 | + | profile.IPC.BrowserLaunchSocket, | |
| 145 | + | err, | |
| 146 | + | ) | |
| 147 | + | } | |
| 148 | + | return run(), nil | |
| 149 | + | } | |
| @@ -0,0 +1,149 @@ | |||||
| 1 | + | package bootstrap | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "fmt" | |||
| 7 | + | "io" | |||
| 8 | + | "os" | |||
| 9 | + | "path/filepath" | |||
| 10 | + | "time" | |||
| 11 | + | ||||
| 12 | + | "golang.org/x/sys/unix" | |||
| 13 | + | ||||
| 14 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 15 | + | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | const ( | |||
| 19 | + | instanceRuntimeDirPerm = 0o700 | |||
| 20 | + | instanceLockFilePerm = 0o600 | |||
| 21 | + | instanceLockRetry = 25 * time.Millisecond | |||
| 22 | + | instanceElectionSlice = 150 * time.Millisecond | |||
| 23 | + | ) | |||
| 24 | + | ||||
| 25 | + | // errInstanceNamespaceContended marks a lease acquisition that failed because | |||
| 26 | + | // another process holds the namespace lock. It is the only acquisition failure | |||
| 27 | + | // callers may retry; permanent failures (mkdir/open/flock) must surface at once. | |||
| 28 | + | var errInstanceNamespaceContended = errors.New("named instance namespace contended") | |||
| 29 | + | ||||
| 30 | + | type namespaceLease struct{ file *os.File } | |||
| 31 | + | ||||
| 32 | + | func (l *namespaceLease) Close() error { | |||
| 33 | + | if l == nil || l.file == nil { | |||
| 34 | + | return nil | |||
| 35 | + | } | |||
| 36 | + | err := unix.Flock(int(l.file.Fd()), unix.LOCK_UN) | |||
| 37 | + | closeErr := l.file.Close() | |||
| 38 | + | l.file = nil | |||
| 39 | + | return errors.Join(err, closeErr) | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | func acquireInstanceNamespace(ctx context.Context, profile runtimeprofile.Profile) (io.Closer, error) { | |||
| 43 | + | if err := os.MkdirAll(profile.InstanceRoot, instanceRuntimeDirPerm); err != nil { | |||
| 44 | + | return nil, fmt.Errorf("create named instance runtime directory: %w", err) | |||
| 45 | + | } | |||
| 46 | + | path := filepath.Join(profile.InstanceRoot, "namespace.lock") | |||
| 47 | + | file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, instanceLockFilePerm) | |||
| 48 | + | if err != nil { | |||
| 49 | + | return nil, fmt.Errorf("open named instance namespace lock: %w", err) | |||
| 50 | + | } | |||
| 51 | + | for { | |||
| 52 | + | if err = unix.Flock(int(file.Fd()), unix.LOCK_EX|unix.LOCK_NB); err == nil { | |||
| 53 | + | return &namespaceLease{file: file}, nil | |||
| 54 | + | } | |||
| 55 | + | if !errors.Is(err, unix.EWOULDBLOCK) { | |||
| 56 | + | _ = file.Close() | |||
| 57 | + | return nil, fmt.Errorf("lock named instance namespace: %w", err) | |||
| 58 | + | } | |||
| 59 | + | select { | |||
| 60 | + | case <-ctx.Done(): | |||
| 61 | + | _ = file.Close() | |||
| 62 | + | return nil, fmt.Errorf("wait for named instance namespace: %w: %w", errInstanceNamespaceContended, ctx.Err()) | |||
| 63 | + | case <-time.After(instanceLockRetry): | |||
| 64 | + | } | |||
| 65 | + | } | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | // DeliverOrRunWithInstanceRelay retries delivery while another process owns the | |||
| 69 | + | // namespace, and only the process that acquires the lease starts the host. | |||
| 70 | + | func DeliverOrRunWithInstanceRelay( | |||
| 71 | + | ctx context.Context, | |||
| 72 | + | profile runtimeprofile.Profile, | |||
| 73 | + | relay port.BrowserLaunchRelay, | |||
| 74 | + | name, url string, | |||
| 75 | + | freshWindow bool, | |||
| 76 | + | run func() int, | |||
| 77 | + | ) (int, error) { | |||
| 78 | + | deliver := relay.DeliverOpenExternalURL | |||
| 79 | + | if freshWindow { | |||
| 80 | + | deliver = relay.DeliverOpenFreshWindow | |||
| 81 | + | } else if name != "" { | |||
| 82 | + | instanceRelay, ok := relay.(port.BrowserInstanceLaunchRelay) | |||
| 83 | + | if !ok { | |||
| 84 | + | return 1, fmt.Errorf("named instance requires an instance launch relay") | |||
| 85 | + | } | |||
| 86 | + | deliver = func(ctx context.Context, url string) (bool, error) { | |||
| 87 | + | return instanceRelay.DeliverOpenInstance(ctx, name, url) | |||
| 88 | + | } | |||
| 89 | + | } | |||
| 90 | + | // Normalize before any acquisition attempt so a cold default profile locks | |||
| 91 | + | // the shared IPC runtime directory instead of retrying a permanent mkdir/open | |||
| 92 | + | // failure on an empty path. | |||
| 93 | + | if profile.InstanceRoot == "" { | |||
| 94 | + | profile.InstanceRoot = profile.IPC.RuntimeDir | |||
| 95 | + | } | |||
| 96 | + | for { | |||
| 97 | + | deliveryCtx, cancel := context.WithTimeout(ctx, instanceElectionSlice) | |||
| 98 | + | delivered, err := deliver(deliveryCtx, url) | |||
| 99 | + | cancel() | |||
| 100 | + | if delivered { | |||
| 101 | + | // An unconfirmed ACK still means ownership may have transferred; retrying | |||
| 102 | + | // the same request would rely on relay request-ID deduplication. | |||
| 103 | + | return 0, err | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | acquireCtx, cancel := context.WithTimeout(ctx, instanceElectionSlice) | |||
| 107 | + | lease, acquireErr := acquireInstanceNamespace(acquireCtx, profile) | |||
| 108 | + | cancel() | |||
| 109 | + | if acquireErr == nil { | |||
| 110 | + | return runWithInstanceLease(profile, relay, lease, run) | |||
| 111 | + | } | |||
| 112 | + | if !errors.Is(acquireErr, errInstanceNamespaceContended) { | |||
| 113 | + | // Permanent failure (mkdir, open, unexpected flock error): retrying | |||
| 114 | + | // cannot succeed, so surface it instead of spinning. | |||
| 115 | + | return 1, fmt.Errorf("elect named instance host: %w", acquireErr) | |||
| 116 | + | } | |||
| 117 | + | if ctx.Err() != nil { | |||
| 118 | + | return 1, fmt.Errorf("elect named instance host: %w", ctx.Err()) | |||
| 119 | + | } | |||
| 120 | + | } | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | // RunWithInstanceRelay owns the namespace lease through full GUI and engine teardown. | |||
| 124 | + | func RunWithInstanceRelay(ctx context.Context, profile runtimeprofile.Profile, relay port.BrowserLaunchRelay, run func() int) (int, error) { | |||
| 125 | + | lease, err := acquireInstanceNamespace(ctx, profile) | |||
| 126 | + | if err != nil { | |||
| 127 | + | return 1, err | |||
| 128 | + | } | |||
| 129 | + | return runWithInstanceLease(profile, relay, lease, run) | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | func runWithInstanceLease(profile runtimeprofile.Profile, relay port.BrowserLaunchRelay, lease io.Closer, run func() int) (int, error) { | |||
| 133 | + | preparer, ok := relay.(port.BrowserLaunchRelayPreparer) | |||
| 134 | + | if !ok { | |||
| 135 | + | return 1, fmt.Errorf("named instance requires a preparable browser launch relay") | |||
| 136 | + | } | |||
| 137 | + | if profile.InstanceRoot == "" { | |||
| 138 | + | profile.InstanceRoot = profile.IPC.RuntimeDir | |||
| 139 | + | } | |||
| 140 | + | defer func() { _ = lease.Close() }() | |||
| 141 | + | if err := preparer.Prepare(lease); err != nil { | |||
| 142 | + | return 1, fmt.Errorf( | |||
| 143 | + | "prepare named instance IPC; remove %q only after verifying no Dumber instance is running: %w", | |||
| 144 | + | profile.IPC.BrowserLaunchSocket, | |||
| 145 | + | err, | |||
| 146 | + | ) | |||
| 147 | + | } | |||
| 148 | + | return run(), nil | |||
| 149 | + | } | |||
A internal/bootstrap/instance_launch_test.go +188 -0
| @@ -0,0 +1,188 @@ | |||
| 1 | + | package bootstrap | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "io" | |
| 7 | + | "os" | |
| 8 | + | "path/filepath" | |
| 9 | + | "testing" | |
| 10 | + | "time" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/dumber/internal/application/port" | |
| 13 | + | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |
| 14 | + | "github.com/stretchr/testify/require" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | type instanceRelayStub struct { | |
| 18 | + | port.BrowserLaunchRelay | |
| 19 | + | err error | |
| 20 | + | prepared bool | |
| 21 | + | lease io.Closer | |
| 22 | + | deliverCalls int | |
| 23 | + | deliverAfter int | |
| 24 | + | deliveredKind string | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | func (r *instanceRelayStub) deliver(kind string) (bool, error) { | |
| 28 | + | r.deliveredKind = kind | |
| 29 | + | r.deliverCalls++ | |
| 30 | + | return r.deliverCalls >= r.deliverAfter, nil | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | func (r *instanceRelayStub) DeliverOpenExternalURL(context.Context, string) (bool, error) { | |
| 34 | + | return r.deliver("external") | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func (r *instanceRelayStub) DeliverOpenFreshWindow(context.Context, string) (bool, error) { | |
| 38 | + | return r.deliver("fresh") | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | func (r *instanceRelayStub) DeliverOpenInstance(context.Context, string, string) (bool, error) { | |
| 42 | + | return r.deliver("instance") | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | func (r *instanceRelayStub) Prepare(lease io.Closer) error { | |
| 46 | + | r.prepared = true | |
| 47 | + | r.lease = lease | |
| 48 | + | return r.err | |
| 49 | + | } | |
| 50 | + | func testInstanceProfile(t *testing.T) runtimeprofile.Profile { | |
| 51 | + | t.Helper() | |
| 52 | + | root := t.TempDir() | |
| 53 | + | return runtimeprofile.Profile{InstanceRoot: root, IPC: runtimeprofile.IPCPaths{BrowserLaunchSocket: filepath.Join(root, "browser-launch.sock")}} | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | // testColdDefaultProfile mirrors main's default-profile path before | |
| 57 | + | // normalization: InstanceRoot is empty while IPC.RuntimeDir is populated. | |
| 58 | + | func testColdDefaultProfile(t *testing.T) runtimeprofile.Profile { | |
| 59 | + | t.Helper() | |
| 60 | + | runtimeDir := t.TempDir() | |
| 61 | + | return runtimeprofile.Profile{IPC: runtimeprofile.IPCPaths{ | |
| 62 | + | RuntimeDir: runtimeDir, | |
| 63 | + | BrowserLaunchSocket: filepath.Join(runtimeDir, "browser-launch.sock"), | |
| 64 | + | }} | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | func TestRunWithInstanceRelayFailsBeforeEngineStartup(t *testing.T) { | |
| 68 | + | failure := errors.New("bind failed") | |
| 69 | + | relay := &instanceRelayStub{err: failure} | |
| 70 | + | code, err := RunWithInstanceRelay(context.Background(), testInstanceProfile(t), relay, func() int { t.Fatal("must not run"); return 0 }) | |
| 71 | + | require.Equal(t, 1, code) | |
| 72 | + | require.ErrorIs(t, err, failure) | |
| 73 | + | require.True(t, relay.prepared) | |
| 74 | + | } | |
| 75 | + | func TestDeliverOrRunWithInstanceRelayPreservesLaunchIntent(t *testing.T) { | |
| 76 | + | for _, test := range []struct { | |
| 77 | + | name string | |
| 78 | + | instance string | |
| 79 | + | freshWindow bool | |
| 80 | + | want string | |
| 81 | + | }{ | |
| 82 | + | {name: "default external", want: "external"}, | |
| 83 | + | {name: "named instance", instance: "work", want: "instance"}, | |
| 84 | + | {name: "fresh window", instance: "work", freshWindow: true, want: "fresh"}, | |
| 85 | + | {name: "fresh window without instance", freshWindow: true, want: "fresh"}, | |
| 86 | + | } { | |
| 87 | + | t.Run(test.name, func(t *testing.T) { | |
| 88 | + | profile := testInstanceProfile(t) | |
| 89 | + | owner, err := acquireInstanceNamespace(context.Background(), profile) | |
| 90 | + | require.NoError(t, err) | |
| 91 | + | defer owner.Close() | |
| 92 | + | relay := &instanceRelayStub{deliverAfter: 1} | |
| 93 | + | code, err := DeliverOrRunWithInstanceRelay(context.Background(), profile, relay, test.instance, "https://example.com", test.freshWindow, func() int { | |
| 94 | + | t.Fatal("delivered request must not start host") | |
| 95 | + | return 1 | |
| 96 | + | }) | |
| 97 | + | require.NoError(t, err) | |
| 98 | + | require.Zero(t, code) | |
| 99 | + | require.Equal(t, test.want, relay.deliveredKind) | |
| 100 | + | }) | |
| 101 | + | } | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | func TestDeliverOrRunWithInstanceRelayRetriesWhileHostStarts(t *testing.T) { | |
| 105 | + | profile := testInstanceProfile(t) | |
| 106 | + | owner, err := acquireInstanceNamespace(context.Background(), profile) | |
| 107 | + | require.NoError(t, err) | |
| 108 | + | defer owner.Close() | |
| 109 | + | relay := &instanceRelayStub{deliverAfter: 3} | |
| 110 | + | ||
| 111 | + | ctx, cancel := context.WithTimeout(context.Background(), time.Second) | |
| 112 | + | defer cancel() | |
| 113 | + | code, err := DeliverOrRunWithInstanceRelay(ctx, profile, relay, "work", "https://example.com", false, func() int { | |
| 114 | + | t.Fatal("loser must not start host") | |
| 115 | + | return 1 | |
| 116 | + | }) | |
| 117 | + | require.NoError(t, err) | |
| 118 | + | require.Zero(t, code) | |
| 119 | + | require.Equal(t, 3, relay.deliverCalls) | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | // With a cold default profile no host is running, so the first process must | |
| 123 | + | // normalize InstanceRoot to IPC.RuntimeDir, acquire the lease, prepare the | |
| 124 | + | // relay, and start the host instead of spinning on an empty runtime path. The | |
| 125 | + | // real default cold start carries an empty instance name (main passes | |
| 126 | + | // args.Instance verbatim), so delivery must use the external-URL path. | |
| 127 | + | func TestDeliverOrRunWithInstanceRelayStartsHostForColdDefaultProfile(t *testing.T) { | |
| 128 | + | profile := testColdDefaultProfile(t) | |
| 129 | + | relay := &instanceRelayStub{deliverAfter: 1 << 30} | |
| 130 | + | ran := false | |
| 131 | + | ||
| 132 | + | ctx, cancel := context.WithTimeout(context.Background(), time.Second) | |
| 133 | + | defer cancel() | |
| 134 | + | code, err := DeliverOrRunWithInstanceRelay(ctx, profile, relay, "", "https://example.com", false, func() int { | |
| 135 | + | ran = true | |
| 136 | + | return 0 | |
| 137 | + | }) | |
| 138 | + | require.NoError(t, err) | |
| 139 | + | require.Zero(t, code) | |
| 140 | + | require.True(t, ran, "cold default profile must start the host") | |
| 141 | + | require.True(t, relay.prepared, "host must prepare the relay") | |
| 142 | + | require.Equal(t, 1, relay.deliverCalls) | |
| 143 | + | require.Equal(t, "external", relay.deliveredKind, "empty instance name must not use the instance relay") | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | // A permanent acquisition failure (for example a runtime root that cannot be | |
| 147 | + | // created) must return immediately rather than retry until the context ends. | |
| 148 | + | func TestDeliverOrRunWithInstanceRelayReturnsPermanentAcquireError(t *testing.T) { | |
| 149 | + | root := filepath.Join(t.TempDir(), "not-a-dir") | |
| 150 | + | require.NoError(t, os.WriteFile(root, []byte("file"), 0o600)) | |
| 151 | + | profile := runtimeprofile.Profile{ | |
| 152 | + | InstanceRoot: root, | |
| 153 | + | IPC: runtimeprofile.IPCPaths{BrowserLaunchSocket: filepath.Join(root, "browser-launch.sock")}, | |
| 154 | + | } | |
| 155 | + | relay := &instanceRelayStub{deliverAfter: 1 << 30} | |
| 156 | + | ||
| 157 | + | ctx, cancel := context.WithTimeout(context.Background(), time.Second) | |
| 158 | + | defer cancel() | |
| 159 | + | start := time.Now() | |
| 160 | + | code, err := DeliverOrRunWithInstanceRelay(ctx, profile, relay, "default", "https://example.com", false, func() int { | |
| 161 | + | t.Fatal("permanent failure must not start the host") | |
| 162 | + | return 1 | |
| 163 | + | }) | |
| 164 | + | elapsed := time.Since(start) | |
| 165 | + | require.Error(t, err) | |
| 166 | + | require.Equal(t, 1, code) | |
| 167 | + | require.NotErrorIs(t, err, errInstanceNamespaceContended) | |
| 168 | + | require.ErrorContains(t, err, "create named instance runtime directory") | |
| 169 | + | require.Less(t, elapsed, instanceElectionSlice, "permanent failure must not retry") | |
| 170 | + | require.Equal(t, 1, relay.deliverCalls, "delivery is attempted once before election") | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | func TestRunWithInstanceRelayOwnsLeaseThroughGUILifetime(t *testing.T) { | |
| 174 | + | profile := testInstanceProfile(t) | |
| 175 | + | relay := &instanceRelayStub{} | |
| 176 | + | code, err := RunWithInstanceRelay(context.Background(), profile, relay, func() int { | |
| 177 | + | ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) | |
| 178 | + | defer cancel() | |
| 179 | + | _, competitorErr := acquireInstanceNamespace(ctx, profile) | |
| 180 | + | require.Error(t, competitorErr) | |
| 181 | + | return 0 | |
| 182 | + | }) | |
| 183 | + | require.NoError(t, err) | |
| 184 | + | require.Zero(t, code) | |
| 185 | + | lease, err := acquireInstanceNamespace(context.Background(), profile) | |
| 186 | + | require.NoError(t, err) | |
| 187 | + | require.NoError(t, lease.Close()) | |
| 188 | + | } | |
| @@ -0,0 +1,188 @@ | |||||
| 1 | + | package bootstrap | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "io" | |||
| 7 | + | "os" | |||
| 8 | + | "path/filepath" | |||
| 9 | + | "testing" | |||
| 10 | + | "time" | |||
| 11 | + | ||||
| 12 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 13 | + | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |||
| 14 | + | "github.com/stretchr/testify/require" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | type instanceRelayStub struct { | |||
| 18 | + | port.BrowserLaunchRelay | |||
| 19 | + | err error | |||
| 20 | + | prepared bool | |||
| 21 | + | lease io.Closer | |||
| 22 | + | deliverCalls int | |||
| 23 | + | deliverAfter int | |||
| 24 | + | deliveredKind string | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | func (r *instanceRelayStub) deliver(kind string) (bool, error) { | |||
| 28 | + | r.deliveredKind = kind | |||
| 29 | + | r.deliverCalls++ | |||
| 30 | + | return r.deliverCalls >= r.deliverAfter, nil | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | func (r *instanceRelayStub) DeliverOpenExternalURL(context.Context, string) (bool, error) { | |||
| 34 | + | return r.deliver("external") | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | func (r *instanceRelayStub) DeliverOpenFreshWindow(context.Context, string) (bool, error) { | |||
| 38 | + | return r.deliver("fresh") | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | func (r *instanceRelayStub) DeliverOpenInstance(context.Context, string, string) (bool, error) { | |||
| 42 | + | return r.deliver("instance") | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | func (r *instanceRelayStub) Prepare(lease io.Closer) error { | |||
| 46 | + | r.prepared = true | |||
| 47 | + | r.lease = lease | |||
| 48 | + | return r.err | |||
| 49 | + | } | |||
| 50 | + | func testInstanceProfile(t *testing.T) runtimeprofile.Profile { | |||
| 51 | + | t.Helper() | |||
| 52 | + | root := t.TempDir() | |||
| 53 | + | return runtimeprofile.Profile{InstanceRoot: root, IPC: runtimeprofile.IPCPaths{BrowserLaunchSocket: filepath.Join(root, "browser-launch.sock")}} | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | // testColdDefaultProfile mirrors main's default-profile path before | |||
| 57 | + | // normalization: InstanceRoot is empty while IPC.RuntimeDir is populated. | |||
| 58 | + | func testColdDefaultProfile(t *testing.T) runtimeprofile.Profile { | |||
| 59 | + | t.Helper() | |||
| 60 | + | runtimeDir := t.TempDir() | |||
| 61 | + | return runtimeprofile.Profile{IPC: runtimeprofile.IPCPaths{ | |||
| 62 | + | RuntimeDir: runtimeDir, | |||
| 63 | + | BrowserLaunchSocket: filepath.Join(runtimeDir, "browser-launch.sock"), | |||
| 64 | + | }} | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | func TestRunWithInstanceRelayFailsBeforeEngineStartup(t *testing.T) { | |||
| 68 | + | failure := errors.New("bind failed") | |||
| 69 | + | relay := &instanceRelayStub{err: failure} | |||
| 70 | + | code, err := RunWithInstanceRelay(context.Background(), testInstanceProfile(t), relay, func() int { t.Fatal("must not run"); return 0 }) | |||
| 71 | + | require.Equal(t, 1, code) | |||
| 72 | + | require.ErrorIs(t, err, failure) | |||
| 73 | + | require.True(t, relay.prepared) | |||
| 74 | + | } | |||
| 75 | + | func TestDeliverOrRunWithInstanceRelayPreservesLaunchIntent(t *testing.T) { | |||
| 76 | + | for _, test := range []struct { | |||
| 77 | + | name string | |||
| 78 | + | instance string | |||
| 79 | + | freshWindow bool | |||
| 80 | + | want string | |||
| 81 | + | }{ | |||
| 82 | + | {name: "default external", want: "external"}, | |||
| 83 | + | {name: "named instance", instance: "work", want: "instance"}, | |||
| 84 | + | {name: "fresh window", instance: "work", freshWindow: true, want: "fresh"}, | |||
| 85 | + | {name: "fresh window without instance", freshWindow: true, want: "fresh"}, | |||
| 86 | + | } { | |||
| 87 | + | t.Run(test.name, func(t *testing.T) { | |||
| 88 | + | profile := testInstanceProfile(t) | |||
| 89 | + | owner, err := acquireInstanceNamespace(context.Background(), profile) | |||
| 90 | + | require.NoError(t, err) | |||
| 91 | + | defer owner.Close() | |||
| 92 | + | relay := &instanceRelayStub{deliverAfter: 1} | |||
| 93 | + | code, err := DeliverOrRunWithInstanceRelay(context.Background(), profile, relay, test.instance, "https://example.com", test.freshWindow, func() int { | |||
| 94 | + | t.Fatal("delivered request must not start host") | |||
| 95 | + | return 1 | |||
| 96 | + | }) | |||
| 97 | + | require.NoError(t, err) | |||
| 98 | + | require.Zero(t, code) | |||
| 99 | + | require.Equal(t, test.want, relay.deliveredKind) | |||
| 100 | + | }) | |||
| 101 | + | } | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | func TestDeliverOrRunWithInstanceRelayRetriesWhileHostStarts(t *testing.T) { | |||
| 105 | + | profile := testInstanceProfile(t) | |||
| 106 | + | owner, err := acquireInstanceNamespace(context.Background(), profile) | |||
| 107 | + | require.NoError(t, err) | |||
| 108 | + | defer owner.Close() | |||
| 109 | + | relay := &instanceRelayStub{deliverAfter: 3} | |||
| 110 | + | ||||
| 111 | + | ctx, cancel := context.WithTimeout(context.Background(), time.Second) | |||
| 112 | + | defer cancel() | |||
| 113 | + | code, err := DeliverOrRunWithInstanceRelay(ctx, profile, relay, "work", "https://example.com", false, func() int { | |||
| 114 | + | t.Fatal("loser must not start host") | |||
| 115 | + | return 1 | |||
| 116 | + | }) | |||
| 117 | + | require.NoError(t, err) | |||
| 118 | + | require.Zero(t, code) | |||
| 119 | + | require.Equal(t, 3, relay.deliverCalls) | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | // With a cold default profile no host is running, so the first process must | |||
| 123 | + | // normalize InstanceRoot to IPC.RuntimeDir, acquire the lease, prepare the | |||
| 124 | + | // relay, and start the host instead of spinning on an empty runtime path. The | |||
| 125 | + | // real default cold start carries an empty instance name (main passes | |||
| 126 | + | // args.Instance verbatim), so delivery must use the external-URL path. | |||
| 127 | + | func TestDeliverOrRunWithInstanceRelayStartsHostForColdDefaultProfile(t *testing.T) { | |||
| 128 | + | profile := testColdDefaultProfile(t) | |||
| 129 | + | relay := &instanceRelayStub{deliverAfter: 1 << 30} | |||
| 130 | + | ran := false | |||
| 131 | + | ||||
| 132 | + | ctx, cancel := context.WithTimeout(context.Background(), time.Second) | |||
| 133 | + | defer cancel() | |||
| 134 | + | code, err := DeliverOrRunWithInstanceRelay(ctx, profile, relay, "", "https://example.com", false, func() int { | |||
| 135 | + | ran = true | |||
| 136 | + | return 0 | |||
| 137 | + | }) | |||
| 138 | + | require.NoError(t, err) | |||
| 139 | + | require.Zero(t, code) | |||
| 140 | + | require.True(t, ran, "cold default profile must start the host") | |||
| 141 | + | require.True(t, relay.prepared, "host must prepare the relay") | |||
| 142 | + | require.Equal(t, 1, relay.deliverCalls) | |||
| 143 | + | require.Equal(t, "external", relay.deliveredKind, "empty instance name must not use the instance relay") | |||
| 144 | + | } | |||
| 145 | + | ||||
| 146 | + | // A permanent acquisition failure (for example a runtime root that cannot be | |||
| 147 | + | // created) must return immediately rather than retry until the context ends. | |||
| 148 | + | func TestDeliverOrRunWithInstanceRelayReturnsPermanentAcquireError(t *testing.T) { | |||
| 149 | + | root := filepath.Join(t.TempDir(), "not-a-dir") | |||
| 150 | + | require.NoError(t, os.WriteFile(root, []byte("file"), 0o600)) | |||
| 151 | + | profile := runtimeprofile.Profile{ | |||
| 152 | + | InstanceRoot: root, | |||
| 153 | + | IPC: runtimeprofile.IPCPaths{BrowserLaunchSocket: filepath.Join(root, "browser-launch.sock")}, | |||
| 154 | + | } | |||
| 155 | + | relay := &instanceRelayStub{deliverAfter: 1 << 30} | |||
| 156 | + | ||||
| 157 | + | ctx, cancel := context.WithTimeout(context.Background(), time.Second) | |||
| 158 | + | defer cancel() | |||
| 159 | + | start := time.Now() | |||
| 160 | + | code, err := DeliverOrRunWithInstanceRelay(ctx, profile, relay, "default", "https://example.com", false, func() int { | |||
| 161 | + | t.Fatal("permanent failure must not start the host") | |||
| 162 | + | return 1 | |||
| 163 | + | }) | |||
| 164 | + | elapsed := time.Since(start) | |||
| 165 | + | require.Error(t, err) | |||
| 166 | + | require.Equal(t, 1, code) | |||
| 167 | + | require.NotErrorIs(t, err, errInstanceNamespaceContended) | |||
| 168 | + | require.ErrorContains(t, err, "create named instance runtime directory") | |||
| 169 | + | require.Less(t, elapsed, instanceElectionSlice, "permanent failure must not retry") | |||
| 170 | + | require.Equal(t, 1, relay.deliverCalls, "delivery is attempted once before election") | |||
| 171 | + | } | |||
| 172 | + | ||||
| 173 | + | func TestRunWithInstanceRelayOwnsLeaseThroughGUILifetime(t *testing.T) { | |||
| 174 | + | profile := testInstanceProfile(t) | |||
| 175 | + | relay := &instanceRelayStub{} | |||
| 176 | + | code, err := RunWithInstanceRelay(context.Background(), profile, relay, func() int { | |||
| 177 | + | ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) | |||
| 178 | + | defer cancel() | |||
| 179 | + | _, competitorErr := acquireInstanceNamespace(ctx, profile) | |||
| 180 | + | require.Error(t, competitorErr) | |||
| 181 | + | return 0 | |||
| 182 | + | }) | |||
| 183 | + | require.NoError(t, err) | |||
| 184 | + | require.Zero(t, code) | |||
| 185 | + | lease, err := acquireInstanceNamespace(context.Background(), profile) | |||
| 186 | + | require.NoError(t, err) | |||
| 187 | + | require.NoError(t, lease.Close()) | |||
| 188 | + | } | |||
M internal/bootstrap/session_spawner.go +12 -0
| @@ -2,8 +2,11 @@ package bootstrap | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "strings" | |
| 5 | 7 | ||
| 6 | 8 | "github.com/bnema/dumber/internal/application/port" | |
| 9 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 7 | 10 | "github.com/bnema/dumber/internal/infrastructure/cef" | |
| 8 | 11 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 9 | 12 | "github.com/bnema/dumber/internal/infrastructure/desktop" | |
| @@ -13,9 +16,18 @@ import ( | |||
| 13 | 16 | // NewSessionSpawner wires the session spawner with any engine-specific launch | |
| 14 | 17 | // environment required by the current browser backend. | |
| 15 | 18 | func NewSessionSpawner(ctx context.Context, profile runtimeprofile.Profile) port.SessionSpawner { | |
| 19 | + | if strings.HasPrefix(profile.InstanceAppIDSuffix, "ephemeral-") { | |
| 20 | + | return ephemeralSessionSpawner{} | |
| 21 | + | } | |
| 16 | 22 | var spawnEnv port.SessionSpawnEnvironment | |
| 17 | 23 | if profile.Engine == config.EngineTypeCEF { | |
| 18 | 24 | spawnEnv = cef.NewSessionSpawnEnvironment(profile.CEFUserDataDir()) | |
| 19 | 25 | } | |
| 20 | 26 | return desktop.NewSessionSpawner(ctx, spawnEnv) | |
| 21 | 27 | } | |
| 28 | + | ||
| 29 | + | type ephemeralSessionSpawner struct{} | |
| 30 | + | ||
| 31 | + | func (ephemeralSessionSpawner) SpawnWithSession(entity.SessionID) error { | |
| 32 | + | return fmt.Errorf("detached session restore is unavailable with --ephemeral; keep the session in the current window or use --profile") | |
| 33 | + | } | |
| @@ -2,8 +2,11 @@ package bootstrap | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "fmt" | |||
| 6 | + | "strings" | |||
| 5 | 7 | ||||
| 6 | "github.com/bnema/dumber/internal/application/port" | 8 | "github.com/bnema/dumber/internal/application/port" | ||
| 9 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 7 | "github.com/bnema/dumber/internal/infrastructure/cef" | 10 | "github.com/bnema/dumber/internal/infrastructure/cef" | ||
| 8 | "github.com/bnema/dumber/internal/infrastructure/config" | 11 | "github.com/bnema/dumber/internal/infrastructure/config" | ||
| 9 | "github.com/bnema/dumber/internal/infrastructure/desktop" | 12 | "github.com/bnema/dumber/internal/infrastructure/desktop" | ||
| @@ -13,9 +16,18 @@ import ( | |||||
| 13 | // NewSessionSpawner wires the session spawner with any engine-specific launch | 16 | // NewSessionSpawner wires the session spawner with any engine-specific launch | ||
| 14 | // environment required by the current browser backend. | 17 | // environment required by the current browser backend. | ||
| 15 | func NewSessionSpawner(ctx context.Context, profile runtimeprofile.Profile) port.SessionSpawner { | 18 | func NewSessionSpawner(ctx context.Context, profile runtimeprofile.Profile) port.SessionSpawner { | ||
| 19 | + | if strings.HasPrefix(profile.InstanceAppIDSuffix, "ephemeral-") { | |||
| 20 | + | return ephemeralSessionSpawner{} | |||
| 21 | + | } | |||
| 16 | var spawnEnv port.SessionSpawnEnvironment | 22 | var spawnEnv port.SessionSpawnEnvironment | ||
| 17 | if profile.Engine == config.EngineTypeCEF { | 23 | if profile.Engine == config.EngineTypeCEF { | ||
| 18 | spawnEnv = cef.NewSessionSpawnEnvironment(profile.CEFUserDataDir()) | 24 | spawnEnv = cef.NewSessionSpawnEnvironment(profile.CEFUserDataDir()) | ||
| 19 | } | 25 | } | ||
| 20 | return desktop.NewSessionSpawner(ctx, spawnEnv) | 26 | return desktop.NewSessionSpawner(ctx, spawnEnv) | ||
| 21 | } | 27 | } | ||
| 28 | + | ||||
| 29 | + | type ephemeralSessionSpawner struct{} | |||
| 30 | + | ||||
| 31 | + | func (ephemeralSessionSpawner) SpawnWithSession(entity.SessionID) error { | |||
| 32 | + | return fmt.Errorf("detached session restore is unavailable with --ephemeral; keep the session in the current window or use --profile") | |||
| 33 | + | } | |||
A internal/bootstrap/session_spawner_test.go +21 -0
| @@ -0,0 +1,21 @@ | |||
| 1 | + | package bootstrap | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 8 | + | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestNewSessionSpawnerRejectsDetachedRestoreForEphemeralProfile(t *testing.T) { | |
| 13 | + | spawner := NewSessionSpawner(context.Background(), runtimeprofile.Profile{ | |
| 14 | + | Engine: "cef", | |
| 15 | + | InstanceAppIDSuffix: "ephemeral-test", | |
| 16 | + | }) | |
| 17 | + | ||
| 18 | + | err := spawner.SpawnWithSession(entity.SessionID("saved")) | |
| 19 | + | require.ErrorContains(t, err, "unavailable with --ephemeral") | |
| 20 | + | require.ErrorContains(t, err, "use --profile") | |
| 21 | + | } | |
| @@ -0,0 +1,21 @@ | |||||
| 1 | + | package bootstrap | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 8 | + | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |||
| 9 | + | "github.com/stretchr/testify/require" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | func TestNewSessionSpawnerRejectsDetachedRestoreForEphemeralProfile(t *testing.T) { | |||
| 13 | + | spawner := NewSessionSpawner(context.Background(), runtimeprofile.Profile{ | |||
| 14 | + | Engine: "cef", | |||
| 15 | + | InstanceAppIDSuffix: "ephemeral-test", | |||
| 16 | + | }) | |||
| 17 | + | ||||
| 18 | + | err := spawner.SpawnWithSession(entity.SessionID("saved")) | |||
| 19 | + | require.ErrorContains(t, err, "unavailable with --ephemeral") | |||
| 20 | + | require.ErrorContains(t, err, "use --profile") | |||
| 21 | + | } | |||
A internal/cli/cmd/browse_args.go +55 -0
| @@ -0,0 +1,55 @@ | |||
| 1 | + | package cmd | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "io" | |
| 5 | + | "strings" | |
| 6 | + | ||
| 7 | + | "github.com/spf13/pflag" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | type BrowseArgs struct { | |
| 11 | + | URL string | |
| 12 | + | Instance string | |
| 13 | + | Profile string | |
| 14 | + | Ephemeral bool | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | func browseFlags() *pflag.FlagSet { | |
| 18 | + | flags := pflag.NewFlagSet("browse", pflag.ContinueOnError) | |
| 19 | + | flags.String("instance", "", "Open a dedicated window in the named logical instance") | |
| 20 | + | flags.String("profile", "", "Use an isolated persistent CEF profile") | |
| 21 | + | flags.Bool("ephemeral", false, "Use a fresh temporary CEF profile for this process") | |
| 22 | + | return flags | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | // ParseBrowseLaunchArgs recognizes GUI launches. Help and invalid input are left to Cobra. | |
| 26 | + | func ParseBrowseLaunchArgs(args []string) (BrowseArgs, bool) { | |
| 27 | + | var result BrowseArgs | |
| 28 | + | flags := browseFlags() | |
| 29 | + | flags.SetOutput(io.Discard) | |
| 30 | + | if err := flags.Parse(args); err != nil || flags.NArg() > 1 { | |
| 31 | + | return result, false | |
| 32 | + | } | |
| 33 | + | result.Instance, _ = flags.GetString("instance") | |
| 34 | + | result.Profile, _ = flags.GetString("profile") | |
| 35 | + | result.Ephemeral, _ = flags.GetBool("ephemeral") | |
| 36 | + | if flags.Changed("instance") && (result.Instance == "" || strings.HasPrefix(result.Instance, "-")) { | |
| 37 | + | return BrowseArgs{}, false | |
| 38 | + | } | |
| 39 | + | if flags.Changed("profile") && (result.Profile == "" || strings.HasPrefix(result.Profile, "-")) { | |
| 40 | + | return BrowseArgs{}, false | |
| 41 | + | } | |
| 42 | + | if result.Profile != "" && result.Ephemeral { | |
| 43 | + | return BrowseArgs{}, false | |
| 44 | + | } | |
| 45 | + | if flags.NArg() == 1 { | |
| 46 | + | result.URL = flags.Arg(0) | |
| 47 | + | } | |
| 48 | + | return result, true | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // ParseBrowseArgs is the compatibility projection used by existing early launch parsing. | |
| 52 | + | func ParseBrowseArgs(args []string) (url, instance string, ok bool) { | |
| 53 | + | parsed, ok := ParseBrowseLaunchArgs(args) | |
| 54 | + | return parsed.URL, parsed.Instance, ok | |
| 55 | + | } | |
| @@ -0,0 +1,55 @@ | |||||
| 1 | + | package cmd | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "io" | |||
| 5 | + | "strings" | |||
| 6 | + | ||||
| 7 | + | "github.com/spf13/pflag" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | type BrowseArgs struct { | |||
| 11 | + | URL string | |||
| 12 | + | Instance string | |||
| 13 | + | Profile string | |||
| 14 | + | Ephemeral bool | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | func browseFlags() *pflag.FlagSet { | |||
| 18 | + | flags := pflag.NewFlagSet("browse", pflag.ContinueOnError) | |||
| 19 | + | flags.String("instance", "", "Open a dedicated window in the named logical instance") | |||
| 20 | + | flags.String("profile", "", "Use an isolated persistent CEF profile") | |||
| 21 | + | flags.Bool("ephemeral", false, "Use a fresh temporary CEF profile for this process") | |||
| 22 | + | return flags | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | // ParseBrowseLaunchArgs recognizes GUI launches. Help and invalid input are left to Cobra. | |||
| 26 | + | func ParseBrowseLaunchArgs(args []string) (BrowseArgs, bool) { | |||
| 27 | + | var result BrowseArgs | |||
| 28 | + | flags := browseFlags() | |||
| 29 | + | flags.SetOutput(io.Discard) | |||
| 30 | + | if err := flags.Parse(args); err != nil || flags.NArg() > 1 { | |||
| 31 | + | return result, false | |||
| 32 | + | } | |||
| 33 | + | result.Instance, _ = flags.GetString("instance") | |||
| 34 | + | result.Profile, _ = flags.GetString("profile") | |||
| 35 | + | result.Ephemeral, _ = flags.GetBool("ephemeral") | |||
| 36 | + | if flags.Changed("instance") && (result.Instance == "" || strings.HasPrefix(result.Instance, "-")) { | |||
| 37 | + | return BrowseArgs{}, false | |||
| 38 | + | } | |||
| 39 | + | if flags.Changed("profile") && (result.Profile == "" || strings.HasPrefix(result.Profile, "-")) { | |||
| 40 | + | return BrowseArgs{}, false | |||
| 41 | + | } | |||
| 42 | + | if result.Profile != "" && result.Ephemeral { | |||
| 43 | + | return BrowseArgs{}, false | |||
| 44 | + | } | |||
| 45 | + | if flags.NArg() == 1 { | |||
| 46 | + | result.URL = flags.Arg(0) | |||
| 47 | + | } | |||
| 48 | + | return result, true | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | // ParseBrowseArgs is the compatibility projection used by existing early launch parsing. | |||
| 52 | + | func ParseBrowseArgs(args []string) (url, instance string, ok bool) { | |||
| 53 | + | parsed, ok := ParseBrowseLaunchArgs(args) | |||
| 54 | + | return parsed.URL, parsed.Instance, ok | |||
| 55 | + | } | |||
A internal/cli/cmd/browse_args_test.go +94 -0
| @@ -0,0 +1,94 @@ | |||
| 1 | + | package cmd | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "io" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/spf13/cobra" | |
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestParseBrowseArgs(t *testing.T) { | |
| 12 | + | for _, tt := range []struct { | |
| 13 | + | name, url, instance string | |
| 14 | + | args []string | |
| 15 | + | ok bool | |
| 16 | + | }{ | |
| 17 | + | {"normal", "", "", nil, true}, | |
| 18 | + | {"named", "", "work", []string{"--instance", "work"}, true}, | |
| 19 | + | {"url after", "https://example.com", "work", []string{"--instance", "work", "https://example.com"}, true}, | |
| 20 | + | {"url before", "https://example.com", "work", []string{"https://example.com", "--instance", "work"}, true}, | |
| 21 | + | {"missing name", "", "", []string{"--instance"}, false}, | |
| 22 | + | {"empty equals", "", "", []string{"--instance="}, false}, | |
| 23 | + | {"empty separate", "", "", []string{"--instance", ""}, false}, | |
| 24 | + | {"flag-like name", "", "", []string{"--instance", "--ephemeral"}, false}, | |
| 25 | + | {"flag-like profile", "", "", []string{"--profile", "--ephemeral"}, false}, | |
| 26 | + | {"help", "", "", []string{"--help"}, false}, | |
| 27 | + | {"unknown", "", "", []string{"--unknown"}, false}, | |
| 28 | + | {"extra", "", "", []string{"--instance", "work", "a", "b"}, false}, | |
| 29 | + | } { | |
| 30 | + | t.Run(tt.name, func(t *testing.T) { | |
| 31 | + | url, instance, ok := ParseBrowseArgs(tt.args) | |
| 32 | + | require.Equal(t, tt.url, url) | |
| 33 | + | require.Equal(t, tt.instance, instance) | |
| 34 | + | require.Equal(t, tt.ok, ok) | |
| 35 | + | }) | |
| 36 | + | } | |
| 37 | + | require.NotNil(t, browseCmd.Flags().Lookup("instance")) | |
| 38 | + | require.NotNil(t, browseCmd.Flags().Lookup("profile")) | |
| 39 | + | require.NotNil(t, browseCmd.Flags().Lookup("ephemeral")) | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func TestParseBrowseLaunchArgsProfiles(t *testing.T) { | |
| 43 | + | parsed, ok := ParseBrowseLaunchArgs([]string{"--instance", "scratch", "https://example.com"}) | |
| 44 | + | require.True(t, ok) | |
| 45 | + | require.Equal(t, BrowseArgs{URL: "https://example.com", Instance: "scratch"}, parsed) | |
| 46 | + | ||
| 47 | + | parsed, ok = ParseBrowseLaunchArgs([]string{"--instance", "work", "--profile", "work"}) | |
| 48 | + | require.True(t, ok) | |
| 49 | + | require.Equal(t, BrowseArgs{Instance: "work", Profile: "work"}, parsed) | |
| 50 | + | ||
| 51 | + | parsed, ok = ParseBrowseLaunchArgs([]string{"--instance", "private", "--ephemeral"}) | |
| 52 | + | require.True(t, ok) | |
| 53 | + | require.Equal(t, BrowseArgs{Instance: "private", Ephemeral: true}, parsed) | |
| 54 | + | ||
| 55 | + | _, ok = ParseBrowseLaunchArgs([]string{"--profile", "work", "--ephemeral"}) | |
| 56 | + | require.False(t, ok) | |
| 57 | + | ||
| 58 | + | _, ok = ParseBrowseLaunchArgs([]string{"--profile", "--ephemeral"}) | |
| 59 | + | require.False(t, ok, "a flag-like profile must not be treated as a profile name") | |
| 60 | + | ||
| 61 | + | _, ok = ParseBrowseLaunchArgs([]string{"--instance", "--ephemeral"}) | |
| 62 | + | require.False(t, ok, "a flag-like instance must not be treated as an instance name") | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | // browsePreRunEError exercises the real browse PreRunE through Cobra flag | |
| 66 | + | // parsing, which is the fallback path main takes when early launch parsing | |
| 67 | + | // rejects the arguments. | |
| 68 | + | func browsePreRunEError(t *testing.T, args []string) error { | |
| 69 | + | t.Helper() | |
| 70 | + | cmd := &cobra.Command{ | |
| 71 | + | Use: "browse", | |
| 72 | + | Args: cobra.MaximumNArgs(1), | |
| 73 | + | SilenceErrors: true, | |
| 74 | + | SilenceUsage: true, | |
| 75 | + | PreRunE: browseCmd.PreRunE, | |
| 76 | + | Run: func(*cobra.Command, []string) {}, | |
| 77 | + | } | |
| 78 | + | cmd.Flags().AddFlagSet(browseFlags()) | |
| 79 | + | cmd.SetOut(io.Discard) | |
| 80 | + | cmd.SetErr(io.Discard) | |
| 81 | + | cmd.SetArgs(args) | |
| 82 | + | return cmd.Execute() | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | func TestBrowseCommandPreRunERejectsFlagLikeValues(t *testing.T) { | |
| 86 | + | require.Error(t, browsePreRunEError(t, []string{"--instance", "--ephemeral"})) | |
| 87 | + | require.Error(t, browsePreRunEError(t, []string{"--profile", "--ephemeral"})) | |
| 88 | + | require.Error(t, browsePreRunEError(t, []string{"--instance", ""})) | |
| 89 | + | require.Error(t, browsePreRunEError(t, []string{"--profile", ""})) | |
| 90 | + | ||
| 91 | + | require.NoError(t, browsePreRunEError(t, []string{"--instance", "work"})) | |
| 92 | + | require.NoError(t, browsePreRunEError(t, []string{"--instance", "private", "--ephemeral"})) | |
| 93 | + | require.NoError(t, browsePreRunEError(t, []string{"--profile", "work"})) | |
| 94 | + | } | |
| @@ -0,0 +1,94 @@ | |||||
| 1 | + | package cmd | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "io" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/spf13/cobra" | |||
| 8 | + | "github.com/stretchr/testify/require" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestParseBrowseArgs(t *testing.T) { | |||
| 12 | + | for _, tt := range []struct { | |||
| 13 | + | name, url, instance string | |||
| 14 | + | args []string | |||
| 15 | + | ok bool | |||
| 16 | + | }{ | |||
| 17 | + | {"normal", "", "", nil, true}, | |||
| 18 | + | {"named", "", "work", []string{"--instance", "work"}, true}, | |||
| 19 | + | {"url after", "https://example.com", "work", []string{"--instance", "work", "https://example.com"}, true}, | |||
| 20 | + | {"url before", "https://example.com", "work", []string{"https://example.com", "--instance", "work"}, true}, | |||
| 21 | + | {"missing name", "", "", []string{"--instance"}, false}, | |||
| 22 | + | {"empty equals", "", "", []string{"--instance="}, false}, | |||
| 23 | + | {"empty separate", "", "", []string{"--instance", ""}, false}, | |||
| 24 | + | {"flag-like name", "", "", []string{"--instance", "--ephemeral"}, false}, | |||
| 25 | + | {"flag-like profile", "", "", []string{"--profile", "--ephemeral"}, false}, | |||
| 26 | + | {"help", "", "", []string{"--help"}, false}, | |||
| 27 | + | {"unknown", "", "", []string{"--unknown"}, false}, | |||
| 28 | + | {"extra", "", "", []string{"--instance", "work", "a", "b"}, false}, | |||
| 29 | + | } { | |||
| 30 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 31 | + | url, instance, ok := ParseBrowseArgs(tt.args) | |||
| 32 | + | require.Equal(t, tt.url, url) | |||
| 33 | + | require.Equal(t, tt.instance, instance) | |||
| 34 | + | require.Equal(t, tt.ok, ok) | |||
| 35 | + | }) | |||
| 36 | + | } | |||
| 37 | + | require.NotNil(t, browseCmd.Flags().Lookup("instance")) | |||
| 38 | + | require.NotNil(t, browseCmd.Flags().Lookup("profile")) | |||
| 39 | + | require.NotNil(t, browseCmd.Flags().Lookup("ephemeral")) | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | func TestParseBrowseLaunchArgsProfiles(t *testing.T) { | |||
| 43 | + | parsed, ok := ParseBrowseLaunchArgs([]string{"--instance", "scratch", "https://example.com"}) | |||
| 44 | + | require.True(t, ok) | |||
| 45 | + | require.Equal(t, BrowseArgs{URL: "https://example.com", Instance: "scratch"}, parsed) | |||
| 46 | + | ||||
| 47 | + | parsed, ok = ParseBrowseLaunchArgs([]string{"--instance", "work", "--profile", "work"}) | |||
| 48 | + | require.True(t, ok) | |||
| 49 | + | require.Equal(t, BrowseArgs{Instance: "work", Profile: "work"}, parsed) | |||
| 50 | + | ||||
| 51 | + | parsed, ok = ParseBrowseLaunchArgs([]string{"--instance", "private", "--ephemeral"}) | |||
| 52 | + | require.True(t, ok) | |||
| 53 | + | require.Equal(t, BrowseArgs{Instance: "private", Ephemeral: true}, parsed) | |||
| 54 | + | ||||
| 55 | + | _, ok = ParseBrowseLaunchArgs([]string{"--profile", "work", "--ephemeral"}) | |||
| 56 | + | require.False(t, ok) | |||
| 57 | + | ||||
| 58 | + | _, ok = ParseBrowseLaunchArgs([]string{"--profile", "--ephemeral"}) | |||
| 59 | + | require.False(t, ok, "a flag-like profile must not be treated as a profile name") | |||
| 60 | + | ||||
| 61 | + | _, ok = ParseBrowseLaunchArgs([]string{"--instance", "--ephemeral"}) | |||
| 62 | + | require.False(t, ok, "a flag-like instance must not be treated as an instance name") | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | // browsePreRunEError exercises the real browse PreRunE through Cobra flag | |||
| 66 | + | // parsing, which is the fallback path main takes when early launch parsing | |||
| 67 | + | // rejects the arguments. | |||
| 68 | + | func browsePreRunEError(t *testing.T, args []string) error { | |||
| 69 | + | t.Helper() | |||
| 70 | + | cmd := &cobra.Command{ | |||
| 71 | + | Use: "browse", | |||
| 72 | + | Args: cobra.MaximumNArgs(1), | |||
| 73 | + | SilenceErrors: true, | |||
| 74 | + | SilenceUsage: true, | |||
| 75 | + | PreRunE: browseCmd.PreRunE, | |||
| 76 | + | Run: func(*cobra.Command, []string) {}, | |||
| 77 | + | } | |||
| 78 | + | cmd.Flags().AddFlagSet(browseFlags()) | |||
| 79 | + | cmd.SetOut(io.Discard) | |||
| 80 | + | cmd.SetErr(io.Discard) | |||
| 81 | + | cmd.SetArgs(args) | |||
| 82 | + | return cmd.Execute() | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | func TestBrowseCommandPreRunERejectsFlagLikeValues(t *testing.T) { | |||
| 86 | + | require.Error(t, browsePreRunEError(t, []string{"--instance", "--ephemeral"})) | |||
| 87 | + | require.Error(t, browsePreRunEError(t, []string{"--profile", "--ephemeral"})) | |||
| 88 | + | require.Error(t, browsePreRunEError(t, []string{"--instance", ""})) | |||
| 89 | + | require.Error(t, browsePreRunEError(t, []string{"--profile", ""})) | |||
| 90 | + | ||||
| 91 | + | require.NoError(t, browsePreRunEError(t, []string{"--instance", "work"})) | |||
| 92 | + | require.NoError(t, browsePreRunEError(t, []string{"--instance", "private", "--ephemeral"})) | |||
| 93 | + | require.NoError(t, browsePreRunEError(t, []string{"--profile", "work"})) | |||
| 94 | + | } | |||
M internal/cli/cmd/root.go +31 -3
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | "errors" | |
| 6 | 6 | "fmt" | |
| 7 | 7 | "os" | |
| 8 | + | "strings" | |
| 8 | 9 | ||
| 9 | 10 | "github.com/spf13/cobra" | |
| 10 | 11 | ||
| @@ -112,15 +113,42 @@ var browseCmd = &cobra.Command{ | |||
| 112 | 113 | ||
| 113 | 114 | If a URL is provided, navigate to it. Otherwise, open the homepage. | |
| 114 | 115 | ||
| 116 | + | With --instance <name> (CEF only), Dumber opens or focuses one dedicated | |
| 117 | + | window inside the current CEF profile host. Named instances share cookies, | |
| 118 | + | tokens, local storage, configuration, favorites and history by default. | |
| 119 | + | ||
| 120 | + | Use --profile <name> for a separate persistent CEF profile and host process. | |
| 121 | + | Use --ephemeral for a fresh temporary CEF profile removed when its host exits. | |
| 122 | + | --profile and --ephemeral are mutually exclusive. | |
| 123 | + | ||
| 115 | 124 | Examples: | |
| 116 | - | dumber browse # Open browser to homepage | |
| 117 | - | dumber browse example.com # Open browser to URL`, | |
| 125 | + | dumber browse # Open browser normally | |
| 126 | + | dumber browse example.com # Open browser to URL | |
| 127 | + | dumber browse --instance scratch example.com # Dedicated shared-profile window | |
| 128 | + | dumber browse --instance work --profile work # Isolated persistent profile | |
| 129 | + | dumber browse --instance private --ephemeral # Temporary isolated profile`, | |
| 130 | + | PreRunE: func(command *cobra.Command, _ []string) error { | |
| 131 | + | instance, _ := command.Flags().GetString("instance") | |
| 132 | + | profile, _ := command.Flags().GetString("profile") | |
| 133 | + | ephemeral, _ := command.Flags().GetBool("ephemeral") | |
| 134 | + | if command.Flags().Changed("instance") && (instance == "" || strings.HasPrefix(instance, "-")) { | |
| 135 | + | return fmt.Errorf("--instance must not be empty or start with '-'") | |
| 136 | + | } | |
| 137 | + | if command.Flags().Changed("profile") && (profile == "" || strings.HasPrefix(profile, "-")) { | |
| 138 | + | return fmt.Errorf("--profile must not be empty or start with '-'") | |
| 139 | + | } | |
| 140 | + | if profile != "" && ephemeral { | |
| 141 | + | return fmt.Errorf("--profile and --ephemeral are mutually exclusive") | |
| 142 | + | } | |
| 143 | + | return nil | |
| 144 | + | }, | |
| 118 | 145 | Run: func(_ *cobra.Command, _ []string) { | |
| 119 | - | // This is handled by main.go before cobra runs | |
| 146 | + | // Valid GUI launches are handled by main.go before Cobra runs. | |
| 120 | 147 | }, | |
| 121 | 148 | } | |
| 122 | 149 | ||
| 123 | 150 | func init() { | |
| 151 | + | browseCmd.Flags().AddFlagSet(browseFlags()) | |
| 124 | 152 | rootCmd.AddCommand(browseCmd) | |
| 125 | 153 | } | |
| 126 | 154 | ||
| @@ -5,6 +5,7 @@ import ( | |||||
| 5 | "errors" | 5 | "errors" | ||
| 6 | "fmt" | 6 | "fmt" | ||
| 7 | "os" | 7 | "os" | ||
| 8 | + | "strings" | |||
| 8 | 9 | ||||
| 9 | "github.com/spf13/cobra" | 10 | "github.com/spf13/cobra" | ||
| 10 | 11 | ||||
| @@ -112,15 +113,42 @@ var browseCmd = &cobra.Command{ | |||||
| 112 | 113 | ||||
| 113 | If a URL is provided, navigate to it. Otherwise, open the homepage. | 114 | If a URL is provided, navigate to it. Otherwise, open the homepage. | ||
| 114 | 115 | ||||
| 116 | + | With --instance <name> (CEF only), Dumber opens or focuses one dedicated | |||
| 117 | + | window inside the current CEF profile host. Named instances share cookies, | |||
| 118 | + | tokens, local storage, configuration, favorites and history by default. | |||
| 119 | + | ||||
| 120 | + | Use --profile <name> for a separate persistent CEF profile and host process. | |||
| 121 | + | Use --ephemeral for a fresh temporary CEF profile removed when its host exits. | |||
| 122 | + | --profile and --ephemeral are mutually exclusive. | |||
| 123 | + | ||||
| 115 | Examples: | 124 | Examples: | ||
| 116 | - | dumber browse # Open browser to homepage | 125 | + | dumber browse # Open browser normally |
| 117 | - | dumber browse example.com # Open browser to URL`, | 126 | + | dumber browse example.com # Open browser to URL |
| 127 | + | dumber browse --instance scratch example.com # Dedicated shared-profile window | |||
| 128 | + | dumber browse --instance work --profile work # Isolated persistent profile | |||
| 129 | + | dumber browse --instance private --ephemeral # Temporary isolated profile`, | |||
| 130 | + | PreRunE: func(command *cobra.Command, _ []string) error { | |||
| 131 | + | instance, _ := command.Flags().GetString("instance") | |||
| 132 | + | profile, _ := command.Flags().GetString("profile") | |||
| 133 | + | ephemeral, _ := command.Flags().GetBool("ephemeral") | |||
| 134 | + | if command.Flags().Changed("instance") && (instance == "" || strings.HasPrefix(instance, "-")) { | |||
| 135 | + | return fmt.Errorf("--instance must not be empty or start with '-'") | |||
| 136 | + | } | |||
| 137 | + | if command.Flags().Changed("profile") && (profile == "" || strings.HasPrefix(profile, "-")) { | |||
| 138 | + | return fmt.Errorf("--profile must not be empty or start with '-'") | |||
| 139 | + | } | |||
| 140 | + | if profile != "" && ephemeral { | |||
| 141 | + | return fmt.Errorf("--profile and --ephemeral are mutually exclusive") | |||
| 142 | + | } | |||
| 143 | + | return nil | |||
| 144 | + | }, | |||
| 118 | Run: func(_ *cobra.Command, _ []string) { | 145 | Run: func(_ *cobra.Command, _ []string) { | ||
| 119 | - | // This is handled by main.go before cobra runs | 146 | + | // Valid GUI launches are handled by main.go before Cobra runs. |
| 120 | }, | 147 | }, | ||
| 121 | } | 148 | } | ||
| 122 | 149 | ||||
| 123 | func init() { | 150 | func init() { | ||
| 151 | + | browseCmd.Flags().AddFlagSet(browseFlags()) | |||
| 124 | rootCmd.AddCommand(browseCmd) | 152 | rootCmd.AddCommand(browseCmd) | ||
| 125 | } | 153 | } | ||
| 126 | 154 | ||||
M internal/infrastructure/cef/app.go +30 -2
| @@ -433,10 +433,38 @@ func parseBrowseRelaunchCommandLine(commandLine purecef.CommandLine) (string, bo | |||
| 433 | 433 | } | |
| 434 | 434 | ||
| 435 | 435 | args := parseRelaunchCommandLineArgs(commandLine.GetCommandLineString()) | |
| 436 | - | if len(args) < 3 || args[1] != "browse" || args[2] == "" { | |
| 436 | + | if len(args) < 2 || args[1] != "browse" { | |
| 437 | 437 | return "", false | |
| 438 | 438 | } | |
| 439 | - | return args[2], true | |
| 439 | + | var browseURL string | |
| 440 | + | for index := 2; index < len(args); index++ { | |
| 441 | + | arg := args[index] | |
| 442 | + | switch { | |
| 443 | + | case arg == "--instance": | |
| 444 | + | index++ | |
| 445 | + | if index >= len(args) || args[index] == "" { | |
| 446 | + | return "", false | |
| 447 | + | } | |
| 448 | + | case strings.HasPrefix(arg, "--instance="): | |
| 449 | + | if strings.TrimPrefix(arg, "--instance=") == "" { | |
| 450 | + | return "", false | |
| 451 | + | } | |
| 452 | + | case arg == "--profile" || strings.HasPrefix(arg, "--profile=") || arg == "--ephemeral": | |
| 453 | + | // Profile-scoped launches must be handled by their own elected host; | |
| 454 | + | // CEF's already-running callback must never route them into this one. | |
| 455 | + | return "", false | |
| 456 | + | case strings.HasPrefix(arg, "-"): | |
| 457 | + | return "", false | |
| 458 | + | case browseURL == "": | |
| 459 | + | browseURL = arg | |
| 460 | + | default: | |
| 461 | + | return "", false | |
| 462 | + | } | |
| 463 | + | } | |
| 464 | + | if browseURL == "" { | |
| 465 | + | return "", false | |
| 466 | + | } | |
| 467 | + | return browseURL, true | |
| 440 | 468 | } | |
| 441 | 469 | ||
| 442 | 470 | func isBrowseRelaunchCommandLine(commandLine purecef.CommandLine) bool { | |
| @@ -433,10 +433,38 @@ func parseBrowseRelaunchCommandLine(commandLine purecef.CommandLine) (string, bo | |||||
| 433 | } | 433 | } | ||
| 434 | 434 | ||||
| 435 | args := parseRelaunchCommandLineArgs(commandLine.GetCommandLineString()) | 435 | args := parseRelaunchCommandLineArgs(commandLine.GetCommandLineString()) | ||
| 436 | - | if len(args) < 3 || args[1] != "browse" || args[2] == "" { | 436 | + | if len(args) < 2 || args[1] != "browse" { |
| 437 | return "", false | 437 | return "", false | ||
| 438 | } | 438 | } | ||
| 439 | - | return args[2], true | 439 | + | var browseURL string |
| 440 | + | for index := 2; index < len(args); index++ { | |||
| 441 | + | arg := args[index] | |||
| 442 | + | switch { | |||
| 443 | + | case arg == "--instance": | |||
| 444 | + | index++ | |||
| 445 | + | if index >= len(args) || args[index] == "" { | |||
| 446 | + | return "", false | |||
| 447 | + | } | |||
| 448 | + | case strings.HasPrefix(arg, "--instance="): | |||
| 449 | + | if strings.TrimPrefix(arg, "--instance=") == "" { | |||
| 450 | + | return "", false | |||
| 451 | + | } | |||
| 452 | + | case arg == "--profile" || strings.HasPrefix(arg, "--profile=") || arg == "--ephemeral": | |||
| 453 | + | // Profile-scoped launches must be handled by their own elected host; | |||
| 454 | + | // CEF's already-running callback must never route them into this one. | |||
| 455 | + | return "", false | |||
| 456 | + | case strings.HasPrefix(arg, "-"): | |||
| 457 | + | return "", false | |||
| 458 | + | case browseURL == "": | |||
| 459 | + | browseURL = arg | |||
| 460 | + | default: | |||
| 461 | + | return "", false | |||
| 462 | + | } | |||
| 463 | + | } | |||
| 464 | + | if browseURL == "" { | |||
| 465 | + | return "", false | |||
| 466 | + | } | |||
| 467 | + | return browseURL, true | |||
| 440 | } | 468 | } | ||
| 441 | 469 | ||||
| 442 | func isBrowseRelaunchCommandLine(commandLine purecef.CommandLine) bool { | 470 | func isBrowseRelaunchCommandLine(commandLine purecef.CommandLine) bool { | ||
M internal/infrastructure/cef/app_test.go +28 -0
| @@ -112,6 +112,34 @@ func TestParseBrowseURLFromRelaunchCommandLine(t *testing.T) { | |||
| 112 | 112 | } | |
| 113 | 113 | } | |
| 114 | 114 | ||
| 115 | + | func TestParseBrowseURLFromRelaunchCommandLineWithInstanceFlag(t *testing.T) { | |
| 116 | + | t.Parallel() | |
| 117 | + | ||
| 118 | + | for _, commandLine := range []string{ | |
| 119 | + | "dumber browse --instance work https://example.com", | |
| 120 | + | "dumber browse https://example.com --instance=work", | |
| 121 | + | } { | |
| 122 | + | got := parseBrowseURLFromRelaunchCommandLine(relaunchCommandLineStub{commandLineString: commandLine}) | |
| 123 | + | if got != "https://example.com" { | |
| 124 | + | t.Fatalf("parsed browse url from %q = %q", commandLine, got) | |
| 125 | + | } | |
| 126 | + | } | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | func TestParseBrowseRelaunchCommandLineRejectsCrossProfileLaunches(t *testing.T) { | |
| 130 | + | t.Parallel() | |
| 131 | + | ||
| 132 | + | for _, commandLine := range []string{ | |
| 133 | + | "dumber browse --profile work https://example.com", | |
| 134 | + | "dumber browse --profile=work https://example.com", | |
| 135 | + | "dumber browse --ephemeral https://example.com", | |
| 136 | + | } { | |
| 137 | + | if got, ok := parseBrowseRelaunchCommandLine(relaunchCommandLineStub{commandLineString: commandLine}); ok || got != "" { | |
| 138 | + | t.Fatalf("parseBrowseRelaunchCommandLine(%q) = (%q, %v), want rejected", commandLine, got, ok) | |
| 139 | + | } | |
| 140 | + | } | |
| 141 | + | } | |
| 142 | + | ||
| 115 | 143 | func TestDumberBPH_OnAlreadyRunningAppRelaunch_ForwardsBrowseURLAndReturns1(t *testing.T) { | |
| 116 | 144 | t.Parallel() | |
| 117 | 145 | ||
| @@ -112,6 +112,34 @@ func TestParseBrowseURLFromRelaunchCommandLine(t *testing.T) { | |||||
| 112 | } | 112 | } | ||
| 113 | } | 113 | } | ||
| 114 | 114 | ||||
| 115 | + | func TestParseBrowseURLFromRelaunchCommandLineWithInstanceFlag(t *testing.T) { | |||
| 116 | + | t.Parallel() | |||
| 117 | + | ||||
| 118 | + | for _, commandLine := range []string{ | |||
| 119 | + | "dumber browse --instance work https://example.com", | |||
| 120 | + | "dumber browse https://example.com --instance=work", | |||
| 121 | + | } { | |||
| 122 | + | got := parseBrowseURLFromRelaunchCommandLine(relaunchCommandLineStub{commandLineString: commandLine}) | |||
| 123 | + | if got != "https://example.com" { | |||
| 124 | + | t.Fatalf("parsed browse url from %q = %q", commandLine, got) | |||
| 125 | + | } | |||
| 126 | + | } | |||
| 127 | + | } | |||
| 128 | + | ||||
| 129 | + | func TestParseBrowseRelaunchCommandLineRejectsCrossProfileLaunches(t *testing.T) { | |||
| 130 | + | t.Parallel() | |||
| 131 | + | ||||
| 132 | + | for _, commandLine := range []string{ | |||
| 133 | + | "dumber browse --profile work https://example.com", | |||
| 134 | + | "dumber browse --profile=work https://example.com", | |||
| 135 | + | "dumber browse --ephemeral https://example.com", | |||
| 136 | + | } { | |||
| 137 | + | if got, ok := parseBrowseRelaunchCommandLine(relaunchCommandLineStub{commandLineString: commandLine}); ok || got != "" { | |||
| 138 | + | t.Fatalf("parseBrowseRelaunchCommandLine(%q) = (%q, %v), want rejected", commandLine, got, ok) | |||
| 139 | + | } | |||
| 140 | + | } | |||
| 141 | + | } | |||
| 142 | + | ||||
| 115 | func TestDumberBPH_OnAlreadyRunningAppRelaunch_ForwardsBrowseURLAndReturns1(t *testing.T) { | 143 | func TestDumberBPH_OnAlreadyRunningAppRelaunch_ForwardsBrowseURLAndReturns1(t *testing.T) { | ||
| 116 | t.Parallel() | 144 | t.Parallel() | ||
| 117 | 145 | ||||
M internal/infrastructure/cef/engine_init.go +15 -2
| @@ -29,6 +29,8 @@ const defaultCEFWindowlessFrameRate = 60 | |||
| 29 | 29 | ||
| 30 | 30 | // RuntimePaths contains the concrete filesystem paths the CEF adapter needs. | |
| 31 | 31 | type RuntimePaths struct { | |
| 32 | + | // IsolatedRoot takes precedence over inherited session-spawn environment. | |
| 33 | + | IsolatedRoot string | |
| 32 | 34 | StateRoot string | |
| 33 | 35 | LogFile string | |
| 34 | 36 | ProfileLogDir string | |
| @@ -44,7 +46,7 @@ func NewEngine( | |||
| 44 | 46 | deps EngineDependencies, | |
| 45 | 47 | ) (*Engine, error) { | |
| 46 | 48 | logger := logging.FromContext(ctx) | |
| 47 | - | stateRoot := resolvedStateRoot(paths.StateRoot, opts) | |
| 49 | + | stateRoot := paths.stateRoot(opts) | |
| 48 | 50 | if consumeNextStartSafetyMarker(stateRoot) { | |
| 49 | 51 | applyNextStartSafetyEnvironment() | |
| 50 | 52 | logger.Warn().Str("state_root", stateRoot).Msg("cef: applying next-start GPU safety recovery") | |
| @@ -136,6 +138,13 @@ func normalizedWindowlessFrameRate(frameRate int32) int32 { | |||
| 136 | 138 | return defaultCEFWindowlessFrameRate | |
| 137 | 139 | } | |
| 138 | 140 | ||
| 141 | + | func (p RuntimePaths) stateRoot(opts port.EngineOptions) string { | |
| 142 | + | if p.IsolatedRoot != "" { | |
| 143 | + | return p.IsolatedRoot | |
| 144 | + | } | |
| 145 | + | return resolvedStateRoot(p.StateRoot, opts) | |
| 146 | + | } | |
| 147 | + | ||
| 139 | 148 | func resolvedStateRoot(defaultStateRoot string, opts port.EngineOptions) string { | |
| 140 | 149 | if root := os.Getenv(CEFRootCachePathEnvVar); root != "" { | |
| 141 | 150 | return root | |
| @@ -189,7 +198,11 @@ func prepareCEFSettings( | |||
| 189 | 198 | settings := purecef.DefaultSettings() | |
| 190 | 199 | settings.MultiThreadedMessageLoop = true | |
| 191 | 200 | settings.ExternalMessagePump = false | |
| 192 | - | settings.RootCachePath = resolvedStateRoot(paths.StateRoot, opts) | |
| 201 | + | settings.RootCachePath = paths.stateRoot(opts) | |
| 202 | + | // purego-cef follows CEF's global request-context contract: RootCachePath | |
| 203 | + | // only bounds acceptable cache paths; CachePath opts the global context | |
| 204 | + | // into persistent cookies and localStorage. | |
| 205 | + | settings.CachePath = settings.RootCachePath | |
| 193 | 206 | settings.CEFDir = configureCEFSubprocessRuntime(cfg.CEFDir) | |
| 194 | 207 | if runtimeLogFile, err := prepareCEFLogFile(paths.LogFile, cfg.LogFile); err != nil { | |
| 195 | 208 | logger.Warn().Err(err).Msg("cef: failed to prepare runtime log file") | |
| @@ -29,6 +29,8 @@ const defaultCEFWindowlessFrameRate = 60 | |||||
| 29 | 29 | ||||
| 30 | // RuntimePaths contains the concrete filesystem paths the CEF adapter needs. | 30 | // RuntimePaths contains the concrete filesystem paths the CEF adapter needs. | ||
| 31 | type RuntimePaths struct { | 31 | type RuntimePaths struct { | ||
| 32 | + | // IsolatedRoot takes precedence over inherited session-spawn environment. | |||
| 33 | + | IsolatedRoot string | |||
| 32 | StateRoot string | 34 | StateRoot string | ||
| 33 | LogFile string | 35 | LogFile string | ||
| 34 | ProfileLogDir string | 36 | ProfileLogDir string | ||
| @@ -44,7 +46,7 @@ func NewEngine( | |||||
| 44 | deps EngineDependencies, | 46 | deps EngineDependencies, | ||
| 45 | ) (*Engine, error) { | 47 | ) (*Engine, error) { | ||
| 46 | logger := logging.FromContext(ctx) | 48 | logger := logging.FromContext(ctx) | ||
| 47 | - | stateRoot := resolvedStateRoot(paths.StateRoot, opts) | 49 | + | stateRoot := paths.stateRoot(opts) |
| 48 | if consumeNextStartSafetyMarker(stateRoot) { | 50 | if consumeNextStartSafetyMarker(stateRoot) { | ||
| 49 | applyNextStartSafetyEnvironment() | 51 | applyNextStartSafetyEnvironment() | ||
| 50 | logger.Warn().Str("state_root", stateRoot).Msg("cef: applying next-start GPU safety recovery") | 52 | logger.Warn().Str("state_root", stateRoot).Msg("cef: applying next-start GPU safety recovery") | ||
| @@ -136,6 +138,13 @@ func normalizedWindowlessFrameRate(frameRate int32) int32 { | |||||
| 136 | return defaultCEFWindowlessFrameRate | 138 | return defaultCEFWindowlessFrameRate | ||
| 137 | } | 139 | } | ||
| 138 | 140 | ||||
| 141 | + | func (p RuntimePaths) stateRoot(opts port.EngineOptions) string { | |||
| 142 | + | if p.IsolatedRoot != "" { | |||
| 143 | + | return p.IsolatedRoot | |||
| 144 | + | } | |||
| 145 | + | return resolvedStateRoot(p.StateRoot, opts) | |||
| 146 | + | } | |||
| 147 | + | ||||
| 139 | func resolvedStateRoot(defaultStateRoot string, opts port.EngineOptions) string { | 148 | func resolvedStateRoot(defaultStateRoot string, opts port.EngineOptions) string { | ||
| 140 | if root := os.Getenv(CEFRootCachePathEnvVar); root != "" { | 149 | if root := os.Getenv(CEFRootCachePathEnvVar); root != "" { | ||
| 141 | return root | 150 | return root | ||
| @@ -189,7 +198,11 @@ func prepareCEFSettings( | |||||
| 189 | settings := purecef.DefaultSettings() | 198 | settings := purecef.DefaultSettings() | ||
| 190 | settings.MultiThreadedMessageLoop = true | 199 | settings.MultiThreadedMessageLoop = true | ||
| 191 | settings.ExternalMessagePump = false | 200 | settings.ExternalMessagePump = false | ||
| 192 | - | settings.RootCachePath = resolvedStateRoot(paths.StateRoot, opts) | 201 | + | settings.RootCachePath = paths.stateRoot(opts) |
| 202 | + | // purego-cef follows CEF's global request-context contract: RootCachePath | |||
| 203 | + | // only bounds acceptable cache paths; CachePath opts the global context | |||
| 204 | + | // into persistent cookies and localStorage. | |||
| 205 | + | settings.CachePath = settings.RootCachePath | |||
| 193 | settings.CEFDir = configureCEFSubprocessRuntime(cfg.CEFDir) | 206 | settings.CEFDir = configureCEFSubprocessRuntime(cfg.CEFDir) | ||
| 194 | if runtimeLogFile, err := prepareCEFLogFile(paths.LogFile, cfg.LogFile); err != nil { | 207 | if runtimeLogFile, err := prepareCEFLogFile(paths.LogFile, cfg.LogFile); err != nil { | ||
| 195 | logger.Warn().Err(err).Msg("cef: failed to prepare runtime log file") | 208 | logger.Warn().Err(err).Msg("cef: failed to prepare runtime log file") | ||
M internal/infrastructure/cef/engine_init_test.go +17 -8
| @@ -13,6 +13,19 @@ import ( | |||
| 13 | 13 | "github.com/stretchr/testify/require" | |
| 14 | 14 | ) | |
| 15 | 15 | ||
| 16 | + | func TestPrepareCEFSettings_IsolatedRootOverridesInheritedSession(t *testing.T) { | |
| 17 | + | t.Setenv(CEFRootCachePathEnvVar, "/normal-session") | |
| 18 | + | paths := RuntimePaths{StateRoot: "/normal", IsolatedRoot: "/tmp/instance/cef"} | |
| 19 | + | opts := port.EngineOptions{CacheDir: "/cache", DataDir: "/data"} | |
| 20 | + | logger := zerolog.Nop() | |
| 21 | + | settings, err := prepareCEFSettings(opts, paths, RuntimeConfig{}, &logger) | |
| 22 | + | require.NoError(t, err) | |
| 23 | + | require.Equal(t, paths.IsolatedRoot, settings.RootCachePath) | |
| 24 | + | require.Equal(t, settings.RootCachePath, paths.stateRoot(opts)) | |
| 25 | + | require.Equal(t, settings.RootCachePath, settings.CachePath) | |
| 26 | + | require.Equal(t, "/normal-session", os.Getenv(CEFRootCachePathEnvVar)) | |
| 27 | + | } | |
| 28 | + | ||
| 16 | 29 | func TestResolvedStateRoot_PrefersCacheDir(t *testing.T) { | |
| 17 | 30 | profile := testCEFDevProfile(t) | |
| 18 | 31 | root := resolvedStateRoot(profile.CEFUserDataDir(), port.EngineOptions{DataDir: "/tmp/data", CacheDir: "/tmp/cache"}) | |
| @@ -62,19 +75,15 @@ func TestPrepareCEFSettings_UsesResolvedProfilePaths(t *testing.T) { | |||
| 62 | 75 | require.Empty(t, settings.BrowserSubprocessPath) | |
| 63 | 76 | } | |
| 64 | 77 | ||
| 65 | - | // TestPrepareCEFSettings_LeavesCachePathToCEFDefault locks the P3 cache | |
| 66 | - | // contract: only RootCachePath is configured and CachePath stays empty, so | |
| 67 | - | // the global request context runs incognito-style with in-memory storage. | |
| 68 | - | // RootCachePath does not supply CachePath and grants no persistent | |
| 69 | - | // localStorage. Setting an explicit CachePath requires the P3.3 | |
| 70 | - | // compatibility decision with data-preservation tests. | |
| 71 | - | func TestPrepareCEFSettings_LeavesCachePathToCEFDefault(t *testing.T) { | |
| 78 | + | // TestPrepareCEFSettings_ConfiguresPersistentGlobalRequestContext locks the | |
| 79 | + | // purego-cef contract needed for profile cookies and localStorage. | |
| 80 | + | func TestPrepareCEFSettings_ConfiguresPersistentGlobalRequestContext(t *testing.T) { | |
| 72 | 81 | logger := zerolog.Nop() | |
| 73 | 82 | profile := testCEFDevProfile(t) | |
| 74 | 83 | settings, err := prepareCEFSettings(port.EngineOptions{}, RuntimePaths{StateRoot: profile.CEFUserDataDir(), LogFile: profile.CEFLogFile()}, RuntimeConfig{}, &logger) | |
| 75 | 84 | require.NoError(t, err) | |
| 76 | 85 | require.NotEmpty(t, settings.RootCachePath) | |
| 77 | - | require.Empty(t, settings.CachePath, "explicit CachePath needs the P3.3 decision; empty CachePath means in-memory global context") | |
| 86 | + | require.Equal(t, settings.RootCachePath, settings.CachePath) | |
| 78 | 87 | } | |
| 79 | 88 | ||
| 80 | 89 | func TestParseLoadedLibCEFPath(t *testing.T) { | |
| @@ -13,6 +13,19 @@ import ( | |||||
| 13 | "github.com/stretchr/testify/require" | 13 | "github.com/stretchr/testify/require" | ||
| 14 | ) | 14 | ) | ||
| 15 | 15 | ||||
| 16 | + | func TestPrepareCEFSettings_IsolatedRootOverridesInheritedSession(t *testing.T) { | |||
| 17 | + | t.Setenv(CEFRootCachePathEnvVar, "/normal-session") | |||
| 18 | + | paths := RuntimePaths{StateRoot: "/normal", IsolatedRoot: "/tmp/instance/cef"} | |||
| 19 | + | opts := port.EngineOptions{CacheDir: "/cache", DataDir: "/data"} | |||
| 20 | + | logger := zerolog.Nop() | |||
| 21 | + | settings, err := prepareCEFSettings(opts, paths, RuntimeConfig{}, &logger) | |||
| 22 | + | require.NoError(t, err) | |||
| 23 | + | require.Equal(t, paths.IsolatedRoot, settings.RootCachePath) | |||
| 24 | + | require.Equal(t, settings.RootCachePath, paths.stateRoot(opts)) | |||
| 25 | + | require.Equal(t, settings.RootCachePath, settings.CachePath) | |||
| 26 | + | require.Equal(t, "/normal-session", os.Getenv(CEFRootCachePathEnvVar)) | |||
| 27 | + | } | |||
| 28 | + | ||||
| 16 | func TestResolvedStateRoot_PrefersCacheDir(t *testing.T) { | 29 | func TestResolvedStateRoot_PrefersCacheDir(t *testing.T) { | ||
| 17 | profile := testCEFDevProfile(t) | 30 | profile := testCEFDevProfile(t) | ||
| 18 | root := resolvedStateRoot(profile.CEFUserDataDir(), port.EngineOptions{DataDir: "/tmp/data", CacheDir: "/tmp/cache"}) | 31 | root := resolvedStateRoot(profile.CEFUserDataDir(), port.EngineOptions{DataDir: "/tmp/data", CacheDir: "/tmp/cache"}) | ||
| @@ -62,19 +75,15 @@ func TestPrepareCEFSettings_UsesResolvedProfilePaths(t *testing.T) { | |||||
| 62 | require.Empty(t, settings.BrowserSubprocessPath) | 75 | require.Empty(t, settings.BrowserSubprocessPath) | ||
| 63 | } | 76 | } | ||
| 64 | 77 | ||||
| 65 | - | // TestPrepareCEFSettings_LeavesCachePathToCEFDefault locks the P3 cache | 78 | + | // TestPrepareCEFSettings_ConfiguresPersistentGlobalRequestContext locks the |
| 66 | - | // contract: only RootCachePath is configured and CachePath stays empty, so | 79 | + | // purego-cef contract needed for profile cookies and localStorage. |
| 67 | - | // the global request context runs incognito-style with in-memory storage. | 80 | + | func TestPrepareCEFSettings_ConfiguresPersistentGlobalRequestContext(t *testing.T) { |
| 68 | - | // RootCachePath does not supply CachePath and grants no persistent | |||
| 69 | - | // localStorage. Setting an explicit CachePath requires the P3.3 | |||
| 70 | - | // compatibility decision with data-preservation tests. | |||
| 71 | - | func TestPrepareCEFSettings_LeavesCachePathToCEFDefault(t *testing.T) { | |||
| 72 | logger := zerolog.Nop() | 81 | logger := zerolog.Nop() | ||
| 73 | profile := testCEFDevProfile(t) | 82 | profile := testCEFDevProfile(t) | ||
| 74 | settings, err := prepareCEFSettings(port.EngineOptions{}, RuntimePaths{StateRoot: profile.CEFUserDataDir(), LogFile: profile.CEFLogFile()}, RuntimeConfig{}, &logger) | 83 | settings, err := prepareCEFSettings(port.EngineOptions{}, RuntimePaths{StateRoot: profile.CEFUserDataDir(), LogFile: profile.CEFLogFile()}, RuntimeConfig{}, &logger) | ||
| 75 | require.NoError(t, err) | 84 | require.NoError(t, err) | ||
| 76 | require.NotEmpty(t, settings.RootCachePath) | 85 | require.NotEmpty(t, settings.RootCachePath) | ||
| 77 | - | require.Empty(t, settings.CachePath, "explicit CachePath needs the P3.3 decision; empty CachePath means in-memory global context") | 86 | + | require.Equal(t, settings.RootCachePath, settings.CachePath) |
| 78 | } | 87 | } | ||
| 79 | 88 | ||||
| 80 | func TestParseLoadedLibCEFPath(t *testing.T) { | 89 | func TestParseLoadedLibCEFPath(t *testing.T) { | ||
A internal/infrastructure/desktop/browser_launch_prepare_test.go +95 -0
| @@ -0,0 +1,95 @@ | |||
| 1 | + | package desktop | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "net" | |
| 6 | + | "os" | |
| 7 | + | "path/filepath" | |
| 8 | + | "testing" | |
| 9 | + | "time" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/dumber/internal/application/port" | |
| 12 | + | "github.com/stretchr/testify/require" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | type prepareTestCloser struct{} | |
| 16 | + | ||
| 17 | + | func (*prepareTestCloser) Close() error { return nil } | |
| 18 | + | ||
| 19 | + | func TestBrowserLaunchRelayPrepareReservesUntilListen(t *testing.T) { | |
| 20 | + | ipc := testIPC(shortTempDir(t)) | |
| 21 | + | relay := NewBrowserLaunchRelay(ipc) | |
| 22 | + | reservation := &prepareTestCloser{} | |
| 23 | + | err := relay.(port.BrowserLaunchRelayPreparer).Prepare(reservation) | |
| 24 | + | require.NoError(t, err) | |
| 25 | + | defer reservation.Close() | |
| 26 | + | ||
| 27 | + | // A competing process must not steal the socket before GTK is ready. | |
| 28 | + | competitor := NewBrowserLaunchRelay(ipc) | |
| 29 | + | err = competitor.(port.BrowserLaunchRelayPreparer).Prepare(&prepareTestCloser{}) | |
| 30 | + | require.Error(t, err) | |
| 31 | + | ||
| 32 | + | // Bound is not ready: requests must not be acknowledged yet. | |
| 33 | + | conn, err := net.Dial("unix", ipc.BrowserLaunchSocket) | |
| 34 | + | require.NoError(t, err) | |
| 35 | + | defer conn.Close() | |
| 36 | + | _, err = conn.Write([]byte("{\"url\":\"https://example.com\"}\n")) | |
| 37 | + | require.NoError(t, err) | |
| 38 | + | require.NoError(t, conn.SetReadDeadline(time.Now().Add(30*time.Millisecond))) | |
| 39 | + | var response [1]byte | |
| 40 | + | _, err = conn.Read(response[:]) | |
| 41 | + | require.Error(t, err) | |
| 42 | + | var timeout net.Error | |
| 43 | + | require.ErrorAs(t, err, &timeout) | |
| 44 | + | require.True(t, timeout.Timeout()) | |
| 45 | + | ||
| 46 | + | received := make(chan string, 2) | |
| 47 | + | closer, err := relay.Listen(t.Context(), browserWindowOpenerFunc(func(_ context.Context, url string) error { | |
| 48 | + | received <- url | |
| 49 | + | return nil | |
| 50 | + | })) | |
| 51 | + | require.NoError(t, err) | |
| 52 | + | require.NotSame(t, reservation, closer) | |
| 53 | + | select { | |
| 54 | + | case url := <-received: | |
| 55 | + | require.Equal(t, "https://example.com", url) | |
| 56 | + | case <-time.After(time.Second): | |
| 57 | + | t.Fatal("reserved socket was not activated") | |
| 58 | + | } | |
| 59 | + | require.NoError(t, closer.Close()) | |
| 60 | + | require.NoError(t, reservation.Close()) | |
| 61 | + | require.NoFileExists(t, ipc.BrowserLaunchSocket) | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | func TestBrowserLaunchRelayPrepareRecoversStalePathWhileLeaseHeld(t *testing.T) { | |
| 65 | + | ipc := testIPC(shortTempDir(t)) | |
| 66 | + | require.NoError(t, os.MkdirAll(filepath.Dir(ipc.BrowserLaunchSocket), 0o700)) | |
| 67 | + | require.NoError(t, os.WriteFile(ipc.BrowserLaunchSocket, []byte("stale"), 0o600)) | |
| 68 | + | relay := NewBrowserLaunchRelay(ipc) | |
| 69 | + | err := relay.(port.BrowserLaunchRelayPreparer).Prepare(&prepareTestCloser{}) | |
| 70 | + | require.NoError(t, err) | |
| 71 | + | info, err := os.Stat(ipc.BrowserLaunchSocket) | |
| 72 | + | require.NoError(t, err) | |
| 73 | + | require.NotZero(t, info.Mode()&os.ModeSocket) | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | func TestBrowserLaunchRelayListenerStopsIndependentlyOfLease(t *testing.T) { | |
| 77 | + | ipc := testIPC(shortTempDir(t)) | |
| 78 | + | relay := NewBrowserLaunchRelay(ipc) | |
| 79 | + | lease := &prepareTestCloser{} | |
| 80 | + | require.NoError(t, relay.(port.BrowserLaunchRelayPreparer).Prepare(lease)) | |
| 81 | + | listener, err := relay.Listen(t.Context(), browserWindowOpenerFunc(func(context.Context, string) error { return nil })) | |
| 82 | + | require.NoError(t, err) | |
| 83 | + | ||
| 84 | + | // Closing the listener unlinks its socket but does not own or release lease. | |
| 85 | + | require.NoError(t, listener.Close()) | |
| 86 | + | require.NoFileExists(t, ipc.BrowserLaunchSocket) | |
| 87 | + | require.NoError(t, lease.Close()) | |
| 88 | + | ||
| 89 | + | replacement := NewBrowserLaunchRelay(ipc) | |
| 90 | + | require.NoError(t, replacement.(port.BrowserLaunchRelayPreparer).Prepare(&prepareTestCloser{})) | |
| 91 | + | require.FileExists(t, ipc.BrowserLaunchSocket) | |
| 92 | + | // Idempotent old close must not unlink the successor socket. | |
| 93 | + | require.NoError(t, listener.Close()) | |
| 94 | + | require.FileExists(t, ipc.BrowserLaunchSocket) | |
| 95 | + | } | |
| @@ -0,0 +1,95 @@ | |||||
| 1 | + | package desktop | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "net" | |||
| 6 | + | "os" | |||
| 7 | + | "path/filepath" | |||
| 8 | + | "testing" | |||
| 9 | + | "time" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 12 | + | "github.com/stretchr/testify/require" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | type prepareTestCloser struct{} | |||
| 16 | + | ||||
| 17 | + | func (*prepareTestCloser) Close() error { return nil } | |||
| 18 | + | ||||
| 19 | + | func TestBrowserLaunchRelayPrepareReservesUntilListen(t *testing.T) { | |||
| 20 | + | ipc := testIPC(shortTempDir(t)) | |||
| 21 | + | relay := NewBrowserLaunchRelay(ipc) | |||
| 22 | + | reservation := &prepareTestCloser{} | |||
| 23 | + | err := relay.(port.BrowserLaunchRelayPreparer).Prepare(reservation) | |||
| 24 | + | require.NoError(t, err) | |||
| 25 | + | defer reservation.Close() | |||
| 26 | + | ||||
| 27 | + | // A competing process must not steal the socket before GTK is ready. | |||
| 28 | + | competitor := NewBrowserLaunchRelay(ipc) | |||
| 29 | + | err = competitor.(port.BrowserLaunchRelayPreparer).Prepare(&prepareTestCloser{}) | |||
| 30 | + | require.Error(t, err) | |||
| 31 | + | ||||
| 32 | + | // Bound is not ready: requests must not be acknowledged yet. | |||
| 33 | + | conn, err := net.Dial("unix", ipc.BrowserLaunchSocket) | |||
| 34 | + | require.NoError(t, err) | |||
| 35 | + | defer conn.Close() | |||
| 36 | + | _, err = conn.Write([]byte("{\"url\":\"https://example.com\"}\n")) | |||
| 37 | + | require.NoError(t, err) | |||
| 38 | + | require.NoError(t, conn.SetReadDeadline(time.Now().Add(30*time.Millisecond))) | |||
| 39 | + | var response [1]byte | |||
| 40 | + | _, err = conn.Read(response[:]) | |||
| 41 | + | require.Error(t, err) | |||
| 42 | + | var timeout net.Error | |||
| 43 | + | require.ErrorAs(t, err, &timeout) | |||
| 44 | + | require.True(t, timeout.Timeout()) | |||
| 45 | + | ||||
| 46 | + | received := make(chan string, 2) | |||
| 47 | + | closer, err := relay.Listen(t.Context(), browserWindowOpenerFunc(func(_ context.Context, url string) error { | |||
| 48 | + | received <- url | |||
| 49 | + | return nil | |||
| 50 | + | })) | |||
| 51 | + | require.NoError(t, err) | |||
| 52 | + | require.NotSame(t, reservation, closer) | |||
| 53 | + | select { | |||
| 54 | + | case url := <-received: | |||
| 55 | + | require.Equal(t, "https://example.com", url) | |||
| 56 | + | case <-time.After(time.Second): | |||
| 57 | + | t.Fatal("reserved socket was not activated") | |||
| 58 | + | } | |||
| 59 | + | require.NoError(t, closer.Close()) | |||
| 60 | + | require.NoError(t, reservation.Close()) | |||
| 61 | + | require.NoFileExists(t, ipc.BrowserLaunchSocket) | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | func TestBrowserLaunchRelayPrepareRecoversStalePathWhileLeaseHeld(t *testing.T) { | |||
| 65 | + | ipc := testIPC(shortTempDir(t)) | |||
| 66 | + | require.NoError(t, os.MkdirAll(filepath.Dir(ipc.BrowserLaunchSocket), 0o700)) | |||
| 67 | + | require.NoError(t, os.WriteFile(ipc.BrowserLaunchSocket, []byte("stale"), 0o600)) | |||
| 68 | + | relay := NewBrowserLaunchRelay(ipc) | |||
| 69 | + | err := relay.(port.BrowserLaunchRelayPreparer).Prepare(&prepareTestCloser{}) | |||
| 70 | + | require.NoError(t, err) | |||
| 71 | + | info, err := os.Stat(ipc.BrowserLaunchSocket) | |||
| 72 | + | require.NoError(t, err) | |||
| 73 | + | require.NotZero(t, info.Mode()&os.ModeSocket) | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | func TestBrowserLaunchRelayListenerStopsIndependentlyOfLease(t *testing.T) { | |||
| 77 | + | ipc := testIPC(shortTempDir(t)) | |||
| 78 | + | relay := NewBrowserLaunchRelay(ipc) | |||
| 79 | + | lease := &prepareTestCloser{} | |||
| 80 | + | require.NoError(t, relay.(port.BrowserLaunchRelayPreparer).Prepare(lease)) | |||
| 81 | + | listener, err := relay.Listen(t.Context(), browserWindowOpenerFunc(func(context.Context, string) error { return nil })) | |||
| 82 | + | require.NoError(t, err) | |||
| 83 | + | ||||
| 84 | + | // Closing the listener unlinks its socket but does not own or release lease. | |||
| 85 | + | require.NoError(t, listener.Close()) | |||
| 86 | + | require.NoFileExists(t, ipc.BrowserLaunchSocket) | |||
| 87 | + | require.NoError(t, lease.Close()) | |||
| 88 | + | ||||
| 89 | + | replacement := NewBrowserLaunchRelay(ipc) | |||
| 90 | + | require.NoError(t, replacement.(port.BrowserLaunchRelayPreparer).Prepare(&prepareTestCloser{})) | |||
| 91 | + | require.FileExists(t, ipc.BrowserLaunchSocket) | |||
| 92 | + | // Idempotent old close must not unlink the successor socket. | |||
| 93 | + | require.NoError(t, listener.Close()) | |||
| 94 | + | require.FileExists(t, ipc.BrowserLaunchSocket) | |||
| 95 | + | } | |||
M internal/infrastructure/desktop/browser_launch_relay.go +182 -56
| @@ -13,6 +13,7 @@ import ( | |||
| 13 | 13 | "os" | |
| 14 | 14 | "path/filepath" | |
| 15 | 15 | "sync" | |
| 16 | + | "sync/atomic" | |
| 16 | 17 | "syscall" | |
| 17 | 18 | "time" | |
| 18 | 19 | ||
| @@ -37,7 +38,11 @@ const browserLaunchDirPerm = 0o700 | |||
| 37 | 38 | var ErrBrowserLaunchRelayUnconfirmed = errors.New("browser launch relay did not confirm delivery") | |
| 38 | 39 | ||
| 39 | 40 | type browserLaunchRelay struct { | |
| 40 | - | ipc runtimeprofile.IPCPaths | |
| 41 | + | ipc runtimeprofile.IPCPaths | |
| 42 | + | prepareMu sync.Mutex | |
| 43 | + | prepared *browserLaunchRelayListener | |
| 44 | + | lease io.Closer | |
| 45 | + | serving bool | |
| 41 | 46 | // admission is the shared admission/work-lease boundary: a lease is | |
| 42 | 47 | // acquired before sending Accepted and released when dispatch settles. | |
| 43 | 48 | // Shutdown closes it so losing requests get an error response instead | |
| @@ -48,6 +53,7 @@ type browserLaunchRelay struct { | |||
| 48 | 53 | type browserLaunchRequest struct { | |
| 49 | 54 | RequestID string `json:"request_id,omitempty"` | |
| 50 | 55 | URL string `json:"url"` | |
| 56 | + | Instance string `json:"instance,omitempty"` | |
| 51 | 57 | Action browserLaunchAction `json:"action,omitempty"` | |
| 52 | 58 | } | |
| 53 | 59 | ||
| @@ -56,6 +62,7 @@ type browserLaunchAction string | |||
| 56 | 62 | const ( | |
| 57 | 63 | browserLaunchActionOpenExternalURL browserLaunchAction = "open-external-url" | |
| 58 | 64 | browserLaunchActionOpenFreshWindow browserLaunchAction = "open-fresh-window" | |
| 65 | + | browserLaunchActionOpenInstance browserLaunchAction = "open-instance" | |
| 59 | 66 | // browserLaunchActionCloseAllWindows is a diagnostic-only action for the | |
| 60 | 67 | // residency harness: it closes every user window through the standard | |
| 61 | 68 | // removal path. It is honored only when the OWNING process sets | |
| @@ -85,9 +92,13 @@ type browserLaunchResponse struct { | |||
| 85 | 92 | type browserLaunchRelayListener struct { | |
| 86 | 93 | listener *net.UnixListener | |
| 87 | 94 | socketPath string | |
| 95 | + | socketInfo os.FileInfo | |
| 88 | 96 | admission *process.AdmissionGate | |
| 97 | + | seenMu sync.Mutex | |
| 98 | + | seen map[string]struct{} | |
| 89 | 99 | once sync.Once | |
| 90 | 100 | err error | |
| 101 | + | closed atomic.Bool | |
| 91 | 102 | } | |
| 92 | 103 | ||
| 93 | 104 | var newBrowserLaunchRequestID = func() string { | |
| @@ -120,19 +131,31 @@ func (r *browserLaunchRelay) AdmissionGate() *process.AdmissionGate { | |||
| 120 | 131 | } | |
| 121 | 132 | ||
| 122 | 133 | func (r *browserLaunchRelay) DeliverOpenExternalURL(ctx context.Context, url string) (bool, error) { | |
| 123 | - | return r.deliver(ctx, url, "") | |
| 134 | + | return r.deliver(ctx, url, "", "") | |
| 124 | 135 | } | |
| 125 | 136 | ||
| 126 | 137 | func (r *browserLaunchRelay) DeliverOpenFreshWindow(ctx context.Context, url string) (bool, error) { | |
| 127 | - | return r.deliver(ctx, url, browserLaunchActionOpenFreshWindow) | |
| 138 | + | return r.deliver(ctx, url, "", browserLaunchActionOpenFreshWindow) | |
| 128 | 139 | } | |
| 129 | 140 | ||
| 130 | - | func (r *browserLaunchRelay) deliver(ctx context.Context, url string, action browserLaunchAction) (bool, error) { | |
| 141 | + | func (r *browserLaunchRelay) DeliverOpenInstance(ctx context.Context, name, url string) (bool, error) { | |
| 142 | + | return r.deliver(ctx, url, name, browserLaunchActionOpenInstance) | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | func (r *browserLaunchRelay) deliver(ctx context.Context, url, instance string, action browserLaunchAction) (bool, error) { | |
| 131 | 146 | socketPath, err := r.socketPath() | |
| 132 | 147 | if err != nil { | |
| 133 | 148 | return false, err | |
| 134 | 149 | } | |
| 135 | 150 | ||
| 151 | + | if _, statErr := os.Stat(filepath.Dir(socketPath)); statErr == nil { | |
| 152 | + | if ownerErr := validateBrowserLaunchSocketDirOwned(socketPath, uint32(os.Geteuid())); ownerErr != nil { | |
| 153 | + | return false, ownerErr | |
| 154 | + | } | |
| 155 | + | } else if !os.IsNotExist(statErr) { | |
| 156 | + | return false, fmt.Errorf("stat browser launch dir: %w", statErr) | |
| 157 | + | } | |
| 158 | + | ||
| 136 | 159 | conn, err := (&net.Dialer{}).DialContext(ctx, "unix", socketPath) | |
| 137 | 160 | if err != nil { | |
| 138 | 161 | if isMissingRelayListener(err) { | |
| @@ -152,7 +175,13 @@ func (r *browserLaunchRelay) deliver(ctx context.Context, url string, action bro | |||
| 152 | 175 | if err := setBrowserLaunchConnDeadline(ctx, conn); err != nil { | |
| 153 | 176 | return false, err | |
| 154 | 177 | } | |
| 155 | - | if err := json.NewEncoder(conn).Encode(browserLaunchRequest{RequestID: requestID, URL: url, Action: action}); err != nil { | |
| 178 | + | request := browserLaunchRequest{ | |
| 179 | + | RequestID: requestID, | |
| 180 | + | URL: url, | |
| 181 | + | Instance: instance, | |
| 182 | + | Action: action, | |
| 183 | + | } | |
| 184 | + | if err := json.NewEncoder(conn).Encode(request); err != nil { | |
| 156 | 185 | return false, err | |
| 157 | 186 | } | |
| 158 | 187 | ||
| @@ -273,6 +302,49 @@ func validateBrowserLaunchSocketDirOwned(socketPath string, expectedUID uint32) | |||
| 273 | 302 | } | |
| 274 | 303 | ||
| 275 | 304 | func (r *browserLaunchRelay) Listen(ctx context.Context, opener port.BrowserWindowOpener) (io.Closer, error) { | |
| 305 | + | r.prepareMu.Lock() | |
| 306 | + | defer r.prepareMu.Unlock() | |
| 307 | + | if r.serving { | |
| 308 | + | return nil, errors.New("browser launch relay already serving") | |
| 309 | + | } | |
| 310 | + | listener := r.prepared | |
| 311 | + | if listener != nil && listener.closed.Load() { | |
| 312 | + | return nil, errors.New("browser launch relay reservation closed") | |
| 313 | + | } | |
| 314 | + | if listener == nil { | |
| 315 | + | var err error | |
| 316 | + | listener, err = r.bind(true) | |
| 317 | + | if err != nil { | |
| 318 | + | return nil, err | |
| 319 | + | } | |
| 320 | + | } | |
| 321 | + | r.serving = true | |
| 322 | + | go listener.serve(ctx, opener) | |
| 323 | + | return listener, nil | |
| 324 | + | } | |
| 325 | + | ||
| 326 | + | // Prepare binds without accepting or acknowledging requests until Listen. | |
| 327 | + | // The supplied lease proves exclusive namespace ownership and must outlive | |
| 328 | + | // this relay; stale socket recovery is only safe while that lease is held. | |
| 329 | + | func (r *browserLaunchRelay) Prepare(namespaceLease io.Closer) error { | |
| 330 | + | r.prepareMu.Lock() | |
| 331 | + | defer r.prepareMu.Unlock() | |
| 332 | + | if r.prepared != nil || r.serving { | |
| 333 | + | return errors.New("browser launch relay already prepared or serving") | |
| 334 | + | } | |
| 335 | + | if namespaceLease == nil { | |
| 336 | + | return errors.New("browser launch relay requires namespace lease") | |
| 337 | + | } | |
| 338 | + | listener, err := r.bind(true) | |
| 339 | + | if err != nil { | |
| 340 | + | return err | |
| 341 | + | } | |
| 342 | + | r.lease = namespaceLease | |
| 343 | + | r.prepared = listener | |
| 344 | + | return nil | |
| 345 | + | } | |
| 346 | + | ||
| 347 | + | func (r *browserLaunchRelay) bind(recoverStale bool) (*browserLaunchRelayListener, error) { | |
| 276 | 348 | socketPath, err := r.socketPath() | |
| 277 | 349 | if err != nil { | |
| 278 | 350 | return nil, err | |
| @@ -286,7 +358,7 @@ func (r *browserLaunchRelay) Listen(ctx context.Context, opener port.BrowserWind | |||
| 286 | 358 | } | |
| 287 | 359 | listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: socketPath, Net: "unix"}) | |
| 288 | 360 | if err != nil { | |
| 289 | - | if !errors.Is(err, syscall.EADDRINUSE) { | |
| 361 | + | if !recoverStale || !errors.Is(err, syscall.EADDRINUSE) { | |
| 290 | 362 | return nil, fmt.Errorf("listen browser launch socket: %w", err) | |
| 291 | 363 | } | |
| 292 | 364 | ||
| @@ -312,10 +384,21 @@ func (r *browserLaunchRelay) Listen(ctx context.Context, opener port.BrowserWind | |||
| 312 | 384 | } | |
| 313 | 385 | } | |
| 314 | 386 | ||
| 315 | - | relayListener := &browserLaunchRelayListener{listener: listener, socketPath: socketPath, admission: r.AdmissionGate()} | |
| 316 | - | go relayListener.serve(ctx, opener) | |
| 317 | - | ||
| 318 | - | return relayListener, nil | |
| 387 | + | // Go's UnixListener otherwise unlinks by pathname on Close, which can | |
| 388 | + | // remove a successor socket created after this listener was closed. | |
| 389 | + | listener.SetUnlinkOnClose(false) | |
| 390 | + | socketInfo, err := os.Lstat(socketPath) | |
| 391 | + | if err != nil { | |
| 392 | + | _ = listener.Close() | |
| 393 | + | return nil, fmt.Errorf("stat bound browser launch socket: %w", err) | |
| 394 | + | } | |
| 395 | + | return &browserLaunchRelayListener{ | |
| 396 | + | listener: listener, | |
| 397 | + | socketPath: socketPath, | |
| 398 | + | socketInfo: socketInfo, | |
| 399 | + | admission: r.AdmissionGate(), | |
| 400 | + | seen: make(map[string]struct{}), | |
| 401 | + | }, nil | |
| 319 | 402 | } | |
| 320 | 403 | ||
| 321 | 404 | func (r *browserLaunchRelay) socketPath() (string, error) { | |
| @@ -330,10 +413,17 @@ func (r *browserLaunchRelay) socketPath() (string, error) { | |||
| 330 | 413 | ||
| 331 | 414 | func (l *browserLaunchRelayListener) Close() error { | |
| 332 | 415 | l.once.Do(func() { | |
| 416 | + | l.closed.Store(true) | |
| 333 | 417 | if l.listener != nil { | |
| 334 | 418 | l.err = l.listener.Close() | |
| 335 | 419 | } | |
| 336 | - | _ = os.Remove(l.socketPath) | |
| 420 | + | // Remove only the directory entry that still names this listener. | |
| 421 | + | // A concurrently-created successor owns a different inode and must live. | |
| 422 | + | if current, err := os.Lstat(l.socketPath); err == nil && l.socketInfo != nil && os.SameFile(current, l.socketInfo) { | |
| 423 | + | l.err = errors.Join(l.err, os.Remove(l.socketPath)) | |
| 424 | + | } else if err != nil && !os.IsNotExist(err) { | |
| 425 | + | l.err = errors.Join(l.err, err) | |
| 426 | + | } | |
| 337 | 427 | }) | |
| 338 | 428 | ||
| 339 | 429 | return l.err | |
| @@ -435,17 +525,30 @@ func (l *browserLaunchRelayListener) handleConnection(ctx context.Context, conn | |||
| 435 | 525 | return | |
| 436 | 526 | } | |
| 437 | 527 | ||
| 528 | + | l.seenMu.Lock() | |
| 529 | + | _, duplicate := l.seen[requestID] | |
| 530 | + | if !duplicate { | |
| 531 | + | l.seen[requestID] = struct{}{} | |
| 532 | + | } | |
| 533 | + | l.seenMu.Unlock() | |
| 534 | + | if duplicate { | |
| 535 | + | defer gate.Release() | |
| 536 | + | _ = json.NewEncoder(conn).Encode(browserLaunchResponse{RequestID: requestID, Accepted: true}) | |
| 537 | + | return | |
| 538 | + | } | |
| 539 | + | ||
| 438 | 540 | if err := conn.SetDeadline(time.Now().Add(browserLaunchIOTimeout)); err != nil { | |
| 439 | 541 | gate.Release() | |
| 440 | 542 | return | |
| 441 | 543 | } | |
| 442 | - | if err := json.NewEncoder(conn).Encode(browserLaunchResponse{RequestID: requestID, Accepted: true}); err != nil { | |
| 443 | - | log.Warn().Err(err). | |
| 544 | + | ackErr := json.NewEncoder(conn).Encode(browserLaunchResponse{RequestID: requestID, Accepted: true}) | |
| 545 | + | if ackErr != nil { | |
| 546 | + | // Decode plus admission transfers ownership of the request to the host. | |
| 547 | + | // Dispatch must not depend on the client remaining alive for the ACK. | |
| 548 | + | log.Warn().Err(ackErr). | |
| 444 | 549 | Str("request_id", requestID). | |
| 445 | 550 | Str("url_host", safeURLHost(request.URL)). | |
| 446 | - | Msg("failed to encode browser launch response") | |
| 447 | - | gate.Release() | |
| 448 | - | return | |
| 551 | + | Msg("failed to encode browser launch response; dispatching retained request") | |
| 449 | 552 | } | |
| 450 | 553 | log.Debug(). | |
| 451 | 554 | Str("request_id", requestID). | |
| @@ -457,53 +560,76 @@ func (l *browserLaunchRelayListener) handleConnection(ctx context.Context, conn | |||
| 457 | 560 | // definitive factory/dispatch failure. Factory/dispatch failure | |
| 458 | 561 | // retains the documented existing failure semantics and never | |
| 459 | 562 | // triggers duplicate fallback. | |
| 460 | - | defer gate.Release() | |
| 461 | - | if opener == nil { | |
| 462 | - | log.Warn(). | |
| 463 | - | Str("request_id", requestID). | |
| 464 | - | Str("url_host", safeURLHost(request.URL)). | |
| 465 | - | Msg("browser launch relay accepted request without opener") | |
| 466 | - | return | |
| 467 | - | } | |
| 468 | - | started := time.Now() | |
| 469 | - | log.Debug(). | |
| 563 | + | dispatchBrowserLaunch(ctx, request, requestID, opener, gate, log) | |
| 564 | + | }() | |
| 565 | + | } | |
| 566 | + | ||
| 567 | + | // dispatchBrowserLaunch performs the admitted request's UI dispatch on behalf | |
| 568 | + | // of handleConnection, holding the admission lease until it completes. It | |
| 569 | + | // retains the documented failure semantics: a definitive factory or dispatch | |
| 570 | + | // failure only logs and releases the lease, never triggering duplicate | |
| 571 | + | // fallback. | |
| 572 | + | func dispatchBrowserLaunch( | |
| 573 | + | ctx context.Context, | |
| 574 | + | request browserLaunchRequest, | |
| 575 | + | requestID string, | |
| 576 | + | opener port.BrowserWindowOpener, | |
| 577 | + | gate *process.AdmissionGate, | |
| 578 | + | log *zerolog.Logger, | |
| 579 | + | ) { | |
| 580 | + | defer gate.Release() | |
| 581 | + | if opener == nil { | |
| 582 | + | log.Warn(). | |
| 470 | 583 | Str("request_id", requestID). | |
| 471 | 584 | Str("url_host", safeURLHost(request.URL)). | |
| 472 | - | Msg("browser launch relay calling browser URL opener") | |
| 473 | - | var err error | |
| 474 | - | switch request.Action { | |
| 475 | - | case "", browserLaunchActionOpenExternalURL: | |
| 476 | - | err = opener.OpenExternalURL(ctx, request.URL) | |
| 477 | - | case browserLaunchActionOpenFreshWindow: | |
| 478 | - | err = opener.OpenFreshWindow(ctx, request.URL) | |
| 479 | - | case browserLaunchActionCloseAllWindows: | |
| 480 | - | if closer, ok := opener.(windowCloseOpener); ok { | |
| 481 | - | err = closer.CloseAllWindows(ctx) | |
| 482 | - | } else { | |
| 483 | - | err = errors.New("browser launch relay opener does not support diagnostic close") | |
| 484 | - | } | |
| 485 | - | default: | |
| 486 | - | log.Warn(). | |
| 487 | - | Str("request_id", requestID). | |
| 488 | - | Str("url_host", safeURLHost(request.URL)). | |
| 489 | - | Str("action", string(request.Action)). | |
| 490 | - | Msg("browser launch relay rejected unknown action") | |
| 491 | - | return | |
| 585 | + | Msg("browser launch relay accepted request without opener") | |
| 586 | + | return | |
| 587 | + | } | |
| 588 | + | started := time.Now() | |
| 589 | + | log.Debug(). | |
| 590 | + | Str("request_id", requestID). | |
| 591 | + | Str("url_host", safeURLHost(request.URL)). | |
| 592 | + | Msg("browser launch relay calling browser URL opener") | |
| 593 | + | var err error | |
| 594 | + | switch request.Action { | |
| 595 | + | case "", browserLaunchActionOpenExternalURL: | |
| 596 | + | err = opener.OpenExternalURL(ctx, request.URL) | |
| 597 | + | case browserLaunchActionOpenFreshWindow: | |
| 598 | + | err = opener.OpenFreshWindow(ctx, request.URL) | |
| 599 | + | case browserLaunchActionOpenInstance: | |
| 600 | + | instanceOpener, ok := opener.(port.BrowserInstanceOpener) | |
| 601 | + | if !ok { | |
| 602 | + | err = errors.New("browser launch relay opener does not support named instances") | |
| 603 | + | } else { | |
| 604 | + | err = instanceOpener.OpenInstance(ctx, request.Instance, request.URL) | |
| 492 | 605 | } | |
| 493 | - | if err != nil { | |
| 494 | - | log.Warn().Err(err). | |
| 495 | - | Str("request_id", requestID). | |
| 496 | - | Str("url_host", safeURLHost(request.URL)). | |
| 497 | - | Dur("elapsed", time.Since(started)). | |
| 498 | - | Msg("browser launch relay browser URL opener failed") | |
| 499 | - | return | |
| 606 | + | case browserLaunchActionCloseAllWindows: | |
| 607 | + | if closer, ok := opener.(windowCloseOpener); ok { | |
| 608 | + | err = closer.CloseAllWindows(ctx) | |
| 609 | + | } else { | |
| 610 | + | err = errors.New("browser launch relay opener does not support diagnostic close") | |
| 500 | 611 | } | |
| 501 | - | log.Debug(). | |
| 612 | + | default: | |
| 613 | + | log.Warn(). | |
| 614 | + | Str("request_id", requestID). | |
| 615 | + | Str("url_host", safeURLHost(request.URL)). | |
| 616 | + | Str("action", string(request.Action)). | |
| 617 | + | Msg("browser launch relay rejected unknown action") | |
| 618 | + | return | |
| 619 | + | } | |
| 620 | + | if err != nil { | |
| 621 | + | log.Warn().Err(err). | |
| 502 | 622 | Str("request_id", requestID). | |
| 503 | 623 | Str("url_host", safeURLHost(request.URL)). | |
| 504 | 624 | Dur("elapsed", time.Since(started)). | |
| 505 | - | Msg("browser launch relay browser URL opener returned success") | |
| 506 | - | }() | |
| 625 | + | Msg("browser launch relay browser URL opener failed") | |
| 626 | + | return | |
| 627 | + | } | |
| 628 | + | log.Debug(). | |
| 629 | + | Str("request_id", requestID). | |
| 630 | + | Str("url_host", safeURLHost(request.URL)). | |
| 631 | + | Dur("elapsed", time.Since(started)). | |
| 632 | + | Msg("browser launch relay browser URL opener returned success") | |
| 507 | 633 | } | |
| 508 | 634 | ||
| 509 | 635 | var _ port.BrowserLaunchRelay = (*browserLaunchRelay)(nil) | |
| @@ -13,6 +13,7 @@ import ( | |||||
| 13 | "os" | 13 | "os" | ||
| 14 | "path/filepath" | 14 | "path/filepath" | ||
| 15 | "sync" | 15 | "sync" | ||
| 16 | + | "sync/atomic" | |||
| 16 | "syscall" | 17 | "syscall" | ||
| 17 | "time" | 18 | "time" | ||
| 18 | 19 | ||||
| @@ -37,7 +38,11 @@ const browserLaunchDirPerm = 0o700 | |||||
| 37 | var ErrBrowserLaunchRelayUnconfirmed = errors.New("browser launch relay did not confirm delivery") | 38 | var ErrBrowserLaunchRelayUnconfirmed = errors.New("browser launch relay did not confirm delivery") | ||
| 38 | 39 | ||||
| 39 | type browserLaunchRelay struct { | 40 | type browserLaunchRelay struct { | ||
| 40 | - | ipc runtimeprofile.IPCPaths | 41 | + | ipc runtimeprofile.IPCPaths |
| 42 | + | prepareMu sync.Mutex | |||
| 43 | + | prepared *browserLaunchRelayListener | |||
| 44 | + | lease io.Closer | |||
| 45 | + | serving bool | |||
| 41 | // admission is the shared admission/work-lease boundary: a lease is | 46 | // admission is the shared admission/work-lease boundary: a lease is | ||
| 42 | // acquired before sending Accepted and released when dispatch settles. | 47 | // acquired before sending Accepted and released when dispatch settles. | ||
| 43 | // Shutdown closes it so losing requests get an error response instead | 48 | // Shutdown closes it so losing requests get an error response instead | ||
| @@ -48,6 +53,7 @@ type browserLaunchRelay struct { | |||||
| 48 | type browserLaunchRequest struct { | 53 | type browserLaunchRequest struct { | ||
| 49 | RequestID string `json:"request_id,omitempty"` | 54 | RequestID string `json:"request_id,omitempty"` | ||
| 50 | URL string `json:"url"` | 55 | URL string `json:"url"` | ||
| 56 | + | Instance string `json:"instance,omitempty"` | |||
| 51 | Action browserLaunchAction `json:"action,omitempty"` | 57 | Action browserLaunchAction `json:"action,omitempty"` | ||
| 52 | } | 58 | } | ||
| 53 | 59 | ||||
| @@ -56,6 +62,7 @@ type browserLaunchAction string | |||||
| 56 | const ( | 62 | const ( | ||
| 57 | browserLaunchActionOpenExternalURL browserLaunchAction = "open-external-url" | 63 | browserLaunchActionOpenExternalURL browserLaunchAction = "open-external-url" | ||
| 58 | browserLaunchActionOpenFreshWindow browserLaunchAction = "open-fresh-window" | 64 | browserLaunchActionOpenFreshWindow browserLaunchAction = "open-fresh-window" | ||
| 65 | + | browserLaunchActionOpenInstance browserLaunchAction = "open-instance" | |||
| 59 | // browserLaunchActionCloseAllWindows is a diagnostic-only action for the | 66 | // browserLaunchActionCloseAllWindows is a diagnostic-only action for the | ||
| 60 | // residency harness: it closes every user window through the standard | 67 | // residency harness: it closes every user window through the standard | ||
| 61 | // removal path. It is honored only when the OWNING process sets | 68 | // removal path. It is honored only when the OWNING process sets | ||
| @@ -85,9 +92,13 @@ type browserLaunchResponse struct { | |||||
| 85 | type browserLaunchRelayListener struct { | 92 | type browserLaunchRelayListener struct { | ||
| 86 | listener *net.UnixListener | 93 | listener *net.UnixListener | ||
| 87 | socketPath string | 94 | socketPath string | ||
| 95 | + | socketInfo os.FileInfo | |||
| 88 | admission *process.AdmissionGate | 96 | admission *process.AdmissionGate | ||
| 97 | + | seenMu sync.Mutex | |||
| 98 | + | seen map[string]struct{} | |||
| 89 | once sync.Once | 99 | once sync.Once | ||
| 90 | err error | 100 | err error | ||
| 101 | + | closed atomic.Bool | |||
| 91 | } | 102 | } | ||
| 92 | 103 | ||||
| 93 | var newBrowserLaunchRequestID = func() string { | 104 | var newBrowserLaunchRequestID = func() string { | ||
| @@ -120,19 +131,31 @@ func (r *browserLaunchRelay) AdmissionGate() *process.AdmissionGate { | |||||
| 120 | } | 131 | } | ||
| 121 | 132 | ||||
| 122 | func (r *browserLaunchRelay) DeliverOpenExternalURL(ctx context.Context, url string) (bool, error) { | 133 | func (r *browserLaunchRelay) DeliverOpenExternalURL(ctx context.Context, url string) (bool, error) { | ||
| 123 | - | return r.deliver(ctx, url, "") | 134 | + | return r.deliver(ctx, url, "", "") |
| 124 | } | 135 | } | ||
| 125 | 136 | ||||
| 126 | func (r *browserLaunchRelay) DeliverOpenFreshWindow(ctx context.Context, url string) (bool, error) { | 137 | func (r *browserLaunchRelay) DeliverOpenFreshWindow(ctx context.Context, url string) (bool, error) { | ||
| 127 | - | return r.deliver(ctx, url, browserLaunchActionOpenFreshWindow) | 138 | + | return r.deliver(ctx, url, "", browserLaunchActionOpenFreshWindow) |
| 128 | } | 139 | } | ||
| 129 | 140 | ||||
| 130 | - | func (r *browserLaunchRelay) deliver(ctx context.Context, url string, action browserLaunchAction) (bool, error) { | 141 | + | func (r *browserLaunchRelay) DeliverOpenInstance(ctx context.Context, name, url string) (bool, error) { |
| 142 | + | return r.deliver(ctx, url, name, browserLaunchActionOpenInstance) | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | func (r *browserLaunchRelay) deliver(ctx context.Context, url, instance string, action browserLaunchAction) (bool, error) { | |||
| 131 | socketPath, err := r.socketPath() | 146 | socketPath, err := r.socketPath() | ||
| 132 | if err != nil { | 147 | if err != nil { | ||
| 133 | return false, err | 148 | return false, err | ||
| 134 | } | 149 | } | ||
| 135 | 150 | ||||
| 151 | + | if _, statErr := os.Stat(filepath.Dir(socketPath)); statErr == nil { | |||
| 152 | + | if ownerErr := validateBrowserLaunchSocketDirOwned(socketPath, uint32(os.Geteuid())); ownerErr != nil { | |||
| 153 | + | return false, ownerErr | |||
| 154 | + | } | |||
| 155 | + | } else if !os.IsNotExist(statErr) { | |||
| 156 | + | return false, fmt.Errorf("stat browser launch dir: %w", statErr) | |||
| 157 | + | } | |||
| 158 | + | ||||
| 136 | conn, err := (&net.Dialer{}).DialContext(ctx, "unix", socketPath) | 159 | conn, err := (&net.Dialer{}).DialContext(ctx, "unix", socketPath) | ||
| 137 | if err != nil { | 160 | if err != nil { | ||
| 138 | if isMissingRelayListener(err) { | 161 | if isMissingRelayListener(err) { | ||
| @@ -152,7 +175,13 @@ func (r *browserLaunchRelay) deliver(ctx context.Context, url string, action bro | |||||
| 152 | if err := setBrowserLaunchConnDeadline(ctx, conn); err != nil { | 175 | if err := setBrowserLaunchConnDeadline(ctx, conn); err != nil { | ||
| 153 | return false, err | 176 | return false, err | ||
| 154 | } | 177 | } | ||
| 155 | - | if err := json.NewEncoder(conn).Encode(browserLaunchRequest{RequestID: requestID, URL: url, Action: action}); err != nil { | 178 | + | request := browserLaunchRequest{ |
| 179 | + | RequestID: requestID, | |||
| 180 | + | URL: url, | |||
| 181 | + | Instance: instance, | |||
| 182 | + | Action: action, | |||
| 183 | + | } | |||
| 184 | + | if err := json.NewEncoder(conn).Encode(request); err != nil { | |||
| 156 | return false, err | 185 | return false, err | ||
| 157 | } | 186 | } | ||
| 158 | 187 | ||||
| @@ -273,6 +302,49 @@ func validateBrowserLaunchSocketDirOwned(socketPath string, expectedUID uint32) | |||||
| 273 | } | 302 | } | ||
| 274 | 303 | ||||
| 275 | func (r *browserLaunchRelay) Listen(ctx context.Context, opener port.BrowserWindowOpener) (io.Closer, error) { | 304 | func (r *browserLaunchRelay) Listen(ctx context.Context, opener port.BrowserWindowOpener) (io.Closer, error) { | ||
| 305 | + | r.prepareMu.Lock() | |||
| 306 | + | defer r.prepareMu.Unlock() | |||
| 307 | + | if r.serving { | |||
| 308 | + | return nil, errors.New("browser launch relay already serving") | |||
| 309 | + | } | |||
| 310 | + | listener := r.prepared | |||
| 311 | + | if listener != nil && listener.closed.Load() { | |||
| 312 | + | return nil, errors.New("browser launch relay reservation closed") | |||
| 313 | + | } | |||
| 314 | + | if listener == nil { | |||
| 315 | + | var err error | |||
| 316 | + | listener, err = r.bind(true) | |||
| 317 | + | if err != nil { | |||
| 318 | + | return nil, err | |||
| 319 | + | } | |||
| 320 | + | } | |||
| 321 | + | r.serving = true | |||
| 322 | + | go listener.serve(ctx, opener) | |||
| 323 | + | return listener, nil | |||
| 324 | + | } | |||
| 325 | + | ||||
| 326 | + | // Prepare binds without accepting or acknowledging requests until Listen. | |||
| 327 | + | // The supplied lease proves exclusive namespace ownership and must outlive | |||
| 328 | + | // this relay; stale socket recovery is only safe while that lease is held. | |||
| 329 | + | func (r *browserLaunchRelay) Prepare(namespaceLease io.Closer) error { | |||
| 330 | + | r.prepareMu.Lock() | |||
| 331 | + | defer r.prepareMu.Unlock() | |||
| 332 | + | if r.prepared != nil || r.serving { | |||
| 333 | + | return errors.New("browser launch relay already prepared or serving") | |||
| 334 | + | } | |||
| 335 | + | if namespaceLease == nil { | |||
| 336 | + | return errors.New("browser launch relay requires namespace lease") | |||
| 337 | + | } | |||
| 338 | + | listener, err := r.bind(true) | |||
| 339 | + | if err != nil { | |||
| 340 | + | return err | |||
| 341 | + | } | |||
| 342 | + | r.lease = namespaceLease | |||
| 343 | + | r.prepared = listener | |||
| 344 | + | return nil | |||
| 345 | + | } | |||
| 346 | + | ||||
| 347 | + | func (r *browserLaunchRelay) bind(recoverStale bool) (*browserLaunchRelayListener, error) { | |||
| 276 | socketPath, err := r.socketPath() | 348 | socketPath, err := r.socketPath() | ||
| 277 | if err != nil { | 349 | if err != nil { | ||
| 278 | return nil, err | 350 | return nil, err | ||
| @@ -286,7 +358,7 @@ func (r *browserLaunchRelay) Listen(ctx context.Context, opener port.BrowserWind | |||||
| 286 | } | 358 | } | ||
| 287 | listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: socketPath, Net: "unix"}) | 359 | listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: socketPath, Net: "unix"}) | ||
| 288 | if err != nil { | 360 | if err != nil { | ||
| 289 | - | if !errors.Is(err, syscall.EADDRINUSE) { | 361 | + | if !recoverStale || !errors.Is(err, syscall.EADDRINUSE) { |
| 290 | return nil, fmt.Errorf("listen browser launch socket: %w", err) | 362 | return nil, fmt.Errorf("listen browser launch socket: %w", err) | ||
| 291 | } | 363 | } | ||
| 292 | 364 | ||||
| @@ -312,10 +384,21 @@ func (r *browserLaunchRelay) Listen(ctx context.Context, opener port.BrowserWind | |||||
| 312 | } | 384 | } | ||
| 313 | } | 385 | } | ||
| 314 | 386 | ||||
| 315 | - | relayListener := &browserLaunchRelayListener{listener: listener, socketPath: socketPath, admission: r.AdmissionGate()} | 387 | + | // Go's UnixListener otherwise unlinks by pathname on Close, which can |
| 316 | - | go relayListener.serve(ctx, opener) | 388 | + | // remove a successor socket created after this listener was closed. |
| 317 | - | 389 | + | listener.SetUnlinkOnClose(false) | |
| 318 | - | return relayListener, nil | 390 | + | socketInfo, err := os.Lstat(socketPath) |
| 391 | + | if err != nil { | |||
| 392 | + | _ = listener.Close() | |||
| 393 | + | return nil, fmt.Errorf("stat bound browser launch socket: %w", err) | |||
| 394 | + | } | |||
| 395 | + | return &browserLaunchRelayListener{ | |||
| 396 | + | listener: listener, | |||
| 397 | + | socketPath: socketPath, | |||
| 398 | + | socketInfo: socketInfo, | |||
| 399 | + | admission: r.AdmissionGate(), | |||
| 400 | + | seen: make(map[string]struct{}), | |||
| 401 | + | }, nil | |||
| 319 | } | 402 | } | ||
| 320 | 403 | ||||
| 321 | func (r *browserLaunchRelay) socketPath() (string, error) { | 404 | func (r *browserLaunchRelay) socketPath() (string, error) { | ||
| @@ -330,10 +413,17 @@ func (r *browserLaunchRelay) socketPath() (string, error) { | |||||
| 330 | 413 | ||||
| 331 | func (l *browserLaunchRelayListener) Close() error { | 414 | func (l *browserLaunchRelayListener) Close() error { | ||
| 332 | l.once.Do(func() { | 415 | l.once.Do(func() { | ||
| 416 | + | l.closed.Store(true) | |||
| 333 | if l.listener != nil { | 417 | if l.listener != nil { | ||
| 334 | l.err = l.listener.Close() | 418 | l.err = l.listener.Close() | ||
| 335 | } | 419 | } | ||
| 336 | - | _ = os.Remove(l.socketPath) | 420 | + | // Remove only the directory entry that still names this listener. |
| 421 | + | // A concurrently-created successor owns a different inode and must live. | |||
| 422 | + | if current, err := os.Lstat(l.socketPath); err == nil && l.socketInfo != nil && os.SameFile(current, l.socketInfo) { | |||
| 423 | + | l.err = errors.Join(l.err, os.Remove(l.socketPath)) | |||
| 424 | + | } else if err != nil && !os.IsNotExist(err) { | |||
| 425 | + | l.err = errors.Join(l.err, err) | |||
| 426 | + | } | |||
| 337 | }) | 427 | }) | ||
| 338 | 428 | ||||
| 339 | return l.err | 429 | return l.err | ||
| @@ -435,17 +525,30 @@ func (l *browserLaunchRelayListener) handleConnection(ctx context.Context, conn | |||||
| 435 | return | 525 | return | ||
| 436 | } | 526 | } | ||
| 437 | 527 | ||||
| 528 | + | l.seenMu.Lock() | |||
| 529 | + | _, duplicate := l.seen[requestID] | |||
| 530 | + | if !duplicate { | |||
| 531 | + | l.seen[requestID] = struct{}{} | |||
| 532 | + | } | |||
| 533 | + | l.seenMu.Unlock() | |||
| 534 | + | if duplicate { | |||
| 535 | + | defer gate.Release() | |||
| 536 | + | _ = json.NewEncoder(conn).Encode(browserLaunchResponse{RequestID: requestID, Accepted: true}) | |||
| 537 | + | return | |||
| 538 | + | } | |||
| 539 | + | ||||
| 438 | if err := conn.SetDeadline(time.Now().Add(browserLaunchIOTimeout)); err != nil { | 540 | if err := conn.SetDeadline(time.Now().Add(browserLaunchIOTimeout)); err != nil { | ||
| 439 | gate.Release() | 541 | gate.Release() | ||
| 440 | return | 542 | return | ||
| 441 | } | 543 | } | ||
| 442 | - | if err := json.NewEncoder(conn).Encode(browserLaunchResponse{RequestID: requestID, Accepted: true}); err != nil { | 544 | + | ackErr := json.NewEncoder(conn).Encode(browserLaunchResponse{RequestID: requestID, Accepted: true}) |
| 443 | - | log.Warn().Err(err). | 545 | + | if ackErr != nil { |
| 546 | + | // Decode plus admission transfers ownership of the request to the host. | |||
| 547 | + | // Dispatch must not depend on the client remaining alive for the ACK. | |||
| 548 | + | log.Warn().Err(ackErr). | |||
| 444 | Str("request_id", requestID). | 549 | Str("request_id", requestID). | ||
| 445 | Str("url_host", safeURLHost(request.URL)). | 550 | Str("url_host", safeURLHost(request.URL)). | ||
| 446 | - | Msg("failed to encode browser launch response") | 551 | + | Msg("failed to encode browser launch response; dispatching retained request") |
| 447 | - | gate.Release() | |||
| 448 | - | return | |||
| 449 | } | 552 | } | ||
| 450 | log.Debug(). | 553 | log.Debug(). | ||
| 451 | Str("request_id", requestID). | 554 | Str("request_id", requestID). | ||
| @@ -457,53 +560,76 @@ func (l *browserLaunchRelayListener) handleConnection(ctx context.Context, conn | |||||
| 457 | // definitive factory/dispatch failure. Factory/dispatch failure | 560 | // definitive factory/dispatch failure. Factory/dispatch failure | ||
| 458 | // retains the documented existing failure semantics and never | 561 | // retains the documented existing failure semantics and never | ||
| 459 | // triggers duplicate fallback. | 562 | // triggers duplicate fallback. | ||
| 460 | - | defer gate.Release() | 563 | + | dispatchBrowserLaunch(ctx, request, requestID, opener, gate, log) |
| 461 | - | if opener == nil { | 564 | + | }() |
| 462 | - | log.Warn(). | 565 | + | } |
| 463 | - | Str("request_id", requestID). | 566 | + | |
| 464 | - | Str("url_host", safeURLHost(request.URL)). | 567 | + | // dispatchBrowserLaunch performs the admitted request's UI dispatch on behalf |
| 465 | - | Msg("browser launch relay accepted request without opener") | 568 | + | // of handleConnection, holding the admission lease until it completes. It |
| 466 | - | return | 569 | + | // retains the documented failure semantics: a definitive factory or dispatch |
| 467 | - | } | 570 | + | // failure only logs and releases the lease, never triggering duplicate |
| 468 | - | started := time.Now() | 571 | + | // fallback. |
| 469 | - | log.Debug(). | 572 | + | func dispatchBrowserLaunch( |
| 573 | + | ctx context.Context, | |||
| 574 | + | request browserLaunchRequest, | |||
| 575 | + | requestID string, | |||
| 576 | + | opener port.BrowserWindowOpener, | |||
| 577 | + | gate *process.AdmissionGate, | |||
| 578 | + | log *zerolog.Logger, | |||
| 579 | + | ) { | |||
| 580 | + | defer gate.Release() | |||
| 581 | + | if opener == nil { | |||
| 582 | + | log.Warn(). | |||
| 470 | Str("request_id", requestID). | 583 | Str("request_id", requestID). | ||
| 471 | Str("url_host", safeURLHost(request.URL)). | 584 | Str("url_host", safeURLHost(request.URL)). | ||
| 472 | - | Msg("browser launch relay calling browser URL opener") | 585 | + | Msg("browser launch relay accepted request without opener") |
| 473 | - | var err error | 586 | + | return |
| 474 | - | switch request.Action { | 587 | + | } |
| 475 | - | case "", browserLaunchActionOpenExternalURL: | 588 | + | started := time.Now() |
| 476 | - | err = opener.OpenExternalURL(ctx, request.URL) | 589 | + | log.Debug(). |
| 477 | - | case browserLaunchActionOpenFreshWindow: | 590 | + | Str("request_id", requestID). |
| 478 | - | err = opener.OpenFreshWindow(ctx, request.URL) | 591 | + | Str("url_host", safeURLHost(request.URL)). |
| 479 | - | case browserLaunchActionCloseAllWindows: | 592 | + | Msg("browser launch relay calling browser URL opener") |
| 480 | - | if closer, ok := opener.(windowCloseOpener); ok { | 593 | + | var err error |
| 481 | - | err = closer.CloseAllWindows(ctx) | 594 | + | switch request.Action { |
| 482 | - | } else { | 595 | + | case "", browserLaunchActionOpenExternalURL: |
| 483 | - | err = errors.New("browser launch relay opener does not support diagnostic close") | 596 | + | err = opener.OpenExternalURL(ctx, request.URL) |
| 484 | - | } | 597 | + | case browserLaunchActionOpenFreshWindow: |
| 485 | - | default: | 598 | + | err = opener.OpenFreshWindow(ctx, request.URL) |
| 486 | - | log.Warn(). | 599 | + | case browserLaunchActionOpenInstance: |
| 487 | - | Str("request_id", requestID). | 600 | + | instanceOpener, ok := opener.(port.BrowserInstanceOpener) |
| 488 | - | Str("url_host", safeURLHost(request.URL)). | 601 | + | if !ok { |
| 489 | - | Str("action", string(request.Action)). | 602 | + | err = errors.New("browser launch relay opener does not support named instances") |
| 490 | - | Msg("browser launch relay rejected unknown action") | 603 | + | } else { |
| 491 | - | return | 604 | + | err = instanceOpener.OpenInstance(ctx, request.Instance, request.URL) |
| 492 | } | 605 | } | ||
| 493 | - | if err != nil { | 606 | + | case browserLaunchActionCloseAllWindows: |
| 494 | - | log.Warn().Err(err). | 607 | + | if closer, ok := opener.(windowCloseOpener); ok { |
| 495 | - | Str("request_id", requestID). | 608 | + | err = closer.CloseAllWindows(ctx) |
| 496 | - | Str("url_host", safeURLHost(request.URL)). | 609 | + | } else { |
| 497 | - | Dur("elapsed", time.Since(started)). | 610 | + | err = errors.New("browser launch relay opener does not support diagnostic close") |
| 498 | - | Msg("browser launch relay browser URL opener failed") | |||
| 499 | - | return | |||
| 500 | } | 611 | } | ||
| 501 | - | log.Debug(). | 612 | + | default: |
| 613 | + | log.Warn(). | |||
| 614 | + | Str("request_id", requestID). | |||
| 615 | + | Str("url_host", safeURLHost(request.URL)). | |||
| 616 | + | Str("action", string(request.Action)). | |||
| 617 | + | Msg("browser launch relay rejected unknown action") | |||
| 618 | + | return | |||
| 619 | + | } | |||
| 620 | + | if err != nil { | |||
| 621 | + | log.Warn().Err(err). | |||
| 502 | Str("request_id", requestID). | 622 | Str("request_id", requestID). | ||
| 503 | Str("url_host", safeURLHost(request.URL)). | 623 | Str("url_host", safeURLHost(request.URL)). | ||
| 504 | Dur("elapsed", time.Since(started)). | 624 | Dur("elapsed", time.Since(started)). | ||
| 505 | - | Msg("browser launch relay browser URL opener returned success") | 625 | + | Msg("browser launch relay browser URL opener failed") |
| 506 | - | }() | 626 | + | return |
| 627 | + | } | |||
| 628 | + | log.Debug(). | |||
| 629 | + | Str("request_id", requestID). | |||
| 630 | + | Str("url_host", safeURLHost(request.URL)). | |||
| 631 | + | Dur("elapsed", time.Since(started)). | |||
| 632 | + | Msg("browser launch relay browser URL opener returned success") | |||
| 507 | } | 633 | } | ||
| 508 | 634 | ||||
| 509 | var _ port.BrowserLaunchRelay = (*browserLaunchRelay)(nil) | 635 | var _ port.BrowserLaunchRelay = (*browserLaunchRelay)(nil) | ||
M internal/infrastructure/desktop/browser_launch_relay_test.go +41 -0
| @@ -430,6 +430,47 @@ func TestBrowserLaunchRelay_CloseRemovesSocketPath(t *testing.T) { | |||
| 430 | 430 | waitForSocketGone(t, ipc.BrowserLaunchSocket) | |
| 431 | 431 | } | |
| 432 | 432 | ||
| 433 | + | func TestBrowserLaunchRelay_CloseDoesNotRemoveSuccessorSocket(t *testing.T) { | |
| 434 | + | ipc := testIPC(shortTempDir(t)) | |
| 435 | + | relay := NewBrowserLaunchRelay(ipc) | |
| 436 | + | closer, err := relay.Listen(t.Context(), browserWindowOpenerFunc(func(context.Context, string) error { return nil })) | |
| 437 | + | require.NoError(t, err) | |
| 438 | + | waitForSocket(t, ipc.BrowserLaunchSocket) | |
| 439 | + | ||
| 440 | + | require.NoError(t, os.Remove(ipc.BrowserLaunchSocket)) | |
| 441 | + | successor, err := net.ListenUnix("unix", &net.UnixAddr{Name: ipc.BrowserLaunchSocket, Net: "unix"}) | |
| 442 | + | require.NoError(t, err) | |
| 443 | + | defer successor.Close() | |
| 444 | + | require.NoError(t, closer.Close()) | |
| 445 | + | _, err = os.Lstat(ipc.BrowserLaunchSocket) | |
| 446 | + | require.NoError(t, err, "closing predecessor must preserve successor pathname") | |
| 447 | + | } | |
| 448 | + | ||
| 449 | + | func TestBrowserLaunchRelay_ClientCloseBeforeAckDispatchesOnceAndDeduplicates(t *testing.T) { | |
| 450 | + | ipc := testIPC(shortTempDir(t)) | |
| 451 | + | relay := NewBrowserLaunchRelay(ipc) | |
| 452 | + | received := make(chan string, 2) | |
| 453 | + | closer, err := relay.Listen(t.Context(), browserWindowOpenerFunc(func(_ context.Context, url string) error { received <- url; return nil })) | |
| 454 | + | require.NoError(t, err) | |
| 455 | + | defer closer.Close() | |
| 456 | + | waitForSocket(t, ipc.BrowserLaunchSocket) | |
| 457 | + | ||
| 458 | + | send := func() { | |
| 459 | + | conn, dialErr := net.Dial("unix", ipc.BrowserLaunchSocket) | |
| 460 | + | require.NoError(t, dialErr) | |
| 461 | + | require.NoError(t, json.NewEncoder(conn).Encode(browserLaunchRequest{RequestID: "retained-1", URL: "https://example.com/retained"})) | |
| 462 | + | require.NoError(t, conn.Close()) | |
| 463 | + | } | |
| 464 | + | send() | |
| 465 | + | require.Equal(t, "https://example.com/retained", <-received) | |
| 466 | + | send() | |
| 467 | + | select { | |
| 468 | + | case got := <-received: | |
| 469 | + | t.Fatalf("duplicate request dispatched: %s", got) | |
| 470 | + | case <-time.After(100 * time.Millisecond): | |
| 471 | + | } | |
| 472 | + | } | |
| 473 | + | ||
| 433 | 474 | func TestBrowserLaunchRelay_ContextCancelStopsServing(t *testing.T) { | |
| 434 | 475 | ipc := testIPC(shortTempDir(t)) | |
| 435 | 476 | relay := NewBrowserLaunchRelay(ipc) | |
| @@ -430,6 +430,47 @@ func TestBrowserLaunchRelay_CloseRemovesSocketPath(t *testing.T) { | |||||
| 430 | waitForSocketGone(t, ipc.BrowserLaunchSocket) | 430 | waitForSocketGone(t, ipc.BrowserLaunchSocket) | ||
| 431 | } | 431 | } | ||
| 432 | 432 | ||||
| 433 | + | func TestBrowserLaunchRelay_CloseDoesNotRemoveSuccessorSocket(t *testing.T) { | |||
| 434 | + | ipc := testIPC(shortTempDir(t)) | |||
| 435 | + | relay := NewBrowserLaunchRelay(ipc) | |||
| 436 | + | closer, err := relay.Listen(t.Context(), browserWindowOpenerFunc(func(context.Context, string) error { return nil })) | |||
| 437 | + | require.NoError(t, err) | |||
| 438 | + | waitForSocket(t, ipc.BrowserLaunchSocket) | |||
| 439 | + | ||||
| 440 | + | require.NoError(t, os.Remove(ipc.BrowserLaunchSocket)) | |||
| 441 | + | successor, err := net.ListenUnix("unix", &net.UnixAddr{Name: ipc.BrowserLaunchSocket, Net: "unix"}) | |||
| 442 | + | require.NoError(t, err) | |||
| 443 | + | defer successor.Close() | |||
| 444 | + | require.NoError(t, closer.Close()) | |||
| 445 | + | _, err = os.Lstat(ipc.BrowserLaunchSocket) | |||
| 446 | + | require.NoError(t, err, "closing predecessor must preserve successor pathname") | |||
| 447 | + | } | |||
| 448 | + | ||||
| 449 | + | func TestBrowserLaunchRelay_ClientCloseBeforeAckDispatchesOnceAndDeduplicates(t *testing.T) { | |||
| 450 | + | ipc := testIPC(shortTempDir(t)) | |||
| 451 | + | relay := NewBrowserLaunchRelay(ipc) | |||
| 452 | + | received := make(chan string, 2) | |||
| 453 | + | closer, err := relay.Listen(t.Context(), browserWindowOpenerFunc(func(_ context.Context, url string) error { received <- url; return nil })) | |||
| 454 | + | require.NoError(t, err) | |||
| 455 | + | defer closer.Close() | |||
| 456 | + | waitForSocket(t, ipc.BrowserLaunchSocket) | |||
| 457 | + | ||||
| 458 | + | send := func() { | |||
| 459 | + | conn, dialErr := net.Dial("unix", ipc.BrowserLaunchSocket) | |||
| 460 | + | require.NoError(t, dialErr) | |||
| 461 | + | require.NoError(t, json.NewEncoder(conn).Encode(browserLaunchRequest{RequestID: "retained-1", URL: "https://example.com/retained"})) | |||
| 462 | + | require.NoError(t, conn.Close()) | |||
| 463 | + | } | |||
| 464 | + | send() | |||
| 465 | + | require.Equal(t, "https://example.com/retained", <-received) | |||
| 466 | + | send() | |||
| 467 | + | select { | |||
| 468 | + | case got := <-received: | |||
| 469 | + | t.Fatalf("duplicate request dispatched: %s", got) | |||
| 470 | + | case <-time.After(100 * time.Millisecond): | |||
| 471 | + | } | |||
| 472 | + | } | |||
| 473 | + | ||||
| 433 | func TestBrowserLaunchRelay_ContextCancelStopsServing(t *testing.T) { | 474 | func TestBrowserLaunchRelay_ContextCancelStopsServing(t *testing.T) { | ||
| 434 | ipc := testIPC(shortTempDir(t)) | 475 | ipc := testIPC(shortTempDir(t)) | ||
| 435 | relay := NewBrowserLaunchRelay(ipc) | 476 | relay := NewBrowserLaunchRelay(ipc) | ||
M internal/infrastructure/persistence/sqlite/connection.go +90 -5
| @@ -3,9 +3,14 @@ package sqlite | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "database/sql" | |
| 6 | + | "errors" | |
| 6 | 7 | "fmt" | |
| 7 | 8 | "os" | |
| 8 | 9 | "path/filepath" | |
| 10 | + | "strings" | |
| 11 | + | "time" | |
| 12 | + | ||
| 13 | + | "golang.org/x/sys/unix" | |
| 9 | 14 | ||
| 10 | 15 | "github.com/bnema/dumber/internal/logging" | |
| 11 | 16 | _ "github.com/bnema/purego-sqlite/driver" // SQLite driver (purego, no WASM/CGo) | |
| @@ -26,6 +31,12 @@ func NewConnection(ctx context.Context, dbPath string) (*sql.DB, error) { | |||
| 26 | 31 | return nil, fmt.Errorf("failed to create database directory: %w", err) | |
| 27 | 32 | } | |
| 28 | 33 | ||
| 34 | + | lock, err := lockDatabaseStartup(ctx, dbPath+".startup.lock") | |
| 35 | + | if err != nil { | |
| 36 | + | return nil, err | |
| 37 | + | } | |
| 38 | + | defer unlockDatabaseStartup(lock) | |
| 39 | + | ||
| 29 | 40 | // Open database connection | |
| 30 | 41 | db, err := sql.Open("sqlite3", dbPath) | |
| 31 | 42 | if err != nil { | |
| @@ -42,7 +53,7 @@ func NewConnection(ctx context.Context, dbPath string) (*sql.DB, error) { | |||
| 42 | 53 | } | |
| 43 | 54 | ||
| 44 | 55 | // Apply performance pragmas | |
| 45 | - | if err := applyPragmas(db); err != nil { | |
| 56 | + | if err := applyPragmas(ctx, db); err != nil { | |
| 46 | 57 | _ = db.Close() | |
| 47 | 58 | return nil, err | |
| 48 | 59 | } | |
| @@ -58,10 +69,14 @@ func NewConnection(ctx context.Context, dbPath string) (*sql.DB, error) { | |||
| 58 | 69 | return db, nil | |
| 59 | 70 | } | |
| 60 | 71 | ||
| 72 | + | const ( | |
| 73 | + | pragmaRetryTimeout = 5 * time.Second | |
| 74 | + | lockRetryInterval = 25 * time.Millisecond | |
| 75 | + | ) | |
| 76 | + | ||
| 61 | 77 | // applyPragmas configures SQLite for optimal performance. | |
| 62 | - | func applyPragmas(db *sql.DB) error { | |
| 78 | + | func applyPragmas(ctx context.Context, db *sql.DB) error { | |
| 63 | 79 | pragmas := []string{ | |
| 64 | - | "PRAGMA journal_mode = WAL", // Write-Ahead Logging for concurrent access | |
| 65 | 80 | "PRAGMA synchronous = NORMAL", // Safe in WAL mode | |
| 66 | 81 | "PRAGMA cache_size = -64000", // 64MB cache | |
| 67 | 82 | "PRAGMA temp_store = MEMORY", // Temporary tables in RAM | |
| @@ -70,15 +85,85 @@ func applyPragmas(db *sql.DB) error { | |||
| 70 | 85 | "PRAGMA foreign_keys = ON", // Enable referential integrity | |
| 71 | 86 | } | |
| 72 | 87 | ||
| 88 | + | var journalMode string | |
| 89 | + | if err := retryPragma(ctx, func() error { | |
| 90 | + | return db.QueryRowContext(ctx, "PRAGMA journal_mode = WAL").Scan(&journalMode) | |
| 91 | + | }); err != nil { | |
| 92 | + | return fmt.Errorf("failed to set pragma %q: %w", "PRAGMA journal_mode = WAL", err) | |
| 93 | + | } | |
| 94 | + | if !strings.EqualFold(journalMode, "wal") { | |
| 95 | + | return fmt.Errorf("failed to set pragma %q: journal mode is %q", "PRAGMA journal_mode = WAL", journalMode) | |
| 96 | + | } | |
| 97 | + | ||
| 73 | 98 | for _, pragma := range pragmas { | |
| 74 | - | if _, err := db.Exec(pragma); err != nil { | |
| 99 | + | if err := retryPragma(ctx, func() error { | |
| 100 | + | _, err := db.ExecContext(ctx, pragma) | |
| 101 | + | return err | |
| 102 | + | }); err != nil { | |
| 75 | 103 | return fmt.Errorf("failed to set pragma %q: %w", pragma, err) | |
| 76 | 104 | } | |
| 77 | 105 | } | |
| 78 | - | ||
| 79 | 106 | return nil | |
| 80 | 107 | } | |
| 81 | 108 | ||
| 109 | + | func retryPragma(ctx context.Context, run func() error) error { | |
| 110 | + | deadline := time.Now().Add(pragmaRetryTimeout) | |
| 111 | + | for { | |
| 112 | + | err := run() | |
| 113 | + | if err == nil || !isSQLiteBusy(err) || !time.Now().Before(deadline) { | |
| 114 | + | return err | |
| 115 | + | } | |
| 116 | + | timer := time.NewTimer(lockRetryInterval) | |
| 117 | + | select { | |
| 118 | + | case <-ctx.Done(): | |
| 119 | + | if !timer.Stop() { | |
| 120 | + | <-timer.C | |
| 121 | + | } | |
| 122 | + | return ctx.Err() | |
| 123 | + | case <-timer.C: | |
| 124 | + | } | |
| 125 | + | } | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | func isSQLiteBusy(err error) bool { | |
| 129 | + | message := strings.ToLower(err.Error()) | |
| 130 | + | return strings.Contains(message, "database is locked") || strings.Contains(message, "database is busy") || | |
| 131 | + | strings.Contains(message, "sqlite_busy") | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | func lockDatabaseStartup(ctx context.Context, path string) (*os.File, error) { | |
| 135 | + | const ( | |
| 136 | + | lockRetryInterval = 25 * time.Millisecond | |
| 137 | + | ownerOnlyFileMode = 0o600 | |
| 138 | + | ) | |
| 139 | + | file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR|os.O_TRUNC, ownerOnlyFileMode) | |
| 140 | + | if err != nil { | |
| 141 | + | return nil, fmt.Errorf("open database startup lock: %w", err) | |
| 142 | + | } | |
| 143 | + | for { | |
| 144 | + | if err := unix.Flock(int(file.Fd()), unix.LOCK_EX|unix.LOCK_NB); err == nil { | |
| 145 | + | return file, nil | |
| 146 | + | } else if !errors.Is(err, unix.EWOULDBLOCK) { | |
| 147 | + | _ = file.Close() | |
| 148 | + | return nil, fmt.Errorf("lock database startup: %w", err) | |
| 149 | + | } | |
| 150 | + | select { | |
| 151 | + | case <-ctx.Done(): | |
| 152 | + | _ = file.Close() | |
| 153 | + | return nil, fmt.Errorf("lock database startup: %w", ctx.Err()) | |
| 154 | + | case <-time.After(lockRetryInterval): | |
| 155 | + | } | |
| 156 | + | } | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | func unlockDatabaseStartup(file *os.File) { | |
| 160 | + | if file == nil { | |
| 161 | + | return | |
| 162 | + | } | |
| 163 | + | _ = unix.Flock(int(file.Fd()), unix.LOCK_UN) | |
| 164 | + | _ = file.Close() | |
| 165 | + | } | |
| 166 | + | ||
| 82 | 167 | // configurePool sets connection pool parameters optimized for SQLite. | |
| 83 | 168 | // SQLite only supports one writer at a time, so we limit connections. | |
| 84 | 169 | // | |
| @@ -3,9 +3,14 @@ package sqlite | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "database/sql" | 5 | "database/sql" | ||
| 6 | + | "errors" | |||
| 6 | "fmt" | 7 | "fmt" | ||
| 7 | "os" | 8 | "os" | ||
| 8 | "path/filepath" | 9 | "path/filepath" | ||
| 10 | + | "strings" | |||
| 11 | + | "time" | |||
| 12 | + | ||||
| 13 | + | "golang.org/x/sys/unix" | |||
| 9 | 14 | ||||
| 10 | "github.com/bnema/dumber/internal/logging" | 15 | "github.com/bnema/dumber/internal/logging" | ||
| 11 | _ "github.com/bnema/purego-sqlite/driver" // SQLite driver (purego, no WASM/CGo) | 16 | _ "github.com/bnema/purego-sqlite/driver" // SQLite driver (purego, no WASM/CGo) | ||
| @@ -26,6 +31,12 @@ func NewConnection(ctx context.Context, dbPath string) (*sql.DB, error) { | |||||
| 26 | return nil, fmt.Errorf("failed to create database directory: %w", err) | 31 | return nil, fmt.Errorf("failed to create database directory: %w", err) | ||
| 27 | } | 32 | } | ||
| 28 | 33 | ||||
| 34 | + | lock, err := lockDatabaseStartup(ctx, dbPath+".startup.lock") | |||
| 35 | + | if err != nil { | |||
| 36 | + | return nil, err | |||
| 37 | + | } | |||
| 38 | + | defer unlockDatabaseStartup(lock) | |||
| 39 | + | ||||
| 29 | // Open database connection | 40 | // Open database connection | ||
| 30 | db, err := sql.Open("sqlite3", dbPath) | 41 | db, err := sql.Open("sqlite3", dbPath) | ||
| 31 | if err != nil { | 42 | if err != nil { | ||
| @@ -42,7 +53,7 @@ func NewConnection(ctx context.Context, dbPath string) (*sql.DB, error) { | |||||
| 42 | } | 53 | } | ||
| 43 | 54 | ||||
| 44 | // Apply performance pragmas | 55 | // Apply performance pragmas | ||
| 45 | - | if err := applyPragmas(db); err != nil { | 56 | + | if err := applyPragmas(ctx, db); err != nil { |
| 46 | _ = db.Close() | 57 | _ = db.Close() | ||
| 47 | return nil, err | 58 | return nil, err | ||
| 48 | } | 59 | } | ||
| @@ -58,10 +69,14 @@ func NewConnection(ctx context.Context, dbPath string) (*sql.DB, error) { | |||||
| 58 | return db, nil | 69 | return db, nil | ||
| 59 | } | 70 | } | ||
| 60 | 71 | ||||
| 72 | + | const ( | |||
| 73 | + | pragmaRetryTimeout = 5 * time.Second | |||
| 74 | + | lockRetryInterval = 25 * time.Millisecond | |||
| 75 | + | ) | |||
| 76 | + | ||||
| 61 | // applyPragmas configures SQLite for optimal performance. | 77 | // applyPragmas configures SQLite for optimal performance. | ||
| 62 | - | func applyPragmas(db *sql.DB) error { | 78 | + | func applyPragmas(ctx context.Context, db *sql.DB) error { |
| 63 | pragmas := []string{ | 79 | pragmas := []string{ | ||
| 64 | - | "PRAGMA journal_mode = WAL", // Write-Ahead Logging for concurrent access | |||
| 65 | "PRAGMA synchronous = NORMAL", // Safe in WAL mode | 80 | "PRAGMA synchronous = NORMAL", // Safe in WAL mode | ||
| 66 | "PRAGMA cache_size = -64000", // 64MB cache | 81 | "PRAGMA cache_size = -64000", // 64MB cache | ||
| 67 | "PRAGMA temp_store = MEMORY", // Temporary tables in RAM | 82 | "PRAGMA temp_store = MEMORY", // Temporary tables in RAM | ||
| @@ -70,15 +85,85 @@ func applyPragmas(db *sql.DB) error { | |||||
| 70 | "PRAGMA foreign_keys = ON", // Enable referential integrity | 85 | "PRAGMA foreign_keys = ON", // Enable referential integrity | ||
| 71 | } | 86 | } | ||
| 72 | 87 | ||||
| 88 | + | var journalMode string | |||
| 89 | + | if err := retryPragma(ctx, func() error { | |||
| 90 | + | return db.QueryRowContext(ctx, "PRAGMA journal_mode = WAL").Scan(&journalMode) | |||
| 91 | + | }); err != nil { | |||
| 92 | + | return fmt.Errorf("failed to set pragma %q: %w", "PRAGMA journal_mode = WAL", err) | |||
| 93 | + | } | |||
| 94 | + | if !strings.EqualFold(journalMode, "wal") { | |||
| 95 | + | return fmt.Errorf("failed to set pragma %q: journal mode is %q", "PRAGMA journal_mode = WAL", journalMode) | |||
| 96 | + | } | |||
| 97 | + | ||||
| 73 | for _, pragma := range pragmas { | 98 | for _, pragma := range pragmas { | ||
| 74 | - | if _, err := db.Exec(pragma); err != nil { | 99 | + | if err := retryPragma(ctx, func() error { |
| 100 | + | _, err := db.ExecContext(ctx, pragma) | |||
| 101 | + | return err | |||
| 102 | + | }); err != nil { | |||
| 75 | return fmt.Errorf("failed to set pragma %q: %w", pragma, err) | 103 | return fmt.Errorf("failed to set pragma %q: %w", pragma, err) | ||
| 76 | } | 104 | } | ||
| 77 | } | 105 | } | ||
| 78 | - | ||||
| 79 | return nil | 106 | return nil | ||
| 80 | } | 107 | } | ||
| 81 | 108 | ||||
| 109 | + | func retryPragma(ctx context.Context, run func() error) error { | |||
| 110 | + | deadline := time.Now().Add(pragmaRetryTimeout) | |||
| 111 | + | for { | |||
| 112 | + | err := run() | |||
| 113 | + | if err == nil || !isSQLiteBusy(err) || !time.Now().Before(deadline) { | |||
| 114 | + | return err | |||
| 115 | + | } | |||
| 116 | + | timer := time.NewTimer(lockRetryInterval) | |||
| 117 | + | select { | |||
| 118 | + | case <-ctx.Done(): | |||
| 119 | + | if !timer.Stop() { | |||
| 120 | + | <-timer.C | |||
| 121 | + | } | |||
| 122 | + | return ctx.Err() | |||
| 123 | + | case <-timer.C: | |||
| 124 | + | } | |||
| 125 | + | } | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | func isSQLiteBusy(err error) bool { | |||
| 129 | + | message := strings.ToLower(err.Error()) | |||
| 130 | + | return strings.Contains(message, "database is locked") || strings.Contains(message, "database is busy") || | |||
| 131 | + | strings.Contains(message, "sqlite_busy") | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | func lockDatabaseStartup(ctx context.Context, path string) (*os.File, error) { | |||
| 135 | + | const ( | |||
| 136 | + | lockRetryInterval = 25 * time.Millisecond | |||
| 137 | + | ownerOnlyFileMode = 0o600 | |||
| 138 | + | ) | |||
| 139 | + | file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR|os.O_TRUNC, ownerOnlyFileMode) | |||
| 140 | + | if err != nil { | |||
| 141 | + | return nil, fmt.Errorf("open database startup lock: %w", err) | |||
| 142 | + | } | |||
| 143 | + | for { | |||
| 144 | + | if err := unix.Flock(int(file.Fd()), unix.LOCK_EX|unix.LOCK_NB); err == nil { | |||
| 145 | + | return file, nil | |||
| 146 | + | } else if !errors.Is(err, unix.EWOULDBLOCK) { | |||
| 147 | + | _ = file.Close() | |||
| 148 | + | return nil, fmt.Errorf("lock database startup: %w", err) | |||
| 149 | + | } | |||
| 150 | + | select { | |||
| 151 | + | case <-ctx.Done(): | |||
| 152 | + | _ = file.Close() | |||
| 153 | + | return nil, fmt.Errorf("lock database startup: %w", ctx.Err()) | |||
| 154 | + | case <-time.After(lockRetryInterval): | |||
| 155 | + | } | |||
| 156 | + | } | |||
| 157 | + | } | |||
| 158 | + | ||||
| 159 | + | func unlockDatabaseStartup(file *os.File) { | |||
| 160 | + | if file == nil { | |||
| 161 | + | return | |||
| 162 | + | } | |||
| 163 | + | _ = unix.Flock(int(file.Fd()), unix.LOCK_UN) | |||
| 164 | + | _ = file.Close() | |||
| 165 | + | } | |||
| 166 | + | ||||
| 82 | // configurePool sets connection pool parameters optimized for SQLite. | 167 | // configurePool sets connection pool parameters optimized for SQLite. | ||
| 83 | // SQLite only supports one writer at a time, so we limit connections. | 168 | // SQLite only supports one writer at a time, so we limit connections. | ||
| 84 | // | 169 | // | ||
A internal/infrastructure/persistence/sqlite/connection_concurrency_test.go +65 -0
| @@ -0,0 +1,65 @@ | |||
| 1 | + | package sqlite | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "os" | |
| 7 | + | "path/filepath" | |
| 8 | + | "sync" | |
| 9 | + | "testing" | |
| 10 | + | "time" | |
| 11 | + | ||
| 12 | + | "github.com/stretchr/testify/require" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | func TestRetryPragmaHonorsCancellation(t *testing.T) { | |
| 16 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 17 | + | cancel() | |
| 18 | + | start := time.Now() | |
| 19 | + | err := retryPragma(ctx, func() error { return errors.New("database is locked") }) | |
| 20 | + | require.ErrorIs(t, err, context.Canceled) | |
| 21 | + | require.Less(t, time.Since(start), lockRetryInterval) | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | func TestLockDatabaseStartupTruncatesStaleContents(t *testing.T) { | |
| 25 | + | path := filepath.Join(t.TempDir(), "startup.lock") | |
| 26 | + | require.NoError(t, os.WriteFile(path, []byte("stale"), 0o600)) | |
| 27 | + | lock, err := lockDatabaseStartup(context.Background(), path) | |
| 28 | + | require.NoError(t, err) | |
| 29 | + | require.NoError(t, lock.Close()) | |
| 30 | + | info, err := os.Stat(path) | |
| 31 | + | require.NoError(t, err) | |
| 32 | + | require.Zero(t, info.Size()) | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func TestNewConnectionUsesWAL(t *testing.T) { | |
| 36 | + | db, err := NewConnection(context.Background(), filepath.Join(t.TempDir(), "wal.db")) | |
| 37 | + | require.NoError(t, err) | |
| 38 | + | defer db.Close() | |
| 39 | + | var mode string | |
| 40 | + | require.NoError(t, db.QueryRow("PRAGMA journal_mode").Scan(&mode)) | |
| 41 | + | require.Equal(t, "wal", mode) | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | func TestNewConnectionConcurrentFirstOpen(t *testing.T) { | |
| 45 | + | dbPath := filepath.Join(t.TempDir(), "shared.db") | |
| 46 | + | const openers = 8 | |
| 47 | + | var wg sync.WaitGroup | |
| 48 | + | openErrors := make(chan error, openers) | |
| 49 | + | for range openers { | |
| 50 | + | wg.Add(1) | |
| 51 | + | go func() { | |
| 52 | + | defer wg.Done() | |
| 53 | + | db, err := NewConnection(context.Background(), dbPath) | |
| 54 | + | if err == nil { | |
| 55 | + | err = db.Close() | |
| 56 | + | } | |
| 57 | + | openErrors <- err | |
| 58 | + | }() | |
| 59 | + | } | |
| 60 | + | wg.Wait() | |
| 61 | + | close(openErrors) | |
| 62 | + | for err := range openErrors { | |
| 63 | + | require.NoError(t, err) | |
| 64 | + | } | |
| 65 | + | } | |
| @@ -0,0 +1,65 @@ | |||||
| 1 | + | package sqlite | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "os" | |||
| 7 | + | "path/filepath" | |||
| 8 | + | "sync" | |||
| 9 | + | "testing" | |||
| 10 | + | "time" | |||
| 11 | + | ||||
| 12 | + | "github.com/stretchr/testify/require" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | func TestRetryPragmaHonorsCancellation(t *testing.T) { | |||
| 16 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 17 | + | cancel() | |||
| 18 | + | start := time.Now() | |||
| 19 | + | err := retryPragma(ctx, func() error { return errors.New("database is locked") }) | |||
| 20 | + | require.ErrorIs(t, err, context.Canceled) | |||
| 21 | + | require.Less(t, time.Since(start), lockRetryInterval) | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | func TestLockDatabaseStartupTruncatesStaleContents(t *testing.T) { | |||
| 25 | + | path := filepath.Join(t.TempDir(), "startup.lock") | |||
| 26 | + | require.NoError(t, os.WriteFile(path, []byte("stale"), 0o600)) | |||
| 27 | + | lock, err := lockDatabaseStartup(context.Background(), path) | |||
| 28 | + | require.NoError(t, err) | |||
| 29 | + | require.NoError(t, lock.Close()) | |||
| 30 | + | info, err := os.Stat(path) | |||
| 31 | + | require.NoError(t, err) | |||
| 32 | + | require.Zero(t, info.Size()) | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | func TestNewConnectionUsesWAL(t *testing.T) { | |||
| 36 | + | db, err := NewConnection(context.Background(), filepath.Join(t.TempDir(), "wal.db")) | |||
| 37 | + | require.NoError(t, err) | |||
| 38 | + | defer db.Close() | |||
| 39 | + | var mode string | |||
| 40 | + | require.NoError(t, db.QueryRow("PRAGMA journal_mode").Scan(&mode)) | |||
| 41 | + | require.Equal(t, "wal", mode) | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | func TestNewConnectionConcurrentFirstOpen(t *testing.T) { | |||
| 45 | + | dbPath := filepath.Join(t.TempDir(), "shared.db") | |||
| 46 | + | const openers = 8 | |||
| 47 | + | var wg sync.WaitGroup | |||
| 48 | + | openErrors := make(chan error, openers) | |||
| 49 | + | for range openers { | |||
| 50 | + | wg.Add(1) | |||
| 51 | + | go func() { | |||
| 52 | + | defer wg.Done() | |||
| 53 | + | db, err := NewConnection(context.Background(), dbPath) | |||
| 54 | + | if err == nil { | |||
| 55 | + | err = db.Close() | |||
| 56 | + | } | |||
| 57 | + | openErrors <- err | |||
| 58 | + | }() | |||
| 59 | + | } | |||
| 60 | + | wg.Wait() | |||
| 61 | + | close(openErrors) | |||
| 62 | + | for err := range openErrors { | |||
| 63 | + | require.NoError(t, err) | |||
| 64 | + | } | |||
| 65 | + | } | |||
A internal/infrastructure/runtimeprofile/browser_profile.go +60 -0
| @@ -0,0 +1,60 @@ | |||
| 1 | + | package runtimeprofile | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "crypto/rand" | |
| 5 | + | "encoding/hex" | |
| 6 | + | "fmt" | |
| 7 | + | "path/filepath" | |
| 8 | + | "regexp" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | var instanceNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]{0,31}$`) | |
| 12 | + | ||
| 13 | + | // WithBrowserProfile selects an isolated persistent CEF profile while keeping | |
| 14 | + | // Dumber application data (configuration, history and favorites) shared. | |
| 15 | + | func (p Profile) WithBrowserProfile(name, runtimeBase string) (Profile, error) { | |
| 16 | + | if p.Engine != engineCEF { | |
| 17 | + | return Profile{}, fmt.Errorf("--profile requires the CEF engine") | |
| 18 | + | } | |
| 19 | + | if !instanceNamePattern.MatchString(name) { | |
| 20 | + | return Profile{}, fmt.Errorf( | |
| 21 | + | "invalid profile name %q: use 1-32 ASCII letters, digits, underscores or hyphens, starting with a letter or digit", | |
| 22 | + | name, | |
| 23 | + | ) | |
| 24 | + | } | |
| 25 | + | if !filepath.IsAbs(runtimeBase) { | |
| 26 | + | return Profile{}, fmt.Errorf("profile runtime base must be absolute") | |
| 27 | + | } | |
| 28 | + | p.InstanceRoot = filepath.Join(p.EnginePaths.RootDir, "profiles", name) | |
| 29 | + | p.InstanceAppIDSuffix = "profile-" + name | |
| 30 | + | p.IPC.RuntimeDir = filepath.Join(runtimeBase, "dumber", "profiles", name) | |
| 31 | + | p.IPC.BrowserLaunchSocket = filepath.Join(p.IPC.RuntimeDir, browserLaunchSocketName) | |
| 32 | + | if len(p.IPC.BrowserLaunchSocket) >= devIPCSocketPathLimit { | |
| 33 | + | return Profile{}, fmt.Errorf("profile socket path too long") | |
| 34 | + | } | |
| 35 | + | return p, nil | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // WithEphemeralBrowserProfile selects a fresh temporary CEF profile. The | |
| 39 | + | // caller owns cleanup after the host exits. | |
| 40 | + | func (p Profile) WithEphemeralBrowserProfile(runtimeBase string) (Profile, error) { | |
| 41 | + | if p.Engine != engineCEF { | |
| 42 | + | return Profile{}, fmt.Errorf("--ephemeral requires the CEF engine") | |
| 43 | + | } | |
| 44 | + | if !filepath.IsAbs(runtimeBase) { | |
| 45 | + | return Profile{}, fmt.Errorf("profile runtime base must be absolute") | |
| 46 | + | } | |
| 47 | + | var nonce [8]byte | |
| 48 | + | if _, err := rand.Read(nonce[:]); err != nil { | |
| 49 | + | return Profile{}, fmt.Errorf("generate ephemeral profile identity: %w", err) | |
| 50 | + | } | |
| 51 | + | key := "ephemeral-" + hex.EncodeToString(nonce[:]) | |
| 52 | + | p.InstanceRoot = filepath.Join(runtimeBase, "dumber", key) | |
| 53 | + | p.InstanceAppIDSuffix = key | |
| 54 | + | p.IPC.RuntimeDir = p.InstanceRoot | |
| 55 | + | p.IPC.BrowserLaunchSocket = filepath.Join(p.IPC.RuntimeDir, browserLaunchSocketName) | |
| 56 | + | if len(p.IPC.BrowserLaunchSocket) >= devIPCSocketPathLimit { | |
| 57 | + | return Profile{}, fmt.Errorf("ephemeral profile socket path too long") | |
| 58 | + | } | |
| 59 | + | return p, nil | |
| 60 | + | } | |
| @@ -0,0 +1,60 @@ | |||||
| 1 | + | package runtimeprofile | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "crypto/rand" | |||
| 5 | + | "encoding/hex" | |||
| 6 | + | "fmt" | |||
| 7 | + | "path/filepath" | |||
| 8 | + | "regexp" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | var instanceNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]{0,31}$`) | |||
| 12 | + | ||||
| 13 | + | // WithBrowserProfile selects an isolated persistent CEF profile while keeping | |||
| 14 | + | // Dumber application data (configuration, history and favorites) shared. | |||
| 15 | + | func (p Profile) WithBrowserProfile(name, runtimeBase string) (Profile, error) { | |||
| 16 | + | if p.Engine != engineCEF { | |||
| 17 | + | return Profile{}, fmt.Errorf("--profile requires the CEF engine") | |||
| 18 | + | } | |||
| 19 | + | if !instanceNamePattern.MatchString(name) { | |||
| 20 | + | return Profile{}, fmt.Errorf( | |||
| 21 | + | "invalid profile name %q: use 1-32 ASCII letters, digits, underscores or hyphens, starting with a letter or digit", | |||
| 22 | + | name, | |||
| 23 | + | ) | |||
| 24 | + | } | |||
| 25 | + | if !filepath.IsAbs(runtimeBase) { | |||
| 26 | + | return Profile{}, fmt.Errorf("profile runtime base must be absolute") | |||
| 27 | + | } | |||
| 28 | + | p.InstanceRoot = filepath.Join(p.EnginePaths.RootDir, "profiles", name) | |||
| 29 | + | p.InstanceAppIDSuffix = "profile-" + name | |||
| 30 | + | p.IPC.RuntimeDir = filepath.Join(runtimeBase, "dumber", "profiles", name) | |||
| 31 | + | p.IPC.BrowserLaunchSocket = filepath.Join(p.IPC.RuntimeDir, browserLaunchSocketName) | |||
| 32 | + | if len(p.IPC.BrowserLaunchSocket) >= devIPCSocketPathLimit { | |||
| 33 | + | return Profile{}, fmt.Errorf("profile socket path too long") | |||
| 34 | + | } | |||
| 35 | + | return p, nil | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | // WithEphemeralBrowserProfile selects a fresh temporary CEF profile. The | |||
| 39 | + | // caller owns cleanup after the host exits. | |||
| 40 | + | func (p Profile) WithEphemeralBrowserProfile(runtimeBase string) (Profile, error) { | |||
| 41 | + | if p.Engine != engineCEF { | |||
| 42 | + | return Profile{}, fmt.Errorf("--ephemeral requires the CEF engine") | |||
| 43 | + | } | |||
| 44 | + | if !filepath.IsAbs(runtimeBase) { | |||
| 45 | + | return Profile{}, fmt.Errorf("profile runtime base must be absolute") | |||
| 46 | + | } | |||
| 47 | + | var nonce [8]byte | |||
| 48 | + | if _, err := rand.Read(nonce[:]); err != nil { | |||
| 49 | + | return Profile{}, fmt.Errorf("generate ephemeral profile identity: %w", err) | |||
| 50 | + | } | |||
| 51 | + | key := "ephemeral-" + hex.EncodeToString(nonce[:]) | |||
| 52 | + | p.InstanceRoot = filepath.Join(runtimeBase, "dumber", key) | |||
| 53 | + | p.InstanceAppIDSuffix = key | |||
| 54 | + | p.IPC.RuntimeDir = p.InstanceRoot | |||
| 55 | + | p.IPC.BrowserLaunchSocket = filepath.Join(p.IPC.RuntimeDir, browserLaunchSocketName) | |||
| 56 | + | if len(p.IPC.BrowserLaunchSocket) >= devIPCSocketPathLimit { | |||
| 57 | + | return Profile{}, fmt.Errorf("ephemeral profile socket path too long") | |||
| 58 | + | } | |||
| 59 | + | return p, nil | |||
| 60 | + | } | |||
A internal/infrastructure/runtimeprofile/browser_profile_test.go +42 -0
| @@ -0,0 +1,42 @@ | |||
| 1 | + | package runtimeprofile | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "path/filepath" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func testCEFProfile(t *testing.T) Profile { | |
| 11 | + | t.Helper() | |
| 12 | + | root := t.TempDir() | |
| 13 | + | return Profile{ | |
| 14 | + | Mode: ModeProd, | |
| 15 | + | Engine: engineCEF, | |
| 16 | + | EnginePaths: EnginePaths{ | |
| 17 | + | RootDir: filepath.Join(root, "state", "engines", "cef"), | |
| 18 | + | }, | |
| 19 | + | Shared: SharedPaths{DataDir: filepath.Join(root, "data")}, | |
| 20 | + | } | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | func TestWithBrowserProfileSeparatesCEFStorageAndIPC(t *testing.T) { | |
| 24 | + | base := testCEFProfile(t) | |
| 25 | + | runtimeBase := "/tmp/dumber-profile-test" | |
| 26 | + | profile, err := base.WithBrowserProfile("work", runtimeBase) | |
| 27 | + | require.NoError(t, err) | |
| 28 | + | require.Equal(t, filepath.Join(base.EnginePaths.RootDir, "profiles", "work", "cef"), profile.CEFUserDataDir()) | |
| 29 | + | require.Equal(t, filepath.Join(runtimeBase, "dumber", "profiles", "work", browserLaunchSocketName), profile.IPC.BrowserLaunchSocket) | |
| 30 | + | require.Equal(t, base.Shared, profile.Shared) | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | func TestWithEphemeralBrowserProfileCreatesUniqueRoots(t *testing.T) { | |
| 34 | + | base := testCEFProfile(t) | |
| 35 | + | runtimeBase := "/tmp/dumber-ephemeral-test" | |
| 36 | + | first, err := base.WithEphemeralBrowserProfile(runtimeBase) | |
| 37 | + | require.NoError(t, err) | |
| 38 | + | second, err := base.WithEphemeralBrowserProfile(runtimeBase) | |
| 39 | + | require.NoError(t, err) | |
| 40 | + | require.NotEqual(t, first.InstanceRoot, second.InstanceRoot) | |
| 41 | + | require.Equal(t, filepath.Join(first.InstanceRoot, "cef"), first.CEFUserDataDir()) | |
| 42 | + | } | |
| @@ -0,0 +1,42 @@ | |||||
| 1 | + | package runtimeprofile | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "path/filepath" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func testCEFProfile(t *testing.T) Profile { | |||
| 11 | + | t.Helper() | |||
| 12 | + | root := t.TempDir() | |||
| 13 | + | return Profile{ | |||
| 14 | + | Mode: ModeProd, | |||
| 15 | + | Engine: engineCEF, | |||
| 16 | + | EnginePaths: EnginePaths{ | |||
| 17 | + | RootDir: filepath.Join(root, "state", "engines", "cef"), | |||
| 18 | + | }, | |||
| 19 | + | Shared: SharedPaths{DataDir: filepath.Join(root, "data")}, | |||
| 20 | + | } | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | func TestWithBrowserProfileSeparatesCEFStorageAndIPC(t *testing.T) { | |||
| 24 | + | base := testCEFProfile(t) | |||
| 25 | + | runtimeBase := "/tmp/dumber-profile-test" | |||
| 26 | + | profile, err := base.WithBrowserProfile("work", runtimeBase) | |||
| 27 | + | require.NoError(t, err) | |||
| 28 | + | require.Equal(t, filepath.Join(base.EnginePaths.RootDir, "profiles", "work", "cef"), profile.CEFUserDataDir()) | |||
| 29 | + | require.Equal(t, filepath.Join(runtimeBase, "dumber", "profiles", "work", browserLaunchSocketName), profile.IPC.BrowserLaunchSocket) | |||
| 30 | + | require.Equal(t, base.Shared, profile.Shared) | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | func TestWithEphemeralBrowserProfileCreatesUniqueRoots(t *testing.T) { | |||
| 34 | + | base := testCEFProfile(t) | |||
| 35 | + | runtimeBase := "/tmp/dumber-ephemeral-test" | |||
| 36 | + | first, err := base.WithEphemeralBrowserProfile(runtimeBase) | |||
| 37 | + | require.NoError(t, err) | |||
| 38 | + | second, err := base.WithEphemeralBrowserProfile(runtimeBase) | |||
| 39 | + | require.NoError(t, err) | |||
| 40 | + | require.NotEqual(t, first.InstanceRoot, second.InstanceRoot) | |||
| 41 | + | require.Equal(t, filepath.Join(first.InstanceRoot, "cef"), first.CEFUserDataDir()) | |||
| 42 | + | } | |||
M internal/infrastructure/runtimeprofile/profile.go +10 -5
| @@ -68,11 +68,13 @@ type IPCPaths struct { | |||
| 68 | 68 | ||
| 69 | 69 | // Profile is the fully resolved runtime profile for one mode+engine namespace. | |
| 70 | 70 | type Profile struct { | |
| 71 | - | Mode Mode | |
| 72 | - | Engine string | |
| 73 | - | Shared SharedPaths | |
| 74 | - | EnginePaths EnginePaths | |
| 75 | - | IPC IPCPaths | |
| 71 | + | InstanceRoot string | |
| 72 | + | InstanceAppIDSuffix string | |
| 73 | + | Mode Mode | |
| 74 | + | Engine string | |
| 75 | + | Shared SharedPaths | |
| 76 | + | EnginePaths EnginePaths | |
| 77 | + | IPC IPCPaths | |
| 76 | 78 | } | |
| 77 | 79 | ||
| 78 | 80 | // Resolve builds a runtime profile from pure inputs. | |
| @@ -201,6 +203,9 @@ func cwdValue(get func() (string, error)) (string, error) { | |||
| 201 | 203 | ||
| 202 | 204 | // CEFUserDataDir returns the resolved CEF root cache/user-data directory. | |
| 203 | 205 | func (p Profile) CEFUserDataDir() string { | |
| 206 | + | if p.InstanceRoot != "" { | |
| 207 | + | return filepath.Join(p.InstanceRoot, "cef") | |
| 208 | + | } | |
| 204 | 209 | if p.Mode == ModeDev { | |
| 205 | 210 | return filepath.Join(p.EnginePaths.RootDir, "data") | |
| 206 | 211 | } | |
| @@ -68,11 +68,13 @@ type IPCPaths struct { | |||||
| 68 | 68 | ||||
| 69 | // Profile is the fully resolved runtime profile for one mode+engine namespace. | 69 | // Profile is the fully resolved runtime profile for one mode+engine namespace. | ||
| 70 | type Profile struct { | 70 | type Profile struct { | ||
| 71 | - | Mode Mode | 71 | + | InstanceRoot string |
| 72 | - | Engine string | 72 | + | InstanceAppIDSuffix string |
| 73 | - | Shared SharedPaths | 73 | + | Mode Mode |
| 74 | - | EnginePaths EnginePaths | 74 | + | Engine string |
| 75 | - | IPC IPCPaths | 75 | + | Shared SharedPaths |
| 76 | + | EnginePaths EnginePaths | |||
| 77 | + | IPC IPCPaths | |||
| 76 | } | 78 | } | ||
| 77 | 79 | ||||
| 78 | // Resolve builds a runtime profile from pure inputs. | 80 | // Resolve builds a runtime profile from pure inputs. | ||
| @@ -201,6 +203,9 @@ func cwdValue(get func() (string, error)) (string, error) { | |||||
| 201 | 203 | ||||
| 202 | // CEFUserDataDir returns the resolved CEF root cache/user-data directory. | 204 | // CEFUserDataDir returns the resolved CEF root cache/user-data directory. | ||
| 203 | func (p Profile) CEFUserDataDir() string { | 205 | func (p Profile) CEFUserDataDir() string { | ||
| 206 | + | if p.InstanceRoot != "" { | |||
| 207 | + | return filepath.Join(p.InstanceRoot, "cef") | |||
| 208 | + | } | |||
| 204 | if p.Mode == ModeDev { | 209 | if p.Mode == ModeDev { | ||
| 205 | return filepath.Join(p.EnginePaths.RootDir, "data") | 210 | return filepath.Join(p.EnginePaths.RootDir, "data") | ||
| 206 | } | 211 | } | ||
M internal/ui/app.go +12 -1
| @@ -91,7 +91,8 @@ type App struct { | |||
| 91 | 91 | runtimeConfig *runtimeConfigState | |
| 92 | 92 | ||
| 93 | 93 | browserWindows map[string]*browserWindow | |
| 94 | - | browserWindowOrder []string // registration order of window IDs | |
| 94 | + | browserWindowOrder []string // registration order of window IDs | |
| 95 | + | instanceWindows map[string]string // logical instance name -> browser window ID | |
| 95 | 96 | lastFocusedWindowID string | |
| 96 | 97 | nativePopupWindows map[port.WebViewID]*nativePopupWindow | |
| 97 | 98 | browserWindowFactory func(context.Context, string) (*browserWindow, error) | |
| @@ -228,6 +229,7 @@ func New(deps *Dependencies) (*App, error) { | |||
| 228 | 229 | windowForTab: make(map[entity.TabID]*browserWindow), | |
| 229 | 230 | floatingSessions: make(map[floatingSessionKey]*floatingWorkspaceSession), | |
| 230 | 231 | browserWindows: make(map[string]*browserWindow), | |
| 232 | + | instanceWindows: make(map[string]string), | |
| 231 | 233 | vimModePolicyUC: usecase.NewVimModePolicyUseCase(), | |
| 232 | 234 | vimNavigationUC: usecase.NewVimNavigationUseCase(), | |
| 233 | 235 | pageEditableFocusByPane: make(map[entity.PaneID]bool), | |
| @@ -344,6 +346,9 @@ func (a *App) Run(ctx context.Context, args []string) int { | |||
| 344 | 346 | } | |
| 345 | 347 | ||
| 346 | 348 | appID := AppID | |
| 349 | + | if a.deps != nil && a.deps.ApplicationID != "" { | |
| 350 | + | appID = a.deps.ApplicationID | |
| 351 | + | } | |
| 347 | 352 | a.gtkApp = gtk.NewApplication(&appID, gtkApplicationFlags()) | |
| 348 | 353 | if a.gtkApp == nil { | |
| 349 | 354 | log.Error().Msg("failed to create GTK application") | |
| @@ -420,6 +425,12 @@ func (a *App) onActivate(ctx context.Context) { | |||
| 420 | 425 | return | |
| 421 | 426 | } | |
| 422 | 427 | ||
| 428 | + | if name := os.Getenv("DUMBER_INITIAL_INSTANCE"); name != "" { | |
| 429 | + | if initial := a.lastFocusedBrowserWindow(); initial != nil { | |
| 430 | + | a.instanceWindows[name] = initial.id | |
| 431 | + | } | |
| 432 | + | } | |
| 433 | + | ||
| 423 | 434 | a.installCrashReportNotifier(ctx) | |
| 424 | 435 | a.initFocusManager() | |
| 425 | 436 | a.registerAccentHandlers(ctx) | |
| @@ -91,7 +91,8 @@ type App struct { | |||||
| 91 | runtimeConfig *runtimeConfigState | 91 | runtimeConfig *runtimeConfigState | ||
| 92 | 92 | ||||
| 93 | browserWindows map[string]*browserWindow | 93 | browserWindows map[string]*browserWindow | ||
| 94 | - | browserWindowOrder []string // registration order of window IDs | 94 | + | browserWindowOrder []string // registration order of window IDs |
| 95 | + | instanceWindows map[string]string // logical instance name -> browser window ID | |||
| 95 | lastFocusedWindowID string | 96 | lastFocusedWindowID string | ||
| 96 | nativePopupWindows map[port.WebViewID]*nativePopupWindow | 97 | nativePopupWindows map[port.WebViewID]*nativePopupWindow | ||
| 97 | browserWindowFactory func(context.Context, string) (*browserWindow, error) | 98 | browserWindowFactory func(context.Context, string) (*browserWindow, error) | ||
| @@ -228,6 +229,7 @@ func New(deps *Dependencies) (*App, error) { | |||||
| 228 | windowForTab: make(map[entity.TabID]*browserWindow), | 229 | windowForTab: make(map[entity.TabID]*browserWindow), | ||
| 229 | floatingSessions: make(map[floatingSessionKey]*floatingWorkspaceSession), | 230 | floatingSessions: make(map[floatingSessionKey]*floatingWorkspaceSession), | ||
| 230 | browserWindows: make(map[string]*browserWindow), | 231 | browserWindows: make(map[string]*browserWindow), | ||
| 232 | + | instanceWindows: make(map[string]string), | |||
| 231 | vimModePolicyUC: usecase.NewVimModePolicyUseCase(), | 233 | vimModePolicyUC: usecase.NewVimModePolicyUseCase(), | ||
| 232 | vimNavigationUC: usecase.NewVimNavigationUseCase(), | 234 | vimNavigationUC: usecase.NewVimNavigationUseCase(), | ||
| 233 | pageEditableFocusByPane: make(map[entity.PaneID]bool), | 235 | pageEditableFocusByPane: make(map[entity.PaneID]bool), | ||
| @@ -344,6 +346,9 @@ func (a *App) Run(ctx context.Context, args []string) int { | |||||
| 344 | } | 346 | } | ||
| 345 | 347 | ||||
| 346 | appID := AppID | 348 | appID := AppID | ||
| 349 | + | if a.deps != nil && a.deps.ApplicationID != "" { | |||
| 350 | + | appID = a.deps.ApplicationID | |||
| 351 | + | } | |||
| 347 | a.gtkApp = gtk.NewApplication(&appID, gtkApplicationFlags()) | 352 | a.gtkApp = gtk.NewApplication(&appID, gtkApplicationFlags()) | ||
| 348 | if a.gtkApp == nil { | 353 | if a.gtkApp == nil { | ||
| 349 | log.Error().Msg("failed to create GTK application") | 354 | log.Error().Msg("failed to create GTK application") | ||
| @@ -420,6 +425,12 @@ func (a *App) onActivate(ctx context.Context) { | |||||
| 420 | return | 425 | return | ||
| 421 | } | 426 | } | ||
| 422 | 427 | ||||
| 428 | + | if name := os.Getenv("DUMBER_INITIAL_INSTANCE"); name != "" { | |||
| 429 | + | if initial := a.lastFocusedBrowserWindow(); initial != nil { | |||
| 430 | + | a.instanceWindows[name] = initial.id | |||
| 431 | + | } | |||
| 432 | + | } | |||
| 433 | + | ||||
| 423 | a.installCrashReportNotifier(ctx) | 434 | a.installCrashReportNotifier(ctx) | ||
| 424 | a.initFocusManager() | 435 | a.initFocusManager() | ||
| 425 | a.registerAccentHandlers(ctx) | 436 | a.registerAccentHandlers(ctx) | ||
M internal/ui/app_window_lifecycle.go +48 -0
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | ||
| 7 | 7 | "github.com/bnema/dumber/internal/application/usecase" | |
| 8 | 8 | "github.com/bnema/dumber/internal/domain/entity" | |
| 9 | + | domainurl "github.com/bnema/dumber/internal/domain/url" | |
| 9 | 10 | "github.com/bnema/dumber/internal/logging" | |
| 10 | 11 | "github.com/bnema/dumber/internal/ui/window" | |
| 11 | 12 | "github.com/bnema/puregotk/v4/gtk" | |
| @@ -102,6 +103,53 @@ func (a *App) openInitialBrowserWindowShell(ctx context.Context, initialURL stri | |||
| 102 | 103 | return nil | |
| 103 | 104 | } | |
| 104 | 105 | ||
| 106 | + | // OpenInstance opens a dedicated window for a logical instance, or focuses the | |
| 107 | + | // existing window and opens the requested URL there. All instances owned by an | |
| 108 | + | // App share the same engine and CEF request context. | |
| 109 | + | func (a *App) OpenInstance(ctx context.Context, name, url string) error { | |
| 110 | + | if name == "" { | |
| 111 | + | return fmt.Errorf("instance name must not be empty") | |
| 112 | + | } | |
| 113 | + | url = domainurl.Normalize(url) | |
| 114 | + | return a.dispatchWindowURLWork(ctx, "ui.open_instance", url, func(ctx context.Context) error { | |
| 115 | + | if a.instanceWindows == nil { | |
| 116 | + | a.instanceWindows = make(map[string]string) | |
| 117 | + | } | |
| 118 | + | if windowID := a.instanceWindows[name]; windowID != "" { | |
| 119 | + | if existing := a.browserWindows[windowID]; existing != nil { | |
| 120 | + | a.activateBrowserWindow(existing) | |
| 121 | + | return a.openExternalURLInWindowOnMainThread(ctx, url, existing) | |
| 122 | + | } | |
| 123 | + | delete(a.instanceWindows, name) | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | before := make(map[string]struct{}, len(a.browserWindows)) | |
| 127 | + | for id := range a.browserWindows { | |
| 128 | + | before[id] = struct{}{} | |
| 129 | + | } | |
| 130 | + | if err := a.openFreshWindow(ctx, url); err != nil { | |
| 131 | + | return err | |
| 132 | + | } | |
| 133 | + | for id := range a.browserWindows { | |
| 134 | + | if _, existed := before[id]; !existed { | |
| 135 | + | a.instanceWindows[name] = id | |
| 136 | + | return nil | |
| 137 | + | } | |
| 138 | + | } | |
| 139 | + | return fmt.Errorf("instance %q did not create a browser window", name) | |
| 140 | + | }) | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | func (a *App) openExternalURLInWindowOnMainThread(ctx context.Context, url string, target *browserWindow) error { | |
| 144 | + | if target == nil { | |
| 145 | + | return fmt.Errorf("target browser window is nil") | |
| 146 | + | } | |
| 147 | + | a.activateBrowserWindow(target) | |
| 148 | + | return a.openExternalURLInFocusedWindow(ctx, url, entity.ExternalLinksConfig{ | |
| 149 | + | Behavior: entity.ExternalLinkBehaviorTabbed, | |
| 150 | + | }) | |
| 151 | + | } | |
| 152 | + | ||
| 105 | 153 | func (a *App) openFreshWindow(ctx context.Context, url string) error { | |
| 106 | 154 | log := logging.FromContext(ctx) | |
| 107 | 155 | log.Debug(). | |
| @@ -6,6 +6,7 @@ import ( | |||||
| 6 | 6 | ||||
| 7 | "github.com/bnema/dumber/internal/application/usecase" | 7 | "github.com/bnema/dumber/internal/application/usecase" | ||
| 8 | "github.com/bnema/dumber/internal/domain/entity" | 8 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 9 | + | domainurl "github.com/bnema/dumber/internal/domain/url" | |||
| 9 | "github.com/bnema/dumber/internal/logging" | 10 | "github.com/bnema/dumber/internal/logging" | ||
| 10 | "github.com/bnema/dumber/internal/ui/window" | 11 | "github.com/bnema/dumber/internal/ui/window" | ||
| 11 | "github.com/bnema/puregotk/v4/gtk" | 12 | "github.com/bnema/puregotk/v4/gtk" | ||
| @@ -102,6 +103,53 @@ func (a *App) openInitialBrowserWindowShell(ctx context.Context, initialURL stri | |||||
| 102 | return nil | 103 | return nil | ||
| 103 | } | 104 | } | ||
| 104 | 105 | ||||
| 106 | + | // OpenInstance opens a dedicated window for a logical instance, or focuses the | |||
| 107 | + | // existing window and opens the requested URL there. All instances owned by an | |||
| 108 | + | // App share the same engine and CEF request context. | |||
| 109 | + | func (a *App) OpenInstance(ctx context.Context, name, url string) error { | |||
| 110 | + | if name == "" { | |||
| 111 | + | return fmt.Errorf("instance name must not be empty") | |||
| 112 | + | } | |||
| 113 | + | url = domainurl.Normalize(url) | |||
| 114 | + | return a.dispatchWindowURLWork(ctx, "ui.open_instance", url, func(ctx context.Context) error { | |||
| 115 | + | if a.instanceWindows == nil { | |||
| 116 | + | a.instanceWindows = make(map[string]string) | |||
| 117 | + | } | |||
| 118 | + | if windowID := a.instanceWindows[name]; windowID != "" { | |||
| 119 | + | if existing := a.browserWindows[windowID]; existing != nil { | |||
| 120 | + | a.activateBrowserWindow(existing) | |||
| 121 | + | return a.openExternalURLInWindowOnMainThread(ctx, url, existing) | |||
| 122 | + | } | |||
| 123 | + | delete(a.instanceWindows, name) | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | before := make(map[string]struct{}, len(a.browserWindows)) | |||
| 127 | + | for id := range a.browserWindows { | |||
| 128 | + | before[id] = struct{}{} | |||
| 129 | + | } | |||
| 130 | + | if err := a.openFreshWindow(ctx, url); err != nil { | |||
| 131 | + | return err | |||
| 132 | + | } | |||
| 133 | + | for id := range a.browserWindows { | |||
| 134 | + | if _, existed := before[id]; !existed { | |||
| 135 | + | a.instanceWindows[name] = id | |||
| 136 | + | return nil | |||
| 137 | + | } | |||
| 138 | + | } | |||
| 139 | + | return fmt.Errorf("instance %q did not create a browser window", name) | |||
| 140 | + | }) | |||
| 141 | + | } | |||
| 142 | + | ||||
| 143 | + | func (a *App) openExternalURLInWindowOnMainThread(ctx context.Context, url string, target *browserWindow) error { | |||
| 144 | + | if target == nil { | |||
| 145 | + | return fmt.Errorf("target browser window is nil") | |||
| 146 | + | } | |||
| 147 | + | a.activateBrowserWindow(target) | |||
| 148 | + | return a.openExternalURLInFocusedWindow(ctx, url, entity.ExternalLinksConfig{ | |||
| 149 | + | Behavior: entity.ExternalLinkBehaviorTabbed, | |||
| 150 | + | }) | |||
| 151 | + | } | |||
| 152 | + | ||||
| 105 | func (a *App) openFreshWindow(ctx context.Context, url string) error { | 153 | func (a *App) openFreshWindow(ctx context.Context, url string) error { | ||
| 106 | log := logging.FromContext(ctx) | 154 | log := logging.FromContext(ctx) | ||
| 107 | log.Debug(). | 155 | log.Debug(). | ||
M internal/ui/browser_window.go +5 -0
| @@ -388,6 +388,11 @@ func (a *App) removeBrowserWindow(id string) { | |||
| 388 | 388 | a.contentCoord.ClearPopupNamedContextsForWindow(id) | |
| 389 | 389 | } | |
| 390 | 390 | delete(a.browserWindows, id) | |
| 391 | + | for name, windowID := range a.instanceWindows { | |
| 392 | + | if windowID == id { | |
| 393 | + | delete(a.instanceWindows, name) | |
| 394 | + | } | |
| 395 | + | } | |
| 391 | 396 | ||
| 392 | 397 | fallback := a.deterministicBrowserWindowFallback() | |
| 393 | 398 | a.updateLastFocusedWindowAfterRemoval(id, fallback) | |
| @@ -388,6 +388,11 @@ func (a *App) removeBrowserWindow(id string) { | |||||
| 388 | a.contentCoord.ClearPopupNamedContextsForWindow(id) | 388 | a.contentCoord.ClearPopupNamedContextsForWindow(id) | ||
| 389 | } | 389 | } | ||
| 390 | delete(a.browserWindows, id) | 390 | delete(a.browserWindows, id) | ||
| 391 | + | for name, windowID := range a.instanceWindows { | |||
| 392 | + | if windowID == id { | |||
| 393 | + | delete(a.instanceWindows, name) | |||
| 394 | + | } | |||
| 395 | + | } | |||
| 391 | 396 | ||||
| 392 | fallback := a.deterministicBrowserWindowFallback() | 397 | fallback := a.deterministicBrowserWindowFallback() | ||
| 393 | a.updateLastFocusedWindowAfterRemoval(id, fallback) | 398 | a.updateLastFocusedWindowAfterRemoval(id, fallback) | ||
M internal/ui/deps.go +2 -0
| @@ -16,6 +16,8 @@ import ( | |||
| 16 | 16 | // Dependencies holds all injected dependencies for the UI layer. | |
| 17 | 17 | // This struct is created once at startup and passed to UI components. | |
| 18 | 18 | type Dependencies struct { | |
| 19 | + | // ApplicationID optionally distinguishes the GUI for the Wayland compositor. | |
| 20 | + | ApplicationID string | |
| 19 | 21 | // Core context and configuration | |
| 20 | 22 | Ctx context.Context | |
| 21 | 23 | RuntimeConfig port.RuntimeConfigProvider | |
| @@ -16,6 +16,8 @@ import ( | |||||
| 16 | // Dependencies holds all injected dependencies for the UI layer. | 16 | // Dependencies holds all injected dependencies for the UI layer. | ||
| 17 | // This struct is created once at startup and passed to UI components. | 17 | // This struct is created once at startup and passed to UI components. | ||
| 18 | type Dependencies struct { | 18 | type Dependencies struct { | ||
| 19 | + | // ApplicationID optionally distinguishes the GUI for the Wayland compositor. | |||
| 20 | + | ApplicationID string | |||
| 19 | // Core context and configuration | 21 | // Core context and configuration | ||
| 20 | Ctx context.Context | 22 | Ctx context.Context | ||
| 21 | RuntimeConfig port.RuntimeConfigProvider | 23 | RuntimeConfig port.RuntimeConfigProvider | ||