detached 39880c9

fix: isolate CEF session restore launches

Give restored sessions their own CEF root cache path so they start as independent Dumber processes, and suppress Chromium fallback for bare browse relaunches.
commit
39880c95b2766361c06fe5f78302690eb03a9b08
author
brice · · 2026-04-15 08:16 UTC
parent
53e2aa3
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +119 -6
M internal/infrastructure/cef/app.go +12 -0
@@ -183,6 +183,15 @@ func parseBrowseRelaunchCommandLine(commandLine purecef.CommandLine) (string, bo
183183 return args[2], true
184184 }
185185
186+func isBrowseRelaunchCommandLine(commandLine purecef.CommandLine) bool {
187+ if commandLine == nil {
188+ return false
189+ }
190+
191+ args := parseRelaunchCommandLineArgs(commandLine.GetCommandLineString())
192+ return len(args) >= 2 && args[1] == "browse"
193+}
194+
186195 func parseBrowseURLFromRelaunchCommandLine(commandLine purecef.CommandLine) string {
187196 browseURL, _ := parseBrowseRelaunchCommandLine(commandLine)
188197 return browseURL
@@ -195,6 +204,9 @@ func (h *dumberBPH) OnAlreadyRunningAppRelaunch(commandLine purecef.CommandLine,
195204 }
196205 return 1
197206 }
207+ if isBrowseRelaunchCommandLine(commandLine) {
208+ return 1
209+ }
198210
199211 return 0
200212 }
@@ -183,6 +183,15 @@ func parseBrowseRelaunchCommandLine(commandLine purecef.CommandLine) (string, bo
183 return args[2], true183 return args[2], true
184 }184 }
185 185
186+func isBrowseRelaunchCommandLine(commandLine purecef.CommandLine) bool {
187+ if commandLine == nil {
188+ return false
189+ }
190+
191+ args := parseRelaunchCommandLineArgs(commandLine.GetCommandLineString())
192+ return len(args) >= 2 && args[1] == "browse"
193+}
194+
186 func parseBrowseURLFromRelaunchCommandLine(commandLine purecef.CommandLine) string {195 func parseBrowseURLFromRelaunchCommandLine(commandLine purecef.CommandLine) string {
187 browseURL, _ := parseBrowseRelaunchCommandLine(commandLine)196 browseURL, _ := parseBrowseRelaunchCommandLine(commandLine)
188 return browseURL197 return browseURL
@@ -195,6 +204,9 @@ func (h *dumberBPH) OnAlreadyRunningAppRelaunch(commandLine purecef.CommandLine,
195 }204 }
196 return 1205 return 1
197 }206 }
207+ if isBrowseRelaunchCommandLine(commandLine) {
208+ return 1
209+ }
198 210
199 return 0211 return 0
200 }212 }
M internal/infrastructure/cef/app_test.go +3 -3
@@ -102,7 +102,7 @@ func TestDumberBPH_OnAlreadyRunningAppRelaunch_ForwardsBrowseURLAndReturns1(t *t
102102 }
103103 }
104104
105-func TestDumberBPH_OnAlreadyRunningAppRelaunch_DoesNotForwardEmptyBrowseURLAndReturns0(t *testing.T) {
105+func TestDumberBPH_OnAlreadyRunningAppRelaunch_DoesNotForwardEmptyBrowseURLAndReturns1(t *testing.T) {
106106 t.Parallel()
107107
108108 gotURL := "sentinel"
@@ -115,8 +115,8 @@ func TestDumberBPH_OnAlreadyRunningAppRelaunch_DoesNotForwardEmptyBrowseURLAndRe
115115 commandLineString: "dumber browse",
116116 }, "")
117117
118- if ret != 0 {
119- t.Fatalf("OnAlreadyRunningAppRelaunch returned %d, want 0", ret)
118+ if ret != 1 {
119+ t.Fatalf("OnAlreadyRunningAppRelaunch returned %d, want 1", ret)
120120 }
121121 if gotURL != "sentinel" {
122122 t.Fatalf("forwarded browse url = %q, want %q", gotURL, "sentinel")
@@ -102,7 +102,7 @@ func TestDumberBPH_OnAlreadyRunningAppRelaunch_ForwardsBrowseURLAndReturns1(t *t
102 }102 }
103 }103 }
104 104
105-func TestDumberBPH_OnAlreadyRunningAppRelaunch_DoesNotForwardEmptyBrowseURLAndReturns0(t *testing.T) {105+func TestDumberBPH_OnAlreadyRunningAppRelaunch_DoesNotForwardEmptyBrowseURLAndReturns1(t *testing.T) {
106 t.Parallel()106 t.Parallel()
107 107
108 gotURL := "sentinel"108 gotURL := "sentinel"
@@ -115,8 +115,8 @@ func TestDumberBPH_OnAlreadyRunningAppRelaunch_DoesNotForwardEmptyBrowseURLAndRe
115 commandLineString: "dumber browse",115 commandLineString: "dumber browse",
116 }, "")116 }, "")
117 117
118- if ret != 0 {118+ if ret != 1 {
119- t.Fatalf("OnAlreadyRunningAppRelaunch returned %d, want 0", ret)119+ t.Fatalf("OnAlreadyRunningAppRelaunch returned %d, want 1", ret)
120 }120 }
121 if gotURL != "sentinel" {121 if gotURL != "sentinel" {
122 t.Fatalf("forwarded browse url = %q, want %q", gotURL, "sentinel")122 t.Fatalf("forwarded browse url = %q, want %q", gotURL, "sentinel")
M internal/infrastructure/cef/engine_init.go +10 -0
@@ -22,6 +22,7 @@ import (
2222 )
2323
2424 const puregoCEFInitTraceEnvVar = "PUREGO_CEF_INIT_TRACE"
25+const CEFRootCachePathEnvVar = "DUMBER_CEF_ROOT_CACHE_PATH"
2526
2627 // NewEngine initializes the CEF runtime and returns a ready-to-use Engine.
2728 func NewEngine(
@@ -89,6 +90,10 @@ func NewEngine(
8990 }
9091
9192 func resolvedStateRoot(opts port.EngineOptions) string {
93+ if root := os.Getenv(CEFRootCachePathEnvVar); root != "" {
94+ return root
95+ }
96+
9297 switch {
9398 case opts.DataDir != "":
9499 return opts.DataDir
@@ -159,6 +164,11 @@ func defaultCEFUserDataDir() string {
159164 return filepath.Join(dirs.DataHome, "cef_user_data")
160165 }
161166
167+// DefaultCEFUserDataDir returns the default CEF state root.
168+func DefaultCEFUserDataDir() string {
169+ return defaultCEFUserDataDir()
170+}
171+
162172 // initializeCEF calls cef_initialize with the App to register custom schemes
163173 // and browser-process callbacks (OnBeforeCommandLineProcessing, etc.).
164174 func initializeCEF(eng *Engine, settings purecef.Settings, logger *zerolog.Logger) error {
@@ -22,6 +22,7 @@ import (
22 )22 )
23 23
24 const puregoCEFInitTraceEnvVar = "PUREGO_CEF_INIT_TRACE"24 const puregoCEFInitTraceEnvVar = "PUREGO_CEF_INIT_TRACE"
25+const CEFRootCachePathEnvVar = "DUMBER_CEF_ROOT_CACHE_PATH"
25 26
26 // NewEngine initializes the CEF runtime and returns a ready-to-use Engine.27 // NewEngine initializes the CEF runtime and returns a ready-to-use Engine.
27 func NewEngine(28 func NewEngine(
@@ -89,6 +90,10 @@ func NewEngine(
89 }90 }
90 91
91 func resolvedStateRoot(opts port.EngineOptions) string {92 func resolvedStateRoot(opts port.EngineOptions) string {
93+ if root := os.Getenv(CEFRootCachePathEnvVar); root != "" {
94+ return root
95+ }
96+
92 switch {97 switch {
93 case opts.DataDir != "":98 case opts.DataDir != "":
94 return opts.DataDir99 return opts.DataDir
@@ -159,6 +164,11 @@ func defaultCEFUserDataDir() string {
159 return filepath.Join(dirs.DataHome, "cef_user_data")164 return filepath.Join(dirs.DataHome, "cef_user_data")
160 }165 }
161 166
167+// DefaultCEFUserDataDir returns the default CEF state root.
168+func DefaultCEFUserDataDir() string {
169+ return defaultCEFUserDataDir()
170+}
171+
162 // initializeCEF calls cef_initialize with the App to register custom schemes172 // initializeCEF calls cef_initialize with the App to register custom schemes
163 // and browser-process callbacks (OnBeforeCommandLineProcessing, etc.).173 // and browser-process callbacks (OnBeforeCommandLineProcessing, etc.).
164 func initializeCEF(eng *Engine, settings purecef.Settings, logger *zerolog.Logger) error {174 func initializeCEF(eng *Engine, settings purecef.Settings, logger *zerolog.Logger) error {
M internal/infrastructure/cef/engine_init_test.go +7 -0
@@ -22,6 +22,13 @@ func TestResolvedStateRoot_FallsBackToCacheDir(t *testing.T) {
2222 require.Equal(t, "/tmp/cache", root)
2323 }
2424
25+func TestResolvedStateRoot_UsesEnvOverride(t *testing.T) {
26+ t.Setenv(CEFRootCachePathEnvVar, "/tmp/override")
27+
28+ root := resolvedStateRoot(port.EngineOptions{})
29+ require.Equal(t, "/tmp/override", root)
30+}
31+
2532 func TestPrepareCEFSettings_UsesResolvedStateRoot(t *testing.T) {
2633 logger := zerolog.Nop()
2734 settings, err := prepareCEFSettings(port.EngineOptions{CacheDir: "/tmp/cache"}, RuntimeConfig{}, &logger)
@@ -22,6 +22,13 @@ func TestResolvedStateRoot_FallsBackToCacheDir(t *testing.T) {
22 require.Equal(t, "/tmp/cache", root)22 require.Equal(t, "/tmp/cache", root)
23 }23 }
24 24
25+func TestResolvedStateRoot_UsesEnvOverride(t *testing.T) {
26+ t.Setenv(CEFRootCachePathEnvVar, "/tmp/override")
27+
28+ root := resolvedStateRoot(port.EngineOptions{})
29+ require.Equal(t, "/tmp/override", root)
30+}
31+
25 func TestPrepareCEFSettings_UsesResolvedStateRoot(t *testing.T) {32 func TestPrepareCEFSettings_UsesResolvedStateRoot(t *testing.T) {
26 logger := zerolog.Nop()33 logger := zerolog.Nop()
27 settings, err := prepareCEFSettings(port.EngineOptions{CacheDir: "/tmp/cache"}, RuntimeConfig{}, &logger)34 settings, err := prepareCEFSettings(port.EngineOptions{CacheDir: "/tmp/cache"}, RuntimeConfig{}, &logger)
M internal/infrastructure/desktop/adapter.go +11 -2
@@ -11,6 +11,7 @@ import (
1111
1212 "github.com/bnema/dumber/internal/application/port"
1313 "github.com/bnema/dumber/internal/domain/entity"
14+ "github.com/bnema/dumber/internal/infrastructure/cef"
1415 "github.com/bnema/dumber/internal/logging"
1516 )
1617
@@ -492,8 +493,12 @@ func (s *SessionSpawner) SpawnWithSession(sessionID entity.SessionID) error {
492493 // Start dumber browse with session ID in environment
493494 cmd := exec.Command(execPath, "browse")
494495
495- // Inherit environment and add restore session ID
496- cmd.Env = append(sanitizedChildEnv(os.Environ()), RestoreSessionEnvVar+"="+string(sessionID))
496+ // Restore sessions get an isolated CEF state root.
497+ cmd.Env = append(
498+ sanitizedChildEnv(os.Environ()),
499+ RestoreSessionEnvVar+"="+string(sessionID),
500+ cef.CEFRootCachePathEnvVar+"="+sessionCEFRootCachePath(sessionID),
501+ )
497502
498503 // Detach from current process group so the new process survives.
499504 cmd.Stdin = nil
@@ -519,3 +524,7 @@ func (s *SessionSpawner) SpawnWithSession(sessionID entity.SessionID) error {
519524
520525 return nil
521526 }
527+
528+func sessionCEFRootCachePath(sessionID entity.SessionID) string {
529+ return filepath.Join(cef.DefaultCEFUserDataDir(), "sessions", string(sessionID))
530+}
@@ -11,6 +11,7 @@ import (
11 11
12 "github.com/bnema/dumber/internal/application/port"12 "github.com/bnema/dumber/internal/application/port"
13 "github.com/bnema/dumber/internal/domain/entity"13 "github.com/bnema/dumber/internal/domain/entity"
14+ "github.com/bnema/dumber/internal/infrastructure/cef"
14 "github.com/bnema/dumber/internal/logging"15 "github.com/bnema/dumber/internal/logging"
15 )16 )
16 17
@@ -492,8 +493,12 @@ func (s *SessionSpawner) SpawnWithSession(sessionID entity.SessionID) error {
492 // Start dumber browse with session ID in environment493 // Start dumber browse with session ID in environment
493 cmd := exec.Command(execPath, "browse")494 cmd := exec.Command(execPath, "browse")
494 495
495- // Inherit environment and add restore session ID496+ // Restore sessions get an isolated CEF state root.
496- cmd.Env = append(sanitizedChildEnv(os.Environ()), RestoreSessionEnvVar+"="+string(sessionID))497+ cmd.Env = append(
498+ sanitizedChildEnv(os.Environ()),
499+ RestoreSessionEnvVar+"="+string(sessionID),
500+ cef.CEFRootCachePathEnvVar+"="+sessionCEFRootCachePath(sessionID),
501+ )
497 502
498 // Detach from current process group so the new process survives.503 // Detach from current process group so the new process survives.
499 cmd.Stdin = nil504 cmd.Stdin = nil
@@ -519,3 +524,7 @@ func (s *SessionSpawner) SpawnWithSession(sessionID entity.SessionID) error {
519 524
520 return nil525 return nil
521 }526 }
527+
528+func sessionCEFRootCachePath(sessionID entity.SessionID) string {
529+ return filepath.Join(cef.DefaultCEFUserDataDir(), "sessions", string(sessionID))
530+}
M internal/infrastructure/desktop/adapter_test.go +76 -1
@@ -1,6 +1,28 @@
11 package desktop
22
3-import "testing"
3+import (
4+ "os"
5+ "path/filepath"
6+ "strings"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/dumber/internal/domain/entity"
11+ "github.com/bnema/dumber/internal/infrastructure/cef"
12+)
13+
14+const testCaptureEnvFile = "DUMBER_TEST_CAPTURE_ENV_FILE"
15+
16+func TestMain(m *testing.M) {
17+ if envFile := os.Getenv(testCaptureEnvFile); envFile != "" && len(os.Args) >= 2 && os.Args[1] == "browse" {
18+ if err := os.WriteFile(envFile, []byte(strings.Join(os.Environ(), "\n")), 0o644); err != nil {
19+ os.Exit(2)
20+ }
21+ os.Exit(0)
22+ }
23+
24+ os.Exit(m.Run())
25+}
426
527 func TestSanitizedChildEnv_RemovesLayerShellPreloadOnly(t *testing.T) {
628 env := sanitizedChildEnv([]string{
@@ -32,3 +54,56 @@ func TestSanitizedChildEnv_PreservesColonSeparatedEntries(t *testing.T) {
3254 t.Fatalf("expected unrelated colon-separated entries to remain, got %#v", env)
3355 }
3456 }
57+
58+func TestSessionSpawner_SpawnWithSession_PassesRestoreSessionAndCEFOverride(t *testing.T) {
59+ spawner := NewSessionSpawner(t.Context())
60+ childEnvFile := filepath.Join(t.TempDir(), "child-env.txt")
61+ t.Setenv(testCaptureEnvFile, childEnvFile)
62+ t.Setenv("XDG_DATA_HOME", filepath.Join(t.TempDir(), "data"))
63+ t.Setenv("ENV", "")
64+
65+ sessionID := entity.SessionID("session-123")
66+ requireNoError(t, spawner.SpawnWithSession(sessionID))
67+
68+ env := readEnvFile(t, childEnvFile)
69+ wantRoot := filepath.Join(cef.DefaultCEFUserDataDir(), "sessions", string(sessionID))
70+ if got := env[RestoreSessionEnvVar]; got != string(sessionID) {
71+ t.Fatalf("restore session env = %q, want %q", got, string(sessionID))
72+ }
73+ if got := env[cef.CEFRootCachePathEnvVar]; got != wantRoot {
74+ t.Fatalf("cef state root env = %q, want %q", got, wantRoot)
75+ }
76+}
77+
78+func readEnvFile(t *testing.T, path string) map[string]string {
79+ t.Helper()
80+
81+ deadline := time.Now().Add(2 * time.Second)
82+ for time.Now().Before(deadline) {
83+ data, err := os.ReadFile(path)
84+ if err == nil {
85+ env := make(map[string]string)
86+ for _, line := range strings.Split(strings.TrimSpace(string(data)), "\n") {
87+ if line == "" {
88+ continue
89+ }
90+ key, value, ok := strings.Cut(line, "=")
91+ if ok {
92+ env[key] = value
93+ }
94+ }
95+ return env
96+ }
97+ time.Sleep(10 * time.Millisecond)
98+ }
99+
100+ t.Fatalf("timed out waiting for child env file %q", path)
101+ return nil
102+}
103+
104+func requireNoError(t *testing.T, err error) {
105+ t.Helper()
106+ if err != nil {
107+ t.Fatal(err)
108+ }
109+}
@@ -1,6 +1,28 @@
1 package desktop1 package desktop
2 2
3-import "testing"3+import (
4+ "os"
5+ "path/filepath"
6+ "strings"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/dumber/internal/domain/entity"
11+ "github.com/bnema/dumber/internal/infrastructure/cef"
12+)
13+
14+const testCaptureEnvFile = "DUMBER_TEST_CAPTURE_ENV_FILE"
15+
16+func TestMain(m *testing.M) {
17+ if envFile := os.Getenv(testCaptureEnvFile); envFile != "" && len(os.Args) >= 2 && os.Args[1] == "browse" {
18+ if err := os.WriteFile(envFile, []byte(strings.Join(os.Environ(), "\n")), 0o644); err != nil {
19+ os.Exit(2)
20+ }
21+ os.Exit(0)
22+ }
23+
24+ os.Exit(m.Run())
25+}
4 26
5 func TestSanitizedChildEnv_RemovesLayerShellPreloadOnly(t *testing.T) {27 func TestSanitizedChildEnv_RemovesLayerShellPreloadOnly(t *testing.T) {
6 env := sanitizedChildEnv([]string{28 env := sanitizedChildEnv([]string{
@@ -32,3 +54,56 @@ func TestSanitizedChildEnv_PreservesColonSeparatedEntries(t *testing.T) {
32 t.Fatalf("expected unrelated colon-separated entries to remain, got %#v", env)54 t.Fatalf("expected unrelated colon-separated entries to remain, got %#v", env)
33 }55 }
34 }56 }
57+
58+func TestSessionSpawner_SpawnWithSession_PassesRestoreSessionAndCEFOverride(t *testing.T) {
59+ spawner := NewSessionSpawner(t.Context())
60+ childEnvFile := filepath.Join(t.TempDir(), "child-env.txt")
61+ t.Setenv(testCaptureEnvFile, childEnvFile)
62+ t.Setenv("XDG_DATA_HOME", filepath.Join(t.TempDir(), "data"))
63+ t.Setenv("ENV", "")
64+
65+ sessionID := entity.SessionID("session-123")
66+ requireNoError(t, spawner.SpawnWithSession(sessionID))
67+
68+ env := readEnvFile(t, childEnvFile)
69+ wantRoot := filepath.Join(cef.DefaultCEFUserDataDir(), "sessions", string(sessionID))
70+ if got := env[RestoreSessionEnvVar]; got != string(sessionID) {
71+ t.Fatalf("restore session env = %q, want %q", got, string(sessionID))
72+ }
73+ if got := env[cef.CEFRootCachePathEnvVar]; got != wantRoot {
74+ t.Fatalf("cef state root env = %q, want %q", got, wantRoot)
75+ }
76+}
77+
78+func readEnvFile(t *testing.T, path string) map[string]string {
79+ t.Helper()
80+
81+ deadline := time.Now().Add(2 * time.Second)
82+ for time.Now().Before(deadline) {
83+ data, err := os.ReadFile(path)
84+ if err == nil {
85+ env := make(map[string]string)
86+ for _, line := range strings.Split(strings.TrimSpace(string(data)), "\n") {
87+ if line == "" {
88+ continue
89+ }
90+ key, value, ok := strings.Cut(line, "=")
91+ if ok {
92+ env[key] = value
93+ }
94+ }
95+ return env
96+ }
97+ time.Sleep(10 * time.Millisecond)
98+ }
99+
100+ t.Fatalf("timed out waiting for child env file %q", path)
101+ return nil
102+}
103+
104+func requireNoError(t *testing.T, err error) {
105+ t.Helper()
106+ if err != nil {
107+ t.Fatal(err)
108+ }
109+}

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