detached df4f3ad

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 UnverifiedB5690EEEBB952194 The 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.
66
77 ### Added
88
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.
910 - **Omnibox visual styles**: Added configurable `command`, `multiplexer`, and `minimal` GTK treatments. The terminal-inspired `command` palette is the default.
1011 - **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.
1112 - **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 ### Added7 ### 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 @@
7171 });
7272 }
7373
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+
7489 if (typeof WebAssembly !== 'object') {
7590 renderWasmError('WebAssembly is not supported in this browser: ');
7691 } else if (typeof Go !== 'function') {
@@ -86,7 +101,8 @@
86101 goExitCode = code;
87102 goExit(code);
88103 };
89- instantiateWasm(go, './systemviews.wasm')
104+ waitForBridge()
105+ .then(() => instantiateWasm(go, './systemviews.wasm'))
90106 // The runtime stays alive: a resolved go.run means main returned
91107 // instead of serving, which is itself a terminal failure.
92108 .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 returned106 // 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) {
7575 if len(args) > 1 {
7676 switch args[1] {
7777 case "browse":
78- if len(args) > 3 {
78+ url, _, ok := cmd.ParseBrowseArgs(args[2:])
79+ if !ok {
7980 return launchModeCLI, ""
8081 }
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
8883 case "omnibox":
8984 if len(args) > 2 {
9085 return launchModeCLI, ""
@@ -221,6 +216,16 @@ func main() {
221216 if mode == launchModeBrowse {
222217 cfg := initConfig()
223218 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+ }
224229 configureBrowserLaunchRelay(cfg)
225230 startupURL := domainurl.ResolveBrowserStartupURL(browseURL)
226231 freshWindow := os.Getenv(desktop.FreshWindowLaunchEnvVar) == "1"
@@ -243,7 +248,7 @@ func main() {
243248 }
244249
245250 initialURL = startupURL
246- restoreSessionID = os.Getenv("DUMBER_RESTORE_SESSION")
251+ restoreSessionID = os.Getenv(desktop.RestoreSessionEnvVar)
247252 os.Args = os.Args[:1]
248253 os.Exit(runGUI(cfg, timing))
249254 return
@@ -277,6 +282,70 @@ func setAlreadyRunningRelaunchHandler(engine port.Engine, app *ui.App, ctx conte
277282 }
278283
279284 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 {
280349 bootstrap.ApplyGTKIMModuleFallbackDefault(os.Stderr)
281350
282351 runtime.LockOSThread()
@@ -296,7 +365,7 @@ func runGUI(cfg *config.Config, timing startupTiming) int {
296365 timer.Mark("logger")
297366 bootstrapLog := logging.FromContext(ctx)
298367
299- initResult, err := runParallelInitPhase(ctx, cfg)
368+ initResult, err := runParallelInitPhase(ctx, cfg, profile)
300369 if err != nil {
301370 return 1
302371 }
@@ -311,14 +380,17 @@ func runGUI(cfg *config.Config, timing startupTiming) int {
311380
312381 engine, repos, dbCleanup, err := initStackAndRepos(ctx, cfg, initResult, needsEagerDB)
313382 if err != nil {
314- bootstrapLog.Fatal().Err(err).Msg("failed to initialize database/webkit")
383+ return reportGUIStackError(bootstrapLog, err)
315384 }
316385 if dbCleanup != nil {
317386 defer dbCleanup()
318387 }
319388 timer.Mark("db_webkit_parallel")
320389
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+ }
322394 defer sessionCleanup()
323395 bootstrap.LogResolvedTheme(ctx, initResult.ResolvedTheme)
324396 timer.Mark("session")
@@ -366,7 +438,7 @@ func runStandaloneOmnibox() int {
366438 ctx := initStartupContext(cfg)
367439 applyCEFRenderStackDefault(ctx, cfg)
368440
369- initResult, err := runParallelInitPhase(ctx, cfg)
441+ initResult, err := runParallelInitPhase(ctx, cfg, runtimeprofile.Profile{})
370442 if err != nil {
371443 return 1
372444 }
@@ -567,28 +639,35 @@ func activateCEFStartupTraceForGUI(
567639 activate(timing.processEntry, timing.configComplete, logger)
568640 }
569641
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) {
571643 initResult, err := bootstrap.RunParallelInit(bootstrap.ParallelInitInput{
572- Ctx: ctx,
573- Config: cfg,
644+ Ctx: ctx,
645+ Config: cfg,
646+ RuntimeProfile: profile,
574647 })
575648 if err != nil {
576- handleParallelInitError(ctx, err)
577- return nil, err
649+ return nil, reportParallelInitError(ctx, err)
578650 }
579651 return initResult, nil
580652 }
581653
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+
582662 func initBrowserSession(
583663 ctx context.Context,
584664 cfg *config.Config,
585665 repos *repositories,
586- bootstrapLog *zerolog.Logger,
587-) (context.Context, *bootstrap.BrowserSession, func()) {
666+) (context.Context, *bootstrap.BrowserSession, func(), error) {
588667 deferPersist := restoreSessionID == "" && !cfg.Session.AutoRestore
589668 browserSession, sessionCtx, err := bootstrap.StartBrowserSession(ctx, cfg, repos.session, deferPersist)
590669 if err != nil {
591- bootstrapLog.Fatal().Err(err).Msg("failed to start session")
670+ return ctx, nil, nil, err
592671 }
593672 cleanup := func() {
594673 _ = browserSession.End(sessionCtx)
@@ -596,7 +675,7 @@ func initBrowserSession(
596675 browserSession.LogCleanup()
597676 }
598677 }
599- return sessionCtx, browserSession, cleanup
678+ return sessionCtx, browserSession, cleanup, nil
600679 }
601680
602681 func closeIdleInhibitor(inhibitor port.IdleInhibitor) {
@@ -625,6 +704,9 @@ func buildAndConfigureApp(
625704 return nil, err
626705 }
627706 configureDeferredInit(uiDeps, cfg, browserSession)
707+ if initResult.RuntimeProfile.InstanceRoot != "" {
708+ uiDeps.ApplicationID = ui.AppID + ".instance." + initResult.RuntimeProfile.InstanceAppIDSuffix
709+ }
628710 return ui.New(uiDeps)
629711 }
630712
@@ -765,15 +847,17 @@ func initConfig() *config.Config {
765847 return config.Get()
766848 }
767849
768-func handleParallelInitError(ctx context.Context, err error) {
850+func reportParallelInitError(ctx context.Context, err error) error {
769851 log := logging.FromContext(ctx)
770852 if runtimeErr, ok := err.(*bootstrap.RuntimeRequirementsError); ok {
771853 runtimeErr.LogDetails(ctx)
772- log.Fatal().Err(runtimeErr).
854+ log.Error().Err(runtimeErr).
773855 Str("hint", "Run: dumber doctor (and set runtime.prefix for /opt installs)").
774856 Msg("runtime requirements not met")
857+ return err
775858 }
776- log.Fatal().Err(err).Msg("initialization failed")
859+ log.Error().Err(err).Msg("initialization failed")
860+ return err
777861 }
778862
779863 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 = startupURL250 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 return254 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 1370 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 1443 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, nil651 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.AutoRestore667 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, cleanup678+ 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, err704 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 (
1414 "github.com/rs/zerolog"
1515 )
1616
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+
1740 func TestLaunchModeFromArgs_DetectsStandaloneOmnibox(t *testing.T) {
1841 mode, _ := launchModeFromArgs([]string{"dumber", "omnibox"})
1942 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 (
2121 github.com/pressly/goose/v3 v3.28.0
2222 github.com/rs/zerolog v1.35.1
2323 github.com/spf13/cobra v1.10.2
24+ github.com/spf13/pflag v1.0.10
2425 github.com/spf13/viper v1.21.0
2526 github.com/stretchr/testify v1.12.1
2627 golang.org/x/image v0.46.0
@@ -65,7 +66,6 @@ require (
6566 github.com/sethvargo/go-retry v0.4.0 // indirect
6667 github.com/spf13/afero v1.15.0 // indirect
6768 github.com/spf13/cast v1.10.0 // indirect
68- github.com/spf13/pflag v1.0.10 // indirect
6969 github.com/stretchr/objx v0.5.3 // indirect
7070 github.com/subosito/gotenv v1.6.0 // indirect
7171 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
@@ -21,6 +21,7 @@ require (
21 github.com/pressly/goose/v3 v3.28.021 github.com/pressly/goose/v3 v3.28.0
22 github.com/rs/zerolog v1.35.122 github.com/rs/zerolog v1.35.1
23 github.com/spf13/cobra v1.10.223 github.com/spf13/cobra v1.10.2
24+ github.com/spf13/pflag v1.0.10
24 github.com/spf13/viper v1.21.025 github.com/spf13/viper v1.21.0
25 github.com/stretchr/testify v1.12.126 github.com/stretchr/testify v1.12.1
26 golang.org/x/image v0.46.027 golang.org/x/image v0.46.0
@@ -65,7 +66,6 @@ require (
65 github.com/sethvargo/go-retry v0.4.0 // indirect66 github.com/sethvargo/go-retry v0.4.0 // indirect
66 github.com/spf13/afero v1.15.0 // indirect67 github.com/spf13/afero v1.15.0 // indirect
67 github.com/spf13/cast v1.10.0 // indirect68 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 // indirect69 github.com/stretchr/objx v0.5.3 // indirect
70 github.com/subosito/gotenv v1.6.0 // indirect70 github.com/subosito/gotenv v1.6.0 // indirect
71 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect71 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 {
1111 OpenFreshWindow(ctx context.Context, url string) error
1212 }
1313
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+
1427 // BrowserLaunchRelay delivers browser URL launch requests.
1528 type BrowserLaunchRelay interface {
1629 // DeliverOpenExternalURL attempts to deliver a request to open an external URL.
@@ -25,6 +38,13 @@ type BrowserLaunchRelay interface {
2538 Listen(ctx context.Context, opener BrowserWindowOpener) (io.Closer, error)
2639 }
2740
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+
2848 // AlreadyRunningAppRelaunchHandlerSetter configures a handler for CEF relaunches.
2949 type AlreadyRunningAppRelaunchHandlerSetter interface {
3050 SetAlreadyRunningAppRelaunchHandler(func(string))
@@ -11,6 +11,19 @@ type BrowserWindowOpener interface {
11 OpenFreshWindow(ctx context.Context, url string) error11 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) {
121121 ImageDataResolver: webkit.NewContextMenuResolver(),
122122 }
123123 audioFactory := audiofactory.NewAudioOutputFactory()
124- return cef.NewEngine(input.Ctx, opts, cef.RuntimePaths{
124+ paths := cef.RuntimePaths{
125125 StateRoot: profile.CEFUserDataDir(),
126126 LogFile: profile.CEFLogFile(),
127127 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)
129133 default:
130134 return nil, fmt.Errorf("unknown engine type: %q", engineType)
131135 }
@@ -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 {
5252
5353 // ParallelInitInput holds the input for parallel initialization.
5454 type ParallelInitInput struct {
55- Ctx context.Context
56- Config *config.Config
55+ RuntimeProfile runtimeprofile.Profile
56+ Ctx context.Context
57+ Config *config.Config
5758 }
5859
5960 // DeferredInitInput holds the input for deferred initialization.
@@ -101,9 +102,13 @@ func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) {
101102 wg sync.WaitGroup
102103 )
103104
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+ }
107112 }
108113
109114 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.Context55+ RuntimeProfile runtimeprofile.Profile
56- Config *config.Config56+ 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.WaitGroup102 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
22
33 import (
44 "context"
5+ "fmt"
6+ "strings"
57
68 "github.com/bnema/dumber/internal/application/port"
9+ "github.com/bnema/dumber/internal/domain/entity"
710 "github.com/bnema/dumber/internal/infrastructure/cef"
811 "github.com/bnema/dumber/internal/infrastructure/config"
912 "github.com/bnema/dumber/internal/infrastructure/desktop"
@@ -13,9 +16,18 @@ import (
1316 // NewSessionSpawner wires the session spawner with any engine-specific launch
1417 // environment required by the current browser backend.
1518 func NewSessionSpawner(ctx context.Context, profile runtimeprofile.Profile) port.SessionSpawner {
19+ if strings.HasPrefix(profile.InstanceAppIDSuffix, "ephemeral-") {
20+ return ephemeralSessionSpawner{}
21+ }
1622 var spawnEnv port.SessionSpawnEnvironment
1723 if profile.Engine == config.EngineTypeCEF {
1824 spawnEnv = cef.NewSessionSpawnEnvironment(profile.CEFUserDataDir())
1925 }
2026 return desktop.NewSessionSpawner(ctx, spawnEnv)
2127 }
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 launch16 // 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.SessionSpawnEnvironment22 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 (
55 "errors"
66 "fmt"
77 "os"
8+ "strings"
89
910 "github.com/spf13/cobra"
1011
@@ -112,15 +113,42 @@ var browseCmd = &cobra.Command{
112113
113114 If a URL is provided, navigate to it. Otherwise, open the homepage.
114115
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+
115124 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+ },
118145 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.
120147 },
121148 }
122149
123150 func init() {
151+ browseCmd.Flags().AddFlagSet(browseFlags())
124152 rootCmd.AddCommand(browseCmd)
125153 }
126154
@@ -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 homepage125+ 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 runs146+ // 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
433433 }
434434
435435 args := parseRelaunchCommandLineArgs(commandLine.GetCommandLineString())
436- if len(args) < 3 || args[1] != "browse" || args[2] == "" {
436+ if len(args) < 2 || args[1] != "browse" {
437437 return "", false
438438 }
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
440468 }
441469
442470 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 "", false437 return "", false
438 }438 }
439- return args[2], true439+ 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) {
112112 }
113113 }
114114
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+
115143 func TestDumberBPH_OnAlreadyRunningAppRelaunch_ForwardsBrowseURLAndReturns1(t *testing.T) {
116144 t.Parallel()
117145
@@ -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
2929
3030 // RuntimePaths contains the concrete filesystem paths the CEF adapter needs.
3131 type RuntimePaths struct {
32+ // IsolatedRoot takes precedence over inherited session-spawn environment.
33+ IsolatedRoot string
3234 StateRoot string
3335 LogFile string
3436 ProfileLogDir string
@@ -44,7 +46,7 @@ func NewEngine(
4446 deps EngineDependencies,
4547 ) (*Engine, error) {
4648 logger := logging.FromContext(ctx)
47- stateRoot := resolvedStateRoot(paths.StateRoot, opts)
49+ stateRoot := paths.stateRoot(opts)
4850 if consumeNextStartSafetyMarker(stateRoot) {
4951 applyNextStartSafetyEnvironment()
5052 logger.Warn().Str("state_root", stateRoot).Msg("cef: applying next-start GPU safety recovery")
@@ -136,6 +138,13 @@ func normalizedWindowlessFrameRate(frameRate int32) int32 {
136138 return defaultCEFWindowlessFrameRate
137139 }
138140
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+
139148 func resolvedStateRoot(defaultStateRoot string, opts port.EngineOptions) string {
140149 if root := os.Getenv(CEFRootCachePathEnvVar); root != "" {
141150 return root
@@ -189,7 +198,11 @@ func prepareCEFSettings(
189198 settings := purecef.DefaultSettings()
190199 settings.MultiThreadedMessageLoop = true
191200 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
193206 settings.CEFDir = configureCEFSubprocessRuntime(cfg.CEFDir)
194207 if runtimeLogFile, err := prepareCEFLogFile(paths.LogFile, cfg.LogFile); err != nil {
195208 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 string34 StateRoot string
33 LogFile string35 LogFile string
34 ProfileLogDir string36 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 defaultCEFWindowlessFrameRate138 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 root150 return root
@@ -189,7 +198,11 @@ func prepareCEFSettings(
189 settings := purecef.DefaultSettings()198 settings := purecef.DefaultSettings()
190 settings.MultiThreadedMessageLoop = true199 settings.MultiThreadedMessageLoop = true
191 settings.ExternalMessagePump = false200 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 (
1313 "github.com/stretchr/testify/require"
1414 )
1515
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+
1629 func TestResolvedStateRoot_PrefersCacheDir(t *testing.T) {
1730 profile := testCEFDevProfile(t)
1831 root := resolvedStateRoot(profile.CEFUserDataDir(), port.EngineOptions{DataDir: "/tmp/data", CacheDir: "/tmp/cache"})
@@ -62,19 +75,15 @@ func TestPrepareCEFSettings_UsesResolvedProfilePaths(t *testing.T) {
6275 require.Empty(t, settings.BrowserSubprocessPath)
6376 }
6477
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) {
7281 logger := zerolog.Nop()
7382 profile := testCEFDevProfile(t)
7483 settings, err := prepareCEFSettings(port.EngineOptions{}, RuntimePaths{StateRoot: profile.CEFUserDataDir(), LogFile: profile.CEFLogFile()}, RuntimeConfig{}, &logger)
7584 require.NoError(t, err)
7685 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)
7887 }
7988
8089 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 cache78+// TestPrepareCEFSettings_ConfiguresPersistentGlobalRequestContext locks the
66-// contract: only RootCachePath is configured and CachePath stays empty, so79+// 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 (
1313 "os"
1414 "path/filepath"
1515 "sync"
16+ "sync/atomic"
1617 "syscall"
1718 "time"
1819
@@ -37,7 +38,11 @@ const browserLaunchDirPerm = 0o700
3738 var ErrBrowserLaunchRelayUnconfirmed = errors.New("browser launch relay did not confirm delivery")
3839
3940 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
4146 // admission is the shared admission/work-lease boundary: a lease is
4247 // acquired before sending Accepted and released when dispatch settles.
4348 // Shutdown closes it so losing requests get an error response instead
@@ -48,6 +53,7 @@ type browserLaunchRelay struct {
4853 type browserLaunchRequest struct {
4954 RequestID string `json:"request_id,omitempty"`
5055 URL string `json:"url"`
56+ Instance string `json:"instance,omitempty"`
5157 Action browserLaunchAction `json:"action,omitempty"`
5258 }
5359
@@ -56,6 +62,7 @@ type browserLaunchAction string
5662 const (
5763 browserLaunchActionOpenExternalURL browserLaunchAction = "open-external-url"
5864 browserLaunchActionOpenFreshWindow browserLaunchAction = "open-fresh-window"
65+ browserLaunchActionOpenInstance browserLaunchAction = "open-instance"
5966 // browserLaunchActionCloseAllWindows is a diagnostic-only action for the
6067 // residency harness: it closes every user window through the standard
6168 // removal path. It is honored only when the OWNING process sets
@@ -85,9 +92,13 @@ type browserLaunchResponse struct {
8592 type browserLaunchRelayListener struct {
8693 listener *net.UnixListener
8794 socketPath string
95+ socketInfo os.FileInfo
8896 admission *process.AdmissionGate
97+ seenMu sync.Mutex
98+ seen map[string]struct{}
8999 once sync.Once
90100 err error
101+ closed atomic.Bool
91102 }
92103
93104 var newBrowserLaunchRequestID = func() string {
@@ -120,19 +131,31 @@ func (r *browserLaunchRelay) AdmissionGate() *process.AdmissionGate {
120131 }
121132
122133 func (r *browserLaunchRelay) DeliverOpenExternalURL(ctx context.Context, url string) (bool, error) {
123- return r.deliver(ctx, url, "")
134+ return r.deliver(ctx, url, "", "")
124135 }
125136
126137 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)
128139 }
129140
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) {
131146 socketPath, err := r.socketPath()
132147 if err != nil {
133148 return false, err
134149 }
135150
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+
136159 conn, err := (&net.Dialer{}).DialContext(ctx, "unix", socketPath)
137160 if err != nil {
138161 if isMissingRelayListener(err) {
@@ -152,7 +175,13 @@ func (r *browserLaunchRelay) deliver(ctx context.Context, url string, action bro
152175 if err := setBrowserLaunchConnDeadline(ctx, conn); err != nil {
153176 return false, err
154177 }
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 {
156185 return false, err
157186 }
158187
@@ -273,6 +302,49 @@ func validateBrowserLaunchSocketDirOwned(socketPath string, expectedUID uint32)
273302 }
274303
275304 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) {
276348 socketPath, err := r.socketPath()
277349 if err != nil {
278350 return nil, err
@@ -286,7 +358,7 @@ func (r *browserLaunchRelay) Listen(ctx context.Context, opener port.BrowserWind
286358 }
287359 listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: socketPath, Net: "unix"})
288360 if err != nil {
289- if !errors.Is(err, syscall.EADDRINUSE) {
361+ if !recoverStale || !errors.Is(err, syscall.EADDRINUSE) {
290362 return nil, fmt.Errorf("listen browser launch socket: %w", err)
291363 }
292364
@@ -312,10 +384,21 @@ func (r *browserLaunchRelay) Listen(ctx context.Context, opener port.BrowserWind
312384 }
313385 }
314386
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
319402 }
320403
321404 func (r *browserLaunchRelay) socketPath() (string, error) {
@@ -330,10 +413,17 @@ func (r *browserLaunchRelay) socketPath() (string, error) {
330413
331414 func (l *browserLaunchRelayListener) Close() error {
332415 l.once.Do(func() {
416+ l.closed.Store(true)
333417 if l.listener != nil {
334418 l.err = l.listener.Close()
335419 }
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+ }
337427 })
338428
339429 return l.err
@@ -435,17 +525,30 @@ func (l *browserLaunchRelayListener) handleConnection(ctx context.Context, conn
435525 return
436526 }
437527
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+
438540 if err := conn.SetDeadline(time.Now().Add(browserLaunchIOTimeout)); err != nil {
439541 gate.Release()
440542 return
441543 }
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).
444549 Str("request_id", requestID).
445550 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")
449552 }
450553 log.Debug().
451554 Str("request_id", requestID).
@@ -457,53 +560,76 @@ func (l *browserLaunchRelayListener) handleConnection(ctx context.Context, conn
457560 // definitive factory/dispatch failure. Factory/dispatch failure
458561 // retains the documented existing failure semantics and never
459562 // 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().
470583 Str("request_id", requestID).
471584 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)
492605 }
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")
500611 }
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).
502622 Str("request_id", requestID).
503623 Str("url_host", safeURLHost(request.URL)).
504624 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")
507633 }
508634
509635 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.IPCPaths41+ 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 is46 // 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 instead48 // 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 the66 // browserLaunchActionCloseAllWindows is a diagnostic-only action for the
60 // residency harness: it closes every user window through the standard67 // residency harness: it closes every user window through the standard
61 // removal path. It is honored only when the OWNING process sets68 // 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.UnixListener93 listener *net.UnixListener
87 socketPath string94 socketPath string
95+ socketInfo os.FileInfo
88 admission *process.AdmissionGate96 admission *process.AdmissionGate
97+ seenMu sync.Mutex
98+ seen map[string]struct{}
89 once sync.Once99 once sync.Once
90 err error100 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, err148 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, err176 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, err185 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, err350 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, nil390+ 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.err429 return l.err
@@ -435,17 +525,30 @@ func (l *browserLaunchRelayListener) handleConnection(ctx context.Context, conn
435 return525 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 return542 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 failure560 // definitive factory/dispatch failure. Factory/dispatch failure
458 // retains the documented existing failure semantics and never561 // 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- return569+// 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 error586+ 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- return604+ 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) {
430430 waitForSocketGone(t, ipc.BrowserLaunchSocket)
431431 }
432432
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+
433474 func TestBrowserLaunchRelay_ContextCancelStopsServing(t *testing.T) {
434475 ipc := testIPC(shortTempDir(t))
435476 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
33 import (
44 "context"
55 "database/sql"
6+ "errors"
67 "fmt"
78 "os"
89 "path/filepath"
10+ "strings"
11+ "time"
12+
13+ "golang.org/x/sys/unix"
914
1015 "github.com/bnema/dumber/internal/logging"
1116 _ "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) {
2631 return nil, fmt.Errorf("failed to create database directory: %w", err)
2732 }
2833
34+ lock, err := lockDatabaseStartup(ctx, dbPath+".startup.lock")
35+ if err != nil {
36+ return nil, err
37+ }
38+ defer unlockDatabaseStartup(lock)
39+
2940 // Open database connection
3041 db, err := sql.Open("sqlite3", dbPath)
3142 if err != nil {
@@ -42,7 +53,7 @@ func NewConnection(ctx context.Context, dbPath string) (*sql.DB, error) {
4253 }
4354
4455 // Apply performance pragmas
45- if err := applyPragmas(db); err != nil {
56+ if err := applyPragmas(ctx, db); err != nil {
4657 _ = db.Close()
4758 return nil, err
4859 }
@@ -58,10 +69,14 @@ func NewConnection(ctx context.Context, dbPath string) (*sql.DB, error) {
5869 return db, nil
5970 }
6071
72+const (
73+ pragmaRetryTimeout = 5 * time.Second
74+ lockRetryInterval = 25 * time.Millisecond
75+)
76+
6177 // applyPragmas configures SQLite for optimal performance.
62-func applyPragmas(db *sql.DB) error {
78+func applyPragmas(ctx context.Context, db *sql.DB) error {
6379 pragmas := []string{
64- "PRAGMA journal_mode = WAL", // Write-Ahead Logging for concurrent access
6580 "PRAGMA synchronous = NORMAL", // Safe in WAL mode
6681 "PRAGMA cache_size = -64000", // 64MB cache
6782 "PRAGMA temp_store = MEMORY", // Temporary tables in RAM
@@ -70,15 +85,85 @@ func applyPragmas(db *sql.DB) error {
7085 "PRAGMA foreign_keys = ON", // Enable referential integrity
7186 }
7287
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+
7398 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 {
75103 return fmt.Errorf("failed to set pragma %q: %w", pragma, err)
76104 }
77105 }
78-
79106 return nil
80107 }
81108
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+
82167 // configurePool sets connection pool parameters optimized for SQLite.
83168 // SQLite only supports one writer at a time, so we limit connections.
84169 //
@@ -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 connection40 // 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 pragmas55 // 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, err58 return nil, err
48 }59 }
@@ -58,10 +69,14 @@ func NewConnection(ctx context.Context, dbPath string) (*sql.DB, error) {
58 return db, nil69 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 mode80 "PRAGMA synchronous = NORMAL", // Safe in WAL mode
66 "PRAGMA cache_size = -64000", // 64MB cache81 "PRAGMA cache_size = -64000", // 64MB cache
67 "PRAGMA temp_store = MEMORY", // Temporary tables in RAM82 "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 integrity85 "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 nil106 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 {
6868
6969 // Profile is the fully resolved runtime profile for one mode+engine namespace.
7070 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
7678 }
7779
7880 // Resolve builds a runtime profile from pure inputs.
@@ -201,6 +203,9 @@ func cwdValue(get func() (string, error)) (string, error) {
201203
202204 // CEFUserDataDir returns the resolved CEF root cache/user-data directory.
203205 func (p Profile) CEFUserDataDir() string {
206+ if p.InstanceRoot != "" {
207+ return filepath.Join(p.InstanceRoot, "cef")
208+ }
204209 if p.Mode == ModeDev {
205210 return filepath.Join(p.EnginePaths.RootDir, "data")
206211 }
@@ -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 Mode71+ InstanceRoot string
72- Engine string72+ InstanceAppIDSuffix string
73- Shared SharedPaths73+ Mode Mode
74- EnginePaths EnginePaths74+ Engine string
75- IPC IPCPaths75+ 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 {
9191 runtimeConfig *runtimeConfigState
9292
9393 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
9596 lastFocusedWindowID string
9697 nativePopupWindows map[port.WebViewID]*nativePopupWindow
9798 browserWindowFactory func(context.Context, string) (*browserWindow, error)
@@ -228,6 +229,7 @@ func New(deps *Dependencies) (*App, error) {
228229 windowForTab: make(map[entity.TabID]*browserWindow),
229230 floatingSessions: make(map[floatingSessionKey]*floatingWorkspaceSession),
230231 browserWindows: make(map[string]*browserWindow),
232+ instanceWindows: make(map[string]string),
231233 vimModePolicyUC: usecase.NewVimModePolicyUseCase(),
232234 vimNavigationUC: usecase.NewVimNavigationUseCase(),
233235 pageEditableFocusByPane: make(map[entity.PaneID]bool),
@@ -344,6 +346,9 @@ func (a *App) Run(ctx context.Context, args []string) int {
344346 }
345347
346348 appID := AppID
349+ if a.deps != nil && a.deps.ApplicationID != "" {
350+ appID = a.deps.ApplicationID
351+ }
347352 a.gtkApp = gtk.NewApplication(&appID, gtkApplicationFlags())
348353 if a.gtkApp == nil {
349354 log.Error().Msg("failed to create GTK application")
@@ -420,6 +425,12 @@ func (a *App) onActivate(ctx context.Context) {
420425 return
421426 }
422427
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+
423434 a.installCrashReportNotifier(ctx)
424435 a.initFocusManager()
425436 a.registerAccentHandlers(ctx)
@@ -91,7 +91,8 @@ type App struct {
91 runtimeConfig *runtimeConfigState91 runtimeConfig *runtimeConfigState
92 92
93 browserWindows map[string]*browserWindow93 browserWindows map[string]*browserWindow
94- browserWindowOrder []string // registration order of window IDs94+ browserWindowOrder []string // registration order of window IDs
95+ instanceWindows map[string]string // logical instance name -> browser window ID
95 lastFocusedWindowID string96 lastFocusedWindowID string
96 nativePopupWindows map[port.WebViewID]*nativePopupWindow97 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 := AppID348 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 return425 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 (
66
77 "github.com/bnema/dumber/internal/application/usecase"
88 "github.com/bnema/dumber/internal/domain/entity"
9+ domainurl "github.com/bnema/dumber/internal/domain/url"
910 "github.com/bnema/dumber/internal/logging"
1011 "github.com/bnema/dumber/internal/ui/window"
1112 "github.com/bnema/puregotk/v4/gtk"
@@ -102,6 +103,53 @@ func (a *App) openInitialBrowserWindowShell(ctx context.Context, initialURL stri
102103 return nil
103104 }
104105
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+
105153 func (a *App) openFreshWindow(ctx context.Context, url string) error {
106154 log := logging.FromContext(ctx)
107155 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 nil103 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) {
388388 a.contentCoord.ClearPopupNamedContextsForWindow(id)
389389 }
390390 delete(a.browserWindows, id)
391+ for name, windowID := range a.instanceWindows {
392+ if windowID == id {
393+ delete(a.instanceWindows, name)
394+ }
395+ }
391396
392397 fallback := a.deterministicBrowserWindowFallback()
393398 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 (
1616 // Dependencies holds all injected dependencies for the UI layer.
1717 // This struct is created once at startup and passed to UI components.
1818 type Dependencies struct {
19+ // ApplicationID optionally distinguishes the GUI for the Wayland compositor.
20+ ApplicationID string
1921 // Core context and configuration
2022 Ctx context.Context
2123 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 configuration21 // Core context and configuration
20 Ctx context.Context22 Ctx context.Context
21 RuntimeConfig port.RuntimeConfigProvider23 RuntimeConfig port.RuntimeConfigProvider

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close