Merge pull request #359 from bnema/next
Merge next into main
76 files · +4096 -1447
M .gitignore +4 -4
| @@ -16,8 +16,8 @@ dumber.exe | |||||
| 16 | # Output of the go coverage tool | 16 | # Output of the go coverage tool | ||
| 17 | *.out | 17 | *.out | ||
| 18 | 18 | ||||
| 19 | - | # Dependency directories (remove the comment below to include it) | 19 | + | # Dependency directories |
| 20 | - | # vendor/ | 20 | + | vendor/ |
| 21 | 21 | ||||
| 22 | # Go workspace file | 22 | # Go workspace file | ||
| 23 | go.work | 23 | go.work | ||
| @@ -94,7 +94,6 @@ templates/ | |||||
| 94 | .serena/ | 94 | .serena/ | ||
| 95 | config.yaml | 95 | config.yaml | ||
| 96 | .dev/ | 96 | .dev/ | ||
| 97 | - | vendor/ | |||
| 98 | 97 | ||||
| 99 | # WebKit runtime data | 98 | # WebKit runtime data | ||
| 100 | serviceworkers/ | 99 | serviceworkers/ | ||
| @@ -119,6 +118,7 @@ aur/*/LICENSE | |||||
| 119 | aur/*/dev.bnema.Dumber.desktop | 118 | aur/*/dev.bnema.Dumber.desktop | ||
| 120 | aur/*/logo-512.png | 119 | aur/*/logo-512.png | ||
| 121 | 120 | ||||
| 122 | - | # Planning/runbook notes stored outside product docs | 121 | + | # Planning/runbook notes and generated roadmap evidence stored outside the repository |
| 123 | docs/reference/stability-performance-multi-agent-runbook-*.md | 122 | docs/reference/stability-performance-multi-agent-runbook-*.md | ||
| 123 | + | /phase1/ | |||
| 124 | perf.data | 124 | perf.data | ||
M Makefile +2 -2
| @@ -239,13 +239,13 @@ flatpak-deps: ## Install Flatpak build dependencies (Arch) | |||||
| 239 | 239 | ||||
| 240 | flatpak-build: ## Build Flatpak bundle locally | 240 | flatpak-build: ## Build Flatpak bundle locally | ||
| 241 | @echo "Building Flatpak bundle..." | 241 | @echo "Building Flatpak bundle..." | ||
| 242 | - | flatpak-builder --force-clean --user --repo=flatpak-repo flatpak-build dev.bnema.Dumber.yml | 242 | + | flatpak-builder --force-clean --user --repo=flatpak-repo flatpak-build flatpak/dev.bnema.Dumber.yml |
| 243 | flatpak build-bundle flatpak-repo dumber.flatpak dev.bnema.Dumber | 243 | flatpak build-bundle flatpak-repo dumber.flatpak dev.bnema.Dumber | ||
| 244 | @echo "Flatpak bundle created: dumber.flatpak" | 244 | @echo "Flatpak bundle created: dumber.flatpak" | ||
| 245 | 245 | ||||
| 246 | flatpak-install: ## Install Flatpak locally for testing | 246 | flatpak-install: ## Install Flatpak locally for testing | ||
| 247 | @echo "Installing Flatpak locally..." | 247 | @echo "Installing Flatpak locally..." | ||
| 248 | - | flatpak-builder --force-clean --user --install flatpak-build dev.bnema.Dumber.yml | 248 | + | flatpak-builder --force-clean --user --install flatpak-build flatpak/dev.bnema.Dumber.yml |
| 249 | @echo "Flatpak installed! Run with: make flatpak-run" | 249 | @echo "Flatpak installed! Run with: make flatpak-run" | ||
| 250 | 250 | ||||
| 251 | flatpak-run: ## Run the installed Flatpak | 251 | flatpak-run: ## Run the installed Flatpak | ||
M README.md +3 -2
| @@ -134,7 +134,7 @@ Dumber uses Chromium Embedded Framework by default. WebKitGTK is available as a | |||||
| 134 | On Arch Linux, install the CEF runtime with: | 134 | On Arch Linux, install the CEF runtime with: | ||
| 135 | 135 | ||||
| 136 | ```bash | 136 | ```bash | ||
| 137 | - | sudo pacman -S cef | 137 | + | sudo pacman -S cef libwebp |
| 138 | ``` | 138 | ``` | ||
| 139 | 139 | ||||
| 140 | For hardware video decoding, `cef-vaapi` is also available from the AUR as an optional CEF build. | 140 | For hardware video decoding, `cef-vaapi` is also available from the AUR as an optional CEF build. | ||
| @@ -179,7 +179,7 @@ CEF OSR frame rate adapts to the active Wayland monitor refresh rate when both ` | |||||
| 179 | **Arch Linux:** | 179 | **Arch Linux:** | ||
| 180 | 180 | ||||
| 181 | ```bash | 181 | ```bash | ||
| 182 | - | sudo pacman -S cef webkitgtk-6.0 gtk4 gst-plugins-base gst-plugins-good gst-plugins-bad gst-plugin-va | 182 | + | sudo pacman -S cef webkitgtk-6.0 gtk4 libwebp gst-plugins-base gst-plugins-good gst-plugins-bad gst-plugin-va |
| 183 | ``` | 183 | ``` | ||
| 184 | 184 | ||||
| 185 | **Debian/Ubuntu:** | 185 | **Debian/Ubuntu:** | ||
| @@ -213,6 +213,7 @@ Set `ENV=dev` to use `.dev/dumber/` for config and data instead of XDG paths. | |||||
| 213 | - CEF runtime (default backend) | 213 | - CEF runtime (default backend) | ||
| 214 | - WebKitGTK 6.0 development/runtime packages (fallback backend and runtime checks) | 214 | - WebKitGTK 6.0 development/runtime packages (fallback backend and runtime checks) | ||
| 215 | - Brotli for compressed systemviews assets | 215 | - Brotli for compressed systemviews assets | ||
| 216 | + | - libwebp runtime for WebP favicon decoding (for example `libwebp` on Arch) | |||
| 216 | 217 | ||||
| 217 | Systemviews assets are generated with `go tool templ` and Go's `js/wasm` toolchain; no root Node toolchain is required. | 218 | Systemviews assets are generated with `go tool templ` and Go's `js/wasm` toolchain; no root Node toolchain is required. | ||
| 218 | 219 | ||||
M aur/dumber-browser-bin/.SRCINFO +2 -1
| @@ -1,13 +1,14 @@ | |||||
| 1 | pkgbase = dumber-browser-bin | 1 | pkgbase = dumber-browser-bin | ||
| 2 | pkgdesc = A minimal keyboard-driven browser for tiling WMs | 2 | pkgdesc = A minimal keyboard-driven browser for tiling WMs | ||
| 3 | pkgver = 0.23.2 | 3 | pkgver = 0.23.2 | ||
| 4 | - | pkgrel = 2 | 4 | + | pkgrel = 3 |
| 5 | url = https://github.com/bnema/dumber | 5 | url = https://github.com/bnema/dumber | ||
| 6 | arch = x86_64 | 6 | arch = x86_64 | ||
| 7 | license = MIT | 7 | license = MIT | ||
| 8 | depends = gtk4 | 8 | depends = gtk4 | ||
| 9 | depends = cef | 9 | depends = cef | ||
| 10 | depends = webkitgtk-6.0 | 10 | depends = webkitgtk-6.0 | ||
| 11 | + | depends = libwebp | |||
| 11 | optdepends = gst-plugins-base: Base media codecs | 12 | optdepends = gst-plugins-base: Base media codecs | ||
| 12 | optdepends = gst-plugins-good: Additional media codecs | 13 | optdepends = gst-plugins-good: Additional media codecs | ||
| 13 | optdepends = gst-plugins-bad: Extended media codecs | 14 | optdepends = gst-plugins-bad: Extended media codecs | ||
M aur/dumber-browser-bin/PKGBUILD +2 -2
| @@ -1,12 +1,12 @@ | |||||
| 1 | # Maintainer: bnema <b at bnema dot dev> | 1 | # Maintainer: bnema <b at bnema dot dev> | ||
| 2 | pkgname=dumber-browser-bin | 2 | pkgname=dumber-browser-bin | ||
| 3 | pkgver=0.23.2 | 3 | pkgver=0.23.2 | ||
| 4 | - | pkgrel=2 | 4 | + | pkgrel=3 |
| 5 | pkgdesc="A minimal keyboard-driven browser for tiling WMs" | 5 | pkgdesc="A minimal keyboard-driven browser for tiling WMs" | ||
| 6 | arch=('x86_64') | 6 | arch=('x86_64') | ||
| 7 | url="https://github.com/bnema/dumber" | 7 | url="https://github.com/bnema/dumber" | ||
| 8 | license=('MIT') | 8 | license=('MIT') | ||
| 9 | - | depends=('gtk4' 'cef' 'webkitgtk-6.0') | 9 | + | depends=('gtk4' 'cef' 'webkitgtk-6.0' 'libwebp') |
| 10 | optdepends=( | 10 | optdepends=( | ||
| 11 | 'gst-plugins-base: Base media codecs' | 11 | 'gst-plugins-base: Base media codecs' | ||
| 12 | 'gst-plugins-good: Additional media codecs' | 12 | 'gst-plugins-good: Additional media codecs' | ||
M aur/dumber-browser-git/.SRCINFO +1 -0
| @@ -11,6 +11,7 @@ pkgbase = dumber-browser-git | |||||
| 11 | depends = gtk4 | 11 | depends = gtk4 | ||
| 12 | depends = cef | 12 | depends = cef | ||
| 13 | depends = webkitgtk-6.0 | 13 | depends = webkitgtk-6.0 | ||
| 14 | + | depends = libwebp | |||
| 14 | optdepends = gst-plugins-base: Base media codecs | 15 | optdepends = gst-plugins-base: Base media codecs | ||
| 15 | optdepends = gst-plugins-good: Additional media codecs | 16 | optdepends = gst-plugins-good: Additional media codecs | ||
| 16 | optdepends = gst-plugins-bad: Extended media codecs | 17 | optdepends = gst-plugins-bad: Extended media codecs | ||
M aur/dumber-browser-git/PKGBUILD +1 -1
| @@ -7,7 +7,7 @@ arch=('x86_64') | |||||
| 7 | url="https://github.com/bnema/dumber" | 7 | url="https://github.com/bnema/dumber" | ||
| 8 | license=('MIT') | 8 | license=('MIT') | ||
| 9 | makedepends=('go>=1.26' 'git' 'brotli') | 9 | makedepends=('go>=1.26' 'git' 'brotli') | ||
| 10 | - | depends=('gtk4' 'cef' 'webkitgtk-6.0') | 10 | + | depends=('gtk4' 'cef' 'webkitgtk-6.0' 'libwebp') |
| 11 | optdepends=( | 11 | optdepends=( | ||
| 12 | 'gst-plugins-base: Base media codecs' | 12 | 'gst-plugins-base: Base media codecs' | ||
| 13 | 'gst-plugins-good: Additional media codecs' | 13 | 'gst-plugins-good: Additional media codecs' | ||
M cmd/dumber/main.go +28 -18
| @@ -10,6 +10,7 @@ import ( | |||||
| 10 | "runtime" | 10 | "runtime" | ||
| 11 | "strings" | 11 | "strings" | ||
| 12 | "syscall" | 12 | "syscall" | ||
| 13 | + | "time" | |||
| 13 | 14 | ||||
| 14 | "github.com/bnema/dumber/internal/application/port" | 15 | "github.com/bnema/dumber/internal/application/port" | ||
| 15 | "github.com/bnema/dumber/internal/application/usecase" | 16 | "github.com/bnema/dumber/internal/application/usecase" | ||
| @@ -140,7 +141,17 @@ func configureBrowserLaunchRelay(cfg *config.Config) { | |||||
| 140 | browserLaunchRelay = relay | 141 | browserLaunchRelay = relay | ||
| 141 | } | 142 | } | ||
| 142 | 143 | ||||
| 144 | + | type startupTiming struct { | |||
| 145 | + | processEntry time.Time | |||
| 146 | + | configComplete time.Time | |||
| 147 | + | } | |||
| 148 | + | ||||
| 143 | func main() { | 149 | func main() { | ||
| 150 | + | // CEF is not known until configuration is complete. Keep these neutral | |||
| 151 | + | // timestamps so a CEF GUI trace can be seeded truthfully without creating | |||
| 152 | + | // one for WebKit, the standalone omnibox, CLI, or CEF helper processes. | |||
| 153 | + | timing := startupTiming{processEntry: time.Now()} | |||
| 154 | + | ||||
| 144 | // CEF subprocess handling: when CEF re-launches this binary with | 155 | // CEF subprocess handling: when CEF re-launches this binary with | ||
| 145 | // --type=renderer/gpu/etc, we must call ExecuteProcess before anything | 156 | // --type=renderer/gpu/etc, we must call ExecuteProcess before anything | ||
| 146 | // else (Cobra, config, arg stripping). We only treat a leading Chromium | 157 | // else (Cobra, config, arg stripping). We only treat a leading Chromium | ||
| @@ -170,6 +181,7 @@ func main() { | |||||
| 170 | // Run GUI mode for browse command | 181 | // Run GUI mode for browse command | ||
| 171 | if mode == launchModeBrowse { | 182 | if mode == launchModeBrowse { | ||
| 172 | cfg := initConfig() | 183 | cfg := initConfig() | ||
| 184 | + | timing.configComplete = time.Now() | |||
| 173 | configureBrowserLaunchRelay(cfg) | 185 | configureBrowserLaunchRelay(cfg) | ||
| 174 | startupURL := domainurl.ResolveBrowserStartupURL(browseURL) | 186 | startupURL := domainurl.ResolveBrowserStartupURL(browseURL) | ||
| 175 | if forwarded, err := tryForwardBrowseURLToRunningInstance(context.Background(), browserLaunchRelay, startupURL); err != nil { | 187 | if forwarded, err := tryForwardBrowseURLToRunningInstance(context.Background(), browserLaunchRelay, startupURL); err != nil { | ||
| @@ -186,7 +198,7 @@ func main() { | |||||
| 186 | initialURL = startupURL | 198 | initialURL = startupURL | ||
| 187 | restoreSessionID = os.Getenv("DUMBER_RESTORE_SESSION") | 199 | restoreSessionID = os.Getenv("DUMBER_RESTORE_SESSION") | ||
| 188 | os.Args = os.Args[:1] | 200 | os.Args = os.Args[:1] | ||
| 189 | - | os.Exit(runGUI(cfg)) | 201 | + | os.Exit(runGUI(cfg, timing)) |
| 190 | return | 202 | return | ||
| 191 | } | 203 | } | ||
| 192 | 204 | ||||
| @@ -207,7 +219,7 @@ func main() { | |||||
| 207 | cmd.Execute() | 219 | cmd.Execute() | ||
| 208 | } | 220 | } | ||
| 209 | 221 | ||||
| 210 | - | func runGUI(cfg *config.Config) int { | 222 | + | func runGUI(cfg *config.Config, timing startupTiming) int { |
| 211 | bootstrap.ApplyGTKIMModuleFallbackDefault(os.Stderr) | 223 | bootstrap.ApplyGTKIMModuleFallbackDefault(os.Stderr) | ||
| 212 | 224 | ||||
| 213 | runtime.LockOSThread() | 225 | runtime.LockOSThread() | ||
| @@ -216,11 +228,13 @@ func runGUI(cfg *config.Config) int { | |||||
| 216 | 228 | ||||
| 217 | if cfg == nil { | 229 | if cfg == nil { | ||
| 218 | cfg = initConfig() | 230 | cfg = initConfig() | ||
| 231 | + | timing.configComplete = time.Now() | |||
| 219 | configureBrowserLaunchRelay(cfg) | 232 | configureBrowserLaunchRelay(cfg) | ||
| 220 | } | 233 | } | ||
| 221 | timer.Mark("config") | 234 | timer.Mark("config") | ||
| 222 | 235 | ||||
| 223 | - | ctx := initStartupContextWithTrace(cfg) | 236 | + | ctx := initStartupContext(cfg) |
| 237 | + | activateCEFStartupTraceForGUI(cfg, timing, logging.FromContext(ctx), infracef.ActivateStartupTrace) | |||
| 224 | applyCEFRenderStackDefault(ctx, cfg) | 238 | applyCEFRenderStackDefault(ctx, cfg) | ||
| 225 | timer.Mark("logger") | 239 | timer.Mark("logger") | ||
| 226 | bootstrapLog := logging.FromContext(ctx) | 240 | bootstrapLog := logging.FromContext(ctx) | ||
| @@ -253,7 +267,6 @@ func runGUI(cfg *config.Config) int { | |||||
| 253 | timer.Mark("session") | 267 | timer.Mark("session") | ||
| 254 | 268 | ||||
| 255 | log := logging.FromContext(ctx) | 269 | log := logging.FromContext(ctx) | ||
| 256 | - | logging.Trace().UpdateLogger(log) | |||
| 257 | logCoreDumpLimits(ctx) | 270 | logCoreDumpLimits(ctx) | ||
| 258 | 271 | ||||
| 259 | engine.SetHandlerContext(ctx) | 272 | engine.SetHandlerContext(ctx) | ||
| @@ -297,7 +310,7 @@ func runStandaloneOmnibox() int { | |||||
| 297 | 310 | ||||
| 298 | cfg := initConfig() | 311 | cfg := initConfig() | ||
| 299 | configureBrowserLaunchRelay(cfg) | 312 | configureBrowserLaunchRelay(cfg) | ||
| 300 | - | ctx := initStartupContextWithTrace(cfg) | 313 | + | ctx := initStartupContext(cfg) |
| 301 | applyCEFRenderStackDefault(ctx, cfg) | 314 | applyCEFRenderStackDefault(ctx, cfg) | ||
| 302 | 315 | ||||
| 303 | initResult, err := runParallelInitPhase(ctx, cfg) | 316 | initResult, err := runParallelInitPhase(ctx, cfg) | ||
| @@ -412,21 +425,19 @@ func resolveCurrentExecutable(executable func() (string, error)) (string, error) | |||||
| 412 | return executable() | 425 | return executable() | ||
| 413 | } | 426 | } | ||
| 414 | 427 | ||||
| 415 | - | func initStartupContextWithTrace(cfg *config.Config) context.Context { | 428 | + | func activateCEFStartupTraceForGUI( |
| 416 | - | logging.InitStartupTrace(cfg.Logging.Level) | 429 | + | cfg *config.Config, |
| 417 | - | logging.Trace().Mark("config_loaded") | 430 | + | timing startupTiming, |
| 418 | - | 431 | + | logger *zerolog.Logger, | |
| 419 | - | ctx := initStartupContext(cfg) | 432 | + | activate func(time.Time, time.Time, *zerolog.Logger), |
| 420 | - | bootstrapLog := logging.FromContext(ctx) | 433 | + | ) { |
| 421 | - | 434 | + | if cfg == nil || cfg.Engine.ResolveEngineType() != config.EngineTypeCEF { | |
| 422 | - | logging.Trace().SetLogger(bootstrapLog) | 435 | + | return |
| 423 | - | logging.Trace().Mark("logger_init") | 436 | + | } |
| 424 | - | 437 | + | activate(timing.processEntry, timing.configComplete, logger) | |
| 425 | - | return ctx | |||
| 426 | } | 438 | } | ||
| 427 | 439 | ||||
| 428 | func runParallelInitPhase(ctx context.Context, cfg *config.Config) (*bootstrap.ParallelInitResult, error) { | 440 | func runParallelInitPhase(ctx context.Context, cfg *config.Config) (*bootstrap.ParallelInitResult, error) { | ||
| 429 | - | logging.Trace().Mark("parallel_start") | |||
| 430 | initResult, err := bootstrap.RunParallelInit(bootstrap.ParallelInitInput{ | 441 | initResult, err := bootstrap.RunParallelInit(bootstrap.ParallelInitInput{ | ||
| 431 | Ctx: ctx, | 442 | Ctx: ctx, | ||
| 432 | Config: cfg, | 443 | Config: cfg, | ||
| @@ -435,7 +446,6 @@ func runParallelInitPhase(ctx context.Context, cfg *config.Config) (*bootstrap.P | |||||
| 435 | handleParallelInitError(ctx, err) | 446 | handleParallelInitError(ctx, err) | ||
| 436 | return nil, err | 447 | return nil, err | ||
| 437 | } | 448 | } | ||
| 438 | - | logging.Trace().Mark("parallel_done") | |||
| 439 | return initResult, nil | 449 | return initResult, nil | ||
| 440 | } | 450 | } | ||
| 441 | 451 | ||||
M cmd/dumber/main_test.go +16 -0
| @@ -4,12 +4,14 @@ import ( | |||||
| 4 | "context" | 4 | "context" | ||
| 5 | "errors" | 5 | "errors" | ||
| 6 | "testing" | 6 | "testing" | ||
| 7 | + | "time" | |||
| 7 | 8 | ||||
| 8 | "github.com/bnema/dumber/internal/application/port/mocks" | 9 | "github.com/bnema/dumber/internal/application/port/mocks" | ||
| 9 | "github.com/bnema/dumber/internal/bootstrap" | 10 | "github.com/bnema/dumber/internal/bootstrap" | ||
| 10 | "github.com/bnema/dumber/internal/infrastructure/colorscheme" | 11 | "github.com/bnema/dumber/internal/infrastructure/colorscheme" | ||
| 11 | "github.com/bnema/dumber/internal/infrastructure/config" | 12 | "github.com/bnema/dumber/internal/infrastructure/config" | ||
| 12 | "github.com/bnema/dumber/internal/infrastructure/desktop" | 13 | "github.com/bnema/dumber/internal/infrastructure/desktop" | ||
| 14 | + | "github.com/rs/zerolog" | |||
| 13 | ) | 15 | ) | ||
| 14 | 16 | ||||
| 15 | func TestLaunchModeFromArgs_DetectsStandaloneOmnibox(t *testing.T) { | 17 | func TestLaunchModeFromArgs_DetectsStandaloneOmnibox(t *testing.T) { | ||
| @@ -172,6 +174,20 @@ func TestResolveCurrentExecutable_PropagatesError(t *testing.T) { | |||||
| 172 | } | 174 | } | ||
| 173 | } | 175 | } | ||
| 174 | 176 | ||||
| 177 | + | func TestWebKitGUIStartupDoesNotActivateCEFTrace(t *testing.T) { | |||
| 178 | + | cfg := &config.Config{} | |||
| 179 | + | cfg.Engine.Type = config.EngineTypeWebKit | |||
| 180 | + | activationCalls := 0 | |||
| 181 | + | ||||
| 182 | + | activateCEFStartupTraceForGUI(cfg, startupTiming{}, nil, func(time.Time, time.Time, *zerolog.Logger) { | |||
| 183 | + | activationCalls++ | |||
| 184 | + | }) | |||
| 185 | + | ||||
| 186 | + | if activationCalls != 0 { | |||
| 187 | + | t.Fatal("WebKit GUI startup must not initialize, record, or summarize the CEF first-presentation trace") | |||
| 188 | + | } | |||
| 189 | + | } | |||
| 190 | + | ||||
| 175 | func TestPreInitializeAdwaitaForCEF_InitializesAndMarksDetector(t *testing.T) { | 191 | func TestPreInitializeAdwaitaForCEF_InitializesAndMarksDetector(t *testing.T) { | ||
| 176 | initResult := &bootstrap.ParallelInitResult{ | 192 | initResult := &bootstrap.ParallelInitResult{ | ||
| 177 | AdwaitaDetector: colorscheme.NewAdwaitaDetector(), | 193 | AdwaitaDetector: colorscheme.NewAdwaitaDetector(), | ||
A docs/first-presentation.md +69 -0
| @@ -0,0 +1,69 @@ | |||||
| 1 | + | # CEF accelerated first-presentation startup trace | |||
| 2 | + | ||||
| 3 | + | Dumber records this cold-start timeline **only for the accelerated CEF → DMABUF | |||
| 4 | + | → GTK path**. It is not a backend-neutral startup metric: the WebKit backend | |||
| 5 | + | does not add milestones to this trace and never emits this CEF summary. | |||
| 6 | + | ||||
| 7 | + | For a selected CEF GUI process, Dumber captures neutral `process_entry` and | |||
| 8 | + | `config_complete` timestamps, then activates and seeds this CEF-owned trace only | |||
| 9 | + | after configuration selects CEF. CEF helper processes, WebKit GUI startup, the | |||
| 10 | + | standalone omnibox, and CLI commands never initialize this trace, record its | |||
| 11 | + | milestones, or emit its summary. A selected CEF GUI trace accepts these one-shot | |||
| 12 | + | milestones only in order: | |||
| 13 | + | ||||
| 14 | + | 1. `process_entry` | |||
| 15 | + | 2. `config_complete` | |||
| 16 | + | 3. `cef_library_load_begin` (immediately before `InitWithApp`) | |||
| 17 | + | 4. `cef_initialized` | |||
| 18 | + | 5. `browser_create_requested` | |||
| 19 | + | 6. `first_accelerated_paint_received` | |||
| 20 | + | 7. `first_dmabuf_texture_swap` (after `GtkPicture.SetPaintable` succeeds) | |||
| 21 | + | 8. `first_gtk_presentation` (the subsequent GTK frame-clock after-paint) | |||
| 22 | + | ||||
| 23 | + | The CEF-to-GTK bridge owns the native accelerated-paint, DMABUF texture-swap, | |||
| 24 | + | and GTK presentation boundaries. Dumber records the bridge's ordered callbacks; | |||
| 25 | + | it does not synthesize them for another engine or rendering path. CEF | |||
| 26 | + | library-load completion is intentionally not recorded: `InitWithApp` is an | |||
| 27 | + | opaque operation. Duplicate, unknown, and out-of-order transitions are | |||
| 28 | + | rejected. At the last milestone Dumber emits exactly one normal-level JSON | |||
| 29 | + | summary (`startup_trace: first presentation`) with the selected CEF render | |||
| 30 | + | backend, an `incomplete_reason`, total milliseconds, and monotonic milestones. | |||
| 31 | + | ||||
| 32 | + | A non-DMABUF CEF backend or a missing accelerated callback does not produce a | |||
| 33 | + | complete summary and is not comparable with this measurement. In particular, | |||
| 34 | + | WebKit startup must be measured with a separately defined WebKit-specific | |||
| 35 | + | contract rather than this CEF trace. | |||
| 36 | + | ||||
| 37 | + | ## Reproducible collection | |||
| 38 | + | ||||
| 39 | + | Build the candidate, then run: | |||
| 40 | + | ||||
| 41 | + | ```bash | |||
| 42 | + | DUMBER_FIRST_PRESENTATION_BIN="$PWD/dist/dumber" \ | |||
| 43 | + | DUMBER_MACHINE_GPU_PROFILE=integrated-gpu \ | |||
| 44 | + | scripts/collect_first_presentation.sh | |||
| 45 | + | ``` | |||
| 46 | + | ||||
| 47 | + | The collector is for the accelerated CEF/DMABUF/GTK contract above. By default | |||
| 48 | + | each collection is a fresh directory below | |||
| 49 | + | `$XDG_STATE_HOME/dumber/roadmap-evidence` (or | |||
| 50 | + | `$HOME/.local/state/dumber/roadmap-evidence` when `XDG_STATE_HOME` is unset), | |||
| 51 | + | not in the repository. `DUMBER_FIRST_PRESENTATION_OUTPUT` may override it only | |||
| 52 | + | with a new absolute directory below an existing non-symlink parent. The | |||
| 53 | + | collector never clears or reuses a caller-supplied path. | |||
| 54 | + | ||||
| 55 | + | The script requires the current display and the CEF 147 runtime at | |||
| 56 | + | `~/.local/share/cef-147-runtime` (override with `DUMBER_CEF_DIR`). It performs | |||
| 57 | + | exactly five bounded launches with fresh XDG homes and CEF root cache, fixes the | |||
| 58 | + | DMABUF/Vulkan renderer, and writes exactly `metadata.json`, `run-01.json` | |||
| 59 | + | through `run-05.json`, and `baseline.json`. Metadata includes only coarse, | |||
| 60 | + | comparison-safe OS/architecture, display protocol, and machine/GPU profile | |||
| 61 | + | labels. Set `DUMBER_MACHINE_GPU_PROFILE` to one of `generic-gpu`, | |||
| 62 | + | `integrated-gpu`, `discrete-gpu`, `hybrid-gpu`, `virtual-gpu`, or `unknown-gpu`; | |||
| 63 | + | do not use a device model or other identifier. Publish only the seven reviewed | |||
| 64 | + | JSON files to an external Gist. Raw logs and temporary XDG homes are removed and | |||
| 65 | + | must not be published. `metadata.json` derives the selected | |||
| 66 | + | `github.com/bnema/purego-cef2gtk` pseudo-version and its full immutable Git | |||
| 67 | + | origin hash from Go module metadata; branch selectors or missing origin metadata | |||
| 68 | + | fail collection. A missing or incomplete timeline, non-DMABUF backend, or invalid | |||
| 69 | + | run also fails collection. | |||
M flatpak/dev.bnema.Dumber.yml +3 -2
| @@ -1,6 +1,7 @@ | |||||
| 1 | id: dev.bnema.Dumber | 1 | id: dev.bnema.Dumber | ||
| 2 | runtime: org.gnome.Platform | 2 | runtime: org.gnome.Platform | ||
| 3 | runtime-version: '49' | 3 | runtime-version: '49' | ||
| 4 | + | # org.gnome.Platform provides libwebp at runtime for purego WebP favicon decoding. | |||
| 4 | sdk: org.gnome.Sdk | 5 | sdk: org.gnome.Sdk | ||
| 5 | command: dumber | 6 | command: dumber | ||
| 6 | 7 | ||||
| @@ -53,10 +54,10 @@ modules: | |||||
| 53 | path: dev.bnema.Dumber.desktop | 54 | path: dev.bnema.Dumber.desktop | ||
| 54 | # Icons | 55 | # Icons | ||
| 55 | - type: file | 56 | - type: file | ||
| 56 | - | path: assets/logo.svg | 57 | + | path: ../assets/logo.svg |
| 57 | dest-filename: logo.svg | 58 | dest-filename: logo.svg | ||
| 58 | - type: file | 59 | - type: file | ||
| 59 | - | path: assets/logo-512.png | 60 | + | path: ../assets/logo-512.png |
| 60 | dest-filename: logo-512.png | 61 | dest-filename: logo-512.png | ||
| 61 | # Metainfo | 62 | # Metainfo | ||
| 62 | - type: file | 63 | - type: file | ||
M go.mod +2 -1
| @@ -6,10 +6,11 @@ require ( | |||||
| 6 | github.com/a-h/templ v0.3.1020 | 6 | github.com/a-h/templ v0.3.1020 | ||
| 7 | github.com/andybalholm/brotli v1.2.2 | 7 | github.com/andybalholm/brotli v1.2.2 | ||
| 8 | github.com/bnema/purego v0.11.0-bnema.4 | 8 | github.com/bnema/purego v0.11.0-bnema.4 | ||
| 9 | - | github.com/bnema/purego-cef v0.13.4 | 9 | + | github.com/bnema/purego-cef v0.14.1 |
| 10 | github.com/bnema/purego-cef2gtk v0.8.5 | 10 | github.com/bnema/purego-cef2gtk v0.8.5 | ||
| 11 | github.com/bnema/purego-pipewire v0.1.5 | 11 | github.com/bnema/purego-pipewire v0.1.5 | ||
| 12 | github.com/bnema/purego-sqlite v0.1.4 | 12 | github.com/bnema/purego-sqlite v0.1.4 | ||
| 13 | + | github.com/bnema/purego-webp v0.2.1 | |||
| 13 | github.com/bnema/puregotk v0.7.1 | 14 | github.com/bnema/puregotk v0.7.1 | ||
| 14 | github.com/charmbracelet/bubbles v1.0.0 | 15 | github.com/charmbracelet/bubbles v1.0.0 | ||
| 15 | github.com/charmbracelet/bubbletea v1.3.10 | 16 | github.com/charmbracelet/bubbletea v1.3.10 | ||
M go.sum +4 -2 generated file: collapsed
M internal/application/port/audio_output.go +4 -3
| @@ -18,9 +18,10 @@ type AudioStreamFormat struct { | |||||
| 18 | // AudioOutputStream represents an active audio output stream. | 18 | // AudioOutputStream represents an active audio output stream. | ||
| 19 | // Implementations handle the low-level audio playback. | 19 | // Implementations handle the low-level audio playback. | ||
| 20 | type AudioOutputStream interface { | 20 | type AudioOutputStream interface { | ||
| 21 | - | // Write sends audio samples to the output device. | 21 | + | // Write sends callback-owned audio samples to the output device. |
| 22 | - | // Samples are provided as a 2D slice: [channel][frame]float32. | 22 | + | // Samples are provided as [channel][frame]float32 matching CEF's planar |
| 23 | - | // This matches CEF's planar float32 audio format. | 23 | + | // format. Implementations must not retain the slices and must be safe to |
| 24 | + | // call concurrently with Close; audio callbacks must not block. | |||
| 24 | Write(samples [][]float32) error | 25 | Write(samples [][]float32) error | ||
| 25 | // Close releases the audio stream and associated resources. | 26 | // Close releases the audio stream and associated resources. | ||
| 26 | Close() error | 27 | Close() error | ||
M internal/application/usecase/favicon_test.go +19 -0
| @@ -404,6 +404,25 @@ func TestFaviconObserveConversionErrorDoesNotMutateExistingAssets(t *testing.T) | |||||
| 404 | } | 404 | } | ||
| 405 | } | 405 | } | ||
| 406 | 406 | ||||
| 407 | + | func TestFaviconObserveUnavailableWebPPreservesCachedAssets(t *testing.T) { | |||
| 408 | + | fx := newFaviconFixture(t) | |||
| 409 | + | fx.seedOriginal([]byte("old")) | |||
| 410 | + | fx.blobs.png["example.com"] = []byte("old-png") | |||
| 411 | + | fx.blobs.sized["example.com"] = map[int][]byte{32: []byte("old-32")} | |||
| 412 | + | fx.converter.errByContentType = map[string]error{"image/webp": ErrFaviconMiss} | |||
| 413 | + | ||||
| 414 | + | _, err := fx.uc.Observe(context.Background(), "https://example.com", "https://example.com/favicon.webp", []byte("webp"), favicon.SourceEngine, "image/webp") | |||
| 415 | + | if !errors.Is(err, ErrFaviconMiss) { | |||
| 416 | + | t.Fatalf("Observe err=%v, want favicon miss", err) | |||
| 417 | + | } | |||
| 418 | + | if string(fx.blobs.original["example.com"]) != "old" || string(fx.blobs.png["example.com"]) != "old-png" || string(fx.blobs.sized["example.com"][32]) != "old-32" { | |||
| 419 | + | t.Fatalf("cached assets changed after unavailable WebP decoder") | |||
| 420 | + | } | |||
| 421 | + | if fx.blobs.writeOriginals != 0 || fx.blobs.removeDerived != 0 || fx.invalidators.calls != 0 { | |||
| 422 | + | t.Fatalf("unavailable decoder mutated cache: writes=%d removes=%d invalidations=%d", fx.blobs.writeOriginals, fx.blobs.removeDerived, fx.invalidators.calls) | |||
| 423 | + | } | |||
| 424 | + | } | |||
| 425 | + | ||||
| 407 | func TestFaviconEnsureSizedPropagatesReadSizedErrors(t *testing.T) { | 426 | func TestFaviconEnsureSizedPropagatesReadSizedErrors(t *testing.T) { | ||
| 408 | fx := newFaviconFixture(t) | 427 | fx := newFaviconFixture(t) | ||
| 409 | fx.seedOriginal([]byte("old")) | 428 | fx.seedOriginal([]byte("old")) | ||
M internal/infrastructure/audio/pipewire/output.go +123 -123
| @@ -15,7 +15,6 @@ import ( | |||||
| 15 | "github.com/bnema/dumber/internal/logging" | 15 | "github.com/bnema/dumber/internal/logging" | ||
| 16 | ) | 16 | ) | ||
| 17 | 17 | ||||
| 18 | - | // Compile-time interface checks. | |||
| 19 | var ( | 18 | var ( | ||
| 20 | _ port.AudioOutputFactory = (*Factory)(nil) | 19 | _ port.AudioOutputFactory = (*Factory)(nil) | ||
| 21 | _ port.AudioOutputStream = (*playerStreamAdapter)(nil) | 20 | _ port.AudioOutputStream = (*playerStreamAdapter)(nil) | ||
| @@ -27,6 +26,8 @@ var ErrInvalidFormat = errors.New("invalid audio stream format") | |||||
| 27 | // ErrStreamClosed is returned when writing to a closed stream. | 26 | // ErrStreamClosed is returned when writing to a closed stream. | ||
| 28 | var ErrStreamClosed = errors.New("audio stream closed") | 27 | var ErrStreamClosed = errors.New("audio stream closed") | ||
| 29 | 28 | ||||
| 29 | + | const packetQueueCapacity = 4 | |||
| 30 | + | ||||
| 30 | // playerCreator is a function type that creates a Player from config and callbacks. | 31 | // playerCreator is a function type that creates a Player from config and callbacks. | ||
| 31 | // This exists to allow dependency injection in tests. | 32 | // This exists to allow dependency injection in tests. | ||
| 32 | type playerCreator func(config pwpipewire.PlayerConfig, callbacks pwpipewire.PlayerCallbacks) (pwpipewire.Player, error) | 33 | type playerCreator func(config pwpipewire.PlayerConfig, callbacks pwpipewire.PlayerCallbacks) (pwpipewire.Player, error) | ||
| @@ -38,24 +39,15 @@ type Factory struct { | |||||
| 38 | } | 39 | } | ||
| 39 | 40 | ||||
| 40 | // NewFactory creates a new PipeWire audio factory. | 41 | // NewFactory creates a new PipeWire audio factory. | ||
| 41 | - | // The factory is stateless; players are created per-stream. | |||
| 42 | func NewFactory() (*Factory, error) { | 42 | func NewFactory() (*Factory, error) { | ||
| 43 | - | return &Factory{ | 43 | + | return &Factory{createPlayer: pwpipewire.NewPlayer}, nil |
| 44 | - | createPlayer: pwpipewire.NewPlayer, | |||
| 45 | - | }, nil | |||
| 46 | } | 44 | } | ||
| 47 | 45 | ||||
| 48 | - | // NewStream creates a new audio output stream with the given format. | 46 | + | // NewStream creates a push-to-pull adapter for a PipeWire player. |
| 49 | - | // It validates the Dumber format, creates a purego-pipewire Player configured | |||
| 50 | - | // from the format, and returns a push-to-pull adapter that bridges Write() | |||
| 51 | - | // calls into the Player's Fill callback. | |||
| 52 | func (f *Factory) NewStream(ctx context.Context, format port.AudioStreamFormat) (port.AudioOutputStream, error) { | 47 | func (f *Factory) NewStream(ctx context.Context, format port.AudioStreamFormat) (port.AudioOutputStream, error) { | ||
| 53 | - | // Check context cancellation | |||
| 54 | if ctx.Err() != nil { | 48 | if ctx.Err() != nil { | ||
| 55 | return nil, ctx.Err() | 49 | return nil, ctx.Err() | ||
| 56 | } | 50 | } | ||
| 57 | - | ||||
| 58 | - | // Validate format | |||
| 59 | if err := validateFormat(format); err != nil { | 51 | if err := validateFormat(format); err != nil { | ||
| 60 | return nil, err | 52 | return nil, err | ||
| 61 | } | 53 | } | ||
| @@ -67,182 +59,195 @@ func (f *Factory) NewStream(ctx context.Context, format port.AudioStreamFormat) | |||||
| 67 | Int("frames_per_buffer", format.FramesPerBuffer). | 59 | Int("frames_per_buffer", format.FramesPerBuffer). | ||
| 68 | Msg("pipewire: creating player") | 60 | Msg("pipewire: creating player") | ||
| 69 | 61 | ||||
| 70 | - | // Create the adapter with its ring buffer before creating the player, | 62 | + | // Every slot owns storage sized from the negotiated CEF format. Write copies |
| 71 | - | // because the Fill callback captures the adapter. | 63 | + | // directly into an available slot; Fill copies that same slot into PipeWire. |
| 72 | - | adapter := &playerStreamAdapter{ | 64 | + | // Callback slices are therefore never queued or retained. |
| 73 | - | ctx: ctx, | 65 | + | adapter := newPlayerStreamAdapter(ctx, format.ChannelCount, format.FramesPerBuffer) |
| 74 | - | buf: make(chan [][]float32, 4), // small buffer for push→pull bridging | |||
| 75 | - | closed: make(chan struct{}), | |||
| 76 | - | } | |||
| 77 | - | ||||
| 78 | config := pwpipewire.PlayerConfig{ | 66 | config := pwpipewire.PlayerConfig{ | ||
| 79 | - | SampleRate: format.SampleRate, | 67 | + | SampleRate: format.SampleRate, Channels: format.ChannelCount, FramesPerBuffer: format.FramesPerBuffer, |
| 80 | - | Channels: format.ChannelCount, | 68 | + | SampleFormat: pwpipewire.SampleFormatF32, UnderrunPolicy: pwpipewire.UnderrunFillSilence, |
| 81 | - | FramesPerBuffer: format.FramesPerBuffer, | |||
| 82 | - | SampleFormat: pwpipewire.SampleFormatF32, | |||
| 83 | - | UnderrunPolicy: pwpipewire.UnderrunFillSilence, | |||
| 84 | } | 69 | } | ||
| 85 | - | 70 | + | player, err := f.createPlayer(config, pwpipewire.PlayerCallbacks{Fill: adapter.fill}) | |
| 86 | - | callbacks := pwpipewire.PlayerCallbacks{ | |||
| 87 | - | Fill: adapter.fill, | |||
| 88 | - | } | |||
| 89 | - | ||||
| 90 | - | player, err := f.createPlayer(config, callbacks) | |||
| 91 | if err != nil { | 71 | if err != nil { | ||
| 92 | log.Warn().Err(err).Msg("pipewire: failed to create player") | 72 | log.Warn().Err(err).Msg("pipewire: failed to create player") | ||
| 93 | return nil, fmt.Errorf("failed to create pipewire player: %w", err) | 73 | return nil, fmt.Errorf("failed to create pipewire player: %w", err) | ||
| 94 | } | 74 | } | ||
| 95 | adapter.player = player | 75 | adapter.player = player | ||
| 96 | - | ||||
| 97 | - | // Start the player so the Fill callback begins being called. | |||
| 98 | if err := player.Start(); err != nil { | 76 | if err := player.Start(); err != nil { | ||
| 99 | _ = player.Close() | 77 | _ = player.Close() | ||
| 100 | log.Warn().Err(err).Msg("pipewire: failed to start player") | 78 | log.Warn().Err(err).Msg("pipewire: failed to start player") | ||
| 101 | return nil, fmt.Errorf("failed to start pipewire player: %w", err) | 79 | return nil, fmt.Errorf("failed to start pipewire player: %w", err) | ||
| 102 | } | 80 | } | ||
| 103 | - | ||||
| 104 | log.Info().Msg("pipewire: player started successfully") | 81 | log.Info().Msg("pipewire: player started successfully") | ||
| 105 | - | ||||
| 106 | return adapter, nil | 82 | return adapter, nil | ||
| 107 | } | 83 | } | ||
| 108 | 84 | ||||
| 109 | - | // validateFormat checks if the audio format parameters are valid. | |||
| 110 | func validateFormat(format port.AudioStreamFormat) error { | 85 | func validateFormat(format port.AudioStreamFormat) error { | ||
| 111 | - | // Sample rate must be reasonable (8kHz to 384kHz) | |||
| 112 | if format.SampleRate < 8000 || format.SampleRate > 384000 { | 86 | if format.SampleRate < 8000 || format.SampleRate > 384000 { | ||
| 113 | - | return fmt.Errorf("%w: sample rate %d Hz out of range [8000, 384000]", | 87 | + | return fmt.Errorf("%w: sample rate %d Hz out of range [8000, 384kHz]", ErrInvalidFormat, format.SampleRate) |
| 114 | - | ErrInvalidFormat, format.SampleRate) | |||
| 115 | } | 88 | } | ||
| 116 | - | ||||
| 117 | - | // Channel count must be 1 to 1024 (reasonable upper limit) | |||
| 118 | if format.ChannelCount < 1 || format.ChannelCount > 1024 { | 89 | if format.ChannelCount < 1 || format.ChannelCount > 1024 { | ||
| 119 | - | return fmt.Errorf("%w: channel count %d out of range [1, 1024]", | 90 | + | return fmt.Errorf("%w: channel count %d out of range [1, 1024]", ErrInvalidFormat, format.ChannelCount) |
| 120 | - | ErrInvalidFormat, format.ChannelCount) | |||
| 121 | } | 91 | } | ||
| 122 | - | ||||
| 123 | - | // Frames per buffer must be positive and reasonable | |||
| 124 | if format.FramesPerBuffer < 1 || format.FramesPerBuffer > 8192 { | 92 | if format.FramesPerBuffer < 1 || format.FramesPerBuffer > 8192 { | ||
| 125 | - | return fmt.Errorf("%w: frames per buffer %d out of range [1, 8192]", | 93 | + | return fmt.Errorf("%w: frames per buffer %d out of range [1, 8192]", ErrInvalidFormat, format.FramesPerBuffer) |
| 126 | - | ErrInvalidFormat, format.FramesPerBuffer) | |||
| 127 | } | 94 | } | ||
| 128 | - | ||||
| 129 | return nil | 95 | return nil | ||
| 130 | } | 96 | } | ||
| 131 | 97 | ||||
| 132 | - | // playerStreamAdapter bridges Dumber's push-style Write(samples) interface | 98 | + | // audioPacket is a reusable, adapter-owned packet slot. Its backing arrays are |
| 133 | - | // to purego-pipewire's pull-style Fill callback. | 99 | + | // allocated only when the stream is created and never alias CEF callback data. |
| 134 | - | // | 100 | + | type audioPacket struct { |
| 135 | - | // Write() sends sample buffers into a channel. The Fill callback drains them | 101 | + | samples [][]float32 |
| 136 | - | // into the PCMBuffer that purego-pipewire passes in. If no data is available, | 102 | + | channels int |
| 137 | - | // the player's UnderrunFillSilence policy handles it. | 103 | + | frames int |
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | // playerStreamAdapter bridges callback-owned CEF audio to PipeWire's Fill | |||
| 107 | + | // callback. available and ready form a bounded reusable packet ring. | |||
| 138 | type playerStreamAdapter struct { | 108 | type playerStreamAdapter struct { | ||
| 139 | player pwpipewire.Player | 109 | player pwpipewire.Player | ||
| 140 | ctx context.Context | 110 | ctx context.Context | ||
| 141 | - | buf chan [][]float32 // push→pull handoff channel | 111 | + | |
| 112 | + | available chan *audioPacket | |||
| 113 | + | ready chan *audioPacket | |||
| 114 | + | // TryLock serializes only simultaneous producers. The CEF callback never | |||
| 115 | + | // waits: contention and saturation both drop the packet. Fill never takes it. | |||
| 116 | + | writeMu sync.Mutex | |||
| 117 | + | closed atomic.Bool | |||
| 142 | 118 | ||||
| 143 | closeOnce sync.Once | 119 | closeOnce sync.Once | ||
| 144 | - | closed chan struct{} // closed when Close is called | |||
| 145 | 120 | ||||
| 146 | - | // Diagnostic counters (atomic, no mutex needed). | 121 | + | fillCount atomic.Uint64 |
| 147 | - | fillCount atomic.Uint64 // total Fill callbacks | 122 | + | underrunCount atomic.Uint64 |
| 148 | - | underrunCount atomic.Uint64 // Fill callbacks with no data available | 123 | + | dropCount atomic.Uint64 |
| 149 | - | dropCount atomic.Uint64 // Write calls that dropped data (buffer full) | 124 | + | } |
| 125 | + | ||||
| 126 | + | func newPlayerStreamAdapter(ctx context.Context, channels, frames int) *playerStreamAdapter { | |||
| 127 | + | a := &playerStreamAdapter{ | |||
| 128 | + | ctx: ctx, available: make(chan *audioPacket, packetQueueCapacity), ready: make(chan *audioPacket, packetQueueCapacity), | |||
| 129 | + | } | |||
| 130 | + | for range packetQueueCapacity { | |||
| 131 | + | packet := &audioPacket{samples: make([][]float32, channels)} | |||
| 132 | + | for ch := range packet.samples { | |||
| 133 | + | packet.samples[ch] = make([]float32, frames) | |||
| 134 | + | } | |||
| 135 | + | a.available <- packet | |||
| 136 | + | } | |||
| 137 | + | return a | |||
| 150 | } | 138 | } | ||
| 151 | 139 | ||||
| 152 | - | // Write sends audio samples to the PipeWire output. | 140 | + | // Write makes exactly one owned copy for each accepted packet. It never blocks: |
| 153 | - | // Samples are provided as [channel][frame]float32 matching CEF's planar format. | 141 | + | // full queues or another producer cause a silent drop, preserving CEF timing. |
| 154 | - | // If the internal buffer is full the packet is dropped silently so the caller | 142 | + | // The supplied slices are callback-owned and are not retained after return. |
| 155 | - | // (CEF audio callback thread) is never blocked. | |||
| 156 | - | // Safe for concurrent use with Close. | |||
| 157 | func (a *playerStreamAdapter) Write(samples [][]float32) error { | 143 | func (a *playerStreamAdapter) Write(samples [][]float32) error { | ||
| 158 | - | // Fast path: already closed. | 144 | + | if a.closed.Load() { |
| 159 | - | select { | |||
| 160 | - | case <-a.closed: | |||
| 161 | return ErrStreamClosed | 145 | return ErrStreamClosed | ||
| 162 | - | default: | |||
| 163 | } | 146 | } | ||
| 164 | - | // Non-blocking send: drop packet when buffer is full. | 147 | + | if !a.writeMu.TryLock() { |
| 165 | - | select { | 148 | + | a.recordDrop() |
| 166 | - | case a.buf <- samples: | |||
| 167 | return nil | 149 | return nil | ||
| 168 | - | case <-a.closed: | 150 | + | } |
| 151 | + | defer a.writeMu.Unlock() | |||
| 152 | + | if a.closed.Load() { | |||
| 169 | return ErrStreamClosed | 153 | return ErrStreamClosed | ||
| 170 | - | default: | 154 | + | } |
| 171 | - | // Buffer full — drop to avoid blocking the CEF audio thread. | 155 | + | |
| 172 | - | n := a.dropCount.Add(1) | 156 | + | select { |
| 173 | - | if n == 1 || n&(n-1) == 0 { // first drop, then powers of two | 157 | + | case packet := <-a.available: |
| 174 | - | if a.ctx != nil { | 158 | + | copyPacket(packet, samples) |
| 175 | - | logging.FromContext(a.ctx).Warn(). | 159 | + | // A close racing after the first check must not publish a packet to a |
| 176 | - | Uint64("total_drops", n). | 160 | + | // stopped player. Returning the slot keeps the ring bounded. |
| 177 | - | Msg("pipewire: dropped audio packet (buffer full)") | 161 | + | if a.closed.Load() { |
| 178 | - | } | 162 | + | a.available <- packet |
| 163 | + | return ErrStreamClosed | |||
| 164 | + | } | |||
| 165 | + | select { | |||
| 166 | + | case a.ready <- packet: | |||
| 167 | + | return nil | |||
| 168 | + | default: | |||
| 169 | + | // This cannot occur while ring invariants hold, but keep Write | |||
| 170 | + | // non-blocking if an implementation change violates them. | |||
| 171 | + | a.available <- packet | |||
| 172 | + | a.recordDrop() | |||
| 173 | + | return nil | |||
| 179 | } | 174 | } | ||
| 175 | + | default: | |||
| 176 | + | a.recordDrop() | |||
| 180 | return nil | 177 | return nil | ||
| 181 | } | 178 | } | ||
| 182 | } | 179 | } | ||
| 183 | 180 | ||||
| 184 | - | // fill is the PlayerCallbacks.Fill function called by purego-pipewire | 181 | + | func copyPacket(packet *audioPacket, samples [][]float32) { |
| 185 | - | // when it needs audio data. It pulls from the internal buffer channel. | 182 | + | packet.channels = min(len(packet.samples), len(samples)) |
| 183 | + | packet.frames = 0 | |||
| 184 | + | if packet.channels == 0 { | |||
| 185 | + | return | |||
| 186 | + | } | |||
| 187 | + | frames := len(packet.samples[0]) | |||
| 188 | + | for ch := 0; ch < packet.channels; ch++ { | |||
| 189 | + | n := min(len(packet.samples[ch]), len(samples[ch])) | |||
| 190 | + | if n < frames { | |||
| 191 | + | frames = n | |||
| 192 | + | } | |||
| 193 | + | } | |||
| 194 | + | packet.frames = frames | |||
| 195 | + | for ch := 0; ch < packet.channels; ch++ { | |||
| 196 | + | copy(packet.samples[ch][:frames], samples[ch][:frames]) | |||
| 197 | + | } | |||
| 198 | + | } | |||
| 199 | + | ||||
| 200 | + | func (a *playerStreamAdapter) recordDrop() { | |||
| 201 | + | // Write runs on CEF's real-time callback thread. Diagnostics must stay | |||
| 202 | + | // allocation-free and must not synchronously emit logs from that path. | |||
| 203 | + | a.dropCount.Add(1) | |||
| 204 | + | } | |||
| 205 | + | ||||
| 206 | + | // fill is the PlayerCallbacks.Fill function. It performs the only post-handoff | |||
| 207 | + | // full-packet copy, directly into PipeWire's destination buffer. | |||
| 186 | func (a *playerStreamAdapter) fill(pcm *pwpipewire.PCMBuffer) (int, error) { | 208 | func (a *playerStreamAdapter) fill(pcm *pwpipewire.PCMBuffer) (int, error) { | ||
| 187 | select { | 209 | select { | ||
| 188 | - | case samples := <-a.buf: | 210 | + | case packet := <-a.ready: |
| 189 | fillNum := a.fillCount.Add(1) | 211 | fillNum := a.fillCount.Add(1) | ||
| 190 | if fillNum == 1 && a.ctx != nil { | 212 | if fillNum == 1 && a.ctx != nil { | ||
| 191 | - | logging.FromContext(a.ctx).Info(). | 213 | + | logging.FromContext(a.ctx).Info().Msg("pipewire: first Fill callback with data") |
| 192 | - | Msg("pipewire: first Fill callback with data") | |||
| 193 | } | 214 | } | ||
| 194 | - | // Copy available samples into the PCM buffer | 215 | + | copied := copyToPCM(pcm, packet.samples[:packet.channels], packet.frames) |
| 195 | - | copied := copyToPCM(pcm, samples) | 216 | + | a.available <- packet |
| 196 | return copied, nil | 217 | return copied, nil | ||
| 197 | default: | 218 | default: | ||
| 198 | - | // No data available — return 0 frames, let underrun policy handle it | |||
| 199 | underruns := a.underrunCount.Add(1) | 219 | underruns := a.underrunCount.Add(1) | ||
| 200 | if underruns == 1 && a.ctx != nil { | 220 | if underruns == 1 && a.ctx != nil { | ||
| 201 | - | logging.FromContext(a.ctx).Debug(). | 221 | + | logging.FromContext(a.ctx).Debug().Msg("pipewire: first Fill underrun (no data available)") |
| 202 | - | Msg("pipewire: first Fill underrun (no data available)") | |||
| 203 | } | 222 | } | ||
| 204 | return 0, nil | 223 | return 0, nil | ||
| 205 | } | 224 | } | ||
| 206 | } | 225 | } | ||
| 207 | 226 | ||||
| 208 | - | // copyToPCM copies planar samples into the PCMBuffer. | 227 | + | func copyToPCM(pcm *pwpipewire.PCMBuffer, samples [][]float32, packetFrames int) int { |
| 209 | - | // Returns the number of frames actually copied. | 228 | + | if pcm == nil || len(samples) == 0 || packetFrames == 0 { |
| 210 | - | func copyToPCM(pcm *pwpipewire.PCMBuffer, samples [][]float32) int { | |||
| 211 | - | if pcm == nil || len(samples) == 0 { | |||
| 212 | return 0 | 229 | return 0 | ||
| 213 | } | 230 | } | ||
| 214 | - | ||||
| 215 | - | frames := pcm.Frames | |||
| 216 | channels := min(min(len(samples), pcm.Channels), len(pcm.Samples)) | 231 | channels := min(min(len(samples), pcm.Channels), len(pcm.Samples)) | ||
| 217 | if channels == 0 { | 232 | if channels == 0 { | ||
| 218 | return 0 | 233 | return 0 | ||
| 219 | } | 234 | } | ||
| 220 | - | 235 | + | frames := min(pcm.Frames, packetFrames) | |
| 221 | - | minFrames := frames | 236 | + | for ch := 0; ch < channels; ch++ { |
| 222 | - | 237 | + | frames = min(frames, min(len(pcm.Samples[ch]), len(samples[ch]))) | |
| 223 | - | for ch := 0; ch < channels && ch < len(samples); ch++ { | |||
| 224 | - | dst := pcm.Samples[ch] | |||
| 225 | - | src := samples[ch] | |||
| 226 | - | if len(dst) == 0 || len(src) == 0 { | |||
| 227 | - | minFrames = 0 | |||
| 228 | - | continue | |||
| 229 | - | } | |||
| 230 | - | srcFrames := len(src) | |||
| 231 | - | n := min(len(dst), min(srcFrames, frames)) | |||
| 232 | - | if n < minFrames { | |||
| 233 | - | minFrames = n | |||
| 234 | - | } | |||
| 235 | - | copy(dst[:n], src[:n]) | |||
| 236 | } | 238 | } | ||
| 237 | - | 239 | + | for ch := 0; ch < channels; ch++ { | |
| 238 | - | return minFrames | 240 | + | copy(pcm.Samples[ch][:frames], samples[ch][:frames]) |
| 241 | + | } | |||
| 242 | + | return frames | |||
| 239 | } | 243 | } | ||
| 240 | 244 | ||||
| 241 | - | // Close stops the player and releases resources. | 245 | + | // Close stops the player. It is safe with Write: Write observes closed before |
| 242 | - | // Safe to call multiple times. | 246 | + | // publishing, and no channel is closed while a callback may be selecting on it. |
| 243 | func (a *playerStreamAdapter) Close() error { | 247 | func (a *playerStreamAdapter) Close() error { | ||
| 244 | var err error | 248 | var err error | ||
| 245 | a.closeOnce.Do(func() { | 249 | a.closeOnce.Do(func() { | ||
| 250 | + | a.closed.Store(true) | |||
| 246 | if a.ctx != nil { | 251 | if a.ctx != nil { | ||
| 247 | logging.FromContext(a.ctx).Info(). | 252 | logging.FromContext(a.ctx).Info(). | ||
| 248 | Uint64("fills", a.fillCount.Load()). | 253 | Uint64("fills", a.fillCount.Load()). | ||
| @@ -250,12 +255,7 @@ func (a *playerStreamAdapter) Close() error { | |||||
| 250 | Uint64("drops", a.dropCount.Load()). | 255 | Uint64("drops", a.dropCount.Load()). | ||
| 251 | Msg("pipewire: closing player") | 256 | Msg("pipewire: closing player") | ||
| 252 | } | 257 | } | ||
| 253 | - | ||||
| 254 | - | // Signal writers to stop | |||
| 255 | - | close(a.closed) | |||
| 256 | - | ||||
| 257 | if a.player != nil { | 258 | if a.player != nil { | ||
| 258 | - | // Stop playback then close the player | |||
| 259 | if stopErr := a.player.Stop(); stopErr != nil { | 259 | if stopErr := a.player.Stop(); stopErr != nil { | ||
| 260 | err = stopErr | 260 | err = stopErr | ||
| 261 | } | 261 | } | ||
M internal/infrastructure/audio/pipewire/output_test.go +139 -494
| @@ -1,11 +1,11 @@ | |||||
| 1 | - | // Package pipewire provides a PipeWire-based audio output implementation. | |||
| 2 | - | // This wraps purego-pipewire's high-level Player API. | |||
| 3 | package pipewire | 1 | package pipewire | ||
| 4 | 2 | ||||
| 5 | import ( | 3 | import ( | ||
| 6 | "context" | 4 | "context" | ||
| 7 | "errors" | 5 | "errors" | ||
| 6 | + | "sync" | |||
| 8 | "testing" | 7 | "testing" | ||
| 8 | + | "time" | |||
| 9 | 9 | ||||
| 10 | "github.com/bnema/dumber/internal/application/port" | 10 | "github.com/bnema/dumber/internal/application/port" | ||
| 11 | pipewiremocks "github.com/bnema/dumber/internal/infrastructure/audio/pipewire/mocks" | 11 | pipewiremocks "github.com/bnema/dumber/internal/infrastructure/audio/pipewire/mocks" | ||
| @@ -16,548 +16,193 @@ import ( | |||||
| 16 | 16 | ||||
| 17 | type playerRecorder struct { | 17 | type playerRecorder struct { | ||
| 18 | *pipewiremocks.MockPlayer | 18 | *pipewiremocks.MockPlayer | ||
| 19 | - | 19 | + | started, stopped, closedFlag bool | |
| 20 | - | started bool | 20 | + | startErr, stopErr, closeErr error |
| 21 | - | paused bool | |||
| 22 | - | stopped bool | |||
| 23 | - | closedFlag bool | |||
| 24 | - | state pwpipewire.PlayerState | |||
| 25 | - | ||||
| 26 | - | startErr error | |||
| 27 | - | stopErr error | |||
| 28 | - | closeErr error | |||
| 29 | } | 21 | } | ||
| 30 | 22 | ||||
| 31 | func newPlayerRecorder(t *testing.T) *playerRecorder { | 23 | func newPlayerRecorder(t *testing.T) *playerRecorder { | ||
| 32 | t.Helper() | 24 | t.Helper() | ||
| 33 | - | 25 | + | p := &playerRecorder{MockPlayer: pipewiremocks.NewMockPlayer(t)} | |
| 34 | - | recorder := &playerRecorder{MockPlayer: pipewiremocks.NewMockPlayer(t)} | 26 | + | p.EXPECT().Start().RunAndReturn(func() error { |
| 35 | - | recorder.EXPECT().Start().RunAndReturn(recorder.start).Maybe() | 27 | + | if p.startErr != nil { |
| 36 | - | recorder.EXPECT().Pause().RunAndReturn(recorder.pause).Maybe() | 28 | + | return p.startErr |
| 37 | - | recorder.EXPECT().Stop().RunAndReturn(recorder.stop).Maybe() | 29 | + | } |
| 38 | - | recorder.EXPECT().Close().RunAndReturn(recorder.close).Maybe() | 30 | + | p.started = true |
| 39 | - | recorder.EXPECT().State().RunAndReturn(recorder.currentState).Maybe() | 31 | + | return nil |
| 40 | - | return recorder | 32 | + | }).Maybe() |
| 41 | - | } | 33 | + | p.EXPECT().Stop().RunAndReturn(func() error { |
| 42 | - | 34 | + | if p.stopErr != nil { | |
| 43 | - | func (p *playerRecorder) start() error { | 35 | + | return p.stopErr |
| 44 | - | if p.startErr != nil { | 36 | + | } |
| 45 | - | return p.startErr | 37 | + | p.stopped = true |
| 46 | - | } | 38 | + | return nil |
| 47 | - | p.started = true | 39 | + | }).Maybe() |
| 48 | - | p.state = pwpipewire.PlayerStatePlaying | 40 | + | p.EXPECT().Close().RunAndReturn(func() error { |
| 49 | - | return nil | 41 | + | if p.closeErr != nil { |
| 50 | - | } | 42 | + | return p.closeErr |
| 51 | - | 43 | + | } | |
| 52 | - | func (p *playerRecorder) pause() error { | 44 | + | p.closedFlag = true |
| 53 | - | p.paused = true | 45 | + | return nil |
| 54 | - | p.state = pwpipewire.PlayerStatePaused | 46 | + | }).Maybe() |
| 55 | - | return nil | 47 | + | return p |
| 56 | - | } | |||
| 57 | - | ||||
| 58 | - | func (p *playerRecorder) stop() error { | |||
| 59 | - | if p.stopErr != nil { | |||
| 60 | - | return p.stopErr | |||
| 61 | - | } | |||
| 62 | - | p.stopped = true | |||
| 63 | - | p.state = pwpipewire.PlayerStateStopped | |||
| 64 | - | return nil | |||
| 65 | } | 48 | } | ||
| 66 | 49 | ||||
| 67 | - | func (p *playerRecorder) close() error { | 50 | + | func testAdapter(frames int) *playerStreamAdapter { |
| 68 | - | if p.closeErr != nil { | 51 | + | return newPlayerStreamAdapter(context.Background(), 2, frames) |
| 69 | - | return p.closeErr | |||
| 70 | - | } | |||
| 71 | - | p.closedFlag = true | |||
| 72 | - | p.state = pwpipewire.PlayerStateClosed | |||
| 73 | - | return nil | |||
| 74 | } | 52 | } | ||
| 75 | 53 | ||||
| 76 | - | func (p *playerRecorder) currentState() pwpipewire.PlayerState { | 54 | + | func stereoPCM(frames int) *pwpipewire.PCMBuffer { |
| 77 | - | return p.state | 55 | + | return &pwpipewire.PCMBuffer{Frames: frames, Channels: 2, Samples: [][]float32{make([]float32, frames), make([]float32, frames)}} |
| 78 | } | 56 | } | ||
| 79 | 57 | ||||
| 80 | - | // --- Factory tests --- | 58 | + | func TestFactory_NewStreamValidatesAndStartsPlayer(t *testing.T) { |
| 81 | - | ||||
| 82 | - | func TestFactory_NewStream_ValidFormat_CreatesStream(t *testing.T) { | |||
| 83 | - | var capturedConfig pwpipewire.PlayerConfig | |||
| 84 | player := newPlayerRecorder(t) | 59 | player := newPlayerRecorder(t) | ||
| 85 | - | 60 | + | factory := &Factory{createPlayer: func(config pwpipewire.PlayerConfig, callbacks pwpipewire.PlayerCallbacks) (pwpipewire.Player, error) { | |
| 86 | - | factory := &Factory{ | 61 | + | assert.Equal(t, 48000, config.SampleRate) |
| 87 | - | createPlayer: func(config pwpipewire.PlayerConfig, _ pwpipewire.PlayerCallbacks) (pwpipewire.Player, error) { | 62 | + | assert.Equal(t, 2, config.Channels) |
| 88 | - | capturedConfig = config | 63 | + | assert.NotNil(t, callbacks.Fill) |
| 89 | - | return player, nil | 64 | + | return player, nil |
| 90 | - | }, | 65 | + | }} |
| 91 | - | } | 66 | + | stream, err := factory.NewStream(context.Background(), port.AudioStreamFormat{SampleRate: 48000, ChannelCount: 2, FramesPerBuffer: 512}) |
| 92 | - | ||||
| 93 | - | format := port.AudioStreamFormat{ | |||
| 94 | - | SampleRate: 48000, | |||
| 95 | - | ChannelCount: 2, | |||
| 96 | - | FramesPerBuffer: 512, | |||
| 97 | - | } | |||
| 98 | - | ||||
| 99 | - | stream, err := factory.NewStream(context.Background(), format) | |||
| 100 | require.NoError(t, err) | 67 | require.NoError(t, err) | ||
| 101 | - | require.NotNil(t, stream) | |||
| 102 | - | ||||
| 103 | - | // Verify config mapping | |||
| 104 | - | assert.Equal(t, 48000, capturedConfig.SampleRate) | |||
| 105 | - | assert.Equal(t, 2, capturedConfig.Channels) | |||
| 106 | - | assert.Equal(t, 512, capturedConfig.FramesPerBuffer) | |||
| 107 | - | assert.Equal(t, pwpipewire.SampleFormatF32, capturedConfig.SampleFormat) | |||
| 108 | - | assert.Equal(t, pwpipewire.UnderrunFillSilence, capturedConfig.UnderrunPolicy) | |||
| 109 | - | ||||
| 110 | - | // Verify player was started | |||
| 111 | assert.True(t, player.started) | 68 | assert.True(t, player.started) | ||
| 112 | - | ||||
| 113 | - | // Clean up | |||
| 114 | require.NoError(t, stream.Close()) | 69 | require.NoError(t, stream.Close()) | ||
| 115 | } | 70 | } | ||
| 116 | 71 | ||||
| 117 | - | func TestFactory_NewStream_InvalidFormat_ReturnsError(t *testing.T) { | 72 | + | func TestFactory_NewStreamRejectsInvalidFormat(t *testing.T) { |
| 118 | - | factory := &Factory{ | 73 | + | factory := &Factory{createPlayer: func(pwpipewire.PlayerConfig, pwpipewire.PlayerCallbacks) (pwpipewire.Player, error) { |
| 119 | - | createPlayer: func(_ pwpipewire.PlayerConfig, _ pwpipewire.PlayerCallbacks) (pwpipewire.Player, error) { | 74 | + | t.Fatal("unexpected player") |
| 120 | - | t.Fatal("createPlayer should not be called for invalid format") | 75 | + | return nil, nil |
| 121 | - | return nil, nil | 76 | + | }} |
| 122 | - | }, | 77 | + | for _, format := range []port.AudioStreamFormat{ |
| 123 | - | } | 78 | + | {SampleRate: 0, ChannelCount: 2, FramesPerBuffer: 512}, |
| 124 | - | 79 | + | {SampleRate: 48000, ChannelCount: 0, FramesPerBuffer: 512}, | |
| 125 | - | tests := []struct { | 80 | + | {SampleRate: 48000, ChannelCount: 2, FramesPerBuffer: 0}, |
| 126 | - | name string | 81 | + | } { |
| 127 | - | format port.AudioStreamFormat | 82 | + | stream, err := factory.NewStream(context.Background(), format) |
| 128 | - | }{ | 83 | + | require.ErrorIs(t, err, ErrInvalidFormat) |
| 129 | - | { | 84 | + | assert.Nil(t, stream) |
| 130 | - | name: "zero sample rate", | |||
| 131 | - | format: port.AudioStreamFormat{SampleRate: 0, ChannelCount: 2, FramesPerBuffer: 512}, | |||
| 132 | - | }, | |||
| 133 | - | { | |||
| 134 | - | name: "zero channels", | |||
| 135 | - | format: port.AudioStreamFormat{SampleRate: 48000, ChannelCount: 0, FramesPerBuffer: 512}, | |||
| 136 | - | }, | |||
| 137 | - | { | |||
| 138 | - | name: "negative sample rate", | |||
| 139 | - | format: port.AudioStreamFormat{SampleRate: -48000, ChannelCount: 2, FramesPerBuffer: 512}, | |||
| 140 | - | }, | |||
| 141 | - | { | |||
| 142 | - | name: "negative channels", | |||
| 143 | - | format: port.AudioStreamFormat{SampleRate: 48000, ChannelCount: -2, FramesPerBuffer: 512}, | |||
| 144 | - | }, | |||
| 145 | - | { | |||
| 146 | - | name: "excessive sample rate", | |||
| 147 | - | format: port.AudioStreamFormat{SampleRate: 1000000, ChannelCount: 2, FramesPerBuffer: 512}, | |||
| 148 | - | }, | |||
| 149 | - | { | |||
| 150 | - | name: "excessive channels", | |||
| 151 | - | format: port.AudioStreamFormat{SampleRate: 48000, ChannelCount: 1025, FramesPerBuffer: 512}, | |||
| 152 | - | }, | |||
| 153 | - | { | |||
| 154 | - | name: "zero frames per buffer", | |||
| 155 | - | format: port.AudioStreamFormat{SampleRate: 48000, ChannelCount: 2, FramesPerBuffer: 0}, | |||
| 156 | - | }, | |||
| 157 | - | { | |||
| 158 | - | name: "excessive frames per buffer", | |||
| 159 | - | format: port.AudioStreamFormat{SampleRate: 48000, ChannelCount: 2, FramesPerBuffer: 10000}, | |||
| 160 | - | }, | |||
| 161 | - | } | |||
| 162 | - | ||||
| 163 | - | for _, tt := range tests { | |||
| 164 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 165 | - | stream, err := factory.NewStream(context.Background(), tt.format) | |||
| 166 | - | require.Error(t, err) | |||
| 167 | - | assert.Nil(t, stream) | |||
| 168 | - | assert.ErrorIs(t, err, ErrInvalidFormat) | |||
| 169 | - | }) | |||
| 170 | - | } | |||
| 171 | - | } | |||
| 172 | - | ||||
| 173 | - | func TestFactory_NewStream_PlayerCreationFails_ReturnsError(t *testing.T) { | |||
| 174 | - | expectedErr := errors.New("pipewire not available") | |||
| 175 | - | factory := &Factory{ | |||
| 176 | - | createPlayer: func(_ pwpipewire.PlayerConfig, _ pwpipewire.PlayerCallbacks) (pwpipewire.Player, error) { | |||
| 177 | - | return nil, expectedErr | |||
| 178 | - | }, | |||
| 179 | } | 85 | } | ||
| 180 | - | ||||
| 181 | - | format := port.AudioStreamFormat{SampleRate: 48000, ChannelCount: 2, FramesPerBuffer: 512} | |||
| 182 | - | stream, err := factory.NewStream(context.Background(), format) | |||
| 183 | - | ||||
| 184 | - | require.Error(t, err) | |||
| 185 | - | assert.Nil(t, stream) | |||
| 186 | - | assert.ErrorIs(t, err, expectedErr) | |||
| 187 | } | 86 | } | ||
| 188 | 87 | ||||
| 189 | - | func TestFactory_NewStream_PlayerStartFails_ClosesAndReturnsError(t *testing.T) { | 88 | + | func TestFactory_NewStreamClosesPlayerWhenStartFails(t *testing.T) { |
| 190 | player := newPlayerRecorder(t) | 89 | player := newPlayerRecorder(t) | ||
| 191 | player.startErr = errors.New("start failed") | 90 | player.startErr = errors.New("start failed") | ||
| 192 | - | factory := &Factory{ | 91 | + | factory := &Factory{createPlayer: func(pwpipewire.PlayerConfig, pwpipewire.PlayerCallbacks) (pwpipewire.Player, error) { |
| 193 | - | createPlayer: func(_ pwpipewire.PlayerConfig, _ pwpipewire.PlayerCallbacks) (pwpipewire.Player, error) { | 92 | + | return player, nil |
| 194 | - | return player, nil | 93 | + | }} |
| 195 | - | }, | 94 | + | stream, err := factory.NewStream(context.Background(), port.AudioStreamFormat{SampleRate: 48000, ChannelCount: 2, FramesPerBuffer: 512}) |
| 196 | - | } | |||
| 197 | - | ||||
| 198 | - | format := port.AudioStreamFormat{SampleRate: 48000, ChannelCount: 2, FramesPerBuffer: 512} | |||
| 199 | - | stream, err := factory.NewStream(context.Background(), format) | |||
| 200 | - | ||||
| 201 | - | require.Error(t, err) | |||
| 202 | - | assert.Nil(t, stream) | |||
| 203 | - | assert.True(t, player.closedFlag, "player should be closed after start failure") | |||
| 204 | - | } | |||
| 205 | - | ||||
| 206 | - | func TestFactory_NewStream_ContextCancelled_ReturnsError(t *testing.T) { | |||
| 207 | - | factory := &Factory{ | |||
| 208 | - | createPlayer: func(_ pwpipewire.PlayerConfig, _ pwpipewire.PlayerCallbacks) (pwpipewire.Player, error) { | |||
| 209 | - | t.Fatal("createPlayer should not be called when context is canceled") | |||
| 210 | - | return nil, nil | |||
| 211 | - | }, | |||
| 212 | - | } | |||
| 213 | - | ||||
| 214 | - | ctx, cancel := context.WithCancel(context.Background()) | |||
| 215 | - | cancel() | |||
| 216 | - | ||||
| 217 | - | format := port.AudioStreamFormat{SampleRate: 48000, ChannelCount: 2, FramesPerBuffer: 512} | |||
| 218 | - | stream, err := factory.NewStream(ctx, format) | |||
| 219 | - | ||||
| 220 | require.Error(t, err) | 95 | require.Error(t, err) | ||
| 221 | assert.Nil(t, stream) | 96 | assert.Nil(t, stream) | ||
| 222 | - | } | |||
| 223 | - | ||||
| 224 | - | func TestFactory_NewStream_MultipleFormats_Accepted(t *testing.T) { | |||
| 225 | - | formats := []port.AudioStreamFormat{ | |||
| 226 | - | {SampleRate: 48000, ChannelCount: 2, FramesPerBuffer: 512}, | |||
| 227 | - | {SampleRate: 44100, ChannelCount: 2, FramesPerBuffer: 512}, | |||
| 228 | - | {SampleRate: 48000, ChannelCount: 1, FramesPerBuffer: 256}, | |||
| 229 | - | {SampleRate: 96000, ChannelCount: 2, FramesPerBuffer: 1024}, | |||
| 230 | - | {SampleRate: 48000, ChannelCount: 6, FramesPerBuffer: 512}, | |||
| 231 | - | } | |||
| 232 | - | ||||
| 233 | - | for _, fmt := range formats { | |||
| 234 | - | t.Run("", func(t *testing.T) { | |||
| 235 | - | factory := &Factory{ | |||
| 236 | - | createPlayer: func(config pwpipewire.PlayerConfig, _ pwpipewire.PlayerCallbacks) (pwpipewire.Player, error) { | |||
| 237 | - | assert.Equal(t, fmt.SampleRate, config.SampleRate) | |||
| 238 | - | assert.Equal(t, fmt.ChannelCount, config.Channels) | |||
| 239 | - | assert.Equal(t, fmt.FramesPerBuffer, config.FramesPerBuffer) | |||
| 240 | - | return newPlayerRecorder(t), nil | |||
| 241 | - | }, | |||
| 242 | - | } | |||
| 243 | - | ||||
| 244 | - | stream, err := factory.NewStream(context.Background(), fmt) | |||
| 245 | - | require.NoError(t, err) | |||
| 246 | - | assert.NotNil(t, stream) | |||
| 247 | - | ||||
| 248 | - | _ = stream.Close() | |||
| 249 | - | }) | |||
| 250 | - | } | |||
| 251 | - | } | |||
| 252 | - | ||||
| 253 | - | func TestFactory_ImplementsPortInterface(_ *testing.T) { | |||
| 254 | - | var _ port.AudioOutputFactory = (*Factory)(nil) | |||
| 255 | - | } | |||
| 256 | - | ||||
| 257 | - | // --- Stream adapter tests --- | |||
| 258 | - | ||||
| 259 | - | func TestPlayerStreamAdapter_Write_SendsToBuffer(t *testing.T) { | |||
| 260 | - | player := newPlayerRecorder(t) | |||
| 261 | - | adapter := &playerStreamAdapter{ | |||
| 262 | - | player: player, | |||
| 263 | - | buf: make(chan [][]float32, 4), | |||
| 264 | - | closed: make(chan struct{}), | |||
| 265 | - | } | |||
| 266 | - | ||||
| 267 | - | samples := [][]float32{ | |||
| 268 | - | {0.1, 0.2}, | |||
| 269 | - | {0.3, 0.4}, | |||
| 270 | - | } | |||
| 271 | - | ||||
| 272 | - | err := adapter.Write(samples) | |||
| 273 | - | require.NoError(t, err) | |||
| 274 | - | ||||
| 275 | - | // Verify data is in the channel | |||
| 276 | - | received := <-adapter.buf | |||
| 277 | - | assert.Equal(t, samples, received) | |||
| 278 | - | } | |||
| 279 | - | ||||
| 280 | - | func TestPlayerStreamAdapter_Write_AfterClose_ReturnsError(t *testing.T) { | |||
| 281 | - | player := newPlayerRecorder(t) | |||
| 282 | - | adapter := &playerStreamAdapter{ | |||
| 283 | - | player: player, | |||
| 284 | - | buf: make(chan [][]float32, 4), | |||
| 285 | - | closed: make(chan struct{}), | |||
| 286 | - | } | |||
| 287 | - | ||||
| 288 | - | require.NoError(t, adapter.Close()) | |||
| 289 | - | ||||
| 290 | - | err := adapter.Write([][]float32{{0.1}}) | |||
| 291 | - | assert.ErrorIs(t, err, ErrStreamClosed) | |||
| 292 | - | } | |||
| 293 | - | ||||
| 294 | - | func TestPlayerStreamAdapter_Close_StopsAndClosesPlayer(t *testing.T) { | |||
| 295 | - | player := newPlayerRecorder(t) | |||
| 296 | - | adapter := &playerStreamAdapter{ | |||
| 297 | - | player: player, | |||
| 298 | - | buf: make(chan [][]float32, 4), | |||
| 299 | - | closed: make(chan struct{}), | |||
| 300 | - | } | |||
| 301 | - | ||||
| 302 | - | err := adapter.Close() | |||
| 303 | - | require.NoError(t, err) | |||
| 304 | - | ||||
| 305 | - | assert.True(t, player.stopped) | |||
| 306 | assert.True(t, player.closedFlag) | 97 | assert.True(t, player.closedFlag) | ||
| 307 | } | 98 | } | ||
| 308 | 99 | ||||
| 309 | - | func TestPlayerStreamAdapter_Close_IsIdempotent(t *testing.T) { | 100 | + | func TestPlayerStreamAdapter_FillGetsOwnedPacketAfterCallbackMutation(t *testing.T) { |
| 310 | - | player := newPlayerRecorder(t) | 101 | + | adapter := testAdapter(3) |
| 311 | - | adapter := &playerStreamAdapter{ | 102 | + | samples := [][]float32{{1, 2, 3}, {4, 5, 6}} |
| 312 | - | player: player, | 103 | + | require.NoError(t, adapter.Write(samples)) |
| 313 | - | buf: make(chan [][]float32, 4), | 104 | + | samples[0][0], samples[1][0] = 99, 99 |
| 314 | - | closed: make(chan struct{}), | 105 | + | pcm := stereoPCM(3) |
| 315 | - | } | |||
| 316 | - | ||||
| 317 | - | require.NoError(t, adapter.Close()) | |||
| 318 | - | require.NoError(t, adapter.Close()) | |||
| 319 | - | } | |||
| 320 | - | ||||
| 321 | - | func TestPlayerStreamAdapter_Close_PropagatesStopError(t *testing.T) { | |||
| 322 | - | player := newPlayerRecorder(t) | |||
| 323 | - | player.stopErr = errors.New("stop error") | |||
| 324 | - | adapter := &playerStreamAdapter{ | |||
| 325 | - | player: player, | |||
| 326 | - | buf: make(chan [][]float32, 4), | |||
| 327 | - | closed: make(chan struct{}), | |||
| 328 | - | } | |||
| 329 | - | ||||
| 330 | - | err := adapter.Close() | |||
| 331 | - | require.Error(t, err) | |||
| 332 | - | assert.Contains(t, err.Error(), "stop error") | |||
| 333 | - | } | |||
| 334 | - | ||||
| 335 | - | func TestPlayerStreamAdapter_Close_PropagatesCloseError(t *testing.T) { | |||
| 336 | - | player := newPlayerRecorder(t) | |||
| 337 | - | player.closeErr = errors.New("close error") | |||
| 338 | - | adapter := &playerStreamAdapter{ | |||
| 339 | - | player: player, | |||
| 340 | - | buf: make(chan [][]float32, 4), | |||
| 341 | - | closed: make(chan struct{}), | |||
| 342 | - | } | |||
| 343 | - | ||||
| 344 | - | err := adapter.Close() | |||
| 345 | - | require.Error(t, err) | |||
| 346 | - | assert.Contains(t, err.Error(), "close error") | |||
| 347 | - | } | |||
| 348 | - | ||||
| 349 | - | // --- Fill callback tests --- | |||
| 350 | - | ||||
| 351 | - | func TestFill_PullsFromBuffer(t *testing.T) { | |||
| 352 | - | adapter := &playerStreamAdapter{ | |||
| 353 | - | buf: make(chan [][]float32, 4), | |||
| 354 | - | } | |||
| 355 | - | ||||
| 356 | - | // Push samples | |||
| 357 | - | samples := [][]float32{ | |||
| 358 | - | {0.1, 0.2, 0.3}, | |||
| 359 | - | {0.4, 0.5, 0.6}, | |||
| 360 | - | } | |||
| 361 | - | adapter.buf <- samples | |||
| 362 | - | ||||
| 363 | - | // Simulate Fill call | |||
| 364 | - | pcm := &pwpipewire.PCMBuffer{ | |||
| 365 | - | Frames: 3, | |||
| 366 | - | Channels: 2, | |||
| 367 | - | Stride: 1, | |||
| 368 | - | Samples: [][]float32{ | |||
| 369 | - | make([]float32, 3), | |||
| 370 | - | make([]float32, 3), | |||
| 371 | - | }, | |||
| 372 | - | } | |||
| 373 | - | ||||
| 374 | frames, err := adapter.fill(pcm) | 106 | frames, err := adapter.fill(pcm) | ||
| 375 | require.NoError(t, err) | 107 | require.NoError(t, err) | ||
| 376 | assert.Equal(t, 3, frames) | 108 | assert.Equal(t, 3, frames) | ||
| 377 | - | assert.InEpsilon(t, 0.1, pcm.Samples[0][0], 0.0001) | 109 | + | assert.InEpsilon(t, 1, pcm.Samples[0][0], 0.0001) |
| 378 | - | assert.InEpsilon(t, 0.4, pcm.Samples[1][0], 0.0001) | 110 | + | assert.InEpsilon(t, 4, pcm.Samples[1][0], 0.0001) |
| 379 | - | } | |||
| 380 | - | ||||
| 381 | - | func TestFill_ReturnsZeroWhenEmpty(t *testing.T) { | |||
| 382 | - | adapter := &playerStreamAdapter{ | |||
| 383 | - | buf: make(chan [][]float32, 4), | |||
| 384 | - | } | |||
| 385 | - | ||||
| 386 | - | pcm := &pwpipewire.PCMBuffer{ | |||
| 387 | - | Frames: 256, | |||
| 388 | - | Channels: 2, | |||
| 389 | - | Stride: 1, | |||
| 390 | - | Samples: [][]float32{ | |||
| 391 | - | make([]float32, 256), | |||
| 392 | - | make([]float32, 256), | |||
| 393 | - | }, | |||
| 394 | - | } | |||
| 395 | - | ||||
| 396 | - | frames, err := adapter.fill(pcm) | |||
| 397 | - | require.NoError(t, err) | |||
| 398 | - | assert.Equal(t, 0, frames) | |||
| 399 | - | } | |||
| 400 | - | ||||
| 401 | - | func TestCopyToPCM_HandlesChannelMismatch(t *testing.T) { | |||
| 402 | - | // More source channels than PCM channels — truncates | |||
| 403 | - | pcm := &pwpipewire.PCMBuffer{ | |||
| 404 | - | Frames: 2, | |||
| 405 | - | Channels: 1, | |||
| 406 | - | Samples: [][]float32{make([]float32, 2)}, | |||
| 407 | - | } | |||
| 408 | - | samples := [][]float32{ | |||
| 409 | - | {1.0, 2.0}, | |||
| 410 | - | {3.0, 4.0}, // should be ignored | |||
| 411 | - | } | |||
| 412 | - | frames := copyToPCM(pcm, samples) | |||
| 413 | - | assert.Equal(t, 2, frames) | |||
| 414 | - | assert.InEpsilon(t, 1.0, pcm.Samples[0][0], 0.0001) | |||
| 415 | } | 111 | } | ||
| 416 | 112 | ||||
| 417 | - | func TestCopyToPCM_HandlesFrameMismatch(t *testing.T) { | 113 | + | func TestPlayerStreamAdapter_SaturationDropsWithoutBlocking(t *testing.T) { |
| 418 | - | // Fewer source frames than PCM expects | 114 | + | adapter := testAdapter(2) |
| 419 | - | pcm := &pwpipewire.PCMBuffer{ | 115 | + | for i := 0; i < packetQueueCapacity; i++ { |
| 420 | - | Frames: 4, | 116 | + | require.NoError(t, adapter.Write([][]float32{{float32(i)}, {float32(i)}})) |
| 421 | - | Channels: 1, | |||
| 422 | - | Samples: [][]float32{make([]float32, 4)}, | |||
| 423 | } | 117 | } | ||
| 424 | - | samples := [][]float32{ | 118 | + | done := make(chan error, 1) |
| 425 | - | {1.0, 2.0}, // only 2 frames | 119 | + | go func() { done <- adapter.Write([][]float32{{99}, {99}}) }() |
| 120 | + | select { | |||
| 121 | + | case err := <-done: | |||
| 122 | + | require.NoError(t, err) | |||
| 123 | + | case <-time.After(time.Second): | |||
| 124 | + | t.Fatal("Write blocked while the bounded queue was saturated") | |||
| 426 | } | 125 | } | ||
| 427 | - | frames := copyToPCM(pcm, samples) | 126 | + | assert.Equal(t, uint64(1), adapter.dropCount.Load()) |
| 428 | - | assert.Equal(t, 2, frames) // reports partial fill | 127 | + | assert.Len(t, adapter.ready, packetQueueCapacity) |
| 429 | - | } | |||
| 430 | - | ||||
| 431 | - | func TestCopyToPCM_NilPCM_ReturnsZero(t *testing.T) { | |||
| 432 | - | assert.Equal(t, 0, copyToPCM(nil, [][]float32{{1.0}})) | |||
| 433 | } | 128 | } | ||
| 434 | 129 | ||||
| 435 | - | func TestCopyToPCM_EmptySamples_ReturnsZero(t *testing.T) { | 130 | + | func TestPlayerStreamAdapter_EmptyFillCountsUnderrun(t *testing.T) { |
| 436 | - | pcm := &pwpipewire.PCMBuffer{Frames: 1, Channels: 1, Samples: [][]float32{make([]float32, 1)}} | 131 | + | adapter := testAdapter(2) |
| 437 | - | assert.Equal(t, 0, copyToPCM(pcm, nil)) | 132 | + | frames, err := adapter.fill(stereoPCM(2)) |
| 438 | - | assert.Equal(t, 0, copyToPCM(pcm, [][]float32{})) | 133 | + | require.NoError(t, err) |
| 134 | + | assert.Zero(t, frames) | |||
| 135 | + | assert.Equal(t, uint64(1), adapter.underrunCount.Load()) | |||
| 439 | } | 136 | } | ||
| 440 | 137 | ||||
| 441 | - | // --- Non-blocking Write tests --- | 138 | + | func TestPlayerStreamAdapter_CopyToPCMHandlesMismatches(t *testing.T) { |
| 442 | - | 139 | + | pcm := &pwpipewire.PCMBuffer{Frames: 4, Channels: 1, Samples: [][]float32{make([]float32, 4)}} | |
| 443 | - | func TestPlayerStreamAdapter_Write_DropsWhenBufferFull(t *testing.T) { | 140 | + | assert.Equal(t, 2, copyToPCM(pcm, [][]float32{{1, 2}, {3, 4}}, 2)) |
| 444 | - | adapter := &playerStreamAdapter{ | 141 | + | assert.InEpsilon(t, 1, pcm.Samples[0][0], 0.0001) |
| 445 | - | player: newPlayerRecorder(t), | 142 | + | assert.Zero(t, copyToPCM(nil, [][]float32{{1}}, 1)) |
| 446 | - | buf: make(chan [][]float32, 4), | |||
| 447 | - | closed: make(chan struct{}), | |||
| 448 | - | } | |||
| 449 | - | ||||
| 450 | - | // Fill the buffer to capacity | |||
| 451 | - | for i := range 4 { | |||
| 452 | - | require.NoError(t, adapter.Write([][]float32{{float32(i)}})) | |||
| 453 | - | } | |||
| 454 | - | ||||
| 455 | - | // The 5th write must NOT block — it should drop the packet and return nil. | |||
| 456 | - | err := adapter.Write([][]float32{{99.0}}) | |||
| 457 | - | require.NoError(t, err, "Write must not return an error when dropping due to full buffer") | |||
| 458 | - | ||||
| 459 | - | // Verify the drop was counted | |||
| 460 | - | assert.Equal(t, uint64(1), adapter.dropCount.Load(), "one packet should have been dropped") | |||
| 461 | - | ||||
| 462 | - | // Verify buffer still contains the original 4 packets (not the dropped one) | |||
| 463 | - | assert.Len(t, adapter.buf, 4) | |||
| 464 | } | 143 | } | ||
| 465 | 144 | ||||
| 466 | - | func TestPlayerStreamAdapter_Write_DropsLogOnlyOnFirstAndPowerOfTwo(t *testing.T) { | 145 | + | func TestPlayerStreamAdapter_CloseVsWrite(t *testing.T) { |
| 467 | - | adapter := &playerStreamAdapter{ | 146 | + | adapter := testAdapter(2) |
| 468 | - | player: newPlayerRecorder(t), | 147 | + | player := newPlayerRecorder(t) |
| 469 | - | buf: make(chan [][]float32, 1), // tiny buffer to force drops | 148 | + | adapter.player = player |
| 470 | - | closed: make(chan struct{}), | 149 | + | require.NoError(t, adapter.Close()) |
| 471 | - | } | 150 | + | require.ErrorIs(t, adapter.Write([][]float32{{1}, {2}}), ErrStreamClosed) |
| 472 | - | 151 | + | assert.True(t, player.stopped) | |
| 473 | - | // Fill buffer | 152 | + | assert.True(t, player.closedFlag) |
| 474 | - | require.NoError(t, adapter.Write([][]float32{{0.0}})) | |||
| 475 | - | ||||
| 476 | - | // Drop 10 packets | |||
| 477 | - | for i := range 10 { | |||
| 478 | - | _ = adapter.Write([][]float32{{float32(i)}}) | |||
| 479 | - | } | |||
| 480 | - | ||||
| 481 | - | // Verify all drops were counted | |||
| 482 | - | assert.Equal(t, uint64(10), adapter.dropCount.Load()) | |||
| 483 | } | 153 | } | ||
| 484 | 154 | ||||
| 485 | - | // --- Concurrent Write / Close tests --- | |||
| 486 | - | ||||
| 487 | func TestPlayerStreamAdapter_ConcurrentWriteClose_NoRace(t *testing.T) { | 155 | func TestPlayerStreamAdapter_ConcurrentWriteClose_NoRace(t *testing.T) { | ||
| 488 | - | player := newPlayerRecorder(t) | 156 | + | adapter := testAdapter(4) |
| 489 | - | adapter := &playerStreamAdapter{ | 157 | + | var writers sync.WaitGroup |
| 490 | - | player: player, | 158 | + | for range 4 { |
| 491 | - | buf: make(chan [][]float32, 4), | 159 | + | writers.Add(1) |
| 492 | - | closed: make(chan struct{}), | 160 | + | go func() { |
| 493 | - | } | 161 | + | defer writers.Done() |
| 494 | - | 162 | + | for range 1000 { | |
| 495 | - | done := make(chan struct{}) | 163 | + | err := adapter.Write([][]float32{{1, 2}, {3, 4}}) |
| 496 | - | go func() { | 164 | + | if err != nil { |
| 497 | - | defer close(done) | 165 | + | assert.ErrorIs(t, err, ErrStreamClosed) |
| 498 | - | for i := range 100 { | 166 | + | return |
| 499 | - | err := adapter.Write([][]float32{{float32(i)}}) | 167 | + | } |
| 500 | - | if err != nil { | |||
| 501 | - | assert.ErrorIs(t, err, ErrStreamClosed) | |||
| 502 | - | return | |||
| 503 | } | 168 | } | ||
| 504 | - | } | 169 | + | }() |
| 505 | - | }() | 170 | + | } |
| 506 | - | ||||
| 507 | - | // Let a few writes land, then close. | |||
| 508 | adapter.Close() | 171 | adapter.Close() | ||
| 509 | - | <-done | 172 | + | writers.Wait() |
| 510 | - | } | |||
| 511 | - | ||||
| 512 | - | // --- NewFactory tests --- | |||
| 513 | - | ||||
| 514 | - | func TestNewFactory_Succeeds(t *testing.T) { | |||
| 515 | - | factory, err := NewFactory() | |||
| 516 | - | require.NoError(t, err) | |||
| 517 | - | require.NotNil(t, factory) | |||
| 518 | - | assert.NotNil(t, factory.createPlayer) | |||
| 519 | } | 173 | } | ||
| 520 | 174 | ||||
| 521 | - | // --- Integration-style test: full write-through --- | |||
| 522 | - | ||||
| 523 | func TestStream_WriteFlowsThroughToFill(t *testing.T) { | 175 | func TestStream_WriteFlowsThroughToFill(t *testing.T) { | ||
| 524 | - | var capturedCallbacks pwpipewire.PlayerCallbacks | 176 | + | var callbacks pwpipewire.PlayerCallbacks |
| 525 | player := newPlayerRecorder(t) | 177 | player := newPlayerRecorder(t) | ||
| 526 | - | 178 | + | factory := &Factory{createPlayer: func(_ pwpipewire.PlayerConfig, got pwpipewire.PlayerCallbacks) (pwpipewire.Player, error) { | |
| 527 | - | factory := &Factory{ | 179 | + | callbacks = got |
| 528 | - | createPlayer: func(_ pwpipewire.PlayerConfig, callbacks pwpipewire.PlayerCallbacks) (pwpipewire.Player, error) { | 180 | + | return player, nil |
| 529 | - | capturedCallbacks = callbacks | 181 | + | }} |
| 530 | - | return player, nil | 182 | + | stream, err := factory.NewStream(context.Background(), port.AudioStreamFormat{SampleRate: 48000, ChannelCount: 2, FramesPerBuffer: 4}) |
| 531 | - | }, | |||
| 532 | - | } | |||
| 533 | - | ||||
| 534 | - | format := port.AudioStreamFormat{SampleRate: 48000, ChannelCount: 2, FramesPerBuffer: 4} | |||
| 535 | - | stream, err := factory.NewStream(context.Background(), format) | |||
| 536 | require.NoError(t, err) | 183 | require.NoError(t, err) | ||
| 537 | - | 184 | + | require.NoError(t, stream.Write([][]float32{{.1, .2, .3, .4}, {.5, .6, .7, .8}})) | |
| 538 | - | // Write samples through the stream | 185 | + | pcm := stereoPCM(4) |
| 539 | - | samples := [][]float32{ | 186 | + | frames, err := callbacks.Fill(pcm) |
| 540 | - | {0.1, 0.2, 0.3, 0.4}, | |||
| 541 | - | {0.5, 0.6, 0.7, 0.8}, | |||
| 542 | - | } | |||
| 543 | - | require.NoError(t, stream.Write(samples)) | |||
| 544 | - | ||||
| 545 | - | // Simulate the Fill callback being called by the player | |||
| 546 | - | pcm := &pwpipewire.PCMBuffer{ | |||
| 547 | - | Frames: 4, | |||
| 548 | - | Channels: 2, | |||
| 549 | - | Stride: 1, | |||
| 550 | - | Samples: [][]float32{ | |||
| 551 | - | make([]float32, 4), | |||
| 552 | - | make([]float32, 4), | |||
| 553 | - | }, | |||
| 554 | - | } | |||
| 555 | - | ||||
| 556 | - | frames, err := capturedCallbacks.Fill(pcm) | |||
| 557 | require.NoError(t, err) | 187 | require.NoError(t, err) | ||
| 558 | assert.Equal(t, 4, frames) | 188 | assert.Equal(t, 4, frames) | ||
| 559 | - | assert.InEpsilon(t, 0.1, pcm.Samples[0][0], 0.0001) | 189 | + | assert.InEpsilon(t, .1, pcm.Samples[0][0], .0001) |
| 560 | - | assert.InEpsilon(t, 0.5, pcm.Samples[1][0], 0.0001) | 190 | + | assert.InEpsilon(t, .5, pcm.Samples[1][0], .0001) |
| 561 | - | ||||
| 562 | require.NoError(t, stream.Close()) | 191 | require.NoError(t, stream.Close()) | ||
| 563 | } | 192 | } | ||
| 193 | + | ||||
| 194 | + | func BenchmarkPlayerStreamAdapterStereo(b *testing.B) { | |||
| 195 | + | adapter := testAdapter(512) | |||
| 196 | + | samples := [][]float32{make([]float32, 512), make([]float32, 512)} | |||
| 197 | + | pcm := stereoPCM(512) | |||
| 198 | + | b.ReportAllocs() | |||
| 199 | + | b.SetBytes(2 * 512 * 4) | |||
| 200 | + | for i := 0; i < b.N; i++ { | |||
| 201 | + | if err := adapter.Write(samples); err != nil { | |||
| 202 | + | b.Fatal(err) | |||
| 203 | + | } | |||
| 204 | + | if _, err := adapter.fill(pcm); err != nil { | |||
| 205 | + | b.Fatal(err) | |||
| 206 | + | } | |||
| 207 | + | } | |||
| 208 | + | } | |||
M internal/infrastructure/cef/app.go +18 -0
| @@ -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 { | ||
A internal/infrastructure/cef/background_guard.go +106 -0
| @@ -0,0 +1,106 @@ | |||||
| 1 | + | package cef | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/puregotk/v4/gdk" | |||
| 7 | + | "github.com/bnema/puregotk/v4/gtk" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // opaqueWhiteBackground is Chromium's standard windowless canvas fallback. It is | |||
| 11 | + | // baked into every BrowserSettings.BackgroundColor so pages that paint nothing | |||
| 12 | + | // get an opaque white canvas (never a theme-dark one). A zero value must not be | |||
| 13 | + | // used: in windowless/OSR mode a zero-alpha background enables transparent | |||
| 14 | + | // painting, which this engine does not want. | |||
| 15 | + | const opaqueWhiteBackground uint32 = 0xFFFFFFFF | |||
| 16 | + | ||||
| 17 | + | // flashGuardCSSClassPrefix scopes each WebView's transient background provider to | |||
| 18 | + | // its own render widget so guards from different tabs never collide. | |||
| 19 | + | const flashGuardCSSClassPrefix = "dumber-cef-flash-guard-" | |||
| 20 | + | ||||
| 21 | + | // colorByteMask isolates a single 8-bit channel from a packed ARGB value. | |||
| 22 | + | const colorByteMask = 0xFF | |||
| 23 | + | ||||
| 24 | + | // applyBackgroundFlashGuard paints the theme background color behind the CEF | |||
| 25 | + | // render widget until the first frame is presented. It is the presentation-layer | |||
| 26 | + | // replacement for the former dark BrowserSettings.BackgroundColor: Chromium's | |||
| 27 | + | // canvas is now always opaque white, so the anti-white-flash lives here instead. | |||
| 28 | + | // The bridge widget (a GtkPicture/GtkGLArea) renders its CSS background until an | |||
| 29 | + | // opaque frame texture covers it, at which point markFirstFramePainted tears the | |||
| 30 | + | // guard down. Must run on the GTK main thread. | |||
| 31 | + | func (wv *WebView) applyBackgroundFlashGuard(widget *gtk.Widget) { | |||
| 32 | + | if wv == nil || widget == nil { | |||
| 33 | + | return | |||
| 34 | + | } | |||
| 35 | + | argb := wv.backgroundColor | |||
| 36 | + | if argb == 0 { | |||
| 37 | + | return // No theme color configured; nothing to guard against. | |||
| 38 | + | } | |||
| 39 | + | display := gdk.DisplayGetDefault() | |||
| 40 | + | if display == nil { | |||
| 41 | + | return | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | a := float64((argb>>24)&colorByteMask) / colorScale | |||
| 45 | + | r := (argb >> 16) & colorByteMask | |||
| 46 | + | g := (argb >> 8) & colorByteMask | |||
| 47 | + | b := argb & colorByteMask | |||
| 48 | + | class := fmt.Sprintf("%s%d", flashGuardCSSClassPrefix, uint64(wv.id)) | |||
| 49 | + | css := fmt.Sprintf(".%s { background-color: rgba(%d, %d, %d, %.4f); }", class, r, g, b, a) | |||
| 50 | + | ||||
| 51 | + | provider := gtk.NewCssProvider() | |||
| 52 | + | if provider == nil { | |||
| 53 | + | return | |||
| 54 | + | } | |||
| 55 | + | provider.LoadFromString(css) | |||
| 56 | + | ||||
| 57 | + | wv.bgGuardMu.Lock() | |||
| 58 | + | defer wv.bgGuardMu.Unlock() | |||
| 59 | + | if wv.firstFramePainted.Load() { | |||
| 60 | + | return // First frame already arrived before the guard could be installed. | |||
| 61 | + | } | |||
| 62 | + | widget.AddCssClass(class) | |||
| 63 | + | gtk.StyleContextAddProviderForDisplay(display, provider, uint(gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)) | |||
| 64 | + | wv.bgGuardProvider = provider | |||
| 65 | + | wv.bgGuardWidget = widget | |||
| 66 | + | wv.bgGuardClass = class | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | // markFirstFramePainted records that the first CEF frame has been presented and | |||
| 70 | + | // schedules teardown of the background flash guard so later frames render | |||
| 71 | + | // normally. Safe to call from any thread and idempotent. | |||
| 72 | + | func (wv *WebView) markFirstFramePainted() { | |||
| 73 | + | if wv == nil || !wv.firstFramePainted.CompareAndSwap(false, true) { | |||
| 74 | + | return | |||
| 75 | + | } | |||
| 76 | + | wv.runOnGTK(func() { | |||
| 77 | + | wv.removeBackgroundFlashGuard() | |||
| 78 | + | }) | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | // removeBackgroundFlashGuard detaches the transient theme-background provider and | |||
| 82 | + | // clears the render widget's guard class. Must run on the GTK main thread. It is | |||
| 83 | + | // safe to call when no guard is installed. | |||
| 84 | + | func (wv *WebView) removeBackgroundFlashGuard() { | |||
| 85 | + | if wv == nil { | |||
| 86 | + | return | |||
| 87 | + | } | |||
| 88 | + | wv.bgGuardMu.Lock() | |||
| 89 | + | provider := wv.bgGuardProvider | |||
| 90 | + | widget := wv.bgGuardWidget | |||
| 91 | + | class := wv.bgGuardClass | |||
| 92 | + | wv.bgGuardProvider = nil | |||
| 93 | + | wv.bgGuardWidget = nil | |||
| 94 | + | wv.bgGuardClass = "" | |||
| 95 | + | wv.bgGuardMu.Unlock() | |||
| 96 | + | ||||
| 97 | + | if provider == nil { | |||
| 98 | + | return | |||
| 99 | + | } | |||
| 100 | + | if widget != nil && class != "" { | |||
| 101 | + | widget.RemoveCssClass(class) | |||
| 102 | + | } | |||
| 103 | + | if display := gdk.DisplayGetDefault(); display != nil { | |||
| 104 | + | gtk.StyleContextRemoveProviderForDisplay(display, provider) | |||
| 105 | + | } | |||
| 106 | + | } | |||
M internal/infrastructure/cef/engine.go +13 -0
| @@ -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 +29 -3
| @@ -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) | ||
| @@ -52,6 +56,7 @@ func NewEngine( | |||||
| 52 | return nil, err | 56 | return nil, err | ||
| 53 | } | 57 | } | ||
| 54 | cef2gtk.ConfigureRenderStackEnvironment(renderStackPlan) | 58 | cef2gtk.ConfigureRenderStackEnvironment(renderStackPlan) | ||
| 59 | + | activeStartupTrace().SetBackend(renderStackPlan.Backend.String()) | |||
| 55 | 60 | ||||
| 56 | settings, err := prepareCEFSettings(opts, paths, cfg, logger) | 61 | settings, err := prepareCEFSettings(opts, paths, cfg, logger) | ||
| 57 | if err != nil { | 62 | if err != nil { | ||
| @@ -67,6 +72,7 @@ func NewEngine( | |||||
| 67 | ctx: ctx, | 72 | ctx: ctx, | ||
| 68 | profileLogDir: paths.ProfileLogDir, | 73 | profileLogDir: paths.ProfileLogDir, | ||
| 69 | runtimeCEFDir: settings.CEFDir, | 74 | runtimeCEFDir: settings.CEFDir, | ||
| 75 | + | stateRoot: stateRoot, | |||
| 70 | renderStackPlan: renderStackPlan, | 76 | renderStackPlan: renderStackPlan, | ||
| 71 | applicationScale: normalizedApplicationScale(cfg.ApplicationScale), | 77 | applicationScale: normalizedApplicationScale(cfg.ApplicationScale), | ||
| 72 | registerHandlers: deps.RegisterHandlers, | 78 | registerHandlers: deps.RegisterHandlers, | ||
| @@ -141,6 +147,24 @@ func resolvedStateRoot(defaultStateRoot string, opts port.EngineOptions) string | |||||
| 141 | return defaultStateRoot | 147 | return defaultStateRoot | ||
| 142 | } | 148 | } | ||
| 143 | 149 | ||||
| 150 | + | // configureCEFSubprocessRuntime chooses the CEF runtime used by both the | |||
| 151 | + | // browser and CEF helper processes. CEF re-execs this binary for helpers before | |||
| 152 | + | // Dumber can load configuration, so the selected configured runtime must be in | |||
| 153 | + | // its inherited environment before InitWithApp launches any child. A caller's | |||
| 154 | + | // explicit CEF_DIR remains the override for both process types. | |||
| 155 | + | func configureCEFSubprocessRuntime(configured string) string { | |||
| 156 | + | if explicit := os.Getenv("CEF_DIR"); explicit != "" { | |||
| 157 | + | return explicit | |||
| 158 | + | } | |||
| 159 | + | if configured == "" { | |||
| 160 | + | return "" | |||
| 161 | + | } | |||
| 162 | + | if err := os.Setenv("CEF_DIR", configured); err != nil { | |||
| 163 | + | return configured | |||
| 164 | + | } | |||
| 165 | + | return configured | |||
| 166 | + | } | |||
| 167 | + | ||||
| 144 | // prepareCEFSettings builds purecef.Settings from the engine config. | 168 | // prepareCEFSettings builds purecef.Settings from the engine config. | ||
| 145 | func prepareCEFSettings( | 169 | func prepareCEFSettings( | ||
| 146 | opts port.EngineOptions, | 170 | opts port.EngineOptions, | ||
| @@ -164,9 +188,7 @@ func prepareCEFSettings( | |||||
| 164 | settings.MultiThreadedMessageLoop = true | 188 | settings.MultiThreadedMessageLoop = true | ||
| 165 | settings.ExternalMessagePump = false | 189 | settings.ExternalMessagePump = false | ||
| 166 | settings.RootCachePath = resolvedStateRoot(paths.StateRoot, opts) | 190 | settings.RootCachePath = resolvedStateRoot(paths.StateRoot, opts) | ||
| 167 | - | if cfg.CEFDir != "" { | 191 | + | settings.CEFDir = configureCEFSubprocessRuntime(cfg.CEFDir) |
| 168 | - | settings.CEFDir = cfg.CEFDir | |||
| 169 | - | } | |||
| 170 | if runtimeLogFile, err := prepareCEFLogFile(paths.LogFile, cfg.LogFile); err != nil { | 192 | if runtimeLogFile, err := prepareCEFLogFile(paths.LogFile, cfg.LogFile); err != nil { | ||
| 171 | logger.Warn().Err(err).Msg("cef: failed to prepare runtime log file") | 193 | logger.Warn().Err(err).Msg("cef: failed to prepare runtime log file") | ||
| 172 | } else { | 194 | } else { | ||
| @@ -194,9 +216,13 @@ func initializeCEF(eng *Engine, settings purecef.Settings, logger *zerolog.Logge | |||||
| 194 | Str("env_cef_dir", os.Getenv("CEF_DIR")). | 216 | Str("env_cef_dir", os.Getenv("CEF_DIR")). | ||
| 195 | Msg("cef: runtime selection") | 217 | Msg("cef: runtime selection") | ||
| 196 | logger.Debug().Msg("cef: calling InitWithApp") | 218 | logger.Debug().Msg("cef: calling InitWithApp") | ||
| 219 | + | // InitWithApp encapsulates loading libcef, so only the begin boundary is | |||
| 220 | + | // observable here; do not invent a separate library-load completion event. | |||
| 221 | + | activeStartupTrace().Mark("cef_library_load_begin") | |||
| 197 | if err := purecef.InitWithApp(settings, app); err != nil { | 222 | if err := purecef.InitWithApp(settings, app); err != nil { | ||
| 198 | return fmt.Errorf("cef.InitWithApp: %w", err) | 223 | return fmt.Errorf("cef.InitWithApp: %w", err) | ||
| 199 | } | 224 | } | ||
| 225 | + | activeStartupTrace().Mark("cef_initialized") | |||
| 200 | if libcefPath := loadedLibCEFPath(); libcefPath != "" { | 226 | if libcefPath := loadedLibCEFPath(); libcefPath != "" { | ||
| 201 | logger.Info().Str("libcef_path", libcefPath).Msg("cef: runtime library loaded") | 227 | logger.Info().Str("libcef_path", libcefPath).Msg("cef: runtime library loaded") | ||
| 202 | } else { | 228 | } else { | ||
M internal/infrastructure/cef/engine_init_test.go +16 -0
| @@ -36,6 +36,22 @@ func TestResolvedStateRoot_UsesProfileDefault(t *testing.T) { | |||||
| 36 | require.Equal(t, profile.CEFUserDataDir(), root) | 36 | require.Equal(t, profile.CEFUserDataDir(), root) | ||
| 37 | } | 37 | } | ||
| 38 | 38 | ||||
| 39 | + | func TestConfigureCEFSubprocessRuntime_PropagatesConfiguredRuntimeBeforeInit(t *testing.T) { | |||
| 40 | + | t.Setenv("CEF_DIR", "") | |||
| 41 | + | ||||
| 42 | + | runtime := filepath.Join(t.TempDir(), "cef-147") | |||
| 43 | + | require.Equal(t, runtime, configureCEFSubprocessRuntime(runtime)) | |||
| 44 | + | require.Equal(t, runtime, os.Getenv("CEF_DIR")) | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | func TestConfigureCEFSubprocessRuntime_PreservesExplicitEnvironmentOverride(t *testing.T) { | |||
| 48 | + | explicitRuntime := filepath.Join(t.TempDir(), "cef-env") | |||
| 49 | + | t.Setenv("CEF_DIR", explicitRuntime) | |||
| 50 | + | ||||
| 51 | + | require.Equal(t, explicitRuntime, configureCEFSubprocessRuntime(filepath.Join(t.TempDir(), "cef-config"))) | |||
| 52 | + | require.Equal(t, explicitRuntime, os.Getenv("CEF_DIR")) | |||
| 53 | + | } | |||
| 54 | + | ||||
| 39 | func TestPrepareCEFSettings_UsesResolvedProfilePaths(t *testing.T) { | 55 | func TestPrepareCEFSettings_UsesResolvedProfilePaths(t *testing.T) { | ||
| 40 | logger := zerolog.Nop() | 56 | logger := zerolog.Nop() | ||
| 41 | profile := testCEFDevProfile(t) | 57 | profile := testCEFDevProfile(t) | ||
M internal/infrastructure/cef/factory.go +14 -8
| @@ -27,7 +27,7 @@ type WebViewFactory struct { | |||||
| 27 | windowlessFrameRate int32 | 27 | windowlessFrameRate int32 | ||
| 28 | windowlessFrameRateMax int32 | 28 | windowlessFrameRateMax int32 | ||
| 29 | inputConfig RuntimeInputConfig | 29 | inputConfig RuntimeInputConfig | ||
| 30 | - | bgColor atomic.Uint32 // packed ARGB for BrowserSettings.BackgroundColor | 30 | + | bgColor atomic.Uint32 // packed ARGB theme color for the presentation-layer flash guard |
| 31 | audioOutputFactory port.AudioOutputFactory | 31 | audioOutputFactory port.AudioOutputFactory | ||
| 32 | } | 32 | } | ||
| 33 | 33 | ||||
| @@ -69,7 +69,9 @@ func newWebViewFactory(engine *Engine, opts webViewFactoryOptions) *WebViewFacto | |||||
| 69 | } | 69 | } | ||
| 70 | } | 70 | } | ||
| 71 | 71 | ||||
| 72 | - | // setDefaultBackgroundColor stores a packed ARGB color for new browser creation. | 72 | + | // setDefaultBackgroundColor stores the packed ARGB theme color used by the |
| 73 | + | // presentation-layer flash guard on newly-created WebViews. It no longer feeds | |||
| 74 | + | // BrowserSettings.BackgroundColor, which is always opaque white. | |||
| 73 | func (f *WebViewFactory) setDefaultBackgroundColor(r, g, b, a float64) { | 75 | func (f *WebViewFactory) setDefaultBackgroundColor(r, g, b, a float64) { | ||
| 74 | const s = colorScale | 76 | const s = colorScale | ||
| 75 | argb := uint32(a*s)<<24 | uint32(r*s)<<16 | uint32(g*s)<<8 | uint32(b*s) | 77 | argb := uint32(a*s)<<24 | uint32(r*s)<<16 | uint32(g*s)<<8 | uint32(b*s) | ||
| @@ -96,9 +98,9 @@ func (f *WebViewFactory) Create(ctx context.Context) (port.WebView, error) { | |||||
| 96 | settings := purecef.NewBrowserSettings() | 98 | settings := purecef.NewBrowserSettings() | ||
| 97 | cef2gtk.ConfigureBrowserSettings(&settings, cef2gtk.BrowserSettingsOptions{WindowlessFrameRate: f.windowlessFrameRate}) | 99 | cef2gtk.ConfigureBrowserSettings(&settings, cef2gtk.BrowserSettingsOptions{WindowlessFrameRate: f.windowlessFrameRate}) | ||
| 98 | settings.LocalStorage = 1 // CEF_STATE_ENABLED | 100 | settings.LocalStorage = 1 // CEF_STATE_ENABLED | ||
| 99 | - | if bg := f.bgColor.Load(); bg != 0 { | 101 | + | // Always opaque white: Chromium's standard canvas fallback. The theme |
| 100 | - | settings.BackgroundColor = bg | 102 | + | // background is applied at the presentation layer as a flash guard instead. |
| 101 | - | } | 103 | + | settings.BackgroundColor = opaqueWhiteBackground |
| 102 | 104 | ||||
| 103 | // Defer browser creation until the bridge view has a non-zero size. | 105 | // Defer browser creation until the bridge view has a non-zero size. | ||
| 104 | // CEF requires GetViewRect to return a non-empty rect, but the GL area | 106 | // CEF requires GetViewRect to return a non-empty rect, but the GL area | ||
| @@ -142,6 +144,8 @@ func (f *WebViewFactory) newWebView(ctx context.Context) (*WebView, error) { | |||||
| 142 | wv.nativeWidget = popupRoot | 144 | wv.nativeWidget = popupRoot | ||
| 143 | } | 145 | } | ||
| 144 | } | 146 | } | ||
| 147 | + | // Guard the render widget with the theme background until the first frame. | |||
| 148 | + | wv.applyBackgroundFlashGuard(nativeWidget) | |||
| 145 | }) | 149 | }) | ||
| 146 | if !result.Completed() { | 150 | if !result.Completed() { | ||
| 147 | wv.destroyViewBridgeOnGTKAsync() | 151 | wv.destroyViewBridgeOnGTKAsync() | ||
| @@ -278,6 +282,8 @@ func (f *WebViewFactory) postPendingBrowserCreate(ctx context.Context, wv *WebVi | |||||
| 278 | // renderer without an initial paint. We replay pending navigation from | 282 | // renderer without an initial paint. We replay pending navigation from | ||
| 279 | // OnAfterCreated once the host is fully wired. | 283 | // OnAfterCreated once the host is fully wired. | ||
| 280 | initialURL := "about:blank" | 284 | initialURL := "about:blank" | ||
| 285 | + | // This is the request boundary, not the later OnAfterCreated callback. | |||
| 286 | + | activeStartupTrace().Mark("browser_create_requested") | |||
| 281 | result := cefBrowserHostCreateBrowser( | 287 | result := cefBrowserHostCreateBrowser( | ||
| 282 | pc.windowInfo, | 288 | pc.windowInfo, | ||
| 283 | pc.client, | 289 | pc.client, | ||
| @@ -381,9 +387,9 @@ func (f *WebViewFactory) CreateRelated(ctx context.Context, parentID port.WebVie | |||||
| 381 | settings := purecef.NewBrowserSettings() | 387 | settings := purecef.NewBrowserSettings() | ||
| 382 | cef2gtk.ConfigureBrowserSettings(&settings, cef2gtk.BrowserSettingsOptions{WindowlessFrameRate: f.windowlessFrameRate}) | 388 | cef2gtk.ConfigureBrowserSettings(&settings, cef2gtk.BrowserSettingsOptions{WindowlessFrameRate: f.windowlessFrameRate}) | ||
| 383 | settings.LocalStorage = 1 // CEF_STATE_ENABLED | 389 | settings.LocalStorage = 1 // CEF_STATE_ENABLED | ||
| 384 | - | if bg := f.bgColor.Load(); bg != 0 { | 390 | + | // Always opaque white: the theme background is applied as a presentation-layer |
| 385 | - | settings.BackgroundColor = bg | 391 | + | // flash guard, not baked into Chromium's canvas. |
| 386 | - | } | 392 | + | settings.BackgroundColor = opaqueWhiteBackground |
| 387 | 393 | ||||
| 388 | if err := f.configureInitialBrowserCreation(ctx, popupWV, popupWV.client, &windowInfo, &settings, func(w, h int32) { | 394 | if err := f.configureInitialBrowserCreation(ctx, popupWV, popupWV.client, &windowInfo, &settings, func(w, h int32) { | ||
| 389 | f.handlePopupShellInitialResize(ctx, popupWV, func(w, h int32) { | 395 | f.handlePopupShellInitialResize(ctx, popupWV, func(w, h int32) { | ||
A internal/infrastructure/cef/first_presentation_collector_test.go +432 -0
| @@ -0,0 +1,432 @@ | |||||
| 1 | + | package cef | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "encoding/json" | |||
| 5 | + | "fmt" | |||
| 6 | + | "os" | |||
| 7 | + | "os/exec" | |||
| 8 | + | "path/filepath" | |||
| 9 | + | "strings" | |||
| 10 | + | "testing" | |||
| 11 | + | ||||
| 12 | + | "github.com/stretchr/testify/require" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | const validFirstPresentationLog = `{"message":"startup_trace: milestone","milestone":"process_entry","t_ms":0,"delta_ms":0,"time":"2026-07-14T09:00:00+02:00","machine_path":"/home/alice/private"} | |||
| 16 | + | {"message":"startup_trace: milestone","milestone":"config_complete","t_ms":1,"delta_ms":1} | |||
| 17 | + | {"message":"startup_trace: milestone","milestone":"cef_library_load_begin","t_ms":2,"delta_ms":1} | |||
| 18 | + | {"message":"startup_trace: milestone","milestone":"cef_initialized","t_ms":3,"delta_ms":1} | |||
| 19 | + | {"message":"startup_trace: milestone","milestone":"browser_create_requested","t_ms":4,"delta_ms":1} | |||
| 20 | + | {"message":"startup_trace: milestone","milestone":"first_accelerated_paint_received","t_ms":5,"delta_ms":1} | |||
| 21 | + | {"message":"startup_trace: milestone","milestone":"first_dmabuf_texture_swap","t_ms":6,"delta_ms":1} | |||
| 22 | + | {"message":"startup_trace: milestone","milestone":"first_gtk_presentation","t_ms":7,"delta_ms":1} | |||
| 23 | + | {"message":"startup_trace: first presentation","backend":"gdk-dmabuf","incomplete_reason":"","total_ms":7,"host":"alice"}` | |||
| 24 | + | ||||
| 25 | + | func TestFirstPresentationCollectorNeverRecursivelyDeletesCallerOutput(t *testing.T) { | |||
| 26 | + | repoRoot, err := filepath.Abs(filepath.Join("..", "..", "..")) | |||
| 27 | + | require.NoError(t, err) | |||
| 28 | + | script, err := os.ReadFile(filepath.Join(repoRoot, "scripts", "collect_first_presentation.sh")) | |||
| 29 | + | require.NoError(t, err) | |||
| 30 | + | require.NotContains(t, string(script), "rm -rf \"$output\"") | |||
| 31 | + | require.NotContains(t, string(script), "rm -rf -- \"$output\"") | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | func TestFirstPresentationCollectorRejectsUnsafeOutputPaths(t *testing.T) { | |||
| 35 | + | repoRoot, err := filepath.Abs(filepath.Join("..", "..", "..")) | |||
| 36 | + | require.NoError(t, err) | |||
| 37 | + | temp := t.TempDir() | |||
| 38 | + | runtime := filepath.Join(temp, "cef-147-runtime") | |||
| 39 | + | require.NoError(t, os.Mkdir(runtime, 0o755)) | |||
| 40 | + | binary := filepath.Join(temp, "dumber") | |||
| 41 | + | require.NoError(t, os.WriteFile(binary, collectorTestBinary(validFirstPresentationLog), 0o755)) | |||
| 42 | + | home := filepath.Join(temp, "home") | |||
| 43 | + | require.NoError(t, os.Mkdir(home, 0o755)) | |||
| 44 | + | ||||
| 45 | + | unrelated := filepath.Join(temp, "unrelated") | |||
| 46 | + | require.NoError(t, os.Mkdir(unrelated, 0o755)) | |||
| 47 | + | unrelatedSentinel := filepath.Join(unrelated, "keep") | |||
| 48 | + | require.NoError(t, os.WriteFile(unrelatedSentinel, []byte("do not delete"), 0o600)) | |||
| 49 | + | ||||
| 50 | + | safeParent := filepath.Join(temp, "safe") | |||
| 51 | + | require.NoError(t, os.Mkdir(safeParent, 0o755)) | |||
| 52 | + | parentSentinel := filepath.Join(safeParent, "keep") | |||
| 53 | + | require.NoError(t, os.WriteFile(parentSentinel, []byte("do not delete"), 0o600)) | |||
| 54 | + | ||||
| 55 | + | external := filepath.Join(temp, "external") | |||
| 56 | + | require.NoError(t, os.Mkdir(external, 0o755)) | |||
| 57 | + | externalSentinel := filepath.Join(external, "keep") | |||
| 58 | + | require.NoError(t, os.WriteFile(externalSentinel, []byte("do not delete"), 0o600)) | |||
| 59 | + | link := filepath.Join(temp, "link") | |||
| 60 | + | require.NoError(t, os.Symlink(external, link)) | |||
| 61 | + | ||||
| 62 | + | for _, test := range []struct { | |||
| 63 | + | name string | |||
| 64 | + | output string | |||
| 65 | + | verify func(t *testing.T) | |||
| 66 | + | }{ | |||
| 67 | + | {name: "empty", output: ""}, | |||
| 68 | + | {name: "root", output: "/"}, | |||
| 69 | + | {name: "home", output: home}, | |||
| 70 | + | {name: "existing unrelated directory", output: unrelated, verify: func(t *testing.T) { | |||
| 71 | + | requireFileContents(t, unrelatedSentinel, "do not delete") | |||
| 72 | + | }}, | |||
| 73 | + | {name: "parent traversal", output: safeParent + "/../escaped", verify: func(t *testing.T) { | |||
| 74 | + | requireFileContents(t, parentSentinel, "do not delete") | |||
| 75 | + | require.NoFileExists(t, filepath.Join(temp, "escaped")) | |||
| 76 | + | }}, | |||
| 77 | + | {name: "symlink escape", output: filepath.Join(link, "escaped"), verify: func(t *testing.T) { | |||
| 78 | + | requireFileContents(t, externalSentinel, "do not delete") | |||
| 79 | + | require.NoFileExists(t, filepath.Join(external, "escaped")) | |||
| 80 | + | }}, | |||
| 81 | + | } { | |||
| 82 | + | t.Run(test.name, func(t *testing.T) { | |||
| 83 | + | cmd := exec.Command(filepath.Join(repoRoot, "scripts", "collect_first_presentation.sh")) | |||
| 84 | + | // An isolated directory prevents a regression from reaching the tracked | |||
| 85 | + | // default artifact path while this test proves output validation is first. | |||
| 86 | + | cmd.Dir = temp | |||
| 87 | + | cmd.Env = append(os.Environ(), | |||
| 88 | + | "HOME="+home, | |||
| 89 | + | "DISPLAY=:test", | |||
| 90 | + | "DUMBER_CEF_DIR="+runtime, | |||
| 91 | + | "DUMBER_FIRST_PRESENTATION_BIN="+binary, | |||
| 92 | + | "DUMBER_FIRST_PRESENTATION_OUTPUT="+test.output, | |||
| 93 | + | ) | |||
| 94 | + | result, err := cmd.CombinedOutput() | |||
| 95 | + | require.Errorf(t, err, "collector accepted unsafe output %q: %s", test.output, result) | |||
| 96 | + | require.Contains(t, string(result), "unsafe output path") | |||
| 97 | + | if test.verify != nil { | |||
| 98 | + | test.verify(t) | |||
| 99 | + | } | |||
| 100 | + | }) | |||
| 101 | + | } | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | func requireFileContents(t *testing.T, path, want string) { | |||
| 105 | + | t.Helper() | |||
| 106 | + | contents, err := os.ReadFile(path) | |||
| 107 | + | require.NoError(t, err) | |||
| 108 | + | require.Equal(t, want, string(contents)) | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | func TestFirstPresentationCollectorSanitizesMachineLocalValues(t *testing.T) { | |||
| 112 | + | repoRoot, err := filepath.Abs(filepath.Join("..", "..", "..")) | |||
| 113 | + | require.NoError(t, err) | |||
| 114 | + | temp := t.TempDir() | |||
| 115 | + | runtime := filepath.Join(temp, "cef-147-runtime") | |||
| 116 | + | require.NoError(t, os.Mkdir(runtime, 0o755)) | |||
| 117 | + | ||||
| 118 | + | binary := filepath.Join(temp, "dumber") | |||
| 119 | + | require.NoError(t, os.WriteFile(binary, collectorTestBinary(validFirstPresentationLog), 0o755)) | |||
| 120 | + | ||||
| 121 | + | output := filepath.Join(temp, "artifacts") | |||
| 122 | + | cmd := exec.Command(filepath.Join(repoRoot, "scripts", "collect_first_presentation.sh")) | |||
| 123 | + | cmd.Dir = repoRoot | |||
| 124 | + | cmd.Env = append(os.Environ(), | |||
| 125 | + | "DISPLAY=:test", | |||
| 126 | + | "WAYLAND_DISPLAY=", | |||
| 127 | + | "DUMBER_CEF_DIR="+runtime, | |||
| 128 | + | "DUMBER_FIRST_PRESENTATION_BIN="+binary, | |||
| 129 | + | "DUMBER_FIRST_PRESENTATION_OUTPUT="+output, | |||
| 130 | + | "DUMBER_FIRST_PRESENTATION_TIMEOUT_SECONDS=1", | |||
| 131 | + | "DUMBER_MACHINE_GPU_PROFILE=integrated-gpu", | |||
| 132 | + | ) | |||
| 133 | + | result, err := cmd.CombinedOutput() | |||
| 134 | + | require.NoErrorf(t, err, "collector failed: %s", result) | |||
| 135 | + | ||||
| 136 | + | var artifacts strings.Builder | |||
| 137 | + | for _, name := range append([]string{"metadata.json", "baseline.json"}, runArtifactNames()...) { | |||
| 138 | + | artifactContents, readErr := os.ReadFile(filepath.Join(output, name)) | |||
| 139 | + | require.NoError(t, readErr) | |||
| 140 | + | artifacts.Write(artifactContents) | |||
| 141 | + | } | |||
| 142 | + | for _, forbidden := range []string{"/home/", temp, "alice", "machine_path", `"time"`} { | |||
| 143 | + | require.NotContainsf(t, artifacts.String(), forbidden, "committed artifact leaked %q", forbidden) | |||
| 144 | + | } | |||
| 145 | + | for _, required := range []string{ | |||
| 146 | + | `"measured_source_revision"`, | |||
| 147 | + | `"version": "v0.8.5"`, | |||
| 148 | + | `"tag": "v0.8.5"`, | |||
| 149 | + | `"revision": "b9fb46ef5c04ca0993943c5ca4b6f0fe83583304"`, | |||
| 150 | + | } { | |||
| 151 | + | require.Contains(t, artifacts.String(), required) | |||
| 152 | + | } | |||
| 153 | + | ||||
| 154 | + | var metadata struct { | |||
| 155 | + | Comparison struct { | |||
| 156 | + | OS string `json:"os"` | |||
| 157 | + | Architecture string `json:"architecture"` | |||
| 158 | + | DisplayProtocol string `json:"display_protocol"` | |||
| 159 | + | MachineGPUProfile string `json:"machine_gpu_profile"` | |||
| 160 | + | } `json:"comparison"` | |||
| 161 | + | } | |||
| 162 | + | contents, err := os.ReadFile(filepath.Join(output, "metadata.json")) | |||
| 163 | + | require.NoError(t, err) | |||
| 164 | + | require.NoError(t, json.Unmarshal(contents, &metadata)) | |||
| 165 | + | require.NotEmpty(t, metadata.Comparison.OS) | |||
| 166 | + | require.NotEmpty(t, metadata.Comparison.Architecture) | |||
| 167 | + | require.Equal(t, "x11", metadata.Comparison.DisplayProtocol) | |||
| 168 | + | require.Equal(t, "integrated-gpu", metadata.Comparison.MachineGPUProfile) | |||
| 169 | + | } | |||
| 170 | + | ||||
| 171 | + | func TestFirstPresentationCollectorDerivesSelectedImmutableModuleProvenance(t *testing.T) { | |||
| 172 | + | repoRoot, err := filepath.Abs(filepath.Join("..", "..", "..")) | |||
| 173 | + | require.NoError(t, err) | |||
| 174 | + | temp := t.TempDir() | |||
| 175 | + | runtime := filepath.Join(temp, "cef-147-runtime") | |||
| 176 | + | require.NoError(t, os.Mkdir(runtime, 0o755)) | |||
| 177 | + | binary := filepath.Join(temp, "dumber") | |||
| 178 | + | require.NoError(t, os.WriteFile(binary, collectorTestBinary(validFirstPresentationLog), 0o755)) | |||
| 179 | + | ||||
| 180 | + | // This exact pseudo-version is deliberately newer than the former v0.8.4 | |||
| 181 | + | // hardcode; the collector must report what the selected module says. | |||
| 182 | + | const version = "v0.8.5-0.20300102030405-bbd397409ebe" | |||
| 183 | + | const revision = "bbd397409ebed75a5979c1e4566a2ef319f6a484" | |||
| 184 | + | goBin := collectorProvenanceGo(t, temp, version, revision, "") | |||
| 185 | + | output := filepath.Join(temp, "artifacts") | |||
| 186 | + | cmd := exec.Command(filepath.Join(repoRoot, "scripts", "collect_first_presentation.sh")) | |||
| 187 | + | cmd.Dir = repoRoot | |||
| 188 | + | cmd.Env = append(os.Environ(), | |||
| 189 | + | "DISPLAY=:test", | |||
| 190 | + | "DUMBER_CEF_DIR="+runtime, | |||
| 191 | + | "DUMBER_FIRST_PRESENTATION_BIN="+binary, | |||
| 192 | + | "DUMBER_FIRST_PRESENTATION_OUTPUT="+output, | |||
| 193 | + | "DUMBER_FIRST_PRESENTATION_TIMEOUT_SECONDS=1", | |||
| 194 | + | "PATH="+filepath.Dir(goBin)+":"+os.Getenv("PATH"), | |||
| 195 | + | ) | |||
| 196 | + | result, err := cmd.CombinedOutput() | |||
| 197 | + | require.NoErrorf(t, err, "collector failed: %s", result) | |||
| 198 | + | ||||
| 199 | + | var metadata struct { | |||
| 200 | + | Upstream struct { | |||
| 201 | + | Module string `json:"module"` | |||
| 202 | + | Version string `json:"version"` | |||
| 203 | + | Tag string `json:"tag"` | |||
| 204 | + | Revision string `json:"revision"` | |||
| 205 | + | } `json:"upstream"` | |||
| 206 | + | } | |||
| 207 | + | contents, err := os.ReadFile(filepath.Join(output, "metadata.json")) | |||
| 208 | + | require.NoError(t, err) | |||
| 209 | + | require.NoError(t, json.Unmarshal(contents, &metadata)) | |||
| 210 | + | require.Equal(t, "github.com/bnema/purego-cef2gtk", metadata.Upstream.Module) | |||
| 211 | + | require.Equal(t, version, metadata.Upstream.Version) | |||
| 212 | + | require.Equal(t, "v0.8.5", metadata.Upstream.Tag) | |||
| 213 | + | require.Equal(t, revision, metadata.Upstream.Revision) | |||
| 214 | + | ||||
| 215 | + | script, err := os.ReadFile(filepath.Join(repoRoot, "scripts", "collect_first_presentation.sh")) | |||
| 216 | + | require.NoError(t, err) | |||
| 217 | + | require.NotContains(t, string(script), "f217ece342dea3ef2a3f98671fcd16a39ad0037d") | |||
| 218 | + | } | |||
| 219 | + | ||||
| 220 | + | func TestFirstPresentationCollectorRejectsNonImmutableModuleProvenanceWithoutLeaks(t *testing.T) { | |||
| 221 | + | repoRoot, err := filepath.Abs(filepath.Join("..", "..", "..")) | |||
| 222 | + | require.NoError(t, err) | |||
| 223 | + | ||||
| 224 | + | for _, test := range []struct { | |||
| 225 | + | name, version, revision, ref string | |||
| 226 | + | }{ | |||
| 227 | + | {name: "branch selector", version: "main", ref: "main"}, | |||
| 228 | + | {name: "missing origin", version: "v0.8.5-0.20300102030405-bbd397409ebe"}, | |||
| 229 | + | {name: "named origin ref", version: "v0.8.5-0.20300102030405-bbd397409ebe", revision: "bbd397409ebed75a5979c1e4566a2ef319f6a484", ref: "main"}, | |||
| 230 | + | } { | |||
| 231 | + | t.Run(test.name, func(t *testing.T) { | |||
| 232 | + | temp := t.TempDir() | |||
| 233 | + | runtime := filepath.Join(temp, "cef-147-runtime") | |||
| 234 | + | require.NoError(t, os.Mkdir(runtime, 0o755)) | |||
| 235 | + | binary := filepath.Join(temp, "dumber") | |||
| 236 | + | require.NoError(t, os.WriteFile(binary, collectorTestBinary(validFirstPresentationLog), 0o755)) | |||
| 237 | + | ||||
| 238 | + | goBin := collectorProvenanceGo(t, temp, test.version, test.revision, test.ref) | |||
| 239 | + | output := filepath.Join(temp, "artifacts") | |||
| 240 | + | cmd := exec.Command(filepath.Join(repoRoot, "scripts", "collect_first_presentation.sh")) | |||
| 241 | + | cmd.Dir = repoRoot | |||
| 242 | + | cmd.Env = append(os.Environ(), | |||
| 243 | + | "DISPLAY=:test", | |||
| 244 | + | "DUMBER_CEF_DIR="+runtime, | |||
| 245 | + | "DUMBER_FIRST_PRESENTATION_BIN="+binary, | |||
| 246 | + | "DUMBER_FIRST_PRESENTATION_OUTPUT="+output, | |||
| 247 | + | "PATH="+filepath.Dir(goBin)+":"+os.Getenv("PATH"), | |||
| 248 | + | ) | |||
| 249 | + | result, runErr := cmd.CombinedOutput() | |||
| 250 | + | require.Errorf(t, runErr, "collector accepted nonimmutable module provenance: %s", result) | |||
| 251 | + | require.Contains(t, string(result), "immutable module provenance is unavailable") | |||
| 252 | + | require.NotContains(t, string(result), temp) | |||
| 253 | + | require.NoFileExists(t, filepath.Join(output, "metadata.json")) | |||
| 254 | + | for _, name := range runArtifactNames() { | |||
| 255 | + | require.NoFileExists(t, filepath.Join(output, name)) | |||
| 256 | + | } | |||
| 257 | + | }) | |||
| 258 | + | } | |||
| 259 | + | } | |||
| 260 | + | ||||
| 261 | + | func collectorProvenanceGo(t *testing.T, temp, version, revision, ref string) string { | |||
| 262 | + | t.Helper() | |||
| 263 | + | infoPath := filepath.Join(temp, "module.info") | |||
| 264 | + | info := map[string]any{"Version": version} | |||
| 265 | + | if revision != "" { | |||
| 266 | + | info["Origin"] = map[string]string{ | |||
| 267 | + | "VCS": "git", "URL": "https://github.com/bnema/purego-cef2gtk", "Hash": revision, "Ref": ref, | |||
| 268 | + | } | |||
| 269 | + | } | |||
| 270 | + | infoJSON, err := json.Marshal(info) | |||
| 271 | + | require.NoError(t, err) | |||
| 272 | + | require.NoError(t, os.WriteFile(infoPath, infoJSON, 0o600)) | |||
| 273 | + | ||||
| 274 | + | selectedJSON, err := json.Marshal(map[string]string{ | |||
| 275 | + | "Path": "github.com/bnema/purego-cef2gtk", "Version": version, | |||
| 276 | + | }) | |||
| 277 | + | require.NoError(t, err) | |||
| 278 | + | downloadedJSON, err := json.Marshal(map[string]string{ | |||
| 279 | + | "Path": "github.com/bnema/purego-cef2gtk", "Version": version, "Info": infoPath, | |||
| 280 | + | }) | |||
| 281 | + | require.NoError(t, err) | |||
| 282 | + | binDir := filepath.Join(temp, "bin") | |||
| 283 | + | require.NoError(t, os.Mkdir(binDir, 0o755)) | |||
| 284 | + | goBin := filepath.Join(binDir, "go") | |||
| 285 | + | script := fmt.Sprintf(`#!/bin/sh | |||
| 286 | + | if [ "$1" = "list" ] && [ "$2" = "-m" ] && [ "$3" = "-json" ] && [ "$4" = "github.com/bnema/purego-cef2gtk" ]; then | |||
| 287 | + | printf '%%s\n' '%s' | |||
| 288 | + | exit 0 | |||
| 289 | + | fi | |||
| 290 | + | if [ "$1" = "mod" ] && [ "$2" = "download" ] && [ "$3" = "-json" ] && [ "$4" = "github.com/bnema/purego-cef2gtk@%s" ]; then | |||
| 291 | + | printf '%%s\n' '%s' | |||
| 292 | + | exit 0 | |||
| 293 | + | fi | |||
| 294 | + | exit 1 | |||
| 295 | + | `, selectedJSON, version, downloadedJSON) | |||
| 296 | + | require.NoError(t, os.WriteFile(goBin, []byte(script), 0o755)) | |||
| 297 | + | return goBin | |||
| 298 | + | } | |||
| 299 | + | ||||
| 300 | + | func TestFirstPresentationCollectorDefaultsToXDGStateEvidenceDirectory(t *testing.T) { | |||
| 301 | + | repoRoot, err := filepath.Abs(filepath.Join("..", "..", "..")) | |||
| 302 | + | require.NoError(t, err) | |||
| 303 | + | temp := t.TempDir() | |||
| 304 | + | runtime := filepath.Join(temp, "cef-147-runtime") | |||
| 305 | + | require.NoError(t, os.Mkdir(runtime, 0o755)) | |||
| 306 | + | binary := filepath.Join(temp, "dumber") | |||
| 307 | + | require.NoError(t, os.WriteFile(binary, collectorTestBinary(validFirstPresentationLog), 0o755)) | |||
| 308 | + | stateHome := filepath.Join(temp, "state") | |||
| 309 | + | ||||
| 310 | + | cmd := exec.Command(filepath.Join(repoRoot, "scripts", "collect_first_presentation.sh")) | |||
| 311 | + | cmd.Dir = repoRoot | |||
| 312 | + | cmd.Env = append(envWithout("DUMBER_FIRST_PRESENTATION_OUTPUT"), | |||
| 313 | + | "DISPLAY=:test", | |||
| 314 | + | "WAYLAND_DISPLAY=", | |||
| 315 | + | "DUMBER_CEF_DIR="+runtime, | |||
| 316 | + | "DUMBER_FIRST_PRESENTATION_BIN="+binary, | |||
| 317 | + | "DUMBER_FIRST_PRESENTATION_TIMEOUT_SECONDS=1", | |||
| 318 | + | "DUMBER_MACHINE_GPU_PROFILE=integrated-gpu", | |||
| 319 | + | "XDG_STATE_HOME="+stateHome, | |||
| 320 | + | ) | |||
| 321 | + | result, err := cmd.CombinedOutput() | |||
| 322 | + | require.NoErrorf(t, err, "collector failed: %s", result) | |||
| 323 | + | ||||
| 324 | + | evidenceRoot := filepath.Join(stateHome, "dumber", "roadmap-evidence") | |||
| 325 | + | entries, err := os.ReadDir(evidenceRoot) | |||
| 326 | + | require.NoError(t, err) | |||
| 327 | + | require.Len(t, entries, 1) | |||
| 328 | + | require.True(t, entries[0].IsDir()) | |||
| 329 | + | require.FileExists(t, filepath.Join(evidenceRoot, entries[0].Name(), "metadata.json")) | |||
| 330 | + | require.NotContains(t, string(result), filepath.Join(repoRoot, "phase1")) | |||
| 331 | + | } | |||
| 332 | + | ||||
| 333 | + | func envWithout(name string) []string { | |||
| 334 | + | prefix := name + "=" | |||
| 335 | + | var environment []string | |||
| 336 | + | for _, entry := range os.Environ() { | |||
| 337 | + | if !strings.HasPrefix(entry, prefix) { | |||
| 338 | + | environment = append(environment, entry) | |||
| 339 | + | } | |||
| 340 | + | } | |||
| 341 | + | return environment | |||
| 342 | + | } | |||
| 343 | + | ||||
| 344 | + | func TestFirstPresentationCollectorReadsProvenanceWithScopedGitSafeDirectory(t *testing.T) { | |||
| 345 | + | repoRoot, err := filepath.Abs(filepath.Join("..", "..", "..")) | |||
| 346 | + | require.NoError(t, err) | |||
| 347 | + | temp := t.TempDir() | |||
| 348 | + | runtime := filepath.Join(temp, "cef-147-runtime") | |||
| 349 | + | require.NoError(t, os.Mkdir(runtime, 0o755)) | |||
| 350 | + | binary := filepath.Join(temp, "dumber") | |||
| 351 | + | require.NoError(t, os.WriteFile(binary, collectorTestBinary(validFirstPresentationLog), 0o755)) | |||
| 352 | + | gitDir := filepath.Join(temp, "bin") | |||
| 353 | + | require.NoError(t, os.Mkdir(gitDir, 0o755)) | |||
| 354 | + | git := filepath.Join(gitDir, "git") | |||
| 355 | + | require.NoError(t, os.WriteFile(git, []byte(`#!/bin/sh | |||
| 356 | + | if [ "$1" = "-c" ] && [ "$2" = "safe.directory=$EXPECTED_SAFE_DIRECTORY" ] && [ "$3" = "rev-parse" ] && [ "$4" = "HEAD" ]; then | |||
| 357 | + | echo 0123456789abcdef0123456789abcdef01234567 | |||
| 358 | + | exit 0 | |||
| 359 | + | fi | |||
| 360 | + | echo "fatal: detected dubious ownership in repository" >&2 | |||
| 361 | + | exit 128 | |||
| 362 | + | `), 0o755)) | |||
| 363 | + | ||||
| 364 | + | output := filepath.Join(temp, "artifacts") | |||
| 365 | + | cmd := exec.Command(filepath.Join(repoRoot, "scripts", "collect_first_presentation.sh")) | |||
| 366 | + | cmd.Dir = repoRoot | |||
| 367 | + | cmd.Env = append(os.Environ(), | |||
| 368 | + | "DISPLAY=:test", | |||
| 369 | + | "DUMBER_CEF_DIR="+runtime, | |||
| 370 | + | "DUMBER_FIRST_PRESENTATION_BIN="+binary, | |||
| 371 | + | "DUMBER_FIRST_PRESENTATION_OUTPUT="+output, | |||
| 372 | + | "DUMBER_FIRST_PRESENTATION_TIMEOUT_SECONDS=1", | |||
| 373 | + | "EXPECTED_SAFE_DIRECTORY="+repoRoot, | |||
| 374 | + | "PATH="+gitDir+":"+os.Getenv("PATH"), | |||
| 375 | + | ) | |||
| 376 | + | result, err := cmd.CombinedOutput() | |||
| 377 | + | require.NoErrorf(t, err, "collector failed: %s", result) | |||
| 378 | + | ||||
| 379 | + | var metadata struct { | |||
| 380 | + | MeasuredSourceRevision string `json:"measured_source_revision"` | |||
| 381 | + | } | |||
| 382 | + | contents, err := os.ReadFile(filepath.Join(output, "metadata.json")) | |||
| 383 | + | require.NoError(t, err) | |||
| 384 | + | require.NoError(t, json.Unmarshal(contents, &metadata)) | |||
| 385 | + | require.Equal(t, "0123456789abcdef0123456789abcdef01234567", metadata.MeasuredSourceRevision) | |||
| 386 | + | } | |||
| 387 | + | ||||
| 388 | + | func TestFirstPresentationCollectorRejectsInconsistentTiming(t *testing.T) { | |||
| 389 | + | repoRoot, err := filepath.Abs(filepath.Join("..", "..", "..")) | |||
| 390 | + | require.NoError(t, err) | |||
| 391 | + | ||||
| 392 | + | for _, test := range []struct { | |||
| 393 | + | name string | |||
| 394 | + | old string | |||
| 395 | + | new string | |||
| 396 | + | }{ | |||
| 397 | + | {name: "non-integer delta", old: `"delta_ms":1}`, new: `"delta_ms":"1"}`}, | |||
| 398 | + | {name: "incorrect delta", old: `"delta_ms":1}`, new: `"delta_ms":2}`}, | |||
| 399 | + | {name: "non-integer summary total", old: `"total_ms":7`, new: `"total_ms":7.5`}, | |||
| 400 | + | {name: "incorrect summary total", old: `"total_ms":7`, new: `"total_ms":8`}, | |||
| 401 | + | } { | |||
| 402 | + | t.Run(test.name, func(t *testing.T) { | |||
| 403 | + | temp := t.TempDir() | |||
| 404 | + | runtime := filepath.Join(temp, "cef-147-runtime") | |||
| 405 | + | require.NoError(t, os.Mkdir(runtime, 0o755)) | |||
| 406 | + | binary := filepath.Join(temp, "dumber") | |||
| 407 | + | log := strings.Replace(validFirstPresentationLog, test.old, test.new, 1) | |||
| 408 | + | require.NoError(t, os.WriteFile(binary, collectorTestBinary(log), 0o755)) | |||
| 409 | + | ||||
| 410 | + | cmd := exec.Command(filepath.Join(repoRoot, "scripts", "collect_first_presentation.sh")) | |||
| 411 | + | cmd.Dir = repoRoot | |||
| 412 | + | cmd.Env = append(os.Environ(), | |||
| 413 | + | "DISPLAY=:test", | |||
| 414 | + | "DUMBER_CEF_DIR="+runtime, | |||
| 415 | + | "DUMBER_FIRST_PRESENTATION_BIN="+binary, | |||
| 416 | + | "DUMBER_FIRST_PRESENTATION_OUTPUT="+filepath.Join(temp, "artifacts"), | |||
| 417 | + | "DUMBER_FIRST_PRESENTATION_TIMEOUT_SECONDS=1", | |||
| 418 | + | ) | |||
| 419 | + | result, err := cmd.CombinedOutput() | |||
| 420 | + | require.Errorf(t, err, "collector accepted malformed timing artifact: %s", result) | |||
| 421 | + | require.Contains(t, string(result), "invalid or incomplete non-DMABUF timeline") | |||
| 422 | + | }) | |||
| 423 | + | } | |||
| 424 | + | } | |||
| 425 | + | ||||
| 426 | + | func collectorTestBinary(log string) []byte { | |||
| 427 | + | return []byte("#!/bin/sh\ncat <<'EOF'\n" + log + "\nEOF\nexit 139\n") | |||
| 428 | + | } | |||
| 429 | + | ||||
| 430 | + | func runArtifactNames() []string { | |||
| 431 | + | return []string{"run-01.json", "run-02.json", "run-03.json", "run-04.json", "run-05.json"} | |||
| 432 | + | } | |||
M internal/infrastructure/cef/render_handler_adapter.go +44 -15
| @@ -23,6 +23,26 @@ type dumberRenderHandler struct { | |||||
| 23 | 23 | ||||
| 24 | var _ purecef.RenderHandler = (*dumberRenderHandler)(nil) | 24 | var _ purecef.RenderHandler = (*dumberRenderHandler)(nil) | ||
| 25 | 25 | ||||
| 26 | + | // startupPresentationHooks consumes the one-shot facts emitted by | |||
| 27 | + | // purego-cef2gtk. The bridge owns the native DMABUF and frame-clock boundaries; | |||
| 28 | + | // Dumber only records their ordered application-level timeline. | |||
| 29 | + | func startupPresentationHooks(trace *startupTrace) cef2gtk.Hooks { | |||
| 30 | + | return cef2gtk.Hooks{ | |||
| 31 | + | OnFirstAcceleratedPaint: func() { | |||
| 32 | + | trace.Mark("first_accelerated_paint_received") | |||
| 33 | + | }, | |||
| 34 | + | OnFirstDMABUFTextureSwap: func() { | |||
| 35 | + | trace.Mark("first_dmabuf_texture_swap") | |||
| 36 | + | }, | |||
| 37 | + | OnFirstPresentation: func() { | |||
| 38 | + | trace.MarkGTKAfterPaint() | |||
| 39 | + | }, | |||
| 40 | + | OnDMABUFUnsupported: func() { | |||
| 41 | + | trace.SetIncompleteReason("dmabuf_texture_swap_unavailable") | |||
| 42 | + | }, | |||
| 43 | + | } | |||
| 44 | + | } | |||
| 45 | + | ||||
| 26 | func newDumberRenderHandler(wv *WebView) purecef.RenderHandler { | 46 | func newDumberRenderHandler(wv *WebView) purecef.RenderHandler { | ||
| 27 | if wv == nil || wv.viewBridge == nil { | 47 | if wv == nil || wv.viewBridge == nil { | ||
| 28 | return nil | 48 | return nil | ||
| @@ -30,23 +50,32 @@ func newDumberRenderHandler(wv *WebView) purecef.RenderHandler { | |||||
| 30 | h := &dumberRenderHandler{wv: wv} | 50 | h := &dumberRenderHandler{wv: wv} | ||
| 31 | 51 | ||||
| 32 | var unsupportedPaintOnce sync.Once | 52 | var unsupportedPaintOnce sync.Once | ||
| 33 | - | h.main = wv.viewBridge.RenderHandler(cef2gtk.Hooks{ | 53 | + | hooks := startupPresentationHooks(activeStartupTrace()) |
| 34 | - | OnUnsupportedPaint: func() { | 54 | + | // Tear down the theme background flash guard once the first frame is |
| 35 | - | unsupportedPaintOnce.Do(func() { | 55 | + | // presented, without dropping the startup trace mark the bridge already set. |
| 36 | - | if wv.ctx != nil { | 56 | + | firstAcceleratedPaint := hooks.OnFirstAcceleratedPaint |
| 37 | - | logging.FromContext(wv.ctx).Warn().Msg("cef: unsupported CPU paint from accelerated bridge") | 57 | + | hooks.OnFirstAcceleratedPaint = func() { |
| 38 | - | } | 58 | + | if firstAcceleratedPaint != nil { |
| 39 | - | }) | 59 | + | firstAcceleratedPaint() |
| 40 | - | }, | 60 | + | } |
| 41 | - | OnError: func(err error) { | 61 | + | wv.markFirstFramePainted() |
| 62 | + | } | |||
| 63 | + | hooks.OnUnsupportedPaint = func() { | |||
| 64 | + | unsupportedPaintOnce.Do(func() { | |||
| 42 | if wv.ctx != nil { | 65 | if wv.ctx != nil { | ||
| 43 | - | logging.FromContext(wv.ctx).Warn().Err(err).Msg("cef: accelerated render bridge error") | 66 | + | logging.FromContext(wv.ctx).Warn().Msg("cef: unsupported CPU paint from accelerated bridge") |
| 44 | } | 67 | } | ||
| 45 | - | }, | 68 | + | }) |
| 46 | - | OnTextSelectionChanged: func(selectedText string, _ *purecef.Range) { | 69 | + | } |
| 47 | - | handleRenderTextSelectionChanged(wv, selectedText) | 70 | + | hooks.OnError = func(err error) { |
| 48 | - | }, | 71 | + | if wv.ctx != nil { |
| 49 | - | }) | 72 | + | logging.FromContext(wv.ctx).Warn().Err(err).Msg("cef: accelerated render bridge error") |
| 73 | + | } | |||
| 74 | + | } | |||
| 75 | + | hooks.OnTextSelectionChanged = func(selectedText string, _ *purecef.Range) { | |||
| 76 | + | handleRenderTextSelectionChanged(wv, selectedText) | |||
| 77 | + | } | |||
| 78 | + | h.main = wv.viewBridge.RenderHandler(hooks) | |||
| 50 | 79 | ||||
| 51 | if wv.popupSurface != nil { | 80 | if wv.popupSurface != nil { | ||
| 52 | var popupUnsupportedPaintOnce sync.Once | 81 | var popupUnsupportedPaintOnce sync.Once | ||
A internal/infrastructure/cef/restart_safety.go +68 -0
| @@ -0,0 +1,68 @@ | |||||
| 1 | + | package cef | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | "os" | |||
| 6 | + | "path/filepath" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | const ( | |||
| 10 | + | nextStartSafetyMarkerFile = "cef-next-start-safe" | |||
| 11 | + | safetyMarkerDirPerm = 0o700 | |||
| 12 | + | safetyMarkerFilePerm = 0o600 | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | // writeNextStartSafetyMarker records a one-shot recovery request for the next | |||
| 16 | + | // CEF start. The marker lives beside CEF's root cache so it follows explicit | |||
| 17 | + | // per-session cache roots as well as the normal profile. | |||
| 18 | + | func writeNextStartSafetyMarker(stateRoot string) error { | |||
| 19 | + | if stateRoot == "" { | |||
| 20 | + | return errors.New("CEF state root is empty") | |||
| 21 | + | } | |||
| 22 | + | if err := os.MkdirAll(stateRoot, safetyMarkerDirPerm); err != nil { | |||
| 23 | + | return err | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | marker := filepath.Join(stateRoot, nextStartSafetyMarkerFile) | |||
| 27 | + | temporary, err := os.CreateTemp(stateRoot, ".cef-next-start-safe-*") | |||
| 28 | + | if err != nil { | |||
| 29 | + | return err | |||
| 30 | + | } | |||
| 31 | + | temporaryName := temporary.Name() | |||
| 32 | + | defer func() { _ = os.Remove(temporaryName) }() | |||
| 33 | + | if err := temporary.Chmod(safetyMarkerFilePerm); err != nil { | |||
| 34 | + | _ = temporary.Close() | |||
| 35 | + | return err | |||
| 36 | + | } | |||
| 37 | + | if _, err := temporary.WriteString("safe\n"); err != nil { | |||
| 38 | + | _ = temporary.Close() | |||
| 39 | + | return err | |||
| 40 | + | } | |||
| 41 | + | if err := temporary.Close(); err != nil { | |||
| 42 | + | return err | |||
| 43 | + | } | |||
| 44 | + | return os.Rename(temporaryName, marker) | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | // consumeNextStartSafetyMarker consumes a one-shot marker. It deliberately | |||
| 48 | + | // does not follow symlinks, so an untrusted cache entry cannot redirect the | |||
| 49 | + | // recovery check outside the CEF state root. | |||
| 50 | + | func consumeNextStartSafetyMarker(stateRoot string) bool { | |||
| 51 | + | if stateRoot == "" { | |||
| 52 | + | return false | |||
| 53 | + | } | |||
| 54 | + | marker := filepath.Join(stateRoot, nextStartSafetyMarkerFile) | |||
| 55 | + | info, err := os.Lstat(marker) | |||
| 56 | + | if err != nil || !info.Mode().IsRegular() { | |||
| 57 | + | return false | |||
| 58 | + | } | |||
| 59 | + | return os.Remove(marker) == nil | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | // applyNextStartSafetyEnvironment leaves the Vulkan render stack intact while | |||
| 63 | + | // returning Chromium to its hardware-decode safety defaults for one start. | |||
| 64 | + | func applyNextStartSafetyEnvironment() { | |||
| 65 | + | _ = os.Setenv(cefEnableVAAPIEnvVar, "0") | |||
| 66 | + | _ = os.Unsetenv(cefChromiumFlagsEnvVar) | |||
| 67 | + | _ = os.Unsetenv(cefRenderNodeEnvVar) | |||
| 68 | + | } | |||
A internal/infrastructure/cef/restart_safety_test.go +38 -0
| @@ -0,0 +1,38 @@ | |||||
| 1 | + | package cef | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "os" | |||
| 5 | + | "path/filepath" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/stretchr/testify/require" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestConsumeNextStartSafetyMarkerDisablesVAAPIWithoutChangingVulkanStack(t *testing.T) { | |||
| 12 | + | root := t.TempDir() | |||
| 13 | + | t.Setenv(cefEnableVAAPIEnvVar, "1") | |||
| 14 | + | t.Setenv(cefChromiumFlagsEnvVar, "--ignore-gpu-blocklist --disable-gpu-driver-bug-workaround") | |||
| 15 | + | t.Setenv(dumberRenderStackEnvVar, renderStackVulkanDMABUF) | |||
| 16 | + | ||||
| 17 | + | require.NoError(t, writeNextStartSafetyMarker(root)) | |||
| 18 | + | require.True(t, consumeNextStartSafetyMarker(root)) | |||
| 19 | + | applyNextStartSafetyEnvironment() | |||
| 20 | + | ||||
| 21 | + | require.Equal(t, "0", os.Getenv(cefEnableVAAPIEnvVar)) | |||
| 22 | + | require.Empty(t, os.Getenv(cefChromiumFlagsEnvVar)) | |||
| 23 | + | require.Equal(t, renderStackVulkanDMABUF, os.Getenv(dumberRenderStackEnvVar)) | |||
| 24 | + | _, err := os.Stat(filepath.Join(root, nextStartSafetyMarkerFile)) | |||
| 25 | + | require.ErrorIs(t, err, os.ErrNotExist) | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func TestGPUProcessRelaunchMarksNextStartSafeAfterRepeatedRelaunch(t *testing.T) { | |||
| 29 | + | root := t.TempDir() | |||
| 30 | + | engine := &Engine{stateRoot: root} | |||
| 31 | + | handler := &dumberBPH{engine: engine} | |||
| 32 | + | commandLine := relaunchCommandLineStub{commandLineString: "dumber --type=gpu-process"} | |||
| 33 | + | ||||
| 34 | + | require.Zero(t, handler.OnAlreadyRunningAppRelaunch(commandLine, "")) | |||
| 35 | + | require.NoFileExists(t, filepath.Join(root, nextStartSafetyMarkerFile)) | |||
| 36 | + | require.Zero(t, handler.OnAlreadyRunningAppRelaunch(commandLine, "")) | |||
| 37 | + | require.FileExists(t, filepath.Join(root, nextStartSafetyMarkerFile)) | |||
| 38 | + | } | |||
A internal/infrastructure/cef/startup_presentation_hooks_test.go +79 -0
| @@ -0,0 +1,79 @@ | |||||
| 1 | + | package cef | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "strings" | |||
| 7 | + | "testing" | |||
| 8 | + | "time" | |||
| 9 | + | ||||
| 10 | + | "github.com/rs/zerolog" | |||
| 11 | + | "github.com/stretchr/testify/require" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | func TestStartupPresentationHooksWireEveryUpstreamFirstPresentationCallback(t *testing.T) { | |||
| 15 | + | var output bytes.Buffer | |||
| 16 | + | trace := newStartupTrace(time.Now, time.Now()) | |||
| 17 | + | trace.SetBackend("gdk-dmabuf") | |||
| 18 | + | logger := zerolog.New(&output) | |||
| 19 | + | trace.SetLogger(&logger) | |||
| 20 | + | for _, milestone := range []string{ | |||
| 21 | + | "process_entry", | |||
| 22 | + | "config_complete", | |||
| 23 | + | "cef_library_load_begin", | |||
| 24 | + | "cef_initialized", | |||
| 25 | + | "browser_create_requested", | |||
| 26 | + | } { | |||
| 27 | + | require.Truef(t, trace.Mark(milestone), "seed milestone %q", milestone) | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | hooks := startupPresentationHooks(trace) | |||
| 31 | + | require.NotNil(t, hooks.OnFirstAcceleratedPaint) | |||
| 32 | + | require.NotNil(t, hooks.OnFirstDMABUFTextureSwap) | |||
| 33 | + | require.NotNil(t, hooks.OnFirstPresentation) | |||
| 34 | + | require.NotNil(t, hooks.OnDMABUFUnsupported) | |||
| 35 | + | ||||
| 36 | + | // The unsupported fact may arrive before the successful presentation path. | |||
| 37 | + | // Each callback is invoked twice to make no-op, swapped, and duplicate bodies | |||
| 38 | + | // observable through the exact trace output below. | |||
| 39 | + | hooks.OnDMABUFUnsupported() | |||
| 40 | + | hooks.OnDMABUFUnsupported() | |||
| 41 | + | hooks.OnFirstAcceleratedPaint() | |||
| 42 | + | hooks.OnFirstAcceleratedPaint() | |||
| 43 | + | hooks.OnFirstDMABUFTextureSwap() | |||
| 44 | + | hooks.OnFirstDMABUFTextureSwap() | |||
| 45 | + | hooks.OnFirstPresentation() | |||
| 46 | + | hooks.OnFirstPresentation() | |||
| 47 | + | ||||
| 48 | + | var milestones []string | |||
| 49 | + | summaryCount := 0 | |||
| 50 | + | var incompleteReason string | |||
| 51 | + | for _, line := range strings.Split(strings.TrimSpace(output.String()), "\n") { | |||
| 52 | + | var event struct { | |||
| 53 | + | Message string `json:"message"` | |||
| 54 | + | Milestone string `json:"milestone"` | |||
| 55 | + | IncompleteReason string `json:"incomplete_reason"` | |||
| 56 | + | } | |||
| 57 | + | require.NoError(t, json.Unmarshal([]byte(line), &event)) | |||
| 58 | + | switch event.Message { | |||
| 59 | + | case "startup_trace: milestone": | |||
| 60 | + | milestones = append(milestones, event.Milestone) | |||
| 61 | + | case "startup_trace: first presentation": | |||
| 62 | + | summaryCount++ | |||
| 63 | + | incompleteReason = event.IncompleteReason | |||
| 64 | + | } | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | require.Equal(t, []string{ | |||
| 68 | + | "process_entry", | |||
| 69 | + | "config_complete", | |||
| 70 | + | "cef_library_load_begin", | |||
| 71 | + | "cef_initialized", | |||
| 72 | + | "browser_create_requested", | |||
| 73 | + | "first_accelerated_paint_received", | |||
| 74 | + | "first_dmabuf_texture_swap", | |||
| 75 | + | "first_gtk_presentation", | |||
| 76 | + | }, milestones) | |||
| 77 | + | require.Equal(t, "dmabuf_texture_swap_unavailable", incompleteReason) | |||
| 78 | + | require.Equal(t, 1, summaryCount) | |||
| 79 | + | } | |||
A internal/infrastructure/cef/startup_trace.go +199 -0
| @@ -0,0 +1,199 @@ | |||||
| 1 | + | package cef | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "sync" | |||
| 5 | + | "time" | |||
| 6 | + | ||||
| 7 | + | "github.com/rs/zerolog" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // startupTrace records the accelerated CEF/DMABUF/GTK path from process entry | |||
| 11 | + | // to the first GTK presentation. It deliberately rejects unknown, duplicate, | |||
| 12 | + | // and out-of-order milestones so collected cold-start measurements remain | |||
| 13 | + | // truthful. | |||
| 14 | + | type startupTrace struct { | |||
| 15 | + | mu sync.Mutex | |||
| 16 | + | now func() time.Time | |||
| 17 | + | t0 time.Time | |||
| 18 | + | milestones []startupMilestone | |||
| 19 | + | logger *zerolog.Logger | |||
| 20 | + | buffered []startupMilestone | |||
| 21 | + | backend string | |||
| 22 | + | incompleteReason string | |||
| 23 | + | summaryEmitted bool | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | type startupMilestone struct { | |||
| 27 | + | Name string | |||
| 28 | + | Elapsed time.Duration | |||
| 29 | + | Delta time.Duration | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | var startupMilestoneOrder = []string{ | |||
| 33 | + | "process_entry", | |||
| 34 | + | "config_complete", | |||
| 35 | + | "cef_library_load_begin", | |||
| 36 | + | "cef_initialized", | |||
| 37 | + | "browser_create_requested", | |||
| 38 | + | "first_accelerated_paint_received", | |||
| 39 | + | "first_dmabuf_texture_swap", | |||
| 40 | + | "first_gtk_presentation", | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | var processStartupTrace struct { | |||
| 44 | + | sync.Mutex | |||
| 45 | + | trace *startupTrace | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | func newStartupTrace(now func() time.Time, processEntry time.Time) *startupTrace { | |||
| 49 | + | return &startupTrace{ | |||
| 50 | + | now: now, | |||
| 51 | + | t0: processEntry, | |||
| 52 | + | milestones: make([]startupMilestone, 0, len(startupMilestoneOrder)), | |||
| 53 | + | buffered: make([]startupMilestone, 0, len(startupMilestoneOrder)), | |||
| 54 | + | } | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | // ActivateStartupTrace starts the CEF-only first-presentation trace after GUI | |||
| 58 | + | // engine selection. The supplied timestamps are captured by the application at | |||
| 59 | + | // process entry and configuration completion, before CEF initialization. | |||
| 60 | + | func ActivateStartupTrace(processEntry, configComplete time.Time, logger *zerolog.Logger) { | |||
| 61 | + | if processEntry.IsZero() || configComplete.IsZero() { | |||
| 62 | + | return | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | processStartupTrace.Lock() | |||
| 66 | + | defer processStartupTrace.Unlock() | |||
| 67 | + | if processStartupTrace.trace != nil { | |||
| 68 | + | return | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | trace := newStartupTrace(time.Now, processEntry) | |||
| 72 | + | if !trace.markAt("process_entry", processEntry) || !trace.markAt("config_complete", configComplete) { | |||
| 73 | + | return | |||
| 74 | + | } | |||
| 75 | + | trace.SetLogger(logger) | |||
| 76 | + | processStartupTrace.trace = trace | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | func activeStartupTrace() *startupTrace { | |||
| 80 | + | processStartupTrace.Lock() | |||
| 81 | + | defer processStartupTrace.Unlock() | |||
| 82 | + | return processStartupTrace.trace | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | func (st *startupTrace) SetLogger(logger *zerolog.Logger) { | |||
| 86 | + | if st == nil || logger == nil { | |||
| 87 | + | return | |||
| 88 | + | } | |||
| 89 | + | st.mu.Lock() | |||
| 90 | + | defer st.mu.Unlock() | |||
| 91 | + | st.logger = logger | |||
| 92 | + | for _, milestone := range st.buffered { | |||
| 93 | + | st.emitMilestone(milestone) | |||
| 94 | + | } | |||
| 95 | + | st.buffered = nil | |||
| 96 | + | st.emitSummaryLocked() | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | func (st *startupTrace) SetBackend(backend string) { | |||
| 100 | + | if st == nil { | |||
| 101 | + | return | |||
| 102 | + | } | |||
| 103 | + | st.mu.Lock() | |||
| 104 | + | defer st.mu.Unlock() | |||
| 105 | + | st.backend = backend | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | func (st *startupTrace) SetIncompleteReason(reason string) { | |||
| 109 | + | if st == nil { | |||
| 110 | + | return | |||
| 111 | + | } | |||
| 112 | + | st.mu.Lock() | |||
| 113 | + | defer st.mu.Unlock() | |||
| 114 | + | if st.incompleteReason == "" { | |||
| 115 | + | st.incompleteReason = reason | |||
| 116 | + | } | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | // Mark accepts exactly the next non-presentation startup milestone. | |||
| 120 | + | func (st *startupTrace) Mark(name string) bool { | |||
| 121 | + | if name == "first_gtk_presentation" { | |||
| 122 | + | return false | |||
| 123 | + | } | |||
| 124 | + | return st.markAt(name, st.currentTime()) | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | // MarkGTKAfterPaint records the final milestone from the CEF-to-GTK bridge's | |||
| 128 | + | // first frame-clock after-paint callback. | |||
| 129 | + | func (st *startupTrace) MarkGTKAfterPaint() bool { | |||
| 130 | + | return st.markAt("first_gtk_presentation", st.currentTime()) | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | func (st *startupTrace) currentTime() time.Time { | |||
| 134 | + | if st == nil || st.now == nil { | |||
| 135 | + | return time.Time{} | |||
| 136 | + | } | |||
| 137 | + | return st.now() | |||
| 138 | + | } | |||
| 139 | + | ||||
| 140 | + | func (st *startupTrace) markAt(name string, at time.Time) bool { | |||
| 141 | + | if st == nil || st.now == nil || at.IsZero() { | |||
| 142 | + | return false | |||
| 143 | + | } | |||
| 144 | + | st.mu.Lock() | |||
| 145 | + | defer st.mu.Unlock() | |||
| 146 | + | if at.Before(st.t0) || len(st.milestones) >= len(startupMilestoneOrder) || name != startupMilestoneOrder[len(st.milestones)] { | |||
| 147 | + | return false | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | milestone := startupMilestone{Name: name, Elapsed: at.Sub(st.t0)} | |||
| 151 | + | previousMilliseconds := int64(0) | |||
| 152 | + | if len(st.milestones) > 0 { | |||
| 153 | + | previousMilliseconds = st.milestones[len(st.milestones)-1].Elapsed.Milliseconds() | |||
| 154 | + | } | |||
| 155 | + | milestone.Delta = time.Duration(milestone.Elapsed.Milliseconds()-previousMilliseconds) * time.Millisecond | |||
| 156 | + | st.milestones = append(st.milestones, milestone) | |||
| 157 | + | if st.logger == nil { | |||
| 158 | + | st.buffered = append(st.buffered, milestone) | |||
| 159 | + | } else { | |||
| 160 | + | st.emitMilestone(milestone) | |||
| 161 | + | } | |||
| 162 | + | st.emitSummaryLocked() | |||
| 163 | + | return true | |||
| 164 | + | } | |||
| 165 | + | ||||
| 166 | + | func (st *startupTrace) emitMilestone(m startupMilestone) { | |||
| 167 | + | if st.logger == nil { | |||
| 168 | + | return | |||
| 169 | + | } | |||
| 170 | + | st.logger.Debug(). | |||
| 171 | + | Str("milestone", m.Name). | |||
| 172 | + | Int64("t_ms", m.Elapsed.Milliseconds()). | |||
| 173 | + | Int64("delta_ms", m.Delta.Milliseconds()). | |||
| 174 | + | Msg("startup_trace: milestone") | |||
| 175 | + | } | |||
| 176 | + | ||||
| 177 | + | func (st *startupTrace) emitSummaryLocked() { | |||
| 178 | + | if st.summaryEmitted || st.logger == nil || len(st.milestones) != len(startupMilestoneOrder) { | |||
| 179 | + | return | |||
| 180 | + | } | |||
| 181 | + | st.summaryEmitted = true | |||
| 182 | + | st.logger.Info(). | |||
| 183 | + | Str("backend", st.backend). | |||
| 184 | + | Str("incomplete_reason", st.incompleteReason). | |||
| 185 | + | Int64("total_ms", st.milestones[len(st.milestones)-1].Elapsed.Milliseconds()). | |||
| 186 | + | Interface("milestones", st.milestones). | |||
| 187 | + | Msg("startup_trace: first presentation") | |||
| 188 | + | } | |||
| 189 | + | ||||
| 190 | + | func (st *startupTrace) Enabled() bool { return st != nil && st.now != nil } | |||
| 191 | + | ||||
| 192 | + | func (st *startupTrace) TotalElapsed() time.Duration { | |||
| 193 | + | if st == nil || st.now == nil { | |||
| 194 | + | return 0 | |||
| 195 | + | } | |||
| 196 | + | st.mu.Lock() | |||
| 197 | + | defer st.mu.Unlock() | |||
| 198 | + | return st.now().Sub(st.t0) | |||
| 199 | + | } | |||
A internal/infrastructure/cef/startup_trace_test.go +116 -0
| @@ -0,0 +1,116 @@ | |||||
| 1 | + | package cef | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "testing" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | "github.com/rs/zerolog" | |||
| 9 | + | "github.com/stretchr/testify/require" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | func TestActivateStartupTraceSeedsNeutralProcessAndConfigTiming(t *testing.T) { | |||
| 13 | + | processStartupTrace.Lock() | |||
| 14 | + | previous := processStartupTrace.trace | |||
| 15 | + | processStartupTrace.trace = nil | |||
| 16 | + | processStartupTrace.Unlock() | |||
| 17 | + | t.Cleanup(func() { | |||
| 18 | + | processStartupTrace.Lock() | |||
| 19 | + | processStartupTrace.trace = previous | |||
| 20 | + | processStartupTrace.Unlock() | |||
| 21 | + | }) | |||
| 22 | + | ||||
| 23 | + | processEntry := time.Unix(100, 0) | |||
| 24 | + | configComplete := processEntry.Add(15 * time.Millisecond) | |||
| 25 | + | var output bytes.Buffer | |||
| 26 | + | logger := zerolog.New(&output).Level(zerolog.DebugLevel) | |||
| 27 | + | ||||
| 28 | + | ActivateStartupTrace(processEntry, configComplete, &logger) | |||
| 29 | + | trace := activeStartupTrace() | |||
| 30 | + | ||||
| 31 | + | require.NotNil(t, trace) | |||
| 32 | + | require.Equal(t, []string{"process_entry", "config_complete"}, []string{trace.milestones[0].Name, trace.milestones[1].Name}) | |||
| 33 | + | require.Equal(t, int64(0), trace.milestones[0].Elapsed.Milliseconds()) | |||
| 34 | + | require.Equal(t, int64(15), trace.milestones[1].Elapsed.Milliseconds()) | |||
| 35 | + | require.Contains(t, output.String(), `"milestone":"process_entry"`) | |||
| 36 | + | require.Contains(t, output.String(), `"milestone":"config_complete"`) | |||
| 37 | + | require.NotContains(t, output.String(), `"message":"startup_trace: first presentation"`) | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | func TestStartupTraceAcceptsOnlyOrderedOneShotMilestones(t *testing.T) { | |||
| 41 | + | now := time.Unix(100, 0) | |||
| 42 | + | trace := newStartupTrace(func() time.Time { return now }, now) | |||
| 43 | + | ||||
| 44 | + | require.True(t, trace.Mark("process_entry")) | |||
| 45 | + | require.False(t, trace.Mark("cef_initialized"), "out-of-order transition must be rejected") | |||
| 46 | + | require.False(t, trace.Mark("process_entry"), "duplicate transition must be rejected") | |||
| 47 | + | ||||
| 48 | + | for _, name := range []string{ | |||
| 49 | + | "config_complete", | |||
| 50 | + | "cef_library_load_begin", | |||
| 51 | + | "cef_initialized", | |||
| 52 | + | "browser_create_requested", | |||
| 53 | + | "first_accelerated_paint_received", | |||
| 54 | + | "first_dmabuf_texture_swap", | |||
| 55 | + | "first_gtk_presentation", | |||
| 56 | + | } { | |||
| 57 | + | now = now.Add(time.Millisecond) | |||
| 58 | + | if name == "first_gtk_presentation" { | |||
| 59 | + | require.Truef(t, trace.MarkGTKAfterPaint(), "milestone %s should be accepted", name) | |||
| 60 | + | } else { | |||
| 61 | + | require.Truef(t, trace.Mark(name), "milestone %s should be accepted", name) | |||
| 62 | + | } | |||
| 63 | + | } | |||
| 64 | + | require.False(t, trace.Mark("first_gtk_presentation")) | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | func TestStartupTracePreservesProcessAndConfigTimestamps(t *testing.T) { | |||
| 68 | + | processEntry := time.Unix(100, 0) | |||
| 69 | + | configComplete := processEntry.Add(1500 * time.Microsecond) | |||
| 70 | + | now := configComplete.Add(900 * time.Microsecond) | |||
| 71 | + | trace := newStartupTrace(func() time.Time { return now }, processEntry) | |||
| 72 | + | ||||
| 73 | + | require.True(t, trace.markAt("process_entry", processEntry)) | |||
| 74 | + | require.True(t, trace.markAt("config_complete", configComplete)) | |||
| 75 | + | ||||
| 76 | + | require.Equal(t, int64(0), trace.milestones[0].Elapsed.Milliseconds()) | |||
| 77 | + | require.Equal(t, int64(0), trace.milestones[0].Delta.Milliseconds()) | |||
| 78 | + | require.Equal(t, int64(1), trace.milestones[1].Elapsed.Milliseconds()) | |||
| 79 | + | require.Equal(t, int64(1), trace.milestones[1].Delta.Milliseconds()) | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | func TestStartupTraceReservesFirstGTKPresentationForAfterPaint(t *testing.T) { | |||
| 83 | + | now := time.Unix(100, 0) | |||
| 84 | + | trace := newStartupTrace(func() time.Time { return now }, now) | |||
| 85 | + | ||||
| 86 | + | for _, name := range startupMilestoneOrder[:len(startupMilestoneOrder)-1] { | |||
| 87 | + | now = now.Add(time.Millisecond) | |||
| 88 | + | require.True(t, trace.Mark(name)) | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | require.Len(t, trace.milestones, len(startupMilestoneOrder)-1) | |||
| 92 | + | require.False(t, trace.Mark("first_gtk_presentation"), "only the CEF-to-GTK after-paint hook can record the reserved milestone") | |||
| 93 | + | require.False(t, trace.summaryEmitted) | |||
| 94 | + | require.True(t, trace.MarkGTKAfterPaint(), "only the after-paint hook may record this milestone") | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | func TestStartupTraceEmitsOneNormalSummaryAtFirstPresentation(t *testing.T) { | |||
| 98 | + | var output bytes.Buffer | |||
| 99 | + | logger := zerolog.New(&output) | |||
| 100 | + | now := time.Unix(100, 0) | |||
| 101 | + | trace := newStartupTrace(func() time.Time { return now }, now) | |||
| 102 | + | trace.SetBackend("gdk-dmabuf") | |||
| 103 | + | trace.SetLogger(&logger) | |||
| 104 | + | ||||
| 105 | + | for _, name := range startupMilestoneOrder[:len(startupMilestoneOrder)-1] { | |||
| 106 | + | now = now.Add(time.Millisecond) | |||
| 107 | + | require.True(t, trace.Mark(name)) | |||
| 108 | + | } | |||
| 109 | + | now = now.Add(time.Millisecond) | |||
| 110 | + | require.True(t, trace.MarkGTKAfterPaint()) | |||
| 111 | + | trace.MarkGTKAfterPaint() | |||
| 112 | + | ||||
| 113 | + | require.Contains(t, output.String(), `"message":"startup_trace: first presentation"`) | |||
| 114 | + | require.Contains(t, output.String(), `"backend":"gdk-dmabuf"`) | |||
| 115 | + | require.Equal(t, 1, bytes.Count(output.Bytes(), []byte(`"message":"startup_trace: first presentation"`))) | |||
| 116 | + | } | |||
M internal/infrastructure/cef/webview.go +111 -33
| @@ -39,6 +39,10 @@ var ( | |||||
| 39 | // errDestroyed is returned when an operation is attempted on a destroyed WebView. | 39 | // errDestroyed is returned when an operation is attempted on a destroyed WebView. | ||
| 40 | var errDestroyed = errors.New("cef: webview is destroyed") | 40 | var errDestroyed = errors.New("cef: webview is destroyed") | ||
| 41 | 41 | ||||
| 42 | + | // unrefTickCallback is the canonical puregotk/purego callback-slot release. | |||
| 43 | + | // It remains injectable so lifecycle tests can prove exactly-once ownership. | |||
| 44 | + | var unrefTickCallback = glib.UnrefCallback | |||
| 45 | + | ||||
| 42 | // errNoBrowser is returned when the browser has not been created yet. | 46 | // errNoBrowser is returned when the browser has not been created yet. | ||
| 43 | var errNoBrowser = errors.New("cef: browser not yet created") | 47 | var errNoBrowser = errors.New("cef: browser not yet created") | ||
| 44 | 48 | ||||
| @@ -107,11 +111,19 @@ type WebView struct { | |||||
| 107 | viewportSyncReason string | 111 | viewportSyncReason string | ||
| 108 | viewportMapFunc func(gtk.Widget) | 112 | viewportMapFunc func(gtk.Widget) | ||
| 109 | viewportShowFunc func(gtk.Widget) | 113 | viewportShowFunc func(gtk.Widget) | ||
| 114 | + | viewportHideFunc func(gtk.Widget) | |||
| 115 | + | viewportUnmapFunc func(gtk.Widget) | |||
| 110 | viewportRealizeFunc func(gtk.Widget) | 116 | viewportRealizeFunc func(gtk.Widget) | ||
| 111 | viewportMapSignalID uint | 117 | viewportMapSignalID uint | ||
| 112 | viewportShowSignalID uint | 118 | viewportShowSignalID uint | ||
| 119 | + | viewportHideSignalID uint | |||
| 120 | + | viewportUnmapSignalID uint | |||
| 113 | viewportRealizeSignalID uint | 121 | viewportRealizeSignalID uint | ||
| 114 | viewportResizePulseSeq atomic.Uint64 | 122 | viewportResizePulseSeq atomic.Uint64 | ||
| 123 | + | // effectiveVisibility records the last CEF WasHidden state dispatched. It | |||
| 124 | + | // is guarded by mu because GTK and CEF UI work cross threads. | |||
| 125 | + | effectiveVisibilityKnown bool | |||
| 126 | + | effectiveVisible bool | |||
| 115 | 127 | ||||
| 116 | adaptiveWindowlessFrameRate bool | 128 | adaptiveWindowlessFrameRate bool | ||
| 117 | windowlessFrameRateMax int32 | 129 | windowlessFrameRateMax int32 | ||
| @@ -227,6 +239,18 @@ type WebView struct { | |||||
| 227 | backgroundColor uint32 | 239 | backgroundColor uint32 | ||
| 228 | inputConfig RuntimeInputConfig | 240 | inputConfig RuntimeInputConfig | ||
| 229 | 241 | ||||
| 242 | + | // Background flash guard: a theme-colored CSS provider painted behind the | |||
| 243 | + | // render widget until the first CEF frame is presented. It replaces the | |||
| 244 | + | // former dark BrowserSettings.BackgroundColor anti-white-flash, whose canvas | |||
| 245 | + | // is now always opaque white. State is guarded by bgGuardMu; firstFramePainted | |||
| 246 | + | // gates the one-shot teardown and resets naturally when a fresh WebView is | |||
| 247 | + | // created after a browser recreation. | |||
| 248 | + | firstFramePainted atomic.Bool | |||
| 249 | + | bgGuardMu sync.Mutex | |||
| 250 | + | bgGuardProvider *gtk.CssProvider | |||
| 251 | + | bgGuardWidget *gtk.Widget | |||
| 252 | + | bgGuardClass string | |||
| 253 | + | ||||
| 230 | // Touchpad input diagnostics are debounced to avoid flooding logs while | 254 | // Touchpad input diagnostics are debounced to avoid flooding logs while | ||
| 231 | // debugging continuous scroll streams. | 255 | // debugging continuous scroll streams. | ||
| 232 | touchpadNavigation *touchpadNavigationRecognizer | 256 | touchpadNavigation *touchpadNavigationRecognizer | ||
| @@ -238,16 +262,25 @@ type WebView struct { | |||||
| 238 | inputDiagDeltaXSum int64 | 262 | inputDiagDeltaXSum int64 | ||
| 239 | inputDiagDeltaYSum int64 | 263 | inputDiagDeltaYSum int64 | ||
| 240 | 264 | ||||
| 241 | - | // Audio output factory and active stream. | 265 | + | // Audio output lifecycle. Packet callbacks load audioStream directly: its |
| 242 | - | audioOutputFactory port.AudioOutputFactory | 266 | + | // immutable handle is atomically published and never requires a mutex. |
| 243 | - | audioStreamMu sync.Mutex | 267 | + | audioOutputFactory port.AudioOutputFactory |
| 244 | - | activeAudioStream port.AudioOutputStream | 268 | + | audioStreamMu sync.Mutex |
| 269 | + | audioStreamGeneration atomic.Uint64 | |||
| 270 | + | audioStream atomic.Pointer[audioStreamHandle] | |||
| 245 | 271 | ||||
| 246 | // Audio instrumentation counters (diagnostic only). | 272 | // Audio instrumentation counters (diagnostic only). | ||
| 247 | audioPacketCount atomic.Uint64 // total OnAudioStreamPacket calls | 273 | audioPacketCount atomic.Uint64 // total OnAudioStreamPacket calls | ||
| 248 | audioWriteCount atomic.Uint64 // successful Write calls to stream | 274 | audioWriteCount atomic.Uint64 // successful Write calls to stream | ||
| 249 | } | 275 | } | ||
| 250 | 276 | ||||
| 277 | + | // audioStreamHandle is immutable after publication so packet callbacks can | |||
| 278 | + | // safely snapshot it without coordinating with lifecycle operations. | |||
| 279 | + | type audioStreamHandle struct { | |||
| 280 | + | stream port.AudioOutputStream | |||
| 281 | + | generation uint64 | |||
| 282 | + | } | |||
| 283 | + | ||||
| 251 | // pendingBrowserCreate holds the parameters needed to create a CEF browser, | 284 | // pendingBrowserCreate holds the parameters needed to create a CEF browser, | ||
| 252 | // deferred until the GL area has a non-zero size. | 285 | // deferred until the GL area has a non-zero size. | ||
| 253 | type pendingBrowserCreate struct { | 286 | type pendingBrowserCreate struct { | ||
| @@ -971,7 +1004,7 @@ func (wv *WebView) prepareNativePopup( | |||||
| 971 | prep.parent.trackPendingNativePopup(popupID, wv) | 1004 | prep.parent.trackPendingNativePopup(popupID, wv) | ||
| 972 | } | 1005 | } | ||
| 973 | 1006 | ||||
| 974 | - | configureNativePopupWindow(windowInfo, settings, prep.frameRate, prep.backgroundColor) | 1007 | + | configureNativePopupWindow(windowInfo, settings, prep.frameRate) |
| 975 | clientSlot.Set(client) | 1008 | clientSlot.Set(client) | ||
| 976 | return true | 1009 | return true | ||
| 977 | } | 1010 | } | ||
| @@ -986,9 +1019,8 @@ func (wv *WebView) nativePopupPreparationSnapshot() (nativePopupPreparation, boo | |||||
| 986 | return nativePopupPreparation{}, false | 1019 | return nativePopupPreparation{}, false | ||
| 987 | } | 1020 | } | ||
| 988 | return nativePopupPreparation{ | 1021 | return nativePopupPreparation{ | ||
| 989 | - | parent: wv.nativePopupParent, | 1022 | + | parent: wv.nativePopupParent, |
| 990 | - | frameRate: wv.windowlessFrameRate, | 1023 | + | frameRate: wv.windowlessFrameRate, |
| 991 | - | backgroundColor: wv.backgroundColor, | |||
| 992 | }, true | 1024 | }, true | ||
| 993 | } | 1025 | } | ||
| 994 | 1026 | ||||
| @@ -1024,7 +1056,6 @@ func configureNativePopupWindow( | |||||
| 1024 | windowInfo *purecef.WindowInfo, | 1056 | windowInfo *purecef.WindowInfo, | ||
| 1025 | settings *purecef.BrowserSettings, | 1057 | settings *purecef.BrowserSettings, | ||
| 1026 | frameRate int32, | 1058 | frameRate int32, | ||
| 1027 | - | backgroundColor uint32, | |||
| 1028 | ) { | 1059 | ) { | ||
| 1029 | cef2gtk.ConfigureWindowInfo(windowInfo, cef2gtk.WindowInfoOptions{}) | 1060 | cef2gtk.ConfigureWindowInfo(windowInfo, cef2gtk.WindowInfoOptions{}) | ||
| 1030 | if externalBeginFrameEnabled() { | 1061 | if externalBeginFrameEnabled() { | ||
| @@ -1035,15 +1066,13 @@ func configureNativePopupWindow( | |||||
| 1035 | } | 1066 | } | ||
| 1036 | cef2gtk.ConfigureBrowserSettings(settings, cef2gtk.BrowserSettingsOptions{WindowlessFrameRate: frameRate}) | 1067 | cef2gtk.ConfigureBrowserSettings(settings, cef2gtk.BrowserSettingsOptions{WindowlessFrameRate: frameRate}) | ||
| 1037 | settings.LocalStorage = 1 | 1068 | settings.LocalStorage = 1 | ||
| 1038 | - | if backgroundColor != 0 { | 1069 | + | // Always opaque white; the popup shell WebView carries the theme flash guard. |
| 1039 | - | settings.BackgroundColor = backgroundColor | 1070 | + | settings.BackgroundColor = opaqueWhiteBackground |
| 1040 | - | } | |||
| 1041 | } | 1071 | } | ||
| 1042 | 1072 | ||||
| 1043 | type nativePopupPreparation struct { | 1073 | type nativePopupPreparation struct { | ||
| 1044 | - | parent *WebView | 1074 | + | parent *WebView |
| 1045 | - | frameRate int32 | 1075 | + | frameRate int32 |
| 1046 | - | backgroundColor uint32 | |||
| 1047 | } | 1076 | } | ||
| 1048 | 1077 | ||||
| 1049 | func (wv *WebView) handleNativePopupAborted() { | 1078 | func (wv *WebView) handleNativePopupAborted() { | ||
| @@ -1223,6 +1252,7 @@ func (wv *WebView) destroyViewBridgeOnGTKThread() { | |||||
| 1223 | if wv == nil || wv.viewBridge == nil { | 1252 | if wv == nil || wv.viewBridge == nil { | ||
| 1224 | return | 1253 | return | ||
| 1225 | } | 1254 | } | ||
| 1255 | + | wv.removeBackgroundFlashGuard() | |||
| 1226 | if wv.removeSizeObserver != nil { | 1256 | if wv.removeSizeObserver != nil { | ||
| 1227 | wv.removeSizeObserver() | 1257 | wv.removeSizeObserver() | ||
| 1228 | wv.removeSizeObserver = nil | 1258 | wv.removeSizeObserver = nil | ||
| @@ -2067,12 +2097,14 @@ func (wv *WebView) startBeginFrameLoop() { | |||||
| 2067 | cb := new(gtk.TickCallback) | 2097 | cb := new(gtk.TickCallback) | ||
| 2068 | *cb = func(_, _, _ uintptr) bool { | 2098 | *cb = func(_, _, _ uintptr) bool { | ||
| 2069 | if wv.destroyed.Load() { | 2099 | if wv.destroyed.Load() { | ||
| 2100 | + | wv.releaseBeginFrameTickCallback() | |||
| 2070 | return false | 2101 | return false | ||
| 2071 | } | 2102 | } | ||
| 2072 | wv.mu.RLock() | 2103 | wv.mu.RLock() | ||
| 2073 | host := wv.host | 2104 | host := wv.host | ||
| 2074 | wv.mu.RUnlock() | 2105 | wv.mu.RUnlock() | ||
| 2075 | if host == nil { | 2106 | if host == nil { | ||
| 2107 | + | wv.releaseBeginFrameTickCallback() | |||
| 2076 | return false | 2108 | return false | ||
| 2077 | } | 2109 | } | ||
| 2078 | host.SendExternalBeginFrame() | 2110 | host.SendExternalBeginFrame() | ||
| @@ -2095,19 +2127,34 @@ func (wv *WebView) startBeginFrameLoop() { | |||||
| 2095 | } | 2127 | } | ||
| 2096 | 2128 | ||||
| 2097 | func (wv *WebView) stopBeginFrameLoop() { | 2129 | func (wv *WebView) stopBeginFrameLoop() { | ||
| 2098 | - | if wv.viewBridge == nil || wv.viewBridge.Widget() == nil { | |||
| 2099 | - | return | |||
| 2100 | - | } | |||
| 2101 | - | ||||
| 2102 | wv.mu.Lock() | 2130 | wv.mu.Lock() | ||
| 2103 | tickID := wv.beginFrameTickID | 2131 | tickID := wv.beginFrameTickID | ||
| 2132 | + | callback := wv.beginFrameTick | |||
| 2104 | wv.beginFrameTickID = 0 | 2133 | wv.beginFrameTickID = 0 | ||
| 2105 | wv.beginFrameTick = nil | 2134 | wv.beginFrameTick = nil | ||
| 2106 | - | widget := wv.viewBridge.Widget() | 2135 | + | bridge := wv.viewBridge |
| 2107 | wv.mu.Unlock() | 2136 | wv.mu.Unlock() | ||
| 2108 | 2137 | ||||
| 2109 | - | if tickID != 0 { | 2138 | + | if tickID != 0 && bridge != nil { |
| 2110 | - | widget.RemoveTickCallback(tickID) | 2139 | + | if widget := bridge.Widget(); widget != nil { |
| 2140 | + | widget.RemoveTickCallback(tickID) | |||
| 2141 | + | } | |||
| 2142 | + | } | |||
| 2143 | + | if callback != nil { | |||
| 2144 | + | _ = unrefTickCallback(callback) | |||
| 2145 | + | } | |||
| 2146 | + | } | |||
| 2147 | + | ||||
| 2148 | + | // releaseBeginFrameTickCallback releases the purego callback slot after GTK | |||
| 2149 | + | // removes a source because its callback returned false. | |||
| 2150 | + | func (wv *WebView) releaseBeginFrameTickCallback() { | |||
| 2151 | + | wv.mu.Lock() | |||
| 2152 | + | callback := wv.beginFrameTick | |||
| 2153 | + | wv.beginFrameTickID = 0 | |||
| 2154 | + | wv.beginFrameTick = nil | |||
| 2155 | + | wv.mu.Unlock() | |||
| 2156 | + | if callback != nil { | |||
| 2157 | + | _ = unrefTickCallback(callback) | |||
| 2111 | } | 2158 | } | ||
| 2112 | } | 2159 | } | ||
| 2113 | 2160 | ||||
| @@ -2302,20 +2349,51 @@ func (wv *WebView) takePendingCreate() *pendingBrowserCreate { | |||||
| 2302 | return pc | 2349 | return pc | ||
| 2303 | } | 2350 | } | ||
| 2304 | 2351 | ||||
| 2305 | - | // closeAudioStream closes and clears the active audio output stream. | 2352 | + | // beginAudioStreamStart invalidates any prior stream before creating a new |
| 2306 | - | // This is safe to call even if no stream is active. | 2353 | + | // one. A stream created for the returned generation may only be published if |
| 2307 | - | func (wv *WebView) closeAudioStream() { | 2354 | + | // that generation is still current. |
| 2355 | + | func (wv *WebView) beginAudioStreamStart() (uint64, *audioStreamHandle, bool) { | |||
| 2308 | wv.audioStreamMu.Lock() | 2356 | wv.audioStreamMu.Lock() | ||
| 2309 | defer wv.audioStreamMu.Unlock() | 2357 | defer wv.audioStreamMu.Unlock() | ||
| 2358 | + | if wv.destroyed.Load() { | |||
| 2359 | + | return 0, nil, false | |||
| 2360 | + | } | |||
| 2361 | + | generation := wv.audioStreamGeneration.Add(1) | |||
| 2362 | + | previous := wv.audioStream.Swap(nil) | |||
| 2363 | + | wv.audioPlaying.Store(false) | |||
| 2364 | + | return generation, previous, true | |||
| 2365 | + | } | |||
| 2310 | 2366 | ||||
| 2311 | - | if wv.activeAudioStream != nil { | 2367 | + | // publishAudioStream publishes stream only if no Stop, Destroy, or newer Start |
| 2312 | - | if err := wv.activeAudioStream.Close(); err != nil { | 2368 | + | // invalidated its creation generation. A rejected stream is owned by the |
| 2313 | - | if wv.ctx != nil { | 2369 | + | // caller and must be closed by it. |
| 2314 | - | logging.FromContext(wv.ctx).Debug(). | 2370 | + | func (wv *WebView) publishAudioStream(generation uint64, stream port.AudioOutputStream) (*audioStreamHandle, bool) { |
| 2315 | - | Err(err). | 2371 | + | wv.audioStreamMu.Lock() |
| 2316 | - | Msg("cef: error closing audio stream") | 2372 | + | defer wv.audioStreamMu.Unlock() |
| 2317 | - | } | 2373 | + | if wv.destroyed.Load() || wv.audioStreamGeneration.Load() != generation { |
| 2374 | + | return nil, false | |||
| 2375 | + | } | |||
| 2376 | + | previous := wv.audioStream.Swap(&audioStreamHandle{stream: stream, generation: generation}) | |||
| 2377 | + | wv.audioPlaying.Store(true) | |||
| 2378 | + | return previous, true | |||
| 2379 | + | } | |||
| 2380 | + | ||||
| 2381 | + | // closeAudioStream invalidates creation in progress, atomically detaches the | |||
| 2382 | + | // published handle, then closes it outside the lifecycle mutex. The port | |||
| 2383 | + | // contract permits Close concurrently with a packet Write. | |||
| 2384 | + | func (wv *WebView) closeAudioStream() { | |||
| 2385 | + | wv.audioStreamMu.Lock() | |||
| 2386 | + | wv.audioStreamGeneration.Add(1) | |||
| 2387 | + | stream := wv.audioStream.Swap(nil) | |||
| 2388 | + | wv.audioPlaying.Store(false) | |||
| 2389 | + | wv.audioStreamMu.Unlock() | |||
| 2390 | + | wv.closeDetachedAudioStream(stream) | |||
| 2391 | + | } | |||
| 2392 | + | ||||
| 2393 | + | func (wv *WebView) closeDetachedAudioStream(handle *audioStreamHandle) { | |||
| 2394 | + | if handle != nil && handle.stream != nil { | |||
| 2395 | + | if err := handle.stream.Close(); err != nil && wv.ctx != nil { | |||
| 2396 | + | logging.FromContext(wv.ctx).Debug().Err(err).Msg("cef: error closing audio stream") | |||
| 2318 | } | 2397 | } | ||
| 2319 | - | wv.activeAudioStream = nil | |||
| 2320 | } | 2398 | } | ||
| 2321 | } | 2399 | } | ||
M internal/infrastructure/cef/webview_audio_test.go +141 -58
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "bytes" | 4 | "bytes" | ||
| 5 | "context" | 5 | "context" | ||
| 6 | "errors" | 6 | "errors" | ||
| 7 | + | "sync/atomic" | |||
| 7 | "testing" | 8 | "testing" | ||
| 8 | "time" | 9 | "time" | ||
| 9 | 10 | ||||
| @@ -83,10 +84,10 @@ func TestWebView_AudioStreamLifecycleFieldsExist(t *testing.T) { | |||||
| 83 | } | 84 | } | ||
| 84 | 85 | ||||
| 85 | // Verify the fields exist and can be set | 86 | // Verify the fields exist and can be set | ||
| 86 | - | wv.activeAudioStream = &mockAudioStream{} | 87 | + | setAudioStream(wv, &mockAudioStream{}) |
| 87 | 88 | ||||
| 88 | assert.NotNil(t, wv.audioOutputFactory) | 89 | assert.NotNil(t, wv.audioOutputFactory) | ||
| 89 | - | assert.NotNil(t, wv.activeAudioStream) | 90 | + | assert.NotNil(t, activeAudioStream(wv)) |
| 90 | } | 91 | } | ||
| 91 | 92 | ||||
| 92 | // mockAudioStream is a test double for port.AudioOutputStream | 93 | // mockAudioStream is a test double for port.AudioOutputStream | ||
| @@ -110,6 +111,18 @@ func (m *mockAudioStream) Close() error { | |||||
| 110 | // Verify mock implements the interface | 111 | // Verify mock implements the interface | ||
| 111 | var _ port.AudioOutputStream = (*mockAudioStream)(nil) | 112 | var _ port.AudioOutputStream = (*mockAudioStream)(nil) | ||
| 112 | 113 | ||||
| 114 | + | func setAudioStream(wv *WebView, stream port.AudioOutputStream) { | |||
| 115 | + | wv.audioStream.Store(&audioStreamHandle{stream: stream, generation: wv.audioStreamGeneration.Load()}) | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | func activeAudioStream(wv *WebView) port.AudioOutputStream { | |||
| 119 | + | handle := wv.audioStream.Load() | |||
| 120 | + | if handle == nil { | |||
| 121 | + | return nil | |||
| 122 | + | } | |||
| 123 | + | return handle.stream | |||
| 124 | + | } | |||
| 125 | + | ||||
| 113 | // racyMockAudioStream blocks inside Write until doneCh is closed, allowing | 126 | // racyMockAudioStream blocks inside Write until doneCh is closed, allowing | ||
| 114 | // tests to verify that the caller holds a lock across the Write call. | 127 | // tests to verify that the caller holds a lock across the Write call. | ||
| 115 | type racyMockAudioStream struct { | 128 | type racyMockAudioStream struct { | ||
| @@ -131,6 +144,26 @@ func (m *racyMockAudioStream) Close() error { | |||||
| 131 | 144 | ||||
| 132 | var _ port.AudioOutputStream = (*racyMockAudioStream)(nil) | 145 | var _ port.AudioOutputStream = (*racyMockAudioStream)(nil) | ||
| 133 | 146 | ||||
| 147 | + | type countedAudioStream struct{ closes atomic.Int32 } | |||
| 148 | + | ||||
| 149 | + | func (*countedAudioStream) Write([][]float32) error { return nil } | |||
| 150 | + | func (s *countedAudioStream) Close() error { | |||
| 151 | + | s.closes.Add(1) | |||
| 152 | + | return nil | |||
| 153 | + | } | |||
| 154 | + | ||||
| 155 | + | type controlledAudioFactory struct { | |||
| 156 | + | entered chan struct{} | |||
| 157 | + | release chan struct{} | |||
| 158 | + | stream port.AudioOutputStream | |||
| 159 | + | } | |||
| 160 | + | ||||
| 161 | + | func (f *controlledAudioFactory) NewStream(context.Context, port.AudioStreamFormat) (port.AudioOutputStream, error) { | |||
| 162 | + | close(f.entered) | |||
| 163 | + | <-f.release | |||
| 164 | + | return f.stream, nil | |||
| 165 | + | } | |||
| 166 | + | ||||
| 134 | // ============================================================================ | 167 | // ============================================================================ | ||
| 135 | // Task 6: Audio Stream Lifecycle Handling Tests | 168 | // Task 6: Audio Stream Lifecycle Handling Tests | ||
| 136 | // ============================================================================ | 169 | // ============================================================================ | ||
| @@ -159,8 +192,8 @@ func TestHandlerSet_OnAudioStreamStarted_CreatesStream(t *testing.T) { | |||||
| 159 | handlers.OnAudioStreamStarted(nil, params, 2) | 192 | handlers.OnAudioStreamStarted(nil, params, 2) | ||
| 160 | 193 | ||||
| 161 | // Assert | 194 | // Assert | ||
| 162 | - | require.NotNil(t, wv.activeAudioStream) | 195 | + | require.NotNil(t, activeAudioStream(wv)) |
| 163 | - | assert.Equal(t, mockStream, wv.activeAudioStream) | 196 | + | assert.Equal(t, mockStream, activeAudioStream(wv)) |
| 164 | assert.True(t, wv.audioPlaying.Load()) | 197 | assert.True(t, wv.audioPlaying.Load()) | ||
| 165 | 198 | ||||
| 166 | // Verify factory was called with correct format | 199 | // Verify factory was called with correct format | ||
| @@ -205,8 +238,8 @@ func TestHandlerSet_OnAudioStreamStarted_ClosesExistingStream(t *testing.T) { | |||||
| 205 | wv := &WebView{ | 238 | wv := &WebView{ | ||
| 206 | ctx: ctx, | 239 | ctx: ctx, | ||
| 207 | audioOutputFactory: mockFactory, | 240 | audioOutputFactory: mockFactory, | ||
| 208 | - | activeAudioStream: oldStream, | |||
| 209 | } | 241 | } | ||
| 242 | + | setAudioStream(wv, oldStream) | |||
| 210 | 243 | ||||
| 211 | handlers := &handlerSet{wv: wv} | 244 | handlers := &handlerSet{wv: wv} | ||
| 212 | 245 | ||||
| @@ -221,7 +254,7 @@ func TestHandlerSet_OnAudioStreamStarted_ClosesExistingStream(t *testing.T) { | |||||
| 221 | 254 | ||||
| 222 | // Assert | 255 | // Assert | ||
| 223 | assert.True(t, oldStream.closeCalled) | 256 | assert.True(t, oldStream.closeCalled) | ||
| 224 | - | assert.Equal(t, newStream, wv.activeAudioStream) | 257 | + | assert.Equal(t, newStream, activeAudioStream(wv)) |
| 225 | } | 258 | } | ||
| 226 | 259 | ||||
| 227 | // TestHandlerSet_OnAudioStreamStarted_FactoryError_HandlesGracefully verifies | 260 | // TestHandlerSet_OnAudioStreamStarted_FactoryError_HandlesGracefully verifies | ||
| @@ -251,23 +284,23 @@ func TestHandlerSet_OnAudioStreamStarted_FactoryError_HandlesGracefully(t *testi | |||||
| 251 | // audioPlaying must be false when NewStream fails — if it stays true, | 284 | // audioPlaying must be false when NewStream fails — if it stays true, | ||
| 252 | // the UI/state layer incorrectly believes audio is active. | 285 | // the UI/state layer incorrectly believes audio is active. | ||
| 253 | assert.False(t, wv.audioPlaying.Load(), "audioPlaying must be false after NewStream failure") | 286 | assert.False(t, wv.audioPlaying.Load(), "audioPlaying must be false after NewStream failure") | ||
| 254 | - | assert.Nil(t, wv.activeAudioStream, "activeAudioStream must be nil after NewStream failure") | 287 | + | assert.Nil(t, activeAudioStream(wv), "activeAudioStream must be nil after NewStream failure") |
| 255 | } | 288 | } | ||
| 256 | 289 | ||||
| 257 | // ============================================================================ | 290 | // ============================================================================ | ||
| 258 | // Task 7: PCM Packet Forwarding Tests | 291 | // Task 7: PCM Packet Forwarding Tests | ||
| 259 | // ============================================================================ | 292 | // ============================================================================ | ||
| 260 | 293 | ||||
| 261 | - | // TestHandlerSet_OnAudioStreamPacket_CopiesAndWrites verifies that | 294 | + | // TestHandlerSet_OnAudioStreamPacket_ForwardsCallbackData verifies that CEF |
| 262 | - | // OnAudioStreamPacket copies samples before writing to avoid data races. | 295 | + | // does not allocate a staging copy; the audio output port owns that handoff. |
| 263 | - | func TestHandlerSet_OnAudioStreamPacket_CopiesAndWrites(t *testing.T) { | 296 | + | func TestHandlerSet_OnAudioStreamPacket_ForwardsCallbackData(t *testing.T) { |
| 264 | ctx := context.Background() | 297 | ctx := context.Background() | ||
| 265 | mockStream := &mockAudioStream{} | 298 | mockStream := &mockAudioStream{} | ||
| 266 | 299 | ||||
| 267 | wv := &WebView{ | 300 | wv := &WebView{ | ||
| 268 | - | ctx: ctx, | 301 | + | ctx: ctx, |
| 269 | - | activeAudioStream: mockStream, | |||
| 270 | } | 302 | } | ||
| 303 | + | setAudioStream(wv, mockStream) | |||
| 271 | 304 | ||||
| 272 | handlers := &handlerSet{wv: wv} | 305 | handlers := &handlerSet{wv: wv} | ||
| 273 | 306 | ||||
| @@ -287,20 +320,16 @@ func TestHandlerSet_OnAudioStreamPacket_CopiesAndWrites(t *testing.T) { | |||||
| 287 | require.Len(t, mockStream.writeSamples, 2) | 320 | require.Len(t, mockStream.writeSamples, 2) | ||
| 288 | require.Len(t, mockStream.writeSamples[0], 512) | 321 | require.Len(t, mockStream.writeSamples[0], 512) | ||
| 289 | 322 | ||||
| 290 | - | // Verify data was copied (values match) | |||
| 291 | assert.InDelta(t, 0.5, mockStream.writeSamples[0][0], 0.000001) | 323 | assert.InDelta(t, 0.5, mockStream.writeSamples[0][0], 0.000001) | ||
| 292 | assert.InDelta(t, -0.5, mockStream.writeSamples[1][0], 0.000001) | 324 | assert.InDelta(t, -0.5, mockStream.writeSamples[1][0], 0.000001) | ||
| 293 | - | 325 | + | assert.Same(t, &originalData[0][0], &mockStream.writeSamples[0][0]) | |
| 294 | - | // Verify it's a copy (different underlying array) | |||
| 295 | - | assert.NotSame(t, &originalData[0][0], &mockStream.writeSamples[0][0]) | |||
| 296 | } | 326 | } | ||
| 297 | 327 | ||||
| 298 | // TestHandlerSet_OnAudioStreamPacket_NoActiveStream_DoesNothing verifies | 328 | // TestHandlerSet_OnAudioStreamPacket_NoActiveStream_DoesNothing verifies | ||
| 299 | // that packets are silently discarded when no stream is active. | 329 | // that packets are silently discarded when no stream is active. | ||
| 300 | func TestHandlerSet_OnAudioStreamPacket_NoActiveStream_DoesNothing(t *testing.T) { | 330 | func TestHandlerSet_OnAudioStreamPacket_NoActiveStream_DoesNothing(t *testing.T) { | ||
| 301 | wv := &WebView{ | 331 | wv := &WebView{ | ||
| 302 | - | ctx: context.Background(), | 332 | + | ctx: context.Background(), |
| 303 | - | activeAudioStream: nil, | |||
| 304 | } | 333 | } | ||
| 305 | 334 | ||||
| 306 | handlers := &handlerSet{wv: wv} | 335 | handlers := &handlerSet{wv: wv} | ||
| @@ -319,10 +348,8 @@ func TestHandlerSet_OnAudioStreamPacket_NoActiveStream_DoesNothing(t *testing.T) | |||||
| 319 | // TestHandlerSet_OnAudioStreamPacket_NilData_DoesNotPanic verifies | 348 | // TestHandlerSet_OnAudioStreamPacket_NilData_DoesNotPanic verifies | ||
| 320 | // that nil data is handled gracefully. | 349 | // that nil data is handled gracefully. | ||
| 321 | func TestHandlerSet_OnAudioStreamPacket_NilData_DoesNotPanic(t *testing.T) { | 350 | func TestHandlerSet_OnAudioStreamPacket_NilData_DoesNotPanic(t *testing.T) { | ||
| 322 | - | mockStream := &mockAudioStream{} | |||
| 323 | wv := &WebView{ | 351 | wv := &WebView{ | ||
| 324 | - | ctx: context.Background(), | 352 | + | ctx: context.Background(), |
| 325 | - | activeAudioStream: mockStream, | |||
| 326 | } | 353 | } | ||
| 327 | 354 | ||||
| 328 | handlers := &handlerSet{wv: wv} | 355 | handlers := &handlerSet{wv: wv} | ||
| @@ -333,6 +360,19 @@ func TestHandlerSet_OnAudioStreamPacket_NilData_DoesNotPanic(t *testing.T) { | |||||
| 333 | }) | 360 | }) | ||
| 334 | } | 361 | } | ||
| 335 | 362 | ||||
| 363 | + | func BenchmarkHandlerSet_OnAudioStreamPacketStereo(b *testing.B) { | |||
| 364 | + | stream := &mockAudioStream{} | |||
| 365 | + | wv := &WebView{ctx: context.Background()} | |||
| 366 | + | setAudioStream(wv, stream) | |||
| 367 | + | handlers := &handlerSet{wv: wv} | |||
| 368 | + | data := [][]float32{make([]float32, 512), make([]float32, 512)} | |||
| 369 | + | b.ReportAllocs() | |||
| 370 | + | b.SetBytes(2 * 512 * 4) | |||
| 371 | + | for i := 0; i < b.N; i++ { | |||
| 372 | + | handlers.OnAudioStreamPacket(nil, data, 512, 0) | |||
| 373 | + | } | |||
| 374 | + | } | |||
| 375 | + | ||||
| 336 | // ============================================================================ | 376 | // ============================================================================ | ||
| 337 | // Task 8: Stream Cleanup Tests | 377 | // Task 8: Stream Cleanup Tests | ||
| 338 | // ============================================================================ | 378 | // ============================================================================ | ||
| @@ -342,9 +382,9 @@ func TestHandlerSet_OnAudioStreamPacket_NilData_DoesNotPanic(t *testing.T) { | |||||
| 342 | func TestHandlerSet_OnAudioStreamStopped_ClosesStream(t *testing.T) { | 382 | func TestHandlerSet_OnAudioStreamStopped_ClosesStream(t *testing.T) { | ||
| 343 | mockStream := &mockAudioStream{} | 383 | mockStream := &mockAudioStream{} | ||
| 344 | wv := &WebView{ | 384 | wv := &WebView{ | ||
| 345 | - | ctx: context.Background(), | 385 | + | ctx: context.Background(), |
| 346 | - | activeAudioStream: mockStream, | |||
| 347 | } | 386 | } | ||
| 387 | + | setAudioStream(wv, mockStream) | |||
| 348 | wv.audioPlaying.Store(true) | 388 | wv.audioPlaying.Store(true) | ||
| 349 | 389 | ||||
| 350 | handlers := &handlerSet{wv: wv} | 390 | handlers := &handlerSet{wv: wv} | ||
| @@ -354,7 +394,7 @@ func TestHandlerSet_OnAudioStreamStopped_ClosesStream(t *testing.T) { | |||||
| 354 | 394 | ||||
| 355 | // Assert | 395 | // Assert | ||
| 356 | assert.True(t, mockStream.closeCalled) | 396 | assert.True(t, mockStream.closeCalled) | ||
| 357 | - | assert.Nil(t, wv.activeAudioStream) | 397 | + | assert.Nil(t, activeAudioStream(wv)) |
| 358 | assert.False(t, wv.audioPlaying.Load()) | 398 | assert.False(t, wv.audioPlaying.Load()) | ||
| 359 | } | 399 | } | ||
| 360 | 400 | ||||
| @@ -362,8 +402,7 @@ func TestHandlerSet_OnAudioStreamStopped_ClosesStream(t *testing.T) { | |||||
| 362 | // that stopping when no stream exists doesn't panic. | 402 | // that stopping when no stream exists doesn't panic. | ||
| 363 | func TestHandlerSet_OnAudioStreamStopped_NoActiveStream_DoesNotPanic(t *testing.T) { | 403 | func TestHandlerSet_OnAudioStreamStopped_NoActiveStream_DoesNotPanic(t *testing.T) { | ||
| 364 | wv := &WebView{ | 404 | wv := &WebView{ | ||
| 365 | - | ctx: context.Background(), | 405 | + | ctx: context.Background(), |
| 366 | - | activeAudioStream: nil, | |||
| 367 | } | 406 | } | ||
| 368 | wv.audioPlaying.Store(true) | 407 | wv.audioPlaying.Store(true) | ||
| 369 | 408 | ||||
| @@ -382,9 +421,9 @@ func TestHandlerSet_OnAudioStreamStopped_NoActiveStream_DoesNotPanic(t *testing. | |||||
| 382 | func TestHandlerSet_OnAudioStreamError_ClosesStream(t *testing.T) { | 421 | func TestHandlerSet_OnAudioStreamError_ClosesStream(t *testing.T) { | ||
| 383 | mockStream := &mockAudioStream{} | 422 | mockStream := &mockAudioStream{} | ||
| 384 | wv := &WebView{ | 423 | wv := &WebView{ | ||
| 385 | - | ctx: context.Background(), | 424 | + | ctx: context.Background(), |
| 386 | - | activeAudioStream: mockStream, | |||
| 387 | } | 425 | } | ||
| 426 | + | setAudioStream(wv, mockStream) | |||
| 388 | wv.audioPlaying.Store(true) | 427 | wv.audioPlaying.Store(true) | ||
| 389 | 428 | ||||
| 390 | handlers := &handlerSet{wv: wv} | 429 | handlers := &handlerSet{wv: wv} | ||
| @@ -394,7 +433,7 @@ func TestHandlerSet_OnAudioStreamError_ClosesStream(t *testing.T) { | |||||
| 394 | 433 | ||||
| 395 | // Assert | 434 | // Assert | ||
| 396 | assert.True(t, mockStream.closeCalled) | 435 | assert.True(t, mockStream.closeCalled) | ||
| 397 | - | assert.Nil(t, wv.activeAudioStream) | 436 | + | assert.Nil(t, activeAudioStream(wv)) |
| 398 | assert.False(t, wv.audioPlaying.Load()) | 437 | assert.False(t, wv.audioPlaying.Load()) | ||
| 399 | } | 438 | } | ||
| 400 | 439 | ||||
| @@ -402,8 +441,7 @@ func TestHandlerSet_OnAudioStreamError_ClosesStream(t *testing.T) { | |||||
| 402 | // that error handling when no stream exists doesn't panic. | 441 | // that error handling when no stream exists doesn't panic. | ||
| 403 | func TestHandlerSet_OnAudioStreamError_NoActiveStream_DoesNotPanic(t *testing.T) { | 442 | func TestHandlerSet_OnAudioStreamError_NoActiveStream_DoesNotPanic(t *testing.T) { | ||
| 404 | wv := &WebView{ | 443 | wv := &WebView{ | ||
| 405 | - | ctx: context.Background(), | 444 | + | ctx: context.Background(), |
| 406 | - | activeAudioStream: nil, | |||
| 407 | } | 445 | } | ||
| 408 | wv.audioPlaying.Store(true) | 446 | wv.audioPlaying.Store(true) | ||
| 409 | 447 | ||||
| @@ -456,9 +494,9 @@ func TestHandlerSet_OnAudioStreamError_ShutdownSocketCloseLogsDebug(t *testing.T | |||||
| 456 | func TestWebView_Destroy_ClosesAudioStream(t *testing.T) { | 494 | func TestWebView_Destroy_ClosesAudioStream(t *testing.T) { | ||
| 457 | mockStream := &mockAudioStream{} | 495 | mockStream := &mockAudioStream{} | ||
| 458 | wv := &WebView{ | 496 | wv := &WebView{ | ||
| 459 | - | ctx: context.Background(), | 497 | + | ctx: context.Background(), |
| 460 | - | activeAudioStream: mockStream, | |||
| 461 | } | 498 | } | ||
| 499 | + | setAudioStream(wv, mockStream) | |||
| 462 | wv.audioPlaying.Store(true) | 500 | wv.audioPlaying.Store(true) | ||
| 463 | 501 | ||||
| 464 | // Act — Destroy must close the audio stream as part of teardown. | 502 | // Act — Destroy must close the audio stream as part of teardown. | ||
| @@ -466,7 +504,7 @@ func TestWebView_Destroy_ClosesAudioStream(t *testing.T) { | |||||
| 466 | 504 | ||||
| 467 | // Assert | 505 | // Assert | ||
| 468 | assert.True(t, mockStream.closeCalled, "Destroy must close the active audio stream") | 506 | assert.True(t, mockStream.closeCalled, "Destroy must close the active audio stream") | ||
| 469 | - | assert.Nil(t, wv.activeAudioStream) | 507 | + | assert.Nil(t, activeAudioStream(wv)) |
| 470 | } | 508 | } | ||
| 471 | 509 | ||||
| 472 | // TestWebView_Destroy_NoAudioStream_DoesNotPanic verifies that Destroy | 510 | // TestWebView_Destroy_NoAudioStream_DoesNotPanic verifies that Destroy | ||
| @@ -558,28 +596,81 @@ func TestOnAudioStreamStarted_CorrectParameterMapping(t *testing.T) { | |||||
| 558 | assert.Equal(t, 1024, mockFactory.lastFormat.FramesPerBuffer, "frames_per_buffer should be 1024 (from params.FramesPerBuffer)") | 596 | assert.Equal(t, 1024, mockFactory.lastFormat.FramesPerBuffer, "frames_per_buffer should be 1024 (from params.FramesPerBuffer)") | ||
| 559 | } | 597 | } | ||
| 560 | 598 | ||||
| 599 | + | func TestHandlerSet_OnAudioStreamStarted_StopRejectsInFlightCreation(t *testing.T) { | |||
| 600 | + | stream := &countedAudioStream{} | |||
| 601 | + | factory := &controlledAudioFactory{entered: make(chan struct{}), release: make(chan struct{}), stream: stream} | |||
| 602 | + | wv := &WebView{ctx: context.Background(), audioOutputFactory: factory} | |||
| 603 | + | handlers := &handlerSet{wv: wv} | |||
| 604 | + | params := &purecef.AudioParameters{SampleRate: 48000, FramesPerBuffer: 512} | |||
| 605 | + | done := make(chan struct{}) | |||
| 606 | + | go func() { handlers.OnAudioStreamStarted(nil, params, 2); close(done) }() | |||
| 607 | + | <-factory.entered | |||
| 608 | + | handlers.OnAudioStreamStopped(nil) | |||
| 609 | + | close(factory.release) | |||
| 610 | + | <-done | |||
| 611 | + | ||||
| 612 | + | assert.Nil(t, activeAudioStream(wv)) | |||
| 613 | + | assert.False(t, wv.audioPlaying.Load()) | |||
| 614 | + | assert.Equal(t, int32(1), stream.closes.Load()) | |||
| 615 | + | } | |||
| 616 | + | ||||
| 617 | + | func TestHandlerSet_OnAudioStreamStarted_DestroyRejectsInFlightCreation(t *testing.T) { | |||
| 618 | + | stream := &countedAudioStream{} | |||
| 619 | + | factory := &controlledAudioFactory{entered: make(chan struct{}), release: make(chan struct{}), stream: stream} | |||
| 620 | + | wv := &WebView{ctx: context.Background(), audioOutputFactory: factory} | |||
| 621 | + | handlers := &handlerSet{wv: wv} | |||
| 622 | + | params := &purecef.AudioParameters{SampleRate: 48000, FramesPerBuffer: 512} | |||
| 623 | + | done := make(chan struct{}) | |||
| 624 | + | go func() { handlers.OnAudioStreamStarted(nil, params, 2); close(done) }() | |||
| 625 | + | <-factory.entered | |||
| 626 | + | wv.Destroy() | |||
| 627 | + | close(factory.release) | |||
| 628 | + | <-done | |||
| 629 | + | ||||
| 630 | + | assert.Nil(t, activeAudioStream(wv)) | |||
| 631 | + | assert.True(t, wv.destroyed.Load()) | |||
| 632 | + | assert.Equal(t, int32(1), stream.closes.Load()) | |||
| 633 | + | } | |||
| 634 | + | ||||
| 635 | + | func TestHandlerSet_OnAudioStreamStarted_ReplacesAndClosesEachStreamOnce(t *testing.T) { | |||
| 636 | + | old, first, second := &countedAudioStream{}, &countedAudioStream{}, &countedAudioStream{} | |||
| 637 | + | factory := &mockAudioFactory{stream: first} | |||
| 638 | + | wv := &WebView{ctx: context.Background(), audioOutputFactory: factory} | |||
| 639 | + | setAudioStream(wv, old) | |||
| 640 | + | handlers := &handlerSet{wv: wv} | |||
| 641 | + | params := &purecef.AudioParameters{SampleRate: 48000, FramesPerBuffer: 512} | |||
| 642 | + | ||||
| 643 | + | handlers.OnAudioStreamStarted(nil, params, 2) | |||
| 644 | + | factory.stream = second | |||
| 645 | + | handlers.OnAudioStreamStarted(nil, params, 2) | |||
| 646 | + | wv.closeAudioStream() | |||
| 647 | + | ||||
| 648 | + | assert.Equal(t, int32(1), old.closes.Load()) | |||
| 649 | + | assert.Equal(t, int32(1), first.closes.Load()) | |||
| 650 | + | assert.Equal(t, int32(1), second.closes.Load()) | |||
| 651 | + | assert.Nil(t, activeAudioStream(wv)) | |||
| 652 | + | } | |||
| 653 | + | ||||
| 561 | // ============================================================================ | 654 | // ============================================================================ | ||
| 562 | // Race-window tests (write/close interleave) | 655 | // Race-window tests (write/close interleave) | ||
| 563 | // ============================================================================ | 656 | // ============================================================================ | ||
| 564 | 657 | ||||
| 565 | - | // TestOnAudioStreamPacket_WriteHoldsLock verifies that closeAudioStream cannot | 658 | + | // TestOnAudioStreamPacket_CloseDoesNotWaitForWrite verifies that lifecycle |
| 566 | - | // interleave between the stream snapshot and Write. If the lock is NOT held | 659 | + | // shutdown is not serialized behind a potentially slow callback write. The |
| 567 | - | // across Write, the goroutine calling closeAudioStream will Close the stream | 660 | + | // port contract makes Close/Write concurrency safe. |
| 568 | - | // while Write is in-flight, producing a write-after-close. | 661 | + | func TestOnAudioStreamPacket_CloseDoesNotWaitForWrite(t *testing.T) { |
| 569 | - | func TestOnAudioStreamPacket_WriteHoldsLock(t *testing.T) { | |||
| 570 | ctx := context.Background() | 662 | ctx := context.Background() | ||
| 571 | 663 | ||||
| 572 | - | // racyStream blocks inside Write until we signal, giving closeAudioStream | 664 | + | // racyStream blocks inside Write until we signal. |
| 573 | - | // a window to race if the mutex isn't held. | |||
| 574 | stream := &racyMockAudioStream{ | 665 | stream := &racyMockAudioStream{ | ||
| 575 | writeCh: make(chan struct{}), | 666 | writeCh: make(chan struct{}), | ||
| 576 | doneCh: make(chan struct{}), | 667 | doneCh: make(chan struct{}), | ||
| 577 | } | 668 | } | ||
| 578 | 669 | ||||
| 579 | wv := &WebView{ | 670 | wv := &WebView{ | ||
| 580 | - | ctx: ctx, | 671 | + | ctx: ctx, |
| 581 | - | activeAudioStream: stream, | |||
| 582 | } | 672 | } | ||
| 673 | + | setAudioStream(wv, stream) | |||
| 583 | handlers := &handlerSet{wv: wv} | 674 | handlers := &handlerSet{wv: wv} | ||
| 584 | 675 | ||||
| 585 | data := [][]float32{{0.1, 0.2}, {0.3, 0.4}} | 676 | data := [][]float32{{0.1, 0.2}, {0.3, 0.4}} | ||
| @@ -590,32 +681,24 @@ func TestOnAudioStreamPacket_WriteHoldsLock(t *testing.T) { | |||||
| 590 | // Wait for Write to be entered. | 681 | // Wait for Write to be entered. | ||
| 591 | <-stream.writeCh | 682 | <-stream.writeCh | ||
| 592 | 683 | ||||
| 593 | - | // Now try to close. If the implementation holds the lock across Write, | |||
| 594 | - | // this will block until Write returns. If not, Close races with Write. | |||
| 595 | closeDone := make(chan struct{}) | 684 | closeDone := make(chan struct{}) | ||
| 596 | go func() { | 685 | go func() { | ||
| 597 | wv.closeAudioStream() | 686 | wv.closeAudioStream() | ||
| 598 | close(closeDone) | 687 | close(closeDone) | ||
| 599 | }() | 688 | }() | ||
| 600 | 689 | ||||
| 601 | - | // Close should NOT have completed yet because Write holds the lock. | |||
| 602 | - | // Give the goroutine time to acquire the lock if it can. | |||
| 603 | - | time.Sleep(50 * time.Millisecond) | |||
| 604 | select { | 690 | select { | ||
| 605 | case <-closeDone: | 691 | case <-closeDone: | ||
| 606 | - | t.Fatal("closeAudioStream completed while Write was in-flight — lock not held across Write") | 692 | + | // expected: only the short stream snapshot takes the mutex |
| 607 | - | default: | 693 | + | case <-time.After(time.Second): |
| 608 | - | // expected: closeAudioStream is blocked | 694 | + | t.Fatal("closeAudioStream waited for an in-flight Write") |
| 609 | } | 695 | } | ||
| 610 | 696 | ||||
| 611 | - | // Unblock Write. | 697 | + | // The write can finish after shutdown without holding the lifecycle mutex. |
| 612 | close(stream.doneCh) | 698 | close(stream.doneCh) | ||
| 613 | 699 | ||||
| 614 | - | // Now closeAudioStream should complete. | |||
| 615 | - | <-closeDone | |||
| 616 | - | ||||
| 617 | assert.True(t, stream.closeCalled) | 700 | assert.True(t, stream.closeCalled) | ||
| 618 | - | assert.Nil(t, wv.activeAudioStream) | 701 | + | assert.Nil(t, activeAudioStream(wv)) |
| 619 | } | 702 | } | ||
| 620 | 703 | ||||
| 621 | // TestOnAudioStreamPacket_StreamClosedBetweenPackets verifies that when a | 704 | // TestOnAudioStreamPacket_StreamClosedBetweenPackets verifies that when a | ||
| @@ -626,9 +709,9 @@ func TestOnAudioStreamPacket_StreamClosedBetweenPackets(t *testing.T) { | |||||
| 626 | stream := &mockAudioStream{} | 709 | stream := &mockAudioStream{} | ||
| 627 | 710 | ||||
| 628 | wv := &WebView{ | 711 | wv := &WebView{ | ||
| 629 | - | ctx: ctx, | 712 | + | ctx: ctx, |
| 630 | - | activeAudioStream: stream, | |||
| 631 | } | 713 | } | ||
| 714 | + | setAudioStream(wv, stream) | |||
| 632 | handlers := &handlerSet{wv: wv} | 715 | handlers := &handlerSet{wv: wv} | ||
| 633 | data := [][]float32{{0.1, 0.2}, {0.3, 0.4}} | 716 | data := [][]float32{{0.1, 0.2}, {0.3, 0.4}} | ||
| 634 | 717 | ||||
A internal/infrastructure/cef/webview_begin_frame_cleanup_test.go +32 -0
| @@ -0,0 +1,32 @@ | |||||
| 1 | + | package cef | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/puregotk/v4/gtk" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestStopBeginFrameLoopReleasesCallbackSlotWithoutWidget(t *testing.T) { | |||
| 10 | + | callback := new(gtk.TickCallback) | |||
| 11 | + | wv := &WebView{beginFrameTick: callback, beginFrameTickID: 17} | |||
| 12 | + | oldUnref := unrefTickCallback | |||
| 13 | + | defer func() { unrefTickCallback = oldUnref }() | |||
| 14 | + | var unrefs int | |||
| 15 | + | unrefTickCallback = func(got any) error { | |||
| 16 | + | if got != callback { | |||
| 17 | + | t.Fatalf("unref callback = %p, want %p", got, callback) | |||
| 18 | + | } | |||
| 19 | + | unrefs++ | |||
| 20 | + | return nil | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | wv.stopBeginFrameLoop() | |||
| 24 | + | wv.stopBeginFrameLoop() | |||
| 25 | + | ||||
| 26 | + | if wv.beginFrameTick != nil || wv.beginFrameTickID != 0 { | |||
| 27 | + | t.Fatal("stop must release the BeginFrame callback slot even after the GTK widget is gone") | |||
| 28 | + | } | |||
| 29 | + | if unrefs != 1 { | |||
| 30 | + | t.Fatalf("callback unrefs = %d, want 1", unrefs) | |||
| 31 | + | } | |||
| 32 | + | } | |||
M internal/infrastructure/cef/webview_handlers.go +29 -69
| @@ -819,9 +819,8 @@ func (h *handlerSet) attachAfterCreatedBrowser( | |||||
| 819 | }) | 819 | }) | ||
| 820 | } | 820 | } | ||
| 821 | 821 | ||||
| 822 | - | // Mark browser as visible — CEF OSR starts in hidden state and suppresses | 822 | + | // CEF OSR starts hidden. SyncViewport reads GTK's mapped, ancestor-aware |
| 823 | - | // painting/caret updates until explicitly told the browser is shown. | 823 | + | // effective state on the main context and reports that truthful initial state. |
| 824 | - | host.WasHidden(0) | |||
| 825 | if h.wv != nil { | 824 | if h.wv != nil { | ||
| 826 | if h.wv.ctx != nil { | 825 | if h.wv.ctx != nil { | ||
| 827 | logging.FromContext(h.wv.ctx).Info(). | 826 | logging.FromContext(h.wv.ctx).Info(). | ||
| @@ -1135,48 +1134,44 @@ func (h *handlerSet) OnAudioStreamStarted(_ purecef.Browser, params *purecef.Aud | |||||
| 1135 | Msg("cef: OnAudioStreamStarted") | 1134 | Msg("cef: OnAudioStreamStarted") | ||
| 1136 | } | 1135 | } | ||
| 1137 | 1136 | ||||
| 1138 | - | // Reset packet counters for the new stream | 1137 | + | // Starting a stream invalidates both a previous publication and any older |
| 1138 | + | // creation already in flight. Close detached streams outside the mutex. | |||
| 1139 | + | generation, previous, accepted := h.wv.beginAudioStreamStart() | |||
| 1140 | + | if !accepted { | |||
| 1141 | + | return | |||
| 1142 | + | } | |||
| 1143 | + | h.wv.closeDetachedAudioStream(previous) | |||
| 1139 | h.wv.audioPacketCount.Store(0) | 1144 | h.wv.audioPacketCount.Store(0) | ||
| 1140 | h.wv.audioWriteCount.Store(0) | 1145 | h.wv.audioWriteCount.Store(0) | ||
| 1141 | 1146 | ||||
| 1142 | - | // Close any existing stream first | |||
| 1143 | - | h.wv.closeAudioStream() | |||
| 1144 | - | ||||
| 1145 | - | // Create new stream | |||
| 1146 | ctx := h.wv.ctx | 1147 | ctx := h.wv.ctx | ||
| 1147 | if ctx == nil { | 1148 | if ctx == nil { | ||
| 1148 | ctx = context.Background() | 1149 | ctx = context.Background() | ||
| 1149 | } | 1150 | } | ||
| 1150 | - | ||||
| 1151 | stream, err := h.wv.audioOutputFactory.NewStream(ctx, format) | 1151 | stream, err := h.wv.audioOutputFactory.NewStream(ctx, format) | ||
| 1152 | if err != nil { | 1152 | if err != nil { | ||
| 1153 | - | // Log error but don't panic - audio is non-critical. | |||
| 1154 | - | // Do NOT set audioPlaying — the stream was never created. | |||
| 1155 | if h.wv.ctx != nil { | 1153 | if h.wv.ctx != nil { | ||
| 1156 | - | logging.FromContext(h.wv.ctx).Warn(). | 1154 | + | logging.FromContext(h.wv.ctx).Warn().Err(err). |
| 1157 | - | Err(err). | 1155 | + | Int("sample_rate", format.SampleRate).Int("channels", format.ChannelCount). |
| 1158 | - | Int("sample_rate", format.SampleRate). | |||
| 1159 | - | Int("channels", format.ChannelCount). | |||
| 1160 | Msg("cef: failed to create audio output stream") | 1156 | Msg("cef: failed to create audio output stream") | ||
| 1161 | } | 1157 | } | ||
| 1162 | return | 1158 | return | ||
| 1163 | } | 1159 | } | ||
| 1164 | 1160 | ||||
| 1165 | - | // Set playing state only after stream creation succeeds | 1161 | + | replaced, published := h.wv.publishAudioStream(generation, stream) |
| 1166 | - | h.wv.setAudioPlaying(true) | 1162 | + | if !published { |
| 1167 | - | 1163 | + | // Stop, Destroy, or a newer Start won the race. This callback owns the | |
| 1164 | + | // unpublishable stream and closes it exactly once. | |||
| 1165 | + | h.wv.closeDetachedAudioStream(&audioStreamHandle{stream: stream, generation: generation}) | |||
| 1166 | + | return | |||
| 1167 | + | } | |||
| 1168 | + | h.wv.closeDetachedAudioStream(replaced) | |||
| 1168 | if h.wv.ctx != nil { | 1169 | if h.wv.ctx != nil { | ||
| 1169 | logging.FromContext(h.wv.ctx).Info(). | 1170 | logging.FromContext(h.wv.ctx).Info(). | ||
| 1170 | - | Int("sample_rate", format.SampleRate). | 1171 | + | Int("sample_rate", format.SampleRate).Int("channels", format.ChannelCount). |
| 1171 | - | Int("channels", format.ChannelCount). | |||
| 1172 | Int("frames_per_buffer", format.FramesPerBuffer). | 1172 | Int("frames_per_buffer", format.FramesPerBuffer). | ||
| 1173 | Msg("cef: audio output stream created successfully") | 1173 | Msg("cef: audio output stream created successfully") | ||
| 1174 | } | 1174 | } | ||
| 1175 | - | ||||
| 1176 | - | // Store the new stream | |||
| 1177 | - | h.wv.audioStreamMu.Lock() | |||
| 1178 | - | h.wv.activeAudioStream = stream | |||
| 1179 | - | h.wv.audioStreamMu.Unlock() | |||
| 1180 | } | 1175 | } | ||
| 1181 | 1176 | ||||
| 1182 | // buildAudioStreamFormat converts CEF AudioParameters to port.AudioStreamFormat. | 1177 | // buildAudioStreamFormat converts CEF AudioParameters to port.AudioStreamFormat. | ||
| @@ -1190,56 +1185,21 @@ func (h *handlerSet) buildAudioStreamFormat(params *purecef.AudioParameters, cha | |||||
| 1190 | } | 1185 | } | ||
| 1191 | } | 1186 | } | ||
| 1192 | 1187 | ||||
| 1193 | - | // OnAudioStreamPacket receives audio packets and forwards them to the output stream. | 1188 | + | // OnAudioStreamPacket is a real-time callback: it atomically snapshots an |
| 1194 | - | // The mutex is held across both the stream snapshot and Write to prevent | 1189 | + | // immutable handle and performs only allocation-free atomic diagnostics. It |
| 1195 | - | // closeAudioStream from closing the stream mid-write. | 1190 | + | // never takes a lifecycle mutex or emits synchronous logs. |
| 1196 | - | func (h *handlerSet) OnAudioStreamPacket(_ purecef.Browser, data [][]float32, frames int32, pts int64) { | 1191 | + | func (h *handlerSet) OnAudioStreamPacket(_ purecef.Browser, data [][]float32, frames int32, _ int64) { |
| 1197 | if len(data) == 0 || frames <= 0 { | 1192 | if len(data) == 0 || frames <= 0 { | ||
| 1198 | return | 1193 | return | ||
| 1199 | } | 1194 | } | ||
| 1200 | - | 1195 | + | handle := h.wv.audioStream.Load() | |
| 1201 | - | // Copy the data before acquiring the stream lock because CEF can reuse the | 1196 | + | if handle == nil || handle.stream == nil { |
| 1202 | - | // buffer as soon as this callback returns. | |||
| 1203 | - | copiedData := make([][]float32, len(data)) | |||
| 1204 | - | for i, channel := range data { | |||
| 1205 | - | if len(channel) < int(frames) { | |||
| 1206 | - | continue | |||
| 1207 | - | } | |||
| 1208 | - | copiedData[i] = make([]float32, frames) | |||
| 1209 | - | copy(copiedData[i], channel[:frames]) | |||
| 1210 | - | } | |||
| 1211 | - | ||||
| 1212 | - | // Hold the lock across the stream read and Write so closeAudioStream | |||
| 1213 | - | // cannot close the stream between snapshot and write. | |||
| 1214 | - | h.wv.audioStreamMu.Lock() | |||
| 1215 | - | stream := h.wv.activeAudioStream | |||
| 1216 | - | if stream == nil { | |||
| 1217 | - | h.wv.audioStreamMu.Unlock() | |||
| 1218 | return | 1197 | return | ||
| 1219 | } | 1198 | } | ||
| 1220 | - | 1199 | + | h.wv.audioPacketCount.Add(1) | |
| 1221 | - | pktNum := h.wv.audioPacketCount.Add(1) | 1200 | + | if handle.stream.Write(data) == nil { |
| 1222 | - | if pktNum == 1 && h.wv.ctx != nil { | 1201 | + | h.wv.audioWriteCount.Add(1) |
| 1223 | - | logging.FromContext(h.wv.ctx).Info(). | |||
| 1224 | - | Int("channels", len(data)). | |||
| 1225 | - | Int32("frames", frames). | |||
| 1226 | - | Int64("pts", pts). | |||
| 1227 | - | Msg("cef: first audio packet received") | |||
| 1228 | - | } | |||
| 1229 | - | ||||
| 1230 | - | err := stream.Write(copiedData) | |||
| 1231 | - | h.wv.audioStreamMu.Unlock() | |||
| 1232 | - | ||||
| 1233 | - | if err != nil { | |||
| 1234 | - | if h.wv.ctx != nil { | |||
| 1235 | - | logging.FromContext(h.wv.ctx).Debug(). | |||
| 1236 | - | Err(err). | |||
| 1237 | - | Uint64("packets_received", pktNum). | |||
| 1238 | - | Msg("cef: audio stream write failed") | |||
| 1239 | - | } | |||
| 1240 | - | return | |||
| 1241 | } | 1202 | } | ||
| 1242 | - | h.wv.audioWriteCount.Add(1) | |||
| 1243 | } | 1203 | } | ||
| 1244 | 1204 | ||||
| 1245 | // OnAudioStreamStopped handles stream stop. | 1205 | // OnAudioStreamStopped handles stream stop. | ||
M internal/infrastructure/cef/webview_handlers_test.go +2 -2
| @@ -380,14 +380,14 @@ func TestConfigureNativePopupWindow_UsesSharedTextureWindowlessDefaults(t *testi | |||||
| 380 | windowInfo := purecef.NewWindowInfo() | 380 | windowInfo := purecef.NewWindowInfo() | ||
| 381 | settings := purecef.NewBrowserSettings() | 381 | settings := purecef.NewBrowserSettings() | ||
| 382 | 382 | ||||
| 383 | - | configureNativePopupWindow(&windowInfo, &settings, 72, 0xFF112233) | 383 | + | configureNativePopupWindow(&windowInfo, &settings, 72) |
| 384 | 384 | ||||
| 385 | require.Equal(t, purecef.WindowHandle(0), windowInfo.ParentWindow) | 385 | require.Equal(t, purecef.WindowHandle(0), windowInfo.ParentWindow) | ||
| 386 | require.Equal(t, int32(1), windowInfo.WindowlessRenderingEnabled) | 386 | require.Equal(t, int32(1), windowInfo.WindowlessRenderingEnabled) | ||
| 387 | require.Equal(t, int32(1), windowInfo.SharedTextureEnabled) | 387 | require.Equal(t, int32(1), windowInfo.SharedTextureEnabled) | ||
| 388 | require.Equal(t, int32(72), settings.WindowlessFrameRate) | 388 | require.Equal(t, int32(72), settings.WindowlessFrameRate) | ||
| 389 | require.Equal(t, int32(1), settings.LocalStorage) | 389 | require.Equal(t, int32(1), settings.LocalStorage) | ||
| 390 | - | require.Equal(t, uint32(0xFF112233), settings.BackgroundColor) | 390 | + | require.Equal(t, opaqueWhiteBackground, settings.BackgroundColor) |
| 391 | } | 391 | } | ||
| 392 | 392 | ||||
| 393 | func TestOnLoadStartFiresCommittedAndUpdatesURI(t *testing.T) { | 393 | func TestOnLoadStartFiresCommittedAndUpdatesURI(t *testing.T) { | ||
M internal/infrastructure/cef/webview_viewport.go +75 -2
| @@ -24,6 +24,38 @@ type pendingBrowserCreateObservedSizeBridge interface { | |||||
| 24 | RefreshObservedSizeOnGTKThread() (int32, int32) | 24 | RefreshObservedSizeOnGTKThread() (int32, int32) | ||
| 25 | } | 25 | } | ||
| 26 | 26 | ||||
| 27 | + | // viewportWidgetVisibility is the effective GTK state of a widget in its | |||
| 28 | + | // hierarchy. Gtk.Widget.IsVisible already includes ancestor visibility; mapped | |||
| 29 | + | // is checked separately because a visible widget can still be unmapped. | |||
| 30 | + | type viewportWidgetVisibility struct { | |||
| 31 | + | visible bool | |||
| 32 | + | mapped bool | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | func effectiveViewportVisibility(states []viewportWidgetVisibility) bool { | |||
| 36 | + | if len(states) == 0 { | |||
| 37 | + | return false | |||
| 38 | + | } | |||
| 39 | + | for _, state := range states { | |||
| 40 | + | if !state.visible || !state.mapped { | |||
| 41 | + | return false | |||
| 42 | + | } | |||
| 43 | + | } | |||
| 44 | + | return true | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | func effectiveViewportWidgetVisibility(widget *gtk.Widget) bool { | |||
| 48 | + | if widget == nil { | |||
| 49 | + | return false | |||
| 50 | + | } | |||
| 51 | + | // IsVisible is GTK's ancestor-aware visibility predicate. Pairing it with | |||
| 52 | + | // GetMapped makes hidden/unmapped ancestors effective CEF-hidden states. | |||
| 53 | + | return effectiveViewportVisibility([]viewportWidgetVisibility{{ | |||
| 54 | + | visible: widget.IsVisible(), | |||
| 55 | + | mapped: widget.GetMapped(), | |||
| 56 | + | }}) | |||
| 57 | + | } | |||
| 58 | + | ||||
| 27 | const ( | 59 | const ( | ||
| 28 | // pendingBrowserCreateObservedSizeRetryDelay spaces retries while waiting | 60 | // pendingBrowserCreateObservedSizeRetryDelay spaces retries while waiting | ||
| 29 | // for the GTK bridge to observe the widget's allocated size. | 61 | // for the GTK bridge to observe the widget's allocated size. | ||
| @@ -129,7 +161,7 @@ func (wv *WebView) syncViewportNowOnGTK(ctx context.Context, reason string) bool | |||||
| 129 | } | 161 | } | ||
| 130 | 162 | ||||
| 131 | widget := wv.viewBridge.Widget() | 163 | widget := wv.viewBridge.Widget() | ||
| 132 | - | visible := widget != nil && widget.IsVisible() | 164 | + | visible := effectiveViewportWidgetVisibility(widget) |
| 133 | wv.notifyViewportSyncOnCEFUIThread(host, visible) | 165 | wv.notifyViewportSyncOnCEFUIThread(host, visible) | ||
| 134 | wv.syncZoomForBackingScaleOnCEFUIThread(host, reason) | 166 | wv.syncZoomForBackingScaleOnCEFUIThread(host, reason) | ||
| 135 | logging.FromContext(ctx).Debug(). | 167 | logging.FromContext(ctx).Debug(). | ||
| @@ -437,12 +469,20 @@ func (wv *WebView) installViewportSyncHooksOnGTKThread() { | |||||
| 437 | wv.viewportShowFunc = func(gtk.Widget) { | 469 | wv.viewportShowFunc = func(gtk.Widget) { | ||
| 438 | wv.SyncViewport(wv.ctx, "gtk-show") | 470 | wv.SyncViewport(wv.ctx, "gtk-show") | ||
| 439 | } | 471 | } | ||
| 472 | + | wv.viewportHideFunc = func(gtk.Widget) { | |||
| 473 | + | wv.SyncViewport(wv.ctx, "gtk-hide") | |||
| 474 | + | } | |||
| 475 | + | wv.viewportUnmapFunc = func(gtk.Widget) { | |||
| 476 | + | wv.SyncViewport(wv.ctx, "gtk-unmap") | |||
| 477 | + | } | |||
| 440 | wv.viewportRealizeFunc = func(gtk.Widget) { | 478 | wv.viewportRealizeFunc = func(gtk.Widget) { | ||
| 441 | wv.SyncViewport(wv.ctx, "gtk-realize") | 479 | wv.SyncViewport(wv.ctx, "gtk-realize") | ||
| 442 | } | 480 | } | ||
| 443 | 481 | ||||
| 444 | wv.viewportMapSignalID = widget.ConnectMap(&wv.viewportMapFunc) | 482 | wv.viewportMapSignalID = widget.ConnectMap(&wv.viewportMapFunc) | ||
| 445 | wv.viewportShowSignalID = widget.ConnectShow(&wv.viewportShowFunc) | 483 | wv.viewportShowSignalID = widget.ConnectShow(&wv.viewportShowFunc) | ||
| 484 | + | wv.viewportHideSignalID = widget.ConnectHide(&wv.viewportHideFunc) | |||
| 485 | + | wv.viewportUnmapSignalID = widget.ConnectUnmap(&wv.viewportUnmapFunc) | |||
| 446 | wv.viewportRealizeSignalID = widget.ConnectRealize(&wv.viewportRealizeFunc) | 486 | wv.viewportRealizeSignalID = widget.ConnectRealize(&wv.viewportRealizeFunc) | ||
| 447 | } | 487 | } | ||
| 448 | 488 | ||||
| @@ -463,6 +503,14 @@ func (wv *WebView) disconnectViewportSyncHooksOnGTKThread() { | |||||
| 463 | gobject.SignalHandlerDisconnect(obj, wv.viewportShowSignalID) | 503 | gobject.SignalHandlerDisconnect(obj, wv.viewportShowSignalID) | ||
| 464 | wv.viewportShowSignalID = 0 | 504 | wv.viewportShowSignalID = 0 | ||
| 465 | } | 505 | } | ||
| 506 | + | if wv.viewportHideSignalID != 0 { | |||
| 507 | + | gobject.SignalHandlerDisconnect(obj, wv.viewportHideSignalID) | |||
| 508 | + | wv.viewportHideSignalID = 0 | |||
| 509 | + | } | |||
| 510 | + | if wv.viewportUnmapSignalID != 0 { | |||
| 511 | + | gobject.SignalHandlerDisconnect(obj, wv.viewportUnmapSignalID) | |||
| 512 | + | wv.viewportUnmapSignalID = 0 | |||
| 513 | + | } | |||
| 466 | if wv.viewportRealizeSignalID != 0 { | 514 | if wv.viewportRealizeSignalID != 0 { | ||
| 467 | gobject.SignalHandlerDisconnect(obj, wv.viewportRealizeSignalID) | 515 | gobject.SignalHandlerDisconnect(obj, wv.viewportRealizeSignalID) | ||
| 468 | wv.viewportRealizeSignalID = 0 | 516 | wv.viewportRealizeSignalID = 0 | ||
| @@ -471,6 +519,8 @@ func (wv *WebView) disconnectViewportSyncHooksOnGTKThread() { | |||||
| 471 | } | 519 | } | ||
| 472 | wv.viewportMapFunc = nil | 520 | wv.viewportMapFunc = nil | ||
| 473 | wv.viewportShowFunc = nil | 521 | wv.viewportShowFunc = nil | ||
| 522 | + | wv.viewportHideFunc = nil | |||
| 523 | + | wv.viewportUnmapFunc = nil | |||
| 474 | wv.viewportRealizeFunc = nil | 524 | wv.viewportRealizeFunc = nil | ||
| 475 | } | 525 | } | ||
| 476 | 526 | ||||
| @@ -488,7 +538,8 @@ func (wv *WebView) notifyViewportSyncOnCEFUIThread(host viewportSyncBrowserHost, | |||||
| 488 | if currentHost != host { | 538 | if currentHost != host { | ||
| 489 | return | 539 | return | ||
| 490 | } | 540 | } | ||
| 491 | - | notifyBrowserViewportSync(host, visible) | 541 | + | wv.applyEffectiveVisibility(host, visible) |
| 542 | + | notifyBrowserViewportResize(host) | |||
| 492 | })) | 543 | })) | ||
| 493 | if task == nil { | 544 | if task == nil { | ||
| 494 | return | 545 | return | ||
| @@ -526,6 +577,28 @@ func (wv *WebView) syncZoomForBackingScaleOnCEFUIThread(host purecef.BrowserHost | |||||
| 526 | cefPostDelayedTask(purecef.ThreadIDTidUi, task, 0) | 577 | cefPostDelayedTask(purecef.ThreadIDTidUi, task, 0) | ||
| 527 | } | 578 | } | ||
| 528 | 579 | ||||
| 580 | + | // applyEffectiveVisibility reports the initial effective visibility and later | |||
| 581 | + | // transitions exactly once. CEF OSR begins hidden, so even an initially hidden | |||
| 582 | + | // GTK widget must be explicitly reported to keep both lifecycles aligned. | |||
| 583 | + | func (wv *WebView) applyEffectiveVisibility(host viewportSyncBrowserHost, visible bool) { | |||
| 584 | + | if wv == nil || host == nil || wv.destroyed.Load() { | |||
| 585 | + | return | |||
| 586 | + | } | |||
| 587 | + | wv.mu.Lock() | |||
| 588 | + | if wv.effectiveVisibilityKnown && wv.effectiveVisible == visible { | |||
| 589 | + | wv.mu.Unlock() | |||
| 590 | + | return | |||
| 591 | + | } | |||
| 592 | + | wv.effectiveVisibilityKnown = true | |||
| 593 | + | wv.effectiveVisible = visible | |||
| 594 | + | wv.mu.Unlock() | |||
| 595 | + | if visible { | |||
| 596 | + | host.WasHidden(0) | |||
| 597 | + | return | |||
| 598 | + | } | |||
| 599 | + | host.WasHidden(1) | |||
| 600 | + | } | |||
| 601 | + | ||||
| 529 | func notifyBrowserViewportSync(host viewportSyncBrowserHost, visible bool) { | 602 | func notifyBrowserViewportSync(host viewportSyncBrowserHost, visible bool) { | ||
| 530 | if host == nil { | 603 | if host == nil { | ||
| 531 | return | 604 | return | ||
M internal/infrastructure/cef/webview_viewport_test.go +0 -1
| @@ -149,7 +149,6 @@ func TestNotifyViewportSyncOnCEFUIThread_RemainsViewportOnlyAcrossRepeatedResize | |||||
| 149 | scheduled.Execute() | 149 | scheduled.Execute() | ||
| 150 | 150 | ||||
| 151 | require.Equal(t, []string{ | 151 | require.Equal(t, []string{ | ||
| 152 | - | "WasHidden", | |||
| 153 | "NotifyScreenInfoChanged", | 152 | "NotifyScreenInfoChanged", | ||
| 154 | "WasResized", | 153 | "WasResized", | ||
| 155 | "Invalidate", | 154 | "Invalidate", | ||
A internal/infrastructure/cef/webview_visibility_test.go +45 -0
| @@ -0,0 +1,45 @@ | |||||
| 1 | + | package cef | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/stretchr/testify/require" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestEffectiveViewportVisibilityRequiresMappedVisibleAncestors(t *testing.T) { | |||
| 10 | + | tests := []struct { | |||
| 11 | + | name string | |||
| 12 | + | states []viewportWidgetVisibility | |||
| 13 | + | visible bool | |||
| 14 | + | }{ | |||
| 15 | + | {name: "mapped visible widget", states: []viewportWidgetVisibility{{visible: true, mapped: true}}, visible: true}, | |||
| 16 | + | {name: "unmapped widget", states: []viewportWidgetVisibility{{visible: true, mapped: false}}}, | |||
| 17 | + | {name: "hidden widget", states: []viewportWidgetVisibility{{visible: false, mapped: true}}}, | |||
| 18 | + | {name: "hidden ancestor", states: []viewportWidgetVisibility{{visible: true, mapped: true}, {visible: false, mapped: true}}}, | |||
| 19 | + | {name: "unmapped ancestor", states: []viewportWidgetVisibility{{visible: true, mapped: true}, {visible: true, mapped: false}}}, | |||
| 20 | + | } | |||
| 21 | + | for _, tt := range tests { | |||
| 22 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 23 | + | require.Equal(t, tt.visible, effectiveViewportVisibility(tt.states)) | |||
| 24 | + | }) | |||
| 25 | + | } | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func TestWebViewEffectiveVisibilitySendsInitialStateAndDeduplicatesTransitions(t *testing.T) { | |||
| 29 | + | host := &viewportSyncOrderHost{} | |||
| 30 | + | wv := &WebView{} | |||
| 31 | + | ||||
| 32 | + | wv.applyEffectiveVisibility(host, false) | |||
| 33 | + | require.Equal(t, []string{"WasHidden", "WasHidden(nonzero)"}, host.calls) | |||
| 34 | + | ||||
| 35 | + | host.calls = nil | |||
| 36 | + | wv.applyEffectiveVisibility(host, false) | |||
| 37 | + | require.Empty(t, host.calls, "unchanged hidden state must not be resent") | |||
| 38 | + | ||||
| 39 | + | wv.applyEffectiveVisibility(host, true) | |||
| 40 | + | require.Equal(t, []string{"WasHidden"}, host.calls) | |||
| 41 | + | ||||
| 42 | + | host.calls = nil | |||
| 43 | + | wv.applyEffectiveVisibility(host, true) | |||
| 44 | + | require.Empty(t, host.calls, "unchanged visible state must not be resent") | |||
| 45 | + | } | |||
M internal/infrastructure/favicon/converter.go +19 -7
| @@ -16,17 +16,25 @@ import ( | |||||
| 16 | ) | 16 | ) | ||
| 17 | 17 | ||||
| 18 | // ImageConverter converts safe favicon image formats into normalized PNGs. | 18 | // ImageConverter converts safe favicon image formats into normalized PNGs. | ||
| 19 | - | type ImageConverter struct{} | 19 | + | type ImageConverter struct { |
| 20 | + | webp *webPDecoder | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | func NewImageConverter() *ImageConverter { return &ImageConverter{webp: newDefaultWebPDecoder()} } | |||
| 20 | 24 | ||||
| 21 | - | func NewImageConverter() *ImageConverter { return &ImageConverter{} } | 25 | + | // newImageConverterWithWebPRuntime provides an infrastructure test seam while |
| 26 | + | // application ports remain independent of the libwebp implementation. | |||
| 27 | + | func newImageConverterWithWebPRuntime(runtime webpRuntime) *ImageConverter { | |||
| 28 | + | return &ImageConverter{webp: newWebPDecoder(runtime)} | |||
| 29 | + | } | |||
| 22 | 30 | ||||
| 23 | - | func (*ImageConverter) Convert(ctx context.Context, original []byte, contentType string, sizes []int) (*appport.ConvertedFavicon, error) { | 31 | + | func (c *ImageConverter) Convert(ctx context.Context, original []byte, contentType string, sizes []int) (*appport.ConvertedFavicon, error) { |
| 24 | _ = ctx | 32 | _ = ctx | ||
| 25 | if len(original) == 0 { | 33 | if len(original) == 0 { | ||
| 26 | return nil, fmt.Errorf("%w: empty image", appport.ErrFaviconMiss) | 34 | return nil, fmt.Errorf("%w: empty image", appport.ErrFaviconMiss) | ||
| 27 | } | 35 | } | ||
| 28 | 36 | ||||
| 29 | - | img, err := decodeFaviconImage(original, normalizeContentType(contentType)) | 37 | + | img, err := decodeFaviconImage(original, normalizeContentType(contentType), c.webp) |
| 30 | if err != nil { | 38 | if err != nil { | ||
| 31 | return nil, err | 39 | return nil, err | ||
| 32 | } | 40 | } | ||
| @@ -48,7 +56,7 @@ func (*ImageConverter) Convert(ctx context.Context, original []byte, contentType | |||||
| 48 | return out, nil | 56 | return out, nil | ||
| 49 | } | 57 | } | ||
| 50 | 58 | ||||
| 51 | - | func decodeFaviconImage(data []byte, ct string) (image.Image, error) { | 59 | + | func decodeFaviconImage(data []byte, ct string, webp *webPDecoder) (image.Image, error) { |
| 52 | switch ct { | 60 | switch ct { | ||
| 53 | case "image/svg+xml": | 61 | case "image/svg+xml": | ||
| 54 | return nil, fmt.Errorf("%w: svg favicon conversion is disabled", appport.ErrFaviconMiss) | 62 | return nil, fmt.Errorf("%w: svg favicon conversion is disabled", appport.ErrFaviconMiss) | ||
| @@ -59,9 +67,13 @@ func decodeFaviconImage(data []byte, ct string) (image.Image, error) { | |||||
| 59 | } | 67 | } | ||
| 60 | return img, nil | 68 | return img, nil | ||
| 61 | case "image/webp": | 69 | case "image/webp": | ||
| 62 | - | return nil, fmt.Errorf("%w: webp favicon conversion is disabled", appport.ErrFaviconMiss) | 70 | + | img, err := webp.Decode(data) |
| 71 | + | if err != nil { | |||
| 72 | + | return nil, fmt.Errorf("%w: decode webp favicon: %w", appport.ErrFaviconMiss, err) | |||
| 73 | + | } | |||
| 74 | + | return img, nil | |||
| 63 | } | 75 | } | ||
| 64 | - | if ct != "" && ct != "image/png" && ct != "image/jpeg" && ct != "image/gif" && ct != "image/webp" { | 76 | + | if ct != "" && ct != "image/png" && ct != "image/jpeg" && ct != "image/gif" { |
| 65 | return nil, fmt.Errorf("%w: unsupported favicon content type %s", appport.ErrFaviconMiss, ct) | 77 | return nil, fmt.Errorf("%w: unsupported favicon content type %s", appport.ErrFaviconMiss, ct) | ||
| 66 | } | 78 | } | ||
| 67 | img, _, err := image.Decode(bytes.NewReader(data)) | 79 | img, _, err := image.Decode(bytes.NewReader(data)) | ||
A internal/infrastructure/favicon/testdata/alpha.webp +0 -0 binary file: no text diff
binary file: no text diff
A internal/infrastructure/favicon/webp_decoder.go +93 -0
| @@ -0,0 +1,93 @@ | |||||
| 1 | + | package favicon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | "image" | |||
| 6 | + | "math" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/purego-webp/libwebp" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | const ( | |||
| 12 | + | // maxWebPInputBytes bounds untrusted compressed favicon input before it is | |||
| 13 | + | // handed to libwebp. | |||
| 14 | + | maxWebPInputBytes = 5 << 20 | |||
| 15 | + | // maxWebPDimension and maxWebPPixels bound the final RGBA allocation. | |||
| 16 | + | maxWebPDimension = 4096 | |||
| 17 | + | maxWebPPixels = 4 << 20 | |||
| 18 | + | ) | |||
| 19 | + | ||||
| 20 | + | var ( | |||
| 21 | + | errWebPInputTooLarge = errors.New("webp favicon input exceeds size limit") | |||
| 22 | + | errWebPImageTooLarge = errors.New("webp favicon dimensions exceed size limit") | |||
| 23 | + | errWebPInvalidMetadata = errors.New("invalid webp favicon metadata") | |||
| 24 | + | ) | |||
| 25 | + | ||||
| 26 | + | // webpRuntime is the narrow infrastructure seam around the dynamically loaded | |||
| 27 | + | // libwebp binding. It deliberately exposes only Go values, keeping backend | |||
| 28 | + | // types out of ports and the domain. | |||
| 29 | + | type webpRuntime interface { | |||
| 30 | + | Info(data []byte) (width, height int, err error) | |||
| 31 | + | DecodeRGBAInto(data, pix []byte, stride, width, height int) error | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | type libwebpRuntime struct{} | |||
| 35 | + | ||||
| 36 | + | func (libwebpRuntime) Info(data []byte) (int, int, error) { | |||
| 37 | + | width, height, ok, err := libwebp.WebPGetInfo(data) | |||
| 38 | + | if err != nil { | |||
| 39 | + | return 0, 0, err | |||
| 40 | + | } | |||
| 41 | + | if !ok { | |||
| 42 | + | return 0, 0, errWebPInvalidMetadata | |||
| 43 | + | } | |||
| 44 | + | return width, height, nil | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | func (libwebpRuntime) DecodeRGBAInto(data, pix []byte, stride, width, height int) error { | |||
| 48 | + | return libwebp.WebPDecodeRGBAIntoWithInfo(data, pix, stride, width, height) | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | // webPDecoder owns the failure boundary for the optional libwebp runtime. | |||
| 52 | + | type webPDecoder struct{ runtime webpRuntime } | |||
| 53 | + | ||||
| 54 | + | func newWebPDecoder(runtime webpRuntime) *webPDecoder { return &webPDecoder{runtime: runtime} } | |||
| 55 | + | ||||
| 56 | + | func newDefaultWebPDecoder() *webPDecoder { return newWebPDecoder(libwebpRuntime{}) } | |||
| 57 | + | ||||
| 58 | + | func (d *webPDecoder) Decode(data []byte) (*image.NRGBA, error) { | |||
| 59 | + | if len(data) == 0 { | |||
| 60 | + | return nil, errWebPInvalidMetadata | |||
| 61 | + | } | |||
| 62 | + | if len(data) > maxWebPInputBytes { | |||
| 63 | + | return nil, errWebPInputTooLarge | |||
| 64 | + | } | |||
| 65 | + | if d == nil || d.runtime == nil { | |||
| 66 | + | return nil, errors.New("webp decoder unavailable") | |||
| 67 | + | } | |||
| 68 | + | width, height, err := d.runtime.Info(data) | |||
| 69 | + | if err != nil { | |||
| 70 | + | return nil, err | |||
| 71 | + | } | |||
| 72 | + | if width <= 0 || height <= 0 { | |||
| 73 | + | return nil, errWebPInvalidMetadata | |||
| 74 | + | } | |||
| 75 | + | if width > maxWebPDimension || height > maxWebPDimension || width > maxWebPPixels/height { | |||
| 76 | + | return nil, errWebPImageTooLarge | |||
| 77 | + | } | |||
| 78 | + | if width > math.MaxInt/4 { | |||
| 79 | + | return nil, errWebPImageTooLarge | |||
| 80 | + | } | |||
| 81 | + | stride := width * 4 | |||
| 82 | + | if height > math.MaxInt/stride { | |||
| 83 | + | return nil, errWebPImageTooLarge | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | // Allocate exactly the destination buffer used by libwebp; no native image | |||
| 87 | + | // buffer or second decoded copy crosses this adapter boundary. | |||
| 88 | + | img := image.NewNRGBA(image.Rect(0, 0, width, height)) | |||
| 89 | + | if err := d.runtime.DecodeRGBAInto(data, img.Pix, img.Stride, width, height); err != nil { | |||
| 90 | + | return nil, err | |||
| 91 | + | } | |||
| 92 | + | return img, nil | |||
| 93 | + | } | |||
A internal/infrastructure/favicon/webp_decoder_test.go +96 -0
| @@ -0,0 +1,96 @@ | |||||
| 1 | + | package favicon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "context" | |||
| 6 | + | "errors" | |||
| 7 | + | "image" | |||
| 8 | + | "image/color" | |||
| 9 | + | "image/png" | |||
| 10 | + | "os" | |||
| 11 | + | "testing" | |||
| 12 | + | ||||
| 13 | + | appport "github.com/bnema/dumber/internal/application/port" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | func TestConverterWebPConvertsAlphaFixture(t *testing.T) { | |||
| 17 | + | data, err := os.ReadFile("testdata/alpha.webp") | |||
| 18 | + | if err != nil { | |||
| 19 | + | t.Fatal(err) | |||
| 20 | + | } | |||
| 21 | + | conv := NewImageConverter() | |||
| 22 | + | out, err := conv.Convert(context.Background(), data, "image/webp", []int{32}) | |||
| 23 | + | if err != nil { | |||
| 24 | + | t.Fatal(err) | |||
| 25 | + | } | |||
| 26 | + | img, err := png.Decode(bytes.NewReader(out.PNG)) | |||
| 27 | + | if err != nil { | |||
| 28 | + | t.Fatal(err) | |||
| 29 | + | } | |||
| 30 | + | if got := color.NRGBAModel.Convert(img.At(0, 0)).(color.NRGBA); got.A != 64 || got.R != 255 { | |||
| 31 | + | t.Fatalf("alpha pixel = %#v, want red alpha 64", got) | |||
| 32 | + | } | |||
| 33 | + | sized, err := png.Decode(bytes.NewReader(out.SizedPNG[32])) | |||
| 34 | + | if err != nil { | |||
| 35 | + | t.Fatal(err) | |||
| 36 | + | } | |||
| 37 | + | if sized.Bounds() != image.Rect(0, 0, 32, 32) { | |||
| 38 | + | t.Fatalf("sized WebP bounds = %v, want 32x32", sized.Bounds()) | |||
| 39 | + | } | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | func TestConverterWebPFailuresAreFaviconMisses(t *testing.T) { | |||
| 43 | + | for _, tc := range []struct { | |||
| 44 | + | name string | |||
| 45 | + | data []byte | |||
| 46 | + | dec webpRuntime | |||
| 47 | + | }{ | |||
| 48 | + | {name: "truncated", data: []byte("RIFF\x04\x00\x00\x00WEBPVP8 "), dec: &fakeWebPRuntime{infoErr: errors.New("truncated")}}, | |||
| 49 | + | {name: "unavailable", data: []byte("webp"), dec: &fakeWebPRuntime{infoErr: errors.New("libwebp unavailable")}}, | |||
| 50 | + | } { | |||
| 51 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 52 | + | conv := newImageConverterWithWebPRuntime(tc.dec) | |||
| 53 | + | if _, err := conv.Convert(context.Background(), tc.data, "image/webp", nil); !errors.Is(err, appport.ErrFaviconMiss) { | |||
| 54 | + | t.Fatalf("err = %v, want favicon miss", err) | |||
| 55 | + | } | |||
| 56 | + | }) | |||
| 57 | + | } | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | func TestWebPDecoderRejectsInputAndDecodedLimitsBeforeAllocation(t *testing.T) { | |||
| 61 | + | for _, tc := range []struct { | |||
| 62 | + | name string | |||
| 63 | + | data []byte | |||
| 64 | + | width, height int | |||
| 65 | + | }{ | |||
| 66 | + | {name: "input", data: bytes.Repeat([]byte("x"), maxWebPInputBytes+1), width: 1, height: 1}, | |||
| 67 | + | {name: "dimension", data: []byte("webp"), width: maxWebPDimension + 1, height: 1}, | |||
| 68 | + | {name: "pixels", data: []byte("webp"), width: maxWebPDimension, height: maxWebPDimension}, | |||
| 69 | + | } { | |||
| 70 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 71 | + | runtime := &fakeWebPRuntime{width: tc.width, height: tc.height} | |||
| 72 | + | _, err := newWebPDecoder(runtime).Decode(tc.data) | |||
| 73 | + | if err == nil { | |||
| 74 | + | t.Fatal("Decode succeeded") | |||
| 75 | + | } | |||
| 76 | + | if runtime.decodeCalls != 0 { | |||
| 77 | + | t.Fatalf("decode calls = %d, want no allocation/decode", runtime.decodeCalls) | |||
| 78 | + | } | |||
| 79 | + | }) | |||
| 80 | + | } | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | type fakeWebPRuntime struct { | |||
| 84 | + | width, height int | |||
| 85 | + | infoErr error | |||
| 86 | + | decodeCalls int | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | func (r *fakeWebPRuntime) Info(_ []byte) (int, int, error) { return r.width, r.height, r.infoErr } | |||
| 90 | + | func (r *fakeWebPRuntime) DecodeRGBAInto(_, pix []byte, _, _, _ int) error { | |||
| 91 | + | r.decodeCalls++ | |||
| 92 | + | for i := range pix { | |||
| 93 | + | pix[i] = 0xff | |||
| 94 | + | } | |||
| 95 | + | return nil | |||
| 96 | + | } | |||
A internal/infrastructure/persistence/sqlite/history_repo_benchmark_test.go +71 -0
| @@ -0,0 +1,71 @@ | |||||
| 1 | + | package sqlite_test | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "path/filepath" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 9 | + | "github.com/bnema/dumber/internal/domain/repository" | |||
| 10 | + | "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // newHistoryBenchmarkRepository creates a representative local history corpus | |||
| 14 | + | // outside the measured operation. Sidebar reads use GetRecent and omnibox | |||
| 15 | + | // history suggestions use Search. | |||
| 16 | + | func newHistoryBenchmarkRepository(b *testing.B) (repository.HistoryRepository, func()) { | |||
| 17 | + | b.Helper() | |||
| 18 | + | ||||
| 19 | + | ctx := historyTestCtx() | |||
| 20 | + | db, err := sqlite.NewConnection(ctx, filepath.Join(b.TempDir(), "dumber.db")) | |||
| 21 | + | if err != nil { | |||
| 22 | + | b.Fatal(err) | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | b.Cleanup(func() { | |||
| 26 | + | if err := db.Close(); err != nil { | |||
| 27 | + | b.Errorf("close benchmark database: %v", err) | |||
| 28 | + | } | |||
| 29 | + | }) | |||
| 30 | + | ||||
| 31 | + | repo := sqlite.NewHistoryRepository(db) | |||
| 32 | + | for i := range 200 { | |||
| 33 | + | entry := &entity.HistoryEntry{ | |||
| 34 | + | URL: fmt.Sprintf("https://example%d.test/dumber/history/%d", i%20, i), | |||
| 35 | + | Title: fmt.Sprintf("Dumber history entry %d", i), | |||
| 36 | + | } | |||
| 37 | + | if err := repo.Save(ctx, entry); err != nil { | |||
| 38 | + | b.Fatal(err) | |||
| 39 | + | } | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | return repo, func() {} | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | func BenchmarkHistorySQLiteSidebarRecent(b *testing.B) { | |||
| 46 | + | repo, closeDB := newHistoryBenchmarkRepository(b) | |||
| 47 | + | defer closeDB() | |||
| 48 | + | ctx := historyTestCtx() | |||
| 49 | + | ||||
| 50 | + | b.ReportAllocs() | |||
| 51 | + | b.ResetTimer() | |||
| 52 | + | for range b.N { | |||
| 53 | + | if _, err := repo.GetRecent(ctx, 50, 0); err != nil { | |||
| 54 | + | b.Fatal(err) | |||
| 55 | + | } | |||
| 56 | + | } | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | func BenchmarkHistorySQLiteOmniboxSearch(b *testing.B) { | |||
| 60 | + | repo, closeDB := newHistoryBenchmarkRepository(b) | |||
| 61 | + | defer closeDB() | |||
| 62 | + | ctx := historyTestCtx() | |||
| 63 | + | ||||
| 64 | + | b.ReportAllocs() | |||
| 65 | + | b.ResetTimer() | |||
| 66 | + | for range b.N { | |||
| 67 | + | if _, err := repo.Search(ctx, "dumber history", 10); err != nil { | |||
| 68 | + | b.Fatal(err) | |||
| 69 | + | } | |||
| 70 | + | } | |||
| 71 | + | } | |||
M internal/infrastructure/webkit/engine_init.go +0 -7
| @@ -40,7 +40,6 @@ func NewEngine( | |||||
| 40 | 40 | ||||
| 41 | // --- Configure rendering environment (must be before GTK/WebKit init) --- | 41 | // --- Configure rendering environment (must be before GTK/WebKit init) --- | ||
| 42 | engineConfigureRenderingEnvironment(ctx, cfg, wkCfg, &perfSettings, logger) | 42 | engineConfigureRenderingEnvironment(ctx, cfg, wkCfg, &perfSettings, logger) | ||
| 43 | - | logging.Trace().Mark("render_env") | |||
| 44 | 43 | ||||
| 45 | // --- Build webKitContextOptions from opts + wkCfg + perfSettings --- | 44 | // --- Build webKitContextOptions from opts + wkCfg + perfSettings --- | ||
| 46 | wkOpts := engineBuildContextOptions(opts, profile, wkCfg, &perfSettings) | 45 | wkOpts := engineBuildContextOptions(opts, profile, wkCfg, &perfSettings) | ||
| @@ -53,7 +52,6 @@ func NewEngine( | |||||
| 53 | if err != nil { | 52 | if err != nil { | ||
| 54 | return nil, err | 53 | return nil, err | ||
| 55 | } | 54 | } | ||
| 56 | - | logging.Trace().Mark("webkit_context") | |||
| 57 | 55 | ||||
| 58 | // --- Filter manager --- | 56 | // --- Filter manager --- | ||
| 59 | filterManager := engineInitFilterManager(ctx, cfg, profile.Shared.DataDir, logger) | 57 | filterManager := engineInitFilterManager(ctx, cfg, profile.Shared.DataDir, logger) | ||
| @@ -77,7 +75,6 @@ func NewEngine( | |||||
| 77 | } | 75 | } | ||
| 78 | 76 | ||||
| 79 | messageRouter := NewMessageRouter(ctx) | 77 | messageRouter := NewMessageRouter(ctx) | ||
| 80 | - | logging.Trace().Mark("settings_manager") | |||
| 81 | 78 | ||||
| 82 | // --- WebView pool --- | 79 | // --- WebView pool --- | ||
| 83 | poolCfg := DefaultPoolConfig() | 80 | poolCfg := DefaultPoolConfig() | ||
| @@ -100,7 +97,6 @@ func NewEngine( | |||||
| 100 | } else { | 97 | } else { | ||
| 101 | logger.Warn().Msg("skipping first webview prewarm: no GDK display available yet") | 98 | logger.Warn().Msg("skipping first webview prewarm: no GDK display available yet") | ||
| 102 | } | 99 | } | ||
| 103 | - | logging.Trace().Mark("pool_prewarm_first") | |||
| 104 | 100 | ||||
| 105 | // --- WebView factory --- | 101 | // --- WebView factory --- | ||
| 106 | factory := NewWebViewFactory(wkCtx, settings, pool, injector, messageRouter) | 102 | factory := NewWebViewFactory(wkCtx, settings, pool, injector, messageRouter) | ||
| @@ -147,7 +143,6 @@ func engineSurveyHardwareAndResolveProfile( | |||||
| 147 | ) config.ResolvedPerformanceSettings { | 143 | ) config.ResolvedPerformanceSettings { | ||
| 148 | hwSurveyor := env.NewHardwareSurveyor() | 144 | hwSurveyor := env.NewHardwareSurveyor() | ||
| 149 | hwInfo := hwSurveyor.Survey(ctx) | 145 | hwInfo := hwSurveyor.Survey(ctx) | ||
| 150 | - | logging.Trace().Mark("hardware_survey") | |||
| 151 | logger.Info(). | 146 | logger.Info(). | ||
| 152 | Int("cpu_cores", hwInfo.CPUCores). | 147 | Int("cpu_cores", hwInfo.CPUCores). | ||
| 153 | Int("cpu_threads", hwInfo.CPUThreads). | 148 | Int("cpu_threads", hwInfo.CPUThreads). | ||
| @@ -158,7 +153,6 @@ func engineSurveyHardwareAndResolveProfile( | |||||
| 158 | 153 | ||||
| 159 | perfCfg := config.PerformanceConfigFromEngine(&cfg.Engine) | 154 | perfCfg := config.PerformanceConfigFromEngine(&cfg.Engine) | ||
| 160 | perfSettings := config.ResolvePerformanceProfile(&perfCfg, &hwInfo) | 155 | perfSettings := config.ResolvePerformanceProfile(&perfCfg, &hwInfo) | ||
| 161 | - | logging.Trace().Mark("performance_profile") | |||
| 162 | logger.Info(). | 156 | logger.Info(). | ||
| 163 | Str("profile", string(cfg.Engine.Profile)). | 157 | Str("profile", string(cfg.Engine.Profile)). | ||
| 164 | Int("skia_cpu_threads", perfSettings.SkiaCPUPaintingThreads). | 158 | Int("skia_cpu_threads", perfSettings.SkiaCPUPaintingThreads). | ||
| @@ -284,7 +278,6 @@ func engineInitFilterManager(ctx context.Context, cfg *config.Config, dataDir st | |||||
| 284 | if err := filterManager.Initialize(ctx); err != nil { | 278 | if err := filterManager.Initialize(ctx); err != nil { | ||
| 285 | logger.Warn().Err(err).Msg("failed to initialize filters, will load async") | 279 | logger.Warn().Err(err).Msg("failed to initialize filters, will load async") | ||
| 286 | } | 280 | } | ||
| 287 | - | logging.Trace().Mark("filter_manager") | |||
| 288 | return filterManager | 281 | return filterManager | ||
| 289 | } | 282 | } | ||
| 290 | 283 | ||||
D internal/logging/startup_trace.go +0 -220
| @@ -1,220 +0,0 @@ | |||||
| 1 | - | package logging | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "fmt" | |||
| 5 | - | "strings" | |||
| 6 | - | "sync" | |||
| 7 | - | "time" | |||
| 8 | - | ||||
| 9 | - | "github.com/rs/zerolog" | |||
| 10 | - | ) | |||
| 11 | - | ||||
| 12 | - | // StartupTrace tracks cold start milestones from process launch to first paint. | |||
| 13 | - | // Thread-safe for use across goroutines. | |||
| 14 | - | // Enabled only when log level is debug or trace. | |||
| 15 | - | type StartupTrace struct { | |||
| 16 | - | mu sync.Mutex | |||
| 17 | - | t0 time.Time | |||
| 18 | - | milestones []Milestone | |||
| 19 | - | enabled bool | |||
| 20 | - | logger *zerolog.Logger | |||
| 21 | - | buffered []Milestone // milestones recorded before logger is set | |||
| 22 | - | finished bool | |||
| 23 | - | } | |||
| 24 | - | ||||
| 25 | - | // Milestone represents a timing checkpoint during startup. | |||
| 26 | - | type Milestone struct { | |||
| 27 | - | Name string | |||
| 28 | - | Elapsed time.Duration // time since t0 | |||
| 29 | - | Delta time.Duration // time since previous milestone | |||
| 30 | - | } | |||
| 31 | - | ||||
| 32 | - | var ( | |||
| 33 | - | globalTrace *StartupTrace | |||
| 34 | - | globalTraceMu sync.Mutex | |||
| 35 | - | globalTraceOnce sync.Once | |||
| 36 | - | ) | |||
| 37 | - | ||||
| 38 | - | // InitStartupTrace initializes the global startup trace. | |||
| 39 | - | // Call this as early as possible in main() to capture T0. | |||
| 40 | - | // The trace is enabled only if the given log level is debug or trace. | |||
| 41 | - | func InitStartupTrace(logLevel string) { | |||
| 42 | - | globalTraceOnce.Do(func() { | |||
| 43 | - | enabled := logLevel == "debug" || logLevel == "trace" | |||
| 44 | - | globalTraceMu.Lock() | |||
| 45 | - | globalTrace = &StartupTrace{ | |||
| 46 | - | t0: time.Now(), | |||
| 47 | - | milestones: make([]Milestone, 0, 32), | |||
| 48 | - | enabled: enabled, | |||
| 49 | - | buffered: make([]Milestone, 0, 8), | |||
| 50 | - | } | |||
| 51 | - | globalTraceMu.Unlock() | |||
| 52 | - | ||||
| 53 | - | if enabled { | |||
| 54 | - | // Record process_start as first milestone | |||
| 55 | - | globalTrace.Mark("process_start") | |||
| 56 | - | } | |||
| 57 | - | }) | |||
| 58 | - | } | |||
| 59 | - | ||||
| 60 | - | // Trace returns the global startup trace instance. | |||
| 61 | - | // Returns a no-op trace if not initialized or disabled. | |||
| 62 | - | func Trace() *StartupTrace { | |||
| 63 | - | globalTraceMu.Lock() | |||
| 64 | - | defer globalTraceMu.Unlock() | |||
| 65 | - | if globalTrace == nil { | |||
| 66 | - | // Return a disabled no-op trace | |||
| 67 | - | return &StartupTrace{enabled: false} | |||
| 68 | - | } | |||
| 69 | - | return globalTrace | |||
| 70 | - | } | |||
| 71 | - | ||||
| 72 | - | // SetLogger sets the logger for emitting milestone logs. | |||
| 73 | - | // Flushes any buffered milestones that were recorded before the logger was set. | |||
| 74 | - | func (st *StartupTrace) SetLogger(logger *zerolog.Logger) { | |||
| 75 | - | if st == nil || !st.enabled { | |||
| 76 | - | return | |||
| 77 | - | } | |||
| 78 | - | ||||
| 79 | - | st.mu.Lock() | |||
| 80 | - | defer st.mu.Unlock() | |||
| 81 | - | ||||
| 82 | - | st.logger = logger | |||
| 83 | - | ||||
| 84 | - | // Flush buffered milestones | |||
| 85 | - | for _, m := range st.buffered { | |||
| 86 | - | st.emitMilestone(m) | |||
| 87 | - | } | |||
| 88 | - | st.buffered = nil | |||
| 89 | - | } | |||
| 90 | - | ||||
| 91 | - | // UpdateLogger updates the logger and re-emits all milestones to the new logger. | |||
| 92 | - | // Call this after the session file logger is available to capture milestones in the log file. | |||
| 93 | - | func (st *StartupTrace) UpdateLogger(logger *zerolog.Logger) { | |||
| 94 | - | if st == nil || !st.enabled { | |||
| 95 | - | return | |||
| 96 | - | } | |||
| 97 | - | ||||
| 98 | - | st.mu.Lock() | |||
| 99 | - | defer st.mu.Unlock() | |||
| 100 | - | ||||
| 101 | - | st.logger = logger | |||
| 102 | - | ||||
| 103 | - | // Re-emit all milestones to the new logger | |||
| 104 | - | for _, m := range st.milestones { | |||
| 105 | - | st.emitMilestone(m) | |||
| 106 | - | } | |||
| 107 | - | } | |||
| 108 | - | ||||
| 109 | - | // Mark records a milestone with the given name. | |||
| 110 | - | // Emits a debug log line showing elapsed time and delta from previous milestone. | |||
| 111 | - | func (st *StartupTrace) Mark(name string) { | |||
| 112 | - | if st == nil || !st.enabled { | |||
| 113 | - | return | |||
| 114 | - | } | |||
| 115 | - | ||||
| 116 | - | st.mu.Lock() | |||
| 117 | - | defer st.mu.Unlock() | |||
| 118 | - | ||||
| 119 | - | if st.finished { | |||
| 120 | - | return | |||
| 121 | - | } | |||
| 122 | - | ||||
| 123 | - | now := time.Now() | |||
| 124 | - | elapsed := now.Sub(st.t0) | |||
| 125 | - | ||||
| 126 | - | var delta time.Duration | |||
| 127 | - | if len(st.milestones) > 0 { | |||
| 128 | - | delta = elapsed - st.milestones[len(st.milestones)-1].Elapsed | |||
| 129 | - | } | |||
| 130 | - | ||||
| 131 | - | m := Milestone{ | |||
| 132 | - | Name: name, | |||
| 133 | - | Elapsed: elapsed, | |||
| 134 | - | Delta: delta, | |||
| 135 | - | } | |||
| 136 | - | st.milestones = append(st.milestones, m) | |||
| 137 | - | ||||
| 138 | - | if st.logger != nil { | |||
| 139 | - | st.emitMilestone(m) | |||
| 140 | - | } else { | |||
| 141 | - | // Buffer until logger is set | |||
| 142 | - | st.buffered = append(st.buffered, m) | |||
| 143 | - | } | |||
| 144 | - | } | |||
| 145 | - | ||||
| 146 | - | // emitMilestone logs a single milestone. Caller must hold mutex. | |||
| 147 | - | func (st *StartupTrace) emitMilestone(m Milestone) { | |||
| 148 | - | if st.logger == nil { | |||
| 149 | - | return | |||
| 150 | - | } | |||
| 151 | - | ||||
| 152 | - | elapsedMs := m.Elapsed.Milliseconds() | |||
| 153 | - | ||||
| 154 | - | event := st.logger.Debug(). | |||
| 155 | - | Str("milestone", m.Name). | |||
| 156 | - | Int64("t_ms", elapsedMs) | |||
| 157 | - | ||||
| 158 | - | if m.Delta > 0 { | |||
| 159 | - | event = event.Int64("delta_ms", m.Delta.Milliseconds()) | |||
| 160 | - | } | |||
| 161 | - | ||||
| 162 | - | // Format: startup_trace: milestone_name (T+123ms, +45ms) | |||
| 163 | - | if m.Delta > 0 { | |||
| 164 | - | event.Msgf("startup_trace: %s (T+%dms, +%dms)", m.Name, elapsedMs, m.Delta.Milliseconds()) | |||
| 165 | - | } else { | |||
| 166 | - | event.Msgf("startup_trace: %s (T+%dms)", m.Name, elapsedMs) | |||
| 167 | - | } | |||
| 168 | - | } | |||
| 169 | - | ||||
| 170 | - | // Finish marks the startup trace as complete and emits a summary. | |||
| 171 | - | // Called on first_paint milestone. | |||
| 172 | - | func (st *StartupTrace) Finish() { | |||
| 173 | - | if st == nil || !st.enabled { | |||
| 174 | - | return | |||
| 175 | - | } | |||
| 176 | - | ||||
| 177 | - | st.mu.Lock() | |||
| 178 | - | defer st.mu.Unlock() | |||
| 179 | - | ||||
| 180 | - | if st.finished { | |||
| 181 | - | return | |||
| 182 | - | } | |||
| 183 | - | st.finished = true | |||
| 184 | - | ||||
| 185 | - | if st.logger == nil { | |||
| 186 | - | return | |||
| 187 | - | } | |||
| 188 | - | ||||
| 189 | - | // Build summary string | |||
| 190 | - | total := time.Since(st.t0) | |||
| 191 | - | var parts []string | |||
| 192 | - | for _, m := range st.milestones { | |||
| 193 | - | parts = append(parts, fmt.Sprintf("%s:%d", m.Name, m.Elapsed.Milliseconds())) | |||
| 194 | - | } | |||
| 195 | - | summary := strings.Join(parts, ",") | |||
| 196 | - | ||||
| 197 | - | st.logger.Info(). | |||
| 198 | - | Int64("total_ms", total.Milliseconds()). | |||
| 199 | - | Str("milestones", summary). | |||
| 200 | - | Msg("startup_trace: cold start complete") | |||
| 201 | - | } | |||
| 202 | - | ||||
| 203 | - | // Enabled returns whether the trace is active. | |||
| 204 | - | func (st *StartupTrace) Enabled() bool { | |||
| 205 | - | if st == nil { | |||
| 206 | - | return false | |||
| 207 | - | } | |||
| 208 | - | return st.enabled | |||
| 209 | - | } | |||
| 210 | - | ||||
| 211 | - | // TotalElapsed returns the total time since T0. | |||
| 212 | - | func (st *StartupTrace) TotalElapsed() time.Duration { | |||
| 213 | - | if st == nil || !st.enabled { | |||
| 214 | - | return 0 | |||
| 215 | - | } | |||
| 216 | - | ||||
| 217 | - | st.mu.Lock() | |||
| 218 | - | defer st.mu.Unlock() | |||
| 219 | - | return time.Since(st.t0) | |||
| 220 | - | } | |||
M internal/ui/app.go +36 -17
| @@ -57,7 +57,10 @@ func gtkApplicationFlags() gio.ApplicationFlags { | |||||
| 57 | return gio.GApplicationNonUniqueValue | 57 | return gio.GApplicationNonUniqueValue | ||
| 58 | } | 58 | } | ||
| 59 | 59 | ||||
| 60 | - | var adwaitaInitOnce sync.Once | 60 | + | var ( |
| 61 | + | adwaitaInitOnce sync.Once | |||
| 62 | + | unrefTickCallback = glib.UnrefCallback | |||
| 63 | + | ) | |||
| 61 | 64 | ||||
| 62 | // EnsureAdwaitaInitialized initializes libadwaita and GTK exactly once. | 65 | // EnsureAdwaitaInitialized initializes libadwaita and GTK exactly once. | ||
| 63 | func EnsureAdwaitaInitialized() { | 66 | func EnsureAdwaitaInitialized() { | ||
| @@ -167,9 +170,12 @@ type floatingWorkspaceSession struct { | |||||
| 167 | omnibox *component.Omnibox | 170 | omnibox *component.Omnibox | ||
| 168 | omniboxWidget layout.Widget | 171 | omniboxWidget layout.Widget | ||
| 169 | resizeWatcherActive bool | 172 | resizeWatcherActive bool | ||
| 170 | - | resizeTickID uint | 173 | + | // resizeTickCallback keeps the purego callback live until its GTK source is |
| 171 | - | appliedWidth int | 174 | + | // removed. It is cleared exactly once with resizeTickID. |
| 172 | - | appliedHeight int | 175 | + | resizeTickCallback *gtk.TickCallback |
| 176 | + | resizeTickID uint | |||
| 177 | + | appliedWidth int | |||
| 178 | + | appliedHeight int | |||
| 173 | } | 179 | } | ||
| 174 | 180 | ||||
| 175 | type floatingSessionKey struct { | 181 | type floatingSessionKey struct { | ||
| @@ -289,7 +295,6 @@ func (a *App) Run(ctx context.Context, args []string) int { | |||||
| 289 | // Initialize libadwaita once (required before using StyleManager). | 295 | // Initialize libadwaita once (required before using StyleManager). | ||
| 290 | // This also initializes GTK implicitly. | 296 | // This also initializes GTK implicitly. | ||
| 291 | EnsureAdwaitaInitialized() | 297 | EnsureAdwaitaInitialized() | ||
| 292 | - | logging.Trace().Mark("gtk_init") | |||
| 293 | 298 | ||||
| 294 | // Mark adwaita detector as available now that adw.Init() is complete. | 299 | // Mark adwaita detector as available now that adw.Init() is complete. | ||
| 295 | // This enables the highest-priority color scheme detector. | 300 | // This enables the highest-priority color scheme detector. | ||
| @@ -311,7 +316,6 @@ func (a *App) Run(ctx context.Context, args []string) int { | |||||
| 311 | return 1 | 316 | return 1 | ||
| 312 | } | 317 | } | ||
| 313 | defer a.gtkApp.Unref() | 318 | defer a.gtkApp.Unref() | ||
| 314 | - | logging.Trace().Mark("gtk_app_created") | |||
| 315 | a.dispatchOnMainThread = a.runOnMainThread | 319 | a.dispatchOnMainThread = a.runOnMainThread | ||
| 316 | 320 | ||||
| 317 | // Connect activate signal | 321 | // Connect activate signal | ||
| @@ -347,7 +351,6 @@ func (a *App) onActivate(ctx context.Context) { | |||||
| 347 | log.Error().Err(err).Msg("failed to create main window") | 351 | log.Error().Err(err).Msg("failed to create main window") | ||
| 348 | return | 352 | return | ||
| 349 | } | 353 | } | ||
| 350 | - | logging.Trace().Mark("window_created") | |||
| 351 | 354 | ||||
| 352 | a.installCrashReportNotifier(ctx) | 355 | a.installCrashReportNotifier(ctx) | ||
| 353 | a.initFocusManager() | 356 | a.initFocusManager() | ||
| @@ -356,7 +359,6 @@ func (a *App) onActivate(ctx context.Context) { | |||||
| 356 | 359 | ||||
| 357 | a.initCoordinators(ctx) | 360 | a.initCoordinators(ctx) | ||
| 358 | a.wireWebRTCPermissionIndicator() | 361 | a.wireWebRTCPermissionIndicator() | ||
| 359 | - | logging.Trace().Mark("coordinators_init") | |||
| 360 | a.initKeyboardHandler(ctx) | 362 | a.initKeyboardHandler(ctx) | ||
| 361 | a.initOmniboxConfig(ctx) | 363 | a.initOmniboxConfig(ctx) | ||
| 362 | a.initFindBarConfig(ctx) | 364 | a.initFindBarConfig(ctx) | ||
| @@ -4594,22 +4596,22 @@ func (a *App) startFloatingResizeWatcher(session *floatingWorkspaceSession) { | |||||
| 4594 | 4596 | ||||
| 4595 | session.resizeWatcherActive = true | 4597 | session.resizeWatcherActive = true | ||
| 4596 | paneID := session.paneID | 4598 | paneID := session.paneID | ||
| 4597 | - | tickCallback := gtk.TickCallback(func(_ uintptr, _ uintptr, _ uintptr) bool { | 4599 | + | callback := new(gtk.TickCallback) |
| 4600 | + | *callback = func(_ uintptr, _ uintptr, _ uintptr) bool { | |||
| 4598 | liveSession := a.floatingSessionByPaneID(paneID) | 4601 | liveSession := a.floatingSessionByPaneID(paneID) | ||
| 4599 | if liveSession == nil { | 4602 | if liveSession == nil { | ||
| 4600 | - | session.resizeWatcherActive = false | 4603 | + | session.releaseResizeTickCallback() |
| 4601 | - | session.resizeTickID = 0 | |||
| 4602 | return false | 4604 | return false | ||
| 4603 | } | 4605 | } | ||
| 4604 | 4606 | ||||
| 4605 | keepRunning := a.handleFloatingViewportTick(liveSession) | 4607 | keepRunning := a.handleFloatingViewportTick(liveSession) | ||
| 4606 | if !keepRunning { | 4608 | if !keepRunning { | ||
| 4607 | - | liveSession.resizeWatcherActive = false | 4609 | + | liveSession.releaseResizeTickCallback() |
| 4608 | - | liveSession.resizeTickID = 0 | |||
| 4609 | } | 4610 | } | ||
| 4610 | return keepRunning | 4611 | return keepRunning | ||
| 4611 | - | }) | 4612 | + | } |
| 4612 | - | session.resizeTickID = overlayWidget.AddTickCallback(&tickCallback, 0, nil) | 4613 | + | session.resizeTickCallback = callback |
| 4614 | + | session.resizeTickID = overlayWidget.AddTickCallback(callback, 0, nil) | |||
| 4613 | } | 4615 | } | ||
| 4614 | 4616 | ||||
| 4615 | func (a *App) stopFloatingResizeWatcher(session *floatingWorkspaceSession) { | 4617 | func (a *App) stopFloatingResizeWatcher(session *floatingWorkspaceSession) { | ||
| @@ -4617,13 +4619,30 @@ func (a *App) stopFloatingResizeWatcher(session *floatingWorkspaceSession) { | |||||
| 4617 | return | 4619 | return | ||
| 4618 | } | 4620 | } | ||
| 4619 | 4621 | ||||
| 4620 | - | if session.resizeTickID != 0 && session.overlay != nil { | 4622 | + | tickID := session.resizeTickID |
| 4623 | + | session.releaseResizeTickCallback() | |||
| 4624 | + | if tickID != 0 && session.overlay != nil { | |||
| 4621 | if overlayWidget := session.overlay.GtkWidget(); overlayWidget != nil { | 4625 | if overlayWidget := session.overlay.GtkWidget(); overlayWidget != nil { | ||
| 4622 | - | overlayWidget.RemoveTickCallback(session.resizeTickID) | 4626 | + | overlayWidget.RemoveTickCallback(tickID) |
| 4623 | } | 4627 | } | ||
| 4624 | } | 4628 | } | ||
| 4629 | + | } | |||
| 4630 | + | ||||
| 4631 | + | // releaseResizeTickCallback releases the raw purego callback slot. It is safe | |||
| 4632 | + | // to call after GTK has already removed a callback that returned false. | |||
| 4633 | + | func (session *floatingWorkspaceSession) releaseResizeTickCallback() { | |||
| 4634 | + | if session == nil { | |||
| 4635 | + | return | |||
| 4636 | + | } | |||
| 4637 | + | callback := session.resizeTickCallback | |||
| 4625 | session.resizeTickID = 0 | 4638 | session.resizeTickID = 0 | ||
| 4639 | + | session.resizeTickCallback = nil | |||
| 4626 | session.resizeWatcherActive = false | 4640 | session.resizeWatcherActive = false | ||
| 4641 | + | if callback != nil { | |||
| 4642 | + | // AddTickCallback creates a purego function-pointer slot. Clearing the Go | |||
| 4643 | + | // pointer alone cannot return that finite runtime slot to the ledger. | |||
| 4644 | + | _ = unrefTickCallback(callback) | |||
| 4645 | + | } | |||
| 4627 | } | 4646 | } | ||
| 4628 | 4647 | ||||
| 4629 | func (a *App) hideFloatingSession(ctx context.Context, session *floatingWorkspaceSession) { | 4648 | func (a *App) hideFloatingSession(ctx context.Context, session *floatingWorkspaceSession) { | ||
M internal/ui/component/favorites_sidebar.go +15 -4
| @@ -55,6 +55,7 @@ type FavoritesSidebar struct { | |||||
| 55 | displayRows []favoriteSidebarDisplayRow | 55 | displayRows []favoriteSidebarDisplayRow | ||
| 56 | currentQuery string | 56 | currentQuery string | ||
| 57 | notice string | 57 | notice string | ||
| 58 | + | noticeLoadGen uint64 | |||
| 58 | loadGen uint64 | 59 | loadGen uint64 | ||
| 59 | destroyed bool | 60 | destroyed bool | ||
| 60 | visible bool | 61 | visible bool | ||
| @@ -263,6 +264,7 @@ func (fs *FavoritesSidebar) startLoad() { | |||||
| 263 | ctx := fs.ctx | 264 | ctx := fs.ctx | ||
| 264 | visible := fs.visible | 265 | visible := fs.visible | ||
| 265 | fs.notice = "Loading favorites..." | 266 | fs.notice = "Loading favorites..." | ||
| 267 | + | fs.noticeLoadGen = gen | |||
| 266 | fs.mu.Unlock() | 268 | fs.mu.Unlock() | ||
| 267 | if visible { | 269 | if visible { | ||
| 268 | fs.scheduleIdle(glib.SourceFunc(func(uintptr) bool { | 270 | fs.scheduleIdle(glib.SourceFunc(func(uintptr) bool { | ||
| @@ -311,10 +313,13 @@ func (fs *FavoritesSidebar) applyLoadedData(favorites []*entity.Favorite, tags [ | |||||
| 311 | } | 313 | } | ||
| 312 | fs.allFavorites = favorites | 314 | fs.allFavorites = favorites | ||
| 313 | fs.allTags = tags | 315 | fs.allTags = tags | ||
| 314 | - | if err != nil { | 316 | + | if fs.noticeLoadGen == gen { |
| 315 | - | fs.notice = err.Error() | 317 | + | fs.noticeLoadGen = 0 |
| 316 | - | } else { | 318 | + | if err != nil { |
| 317 | - | fs.notice = "" | 319 | + | fs.notice = err.Error() |
| 320 | + | } else { | |||
| 321 | + | fs.notice = "" | |||
| 322 | + | } | |||
| 318 | } | 323 | } | ||
| 319 | fs.rebuildDisplayRowsLocked() | 324 | fs.rebuildDisplayRowsLocked() | ||
| 320 | fs.mu.Unlock() | 325 | fs.mu.Unlock() | ||
| @@ -363,6 +368,12 @@ func (fs *FavoritesSidebar) rebuildDisplayRowsLocked() { | |||||
| 363 | fs.displayRows = buildFavoriteSidebarDisplayRows(filtered) | 368 | fs.displayRows = buildFavoriteSidebarDisplayRows(filtered) | ||
| 364 | } | 369 | } | ||
| 365 | 370 | ||||
| 371 | + | // setNoticeLocked records a notice not owned by an in-flight load. fs.mu must be held. | |||
| 372 | + | func (fs *FavoritesSidebar) setNoticeLocked(notice string) { | |||
| 373 | + | fs.notice = notice | |||
| 374 | + | fs.noticeLoadGen = 0 | |||
| 375 | + | } | |||
| 376 | + | ||||
| 366 | func (fs *FavoritesSidebar) scheduleIdle(cb glib.SourceFunc) { | 377 | func (fs *FavoritesSidebar) scheduleIdle(cb glib.SourceFunc) { | ||
| 367 | if fs != nil && fs.idleScheduler != nil { | 378 | if fs != nil && fs.idleScheduler != nil { | ||
| 368 | fs.idleScheduler(cb) | 379 | fs.idleScheduler(cb) | ||
M internal/ui/component/favorites_sidebar_forms.go +6 -6
| @@ -42,7 +42,7 @@ func (fs *FavoritesSidebar) beginAddForm() { | |||||
| 42 | fs.editingID = 0 | 42 | fs.editingID = 0 | ||
| 43 | fs.confirmDelete = false | 43 | fs.confirmDelete = false | ||
| 44 | fs.confirmDeleteID = 0 | 44 | fs.confirmDeleteID = 0 | ||
| 45 | - | fs.notice = "Add favorite: URL, title, comma-separated tag IDs, shortcut 1-9. Press Ctrl+Enter or Save." | 45 | + | fs.setNoticeLocked("Add favorite: URL, title, comma-separated tag IDs, shortcut 1-9. Press Ctrl+Enter or Save.") |
| 46 | fs.mu.Unlock() | 46 | fs.mu.Unlock() | ||
| 47 | fs.renderForm(nil) | 47 | fs.renderForm(nil) | ||
| 48 | fs.rebuildList() | 48 | fs.rebuildList() | ||
| @@ -63,7 +63,7 @@ func (fs *FavoritesSidebar) beginEditForm() { | |||||
| 63 | fs.editingID = fav.ID | 63 | fs.editingID = fav.ID | ||
| 64 | fs.confirmDelete = false | 64 | fs.confirmDelete = false | ||
| 65 | fs.confirmDeleteID = 0 | 65 | fs.confirmDeleteID = 0 | ||
| 66 | - | fs.notice = "Edit favorite: URL is read-only; use tag mode for tags. Press Ctrl+Enter or Save." | 66 | + | fs.setNoticeLocked("Edit favorite: URL is read-only; use tag mode for tags. Press Ctrl+Enter or Save.") |
| 67 | fs.mu.Unlock() | 67 | fs.mu.Unlock() | ||
| 68 | fs.renderForm(fav) | 68 | fs.renderForm(fav) | ||
| 69 | fs.rebuildList() | 69 | fs.rebuildList() | ||
| @@ -170,7 +170,7 @@ func (fs *FavoritesSidebar) cancelManagement() bool { | |||||
| 170 | fs.confirmDelete = false | 170 | fs.confirmDelete = false | ||
| 171 | fs.confirmDeleteID = 0 | 171 | fs.confirmDeleteID = 0 | ||
| 172 | if active { | 172 | if active { | ||
| 173 | - | fs.notice = "" | 173 | + | fs.setNoticeLocked("") |
| 174 | } | 174 | } | ||
| 175 | fs.mu.Unlock() | 175 | fs.mu.Unlock() | ||
| 176 | if fs.formBox != nil { | 176 | if fs.formBox != nil { | ||
| @@ -250,7 +250,7 @@ func (fs *FavoritesSidebar) setModeNotice(mode favoritesSidebarMode, notice stri | |||||
| 250 | fs.mode = mode | 250 | fs.mode = mode | ||
| 251 | fs.confirmDelete = false | 251 | fs.confirmDelete = false | ||
| 252 | fs.confirmDeleteID = 0 | 252 | fs.confirmDeleteID = 0 | ||
| 253 | - | fs.notice = notice | 253 | + | fs.setNoticeLocked(notice) |
| 254 | } | 254 | } | ||
| 255 | fs.mu.Unlock() | 255 | fs.mu.Unlock() | ||
| 256 | fs.rebuildList() | 256 | fs.rebuildList() | ||
| @@ -324,7 +324,7 @@ func (fs *FavoritesSidebar) requestDeleteConfirmation() bool { | |||||
| 324 | if !fs.destroyed { | 324 | if !fs.destroyed { | ||
| 325 | fs.confirmDelete = true | 325 | fs.confirmDelete = true | ||
| 326 | fs.confirmDeleteID = fav.ID | 326 | fs.confirmDeleteID = fav.ID | ||
| 327 | - | fs.notice = "Delete selected favorite? Press Delete again to confirm, Esc to cancel." | 327 | + | fs.setNoticeLocked("Delete selected favorite? Press Delete again to confirm, Esc to cancel.") |
| 328 | } | 328 | } | ||
| 329 | fs.mu.Unlock() | 329 | fs.mu.Unlock() | ||
| 330 | fs.rebuildList() | 330 | fs.rebuildList() | ||
| @@ -396,7 +396,7 @@ func (fs *FavoritesSidebar) selectedFavorite() *entity.Favorite { | |||||
| 396 | func (fs *FavoritesSidebar) setNotice(notice string) { | 396 | func (fs *FavoritesSidebar) setNotice(notice string) { | ||
| 397 | fs.mu.Lock() | 397 | fs.mu.Lock() | ||
| 398 | if !fs.destroyed { | 398 | if !fs.destroyed { | ||
| 399 | - | fs.notice = notice | 399 | + | fs.setNoticeLocked(notice) |
| 400 | } | 400 | } | ||
| 401 | fs.mu.Unlock() | 401 | fs.mu.Unlock() | ||
| 402 | fs.rebuildList() | 402 | fs.rebuildList() | ||
M internal/ui/component/favorites_sidebar_keyboard.go +1 -1
| @@ -463,7 +463,7 @@ func (fs *FavoritesSidebar) handleNavigationError(err error) { | |||||
| 463 | fs.mu.Unlock() | 463 | fs.mu.Unlock() | ||
| 464 | return | 464 | return | ||
| 465 | } | 465 | } | ||
| 466 | - | fs.notice = err.Error() | 466 | + | fs.setNoticeLocked(err.Error()) |
| 467 | fs.mu.Unlock() | 467 | fs.mu.Unlock() | ||
| 468 | fs.rebuildList() | 468 | fs.rebuildList() | ||
| 469 | } | 469 | } | ||
M internal/ui/component/favorites_sidebar_test.go +8 -0
| @@ -486,6 +486,8 @@ func TestFavoritesSidebarAddShortcutFailureClosesFormAfterCreate(t *testing.T) { | |||||
| 486 | uc := &fakeFavoritesSidebarUC{errOnSetShortcut: errors.New("shortcut failed")} | 486 | uc := &fakeFavoritesSidebarUC{errOnSetShortcut: errors.New("shortcut failed")} | ||
| 487 | fs := newFavoritesSidebarHarness(nil, nil) | 487 | fs := newFavoritesSidebarHarness(nil, nil) | ||
| 488 | fs.favoritesUC = uc | 488 | fs.favoritesUC = uc | ||
| 489 | + | loaded := make(chan glib.SourceFunc, 1) | |||
| 490 | + | fs.idleScheduler = func(cb glib.SourceFunc) { loaded <- cb } | |||
| 489 | fs.formURL = "https://add.test" | 491 | fs.formURL = "https://add.test" | ||
| 490 | fs.formTitle = "Added" | 492 | fs.formTitle = "Added" | ||
| 491 | fs.formShortcut = "3" | 493 | fs.formShortcut = "3" | ||
| @@ -493,6 +495,12 @@ func TestFavoritesSidebarAddShortcutFailureClosesFormAfterCreate(t *testing.T) { | |||||
| 493 | 495 | ||||
| 494 | assert.True(t, fs.submitForm()) | 496 | assert.True(t, fs.submitForm()) | ||
| 495 | require.Len(t, uc.addInputs, 1) | 497 | require.Len(t, uc.addInputs, 1) | ||
| 498 | + | select { | |||
| 499 | + | case cb := <-loaded: | |||
| 500 | + | cb(0) | |||
| 501 | + | case <-time.After(time.Second): | |||
| 502 | + | t.Fatal("timed out waiting for favorites reload") | |||
| 503 | + | } | |||
| 496 | assert.Equal(t, favoritesSidebarModeNone, fs.mode) | 504 | assert.Equal(t, favoritesSidebarModeNone, fs.mode) | ||
| 497 | assert.Contains(t, fs.notice, "failed to set shortcut") | 505 | assert.Contains(t, fs.notice, "failed to set shortcut") | ||
| 498 | } | 506 | } | ||
A internal/ui/component/omnibox_callback_pressure_stress_test.go +155 -0
| @@ -0,0 +1,155 @@ | |||||
| 1 | + | package component | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bufio" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "os" | |||
| 7 | + | "testing" | |||
| 8 | + | "time" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/purego" | |||
| 11 | + | "github.com/bnema/puregotk/v4/glib" | |||
| 12 | + | "github.com/bnema/puregotk/v4/gobject" | |||
| 13 | + | "github.com/bnema/puregotk/v4/gtk" | |||
| 14 | + | "github.com/stretchr/testify/require" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | const gtkCallbackStressIterations = 3_200 | |||
| 18 | + | ||||
| 19 | + | type callbackLedgerEvent struct { | |||
| 20 | + | Event string `json:"event"` | |||
| 21 | + | Type string `json:"type"` | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | // runGTKCallbackLifecycleStress exercises real GTK motion controllers and raw | |||
| 25 | + | // tick callbacks while p3 and p0 are repeatedly removed/rebuilt. It is never | |||
| 26 | + | // opt-in: GTK init is skipped only when the host has no usable native display. | |||
| 27 | + | func runGTKCallbackLifecycleStress(t *testing.T) { | |||
| 28 | + | t.Helper() | |||
| 29 | + | ledgerPath := t.TempDir() + "/purego-callback-ledger.jsonl" | |||
| 30 | + | t.Setenv("PUREGO_CALLBACK_LEDGER", "1") | |||
| 31 | + | t.Setenv("PUREGO_CALLBACK_LEDGER_FILE", ledgerPath) | |||
| 32 | + | if !gtk.InitCheck() { | |||
| 33 | + | t.Skip("GTK native display prerequisite unavailable (gtk.InitCheck returned false)") | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | mainContext := glib.MainContextDefault() | |||
| 37 | + | require.NotNil(t, mainContext) | |||
| 38 | + | done := make(chan struct{}) | |||
| 39 | + | stress := glib.SourceFunc(func(uintptr) bool { | |||
| 40 | + | defer close(done) | |||
| 41 | + | runGTKCallbackLifecycleStressOnMain(t, mainContext, ledgerPath) | |||
| 42 | + | return false | |||
| 43 | + | }) | |||
| 44 | + | // Invoke guarantees that the entire GTK mutation sequence is owned by the | |||
| 45 | + | // default context, whether the test thread owns it or a native GTK loop does. | |||
| 46 | + | mainContext.Invoke(&stress, 0) | |||
| 47 | + | select { | |||
| 48 | + | case <-done: | |||
| 49 | + | case <-time.After(30 * time.Second): | |||
| 50 | + | t.Fatal("GTK main context did not execute callback lifecycle stress") | |||
| 51 | + | } | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | func runGTKCallbackLifecycleStressOnMain(t *testing.T, mainContext *glib.MainContext, ledgerPath string) { | |||
| 55 | + | t.Helper() | |||
| 56 | + | assertMainContextOwner := func() { | |||
| 57 | + | t.Helper() | |||
| 58 | + | require.True(t, mainContext.IsOwner(), "GTK mutation must own the default GLib main context") | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | assertMainContextOwner() | |||
| 62 | + | root := gtk.NewBox(gtk.OrientationVerticalValue, 0) | |||
| 63 | + | assertMainContextOwner() | |||
| 64 | + | p0 := gtk.NewBox(gtk.OrientationVerticalValue, 0) | |||
| 65 | + | assertMainContextOwner() | |||
| 66 | + | p3 := gtk.NewBox(gtk.OrientationVerticalValue, 0) | |||
| 67 | + | require.NotNil(t, root) | |||
| 68 | + | require.NotNil(t, p0) | |||
| 69 | + | require.NotNil(t, p3) | |||
| 70 | + | assertMainContextOwner() | |||
| 71 | + | root.Append(&p0.Widget) | |||
| 72 | + | assertMainContextOwner() | |||
| 73 | + | root.Append(&p3.Widget) | |||
| 74 | + | ledgerStart := len(readCallbackLedgerEvents(t, ledgerPath)) | |||
| 75 | + | ||||
| 76 | + | enters, leaves := 0, 0 | |||
| 77 | + | for i := 0; i < gtkCallbackStressIterations; i++ { | |||
| 78 | + | pane := p0 | |||
| 79 | + | if i%2 != 0 { | |||
| 80 | + | pane = p3 | |||
| 81 | + | } | |||
| 82 | + | assertMainContextOwner() | |||
| 83 | + | controller := gtk.NewEventControllerMotion() | |||
| 84 | + | require.NotNil(t, controller) | |||
| 85 | + | enter := func(_ gtk.EventControllerMotion, _ float64, _ float64) { enters++ } | |||
| 86 | + | leave := func(_ gtk.EventControllerMotion) { leaves++ } | |||
| 87 | + | enterID := controller.ConnectEnter(&enter) | |||
| 88 | + | leaveID := controller.ConnectLeave(&leave) | |||
| 89 | + | assertMainContextOwner() | |||
| 90 | + | pane.AddController(&controller.EventController) | |||
| 91 | + | ||||
| 92 | + | // Invoke the real connected handler functions in GTK enter/leave order | |||
| 93 | + | // while the alternate pane is closed and the workspace rebuilt. | |||
| 94 | + | enter(*controller, 0, 0) | |||
| 95 | + | leave(*controller) | |||
| 96 | + | ||||
| 97 | + | tick := gtk.TickCallback(func(uintptr, uintptr, uintptr) bool { return false }) | |||
| 98 | + | tickCallback := glib.NewCallback(&tick) | |||
| 99 | + | assertMainContextOwner() | |||
| 100 | + | tickID := pane.AddTickCallback(&tick, 0, nil) | |||
| 101 | + | require.NotZero(t, tickID) | |||
| 102 | + | assertMainContextOwner() | |||
| 103 | + | pane.RemoveTickCallback(tickID) | |||
| 104 | + | require.NoError(t, purego.UnrefCallback(tickCallback), "raw tick slot must be released after GTK removal") | |||
| 105 | + | ||||
| 106 | + | assertMainContextOwner() | |||
| 107 | + | gobject.SignalHandlerDisconnect(gobject.ObjectNewFromInternalPtr(controller.GoPointer()), enterID) | |||
| 108 | + | assertMainContextOwner() | |||
| 109 | + | gobject.SignalHandlerDisconnect(gobject.ObjectNewFromInternalPtr(controller.GoPointer()), leaveID) | |||
| 110 | + | assertMainContextOwner() | |||
| 111 | + | pane.RemoveController(&controller.EventController) | |||
| 112 | + | ||||
| 113 | + | // Alternate p3 <-> p0 close/rebuild mutations on the same main context. | |||
| 114 | + | assertMainContextOwner() | |||
| 115 | + | root.Remove(&pane.Widget) | |||
| 116 | + | assertMainContextOwner() | |||
| 117 | + | root.Append(&pane.Widget) | |||
| 118 | + | } | |||
| 119 | + | require.Equal(t, gtkCallbackStressIterations, enters) | |||
| 120 | + | require.Equal(t, gtkCallbackStressIterations, leaves) | |||
| 121 | + | ||||
| 122 | + | events := readCallbackLedgerEvents(t, ledgerPath)[ledgerStart:] | |||
| 123 | + | var tickReleases, tickReuses int | |||
| 124 | + | for _, event := range events { | |||
| 125 | + | switch event.Event { | |||
| 126 | + | case "release-fnptr", "release": | |||
| 127 | + | tickReleases++ | |||
| 128 | + | case "reuse": | |||
| 129 | + | tickReuses++ | |||
| 130 | + | case "release-miss", "release-fnptr-miss", "exhausted": | |||
| 131 | + | t.Fatalf("callback ledger recorded %s for tick callback", event.Event) | |||
| 132 | + | } | |||
| 133 | + | } | |||
| 134 | + | require.Equal(t, gtkCallbackStressIterations, tickReleases, "every raw GTK tick slot must be released exactly once") | |||
| 135 | + | // The first motion-controller connection may initialize one shared GTK | |||
| 136 | + | // trampoline after the baseline; every subsequent raw tick slot is reused. | |||
| 137 | + | require.GreaterOrEqual(t, tickReuses, gtkCallbackStressIterations-2, "released raw tick slots must be reused") | |||
| 138 | + | } | |||
| 139 | + | ||||
| 140 | + | func readCallbackLedgerEvents(t *testing.T, path string) []callbackLedgerEvent { | |||
| 141 | + | t.Helper() | |||
| 142 | + | file, err := os.Open(path) | |||
| 143 | + | require.NoError(t, err) | |||
| 144 | + | defer file.Close() | |||
| 145 | + | ||||
| 146 | + | var events []callbackLedgerEvent | |||
| 147 | + | scanner := bufio.NewScanner(file) | |||
| 148 | + | for scanner.Scan() { | |||
| 149 | + | var event callbackLedgerEvent | |||
| 150 | + | require.NoError(t, json.Unmarshal(scanner.Bytes(), &event)) | |||
| 151 | + | events = append(events, event) | |||
| 152 | + | } | |||
| 153 | + | require.NoError(t, scanner.Err()) | |||
| 154 | + | return events | |||
| 155 | + | } | |||
M internal/ui/component/omnibox_callback_pressure_test.go +2 -8
| @@ -1,13 +1,7 @@ | |||||
| 1 | package component | 1 | package component | ||
| 2 | 2 | ||||
| 3 | - | import ( | 3 | + | import "testing" |
| 4 | - | "os" | |||
| 5 | - | "testing" | |||
| 6 | - | ) | |||
| 7 | 4 | ||||
| 8 | func TestOmniboxCallbackPressure_NoCrashSignal(t *testing.T) { | 5 | func TestOmniboxCallbackPressure_NoCrashSignal(t *testing.T) { | ||
| 9 | - | if os.Getenv("OMNIBOX_STRESS") != "1" { | 6 | + | runGTKCallbackLifecycleStress(t) |
| 10 | - | t.Skip("skipping: set OMNIBOX_STRESS=1 to run with GTK stress harness") | |||
| 11 | - | } | |||
| 12 | - | // TODO: wire GTK stress harness and assert no abrupt crash markers | |||
| 13 | } | 7 | } | ||
M internal/ui/component/pane_view.go +59 -39
| @@ -135,58 +135,73 @@ func (pv *PaneView) WebViewWidget() layout.Widget { | |||||
| 135 | return pv.webViewWidget | 135 | return pv.webViewWidget | ||
| 136 | } | 136 | } | ||
| 137 | 137 | ||||
| 138 | - | // SetWebViewWidget replaces the WebView widget. | 138 | + | // SetWebViewWidget replaces the WebView widget as not-yet-revealed content. |
| 139 | + | // Call AttachWebViewWidget when the content coordinator knows the WebView has | |||
| 140 | + | // already painted. | |||
| 139 | func (pv *PaneView) SetWebViewWidget(widget layout.Widget) { | 141 | func (pv *PaneView) SetWebViewWidget(widget layout.Widget) { | ||
| 142 | + | pv.AttachWebViewWidget(widget, false) | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | // AttachWebViewWidget replaces the WebView widget and applies its explicit | |||
| 146 | + | // content reveal state. GTK parentage is used only to satisfy GTK's one-parent | |||
| 147 | + | // ownership rule; it must not be used to infer whether CEF has painted. | |||
| 148 | + | func (pv *PaneView) AttachWebViewWidget(widget layout.Widget, revealed bool) { | |||
| 140 | pv.mu.Lock() | 149 | pv.mu.Lock() | ||
| 141 | defer pv.mu.Unlock() | 150 | defer pv.mu.Unlock() | ||
| 142 | 151 | ||||
| 143 | - | // Remove old widget from this overlay | 152 | + | // Remove old widget from this overlay. |
| 144 | if pv.webViewWidget != nil { | 153 | if pv.webViewWidget != nil { | ||
| 145 | pv.overlay.SetChild(nil) | 154 | pv.overlay.SetChild(nil) | ||
| 146 | } | 155 | } | ||
| 147 | 156 | ||||
| 148 | pv.webViewWidget = widget | 157 | pv.webViewWidget = widget | ||
| 158 | + | if widget == nil { | |||
| 159 | + | return | |||
| 160 | + | } | |||
| 149 | 161 | ||||
| 150 | - | if widget != nil { | 162 | + | overlayAllocWidthBefore := pv.overlay.GetAllocatedWidth() |
| 151 | - | overlayAllocWidthBefore := pv.overlay.GetAllocatedWidth() | 163 | + | overlayAllocHeightBefore := pv.overlay.GetAllocatedHeight() |
| 152 | - | overlayAllocHeightBefore := pv.overlay.GetAllocatedHeight() | 164 | + | widgetAllocWidthBefore := widget.GetAllocatedWidth() |
| 153 | - | widgetAllocWidthBefore := widget.GetAllocatedWidth() | 165 | + | widgetAllocHeightBefore := widget.GetAllocatedHeight() |
| 154 | - | widgetAllocHeightBefore := widget.GetAllocatedHeight() | 166 | + | hadParent := widget.GetParent() != nil |
| 155 | - | hadParent := widget.GetParent() != nil | 167 | + | wasVisible := widget.IsVisible() |
| 156 | - | wasVisible := widget.IsVisible() | 168 | + | |
| 157 | - | 169 | + | // GTK4 widgets may have only one parent. Cleanup during a WorkspaceView | |
| 158 | - | // If this widget already had a parent, we're reparenting an existing WebView | 170 | + | // rebuild leaves a reusable native widget unparented, which says nothing |
| 159 | - | // (e.g. after rebuilding/moving panes). In that case the WebView has likely | 171 | + | // about whether its content was previously revealed. |
| 160 | - | // already painted, so the loading skeleton should be hidden immediately. | 172 | + | if hadParent { |
| 161 | - | 173 | + | widget.Unparent() | |
| 162 | - | // Unparent widget from any previous parent (critical for rebuild scenarios) | 174 | + | } |
| 163 | - | // In GTK4, a widget can only have one parent at a time | 175 | + | pv.overlay.SetChild(widget) |
| 164 | - | if hadParent { | |||
| 165 | - | widget.Unparent() | |||
| 166 | - | } | |||
| 167 | - | pv.overlay.SetChild(widget) | |||
| 168 | 176 | ||||
| 169 | - | if hadParent && wasVisible { | 177 | + | if hadParent && wasVisible { |
| 170 | - | widget.SetVisible(true) | 178 | + | widget.SetVisible(true) |
| 171 | - | } | 179 | + | } |
| 172 | - | if hadParent && pv.loading != nil { | 180 | + | // A replacement can follow an already-revealed WebView in the same pane. |
| 173 | - | pv.loading.SetVisible(false) | 181 | + | // Apply both states explicitly: an unrevealed current WebView must restart |
| 174 | - | } | 182 | + | // the skeleton rather than inheriting the previous view's hidden overlay. |
| 183 | + | if pv.loading != nil { | |||
| 184 | + | pv.loading.SetVisible(!revealed) | |||
| 185 | + | } | |||
| 175 | 186 | ||||
| 176 | - | logging.FromContext(pv.ctx).Debug(). | 187 | + | ctx := pv.ctx |
| 177 | - | Str("pane_id", string(pv.paneID)). | 188 | + | if ctx == nil { |
| 178 | - | Bool("had_parent", hadParent). | 189 | + | ctx = context.Background() |
| 179 | - | Bool("was_visible", wasVisible). | |||
| 180 | - | Int("overlay_alloc_width_before", overlayAllocWidthBefore). | |||
| 181 | - | Int("overlay_alloc_height_before", overlayAllocHeightBefore). | |||
| 182 | - | Int("widget_alloc_width_before", widgetAllocWidthBefore). | |||
| 183 | - | Int("widget_alloc_height_before", widgetAllocHeightBefore). | |||
| 184 | - | Int("overlay_alloc_width_after", pv.overlay.GetAllocatedWidth()). | |||
| 185 | - | Int("overlay_alloc_height_after", pv.overlay.GetAllocatedHeight()). | |||
| 186 | - | Int("widget_alloc_width_after", widget.GetAllocatedWidth()). | |||
| 187 | - | Int("widget_alloc_height_after", widget.GetAllocatedHeight()). | |||
| 188 | - | Msg("pane view webview widget attached") | |||
| 189 | } | 190 | } | ||
| 191 | + | logging.FromContext(ctx).Debug(). | |||
| 192 | + | Str("pane_id", string(pv.paneID)). | |||
| 193 | + | Bool("had_parent", hadParent). | |||
| 194 | + | Bool("was_visible", wasVisible). | |||
| 195 | + | Bool("revealed", revealed). | |||
| 196 | + | Int("overlay_alloc_width_before", overlayAllocWidthBefore). | |||
| 197 | + | Int("overlay_alloc_height_before", overlayAllocHeightBefore). | |||
| 198 | + | Int("widget_alloc_width_before", widgetAllocWidthBefore). | |||
| 199 | + | Int("widget_alloc_height_before", widgetAllocHeightBefore). | |||
| 200 | + | Int("overlay_alloc_width_after", pv.overlay.GetAllocatedWidth()). | |||
| 201 | + | Int("overlay_alloc_height_after", pv.overlay.GetAllocatedHeight()). | |||
| 202 | + | Int("widget_alloc_width_after", widget.GetAllocatedWidth()). | |||
| 203 | + | Int("widget_alloc_height_after", widget.GetAllocatedHeight()). | |||
| 204 | + | Msg("pane view webview widget attached") | |||
| 190 | } | 205 | } | ||
| 191 | 206 | ||||
| 192 | // GrabFocus attempts to focus the WebView. | 207 | // GrabFocus attempts to focus the WebView. | ||
| @@ -516,6 +531,7 @@ func (pv *PaneView) Cleanup() { | |||||
| 516 | pv.onFocusIn = nil | 531 | pv.onFocusIn = nil | ||
| 517 | pv.onFocusOut = nil | 532 | pv.onFocusOut = nil | ||
| 518 | pv.onHover = nil | 533 | pv.onHover = nil | ||
| 534 | + | pv.onMouseMotion = nil | |||
| 519 | 535 | ||||
| 520 | // Detach hover handler if present | 536 | // Detach hover handler if present | ||
| 521 | if pv.hoverHandler != nil { | 537 | if pv.hoverHandler != nil { | ||
| @@ -530,6 +546,10 @@ func (pv *PaneView) Cleanup() { | |||||
| 530 | } | 546 | } | ||
| 531 | 547 | ||||
| 532 | // Clear other overlay children | 548 | // Clear other overlay children | ||
| 549 | + | if pv.loading != nil { | |||
| 550 | + | pv.overlay.RemoveOverlay(pv.loading.Widget()) | |||
| 551 | + | pv.loading = nil | |||
| 552 | + | } | |||
| 533 | if pv.progressBar != nil { | 553 | if pv.progressBar != nil { | ||
| 534 | pv.overlay.RemoveOverlay(pv.progressBar.Widget()) | 554 | pv.overlay.RemoveOverlay(pv.progressBar.Widget()) | ||
| 535 | pv.progressBar = nil | 555 | pv.progressBar = nil | ||
A internal/ui/component/pane_view_cleanup_test.go +13 -0
| @@ -0,0 +1,13 @@ | |||||
| 1 | + | package component | |||
| 2 | + | ||||
| 3 | + | import "testing" | |||
| 4 | + | ||||
| 5 | + | func TestPaneViewCleanupClearsMouseMotionCallback(t *testing.T) { | |||
| 6 | + | pv := &PaneView{onMouseMotion: func() { t.Fatal("stale callback invoked") }} | |||
| 7 | + | ||||
| 8 | + | pv.Cleanup() | |||
| 9 | + | ||||
| 10 | + | if pv.onMouseMotion != nil { | |||
| 11 | + | t.Fatal("Cleanup must release the mouse-motion callback slot") | |||
| 12 | + | } | |||
| 13 | + | } | |||
M internal/ui/component/pane_view_internal_test.go +35 -0
| @@ -3,6 +3,7 @@ package component | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "testing" | 4 | "testing" | ||
| 5 | 5 | ||||
| 6 | + | "github.com/stretchr/testify/assert" | |||
| 6 | "github.com/stretchr/testify/mock" | 7 | "github.com/stretchr/testify/mock" | ||
| 7 | 8 | ||||
| 8 | "github.com/bnema/dumber/internal/ui/layout" | 9 | "github.com/bnema/dumber/internal/ui/layout" | ||
| @@ -80,3 +81,37 @@ func TestPaneView_HideLoadingSkeleton_NoOpWhenNil(t *testing.T) { | |||||
| 80 | pv := &PaneView{} | 81 | pv := &PaneView{} | ||
| 81 | pv.HideLoadingSkeleton() | 82 | pv.HideLoadingSkeleton() | ||
| 82 | } | 83 | } | ||
| 84 | + | ||||
| 85 | + | func TestPaneView_AttachWebViewWidget_RevealedThenUnrevealedReplacementRestartsSkeleton(t *testing.T) { | |||
| 86 | + | overlay := mocks.NewMockOverlayWidget(t) | |||
| 87 | + | revealedWidget := mocks.NewMockWidget(t) | |||
| 88 | + | unrevealedWidget := mocks.NewMockWidget(t) | |||
| 89 | + | loadingContainer := mocks.NewMockBoxWidget(t) | |||
| 90 | + | spinner := mocks.NewMockSpinnerWidget(t) | |||
| 91 | + | ||||
| 92 | + | overlay.EXPECT().GetAllocatedWidth().Return(0).Times(4) | |||
| 93 | + | overlay.EXPECT().GetAllocatedHeight().Return(0).Times(4) | |||
| 94 | + | for _, widget := range []*mocks.MockWidget{revealedWidget, unrevealedWidget} { | |||
| 95 | + | widget.EXPECT().GetAllocatedWidth().Return(0).Twice() | |||
| 96 | + | widget.EXPECT().GetAllocatedHeight().Return(0).Twice() | |||
| 97 | + | widget.EXPECT().GetParent().Return(nil).Once() | |||
| 98 | + | widget.EXPECT().IsVisible().Return(true).Once() | |||
| 99 | + | } | |||
| 100 | + | overlay.EXPECT().SetChild(revealedWidget).Once() | |||
| 101 | + | loadingContainer.EXPECT().SetVisible(false).Once() | |||
| 102 | + | spinner.EXPECT().Stop().Once() | |||
| 103 | + | overlay.EXPECT().SetChild(nil).Once() | |||
| 104 | + | overlay.EXPECT().SetChild(unrevealedWidget).Once() | |||
| 105 | + | loadingContainer.EXPECT().SetVisible(true).Once() | |||
| 106 | + | spinner.EXPECT().Start().Once() | |||
| 107 | + | ||||
| 108 | + | pv := &PaneView{ | |||
| 109 | + | overlay: overlay, | |||
| 110 | + | loading: &LoadingSkeleton{container: loadingContainer, spinner: spinner}, | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | pv.AttachWebViewWidget(revealedWidget, true) | |||
| 114 | + | pv.AttachWebViewWidget(unrevealedWidget, false) | |||
| 115 | + | ||||
| 116 | + | assert.Same(t, unrevealedWidget, pv.WebViewWidget()) | |||
| 117 | + | } | |||
M internal/ui/component/workspace_view.go +18 -2
| @@ -192,6 +192,14 @@ func (wv *WorkspaceView) SetWorkspace(ctx context.Context, ws *entity.Workspace) | |||||
| 192 | wv.mu.Lock() | 192 | wv.mu.Lock() | ||
| 193 | defer wv.mu.Unlock() | 193 | defer wv.mu.Unlock() | ||
| 194 | 194 | ||||
| 195 | + | // Release previous views before dropping their ownership. This also detaches | |||
| 196 | + | // hover controllers and callback closures before their widgets are rebuilt. | |||
| 197 | + | for _, pv := range wv.paneViews { | |||
| 198 | + | if pv != nil { | |||
| 199 | + | pv.Cleanup() | |||
| 200 | + | } | |||
| 201 | + | } | |||
| 202 | + | ||||
| 195 | // Clear previous state | 203 | // Clear previous state | ||
| 196 | wv.workspace = ws | 204 | wv.workspace = ws | ||
| 197 | wv.paneViews = make(map[entity.PaneID]*PaneView) | 205 | wv.paneViews = make(map[entity.PaneID]*PaneView) | ||
| @@ -474,8 +482,16 @@ func (wv *WorkspaceView) DeactivatePane(paneID entity.PaneID) { | |||||
| 474 | } | 482 | } | ||
| 475 | } | 483 | } | ||
| 476 | 484 | ||||
| 477 | - | // SetWebViewWidget attaches a WebView widget to a specific pane. | 485 | + | // SetWebViewWidget attaches a not-yet-revealed WebView widget to a specific pane. |
| 486 | + | // Call AttachWebViewWidget when the content coordinator has explicit reveal state. | |||
| 478 | func (wv *WorkspaceView) SetWebViewWidget(paneID entity.PaneID, widget layout.Widget) error { | 487 | func (wv *WorkspaceView) SetWebViewWidget(paneID entity.PaneID, widget layout.Widget) error { | ||
| 488 | + | return wv.AttachWebViewWidget(paneID, widget, false) | |||
| 489 | + | } | |||
| 490 | + | ||||
| 491 | + | // AttachWebViewWidget attaches a WebView widget with the content coordinator's | |||
| 492 | + | // explicit reveal state. A WorkspaceView rebuild can unparent a live widget, so | |||
| 493 | + | // widget parentage cannot determine whether its loading skeleton is still needed. | |||
| 494 | + | func (wv *WorkspaceView) AttachWebViewWidget(paneID entity.PaneID, widget layout.Widget, revealed bool) error { | |||
| 479 | wv.mu.RLock() | 495 | wv.mu.RLock() | ||
| 480 | pv, ok := wv.paneViews[paneID] | 496 | pv, ok := wv.paneViews[paneID] | ||
| 481 | callback := wv.onWebViewAttached | 497 | callback := wv.onWebViewAttached | ||
| @@ -485,7 +501,7 @@ func (wv *WorkspaceView) SetWebViewWidget(paneID entity.PaneID, widget layout.Wi | |||||
| 485 | return ErrPaneNotFound | 501 | return ErrPaneNotFound | ||
| 486 | } | 502 | } | ||
| 487 | 503 | ||||
| 488 | - | pv.SetWebViewWidget(widget) | 504 | + | pv.AttachWebViewWidget(widget, revealed) |
| 489 | if widget != nil && callback != nil { | 505 | if widget != nil && callback != nil { | ||
| 490 | callback(paneID) | 506 | callback(paneID) | ||
| 491 | } | 507 | } | ||
M internal/ui/coordinator/content/callbacks.go +9 -4
| @@ -62,8 +62,13 @@ func buildCrashPageURI(originalURI string) string { | |||||
| 62 | // setupWebViewCallbacks configures standard callbacks and popup handling. | 62 | // setupWebViewCallbacks configures standard callbacks and popup handling. | ||
| 63 | func (c *Coordinator) setupWebViewCallbacks(ctx context.Context, paneID entity.PaneID, wv port.WebView) { | 63 | func (c *Coordinator) setupWebViewCallbacks(ctx context.Context, paneID entity.PaneID, wv port.WebView) { | ||
| 64 | log := logging.FromContext(ctx) | 64 | log := logging.FromContext(ctx) | ||
| 65 | + | identity, ok := identityForWebView(wv) | |||
| 66 | + | if !ok { | |||
| 67 | + | return | |||
| 68 | + | } | |||
| 65 | 69 | ||||
| 66 | - | // Build port callbacks for standard events | 70 | + | // Capture the association at installation, rather than reading mutable |
| 71 | + | // WebView identity from a callback that can outlive a pool reuse. | |||
| 67 | callbacks := &port.WebViewCallbacks{ | 72 | callbacks := &port.WebViewCallbacks{ | ||
| 68 | OnTitleChanged: func(title string) { | 73 | OnTitleChanged: func(title string) { | ||
| 69 | c.onTitleChanged(ctx, paneID, title) | 74 | c.onTitleChanged(ctx, paneID, title) | ||
| @@ -73,13 +78,13 @@ func (c *Coordinator) setupWebViewCallbacks(ctx context.Context, paneID entity.P | |||||
| 73 | case port.LoadStarted: | 78 | case port.LoadStarted: | ||
| 74 | c.onLoadStarted(paneID) | 79 | c.onLoadStarted(paneID) | ||
| 75 | case port.LoadCommitted: | 80 | case port.LoadCommitted: | ||
| 76 | - | c.onLoadCommitted(ctx, paneID, wv) | 81 | + | c.onLoadCommitted(ctx, paneID, wv, identity) |
| 77 | case port.LoadFinished: | 82 | case port.LoadFinished: | ||
| 78 | - | c.onLoadFinished(ctx, paneID, wv) | 83 | + | c.onLoadFinished(ctx, paneID, wv, identity) |
| 79 | } | 84 | } | ||
| 80 | }, | 85 | }, | ||
| 81 | OnProgressChanged: func(progress float64) { | 86 | OnProgressChanged: func(progress float64) { | ||
| 82 | - | c.onProgressChanged(paneID, progress) | 87 | + | c.onProgressChanged(paneID, wv, identity, progress) |
| 83 | }, | 88 | }, | ||
| 84 | OnURIChanged: func(uri string) { | 89 | OnURIChanged: func(uri string) { | ||
| 85 | c.handleURIChanged(ctx, paneID, wv, uri) | 90 | c.handleURIChanged(ctx, paneID, wv, uri) | ||
M internal/ui/coordinator/content/content_reveal_test.go +166 -167
| @@ -2,10 +2,10 @@ package content | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | - | "sync" | |||
| 6 | "testing" | 5 | "testing" | ||
| 7 | 6 | ||||
| 8 | "github.com/stretchr/testify/assert" | 7 | "github.com/stretchr/testify/assert" | ||
| 8 | + | "github.com/stretchr/testify/mock" | |||
| 9 | 9 | ||||
| 10 | "github.com/bnema/dumber/internal/application/port" | 10 | "github.com/bnema/dumber/internal/application/port" | ||
| 11 | "github.com/bnema/dumber/internal/application/port/mocks" | 11 | "github.com/bnema/dumber/internal/application/port/mocks" | ||
| @@ -13,201 +13,200 @@ import ( | |||||
| 13 | "github.com/bnema/dumber/internal/ui/component" | 13 | "github.com/bnema/dumber/internal/ui/component" | ||
| 14 | ) | 14 | ) | ||
| 15 | 15 | ||||
| 16 | - | // TestPendingReveal tests the pending reveal state management. | 16 | + | func newRevealTestCoordinator() *Coordinator { |
| 17 | - | // These are internal package tests that verify the state transitions | 17 | + | return &Coordinator{ |
| 18 | - | // without needing to mock WebKit dependencies. | 18 | + | webViews: make(map[entity.PaneID]port.WebView), |
| 19 | - | 19 | + | webViewPaneIDs: make(map[port.WebViewID]entity.PaneID), | |
| 20 | - | func TestMarkPendingReveal_SetsState(t *testing.T) { | 20 | + | paneTitles: make(map[entity.PaneID]string), |
| 21 | - | c := &Coordinator{ | 21 | + | navOrigins: make(map[entity.PaneID]string), |
| 22 | - | pendingReveal: make(map[entity.PaneID]bool), | |||
| 23 | } | 22 | } | ||
| 24 | - | paneID := entity.PaneID("pane-1") | 23 | + | } |
| 25 | - | ||||
| 26 | - | c.markPendingReveal(paneID) | |||
| 27 | 24 | ||||
| 28 | - | c.revealMu.Lock() | 25 | + | func revealTestWebView(t *testing.T, id port.WebViewID, generation uint64) *mocks.MockWebView { |
| 29 | - | defer c.revealMu.Unlock() | 26 | + | t.Helper() |
| 30 | - | assert.True(t, c.pendingReveal[paneID]) | 27 | + | wv := mocks.NewMockWebView(t) |
| 28 | + | wv.EXPECT().ID().Return(id).Maybe() | |||
| 29 | + | wv.EXPECT().Generation().Return(generation).Maybe() | |||
| 30 | + | return wv | |||
| 31 | } | 31 | } | ||
| 32 | 32 | ||||
| 33 | - | func TestClearPendingReveal_RemovesState(t *testing.T) { | 33 | + | func TestRevealState_IsBoundToCurrentWebViewAcrossReplacementPaths(t *testing.T) { |
| 34 | - | c := &Coordinator{ | |||
| 35 | - | pendingReveal: make(map[entity.PaneID]bool), | |||
| 36 | - | } | |||
| 37 | paneID := entity.PaneID("pane-1") | 34 | paneID := entity.PaneID("pane-1") | ||
| 35 | + | first := revealTestWebView(t, 101, 1) | |||
| 36 | + | // The pool may reuse the same native WebView ID; its reuse generation is | |||
| 37 | + | // part of the presentation identity. | |||
| 38 | + | directReplacement := revealTestWebView(t, 101, 2) | |||
| 39 | + | popupReplacement := revealTestWebView(t, 103, 1) | |||
| 40 | + | c := newRevealTestCoordinator() | |||
| 41 | + | ||||
| 42 | + | c.setWebViewLocked(paneID, first) | |||
| 43 | + | firstIdentity, _ := identityForWebView(first) | |||
| 44 | + | c.markWebViewRevealed(paneID, firstIdentity) | |||
| 45 | + | assert.True(t, c.WebViewRevealed(paneID)) | |||
| 46 | + | ||||
| 47 | + | // A direct replacement for the same pane must show the fresh loading state. | |||
| 48 | + | c.setWebViewLocked(paneID, directReplacement) | |||
| 49 | + | assert.False(t, c.WebViewRevealed(paneID)) | |||
| 50 | + | ||||
| 51 | + | directReplacementIdentity, _ := identityForWebView(directReplacement) | |||
| 52 | + | c.markWebViewRevealed(paneID, directReplacementIdentity) | |||
| 53 | + | c.RegisterPopupWebView(paneID, popupReplacement) | |||
| 54 | + | assert.False(t, c.WebViewRevealed(paneID), "popup registration must not inherit a prior WebView reveal") | |||
| 55 | + | } | |||
| 38 | 56 | ||||
| 39 | - | // First mark as pending | 57 | + | func TestRevealState_StoresIdentityAsTheOnlyStateValue(t *testing.T) { |
| 40 | - | c.markPendingReveal(paneID) | 58 | + | paneID := entity.PaneID("pane-1") |
| 59 | + | wv := revealTestWebView(t, 101, 1) | |||
| 60 | + | c := newRevealTestCoordinator() | |||
| 61 | + | c.setWebViewLocked(paneID, wv) | |||
| 62 | + | identity, _ := identityForWebView(wv) | |||
| 41 | 63 | ||||
| 42 | - | // Then clear | 64 | + | c.markPendingReveal(paneID, identity) |
| 43 | - | c.clearPendingReveal(paneID) | 65 | + | c.markWebViewRevealed(paneID, identity) |
| 44 | 66 | ||||
| 45 | c.revealMu.Lock() | 67 | c.revealMu.Lock() | ||
| 46 | defer c.revealMu.Unlock() | 68 | defer c.revealMu.Unlock() | ||
| 47 | - | assert.False(t, c.pendingReveal[paneID]) | 69 | + | assert.Equal(t, identity, c.pendingReveal[paneID]) |
| 70 | + | assert.Equal(t, identity, c.revealedWebViews[paneID]) | |||
| 48 | } | 71 | } | ||
| 49 | 72 | ||||
| 50 | - | func TestRevealIfPending_NotPending_DoesNothing(t *testing.T) { | 73 | + | func TestRevealState_ReleaseClearsStateWithoutWebViewMapping(t *testing.T) { |
| 51 | - | callbackCalled := false | |||
| 52 | - | c := &Coordinator{ | |||
| 53 | - | pendingReveal: make(map[entity.PaneID]bool), | |||
| 54 | - | webViews: make(map[entity.PaneID]port.WebView), | |||
| 55 | - | onWebViewShown: func(paneID entity.PaneID) { | |||
| 56 | - | callbackCalled = true | |||
| 57 | - | }, | |||
| 58 | - | } | |||
| 59 | paneID := entity.PaneID("pane-1") | 74 | paneID := entity.PaneID("pane-1") | ||
| 75 | + | wv := revealTestWebView(t, 101, 1) | |||
| 76 | + | c := newRevealTestCoordinator() | |||
| 77 | + | c.setWebViewLocked(paneID, wv) | |||
| 78 | + | identity, _ := identityForWebView(wv) | |||
| 79 | + | c.markWebViewRevealed(paneID, identity) | |||
| 80 | + | ||||
| 81 | + | // Model an earlier partial cleanup that lost the WebView mapping. Release | |||
| 82 | + | // remains responsible for clearing presentation state on its early return. | |||
| 83 | + | c.webViewsMu.Lock() | |||
| 84 | + | delete(c.webViews, paneID) | |||
| 85 | + | c.webViewsMu.Unlock() | |||
| 86 | + | c.ReleaseWebView(context.Background(), paneID) | |||
| 60 | 87 | ||||
| 61 | - | // Call reveal without marking pending first | 88 | + | c.revealMu.Lock() |
| 62 | - | c.revealIfPending(context.Background(), paneID, "http://example.com", "test") | 89 | + | _, retained := c.revealedWebViews[paneID] |
| 63 | - | 90 | + | c.revealMu.Unlock() | |
| 64 | - | assert.False(t, callbackCalled, "callback should not be called when not pending") | 91 | + | assert.False(t, retained) |
| 65 | } | 92 | } | ||
| 66 | 93 | ||||
| 67 | - | func TestRevealIfPending_ClearsStateOnReveal(t *testing.T) { | 94 | + | func TestRevealState_ReleaseMappedWebViewClearsRevealStateAndReturnsToPool(t *testing.T) { |
| 68 | - | c := &Coordinator{ | |||
| 69 | - | pendingReveal: make(map[entity.PaneID]bool), | |||
| 70 | - | webViews: make(map[entity.PaneID]port.WebView), | |||
| 71 | - | } | |||
| 72 | paneID := entity.PaneID("pane-1") | 95 | paneID := entity.PaneID("pane-1") | ||
| 73 | - | 96 | + | wv := revealTestWebView(t, 101, 1) | |
| 74 | - | // Mark as pending | 97 | + | pool := mocks.NewMockWebViewPool(t) |
| 75 | - | c.markPendingReveal(paneID) | 98 | + | pool.EXPECT().Release(wv).Once() |
| 76 | - | 99 | + | c := newRevealTestCoordinator() | |
| 77 | - | // Reveal (will return early since webview is nil, but should still clear state) | 100 | + | c.pool = pool |
| 78 | - | c.revealIfPending(context.Background(), paneID, "http://example.com", "test") | 101 | + | c.setWebViewLocked(paneID, wv) |
| 79 | - | 102 | + | identity, _ := identityForWebView(wv) | |
| 80 | - | c.revealMu.Lock() | 103 | + | c.markWebViewRevealed(paneID, identity) |
| 81 | - | defer c.revealMu.Unlock() | 104 | + | |
| 82 | - | assert.False(t, c.pendingReveal[paneID], "pending state should be cleared after reveal attempt") | 105 | + | c.ReleaseWebView(context.Background(), paneID) |
| 106 | + | ||||
| 107 | + | assert.Nil(t, c.GetWebView(paneID)) | |||
| 108 | + | assert.False(t, c.WebViewRevealed(paneID)) | |||
| 83 | } | 109 | } | ||
| 84 | 110 | ||||
| 85 | - | func TestRevealIfPending_OnlyRevealsOnce(t *testing.T) { | 111 | + | func TestRevealState_StaleCallbackCannotRevealReplacement(t *testing.T) { |
| 86 | - | revealCount := 0 | |||
| 87 | - | c := &Coordinator{ | |||
| 88 | - | pendingReveal: make(map[entity.PaneID]bool), | |||
| 89 | - | webViews: make(map[entity.PaneID]port.WebView), | |||
| 90 | - | onWebViewShown: func(paneID entity.PaneID) { | |||
| 91 | - | revealCount++ | |||
| 92 | - | }, | |||
| 93 | - | } | |||
| 94 | paneID := entity.PaneID("pane-1") | 112 | paneID := entity.PaneID("pane-1") | ||
| 95 | - | 113 | + | oldWV := revealTestWebView(t, 101, 1) | |
| 96 | - | // Mark as pending | 114 | + | newWV := revealTestWebView(t, 102, 1) |
| 97 | - | c.markPendingReveal(paneID) | 115 | + | oldWV.EXPECT().IsDestroyed().Return(false).Maybe() |
| 98 | - | 116 | + | c := newRevealTestCoordinator() | |
| 99 | - | // Call reveal multiple times | 117 | + | c.setWebViewLocked(paneID, oldWV) |
| 100 | - | c.revealIfPending(context.Background(), paneID, "", "first") | 118 | + | oldIdentity, _ := identityForWebView(oldWV) |
| 101 | - | c.revealIfPending(context.Background(), paneID, "", "second") | 119 | + | c.markPendingReveal(paneID, oldIdentity) |
| 102 | - | c.revealIfPending(context.Background(), paneID, "", "third") | 120 | + | c.setWebViewLocked(paneID, newWV) |
| 103 | - | 121 | + | ||
| 104 | - | // Callback should not be called since webview is nil, but state should be cleared | 122 | + | c.revealIfPending(context.Background(), paneID, oldWV, oldIdentity, "", "stale") |
| 105 | - | assert.Equal(t, 0, revealCount, "callback not called when webview is nil") | 123 | + | assert.False(t, c.WebViewRevealed(paneID)) |
| 106 | - | ||||
| 107 | - | // Verify state is cleared | |||
| 108 | - | c.revealMu.Lock() | |||
| 109 | - | defer c.revealMu.Unlock() | |||
| 110 | - | assert.False(t, c.pendingReveal[paneID]) | |||
| 111 | } | 124 | } | ||
| 112 | 125 | ||||
| 113 | - | func TestOnLoadCommitted_RevealsPendingWebViewForCommittedPage(t *testing.T) { | 126 | + | func TestRevealState_RevealedCurrentWebViewPersistsForWorkspaceRebuild(t *testing.T) { |
| 114 | paneID := entity.PaneID("pane-1") | 127 | paneID := entity.PaneID("pane-1") | ||
| 115 | - | shownCount := 0 | 128 | + | wv := revealTestWebView(t, 101, 1) |
| 116 | - | var shownPane entity.PaneID | 129 | + | c := newRevealTestCoordinator() |
| 130 | + | c.setWebViewLocked(paneID, wv) | |||
| 131 | + | identity, _ := identityForWebView(wv) | |||
| 132 | + | c.markWebViewRevealed(paneID, identity) | |||
| 133 | + | ||||
| 134 | + | // AttachToWorkspace asks this identity-aware query when rebuilding a pane, | |||
| 135 | + | // so the replacement PaneView hides its skeleton for this same WebView. | |||
| 136 | + | assert.True(t, c.webViewRevealed(paneID, wv)) | |||
| 137 | + | ||||
| 138 | + | replacement := revealTestWebView(t, 102, 1) | |||
| 139 | + | assert.False(t, c.webViewRevealed(paneID, replacement)) | |||
| 140 | + | } | |||
| 117 | 141 | ||||
| 142 | + | func TestRevealCallback_CapturesGenerationBeforeSameObjectPoolReuse(t *testing.T) { | |||
| 143 | + | paneID := entity.PaneID("pane-1") | |||
| 118 | wv := mocks.NewMockWebView(t) | 144 | wv := mocks.NewMockWebView(t) | ||
| 119 | - | wv.EXPECT().URI().Return("https://example.com") | 145 | + | generation := uint64(1) |
| 120 | - | wv.EXPECT().ResetBackgroundToDefault() | 146 | + | wv.EXPECT().ID().Return(port.WebViewID(101)).Maybe() |
| 121 | - | wv.EXPECT().Title().Return("") | 147 | + | wv.EXPECT().Generation().RunAndReturn(func() uint64 { return generation }).Maybe() |
| 122 | wv.EXPECT().IsDestroyed().Return(false).Maybe() | 148 | wv.EXPECT().IsDestroyed().Return(false).Maybe() | ||
| 123 | - | 149 | + | var callbacks *port.WebViewCallbacks | |
| 124 | - | c := &Coordinator{ | 150 | + | wv.EXPECT().SetCallbacks(mock.Anything).RunAndReturn(func(value *port.WebViewCallbacks) { |
| 125 | - | pendingReveal: make(map[entity.PaneID]bool), | 151 | + | callbacks = value |
| 126 | - | webViews: map[entity.PaneID]port.WebView{ | 152 | + | }).Once() |
| 127 | - | paneID: wv, | 153 | + | |
| 128 | - | }, | 154 | + | c := newRevealTestCoordinator() |
| 129 | - | paneTitles: make(map[entity.PaneID]string), | 155 | + | c.getActiveWS = func() (*entity.Workspace, *component.WorkspaceView) { return nil, nil } |
| 130 | - | navOrigins: make(map[entity.PaneID]string), | 156 | + | c.setWebViewLocked(paneID, wv) |
| 131 | - | getActiveWS: func() (*entity.Workspace, *component.WorkspaceView) { return nil, nil }, | 157 | + | installedIdentity, _ := identityForWebView(wv) |
| 132 | - | onWebViewShown: func(id entity.PaneID) { | 158 | + | c.markPendingReveal(paneID, installedIdentity) |
| 133 | - | shownCount++ | 159 | + | c.setupWebViewCallbacks(context.Background(), paneID, wv) |
| 134 | - | shownPane = id | 160 | + | revealed := false |
| 135 | - | }, | 161 | + | c.setWebViewVisible = func(port.WebView) { revealed = true } |
| 136 | - | } | 162 | + | |
| 137 | - | c.pendingReveal[paneID] = true | 163 | + | // The callback was installed at generation 1. A pool reuse can advance the |
| 138 | - | 164 | + | // same Go WebView object before its old callback is delivered. | |
| 139 | - | c.onLoadCommitted(context.Background(), paneID, wv) | 165 | + | generation = 2 |
| 140 | - | 166 | + | callbacks.OnProgressChanged(1) | |
| 141 | - | assert.Equal(t, 1, shownCount) | 167 | + | |
| 142 | - | assert.Equal(t, paneID, shownPane) | 168 | + | assert.False(t, revealed, "the generation-1 callback must not reveal generation 2") |
| 143 | - | } | 169 | + | assert.False(t, c.WebViewRevealed(paneID)) |
| 144 | - | ||||
| 145 | - | func TestPendingReveal_ConcurrentAccess(t *testing.T) { | |||
| 146 | - | c := &Coordinator{ | |||
| 147 | - | pendingReveal: make(map[entity.PaneID]bool), | |||
| 148 | - | webViews: make(map[entity.PaneID]port.WebView), | |||
| 149 | - | } | |||
| 150 | - | ||||
| 151 | - | var wg sync.WaitGroup | |||
| 152 | - | paneIDs := []entity.PaneID{"pane-1", "pane-2", "pane-3", "pane-4", "pane-5"} | |||
| 153 | - | ||||
| 154 | - | // Concurrently mark and clear panes | |||
| 155 | - | for range 100 { | |||
| 156 | - | for _, paneID := range paneIDs { | |||
| 157 | - | wg.Add(3) | |||
| 158 | - | ||||
| 159 | - | go func(id entity.PaneID) { | |||
| 160 | - | defer wg.Done() | |||
| 161 | - | c.markPendingReveal(id) | |||
| 162 | - | }(paneID) | |||
| 163 | - | ||||
| 164 | - | go func(id entity.PaneID) { | |||
| 165 | - | defer wg.Done() | |||
| 166 | - | c.clearPendingReveal(id) | |||
| 167 | - | }(paneID) | |||
| 168 | - | ||||
| 169 | - | go func(id entity.PaneID) { | |||
| 170 | - | defer wg.Done() | |||
| 171 | - | c.revealIfPending(context.Background(), id, "", "concurrent") | |||
| 172 | - | }(paneID) | |||
| 173 | - | } | |||
| 174 | - | } | |||
| 175 | - | ||||
| 176 | - | wg.Wait() | |||
| 177 | - | // Test passes if no race conditions occur | |||
| 178 | } | 170 | } | ||
| 179 | 171 | ||||
| 180 | - | func TestMarkPendingReveal_MultiplePanes(t *testing.T) { | 172 | + | func TestRevealIfPending_SerializesReplacementUntilNativeVisibility(t *testing.T) { |
| 181 | - | c := &Coordinator{ | 173 | + | paneID := entity.PaneID("pane-1") |
| 182 | - | pendingReveal: make(map[entity.PaneID]bool), | 174 | + | oldWV := revealTestWebView(t, 101, 1) |
| 175 | + | oldWV.EXPECT().IsDestroyed().Return(false).Once() | |||
| 176 | + | newWV := revealTestWebView(t, 102, 1) | |||
| 177 | + | c := newRevealTestCoordinator() | |||
| 178 | + | c.setWebViewLocked(paneID, oldWV) | |||
| 179 | + | identity, _ := identityForWebView(oldWV) | |||
| 180 | + | c.markPendingReveal(paneID, identity) | |||
| 181 | + | ||||
| 182 | + | nativeStarted := make(chan struct{}) | |||
| 183 | + | allowNativeReturn := make(chan struct{}) | |||
| 184 | + | c.setWebViewVisible = func(wv port.WebView) { | |||
| 185 | + | assert.Same(t, oldWV, wv) | |||
| 186 | + | close(nativeStarted) | |||
| 187 | + | <-allowNativeReturn | |||
| 183 | } | 188 | } | ||
| 184 | - | 189 | + | revealed := make(chan struct{}) | |
| 185 | - | pane1 := entity.PaneID("pane-1") | 190 | + | go func() { |
| 186 | - | pane2 := entity.PaneID("pane-2") | 191 | + | c.revealIfPending(context.Background(), paneID, oldWV, identity, "", "test") |
| 187 | - | 192 | + | close(revealed) | |
| 188 | - | c.markPendingReveal(pane1) | 193 | + | }() |
| 189 | - | c.markPendingReveal(pane2) | 194 | + | <-nativeStarted |
| 190 | - | 195 | + | ||
| 191 | - | c.revealMu.Lock() | 196 | + | replaced := make(chan struct{}) |
| 192 | - | defer c.revealMu.Unlock() | 197 | + | go func() { |
| 193 | - | assert.True(t, c.pendingReveal[pane1]) | 198 | + | c.setWebViewLocked(paneID, newWV) |
| 194 | - | assert.True(t, c.pendingReveal[pane2]) | 199 | + | close(replaced) |
| 195 | - | } | 200 | + | }() |
| 196 | - | 201 | + | select { | |
| 197 | - | func TestClearPendingReveal_OnlyAffectsTargetPane(t *testing.T) { | 202 | + | case <-replaced: |
| 198 | - | c := &Coordinator{ | 203 | + | t.Fatal("replacement interleaved before native visibility completed") |
| 199 | - | pendingReveal: make(map[entity.PaneID]bool), | 204 | + | default: |
| 200 | } | 205 | } | ||
| 201 | 206 | ||||
| 202 | - | pane1 := entity.PaneID("pane-1") | 207 | + | close(allowNativeReturn) |
| 203 | - | pane2 := entity.PaneID("pane-2") | 208 | + | <-revealed |
| 204 | - | 209 | + | <-replaced | |
| 205 | - | c.markPendingReveal(pane1) | 210 | + | assert.Same(t, newWV, c.GetWebView(paneID)) |
| 206 | - | c.markPendingReveal(pane2) | 211 | + | assert.False(t, c.WebViewRevealed(paneID), "replacement must not inherit old reveal state") |
| 207 | - | c.clearPendingReveal(pane1) | |||
| 208 | - | ||||
| 209 | - | c.revealMu.Lock() | |||
| 210 | - | defer c.revealMu.Unlock() | |||
| 211 | - | assert.False(t, c.pendingReveal[pane1], "pane1 should be cleared") | |||
| 212 | - | assert.True(t, c.pendingReveal[pane2], "pane2 should still be pending") | |||
| 213 | } | 212 | } | ||
M internal/ui/coordinator/content/coordinator.go +55 -10
| @@ -70,8 +70,19 @@ type Coordinator struct { | |||||
| 70 | // Callback when the WebView becomes visible (first real commit) | 70 | // Callback when the WebView becomes visible (first real commit) | ||
| 71 | onWebViewShown func(paneID entity.PaneID) | 71 | onWebViewShown func(paneID entity.PaneID) | ||
| 72 | 72 | ||||
| 73 | - | revealMu sync.Mutex | 73 | + | // setWebViewVisible is test-only injection for deterministic lifecycle |
| 74 | - | pendingReveal map[entity.PaneID]bool | 74 | + | // serialization tests. Production uses the native widget provider directly. |
| 75 | + | setWebViewVisible func(port.WebView) | |||
| 76 | + | ||||
| 77 | + | // revealMutationMu serializes mapping replacement/release with the native | |||
| 78 | + | // visibility transition. It is acquired before webViewsMu and revealMu. | |||
| 79 | + | revealMutationMu sync.Mutex | |||
| 80 | + | ||||
| 81 | + | // revealMu is always acquired after webViewsMu. Reveal state is keyed by the | |||
| 82 | + | // WebView identity (ID plus pool-reuse generation), never by pane alone. | |||
| 83 | + | revealMu sync.Mutex | |||
| 84 | + | pendingReveal map[entity.PaneID]webViewIdentity | |||
| 85 | + | revealedWebViews map[entity.PaneID]webViewIdentity | |||
| 75 | 86 | ||||
| 76 | appearanceMu sync.Mutex | 87 | appearanceMu sync.Mutex | ||
| 77 | pendingScriptRefresh map[entity.PaneID]bool | 88 | pendingScriptRefresh map[entity.PaneID]bool | ||
| @@ -147,7 +158,8 @@ func NewCoordinator( | |||||
| 147 | webViewPaneIDs: make(map[port.WebViewID]entity.PaneID), | 158 | webViewPaneIDs: make(map[port.WebViewID]entity.PaneID), | ||
| 148 | paneTitles: make(map[entity.PaneID]string), | 159 | paneTitles: make(map[entity.PaneID]string), | ||
| 149 | navOrigins: make(map[entity.PaneID]string), | 160 | navOrigins: make(map[entity.PaneID]string), | ||
| 150 | - | pendingReveal: make(map[entity.PaneID]bool), | 161 | + | pendingReveal: make(map[entity.PaneID]webViewIdentity), |
| 162 | + | revealedWebViews: make(map[entity.PaneID]webViewIdentity), | |||
| 151 | pendingScriptRefresh: make(map[entity.PaneID]bool), | 163 | pendingScriptRefresh: make(map[entity.PaneID]bool), | ||
| 152 | pendingThemePanes: make(map[entity.PaneID]bool), | 164 | pendingThemePanes: make(map[entity.PaneID]bool), | ||
| 153 | getActiveWS: getActiveWS, | 165 | getActiveWS: getActiveWS, | ||
| @@ -298,20 +310,42 @@ func (c *Coordinator) activePaneOverrideID() (entity.PaneID, bool) { | |||||
| 298 | return c.activePaneOverride, true | 310 | return c.activePaneOverride, true | ||
| 299 | } | 311 | } | ||
| 300 | 312 | ||||
| 301 | - | func (c *Coordinator) webViewCount() int { | |||
| 302 | - | c.webViewsMu.RLock() | |||
| 303 | - | defer c.webViewsMu.RUnlock() | |||
| 304 | - | return len(c.webViews) | |||
| 305 | - | } | |||
| 306 | - | ||||
| 307 | func (c *Coordinator) getWebViewLocked(paneID entity.PaneID) port.WebView { | 313 | func (c *Coordinator) getWebViewLocked(paneID entity.PaneID) port.WebView { | ||
| 308 | c.webViewsMu.RLock() | 314 | c.webViewsMu.RLock() | ||
| 309 | defer c.webViewsMu.RUnlock() | 315 | defer c.webViewsMu.RUnlock() | ||
| 310 | return c.webViews[paneID] | 316 | return c.webViews[paneID] | ||
| 311 | } | 317 | } | ||
| 312 | 318 | ||||
| 319 | + | type webViewIdentity struct { | |||
| 320 | + | id port.WebViewID | |||
| 321 | + | generation uint64 | |||
| 322 | + | } | |||
| 323 | + | ||||
| 324 | + | func identityForWebView(wv port.WebView) (webViewIdentity, bool) { | |||
| 325 | + | if wv == nil { | |||
| 326 | + | return webViewIdentity{}, false | |||
| 327 | + | } | |||
| 328 | + | return webViewIdentity{id: wv.ID(), generation: wv.Generation()}, true | |||
| 329 | + | } | |||
| 330 | + | ||||
| 331 | + | func (c *Coordinator) ensureRevealMapsLocked() { | |||
| 332 | + | if c.pendingReveal == nil { | |||
| 333 | + | c.pendingReveal = make(map[entity.PaneID]webViewIdentity) | |||
| 334 | + | } | |||
| 335 | + | if c.revealedWebViews == nil { | |||
| 336 | + | c.revealedWebViews = make(map[entity.PaneID]webViewIdentity) | |||
| 337 | + | } | |||
| 338 | + | } | |||
| 339 | + | ||||
| 340 | + | // setWebViewLocked replaces the pane mapping and resets all reveal state in | |||
| 341 | + | // one critical section. It is used by normal acquisition and popup paths. | |||
| 313 | func (c *Coordinator) setWebViewLocked(paneID entity.PaneID, wv port.WebView) { | 342 | func (c *Coordinator) setWebViewLocked(paneID entity.PaneID, wv port.WebView) { | ||
| 343 | + | c.revealMutationMu.Lock() | |||
| 344 | + | defer c.revealMutationMu.Unlock() | |||
| 314 | c.webViewsMu.Lock() | 345 | c.webViewsMu.Lock() | ||
| 346 | + | c.revealMu.Lock() | |||
| 347 | + | defer c.revealMu.Unlock() | |||
| 348 | + | defer c.webViewsMu.Unlock() | |||
| 315 | if c.webViews == nil { | 349 | if c.webViews == nil { | ||
| 316 | c.webViews = make(map[entity.PaneID]port.WebView) | 350 | c.webViews = make(map[entity.PaneID]port.WebView) | ||
| 317 | } | 351 | } | ||
| @@ -322,17 +356,28 @@ func (c *Coordinator) setWebViewLocked(paneID entity.PaneID, wv port.WebView) { | |||||
| 322 | if wv != nil && c.webViewPaneIDs != nil { | 356 | if wv != nil && c.webViewPaneIDs != nil { | ||
| 323 | c.webViewPaneIDs[wv.ID()] = paneID | 357 | c.webViewPaneIDs[wv.ID()] = paneID | ||
| 324 | } | 358 | } | ||
| 325 | - | c.webViewsMu.Unlock() | 359 | + | c.ensureRevealMapsLocked() |
| 360 | + | delete(c.pendingReveal, paneID) | |||
| 361 | + | delete(c.revealedWebViews, paneID) | |||
| 326 | } | 362 | } | ||
| 327 | 363 | ||||
| 364 | + | // deleteWebViewLocked removes both the mapping and any presentation state, | |||
| 365 | + | // including callers which release after another path already removed the map. | |||
| 328 | func (c *Coordinator) deleteWebViewLocked(paneID entity.PaneID) port.WebView { | 366 | func (c *Coordinator) deleteWebViewLocked(paneID entity.PaneID) port.WebView { | ||
| 367 | + | c.revealMutationMu.Lock() | |||
| 368 | + | defer c.revealMutationMu.Unlock() | |||
| 329 | c.webViewsMu.Lock() | 369 | c.webViewsMu.Lock() | ||
| 370 | + | c.revealMu.Lock() | |||
| 371 | + | defer c.revealMu.Unlock() | |||
| 330 | defer c.webViewsMu.Unlock() | 372 | defer c.webViewsMu.Unlock() | ||
| 331 | wv := c.webViews[paneID] | 373 | wv := c.webViews[paneID] | ||
| 332 | delete(c.webViews, paneID) | 374 | delete(c.webViews, paneID) | ||
| 333 | if wv != nil && c.webViewPaneIDs != nil { | 375 | if wv != nil && c.webViewPaneIDs != nil { | ||
| 334 | delete(c.webViewPaneIDs, wv.ID()) | 376 | delete(c.webViewPaneIDs, wv.ID()) | ||
| 335 | } | 377 | } | ||
| 378 | + | c.ensureRevealMapsLocked() | |||
| 379 | + | delete(c.pendingReveal, paneID) | |||
| 380 | + | delete(c.revealedWebViews, paneID) | |||
| 336 | return wv | 381 | return wv | ||
| 337 | } | 382 | } | ||
| 338 | 383 | ||||
M internal/ui/coordinator/content/lifecycle.go +5 -8
| @@ -28,17 +28,13 @@ func (c *Coordinator) EnsureWebView(ctx context.Context, paneID entity.PaneID) ( | |||||
| 28 | return nil, fmt.Errorf("webview pool not configured") | 28 | return nil, fmt.Errorf("webview pool not configured") | ||
| 29 | } | 29 | } | ||
| 30 | 30 | ||||
| 31 | - | // Mark tab_created on first webview (first tab) | |||
| 32 | - | if c.webViewCount() == 0 { | |||
| 33 | - | logging.Trace().Mark("tab_created") | |||
| 34 | - | } | |||
| 35 | - | ||||
| 36 | wv, err := c.pool.Acquire(ctx) | 31 | wv, err := c.pool.Acquire(ctx) | ||
| 37 | if err != nil { | 32 | if err != nil { | ||
| 38 | return nil, err | 33 | return nil, err | ||
| 39 | } | 34 | } | ||
| 40 | - | logging.Trace().Mark("webview_acquired") | |||
| 41 | 35 | ||||
| 36 | + | // setWebViewLocked atomically resets presentation state for the acquired | |||
| 37 | + | // WebView, including a pooled instance previously revealed in another pane. | |||
| 42 | c.setWebViewLocked(paneID, wv) | 38 | c.setWebViewLocked(paneID, wv) | ||
| 43 | c.setupWebViewCallbacks(ctx, paneID, wv) | 39 | c.setupWebViewCallbacks(ctx, paneID, wv) | ||
| 44 | 40 | ||||
| @@ -53,6 +49,8 @@ func (c *Coordinator) EnsureWebView(ctx context.Context, paneID entity.PaneID) ( | |||||
| 53 | func (c *Coordinator) ReleaseWebView(ctx context.Context, paneID entity.PaneID) { | 49 | func (c *Coordinator) ReleaseWebView(ctx context.Context, paneID entity.PaneID) { | ||
| 54 | log := logging.FromContext(ctx) | 50 | log := logging.FromContext(ctx) | ||
| 55 | 51 | ||||
| 52 | + | // deleteWebViewLocked clears reveal state before returning, even when a | |||
| 53 | + | // previous cleanup already removed this pane's mapping. | |||
| 56 | wv := c.deleteWebViewLocked(paneID) | 54 | wv := c.deleteWebViewLocked(paneID) | ||
| 57 | if wv == nil { | 55 | if wv == nil { | ||
| 58 | return | 56 | return | ||
| @@ -136,11 +134,10 @@ func (c *Coordinator) AttachToWorkspace(ctx context.Context, ws *entity.Workspac | |||||
| 136 | Int("wrapped_widget_alloc_height", widget.GetAllocatedHeight()). | 134 | Int("wrapped_widget_alloc_height", widget.GetAllocatedHeight()). | ||
| 137 | Msg("wrapped webview widget prepared for pane") | 135 | Msg("wrapped webview widget prepared for pane") | ||
| 138 | 136 | ||||
| 139 | - | if err := wsView.SetWebViewWidget(pane.ID, widget); err != nil { | 137 | + | if err := wsView.AttachWebViewWidget(pane.ID, widget, c.webViewRevealed(pane.ID, wv)); err != nil { |
| 140 | log.Warn().Err(err).Str("pane_id", string(pane.ID)).Msg("failed to attach webview widget") | 138 | log.Warn().Err(err).Str("pane_id", string(pane.ID)).Msg("failed to attach webview widget") | ||
| 141 | continue | 139 | continue | ||
| 142 | } | 140 | } | ||
| 143 | - | logging.Trace().Mark("webview_attached") | |||
| 144 | } | 141 | } | ||
| 145 | } | 142 | } | ||
| 146 | 143 | ||||
M internal/ui/coordinator/content/lifecycle_test.go +204 -2
| @@ -58,7 +58,7 @@ func TestLifecycle_EnsureWebView_AcquiresFromPoolWhenNoneExists(t *testing.T) { | |||||
| 58 | newWV := mocks.NewMockWebView(t) | 58 | newWV := mocks.NewMockWebView(t) | ||
| 59 | newWV.EXPECT().SetCallbacks(mock.Anything).Maybe() | 59 | newWV.EXPECT().SetCallbacks(mock.Anything).Maybe() | ||
| 60 | newWV.EXPECT().Generation().Return(uint64(0)).Maybe() | 60 | newWV.EXPECT().Generation().Return(uint64(0)).Maybe() | ||
| 61 | - | newWV.EXPECT().ID().Return(port.WebViewID(102)).Once() | 61 | + | newWV.EXPECT().ID().Return(port.WebViewID(102)).Maybe() |
| 62 | 62 | ||||
| 63 | pool := mocks.NewMockWebViewPool(t) | 63 | pool := mocks.NewMockWebViewPool(t) | ||
| 64 | pool.EXPECT().Acquire(mock.Anything).Return(newWV, nil) | 64 | pool.EXPECT().Acquire(mock.Anything).Return(newWV, nil) | ||
| @@ -82,7 +82,7 @@ func TestLifecycle_EnsureWebView_AcquiresFromPoolWhenExistingIsDestroyed(t *test | |||||
| 82 | newWV := mocks.NewMockWebView(t) | 82 | newWV := mocks.NewMockWebView(t) | ||
| 83 | newWV.EXPECT().SetCallbacks(mock.Anything).Maybe() | 83 | newWV.EXPECT().SetCallbacks(mock.Anything).Maybe() | ||
| 84 | newWV.EXPECT().Generation().Return(uint64(0)).Maybe() | 84 | newWV.EXPECT().Generation().Return(uint64(0)).Maybe() | ||
| 85 | - | newWV.EXPECT().ID().Return(port.WebViewID(103)).Once() | 85 | + | newWV.EXPECT().ID().Return(port.WebViewID(103)).Maybe() |
| 86 | 86 | ||||
| 87 | pool := mocks.NewMockWebViewPool(t) | 87 | pool := mocks.NewMockWebViewPool(t) | ||
| 88 | pool.EXPECT().Acquire(mock.Anything).Return(newWV, nil) | 88 | pool.EXPECT().Acquire(mock.Anything).Return(newWV, nil) | ||
| @@ -458,3 +458,205 @@ func TestLifecycle_SyncWebViewViewport_NoopWithoutCapability(t *testing.T) { | |||||
| 458 | 458 | ||||
| 459 | c.SyncWebViewViewport(context.Background(), "pane-1", "unit-test") | 459 | c.SyncWebViewViewport(context.Background(), "pane-1", "unit-test") | ||
| 460 | } | 460 | } | ||
| 461 | + | ||||
| 462 | + | type nativeLifecycleWebView struct { | |||
| 463 | + | port.WebView | |||
| 464 | + | widget uintptr | |||
| 465 | + | } | |||
| 466 | + | ||||
| 467 | + | func (w *nativeLifecycleWebView) NativeWidget() uintptr { return w.widget } | |||
| 468 | + | ||||
| 469 | + | func TestLifecycle_AttachToWorkspace_RebuildPropagatesRevealStateToFreshPaneView(t *testing.T) { | |||
| 470 | + | for _, tc := range []struct { | |||
| 471 | + | name string | |||
| 472 | + | revealed bool | |||
| 473 | + | }{ | |||
| 474 | + | {name: "revealed current identity hides and stops skeleton", revealed: true}, | |||
| 475 | + | {name: "unrevealed current identity shows and starts skeleton", revealed: false}, | |||
| 476 | + | } { | |||
| 477 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 478 | + | paneID := entity.PaneID("pane-1") | |||
| 479 | + | ctx := context.Background() | |||
| 480 | + | factory := layoutmocks.NewMockWidgetFactory(t) | |||
| 481 | + | wsView, workspace, paneOverlay, loadingContainer, spinner := newLifecycleRebuildWorkspaceView(t, ctx, factory, paneID) | |||
| 482 | + | wrappedWidget := layoutmocks.NewMockWidget(t) | |||
| 483 | + | webViewMock := mocks.NewMockWebView(t) | |||
| 484 | + | webViewMock.EXPECT().ID().Return(port.WebViewID(201)).Maybe() | |||
| 485 | + | webViewMock.EXPECT().Generation().Return(uint64(3)).Maybe() | |||
| 486 | + | webViewMock.EXPECT().IsDestroyed().Return(false).Once() | |||
| 487 | + | webView := &nativeLifecycleWebView{WebView: webViewMock, widget: 0x351} | |||
| 488 | + | ||||
| 489 | + | factory.EXPECT().WrapNativeWidget(uintptr(0x351)).Return(wrappedWidget).Once() | |||
| 490 | + | wrappedWidget.EXPECT().GtkWidget().Return(nil).Once() | |||
| 491 | + | wrappedWidget.EXPECT().GetAllocatedWidth().Return(0).Times(3) | |||
| 492 | + | wrappedWidget.EXPECT().GetAllocatedHeight().Return(0).Times(3) | |||
| 493 | + | wrappedWidget.EXPECT().GetParent().Return(nil).Once() | |||
| 494 | + | wrappedWidget.EXPECT().IsVisible().Return(true).Once() | |||
| 495 | + | paneOverlay.EXPECT().GetAllocatedWidth().Return(0).Twice() | |||
| 496 | + | paneOverlay.EXPECT().GetAllocatedHeight().Return(0).Twice() | |||
| 497 | + | paneOverlay.EXPECT().SetChild(wrappedWidget).Once() | |||
| 498 | + | loadingContainer.EXPECT().SetVisible(!tc.revealed).Once() | |||
| 499 | + | if tc.revealed { | |||
| 500 | + | spinner.EXPECT().Stop().Once() | |||
| 501 | + | } else { | |||
| 502 | + | spinner.EXPECT().Start().Once() | |||
| 503 | + | } | |||
| 504 | + | ||||
| 505 | + | coordinator := newRevealTestCoordinator() | |||
| 506 | + | coordinator.widgetFactory = factory | |||
| 507 | + | coordinator.setWebViewLocked(paneID, webView) | |||
| 508 | + | if tc.revealed { | |||
| 509 | + | identity, ok := identityForWebView(webView) | |||
| 510 | + | require.True(t, ok) | |||
| 511 | + | coordinator.markWebViewRevealed(paneID, identity) | |||
| 512 | + | } | |||
| 513 | + | ||||
| 514 | + | coordinator.AttachToWorkspace(ctx, workspace, wsView) | |||
| 515 | + | ||||
| 516 | + | assert.Same(t, wrappedWidget, wsView.GetPaneView(paneID).WebViewWidget()) | |||
| 517 | + | }) | |||
| 518 | + | } | |||
| 519 | + | } | |||
| 520 | + | ||||
| 521 | + | func newLifecycleRebuildWorkspaceView( | |||
| 522 | + | t *testing.T, | |||
| 523 | + | ctx context.Context, | |||
| 524 | + | factory *layoutmocks.MockWidgetFactory, | |||
| 525 | + | paneID entity.PaneID, | |||
| 526 | + | ) (*component.WorkspaceView, *entity.Workspace, *layoutmocks.MockOverlayWidget, *layoutmocks.MockBoxWidget, *layoutmocks.MockSpinnerWidget) { | |||
| 527 | + | t.Helper() | |||
| 528 | + | workspaceContainer := layoutmocks.NewMockBoxWidget(t) | |||
| 529 | + | workspaceOverlay := layoutmocks.NewMockOverlayWidget(t) | |||
| 530 | + | factory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(workspaceContainer).Once() | |||
| 531 | + | workspaceContainer.EXPECT().SetHexpand(true).Once() | |||
| 532 | + | workspaceContainer.EXPECT().SetVexpand(true).Once() | |||
| 533 | + | workspaceContainer.EXPECT().SetVisible(true).Once() | |||
| 534 | + | factory.EXPECT().NewOverlay().Return(workspaceOverlay).Once() | |||
| 535 | + | workspaceOverlay.EXPECT().SetHexpand(true).Once() | |||
| 536 | + | workspaceOverlay.EXPECT().SetVexpand(true).Once() | |||
| 537 | + | workspaceOverlay.EXPECT().SetChild(workspaceContainer).Once() | |||
| 538 | + | workspaceOverlay.EXPECT().SetVisible(true).Once() | |||
| 539 | + | wsView := component.NewWorkspaceView(ctx, factory) | |||
| 540 | + | ||||
| 541 | + | paneOverlay := layoutmocks.NewMockOverlayWidget(t) | |||
| 542 | + | loadingContainer := layoutmocks.NewMockBoxWidget(t) | |||
| 543 | + | loadingContent := layoutmocks.NewMockBoxWidget(t) | |||
| 544 | + | spinner := layoutmocks.NewMockSpinnerWidget(t) | |||
| 545 | + | logo := layoutmocks.NewMockImageWidget(t) | |||
| 546 | + | version := layoutmocks.NewMockLabelWidget(t) | |||
| 547 | + | border := layoutmocks.NewMockBoxWidget(t) | |||
| 548 | + | ||||
| 549 | + | factory.EXPECT().NewOverlay().Return(paneOverlay).Once() | |||
| 550 | + | paneOverlay.EXPECT().SetHexpand(true).Once() | |||
| 551 | + | paneOverlay.EXPECT().SetVexpand(true).Once() | |||
| 552 | + | paneOverlay.EXPECT().SetVisible(true).Once() | |||
| 553 | + | paneOverlay.EXPECT().AddCssClass("pane-overlay").Once() | |||
| 554 | + | factory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(loadingContainer).Once() | |||
| 555 | + | loadingContainer.EXPECT().SetHexpand(true).Once() | |||
| 556 | + | loadingContainer.EXPECT().SetVexpand(true).Once() | |||
| 557 | + | loadingContainer.EXPECT().SetHalign(mock.Anything).Once() | |||
| 558 | + | loadingContainer.EXPECT().SetValign(mock.Anything).Once() | |||
| 559 | + | loadingContainer.EXPECT().SetCanFocus(false).Times(2) | |||
| 560 | + | loadingContainer.EXPECT().SetCanTarget(false).Times(2) | |||
| 561 | + | loadingContainer.EXPECT().AddCssClass("loading-skeleton").Once() | |||
| 562 | + | factory.EXPECT().NewBox(layout.OrientationVertical, 6).Return(loadingContent).Once() | |||
| 563 | + | loadingContent.EXPECT().SetHalign(mock.Anything).Once() | |||
| 564 | + | loadingContent.EXPECT().SetValign(mock.Anything).Once() | |||
| 565 | + | loadingContent.EXPECT().SetCanFocus(false).Once() | |||
| 566 | + | loadingContent.EXPECT().SetCanTarget(false).Once() | |||
| 567 | + | loadingContent.EXPECT().AddCssClass("loading-skeleton-content").Once() | |||
| 568 | + | factory.EXPECT().NewImage().Return(logo).Once() | |||
| 569 | + | logo.EXPECT().SetHalign(mock.Anything).Once() | |||
| 570 | + | logo.EXPECT().SetValign(mock.Anything).Once() | |||
| 571 | + | logo.EXPECT().SetCanFocus(false).Once() | |||
| 572 | + | logo.EXPECT().SetCanTarget(false).Once() | |||
| 573 | + | logo.EXPECT().SetSizeRequest(256, 256).Once() | |||
| 574 | + | logo.EXPECT().SetPixelSize(256).Once() | |||
| 575 | + | logo.EXPECT().AddCssClass("loading-skeleton-logo").Once() | |||
| 576 | + | logo.EXPECT().SetFromPaintable(mock.Anything).Maybe() | |||
| 577 | + | factory.EXPECT().NewSpinner().Return(spinner).Once() | |||
| 578 | + | spinner.EXPECT().SetHalign(mock.Anything).Once() | |||
| 579 | + | spinner.EXPECT().SetValign(mock.Anything).Once() | |||
| 580 | + | spinner.EXPECT().SetCanFocus(false).Once() | |||
| 581 | + | spinner.EXPECT().SetCanTarget(false).Once() | |||
| 582 | + | spinner.EXPECT().SetSizeRequest(32, 32).Once() | |||
| 583 | + | spinner.EXPECT().AddCssClass("loading-skeleton-spinner").Once() | |||
| 584 | + | factory.EXPECT().NewLabel(mock.Anything).Return(version).Once() | |||
| 585 | + | version.EXPECT().SetHalign(mock.Anything).Once() | |||
| 586 | + | version.EXPECT().SetValign(mock.Anything).Once() | |||
| 587 | + | version.EXPECT().SetCanFocus(false).Once() | |||
| 588 | + | version.EXPECT().SetCanTarget(false).Once() | |||
| 589 | + | version.EXPECT().SetMaxWidthChars(mock.Anything).Once() | |||
| 590 | + | version.EXPECT().SetEllipsize(mock.Anything).Once() | |||
| 591 | + | version.EXPECT().AddCssClass("loading-skeleton-version").Once() | |||
| 592 | + | loadingContent.EXPECT().Append(logo).Once() | |||
| 593 | + | loadingContent.EXPECT().Append(spinner).Once() | |||
| 594 | + | loadingContent.EXPECT().Append(version).Once() | |||
| 595 | + | loadingContainer.EXPECT().Append(loadingContent).Once() | |||
| 596 | + | loadingContainer.EXPECT().SetVisible(true).Once() | |||
| 597 | + | spinner.EXPECT().Start().Once() | |||
| 598 | + | paneOverlay.EXPECT().AddOverlay(loadingContainer).Once() | |||
| 599 | + | paneOverlay.EXPECT().SetClipOverlay(loadingContainer, false).Once() | |||
| 600 | + | paneOverlay.EXPECT().SetMeasureOverlay(loadingContainer, false).Once() | |||
| 601 | + | factory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(border).Once() | |||
| 602 | + | border.EXPECT().SetCanFocus(false).Once() | |||
| 603 | + | border.EXPECT().SetCanTarget(false).Once() | |||
| 604 | + | border.EXPECT().AddCssClass("pane-border").Once() | |||
| 605 | + | border.EXPECT().SetHexpand(true).Once() | |||
| 606 | + | border.EXPECT().SetVexpand(true).Once() | |||
| 607 | + | paneOverlay.EXPECT().AddOverlay(border).Once() | |||
| 608 | + | paneOverlay.EXPECT().SetClipOverlay(border, false).Once() | |||
| 609 | + | paneOverlay.EXPECT().SetMeasureOverlay(border, false).Once() | |||
| 610 | + | ||||
| 611 | + | stackBox := layoutmocks.NewMockBoxWidget(t) | |||
| 612 | + | titleBar := layoutmocks.NewMockBoxWidget(t) | |||
| 613 | + | favicon := layoutmocks.NewMockImageWidget(t) | |||
| 614 | + | title := layoutmocks.NewMockLabelWidget(t) | |||
| 615 | + | closeButton := layoutmocks.NewMockButtonWidget(t) | |||
| 616 | + | factory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(stackBox).Once() | |||
| 617 | + | stackBox.EXPECT().SetHexpand(true).Once() | |||
| 618 | + | stackBox.EXPECT().SetVexpand(true).Once() | |||
| 619 | + | factory.EXPECT().NewBox(layout.OrientationHorizontal, 4).Return(titleBar).Once() | |||
| 620 | + | titleBar.EXPECT().AddCssClass("stacked-pane-titlebar").Once() | |||
| 621 | + | titleBar.EXPECT().AddCssClass("stacked-pane-title-clickable").Once() | |||
| 622 | + | titleBar.EXPECT().SetVexpand(false).Once() | |||
| 623 | + | titleBar.EXPECT().SetHexpand(true).Once() | |||
| 624 | + | factory.EXPECT().NewImage().Return(favicon).Once() | |||
| 625 | + | favicon.EXPECT().SetFromIconName(mock.Anything).Once() | |||
| 626 | + | favicon.EXPECT().SetPixelSize(16).Once() | |||
| 627 | + | titleBar.EXPECT().Append(favicon).Once() | |||
| 628 | + | factory.EXPECT().NewLabel(mock.Anything).Return(title).Once() | |||
| 629 | + | title.EXPECT().SetEllipsize(layout.EllipsizeEnd).Once() | |||
| 630 | + | title.EXPECT().SetMaxWidthChars(30).Once() | |||
| 631 | + | title.EXPECT().SetHexpand(true).Once() | |||
| 632 | + | title.EXPECT().SetXalign(float32(0)).Once() | |||
| 633 | + | titleBar.EXPECT().Append(title).Once() | |||
| 634 | + | factory.EXPECT().NewButton().Return(closeButton).Once() | |||
| 635 | + | closeButton.EXPECT().SetIconName("window-close-symbolic").Once() | |||
| 636 | + | closeButton.EXPECT().AddCssClass("stacked-pane-close-button").Once() | |||
| 637 | + | closeButton.EXPECT().SetFocusOnClick(false).Once() | |||
| 638 | + | closeButton.EXPECT().SetVexpand(false).Once() | |||
| 639 | + | closeButton.EXPECT().SetHexpand(false).Once() | |||
| 640 | + | titleBar.EXPECT().Append(closeButton).Once() | |||
| 641 | + | titleBar.EXPECT().AddController(mock.Anything).Once() | |||
| 642 | + | closeButton.EXPECT().ConnectClicked(mock.Anything).Return(uint(2)).Once() | |||
| 643 | + | closeButton.EXPECT().GtkWidget().Return(nil).Maybe() | |||
| 644 | + | stackBox.EXPECT().Append(titleBar).Once() | |||
| 645 | + | stackBox.EXPECT().Append(paneOverlay).Once() | |||
| 646 | + | titleBar.EXPECT().SetVisible(false).Once() | |||
| 647 | + | paneOverlay.EXPECT().SetVisible(true).Once() | |||
| 648 | + | titleBar.EXPECT().AddCssClass("active").Once() | |||
| 649 | + | paneOverlay.EXPECT().GtkWidget().Return(nil).Maybe() | |||
| 650 | + | stackBox.EXPECT().SetVisible(true).Once() | |||
| 651 | + | workspaceContainer.EXPECT().Append(stackBox).Once() | |||
| 652 | + | workspaceContainer.EXPECT().AddCssClass("single-pane").Once() | |||
| 653 | + | border.EXPECT().AddCssClass("pane-active").Once() | |||
| 654 | + | ||||
| 655 | + | workspace := &entity.Workspace{ | |||
| 656 | + | ID: "workspace-1", | |||
| 657 | + | Root: &entity.PaneNode{ID: "node-1", Pane: entity.NewPane(paneID)}, | |||
| 658 | + | ActivePaneID: paneID, | |||
| 659 | + | } | |||
| 660 | + | require.NoError(t, wsView.SetWorkspace(ctx, workspace)) | |||
| 661 | + | return wsView, workspace, paneOverlay, loadingContainer, spinner | |||
| 662 | + | } | |||
M internal/ui/coordinator/content/navigation.go +102 -31
| @@ -17,9 +17,8 @@ import ( | |||||
| 17 | // WebKit may reset zoom during document transitions, so we reapply after LoadCommitted. | 17 | // WebKit may reset zoom during document transitions, so we reapply after LoadCommitted. | ||
| 18 | // History is recorded here because the URI is guaranteed to be correct after commit. | 18 | // History is recorded here because the URI is guaranteed to be correct after commit. | ||
| 19 | // Also shows the WebView widget (it's hidden during creation to avoid white flash). | 19 | // Also shows the WebView widget (it's hidden during creation to avoid white flash). | ||
| 20 | - | func (c *Coordinator) onLoadCommitted(ctx context.Context, paneID entity.PaneID, wv port.WebView) { | 20 | + | func (c *Coordinator) onLoadCommitted(ctx context.Context, paneID entity.PaneID, wv port.WebView, identity webViewIdentity) { |
| 21 | log := logging.FromContext(ctx) | 21 | log := logging.FromContext(ctx) | ||
| 22 | - | logging.Trace().Mark("load_committed") | |||
| 23 | 22 | ||||
| 24 | uri := wv.URI() | 23 | uri := wv.URI() | ||
| 25 | if uri == "" { | 24 | if uri == "" { | ||
| @@ -69,7 +68,7 @@ func (c *Coordinator) onLoadCommitted(ctx context.Context, paneID entity.PaneID, | |||||
| 69 | // Avoid updating UI/domain state to about:blank when we know the pane is | 68 | // Avoid updating UI/domain state to about:blank when we know the pane is | ||
| 70 | // navigating to a different URL. This prevents the omnibox/window title from | 69 | // navigating to a different URL. This prevents the omnibox/window title from | ||
| 71 | // briefly showing about:blank on cold start. | 70 | // briefly showing about:blank on cold start. | ||
| 72 | - | c.clearPendingReveal(paneID) | 71 | + | c.clearPendingReveal(paneID, identity) |
| 73 | return | 72 | return | ||
| 74 | } | 73 | } | ||
| 75 | 74 | ||||
| @@ -77,8 +76,8 @@ func (c *Coordinator) onLoadCommitted(ctx context.Context, paneID entity.PaneID, | |||||
| 77 | c.applyCurrentTheme(ctx, paneID, wv) | 76 | c.applyCurrentTheme(ctx, paneID, wv) | ||
| 78 | } | 77 | } | ||
| 79 | 78 | ||||
| 80 | - | c.markPendingReveal(paneID) | 79 | + | c.markPendingReveal(paneID, identity) |
| 81 | - | c.revealIfPending(ctx, paneID, uri, "progress-after-commit") | 80 | + | c.revealIfPending(ctx, paneID, wv, identity, uri, "progress-after-commit") |
| 82 | 81 | ||||
| 83 | // Update domain model with current URI for session snapshots | 82 | // Update domain model with current URI for session snapshots | ||
| 84 | c.updatePaneURI(paneID, uri) | 83 | c.updatePaneURI(paneID, uri) | ||
| @@ -177,8 +176,6 @@ func (c *Coordinator) updatePaneURI(paneID entity.PaneID, uri string) { | |||||
| 177 | 176 | ||||
| 178 | // onLoadStarted shows the progress bar when page loading begins. | 177 | // onLoadStarted shows the progress bar when page loading begins. | ||
| 179 | func (c *Coordinator) onLoadStarted(paneID entity.PaneID) { | 178 | func (c *Coordinator) onLoadStarted(paneID entity.PaneID) { | ||
| 180 | - | logging.Trace().Mark("load_started") | |||
| 181 | - | ||||
| 182 | // Trigger deferred initialization on first load_started. | 179 | // Trigger deferred initialization on first load_started. | ||
| 183 | // This ensures non-critical init runs after initial navigation starts. | 180 | // This ensures non-critical init runs after initial navigation starts. | ||
| 184 | c.loadStartedOnce.Do(func() { | 181 | c.loadStartedOnce.Do(func() { | ||
| @@ -202,7 +199,7 @@ func (c *Coordinator) onLoadStarted(paneID entity.PaneID) { | |||||
| 202 | } | 199 | } | ||
| 203 | 200 | ||||
| 204 | // onLoadFinished hides the progress bar when page loading completes. | 201 | // onLoadFinished hides the progress bar when page loading completes. | ||
| 205 | - | func (c *Coordinator) onLoadFinished(ctx context.Context, paneID entity.PaneID, wv port.WebView) { | 202 | + | func (c *Coordinator) onLoadFinished(ctx context.Context, paneID entity.PaneID, wv port.WebView, identity webViewIdentity) { |
| 206 | _, wsView := c.getActiveWS() | 203 | _, wsView := c.getActiveWS() | ||
| 207 | var paneView *component.PaneView | 204 | var paneView *component.PaneView | ||
| 208 | if wsView != nil { | 205 | if wsView != nil { | ||
| @@ -216,7 +213,7 @@ func (c *Coordinator) onLoadFinished(ctx context.Context, paneID entity.PaneID, | |||||
| 216 | paneView.SetLoading(false) | 213 | paneView.SetLoading(false) | ||
| 217 | } | 214 | } | ||
| 218 | 215 | ||||
| 219 | - | c.revealIfPending(ctx, paneID, "", "load-finished") | 216 | + | c.revealIfPending(ctx, paneID, wv, identity, "", "load-finished") |
| 220 | if c.shouldSkipAboutBlankAppearance(paneID, wv) { | 217 | if c.shouldSkipAboutBlankAppearance(paneID, wv) { | ||
| 221 | return | 218 | return | ||
| 222 | } | 219 | } | ||
| @@ -225,9 +222,9 @@ func (c *Coordinator) onLoadFinished(ctx context.Context, paneID entity.PaneID, | |||||
| 225 | } | 222 | } | ||
| 226 | 223 | ||||
| 227 | // onProgressChanged updates the progress bar with current load progress. | 224 | // onProgressChanged updates the progress bar with current load progress. | ||
| 228 | - | func (c *Coordinator) onProgressChanged(paneID entity.PaneID, progress float64) { | 225 | + | func (c *Coordinator) onProgressChanged(paneID entity.PaneID, wv port.WebView, identity webViewIdentity, progress float64) { |
| 229 | if progress > 0 { | 226 | if progress > 0 { | ||
| 230 | - | c.revealIfPending(context.Background(), paneID, "", "progress") | 227 | + | c.revealIfPending(context.Background(), paneID, wv, identity, "", "progress") |
| 231 | } | 228 | } | ||
| 232 | 229 | ||||
| 233 | _, wsView := c.getActiveWS() | 230 | _, wsView := c.getActiveWS() | ||
| @@ -244,37 +241,111 @@ func (c *Coordinator) onProgressChanged(paneID entity.PaneID, progress float64) | |||||
| 244 | } | 241 | } | ||
| 245 | } | 242 | } | ||
| 246 | 243 | ||||
| 247 | - | func (c *Coordinator) markPendingReveal(paneID entity.PaneID) { | 244 | + | func (c *Coordinator) markPendingReveal(paneID entity.PaneID, identity webViewIdentity) { |
| 245 | + | c.webViewsMu.RLock() | |||
| 248 | c.revealMu.Lock() | 246 | c.revealMu.Lock() | ||
| 249 | - | c.pendingReveal[paneID] = true | 247 | + | defer c.revealMu.Unlock() |
| 250 | - | c.revealMu.Unlock() | 248 | + | defer c.webViewsMu.RUnlock() |
| 249 | + | currentWebView := c.webViews[paneID] | |||
| 250 | + | if currentWebView == nil { | |||
| 251 | + | return | |||
| 252 | + | } | |||
| 253 | + | currentIdentity, ok := identityForWebView(currentWebView) | |||
| 254 | + | if !ok || currentIdentity != identity { | |||
| 255 | + | return | |||
| 256 | + | } | |||
| 257 | + | c.ensureRevealMapsLocked() | |||
| 258 | + | c.pendingReveal[paneID] = identity | |||
| 251 | } | 259 | } | ||
| 252 | 260 | ||||
| 253 | - | func (c *Coordinator) clearPendingReveal(paneID entity.PaneID) { | 261 | + | func (c *Coordinator) clearPendingReveal(paneID entity.PaneID, identity webViewIdentity) { |
| 262 | + | c.webViewsMu.RLock() | |||
| 254 | c.revealMu.Lock() | 263 | c.revealMu.Lock() | ||
| 264 | + | defer c.revealMu.Unlock() | |||
| 265 | + | defer c.webViewsMu.RUnlock() | |||
| 266 | + | pendingIdentity, pending := c.pendingReveal[paneID] | |||
| 267 | + | if !pending || pendingIdentity != identity { | |||
| 268 | + | return | |||
| 269 | + | } | |||
| 255 | delete(c.pendingReveal, paneID) | 270 | delete(c.pendingReveal, paneID) | ||
| 256 | - | c.revealMu.Unlock() | |||
| 257 | } | 271 | } | ||
| 258 | 272 | ||||
| 259 | - | func (c *Coordinator) revealIfPending(ctx context.Context, paneID entity.PaneID, uri, reason string) { | 273 | + | // WebViewRevealed reports whether this pane's current WebView has completed a |
| 274 | + | // reveal. State is identity-bound so a replacement cannot inherit it. | |||
| 275 | + | func (c *Coordinator) WebViewRevealed(paneID entity.PaneID) bool { | |||
| 276 | + | return c.webViewRevealed(paneID, nil) | |||
| 277 | + | } | |||
| 278 | + | ||||
| 279 | + | // webViewRevealed verifies the identity being attached when one is supplied. | |||
| 280 | + | // This prevents a rebuild from applying a replacement's reveal state to a | |||
| 281 | + | // previously read pooled WebView. | |||
| 282 | + | func (c *Coordinator) webViewRevealed(paneID entity.PaneID, expected port.WebView) bool { | |||
| 283 | + | c.webViewsMu.RLock() | |||
| 260 | c.revealMu.Lock() | 284 | c.revealMu.Lock() | ||
| 261 | - | pending := c.pendingReveal[paneID] | 285 | + | defer c.revealMu.Unlock() |
| 262 | - | if pending { | 286 | + | defer c.webViewsMu.RUnlock() |
| 263 | - | delete(c.pendingReveal, paneID) | 287 | + | current := c.webViews[paneID] |
| 288 | + | identity, ok := identityForWebView(current) | |||
| 289 | + | if !ok || expected != nil { | |||
| 290 | + | expectedIdentity, expectedOK := identityForWebView(expected) | |||
| 291 | + | if !expectedOK || expectedIdentity != identity { | |||
| 292 | + | return false | |||
| 293 | + | } | |||
| 264 | } | 294 | } | ||
| 265 | - | c.revealMu.Unlock() | 295 | + | revealedIdentity, revealed := c.revealedWebViews[paneID] |
| 296 | + | return revealed && revealedIdentity == identity | |||
| 297 | + | } | |||
| 266 | 298 | ||||
| 267 | - | if !pending { | 299 | + | func (c *Coordinator) markWebViewRevealed(paneID entity.PaneID, identity webViewIdentity) bool { |
| 300 | + | c.webViewsMu.RLock() | |||
| 301 | + | c.revealMu.Lock() | |||
| 302 | + | defer c.revealMu.Unlock() | |||
| 303 | + | defer c.webViewsMu.RUnlock() | |||
| 304 | + | currentIdentity, current := identityForWebView(c.webViews[paneID]) | |||
| 305 | + | if !current || currentIdentity != identity { | |||
| 306 | + | return false | |||
| 307 | + | } | |||
| 308 | + | c.ensureRevealMapsLocked() | |||
| 309 | + | c.revealedWebViews[paneID] = identity | |||
| 310 | + | return true | |||
| 311 | + | } | |||
| 312 | + | ||||
| 313 | + | func (c *Coordinator) revealIfPending( | |||
| 314 | + | ctx context.Context, paneID entity.PaneID, wv port.WebView, identity webViewIdentity, uri, reason string, | |||
| 315 | + | ) { | |||
| 316 | + | // Mapping replacement and release acquire this gate too. No ordinary map | |||
| 317 | + | // lock is held across the native call, but the gate keeps the validated | |||
| 318 | + | // identity current until its widget visibility has been changed. | |||
| 319 | + | c.revealMutationMu.Lock() | |||
| 320 | + | ||||
| 321 | + | c.webViewsMu.RLock() | |||
| 322 | + | c.revealMu.Lock() | |||
| 323 | + | pendingIdentity, pending := c.pendingReveal[paneID] | |||
| 324 | + | if !pending || pendingIdentity != identity { | |||
| 325 | + | c.revealMu.Unlock() | |||
| 326 | + | c.webViewsMu.RUnlock() | |||
| 327 | + | c.revealMutationMu.Unlock() | |||
| 268 | return | 328 | return | ||
| 269 | } | 329 | } | ||
| 330 | + | currentIdentity, current := identityForWebView(c.webViews[paneID]) | |||
| 331 | + | if !current || currentIdentity != identity { | |||
| 332 | + | c.revealMu.Unlock() | |||
| 333 | + | c.webViewsMu.RUnlock() | |||
| 334 | + | c.revealMutationMu.Unlock() | |||
| 335 | + | return | |||
| 336 | + | } | |||
| 337 | + | delete(c.pendingReveal, paneID) | |||
| 338 | + | c.revealMu.Unlock() | |||
| 339 | + | c.webViewsMu.RUnlock() | |||
| 270 | 340 | ||||
| 271 | - | wv := c.getWebViewLocked(paneID) | 341 | + | if wv.IsDestroyed() { |
| 272 | - | if wv == nil || wv.IsDestroyed() { | 342 | + | c.revealMutationMu.Unlock() |
| 273 | return | 343 | return | ||
| 274 | } | 344 | } | ||
| 275 | 345 | ||||
| 276 | - | // Use NativeWidgetProvider to make the widget visible (engine-agnostic) | 346 | + | if c.setWebViewVisible != nil { |
| 277 | - | if nwp, ok := wv.(port.NativeWidgetProvider); ok { | 347 | + | c.setWebViewVisible(wv) |
| 348 | + | } else if nwp, ok := wv.(port.NativeWidgetProvider); ok { | |||
| 278 | if ptr := nwp.NativeWidget(); ptr != 0 { | 349 | if ptr := nwp.NativeWidget(); ptr != 0 { | ||
| 279 | gtkWidget := >k.Widget{} | 350 | gtkWidget := >k.Widget{} | ||
| 280 | gtkWidget.Ptr = ptr | 351 | gtkWidget.Ptr = ptr | ||
| @@ -288,11 +359,11 @@ func (c *Coordinator) revealIfPending(ctx context.Context, paneID entity.PaneID, | |||||
| 288 | } | 359 | } | ||
| 289 | } | 360 | } | ||
| 290 | 361 | ||||
| 291 | - | // Mark first_paint and finish startup trace | 362 | + | // Keep the presentation record in the same serialized operation as the |
| 292 | - | logging.Trace().Mark("first_paint") | 363 | + | // native mutation. User callbacks run only after the gate is released. |
| 293 | - | logging.Trace().Finish() | 364 | + | revealed := c.markWebViewRevealed(paneID, identity) |
| 294 | - | 365 | + | c.revealMutationMu.Unlock() | |
| 295 | - | if c.onWebViewShown != nil { | 366 | + | if revealed && c.onWebViewShown != nil { |
| 296 | c.onWebViewShown(paneID) | 367 | c.onWebViewShown(paneID) | ||
| 297 | } | 368 | } | ||
| 298 | } | 369 | } | ||
M internal/ui/coordinator/content/popup_test.go +7 -7
| @@ -137,7 +137,7 @@ func newPopupCreateCoordinatorForTest(t *testing.T, popupID port.WebViewID) (con | |||||
| 137 | parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once() | 137 | parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once() | ||
| 138 | 138 | ||||
| 139 | popupWV := mocks.NewMockWebView(t) | 139 | popupWV := mocks.NewMockWebView(t) | ||
| 140 | - | popupWV.EXPECT().ID().Return(popupID).Once() | 140 | + | popupWV.EXPECT().ID().Return(popupID).Maybe() |
| 141 | 141 | ||||
| 142 | factory := mocks.NewMockWebViewFactory(t) | 142 | factory := mocks.NewMockWebViewFactory(t) | ||
| 143 | factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once() | 143 | factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once() | ||
| @@ -158,8 +158,8 @@ func TestHandlePopupCreate_PrimesPopupNavigationCapability(t *testing.T) { | |||||
| 158 | parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once() | 158 | parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once() | ||
| 159 | 159 | ||||
| 160 | popupWV := &popupNavigationWebViewStub{MockWebView: mocks.NewMockWebView(t)} | 160 | popupWV := &popupNavigationWebViewStub{MockWebView: mocks.NewMockWebView(t)} | ||
| 161 | - | popupWV.EXPECT().ID().Return(port.WebViewID(149)).Once() | 161 | + | popupWV.EXPECT().ID().Return(port.WebViewID(149)).Maybe() |
| 162 | - | popupWV.EXPECT().Generation().Return(uint64(1)).Once() | 162 | + | popupWV.EXPECT().Generation().Return(uint64(1)).Maybe() |
| 163 | popupWV.EXPECT().SetCallbacks(mock.Anything).Once() | 163 | popupWV.EXPECT().SetCallbacks(mock.Anything).Once() | ||
| 164 | 164 | ||||
| 165 | factory := mocks.NewMockWebViewFactory(t) | 165 | factory := mocks.NewMockWebViewFactory(t) | ||
| @@ -187,7 +187,7 @@ func TestHandlePopupCreate_PrimesPopupNavigationCapability(t *testing.T) { | |||||
| 187 | 187 | ||||
| 188 | func TestHandlePopupCreate_RegistersPopupWebViewBeforeWorkspaceInsertion(t *testing.T) { | 188 | func TestHandlePopupCreate_RegistersPopupWebViewBeforeWorkspaceInsertion(t *testing.T) { | ||
| 189 | ctx, parentPaneID, parentWV, popupWV, c := newPopupCreateCoordinatorForTest(t, port.WebViewID(150)) | 189 | ctx, parentPaneID, parentWV, popupWV, c := newPopupCreateCoordinatorForTest(t, port.WebViewID(150)) | ||
| 190 | - | popupWV.EXPECT().Generation().Return(uint64(1)).Once() | 190 | + | popupWV.EXPECT().Generation().Return(uint64(1)).Maybe() |
| 191 | popupWV.EXPECT().SetCallbacks(mock.Anything).Once() | 191 | popupWV.EXPECT().SetCallbacks(mock.Anything).Once() | ||
| 192 | popupWV.EXPECT().IsLoading().Return(false).Once() | 192 | popupWV.EXPECT().IsLoading().Return(false).Once() | ||
| 193 | popupWV.EXPECT().URI().Return("").Once() | 193 | popupWV.EXPECT().URI().Return("").Once() | ||
| @@ -211,7 +211,7 @@ func TestHandlePopupCreate_RegistersPopupWebViewBeforeWorkspaceInsertion(t *test | |||||
| 211 | 211 | ||||
| 212 | func TestHandlePopupCreate_CleansUpPreRegisteredPopupWebViewWhenInsertionFails(t *testing.T) { | 212 | func TestHandlePopupCreate_CleansUpPreRegisteredPopupWebViewWhenInsertionFails(t *testing.T) { | ||
| 213 | ctx, parentPaneID, parentWV, popupWV, c := newPopupCreateCoordinatorForTest(t, port.WebViewID(151)) | 213 | ctx, parentPaneID, parentWV, popupWV, c := newPopupCreateCoordinatorForTest(t, port.WebViewID(151)) | ||
| 214 | - | popupWV.EXPECT().Generation().Return(uint64(1)).Once() | 214 | + | popupWV.EXPECT().Generation().Return(uint64(1)).Maybe() |
| 215 | popupWV.EXPECT().SetCallbacks(mock.Anything).Once() | 215 | popupWV.EXPECT().SetCallbacks(mock.Anything).Once() | ||
| 216 | popupWV.EXPECT().Destroy().Once() | 216 | popupWV.EXPECT().Destroy().Once() | ||
| 217 | 217 | ||||
| @@ -238,8 +238,8 @@ func TestHandlePopupCreate_UsesRelatedWebViewWhenPopupDisablesJavaScriptAccess(t | |||||
| 238 | parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once() | 238 | parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once() | ||
| 239 | 239 | ||||
| 240 | popupWV := mocks.NewMockWebView(t) | 240 | popupWV := mocks.NewMockWebView(t) | ||
| 241 | - | popupWV.EXPECT().ID().Return(port.WebViewID(150)).Once() | 241 | + | popupWV.EXPECT().ID().Return(port.WebViewID(150)).Maybe() |
| 242 | - | popupWV.EXPECT().Generation().Return(uint64(1)).Once() | 242 | + | popupWV.EXPECT().Generation().Return(uint64(1)).Maybe() |
| 243 | popupWV.EXPECT().SetCallbacks(mock.Anything).Once() | 243 | popupWV.EXPECT().SetCallbacks(mock.Anything).Once() | ||
| 244 | popupWV.EXPECT().IsLoading().Return(false).Once() | 244 | popupWV.EXPECT().IsLoading().Return(false).Once() | ||
| 245 | popupWV.EXPECT().URI().Return("").Once() | 245 | popupWV.EXPECT().URI().Return("").Once() | ||
A internal/ui/floating_pane_callback_cleanup_test.go +32 -0
| @@ -0,0 +1,32 @@ | |||||
| 1 | + | package ui | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/puregotk/v4/gtk" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestStopFloatingResizeWatcherReleasesRawCallbackSlot(t *testing.T) { | |||
| 10 | + | callback := new(gtk.TickCallback) | |||
| 11 | + | session := &floatingWorkspaceSession{resizeTickCallback: callback, resizeTickID: 17, resizeWatcherActive: true} | |||
| 12 | + | oldUnref := unrefTickCallback | |||
| 13 | + | defer func() { unrefTickCallback = oldUnref }() | |||
| 14 | + | var unrefs int | |||
| 15 | + | unrefTickCallback = func(got any) error { | |||
| 16 | + | if got != callback { | |||
| 17 | + | t.Fatalf("unref callback = %p, want %p", got, callback) | |||
| 18 | + | } | |||
| 19 | + | unrefs++ | |||
| 20 | + | return nil | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | (&App{}).stopFloatingResizeWatcher(session) | |||
| 24 | + | (&App{}).stopFloatingResizeWatcher(session) | |||
| 25 | + | ||||
| 26 | + | if session.resizeTickCallback != nil || session.resizeTickID != 0 || session.resizeWatcherActive { | |||
| 27 | + | t.Fatal("stop must release the floating resize callback slot exactly once") | |||
| 28 | + | } | |||
| 29 | + | if unrefs != 1 { | |||
| 30 | + | t.Fatalf("callback unrefs = %d, want 1", unrefs) | |||
| 31 | + | } | |||
| 32 | + | } | |||
M internal/ui/input/hover.go +119 -41
| @@ -7,6 +7,8 @@ import ( | |||||
| 7 | 7 | ||||
| 8 | "github.com/bnema/dumber/internal/domain/entity" | 8 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 9 | "github.com/bnema/dumber/internal/logging" | 9 | "github.com/bnema/dumber/internal/logging" | ||
| 10 | + | "github.com/bnema/puregotk/v4/glib" | |||
| 11 | + | "github.com/bnema/puregotk/v4/gobject" | |||
| 10 | "github.com/bnema/puregotk/v4/gtk" | 12 | "github.com/bnema/puregotk/v4/gtk" | ||
| 11 | ) | 13 | ) | ||
| 12 | 14 | ||||
| @@ -24,19 +26,28 @@ type MotionCallback func() | |||||
| 24 | // HoverHandler handles mouse hover events for focus-follows-mouse behavior. | 26 | // HoverHandler handles mouse hover events for focus-follows-mouse behavior. | ||
| 25 | // It uses a debounce timer to avoid rapid focus switches. | 27 | // It uses a debounce timer to avoid rapid focus switches. | ||
| 26 | type HoverHandler struct { | 28 | type HoverHandler struct { | ||
| 27 | - | motionCtrl *gtk.EventControllerMotion | 29 | + | attachedWidget *gtk.Widget |
| 28 | - | paneID entity.PaneID | 30 | + | motionCtrl *gtk.EventControllerMotion |
| 29 | - | onEnter HoverCallback | 31 | + | enterSignalID uint |
| 30 | - | onMotion MotionCallback | 32 | + | leaveSignalID uint |
| 33 | + | motionSignalID uint | |||
| 34 | + | paneID entity.PaneID | |||
| 35 | + | onEnter HoverCallback | |||
| 36 | + | onMotion MotionCallback | |||
| 31 | 37 | ||||
| 32 | // Callback retention: must stay reachable by Go GC. | 38 | // Callback retention: must stay reachable by Go GC. | ||
| 33 | enterCb func(gtk.EventControllerMotion, float64, float64) | 39 | enterCb func(gtk.EventControllerMotion, float64, float64) | ||
| 34 | leaveCb func(gtk.EventControllerMotion) | 40 | leaveCb func(gtk.EventControllerMotion) | ||
| 35 | motionCb func(gtk.EventControllerMotion, float64, float64) | 41 | motionCb func(gtk.EventControllerMotion, float64, float64) | ||
| 36 | 42 | ||||
| 37 | - | timer *time.Timer | 43 | + | timerMu sync.Mutex |
| 38 | - | timerMu sync.Mutex | 44 | + | sourceID uint |
| 39 | - | canceled bool | 45 | + | generation uint64 |
| 46 | + | detached bool | |||
| 47 | + | schedule func(uint, func(uintptr) bool) uint | |||
| 48 | + | removeSource func(uint) bool | |||
| 49 | + | disconnectSignal func(*gtk.EventControllerMotion, uint) | |||
| 50 | + | removeController func(*gtk.Widget, *gtk.EventController) | |||
| 40 | 51 | ||||
| 41 | ctx context.Context | 52 | ctx context.Context | ||
| 42 | } | 53 | } | ||
| @@ -47,8 +58,12 @@ func NewHoverHandler(ctx context.Context, paneID entity.PaneID) *HoverHandler { | |||||
| 47 | log.Debug().Str("pane_id", string(paneID)).Msg("creating hover handler") | 58 | log.Debug().Str("pane_id", string(paneID)).Msg("creating hover handler") | ||
| 48 | 59 | ||||
| 49 | return &HoverHandler{ | 60 | return &HoverHandler{ | ||
| 50 | - | ctx: ctx, | 61 | + | ctx: ctx, |
| 51 | - | paneID: paneID, | 62 | + | paneID: paneID, |
| 63 | + | schedule: scheduleHoverSource, | |||
| 64 | + | removeSource: glib.SourceRemove, | |||
| 65 | + | disconnectSignal: disconnectHoverSignal, | |||
| 66 | + | removeController: removeHoverController, | |||
| 52 | } | 67 | } | ||
| 53 | } | 68 | } | ||
| 54 | 69 | ||||
| @@ -73,6 +88,15 @@ func (h *HoverHandler) AttachTo(widget *gtk.Widget) { | |||||
| 73 | return | 88 | return | ||
| 74 | } | 89 | } | ||
| 75 | 90 | ||||
| 91 | + | // Reattachment must not leave a controller owned by the previous widget. | |||
| 92 | + | if h.motionCtrl != nil || h.attachedWidget != nil { | |||
| 93 | + | h.Detach() | |||
| 94 | + | } | |||
| 95 | + | h.timerMu.Lock() | |||
| 96 | + | h.detached = false | |||
| 97 | + | h.attachedWidget = widget | |||
| 98 | + | h.timerMu.Unlock() | |||
| 99 | + | ||||
| 76 | h.motionCtrl = gtk.NewEventControllerMotion() | 100 | h.motionCtrl = gtk.NewEventControllerMotion() | ||
| 77 | if h.motionCtrl == nil { | 101 | if h.motionCtrl == nil { | ||
| 78 | log.Error().Msg("failed to create motion controller") | 102 | log.Error().Msg("failed to create motion controller") | ||
| @@ -83,13 +107,13 @@ func (h *HoverHandler) AttachTo(widget *gtk.Widget) { | |||||
| 83 | h.enterCb = func(_ gtk.EventControllerMotion, _ float64, _ float64) { | 107 | h.enterCb = func(_ gtk.EventControllerMotion, _ float64, _ float64) { | ||
| 84 | h.handleEnter() | 108 | h.handleEnter() | ||
| 85 | } | 109 | } | ||
| 86 | - | h.motionCtrl.ConnectEnter(&h.enterCb) | 110 | + | h.enterSignalID = h.motionCtrl.ConnectEnter(&h.enterCb) |
| 87 | 111 | ||||
| 88 | // Connect leave handler to cancel pending focus | 112 | // Connect leave handler to cancel pending focus | ||
| 89 | h.leaveCb = func(_ gtk.EventControllerMotion) { | 113 | h.leaveCb = func(_ gtk.EventControllerMotion) { | ||
| 90 | h.handleLeave() | 114 | h.handleLeave() | ||
| 91 | } | 115 | } | ||
| 92 | - | h.motionCtrl.ConnectLeave(&h.leaveCb) | 116 | + | h.leaveSignalID = h.motionCtrl.ConnectLeave(&h.leaveCb) |
| 93 | 117 | ||||
| 94 | // Connect motion handler to detect intentional mouse movement. | 118 | // Connect motion handler to detect intentional mouse movement. | ||
| 95 | // Unlike enter/leave, motion only fires when the cursor physically moves | 119 | // Unlike enter/leave, motion only fires when the cursor physically moves | ||
| @@ -99,7 +123,7 @@ func (h *HoverHandler) AttachTo(widget *gtk.Widget) { | |||||
| 99 | h.onMotion() | 123 | h.onMotion() | ||
| 100 | } | 124 | } | ||
| 101 | } | 125 | } | ||
| 102 | - | h.motionCtrl.ConnectMotion(&h.motionCb) | 126 | + | h.motionSignalID = h.motionCtrl.ConnectMotion(&h.motionCb) |
| 103 | 127 | ||||
| 104 | // Add controller to widget | 128 | // Add controller to widget | ||
| 105 | widget.AddController(&h.motionCtrl.EventController) | 129 | widget.AddController(&h.motionCtrl.EventController) | ||
| @@ -107,32 +131,49 @@ func (h *HoverHandler) AttachTo(widget *gtk.Widget) { | |||||
| 107 | log.Debug().Str("pane_id", string(h.paneID)).Msg("hover handler attached to widget") | 131 | log.Debug().Str("pane_id", string(h.paneID)).Msg("hover handler attached to widget") | ||
| 108 | } | 132 | } | ||
| 109 | 133 | ||||
| 134 | + | // scheduleHoverSource schedules a one-shot callback on GTK's owning GLib main | |||
| 135 | + | // context. GTK state must never be touched by a Go timer goroutine. | |||
| 136 | + | func scheduleHoverSource(delay uint, fn func(uintptr) bool) uint { | |||
| 137 | + | cb := glib.SourceFunc(fn) | |||
| 138 | + | return glib.TimeoutAdd(delay, &cb, 0) | |||
| 139 | + | } | |||
| 140 | + | ||||
| 110 | // handleEnter processes the mouse enter event with debouncing. | 141 | // handleEnter processes the mouse enter event with debouncing. | ||
| 111 | func (h *HoverHandler) handleEnter() { | 142 | func (h *HoverHandler) handleEnter() { | ||
| 112 | h.timerMu.Lock() | 143 | h.timerMu.Lock() | ||
| 113 | - | defer h.timerMu.Unlock() | 144 | + | if h.detached { |
| 114 | - | 145 | + | h.timerMu.Unlock() | |
| 115 | - | // Cancel any existing timer | 146 | + | return |
| 116 | - | if h.timer != nil { | |||
| 117 | - | h.timer.Stop() | |||
| 118 | } | 147 | } | ||
| 119 | - | 148 | + | h.cancelSourceLocked() | |
| 120 | - | h.canceled = false | 149 | + | h.generation++ |
| 121 | - | 150 | + | generation := h.generation | |
| 122 | - | // Start new debounce timer | 151 | + | schedule := h.schedule |
| 123 | - | h.timer = time.AfterFunc(HoverFocusDelay, func() { | 152 | + | h.sourceID = schedule(uint(HoverFocusDelay.Milliseconds()), func(_ uintptr) bool { |
| 124 | h.timerMu.Lock() | 153 | h.timerMu.Lock() | ||
| 125 | - | canceled := h.canceled | 154 | + | if h.detached || h.generation != generation { |
| 126 | - | h.timerMu.Unlock() | 155 | + | h.timerMu.Unlock() |
| 127 | - | 156 | + | return false | |
| 128 | - | if canceled { | |||
| 129 | - | return | |||
| 130 | } | 157 | } | ||
| 158 | + | h.sourceID = 0 | |||
| 159 | + | callback := h.onEnter | |||
| 160 | + | paneID := h.paneID | |||
| 161 | + | h.timerMu.Unlock() | |||
| 131 | 162 | ||||
| 132 | - | if h.onEnter != nil { | 163 | + | if callback != nil { |
| 133 | - | h.onEnter(h.paneID) | 164 | + | callback(paneID) |
| 134 | } | 165 | } | ||
| 166 | + | return false | |||
| 135 | }) | 167 | }) | ||
| 168 | + | h.timerMu.Unlock() | |||
| 169 | + | } | |||
| 170 | + | ||||
| 171 | + | func (h *HoverHandler) cancelSourceLocked() { | |||
| 172 | + | if h.sourceID == 0 { | |||
| 173 | + | return | |||
| 174 | + | } | |||
| 175 | + | h.removeSource(h.sourceID) | |||
| 176 | + | h.sourceID = 0 | |||
| 136 | } | 177 | } | ||
| 137 | 178 | ||||
| 138 | // handleLeave cancels any pending focus switch. | 179 | // handleLeave cancels any pending focus switch. | ||
| @@ -146,24 +187,61 @@ func (h *HoverHandler) Cancel() { | |||||
| 146 | h.timerMu.Lock() | 187 | h.timerMu.Lock() | ||
| 147 | defer h.timerMu.Unlock() | 188 | defer h.timerMu.Unlock() | ||
| 148 | 189 | ||||
| 149 | - | h.canceled = true | 190 | + | h.generation++ |
| 150 | - | if h.timer != nil { | 191 | + | h.cancelSourceLocked() |
| 151 | - | h.timer.Stop() | |||
| 152 | - | h.timer = nil | |||
| 153 | - | } | |||
| 154 | } | 192 | } | ||
| 155 | 193 | ||||
| 156 | - | // Detach removes the hover handler. | 194 | + | // Detach removes the hover handler and invalidates callbacks already queued on |
| 195 | + | // the GLib main context. The generation check is required because removing a | |||
| 196 | + | // source can race with dispatch. | |||
| 157 | func (h *HoverHandler) Detach() { | 197 | func (h *HoverHandler) Detach() { | ||
| 158 | h.timerMu.Lock() | 198 | h.timerMu.Lock() | ||
| 159 | - | if h.timer != nil { | 199 | + | h.detached = true |
| 160 | - | h.timer.Stop() | 200 | + | h.generation++ |
| 161 | - | h.timer = nil | 201 | + | h.cancelSourceLocked() |
| 162 | - | } | 202 | + | h.onEnter = nil |
| 163 | - | h.timerMu.Unlock() | 203 | + | h.onMotion = nil |
| 164 | - | 204 | + | widget := h.attachedWidget | |
| 205 | + | controller := h.motionCtrl | |||
| 206 | + | enterID := h.enterSignalID | |||
| 207 | + | leaveID := h.leaveSignalID | |||
| 208 | + | motionID := h.motionSignalID | |||
| 209 | + | h.attachedWidget = nil | |||
| 165 | h.motionCtrl = nil | 210 | h.motionCtrl = nil | ||
| 211 | + | h.enterSignalID = 0 | |||
| 212 | + | h.leaveSignalID = 0 | |||
| 213 | + | h.motionSignalID = 0 | |||
| 166 | h.enterCb = nil | 214 | h.enterCb = nil | ||
| 167 | h.leaveCb = nil | 215 | h.leaveCb = nil | ||
| 168 | h.motionCb = nil | 216 | h.motionCb = nil | ||
| 217 | + | disconnectSignal := h.disconnectSignal | |||
| 218 | + | removeController := h.removeController | |||
| 219 | + | h.timerMu.Unlock() | |||
| 220 | + | ||||
| 221 | + | if controller == nil { | |||
| 222 | + | return | |||
| 223 | + | } | |||
| 224 | + | if disconnectSignal != nil { | |||
| 225 | + | for _, id := range []uint{enterID, leaveID, motionID} { | |||
| 226 | + | if id != 0 { | |||
| 227 | + | disconnectSignal(controller, id) | |||
| 228 | + | } | |||
| 229 | + | } | |||
| 230 | + | } | |||
| 231 | + | if widget != nil && removeController != nil { | |||
| 232 | + | removeController(widget, &controller.EventController) | |||
| 233 | + | } | |||
| 234 | + | } | |||
| 235 | + | ||||
| 236 | + | func disconnectHoverSignal(controller *gtk.EventControllerMotion, id uint) { | |||
| 237 | + | if controller == nil || id == 0 || controller.GoPointer() == 0 { | |||
| 238 | + | return | |||
| 239 | + | } | |||
| 240 | + | gobject.SignalHandlerDisconnect(gobject.ObjectNewFromInternalPtr(controller.GoPointer()), id) | |||
| 241 | + | } | |||
| 242 | + | ||||
| 243 | + | func removeHoverController(widget *gtk.Widget, controller *gtk.EventController) { | |||
| 244 | + | if widget != nil && controller != nil { | |||
| 245 | + | widget.RemoveController(controller) | |||
| 246 | + | } | |||
| 169 | } | 247 | } | ||
A internal/ui/input/hover_test.go +90 -0
| @@ -0,0 +1,90 @@ | |||||
| 1 | + | package input | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "reflect" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 9 | + | "github.com/bnema/puregotk/v4/gtk" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | func TestHoverHandlerSchedulesCallbackOnGLibAndRejectsStaleGeneration(t *testing.T) { | |||
| 13 | + | h := NewHoverHandler(context.Background(), entity.PaneID("pane-1")) | |||
| 14 | + | var scheduled func(uintptr) bool | |||
| 15 | + | h.schedule = func(_ uint, fn func(uintptr) bool) uint { | |||
| 16 | + | scheduled = fn | |||
| 17 | + | return 7 | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | var calls int | |||
| 21 | + | h.SetOnEnter(func(entity.PaneID) { calls++ }) | |||
| 22 | + | h.handleEnter() | |||
| 23 | + | first := scheduled | |||
| 24 | + | h.handleEnter() | |||
| 25 | + | if first(0) { | |||
| 26 | + | t.Fatal("hover source must be one-shot") | |||
| 27 | + | } | |||
| 28 | + | if calls != 0 { | |||
| 29 | + | t.Fatalf("stale hover callback ran %d times", calls) | |||
| 30 | + | } | |||
| 31 | + | if scheduled == nil { | |||
| 32 | + | t.Fatal("current hover callback was not scheduled") | |||
| 33 | + | } | |||
| 34 | + | if scheduled(0) { | |||
| 35 | + | t.Fatal("hover source must be one-shot") | |||
| 36 | + | } | |||
| 37 | + | if calls != 1 { | |||
| 38 | + | t.Fatalf("current hover callback ran %d times, want 1", calls) | |||
| 39 | + | } | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | func TestHoverHandlerDetachRemovesNativeControllerAndSignalsExactlyOnce(t *testing.T) { | |||
| 43 | + | widget := >k.Widget{} | |||
| 44 | + | controller := >k.EventControllerMotion{} | |||
| 45 | + | h := NewHoverHandler(context.Background(), entity.PaneID("pane-1")) | |||
| 46 | + | h.attachedWidget = widget | |||
| 47 | + | h.motionCtrl = controller | |||
| 48 | + | h.enterSignalID = 1 | |||
| 49 | + | h.leaveSignalID = 2 | |||
| 50 | + | h.motionSignalID = 3 | |||
| 51 | + | var disconnected []uint | |||
| 52 | + | var removed int | |||
| 53 | + | h.disconnectSignal = func(_ *gtk.EventControllerMotion, id uint) { disconnected = append(disconnected, id) } | |||
| 54 | + | h.removeController = func(gotWidget *gtk.Widget, gotController *gtk.EventController) { | |||
| 55 | + | if gotWidget != widget || gotController != &controller.EventController { | |||
| 56 | + | t.Fatal("detach removed the wrong native controller") | |||
| 57 | + | } | |||
| 58 | + | removed++ | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | h.Detach() | |||
| 62 | + | h.Detach() | |||
| 63 | + | ||||
| 64 | + | if got, want := disconnected, []uint{1, 2, 3}; !reflect.DeepEqual(got, want) { | |||
| 65 | + | t.Fatalf("disconnected signals = %v, want %v", got, want) | |||
| 66 | + | } | |||
| 67 | + | if removed != 1 { | |||
| 68 | + | t.Fatalf("native controllers removed = %d, want 1", removed) | |||
| 69 | + | } | |||
| 70 | + | if h.attachedWidget != nil || h.motionCtrl != nil || h.enterCb != nil || h.leaveCb != nil || h.motionCb != nil { | |||
| 71 | + | t.Fatal("detach must clear native and callback ownership") | |||
| 72 | + | } | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | func TestHoverHandlerDetachInvalidatesScheduledCallbackAndRemovesSource(t *testing.T) { | |||
| 76 | + | h := NewHoverHandler(context.Background(), entity.PaneID("pane-1")) | |||
| 77 | + | var scheduled func(uintptr) bool | |||
| 78 | + | var removed []uint | |||
| 79 | + | h.schedule = func(_ uint, fn func(uintptr) bool) uint { scheduled = fn; return 9 } | |||
| 80 | + | h.removeSource = func(id uint) bool { removed = append(removed, id); return true } | |||
| 81 | + | h.SetOnEnter(func(entity.PaneID) { t.Fatal("detached hover callback ran") }) | |||
| 82 | + | h.handleEnter() | |||
| 83 | + | h.Detach() | |||
| 84 | + | if len(removed) != 1 || removed[0] != 9 { | |||
| 85 | + | t.Fatalf("removed sources = %v, want [9]", removed) | |||
| 86 | + | } | |||
| 87 | + | if scheduled == nil || scheduled(0) { | |||
| 88 | + | t.Fatal("detached callback must be rejected and one-shot") | |||
| 89 | + | } | |||
| 90 | + | } | |||
M internal/ui/layout/gtk_widgets.go +49 -5
| @@ -1,7 +1,10 @@ | |||||
| 1 | package layout | 1 | package layout | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "sync" | |||
| 5 | + | ||||
| 4 | "github.com/bnema/puregotk/v4/gdk" | 6 | "github.com/bnema/puregotk/v4/gdk" | ||
| 7 | + | "github.com/bnema/puregotk/v4/glib" | |||
| 5 | "github.com/bnema/puregotk/v4/gobject" | 8 | "github.com/bnema/puregotk/v4/gobject" | ||
| 6 | "github.com/bnema/puregotk/v4/graphene" | 9 | "github.com/bnema/puregotk/v4/graphene" | ||
| 7 | "github.com/bnema/puregotk/v4/gtk" | 10 | "github.com/bnema/puregotk/v4/gtk" | ||
| @@ -82,8 +85,14 @@ func (w *gtkWidget) ComputePoint(target Widget) (x, y float64, ok bool) { | |||||
| 82 | // gtkPaned wraps gtk.Paned to implement PanedWidget. | 85 | // gtkPaned wraps gtk.Paned to implement PanedWidget. | ||
| 83 | type gtkPaned struct { | 86 | type gtkPaned struct { | ||
| 84 | inner *gtk.Paned | 87 | inner *gtk.Paned | ||
| 88 | + | ||||
| 89 | + | tickMu sync.Mutex | |||
| 90 | + | tickCallbacks map[uint]*gtk.TickCallback | |||
| 85 | } | 91 | } | ||
| 86 | 92 | ||||
| 93 | + | // unrefTickCallback is the canonical puregotk/purego callback-slot release. | |||
| 94 | + | var unrefTickCallback = glib.UnrefCallback | |||
| 95 | + | ||||
| 87 | func (p *gtkPaned) Show() { p.inner.Show() } | 96 | func (p *gtkPaned) Show() { p.inner.Show() } | ||
| 88 | func (p *gtkPaned) Hide() { p.inner.Hide() } | 97 | func (p *gtkPaned) Hide() { p.inner.Hide() } | ||
| 89 | func (p *gtkPaned) SetVisible(visible bool) { p.inner.SetVisible(visible) } | 98 | func (p *gtkPaned) SetVisible(visible bool) { p.inner.SetVisible(visible) } | ||
| @@ -176,10 +185,45 @@ func (p *gtkPaned) ConnectNotifyPosition(callback func()) uint { | |||||
| 176 | } | 185 | } | ||
| 177 | 186 | ||||
| 178 | func (p *gtkPaned) AddTickCallback(callback func() bool) uint { | 187 | func (p *gtkPaned) AddTickCallback(callback func() bool) uint { | ||
| 179 | - | cb := gtk.TickCallback(func(widget uintptr, frameClock uintptr, userData uintptr) bool { | 188 | + | cb := new(gtk.TickCallback) |
| 180 | - | return callback() | 189 | + | var id uint |
| 181 | - | }) | 190 | + | *cb = func(_ uintptr, _ uintptr, _ uintptr) bool { |
| 182 | - | return p.inner.AddTickCallback(&cb, 0, nil) | 191 | + | keepRunning := callback() |
| 192 | + | if !keepRunning { | |||
| 193 | + | // GTK removes a false-returning source itself; release its matching | |||
| 194 | + | // purego slot exactly once rather than merely dropping the Go pointer. | |||
| 195 | + | p.releaseTickCallback(id) | |||
| 196 | + | } | |||
| 197 | + | return keepRunning | |||
| 198 | + | } | |||
| 199 | + | id = p.inner.AddTickCallback(cb, 0, nil) | |||
| 200 | + | if id != 0 { | |||
| 201 | + | p.tickMu.Lock() | |||
| 202 | + | if p.tickCallbacks == nil { | |||
| 203 | + | p.tickCallbacks = make(map[uint]*gtk.TickCallback) | |||
| 204 | + | } | |||
| 205 | + | p.tickCallbacks[id] = cb | |||
| 206 | + | p.tickMu.Unlock() | |||
| 207 | + | } | |||
| 208 | + | return id | |||
| 209 | + | } | |||
| 210 | + | ||||
| 211 | + | func (p *gtkPaned) RemoveTickCallback(id uint) { | |||
| 212 | + | p.inner.RemoveTickCallback(id) | |||
| 213 | + | p.releaseTickCallback(id) | |||
| 214 | + | } | |||
| 215 | + | ||||
| 216 | + | func (p *gtkPaned) releaseTickCallback(id uint) { | |||
| 217 | + | if id == 0 { | |||
| 218 | + | return | |||
| 219 | + | } | |||
| 220 | + | p.tickMu.Lock() | |||
| 221 | + | callback := p.tickCallbacks[id] | |||
| 222 | + | delete(p.tickCallbacks, id) | |||
| 223 | + | p.tickMu.Unlock() | |||
| 224 | + | if callback != nil { | |||
| 225 | + | _ = unrefTickCallback(callback) | |||
| 226 | + | } | |||
| 183 | } | 227 | } | ||
| 184 | 228 | ||||
| 185 | func (p *gtkPaned) GetAllocatedWidth() int { return p.inner.GetAllocatedWidth() } | 229 | func (p *gtkPaned) GetAllocatedWidth() int { return p.inner.GetAllocatedWidth() } | ||
| @@ -779,7 +823,7 @@ func (f *GtkWidgetFactory) NewPaned(orientation Orientation) PanedWidget { | |||||
| 779 | paned := gtk.NewPaned(orientation) | 823 | paned := gtk.NewPaned(orientation) | ||
| 780 | paned.SetHexpand(true) | 824 | paned.SetHexpand(true) | ||
| 781 | paned.SetVexpand(true) | 825 | paned.SetVexpand(true) | ||
| 782 | - | return >kPaned{inner: paned} | 826 | + | return >kPaned{inner: paned, tickCallbacks: make(map[uint]*gtk.TickCallback)} |
| 783 | } | 827 | } | ||
| 784 | 828 | ||||
| 785 | func (f *GtkWidgetFactory) NewBox(orientation Orientation, spacing int) BoxWidget { | 829 | func (f *GtkWidgetFactory) NewBox(orientation Orientation, spacing int) BoxWidget { | ||
A internal/ui/layout/gtk_widgets_tick_cleanup_test.go +29 -0
| @@ -0,0 +1,29 @@ | |||||
| 1 | + | package layout | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/puregotk/v4/gtk" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestGtkPanedReleaseTickCallbackUnrefsRawSlotExactlyOnce(t *testing.T) { | |||
| 10 | + | callback := new(gtk.TickCallback) | |||
| 11 | + | paned := >kPaned{tickCallbacks: map[uint]*gtk.TickCallback{7: callback}} | |||
| 12 | + | oldUnref := unrefTickCallback | |||
| 13 | + | defer func() { unrefTickCallback = oldUnref }() | |||
| 14 | + | var unrefs int | |||
| 15 | + | unrefTickCallback = func(got any) error { | |||
| 16 | + | if got != callback { | |||
| 17 | + | t.Fatalf("unref callback = %p, want %p", got, callback) | |||
| 18 | + | } | |||
| 19 | + | unrefs++ | |||
| 20 | + | return nil | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | paned.releaseTickCallback(7) | |||
| 24 | + | paned.releaseTickCallback(7) | |||
| 25 | + | ||||
| 26 | + | if unrefs != 1 { | |||
| 27 | + | t.Fatalf("callback unrefs = %d, want 1", unrefs) | |||
| 28 | + | } | |||
| 29 | + | } | |||
M internal/ui/layout/mocks/widgets.go +40 -0
| @@ -2425,6 +2425,46 @@ func (_c *MockPanedWidget_RemoveCssClass_Call) RunAndReturn(run func(cssClass st | |||||
| 2425 | return _c | 2425 | return _c | ||
| 2426 | } | 2426 | } | ||
| 2427 | 2427 | ||||
| 2428 | + | // RemoveTickCallback provides a mock function for the type MockPanedWidget | |||
| 2429 | + | func (_mock *MockPanedWidget) RemoveTickCallback(id uint) { | |||
| 2430 | + | _mock.Called(id) | |||
| 2431 | + | return | |||
| 2432 | + | } | |||
| 2433 | + | ||||
| 2434 | + | // MockPanedWidget_RemoveTickCallback_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RemoveTickCallback' | |||
| 2435 | + | type MockPanedWidget_RemoveTickCallback_Call struct { | |||
| 2436 | + | *mock.Call | |||
| 2437 | + | } | |||
| 2438 | + | ||||
| 2439 | + | // RemoveTickCallback is a helper method to define mock.On call | |||
| 2440 | + | // - id uint | |||
| 2441 | + | func (_e *MockPanedWidget_Expecter) RemoveTickCallback(id any) *MockPanedWidget_RemoveTickCallback_Call { | |||
| 2442 | + | return &MockPanedWidget_RemoveTickCallback_Call{Call: _e.mock.On("RemoveTickCallback", id)} | |||
| 2443 | + | } | |||
| 2444 | + | ||||
| 2445 | + | func (_c *MockPanedWidget_RemoveTickCallback_Call) Run(run func(id uint)) *MockPanedWidget_RemoveTickCallback_Call { | |||
| 2446 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 2447 | + | var arg0 uint | |||
| 2448 | + | if args[0] != nil { | |||
| 2449 | + | arg0 = args[0].(uint) | |||
| 2450 | + | } | |||
| 2451 | + | run( | |||
| 2452 | + | arg0, | |||
| 2453 | + | ) | |||
| 2454 | + | }) | |||
| 2455 | + | return _c | |||
| 2456 | + | } | |||
| 2457 | + | ||||
| 2458 | + | func (_c *MockPanedWidget_RemoveTickCallback_Call) Return() *MockPanedWidget_RemoveTickCallback_Call { | |||
| 2459 | + | _c.Call.Return() | |||
| 2460 | + | return _c | |||
| 2461 | + | } | |||
| 2462 | + | ||||
| 2463 | + | func (_c *MockPanedWidget_RemoveTickCallback_Call) RunAndReturn(run func(id uint)) *MockPanedWidget_RemoveTickCallback_Call { | |||
| 2464 | + | _c.Run(run) | |||
| 2465 | + | return _c | |||
| 2466 | + | } | |||
| 2467 | + | ||||
| 2428 | // SetCanFocus provides a mock function for the type MockPanedWidget | 2468 | // SetCanFocus provides a mock function for the type MockPanedWidget | ||
| 2429 | func (_mock *MockPanedWidget) SetCanFocus(canFocus bool) { | 2469 | func (_mock *MockPanedWidget) SetCanFocus(canFocus bool) { | ||
| 2430 | _mock.Called(canFocus) | 2470 | _mock.Called(canFocus) | ||
M internal/ui/layout/split.go +51 -8
| @@ -21,8 +21,11 @@ type SplitView struct { | |||||
| 21 | logger zerolog.Logger | 21 | logger zerolog.Logger | ||
| 22 | 22 | ||||
| 23 | onRatioChanged func(ratio float64) | 23 | onRatioChanged func(ratio float64) | ||
| 24 | - | pendingNotifyRatio float64 | 24 | + | idleAdd func(*glib.SourceFunc, uintptr) uint |
| 25 | notifyDebounceTimer *time.Timer | 25 | notifyDebounceTimer *time.Timer | ||
| 26 | + | notifyGeneration uint64 | |||
| 27 | + | tickCallbackID uint | |||
| 28 | + | cleanedUp bool | |||
| 26 | 29 | ||||
| 27 | hasAppliedRatio bool | 30 | hasAppliedRatio bool | ||
| 28 | suppressNotifyUntil time.Time | 31 | suppressNotifyUntil time.Time | ||
| @@ -109,6 +112,7 @@ func NewSplitView( | |||||
| 109 | endChild: endChild, | 112 | endChild: endChild, | ||
| 110 | ratio: clampRatio(ratio), | 113 | ratio: clampRatio(ratio), | ||
| 111 | logger: log.With().Str("component", "split-view").Logger(), | 114 | logger: log.With().Str("component", "split-view").Logger(), | ||
| 115 | + | idleAdd: glib.IdleAdd, | |||
| 112 | } | 116 | } | ||
| 113 | 117 | ||||
| 114 | // Set children | 118 | // Set children | ||
| @@ -151,25 +155,40 @@ func NewSplitView( | |||||
| 151 | ratio := clampRatio(float64(position) / float64(totalSize)) | 155 | ratio := clampRatio(float64(position) / float64(totalSize)) | ||
| 152 | 156 | ||||
| 153 | sv.mu.Lock() | 157 | sv.mu.Lock() | ||
| 158 | + | if sv.cleanedUp { | |||
| 159 | + | sv.mu.Unlock() | |||
| 160 | + | return | |||
| 161 | + | } | |||
| 154 | sv.ratio = ratio | 162 | sv.ratio = ratio | ||
| 155 | - | sv.pendingNotifyRatio = ratio | 163 | + | sv.notifyGeneration++ |
| 156 | - | onRatioChanged := sv.onRatioChanged | 164 | + | generation := sv.notifyGeneration |
| 157 | 165 | ||||
| 158 | if sv.notifyDebounceTimer != nil { | 166 | if sv.notifyDebounceTimer != nil { | ||
| 159 | sv.notifyDebounceTimer.Stop() | 167 | sv.notifyDebounceTimer.Stop() | ||
| 160 | } | 168 | } | ||
| 161 | sv.notifyDebounceTimer = time.AfterFunc(notifyPositionDebounceDelay, func() { | 169 | sv.notifyDebounceTimer = time.AfterFunc(notifyPositionDebounceDelay, func() { | ||
| 162 | - | if onRatioChanged == nil { | 170 | + | sv.mu.RLock() |
| 171 | + | valid := !sv.cleanedUp && generation == sv.notifyGeneration | |||
| 172 | + | sv.mu.RUnlock() | |||
| 173 | + | if !valid { | |||
| 163 | return | 174 | return | ||
| 164 | } | 175 | } | ||
| 176 | + | ||||
| 165 | cb := glib.SourceFunc(func(_ uintptr) bool { | 177 | cb := glib.SourceFunc(func(_ uintptr) bool { | ||
| 166 | sv.mu.RLock() | 178 | sv.mu.RLock() | ||
| 167 | - | pending := sv.pendingNotifyRatio | 179 | + | if sv.cleanedUp || generation != sv.notifyGeneration { |
| 180 | + | sv.mu.RUnlock() | |||
| 181 | + | return false | |||
| 182 | + | } | |||
| 183 | + | onRatioChanged := sv.onRatioChanged | |||
| 168 | sv.mu.RUnlock() | 184 | sv.mu.RUnlock() | ||
| 169 | - | onRatioChanged(pending) | 185 | + | |
| 186 | + | if onRatioChanged != nil { | |||
| 187 | + | onRatioChanged(ratio) | |||
| 188 | + | } | |||
| 170 | return false | 189 | return false | ||
| 171 | }) | 190 | }) | ||
| 172 | - | glib.IdleAdd(&cb, 0) | 191 | + | sv.idleAdd(&cb, 0) |
| 173 | }) | 192 | }) | ||
| 174 | 193 | ||||
| 175 | sv.mu.Unlock() | 194 | sv.mu.Unlock() | ||
| @@ -188,7 +207,7 @@ func NewSplitView( | |||||
| 188 | // Add tick callback to retry applying ratio every frame until successful. | 207 | // Add tick callback to retry applying ratio every frame until successful. | ||
| 189 | // This handles cases where allocation isn't ready even after Map signal. | 208 | // This handles cases where allocation isn't ready even after Map signal. | ||
| 190 | frames := 0 | 209 | frames := 0 | ||
| 191 | - | paned.AddTickCallback(func() bool { | 210 | + | sv.tickCallbackID = paned.AddTickCallback(func() bool { |
| 192 | frames++ | 211 | frames++ | ||
| 193 | if sv.ApplyRatio() { | 212 | if sv.ApplyRatio() { | ||
| 194 | sv.logger.Debug(). | 213 | sv.logger.Debug(). | ||
| @@ -213,6 +232,30 @@ func NewSplitView( | |||||
| 213 | // The ratio is clamped to the range [0.0, 1.0]. | 232 | // The ratio is clamped to the range [0.0, 1.0]. | ||
| 214 | // Note: This sets the position based on ratio; actual pixel position | 233 | // Note: This sets the position based on ratio; actual pixel position | ||
| 215 | // depends on the allocated size of the paned widget. | 234 | // depends on the allocated size of the paned widget. | ||
| 235 | + | // Cleanup releases callbacks owned by this split. It is idempotent because | |||
| 236 | + | // tree rebuild and GTK teardown can both reach the same view. | |||
| 237 | + | func (sv *SplitView) Cleanup() { | |||
| 238 | + | sv.mu.Lock() | |||
| 239 | + | if sv.cleanedUp { | |||
| 240 | + | sv.mu.Unlock() | |||
| 241 | + | return | |||
| 242 | + | } | |||
| 243 | + | sv.cleanedUp = true | |||
| 244 | + | sv.notifyGeneration++ | |||
| 245 | + | sv.onRatioChanged = nil | |||
| 246 | + | if sv.notifyDebounceTimer != nil { | |||
| 247 | + | sv.notifyDebounceTimer.Stop() | |||
| 248 | + | sv.notifyDebounceTimer = nil | |||
| 249 | + | } | |||
| 250 | + | tickCallbackID := sv.tickCallbackID | |||
| 251 | + | sv.tickCallbackID = 0 | |||
| 252 | + | sv.mu.Unlock() | |||
| 253 | + | ||||
| 254 | + | if tickCallbackID != 0 { | |||
| 255 | + | sv.paned.RemoveTickCallback(tickCallbackID) | |||
| 256 | + | } | |||
| 257 | + | } | |||
| 258 | + | ||||
| 216 | func (sv *SplitView) SetRatio(ratio float64) { | 259 | func (sv *SplitView) SetRatio(ratio float64) { | ||
| 217 | sv.mu.Lock() | 260 | sv.mu.Lock() | ||
| 218 | defer sv.mu.Unlock() | 261 | defer sv.mu.Unlock() | ||
A internal/ui/layout/split_cleanup_test.go +29 -0
| @@ -0,0 +1,29 @@ | |||||
| 1 | + | package layout_test | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/stretchr/testify/mock" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/dumber/internal/ui/layout" | |||
| 10 | + | "github.com/bnema/dumber/internal/ui/layout/mocks" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | func TestSplitViewCleanupReleasesTickCallbackExactlyOnce(t *testing.T) { | |||
| 14 | + | factory := mocks.NewMockWidgetFactory(t) | |||
| 15 | + | paned := mocks.NewMockPanedWidget(t) | |||
| 16 | + | factory.EXPECT().NewPaned(layout.OrientationHorizontal).Return(paned).Once() | |||
| 17 | + | paned.EXPECT().SetResizeStartChild(true).Once() | |||
| 18 | + | paned.EXPECT().SetResizeEndChild(true).Once() | |||
| 19 | + | paned.EXPECT().SetVisible(true).Once() | |||
| 20 | + | paned.EXPECT().GetAllocatedWidth().Return(0).Once() | |||
| 21 | + | paned.EXPECT().ConnectNotifyPosition(mock.Anything).Return(uint(0)).Once() | |||
| 22 | + | paned.EXPECT().ConnectMap(mock.Anything).Return(uint(0)).Once() | |||
| 23 | + | paned.EXPECT().AddTickCallback(mock.Anything).Return(uint(42)).Once() | |||
| 24 | + | paned.EXPECT().RemoveTickCallback(uint(42)).Once() | |||
| 25 | + | ||||
| 26 | + | sv := layout.NewSplitView(context.Background(), factory, layout.OrientationHorizontal, nil, nil, 0.5) | |||
| 27 | + | sv.Cleanup() | |||
| 28 | + | sv.Cleanup() | |||
| 29 | + | } | |||
A internal/ui/layout/split_notify_cleanup_test.go +123 -0
| @@ -0,0 +1,123 @@ | |||||
| 1 | + | package layout | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "sync/atomic" | |||
| 6 | + | "testing" | |||
| 7 | + | "time" | |||
| 8 | + | ||||
| 9 | + | "github.com/stretchr/testify/require" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/puregotk/v4/glib" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // notifyingPaned only implements the SplitView interactions exercised below. | |||
| 15 | + | // Embedding the interface supplies the unused methods. | |||
| 16 | + | type notifyingPaned struct { | |||
| 17 | + | PanedWidget | |||
| 18 | + | position int | |||
| 19 | + | notify func() | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | func (p *notifyingPaned) SetResizeStartChild(bool) {} | |||
| 23 | + | func (p *notifyingPaned) SetResizeEndChild(bool) {} | |||
| 24 | + | func (p *notifyingPaned) SetVisible(bool) {} | |||
| 25 | + | func (p *notifyingPaned) ConnectNotifyPosition(fn func()) uint { p.notify = fn; return 0 } | |||
| 26 | + | func (p *notifyingPaned) GetAllocatedWidth() int { return 100 } | |||
| 27 | + | func (p *notifyingPaned) GetPosition() int { return p.position } | |||
| 28 | + | func (p *notifyingPaned) SetPosition(position int) { p.position = position } | |||
| 29 | + | ||||
| 30 | + | type notifyingFactory struct { | |||
| 31 | + | WidgetFactory | |||
| 32 | + | paned PanedWidget | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | func (f notifyingFactory) NewPaned(Orientation) PanedWidget { return f.paned } | |||
| 36 | + | ||||
| 37 | + | func newNotifyingSplitView(t *testing.T) (*SplitView, func(int)) { | |||
| 38 | + | t.Helper() | |||
| 39 | + | ||||
| 40 | + | paned := ¬ifyingPaned{} | |||
| 41 | + | sv := NewSplitView(context.Background(), notifyingFactory{paned: paned}, OrientationHorizontal, nil, nil, 0) | |||
| 42 | + | sv.mu.Lock() | |||
| 43 | + | sv.suppressNotifyUntil = time.Time{} | |||
| 44 | + | sv.mu.Unlock() | |||
| 45 | + | require.NotNil(t, paned.notify) | |||
| 46 | + | return sv, func(nextPosition int) { | |||
| 47 | + | paned.position = nextPosition | |||
| 48 | + | paned.notify() | |||
| 49 | + | } | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | func receiveQueuedIdle(t *testing.T, queued <-chan *glib.SourceFunc) *glib.SourceFunc { | |||
| 53 | + | t.Helper() | |||
| 54 | + | select { | |||
| 55 | + | case source := <-queued: | |||
| 56 | + | return source | |||
| 57 | + | case <-time.After(time.Second): | |||
| 58 | + | t.Fatal("timed out waiting for debounce to queue idle callback") | |||
| 59 | + | return nil | |||
| 60 | + | } | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | func setIdleScheduler(sv *SplitView, queued chan<- *glib.SourceFunc) { | |||
| 64 | + | sv.idleAdd = func(source *glib.SourceFunc, _ uintptr) uint { | |||
| 65 | + | queued <- source | |||
| 66 | + | return 1 | |||
| 67 | + | } | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | func TestSplitViewQueuedRatioCallbackDoesNotRunAfterCleanup(t *testing.T) { | |||
| 71 | + | queued := make(chan *glib.SourceFunc, 1) | |||
| 72 | + | sv, notify := newNotifyingSplitView(t) | |||
| 73 | + | setIdleScheduler(sv, queued) | |||
| 74 | + | var calls atomic.Int32 | |||
| 75 | + | sv.SetOnRatioChanged(func(float64) { calls.Add(1) }) | |||
| 76 | + | ||||
| 77 | + | notify(25) | |||
| 78 | + | source := receiveQueuedIdle(t, queued) // Debounce has fired and queued this work. | |||
| 79 | + | sv.Cleanup() | |||
| 80 | + | (*source)(0) // Run the queued GLib closure after cleanup. | |||
| 81 | + | ||||
| 82 | + | require.Zero(t, calls.Load(), "cleanup must invalidate queued ratio callbacks") | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | func TestSplitViewSupersededRatioCallbackDeliversOnlyLatestValueOnce(t *testing.T) { | |||
| 86 | + | queued := make(chan *glib.SourceFunc, 2) | |||
| 87 | + | sv, notify := newNotifyingSplitView(t) | |||
| 88 | + | setIdleScheduler(sv, queued) | |||
| 89 | + | var ratios []float64 | |||
| 90 | + | sv.SetOnRatioChanged(func(ratio float64) { ratios = append(ratios, ratio) }) | |||
| 91 | + | ||||
| 92 | + | notify(20) | |||
| 93 | + | first := receiveQueuedIdle(t, queued) | |||
| 94 | + | notify(80) | |||
| 95 | + | second := receiveQueuedIdle(t, queued) | |||
| 96 | + | (*first)(0) | |||
| 97 | + | (*second)(0) | |||
| 98 | + | ||||
| 99 | + | require.Equal(t, []float64{0.8}, ratios) | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | func TestSplitViewIdleSchedulersAreInstanceScoped(t *testing.T) { | |||
| 103 | + | firstQueued := make(chan *glib.SourceFunc, 1) | |||
| 104 | + | secondQueued := make(chan *glib.SourceFunc, 1) | |||
| 105 | + | first, firstNotify := newNotifyingSplitView(t) | |||
| 106 | + | second, secondNotify := newNotifyingSplitView(t) | |||
| 107 | + | setIdleScheduler(first, firstQueued) | |||
| 108 | + | setIdleScheduler(second, secondQueued) | |||
| 109 | + | ||||
| 110 | + | firstNotify(20) | |||
| 111 | + | secondNotify(80) | |||
| 112 | + | ||||
| 113 | + | firstSource := receiveQueuedIdle(t, firstQueued) | |||
| 114 | + | secondSource := receiveQueuedIdle(t, secondQueued) | |||
| 115 | + | var firstCalls, secondCalls atomic.Int32 | |||
| 116 | + | first.SetOnRatioChanged(func(float64) { firstCalls.Add(1) }) | |||
| 117 | + | second.SetOnRatioChanged(func(float64) { secondCalls.Add(1) }) | |||
| 118 | + | (*firstSource)(0) | |||
| 119 | + | (*secondSource)(0) | |||
| 120 | + | ||||
| 121 | + | require.Equal(t, int32(1), firstCalls.Load()) | |||
| 122 | + | require.Equal(t, int32(1), secondCalls.Load()) | |||
| 123 | + | } | |||
M internal/ui/layout/tree.go +24 -5
| @@ -33,6 +33,9 @@ type TreeRenderer struct { | |||||
| 33 | logger zerolog.Logger | 33 | logger zerolog.Logger | ||
| 34 | nodeToWidget map[string]Widget | 34 | nodeToWidget map[string]Widget | ||
| 35 | splitOrientation map[string]Orientation | 35 | splitOrientation map[string]Orientation | ||
| 36 | + | // splitViews owns the SplitView callback registrations created while | |||
| 37 | + | // rendering. Widgets alone cannot release their tick callbacks. | |||
| 38 | + | splitViews map[string]*SplitView | |||
| 36 | // paneToStack maps pane IDs to their containing StackedView. | 39 | // paneToStack maps pane IDs to their containing StackedView. | ||
| 37 | // Every leaf pane is wrapped in a StackedView for easy stacking later. | 40 | // Every leaf pane is wrapped in a StackedView for easy stacking later. | ||
| 38 | paneToStack map[string]*StackedView | 41 | paneToStack map[string]*StackedView | ||
| @@ -52,6 +55,7 @@ func NewTreeRenderer(ctx context.Context, factory WidgetFactory, paneViewFactory | |||||
| 52 | logger: log.With().Str("component", "tree-renderer").Logger(), | 55 | logger: log.With().Str("component", "tree-renderer").Logger(), | ||
| 53 | nodeToWidget: make(map[string]Widget), | 56 | nodeToWidget: make(map[string]Widget), | ||
| 54 | splitOrientation: make(map[string]Orientation), | 57 | splitOrientation: make(map[string]Orientation), | ||
| 58 | + | splitViews: make(map[string]*SplitView), | |||
| 55 | paneToStack: make(map[string]*StackedView), | 59 | paneToStack: make(map[string]*StackedView), | ||
| 56 | } | 60 | } | ||
| 57 | } | 61 | } | ||
| @@ -73,10 +77,7 @@ func (tr *TreeRenderer) Build(ctx context.Context, root *entity.PaneNode) (Widge | |||||
| 73 | tr.mu.Lock() | 77 | tr.mu.Lock() | ||
| 74 | defer tr.mu.Unlock() | 78 | defer tr.mu.Unlock() | ||
| 75 | 79 | ||||
| 76 | - | // Clear previous mappings | 80 | + | tr.clearInternal() |
| 77 | - | tr.nodeToWidget = make(map[string]Widget) | |||
| 78 | - | tr.splitOrientation = make(map[string]Orientation) | |||
| 79 | - | tr.paneToStack = make(map[string]*StackedView) | |||
| 80 | 81 | ||||
| 81 | return tr.renderNode(ctx, root) | 82 | return tr.renderNode(ctx, root) | ||
| 82 | } | 83 | } | ||
| @@ -189,6 +190,7 @@ func (tr *TreeRenderer) renderSplit(ctx context.Context, node *entity.PaneNode) | |||||
| 189 | } | 190 | } | ||
| 190 | }) | 191 | }) | ||
| 191 | tr.splitOrientation[node.ID] = orientation | 192 | tr.splitOrientation[node.ID] = orientation | ||
| 193 | + | tr.splitViews[node.ID] = splitView | |||
| 192 | 194 | ||||
| 193 | return splitView.Widget(), nil | 195 | return splitView.Widget(), nil | ||
| 194 | } | 196 | } | ||
| @@ -264,13 +266,26 @@ func (tr *TreeRenderer) NodeCount() int { | |||||
| 264 | return len(tr.nodeToWidget) | 266 | return len(tr.nodeToWidget) | ||
| 265 | } | 267 | } | ||
| 266 | 268 | ||||
| 267 | - | // Clear removes all node-to-widget mappings. | 269 | + | // Clear releases all renderer-owned split callbacks and removes mappings. |
| 268 | func (tr *TreeRenderer) Clear() { | 270 | func (tr *TreeRenderer) Clear() { | ||
| 269 | tr.mu.Lock() | 271 | tr.mu.Lock() | ||
| 270 | defer tr.mu.Unlock() | 272 | defer tr.mu.Unlock() | ||
| 271 | 273 | ||||
| 274 | + | tr.clearInternal() | |||
| 275 | + | } | |||
| 276 | + | ||||
| 277 | + | // clearInternal releases renderer-owned views and resets the lookup maps. | |||
| 278 | + | // The caller must hold tr.mu. | |||
| 279 | + | func (tr *TreeRenderer) clearInternal() { | |||
| 280 | + | for _, splitView := range tr.splitViews { | |||
| 281 | + | if splitView != nil { | |||
| 282 | + | splitView.Cleanup() | |||
| 283 | + | } | |||
| 284 | + | } | |||
| 272 | tr.nodeToWidget = make(map[string]Widget) | 285 | tr.nodeToWidget = make(map[string]Widget) | ||
| 273 | tr.splitOrientation = make(map[string]Orientation) | 286 | tr.splitOrientation = make(map[string]Orientation) | ||
| 287 | + | tr.splitViews = make(map[string]*SplitView) | |||
| 288 | + | tr.paneToStack = make(map[string]*StackedView) | |||
| 274 | } | 289 | } | ||
| 275 | 290 | ||||
| 276 | // RegisterWidget adds or updates a node-to-widget mapping. | 291 | // RegisterWidget adds or updates a node-to-widget mapping. | ||
| @@ -297,8 +312,12 @@ func (tr *TreeRenderer) UnregisterWidget(nodeID string) { | |||||
| 297 | tr.mu.Lock() | 312 | tr.mu.Lock() | ||
| 298 | defer tr.mu.Unlock() | 313 | defer tr.mu.Unlock() | ||
| 299 | 314 | ||||
| 315 | + | if splitView := tr.splitViews[nodeID]; splitView != nil { | |||
| 316 | + | splitView.Cleanup() | |||
| 317 | + | } | |||
| 300 | delete(tr.nodeToWidget, nodeID) | 318 | delete(tr.nodeToWidget, nodeID) | ||
| 301 | delete(tr.splitOrientation, nodeID) | 319 | delete(tr.splitOrientation, nodeID) | ||
| 320 | + | delete(tr.splitViews, nodeID) | |||
| 302 | } | 321 | } | ||
| 303 | 322 | ||||
| 304 | // UpdateSplitRatio updates the ratio of a split view by node ID. | 323 | // UpdateSplitRatio updates the ratio of a split view by node ID. | ||
A internal/ui/layout/tree_cleanup_test.go +48 -0
| @@ -0,0 +1,48 @@ | |||||
| 1 | + | package layout_test | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 8 | + | "github.com/bnema/dumber/internal/ui/layout" | |||
| 9 | + | "github.com/bnema/dumber/internal/ui/layout/mocks" | |||
| 10 | + | "github.com/stretchr/testify/mock" | |||
| 11 | + | "github.com/stretchr/testify/require" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | func TestTreeRendererClearReleasesOwnedSplitTickExactlyOnce(t *testing.T) { | |||
| 15 | + | ctx := context.Background() | |||
| 16 | + | factory := mocks.NewMockWidgetFactory(t) | |||
| 17 | + | paneFactory := mocks.NewMockPaneViewFactory(t) | |||
| 18 | + | paned := mocks.NewMockPanedWidget(t) | |||
| 19 | + | leftStack, leftContainer := setupStackedLeafMocks(t, factory) | |||
| 20 | + | rightStack, rightContainer := setupStackedLeafMocks(t, factory) | |||
| 21 | + | ||||
| 22 | + | left := &entity.PaneNode{ID: "left", Pane: entity.NewPane("left")} | |||
| 23 | + | right := &entity.PaneNode{ID: "right", Pane: entity.NewPane("right")} | |||
| 24 | + | root := &entity.PaneNode{ID: "split", SplitDir: entity.SplitHorizontal, SplitRatio: 0.5, Children: []*entity.PaneNode{left, right}} | |||
| 25 | + | ||||
| 26 | + | paneFactory.EXPECT().CreatePaneView(left).Return(leftContainer).Once() | |||
| 27 | + | paneFactory.EXPECT().CreatePaneView(right).Return(rightContainer).Once() | |||
| 28 | + | factory.EXPECT().NewPaned(layout.OrientationHorizontal).Return(paned).Once() | |||
| 29 | + | paned.EXPECT().SetResizeStartChild(true).Once() | |||
| 30 | + | paned.EXPECT().SetResizeEndChild(true).Once() | |||
| 31 | + | paned.EXPECT().SetVisible(true).Once() | |||
| 32 | + | leftStack.EXPECT().SetVisible(true).Once() | |||
| 33 | + | rightStack.EXPECT().SetVisible(true).Once() | |||
| 34 | + | paned.EXPECT().SetStartChild(leftStack).Once() | |||
| 35 | + | paned.EXPECT().SetEndChild(rightStack).Once() | |||
| 36 | + | paned.EXPECT().ConnectNotifyPosition(mock.Anything).Return(uint(0)).Once() | |||
| 37 | + | paned.EXPECT().GetAllocatedWidth().Return(0).Once() | |||
| 38 | + | paned.EXPECT().ConnectMap(mock.Anything).Return(uint(0)).Once() | |||
| 39 | + | paned.EXPECT().AddTickCallback(mock.Anything).Return(uint(42)).Once() | |||
| 40 | + | paned.EXPECT().RemoveTickCallback(uint(42)).Once() | |||
| 41 | + | ||||
| 42 | + | renderer := layout.NewTreeRenderer(ctx, factory, paneFactory) | |||
| 43 | + | _, err := renderer.Build(ctx, root) | |||
| 44 | + | require.NoError(t, err) | |||
| 45 | + | ||||
| 46 | + | renderer.Clear() | |||
| 47 | + | renderer.Clear() | |||
| 48 | + | } | |||
M internal/ui/layout/widgets.go +5 -2
| @@ -99,9 +99,12 @@ type PanedWidget interface { | |||||
| 99 | ConnectMap(callback func()) uint | 99 | ConnectMap(callback func()) uint | ||
| 100 | ConnectNotifyPosition(callback func()) uint | 100 | ConnectNotifyPosition(callback func()) uint | ||
| 101 | 101 | ||||
| 102 | - | // Tick callback for frame-based updates (returns signal ID) | 102 | + | // Tick callback for frame-based updates (returns signal ID). |
| 103 | - | // Callback returns true to continue, false to stop | 103 | + | // Callback returns true to continue, false to stop. |
| 104 | AddTickCallback(callback func() bool) uint | 104 | AddTickCallback(callback func() bool) uint | ||
| 105 | + | // RemoveTickCallback releases an active callback slot. It is safe to call | |||
| 106 | + | // once after the callback has stopped itself. | |||
| 107 | + | RemoveTickCallback(id uint) | |||
| 105 | 108 | ||||
| 106 | // Handle appearance | 109 | // Handle appearance | ||
| 107 | SetWideHandle(wide bool) | 110 | SetWideHandle(wide bool) | ||
A scripts/collect_first_presentation.sh +253 -0
| @@ -0,0 +1,253 @@ | |||||
| 1 | + | #!/usr/bin/env bash | |||
| 2 | + | # Collect exactly five isolated CEF/DMABUF cold starts. The application is | |||
| 3 | + | # deliberately terminated after the bounded observation window; a completed | |||
| 4 | + | # first-presentation summary, rather than process exit, is the success signal. | |||
| 5 | + | set -euo pipefail | |||
| 6 | + | ||||
| 7 | + | readonly runs=5 | |||
| 8 | + | readonly timeout_seconds="${DUMBER_FIRST_PRESENTATION_TIMEOUT_SECONDS:-45}" | |||
| 9 | + | readonly runtime="${DUMBER_CEF_DIR:-$HOME/.local/share/cef-147-runtime}" | |||
| 10 | + | readonly binary="${DUMBER_FIRST_PRESENTATION_BIN:-$PWD/dist/dumber}" | |||
| 11 | + | ||||
| 12 | + | fail_unsafe_output() { | |||
| 13 | + | echo "first-presentation: unsafe output path: $1" >&2 | |||
| 14 | + | exit 2 | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | if [[ -v DUMBER_FIRST_PRESENTATION_OUTPUT ]]; then | |||
| 18 | + | output="$DUMBER_FIRST_PRESENTATION_OUTPUT" | |||
| 19 | + | else | |||
| 20 | + | # Evidence is state, not source: keep the default outside the checkout and | |||
| 21 | + | # make every invocation a fresh child of the external evidence directory. | |||
| 22 | + | readonly state_home="${XDG_STATE_HOME:-$HOME/.local/state}" | |||
| 23 | + | [[ "$state_home" == /* ]] || fail_unsafe_output "XDG_STATE_HOME must be absolute" | |||
| 24 | + | readonly evidence_root="$state_home/dumber/roadmap-evidence" | |||
| 25 | + | mkdir -p -- "$evidence_root" || fail_unsafe_output "could not create XDG state directory" | |||
| 26 | + | canonical_evidence_root="$(realpath -e -- "$evidence_root")" || \ | |||
| 27 | + | fail_unsafe_output "XDG state directory cannot be canonicalized" | |||
| 28 | + | [[ "$canonical_evidence_root" == "$evidence_root" && ! -L "$evidence_root" ]] || \ | |||
| 29 | + | fail_unsafe_output "XDG state directory must not contain symbolic links" | |||
| 30 | + | output="$evidence_root/first-presentation-$(date -u +%Y%m%dT%H%M%S)-$$" | |||
| 31 | + | fi | |||
| 32 | + | readonly output | |||
| 33 | + | readonly upstream_module="github.com/bnema/purego-cef2gtk" | |||
| 34 | + | ||||
| 35 | + | # Resolve the version selected by this checkout, then obtain its immutable VCS | |||
| 36 | + | # origin from Go's cached module metadata. Do not infer a revision from a tag, | |||
| 37 | + | # a branch, or a truncated pseudo-version suffix. | |||
| 38 | + | resolve_upstream_provenance() { | |||
| 39 | + | local selected_metadata selected_version downloaded_metadata | |||
| 40 | + | ||||
| 41 | + | selected_metadata="$(GOFLAGS=-mod=mod go list -m -json "$upstream_module" 2>/dev/null)" || { | |||
| 42 | + | echo "first-presentation: immutable module provenance is unavailable" >&2 | |||
| 43 | + | exit 2 | |||
| 44 | + | } | |||
| 45 | + | selected_version="$(python3 - "$upstream_module" "$selected_metadata" <<'PY' | |||
| 46 | + | import json, sys | |||
| 47 | + | try: | |||
| 48 | + | metadata = json.loads(sys.argv[2]) | |||
| 49 | + | if metadata.get("Path") != sys.argv[1] or not isinstance(metadata.get("Version"), str): | |||
| 50 | + | raise ValueError | |||
| 51 | + | print(metadata["Version"]) | |||
| 52 | + | except (AttributeError, TypeError, ValueError, json.JSONDecodeError): | |||
| 53 | + | raise SystemExit("first-presentation: immutable module provenance is unavailable") | |||
| 54 | + | PY | |||
| 55 | + | )" || exit $? | |||
| 56 | + | downloaded_metadata="$(go mod download -json "$upstream_module@$selected_version" 2>/dev/null)" || { | |||
| 57 | + | echo "first-presentation: immutable module provenance is unavailable" >&2 | |||
| 58 | + | exit 2 | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | python3 - "$upstream_module" "$selected_metadata" "$downloaded_metadata" <<'PY' | |||
| 62 | + | import json, re, sys | |||
| 63 | + | ||||
| 64 | + | module, selected_raw, downloaded_raw = sys.argv[1:] | |||
| 65 | + | ||||
| 66 | + | def fail(): | |||
| 67 | + | # Do not expose Go cache locations or other machine-local values. | |||
| 68 | + | raise SystemExit("first-presentation: immutable module provenance is unavailable") | |||
| 69 | + | ||||
| 70 | + | try: | |||
| 71 | + | selected = json.loads(selected_raw) | |||
| 72 | + | downloaded = json.loads(downloaded_raw) | |||
| 73 | + | version = selected["Version"] | |||
| 74 | + | if selected.get("Path") != module or downloaded.get("Path") != module: | |||
| 75 | + | fail() | |||
| 76 | + | if downloaded.get("Version") != version: | |||
| 77 | + | fail() | |||
| 78 | + | # A pseudo-version identifies an immutable commit; a released semver tag is | |||
| 79 | + | # accepted only when Go records that exact tag and its immutable hash. | |||
| 80 | + | pseudo_match = re.fullmatch(r"(v\d+\.\d+\.\d+)-0\.\d{14}-([0-9a-f]{12})", version) | |||
| 81 | + | tag_match = re.fullmatch(r"v\d+\.\d+\.\d+", version) | |||
| 82 | + | if not pseudo_match and not tag_match: | |||
| 83 | + | fail() | |||
| 84 | + | info_path = downloaded.get("Info") | |||
| 85 | + | if not isinstance(info_path, str) or not info_path: | |||
| 86 | + | fail() | |||
| 87 | + | with open(info_path, encoding="utf-8") as info_file: | |||
| 88 | + | info = json.load(info_file) | |||
| 89 | + | origin = info.get("Origin") or downloaded.get("Origin") | |||
| 90 | + | if info.get("Version") != version or not isinstance(origin, dict): | |||
| 91 | + | fail() | |||
| 92 | + | revision = origin.get("Hash") | |||
| 93 | + | if origin.get("VCS") != "git" or origin.get("URL") != "https://github.com/bnema/purego-cef2gtk": | |||
| 94 | + | fail() | |||
| 95 | + | if not isinstance(revision, str) or not re.fullmatch(r"[0-9a-f]{40}", revision): | |||
| 96 | + | fail() | |||
| 97 | + | if pseudo_match: | |||
| 98 | + | if not revision.startswith(pseudo_match.group(2)): | |||
| 99 | + | fail() | |||
| 100 | + | # A named ref can move. Only the immutable hash itself is acceptable. | |||
| 101 | + | if origin.get("Ref") not in (None, "", revision): | |||
| 102 | + | fail() | |||
| 103 | + | tag = pseudo_match.group(1) | |||
| 104 | + | else: | |||
| 105 | + | if origin.get("Ref") != "refs/tags/" + version: | |||
| 106 | + | fail() | |||
| 107 | + | tag = version | |||
| 108 | + | except (AttributeError, KeyError, OSError, TypeError, ValueError, json.JSONDecodeError): | |||
| 109 | + | fail() | |||
| 110 | + | ||||
| 111 | + | print("\t".join((version, tag, revision))) | |||
| 112 | + | PY | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | # The artifact destination is caller-controlled. Never empty or recursively | |||
| 116 | + | # clear it: collection only writes to a newly-created directory whose parent is | |||
| 117 | + | # already canonical and contains no symbolic-link hop. | |||
| 118 | + | prepare_output() { | |||
| 119 | + | local parent name canonical_parent canonical_output | |||
| 120 | + | ||||
| 121 | + | [[ -n "$output" ]] || fail_unsafe_output "path must not be empty" | |||
| 122 | + | [[ "$output" == /* ]] || fail_unsafe_output "path must be absolute" | |||
| 123 | + | [[ "$output" != "/" ]] || fail_unsafe_output "path must not be /" | |||
| 124 | + | [[ "${output%/}" != "${HOME%/}" ]] || fail_unsafe_output "path must not be HOME" | |||
| 125 | + | [[ "$output" != .. && "$output" != ../* && "$output" != */.. && "$output" != */../* ]] || \ | |||
| 126 | + | fail_unsafe_output "path must not contain parent traversal" | |||
| 127 | + | [[ ! -e "$output" && ! -L "$output" ]] || fail_unsafe_output "path must be a fresh directory" | |||
| 128 | + | ||||
| 129 | + | parent="$(dirname -- "$output")" | |||
| 130 | + | name="$(basename -- "$output")" | |||
| 131 | + | [[ -n "$name" && "$name" != "." && "$name" != ".." ]] || fail_unsafe_output "invalid directory name" | |||
| 132 | + | [[ -d "$parent" && ! -L "$parent" ]] || fail_unsafe_output "parent must be an existing directory" | |||
| 133 | + | canonical_parent="$(realpath -e -- "$parent")" || fail_unsafe_output "parent cannot be canonicalized" | |||
| 134 | + | [[ "$parent" == "$canonical_parent" ]] || fail_unsafe_output "parent must not contain symbolic links" | |||
| 135 | + | ||||
| 136 | + | mkdir -- "$output" || fail_unsafe_output "could not create fresh directory" | |||
| 137 | + | canonical_output="$(realpath -e -- "$output")" || fail_unsafe_output "created directory cannot be canonicalized" | |||
| 138 | + | [[ -d "$output" && ! -L "$output" && "$canonical_output" == "$canonical_parent/$name" ]] || \ | |||
| 139 | + | fail_unsafe_output "created directory changed unexpectedly" | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | prepare_output | |||
| 143 | + | ||||
| 144 | + | [[ -x "$binary" ]] || { echo "first-presentation: executable not found: $binary" >&2; exit 2; } | |||
| 145 | + | [[ -d "$runtime" ]] || { echo "first-presentation: CEF runtime not found: $runtime" >&2; exit 2; } | |||
| 146 | + | [[ -n "${WAYLAND_DISPLAY:-}${DISPLAY:-}" ]] || { echo "first-presentation: a current Wayland/X11 display is required" >&2; exit 2; } | |||
| 147 | + | upstream_provenance="$(resolve_upstream_provenance)" || exit $? | |||
| 148 | + | IFS=$'\t' read -r upstream_version upstream_tag upstream_revision <<<"$upstream_provenance" | |||
| 149 | + | readonly upstream_version upstream_tag upstream_revision | |||
| 150 | + | ||||
| 151 | + | # Raw logs and temporary XDG homes may contain machine-local paths. Keep them | |||
| 152 | + | # outside the committed artifact directory and always remove them. | |||
| 153 | + | work_root="$(mktemp -d "${TMPDIR:-/tmp}/dumber-first-presentation.XXXXXX")" | |||
| 154 | + | readonly work_root | |||
| 155 | + | cleanup_work_root() { | |||
| 156 | + | [[ -n "${work_root:-}" && -d "$work_root" && ! -L "$work_root" ]] || return | |||
| 157 | + | rm -rf -- "$work_root" | |||
| 158 | + | } | |||
| 159 | + | trap cleanup_work_root EXIT | |||
| 160 | + | python3 - "$output/metadata.json" "$binary" "$timeout_seconds" "$upstream_module" "$upstream_version" "$upstream_tag" "$upstream_revision" <<'PY' | |||
| 161 | + | import hashlib, json, os, platform, subprocess, sys | |||
| 162 | + | path, binary, timeout, upstream_module, upstream_version, upstream_tag, upstream_revision = sys.argv[1:] | |||
| 163 | + | with open(binary, "rb") as candidate: | |||
| 164 | + | binary_sha256 = hashlib.file_digest(candidate, "sha256").hexdigest() | |||
| 165 | + | ||||
| 166 | + | # These deliberately coarse labels support comparison without exposing a host, | |||
| 167 | + | # device name, driver version, path, or environment dump. | |||
| 168 | + | architecture = {"x86_64": "amd64", "amd64": "amd64", "aarch64": "arm64", "arm64": "arm64"}.get(platform.machine().lower(), "other") | |||
| 169 | + | os_label = {"linux": "linux", "darwin": "darwin", "windows": "windows"}.get(platform.system().lower(), "other") | |||
| 170 | + | display_protocol = "wayland" if os.environ.get("WAYLAND_DISPLAY") else "x11" | |||
| 171 | + | gpu_profile = os.environ.get("DUMBER_MACHINE_GPU_PROFILE", "generic-gpu") | |||
| 172 | + | allowed_gpu_profiles = {"generic-gpu", "integrated-gpu", "discrete-gpu", "hybrid-gpu", "virtual-gpu", "unknown-gpu"} | |||
| 173 | + | if gpu_profile not in allowed_gpu_profiles: | |||
| 174 | + | raise SystemExit("DUMBER_MACHINE_GPU_PROFILE must be a non-identifying profile label") | |||
| 175 | + | ||||
| 176 | + | json.dump({ | |||
| 177 | + | "runs": 5, | |||
| 178 | + | "runtime": {"label": "cef", "version": "147"}, | |||
| 179 | + | "binary": {"label": "dumber", "sha256": binary_sha256}, | |||
| 180 | + | "timeout_seconds": int(timeout), | |||
| 181 | + | "measured_source_revision": subprocess.check_output(["git", "-c", f"safe.directory={os.getcwd()}", "rev-parse", "HEAD"], text=True).strip(), | |||
| 182 | + | "upstream": {"module": upstream_module, "version": upstream_version, "tag": upstream_tag, "revision": upstream_revision}, | |||
| 183 | + | "comparison": {"os": os_label, "architecture": architecture, "display_protocol": display_protocol, "machine_gpu_profile": gpu_profile}, | |||
| 184 | + | "render_configuration": {"backend": "gdk-dmabuf", "buffer_sharing": "dmabuf", "renderer": "vulkan"} | |||
| 185 | + | }, open(path, "w"), indent=2, sort_keys=True) | |||
| 186 | + | PY | |||
| 187 | + | ||||
| 188 | + | for number in $(seq 1 "$runs"); do | |||
| 189 | + | run="$(printf 'run-%02d' "$number")" | |||
| 190 | + | root="$work_root/$run" | |||
| 191 | + | mkdir -p "$root"/{config,data,state,cache} | |||
| 192 | + | mkdir -p "$root/config/dumber" | |||
| 193 | + | cat >"$root/config/dumber/config.toml" <<EOF | |||
| 194 | + | [logging] | |||
| 195 | + | level = "debug" | |||
| 196 | + | format = "json" | |||
| 197 | + | enable_file_log = false | |||
| 198 | + | ||||
| 199 | + | [engine.cef] | |||
| 200 | + | cef_dir = "$runtime" | |||
| 201 | + | EOF | |||
| 202 | + | # Every directory below is newly created for this one launch. Do not inherit | |||
| 203 | + | # a profile, shader cache, CEF root cache, or mutable app configuration. | |||
| 204 | + | set +e | |||
| 205 | + | env -i \ | |||
| 206 | + | HOME="$HOME" PATH="$PATH" LANG="${LANG:-C.UTF-8}" \ | |||
| 207 | + | WAYLAND_DISPLAY="${WAYLAND_DISPLAY:-}" DISPLAY="${DISPLAY:-}" XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-}" \ | |||
| 208 | + | DBUS_SESSION_BUS_ADDRESS="${DBUS_SESSION_BUS_ADDRESS:-}" XAUTHORITY="${XAUTHORITY:-}" \ | |||
| 209 | + | XDG_CONFIG_HOME="$root/config" XDG_DATA_HOME="$root/data" XDG_STATE_HOME="$root/state" XDG_CACHE_HOME="$root/cache" \ | |||
| 210 | + | DUMBER_CEF_ROOT_CACHE_PATH="$root/cef-root-cache" DUMBER_RENDER_STACK="vulkan-dmabuf" \ | |||
| 211 | + | PUREGO_CEF2GTK_BACKEND="gdk-dmabuf" PUREGO_CEF2GTK_ANGLE_BACKEND="vulkan" GSK_RENDERER="vulkan" \ | |||
| 212 | + | timeout --signal=TERM --kill-after=5s "${timeout_seconds}s" "$binary" browse about:blank >"$root/process.log" 2>&1 | |||
| 213 | + | set -e | |||
| 214 | + | # A completed, valid first-presentation summary is the observation success | |||
| 215 | + | # signal. The GUI may later be stopped by timeout or terminate independently. | |||
| 216 | + | python3 - "$root/process.log" "$output/$run.json" "$run" <<'PY' | |||
| 217 | + | import json, sys | |||
| 218 | + | log, destination, run = sys.argv[1:] | |||
| 219 | + | order = ["process_entry", "config_complete", "cef_library_load_begin", "cef_initialized", "browser_create_requested", "first_accelerated_paint_received", "first_dmabuf_texture_swap", "first_gtk_presentation"] | |||
| 220 | + | records, summary = [], None | |||
| 221 | + | for line in open(log, errors="replace"): | |||
| 222 | + | try: event = json.loads(line) | |||
| 223 | + | except json.JSONDecodeError: continue | |||
| 224 | + | if event.get("message") == "startup_trace: milestone": | |||
| 225 | + | records.append({key: event.get(key) for key in ("milestone", "t_ms", "delta_ms")}) | |||
| 226 | + | if event.get("message") == "startup_trace: first presentation": | |||
| 227 | + | summary = {key: event.get(key) for key in ("backend", "incomplete_reason", "total_ms")} | |||
| 228 | + | names = [event.get("milestone") for event in records] | |||
| 229 | + | times = [event.get("t_ms") for event in records] | |||
| 230 | + | deltas = [event.get("delta_ms") for event in records] | |||
| 231 | + | valid = names == order and all(type(t) is int for t in times) and times == sorted(times) | |||
| 232 | + | valid = valid and all(type(delta) is int for delta in deltas) | |||
| 233 | + | valid = valid and all(delta == current - previous for previous, current, delta in zip([0] + times, times, deltas)) | |||
| 234 | + | valid = valid and summary is not None and summary.get("backend") == "gdk-dmabuf" and not summary.get("incomplete_reason") | |||
| 235 | + | valid = valid and type(summary.get("total_ms")) is int and summary["total_ms"] == times[-1] | |||
| 236 | + | result = {"run": run, "valid": valid, "milestones": records, "summary": summary} | |||
| 237 | + | json.dump(result, open(destination, "w"), indent=2) | |||
| 238 | + | if not valid: | |||
| 239 | + | raise SystemExit(f"{run}: invalid or incomplete non-DMABUF timeline") | |||
| 240 | + | PY | |||
| 241 | + | done | |||
| 242 | + | ||||
| 243 | + | python3 - "$output" <<'PY' | |||
| 244 | + | import json, math, statistics, sys | |||
| 245 | + | root = sys.argv[1] | |||
| 246 | + | runs = [json.load(open(f"{root}/run-{n:02d}.json")) for n in range(1, 6)] | |||
| 247 | + | if len(runs) != 5 or not all(run["valid"] for run in runs): raise SystemExit("expected exactly five valid runs") | |||
| 248 | + | totals = sorted(run["summary"]["total_ms"] for run in runs) | |||
| 249 | + | p95 = totals[max(0, math.ceil(.95 * len(totals)) - 1)] | |||
| 250 | + | json.dump({"runs": 5, "total_ms": {"min": totals[0], "median": statistics.median(totals), "max": totals[-1], "p95": p95}}, open(f"{root}/baseline.json", "w"), indent=2) | |||
| 251 | + | PY | |||
| 252 | + | ||||
| 253 | + | echo "first-presentation artifacts: $output" | |||