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 | 16 | # Output of the go coverage tool | |
| 17 | 17 | *.out | |
| 18 | 18 | ||
| 19 | - | # Dependency directories (remove the comment below to include it) | |
| 20 | - | # vendor/ | |
| 19 | + | # Dependency directories | |
| 20 | + | vendor/ | |
| 21 | 21 | ||
| 22 | 22 | # Go workspace file | |
| 23 | 23 | go.work | |
| @@ -94,7 +94,6 @@ templates/ | |||
| 94 | 94 | .serena/ | |
| 95 | 95 | config.yaml | |
| 96 | 96 | .dev/ | |
| 97 | - | vendor/ | |
| 98 | 97 | ||
| 99 | 98 | # WebKit runtime data | |
| 100 | 99 | serviceworkers/ | |
| @@ -119,6 +118,7 @@ aur/*/LICENSE | |||
| 119 | 118 | aur/*/dev.bnema.Dumber.desktop | |
| 120 | 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 | 122 | docs/reference/stability-performance-multi-agent-runbook-*.md | |
| 123 | + | /phase1/ | |
| 124 | 124 | perf.data | |
M Makefile +2 -2
| @@ -239,13 +239,13 @@ flatpak-deps: ## Install Flatpak build dependencies (Arch) | |||
| 239 | 239 | ||
| 240 | 240 | flatpak-build: ## Build Flatpak bundle locally | |
| 241 | 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 | 243 | flatpak build-bundle flatpak-repo dumber.flatpak dev.bnema.Dumber | |
| 244 | 244 | @echo "Flatpak bundle created: dumber.flatpak" | |
| 245 | 245 | ||
| 246 | 246 | flatpak-install: ## Install Flatpak locally for testing | |
| 247 | 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 | 249 | @echo "Flatpak installed! Run with: make flatpak-run" | |
| 250 | 250 | ||
| 251 | 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 | 134 | On Arch Linux, install the CEF runtime with: | |
| 135 | 135 | ||
| 136 | 136 | ```bash | |
| 137 | - | sudo pacman -S cef | |
| 137 | + | sudo pacman -S cef libwebp | |
| 138 | 138 | ``` | |
| 139 | 139 | ||
| 140 | 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 | 179 | **Arch Linux:** | |
| 180 | 180 | ||
| 181 | 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 | 185 | **Debian/Ubuntu:** | |
| @@ -213,6 +213,7 @@ Set `ENV=dev` to use `.dev/dumber/` for config and data instead of XDG paths. | |||
| 213 | 213 | - CEF runtime (default backend) | |
| 214 | 214 | - WebKitGTK 6.0 development/runtime packages (fallback backend and runtime checks) | |
| 215 | 215 | - Brotli for compressed systemviews assets | |
| 216 | + | - libwebp runtime for WebP favicon decoding (for example `libwebp` on Arch) | |
| 216 | 217 | ||
| 217 | 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 | 1 | pkgbase = dumber-browser-bin | |
| 2 | 2 | pkgdesc = A minimal keyboard-driven browser for tiling WMs | |
| 3 | 3 | pkgver = 0.23.2 | |
| 4 | - | pkgrel = 2 | |
| 4 | + | pkgrel = 3 | |
| 5 | 5 | url = https://github.com/bnema/dumber | |
| 6 | 6 | arch = x86_64 | |
| 7 | 7 | license = MIT | |
| 8 | 8 | depends = gtk4 | |
| 9 | 9 | depends = cef | |
| 10 | 10 | depends = webkitgtk-6.0 | |
| 11 | + | depends = libwebp | |
| 11 | 12 | optdepends = gst-plugins-base: Base media codecs | |
| 12 | 13 | optdepends = gst-plugins-good: Additional media codecs | |
| 13 | 14 | optdepends = gst-plugins-bad: Extended media codecs | |
M aur/dumber-browser-bin/PKGBUILD +2 -2
| @@ -1,12 +1,12 @@ | |||
| 1 | 1 | # Maintainer: bnema <b at bnema dot dev> | |
| 2 | 2 | pkgname=dumber-browser-bin | |
| 3 | 3 | pkgver=0.23.2 | |
| 4 | - | pkgrel=2 | |
| 4 | + | pkgrel=3 | |
| 5 | 5 | pkgdesc="A minimal keyboard-driven browser for tiling WMs" | |
| 6 | 6 | arch=('x86_64') | |
| 7 | 7 | url="https://github.com/bnema/dumber" | |
| 8 | 8 | license=('MIT') | |
| 9 | - | depends=('gtk4' 'cef' 'webkitgtk-6.0') | |
| 9 | + | depends=('gtk4' 'cef' 'webkitgtk-6.0' 'libwebp') | |
| 10 | 10 | optdepends=( | |
| 11 | 11 | 'gst-plugins-base: Base media codecs' | |
| 12 | 12 | 'gst-plugins-good: Additional media codecs' | |
M aur/dumber-browser-git/.SRCINFO +1 -0
| @@ -11,6 +11,7 @@ pkgbase = dumber-browser-git | |||
| 11 | 11 | depends = gtk4 | |
| 12 | 12 | depends = cef | |
| 13 | 13 | depends = webkitgtk-6.0 | |
| 14 | + | depends = libwebp | |
| 14 | 15 | optdepends = gst-plugins-base: Base media codecs | |
| 15 | 16 | optdepends = gst-plugins-good: Additional media codecs | |
| 16 | 17 | optdepends = gst-plugins-bad: Extended media codecs | |
M aur/dumber-browser-git/PKGBUILD +1 -1
| @@ -7,7 +7,7 @@ arch=('x86_64') | |||
| 7 | 7 | url="https://github.com/bnema/dumber" | |
| 8 | 8 | license=('MIT') | |
| 9 | 9 | makedepends=('go>=1.26' 'git' 'brotli') | |
| 10 | - | depends=('gtk4' 'cef' 'webkitgtk-6.0') | |
| 10 | + | depends=('gtk4' 'cef' 'webkitgtk-6.0' 'libwebp') | |
| 11 | 11 | optdepends=( | |
| 12 | 12 | 'gst-plugins-base: Base media codecs' | |
| 13 | 13 | 'gst-plugins-good: Additional media codecs' | |
M cmd/dumber/main.go +28 -18
| @@ -10,6 +10,7 @@ import ( | |||
| 10 | 10 | "runtime" | |
| 11 | 11 | "strings" | |
| 12 | 12 | "syscall" | |
| 13 | + | "time" | |
| 13 | 14 | ||
| 14 | 15 | "github.com/bnema/dumber/internal/application/port" | |
| 15 | 16 | "github.com/bnema/dumber/internal/application/usecase" | |
| @@ -140,7 +141,17 @@ func configureBrowserLaunchRelay(cfg *config.Config) { | |||
| 140 | 141 | browserLaunchRelay = relay | |
| 141 | 142 | } | |
| 142 | 143 | ||
| 144 | + | type startupTiming struct { | |
| 145 | + | processEntry time.Time | |
| 146 | + | configComplete time.Time | |
| 147 | + | } | |
| 148 | + | ||
| 143 | 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 | 155 | // CEF subprocess handling: when CEF re-launches this binary with | |
| 145 | 156 | // --type=renderer/gpu/etc, we must call ExecuteProcess before anything | |
| 146 | 157 | // else (Cobra, config, arg stripping). We only treat a leading Chromium | |
| @@ -170,6 +181,7 @@ func main() { | |||
| 170 | 181 | // Run GUI mode for browse command | |
| 171 | 182 | if mode == launchModeBrowse { | |
| 172 | 183 | cfg := initConfig() | |
| 184 | + | timing.configComplete = time.Now() | |
| 173 | 185 | configureBrowserLaunchRelay(cfg) | |
| 174 | 186 | startupURL := domainurl.ResolveBrowserStartupURL(browseURL) | |
| 175 | 187 | if forwarded, err := tryForwardBrowseURLToRunningInstance(context.Background(), browserLaunchRelay, startupURL); err != nil { | |
| @@ -186,7 +198,7 @@ func main() { | |||
| 186 | 198 | initialURL = startupURL | |
| 187 | 199 | restoreSessionID = os.Getenv("DUMBER_RESTORE_SESSION") | |
| 188 | 200 | os.Args = os.Args[:1] | |
| 189 | - | os.Exit(runGUI(cfg)) | |
| 201 | + | os.Exit(runGUI(cfg, timing)) | |
| 190 | 202 | return | |
| 191 | 203 | } | |
| 192 | 204 | ||
| @@ -207,7 +219,7 @@ func main() { | |||
| 207 | 219 | cmd.Execute() | |
| 208 | 220 | } | |
| 209 | 221 | ||
| 210 | - | func runGUI(cfg *config.Config) int { | |
| 222 | + | func runGUI(cfg *config.Config, timing startupTiming) int { | |
| 211 | 223 | bootstrap.ApplyGTKIMModuleFallbackDefault(os.Stderr) | |
| 212 | 224 | ||
| 213 | 225 | runtime.LockOSThread() | |
| @@ -216,11 +228,13 @@ func runGUI(cfg *config.Config) int { | |||
| 216 | 228 | ||
| 217 | 229 | if cfg == nil { | |
| 218 | 230 | cfg = initConfig() | |
| 231 | + | timing.configComplete = time.Now() | |
| 219 | 232 | configureBrowserLaunchRelay(cfg) | |
| 220 | 233 | } | |
| 221 | 234 | timer.Mark("config") | |
| 222 | 235 | ||
| 223 | - | ctx := initStartupContextWithTrace(cfg) | |
| 236 | + | ctx := initStartupContext(cfg) | |
| 237 | + | activateCEFStartupTraceForGUI(cfg, timing, logging.FromContext(ctx), infracef.ActivateStartupTrace) | |
| 224 | 238 | applyCEFRenderStackDefault(ctx, cfg) | |
| 225 | 239 | timer.Mark("logger") | |
| 226 | 240 | bootstrapLog := logging.FromContext(ctx) | |
| @@ -253,7 +267,6 @@ func runGUI(cfg *config.Config) int { | |||
| 253 | 267 | timer.Mark("session") | |
| 254 | 268 | ||
| 255 | 269 | log := logging.FromContext(ctx) | |
| 256 | - | logging.Trace().UpdateLogger(log) | |
| 257 | 270 | logCoreDumpLimits(ctx) | |
| 258 | 271 | ||
| 259 | 272 | engine.SetHandlerContext(ctx) | |
| @@ -297,7 +310,7 @@ func runStandaloneOmnibox() int { | |||
| 297 | 310 | ||
| 298 | 311 | cfg := initConfig() | |
| 299 | 312 | configureBrowserLaunchRelay(cfg) | |
| 300 | - | ctx := initStartupContextWithTrace(cfg) | |
| 313 | + | ctx := initStartupContext(cfg) | |
| 301 | 314 | applyCEFRenderStackDefault(ctx, cfg) | |
| 302 | 315 | ||
| 303 | 316 | initResult, err := runParallelInitPhase(ctx, cfg) | |
| @@ -412,21 +425,19 @@ func resolveCurrentExecutable(executable func() (string, error)) (string, error) | |||
| 412 | 425 | return executable() | |
| 413 | 426 | } | |
| 414 | 427 | ||
| 415 | - | func initStartupContextWithTrace(cfg *config.Config) context.Context { | |
| 416 | - | logging.InitStartupTrace(cfg.Logging.Level) | |
| 417 | - | logging.Trace().Mark("config_loaded") | |
| 418 | - | ||
| 419 | - | ctx := initStartupContext(cfg) | |
| 420 | - | bootstrapLog := logging.FromContext(ctx) | |
| 421 | - | ||
| 422 | - | logging.Trace().SetLogger(bootstrapLog) | |
| 423 | - | logging.Trace().Mark("logger_init") | |
| 424 | - | ||
| 425 | - | return ctx | |
| 428 | + | func activateCEFStartupTraceForGUI( | |
| 429 | + | cfg *config.Config, | |
| 430 | + | timing startupTiming, | |
| 431 | + | logger *zerolog.Logger, | |
| 432 | + | activate func(time.Time, time.Time, *zerolog.Logger), | |
| 433 | + | ) { | |
| 434 | + | if cfg == nil || cfg.Engine.ResolveEngineType() != config.EngineTypeCEF { | |
| 435 | + | return | |
| 436 | + | } | |
| 437 | + | activate(timing.processEntry, timing.configComplete, logger) | |
| 426 | 438 | } | |
| 427 | 439 | ||
| 428 | 440 | func runParallelInitPhase(ctx context.Context, cfg *config.Config) (*bootstrap.ParallelInitResult, error) { | |
| 429 | - | logging.Trace().Mark("parallel_start") | |
| 430 | 441 | initResult, err := bootstrap.RunParallelInit(bootstrap.ParallelInitInput{ | |
| 431 | 442 | Ctx: ctx, | |
| 432 | 443 | Config: cfg, | |
| @@ -435,7 +446,6 @@ func runParallelInitPhase(ctx context.Context, cfg *config.Config) (*bootstrap.P | |||
| 435 | 446 | handleParallelInitError(ctx, err) | |
| 436 | 447 | return nil, err | |
| 437 | 448 | } | |
| 438 | - | logging.Trace().Mark("parallel_done") | |
| 439 | 449 | return initResult, nil | |
| 440 | 450 | } | |
| 441 | 451 | ||
M cmd/dumber/main_test.go +16 -0
| @@ -4,12 +4,14 @@ import ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "errors" | |
| 6 | 6 | "testing" | |
| 7 | + | "time" | |
| 7 | 8 | ||
| 8 | 9 | "github.com/bnema/dumber/internal/application/port/mocks" | |
| 9 | 10 | "github.com/bnema/dumber/internal/bootstrap" | |
| 10 | 11 | "github.com/bnema/dumber/internal/infrastructure/colorscheme" | |
| 11 | 12 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 12 | 13 | "github.com/bnema/dumber/internal/infrastructure/desktop" | |
| 14 | + | "github.com/rs/zerolog" | |
| 13 | 15 | ) | |
| 14 | 16 | ||
| 15 | 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 | 191 | func TestPreInitializeAdwaitaForCEF_InitializesAndMarksDetector(t *testing.T) { | |
| 176 | 192 | initResult := &bootstrap.ParallelInitResult{ | |
| 177 | 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 | 1 | id: dev.bnema.Dumber | |
| 2 | 2 | runtime: org.gnome.Platform | |
| 3 | 3 | runtime-version: '49' | |
| 4 | + | # org.gnome.Platform provides libwebp at runtime for purego WebP favicon decoding. | |
| 4 | 5 | sdk: org.gnome.Sdk | |
| 5 | 6 | command: dumber | |
| 6 | 7 | ||
| @@ -53,10 +54,10 @@ modules: | |||
| 53 | 54 | path: dev.bnema.Dumber.desktop | |
| 54 | 55 | # Icons | |
| 55 | 56 | - type: file | |
| 56 | - | path: assets/logo.svg | |
| 57 | + | path: ../assets/logo.svg | |
| 57 | 58 | dest-filename: logo.svg | |
| 58 | 59 | - type: file | |
| 59 | - | path: assets/logo-512.png | |
| 60 | + | path: ../assets/logo-512.png | |
| 60 | 61 | dest-filename: logo-512.png | |
| 61 | 62 | # Metainfo | |
| 62 | 63 | - type: file | |
M go.mod +2 -1
| @@ -6,10 +6,11 @@ require ( | |||
| 6 | 6 | github.com/a-h/templ v0.3.1020 | |
| 7 | 7 | github.com/andybalholm/brotli v1.2.2 | |
| 8 | 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 | 10 | github.com/bnema/purego-cef2gtk v0.8.5 | |
| 11 | 11 | github.com/bnema/purego-pipewire v0.1.5 | |
| 12 | 12 | github.com/bnema/purego-sqlite v0.1.4 | |
| 13 | + | github.com/bnema/purego-webp v0.2.1 | |
| 13 | 14 | github.com/bnema/puregotk v0.7.1 | |
| 14 | 15 | github.com/charmbracelet/bubbles v1.0.0 | |
| 15 | 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 | 18 | // AudioOutputStream represents an active audio output stream. | |
| 19 | 19 | // Implementations handle the low-level audio playback. | |
| 20 | 20 | type AudioOutputStream interface { | |
| 21 | - | // Write sends audio samples to the output device. | |
| 22 | - | // Samples are provided as a 2D slice: [channel][frame]float32. | |
| 23 | - | // This matches CEF's planar float32 audio format. | |
| 21 | + | // Write sends callback-owned audio samples to the output device. | |
| 22 | + | // Samples are provided as [channel][frame]float32 matching CEF's planar | |
| 23 | + | // format. Implementations must not retain the slices and must be safe to | |
| 24 | + | // call concurrently with Close; audio callbacks must not block. | |
| 24 | 25 | Write(samples [][]float32) error | |
| 25 | 26 | // Close releases the audio stream and associated resources. | |
| 26 | 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 | 426 | func TestFaviconEnsureSizedPropagatesReadSizedErrors(t *testing.T) { | |
| 408 | 427 | fx := newFaviconFixture(t) | |
| 409 | 428 | fx.seedOriginal([]byte("old")) | |
M internal/infrastructure/audio/pipewire/output.go +123 -123
| @@ -15,7 +15,6 @@ import ( | |||
| 15 | 15 | "github.com/bnema/dumber/internal/logging" | |
| 16 | 16 | ) | |
| 17 | 17 | ||
| 18 | - | // Compile-time interface checks. | |
| 19 | 18 | var ( | |
| 20 | 19 | _ port.AudioOutputFactory = (*Factory)(nil) | |
| 21 | 20 | _ port.AudioOutputStream = (*playerStreamAdapter)(nil) | |
| @@ -27,6 +26,8 @@ var ErrInvalidFormat = errors.New("invalid audio stream format") | |||
| 27 | 26 | // ErrStreamClosed is returned when writing to a closed stream. | |
| 28 | 27 | var ErrStreamClosed = errors.New("audio stream closed") | |
| 29 | 28 | ||
| 29 | + | const packetQueueCapacity = 4 | |
| 30 | + | ||
| 30 | 31 | // playerCreator is a function type that creates a Player from config and callbacks. | |
| 31 | 32 | // This exists to allow dependency injection in tests. | |
| 32 | 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 | 41 | // NewFactory creates a new PipeWire audio factory. | |
| 41 | - | // The factory is stateless; players are created per-stream. | |
| 42 | 42 | func NewFactory() (*Factory, error) { | |
| 43 | - | return &Factory{ | |
| 44 | - | createPlayer: pwpipewire.NewPlayer, | |
| 45 | - | }, nil | |
| 43 | + | return &Factory{createPlayer: pwpipewire.NewPlayer}, nil | |
| 46 | 44 | } | |
| 47 | 45 | ||
| 48 | - | // NewStream creates a new audio output stream with the given format. | |
| 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. | |
| 46 | + | // NewStream creates a push-to-pull adapter for a PipeWire player. | |
| 52 | 47 | func (f *Factory) NewStream(ctx context.Context, format port.AudioStreamFormat) (port.AudioOutputStream, error) { | |
| 53 | - | // Check context cancellation | |
| 54 | 48 | if ctx.Err() != nil { | |
| 55 | 49 | return nil, ctx.Err() | |
| 56 | 50 | } | |
| 57 | - | ||
| 58 | - | // Validate format | |
| 59 | 51 | if err := validateFormat(format); err != nil { | |
| 60 | 52 | return nil, err | |
| 61 | 53 | } | |
| @@ -67,182 +59,195 @@ func (f *Factory) NewStream(ctx context.Context, format port.AudioStreamFormat) | |||
| 67 | 59 | Int("frames_per_buffer", format.FramesPerBuffer). | |
| 68 | 60 | Msg("pipewire: creating player") | |
| 69 | 61 | ||
| 70 | - | // Create the adapter with its ring buffer before creating the player, | |
| 71 | - | // because the Fill callback captures the adapter. | |
| 72 | - | adapter := &playerStreamAdapter{ | |
| 73 | - | ctx: ctx, | |
| 74 | - | buf: make(chan [][]float32, 4), // small buffer for push→pull bridging | |
| 75 | - | closed: make(chan struct{}), | |
| 76 | - | } | |
| 77 | - | ||
| 62 | + | // Every slot owns storage sized from the negotiated CEF format. Write copies | |
| 63 | + | // directly into an available slot; Fill copies that same slot into PipeWire. | |
| 64 | + | // Callback slices are therefore never queued or retained. | |
| 65 | + | adapter := newPlayerStreamAdapter(ctx, format.ChannelCount, format.FramesPerBuffer) | |
| 78 | 66 | config := pwpipewire.PlayerConfig{ | |
| 79 | - | SampleRate: format.SampleRate, | |
| 80 | - | Channels: format.ChannelCount, | |
| 81 | - | FramesPerBuffer: format.FramesPerBuffer, | |
| 82 | - | SampleFormat: pwpipewire.SampleFormatF32, | |
| 83 | - | UnderrunPolicy: pwpipewire.UnderrunFillSilence, | |
| 67 | + | SampleRate: format.SampleRate, Channels: format.ChannelCount, FramesPerBuffer: format.FramesPerBuffer, | |
| 68 | + | SampleFormat: pwpipewire.SampleFormatF32, UnderrunPolicy: pwpipewire.UnderrunFillSilence, | |
| 84 | 69 | } | |
| 85 | - | ||
| 86 | - | callbacks := pwpipewire.PlayerCallbacks{ | |
| 87 | - | Fill: adapter.fill, | |
| 88 | - | } | |
| 89 | - | ||
| 90 | - | player, err := f.createPlayer(config, callbacks) | |
| 70 | + | player, err := f.createPlayer(config, pwpipewire.PlayerCallbacks{Fill: adapter.fill}) | |
| 91 | 71 | if err != nil { | |
| 92 | 72 | log.Warn().Err(err).Msg("pipewire: failed to create player") | |
| 93 | 73 | return nil, fmt.Errorf("failed to create pipewire player: %w", err) | |
| 94 | 74 | } | |
| 95 | 75 | adapter.player = player | |
| 96 | - | ||
| 97 | - | // Start the player so the Fill callback begins being called. | |
| 98 | 76 | if err := player.Start(); err != nil { | |
| 99 | 77 | _ = player.Close() | |
| 100 | 78 | log.Warn().Err(err).Msg("pipewire: failed to start player") | |
| 101 | 79 | return nil, fmt.Errorf("failed to start pipewire player: %w", err) | |
| 102 | 80 | } | |
| 103 | - | ||
| 104 | 81 | log.Info().Msg("pipewire: player started successfully") | |
| 105 | - | ||
| 106 | 82 | return adapter, nil | |
| 107 | 83 | } | |
| 108 | 84 | ||
| 109 | - | // validateFormat checks if the audio format parameters are valid. | |
| 110 | 85 | func validateFormat(format port.AudioStreamFormat) error { | |
| 111 | - | // Sample rate must be reasonable (8kHz to 384kHz) | |
| 112 | 86 | if format.SampleRate < 8000 || format.SampleRate > 384000 { | |
| 113 | - | return fmt.Errorf("%w: sample rate %d Hz out of range [8000, 384000]", | |
| 114 | - | ErrInvalidFormat, format.SampleRate) | |
| 87 | + | return fmt.Errorf("%w: sample rate %d Hz out of range [8000, 384kHz]", ErrInvalidFormat, format.SampleRate) | |
| 115 | 88 | } | |
| 116 | - | ||
| 117 | - | // Channel count must be 1 to 1024 (reasonable upper limit) | |
| 118 | 89 | if format.ChannelCount < 1 || format.ChannelCount > 1024 { | |
| 119 | - | return fmt.Errorf("%w: channel count %d out of range [1, 1024]", | |
| 120 | - | ErrInvalidFormat, format.ChannelCount) | |
| 90 | + | return fmt.Errorf("%w: channel count %d out of range [1, 1024]", ErrInvalidFormat, format.ChannelCount) | |
| 121 | 91 | } | |
| 122 | - | ||
| 123 | - | // Frames per buffer must be positive and reasonable | |
| 124 | 92 | if format.FramesPerBuffer < 1 || format.FramesPerBuffer > 8192 { | |
| 125 | - | return fmt.Errorf("%w: frames per buffer %d out of range [1, 8192]", | |
| 126 | - | ErrInvalidFormat, format.FramesPerBuffer) | |
| 93 | + | return fmt.Errorf("%w: frames per buffer %d out of range [1, 8192]", ErrInvalidFormat, format.FramesPerBuffer) | |
| 127 | 94 | } | |
| 128 | - | ||
| 129 | 95 | return nil | |
| 130 | 96 | } | |
| 131 | 97 | ||
| 132 | - | // playerStreamAdapter bridges Dumber's push-style Write(samples) interface | |
| 133 | - | // to purego-pipewire's pull-style Fill callback. | |
| 134 | - | // | |
| 135 | - | // Write() sends sample buffers into a channel. The Fill callback drains them | |
| 136 | - | // into the PCMBuffer that purego-pipewire passes in. If no data is available, | |
| 137 | - | // the player's UnderrunFillSilence policy handles it. | |
| 98 | + | // audioPacket is a reusable, adapter-owned packet slot. Its backing arrays are | |
| 99 | + | // allocated only when the stream is created and never alias CEF callback data. | |
| 100 | + | type audioPacket struct { | |
| 101 | + | samples [][]float32 | |
| 102 | + | channels int | |
| 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 | 108 | type playerStreamAdapter struct { | |
| 139 | 109 | player pwpipewire.Player | |
| 140 | 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 | 119 | closeOnce sync.Once | |
| 144 | - | closed chan struct{} // closed when Close is called | |
| 145 | 120 | ||
| 146 | - | // Diagnostic counters (atomic, no mutex needed). | |
| 147 | - | fillCount atomic.Uint64 // total Fill callbacks | |
| 148 | - | underrunCount atomic.Uint64 // Fill callbacks with no data available | |
| 149 | - | dropCount atomic.Uint64 // Write calls that dropped data (buffer full) | |
| 121 | + | fillCount atomic.Uint64 | |
| 122 | + | underrunCount atomic.Uint64 | |
| 123 | + | dropCount atomic.Uint64 | |
| 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. | |
| 153 | - | // Samples are provided as [channel][frame]float32 matching CEF's planar format. | |
| 154 | - | // If the internal buffer is full the packet is dropped silently so the caller | |
| 155 | - | // (CEF audio callback thread) is never blocked. | |
| 156 | - | // Safe for concurrent use with Close. | |
| 140 | + | // Write makes exactly one owned copy for each accepted packet. It never blocks: | |
| 141 | + | // full queues or another producer cause a silent drop, preserving CEF timing. | |
| 142 | + | // The supplied slices are callback-owned and are not retained after return. | |
| 157 | 143 | func (a *playerStreamAdapter) Write(samples [][]float32) error { | |
| 158 | - | // Fast path: already closed. | |
| 159 | - | select { | |
| 160 | - | case <-a.closed: | |
| 144 | + | if a.closed.Load() { | |
| 161 | 145 | return ErrStreamClosed | |
| 162 | - | default: | |
| 163 | 146 | } | |
| 164 | - | // Non-blocking send: drop packet when buffer is full. | |
| 165 | - | select { | |
| 166 | - | case a.buf <- samples: | |
| 147 | + | if !a.writeMu.TryLock() { | |
| 148 | + | a.recordDrop() | |
| 167 | 149 | return nil | |
| 168 | - | case <-a.closed: | |
| 150 | + | } | |
| 151 | + | defer a.writeMu.Unlock() | |
| 152 | + | if a.closed.Load() { | |
| 169 | 153 | return ErrStreamClosed | |
| 170 | - | default: | |
| 171 | - | // Buffer full — drop to avoid blocking the CEF audio thread. | |
| 172 | - | n := a.dropCount.Add(1) | |
| 173 | - | if n == 1 || n&(n-1) == 0 { // first drop, then powers of two | |
| 174 | - | if a.ctx != nil { | |
| 175 | - | logging.FromContext(a.ctx).Warn(). | |
| 176 | - | Uint64("total_drops", n). | |
| 177 | - | Msg("pipewire: dropped audio packet (buffer full)") | |
| 178 | - | } | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | select { | |
| 157 | + | case packet := <-a.available: | |
| 158 | + | copyPacket(packet, samples) | |
| 159 | + | // A close racing after the first check must not publish a packet to a | |
| 160 | + | // stopped player. Returning the slot keeps the ring bounded. | |
| 161 | + | if a.closed.Load() { | |
| 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 | 177 | return nil | |
| 181 | 178 | } | |
| 182 | 179 | } | |
| 183 | 180 | ||
| 184 | - | // fill is the PlayerCallbacks.Fill function called by purego-pipewire | |
| 185 | - | // when it needs audio data. It pulls from the internal buffer channel. | |
| 181 | + | func copyPacket(packet *audioPacket, samples [][]float32) { | |
| 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 | 208 | func (a *playerStreamAdapter) fill(pcm *pwpipewire.PCMBuffer) (int, error) { | |
| 187 | 209 | select { | |
| 188 | - | case samples := <-a.buf: | |
| 210 | + | case packet := <-a.ready: | |
| 189 | 211 | fillNum := a.fillCount.Add(1) | |
| 190 | 212 | if fillNum == 1 && a.ctx != nil { | |
| 191 | - | logging.FromContext(a.ctx).Info(). | |
| 192 | - | Msg("pipewire: first Fill callback with data") | |
| 213 | + | logging.FromContext(a.ctx).Info().Msg("pipewire: first Fill callback with data") | |
| 193 | 214 | } | |
| 194 | - | // Copy available samples into the PCM buffer | |
| 195 | - | copied := copyToPCM(pcm, samples) | |
| 215 | + | copied := copyToPCM(pcm, packet.samples[:packet.channels], packet.frames) | |
| 216 | + | a.available <- packet | |
| 196 | 217 | return copied, nil | |
| 197 | 218 | default: | |
| 198 | - | // No data available — return 0 frames, let underrun policy handle it | |
| 199 | 219 | underruns := a.underrunCount.Add(1) | |
| 200 | 220 | if underruns == 1 && a.ctx != nil { | |
| 201 | - | logging.FromContext(a.ctx).Debug(). | |
| 202 | - | Msg("pipewire: first Fill underrun (no data available)") | |
| 221 | + | logging.FromContext(a.ctx).Debug().Msg("pipewire: first Fill underrun (no data available)") | |
| 203 | 222 | } | |
| 204 | 223 | return 0, nil | |
| 205 | 224 | } | |
| 206 | 225 | } | |
| 207 | 226 | ||
| 208 | - | // copyToPCM copies planar samples into the PCMBuffer. | |
| 209 | - | // Returns the number of frames actually copied. | |
| 210 | - | func copyToPCM(pcm *pwpipewire.PCMBuffer, samples [][]float32) int { | |
| 211 | - | if pcm == nil || len(samples) == 0 { | |
| 227 | + | func copyToPCM(pcm *pwpipewire.PCMBuffer, samples [][]float32, packetFrames int) int { | |
| 228 | + | if pcm == nil || len(samples) == 0 || packetFrames == 0 { | |
| 212 | 229 | return 0 | |
| 213 | 230 | } | |
| 214 | - | ||
| 215 | - | frames := pcm.Frames | |
| 216 | 231 | channels := min(min(len(samples), pcm.Channels), len(pcm.Samples)) | |
| 217 | 232 | if channels == 0 { | |
| 218 | 233 | return 0 | |
| 219 | 234 | } | |
| 220 | - | ||
| 221 | - | minFrames := frames | |
| 222 | - | ||
| 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]) | |
| 235 | + | frames := min(pcm.Frames, packetFrames) | |
| 236 | + | for ch := 0; ch < channels; ch++ { | |
| 237 | + | frames = min(frames, min(len(pcm.Samples[ch]), len(samples[ch]))) | |
| 236 | 238 | } | |
| 237 | - | ||
| 238 | - | return minFrames | |
| 239 | + | for ch := 0; ch < channels; ch++ { | |
| 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. | |
| 242 | - | // Safe to call multiple times. | |
| 245 | + | // Close stops the player. It is safe with Write: Write observes closed before | |
| 246 | + | // publishing, and no channel is closed while a callback may be selecting on it. | |
| 243 | 247 | func (a *playerStreamAdapter) Close() error { | |
| 244 | 248 | var err error | |
| 245 | 249 | a.closeOnce.Do(func() { | |
| 250 | + | a.closed.Store(true) | |
| 246 | 251 | if a.ctx != nil { | |
| 247 | 252 | logging.FromContext(a.ctx).Info(). | |
| 248 | 253 | Uint64("fills", a.fillCount.Load()). | |
| @@ -250,12 +255,7 @@ func (a *playerStreamAdapter) Close() error { | |||
| 250 | 255 | Uint64("drops", a.dropCount.Load()). | |
| 251 | 256 | Msg("pipewire: closing player") | |
| 252 | 257 | } | |
| 253 | - | ||
| 254 | - | // Signal writers to stop | |
| 255 | - | close(a.closed) | |
| 256 | - | ||
| 257 | 258 | if a.player != nil { | |
| 258 | - | // Stop playback then close the player | |
| 259 | 259 | if stopErr := a.player.Stop(); stopErr != nil { | |
| 260 | 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 | 1 | package pipewire | |
| 4 | 2 | ||
| 5 | 3 | import ( | |
| 6 | 4 | "context" | |
| 7 | 5 | "errors" | |
| 6 | + | "sync" | |
| 8 | 7 | "testing" | |
| 8 | + | "time" | |
| 9 | 9 | ||
| 10 | 10 | "github.com/bnema/dumber/internal/application/port" | |
| 11 | 11 | pipewiremocks "github.com/bnema/dumber/internal/infrastructure/audio/pipewire/mocks" | |
| @@ -16,548 +16,193 @@ import ( | |||
| 16 | 16 | ||
| 17 | 17 | type playerRecorder struct { | |
| 18 | 18 | *pipewiremocks.MockPlayer | |
| 19 | - | ||
| 20 | - | started bool | |
| 21 | - | paused bool | |
| 22 | - | stopped bool | |
| 23 | - | closedFlag bool | |
| 24 | - | state pwpipewire.PlayerState | |
| 25 | - | ||
| 26 | - | startErr error | |
| 27 | - | stopErr error | |
| 28 | - | closeErr error | |
| 19 | + | started, stopped, closedFlag bool | |
| 20 | + | startErr, stopErr, closeErr error | |
| 29 | 21 | } | |
| 30 | 22 | ||
| 31 | 23 | func newPlayerRecorder(t *testing.T) *playerRecorder { | |
| 32 | 24 | t.Helper() | |
| 33 | - | ||
| 34 | - | recorder := &playerRecorder{MockPlayer: pipewiremocks.NewMockPlayer(t)} | |
| 35 | - | recorder.EXPECT().Start().RunAndReturn(recorder.start).Maybe() | |
| 36 | - | recorder.EXPECT().Pause().RunAndReturn(recorder.pause).Maybe() | |
| 37 | - | recorder.EXPECT().Stop().RunAndReturn(recorder.stop).Maybe() | |
| 38 | - | recorder.EXPECT().Close().RunAndReturn(recorder.close).Maybe() | |
| 39 | - | recorder.EXPECT().State().RunAndReturn(recorder.currentState).Maybe() | |
| 40 | - | return recorder | |
| 41 | - | } | |
| 42 | - | ||
| 43 | - | func (p *playerRecorder) start() error { | |
| 44 | - | if p.startErr != nil { | |
| 45 | - | return p.startErr | |
| 46 | - | } | |
| 47 | - | p.started = true | |
| 48 | - | p.state = pwpipewire.PlayerStatePlaying | |
| 49 | - | return nil | |
| 50 | - | } | |
| 51 | - | ||
| 52 | - | func (p *playerRecorder) pause() error { | |
| 53 | - | p.paused = true | |
| 54 | - | p.state = pwpipewire.PlayerStatePaused | |
| 55 | - | return nil | |
| 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 | |
| 25 | + | p := &playerRecorder{MockPlayer: pipewiremocks.NewMockPlayer(t)} | |
| 26 | + | p.EXPECT().Start().RunAndReturn(func() error { | |
| 27 | + | if p.startErr != nil { | |
| 28 | + | return p.startErr | |
| 29 | + | } | |
| 30 | + | p.started = true | |
| 31 | + | return nil | |
| 32 | + | }).Maybe() | |
| 33 | + | p.EXPECT().Stop().RunAndReturn(func() error { | |
| 34 | + | if p.stopErr != nil { | |
| 35 | + | return p.stopErr | |
| 36 | + | } | |
| 37 | + | p.stopped = true | |
| 38 | + | return nil | |
| 39 | + | }).Maybe() | |
| 40 | + | p.EXPECT().Close().RunAndReturn(func() error { | |
| 41 | + | if p.closeErr != nil { | |
| 42 | + | return p.closeErr | |
| 43 | + | } | |
| 44 | + | p.closedFlag = true | |
| 45 | + | return nil | |
| 46 | + | }).Maybe() | |
| 47 | + | return p | |
| 65 | 48 | } | |
| 66 | 49 | ||
| 67 | - | func (p *playerRecorder) close() error { | |
| 68 | - | if p.closeErr != nil { | |
| 69 | - | return p.closeErr | |
| 70 | - | } | |
| 71 | - | p.closedFlag = true | |
| 72 | - | p.state = pwpipewire.PlayerStateClosed | |
| 73 | - | return nil | |
| 50 | + | func testAdapter(frames int) *playerStreamAdapter { | |
| 51 | + | return newPlayerStreamAdapter(context.Background(), 2, frames) | |
| 74 | 52 | } | |
| 75 | 53 | ||
| 76 | - | func (p *playerRecorder) currentState() pwpipewire.PlayerState { | |
| 77 | - | return p.state | |
| 54 | + | func stereoPCM(frames int) *pwpipewire.PCMBuffer { | |
| 55 | + | return &pwpipewire.PCMBuffer{Frames: frames, Channels: 2, Samples: [][]float32{make([]float32, frames), make([]float32, frames)}} | |
| 78 | 56 | } | |
| 79 | 57 | ||
| 80 | - | // --- Factory tests --- | |
| 81 | - | ||
| 82 | - | func TestFactory_NewStream_ValidFormat_CreatesStream(t *testing.T) { | |
| 83 | - | var capturedConfig pwpipewire.PlayerConfig | |
| 58 | + | func TestFactory_NewStreamValidatesAndStartsPlayer(t *testing.T) { | |
| 84 | 59 | player := newPlayerRecorder(t) | |
| 85 | - | ||
| 86 | - | factory := &Factory{ | |
| 87 | - | createPlayer: func(config pwpipewire.PlayerConfig, _ pwpipewire.PlayerCallbacks) (pwpipewire.Player, error) { | |
| 88 | - | capturedConfig = config | |
| 89 | - | return player, nil | |
| 90 | - | }, | |
| 91 | - | } | |
| 92 | - | ||
| 93 | - | format := port.AudioStreamFormat{ | |
| 94 | - | SampleRate: 48000, | |
| 95 | - | ChannelCount: 2, | |
| 96 | - | FramesPerBuffer: 512, | |
| 97 | - | } | |
| 98 | - | ||
| 99 | - | stream, err := factory.NewStream(context.Background(), format) | |
| 60 | + | factory := &Factory{createPlayer: func(config pwpipewire.PlayerConfig, callbacks pwpipewire.PlayerCallbacks) (pwpipewire.Player, error) { | |
| 61 | + | assert.Equal(t, 48000, config.SampleRate) | |
| 62 | + | assert.Equal(t, 2, config.Channels) | |
| 63 | + | assert.NotNil(t, callbacks.Fill) | |
| 64 | + | return player, nil | |
| 65 | + | }} | |
| 66 | + | stream, err := factory.NewStream(context.Background(), port.AudioStreamFormat{SampleRate: 48000, ChannelCount: 2, FramesPerBuffer: 512}) | |
| 100 | 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 | 68 | assert.True(t, player.started) | |
| 112 | - | ||
| 113 | - | // Clean up | |
| 114 | 69 | require.NoError(t, stream.Close()) | |
| 115 | 70 | } | |
| 116 | 71 | ||
| 117 | - | func TestFactory_NewStream_InvalidFormat_ReturnsError(t *testing.T) { | |
| 118 | - | factory := &Factory{ | |
| 119 | - | createPlayer: func(_ pwpipewire.PlayerConfig, _ pwpipewire.PlayerCallbacks) (pwpipewire.Player, error) { | |
| 120 | - | t.Fatal("createPlayer should not be called for invalid format") | |
| 121 | - | return nil, nil | |
| 122 | - | }, | |
| 123 | - | } | |
| 124 | - | ||
| 125 | - | tests := []struct { | |
| 126 | - | name string | |
| 127 | - | format port.AudioStreamFormat | |
| 128 | - | }{ | |
| 129 | - | { | |
| 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 | - | }, | |
| 72 | + | func TestFactory_NewStreamRejectsInvalidFormat(t *testing.T) { | |
| 73 | + | factory := &Factory{createPlayer: func(pwpipewire.PlayerConfig, pwpipewire.PlayerCallbacks) (pwpipewire.Player, error) { | |
| 74 | + | t.Fatal("unexpected player") | |
| 75 | + | return nil, nil | |
| 76 | + | }} | |
| 77 | + | for _, format := range []port.AudioStreamFormat{ | |
| 78 | + | {SampleRate: 0, ChannelCount: 2, FramesPerBuffer: 512}, | |
| 79 | + | {SampleRate: 48000, ChannelCount: 0, FramesPerBuffer: 512}, | |
| 80 | + | {SampleRate: 48000, ChannelCount: 2, FramesPerBuffer: 0}, | |
| 81 | + | } { | |
| 82 | + | stream, err := factory.NewStream(context.Background(), format) | |
| 83 | + | require.ErrorIs(t, err, ErrInvalidFormat) | |
| 84 | + | assert.Nil(t, stream) | |
| 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 | 89 | player := newPlayerRecorder(t) | |
| 191 | 90 | player.startErr = errors.New("start failed") | |
| 192 | - | factory := &Factory{ | |
| 193 | - | createPlayer: func(_ pwpipewire.PlayerConfig, _ pwpipewire.PlayerCallbacks) (pwpipewire.Player, error) { | |
| 194 | - | return player, nil | |
| 195 | - | }, | |
| 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 | - | ||
| 91 | + | factory := &Factory{createPlayer: func(pwpipewire.PlayerConfig, pwpipewire.PlayerCallbacks) (pwpipewire.Player, error) { | |
| 92 | + | return player, nil | |
| 93 | + | }} | |
| 94 | + | stream, err := factory.NewStream(context.Background(), port.AudioStreamFormat{SampleRate: 48000, ChannelCount: 2, FramesPerBuffer: 512}) | |
| 220 | 95 | require.Error(t, err) | |
| 221 | 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 | 97 | assert.True(t, player.closedFlag) | |
| 307 | 98 | } | |
| 308 | 99 | ||
| 309 | - | func TestPlayerStreamAdapter_Close_IsIdempotent(t *testing.T) { | |
| 310 | - | player := newPlayerRecorder(t) | |
| 311 | - | adapter := &playerStreamAdapter{ | |
| 312 | - | player: player, | |
| 313 | - | buf: make(chan [][]float32, 4), | |
| 314 | - | closed: make(chan struct{}), | |
| 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 | - | ||
| 100 | + | func TestPlayerStreamAdapter_FillGetsOwnedPacketAfterCallbackMutation(t *testing.T) { | |
| 101 | + | adapter := testAdapter(3) | |
| 102 | + | samples := [][]float32{{1, 2, 3}, {4, 5, 6}} | |
| 103 | + | require.NoError(t, adapter.Write(samples)) | |
| 104 | + | samples[0][0], samples[1][0] = 99, 99 | |
| 105 | + | pcm := stereoPCM(3) | |
| 374 | 106 | frames, err := adapter.fill(pcm) | |
| 375 | 107 | require.NoError(t, err) | |
| 376 | 108 | assert.Equal(t, 3, frames) | |
| 377 | - | assert.InEpsilon(t, 0.1, pcm.Samples[0][0], 0.0001) | |
| 378 | - | assert.InEpsilon(t, 0.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) | |
| 109 | + | assert.InEpsilon(t, 1, pcm.Samples[0][0], 0.0001) | |
| 110 | + | assert.InEpsilon(t, 4, pcm.Samples[1][0], 0.0001) | |
| 415 | 111 | } | |
| 416 | 112 | ||
| 417 | - | func TestCopyToPCM_HandlesFrameMismatch(t *testing.T) { | |
| 418 | - | // Fewer source frames than PCM expects | |
| 419 | - | pcm := &pwpipewire.PCMBuffer{ | |
| 420 | - | Frames: 4, | |
| 421 | - | Channels: 1, | |
| 422 | - | Samples: [][]float32{make([]float32, 4)}, | |
| 113 | + | func TestPlayerStreamAdapter_SaturationDropsWithoutBlocking(t *testing.T) { | |
| 114 | + | adapter := testAdapter(2) | |
| 115 | + | for i := 0; i < packetQueueCapacity; i++ { | |
| 116 | + | require.NoError(t, adapter.Write([][]float32{{float32(i)}, {float32(i)}})) | |
| 423 | 117 | } | |
| 424 | - | samples := [][]float32{ | |
| 425 | - | {1.0, 2.0}, // only 2 frames | |
| 118 | + | done := make(chan error, 1) | |
| 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) | |
| 428 | - | assert.Equal(t, 2, frames) // reports partial fill | |
| 429 | - | } | |
| 430 | - | ||
| 431 | - | func TestCopyToPCM_NilPCM_ReturnsZero(t *testing.T) { | |
| 432 | - | assert.Equal(t, 0, copyToPCM(nil, [][]float32{{1.0}})) | |
| 126 | + | assert.Equal(t, uint64(1), adapter.dropCount.Load()) | |
| 127 | + | assert.Len(t, adapter.ready, packetQueueCapacity) | |
| 433 | 128 | } | |
| 434 | 129 | ||
| 435 | - | func TestCopyToPCM_EmptySamples_ReturnsZero(t *testing.T) { | |
| 436 | - | pcm := &pwpipewire.PCMBuffer{Frames: 1, Channels: 1, Samples: [][]float32{make([]float32, 1)}} | |
| 437 | - | assert.Equal(t, 0, copyToPCM(pcm, nil)) | |
| 438 | - | assert.Equal(t, 0, copyToPCM(pcm, [][]float32{})) | |
| 130 | + | func TestPlayerStreamAdapter_EmptyFillCountsUnderrun(t *testing.T) { | |
| 131 | + | adapter := testAdapter(2) | |
| 132 | + | frames, err := adapter.fill(stereoPCM(2)) | |
| 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 --- | |
| 442 | - | ||
| 443 | - | func TestPlayerStreamAdapter_Write_DropsWhenBufferFull(t *testing.T) { | |
| 444 | - | adapter := &playerStreamAdapter{ | |
| 445 | - | player: newPlayerRecorder(t), | |
| 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) | |
| 138 | + | func TestPlayerStreamAdapter_CopyToPCMHandlesMismatches(t *testing.T) { | |
| 139 | + | pcm := &pwpipewire.PCMBuffer{Frames: 4, Channels: 1, Samples: [][]float32{make([]float32, 4)}} | |
| 140 | + | assert.Equal(t, 2, copyToPCM(pcm, [][]float32{{1, 2}, {3, 4}}, 2)) | |
| 141 | + | assert.InEpsilon(t, 1, pcm.Samples[0][0], 0.0001) | |
| 142 | + | assert.Zero(t, copyToPCM(nil, [][]float32{{1}}, 1)) | |
| 464 | 143 | } | |
| 465 | 144 | ||
| 466 | - | func TestPlayerStreamAdapter_Write_DropsLogOnlyOnFirstAndPowerOfTwo(t *testing.T) { | |
| 467 | - | adapter := &playerStreamAdapter{ | |
| 468 | - | player: newPlayerRecorder(t), | |
| 469 | - | buf: make(chan [][]float32, 1), // tiny buffer to force drops | |
| 470 | - | closed: make(chan struct{}), | |
| 471 | - | } | |
| 472 | - | ||
| 473 | - | // Fill buffer | |
| 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()) | |
| 145 | + | func TestPlayerStreamAdapter_CloseVsWrite(t *testing.T) { | |
| 146 | + | adapter := testAdapter(2) | |
| 147 | + | player := newPlayerRecorder(t) | |
| 148 | + | adapter.player = player | |
| 149 | + | require.NoError(t, adapter.Close()) | |
| 150 | + | require.ErrorIs(t, adapter.Write([][]float32{{1}, {2}}), ErrStreamClosed) | |
| 151 | + | assert.True(t, player.stopped) | |
| 152 | + | assert.True(t, player.closedFlag) | |
| 483 | 153 | } | |
| 484 | 154 | ||
| 485 | - | // --- Concurrent Write / Close tests --- | |
| 486 | - | ||
| 487 | 155 | func TestPlayerStreamAdapter_ConcurrentWriteClose_NoRace(t *testing.T) { | |
| 488 | - | player := newPlayerRecorder(t) | |
| 489 | - | adapter := &playerStreamAdapter{ | |
| 490 | - | player: player, | |
| 491 | - | buf: make(chan [][]float32, 4), | |
| 492 | - | closed: make(chan struct{}), | |
| 493 | - | } | |
| 494 | - | ||
| 495 | - | done := make(chan struct{}) | |
| 496 | - | go func() { | |
| 497 | - | defer close(done) | |
| 498 | - | for i := range 100 { | |
| 499 | - | err := adapter.Write([][]float32{{float32(i)}}) | |
| 500 | - | if err != nil { | |
| 501 | - | assert.ErrorIs(t, err, ErrStreamClosed) | |
| 502 | - | return | |
| 156 | + | adapter := testAdapter(4) | |
| 157 | + | var writers sync.WaitGroup | |
| 158 | + | for range 4 { | |
| 159 | + | writers.Add(1) | |
| 160 | + | go func() { | |
| 161 | + | defer writers.Done() | |
| 162 | + | for range 1000 { | |
| 163 | + | err := adapter.Write([][]float32{{1, 2}, {3, 4}}) | |
| 164 | + | if err != nil { | |
| 165 | + | assert.ErrorIs(t, err, ErrStreamClosed) | |
| 166 | + | return | |
| 167 | + | } | |
| 503 | 168 | } | |
| 504 | - | } | |
| 505 | - | }() | |
| 506 | - | ||
| 507 | - | // Let a few writes land, then close. | |
| 169 | + | }() | |
| 170 | + | } | |
| 508 | 171 | adapter.Close() | |
| 509 | - | <-done | |
| 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) | |
| 172 | + | writers.Wait() | |
| 519 | 173 | } | |
| 520 | 174 | ||
| 521 | - | // --- Integration-style test: full write-through --- | |
| 522 | - | ||
| 523 | 175 | func TestStream_WriteFlowsThroughToFill(t *testing.T) { | |
| 524 | - | var capturedCallbacks pwpipewire.PlayerCallbacks | |
| 176 | + | var callbacks pwpipewire.PlayerCallbacks | |
| 525 | 177 | player := newPlayerRecorder(t) | |
| 526 | - | ||
| 527 | - | factory := &Factory{ | |
| 528 | - | createPlayer: func(_ pwpipewire.PlayerConfig, callbacks pwpipewire.PlayerCallbacks) (pwpipewire.Player, error) { | |
| 529 | - | capturedCallbacks = callbacks | |
| 530 | - | return player, nil | |
| 531 | - | }, | |
| 532 | - | } | |
| 533 | - | ||
| 534 | - | format := port.AudioStreamFormat{SampleRate: 48000, ChannelCount: 2, FramesPerBuffer: 4} | |
| 535 | - | stream, err := factory.NewStream(context.Background(), format) | |
| 178 | + | factory := &Factory{createPlayer: func(_ pwpipewire.PlayerConfig, got pwpipewire.PlayerCallbacks) (pwpipewire.Player, error) { | |
| 179 | + | callbacks = got | |
| 180 | + | return player, nil | |
| 181 | + | }} | |
| 182 | + | stream, err := factory.NewStream(context.Background(), port.AudioStreamFormat{SampleRate: 48000, ChannelCount: 2, FramesPerBuffer: 4}) | |
| 536 | 183 | require.NoError(t, err) | |
| 537 | - | ||
| 538 | - | // Write samples through the stream | |
| 539 | - | samples := [][]float32{ | |
| 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) | |
| 184 | + | require.NoError(t, stream.Write([][]float32{{.1, .2, .3, .4}, {.5, .6, .7, .8}})) | |
| 185 | + | pcm := stereoPCM(4) | |
| 186 | + | frames, err := callbacks.Fill(pcm) | |
| 557 | 187 | require.NoError(t, err) | |
| 558 | 188 | assert.Equal(t, 4, frames) | |
| 559 | - | assert.InEpsilon(t, 0.1, pcm.Samples[0][0], 0.0001) | |
| 560 | - | assert.InEpsilon(t, 0.5, pcm.Samples[1][0], 0.0001) | |
| 561 | - | ||
| 189 | + | assert.InEpsilon(t, .1, pcm.Samples[0][0], .0001) | |
| 190 | + | assert.InEpsilon(t, .5, pcm.Samples[1][0], .0001) | |
| 562 | 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 | 448 | return len(args) >= 2 && args[1] == "browse" | |
| 449 | 449 | } | |
| 450 | 450 | ||
| 451 | + | func isGPUProcessRelaunchCommandLine(commandLine purecef.CommandLine) bool { | |
| 452 | + | if commandLine == nil { | |
| 453 | + | return false | |
| 454 | + | } | |
| 455 | + | for _, arg := range parseRelaunchCommandLineArgs(commandLine.GetCommandLineString()) { | |
| 456 | + | if arg == "--type=gpu-process" { | |
| 457 | + | return true | |
| 458 | + | } | |
| 459 | + | } | |
| 460 | + | return false | |
| 461 | + | } | |
| 462 | + | ||
| 451 | 463 | func parseBrowseURLFromRelaunchCommandLine(commandLine purecef.CommandLine) string { | |
| 452 | 464 | browseURL, _ := parseBrowseRelaunchCommandLine(commandLine) | |
| 453 | 465 | return browseURL | |
| 454 | 466 | } | |
| 455 | 467 | ||
| 456 | 468 | func (h *dumberBPH) OnAlreadyRunningAppRelaunch(commandLine purecef.CommandLine, _ string) int32 { | |
| 469 | + | if isGPUProcessRelaunchCommandLine(commandLine) { | |
| 470 | + | if h != nil && h.engine != nil { | |
| 471 | + | h.engine.recordGPURelaunch() | |
| 472 | + | } | |
| 473 | + | return 0 | |
| 474 | + | } | |
| 457 | 475 | if browseURL, ok := parseBrowseRelaunchCommandLine(commandLine); ok { | |
| 458 | 476 | if h != nil && h.engine != nil { | |
| 459 | 477 | if handler := h.engine.alreadyRunningAppRelaunchCallback(); handler != nil { | |
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 | 31 | pool *WebViewPool | |
| 32 | 32 | profileLogDir string | |
| 33 | 33 | runtimeCEFDir string | |
| 34 | + | stateRoot string | |
| 34 | 35 | renderStackPlan cef2gtk.RenderStackPlan | |
| 35 | 36 | applicationScaleMu sync.RWMutex | |
| 36 | 37 | applicationScale float64 | |
| @@ -54,6 +55,7 @@ type Engine struct { | |||
| 54 | 55 | downloadHandler *downloadHandler | |
| 55 | 56 | alreadyRunningAppRelaunchMu sync.RWMutex | |
| 56 | 57 | alreadyRunningAppRelaunchHandler func(string) | |
| 58 | + | gpuRelaunchCount atomic.Int32 | |
| 57 | 59 | ||
| 58 | 60 | // activeWebViews tracks all live webviews for CSS broadcast. | |
| 59 | 61 | activeWebViews sync.Map // map[port.WebViewID]*WebView | |
| @@ -145,6 +147,17 @@ func (e *Engine) alreadyRunningAppRelaunchCallback() func(string) { | |||
| 145 | 147 | return e.alreadyRunningAppRelaunchHandler | |
| 146 | 148 | } | |
| 147 | 149 | ||
| 150 | + | // recordGPURelaunch asks the next engine start to use Chromium's conservative | |
| 151 | + | // defaults after two GPU-process relaunch attempts in one running instance. | |
| 152 | + | func (e *Engine) recordGPURelaunch() { | |
| 153 | + | if e == nil || e.gpuRelaunchCount.Add(1) != 2 { | |
| 154 | + | return | |
| 155 | + | } | |
| 156 | + | if err := writeNextStartSafetyMarker(e.stateRoot); err != nil { | |
| 157 | + | logging.FromContext(e.ctx).Warn().Err(err).Msg("cef: failed to mark next start safe after repeated GPU relaunch") | |
| 158 | + | } | |
| 159 | + | } | |
| 160 | + | ||
| 148 | 161 | // registerWebView adds a webview to the active tracking map. | |
| 149 | 162 | func (e *Engine) registerWebView(wv *WebView) { | |
| 150 | 163 | e.activeWebViews.Store(wv.id, wv) | |
M internal/infrastructure/cef/engine_init.go +29 -3
| @@ -45,6 +45,10 @@ func NewEngine( | |||
| 45 | 45 | ) (*Engine, error) { | |
| 46 | 46 | logger := logging.FromContext(ctx) | |
| 47 | 47 | stateRoot := resolvedStateRoot(paths.StateRoot, opts) | |
| 48 | + | if consumeNextStartSafetyMarker(stateRoot) { | |
| 49 | + | applyNextStartSafetyEnvironment() | |
| 50 | + | logger.Warn().Str("state_root", stateRoot).Msg("cef: applying next-start GPU safety recovery") | |
| 51 | + | } | |
| 48 | 52 | cleanStaleSingletonLocks(logger, stateRoot) | |
| 49 | 53 | windowlessFrameRate := normalizedWindowlessFrameRate(cfg.WindowlessFrameRate) | |
| 50 | 54 | renderStackPlan, err := resolveCEFRenderStackPlan(cfg.RenderStack) | |
| @@ -52,6 +56,7 @@ func NewEngine( | |||
| 52 | 56 | return nil, err | |
| 53 | 57 | } | |
| 54 | 58 | cef2gtk.ConfigureRenderStackEnvironment(renderStackPlan) | |
| 59 | + | activeStartupTrace().SetBackend(renderStackPlan.Backend.String()) | |
| 55 | 60 | ||
| 56 | 61 | settings, err := prepareCEFSettings(opts, paths, cfg, logger) | |
| 57 | 62 | if err != nil { | |
| @@ -67,6 +72,7 @@ func NewEngine( | |||
| 67 | 72 | ctx: ctx, | |
| 68 | 73 | profileLogDir: paths.ProfileLogDir, | |
| 69 | 74 | runtimeCEFDir: settings.CEFDir, | |
| 75 | + | stateRoot: stateRoot, | |
| 70 | 76 | renderStackPlan: renderStackPlan, | |
| 71 | 77 | applicationScale: normalizedApplicationScale(cfg.ApplicationScale), | |
| 72 | 78 | registerHandlers: deps.RegisterHandlers, | |
| @@ -141,6 +147,24 @@ func resolvedStateRoot(defaultStateRoot string, opts port.EngineOptions) string | |||
| 141 | 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 | 168 | // prepareCEFSettings builds purecef.Settings from the engine config. | |
| 145 | 169 | func prepareCEFSettings( | |
| 146 | 170 | opts port.EngineOptions, | |
| @@ -164,9 +188,7 @@ func prepareCEFSettings( | |||
| 164 | 188 | settings.MultiThreadedMessageLoop = true | |
| 165 | 189 | settings.ExternalMessagePump = false | |
| 166 | 190 | settings.RootCachePath = resolvedStateRoot(paths.StateRoot, opts) | |
| 167 | - | if cfg.CEFDir != "" { | |
| 168 | - | settings.CEFDir = cfg.CEFDir | |
| 169 | - | } | |
| 191 | + | settings.CEFDir = configureCEFSubprocessRuntime(cfg.CEFDir) | |
| 170 | 192 | if runtimeLogFile, err := prepareCEFLogFile(paths.LogFile, cfg.LogFile); err != nil { | |
| 171 | 193 | logger.Warn().Err(err).Msg("cef: failed to prepare runtime log file") | |
| 172 | 194 | } else { | |
| @@ -194,9 +216,13 @@ func initializeCEF(eng *Engine, settings purecef.Settings, logger *zerolog.Logge | |||
| 194 | 216 | Str("env_cef_dir", os.Getenv("CEF_DIR")). | |
| 195 | 217 | Msg("cef: runtime selection") | |
| 196 | 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 | 222 | if err := purecef.InitWithApp(settings, app); err != nil { | |
| 198 | 223 | return fmt.Errorf("cef.InitWithApp: %w", err) | |
| 199 | 224 | } | |
| 225 | + | activeStartupTrace().Mark("cef_initialized") | |
| 200 | 226 | if libcefPath := loadedLibCEFPath(); libcefPath != "" { | |
| 201 | 227 | logger.Info().Str("libcef_path", libcefPath).Msg("cef: runtime library loaded") | |
| 202 | 228 | } else { | |
M internal/infrastructure/cef/engine_init_test.go +16 -0
| @@ -36,6 +36,22 @@ func TestResolvedStateRoot_UsesProfileDefault(t *testing.T) { | |||
| 36 | 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 | 55 | func TestPrepareCEFSettings_UsesResolvedProfilePaths(t *testing.T) { | |
| 40 | 56 | logger := zerolog.Nop() | |
| 41 | 57 | profile := testCEFDevProfile(t) | |
M internal/infrastructure/cef/factory.go +14 -8
| @@ -27,7 +27,7 @@ type WebViewFactory struct { | |||
| 27 | 27 | windowlessFrameRate int32 | |
| 28 | 28 | windowlessFrameRateMax int32 | |
| 29 | 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 | 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 | 75 | func (f *WebViewFactory) setDefaultBackgroundColor(r, g, b, a float64) { | |
| 74 | 76 | const s = colorScale | |
| 75 | 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 | 98 | settings := purecef.NewBrowserSettings() | |
| 97 | 99 | cef2gtk.ConfigureBrowserSettings(&settings, cef2gtk.BrowserSettingsOptions{WindowlessFrameRate: f.windowlessFrameRate}) | |
| 98 | 100 | settings.LocalStorage = 1 // CEF_STATE_ENABLED | |
| 99 | - | if bg := f.bgColor.Load(); bg != 0 { | |
| 100 | - | settings.BackgroundColor = bg | |
| 101 | - | } | |
| 101 | + | // Always opaque white: Chromium's standard canvas fallback. The theme | |
| 102 | + | // background is applied at the presentation layer as a flash guard instead. | |
| 103 | + | settings.BackgroundColor = opaqueWhiteBackground | |
| 102 | 104 | ||
| 103 | 105 | // Defer browser creation until the bridge view has a non-zero size. | |
| 104 | 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 | 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 | 150 | if !result.Completed() { | |
| 147 | 151 | wv.destroyViewBridgeOnGTKAsync() | |
| @@ -278,6 +282,8 @@ func (f *WebViewFactory) postPendingBrowserCreate(ctx context.Context, wv *WebVi | |||
| 278 | 282 | // renderer without an initial paint. We replay pending navigation from | |
| 279 | 283 | // OnAfterCreated once the host is fully wired. | |
| 280 | 284 | initialURL := "about:blank" | |
| 285 | + | // This is the request boundary, not the later OnAfterCreated callback. | |
| 286 | + | activeStartupTrace().Mark("browser_create_requested") | |
| 281 | 287 | result := cefBrowserHostCreateBrowser( | |
| 282 | 288 | pc.windowInfo, | |
| 283 | 289 | pc.client, | |
| @@ -381,9 +387,9 @@ func (f *WebViewFactory) CreateRelated(ctx context.Context, parentID port.WebVie | |||
| 381 | 387 | settings := purecef.NewBrowserSettings() | |
| 382 | 388 | cef2gtk.ConfigureBrowserSettings(&settings, cef2gtk.BrowserSettingsOptions{WindowlessFrameRate: f.windowlessFrameRate}) | |
| 383 | 389 | settings.LocalStorage = 1 // CEF_STATE_ENABLED | |
| 384 | - | if bg := f.bgColor.Load(); bg != 0 { | |
| 385 | - | settings.BackgroundColor = bg | |
| 386 | - | } | |
| 390 | + | // Always opaque white: the theme background is applied as a presentation-layer | |
| 391 | + | // flash guard, not baked into Chromium's canvas. | |
| 392 | + | settings.BackgroundColor = opaqueWhiteBackground | |
| 387 | 393 | ||
| 388 | 394 | if err := f.configureInitialBrowserCreation(ctx, popupWV, popupWV.client, &windowInfo, &settings, func(w, h int32) { | |
| 389 | 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 | 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 | 46 | func newDumberRenderHandler(wv *WebView) purecef.RenderHandler { | |
| 27 | 47 | if wv == nil || wv.viewBridge == nil { | |
| 28 | 48 | return nil | |
| @@ -30,23 +50,32 @@ func newDumberRenderHandler(wv *WebView) purecef.RenderHandler { | |||
| 30 | 50 | h := &dumberRenderHandler{wv: wv} | |
| 31 | 51 | ||
| 32 | 52 | var unsupportedPaintOnce sync.Once | |
| 33 | - | h.main = wv.viewBridge.RenderHandler(cef2gtk.Hooks{ | |
| 34 | - | OnUnsupportedPaint: func() { | |
| 35 | - | unsupportedPaintOnce.Do(func() { | |
| 36 | - | if wv.ctx != nil { | |
| 37 | - | logging.FromContext(wv.ctx).Warn().Msg("cef: unsupported CPU paint from accelerated bridge") | |
| 38 | - | } | |
| 39 | - | }) | |
| 40 | - | }, | |
| 41 | - | OnError: func(err error) { | |
| 53 | + | hooks := startupPresentationHooks(activeStartupTrace()) | |
| 54 | + | // Tear down the theme background flash guard once the first frame is | |
| 55 | + | // presented, without dropping the startup trace mark the bridge already set. | |
| 56 | + | firstAcceleratedPaint := hooks.OnFirstAcceleratedPaint | |
| 57 | + | hooks.OnFirstAcceleratedPaint = func() { | |
| 58 | + | if firstAcceleratedPaint != nil { | |
| 59 | + | firstAcceleratedPaint() | |
| 60 | + | } | |
| 61 | + | wv.markFirstFramePainted() | |
| 62 | + | } | |
| 63 | + | hooks.OnUnsupportedPaint = func() { | |
| 64 | + | unsupportedPaintOnce.Do(func() { | |
| 42 | 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 | - | }, | |
| 46 | - | OnTextSelectionChanged: func(selectedText string, _ *purecef.Range) { | |
| 47 | - | handleRenderTextSelectionChanged(wv, selectedText) | |
| 48 | - | }, | |
| 49 | - | }) | |
| 68 | + | }) | |
| 69 | + | } | |
| 70 | + | hooks.OnError = func(err error) { | |
| 71 | + | if wv.ctx != nil { | |
| 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 | 80 | if wv.popupSurface != nil { | |
| 52 | 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 | 39 | // errDestroyed is returned when an operation is attempted on a destroyed WebView. | |
| 40 | 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 | 46 | // errNoBrowser is returned when the browser has not been created yet. | |
| 43 | 47 | var errNoBrowser = errors.New("cef: browser not yet created") | |
| 44 | 48 | ||
| @@ -107,11 +111,19 @@ type WebView struct { | |||
| 107 | 111 | viewportSyncReason string | |
| 108 | 112 | viewportMapFunc func(gtk.Widget) | |
| 109 | 113 | viewportShowFunc func(gtk.Widget) | |
| 114 | + | viewportHideFunc func(gtk.Widget) | |
| 115 | + | viewportUnmapFunc func(gtk.Widget) | |
| 110 | 116 | viewportRealizeFunc func(gtk.Widget) | |
| 111 | 117 | viewportMapSignalID uint | |
| 112 | 118 | viewportShowSignalID uint | |
| 119 | + | viewportHideSignalID uint | |
| 120 | + | viewportUnmapSignalID uint | |
| 113 | 121 | viewportRealizeSignalID uint | |
| 114 | 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 | 128 | adaptiveWindowlessFrameRate bool | |
| 117 | 129 | windowlessFrameRateMax int32 | |
| @@ -227,6 +239,18 @@ type WebView struct { | |||
| 227 | 239 | backgroundColor uint32 | |
| 228 | 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 | 254 | // Touchpad input diagnostics are debounced to avoid flooding logs while | |
| 231 | 255 | // debugging continuous scroll streams. | |
| 232 | 256 | touchpadNavigation *touchpadNavigationRecognizer | |
| @@ -238,16 +262,25 @@ type WebView struct { | |||
| 238 | 262 | inputDiagDeltaXSum int64 | |
| 239 | 263 | inputDiagDeltaYSum int64 | |
| 240 | 264 | ||
| 241 | - | // Audio output factory and active stream. | |
| 242 | - | audioOutputFactory port.AudioOutputFactory | |
| 243 | - | audioStreamMu sync.Mutex | |
| 244 | - | activeAudioStream port.AudioOutputStream | |
| 265 | + | // Audio output lifecycle. Packet callbacks load audioStream directly: its | |
| 266 | + | // immutable handle is atomically published and never requires a mutex. | |
| 267 | + | audioOutputFactory port.AudioOutputFactory | |
| 268 | + | audioStreamMu sync.Mutex | |
| 269 | + | audioStreamGeneration atomic.Uint64 | |
| 270 | + | audioStream atomic.Pointer[audioStreamHandle] | |
| 245 | 271 | ||
| 246 | 272 | // Audio instrumentation counters (diagnostic only). | |
| 247 | 273 | audioPacketCount atomic.Uint64 // total OnAudioStreamPacket calls | |
| 248 | 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 | 284 | // pendingBrowserCreate holds the parameters needed to create a CEF browser, | |
| 252 | 285 | // deferred until the GL area has a non-zero size. | |
| 253 | 286 | type pendingBrowserCreate struct { | |
| @@ -971,7 +1004,7 @@ func (wv *WebView) prepareNativePopup( | |||
| 971 | 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 | 1008 | clientSlot.Set(client) | |
| 976 | 1009 | return true | |
| 977 | 1010 | } | |
| @@ -986,9 +1019,8 @@ func (wv *WebView) nativePopupPreparationSnapshot() (nativePopupPreparation, boo | |||
| 986 | 1019 | return nativePopupPreparation{}, false | |
| 987 | 1020 | } | |
| 988 | 1021 | return nativePopupPreparation{ | |
| 989 | - | parent: wv.nativePopupParent, | |
| 990 | - | frameRate: wv.windowlessFrameRate, | |
| 991 | - | backgroundColor: wv.backgroundColor, | |
| 1022 | + | parent: wv.nativePopupParent, | |
| 1023 | + | frameRate: wv.windowlessFrameRate, | |
| 992 | 1024 | }, true | |
| 993 | 1025 | } | |
| 994 | 1026 | ||
| @@ -1024,7 +1056,6 @@ func configureNativePopupWindow( | |||
| 1024 | 1056 | windowInfo *purecef.WindowInfo, | |
| 1025 | 1057 | settings *purecef.BrowserSettings, | |
| 1026 | 1058 | frameRate int32, | |
| 1027 | - | backgroundColor uint32, | |
| 1028 | 1059 | ) { | |
| 1029 | 1060 | cef2gtk.ConfigureWindowInfo(windowInfo, cef2gtk.WindowInfoOptions{}) | |
| 1030 | 1061 | if externalBeginFrameEnabled() { | |
| @@ -1035,15 +1066,13 @@ func configureNativePopupWindow( | |||
| 1035 | 1066 | } | |
| 1036 | 1067 | cef2gtk.ConfigureBrowserSettings(settings, cef2gtk.BrowserSettingsOptions{WindowlessFrameRate: frameRate}) | |
| 1037 | 1068 | settings.LocalStorage = 1 | |
| 1038 | - | if backgroundColor != 0 { | |
| 1039 | - | settings.BackgroundColor = backgroundColor | |
| 1040 | - | } | |
| 1069 | + | // Always opaque white; the popup shell WebView carries the theme flash guard. | |
| 1070 | + | settings.BackgroundColor = opaqueWhiteBackground | |
| 1041 | 1071 | } | |
| 1042 | 1072 | ||
| 1043 | 1073 | type nativePopupPreparation struct { | |
| 1044 | - | parent *WebView | |
| 1045 | - | frameRate int32 | |
| 1046 | - | backgroundColor uint32 | |
| 1074 | + | parent *WebView | |
| 1075 | + | frameRate int32 | |
| 1047 | 1076 | } | |
| 1048 | 1077 | ||
| 1049 | 1078 | func (wv *WebView) handleNativePopupAborted() { | |
| @@ -1223,6 +1252,7 @@ func (wv *WebView) destroyViewBridgeOnGTKThread() { | |||
| 1223 | 1252 | if wv == nil || wv.viewBridge == nil { | |
| 1224 | 1253 | return | |
| 1225 | 1254 | } | |
| 1255 | + | wv.removeBackgroundFlashGuard() | |
| 1226 | 1256 | if wv.removeSizeObserver != nil { | |
| 1227 | 1257 | wv.removeSizeObserver() | |
| 1228 | 1258 | wv.removeSizeObserver = nil | |
| @@ -2067,12 +2097,14 @@ func (wv *WebView) startBeginFrameLoop() { | |||
| 2067 | 2097 | cb := new(gtk.TickCallback) | |
| 2068 | 2098 | *cb = func(_, _, _ uintptr) bool { | |
| 2069 | 2099 | if wv.destroyed.Load() { | |
| 2100 | + | wv.releaseBeginFrameTickCallback() | |
| 2070 | 2101 | return false | |
| 2071 | 2102 | } | |
| 2072 | 2103 | wv.mu.RLock() | |
| 2073 | 2104 | host := wv.host | |
| 2074 | 2105 | wv.mu.RUnlock() | |
| 2075 | 2106 | if host == nil { | |
| 2107 | + | wv.releaseBeginFrameTickCallback() | |
| 2076 | 2108 | return false | |
| 2077 | 2109 | } | |
| 2078 | 2110 | host.SendExternalBeginFrame() | |
| @@ -2095,19 +2127,34 @@ func (wv *WebView) startBeginFrameLoop() { | |||
| 2095 | 2127 | } | |
| 2096 | 2128 | ||
| 2097 | 2129 | func (wv *WebView) stopBeginFrameLoop() { | |
| 2098 | - | if wv.viewBridge == nil || wv.viewBridge.Widget() == nil { | |
| 2099 | - | return | |
| 2100 | - | } | |
| 2101 | - | ||
| 2102 | 2130 | wv.mu.Lock() | |
| 2103 | 2131 | tickID := wv.beginFrameTickID | |
| 2132 | + | callback := wv.beginFrameTick | |
| 2104 | 2133 | wv.beginFrameTickID = 0 | |
| 2105 | 2134 | wv.beginFrameTick = nil | |
| 2106 | - | widget := wv.viewBridge.Widget() | |
| 2135 | + | bridge := wv.viewBridge | |
| 2107 | 2136 | wv.mu.Unlock() | |
| 2108 | 2137 | ||
| 2109 | - | if tickID != 0 { | |
| 2110 | - | widget.RemoveTickCallback(tickID) | |
| 2138 | + | if tickID != 0 && bridge != nil { | |
| 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 | 2349 | return pc | |
| 2303 | 2350 | } | |
| 2304 | 2351 | ||
| 2305 | - | // closeAudioStream closes and clears the active audio output stream. | |
| 2306 | - | // This is safe to call even if no stream is active. | |
| 2307 | - | func (wv *WebView) closeAudioStream() { | |
| 2352 | + | // beginAudioStreamStart invalidates any prior stream before creating a new | |
| 2353 | + | // one. A stream created for the returned generation may only be published if | |
| 2354 | + | // that generation is still current. | |
| 2355 | + | func (wv *WebView) beginAudioStreamStart() (uint64, *audioStreamHandle, bool) { | |
| 2308 | 2356 | wv.audioStreamMu.Lock() | |
| 2309 | 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 { | |
| 2312 | - | if err := wv.activeAudioStream.Close(); err != nil { | |
| 2313 | - | if wv.ctx != nil { | |
| 2314 | - | logging.FromContext(wv.ctx).Debug(). | |
| 2315 | - | Err(err). | |
| 2316 | - | Msg("cef: error closing audio stream") | |
| 2317 | - | } | |
| 2367 | + | // publishAudioStream publishes stream only if no Stop, Destroy, or newer Start | |
| 2368 | + | // invalidated its creation generation. A rejected stream is owned by the | |
| 2369 | + | // caller and must be closed by it. | |
| 2370 | + | func (wv *WebView) publishAudioStream(generation uint64, stream port.AudioOutputStream) (*audioStreamHandle, bool) { | |
| 2371 | + | wv.audioStreamMu.Lock() | |
| 2372 | + | defer wv.audioStreamMu.Unlock() | |
| 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 | 4 | "bytes" | |
| 5 | 5 | "context" | |
| 6 | 6 | "errors" | |
| 7 | + | "sync/atomic" | |
| 7 | 8 | "testing" | |
| 8 | 9 | "time" | |
| 9 | 10 | ||
| @@ -83,10 +84,10 @@ func TestWebView_AudioStreamLifecycleFieldsExist(t *testing.T) { | |||
| 83 | 84 | } | |
| 84 | 85 | ||
| 85 | 86 | // Verify the fields exist and can be set | |
| 86 | - | wv.activeAudioStream = &mockAudioStream{} | |
| 87 | + | setAudioStream(wv, &mockAudioStream{}) | |
| 87 | 88 | ||
| 88 | 89 | assert.NotNil(t, wv.audioOutputFactory) | |
| 89 | - | assert.NotNil(t, wv.activeAudioStream) | |
| 90 | + | assert.NotNil(t, activeAudioStream(wv)) | |
| 90 | 91 | } | |
| 91 | 92 | ||
| 92 | 93 | // mockAudioStream is a test double for port.AudioOutputStream | |
| @@ -110,6 +111,18 @@ func (m *mockAudioStream) Close() error { | |||
| 110 | 111 | // Verify mock implements the interface | |
| 111 | 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 | 126 | // racyMockAudioStream blocks inside Write until doneCh is closed, allowing | |
| 114 | 127 | // tests to verify that the caller holds a lock across the Write call. | |
| 115 | 128 | type racyMockAudioStream struct { | |
| @@ -131,6 +144,26 @@ func (m *racyMockAudioStream) Close() error { | |||
| 131 | 144 | ||
| 132 | 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 | 168 | // Task 6: Audio Stream Lifecycle Handling Tests | |
| 136 | 169 | // ============================================================================ | |
| @@ -159,8 +192,8 @@ func TestHandlerSet_OnAudioStreamStarted_CreatesStream(t *testing.T) { | |||
| 159 | 192 | handlers.OnAudioStreamStarted(nil, params, 2) | |
| 160 | 193 | ||
| 161 | 194 | // Assert | |
| 162 | - | require.NotNil(t, wv.activeAudioStream) | |
| 163 | - | assert.Equal(t, mockStream, wv.activeAudioStream) | |
| 195 | + | require.NotNil(t, activeAudioStream(wv)) | |
| 196 | + | assert.Equal(t, mockStream, activeAudioStream(wv)) | |
| 164 | 197 | assert.True(t, wv.audioPlaying.Load()) | |
| 165 | 198 | ||
| 166 | 199 | // Verify factory was called with correct format | |
| @@ -205,8 +238,8 @@ func TestHandlerSet_OnAudioStreamStarted_ClosesExistingStream(t *testing.T) { | |||
| 205 | 238 | wv := &WebView{ | |
| 206 | 239 | ctx: ctx, | |
| 207 | 240 | audioOutputFactory: mockFactory, | |
| 208 | - | activeAudioStream: oldStream, | |
| 209 | 241 | } | |
| 242 | + | setAudioStream(wv, oldStream) | |
| 210 | 243 | ||
| 211 | 244 | handlers := &handlerSet{wv: wv} | |
| 212 | 245 | ||
| @@ -221,7 +254,7 @@ func TestHandlerSet_OnAudioStreamStarted_ClosesExistingStream(t *testing.T) { | |||
| 221 | 254 | ||
| 222 | 255 | // Assert | |
| 223 | 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 | 260 | // TestHandlerSet_OnAudioStreamStarted_FactoryError_HandlesGracefully verifies | |
| @@ -251,23 +284,23 @@ func TestHandlerSet_OnAudioStreamStarted_FactoryError_HandlesGracefully(t *testi | |||
| 251 | 284 | // audioPlaying must be false when NewStream fails — if it stays true, | |
| 252 | 285 | // the UI/state layer incorrectly believes audio is active. | |
| 253 | 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 | 291 | // Task 7: PCM Packet Forwarding Tests | |
| 259 | 292 | // ============================================================================ | |
| 260 | 293 | ||
| 261 | - | // TestHandlerSet_OnAudioStreamPacket_CopiesAndWrites verifies that | |
| 262 | - | // OnAudioStreamPacket copies samples before writing to avoid data races. | |
| 263 | - | func TestHandlerSet_OnAudioStreamPacket_CopiesAndWrites(t *testing.T) { | |
| 294 | + | // TestHandlerSet_OnAudioStreamPacket_ForwardsCallbackData verifies that CEF | |
| 295 | + | // does not allocate a staging copy; the audio output port owns that handoff. | |
| 296 | + | func TestHandlerSet_OnAudioStreamPacket_ForwardsCallbackData(t *testing.T) { | |
| 264 | 297 | ctx := context.Background() | |
| 265 | 298 | mockStream := &mockAudioStream{} | |
| 266 | 299 | ||
| 267 | 300 | wv := &WebView{ | |
| 268 | - | ctx: ctx, | |
| 269 | - | activeAudioStream: mockStream, | |
| 301 | + | ctx: ctx, | |
| 270 | 302 | } | |
| 303 | + | setAudioStream(wv, mockStream) | |
| 271 | 304 | ||
| 272 | 305 | handlers := &handlerSet{wv: wv} | |
| 273 | 306 | ||
| @@ -287,20 +320,16 @@ func TestHandlerSet_OnAudioStreamPacket_CopiesAndWrites(t *testing.T) { | |||
| 287 | 320 | require.Len(t, mockStream.writeSamples, 2) | |
| 288 | 321 | require.Len(t, mockStream.writeSamples[0], 512) | |
| 289 | 322 | ||
| 290 | - | // Verify data was copied (values match) | |
| 291 | 323 | assert.InDelta(t, 0.5, mockStream.writeSamples[0][0], 0.000001) | |
| 292 | 324 | assert.InDelta(t, -0.5, mockStream.writeSamples[1][0], 0.000001) | |
| 293 | - | ||
| 294 | - | // Verify it's a copy (different underlying array) | |
| 295 | - | assert.NotSame(t, &originalData[0][0], &mockStream.writeSamples[0][0]) | |
| 325 | + | assert.Same(t, &originalData[0][0], &mockStream.writeSamples[0][0]) | |
| 296 | 326 | } | |
| 297 | 327 | ||
| 298 | 328 | // TestHandlerSet_OnAudioStreamPacket_NoActiveStream_DoesNothing verifies | |
| 299 | 329 | // that packets are silently discarded when no stream is active. | |
| 300 | 330 | func TestHandlerSet_OnAudioStreamPacket_NoActiveStream_DoesNothing(t *testing.T) { | |
| 301 | 331 | wv := &WebView{ | |
| 302 | - | ctx: context.Background(), | |
| 303 | - | activeAudioStream: nil, | |
| 332 | + | ctx: context.Background(), | |
| 304 | 333 | } | |
| 305 | 334 | ||
| 306 | 335 | handlers := &handlerSet{wv: wv} | |
| @@ -319,10 +348,8 @@ func TestHandlerSet_OnAudioStreamPacket_NoActiveStream_DoesNothing(t *testing.T) | |||
| 319 | 348 | // TestHandlerSet_OnAudioStreamPacket_NilData_DoesNotPanic verifies | |
| 320 | 349 | // that nil data is handled gracefully. | |
| 321 | 350 | func TestHandlerSet_OnAudioStreamPacket_NilData_DoesNotPanic(t *testing.T) { | |
| 322 | - | mockStream := &mockAudioStream{} | |
| 323 | 351 | wv := &WebView{ | |
| 324 | - | ctx: context.Background(), | |
| 325 | - | activeAudioStream: mockStream, | |
| 352 | + | ctx: context.Background(), | |
| 326 | 353 | } | |
| 327 | 354 | ||
| 328 | 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 | 377 | // Task 8: Stream Cleanup Tests | |
| 338 | 378 | // ============================================================================ | |
| @@ -342,9 +382,9 @@ func TestHandlerSet_OnAudioStreamPacket_NilData_DoesNotPanic(t *testing.T) { | |||
| 342 | 382 | func TestHandlerSet_OnAudioStreamStopped_ClosesStream(t *testing.T) { | |
| 343 | 383 | mockStream := &mockAudioStream{} | |
| 344 | 384 | wv := &WebView{ | |
| 345 | - | ctx: context.Background(), | |
| 346 | - | activeAudioStream: mockStream, | |
| 385 | + | ctx: context.Background(), | |
| 347 | 386 | } | |
| 387 | + | setAudioStream(wv, mockStream) | |
| 348 | 388 | wv.audioPlaying.Store(true) | |
| 349 | 389 | ||
| 350 | 390 | handlers := &handlerSet{wv: wv} | |
| @@ -354,7 +394,7 @@ func TestHandlerSet_OnAudioStreamStopped_ClosesStream(t *testing.T) { | |||
| 354 | 394 | ||
| 355 | 395 | // Assert | |
| 356 | 396 | assert.True(t, mockStream.closeCalled) | |
| 357 | - | assert.Nil(t, wv.activeAudioStream) | |
| 397 | + | assert.Nil(t, activeAudioStream(wv)) | |
| 358 | 398 | assert.False(t, wv.audioPlaying.Load()) | |
| 359 | 399 | } | |
| 360 | 400 | ||
| @@ -362,8 +402,7 @@ func TestHandlerSet_OnAudioStreamStopped_ClosesStream(t *testing.T) { | |||
| 362 | 402 | // that stopping when no stream exists doesn't panic. | |
| 363 | 403 | func TestHandlerSet_OnAudioStreamStopped_NoActiveStream_DoesNotPanic(t *testing.T) { | |
| 364 | 404 | wv := &WebView{ | |
| 365 | - | ctx: context.Background(), | |
| 366 | - | activeAudioStream: nil, | |
| 405 | + | ctx: context.Background(), | |
| 367 | 406 | } | |
| 368 | 407 | wv.audioPlaying.Store(true) | |
| 369 | 408 | ||
| @@ -382,9 +421,9 @@ func TestHandlerSet_OnAudioStreamStopped_NoActiveStream_DoesNotPanic(t *testing. | |||
| 382 | 421 | func TestHandlerSet_OnAudioStreamError_ClosesStream(t *testing.T) { | |
| 383 | 422 | mockStream := &mockAudioStream{} | |
| 384 | 423 | wv := &WebView{ | |
| 385 | - | ctx: context.Background(), | |
| 386 | - | activeAudioStream: mockStream, | |
| 424 | + | ctx: context.Background(), | |
| 387 | 425 | } | |
| 426 | + | setAudioStream(wv, mockStream) | |
| 388 | 427 | wv.audioPlaying.Store(true) | |
| 389 | 428 | ||
| 390 | 429 | handlers := &handlerSet{wv: wv} | |
| @@ -394,7 +433,7 @@ func TestHandlerSet_OnAudioStreamError_ClosesStream(t *testing.T) { | |||
| 394 | 433 | ||
| 395 | 434 | // Assert | |
| 396 | 435 | assert.True(t, mockStream.closeCalled) | |
| 397 | - | assert.Nil(t, wv.activeAudioStream) | |
| 436 | + | assert.Nil(t, activeAudioStream(wv)) | |
| 398 | 437 | assert.False(t, wv.audioPlaying.Load()) | |
| 399 | 438 | } | |
| 400 | 439 | ||
| @@ -402,8 +441,7 @@ func TestHandlerSet_OnAudioStreamError_ClosesStream(t *testing.T) { | |||
| 402 | 441 | // that error handling when no stream exists doesn't panic. | |
| 403 | 442 | func TestHandlerSet_OnAudioStreamError_NoActiveStream_DoesNotPanic(t *testing.T) { | |
| 404 | 443 | wv := &WebView{ | |
| 405 | - | ctx: context.Background(), | |
| 406 | - | activeAudioStream: nil, | |
| 444 | + | ctx: context.Background(), | |
| 407 | 445 | } | |
| 408 | 446 | wv.audioPlaying.Store(true) | |
| 409 | 447 | ||
| @@ -456,9 +494,9 @@ func TestHandlerSet_OnAudioStreamError_ShutdownSocketCloseLogsDebug(t *testing.T | |||
| 456 | 494 | func TestWebView_Destroy_ClosesAudioStream(t *testing.T) { | |
| 457 | 495 | mockStream := &mockAudioStream{} | |
| 458 | 496 | wv := &WebView{ | |
| 459 | - | ctx: context.Background(), | |
| 460 | - | activeAudioStream: mockStream, | |
| 497 | + | ctx: context.Background(), | |
| 461 | 498 | } | |
| 499 | + | setAudioStream(wv, mockStream) | |
| 462 | 500 | wv.audioPlaying.Store(true) | |
| 463 | 501 | ||
| 464 | 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 | 505 | // Assert | |
| 468 | 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 | 510 | // TestWebView_Destroy_NoAudioStream_DoesNotPanic verifies that Destroy | |
| @@ -558,28 +596,81 @@ func TestOnAudioStreamStarted_CorrectParameterMapping(t *testing.T) { | |||
| 558 | 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 | 655 | // Race-window tests (write/close interleave) | |
| 563 | 656 | // ============================================================================ | |
| 564 | 657 | ||
| 565 | - | // TestOnAudioStreamPacket_WriteHoldsLock verifies that closeAudioStream cannot | |
| 566 | - | // interleave between the stream snapshot and Write. If the lock is NOT held | |
| 567 | - | // across Write, the goroutine calling closeAudioStream will Close the stream | |
| 568 | - | // while Write is in-flight, producing a write-after-close. | |
| 569 | - | func TestOnAudioStreamPacket_WriteHoldsLock(t *testing.T) { | |
| 658 | + | // TestOnAudioStreamPacket_CloseDoesNotWaitForWrite verifies that lifecycle | |
| 659 | + | // shutdown is not serialized behind a potentially slow callback write. The | |
| 660 | + | // port contract makes Close/Write concurrency safe. | |
| 661 | + | func TestOnAudioStreamPacket_CloseDoesNotWaitForWrite(t *testing.T) { | |
| 570 | 662 | ctx := context.Background() | |
| 571 | 663 | ||
| 572 | - | // racyStream blocks inside Write until we signal, giving closeAudioStream | |
| 573 | - | // a window to race if the mutex isn't held. | |
| 664 | + | // racyStream blocks inside Write until we signal. | |
| 574 | 665 | stream := &racyMockAudioStream{ | |
| 575 | 666 | writeCh: make(chan struct{}), | |
| 576 | 667 | doneCh: make(chan struct{}), | |
| 577 | 668 | } | |
| 578 | 669 | ||
| 579 | 670 | wv := &WebView{ | |
| 580 | - | ctx: ctx, | |
| 581 | - | activeAudioStream: stream, | |
| 671 | + | ctx: ctx, | |
| 582 | 672 | } | |
| 673 | + | setAudioStream(wv, stream) | |
| 583 | 674 | handlers := &handlerSet{wv: wv} | |
| 584 | 675 | ||
| 585 | 676 | data := [][]float32{{0.1, 0.2}, {0.3, 0.4}} | |
| @@ -590,32 +681,24 @@ func TestOnAudioStreamPacket_WriteHoldsLock(t *testing.T) { | |||
| 590 | 681 | // Wait for Write to be entered. | |
| 591 | 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 | 684 | closeDone := make(chan struct{}) | |
| 596 | 685 | go func() { | |
| 597 | 686 | wv.closeAudioStream() | |
| 598 | 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 | 690 | select { | |
| 605 | 691 | case <-closeDone: | |
| 606 | - | t.Fatal("closeAudioStream completed while Write was in-flight — lock not held across Write") | |
| 607 | - | default: | |
| 608 | - | // expected: closeAudioStream is blocked | |
| 692 | + | // expected: only the short stream snapshot takes the mutex | |
| 693 | + | case <-time.After(time.Second): | |
| 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 | 698 | close(stream.doneCh) | |
| 613 | 699 | ||
| 614 | - | // Now closeAudioStream should complete. | |
| 615 | - | <-closeDone | |
| 616 | - | ||
| 617 | 700 | assert.True(t, stream.closeCalled) | |
| 618 | - | assert.Nil(t, wv.activeAudioStream) | |
| 701 | + | assert.Nil(t, activeAudioStream(wv)) | |
| 619 | 702 | } | |
| 620 | 703 | ||
| 621 | 704 | // TestOnAudioStreamPacket_StreamClosedBetweenPackets verifies that when a | |
| @@ -626,9 +709,9 @@ func TestOnAudioStreamPacket_StreamClosedBetweenPackets(t *testing.T) { | |||
| 626 | 709 | stream := &mockAudioStream{} | |
| 627 | 710 | ||
| 628 | 711 | wv := &WebView{ | |
| 629 | - | ctx: ctx, | |
| 630 | - | activeAudioStream: stream, | |
| 712 | + | ctx: ctx, | |
| 631 | 713 | } | |
| 714 | + | setAudioStream(wv, stream) | |
| 632 | 715 | handlers := &handlerSet{wv: wv} | |
| 633 | 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 | |
| 823 | - | // painting/caret updates until explicitly told the browser is shown. | |
| 824 | - | host.WasHidden(0) | |
| 822 | + | // CEF OSR starts hidden. SyncViewport reads GTK's mapped, ancestor-aware | |
| 823 | + | // effective state on the main context and reports that truthful initial state. | |
| 825 | 824 | if h.wv != nil { | |
| 826 | 825 | if h.wv.ctx != nil { | |
| 827 | 826 | logging.FromContext(h.wv.ctx).Info(). | |
| @@ -1135,48 +1134,44 @@ func (h *handlerSet) OnAudioStreamStarted(_ purecef.Browser, params *purecef.Aud | |||
| 1135 | 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 | 1144 | h.wv.audioPacketCount.Store(0) | |
| 1140 | 1145 | h.wv.audioWriteCount.Store(0) | |
| 1141 | 1146 | ||
| 1142 | - | // Close any existing stream first | |
| 1143 | - | h.wv.closeAudioStream() | |
| 1144 | - | ||
| 1145 | - | // Create new stream | |
| 1146 | 1147 | ctx := h.wv.ctx | |
| 1147 | 1148 | if ctx == nil { | |
| 1148 | 1149 | ctx = context.Background() | |
| 1149 | 1150 | } | |
| 1150 | - | ||
| 1151 | 1151 | stream, err := h.wv.audioOutputFactory.NewStream(ctx, format) | |
| 1152 | 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 | 1153 | if h.wv.ctx != nil { | |
| 1156 | - | logging.FromContext(h.wv.ctx).Warn(). | |
| 1157 | - | Err(err). | |
| 1158 | - | Int("sample_rate", format.SampleRate). | |
| 1159 | - | Int("channels", format.ChannelCount). | |
| 1154 | + | logging.FromContext(h.wv.ctx).Warn().Err(err). | |
| 1155 | + | Int("sample_rate", format.SampleRate).Int("channels", format.ChannelCount). | |
| 1160 | 1156 | Msg("cef: failed to create audio output stream") | |
| 1161 | 1157 | } | |
| 1162 | 1158 | return | |
| 1163 | 1159 | } | |
| 1164 | 1160 | ||
| 1165 | - | // Set playing state only after stream creation succeeds | |
| 1166 | - | h.wv.setAudioPlaying(true) | |
| 1167 | - | ||
| 1161 | + | replaced, published := h.wv.publishAudioStream(generation, stream) | |
| 1162 | + | if !published { | |
| 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 | 1169 | if h.wv.ctx != nil { | |
| 1169 | 1170 | logging.FromContext(h.wv.ctx).Info(). | |
| 1170 | - | Int("sample_rate", format.SampleRate). | |
| 1171 | - | Int("channels", format.ChannelCount). | |
| 1171 | + | Int("sample_rate", format.SampleRate).Int("channels", format.ChannelCount). | |
| 1172 | 1172 | Int("frames_per_buffer", format.FramesPerBuffer). | |
| 1173 | 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 | 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. | |
| 1194 | - | // The mutex is held across both the stream snapshot and Write to prevent | |
| 1195 | - | // closeAudioStream from closing the stream mid-write. | |
| 1196 | - | func (h *handlerSet) OnAudioStreamPacket(_ purecef.Browser, data [][]float32, frames int32, pts int64) { | |
| 1188 | + | // OnAudioStreamPacket is a real-time callback: it atomically snapshots an | |
| 1189 | + | // immutable handle and performs only allocation-free atomic diagnostics. It | |
| 1190 | + | // never takes a lifecycle mutex or emits synchronous logs. | |
| 1191 | + | func (h *handlerSet) OnAudioStreamPacket(_ purecef.Browser, data [][]float32, frames int32, _ int64) { | |
| 1197 | 1192 | if len(data) == 0 || frames <= 0 { | |
| 1198 | 1193 | return | |
| 1199 | 1194 | } | |
| 1200 | - | ||
| 1201 | - | // Copy the data before acquiring the stream lock because CEF can reuse the | |
| 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() | |
| 1195 | + | handle := h.wv.audioStream.Load() | |
| 1196 | + | if handle == nil || handle.stream == nil { | |
| 1218 | 1197 | return | |
| 1219 | 1198 | } | |
| 1220 | - | ||
| 1221 | - | pktNum := h.wv.audioPacketCount.Add(1) | |
| 1222 | - | if pktNum == 1 && h.wv.ctx != nil { | |
| 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 | |
| 1199 | + | h.wv.audioPacketCount.Add(1) | |
| 1200 | + | if handle.stream.Write(data) == nil { | |
| 1201 | + | h.wv.audioWriteCount.Add(1) | |
| 1241 | 1202 | } | |
| 1242 | - | h.wv.audioWriteCount.Add(1) | |
| 1243 | 1203 | } | |
| 1244 | 1204 | ||
| 1245 | 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 | 380 | windowInfo := purecef.NewWindowInfo() | |
| 381 | 381 | settings := purecef.NewBrowserSettings() | |
| 382 | 382 | ||
| 383 | - | configureNativePopupWindow(&windowInfo, &settings, 72, 0xFF112233) | |
| 383 | + | configureNativePopupWindow(&windowInfo, &settings, 72) | |
| 384 | 384 | ||
| 385 | 385 | require.Equal(t, purecef.WindowHandle(0), windowInfo.ParentWindow) | |
| 386 | 386 | require.Equal(t, int32(1), windowInfo.WindowlessRenderingEnabled) | |
| 387 | 387 | require.Equal(t, int32(1), windowInfo.SharedTextureEnabled) | |
| 388 | 388 | require.Equal(t, int32(72), settings.WindowlessFrameRate) | |
| 389 | 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 | 393 | func TestOnLoadStartFiresCommittedAndUpdatesURI(t *testing.T) { | |
M internal/infrastructure/cef/webview_viewport.go +75 -2
| @@ -24,6 +24,38 @@ type pendingBrowserCreateObservedSizeBridge interface { | |||
| 24 | 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 | 59 | const ( | |
| 28 | 60 | // pendingBrowserCreateObservedSizeRetryDelay spaces retries while waiting | |
| 29 | 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 | 163 | widget := wv.viewBridge.Widget() | |
| 132 | - | visible := widget != nil && widget.IsVisible() | |
| 164 | + | visible := effectiveViewportWidgetVisibility(widget) | |
| 133 | 165 | wv.notifyViewportSyncOnCEFUIThread(host, visible) | |
| 134 | 166 | wv.syncZoomForBackingScaleOnCEFUIThread(host, reason) | |
| 135 | 167 | logging.FromContext(ctx).Debug(). | |
| @@ -437,12 +469,20 @@ func (wv *WebView) installViewportSyncHooksOnGTKThread() { | |||
| 437 | 469 | wv.viewportShowFunc = func(gtk.Widget) { | |
| 438 | 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 | 478 | wv.viewportRealizeFunc = func(gtk.Widget) { | |
| 441 | 479 | wv.SyncViewport(wv.ctx, "gtk-realize") | |
| 442 | 480 | } | |
| 443 | 481 | ||
| 444 | 482 | wv.viewportMapSignalID = widget.ConnectMap(&wv.viewportMapFunc) | |
| 445 | 483 | wv.viewportShowSignalID = widget.ConnectShow(&wv.viewportShowFunc) | |
| 484 | + | wv.viewportHideSignalID = widget.ConnectHide(&wv.viewportHideFunc) | |
| 485 | + | wv.viewportUnmapSignalID = widget.ConnectUnmap(&wv.viewportUnmapFunc) | |
| 446 | 486 | wv.viewportRealizeSignalID = widget.ConnectRealize(&wv.viewportRealizeFunc) | |
| 447 | 487 | } | |
| 448 | 488 | ||
| @@ -463,6 +503,14 @@ func (wv *WebView) disconnectViewportSyncHooksOnGTKThread() { | |||
| 463 | 503 | gobject.SignalHandlerDisconnect(obj, wv.viewportShowSignalID) | |
| 464 | 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 | 514 | if wv.viewportRealizeSignalID != 0 { | |
| 467 | 515 | gobject.SignalHandlerDisconnect(obj, wv.viewportRealizeSignalID) | |
| 468 | 516 | wv.viewportRealizeSignalID = 0 | |
| @@ -471,6 +519,8 @@ func (wv *WebView) disconnectViewportSyncHooksOnGTKThread() { | |||
| 471 | 519 | } | |
| 472 | 520 | wv.viewportMapFunc = nil | |
| 473 | 521 | wv.viewportShowFunc = nil | |
| 522 | + | wv.viewportHideFunc = nil | |
| 523 | + | wv.viewportUnmapFunc = nil | |
| 474 | 524 | wv.viewportRealizeFunc = nil | |
| 475 | 525 | } | |
| 476 | 526 | ||
| @@ -488,7 +538,8 @@ func (wv *WebView) notifyViewportSyncOnCEFUIThread(host viewportSyncBrowserHost, | |||
| 488 | 538 | if currentHost != host { | |
| 489 | 539 | return | |
| 490 | 540 | } | |
| 491 | - | notifyBrowserViewportSync(host, visible) | |
| 541 | + | wv.applyEffectiveVisibility(host, visible) | |
| 542 | + | notifyBrowserViewportResize(host) | |
| 492 | 543 | })) | |
| 493 | 544 | if task == nil { | |
| 494 | 545 | return | |
| @@ -526,6 +577,28 @@ func (wv *WebView) syncZoomForBackingScaleOnCEFUIThread(host purecef.BrowserHost | |||
| 526 | 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 | 602 | func notifyBrowserViewportSync(host viewportSyncBrowserHost, visible bool) { | |
| 530 | 603 | if host == nil { | |
| 531 | 604 | return | |
M internal/infrastructure/cef/webview_viewport_test.go +0 -1
| @@ -149,7 +149,6 @@ func TestNotifyViewportSyncOnCEFUIThread_RemainsViewportOnlyAcrossRepeatedResize | |||
| 149 | 149 | scheduled.Execute() | |
| 150 | 150 | ||
| 151 | 151 | require.Equal(t, []string{ | |
| 152 | - | "WasHidden", | |
| 153 | 152 | "NotifyScreenInfoChanged", | |
| 154 | 153 | "WasResized", | |
| 155 | 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 | 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 | 32 | _ = ctx | |
| 25 | 33 | if len(original) == 0 { | |
| 26 | 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 | 38 | if err != nil { | |
| 31 | 39 | return nil, err | |
| 32 | 40 | } | |
| @@ -48,7 +56,7 @@ func (*ImageConverter) Convert(ctx context.Context, original []byte, contentType | |||
| 48 | 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 | 60 | switch ct { | |
| 53 | 61 | case "image/svg+xml": | |
| 54 | 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 | 68 | return img, nil | |
| 61 | 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 | 77 | return nil, fmt.Errorf("%w: unsupported favicon content type %s", appport.ErrFaviconMiss, ct) | |
| 66 | 78 | } | |
| 67 | 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 | 41 | // --- Configure rendering environment (must be before GTK/WebKit init) --- | |
| 42 | 42 | engineConfigureRenderingEnvironment(ctx, cfg, wkCfg, &perfSettings, logger) | |
| 43 | - | logging.Trace().Mark("render_env") | |
| 44 | 43 | ||
| 45 | 44 | // --- Build webKitContextOptions from opts + wkCfg + perfSettings --- | |
| 46 | 45 | wkOpts := engineBuildContextOptions(opts, profile, wkCfg, &perfSettings) | |
| @@ -53,7 +52,6 @@ func NewEngine( | |||
| 53 | 52 | if err != nil { | |
| 54 | 53 | return nil, err | |
| 55 | 54 | } | |
| 56 | - | logging.Trace().Mark("webkit_context") | |
| 57 | 55 | ||
| 58 | 56 | // --- Filter manager --- | |
| 59 | 57 | filterManager := engineInitFilterManager(ctx, cfg, profile.Shared.DataDir, logger) | |
| @@ -77,7 +75,6 @@ func NewEngine( | |||
| 77 | 75 | } | |
| 78 | 76 | ||
| 79 | 77 | messageRouter := NewMessageRouter(ctx) | |
| 80 | - | logging.Trace().Mark("settings_manager") | |
| 81 | 78 | ||
| 82 | 79 | // --- WebView pool --- | |
| 83 | 80 | poolCfg := DefaultPoolConfig() | |
| @@ -100,7 +97,6 @@ func NewEngine( | |||
| 100 | 97 | } else { | |
| 101 | 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 | 101 | // --- WebView factory --- | |
| 106 | 102 | factory := NewWebViewFactory(wkCtx, settings, pool, injector, messageRouter) | |
| @@ -147,7 +143,6 @@ func engineSurveyHardwareAndResolveProfile( | |||
| 147 | 143 | ) config.ResolvedPerformanceSettings { | |
| 148 | 144 | hwSurveyor := env.NewHardwareSurveyor() | |
| 149 | 145 | hwInfo := hwSurveyor.Survey(ctx) | |
| 150 | - | logging.Trace().Mark("hardware_survey") | |
| 151 | 146 | logger.Info(). | |
| 152 | 147 | Int("cpu_cores", hwInfo.CPUCores). | |
| 153 | 148 | Int("cpu_threads", hwInfo.CPUThreads). | |
| @@ -158,7 +153,6 @@ func engineSurveyHardwareAndResolveProfile( | |||
| 158 | 153 | ||
| 159 | 154 | perfCfg := config.PerformanceConfigFromEngine(&cfg.Engine) | |
| 160 | 155 | perfSettings := config.ResolvePerformanceProfile(&perfCfg, &hwInfo) | |
| 161 | - | logging.Trace().Mark("performance_profile") | |
| 162 | 156 | logger.Info(). | |
| 163 | 157 | Str("profile", string(cfg.Engine.Profile)). | |
| 164 | 158 | Int("skia_cpu_threads", perfSettings.SkiaCPUPaintingThreads). | |
| @@ -284,7 +278,6 @@ func engineInitFilterManager(ctx context.Context, cfg *config.Config, dataDir st | |||
| 284 | 278 | if err := filterManager.Initialize(ctx); err != nil { | |
| 285 | 279 | logger.Warn().Err(err).Msg("failed to initialize filters, will load async") | |
| 286 | 280 | } | |
| 287 | - | logging.Trace().Mark("filter_manager") | |
| 288 | 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 | 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 | 65 | // EnsureAdwaitaInitialized initializes libadwaita and GTK exactly once. | |
| 63 | 66 | func EnsureAdwaitaInitialized() { | |
| @@ -167,9 +170,12 @@ type floatingWorkspaceSession struct { | |||
| 167 | 170 | omnibox *component.Omnibox | |
| 168 | 171 | omniboxWidget layout.Widget | |
| 169 | 172 | resizeWatcherActive bool | |
| 170 | - | resizeTickID uint | |
| 171 | - | appliedWidth int | |
| 172 | - | appliedHeight int | |
| 173 | + | // resizeTickCallback keeps the purego callback live until its GTK source is | |
| 174 | + | // removed. It is cleared exactly once with resizeTickID. | |
| 175 | + | resizeTickCallback *gtk.TickCallback | |
| 176 | + | resizeTickID uint | |
| 177 | + | appliedWidth int | |
| 178 | + | appliedHeight int | |
| 173 | 179 | } | |
| 174 | 180 | ||
| 175 | 181 | type floatingSessionKey struct { | |
| @@ -289,7 +295,6 @@ func (a *App) Run(ctx context.Context, args []string) int { | |||
| 289 | 295 | // Initialize libadwaita once (required before using StyleManager). | |
| 290 | 296 | // This also initializes GTK implicitly. | |
| 291 | 297 | EnsureAdwaitaInitialized() | |
| 292 | - | logging.Trace().Mark("gtk_init") | |
| 293 | 298 | ||
| 294 | 299 | // Mark adwaita detector as available now that adw.Init() is complete. | |
| 295 | 300 | // This enables the highest-priority color scheme detector. | |
| @@ -311,7 +316,6 @@ func (a *App) Run(ctx context.Context, args []string) int { | |||
| 311 | 316 | return 1 | |
| 312 | 317 | } | |
| 313 | 318 | defer a.gtkApp.Unref() | |
| 314 | - | logging.Trace().Mark("gtk_app_created") | |
| 315 | 319 | a.dispatchOnMainThread = a.runOnMainThread | |
| 316 | 320 | ||
| 317 | 321 | // Connect activate signal | |
| @@ -347,7 +351,6 @@ func (a *App) onActivate(ctx context.Context) { | |||
| 347 | 351 | log.Error().Err(err).Msg("failed to create main window") | |
| 348 | 352 | return | |
| 349 | 353 | } | |
| 350 | - | logging.Trace().Mark("window_created") | |
| 351 | 354 | ||
| 352 | 355 | a.installCrashReportNotifier(ctx) | |
| 353 | 356 | a.initFocusManager() | |
| @@ -356,7 +359,6 @@ func (a *App) onActivate(ctx context.Context) { | |||
| 356 | 359 | ||
| 357 | 360 | a.initCoordinators(ctx) | |
| 358 | 361 | a.wireWebRTCPermissionIndicator() | |
| 359 | - | logging.Trace().Mark("coordinators_init") | |
| 360 | 362 | a.initKeyboardHandler(ctx) | |
| 361 | 363 | a.initOmniboxConfig(ctx) | |
| 362 | 364 | a.initFindBarConfig(ctx) | |
| @@ -4594,22 +4596,22 @@ func (a *App) startFloatingResizeWatcher(session *floatingWorkspaceSession) { | |||
| 4594 | 4596 | ||
| 4595 | 4597 | session.resizeWatcherActive = true | |
| 4596 | 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 | 4601 | liveSession := a.floatingSessionByPaneID(paneID) | |
| 4599 | 4602 | if liveSession == nil { | |
| 4600 | - | session.resizeWatcherActive = false | |
| 4601 | - | session.resizeTickID = 0 | |
| 4603 | + | session.releaseResizeTickCallback() | |
| 4602 | 4604 | return false | |
| 4603 | 4605 | } | |
| 4604 | 4606 | ||
| 4605 | 4607 | keepRunning := a.handleFloatingViewportTick(liveSession) | |
| 4606 | 4608 | if !keepRunning { | |
| 4607 | - | liveSession.resizeWatcherActive = false | |
| 4608 | - | liveSession.resizeTickID = 0 | |
| 4609 | + | liveSession.releaseResizeTickCallback() | |
| 4609 | 4610 | } | |
| 4610 | 4611 | return keepRunning | |
| 4611 | - | }) | |
| 4612 | - | session.resizeTickID = overlayWidget.AddTickCallback(&tickCallback, 0, nil) | |
| 4612 | + | } | |
| 4613 | + | session.resizeTickCallback = callback | |
| 4614 | + | session.resizeTickID = overlayWidget.AddTickCallback(callback, 0, nil) | |
| 4613 | 4615 | } | |
| 4614 | 4616 | ||
| 4615 | 4617 | func (a *App) stopFloatingResizeWatcher(session *floatingWorkspaceSession) { | |
| @@ -4617,13 +4619,30 @@ func (a *App) stopFloatingResizeWatcher(session *floatingWorkspaceSession) { | |||
| 4617 | 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 | 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 | 4638 | session.resizeTickID = 0 | |
| 4639 | + | session.resizeTickCallback = nil | |
| 4626 | 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 | 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 | 55 | displayRows []favoriteSidebarDisplayRow | |
| 56 | 56 | currentQuery string | |
| 57 | 57 | notice string | |
| 58 | + | noticeLoadGen uint64 | |
| 58 | 59 | loadGen uint64 | |
| 59 | 60 | destroyed bool | |
| 60 | 61 | visible bool | |
| @@ -263,6 +264,7 @@ func (fs *FavoritesSidebar) startLoad() { | |||
| 263 | 264 | ctx := fs.ctx | |
| 264 | 265 | visible := fs.visible | |
| 265 | 266 | fs.notice = "Loading favorites..." | |
| 267 | + | fs.noticeLoadGen = gen | |
| 266 | 268 | fs.mu.Unlock() | |
| 267 | 269 | if visible { | |
| 268 | 270 | fs.scheduleIdle(glib.SourceFunc(func(uintptr) bool { | |
| @@ -311,10 +313,13 @@ func (fs *FavoritesSidebar) applyLoadedData(favorites []*entity.Favorite, tags [ | |||
| 311 | 313 | } | |
| 312 | 314 | fs.allFavorites = favorites | |
| 313 | 315 | fs.allTags = tags | |
| 314 | - | if err != nil { | |
| 315 | - | fs.notice = err.Error() | |
| 316 | - | } else { | |
| 317 | - | fs.notice = "" | |
| 316 | + | if fs.noticeLoadGen == gen { | |
| 317 | + | fs.noticeLoadGen = 0 | |
| 318 | + | if err != nil { | |
| 319 | + | fs.notice = err.Error() | |
| 320 | + | } else { | |
| 321 | + | fs.notice = "" | |
| 322 | + | } | |
| 318 | 323 | } | |
| 319 | 324 | fs.rebuildDisplayRowsLocked() | |
| 320 | 325 | fs.mu.Unlock() | |
| @@ -363,6 +368,12 @@ func (fs *FavoritesSidebar) rebuildDisplayRowsLocked() { | |||
| 363 | 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 | 377 | func (fs *FavoritesSidebar) scheduleIdle(cb glib.SourceFunc) { | |
| 367 | 378 | if fs != nil && fs.idleScheduler != nil { | |
| 368 | 379 | fs.idleScheduler(cb) | |
M internal/ui/component/favorites_sidebar_forms.go +6 -6
| @@ -42,7 +42,7 @@ func (fs *FavoritesSidebar) beginAddForm() { | |||
| 42 | 42 | fs.editingID = 0 | |
| 43 | 43 | fs.confirmDelete = false | |
| 44 | 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 | 46 | fs.mu.Unlock() | |
| 47 | 47 | fs.renderForm(nil) | |
| 48 | 48 | fs.rebuildList() | |
| @@ -63,7 +63,7 @@ func (fs *FavoritesSidebar) beginEditForm() { | |||
| 63 | 63 | fs.editingID = fav.ID | |
| 64 | 64 | fs.confirmDelete = false | |
| 65 | 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 | 67 | fs.mu.Unlock() | |
| 68 | 68 | fs.renderForm(fav) | |
| 69 | 69 | fs.rebuildList() | |
| @@ -170,7 +170,7 @@ func (fs *FavoritesSidebar) cancelManagement() bool { | |||
| 170 | 170 | fs.confirmDelete = false | |
| 171 | 171 | fs.confirmDeleteID = 0 | |
| 172 | 172 | if active { | |
| 173 | - | fs.notice = "" | |
| 173 | + | fs.setNoticeLocked("") | |
| 174 | 174 | } | |
| 175 | 175 | fs.mu.Unlock() | |
| 176 | 176 | if fs.formBox != nil { | |
| @@ -250,7 +250,7 @@ func (fs *FavoritesSidebar) setModeNotice(mode favoritesSidebarMode, notice stri | |||
| 250 | 250 | fs.mode = mode | |
| 251 | 251 | fs.confirmDelete = false | |
| 252 | 252 | fs.confirmDeleteID = 0 | |
| 253 | - | fs.notice = notice | |
| 253 | + | fs.setNoticeLocked(notice) | |
| 254 | 254 | } | |
| 255 | 255 | fs.mu.Unlock() | |
| 256 | 256 | fs.rebuildList() | |
| @@ -324,7 +324,7 @@ func (fs *FavoritesSidebar) requestDeleteConfirmation() bool { | |||
| 324 | 324 | if !fs.destroyed { | |
| 325 | 325 | fs.confirmDelete = true | |
| 326 | 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 | 329 | fs.mu.Unlock() | |
| 330 | 330 | fs.rebuildList() | |
| @@ -396,7 +396,7 @@ func (fs *FavoritesSidebar) selectedFavorite() *entity.Favorite { | |||
| 396 | 396 | func (fs *FavoritesSidebar) setNotice(notice string) { | |
| 397 | 397 | fs.mu.Lock() | |
| 398 | 398 | if !fs.destroyed { | |
| 399 | - | fs.notice = notice | |
| 399 | + | fs.setNoticeLocked(notice) | |
| 400 | 400 | } | |
| 401 | 401 | fs.mu.Unlock() | |
| 402 | 402 | fs.rebuildList() | |
M internal/ui/component/favorites_sidebar_keyboard.go +1 -1
| @@ -463,7 +463,7 @@ func (fs *FavoritesSidebar) handleNavigationError(err error) { | |||
| 463 | 463 | fs.mu.Unlock() | |
| 464 | 464 | return | |
| 465 | 465 | } | |
| 466 | - | fs.notice = err.Error() | |
| 466 | + | fs.setNoticeLocked(err.Error()) | |
| 467 | 467 | fs.mu.Unlock() | |
| 468 | 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 | 486 | uc := &fakeFavoritesSidebarUC{errOnSetShortcut: errors.New("shortcut failed")} | |
| 487 | 487 | fs := newFavoritesSidebarHarness(nil, nil) | |
| 488 | 488 | fs.favoritesUC = uc | |
| 489 | + | loaded := make(chan glib.SourceFunc, 1) | |
| 490 | + | fs.idleScheduler = func(cb glib.SourceFunc) { loaded <- cb } | |
| 489 | 491 | fs.formURL = "https://add.test" | |
| 490 | 492 | fs.formTitle = "Added" | |
| 491 | 493 | fs.formShortcut = "3" | |
| @@ -493,6 +495,12 @@ func TestFavoritesSidebarAddShortcutFailureClosesFormAfterCreate(t *testing.T) { | |||
| 493 | 495 | ||
| 494 | 496 | assert.True(t, fs.submitForm()) | |
| 495 | 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 | 504 | assert.Equal(t, favoritesSidebarModeNone, fs.mode) | |
| 497 | 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 | 1 | package component | |
| 2 | 2 | ||
| 3 | - | import ( | |
| 4 | - | "os" | |
| 5 | - | "testing" | |
| 6 | - | ) | |
| 3 | + | import "testing" | |
| 7 | 4 | ||
| 8 | 5 | func TestOmniboxCallbackPressure_NoCrashSignal(t *testing.T) { | |
| 9 | - | if os.Getenv("OMNIBOX_STRESS") != "1" { | |
| 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 | |
| 6 | + | runGTKCallbackLifecycleStress(t) | |
| 13 | 7 | } | |
M internal/ui/component/pane_view.go +59 -39
| @@ -135,58 +135,73 @@ func (pv *PaneView) WebViewWidget() layout.Widget { | |||
| 135 | 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 | 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 | 149 | pv.mu.Lock() | |
| 141 | 150 | defer pv.mu.Unlock() | |
| 142 | 151 | ||
| 143 | - | // Remove old widget from this overlay | |
| 152 | + | // Remove old widget from this overlay. | |
| 144 | 153 | if pv.webViewWidget != nil { | |
| 145 | 154 | pv.overlay.SetChild(nil) | |
| 146 | 155 | } | |
| 147 | 156 | ||
| 148 | 157 | pv.webViewWidget = widget | |
| 158 | + | if widget == nil { | |
| 159 | + | return | |
| 160 | + | } | |
| 149 | 161 | ||
| 150 | - | if widget != nil { | |
| 151 | - | overlayAllocWidthBefore := pv.overlay.GetAllocatedWidth() | |
| 152 | - | overlayAllocHeightBefore := pv.overlay.GetAllocatedHeight() | |
| 153 | - | widgetAllocWidthBefore := widget.GetAllocatedWidth() | |
| 154 | - | widgetAllocHeightBefore := widget.GetAllocatedHeight() | |
| 155 | - | hadParent := widget.GetParent() != nil | |
| 156 | - | wasVisible := widget.IsVisible() | |
| 157 | - | ||
| 158 | - | // If this widget already had a parent, we're reparenting an existing WebView | |
| 159 | - | // (e.g. after rebuilding/moving panes). In that case the WebView has likely | |
| 160 | - | // already painted, so the loading skeleton should be hidden immediately. | |
| 161 | - | ||
| 162 | - | // Unparent widget from any previous parent (critical for rebuild scenarios) | |
| 163 | - | // In GTK4, a widget can only have one parent at a time | |
| 164 | - | if hadParent { | |
| 165 | - | widget.Unparent() | |
| 166 | - | } | |
| 167 | - | pv.overlay.SetChild(widget) | |
| 162 | + | overlayAllocWidthBefore := pv.overlay.GetAllocatedWidth() | |
| 163 | + | overlayAllocHeightBefore := pv.overlay.GetAllocatedHeight() | |
| 164 | + | widgetAllocWidthBefore := widget.GetAllocatedWidth() | |
| 165 | + | widgetAllocHeightBefore := widget.GetAllocatedHeight() | |
| 166 | + | hadParent := widget.GetParent() != nil | |
| 167 | + | wasVisible := widget.IsVisible() | |
| 168 | + | ||
| 169 | + | // GTK4 widgets may have only one parent. Cleanup during a WorkspaceView | |
| 170 | + | // rebuild leaves a reusable native widget unparented, which says nothing | |
| 171 | + | // about whether its content was previously revealed. | |
| 172 | + | if hadParent { | |
| 173 | + | widget.Unparent() | |
| 174 | + | } | |
| 175 | + | pv.overlay.SetChild(widget) | |
| 168 | 176 | ||
| 169 | - | if hadParent && wasVisible { | |
| 170 | - | widget.SetVisible(true) | |
| 171 | - | } | |
| 172 | - | if hadParent && pv.loading != nil { | |
| 173 | - | pv.loading.SetVisible(false) | |
| 174 | - | } | |
| 177 | + | if hadParent && wasVisible { | |
| 178 | + | widget.SetVisible(true) | |
| 179 | + | } | |
| 180 | + | // A replacement can follow an already-revealed WebView in the same pane. | |
| 181 | + | // Apply both states explicitly: an unrevealed current WebView must restart | |
| 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(). | |
| 177 | - | Str("pane_id", string(pv.paneID)). | |
| 178 | - | Bool("had_parent", hadParent). | |
| 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") | |
| 187 | + | ctx := pv.ctx | |
| 188 | + | if ctx == nil { | |
| 189 | + | ctx = context.Background() | |
| 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 | 207 | // GrabFocus attempts to focus the WebView. | |
| @@ -516,6 +531,7 @@ func (pv *PaneView) Cleanup() { | |||
| 516 | 531 | pv.onFocusIn = nil | |
| 517 | 532 | pv.onFocusOut = nil | |
| 518 | 533 | pv.onHover = nil | |
| 534 | + | pv.onMouseMotion = nil | |
| 519 | 535 | ||
| 520 | 536 | // Detach hover handler if present | |
| 521 | 537 | if pv.hoverHandler != nil { | |
| @@ -530,6 +546,10 @@ func (pv *PaneView) Cleanup() { | |||
| 530 | 546 | } | |
| 531 | 547 | ||
| 532 | 548 | // Clear other overlay children | |
| 549 | + | if pv.loading != nil { | |
| 550 | + | pv.overlay.RemoveOverlay(pv.loading.Widget()) | |
| 551 | + | pv.loading = nil | |
| 552 | + | } | |
| 533 | 553 | if pv.progressBar != nil { | |
| 534 | 554 | pv.overlay.RemoveOverlay(pv.progressBar.Widget()) | |
| 535 | 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 | 3 | import ( | |
| 4 | 4 | "testing" | |
| 5 | 5 | ||
| 6 | + | "github.com/stretchr/testify/assert" | |
| 6 | 7 | "github.com/stretchr/testify/mock" | |
| 7 | 8 | ||
| 8 | 9 | "github.com/bnema/dumber/internal/ui/layout" | |
| @@ -80,3 +81,37 @@ func TestPaneView_HideLoadingSkeleton_NoOpWhenNil(t *testing.T) { | |||
| 80 | 81 | pv := &PaneView{} | |
| 81 | 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 | 192 | wv.mu.Lock() | |
| 193 | 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 | 203 | // Clear previous state | |
| 196 | 204 | wv.workspace = ws | |
| 197 | 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 | 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 | 495 | wv.mu.RLock() | |
| 480 | 496 | pv, ok := wv.paneViews[paneID] | |
| 481 | 497 | callback := wv.onWebViewAttached | |
| @@ -485,7 +501,7 @@ func (wv *WorkspaceView) SetWebViewWidget(paneID entity.PaneID, widget layout.Wi | |||
| 485 | 501 | return ErrPaneNotFound | |
| 486 | 502 | } | |
| 487 | 503 | ||
| 488 | - | pv.SetWebViewWidget(widget) | |
| 504 | + | pv.AttachWebViewWidget(widget, revealed) | |
| 489 | 505 | if widget != nil && callback != nil { | |
| 490 | 506 | callback(paneID) | |
| 491 | 507 | } | |
M internal/ui/coordinator/content/callbacks.go +9 -4
| @@ -62,8 +62,13 @@ func buildCrashPageURI(originalURI string) string { | |||
| 62 | 62 | // setupWebViewCallbacks configures standard callbacks and popup handling. | |
| 63 | 63 | func (c *Coordinator) setupWebViewCallbacks(ctx context.Context, paneID entity.PaneID, wv port.WebView) { | |
| 64 | 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 | 72 | callbacks := &port.WebViewCallbacks{ | |
| 68 | 73 | OnTitleChanged: func(title string) { | |
| 69 | 74 | c.onTitleChanged(ctx, paneID, title) | |
| @@ -73,13 +78,13 @@ func (c *Coordinator) setupWebViewCallbacks(ctx context.Context, paneID entity.P | |||
| 73 | 78 | case port.LoadStarted: | |
| 74 | 79 | c.onLoadStarted(paneID) | |
| 75 | 80 | case port.LoadCommitted: | |
| 76 | - | c.onLoadCommitted(ctx, paneID, wv) | |
| 81 | + | c.onLoadCommitted(ctx, paneID, wv, identity) | |
| 77 | 82 | case port.LoadFinished: | |
| 78 | - | c.onLoadFinished(ctx, paneID, wv) | |
| 83 | + | c.onLoadFinished(ctx, paneID, wv, identity) | |
| 79 | 84 | } | |
| 80 | 85 | }, | |
| 81 | 86 | OnProgressChanged: func(progress float64) { | |
| 82 | - | c.onProgressChanged(paneID, progress) | |
| 87 | + | c.onProgressChanged(paneID, wv, identity, progress) | |
| 83 | 88 | }, | |
| 84 | 89 | OnURIChanged: func(uri string) { | |
| 85 | 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 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | - | "sync" | |
| 6 | 5 | "testing" | |
| 7 | 6 | ||
| 8 | 7 | "github.com/stretchr/testify/assert" | |
| 8 | + | "github.com/stretchr/testify/mock" | |
| 9 | 9 | ||
| 10 | 10 | "github.com/bnema/dumber/internal/application/port" | |
| 11 | 11 | "github.com/bnema/dumber/internal/application/port/mocks" | |
| @@ -13,201 +13,200 @@ import ( | |||
| 13 | 13 | "github.com/bnema/dumber/internal/ui/component" | |
| 14 | 14 | ) | |
| 15 | 15 | ||
| 16 | - | // TestPendingReveal tests the pending reveal state management. | |
| 17 | - | // These are internal package tests that verify the state transitions | |
| 18 | - | // without needing to mock WebKit dependencies. | |
| 19 | - | ||
| 20 | - | func TestMarkPendingReveal_SetsState(t *testing.T) { | |
| 21 | - | c := &Coordinator{ | |
| 22 | - | pendingReveal: make(map[entity.PaneID]bool), | |
| 16 | + | func newRevealTestCoordinator() *Coordinator { | |
| 17 | + | return &Coordinator{ | |
| 18 | + | webViews: make(map[entity.PaneID]port.WebView), | |
| 19 | + | webViewPaneIDs: make(map[port.WebViewID]entity.PaneID), | |
| 20 | + | paneTitles: make(map[entity.PaneID]string), | |
| 21 | + | navOrigins: make(map[entity.PaneID]string), | |
| 23 | 22 | } | |
| 24 | - | paneID := entity.PaneID("pane-1") | |
| 25 | - | ||
| 26 | - | c.markPendingReveal(paneID) | |
| 23 | + | } | |
| 27 | 24 | ||
| 28 | - | c.revealMu.Lock() | |
| 29 | - | defer c.revealMu.Unlock() | |
| 30 | - | assert.True(t, c.pendingReveal[paneID]) | |
| 25 | + | func revealTestWebView(t *testing.T, id port.WebViewID, generation uint64) *mocks.MockWebView { | |
| 26 | + | t.Helper() | |
| 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) { | |
| 34 | - | c := &Coordinator{ | |
| 35 | - | pendingReveal: make(map[entity.PaneID]bool), | |
| 36 | - | } | |
| 33 | + | func TestRevealState_IsBoundToCurrentWebViewAcrossReplacementPaths(t *testing.T) { | |
| 37 | 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 | |
| 40 | - | c.markPendingReveal(paneID) | |
| 57 | + | func TestRevealState_StoresIdentityAsTheOnlyStateValue(t *testing.T) { | |
| 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 | |
| 43 | - | c.clearPendingReveal(paneID) | |
| 64 | + | c.markPendingReveal(paneID, identity) | |
| 65 | + | c.markWebViewRevealed(paneID, identity) | |
| 44 | 66 | ||
| 45 | 67 | c.revealMu.Lock() | |
| 46 | 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) { | |
| 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 | - | } | |
| 73 | + | func TestRevealState_ReleaseClearsStateWithoutWebViewMapping(t *testing.T) { | |
| 59 | 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 | |
| 62 | - | c.revealIfPending(context.Background(), paneID, "http://example.com", "test") | |
| 63 | - | ||
| 64 | - | assert.False(t, callbackCalled, "callback should not be called when not pending") | |
| 88 | + | c.revealMu.Lock() | |
| 89 | + | _, retained := c.revealedWebViews[paneID] | |
| 90 | + | c.revealMu.Unlock() | |
| 91 | + | assert.False(t, retained) | |
| 65 | 92 | } | |
| 66 | 93 | ||
| 67 | - | func TestRevealIfPending_ClearsStateOnReveal(t *testing.T) { | |
| 68 | - | c := &Coordinator{ | |
| 69 | - | pendingReveal: make(map[entity.PaneID]bool), | |
| 70 | - | webViews: make(map[entity.PaneID]port.WebView), | |
| 71 | - | } | |
| 94 | + | func TestRevealState_ReleaseMappedWebViewClearsRevealStateAndReturnsToPool(t *testing.T) { | |
| 72 | 95 | paneID := entity.PaneID("pane-1") | |
| 73 | - | ||
| 74 | - | // Mark as pending | |
| 75 | - | c.markPendingReveal(paneID) | |
| 76 | - | ||
| 77 | - | // Reveal (will return early since webview is nil, but should still clear state) | |
| 78 | - | c.revealIfPending(context.Background(), paneID, "http://example.com", "test") | |
| 79 | - | ||
| 80 | - | c.revealMu.Lock() | |
| 81 | - | defer c.revealMu.Unlock() | |
| 82 | - | assert.False(t, c.pendingReveal[paneID], "pending state should be cleared after reveal attempt") | |
| 96 | + | wv := revealTestWebView(t, 101, 1) | |
| 97 | + | pool := mocks.NewMockWebViewPool(t) | |
| 98 | + | pool.EXPECT().Release(wv).Once() | |
| 99 | + | c := newRevealTestCoordinator() | |
| 100 | + | c.pool = pool | |
| 101 | + | c.setWebViewLocked(paneID, wv) | |
| 102 | + | identity, _ := identityForWebView(wv) | |
| 103 | + | c.markWebViewRevealed(paneID, identity) | |
| 104 | + | ||
| 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) { | |
| 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 | - | } | |
| 111 | + | func TestRevealState_StaleCallbackCannotRevealReplacement(t *testing.T) { | |
| 94 | 112 | paneID := entity.PaneID("pane-1") | |
| 95 | - | ||
| 96 | - | // Mark as pending | |
| 97 | - | c.markPendingReveal(paneID) | |
| 98 | - | ||
| 99 | - | // Call reveal multiple times | |
| 100 | - | c.revealIfPending(context.Background(), paneID, "", "first") | |
| 101 | - | c.revealIfPending(context.Background(), paneID, "", "second") | |
| 102 | - | c.revealIfPending(context.Background(), paneID, "", "third") | |
| 103 | - | ||
| 104 | - | // Callback should not be called since webview is nil, but state should be cleared | |
| 105 | - | assert.Equal(t, 0, revealCount, "callback not called when webview is nil") | |
| 106 | - | ||
| 107 | - | // Verify state is cleared | |
| 108 | - | c.revealMu.Lock() | |
| 109 | - | defer c.revealMu.Unlock() | |
| 110 | - | assert.False(t, c.pendingReveal[paneID]) | |
| 113 | + | oldWV := revealTestWebView(t, 101, 1) | |
| 114 | + | newWV := revealTestWebView(t, 102, 1) | |
| 115 | + | oldWV.EXPECT().IsDestroyed().Return(false).Maybe() | |
| 116 | + | c := newRevealTestCoordinator() | |
| 117 | + | c.setWebViewLocked(paneID, oldWV) | |
| 118 | + | oldIdentity, _ := identityForWebView(oldWV) | |
| 119 | + | c.markPendingReveal(paneID, oldIdentity) | |
| 120 | + | c.setWebViewLocked(paneID, newWV) | |
| 121 | + | ||
| 122 | + | c.revealIfPending(context.Background(), paneID, oldWV, oldIdentity, "", "stale") | |
| 123 | + | assert.False(t, c.WebViewRevealed(paneID)) | |
| 111 | 124 | } | |
| 112 | 125 | ||
| 113 | - | func TestOnLoadCommitted_RevealsPendingWebViewForCommittedPage(t *testing.T) { | |
| 126 | + | func TestRevealState_RevealedCurrentWebViewPersistsForWorkspaceRebuild(t *testing.T) { | |
| 114 | 127 | paneID := entity.PaneID("pane-1") | |
| 115 | - | shownCount := 0 | |
| 116 | - | var shownPane entity.PaneID | |
| 128 | + | wv := revealTestWebView(t, 101, 1) | |
| 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 | 144 | wv := mocks.NewMockWebView(t) | |
| 119 | - | wv.EXPECT().URI().Return("https://example.com") | |
| 120 | - | wv.EXPECT().ResetBackgroundToDefault() | |
| 121 | - | wv.EXPECT().Title().Return("") | |
| 145 | + | generation := uint64(1) | |
| 146 | + | wv.EXPECT().ID().Return(port.WebViewID(101)).Maybe() | |
| 147 | + | wv.EXPECT().Generation().RunAndReturn(func() uint64 { return generation }).Maybe() | |
| 122 | 148 | wv.EXPECT().IsDestroyed().Return(false).Maybe() | |
| 123 | - | ||
| 124 | - | c := &Coordinator{ | |
| 125 | - | pendingReveal: make(map[entity.PaneID]bool), | |
| 126 | - | webViews: map[entity.PaneID]port.WebView{ | |
| 127 | - | paneID: wv, | |
| 128 | - | }, | |
| 129 | - | paneTitles: make(map[entity.PaneID]string), | |
| 130 | - | navOrigins: make(map[entity.PaneID]string), | |
| 131 | - | getActiveWS: func() (*entity.Workspace, *component.WorkspaceView) { return nil, nil }, | |
| 132 | - | onWebViewShown: func(id entity.PaneID) { | |
| 133 | - | shownCount++ | |
| 134 | - | shownPane = id | |
| 135 | - | }, | |
| 136 | - | } | |
| 137 | - | c.pendingReveal[paneID] = true | |
| 138 | - | ||
| 139 | - | c.onLoadCommitted(context.Background(), paneID, wv) | |
| 140 | - | ||
| 141 | - | assert.Equal(t, 1, shownCount) | |
| 142 | - | assert.Equal(t, paneID, shownPane) | |
| 143 | - | } | |
| 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 | |
| 149 | + | var callbacks *port.WebViewCallbacks | |
| 150 | + | wv.EXPECT().SetCallbacks(mock.Anything).RunAndReturn(func(value *port.WebViewCallbacks) { | |
| 151 | + | callbacks = value | |
| 152 | + | }).Once() | |
| 153 | + | ||
| 154 | + | c := newRevealTestCoordinator() | |
| 155 | + | c.getActiveWS = func() (*entity.Workspace, *component.WorkspaceView) { return nil, nil } | |
| 156 | + | c.setWebViewLocked(paneID, wv) | |
| 157 | + | installedIdentity, _ := identityForWebView(wv) | |
| 158 | + | c.markPendingReveal(paneID, installedIdentity) | |
| 159 | + | c.setupWebViewCallbacks(context.Background(), paneID, wv) | |
| 160 | + | revealed := false | |
| 161 | + | c.setWebViewVisible = func(port.WebView) { revealed = true } | |
| 162 | + | ||
| 163 | + | // The callback was installed at generation 1. A pool reuse can advance the | |
| 164 | + | // same Go WebView object before its old callback is delivered. | |
| 165 | + | generation = 2 | |
| 166 | + | callbacks.OnProgressChanged(1) | |
| 167 | + | ||
| 168 | + | assert.False(t, revealed, "the generation-1 callback must not reveal generation 2") | |
| 169 | + | assert.False(t, c.WebViewRevealed(paneID)) | |
| 178 | 170 | } | |
| 179 | 171 | ||
| 180 | - | func TestMarkPendingReveal_MultiplePanes(t *testing.T) { | |
| 181 | - | c := &Coordinator{ | |
| 182 | - | pendingReveal: make(map[entity.PaneID]bool), | |
| 172 | + | func TestRevealIfPending_SerializesReplacementUntilNativeVisibility(t *testing.T) { | |
| 173 | + | paneID := entity.PaneID("pane-1") | |
| 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 | - | ||
| 185 | - | pane1 := entity.PaneID("pane-1") | |
| 186 | - | pane2 := entity.PaneID("pane-2") | |
| 187 | - | ||
| 188 | - | c.markPendingReveal(pane1) | |
| 189 | - | c.markPendingReveal(pane2) | |
| 190 | - | ||
| 191 | - | c.revealMu.Lock() | |
| 192 | - | defer c.revealMu.Unlock() | |
| 193 | - | assert.True(t, c.pendingReveal[pane1]) | |
| 194 | - | assert.True(t, c.pendingReveal[pane2]) | |
| 195 | - | } | |
| 196 | - | ||
| 197 | - | func TestClearPendingReveal_OnlyAffectsTargetPane(t *testing.T) { | |
| 198 | - | c := &Coordinator{ | |
| 199 | - | pendingReveal: make(map[entity.PaneID]bool), | |
| 189 | + | revealed := make(chan struct{}) | |
| 190 | + | go func() { | |
| 191 | + | c.revealIfPending(context.Background(), paneID, oldWV, identity, "", "test") | |
| 192 | + | close(revealed) | |
| 193 | + | }() | |
| 194 | + | <-nativeStarted | |
| 195 | + | ||
| 196 | + | replaced := make(chan struct{}) | |
| 197 | + | go func() { | |
| 198 | + | c.setWebViewLocked(paneID, newWV) | |
| 199 | + | close(replaced) | |
| 200 | + | }() | |
| 201 | + | select { | |
| 202 | + | case <-replaced: | |
| 203 | + | t.Fatal("replacement interleaved before native visibility completed") | |
| 204 | + | default: | |
| 200 | 205 | } | |
| 201 | 206 | ||
| 202 | - | pane1 := entity.PaneID("pane-1") | |
| 203 | - | pane2 := entity.PaneID("pane-2") | |
| 204 | - | ||
| 205 | - | c.markPendingReveal(pane1) | |
| 206 | - | c.markPendingReveal(pane2) | |
| 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") | |
| 207 | + | close(allowNativeReturn) | |
| 208 | + | <-revealed | |
| 209 | + | <-replaced | |
| 210 | + | assert.Same(t, newWV, c.GetWebView(paneID)) | |
| 211 | + | assert.False(t, c.WebViewRevealed(paneID), "replacement must not inherit old reveal state") | |
| 213 | 212 | } | |
M internal/ui/coordinator/content/coordinator.go +55 -10
| @@ -70,8 +70,19 @@ type Coordinator struct { | |||
| 70 | 70 | // Callback when the WebView becomes visible (first real commit) | |
| 71 | 71 | onWebViewShown func(paneID entity.PaneID) | |
| 72 | 72 | ||
| 73 | - | revealMu sync.Mutex | |
| 74 | - | pendingReveal map[entity.PaneID]bool | |
| 73 | + | // setWebViewVisible is test-only injection for deterministic lifecycle | |
| 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 | 87 | appearanceMu sync.Mutex | |
| 77 | 88 | pendingScriptRefresh map[entity.PaneID]bool | |
| @@ -147,7 +158,8 @@ func NewCoordinator( | |||
| 147 | 158 | webViewPaneIDs: make(map[port.WebViewID]entity.PaneID), | |
| 148 | 159 | paneTitles: make(map[entity.PaneID]string), | |
| 149 | 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 | 163 | pendingScriptRefresh: make(map[entity.PaneID]bool), | |
| 152 | 164 | pendingThemePanes: make(map[entity.PaneID]bool), | |
| 153 | 165 | getActiveWS: getActiveWS, | |
| @@ -298,20 +310,42 @@ func (c *Coordinator) activePaneOverrideID() (entity.PaneID, bool) { | |||
| 298 | 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 | 313 | func (c *Coordinator) getWebViewLocked(paneID entity.PaneID) port.WebView { | |
| 308 | 314 | c.webViewsMu.RLock() | |
| 309 | 315 | defer c.webViewsMu.RUnlock() | |
| 310 | 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 | 342 | func (c *Coordinator) setWebViewLocked(paneID entity.PaneID, wv port.WebView) { | |
| 343 | + | c.revealMutationMu.Lock() | |
| 344 | + | defer c.revealMutationMu.Unlock() | |
| 314 | 345 | c.webViewsMu.Lock() | |
| 346 | + | c.revealMu.Lock() | |
| 347 | + | defer c.revealMu.Unlock() | |
| 348 | + | defer c.webViewsMu.Unlock() | |
| 315 | 349 | if c.webViews == nil { | |
| 316 | 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 | 356 | if wv != nil && c.webViewPaneIDs != nil { | |
| 323 | 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 | 366 | func (c *Coordinator) deleteWebViewLocked(paneID entity.PaneID) port.WebView { | |
| 367 | + | c.revealMutationMu.Lock() | |
| 368 | + | defer c.revealMutationMu.Unlock() | |
| 329 | 369 | c.webViewsMu.Lock() | |
| 370 | + | c.revealMu.Lock() | |
| 371 | + | defer c.revealMu.Unlock() | |
| 330 | 372 | defer c.webViewsMu.Unlock() | |
| 331 | 373 | wv := c.webViews[paneID] | |
| 332 | 374 | delete(c.webViews, paneID) | |
| 333 | 375 | if wv != nil && c.webViewPaneIDs != nil { | |
| 334 | 376 | delete(c.webViewPaneIDs, wv.ID()) | |
| 335 | 377 | } | |
| 378 | + | c.ensureRevealMapsLocked() | |
| 379 | + | delete(c.pendingReveal, paneID) | |
| 380 | + | delete(c.revealedWebViews, paneID) | |
| 336 | 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 | 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 | 31 | wv, err := c.pool.Acquire(ctx) | |
| 37 | 32 | if err != nil { | |
| 38 | 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 | 38 | c.setWebViewLocked(paneID, wv) | |
| 43 | 39 | c.setupWebViewCallbacks(ctx, paneID, wv) | |
| 44 | 40 | ||
| @@ -53,6 +49,8 @@ func (c *Coordinator) EnsureWebView(ctx context.Context, paneID entity.PaneID) ( | |||
| 53 | 49 | func (c *Coordinator) ReleaseWebView(ctx context.Context, paneID entity.PaneID) { | |
| 54 | 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 | 54 | wv := c.deleteWebViewLocked(paneID) | |
| 57 | 55 | if wv == nil { | |
| 58 | 56 | return | |
| @@ -136,11 +134,10 @@ func (c *Coordinator) AttachToWorkspace(ctx context.Context, ws *entity.Workspac | |||
| 136 | 134 | Int("wrapped_widget_alloc_height", widget.GetAllocatedHeight()). | |
| 137 | 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 | 138 | log.Warn().Err(err).Str("pane_id", string(pane.ID)).Msg("failed to attach webview widget") | |
| 141 | 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 | 58 | newWV := mocks.NewMockWebView(t) | |
| 59 | 59 | newWV.EXPECT().SetCallbacks(mock.Anything).Maybe() | |
| 60 | 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 | 63 | pool := mocks.NewMockWebViewPool(t) | |
| 64 | 64 | pool.EXPECT().Acquire(mock.Anything).Return(newWV, nil) | |
| @@ -82,7 +82,7 @@ func TestLifecycle_EnsureWebView_AcquiresFromPoolWhenExistingIsDestroyed(t *test | |||
| 82 | 82 | newWV := mocks.NewMockWebView(t) | |
| 83 | 83 | newWV.EXPECT().SetCallbacks(mock.Anything).Maybe() | |
| 84 | 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 | 87 | pool := mocks.NewMockWebViewPool(t) | |
| 88 | 88 | pool.EXPECT().Acquire(mock.Anything).Return(newWV, nil) | |
| @@ -458,3 +458,205 @@ func TestLifecycle_SyncWebViewViewport_NoopWithoutCapability(t *testing.T) { | |||
| 458 | 458 | ||
| 459 | 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 | 17 | // WebKit may reset zoom during document transitions, so we reapply after LoadCommitted. | |
| 18 | 18 | // History is recorded here because the URI is guaranteed to be correct after commit. | |
| 19 | 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 | 21 | log := logging.FromContext(ctx) | |
| 22 | - | logging.Trace().Mark("load_committed") | |
| 23 | 22 | ||
| 24 | 23 | uri := wv.URI() | |
| 25 | 24 | if uri == "" { | |
| @@ -69,7 +68,7 @@ func (c *Coordinator) onLoadCommitted(ctx context.Context, paneID entity.PaneID, | |||
| 69 | 68 | // Avoid updating UI/domain state to about:blank when we know the pane is | |
| 70 | 69 | // navigating to a different URL. This prevents the omnibox/window title from | |
| 71 | 70 | // briefly showing about:blank on cold start. | |
| 72 | - | c.clearPendingReveal(paneID) | |
| 71 | + | c.clearPendingReveal(paneID, identity) | |
| 73 | 72 | return | |
| 74 | 73 | } | |
| 75 | 74 | ||
| @@ -77,8 +76,8 @@ func (c *Coordinator) onLoadCommitted(ctx context.Context, paneID entity.PaneID, | |||
| 77 | 76 | c.applyCurrentTheme(ctx, paneID, wv) | |
| 78 | 77 | } | |
| 79 | 78 | ||
| 80 | - | c.markPendingReveal(paneID) | |
| 81 | - | c.revealIfPending(ctx, paneID, uri, "progress-after-commit") | |
| 79 | + | c.markPendingReveal(paneID, identity) | |
| 80 | + | c.revealIfPending(ctx, paneID, wv, identity, uri, "progress-after-commit") | |
| 82 | 81 | ||
| 83 | 82 | // Update domain model with current URI for session snapshots | |
| 84 | 83 | c.updatePaneURI(paneID, uri) | |
| @@ -177,8 +176,6 @@ func (c *Coordinator) updatePaneURI(paneID entity.PaneID, uri string) { | |||
| 177 | 176 | ||
| 178 | 177 | // onLoadStarted shows the progress bar when page loading begins. | |
| 179 | 178 | func (c *Coordinator) onLoadStarted(paneID entity.PaneID) { | |
| 180 | - | logging.Trace().Mark("load_started") | |
| 181 | - | ||
| 182 | 179 | // Trigger deferred initialization on first load_started. | |
| 183 | 180 | // This ensures non-critical init runs after initial navigation starts. | |
| 184 | 181 | c.loadStartedOnce.Do(func() { | |
| @@ -202,7 +199,7 @@ func (c *Coordinator) onLoadStarted(paneID entity.PaneID) { | |||
| 202 | 199 | } | |
| 203 | 200 | ||
| 204 | 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 | 203 | _, wsView := c.getActiveWS() | |
| 207 | 204 | var paneView *component.PaneView | |
| 208 | 205 | if wsView != nil { | |
| @@ -216,7 +213,7 @@ func (c *Coordinator) onLoadFinished(ctx context.Context, paneID entity.PaneID, | |||
| 216 | 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 | 217 | if c.shouldSkipAboutBlankAppearance(paneID, wv) { | |
| 221 | 218 | return | |
| 222 | 219 | } | |
| @@ -225,9 +222,9 @@ func (c *Coordinator) onLoadFinished(ctx context.Context, paneID entity.PaneID, | |||
| 225 | 222 | } | |
| 226 | 223 | ||
| 227 | 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 | 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 | 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 | 246 | c.revealMu.Lock() | |
| 249 | - | c.pendingReveal[paneID] = true | |
| 250 | - | c.revealMu.Unlock() | |
| 247 | + | defer 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 | 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 | 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 | 284 | c.revealMu.Lock() | |
| 261 | - | pending := c.pendingReveal[paneID] | |
| 262 | - | if pending { | |
| 263 | - | delete(c.pendingReveal, paneID) | |
| 285 | + | defer c.revealMu.Unlock() | |
| 286 | + | defer c.webViewsMu.RUnlock() | |
| 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 | 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) | |
| 272 | - | if wv == nil || wv.IsDestroyed() { | |
| 341 | + | if wv.IsDestroyed() { | |
| 342 | + | c.revealMutationMu.Unlock() | |
| 273 | 343 | return | |
| 274 | 344 | } | |
| 275 | 345 | ||
| 276 | - | // Use NativeWidgetProvider to make the widget visible (engine-agnostic) | |
| 277 | - | if nwp, ok := wv.(port.NativeWidgetProvider); ok { | |
| 346 | + | if c.setWebViewVisible != nil { | |
| 347 | + | c.setWebViewVisible(wv) | |
| 348 | + | } else if nwp, ok := wv.(port.NativeWidgetProvider); ok { | |
| 278 | 349 | if ptr := nwp.NativeWidget(); ptr != 0 { | |
| 279 | 350 | gtkWidget := >k.Widget{} | |
| 280 | 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 | |
| 292 | - | logging.Trace().Mark("first_paint") | |
| 293 | - | logging.Trace().Finish() | |
| 294 | - | ||
| 295 | - | if c.onWebViewShown != nil { | |
| 362 | + | // Keep the presentation record in the same serialized operation as the | |
| 363 | + | // native mutation. User callbacks run only after the gate is released. | |
| 364 | + | revealed := c.markWebViewRevealed(paneID, identity) | |
| 365 | + | c.revealMutationMu.Unlock() | |
| 366 | + | if revealed && c.onWebViewShown != nil { | |
| 296 | 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 | 137 | parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once() | |
| 138 | 138 | ||
| 139 | 139 | popupWV := mocks.NewMockWebView(t) | |
| 140 | - | popupWV.EXPECT().ID().Return(popupID).Once() | |
| 140 | + | popupWV.EXPECT().ID().Return(popupID).Maybe() | |
| 141 | 141 | ||
| 142 | 142 | factory := mocks.NewMockWebViewFactory(t) | |
| 143 | 143 | factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once() | |
| @@ -158,8 +158,8 @@ func TestHandlePopupCreate_PrimesPopupNavigationCapability(t *testing.T) { | |||
| 158 | 158 | parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once() | |
| 159 | 159 | ||
| 160 | 160 | popupWV := &popupNavigationWebViewStub{MockWebView: mocks.NewMockWebView(t)} | |
| 161 | - | popupWV.EXPECT().ID().Return(port.WebViewID(149)).Once() | |
| 162 | - | popupWV.EXPECT().Generation().Return(uint64(1)).Once() | |
| 161 | + | popupWV.EXPECT().ID().Return(port.WebViewID(149)).Maybe() | |
| 162 | + | popupWV.EXPECT().Generation().Return(uint64(1)).Maybe() | |
| 163 | 163 | popupWV.EXPECT().SetCallbacks(mock.Anything).Once() | |
| 164 | 164 | ||
| 165 | 165 | factory := mocks.NewMockWebViewFactory(t) | |
| @@ -187,7 +187,7 @@ func TestHandlePopupCreate_PrimesPopupNavigationCapability(t *testing.T) { | |||
| 187 | 187 | ||
| 188 | 188 | func TestHandlePopupCreate_RegistersPopupWebViewBeforeWorkspaceInsertion(t *testing.T) { | |
| 189 | 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 | 191 | popupWV.EXPECT().SetCallbacks(mock.Anything).Once() | |
| 192 | 192 | popupWV.EXPECT().IsLoading().Return(false).Once() | |
| 193 | 193 | popupWV.EXPECT().URI().Return("").Once() | |
| @@ -211,7 +211,7 @@ func TestHandlePopupCreate_RegistersPopupWebViewBeforeWorkspaceInsertion(t *test | |||
| 211 | 211 | ||
| 212 | 212 | func TestHandlePopupCreate_CleansUpPreRegisteredPopupWebViewWhenInsertionFails(t *testing.T) { | |
| 213 | 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 | 215 | popupWV.EXPECT().SetCallbacks(mock.Anything).Once() | |
| 216 | 216 | popupWV.EXPECT().Destroy().Once() | |
| 217 | 217 | ||
| @@ -238,8 +238,8 @@ func TestHandlePopupCreate_UsesRelatedWebViewWhenPopupDisablesJavaScriptAccess(t | |||
| 238 | 238 | parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once() | |
| 239 | 239 | ||
| 240 | 240 | popupWV := mocks.NewMockWebView(t) | |
| 241 | - | popupWV.EXPECT().ID().Return(port.WebViewID(150)).Once() | |
| 242 | - | popupWV.EXPECT().Generation().Return(uint64(1)).Once() | |
| 241 | + | popupWV.EXPECT().ID().Return(port.WebViewID(150)).Maybe() | |
| 242 | + | popupWV.EXPECT().Generation().Return(uint64(1)).Maybe() | |
| 243 | 243 | popupWV.EXPECT().SetCallbacks(mock.Anything).Once() | |
| 244 | 244 | popupWV.EXPECT().IsLoading().Return(false).Once() | |
| 245 | 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 | 8 | "github.com/bnema/dumber/internal/domain/entity" | |
| 9 | 9 | "github.com/bnema/dumber/internal/logging" | |
| 10 | + | "github.com/bnema/puregotk/v4/glib" | |
| 11 | + | "github.com/bnema/puregotk/v4/gobject" | |
| 10 | 12 | "github.com/bnema/puregotk/v4/gtk" | |
| 11 | 13 | ) | |
| 12 | 14 | ||
| @@ -24,19 +26,28 @@ type MotionCallback func() | |||
| 24 | 26 | // HoverHandler handles mouse hover events for focus-follows-mouse behavior. | |
| 25 | 27 | // It uses a debounce timer to avoid rapid focus switches. | |
| 26 | 28 | type HoverHandler struct { | |
| 27 | - | motionCtrl *gtk.EventControllerMotion | |
| 28 | - | paneID entity.PaneID | |
| 29 | - | onEnter HoverCallback | |
| 30 | - | onMotion MotionCallback | |
| 29 | + | attachedWidget *gtk.Widget | |
| 30 | + | motionCtrl *gtk.EventControllerMotion | |
| 31 | + | enterSignalID uint | |
| 32 | + | leaveSignalID uint | |
| 33 | + | motionSignalID uint | |
| 34 | + | paneID entity.PaneID | |
| 35 | + | onEnter HoverCallback | |
| 36 | + | onMotion MotionCallback | |
| 31 | 37 | ||
| 32 | 38 | // Callback retention: must stay reachable by Go GC. | |
| 33 | 39 | enterCb func(gtk.EventControllerMotion, float64, float64) | |
| 34 | 40 | leaveCb func(gtk.EventControllerMotion) | |
| 35 | 41 | motionCb func(gtk.EventControllerMotion, float64, float64) | |
| 36 | 42 | ||
| 37 | - | timer *time.Timer | |
| 38 | - | timerMu sync.Mutex | |
| 39 | - | canceled bool | |
| 43 | + | timerMu sync.Mutex | |
| 44 | + | sourceID uint | |
| 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 | 52 | ctx context.Context | |
| 42 | 53 | } | |
| @@ -47,8 +58,12 @@ func NewHoverHandler(ctx context.Context, paneID entity.PaneID) *HoverHandler { | |||
| 47 | 58 | log.Debug().Str("pane_id", string(paneID)).Msg("creating hover handler") | |
| 48 | 59 | ||
| 49 | 60 | return &HoverHandler{ | |
| 50 | - | ctx: ctx, | |
| 51 | - | paneID: paneID, | |
| 61 | + | ctx: ctx, | |
| 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 | 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 | 100 | h.motionCtrl = gtk.NewEventControllerMotion() | |
| 77 | 101 | if h.motionCtrl == nil { | |
| 78 | 102 | log.Error().Msg("failed to create motion controller") | |
| @@ -83,13 +107,13 @@ func (h *HoverHandler) AttachTo(widget *gtk.Widget) { | |||
| 83 | 107 | h.enterCb = func(_ gtk.EventControllerMotion, _ float64, _ float64) { | |
| 84 | 108 | h.handleEnter() | |
| 85 | 109 | } | |
| 86 | - | h.motionCtrl.ConnectEnter(&h.enterCb) | |
| 110 | + | h.enterSignalID = h.motionCtrl.ConnectEnter(&h.enterCb) | |
| 87 | 111 | ||
| 88 | 112 | // Connect leave handler to cancel pending focus | |
| 89 | 113 | h.leaveCb = func(_ gtk.EventControllerMotion) { | |
| 90 | 114 | h.handleLeave() | |
| 91 | 115 | } | |
| 92 | - | h.motionCtrl.ConnectLeave(&h.leaveCb) | |
| 116 | + | h.leaveSignalID = h.motionCtrl.ConnectLeave(&h.leaveCb) | |
| 93 | 117 | ||
| 94 | 118 | // Connect motion handler to detect intentional mouse movement. | |
| 95 | 119 | // Unlike enter/leave, motion only fires when the cursor physically moves | |
| @@ -99,7 +123,7 @@ func (h *HoverHandler) AttachTo(widget *gtk.Widget) { | |||
| 99 | 123 | h.onMotion() | |
| 100 | 124 | } | |
| 101 | 125 | } | |
| 102 | - | h.motionCtrl.ConnectMotion(&h.motionCb) | |
| 126 | + | h.motionSignalID = h.motionCtrl.ConnectMotion(&h.motionCb) | |
| 103 | 127 | ||
| 104 | 128 | // Add controller to widget | |
| 105 | 129 | widget.AddController(&h.motionCtrl.EventController) | |
| @@ -107,32 +131,49 @@ func (h *HoverHandler) AttachTo(widget *gtk.Widget) { | |||
| 107 | 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 | 141 | // handleEnter processes the mouse enter event with debouncing. | |
| 111 | 142 | func (h *HoverHandler) handleEnter() { | |
| 112 | 143 | h.timerMu.Lock() | |
| 113 | - | defer h.timerMu.Unlock() | |
| 114 | - | ||
| 115 | - | // Cancel any existing timer | |
| 116 | - | if h.timer != nil { | |
| 117 | - | h.timer.Stop() | |
| 144 | + | if h.detached { | |
| 145 | + | h.timerMu.Unlock() | |
| 146 | + | return | |
| 118 | 147 | } | |
| 119 | - | ||
| 120 | - | h.canceled = false | |
| 121 | - | ||
| 122 | - | // Start new debounce timer | |
| 123 | - | h.timer = time.AfterFunc(HoverFocusDelay, func() { | |
| 148 | + | h.cancelSourceLocked() | |
| 149 | + | h.generation++ | |
| 150 | + | generation := h.generation | |
| 151 | + | schedule := h.schedule | |
| 152 | + | h.sourceID = schedule(uint(HoverFocusDelay.Milliseconds()), func(_ uintptr) bool { | |
| 124 | 153 | h.timerMu.Lock() | |
| 125 | - | canceled := h.canceled | |
| 126 | - | h.timerMu.Unlock() | |
| 127 | - | ||
| 128 | - | if canceled { | |
| 129 | - | return | |
| 154 | + | if h.detached || h.generation != generation { | |
| 155 | + | h.timerMu.Unlock() | |
| 156 | + | return false | |
| 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 { | |
| 133 | - | h.onEnter(h.paneID) | |
| 163 | + | if callback != nil { | |
| 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 | 179 | // handleLeave cancels any pending focus switch. | |
| @@ -146,24 +187,61 @@ func (h *HoverHandler) Cancel() { | |||
| 146 | 187 | h.timerMu.Lock() | |
| 147 | 188 | defer h.timerMu.Unlock() | |
| 148 | 189 | ||
| 149 | - | h.canceled = true | |
| 150 | - | if h.timer != nil { | |
| 151 | - | h.timer.Stop() | |
| 152 | - | h.timer = nil | |
| 153 | - | } | |
| 190 | + | h.generation++ | |
| 191 | + | h.cancelSourceLocked() | |
| 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 | 197 | func (h *HoverHandler) Detach() { | |
| 158 | 198 | h.timerMu.Lock() | |
| 159 | - | if h.timer != nil { | |
| 160 | - | h.timer.Stop() | |
| 161 | - | h.timer = nil | |
| 162 | - | } | |
| 163 | - | h.timerMu.Unlock() | |
| 164 | - | ||
| 199 | + | h.detached = true | |
| 200 | + | h.generation++ | |
| 201 | + | h.cancelSourceLocked() | |
| 202 | + | h.onEnter = nil | |
| 203 | + | h.onMotion = nil | |
| 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 | 210 | h.motionCtrl = nil | |
| 211 | + | h.enterSignalID = 0 | |
| 212 | + | h.leaveSignalID = 0 | |
| 213 | + | h.motionSignalID = 0 | |
| 166 | 214 | h.enterCb = nil | |
| 167 | 215 | h.leaveCb = nil | |
| 168 | 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 | 1 | package layout | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "sync" | |
| 5 | + | ||
| 4 | 6 | "github.com/bnema/puregotk/v4/gdk" | |
| 7 | + | "github.com/bnema/puregotk/v4/glib" | |
| 5 | 8 | "github.com/bnema/puregotk/v4/gobject" | |
| 6 | 9 | "github.com/bnema/puregotk/v4/graphene" | |
| 7 | 10 | "github.com/bnema/puregotk/v4/gtk" | |
| @@ -82,8 +85,14 @@ func (w *gtkWidget) ComputePoint(target Widget) (x, y float64, ok bool) { | |||
| 82 | 85 | // gtkPaned wraps gtk.Paned to implement PanedWidget. | |
| 83 | 86 | type gtkPaned struct { | |
| 84 | 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 | 96 | func (p *gtkPaned) Show() { p.inner.Show() } | |
| 88 | 97 | func (p *gtkPaned) Hide() { p.inner.Hide() } | |
| 89 | 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 | 187 | func (p *gtkPaned) AddTickCallback(callback func() bool) uint { | |
| 179 | - | cb := gtk.TickCallback(func(widget uintptr, frameClock uintptr, userData uintptr) bool { | |
| 180 | - | return callback() | |
| 181 | - | }) | |
| 182 | - | return p.inner.AddTickCallback(&cb, 0, nil) | |
| 188 | + | cb := new(gtk.TickCallback) | |
| 189 | + | var id uint | |
| 190 | + | *cb = func(_ uintptr, _ uintptr, _ uintptr) bool { | |
| 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 | 229 | func (p *gtkPaned) GetAllocatedWidth() int { return p.inner.GetAllocatedWidth() } | |
| @@ -779,7 +823,7 @@ func (f *GtkWidgetFactory) NewPaned(orientation Orientation) PanedWidget { | |||
| 779 | 823 | paned := gtk.NewPaned(orientation) | |
| 780 | 824 | paned.SetHexpand(true) | |
| 781 | 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 | 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 | 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 | 2468 | // SetCanFocus provides a mock function for the type MockPanedWidget | |
| 2429 | 2469 | func (_mock *MockPanedWidget) SetCanFocus(canFocus bool) { | |
| 2430 | 2470 | _mock.Called(canFocus) | |
M internal/ui/layout/split.go +51 -8
| @@ -21,8 +21,11 @@ type SplitView struct { | |||
| 21 | 21 | logger zerolog.Logger | |
| 22 | 22 | ||
| 23 | 23 | onRatioChanged func(ratio float64) | |
| 24 | - | pendingNotifyRatio float64 | |
| 24 | + | idleAdd func(*glib.SourceFunc, uintptr) uint | |
| 25 | 25 | notifyDebounceTimer *time.Timer | |
| 26 | + | notifyGeneration uint64 | |
| 27 | + | tickCallbackID uint | |
| 28 | + | cleanedUp bool | |
| 26 | 29 | ||
| 27 | 30 | hasAppliedRatio bool | |
| 28 | 31 | suppressNotifyUntil time.Time | |
| @@ -109,6 +112,7 @@ func NewSplitView( | |||
| 109 | 112 | endChild: endChild, | |
| 110 | 113 | ratio: clampRatio(ratio), | |
| 111 | 114 | logger: log.With().Str("component", "split-view").Logger(), | |
| 115 | + | idleAdd: glib.IdleAdd, | |
| 112 | 116 | } | |
| 113 | 117 | ||
| 114 | 118 | // Set children | |
| @@ -151,25 +155,40 @@ func NewSplitView( | |||
| 151 | 155 | ratio := clampRatio(float64(position) / float64(totalSize)) | |
| 152 | 156 | ||
| 153 | 157 | sv.mu.Lock() | |
| 158 | + | if sv.cleanedUp { | |
| 159 | + | sv.mu.Unlock() | |
| 160 | + | return | |
| 161 | + | } | |
| 154 | 162 | sv.ratio = ratio | |
| 155 | - | sv.pendingNotifyRatio = ratio | |
| 156 | - | onRatioChanged := sv.onRatioChanged | |
| 163 | + | sv.notifyGeneration++ | |
| 164 | + | generation := sv.notifyGeneration | |
| 157 | 165 | ||
| 158 | 166 | if sv.notifyDebounceTimer != nil { | |
| 159 | 167 | sv.notifyDebounceTimer.Stop() | |
| 160 | 168 | } | |
| 161 | 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 | 174 | return | |
| 164 | 175 | } | |
| 176 | + | ||
| 165 | 177 | cb := glib.SourceFunc(func(_ uintptr) bool { | |
| 166 | 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 | 184 | sv.mu.RUnlock() | |
| 169 | - | onRatioChanged(pending) | |
| 185 | + | ||
| 186 | + | if onRatioChanged != nil { | |
| 187 | + | onRatioChanged(ratio) | |
| 188 | + | } | |
| 170 | 189 | return false | |
| 171 | 190 | }) | |
| 172 | - | glib.IdleAdd(&cb, 0) | |
| 191 | + | sv.idleAdd(&cb, 0) | |
| 173 | 192 | }) | |
| 174 | 193 | ||
| 175 | 194 | sv.mu.Unlock() | |
| @@ -188,7 +207,7 @@ func NewSplitView( | |||
| 188 | 207 | // Add tick callback to retry applying ratio every frame until successful. | |
| 189 | 208 | // This handles cases where allocation isn't ready even after Map signal. | |
| 190 | 209 | frames := 0 | |
| 191 | - | paned.AddTickCallback(func() bool { | |
| 210 | + | sv.tickCallbackID = paned.AddTickCallback(func() bool { | |
| 192 | 211 | frames++ | |
| 193 | 212 | if sv.ApplyRatio() { | |
| 194 | 213 | sv.logger.Debug(). | |
| @@ -213,6 +232,30 @@ func NewSplitView( | |||
| 213 | 232 | // The ratio is clamped to the range [0.0, 1.0]. | |
| 214 | 233 | // Note: This sets the position based on ratio; actual pixel position | |
| 215 | 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 | 259 | func (sv *SplitView) SetRatio(ratio float64) { | |
| 217 | 260 | sv.mu.Lock() | |
| 218 | 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 | 33 | logger zerolog.Logger | |
| 34 | 34 | nodeToWidget map[string]Widget | |
| 35 | 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 | 39 | // paneToStack maps pane IDs to their containing StackedView. | |
| 37 | 40 | // Every leaf pane is wrapped in a StackedView for easy stacking later. | |
| 38 | 41 | paneToStack map[string]*StackedView | |
| @@ -52,6 +55,7 @@ func NewTreeRenderer(ctx context.Context, factory WidgetFactory, paneViewFactory | |||
| 52 | 55 | logger: log.With().Str("component", "tree-renderer").Logger(), | |
| 53 | 56 | nodeToWidget: make(map[string]Widget), | |
| 54 | 57 | splitOrientation: make(map[string]Orientation), | |
| 58 | + | splitViews: make(map[string]*SplitView), | |
| 55 | 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 | 77 | tr.mu.Lock() | |
| 74 | 78 | defer tr.mu.Unlock() | |
| 75 | 79 | ||
| 76 | - | // Clear previous mappings | |
| 77 | - | tr.nodeToWidget = make(map[string]Widget) | |
| 78 | - | tr.splitOrientation = make(map[string]Orientation) | |
| 79 | - | tr.paneToStack = make(map[string]*StackedView) | |
| 80 | + | tr.clearInternal() | |
| 80 | 81 | ||
| 81 | 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 | 192 | tr.splitOrientation[node.ID] = orientation | |
| 193 | + | tr.splitViews[node.ID] = splitView | |
| 192 | 194 | ||
| 193 | 195 | return splitView.Widget(), nil | |
| 194 | 196 | } | |
| @@ -264,13 +266,26 @@ func (tr *TreeRenderer) NodeCount() int { | |||
| 264 | 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 | 270 | func (tr *TreeRenderer) Clear() { | |
| 269 | 271 | tr.mu.Lock() | |
| 270 | 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 | 285 | tr.nodeToWidget = make(map[string]Widget) | |
| 273 | 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 | 291 | // RegisterWidget adds or updates a node-to-widget mapping. | |
| @@ -297,8 +312,12 @@ func (tr *TreeRenderer) UnregisterWidget(nodeID string) { | |||
| 297 | 312 | tr.mu.Lock() | |
| 298 | 313 | defer tr.mu.Unlock() | |
| 299 | 314 | ||
| 315 | + | if splitView := tr.splitViews[nodeID]; splitView != nil { | |
| 316 | + | splitView.Cleanup() | |
| 317 | + | } | |
| 300 | 318 | delete(tr.nodeToWidget, nodeID) | |
| 301 | 319 | delete(tr.splitOrientation, nodeID) | |
| 320 | + | delete(tr.splitViews, nodeID) | |
| 302 | 321 | } | |
| 303 | 322 | ||
| 304 | 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 | 99 | ConnectMap(callback func()) uint | |
| 100 | 100 | ConnectNotifyPosition(callback func()) uint | |
| 101 | 101 | ||
| 102 | - | // Tick callback for frame-based updates (returns signal ID) | |
| 103 | - | // Callback returns true to continue, false to stop | |
| 102 | + | // Tick callback for frame-based updates (returns signal ID). | |
| 103 | + | // Callback returns true to continue, false to stop. | |
| 104 | 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 | 109 | // Handle appearance | |
| 107 | 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" | |