fix(cef): recover safely after repeated GPU relaunch
- commit
- 636c5563058b76802f1c200e4f6829f806ef6cb3
- author
- brice · · 2026-07-14 06:25 UTC
- committer
- brice · · 2026-07-14 07:06 UTC
- parent
- a347952
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +100 -0
M internal/infrastructure/cef/app.go +18 -0
| @@ -448,12 +448,30 @@ func isBrowseRelaunchCommandLine(commandLine purecef.CommandLine) bool { | |||
| 448 | 448 | return len(args) >= 2 && args[1] == "browse" | |
| 449 | 449 | } | |
| 450 | 450 | ||
| 451 | + | func isGPUProcessRelaunchCommandLine(commandLine purecef.CommandLine) bool { | |
| 452 | + | if commandLine == nil { | |
| 453 | + | return false | |
| 454 | + | } | |
| 455 | + | for _, arg := range parseRelaunchCommandLineArgs(commandLine.GetCommandLineString()) { | |
| 456 | + | if arg == "--type=gpu-process" { | |
| 457 | + | return true | |
| 458 | + | } | |
| 459 | + | } | |
| 460 | + | return false | |
| 461 | + | } | |
| 462 | + | ||
| 451 | 463 | func parseBrowseURLFromRelaunchCommandLine(commandLine purecef.CommandLine) string { | |
| 452 | 464 | browseURL, _ := parseBrowseRelaunchCommandLine(commandLine) | |
| 453 | 465 | return browseURL | |
| 454 | 466 | } | |
| 455 | 467 | ||
| 456 | 468 | func (h *dumberBPH) OnAlreadyRunningAppRelaunch(commandLine purecef.CommandLine, _ string) int32 { | |
| 469 | + | if isGPUProcessRelaunchCommandLine(commandLine) { | |
| 470 | + | if h != nil && h.engine != nil { | |
| 471 | + | h.engine.recordGPURelaunch() | |
| 472 | + | } | |
| 473 | + | return 0 | |
| 474 | + | } | |
| 457 | 475 | if browseURL, ok := parseBrowseRelaunchCommandLine(commandLine); ok { | |
| 458 | 476 | if h != nil && h.engine != nil { | |
| 459 | 477 | if handler := h.engine.alreadyRunningAppRelaunchCallback(); handler != nil { | |
| @@ -448,12 +448,30 @@ func isBrowseRelaunchCommandLine(commandLine purecef.CommandLine) bool { | |||||
| 448 | return len(args) >= 2 && args[1] == "browse" | 448 | return len(args) >= 2 && args[1] == "browse" | ||
| 449 | } | 449 | } | ||
| 450 | 450 | ||||
| 451 | + | func isGPUProcessRelaunchCommandLine(commandLine purecef.CommandLine) bool { | |||
| 452 | + | if commandLine == nil { | |||
| 453 | + | return false | |||
| 454 | + | } | |||
| 455 | + | for _, arg := range parseRelaunchCommandLineArgs(commandLine.GetCommandLineString()) { | |||
| 456 | + | if arg == "--type=gpu-process" { | |||
| 457 | + | return true | |||
| 458 | + | } | |||
| 459 | + | } | |||
| 460 | + | return false | |||
| 461 | + | } | |||
| 462 | + | ||||
| 451 | func parseBrowseURLFromRelaunchCommandLine(commandLine purecef.CommandLine) string { | 463 | func parseBrowseURLFromRelaunchCommandLine(commandLine purecef.CommandLine) string { | ||
| 452 | browseURL, _ := parseBrowseRelaunchCommandLine(commandLine) | 464 | browseURL, _ := parseBrowseRelaunchCommandLine(commandLine) | ||
| 453 | return browseURL | 465 | return browseURL | ||
| 454 | } | 466 | } | ||
| 455 | 467 | ||||
| 456 | func (h *dumberBPH) OnAlreadyRunningAppRelaunch(commandLine purecef.CommandLine, _ string) int32 { | 468 | func (h *dumberBPH) OnAlreadyRunningAppRelaunch(commandLine purecef.CommandLine, _ string) int32 { | ||
| 469 | + | if isGPUProcessRelaunchCommandLine(commandLine) { | |||
| 470 | + | if h != nil && h.engine != nil { | |||
| 471 | + | h.engine.recordGPURelaunch() | |||
| 472 | + | } | |||
| 473 | + | return 0 | |||
| 474 | + | } | |||
| 457 | if browseURL, ok := parseBrowseRelaunchCommandLine(commandLine); ok { | 475 | if browseURL, ok := parseBrowseRelaunchCommandLine(commandLine); ok { | ||
| 458 | if h != nil && h.engine != nil { | 476 | if h != nil && h.engine != nil { | ||
| 459 | if handler := h.engine.alreadyRunningAppRelaunchCallback(); handler != nil { | 477 | if handler := h.engine.alreadyRunningAppRelaunchCallback(); handler != nil { | ||
M internal/infrastructure/cef/engine.go +13 -0
| @@ -31,6 +31,7 @@ type Engine struct { | |||
| 31 | 31 | pool *WebViewPool | |
| 32 | 32 | profileLogDir string | |
| 33 | 33 | runtimeCEFDir string | |
| 34 | + | stateRoot string | |
| 34 | 35 | renderStackPlan cef2gtk.RenderStackPlan | |
| 35 | 36 | applicationScaleMu sync.RWMutex | |
| 36 | 37 | applicationScale float64 | |
| @@ -54,6 +55,7 @@ type Engine struct { | |||
| 54 | 55 | downloadHandler *downloadHandler | |
| 55 | 56 | alreadyRunningAppRelaunchMu sync.RWMutex | |
| 56 | 57 | alreadyRunningAppRelaunchHandler func(string) | |
| 58 | + | gpuRelaunchCount atomic.Int32 | |
| 57 | 59 | ||
| 58 | 60 | // activeWebViews tracks all live webviews for CSS broadcast. | |
| 59 | 61 | activeWebViews sync.Map // map[port.WebViewID]*WebView | |
| @@ -145,6 +147,17 @@ func (e *Engine) alreadyRunningAppRelaunchCallback() func(string) { | |||
| 145 | 147 | return e.alreadyRunningAppRelaunchHandler | |
| 146 | 148 | } | |
| 147 | 149 | ||
| 150 | + | // recordGPURelaunch asks the next engine start to use Chromium's conservative | |
| 151 | + | // defaults after two GPU-process relaunch attempts in one running instance. | |
| 152 | + | func (e *Engine) recordGPURelaunch() { | |
| 153 | + | if e == nil || e.gpuRelaunchCount.Add(1) != 2 { | |
| 154 | + | return | |
| 155 | + | } | |
| 156 | + | if err := writeNextStartSafetyMarker(e.stateRoot); err != nil { | |
| 157 | + | logging.FromContext(e.ctx).Warn().Err(err).Msg("cef: failed to mark next start safe after repeated GPU relaunch") | |
| 158 | + | } | |
| 159 | + | } | |
| 160 | + | ||
| 148 | 161 | // registerWebView adds a webview to the active tracking map. | |
| 149 | 162 | func (e *Engine) registerWebView(wv *WebView) { | |
| 150 | 163 | e.activeWebViews.Store(wv.id, wv) | |
| @@ -31,6 +31,7 @@ type Engine struct { | |||||
| 31 | pool *WebViewPool | 31 | pool *WebViewPool | ||
| 32 | profileLogDir string | 32 | profileLogDir string | ||
| 33 | runtimeCEFDir string | 33 | runtimeCEFDir string | ||
| 34 | + | stateRoot string | |||
| 34 | renderStackPlan cef2gtk.RenderStackPlan | 35 | renderStackPlan cef2gtk.RenderStackPlan | ||
| 35 | applicationScaleMu sync.RWMutex | 36 | applicationScaleMu sync.RWMutex | ||
| 36 | applicationScale float64 | 37 | applicationScale float64 | ||
| @@ -54,6 +55,7 @@ type Engine struct { | |||||
| 54 | downloadHandler *downloadHandler | 55 | downloadHandler *downloadHandler | ||
| 55 | alreadyRunningAppRelaunchMu sync.RWMutex | 56 | alreadyRunningAppRelaunchMu sync.RWMutex | ||
| 56 | alreadyRunningAppRelaunchHandler func(string) | 57 | alreadyRunningAppRelaunchHandler func(string) | ||
| 58 | + | gpuRelaunchCount atomic.Int32 | |||
| 57 | 59 | ||||
| 58 | // activeWebViews tracks all live webviews for CSS broadcast. | 60 | // activeWebViews tracks all live webviews for CSS broadcast. | ||
| 59 | activeWebViews sync.Map // map[port.WebViewID]*WebView | 61 | activeWebViews sync.Map // map[port.WebViewID]*WebView | ||
| @@ -145,6 +147,17 @@ func (e *Engine) alreadyRunningAppRelaunchCallback() func(string) { | |||||
| 145 | return e.alreadyRunningAppRelaunchHandler | 147 | return e.alreadyRunningAppRelaunchHandler | ||
| 146 | } | 148 | } | ||
| 147 | 149 | ||||
| 150 | + | // recordGPURelaunch asks the next engine start to use Chromium's conservative | |||
| 151 | + | // defaults after two GPU-process relaunch attempts in one running instance. | |||
| 152 | + | func (e *Engine) recordGPURelaunch() { | |||
| 153 | + | if e == nil || e.gpuRelaunchCount.Add(1) != 2 { | |||
| 154 | + | return | |||
| 155 | + | } | |||
| 156 | + | if err := writeNextStartSafetyMarker(e.stateRoot); err != nil { | |||
| 157 | + | logging.FromContext(e.ctx).Warn().Err(err).Msg("cef: failed to mark next start safe after repeated GPU relaunch") | |||
| 158 | + | } | |||
| 159 | + | } | |||
| 160 | + | ||||
| 148 | // registerWebView adds a webview to the active tracking map. | 161 | // registerWebView adds a webview to the active tracking map. | ||
| 149 | func (e *Engine) registerWebView(wv *WebView) { | 162 | func (e *Engine) registerWebView(wv *WebView) { | ||
| 150 | e.activeWebViews.Store(wv.id, wv) | 163 | e.activeWebViews.Store(wv.id, wv) | ||
M internal/infrastructure/cef/engine_init.go +5 -0
| @@ -45,6 +45,10 @@ func NewEngine( | |||
| 45 | 45 | ) (*Engine, error) { | |
| 46 | 46 | logger := logging.FromContext(ctx) | |
| 47 | 47 | stateRoot := resolvedStateRoot(paths.StateRoot, opts) | |
| 48 | + | if consumeNextStartSafetyMarker(stateRoot) { | |
| 49 | + | applyNextStartSafetyEnvironment() | |
| 50 | + | logger.Warn().Str("state_root", stateRoot).Msg("cef: applying next-start GPU safety recovery") | |
| 51 | + | } | |
| 48 | 52 | cleanStaleSingletonLocks(logger, stateRoot) | |
| 49 | 53 | windowlessFrameRate := normalizedWindowlessFrameRate(cfg.WindowlessFrameRate) | |
| 50 | 54 | renderStackPlan, err := resolveCEFRenderStackPlan(cfg.RenderStack) | |
| @@ -67,6 +71,7 @@ func NewEngine( | |||
| 67 | 71 | ctx: ctx, | |
| 68 | 72 | profileLogDir: paths.ProfileLogDir, | |
| 69 | 73 | runtimeCEFDir: settings.CEFDir, | |
| 74 | + | stateRoot: stateRoot, | |
| 70 | 75 | renderStackPlan: renderStackPlan, | |
| 71 | 76 | applicationScale: normalizedApplicationScale(cfg.ApplicationScale), | |
| 72 | 77 | registerHandlers: deps.RegisterHandlers, | |
| @@ -45,6 +45,10 @@ func NewEngine( | |||||
| 45 | ) (*Engine, error) { | 45 | ) (*Engine, error) { | ||
| 46 | logger := logging.FromContext(ctx) | 46 | logger := logging.FromContext(ctx) | ||
| 47 | stateRoot := resolvedStateRoot(paths.StateRoot, opts) | 47 | stateRoot := resolvedStateRoot(paths.StateRoot, opts) | ||
| 48 | + | if consumeNextStartSafetyMarker(stateRoot) { | |||
| 49 | + | applyNextStartSafetyEnvironment() | |||
| 50 | + | logger.Warn().Str("state_root", stateRoot).Msg("cef: applying next-start GPU safety recovery") | |||
| 51 | + | } | |||
| 48 | cleanStaleSingletonLocks(logger, stateRoot) | 52 | cleanStaleSingletonLocks(logger, stateRoot) | ||
| 49 | windowlessFrameRate := normalizedWindowlessFrameRate(cfg.WindowlessFrameRate) | 53 | windowlessFrameRate := normalizedWindowlessFrameRate(cfg.WindowlessFrameRate) | ||
| 50 | renderStackPlan, err := resolveCEFRenderStackPlan(cfg.RenderStack) | 54 | renderStackPlan, err := resolveCEFRenderStackPlan(cfg.RenderStack) | ||
| @@ -67,6 +71,7 @@ func NewEngine( | |||||
| 67 | ctx: ctx, | 71 | ctx: ctx, | ||
| 68 | profileLogDir: paths.ProfileLogDir, | 72 | profileLogDir: paths.ProfileLogDir, | ||
| 69 | runtimeCEFDir: settings.CEFDir, | 73 | runtimeCEFDir: settings.CEFDir, | ||
| 74 | + | stateRoot: stateRoot, | |||
| 70 | renderStackPlan: renderStackPlan, | 75 | renderStackPlan: renderStackPlan, | ||
| 71 | applicationScale: normalizedApplicationScale(cfg.ApplicationScale), | 76 | applicationScale: normalizedApplicationScale(cfg.ApplicationScale), | ||
| 72 | registerHandlers: deps.RegisterHandlers, | 77 | registerHandlers: deps.RegisterHandlers, | ||
A internal/infrastructure/cef/restart_safety.go +64 -0
| @@ -0,0 +1,64 @@ | |||
| 1 | + | package cef | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "os" | |
| 6 | + | "path/filepath" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | const nextStartSafetyMarkerFile = "cef-next-start-safe" | |
| 10 | + | ||
| 11 | + | // writeNextStartSafetyMarker records a one-shot recovery request for the next | |
| 12 | + | // CEF start. The marker lives beside CEF's root cache so it follows explicit | |
| 13 | + | // per-session cache roots as well as the normal profile. | |
| 14 | + | func writeNextStartSafetyMarker(stateRoot string) error { | |
| 15 | + | if stateRoot == "" { | |
| 16 | + | return errors.New("CEF state root is empty") | |
| 17 | + | } | |
| 18 | + | if err := os.MkdirAll(stateRoot, 0o700); err != nil { | |
| 19 | + | return err | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | marker := filepath.Join(stateRoot, nextStartSafetyMarkerFile) | |
| 23 | + | temporary, err := os.CreateTemp(stateRoot, ".cef-next-start-safe-*") | |
| 24 | + | if err != nil { | |
| 25 | + | return err | |
| 26 | + | } | |
| 27 | + | temporaryName := temporary.Name() | |
| 28 | + | defer os.Remove(temporaryName) | |
| 29 | + | if err := temporary.Chmod(0o600); err != nil { | |
| 30 | + | _ = temporary.Close() | |
| 31 | + | return err | |
| 32 | + | } | |
| 33 | + | if _, err := temporary.WriteString("safe\n"); err != nil { | |
| 34 | + | _ = temporary.Close() | |
| 35 | + | return err | |
| 36 | + | } | |
| 37 | + | if err := temporary.Close(); err != nil { | |
| 38 | + | return err | |
| 39 | + | } | |
| 40 | + | return os.Rename(temporaryName, marker) | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | // consumeNextStartSafetyMarker consumes a one-shot marker. It deliberately | |
| 44 | + | // does not follow symlinks, so an untrusted cache entry cannot redirect the | |
| 45 | + | // recovery check outside the CEF state root. | |
| 46 | + | func consumeNextStartSafetyMarker(stateRoot string) bool { | |
| 47 | + | if stateRoot == "" { | |
| 48 | + | return false | |
| 49 | + | } | |
| 50 | + | marker := filepath.Join(stateRoot, nextStartSafetyMarkerFile) | |
| 51 | + | info, err := os.Lstat(marker) | |
| 52 | + | if err != nil || !info.Mode().IsRegular() { | |
| 53 | + | return false | |
| 54 | + | } | |
| 55 | + | return os.Remove(marker) == nil | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | // applyNextStartSafetyEnvironment leaves the Vulkan render stack intact while | |
| 59 | + | // returning Chromium to its hardware-decode safety defaults for one start. | |
| 60 | + | func applyNextStartSafetyEnvironment() { | |
| 61 | + | _ = os.Setenv(cefEnableVAAPIEnvVar, "0") | |
| 62 | + | _ = os.Unsetenv(cefChromiumFlagsEnvVar) | |
| 63 | + | _ = os.Unsetenv(cefRenderNodeEnvVar) | |
| 64 | + | } | |
| @@ -0,0 +1,64 @@ | |||||
| 1 | + | package cef | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | "os" | |||
| 6 | + | "path/filepath" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | const nextStartSafetyMarkerFile = "cef-next-start-safe" | |||
| 10 | + | ||||
| 11 | + | // writeNextStartSafetyMarker records a one-shot recovery request for the next | |||
| 12 | + | // CEF start. The marker lives beside CEF's root cache so it follows explicit | |||
| 13 | + | // per-session cache roots as well as the normal profile. | |||
| 14 | + | func writeNextStartSafetyMarker(stateRoot string) error { | |||
| 15 | + | if stateRoot == "" { | |||
| 16 | + | return errors.New("CEF state root is empty") | |||
| 17 | + | } | |||
| 18 | + | if err := os.MkdirAll(stateRoot, 0o700); err != nil { | |||
| 19 | + | return err | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | marker := filepath.Join(stateRoot, nextStartSafetyMarkerFile) | |||
| 23 | + | temporary, err := os.CreateTemp(stateRoot, ".cef-next-start-safe-*") | |||
| 24 | + | if err != nil { | |||
| 25 | + | return err | |||
| 26 | + | } | |||
| 27 | + | temporaryName := temporary.Name() | |||
| 28 | + | defer os.Remove(temporaryName) | |||
| 29 | + | if err := temporary.Chmod(0o600); err != nil { | |||
| 30 | + | _ = temporary.Close() | |||
| 31 | + | return err | |||
| 32 | + | } | |||
| 33 | + | if _, err := temporary.WriteString("safe\n"); err != nil { | |||
| 34 | + | _ = temporary.Close() | |||
| 35 | + | return err | |||
| 36 | + | } | |||
| 37 | + | if err := temporary.Close(); err != nil { | |||
| 38 | + | return err | |||
| 39 | + | } | |||
| 40 | + | return os.Rename(temporaryName, marker) | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | // consumeNextStartSafetyMarker consumes a one-shot marker. It deliberately | |||
| 44 | + | // does not follow symlinks, so an untrusted cache entry cannot redirect the | |||
| 45 | + | // recovery check outside the CEF state root. | |||
| 46 | + | func consumeNextStartSafetyMarker(stateRoot string) bool { | |||
| 47 | + | if stateRoot == "" { | |||
| 48 | + | return false | |||
| 49 | + | } | |||
| 50 | + | marker := filepath.Join(stateRoot, nextStartSafetyMarkerFile) | |||
| 51 | + | info, err := os.Lstat(marker) | |||
| 52 | + | if err != nil || !info.Mode().IsRegular() { | |||
| 53 | + | return false | |||
| 54 | + | } | |||
| 55 | + | return os.Remove(marker) == nil | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | // applyNextStartSafetyEnvironment leaves the Vulkan render stack intact while | |||
| 59 | + | // returning Chromium to its hardware-decode safety defaults for one start. | |||
| 60 | + | func applyNextStartSafetyEnvironment() { | |||
| 61 | + | _ = os.Setenv(cefEnableVAAPIEnvVar, "0") | |||
| 62 | + | _ = os.Unsetenv(cefChromiumFlagsEnvVar) | |||
| 63 | + | _ = os.Unsetenv(cefRenderNodeEnvVar) | |||
| 64 | + | } | |||