detached d0b155d

Merge pull request #359 from bnema/next

Merge next into main
commit
d0b155d7f6dbe93cb825d05589032009461641fe
author
bnema · · 2026-07-22 08:54 UTC
committer
GitHub · · 2026-07-22 08:54 UTC
merge of
8f0204c + 99a0f9c · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
76 files · +4096 -1447
M .gitignore +4 -4
@@ -16,8 +16,8 @@ dumber.exe
16 # Output of the go coverage tool16 # Output of the go coverage tool
17 *.out17 *.out
18 18
19-# Dependency directories (remove the comment below to include it)19+# Dependency directories
20-# vendor/20+vendor/
21 21
22 # Go workspace file22 # Go workspace file
23 go.work23 go.work
@@ -94,7 +94,6 @@ templates/
94 .serena/94 .serena/
95 config.yaml95 config.yaml
96 .dev/96 .dev/
97-vendor/
98 97
99 # WebKit runtime data98 # WebKit runtime data
100 serviceworkers/99 serviceworkers/
@@ -119,6 +118,7 @@ aur/*/LICENSE
119 aur/*/dev.bnema.Dumber.desktop118 aur/*/dev.bnema.Dumber.desktop
120 aur/*/logo-512.png119 aur/*/logo-512.png
121 120
122-# Planning/runbook notes stored outside product docs121+# Planning/runbook notes and generated roadmap evidence stored outside the repository
123 docs/reference/stability-performance-multi-agent-runbook-*.md122 docs/reference/stability-performance-multi-agent-runbook-*.md
123+/phase1/
124 perf.data124 perf.data
M Makefile +2 -2
@@ -239,13 +239,13 @@ flatpak-deps: ## Install Flatpak build dependencies (Arch)
239 239
240 flatpak-build: ## Build Flatpak bundle locally240 flatpak-build: ## Build Flatpak bundle locally
241 @echo "Building Flatpak bundle..."241 @echo "Building Flatpak bundle..."
242- flatpak-builder --force-clean --user --repo=flatpak-repo flatpak-build dev.bnema.Dumber.yml242+ flatpak-builder --force-clean --user --repo=flatpak-repo flatpak-build flatpak/dev.bnema.Dumber.yml
243 flatpak build-bundle flatpak-repo dumber.flatpak dev.bnema.Dumber243 flatpak build-bundle flatpak-repo dumber.flatpak dev.bnema.Dumber
244 @echo "Flatpak bundle created: dumber.flatpak"244 @echo "Flatpak bundle created: dumber.flatpak"
245 245
246 flatpak-install: ## Install Flatpak locally for testing246 flatpak-install: ## Install Flatpak locally for testing
247 @echo "Installing Flatpak locally..."247 @echo "Installing Flatpak locally..."
248- flatpak-builder --force-clean --user --install flatpak-build dev.bnema.Dumber.yml248+ flatpak-builder --force-clean --user --install flatpak-build flatpak/dev.bnema.Dumber.yml
249 @echo "Flatpak installed! Run with: make flatpak-run"249 @echo "Flatpak installed! Run with: make flatpak-run"
250 250
251 flatpak-run: ## Run the installed Flatpak251 flatpak-run: ## Run the installed Flatpak
M README.md +3 -2
@@ -134,7 +134,7 @@ Dumber uses Chromium Embedded Framework by default. WebKitGTK is available as a
134 On Arch Linux, install the CEF runtime with:134 On Arch Linux, install the CEF runtime with:
135 135
136 ```bash136 ```bash
137-sudo pacman -S cef137+sudo pacman -S cef libwebp
138 ```138 ```
139 139
140 For hardware video decoding, `cef-vaapi` is also available from the AUR as an optional CEF build.140 For hardware video decoding, `cef-vaapi` is also available from the AUR as an optional CEF build.
@@ -179,7 +179,7 @@ CEF OSR frame rate adapts to the active Wayland monitor refresh rate when both `
179 **Arch Linux:**179 **Arch Linux:**
180 180
181 ```bash181 ```bash
182-sudo pacman -S cef webkitgtk-6.0 gtk4 gst-plugins-base gst-plugins-good gst-plugins-bad gst-plugin-va182+sudo pacman -S cef webkitgtk-6.0 gtk4 libwebp gst-plugins-base gst-plugins-good gst-plugins-bad gst-plugin-va
183 ```183 ```
184 184
185 **Debian/Ubuntu:**185 **Debian/Ubuntu:**
@@ -213,6 +213,7 @@ Set `ENV=dev` to use `.dev/dumber/` for config and data instead of XDG paths.
213 - CEF runtime (default backend)213 - CEF runtime (default backend)
214 - WebKitGTK 6.0 development/runtime packages (fallback backend and runtime checks)214 - WebKitGTK 6.0 development/runtime packages (fallback backend and runtime checks)
215 - Brotli for compressed systemviews assets215 - Brotli for compressed systemviews assets
216+- libwebp runtime for WebP favicon decoding (for example `libwebp` on Arch)
216 217
217 Systemviews assets are generated with `go tool templ` and Go's `js/wasm` toolchain; no root Node toolchain is required.218 Systemviews assets are generated with `go tool templ` and Go's `js/wasm` toolchain; no root Node toolchain is required.
218 219
M aur/dumber-browser-bin/.SRCINFO +2 -1
@@ -1,13 +1,14 @@
1 pkgbase = dumber-browser-bin1 pkgbase = dumber-browser-bin
2 pkgdesc = A minimal keyboard-driven browser for tiling WMs2 pkgdesc = A minimal keyboard-driven browser for tiling WMs
3 pkgver = 0.23.23 pkgver = 0.23.2
4- pkgrel = 24+ pkgrel = 3
5 url = https://github.com/bnema/dumber5 url = https://github.com/bnema/dumber
6 arch = x86_646 arch = x86_64
7 license = MIT7 license = MIT
8 depends = gtk48 depends = gtk4
9 depends = cef9 depends = cef
10 depends = webkitgtk-6.010 depends = webkitgtk-6.0
11+ depends = libwebp
11 optdepends = gst-plugins-base: Base media codecs12 optdepends = gst-plugins-base: Base media codecs
12 optdepends = gst-plugins-good: Additional media codecs13 optdepends = gst-plugins-good: Additional media codecs
13 optdepends = gst-plugins-bad: Extended media codecs14 optdepends = gst-plugins-bad: Extended media codecs
M aur/dumber-browser-bin/PKGBUILD +2 -2
@@ -1,12 +1,12 @@
1 # Maintainer: bnema <b at bnema dot dev>1 # Maintainer: bnema <b at bnema dot dev>
2 pkgname=dumber-browser-bin2 pkgname=dumber-browser-bin
3 pkgver=0.23.23 pkgver=0.23.2
4-pkgrel=24+pkgrel=3
5 pkgdesc="A minimal keyboard-driven browser for tiling WMs"5 pkgdesc="A minimal keyboard-driven browser for tiling WMs"
6 arch=('x86_64')6 arch=('x86_64')
7 url="https://github.com/bnema/dumber"7 url="https://github.com/bnema/dumber"
8 license=('MIT')8 license=('MIT')
9-depends=('gtk4' 'cef' 'webkitgtk-6.0')9+depends=('gtk4' 'cef' 'webkitgtk-6.0' 'libwebp')
10 optdepends=(10 optdepends=(
11 'gst-plugins-base: Base media codecs'11 'gst-plugins-base: Base media codecs'
12 'gst-plugins-good: Additional media codecs'12 'gst-plugins-good: Additional media codecs'
M aur/dumber-browser-git/.SRCINFO +1 -0
@@ -11,6 +11,7 @@ pkgbase = dumber-browser-git
11 depends = gtk411 depends = gtk4
12 depends = cef12 depends = cef
13 depends = webkitgtk-6.013 depends = webkitgtk-6.0
14+ depends = libwebp
14 optdepends = gst-plugins-base: Base media codecs15 optdepends = gst-plugins-base: Base media codecs
15 optdepends = gst-plugins-good: Additional media codecs16 optdepends = gst-plugins-good: Additional media codecs
16 optdepends = gst-plugins-bad: Extended media codecs17 optdepends = gst-plugins-bad: Extended media codecs
M aur/dumber-browser-git/PKGBUILD +1 -1
@@ -7,7 +7,7 @@ arch=('x86_64')
7 url="https://github.com/bnema/dumber"7 url="https://github.com/bnema/dumber"
8 license=('MIT')8 license=('MIT')
9 makedepends=('go>=1.26' 'git' 'brotli')9 makedepends=('go>=1.26' 'git' 'brotli')
10-depends=('gtk4' 'cef' 'webkitgtk-6.0')10+depends=('gtk4' 'cef' 'webkitgtk-6.0' 'libwebp')
11 optdepends=(11 optdepends=(
12 'gst-plugins-base: Base media codecs'12 'gst-plugins-base: Base media codecs'
13 'gst-plugins-good: Additional media codecs'13 'gst-plugins-good: Additional media codecs'
M cmd/dumber/main.go +28 -18
@@ -10,6 +10,7 @@ import (
10 "runtime"10 "runtime"
11 "strings"11 "strings"
12 "syscall"12 "syscall"
13+ "time"
13 14
14 "github.com/bnema/dumber/internal/application/port"15 "github.com/bnema/dumber/internal/application/port"
15 "github.com/bnema/dumber/internal/application/usecase"16 "github.com/bnema/dumber/internal/application/usecase"
@@ -140,7 +141,17 @@ func configureBrowserLaunchRelay(cfg *config.Config) {
140 browserLaunchRelay = relay141 browserLaunchRelay = relay
141 }142 }
142 143
144+type startupTiming struct {
145+ processEntry time.Time
146+ configComplete time.Time
147+}
148+
143 func main() {149 func main() {
150+ // CEF is not known until configuration is complete. Keep these neutral
151+ // timestamps so a CEF GUI trace can be seeded truthfully without creating
152+ // one for WebKit, the standalone omnibox, CLI, or CEF helper processes.
153+ timing := startupTiming{processEntry: time.Now()}
154+
144 // CEF subprocess handling: when CEF re-launches this binary with155 // CEF subprocess handling: when CEF re-launches this binary with
145 // --type=renderer/gpu/etc, we must call ExecuteProcess before anything156 // --type=renderer/gpu/etc, we must call ExecuteProcess before anything
146 // else (Cobra, config, arg stripping). We only treat a leading Chromium157 // else (Cobra, config, arg stripping). We only treat a leading Chromium
@@ -170,6 +181,7 @@ func main() {
170 // Run GUI mode for browse command181 // Run GUI mode for browse command
171 if mode == launchModeBrowse {182 if mode == launchModeBrowse {
172 cfg := initConfig()183 cfg := initConfig()
184+ timing.configComplete = time.Now()
173 configureBrowserLaunchRelay(cfg)185 configureBrowserLaunchRelay(cfg)
174 startupURL := domainurl.ResolveBrowserStartupURL(browseURL)186 startupURL := domainurl.ResolveBrowserStartupURL(browseURL)
175 if forwarded, err := tryForwardBrowseURLToRunningInstance(context.Background(), browserLaunchRelay, startupURL); err != nil {187 if forwarded, err := tryForwardBrowseURLToRunningInstance(context.Background(), browserLaunchRelay, startupURL); err != nil {
@@ -186,7 +198,7 @@ func main() {
186 initialURL = startupURL198 initialURL = startupURL
187 restoreSessionID = os.Getenv("DUMBER_RESTORE_SESSION")199 restoreSessionID = os.Getenv("DUMBER_RESTORE_SESSION")
188 os.Args = os.Args[:1]200 os.Args = os.Args[:1]
189- os.Exit(runGUI(cfg))201+ os.Exit(runGUI(cfg, timing))
190 return202 return
191 }203 }
192 204
@@ -207,7 +219,7 @@ func main() {
207 cmd.Execute()219 cmd.Execute()
208 }220 }
209 221
210-func runGUI(cfg *config.Config) int {222+func runGUI(cfg *config.Config, timing startupTiming) int {
211 bootstrap.ApplyGTKIMModuleFallbackDefault(os.Stderr)223 bootstrap.ApplyGTKIMModuleFallbackDefault(os.Stderr)
212 224
213 runtime.LockOSThread()225 runtime.LockOSThread()
@@ -216,11 +228,13 @@ func runGUI(cfg *config.Config) int {
216 228
217 if cfg == nil {229 if cfg == nil {
218 cfg = initConfig()230 cfg = initConfig()
231+ timing.configComplete = time.Now()
219 configureBrowserLaunchRelay(cfg)232 configureBrowserLaunchRelay(cfg)
220 }233 }
221 timer.Mark("config")234 timer.Mark("config")
222 235
223- ctx := initStartupContextWithTrace(cfg)236+ ctx := initStartupContext(cfg)
237+ activateCEFStartupTraceForGUI(cfg, timing, logging.FromContext(ctx), infracef.ActivateStartupTrace)
224 applyCEFRenderStackDefault(ctx, cfg)238 applyCEFRenderStackDefault(ctx, cfg)
225 timer.Mark("logger")239 timer.Mark("logger")
226 bootstrapLog := logging.FromContext(ctx)240 bootstrapLog := logging.FromContext(ctx)
@@ -253,7 +267,6 @@ func runGUI(cfg *config.Config) int {
253 timer.Mark("session")267 timer.Mark("session")
254 268
255 log := logging.FromContext(ctx)269 log := logging.FromContext(ctx)
256- logging.Trace().UpdateLogger(log)
257 logCoreDumpLimits(ctx)270 logCoreDumpLimits(ctx)
258 271
259 engine.SetHandlerContext(ctx)272 engine.SetHandlerContext(ctx)
@@ -297,7 +310,7 @@ func runStandaloneOmnibox() int {
297 310
298 cfg := initConfig()311 cfg := initConfig()
299 configureBrowserLaunchRelay(cfg)312 configureBrowserLaunchRelay(cfg)
300- ctx := initStartupContextWithTrace(cfg)313+ ctx := initStartupContext(cfg)
301 applyCEFRenderStackDefault(ctx, cfg)314 applyCEFRenderStackDefault(ctx, cfg)
302 315
303 initResult, err := runParallelInitPhase(ctx, cfg)316 initResult, err := runParallelInitPhase(ctx, cfg)
@@ -412,21 +425,19 @@ func resolveCurrentExecutable(executable func() (string, error)) (string, error)
412 return executable()425 return executable()
413 }426 }
414 427
415-func initStartupContextWithTrace(cfg *config.Config) context.Context {428+func activateCEFStartupTraceForGUI(
416- logging.InitStartupTrace(cfg.Logging.Level)429+ cfg *config.Config,
417- logging.Trace().Mark("config_loaded")430+ timing startupTiming,
418-431+ logger *zerolog.Logger,
419- ctx := initStartupContext(cfg)432+ activate func(time.Time, time.Time, *zerolog.Logger),
420- bootstrapLog := logging.FromContext(ctx)433+) {
421-434+ if cfg == nil || cfg.Engine.ResolveEngineType() != config.EngineTypeCEF {
422- logging.Trace().SetLogger(bootstrapLog)435+ return
423- logging.Trace().Mark("logger_init")436+ }
424-437+ activate(timing.processEntry, timing.configComplete, logger)
425- return ctx
426 }438 }
427 439
428 func runParallelInitPhase(ctx context.Context, cfg *config.Config) (*bootstrap.ParallelInitResult, error) {440 func runParallelInitPhase(ctx context.Context, cfg *config.Config) (*bootstrap.ParallelInitResult, error) {
429- logging.Trace().Mark("parallel_start")
430 initResult, err := bootstrap.RunParallelInit(bootstrap.ParallelInitInput{441 initResult, err := bootstrap.RunParallelInit(bootstrap.ParallelInitInput{
431 Ctx: ctx,442 Ctx: ctx,
432 Config: cfg,443 Config: cfg,
@@ -435,7 +446,6 @@ func runParallelInitPhase(ctx context.Context, cfg *config.Config) (*bootstrap.P
435 handleParallelInitError(ctx, err)446 handleParallelInitError(ctx, err)
436 return nil, err447 return nil, err
437 }448 }
438- logging.Trace().Mark("parallel_done")
439 return initResult, nil449 return initResult, nil
440 }450 }
441 451
M cmd/dumber/main_test.go +16 -0
@@ -4,12 +4,14 @@ import (
4 "context"4 "context"
5 "errors"5 "errors"
6 "testing"6 "testing"
7+ "time"
7 8
8 "github.com/bnema/dumber/internal/application/port/mocks"9 "github.com/bnema/dumber/internal/application/port/mocks"
9 "github.com/bnema/dumber/internal/bootstrap"10 "github.com/bnema/dumber/internal/bootstrap"
10 "github.com/bnema/dumber/internal/infrastructure/colorscheme"11 "github.com/bnema/dumber/internal/infrastructure/colorscheme"
11 "github.com/bnema/dumber/internal/infrastructure/config"12 "github.com/bnema/dumber/internal/infrastructure/config"
12 "github.com/bnema/dumber/internal/infrastructure/desktop"13 "github.com/bnema/dumber/internal/infrastructure/desktop"
14+ "github.com/rs/zerolog"
13 )15 )
14 16
15 func TestLaunchModeFromArgs_DetectsStandaloneOmnibox(t *testing.T) {17 func TestLaunchModeFromArgs_DetectsStandaloneOmnibox(t *testing.T) {
@@ -172,6 +174,20 @@ func TestResolveCurrentExecutable_PropagatesError(t *testing.T) {
172 }174 }
173 }175 }
174 176
177+func TestWebKitGUIStartupDoesNotActivateCEFTrace(t *testing.T) {
178+ cfg := &config.Config{}
179+ cfg.Engine.Type = config.EngineTypeWebKit
180+ activationCalls := 0
181+
182+ activateCEFStartupTraceForGUI(cfg, startupTiming{}, nil, func(time.Time, time.Time, *zerolog.Logger) {
183+ activationCalls++
184+ })
185+
186+ if activationCalls != 0 {
187+ t.Fatal("WebKit GUI startup must not initialize, record, or summarize the CEF first-presentation trace")
188+ }
189+}
190+
175 func TestPreInitializeAdwaitaForCEF_InitializesAndMarksDetector(t *testing.T) {191 func TestPreInitializeAdwaitaForCEF_InitializesAndMarksDetector(t *testing.T) {
176 initResult := &bootstrap.ParallelInitResult{192 initResult := &bootstrap.ParallelInitResult{
177 AdwaitaDetector: colorscheme.NewAdwaitaDetector(),193 AdwaitaDetector: colorscheme.NewAdwaitaDetector(),
A docs/first-presentation.md +69 -0
@@ -0,0 +1,69 @@
1+# CEF accelerated first-presentation startup trace
2+
3+Dumber records this cold-start timeline **only for the accelerated CEF → DMABUF
4+→ GTK path**. It is not a backend-neutral startup metric: the WebKit backend
5+does not add milestones to this trace and never emits this CEF summary.
6+
7+For a selected CEF GUI process, Dumber captures neutral `process_entry` and
8+`config_complete` timestamps, then activates and seeds this CEF-owned trace only
9+after configuration selects CEF. CEF helper processes, WebKit GUI startup, the
10+standalone omnibox, and CLI commands never initialize this trace, record its
11+milestones, or emit its summary. A selected CEF GUI trace accepts these one-shot
12+milestones only in order:
13+
14+1. `process_entry`
15+2. `config_complete`
16+3. `cef_library_load_begin` (immediately before `InitWithApp`)
17+4. `cef_initialized`
18+5. `browser_create_requested`
19+6. `first_accelerated_paint_received`
20+7. `first_dmabuf_texture_swap` (after `GtkPicture.SetPaintable` succeeds)
21+8. `first_gtk_presentation` (the subsequent GTK frame-clock after-paint)
22+
23+The CEF-to-GTK bridge owns the native accelerated-paint, DMABUF texture-swap,
24+and GTK presentation boundaries. Dumber records the bridge's ordered callbacks;
25+it does not synthesize them for another engine or rendering path. CEF
26+library-load completion is intentionally not recorded: `InitWithApp` is an
27+opaque operation. Duplicate, unknown, and out-of-order transitions are
28+rejected. At the last milestone Dumber emits exactly one normal-level JSON
29+summary (`startup_trace: first presentation`) with the selected CEF render
30+backend, an `incomplete_reason`, total milliseconds, and monotonic milestones.
31+
32+A non-DMABUF CEF backend or a missing accelerated callback does not produce a
33+complete summary and is not comparable with this measurement. In particular,
34+WebKit startup must be measured with a separately defined WebKit-specific
35+contract rather than this CEF trace.
36+
37+## Reproducible collection
38+
39+Build the candidate, then run:
40+
41+```bash
42+DUMBER_FIRST_PRESENTATION_BIN="$PWD/dist/dumber" \
43+DUMBER_MACHINE_GPU_PROFILE=integrated-gpu \
44+ scripts/collect_first_presentation.sh
45+```
46+
47+The collector is for the accelerated CEF/DMABUF/GTK contract above. By default
48+each collection is a fresh directory below
49+`$XDG_STATE_HOME/dumber/roadmap-evidence` (or
50+`$HOME/.local/state/dumber/roadmap-evidence` when `XDG_STATE_HOME` is unset),
51+not in the repository. `DUMBER_FIRST_PRESENTATION_OUTPUT` may override it only
52+with a new absolute directory below an existing non-symlink parent. The
53+collector never clears or reuses a caller-supplied path.
54+
55+The script requires the current display and the CEF 147 runtime at
56+`~/.local/share/cef-147-runtime` (override with `DUMBER_CEF_DIR`). It performs
57+exactly five bounded launches with fresh XDG homes and CEF root cache, fixes the
58+DMABUF/Vulkan renderer, and writes exactly `metadata.json`, `run-01.json`
59+through `run-05.json`, and `baseline.json`. Metadata includes only coarse,
60+comparison-safe OS/architecture, display protocol, and machine/GPU profile
61+labels. Set `DUMBER_MACHINE_GPU_PROFILE` to one of `generic-gpu`,
62+`integrated-gpu`, `discrete-gpu`, `hybrid-gpu`, `virtual-gpu`, or `unknown-gpu`;
63+do not use a device model or other identifier. Publish only the seven reviewed
64+JSON files to an external Gist. Raw logs and temporary XDG homes are removed and
65+must not be published. `metadata.json` derives the selected
66+`github.com/bnema/purego-cef2gtk` pseudo-version and its full immutable Git
67+origin hash from Go module metadata; branch selectors or missing origin metadata
68+fail collection. A missing or incomplete timeline, non-DMABUF backend, or invalid
69+run also fails collection.
M flatpak/dev.bnema.Dumber.yml +3 -2
@@ -1,6 +1,7 @@
1 id: dev.bnema.Dumber1 id: dev.bnema.Dumber
2 runtime: org.gnome.Platform2 runtime: org.gnome.Platform
3 runtime-version: '49'3 runtime-version: '49'
4+# org.gnome.Platform provides libwebp at runtime for purego WebP favicon decoding.
4 sdk: org.gnome.Sdk5 sdk: org.gnome.Sdk
5 command: dumber6 command: dumber
6 7
@@ -53,10 +54,10 @@ modules:
53 path: dev.bnema.Dumber.desktop54 path: dev.bnema.Dumber.desktop
54 # Icons55 # Icons
55 - type: file56 - type: file
56- path: assets/logo.svg57+ path: ../assets/logo.svg
57 dest-filename: logo.svg58 dest-filename: logo.svg
58 - type: file59 - type: file
59- path: assets/logo-512.png60+ path: ../assets/logo-512.png
60 dest-filename: logo-512.png61 dest-filename: logo-512.png
61 # Metainfo62 # Metainfo
62 - type: file63 - type: file
M go.mod +2 -1
@@ -6,10 +6,11 @@ require (
6 github.com/a-h/templ v0.3.10206 github.com/a-h/templ v0.3.1020
7 github.com/andybalholm/brotli v1.2.27 github.com/andybalholm/brotli v1.2.2
8 github.com/bnema/purego v0.11.0-bnema.48 github.com/bnema/purego v0.11.0-bnema.4
9- github.com/bnema/purego-cef v0.13.49+ github.com/bnema/purego-cef v0.14.1
10 github.com/bnema/purego-cef2gtk v0.8.510 github.com/bnema/purego-cef2gtk v0.8.5
11 github.com/bnema/purego-pipewire v0.1.511 github.com/bnema/purego-pipewire v0.1.5
12 github.com/bnema/purego-sqlite v0.1.412 github.com/bnema/purego-sqlite v0.1.4
13+ github.com/bnema/purego-webp v0.2.1
13 github.com/bnema/puregotk v0.7.114 github.com/bnema/puregotk v0.7.1
14 github.com/charmbracelet/bubbles v1.0.015 github.com/charmbracelet/bubbles v1.0.0
15 github.com/charmbracelet/bubbletea v1.3.1016 github.com/charmbracelet/bubbletea v1.3.10
M go.sum +4 -2 generated file: collapsed
M internal/application/port/audio_output.go +4 -3
@@ -18,9 +18,10 @@ type AudioStreamFormat struct {
18 // AudioOutputStream represents an active audio output stream.18 // AudioOutputStream represents an active audio output stream.
19 // Implementations handle the low-level audio playback.19 // Implementations handle the low-level audio playback.
20 type AudioOutputStream interface {20 type AudioOutputStream interface {
21- // Write sends audio samples to the output device.21+ // Write sends callback-owned audio samples to the output device.
22- // Samples are provided as a 2D slice: [channel][frame]float32.22+ // Samples are provided as [channel][frame]float32 matching CEF's planar
23- // This matches CEF's planar float32 audio format.23+ // format. Implementations must not retain the slices and must be safe to
24+ // call concurrently with Close; audio callbacks must not block.
24 Write(samples [][]float32) error25 Write(samples [][]float32) error
25 // Close releases the audio stream and associated resources.26 // Close releases the audio stream and associated resources.
26 Close() error27 Close() error
M internal/application/usecase/favicon_test.go +19 -0
@@ -404,6 +404,25 @@ func TestFaviconObserveConversionErrorDoesNotMutateExistingAssets(t *testing.T)
404 }404 }
405 }405 }
406 406
407+func TestFaviconObserveUnavailableWebPPreservesCachedAssets(t *testing.T) {
408+ fx := newFaviconFixture(t)
409+ fx.seedOriginal([]byte("old"))
410+ fx.blobs.png["example.com"] = []byte("old-png")
411+ fx.blobs.sized["example.com"] = map[int][]byte{32: []byte("old-32")}
412+ fx.converter.errByContentType = map[string]error{"image/webp": ErrFaviconMiss}
413+
414+ _, err := fx.uc.Observe(context.Background(), "https://example.com", "https://example.com/favicon.webp", []byte("webp"), favicon.SourceEngine, "image/webp")
415+ if !errors.Is(err, ErrFaviconMiss) {
416+ t.Fatalf("Observe err=%v, want favicon miss", err)
417+ }
418+ if string(fx.blobs.original["example.com"]) != "old" || string(fx.blobs.png["example.com"]) != "old-png" || string(fx.blobs.sized["example.com"][32]) != "old-32" {
419+ t.Fatalf("cached assets changed after unavailable WebP decoder")
420+ }
421+ if fx.blobs.writeOriginals != 0 || fx.blobs.removeDerived != 0 || fx.invalidators.calls != 0 {
422+ t.Fatalf("unavailable decoder mutated cache: writes=%d removes=%d invalidations=%d", fx.blobs.writeOriginals, fx.blobs.removeDerived, fx.invalidators.calls)
423+ }
424+}
425+
407 func TestFaviconEnsureSizedPropagatesReadSizedErrors(t *testing.T) {426 func TestFaviconEnsureSizedPropagatesReadSizedErrors(t *testing.T) {
408 fx := newFaviconFixture(t)427 fx := newFaviconFixture(t)
409 fx.seedOriginal([]byte("old"))428 fx.seedOriginal([]byte("old"))
M internal/infrastructure/audio/pipewire/output.go +123 -123
@@ -15,7 +15,6 @@ import (
15 "github.com/bnema/dumber/internal/logging"15 "github.com/bnema/dumber/internal/logging"
16 )16 )
17 17
18-// Compile-time interface checks.
19 var (18 var (
20 _ port.AudioOutputFactory = (*Factory)(nil)19 _ port.AudioOutputFactory = (*Factory)(nil)
21 _ port.AudioOutputStream = (*playerStreamAdapter)(nil)20 _ port.AudioOutputStream = (*playerStreamAdapter)(nil)
@@ -27,6 +26,8 @@ var ErrInvalidFormat = errors.New("invalid audio stream format")
27 // ErrStreamClosed is returned when writing to a closed stream.26 // ErrStreamClosed is returned when writing to a closed stream.
28 var ErrStreamClosed = errors.New("audio stream closed")27 var ErrStreamClosed = errors.New("audio stream closed")
29 28
29+const packetQueueCapacity = 4
30+
30 // playerCreator is a function type that creates a Player from config and callbacks.31 // playerCreator is a function type that creates a Player from config and callbacks.
31 // This exists to allow dependency injection in tests.32 // This exists to allow dependency injection in tests.
32 type playerCreator func(config pwpipewire.PlayerConfig, callbacks pwpipewire.PlayerCallbacks) (pwpipewire.Player, error)33 type playerCreator func(config pwpipewire.PlayerConfig, callbacks pwpipewire.PlayerCallbacks) (pwpipewire.Player, error)
@@ -38,24 +39,15 @@ type Factory struct {
38 }39 }
39 40
40 // NewFactory creates a new PipeWire audio factory.41 // NewFactory creates a new PipeWire audio factory.
41-// The factory is stateless; players are created per-stream.
42 func NewFactory() (*Factory, error) {42 func NewFactory() (*Factory, error) {
43- return &Factory{43+ return &Factory{createPlayer: pwpipewire.NewPlayer}, nil
44- createPlayer: pwpipewire.NewPlayer,
45- }, nil
46 }44 }
47 45
48-// NewStream creates a new audio output stream with the given format.46+// NewStream creates a push-to-pull adapter for a PipeWire player.
49-// It validates the Dumber format, creates a purego-pipewire Player configured
50-// from the format, and returns a push-to-pull adapter that bridges Write()
51-// calls into the Player's Fill callback.
52 func (f *Factory) NewStream(ctx context.Context, format port.AudioStreamFormat) (port.AudioOutputStream, error) {47 func (f *Factory) NewStream(ctx context.Context, format port.AudioStreamFormat) (port.AudioOutputStream, error) {
53- // Check context cancellation
54 if ctx.Err() != nil {48 if ctx.Err() != nil {
55 return nil, ctx.Err()49 return nil, ctx.Err()
56 }50 }
57-
58- // Validate format
59 if err := validateFormat(format); err != nil {51 if err := validateFormat(format); err != nil {
60 return nil, err52 return nil, err
61 }53 }
@@ -67,182 +59,195 @@ func (f *Factory) NewStream(ctx context.Context, format port.AudioStreamFormat)
67 Int("frames_per_buffer", format.FramesPerBuffer).59 Int("frames_per_buffer", format.FramesPerBuffer).
68 Msg("pipewire: creating player")60 Msg("pipewire: creating player")
69 61
70- // Create the adapter with its ring buffer before creating the player,62+ // Every slot owns storage sized from the negotiated CEF format. Write copies
71- // because the Fill callback captures the adapter.63+ // directly into an available slot; Fill copies that same slot into PipeWire.
72- adapter := &playerStreamAdapter{64+ // Callback slices are therefore never queued or retained.
73- ctx: ctx,65+ adapter := newPlayerStreamAdapter(ctx, format.ChannelCount, format.FramesPerBuffer)
74- buf: make(chan [][]float32, 4), // small buffer for push→pull bridging
75- closed: make(chan struct{}),
76- }
77-
78 config := pwpipewire.PlayerConfig{66 config := pwpipewire.PlayerConfig{
79- SampleRate: format.SampleRate,67+ SampleRate: format.SampleRate, Channels: format.ChannelCount, FramesPerBuffer: format.FramesPerBuffer,
80- Channels: format.ChannelCount,68+ SampleFormat: pwpipewire.SampleFormatF32, UnderrunPolicy: pwpipewire.UnderrunFillSilence,
81- FramesPerBuffer: format.FramesPerBuffer,
82- SampleFormat: pwpipewire.SampleFormatF32,
83- UnderrunPolicy: pwpipewire.UnderrunFillSilence,
84 }69 }
85-70+ player, err := f.createPlayer(config, pwpipewire.PlayerCallbacks{Fill: adapter.fill})
86- callbacks := pwpipewire.PlayerCallbacks{
87- Fill: adapter.fill,
88- }
89-
90- player, err := f.createPlayer(config, callbacks)
91 if err != nil {71 if err != nil {
92 log.Warn().Err(err).Msg("pipewire: failed to create player")72 log.Warn().Err(err).Msg("pipewire: failed to create player")
93 return nil, fmt.Errorf("failed to create pipewire player: %w", err)73 return nil, fmt.Errorf("failed to create pipewire player: %w", err)
94 }74 }
95 adapter.player = player75 adapter.player = player
96-
97- // Start the player so the Fill callback begins being called.
98 if err := player.Start(); err != nil {76 if err := player.Start(); err != nil {
99 _ = player.Close()77 _ = player.Close()
100 log.Warn().Err(err).Msg("pipewire: failed to start player")78 log.Warn().Err(err).Msg("pipewire: failed to start player")
101 return nil, fmt.Errorf("failed to start pipewire player: %w", err)79 return nil, fmt.Errorf("failed to start pipewire player: %w", err)
102 }80 }
103-
104 log.Info().Msg("pipewire: player started successfully")81 log.Info().Msg("pipewire: player started successfully")
105-
106 return adapter, nil82 return adapter, nil
107 }83 }
108 84
109-// validateFormat checks if the audio format parameters are valid.
110 func validateFormat(format port.AudioStreamFormat) error {85 func validateFormat(format port.AudioStreamFormat) error {
111- // Sample rate must be reasonable (8kHz to 384kHz)
112 if format.SampleRate < 8000 || format.SampleRate > 384000 {86 if format.SampleRate < 8000 || format.SampleRate > 384000 {
113- return fmt.Errorf("%w: sample rate %d Hz out of range [8000, 384000]",87+ return fmt.Errorf("%w: sample rate %d Hz out of range [8000, 384kHz]", ErrInvalidFormat, format.SampleRate)
114- ErrInvalidFormat, format.SampleRate)
115 }88 }
116-
117- // Channel count must be 1 to 1024 (reasonable upper limit)
118 if format.ChannelCount < 1 || format.ChannelCount > 1024 {89 if format.ChannelCount < 1 || format.ChannelCount > 1024 {
119- return fmt.Errorf("%w: channel count %d out of range [1, 1024]",90+ return fmt.Errorf("%w: channel count %d out of range [1, 1024]", ErrInvalidFormat, format.ChannelCount)
120- ErrInvalidFormat, format.ChannelCount)
121 }91 }
122-
123- // Frames per buffer must be positive and reasonable
124 if format.FramesPerBuffer < 1 || format.FramesPerBuffer > 8192 {92 if format.FramesPerBuffer < 1 || format.FramesPerBuffer > 8192 {
125- return fmt.Errorf("%w: frames per buffer %d out of range [1, 8192]",93+ return fmt.Errorf("%w: frames per buffer %d out of range [1, 8192]", ErrInvalidFormat, format.FramesPerBuffer)
126- ErrInvalidFormat, format.FramesPerBuffer)
127 }94 }
128-
129 return nil95 return nil
130 }96 }
131 97
132-// playerStreamAdapter bridges Dumber's push-style Write(samples) interface98+// audioPacket is a reusable, adapter-owned packet slot. Its backing arrays are
133-// to purego-pipewire's pull-style Fill callback.99+// allocated only when the stream is created and never alias CEF callback data.
134-//100+type audioPacket struct {
135-// Write() sends sample buffers into a channel. The Fill callback drains them101+ samples [][]float32
136-// into the PCMBuffer that purego-pipewire passes in. If no data is available,102+ channels int
137-// the player's UnderrunFillSilence policy handles it.103+ frames int
104+}
105+
106+// playerStreamAdapter bridges callback-owned CEF audio to PipeWire's Fill
107+// callback. available and ready form a bounded reusable packet ring.
138 type playerStreamAdapter struct {108 type playerStreamAdapter struct {
139 player pwpipewire.Player109 player pwpipewire.Player
140 ctx context.Context110 ctx context.Context
141- buf chan [][]float32 // push→pull handoff channel111+
112+ available chan *audioPacket
113+ ready chan *audioPacket
114+ // TryLock serializes only simultaneous producers. The CEF callback never
115+ // waits: contention and saturation both drop the packet. Fill never takes it.
116+ writeMu sync.Mutex
117+ closed atomic.Bool
142 118
143 closeOnce sync.Once119 closeOnce sync.Once
144- closed chan struct{} // closed when Close is called
145 120
146- // Diagnostic counters (atomic, no mutex needed).121+ fillCount atomic.Uint64
147- fillCount atomic.Uint64 // total Fill callbacks122+ underrunCount atomic.Uint64
148- underrunCount atomic.Uint64 // Fill callbacks with no data available123+ dropCount atomic.Uint64
149- dropCount atomic.Uint64 // Write calls that dropped data (buffer full)124+}
125+
126+func newPlayerStreamAdapter(ctx context.Context, channels, frames int) *playerStreamAdapter {
127+ a := &playerStreamAdapter{
128+ ctx: ctx, available: make(chan *audioPacket, packetQueueCapacity), ready: make(chan *audioPacket, packetQueueCapacity),
129+ }
130+ for range packetQueueCapacity {
131+ packet := &audioPacket{samples: make([][]float32, channels)}
132+ for ch := range packet.samples {
133+ packet.samples[ch] = make([]float32, frames)
134+ }
135+ a.available <- packet
136+ }
137+ return a
150 }138 }
151 139
152-// Write sends audio samples to the PipeWire output.140+// Write makes exactly one owned copy for each accepted packet. It never blocks:
153-// Samples are provided as [channel][frame]float32 matching CEF's planar format.141+// full queues or another producer cause a silent drop, preserving CEF timing.
154-// If the internal buffer is full the packet is dropped silently so the caller142+// The supplied slices are callback-owned and are not retained after return.
155-// (CEF audio callback thread) is never blocked.
156-// Safe for concurrent use with Close.
157 func (a *playerStreamAdapter) Write(samples [][]float32) error {143 func (a *playerStreamAdapter) Write(samples [][]float32) error {
158- // Fast path: already closed.144+ if a.closed.Load() {
159- select {
160- case <-a.closed:
161 return ErrStreamClosed145 return ErrStreamClosed
162- default:
163 }146 }
164- // Non-blocking send: drop packet when buffer is full.147+ if !a.writeMu.TryLock() {
165- select {148+ a.recordDrop()
166- case a.buf <- samples:
167 return nil149 return nil
168- case <-a.closed:150+ }
151+ defer a.writeMu.Unlock()
152+ if a.closed.Load() {
169 return ErrStreamClosed153 return ErrStreamClosed
170- default:154+ }
171- // Buffer full — drop to avoid blocking the CEF audio thread.155+
172- n := a.dropCount.Add(1)156+ select {
173- if n == 1 || n&(n-1) == 0 { // first drop, then powers of two157+ case packet := <-a.available:
174- if a.ctx != nil {158+ copyPacket(packet, samples)
175- logging.FromContext(a.ctx).Warn().159+ // A close racing after the first check must not publish a packet to a
176- Uint64("total_drops", n).160+ // stopped player. Returning the slot keeps the ring bounded.
177- Msg("pipewire: dropped audio packet (buffer full)")161+ if a.closed.Load() {
178- }162+ a.available <- packet
163+ return ErrStreamClosed
164+ }
165+ select {
166+ case a.ready <- packet:
167+ return nil
168+ default:
169+ // This cannot occur while ring invariants hold, but keep Write
170+ // non-blocking if an implementation change violates them.
171+ a.available <- packet
172+ a.recordDrop()
173+ return nil
179 }174 }
175+ default:
176+ a.recordDrop()
180 return nil177 return nil
181 }178 }
182 }179 }
183 180
184-// fill is the PlayerCallbacks.Fill function called by purego-pipewire181+func copyPacket(packet *audioPacket, samples [][]float32) {
185-// when it needs audio data. It pulls from the internal buffer channel.182+ packet.channels = min(len(packet.samples), len(samples))
183+ packet.frames = 0
184+ if packet.channels == 0 {
185+ return
186+ }
187+ frames := len(packet.samples[0])
188+ for ch := 0; ch < packet.channels; ch++ {
189+ n := min(len(packet.samples[ch]), len(samples[ch]))
190+ if n < frames {
191+ frames = n
192+ }
193+ }
194+ packet.frames = frames
195+ for ch := 0; ch < packet.channels; ch++ {
196+ copy(packet.samples[ch][:frames], samples[ch][:frames])
197+ }
198+}
199+
200+func (a *playerStreamAdapter) recordDrop() {
201+ // Write runs on CEF's real-time callback thread. Diagnostics must stay
202+ // allocation-free and must not synchronously emit logs from that path.
203+ a.dropCount.Add(1)
204+}
205+
206+// fill is the PlayerCallbacks.Fill function. It performs the only post-handoff
207+// full-packet copy, directly into PipeWire's destination buffer.
186 func (a *playerStreamAdapter) fill(pcm *pwpipewire.PCMBuffer) (int, error) {208 func (a *playerStreamAdapter) fill(pcm *pwpipewire.PCMBuffer) (int, error) {
187 select {209 select {
188- case samples := <-a.buf:210+ case packet := <-a.ready:
189 fillNum := a.fillCount.Add(1)211 fillNum := a.fillCount.Add(1)
190 if fillNum == 1 && a.ctx != nil {212 if fillNum == 1 && a.ctx != nil {
191- logging.FromContext(a.ctx).Info().213+ logging.FromContext(a.ctx).Info().Msg("pipewire: first Fill callback with data")
192- Msg("pipewire: first Fill callback with data")
193 }214 }
194- // Copy available samples into the PCM buffer215+ copied := copyToPCM(pcm, packet.samples[:packet.channels], packet.frames)
195- copied := copyToPCM(pcm, samples)216+ a.available <- packet
196 return copied, nil217 return copied, nil
197 default:218 default:
198- // No data available — return 0 frames, let underrun policy handle it
199 underruns := a.underrunCount.Add(1)219 underruns := a.underrunCount.Add(1)
200 if underruns == 1 && a.ctx != nil {220 if underruns == 1 && a.ctx != nil {
201- logging.FromContext(a.ctx).Debug().221+ logging.FromContext(a.ctx).Debug().Msg("pipewire: first Fill underrun (no data available)")
202- Msg("pipewire: first Fill underrun (no data available)")
203 }222 }
204 return 0, nil223 return 0, nil
205 }224 }
206 }225 }
207 226
208-// copyToPCM copies planar samples into the PCMBuffer.227+func copyToPCM(pcm *pwpipewire.PCMBuffer, samples [][]float32, packetFrames int) int {
209-// Returns the number of frames actually copied.228+ if pcm == nil || len(samples) == 0 || packetFrames == 0 {
210-func copyToPCM(pcm *pwpipewire.PCMBuffer, samples [][]float32) int {
211- if pcm == nil || len(samples) == 0 {
212 return 0229 return 0
213 }230 }
214-
215- frames := pcm.Frames
216 channels := min(min(len(samples), pcm.Channels), len(pcm.Samples))231 channels := min(min(len(samples), pcm.Channels), len(pcm.Samples))
217 if channels == 0 {232 if channels == 0 {
218 return 0233 return 0
219 }234 }
220-235+ frames := min(pcm.Frames, packetFrames)
221- minFrames := frames236+ for ch := 0; ch < channels; ch++ {
222-237+ frames = min(frames, min(len(pcm.Samples[ch]), len(samples[ch])))
223- for ch := 0; ch < channels && ch < len(samples); ch++ {
224- dst := pcm.Samples[ch]
225- src := samples[ch]
226- if len(dst) == 0 || len(src) == 0 {
227- minFrames = 0
228- continue
229- }
230- srcFrames := len(src)
231- n := min(len(dst), min(srcFrames, frames))
232- if n < minFrames {
233- minFrames = n
234- }
235- copy(dst[:n], src[:n])
236 }238 }
237-239+ for ch := 0; ch < channels; ch++ {
238- return minFrames240+ copy(pcm.Samples[ch][:frames], samples[ch][:frames])
241+ }
242+ return frames
239 }243 }
240 244
241-// Close stops the player and releases resources.245+// Close stops the player. It is safe with Write: Write observes closed before
242-// Safe to call multiple times.246+// publishing, and no channel is closed while a callback may be selecting on it.
243 func (a *playerStreamAdapter) Close() error {247 func (a *playerStreamAdapter) Close() error {
244 var err error248 var err error
245 a.closeOnce.Do(func() {249 a.closeOnce.Do(func() {
250+ a.closed.Store(true)
246 if a.ctx != nil {251 if a.ctx != nil {
247 logging.FromContext(a.ctx).Info().252 logging.FromContext(a.ctx).Info().
248 Uint64("fills", a.fillCount.Load()).253 Uint64("fills", a.fillCount.Load()).
@@ -250,12 +255,7 @@ func (a *playerStreamAdapter) Close() error {
250 Uint64("drops", a.dropCount.Load()).255 Uint64("drops", a.dropCount.Load()).
251 Msg("pipewire: closing player")256 Msg("pipewire: closing player")
252 }257 }
253-
254- // Signal writers to stop
255- close(a.closed)
256-
257 if a.player != nil {258 if a.player != nil {
258- // Stop playback then close the player
259 if stopErr := a.player.Stop(); stopErr != nil {259 if stopErr := a.player.Stop(); stopErr != nil {
260 err = stopErr260 err = stopErr
261 }261 }
M internal/infrastructure/audio/pipewire/output_test.go +139 -494
@@ -1,11 +1,11 @@
1-// Package pipewire provides a PipeWire-based audio output implementation.
2-// This wraps purego-pipewire's high-level Player API.
3 package pipewire1 package pipewire
4 2
5 import (3 import (
6 "context"4 "context"
7 "errors"5 "errors"
6+ "sync"
8 "testing"7 "testing"
8+ "time"
9 9
10 "github.com/bnema/dumber/internal/application/port"10 "github.com/bnema/dumber/internal/application/port"
11 pipewiremocks "github.com/bnema/dumber/internal/infrastructure/audio/pipewire/mocks"11 pipewiremocks "github.com/bnema/dumber/internal/infrastructure/audio/pipewire/mocks"
@@ -16,548 +16,193 @@ import (
16 16
17 type playerRecorder struct {17 type playerRecorder struct {
18 *pipewiremocks.MockPlayer18 *pipewiremocks.MockPlayer
19-19+ started, stopped, closedFlag bool
20- started bool20+ startErr, stopErr, closeErr error
21- paused bool
22- stopped bool
23- closedFlag bool
24- state pwpipewire.PlayerState
25-
26- startErr error
27- stopErr error
28- closeErr error
29 }21 }
30 22
31 func newPlayerRecorder(t *testing.T) *playerRecorder {23 func newPlayerRecorder(t *testing.T) *playerRecorder {
32 t.Helper()24 t.Helper()
33-25+ p := &playerRecorder{MockPlayer: pipewiremocks.NewMockPlayer(t)}
34- recorder := &playerRecorder{MockPlayer: pipewiremocks.NewMockPlayer(t)}26+ p.EXPECT().Start().RunAndReturn(func() error {
35- recorder.EXPECT().Start().RunAndReturn(recorder.start).Maybe()27+ if p.startErr != nil {
36- recorder.EXPECT().Pause().RunAndReturn(recorder.pause).Maybe()28+ return p.startErr
37- recorder.EXPECT().Stop().RunAndReturn(recorder.stop).Maybe()29+ }
38- recorder.EXPECT().Close().RunAndReturn(recorder.close).Maybe()30+ p.started = true
39- recorder.EXPECT().State().RunAndReturn(recorder.currentState).Maybe()31+ return nil
40- return recorder32+ }).Maybe()
41-}33+ p.EXPECT().Stop().RunAndReturn(func() error {
42-34+ if p.stopErr != nil {
43-func (p *playerRecorder) start() error {35+ return p.stopErr
44- if p.startErr != nil {36+ }
45- return p.startErr37+ p.stopped = true
46- }38+ return nil
47- p.started = true39+ }).Maybe()
48- p.state = pwpipewire.PlayerStatePlaying40+ p.EXPECT().Close().RunAndReturn(func() error {
49- return nil41+ if p.closeErr != nil {
50-}42+ return p.closeErr
51-43+ }
52-func (p *playerRecorder) pause() error {44+ p.closedFlag = true
53- p.paused = true45+ return nil
54- p.state = pwpipewire.PlayerStatePaused46+ }).Maybe()
55- return nil47+ return p
56-}
57-
58-func (p *playerRecorder) stop() error {
59- if p.stopErr != nil {
60- return p.stopErr
61- }
62- p.stopped = true
63- p.state = pwpipewire.PlayerStateStopped
64- return nil
65 }48 }
66 49
67-func (p *playerRecorder) close() error {50+func testAdapter(frames int) *playerStreamAdapter {
68- if p.closeErr != nil {51+ return newPlayerStreamAdapter(context.Background(), 2, frames)
69- return p.closeErr
70- }
71- p.closedFlag = true
72- p.state = pwpipewire.PlayerStateClosed
73- return nil
74 }52 }
75 53
76-func (p *playerRecorder) currentState() pwpipewire.PlayerState {54+func stereoPCM(frames int) *pwpipewire.PCMBuffer {
77- return p.state55+ return &pwpipewire.PCMBuffer{Frames: frames, Channels: 2, Samples: [][]float32{make([]float32, frames), make([]float32, frames)}}
78 }56 }
79 57
80-// --- Factory tests ---58+func TestFactory_NewStreamValidatesAndStartsPlayer(t *testing.T) {
81-
82-func TestFactory_NewStream_ValidFormat_CreatesStream(t *testing.T) {
83- var capturedConfig pwpipewire.PlayerConfig
84 player := newPlayerRecorder(t)59 player := newPlayerRecorder(t)
85-60+ factory := &Factory{createPlayer: func(config pwpipewire.PlayerConfig, callbacks pwpipewire.PlayerCallbacks) (pwpipewire.Player, error) {
86- factory := &Factory{61+ assert.Equal(t, 48000, config.SampleRate)
87- createPlayer: func(config pwpipewire.PlayerConfig, _ pwpipewire.PlayerCallbacks) (pwpipewire.Player, error) {62+ assert.Equal(t, 2, config.Channels)
88- capturedConfig = config63+ assert.NotNil(t, callbacks.Fill)
89- return player, nil64+ return player, nil
90- },65+ }}
91- }66+ stream, err := factory.NewStream(context.Background(), port.AudioStreamFormat{SampleRate: 48000, ChannelCount: 2, FramesPerBuffer: 512})
92-
93- format := port.AudioStreamFormat{
94- SampleRate: 48000,
95- ChannelCount: 2,
96- FramesPerBuffer: 512,
97- }
98-
99- stream, err := factory.NewStream(context.Background(), format)
100 require.NoError(t, err)67 require.NoError(t, err)
101- require.NotNil(t, stream)
102-
103- // Verify config mapping
104- assert.Equal(t, 48000, capturedConfig.SampleRate)
105- assert.Equal(t, 2, capturedConfig.Channels)
106- assert.Equal(t, 512, capturedConfig.FramesPerBuffer)
107- assert.Equal(t, pwpipewire.SampleFormatF32, capturedConfig.SampleFormat)
108- assert.Equal(t, pwpipewire.UnderrunFillSilence, capturedConfig.UnderrunPolicy)
109-
110- // Verify player was started
111 assert.True(t, player.started)68 assert.True(t, player.started)
112-
113- // Clean up
114 require.NoError(t, stream.Close())69 require.NoError(t, stream.Close())
115 }70 }
116 71
117-func TestFactory_NewStream_InvalidFormat_ReturnsError(t *testing.T) {72+func TestFactory_NewStreamRejectsInvalidFormat(t *testing.T) {
118- factory := &Factory{73+ factory := &Factory{createPlayer: func(pwpipewire.PlayerConfig, pwpipewire.PlayerCallbacks) (pwpipewire.Player, error) {
119- createPlayer: func(_ pwpipewire.PlayerConfig, _ pwpipewire.PlayerCallbacks) (pwpipewire.Player, error) {74+ t.Fatal("unexpected player")
120- t.Fatal("createPlayer should not be called for invalid format")75+ return nil, nil
121- return nil, nil76+ }}
122- },77+ for _, format := range []port.AudioStreamFormat{
123- }78+ {SampleRate: 0, ChannelCount: 2, FramesPerBuffer: 512},
124-79+ {SampleRate: 48000, ChannelCount: 0, FramesPerBuffer: 512},
125- tests := []struct {80+ {SampleRate: 48000, ChannelCount: 2, FramesPerBuffer: 0},
126- name string81+ } {
127- format port.AudioStreamFormat82+ stream, err := factory.NewStream(context.Background(), format)
128- }{83+ require.ErrorIs(t, err, ErrInvalidFormat)
129- {84+ assert.Nil(t, stream)
130- name: "zero sample rate",
131- format: port.AudioStreamFormat{SampleRate: 0, ChannelCount: 2, FramesPerBuffer: 512},
132- },
133- {
134- name: "zero channels",
135- format: port.AudioStreamFormat{SampleRate: 48000, ChannelCount: 0, FramesPerBuffer: 512},
136- },
137- {
138- name: "negative sample rate",
139- format: port.AudioStreamFormat{SampleRate: -48000, ChannelCount: 2, FramesPerBuffer: 512},
140- },
141- {
142- name: "negative channels",
143- format: port.AudioStreamFormat{SampleRate: 48000, ChannelCount: -2, FramesPerBuffer: 512},
144- },
145- {
146- name: "excessive sample rate",
147- format: port.AudioStreamFormat{SampleRate: 1000000, ChannelCount: 2, FramesPerBuffer: 512},
148- },
149- {
150- name: "excessive channels",
151- format: port.AudioStreamFormat{SampleRate: 48000, ChannelCount: 1025, FramesPerBuffer: 512},
152- },
153- {
154- name: "zero frames per buffer",
155- format: port.AudioStreamFormat{SampleRate: 48000, ChannelCount: 2, FramesPerBuffer: 0},
156- },
157- {
158- name: "excessive frames per buffer",
159- format: port.AudioStreamFormat{SampleRate: 48000, ChannelCount: 2, FramesPerBuffer: 10000},
160- },
161- }
162-
163- for _, tt := range tests {
164- t.Run(tt.name, func(t *testing.T) {
165- stream, err := factory.NewStream(context.Background(), tt.format)
166- require.Error(t, err)
167- assert.Nil(t, stream)
168- assert.ErrorIs(t, err, ErrInvalidFormat)
169- })
170- }
171-}
172-
173-func TestFactory_NewStream_PlayerCreationFails_ReturnsError(t *testing.T) {
174- expectedErr := errors.New("pipewire not available")
175- factory := &Factory{
176- createPlayer: func(_ pwpipewire.PlayerConfig, _ pwpipewire.PlayerCallbacks) (pwpipewire.Player, error) {
177- return nil, expectedErr
178- },
179 }85 }
180-
181- format := port.AudioStreamFormat{SampleRate: 48000, ChannelCount: 2, FramesPerBuffer: 512}
182- stream, err := factory.NewStream(context.Background(), format)
183-
184- require.Error(t, err)
185- assert.Nil(t, stream)
186- assert.ErrorIs(t, err, expectedErr)
187 }86 }
188 87
189-func TestFactory_NewStream_PlayerStartFails_ClosesAndReturnsError(t *testing.T) {88+func TestFactory_NewStreamClosesPlayerWhenStartFails(t *testing.T) {
190 player := newPlayerRecorder(t)89 player := newPlayerRecorder(t)
191 player.startErr = errors.New("start failed")90 player.startErr = errors.New("start failed")
192- factory := &Factory{91+ factory := &Factory{createPlayer: func(pwpipewire.PlayerConfig, pwpipewire.PlayerCallbacks) (pwpipewire.Player, error) {
193- createPlayer: func(_ pwpipewire.PlayerConfig, _ pwpipewire.PlayerCallbacks) (pwpipewire.Player, error) {92+ return player, nil
194- return player, nil93+ }}
195- },94+ stream, err := factory.NewStream(context.Background(), port.AudioStreamFormat{SampleRate: 48000, ChannelCount: 2, FramesPerBuffer: 512})
196- }
197-
198- format := port.AudioStreamFormat{SampleRate: 48000, ChannelCount: 2, FramesPerBuffer: 512}
199- stream, err := factory.NewStream(context.Background(), format)
200-
201- require.Error(t, err)
202- assert.Nil(t, stream)
203- assert.True(t, player.closedFlag, "player should be closed after start failure")
204-}
205-
206-func TestFactory_NewStream_ContextCancelled_ReturnsError(t *testing.T) {
207- factory := &Factory{
208- createPlayer: func(_ pwpipewire.PlayerConfig, _ pwpipewire.PlayerCallbacks) (pwpipewire.Player, error) {
209- t.Fatal("createPlayer should not be called when context is canceled")
210- return nil, nil
211- },
212- }
213-
214- ctx, cancel := context.WithCancel(context.Background())
215- cancel()
216-
217- format := port.AudioStreamFormat{SampleRate: 48000, ChannelCount: 2, FramesPerBuffer: 512}
218- stream, err := factory.NewStream(ctx, format)
219-
220 require.Error(t, err)95 require.Error(t, err)
221 assert.Nil(t, stream)96 assert.Nil(t, stream)
222-}
223-
224-func TestFactory_NewStream_MultipleFormats_Accepted(t *testing.T) {
225- formats := []port.AudioStreamFormat{
226- {SampleRate: 48000, ChannelCount: 2, FramesPerBuffer: 512},
227- {SampleRate: 44100, ChannelCount: 2, FramesPerBuffer: 512},
228- {SampleRate: 48000, ChannelCount: 1, FramesPerBuffer: 256},
229- {SampleRate: 96000, ChannelCount: 2, FramesPerBuffer: 1024},
230- {SampleRate: 48000, ChannelCount: 6, FramesPerBuffer: 512},
231- }
232-
233- for _, fmt := range formats {
234- t.Run("", func(t *testing.T) {
235- factory := &Factory{
236- createPlayer: func(config pwpipewire.PlayerConfig, _ pwpipewire.PlayerCallbacks) (pwpipewire.Player, error) {
237- assert.Equal(t, fmt.SampleRate, config.SampleRate)
238- assert.Equal(t, fmt.ChannelCount, config.Channels)
239- assert.Equal(t, fmt.FramesPerBuffer, config.FramesPerBuffer)
240- return newPlayerRecorder(t), nil
241- },
242- }
243-
244- stream, err := factory.NewStream(context.Background(), fmt)
245- require.NoError(t, err)
246- assert.NotNil(t, stream)
247-
248- _ = stream.Close()
249- })
250- }
251-}
252-
253-func TestFactory_ImplementsPortInterface(_ *testing.T) {
254- var _ port.AudioOutputFactory = (*Factory)(nil)
255-}
256-
257-// --- Stream adapter tests ---
258-
259-func TestPlayerStreamAdapter_Write_SendsToBuffer(t *testing.T) {
260- player := newPlayerRecorder(t)
261- adapter := &playerStreamAdapter{
262- player: player,
263- buf: make(chan [][]float32, 4),
264- closed: make(chan struct{}),
265- }
266-
267- samples := [][]float32{
268- {0.1, 0.2},
269- {0.3, 0.4},
270- }
271-
272- err := adapter.Write(samples)
273- require.NoError(t, err)
274-
275- // Verify data is in the channel
276- received := <-adapter.buf
277- assert.Equal(t, samples, received)
278-}
279-
280-func TestPlayerStreamAdapter_Write_AfterClose_ReturnsError(t *testing.T) {
281- player := newPlayerRecorder(t)
282- adapter := &playerStreamAdapter{
283- player: player,
284- buf: make(chan [][]float32, 4),
285- closed: make(chan struct{}),
286- }
287-
288- require.NoError(t, adapter.Close())
289-
290- err := adapter.Write([][]float32{{0.1}})
291- assert.ErrorIs(t, err, ErrStreamClosed)
292-}
293-
294-func TestPlayerStreamAdapter_Close_StopsAndClosesPlayer(t *testing.T) {
295- player := newPlayerRecorder(t)
296- adapter := &playerStreamAdapter{
297- player: player,
298- buf: make(chan [][]float32, 4),
299- closed: make(chan struct{}),
300- }
301-
302- err := adapter.Close()
303- require.NoError(t, err)
304-
305- assert.True(t, player.stopped)
306 assert.True(t, player.closedFlag)97 assert.True(t, player.closedFlag)
307 }98 }
308 99
309-func TestPlayerStreamAdapter_Close_IsIdempotent(t *testing.T) {100+func TestPlayerStreamAdapter_FillGetsOwnedPacketAfterCallbackMutation(t *testing.T) {
310- player := newPlayerRecorder(t)101+ adapter := testAdapter(3)
311- adapter := &playerStreamAdapter{102+ samples := [][]float32{{1, 2, 3}, {4, 5, 6}}
312- player: player,103+ require.NoError(t, adapter.Write(samples))
313- buf: make(chan [][]float32, 4),104+ samples[0][0], samples[1][0] = 99, 99
314- closed: make(chan struct{}),105+ pcm := stereoPCM(3)
315- }
316-
317- require.NoError(t, adapter.Close())
318- require.NoError(t, adapter.Close())
319-}
320-
321-func TestPlayerStreamAdapter_Close_PropagatesStopError(t *testing.T) {
322- player := newPlayerRecorder(t)
323- player.stopErr = errors.New("stop error")
324- adapter := &playerStreamAdapter{
325- player: player,
326- buf: make(chan [][]float32, 4),
327- closed: make(chan struct{}),
328- }
329-
330- err := adapter.Close()
331- require.Error(t, err)
332- assert.Contains(t, err.Error(), "stop error")
333-}
334-
335-func TestPlayerStreamAdapter_Close_PropagatesCloseError(t *testing.T) {
336- player := newPlayerRecorder(t)
337- player.closeErr = errors.New("close error")
338- adapter := &playerStreamAdapter{
339- player: player,
340- buf: make(chan [][]float32, 4),
341- closed: make(chan struct{}),
342- }
343-
344- err := adapter.Close()
345- require.Error(t, err)
346- assert.Contains(t, err.Error(), "close error")
347-}
348-
349-// --- Fill callback tests ---
350-
351-func TestFill_PullsFromBuffer(t *testing.T) {
352- adapter := &playerStreamAdapter{
353- buf: make(chan [][]float32, 4),
354- }
355-
356- // Push samples
357- samples := [][]float32{
358- {0.1, 0.2, 0.3},
359- {0.4, 0.5, 0.6},
360- }
361- adapter.buf <- samples
362-
363- // Simulate Fill call
364- pcm := &pwpipewire.PCMBuffer{
365- Frames: 3,
366- Channels: 2,
367- Stride: 1,
368- Samples: [][]float32{
369- make([]float32, 3),
370- make([]float32, 3),
371- },
372- }
373-
374 frames, err := adapter.fill(pcm)106 frames, err := adapter.fill(pcm)
375 require.NoError(t, err)107 require.NoError(t, err)
376 assert.Equal(t, 3, frames)108 assert.Equal(t, 3, frames)
377- assert.InEpsilon(t, 0.1, pcm.Samples[0][0], 0.0001)109+ assert.InEpsilon(t, 1, pcm.Samples[0][0], 0.0001)
378- assert.InEpsilon(t, 0.4, pcm.Samples[1][0], 0.0001)110+ assert.InEpsilon(t, 4, pcm.Samples[1][0], 0.0001)
379-}
380-
381-func TestFill_ReturnsZeroWhenEmpty(t *testing.T) {
382- adapter := &playerStreamAdapter{
383- buf: make(chan [][]float32, 4),
384- }
385-
386- pcm := &pwpipewire.PCMBuffer{
387- Frames: 256,
388- Channels: 2,
389- Stride: 1,
390- Samples: [][]float32{
391- make([]float32, 256),
392- make([]float32, 256),
393- },
394- }
395-
396- frames, err := adapter.fill(pcm)
397- require.NoError(t, err)
398- assert.Equal(t, 0, frames)
399-}
400-
401-func TestCopyToPCM_HandlesChannelMismatch(t *testing.T) {
402- // More source channels than PCM channels — truncates
403- pcm := &pwpipewire.PCMBuffer{
404- Frames: 2,
405- Channels: 1,
406- Samples: [][]float32{make([]float32, 2)},
407- }
408- samples := [][]float32{
409- {1.0, 2.0},
410- {3.0, 4.0}, // should be ignored
411- }
412- frames := copyToPCM(pcm, samples)
413- assert.Equal(t, 2, frames)
414- assert.InEpsilon(t, 1.0, pcm.Samples[0][0], 0.0001)
415 }111 }
416 112
417-func TestCopyToPCM_HandlesFrameMismatch(t *testing.T) {113+func TestPlayerStreamAdapter_SaturationDropsWithoutBlocking(t *testing.T) {
418- // Fewer source frames than PCM expects114+ adapter := testAdapter(2)
419- pcm := &pwpipewire.PCMBuffer{115+ for i := 0; i < packetQueueCapacity; i++ {
420- Frames: 4,116+ require.NoError(t, adapter.Write([][]float32{{float32(i)}, {float32(i)}}))
421- Channels: 1,
422- Samples: [][]float32{make([]float32, 4)},
423 }117 }
424- samples := [][]float32{118+ done := make(chan error, 1)
425- {1.0, 2.0}, // only 2 frames119+ go func() { done <- adapter.Write([][]float32{{99}, {99}}) }()
120+ select {
121+ case err := <-done:
122+ require.NoError(t, err)
123+ case <-time.After(time.Second):
124+ t.Fatal("Write blocked while the bounded queue was saturated")
426 }125 }
427- frames := copyToPCM(pcm, samples)126+ assert.Equal(t, uint64(1), adapter.dropCount.Load())
428- assert.Equal(t, 2, frames) // reports partial fill127+ assert.Len(t, adapter.ready, packetQueueCapacity)
429-}
430-
431-func TestCopyToPCM_NilPCM_ReturnsZero(t *testing.T) {
432- assert.Equal(t, 0, copyToPCM(nil, [][]float32{{1.0}}))
433 }128 }
434 129
435-func TestCopyToPCM_EmptySamples_ReturnsZero(t *testing.T) {130+func TestPlayerStreamAdapter_EmptyFillCountsUnderrun(t *testing.T) {
436- pcm := &pwpipewire.PCMBuffer{Frames: 1, Channels: 1, Samples: [][]float32{make([]float32, 1)}}131+ adapter := testAdapter(2)
437- assert.Equal(t, 0, copyToPCM(pcm, nil))132+ frames, err := adapter.fill(stereoPCM(2))
438- assert.Equal(t, 0, copyToPCM(pcm, [][]float32{}))133+ require.NoError(t, err)
134+ assert.Zero(t, frames)
135+ assert.Equal(t, uint64(1), adapter.underrunCount.Load())
439 }136 }
440 137
441-// --- Non-blocking Write tests ---138+func TestPlayerStreamAdapter_CopyToPCMHandlesMismatches(t *testing.T) {
442-139+ pcm := &pwpipewire.PCMBuffer{Frames: 4, Channels: 1, Samples: [][]float32{make([]float32, 4)}}
443-func TestPlayerStreamAdapter_Write_DropsWhenBufferFull(t *testing.T) {140+ assert.Equal(t, 2, copyToPCM(pcm, [][]float32{{1, 2}, {3, 4}}, 2))
444- adapter := &playerStreamAdapter{141+ assert.InEpsilon(t, 1, pcm.Samples[0][0], 0.0001)
445- player: newPlayerRecorder(t),142+ assert.Zero(t, copyToPCM(nil, [][]float32{{1}}, 1))
446- buf: make(chan [][]float32, 4),
447- closed: make(chan struct{}),
448- }
449-
450- // Fill the buffer to capacity
451- for i := range 4 {
452- require.NoError(t, adapter.Write([][]float32{{float32(i)}}))
453- }
454-
455- // The 5th write must NOT block — it should drop the packet and return nil.
456- err := adapter.Write([][]float32{{99.0}})
457- require.NoError(t, err, "Write must not return an error when dropping due to full buffer")
458-
459- // Verify the drop was counted
460- assert.Equal(t, uint64(1), adapter.dropCount.Load(), "one packet should have been dropped")
461-
462- // Verify buffer still contains the original 4 packets (not the dropped one)
463- assert.Len(t, adapter.buf, 4)
464 }143 }
465 144
466-func TestPlayerStreamAdapter_Write_DropsLogOnlyOnFirstAndPowerOfTwo(t *testing.T) {145+func TestPlayerStreamAdapter_CloseVsWrite(t *testing.T) {
467- adapter := &playerStreamAdapter{146+ adapter := testAdapter(2)
468- player: newPlayerRecorder(t),147+ player := newPlayerRecorder(t)
469- buf: make(chan [][]float32, 1), // tiny buffer to force drops148+ adapter.player = player
470- closed: make(chan struct{}),149+ require.NoError(t, adapter.Close())
471- }150+ require.ErrorIs(t, adapter.Write([][]float32{{1}, {2}}), ErrStreamClosed)
472-151+ assert.True(t, player.stopped)
473- // Fill buffer152+ assert.True(t, player.closedFlag)
474- require.NoError(t, adapter.Write([][]float32{{0.0}}))
475-
476- // Drop 10 packets
477- for i := range 10 {
478- _ = adapter.Write([][]float32{{float32(i)}})
479- }
480-
481- // Verify all drops were counted
482- assert.Equal(t, uint64(10), adapter.dropCount.Load())
483 }153 }
484 154
485-// --- Concurrent Write / Close tests ---
486-
487 func TestPlayerStreamAdapter_ConcurrentWriteClose_NoRace(t *testing.T) {155 func TestPlayerStreamAdapter_ConcurrentWriteClose_NoRace(t *testing.T) {
488- player := newPlayerRecorder(t)156+ adapter := testAdapter(4)
489- adapter := &playerStreamAdapter{157+ var writers sync.WaitGroup
490- player: player,158+ for range 4 {
491- buf: make(chan [][]float32, 4),159+ writers.Add(1)
492- closed: make(chan struct{}),160+ go func() {
493- }161+ defer writers.Done()
494-162+ for range 1000 {
495- done := make(chan struct{})163+ err := adapter.Write([][]float32{{1, 2}, {3, 4}})
496- go func() {164+ if err != nil {
497- defer close(done)165+ assert.ErrorIs(t, err, ErrStreamClosed)
498- for i := range 100 {166+ return
499- err := adapter.Write([][]float32{{float32(i)}})167+ }
500- if err != nil {
501- assert.ErrorIs(t, err, ErrStreamClosed)
502- return
503 }168 }
504- }169+ }()
505- }()170+ }
506-
507- // Let a few writes land, then close.
508 adapter.Close()171 adapter.Close()
509- <-done172+ writers.Wait()
510-}
511-
512-// --- NewFactory tests ---
513-
514-func TestNewFactory_Succeeds(t *testing.T) {
515- factory, err := NewFactory()
516- require.NoError(t, err)
517- require.NotNil(t, factory)
518- assert.NotNil(t, factory.createPlayer)
519 }173 }
520 174
521-// --- Integration-style test: full write-through ---
522-
523 func TestStream_WriteFlowsThroughToFill(t *testing.T) {175 func TestStream_WriteFlowsThroughToFill(t *testing.T) {
524- var capturedCallbacks pwpipewire.PlayerCallbacks176+ var callbacks pwpipewire.PlayerCallbacks
525 player := newPlayerRecorder(t)177 player := newPlayerRecorder(t)
526-178+ factory := &Factory{createPlayer: func(_ pwpipewire.PlayerConfig, got pwpipewire.PlayerCallbacks) (pwpipewire.Player, error) {
527- factory := &Factory{179+ callbacks = got
528- createPlayer: func(_ pwpipewire.PlayerConfig, callbacks pwpipewire.PlayerCallbacks) (pwpipewire.Player, error) {180+ return player, nil
529- capturedCallbacks = callbacks181+ }}
530- return player, nil182+ stream, err := factory.NewStream(context.Background(), port.AudioStreamFormat{SampleRate: 48000, ChannelCount: 2, FramesPerBuffer: 4})
531- },
532- }
533-
534- format := port.AudioStreamFormat{SampleRate: 48000, ChannelCount: 2, FramesPerBuffer: 4}
535- stream, err := factory.NewStream(context.Background(), format)
536 require.NoError(t, err)183 require.NoError(t, err)
537-184+ require.NoError(t, stream.Write([][]float32{{.1, .2, .3, .4}, {.5, .6, .7, .8}}))
538- // Write samples through the stream185+ pcm := stereoPCM(4)
539- samples := [][]float32{186+ frames, err := callbacks.Fill(pcm)
540- {0.1, 0.2, 0.3, 0.4},
541- {0.5, 0.6, 0.7, 0.8},
542- }
543- require.NoError(t, stream.Write(samples))
544-
545- // Simulate the Fill callback being called by the player
546- pcm := &pwpipewire.PCMBuffer{
547- Frames: 4,
548- Channels: 2,
549- Stride: 1,
550- Samples: [][]float32{
551- make([]float32, 4),
552- make([]float32, 4),
553- },
554- }
555-
556- frames, err := capturedCallbacks.Fill(pcm)
557 require.NoError(t, err)187 require.NoError(t, err)
558 assert.Equal(t, 4, frames)188 assert.Equal(t, 4, frames)
559- assert.InEpsilon(t, 0.1, pcm.Samples[0][0], 0.0001)189+ assert.InEpsilon(t, .1, pcm.Samples[0][0], .0001)
560- assert.InEpsilon(t, 0.5, pcm.Samples[1][0], 0.0001)190+ assert.InEpsilon(t, .5, pcm.Samples[1][0], .0001)
561-
562 require.NoError(t, stream.Close())191 require.NoError(t, stream.Close())
563 }192 }
193+
194+func BenchmarkPlayerStreamAdapterStereo(b *testing.B) {
195+ adapter := testAdapter(512)
196+ samples := [][]float32{make([]float32, 512), make([]float32, 512)}
197+ pcm := stereoPCM(512)
198+ b.ReportAllocs()
199+ b.SetBytes(2 * 512 * 4)
200+ for i := 0; i < b.N; i++ {
201+ if err := adapter.Write(samples); err != nil {
202+ b.Fatal(err)
203+ }
204+ if _, err := adapter.fill(pcm); err != nil {
205+ b.Fatal(err)
206+ }
207+ }
208+}
M internal/infrastructure/cef/app.go +18 -0
@@ -448,12 +448,30 @@ func isBrowseRelaunchCommandLine(commandLine purecef.CommandLine) bool {
448 return len(args) >= 2 && args[1] == "browse"448 return len(args) >= 2 && args[1] == "browse"
449 }449 }
450 450
451+func isGPUProcessRelaunchCommandLine(commandLine purecef.CommandLine) bool {
452+ if commandLine == nil {
453+ return false
454+ }
455+ for _, arg := range parseRelaunchCommandLineArgs(commandLine.GetCommandLineString()) {
456+ if arg == "--type=gpu-process" {
457+ return true
458+ }
459+ }
460+ return false
461+}
462+
451 func parseBrowseURLFromRelaunchCommandLine(commandLine purecef.CommandLine) string {463 func parseBrowseURLFromRelaunchCommandLine(commandLine purecef.CommandLine) string {
452 browseURL, _ := parseBrowseRelaunchCommandLine(commandLine)464 browseURL, _ := parseBrowseRelaunchCommandLine(commandLine)
453 return browseURL465 return browseURL
454 }466 }
455 467
456 func (h *dumberBPH) OnAlreadyRunningAppRelaunch(commandLine purecef.CommandLine, _ string) int32 {468 func (h *dumberBPH) OnAlreadyRunningAppRelaunch(commandLine purecef.CommandLine, _ string) int32 {
469+ if isGPUProcessRelaunchCommandLine(commandLine) {
470+ if h != nil && h.engine != nil {
471+ h.engine.recordGPURelaunch()
472+ }
473+ return 0
474+ }
457 if browseURL, ok := parseBrowseRelaunchCommandLine(commandLine); ok {475 if browseURL, ok := parseBrowseRelaunchCommandLine(commandLine); ok {
458 if h != nil && h.engine != nil {476 if h != nil && h.engine != nil {
459 if handler := h.engine.alreadyRunningAppRelaunchCallback(); handler != nil {477 if handler := h.engine.alreadyRunningAppRelaunchCallback(); handler != nil {
A internal/infrastructure/cef/background_guard.go +106 -0
@@ -0,0 +1,106 @@
1+package cef
2+
3+import (
4+ "fmt"
5+
6+ "github.com/bnema/puregotk/v4/gdk"
7+ "github.com/bnema/puregotk/v4/gtk"
8+)
9+
10+// opaqueWhiteBackground is Chromium's standard windowless canvas fallback. It is
11+// baked into every BrowserSettings.BackgroundColor so pages that paint nothing
12+// get an opaque white canvas (never a theme-dark one). A zero value must not be
13+// used: in windowless/OSR mode a zero-alpha background enables transparent
14+// painting, which this engine does not want.
15+const opaqueWhiteBackground uint32 = 0xFFFFFFFF
16+
17+// flashGuardCSSClassPrefix scopes each WebView's transient background provider to
18+// its own render widget so guards from different tabs never collide.
19+const flashGuardCSSClassPrefix = "dumber-cef-flash-guard-"
20+
21+// colorByteMask isolates a single 8-bit channel from a packed ARGB value.
22+const colorByteMask = 0xFF
23+
24+// applyBackgroundFlashGuard paints the theme background color behind the CEF
25+// render widget until the first frame is presented. It is the presentation-layer
26+// replacement for the former dark BrowserSettings.BackgroundColor: Chromium's
27+// canvas is now always opaque white, so the anti-white-flash lives here instead.
28+// The bridge widget (a GtkPicture/GtkGLArea) renders its CSS background until an
29+// opaque frame texture covers it, at which point markFirstFramePainted tears the
30+// guard down. Must run on the GTK main thread.
31+func (wv *WebView) applyBackgroundFlashGuard(widget *gtk.Widget) {
32+ if wv == nil || widget == nil {
33+ return
34+ }
35+ argb := wv.backgroundColor
36+ if argb == 0 {
37+ return // No theme color configured; nothing to guard against.
38+ }
39+ display := gdk.DisplayGetDefault()
40+ if display == nil {
41+ return
42+ }
43+
44+ a := float64((argb>>24)&colorByteMask) / colorScale
45+ r := (argb >> 16) & colorByteMask
46+ g := (argb >> 8) & colorByteMask
47+ b := argb & colorByteMask
48+ class := fmt.Sprintf("%s%d", flashGuardCSSClassPrefix, uint64(wv.id))
49+ css := fmt.Sprintf(".%s { background-color: rgba(%d, %d, %d, %.4f); }", class, r, g, b, a)
50+
51+ provider := gtk.NewCssProvider()
52+ if provider == nil {
53+ return
54+ }
55+ provider.LoadFromString(css)
56+
57+ wv.bgGuardMu.Lock()
58+ defer wv.bgGuardMu.Unlock()
59+ if wv.firstFramePainted.Load() {
60+ return // First frame already arrived before the guard could be installed.
61+ }
62+ widget.AddCssClass(class)
63+ gtk.StyleContextAddProviderForDisplay(display, provider, uint(gtk.STYLE_PROVIDER_PRIORITY_APPLICATION))
64+ wv.bgGuardProvider = provider
65+ wv.bgGuardWidget = widget
66+ wv.bgGuardClass = class
67+}
68+
69+// markFirstFramePainted records that the first CEF frame has been presented and
70+// schedules teardown of the background flash guard so later frames render
71+// normally. Safe to call from any thread and idempotent.
72+func (wv *WebView) markFirstFramePainted() {
73+ if wv == nil || !wv.firstFramePainted.CompareAndSwap(false, true) {
74+ return
75+ }
76+ wv.runOnGTK(func() {
77+ wv.removeBackgroundFlashGuard()
78+ })
79+}
80+
81+// removeBackgroundFlashGuard detaches the transient theme-background provider and
82+// clears the render widget's guard class. Must run on the GTK main thread. It is
83+// safe to call when no guard is installed.
84+func (wv *WebView) removeBackgroundFlashGuard() {
85+ if wv == nil {
86+ return
87+ }
88+ wv.bgGuardMu.Lock()
89+ provider := wv.bgGuardProvider
90+ widget := wv.bgGuardWidget
91+ class := wv.bgGuardClass
92+ wv.bgGuardProvider = nil
93+ wv.bgGuardWidget = nil
94+ wv.bgGuardClass = ""
95+ wv.bgGuardMu.Unlock()
96+
97+ if provider == nil {
98+ return
99+ }
100+ if widget != nil && class != "" {
101+ widget.RemoveCssClass(class)
102+ }
103+ if display := gdk.DisplayGetDefault(); display != nil {
104+ gtk.StyleContextRemoveProviderForDisplay(display, provider)
105+ }
106+}
M internal/infrastructure/cef/engine.go +13 -0
@@ -31,6 +31,7 @@ type Engine struct {
31 pool *WebViewPool31 pool *WebViewPool
32 profileLogDir string32 profileLogDir string
33 runtimeCEFDir string33 runtimeCEFDir string
34+ stateRoot string
34 renderStackPlan cef2gtk.RenderStackPlan35 renderStackPlan cef2gtk.RenderStackPlan
35 applicationScaleMu sync.RWMutex36 applicationScaleMu sync.RWMutex
36 applicationScale float6437 applicationScale float64
@@ -54,6 +55,7 @@ type Engine struct {
54 downloadHandler *downloadHandler55 downloadHandler *downloadHandler
55 alreadyRunningAppRelaunchMu sync.RWMutex56 alreadyRunningAppRelaunchMu sync.RWMutex
56 alreadyRunningAppRelaunchHandler func(string)57 alreadyRunningAppRelaunchHandler func(string)
58+ gpuRelaunchCount atomic.Int32
57 59
58 // activeWebViews tracks all live webviews for CSS broadcast.60 // activeWebViews tracks all live webviews for CSS broadcast.
59 activeWebViews sync.Map // map[port.WebViewID]*WebView61 activeWebViews sync.Map // map[port.WebViewID]*WebView
@@ -145,6 +147,17 @@ func (e *Engine) alreadyRunningAppRelaunchCallback() func(string) {
145 return e.alreadyRunningAppRelaunchHandler147 return e.alreadyRunningAppRelaunchHandler
146 }148 }
147 149
150+// recordGPURelaunch asks the next engine start to use Chromium's conservative
151+// defaults after two GPU-process relaunch attempts in one running instance.
152+func (e *Engine) recordGPURelaunch() {
153+ if e == nil || e.gpuRelaunchCount.Add(1) != 2 {
154+ return
155+ }
156+ if err := writeNextStartSafetyMarker(e.stateRoot); err != nil {
157+ logging.FromContext(e.ctx).Warn().Err(err).Msg("cef: failed to mark next start safe after repeated GPU relaunch")
158+ }
159+}
160+
148 // registerWebView adds a webview to the active tracking map.161 // registerWebView adds a webview to the active tracking map.
149 func (e *Engine) registerWebView(wv *WebView) {162 func (e *Engine) registerWebView(wv *WebView) {
150 e.activeWebViews.Store(wv.id, wv)163 e.activeWebViews.Store(wv.id, wv)
M internal/infrastructure/cef/engine_init.go +29 -3
@@ -45,6 +45,10 @@ func NewEngine(
45 ) (*Engine, error) {45 ) (*Engine, error) {
46 logger := logging.FromContext(ctx)46 logger := logging.FromContext(ctx)
47 stateRoot := resolvedStateRoot(paths.StateRoot, opts)47 stateRoot := resolvedStateRoot(paths.StateRoot, opts)
48+ if consumeNextStartSafetyMarker(stateRoot) {
49+ applyNextStartSafetyEnvironment()
50+ logger.Warn().Str("state_root", stateRoot).Msg("cef: applying next-start GPU safety recovery")
51+ }
48 cleanStaleSingletonLocks(logger, stateRoot)52 cleanStaleSingletonLocks(logger, stateRoot)
49 windowlessFrameRate := normalizedWindowlessFrameRate(cfg.WindowlessFrameRate)53 windowlessFrameRate := normalizedWindowlessFrameRate(cfg.WindowlessFrameRate)
50 renderStackPlan, err := resolveCEFRenderStackPlan(cfg.RenderStack)54 renderStackPlan, err := resolveCEFRenderStackPlan(cfg.RenderStack)
@@ -52,6 +56,7 @@ func NewEngine(
52 return nil, err56 return nil, err
53 }57 }
54 cef2gtk.ConfigureRenderStackEnvironment(renderStackPlan)58 cef2gtk.ConfigureRenderStackEnvironment(renderStackPlan)
59+ activeStartupTrace().SetBackend(renderStackPlan.Backend.String())
55 60
56 settings, err := prepareCEFSettings(opts, paths, cfg, logger)61 settings, err := prepareCEFSettings(opts, paths, cfg, logger)
57 if err != nil {62 if err != nil {
@@ -67,6 +72,7 @@ func NewEngine(
67 ctx: ctx,72 ctx: ctx,
68 profileLogDir: paths.ProfileLogDir,73 profileLogDir: paths.ProfileLogDir,
69 runtimeCEFDir: settings.CEFDir,74 runtimeCEFDir: settings.CEFDir,
75+ stateRoot: stateRoot,
70 renderStackPlan: renderStackPlan,76 renderStackPlan: renderStackPlan,
71 applicationScale: normalizedApplicationScale(cfg.ApplicationScale),77 applicationScale: normalizedApplicationScale(cfg.ApplicationScale),
72 registerHandlers: deps.RegisterHandlers,78 registerHandlers: deps.RegisterHandlers,
@@ -141,6 +147,24 @@ func resolvedStateRoot(defaultStateRoot string, opts port.EngineOptions) string
141 return defaultStateRoot147 return defaultStateRoot
142 }148 }
143 149
150+// configureCEFSubprocessRuntime chooses the CEF runtime used by both the
151+// browser and CEF helper processes. CEF re-execs this binary for helpers before
152+// Dumber can load configuration, so the selected configured runtime must be in
153+// its inherited environment before InitWithApp launches any child. A caller's
154+// explicit CEF_DIR remains the override for both process types.
155+func configureCEFSubprocessRuntime(configured string) string {
156+ if explicit := os.Getenv("CEF_DIR"); explicit != "" {
157+ return explicit
158+ }
159+ if configured == "" {
160+ return ""
161+ }
162+ if err := os.Setenv("CEF_DIR", configured); err != nil {
163+ return configured
164+ }
165+ return configured
166+}
167+
144 // prepareCEFSettings builds purecef.Settings from the engine config.168 // prepareCEFSettings builds purecef.Settings from the engine config.
145 func prepareCEFSettings(169 func prepareCEFSettings(
146 opts port.EngineOptions,170 opts port.EngineOptions,
@@ -164,9 +188,7 @@ func prepareCEFSettings(
164 settings.MultiThreadedMessageLoop = true188 settings.MultiThreadedMessageLoop = true
165 settings.ExternalMessagePump = false189 settings.ExternalMessagePump = false
166 settings.RootCachePath = resolvedStateRoot(paths.StateRoot, opts)190 settings.RootCachePath = resolvedStateRoot(paths.StateRoot, opts)
167- if cfg.CEFDir != "" {191+ settings.CEFDir = configureCEFSubprocessRuntime(cfg.CEFDir)
168- settings.CEFDir = cfg.CEFDir
169- }
170 if runtimeLogFile, err := prepareCEFLogFile(paths.LogFile, cfg.LogFile); err != nil {192 if runtimeLogFile, err := prepareCEFLogFile(paths.LogFile, cfg.LogFile); err != nil {
171 logger.Warn().Err(err).Msg("cef: failed to prepare runtime log file")193 logger.Warn().Err(err).Msg("cef: failed to prepare runtime log file")
172 } else {194 } else {
@@ -194,9 +216,13 @@ func initializeCEF(eng *Engine, settings purecef.Settings, logger *zerolog.Logge
194 Str("env_cef_dir", os.Getenv("CEF_DIR")).216 Str("env_cef_dir", os.Getenv("CEF_DIR")).
195 Msg("cef: runtime selection")217 Msg("cef: runtime selection")
196 logger.Debug().Msg("cef: calling InitWithApp")218 logger.Debug().Msg("cef: calling InitWithApp")
219+ // InitWithApp encapsulates loading libcef, so only the begin boundary is
220+ // observable here; do not invent a separate library-load completion event.
221+ activeStartupTrace().Mark("cef_library_load_begin")
197 if err := purecef.InitWithApp(settings, app); err != nil {222 if err := purecef.InitWithApp(settings, app); err != nil {
198 return fmt.Errorf("cef.InitWithApp: %w", err)223 return fmt.Errorf("cef.InitWithApp: %w", err)
199 }224 }
225+ activeStartupTrace().Mark("cef_initialized")
200 if libcefPath := loadedLibCEFPath(); libcefPath != "" {226 if libcefPath := loadedLibCEFPath(); libcefPath != "" {
201 logger.Info().Str("libcef_path", libcefPath).Msg("cef: runtime library loaded")227 logger.Info().Str("libcef_path", libcefPath).Msg("cef: runtime library loaded")
202 } else {228 } else {
M internal/infrastructure/cef/engine_init_test.go +16 -0
@@ -36,6 +36,22 @@ func TestResolvedStateRoot_UsesProfileDefault(t *testing.T) {
36 require.Equal(t, profile.CEFUserDataDir(), root)36 require.Equal(t, profile.CEFUserDataDir(), root)
37 }37 }
38 38
39+func TestConfigureCEFSubprocessRuntime_PropagatesConfiguredRuntimeBeforeInit(t *testing.T) {
40+ t.Setenv("CEF_DIR", "")
41+
42+ runtime := filepath.Join(t.TempDir(), "cef-147")
43+ require.Equal(t, runtime, configureCEFSubprocessRuntime(runtime))
44+ require.Equal(t, runtime, os.Getenv("CEF_DIR"))
45+}
46+
47+func TestConfigureCEFSubprocessRuntime_PreservesExplicitEnvironmentOverride(t *testing.T) {
48+ explicitRuntime := filepath.Join(t.TempDir(), "cef-env")
49+ t.Setenv("CEF_DIR", explicitRuntime)
50+
51+ require.Equal(t, explicitRuntime, configureCEFSubprocessRuntime(filepath.Join(t.TempDir(), "cef-config")))
52+ require.Equal(t, explicitRuntime, os.Getenv("CEF_DIR"))
53+}
54+
39 func TestPrepareCEFSettings_UsesResolvedProfilePaths(t *testing.T) {55 func TestPrepareCEFSettings_UsesResolvedProfilePaths(t *testing.T) {
40 logger := zerolog.Nop()56 logger := zerolog.Nop()
41 profile := testCEFDevProfile(t)57 profile := testCEFDevProfile(t)
M internal/infrastructure/cef/factory.go +14 -8
@@ -27,7 +27,7 @@ type WebViewFactory struct {
27 windowlessFrameRate int3227 windowlessFrameRate int32
28 windowlessFrameRateMax int3228 windowlessFrameRateMax int32
29 inputConfig RuntimeInputConfig29 inputConfig RuntimeInputConfig
30- bgColor atomic.Uint32 // packed ARGB for BrowserSettings.BackgroundColor30+ bgColor atomic.Uint32 // packed ARGB theme color for the presentation-layer flash guard
31 audioOutputFactory port.AudioOutputFactory31 audioOutputFactory port.AudioOutputFactory
32 }32 }
33 33
@@ -69,7 +69,9 @@ func newWebViewFactory(engine *Engine, opts webViewFactoryOptions) *WebViewFacto
69 }69 }
70 }70 }
71 71
72-// setDefaultBackgroundColor stores a packed ARGB color for new browser creation.72+// setDefaultBackgroundColor stores the packed ARGB theme color used by the
73+// presentation-layer flash guard on newly-created WebViews. It no longer feeds
74+// BrowserSettings.BackgroundColor, which is always opaque white.
73 func (f *WebViewFactory) setDefaultBackgroundColor(r, g, b, a float64) {75 func (f *WebViewFactory) setDefaultBackgroundColor(r, g, b, a float64) {
74 const s = colorScale76 const s = colorScale
75 argb := uint32(a*s)<<24 | uint32(r*s)<<16 | uint32(g*s)<<8 | uint32(b*s)77 argb := uint32(a*s)<<24 | uint32(r*s)<<16 | uint32(g*s)<<8 | uint32(b*s)
@@ -96,9 +98,9 @@ func (f *WebViewFactory) Create(ctx context.Context) (port.WebView, error) {
96 settings := purecef.NewBrowserSettings()98 settings := purecef.NewBrowserSettings()
97 cef2gtk.ConfigureBrowserSettings(&settings, cef2gtk.BrowserSettingsOptions{WindowlessFrameRate: f.windowlessFrameRate})99 cef2gtk.ConfigureBrowserSettings(&settings, cef2gtk.BrowserSettingsOptions{WindowlessFrameRate: f.windowlessFrameRate})
98 settings.LocalStorage = 1 // CEF_STATE_ENABLED100 settings.LocalStorage = 1 // CEF_STATE_ENABLED
99- if bg := f.bgColor.Load(); bg != 0 {101+ // Always opaque white: Chromium's standard canvas fallback. The theme
100- settings.BackgroundColor = bg102+ // background is applied at the presentation layer as a flash guard instead.
101- }103+ settings.BackgroundColor = opaqueWhiteBackground
102 104
103 // Defer browser creation until the bridge view has a non-zero size.105 // Defer browser creation until the bridge view has a non-zero size.
104 // CEF requires GetViewRect to return a non-empty rect, but the GL area106 // CEF requires GetViewRect to return a non-empty rect, but the GL area
@@ -142,6 +144,8 @@ func (f *WebViewFactory) newWebView(ctx context.Context) (*WebView, error) {
142 wv.nativeWidget = popupRoot144 wv.nativeWidget = popupRoot
143 }145 }
144 }146 }
147+ // Guard the render widget with the theme background until the first frame.
148+ wv.applyBackgroundFlashGuard(nativeWidget)
145 })149 })
146 if !result.Completed() {150 if !result.Completed() {
147 wv.destroyViewBridgeOnGTKAsync()151 wv.destroyViewBridgeOnGTKAsync()
@@ -278,6 +282,8 @@ func (f *WebViewFactory) postPendingBrowserCreate(ctx context.Context, wv *WebVi
278 // renderer without an initial paint. We replay pending navigation from282 // renderer without an initial paint. We replay pending navigation from
279 // OnAfterCreated once the host is fully wired.283 // OnAfterCreated once the host is fully wired.
280 initialURL := "about:blank"284 initialURL := "about:blank"
285+ // This is the request boundary, not the later OnAfterCreated callback.
286+ activeStartupTrace().Mark("browser_create_requested")
281 result := cefBrowserHostCreateBrowser(287 result := cefBrowserHostCreateBrowser(
282 pc.windowInfo,288 pc.windowInfo,
283 pc.client,289 pc.client,
@@ -381,9 +387,9 @@ func (f *WebViewFactory) CreateRelated(ctx context.Context, parentID port.WebVie
381 settings := purecef.NewBrowserSettings()387 settings := purecef.NewBrowserSettings()
382 cef2gtk.ConfigureBrowserSettings(&settings, cef2gtk.BrowserSettingsOptions{WindowlessFrameRate: f.windowlessFrameRate})388 cef2gtk.ConfigureBrowserSettings(&settings, cef2gtk.BrowserSettingsOptions{WindowlessFrameRate: f.windowlessFrameRate})
383 settings.LocalStorage = 1 // CEF_STATE_ENABLED389 settings.LocalStorage = 1 // CEF_STATE_ENABLED
384- if bg := f.bgColor.Load(); bg != 0 {390+ // Always opaque white: the theme background is applied as a presentation-layer
385- settings.BackgroundColor = bg391+ // flash guard, not baked into Chromium's canvas.
386- }392+ settings.BackgroundColor = opaqueWhiteBackground
387 393
388 if err := f.configureInitialBrowserCreation(ctx, popupWV, popupWV.client, &windowInfo, &settings, func(w, h int32) {394 if err := f.configureInitialBrowserCreation(ctx, popupWV, popupWV.client, &windowInfo, &settings, func(w, h int32) {
389 f.handlePopupShellInitialResize(ctx, popupWV, func(w, h int32) {395 f.handlePopupShellInitialResize(ctx, popupWV, func(w, h int32) {
A internal/infrastructure/cef/first_presentation_collector_test.go +432 -0
@@ -0,0 +1,432 @@
1+package cef
2+
3+import (
4+ "encoding/json"
5+ "fmt"
6+ "os"
7+ "os/exec"
8+ "path/filepath"
9+ "strings"
10+ "testing"
11+
12+ "github.com/stretchr/testify/require"
13+)
14+
15+const validFirstPresentationLog = `{"message":"startup_trace: milestone","milestone":"process_entry","t_ms":0,"delta_ms":0,"time":"2026-07-14T09:00:00+02:00","machine_path":"/home/alice/private"}
16+{"message":"startup_trace: milestone","milestone":"config_complete","t_ms":1,"delta_ms":1}
17+{"message":"startup_trace: milestone","milestone":"cef_library_load_begin","t_ms":2,"delta_ms":1}
18+{"message":"startup_trace: milestone","milestone":"cef_initialized","t_ms":3,"delta_ms":1}
19+{"message":"startup_trace: milestone","milestone":"browser_create_requested","t_ms":4,"delta_ms":1}
20+{"message":"startup_trace: milestone","milestone":"first_accelerated_paint_received","t_ms":5,"delta_ms":1}
21+{"message":"startup_trace: milestone","milestone":"first_dmabuf_texture_swap","t_ms":6,"delta_ms":1}
22+{"message":"startup_trace: milestone","milestone":"first_gtk_presentation","t_ms":7,"delta_ms":1}
23+{"message":"startup_trace: first presentation","backend":"gdk-dmabuf","incomplete_reason":"","total_ms":7,"host":"alice"}`
24+
25+func TestFirstPresentationCollectorNeverRecursivelyDeletesCallerOutput(t *testing.T) {
26+ repoRoot, err := filepath.Abs(filepath.Join("..", "..", ".."))
27+ require.NoError(t, err)
28+ script, err := os.ReadFile(filepath.Join(repoRoot, "scripts", "collect_first_presentation.sh"))
29+ require.NoError(t, err)
30+ require.NotContains(t, string(script), "rm -rf \"$output\"")
31+ require.NotContains(t, string(script), "rm -rf -- \"$output\"")
32+}
33+
34+func TestFirstPresentationCollectorRejectsUnsafeOutputPaths(t *testing.T) {
35+ repoRoot, err := filepath.Abs(filepath.Join("..", "..", ".."))
36+ require.NoError(t, err)
37+ temp := t.TempDir()
38+ runtime := filepath.Join(temp, "cef-147-runtime")
39+ require.NoError(t, os.Mkdir(runtime, 0o755))
40+ binary := filepath.Join(temp, "dumber")
41+ require.NoError(t, os.WriteFile(binary, collectorTestBinary(validFirstPresentationLog), 0o755))
42+ home := filepath.Join(temp, "home")
43+ require.NoError(t, os.Mkdir(home, 0o755))
44+
45+ unrelated := filepath.Join(temp, "unrelated")
46+ require.NoError(t, os.Mkdir(unrelated, 0o755))
47+ unrelatedSentinel := filepath.Join(unrelated, "keep")
48+ require.NoError(t, os.WriteFile(unrelatedSentinel, []byte("do not delete"), 0o600))
49+
50+ safeParent := filepath.Join(temp, "safe")
51+ require.NoError(t, os.Mkdir(safeParent, 0o755))
52+ parentSentinel := filepath.Join(safeParent, "keep")
53+ require.NoError(t, os.WriteFile(parentSentinel, []byte("do not delete"), 0o600))
54+
55+ external := filepath.Join(temp, "external")
56+ require.NoError(t, os.Mkdir(external, 0o755))
57+ externalSentinel := filepath.Join(external, "keep")
58+ require.NoError(t, os.WriteFile(externalSentinel, []byte("do not delete"), 0o600))
59+ link := filepath.Join(temp, "link")
60+ require.NoError(t, os.Symlink(external, link))
61+
62+ for _, test := range []struct {
63+ name string
64+ output string
65+ verify func(t *testing.T)
66+ }{
67+ {name: "empty", output: ""},
68+ {name: "root", output: "/"},
69+ {name: "home", output: home},
70+ {name: "existing unrelated directory", output: unrelated, verify: func(t *testing.T) {
71+ requireFileContents(t, unrelatedSentinel, "do not delete")
72+ }},
73+ {name: "parent traversal", output: safeParent + "/../escaped", verify: func(t *testing.T) {
74+ requireFileContents(t, parentSentinel, "do not delete")
75+ require.NoFileExists(t, filepath.Join(temp, "escaped"))
76+ }},
77+ {name: "symlink escape", output: filepath.Join(link, "escaped"), verify: func(t *testing.T) {
78+ requireFileContents(t, externalSentinel, "do not delete")
79+ require.NoFileExists(t, filepath.Join(external, "escaped"))
80+ }},
81+ } {
82+ t.Run(test.name, func(t *testing.T) {
83+ cmd := exec.Command(filepath.Join(repoRoot, "scripts", "collect_first_presentation.sh"))
84+ // An isolated directory prevents a regression from reaching the tracked
85+ // default artifact path while this test proves output validation is first.
86+ cmd.Dir = temp
87+ cmd.Env = append(os.Environ(),
88+ "HOME="+home,
89+ "DISPLAY=:test",
90+ "DUMBER_CEF_DIR="+runtime,
91+ "DUMBER_FIRST_PRESENTATION_BIN="+binary,
92+ "DUMBER_FIRST_PRESENTATION_OUTPUT="+test.output,
93+ )
94+ result, err := cmd.CombinedOutput()
95+ require.Errorf(t, err, "collector accepted unsafe output %q: %s", test.output, result)
96+ require.Contains(t, string(result), "unsafe output path")
97+ if test.verify != nil {
98+ test.verify(t)
99+ }
100+ })
101+ }
102+}
103+
104+func requireFileContents(t *testing.T, path, want string) {
105+ t.Helper()
106+ contents, err := os.ReadFile(path)
107+ require.NoError(t, err)
108+ require.Equal(t, want, string(contents))
109+}
110+
111+func TestFirstPresentationCollectorSanitizesMachineLocalValues(t *testing.T) {
112+ repoRoot, err := filepath.Abs(filepath.Join("..", "..", ".."))
113+ require.NoError(t, err)
114+ temp := t.TempDir()
115+ runtime := filepath.Join(temp, "cef-147-runtime")
116+ require.NoError(t, os.Mkdir(runtime, 0o755))
117+
118+ binary := filepath.Join(temp, "dumber")
119+ require.NoError(t, os.WriteFile(binary, collectorTestBinary(validFirstPresentationLog), 0o755))
120+
121+ output := filepath.Join(temp, "artifacts")
122+ cmd := exec.Command(filepath.Join(repoRoot, "scripts", "collect_first_presentation.sh"))
123+ cmd.Dir = repoRoot
124+ cmd.Env = append(os.Environ(),
125+ "DISPLAY=:test",
126+ "WAYLAND_DISPLAY=",
127+ "DUMBER_CEF_DIR="+runtime,
128+ "DUMBER_FIRST_PRESENTATION_BIN="+binary,
129+ "DUMBER_FIRST_PRESENTATION_OUTPUT="+output,
130+ "DUMBER_FIRST_PRESENTATION_TIMEOUT_SECONDS=1",
131+ "DUMBER_MACHINE_GPU_PROFILE=integrated-gpu",
132+ )
133+ result, err := cmd.CombinedOutput()
134+ require.NoErrorf(t, err, "collector failed: %s", result)
135+
136+ var artifacts strings.Builder
137+ for _, name := range append([]string{"metadata.json", "baseline.json"}, runArtifactNames()...) {
138+ artifactContents, readErr := os.ReadFile(filepath.Join(output, name))
139+ require.NoError(t, readErr)
140+ artifacts.Write(artifactContents)
141+ }
142+ for _, forbidden := range []string{"/home/", temp, "alice", "machine_path", `"time"`} {
143+ require.NotContainsf(t, artifacts.String(), forbidden, "committed artifact leaked %q", forbidden)
144+ }
145+ for _, required := range []string{
146+ `"measured_source_revision"`,
147+ `"version": "v0.8.5"`,
148+ `"tag": "v0.8.5"`,
149+ `"revision": "b9fb46ef5c04ca0993943c5ca4b6f0fe83583304"`,
150+ } {
151+ require.Contains(t, artifacts.String(), required)
152+ }
153+
154+ var metadata struct {
155+ Comparison struct {
156+ OS string `json:"os"`
157+ Architecture string `json:"architecture"`
158+ DisplayProtocol string `json:"display_protocol"`
159+ MachineGPUProfile string `json:"machine_gpu_profile"`
160+ } `json:"comparison"`
161+ }
162+ contents, err := os.ReadFile(filepath.Join(output, "metadata.json"))
163+ require.NoError(t, err)
164+ require.NoError(t, json.Unmarshal(contents, &metadata))
165+ require.NotEmpty(t, metadata.Comparison.OS)
166+ require.NotEmpty(t, metadata.Comparison.Architecture)
167+ require.Equal(t, "x11", metadata.Comparison.DisplayProtocol)
168+ require.Equal(t, "integrated-gpu", metadata.Comparison.MachineGPUProfile)
169+}
170+
171+func TestFirstPresentationCollectorDerivesSelectedImmutableModuleProvenance(t *testing.T) {
172+ repoRoot, err := filepath.Abs(filepath.Join("..", "..", ".."))
173+ require.NoError(t, err)
174+ temp := t.TempDir()
175+ runtime := filepath.Join(temp, "cef-147-runtime")
176+ require.NoError(t, os.Mkdir(runtime, 0o755))
177+ binary := filepath.Join(temp, "dumber")
178+ require.NoError(t, os.WriteFile(binary, collectorTestBinary(validFirstPresentationLog), 0o755))
179+
180+ // This exact pseudo-version is deliberately newer than the former v0.8.4
181+ // hardcode; the collector must report what the selected module says.
182+ const version = "v0.8.5-0.20300102030405-bbd397409ebe"
183+ const revision = "bbd397409ebed75a5979c1e4566a2ef319f6a484"
184+ goBin := collectorProvenanceGo(t, temp, version, revision, "")
185+ output := filepath.Join(temp, "artifacts")
186+ cmd := exec.Command(filepath.Join(repoRoot, "scripts", "collect_first_presentation.sh"))
187+ cmd.Dir = repoRoot
188+ cmd.Env = append(os.Environ(),
189+ "DISPLAY=:test",
190+ "DUMBER_CEF_DIR="+runtime,
191+ "DUMBER_FIRST_PRESENTATION_BIN="+binary,
192+ "DUMBER_FIRST_PRESENTATION_OUTPUT="+output,
193+ "DUMBER_FIRST_PRESENTATION_TIMEOUT_SECONDS=1",
194+ "PATH="+filepath.Dir(goBin)+":"+os.Getenv("PATH"),
195+ )
196+ result, err := cmd.CombinedOutput()
197+ require.NoErrorf(t, err, "collector failed: %s", result)
198+
199+ var metadata struct {
200+ Upstream struct {
201+ Module string `json:"module"`
202+ Version string `json:"version"`
203+ Tag string `json:"tag"`
204+ Revision string `json:"revision"`
205+ } `json:"upstream"`
206+ }
207+ contents, err := os.ReadFile(filepath.Join(output, "metadata.json"))
208+ require.NoError(t, err)
209+ require.NoError(t, json.Unmarshal(contents, &metadata))
210+ require.Equal(t, "github.com/bnema/purego-cef2gtk", metadata.Upstream.Module)
211+ require.Equal(t, version, metadata.Upstream.Version)
212+ require.Equal(t, "v0.8.5", metadata.Upstream.Tag)
213+ require.Equal(t, revision, metadata.Upstream.Revision)
214+
215+ script, err := os.ReadFile(filepath.Join(repoRoot, "scripts", "collect_first_presentation.sh"))
216+ require.NoError(t, err)
217+ require.NotContains(t, string(script), "f217ece342dea3ef2a3f98671fcd16a39ad0037d")
218+}
219+
220+func TestFirstPresentationCollectorRejectsNonImmutableModuleProvenanceWithoutLeaks(t *testing.T) {
221+ repoRoot, err := filepath.Abs(filepath.Join("..", "..", ".."))
222+ require.NoError(t, err)
223+
224+ for _, test := range []struct {
225+ name, version, revision, ref string
226+ }{
227+ {name: "branch selector", version: "main", ref: "main"},
228+ {name: "missing origin", version: "v0.8.5-0.20300102030405-bbd397409ebe"},
229+ {name: "named origin ref", version: "v0.8.5-0.20300102030405-bbd397409ebe", revision: "bbd397409ebed75a5979c1e4566a2ef319f6a484", ref: "main"},
230+ } {
231+ t.Run(test.name, func(t *testing.T) {
232+ temp := t.TempDir()
233+ runtime := filepath.Join(temp, "cef-147-runtime")
234+ require.NoError(t, os.Mkdir(runtime, 0o755))
235+ binary := filepath.Join(temp, "dumber")
236+ require.NoError(t, os.WriteFile(binary, collectorTestBinary(validFirstPresentationLog), 0o755))
237+
238+ goBin := collectorProvenanceGo(t, temp, test.version, test.revision, test.ref)
239+ output := filepath.Join(temp, "artifacts")
240+ cmd := exec.Command(filepath.Join(repoRoot, "scripts", "collect_first_presentation.sh"))
241+ cmd.Dir = repoRoot
242+ cmd.Env = append(os.Environ(),
243+ "DISPLAY=:test",
244+ "DUMBER_CEF_DIR="+runtime,
245+ "DUMBER_FIRST_PRESENTATION_BIN="+binary,
246+ "DUMBER_FIRST_PRESENTATION_OUTPUT="+output,
247+ "PATH="+filepath.Dir(goBin)+":"+os.Getenv("PATH"),
248+ )
249+ result, runErr := cmd.CombinedOutput()
250+ require.Errorf(t, runErr, "collector accepted nonimmutable module provenance: %s", result)
251+ require.Contains(t, string(result), "immutable module provenance is unavailable")
252+ require.NotContains(t, string(result), temp)
253+ require.NoFileExists(t, filepath.Join(output, "metadata.json"))
254+ for _, name := range runArtifactNames() {
255+ require.NoFileExists(t, filepath.Join(output, name))
256+ }
257+ })
258+ }
259+}
260+
261+func collectorProvenanceGo(t *testing.T, temp, version, revision, ref string) string {
262+ t.Helper()
263+ infoPath := filepath.Join(temp, "module.info")
264+ info := map[string]any{"Version": version}
265+ if revision != "" {
266+ info["Origin"] = map[string]string{
267+ "VCS": "git", "URL": "https://github.com/bnema/purego-cef2gtk", "Hash": revision, "Ref": ref,
268+ }
269+ }
270+ infoJSON, err := json.Marshal(info)
271+ require.NoError(t, err)
272+ require.NoError(t, os.WriteFile(infoPath, infoJSON, 0o600))
273+
274+ selectedJSON, err := json.Marshal(map[string]string{
275+ "Path": "github.com/bnema/purego-cef2gtk", "Version": version,
276+ })
277+ require.NoError(t, err)
278+ downloadedJSON, err := json.Marshal(map[string]string{
279+ "Path": "github.com/bnema/purego-cef2gtk", "Version": version, "Info": infoPath,
280+ })
281+ require.NoError(t, err)
282+ binDir := filepath.Join(temp, "bin")
283+ require.NoError(t, os.Mkdir(binDir, 0o755))
284+ goBin := filepath.Join(binDir, "go")
285+ script := fmt.Sprintf(`#!/bin/sh
286+if [ "$1" = "list" ] && [ "$2" = "-m" ] && [ "$3" = "-json" ] && [ "$4" = "github.com/bnema/purego-cef2gtk" ]; then
287+ printf '%%s\n' '%s'
288+ exit 0
289+fi
290+if [ "$1" = "mod" ] && [ "$2" = "download" ] && [ "$3" = "-json" ] && [ "$4" = "github.com/bnema/purego-cef2gtk@%s" ]; then
291+ printf '%%s\n' '%s'
292+ exit 0
293+fi
294+exit 1
295+`, selectedJSON, version, downloadedJSON)
296+ require.NoError(t, os.WriteFile(goBin, []byte(script), 0o755))
297+ return goBin
298+}
299+
300+func TestFirstPresentationCollectorDefaultsToXDGStateEvidenceDirectory(t *testing.T) {
301+ repoRoot, err := filepath.Abs(filepath.Join("..", "..", ".."))
302+ require.NoError(t, err)
303+ temp := t.TempDir()
304+ runtime := filepath.Join(temp, "cef-147-runtime")
305+ require.NoError(t, os.Mkdir(runtime, 0o755))
306+ binary := filepath.Join(temp, "dumber")
307+ require.NoError(t, os.WriteFile(binary, collectorTestBinary(validFirstPresentationLog), 0o755))
308+ stateHome := filepath.Join(temp, "state")
309+
310+ cmd := exec.Command(filepath.Join(repoRoot, "scripts", "collect_first_presentation.sh"))
311+ cmd.Dir = repoRoot
312+ cmd.Env = append(envWithout("DUMBER_FIRST_PRESENTATION_OUTPUT"),
313+ "DISPLAY=:test",
314+ "WAYLAND_DISPLAY=",
315+ "DUMBER_CEF_DIR="+runtime,
316+ "DUMBER_FIRST_PRESENTATION_BIN="+binary,
317+ "DUMBER_FIRST_PRESENTATION_TIMEOUT_SECONDS=1",
318+ "DUMBER_MACHINE_GPU_PROFILE=integrated-gpu",
319+ "XDG_STATE_HOME="+stateHome,
320+ )
321+ result, err := cmd.CombinedOutput()
322+ require.NoErrorf(t, err, "collector failed: %s", result)
323+
324+ evidenceRoot := filepath.Join(stateHome, "dumber", "roadmap-evidence")
325+ entries, err := os.ReadDir(evidenceRoot)
326+ require.NoError(t, err)
327+ require.Len(t, entries, 1)
328+ require.True(t, entries[0].IsDir())
329+ require.FileExists(t, filepath.Join(evidenceRoot, entries[0].Name(), "metadata.json"))
330+ require.NotContains(t, string(result), filepath.Join(repoRoot, "phase1"))
331+}
332+
333+func envWithout(name string) []string {
334+ prefix := name + "="
335+ var environment []string
336+ for _, entry := range os.Environ() {
337+ if !strings.HasPrefix(entry, prefix) {
338+ environment = append(environment, entry)
339+ }
340+ }
341+ return environment
342+}
343+
344+func TestFirstPresentationCollectorReadsProvenanceWithScopedGitSafeDirectory(t *testing.T) {
345+ repoRoot, err := filepath.Abs(filepath.Join("..", "..", ".."))
346+ require.NoError(t, err)
347+ temp := t.TempDir()
348+ runtime := filepath.Join(temp, "cef-147-runtime")
349+ require.NoError(t, os.Mkdir(runtime, 0o755))
350+ binary := filepath.Join(temp, "dumber")
351+ require.NoError(t, os.WriteFile(binary, collectorTestBinary(validFirstPresentationLog), 0o755))
352+ gitDir := filepath.Join(temp, "bin")
353+ require.NoError(t, os.Mkdir(gitDir, 0o755))
354+ git := filepath.Join(gitDir, "git")
355+ require.NoError(t, os.WriteFile(git, []byte(`#!/bin/sh
356+if [ "$1" = "-c" ] && [ "$2" = "safe.directory=$EXPECTED_SAFE_DIRECTORY" ] && [ "$3" = "rev-parse" ] && [ "$4" = "HEAD" ]; then
357+ echo 0123456789abcdef0123456789abcdef01234567
358+ exit 0
359+fi
360+echo "fatal: detected dubious ownership in repository" >&2
361+exit 128
362+`), 0o755))
363+
364+ output := filepath.Join(temp, "artifacts")
365+ cmd := exec.Command(filepath.Join(repoRoot, "scripts", "collect_first_presentation.sh"))
366+ cmd.Dir = repoRoot
367+ cmd.Env = append(os.Environ(),
368+ "DISPLAY=:test",
369+ "DUMBER_CEF_DIR="+runtime,
370+ "DUMBER_FIRST_PRESENTATION_BIN="+binary,
371+ "DUMBER_FIRST_PRESENTATION_OUTPUT="+output,
372+ "DUMBER_FIRST_PRESENTATION_TIMEOUT_SECONDS=1",
373+ "EXPECTED_SAFE_DIRECTORY="+repoRoot,
374+ "PATH="+gitDir+":"+os.Getenv("PATH"),
375+ )
376+ result, err := cmd.CombinedOutput()
377+ require.NoErrorf(t, err, "collector failed: %s", result)
378+
379+ var metadata struct {
380+ MeasuredSourceRevision string `json:"measured_source_revision"`
381+ }
382+ contents, err := os.ReadFile(filepath.Join(output, "metadata.json"))
383+ require.NoError(t, err)
384+ require.NoError(t, json.Unmarshal(contents, &metadata))
385+ require.Equal(t, "0123456789abcdef0123456789abcdef01234567", metadata.MeasuredSourceRevision)
386+}
387+
388+func TestFirstPresentationCollectorRejectsInconsistentTiming(t *testing.T) {
389+ repoRoot, err := filepath.Abs(filepath.Join("..", "..", ".."))
390+ require.NoError(t, err)
391+
392+ for _, test := range []struct {
393+ name string
394+ old string
395+ new string
396+ }{
397+ {name: "non-integer delta", old: `"delta_ms":1}`, new: `"delta_ms":"1"}`},
398+ {name: "incorrect delta", old: `"delta_ms":1}`, new: `"delta_ms":2}`},
399+ {name: "non-integer summary total", old: `"total_ms":7`, new: `"total_ms":7.5`},
400+ {name: "incorrect summary total", old: `"total_ms":7`, new: `"total_ms":8`},
401+ } {
402+ t.Run(test.name, func(t *testing.T) {
403+ temp := t.TempDir()
404+ runtime := filepath.Join(temp, "cef-147-runtime")
405+ require.NoError(t, os.Mkdir(runtime, 0o755))
406+ binary := filepath.Join(temp, "dumber")
407+ log := strings.Replace(validFirstPresentationLog, test.old, test.new, 1)
408+ require.NoError(t, os.WriteFile(binary, collectorTestBinary(log), 0o755))
409+
410+ cmd := exec.Command(filepath.Join(repoRoot, "scripts", "collect_first_presentation.sh"))
411+ cmd.Dir = repoRoot
412+ cmd.Env = append(os.Environ(),
413+ "DISPLAY=:test",
414+ "DUMBER_CEF_DIR="+runtime,
415+ "DUMBER_FIRST_PRESENTATION_BIN="+binary,
416+ "DUMBER_FIRST_PRESENTATION_OUTPUT="+filepath.Join(temp, "artifacts"),
417+ "DUMBER_FIRST_PRESENTATION_TIMEOUT_SECONDS=1",
418+ )
419+ result, err := cmd.CombinedOutput()
420+ require.Errorf(t, err, "collector accepted malformed timing artifact: %s", result)
421+ require.Contains(t, string(result), "invalid or incomplete non-DMABUF timeline")
422+ })
423+ }
424+}
425+
426+func collectorTestBinary(log string) []byte {
427+ return []byte("#!/bin/sh\ncat <<'EOF'\n" + log + "\nEOF\nexit 139\n")
428+}
429+
430+func runArtifactNames() []string {
431+ return []string{"run-01.json", "run-02.json", "run-03.json", "run-04.json", "run-05.json"}
432+}
M internal/infrastructure/cef/render_handler_adapter.go +44 -15
@@ -23,6 +23,26 @@ type dumberRenderHandler struct {
23 23
24 var _ purecef.RenderHandler = (*dumberRenderHandler)(nil)24 var _ purecef.RenderHandler = (*dumberRenderHandler)(nil)
25 25
26+// startupPresentationHooks consumes the one-shot facts emitted by
27+// purego-cef2gtk. The bridge owns the native DMABUF and frame-clock boundaries;
28+// Dumber only records their ordered application-level timeline.
29+func startupPresentationHooks(trace *startupTrace) cef2gtk.Hooks {
30+ return cef2gtk.Hooks{
31+ OnFirstAcceleratedPaint: func() {
32+ trace.Mark("first_accelerated_paint_received")
33+ },
34+ OnFirstDMABUFTextureSwap: func() {
35+ trace.Mark("first_dmabuf_texture_swap")
36+ },
37+ OnFirstPresentation: func() {
38+ trace.MarkGTKAfterPaint()
39+ },
40+ OnDMABUFUnsupported: func() {
41+ trace.SetIncompleteReason("dmabuf_texture_swap_unavailable")
42+ },
43+ }
44+}
45+
26 func newDumberRenderHandler(wv *WebView) purecef.RenderHandler {46 func newDumberRenderHandler(wv *WebView) purecef.RenderHandler {
27 if wv == nil || wv.viewBridge == nil {47 if wv == nil || wv.viewBridge == nil {
28 return nil48 return nil
@@ -30,23 +50,32 @@ func newDumberRenderHandler(wv *WebView) purecef.RenderHandler {
30 h := &dumberRenderHandler{wv: wv}50 h := &dumberRenderHandler{wv: wv}
31 51
32 var unsupportedPaintOnce sync.Once52 var unsupportedPaintOnce sync.Once
33- h.main = wv.viewBridge.RenderHandler(cef2gtk.Hooks{53+ hooks := startupPresentationHooks(activeStartupTrace())
34- OnUnsupportedPaint: func() {54+ // Tear down the theme background flash guard once the first frame is
35- unsupportedPaintOnce.Do(func() {55+ // presented, without dropping the startup trace mark the bridge already set.
36- if wv.ctx != nil {56+ firstAcceleratedPaint := hooks.OnFirstAcceleratedPaint
37- logging.FromContext(wv.ctx).Warn().Msg("cef: unsupported CPU paint from accelerated bridge")57+ hooks.OnFirstAcceleratedPaint = func() {
38- }58+ if firstAcceleratedPaint != nil {
39- })59+ firstAcceleratedPaint()
40- },60+ }
41- OnError: func(err error) {61+ wv.markFirstFramePainted()
62+ }
63+ hooks.OnUnsupportedPaint = func() {
64+ unsupportedPaintOnce.Do(func() {
42 if wv.ctx != nil {65 if wv.ctx != nil {
43- logging.FromContext(wv.ctx).Warn().Err(err).Msg("cef: accelerated render bridge error")66+ logging.FromContext(wv.ctx).Warn().Msg("cef: unsupported CPU paint from accelerated bridge")
44 }67 }
45- },68+ })
46- OnTextSelectionChanged: func(selectedText string, _ *purecef.Range) {69+ }
47- handleRenderTextSelectionChanged(wv, selectedText)70+ hooks.OnError = func(err error) {
48- },71+ if wv.ctx != nil {
49- })72+ logging.FromContext(wv.ctx).Warn().Err(err).Msg("cef: accelerated render bridge error")
73+ }
74+ }
75+ hooks.OnTextSelectionChanged = func(selectedText string, _ *purecef.Range) {
76+ handleRenderTextSelectionChanged(wv, selectedText)
77+ }
78+ h.main = wv.viewBridge.RenderHandler(hooks)
50 79
51 if wv.popupSurface != nil {80 if wv.popupSurface != nil {
52 var popupUnsupportedPaintOnce sync.Once81 var popupUnsupportedPaintOnce sync.Once
A internal/infrastructure/cef/restart_safety.go +68 -0
@@ -0,0 +1,68 @@
1+package cef
2+
3+import (
4+ "errors"
5+ "os"
6+ "path/filepath"
7+)
8+
9+const (
10+ nextStartSafetyMarkerFile = "cef-next-start-safe"
11+ safetyMarkerDirPerm = 0o700
12+ safetyMarkerFilePerm = 0o600
13+)
14+
15+// writeNextStartSafetyMarker records a one-shot recovery request for the next
16+// CEF start. The marker lives beside CEF's root cache so it follows explicit
17+// per-session cache roots as well as the normal profile.
18+func writeNextStartSafetyMarker(stateRoot string) error {
19+ if stateRoot == "" {
20+ return errors.New("CEF state root is empty")
21+ }
22+ if err := os.MkdirAll(stateRoot, safetyMarkerDirPerm); err != nil {
23+ return err
24+ }
25+
26+ marker := filepath.Join(stateRoot, nextStartSafetyMarkerFile)
27+ temporary, err := os.CreateTemp(stateRoot, ".cef-next-start-safe-*")
28+ if err != nil {
29+ return err
30+ }
31+ temporaryName := temporary.Name()
32+ defer func() { _ = os.Remove(temporaryName) }()
33+ if err := temporary.Chmod(safetyMarkerFilePerm); err != nil {
34+ _ = temporary.Close()
35+ return err
36+ }
37+ if _, err := temporary.WriteString("safe\n"); err != nil {
38+ _ = temporary.Close()
39+ return err
40+ }
41+ if err := temporary.Close(); err != nil {
42+ return err
43+ }
44+ return os.Rename(temporaryName, marker)
45+}
46+
47+// consumeNextStartSafetyMarker consumes a one-shot marker. It deliberately
48+// does not follow symlinks, so an untrusted cache entry cannot redirect the
49+// recovery check outside the CEF state root.
50+func consumeNextStartSafetyMarker(stateRoot string) bool {
51+ if stateRoot == "" {
52+ return false
53+ }
54+ marker := filepath.Join(stateRoot, nextStartSafetyMarkerFile)
55+ info, err := os.Lstat(marker)
56+ if err != nil || !info.Mode().IsRegular() {
57+ return false
58+ }
59+ return os.Remove(marker) == nil
60+}
61+
62+// applyNextStartSafetyEnvironment leaves the Vulkan render stack intact while
63+// returning Chromium to its hardware-decode safety defaults for one start.
64+func applyNextStartSafetyEnvironment() {
65+ _ = os.Setenv(cefEnableVAAPIEnvVar, "0")
66+ _ = os.Unsetenv(cefChromiumFlagsEnvVar)
67+ _ = os.Unsetenv(cefRenderNodeEnvVar)
68+}
A internal/infrastructure/cef/restart_safety_test.go +38 -0
@@ -0,0 +1,38 @@
1+package cef
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestConsumeNextStartSafetyMarkerDisablesVAAPIWithoutChangingVulkanStack(t *testing.T) {
12+ root := t.TempDir()
13+ t.Setenv(cefEnableVAAPIEnvVar, "1")
14+ t.Setenv(cefChromiumFlagsEnvVar, "--ignore-gpu-blocklist --disable-gpu-driver-bug-workaround")
15+ t.Setenv(dumberRenderStackEnvVar, renderStackVulkanDMABUF)
16+
17+ require.NoError(t, writeNextStartSafetyMarker(root))
18+ require.True(t, consumeNextStartSafetyMarker(root))
19+ applyNextStartSafetyEnvironment()
20+
21+ require.Equal(t, "0", os.Getenv(cefEnableVAAPIEnvVar))
22+ require.Empty(t, os.Getenv(cefChromiumFlagsEnvVar))
23+ require.Equal(t, renderStackVulkanDMABUF, os.Getenv(dumberRenderStackEnvVar))
24+ _, err := os.Stat(filepath.Join(root, nextStartSafetyMarkerFile))
25+ require.ErrorIs(t, err, os.ErrNotExist)
26+}
27+
28+func TestGPUProcessRelaunchMarksNextStartSafeAfterRepeatedRelaunch(t *testing.T) {
29+ root := t.TempDir()
30+ engine := &Engine{stateRoot: root}
31+ handler := &dumberBPH{engine: engine}
32+ commandLine := relaunchCommandLineStub{commandLineString: "dumber --type=gpu-process"}
33+
34+ require.Zero(t, handler.OnAlreadyRunningAppRelaunch(commandLine, ""))
35+ require.NoFileExists(t, filepath.Join(root, nextStartSafetyMarkerFile))
36+ require.Zero(t, handler.OnAlreadyRunningAppRelaunch(commandLine, ""))
37+ require.FileExists(t, filepath.Join(root, nextStartSafetyMarkerFile))
38+}
A internal/infrastructure/cef/startup_presentation_hooks_test.go +79 -0
@@ -0,0 +1,79 @@
1+package cef
2+
3+import (
4+ "bytes"
5+ "encoding/json"
6+ "strings"
7+ "testing"
8+ "time"
9+
10+ "github.com/rs/zerolog"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+func TestStartupPresentationHooksWireEveryUpstreamFirstPresentationCallback(t *testing.T) {
15+ var output bytes.Buffer
16+ trace := newStartupTrace(time.Now, time.Now())
17+ trace.SetBackend("gdk-dmabuf")
18+ logger := zerolog.New(&output)
19+ trace.SetLogger(&logger)
20+ for _, milestone := range []string{
21+ "process_entry",
22+ "config_complete",
23+ "cef_library_load_begin",
24+ "cef_initialized",
25+ "browser_create_requested",
26+ } {
27+ require.Truef(t, trace.Mark(milestone), "seed milestone %q", milestone)
28+ }
29+
30+ hooks := startupPresentationHooks(trace)
31+ require.NotNil(t, hooks.OnFirstAcceleratedPaint)
32+ require.NotNil(t, hooks.OnFirstDMABUFTextureSwap)
33+ require.NotNil(t, hooks.OnFirstPresentation)
34+ require.NotNil(t, hooks.OnDMABUFUnsupported)
35+
36+ // The unsupported fact may arrive before the successful presentation path.
37+ // Each callback is invoked twice to make no-op, swapped, and duplicate bodies
38+ // observable through the exact trace output below.
39+ hooks.OnDMABUFUnsupported()
40+ hooks.OnDMABUFUnsupported()
41+ hooks.OnFirstAcceleratedPaint()
42+ hooks.OnFirstAcceleratedPaint()
43+ hooks.OnFirstDMABUFTextureSwap()
44+ hooks.OnFirstDMABUFTextureSwap()
45+ hooks.OnFirstPresentation()
46+ hooks.OnFirstPresentation()
47+
48+ var milestones []string
49+ summaryCount := 0
50+ var incompleteReason string
51+ for _, line := range strings.Split(strings.TrimSpace(output.String()), "\n") {
52+ var event struct {
53+ Message string `json:"message"`
54+ Milestone string `json:"milestone"`
55+ IncompleteReason string `json:"incomplete_reason"`
56+ }
57+ require.NoError(t, json.Unmarshal([]byte(line), &event))
58+ switch event.Message {
59+ case "startup_trace: milestone":
60+ milestones = append(milestones, event.Milestone)
61+ case "startup_trace: first presentation":
62+ summaryCount++
63+ incompleteReason = event.IncompleteReason
64+ }
65+ }
66+
67+ require.Equal(t, []string{
68+ "process_entry",
69+ "config_complete",
70+ "cef_library_load_begin",
71+ "cef_initialized",
72+ "browser_create_requested",
73+ "first_accelerated_paint_received",
74+ "first_dmabuf_texture_swap",
75+ "first_gtk_presentation",
76+ }, milestones)
77+ require.Equal(t, "dmabuf_texture_swap_unavailable", incompleteReason)
78+ require.Equal(t, 1, summaryCount)
79+}
A internal/infrastructure/cef/startup_trace.go +199 -0
@@ -0,0 +1,199 @@
1+package cef
2+
3+import (
4+ "sync"
5+ "time"
6+
7+ "github.com/rs/zerolog"
8+)
9+
10+// startupTrace records the accelerated CEF/DMABUF/GTK path from process entry
11+// to the first GTK presentation. It deliberately rejects unknown, duplicate,
12+// and out-of-order milestones so collected cold-start measurements remain
13+// truthful.
14+type startupTrace struct {
15+ mu sync.Mutex
16+ now func() time.Time
17+ t0 time.Time
18+ milestones []startupMilestone
19+ logger *zerolog.Logger
20+ buffered []startupMilestone
21+ backend string
22+ incompleteReason string
23+ summaryEmitted bool
24+}
25+
26+type startupMilestone struct {
27+ Name string
28+ Elapsed time.Duration
29+ Delta time.Duration
30+}
31+
32+var startupMilestoneOrder = []string{
33+ "process_entry",
34+ "config_complete",
35+ "cef_library_load_begin",
36+ "cef_initialized",
37+ "browser_create_requested",
38+ "first_accelerated_paint_received",
39+ "first_dmabuf_texture_swap",
40+ "first_gtk_presentation",
41+}
42+
43+var processStartupTrace struct {
44+ sync.Mutex
45+ trace *startupTrace
46+}
47+
48+func newStartupTrace(now func() time.Time, processEntry time.Time) *startupTrace {
49+ return &startupTrace{
50+ now: now,
51+ t0: processEntry,
52+ milestones: make([]startupMilestone, 0, len(startupMilestoneOrder)),
53+ buffered: make([]startupMilestone, 0, len(startupMilestoneOrder)),
54+ }
55+}
56+
57+// ActivateStartupTrace starts the CEF-only first-presentation trace after GUI
58+// engine selection. The supplied timestamps are captured by the application at
59+// process entry and configuration completion, before CEF initialization.
60+func ActivateStartupTrace(processEntry, configComplete time.Time, logger *zerolog.Logger) {
61+ if processEntry.IsZero() || configComplete.IsZero() {
62+ return
63+ }
64+
65+ processStartupTrace.Lock()
66+ defer processStartupTrace.Unlock()
67+ if processStartupTrace.trace != nil {
68+ return
69+ }
70+
71+ trace := newStartupTrace(time.Now, processEntry)
72+ if !trace.markAt("process_entry", processEntry) || !trace.markAt("config_complete", configComplete) {
73+ return
74+ }
75+ trace.SetLogger(logger)
76+ processStartupTrace.trace = trace
77+}
78+
79+func activeStartupTrace() *startupTrace {
80+ processStartupTrace.Lock()
81+ defer processStartupTrace.Unlock()
82+ return processStartupTrace.trace
83+}
84+
85+func (st *startupTrace) SetLogger(logger *zerolog.Logger) {
86+ if st == nil || logger == nil {
87+ return
88+ }
89+ st.mu.Lock()
90+ defer st.mu.Unlock()
91+ st.logger = logger
92+ for _, milestone := range st.buffered {
93+ st.emitMilestone(milestone)
94+ }
95+ st.buffered = nil
96+ st.emitSummaryLocked()
97+}
98+
99+func (st *startupTrace) SetBackend(backend string) {
100+ if st == nil {
101+ return
102+ }
103+ st.mu.Lock()
104+ defer st.mu.Unlock()
105+ st.backend = backend
106+}
107+
108+func (st *startupTrace) SetIncompleteReason(reason string) {
109+ if st == nil {
110+ return
111+ }
112+ st.mu.Lock()
113+ defer st.mu.Unlock()
114+ if st.incompleteReason == "" {
115+ st.incompleteReason = reason
116+ }
117+}
118+
119+// Mark accepts exactly the next non-presentation startup milestone.
120+func (st *startupTrace) Mark(name string) bool {
121+ if name == "first_gtk_presentation" {
122+ return false
123+ }
124+ return st.markAt(name, st.currentTime())
125+}
126+
127+// MarkGTKAfterPaint records the final milestone from the CEF-to-GTK bridge's
128+// first frame-clock after-paint callback.
129+func (st *startupTrace) MarkGTKAfterPaint() bool {
130+ return st.markAt("first_gtk_presentation", st.currentTime())
131+}
132+
133+func (st *startupTrace) currentTime() time.Time {
134+ if st == nil || st.now == nil {
135+ return time.Time{}
136+ }
137+ return st.now()
138+}
139+
140+func (st *startupTrace) markAt(name string, at time.Time) bool {
141+ if st == nil || st.now == nil || at.IsZero() {
142+ return false
143+ }
144+ st.mu.Lock()
145+ defer st.mu.Unlock()
146+ if at.Before(st.t0) || len(st.milestones) >= len(startupMilestoneOrder) || name != startupMilestoneOrder[len(st.milestones)] {
147+ return false
148+ }
149+
150+ milestone := startupMilestone{Name: name, Elapsed: at.Sub(st.t0)}
151+ previousMilliseconds := int64(0)
152+ if len(st.milestones) > 0 {
153+ previousMilliseconds = st.milestones[len(st.milestones)-1].Elapsed.Milliseconds()
154+ }
155+ milestone.Delta = time.Duration(milestone.Elapsed.Milliseconds()-previousMilliseconds) * time.Millisecond
156+ st.milestones = append(st.milestones, milestone)
157+ if st.logger == nil {
158+ st.buffered = append(st.buffered, milestone)
159+ } else {
160+ st.emitMilestone(milestone)
161+ }
162+ st.emitSummaryLocked()
163+ return true
164+}
165+
166+func (st *startupTrace) emitMilestone(m startupMilestone) {
167+ if st.logger == nil {
168+ return
169+ }
170+ st.logger.Debug().
171+ Str("milestone", m.Name).
172+ Int64("t_ms", m.Elapsed.Milliseconds()).
173+ Int64("delta_ms", m.Delta.Milliseconds()).
174+ Msg("startup_trace: milestone")
175+}
176+
177+func (st *startupTrace) emitSummaryLocked() {
178+ if st.summaryEmitted || st.logger == nil || len(st.milestones) != len(startupMilestoneOrder) {
179+ return
180+ }
181+ st.summaryEmitted = true
182+ st.logger.Info().
183+ Str("backend", st.backend).
184+ Str("incomplete_reason", st.incompleteReason).
185+ Int64("total_ms", st.milestones[len(st.milestones)-1].Elapsed.Milliseconds()).
186+ Interface("milestones", st.milestones).
187+ Msg("startup_trace: first presentation")
188+}
189+
190+func (st *startupTrace) Enabled() bool { return st != nil && st.now != nil }
191+
192+func (st *startupTrace) TotalElapsed() time.Duration {
193+ if st == nil || st.now == nil {
194+ return 0
195+ }
196+ st.mu.Lock()
197+ defer st.mu.Unlock()
198+ return st.now().Sub(st.t0)
199+}
A internal/infrastructure/cef/startup_trace_test.go +116 -0
@@ -0,0 +1,116 @@
1+package cef
2+
3+import (
4+ "bytes"
5+ "testing"
6+ "time"
7+
8+ "github.com/rs/zerolog"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestActivateStartupTraceSeedsNeutralProcessAndConfigTiming(t *testing.T) {
13+ processStartupTrace.Lock()
14+ previous := processStartupTrace.trace
15+ processStartupTrace.trace = nil
16+ processStartupTrace.Unlock()
17+ t.Cleanup(func() {
18+ processStartupTrace.Lock()
19+ processStartupTrace.trace = previous
20+ processStartupTrace.Unlock()
21+ })
22+
23+ processEntry := time.Unix(100, 0)
24+ configComplete := processEntry.Add(15 * time.Millisecond)
25+ var output bytes.Buffer
26+ logger := zerolog.New(&output).Level(zerolog.DebugLevel)
27+
28+ ActivateStartupTrace(processEntry, configComplete, &logger)
29+ trace := activeStartupTrace()
30+
31+ require.NotNil(t, trace)
32+ require.Equal(t, []string{"process_entry", "config_complete"}, []string{trace.milestones[0].Name, trace.milestones[1].Name})
33+ require.Equal(t, int64(0), trace.milestones[0].Elapsed.Milliseconds())
34+ require.Equal(t, int64(15), trace.milestones[1].Elapsed.Milliseconds())
35+ require.Contains(t, output.String(), `"milestone":"process_entry"`)
36+ require.Contains(t, output.String(), `"milestone":"config_complete"`)
37+ require.NotContains(t, output.String(), `"message":"startup_trace: first presentation"`)
38+}
39+
40+func TestStartupTraceAcceptsOnlyOrderedOneShotMilestones(t *testing.T) {
41+ now := time.Unix(100, 0)
42+ trace := newStartupTrace(func() time.Time { return now }, now)
43+
44+ require.True(t, trace.Mark("process_entry"))
45+ require.False(t, trace.Mark("cef_initialized"), "out-of-order transition must be rejected")
46+ require.False(t, trace.Mark("process_entry"), "duplicate transition must be rejected")
47+
48+ for _, name := range []string{
49+ "config_complete",
50+ "cef_library_load_begin",
51+ "cef_initialized",
52+ "browser_create_requested",
53+ "first_accelerated_paint_received",
54+ "first_dmabuf_texture_swap",
55+ "first_gtk_presentation",
56+ } {
57+ now = now.Add(time.Millisecond)
58+ if name == "first_gtk_presentation" {
59+ require.Truef(t, trace.MarkGTKAfterPaint(), "milestone %s should be accepted", name)
60+ } else {
61+ require.Truef(t, trace.Mark(name), "milestone %s should be accepted", name)
62+ }
63+ }
64+ require.False(t, trace.Mark("first_gtk_presentation"))
65+}
66+
67+func TestStartupTracePreservesProcessAndConfigTimestamps(t *testing.T) {
68+ processEntry := time.Unix(100, 0)
69+ configComplete := processEntry.Add(1500 * time.Microsecond)
70+ now := configComplete.Add(900 * time.Microsecond)
71+ trace := newStartupTrace(func() time.Time { return now }, processEntry)
72+
73+ require.True(t, trace.markAt("process_entry", processEntry))
74+ require.True(t, trace.markAt("config_complete", configComplete))
75+
76+ require.Equal(t, int64(0), trace.milestones[0].Elapsed.Milliseconds())
77+ require.Equal(t, int64(0), trace.milestones[0].Delta.Milliseconds())
78+ require.Equal(t, int64(1), trace.milestones[1].Elapsed.Milliseconds())
79+ require.Equal(t, int64(1), trace.milestones[1].Delta.Milliseconds())
80+}
81+
82+func TestStartupTraceReservesFirstGTKPresentationForAfterPaint(t *testing.T) {
83+ now := time.Unix(100, 0)
84+ trace := newStartupTrace(func() time.Time { return now }, now)
85+
86+ for _, name := range startupMilestoneOrder[:len(startupMilestoneOrder)-1] {
87+ now = now.Add(time.Millisecond)
88+ require.True(t, trace.Mark(name))
89+ }
90+
91+ require.Len(t, trace.milestones, len(startupMilestoneOrder)-1)
92+ require.False(t, trace.Mark("first_gtk_presentation"), "only the CEF-to-GTK after-paint hook can record the reserved milestone")
93+ require.False(t, trace.summaryEmitted)
94+ require.True(t, trace.MarkGTKAfterPaint(), "only the after-paint hook may record this milestone")
95+}
96+
97+func TestStartupTraceEmitsOneNormalSummaryAtFirstPresentation(t *testing.T) {
98+ var output bytes.Buffer
99+ logger := zerolog.New(&output)
100+ now := time.Unix(100, 0)
101+ trace := newStartupTrace(func() time.Time { return now }, now)
102+ trace.SetBackend("gdk-dmabuf")
103+ trace.SetLogger(&logger)
104+
105+ for _, name := range startupMilestoneOrder[:len(startupMilestoneOrder)-1] {
106+ now = now.Add(time.Millisecond)
107+ require.True(t, trace.Mark(name))
108+ }
109+ now = now.Add(time.Millisecond)
110+ require.True(t, trace.MarkGTKAfterPaint())
111+ trace.MarkGTKAfterPaint()
112+
113+ require.Contains(t, output.String(), `"message":"startup_trace: first presentation"`)
114+ require.Contains(t, output.String(), `"backend":"gdk-dmabuf"`)
115+ require.Equal(t, 1, bytes.Count(output.Bytes(), []byte(`"message":"startup_trace: first presentation"`)))
116+}
M internal/infrastructure/cef/webview.go +111 -33
@@ -39,6 +39,10 @@ var (
39 // errDestroyed is returned when an operation is attempted on a destroyed WebView.39 // errDestroyed is returned when an operation is attempted on a destroyed WebView.
40 var errDestroyed = errors.New("cef: webview is destroyed")40 var errDestroyed = errors.New("cef: webview is destroyed")
41 41
42+// unrefTickCallback is the canonical puregotk/purego callback-slot release.
43+// It remains injectable so lifecycle tests can prove exactly-once ownership.
44+var unrefTickCallback = glib.UnrefCallback
45+
42 // errNoBrowser is returned when the browser has not been created yet.46 // errNoBrowser is returned when the browser has not been created yet.
43 var errNoBrowser = errors.New("cef: browser not yet created")47 var errNoBrowser = errors.New("cef: browser not yet created")
44 48
@@ -107,11 +111,19 @@ type WebView struct {
107 viewportSyncReason string111 viewportSyncReason string
108 viewportMapFunc func(gtk.Widget)112 viewportMapFunc func(gtk.Widget)
109 viewportShowFunc func(gtk.Widget)113 viewportShowFunc func(gtk.Widget)
114+ viewportHideFunc func(gtk.Widget)
115+ viewportUnmapFunc func(gtk.Widget)
110 viewportRealizeFunc func(gtk.Widget)116 viewportRealizeFunc func(gtk.Widget)
111 viewportMapSignalID uint117 viewportMapSignalID uint
112 viewportShowSignalID uint118 viewportShowSignalID uint
119+ viewportHideSignalID uint
120+ viewportUnmapSignalID uint
113 viewportRealizeSignalID uint121 viewportRealizeSignalID uint
114 viewportResizePulseSeq atomic.Uint64122 viewportResizePulseSeq atomic.Uint64
123+ // effectiveVisibility records the last CEF WasHidden state dispatched. It
124+ // is guarded by mu because GTK and CEF UI work cross threads.
125+ effectiveVisibilityKnown bool
126+ effectiveVisible bool
115 127
116 adaptiveWindowlessFrameRate bool128 adaptiveWindowlessFrameRate bool
117 windowlessFrameRateMax int32129 windowlessFrameRateMax int32
@@ -227,6 +239,18 @@ type WebView struct {
227 backgroundColor uint32239 backgroundColor uint32
228 inputConfig RuntimeInputConfig240 inputConfig RuntimeInputConfig
229 241
242+ // Background flash guard: a theme-colored CSS provider painted behind the
243+ // render widget until the first CEF frame is presented. It replaces the
244+ // former dark BrowserSettings.BackgroundColor anti-white-flash, whose canvas
245+ // is now always opaque white. State is guarded by bgGuardMu; firstFramePainted
246+ // gates the one-shot teardown and resets naturally when a fresh WebView is
247+ // created after a browser recreation.
248+ firstFramePainted atomic.Bool
249+ bgGuardMu sync.Mutex
250+ bgGuardProvider *gtk.CssProvider
251+ bgGuardWidget *gtk.Widget
252+ bgGuardClass string
253+
230 // Touchpad input diagnostics are debounced to avoid flooding logs while254 // Touchpad input diagnostics are debounced to avoid flooding logs while
231 // debugging continuous scroll streams.255 // debugging continuous scroll streams.
232 touchpadNavigation *touchpadNavigationRecognizer256 touchpadNavigation *touchpadNavigationRecognizer
@@ -238,16 +262,25 @@ type WebView struct {
238 inputDiagDeltaXSum int64262 inputDiagDeltaXSum int64
239 inputDiagDeltaYSum int64263 inputDiagDeltaYSum int64
240 264
241- // Audio output factory and active stream.265+ // Audio output lifecycle. Packet callbacks load audioStream directly: its
242- audioOutputFactory port.AudioOutputFactory266+ // immutable handle is atomically published and never requires a mutex.
243- audioStreamMu sync.Mutex267+ audioOutputFactory port.AudioOutputFactory
244- activeAudioStream port.AudioOutputStream268+ audioStreamMu sync.Mutex
269+ audioStreamGeneration atomic.Uint64
270+ audioStream atomic.Pointer[audioStreamHandle]
245 271
246 // Audio instrumentation counters (diagnostic only).272 // Audio instrumentation counters (diagnostic only).
247 audioPacketCount atomic.Uint64 // total OnAudioStreamPacket calls273 audioPacketCount atomic.Uint64 // total OnAudioStreamPacket calls
248 audioWriteCount atomic.Uint64 // successful Write calls to stream274 audioWriteCount atomic.Uint64 // successful Write calls to stream
249 }275 }
250 276
277+// audioStreamHandle is immutable after publication so packet callbacks can
278+// safely snapshot it without coordinating with lifecycle operations.
279+type audioStreamHandle struct {
280+ stream port.AudioOutputStream
281+ generation uint64
282+}
283+
251 // pendingBrowserCreate holds the parameters needed to create a CEF browser,284 // pendingBrowserCreate holds the parameters needed to create a CEF browser,
252 // deferred until the GL area has a non-zero size.285 // deferred until the GL area has a non-zero size.
253 type pendingBrowserCreate struct {286 type pendingBrowserCreate struct {
@@ -971,7 +1004,7 @@ func (wv *WebView) prepareNativePopup(
971 prep.parent.trackPendingNativePopup(popupID, wv)1004 prep.parent.trackPendingNativePopup(popupID, wv)
972 }1005 }
973 1006
974- configureNativePopupWindow(windowInfo, settings, prep.frameRate, prep.backgroundColor)1007+ configureNativePopupWindow(windowInfo, settings, prep.frameRate)
975 clientSlot.Set(client)1008 clientSlot.Set(client)
976 return true1009 return true
977 }1010 }
@@ -986,9 +1019,8 @@ func (wv *WebView) nativePopupPreparationSnapshot() (nativePopupPreparation, boo
986 return nativePopupPreparation{}, false1019 return nativePopupPreparation{}, false
987 }1020 }
988 return nativePopupPreparation{1021 return nativePopupPreparation{
989- parent: wv.nativePopupParent,1022+ parent: wv.nativePopupParent,
990- frameRate: wv.windowlessFrameRate,1023+ frameRate: wv.windowlessFrameRate,
991- backgroundColor: wv.backgroundColor,
992 }, true1024 }, true
993 }1025 }
994 1026
@@ -1024,7 +1056,6 @@ func configureNativePopupWindow(
1024 windowInfo *purecef.WindowInfo,1056 windowInfo *purecef.WindowInfo,
1025 settings *purecef.BrowserSettings,1057 settings *purecef.BrowserSettings,
1026 frameRate int32,1058 frameRate int32,
1027- backgroundColor uint32,
1028 ) {1059 ) {
1029 cef2gtk.ConfigureWindowInfo(windowInfo, cef2gtk.WindowInfoOptions{})1060 cef2gtk.ConfigureWindowInfo(windowInfo, cef2gtk.WindowInfoOptions{})
1030 if externalBeginFrameEnabled() {1061 if externalBeginFrameEnabled() {
@@ -1035,15 +1066,13 @@ func configureNativePopupWindow(
1035 }1066 }
1036 cef2gtk.ConfigureBrowserSettings(settings, cef2gtk.BrowserSettingsOptions{WindowlessFrameRate: frameRate})1067 cef2gtk.ConfigureBrowserSettings(settings, cef2gtk.BrowserSettingsOptions{WindowlessFrameRate: frameRate})
1037 settings.LocalStorage = 11068 settings.LocalStorage = 1
1038- if backgroundColor != 0 {1069+ // Always opaque white; the popup shell WebView carries the theme flash guard.
1039- settings.BackgroundColor = backgroundColor1070+ settings.BackgroundColor = opaqueWhiteBackground
1040- }
1041 }1071 }
1042 1072
1043 type nativePopupPreparation struct {1073 type nativePopupPreparation struct {
1044- parent *WebView1074+ parent *WebView
1045- frameRate int321075+ frameRate int32
1046- backgroundColor uint32
1047 }1076 }
1048 1077
1049 func (wv *WebView) handleNativePopupAborted() {1078 func (wv *WebView) handleNativePopupAborted() {
@@ -1223,6 +1252,7 @@ func (wv *WebView) destroyViewBridgeOnGTKThread() {
1223 if wv == nil || wv.viewBridge == nil {1252 if wv == nil || wv.viewBridge == nil {
1224 return1253 return
1225 }1254 }
1255+ wv.removeBackgroundFlashGuard()
1226 if wv.removeSizeObserver != nil {1256 if wv.removeSizeObserver != nil {
1227 wv.removeSizeObserver()1257 wv.removeSizeObserver()
1228 wv.removeSizeObserver = nil1258 wv.removeSizeObserver = nil
@@ -2067,12 +2097,14 @@ func (wv *WebView) startBeginFrameLoop() {
2067 cb := new(gtk.TickCallback)2097 cb := new(gtk.TickCallback)
2068 *cb = func(_, _, _ uintptr) bool {2098 *cb = func(_, _, _ uintptr) bool {
2069 if wv.destroyed.Load() {2099 if wv.destroyed.Load() {
2100+ wv.releaseBeginFrameTickCallback()
2070 return false2101 return false
2071 }2102 }
2072 wv.mu.RLock()2103 wv.mu.RLock()
2073 host := wv.host2104 host := wv.host
2074 wv.mu.RUnlock()2105 wv.mu.RUnlock()
2075 if host == nil {2106 if host == nil {
2107+ wv.releaseBeginFrameTickCallback()
2076 return false2108 return false
2077 }2109 }
2078 host.SendExternalBeginFrame()2110 host.SendExternalBeginFrame()
@@ -2095,19 +2127,34 @@ func (wv *WebView) startBeginFrameLoop() {
2095 }2127 }
2096 2128
2097 func (wv *WebView) stopBeginFrameLoop() {2129 func (wv *WebView) stopBeginFrameLoop() {
2098- if wv.viewBridge == nil || wv.viewBridge.Widget() == nil {
2099- return
2100- }
2101-
2102 wv.mu.Lock()2130 wv.mu.Lock()
2103 tickID := wv.beginFrameTickID2131 tickID := wv.beginFrameTickID
2132+ callback := wv.beginFrameTick
2104 wv.beginFrameTickID = 02133 wv.beginFrameTickID = 0
2105 wv.beginFrameTick = nil2134 wv.beginFrameTick = nil
2106- widget := wv.viewBridge.Widget()2135+ bridge := wv.viewBridge
2107 wv.mu.Unlock()2136 wv.mu.Unlock()
2108 2137
2109- if tickID != 0 {2138+ if tickID != 0 && bridge != nil {
2110- widget.RemoveTickCallback(tickID)2139+ if widget := bridge.Widget(); widget != nil {
2140+ widget.RemoveTickCallback(tickID)
2141+ }
2142+ }
2143+ if callback != nil {
2144+ _ = unrefTickCallback(callback)
2145+ }
2146+}
2147+
2148+// releaseBeginFrameTickCallback releases the purego callback slot after GTK
2149+// removes a source because its callback returned false.
2150+func (wv *WebView) releaseBeginFrameTickCallback() {
2151+ wv.mu.Lock()
2152+ callback := wv.beginFrameTick
2153+ wv.beginFrameTickID = 0
2154+ wv.beginFrameTick = nil
2155+ wv.mu.Unlock()
2156+ if callback != nil {
2157+ _ = unrefTickCallback(callback)
2111 }2158 }
2112 }2159 }
2113 2160
@@ -2302,20 +2349,51 @@ func (wv *WebView) takePendingCreate() *pendingBrowserCreate {
2302 return pc2349 return pc
2303 }2350 }
2304 2351
2305-// closeAudioStream closes and clears the active audio output stream.2352+// beginAudioStreamStart invalidates any prior stream before creating a new
2306-// This is safe to call even if no stream is active.2353+// one. A stream created for the returned generation may only be published if
2307-func (wv *WebView) closeAudioStream() {2354+// that generation is still current.
2355+func (wv *WebView) beginAudioStreamStart() (uint64, *audioStreamHandle, bool) {
2308 wv.audioStreamMu.Lock()2356 wv.audioStreamMu.Lock()
2309 defer wv.audioStreamMu.Unlock()2357 defer wv.audioStreamMu.Unlock()
2358+ if wv.destroyed.Load() {
2359+ return 0, nil, false
2360+ }
2361+ generation := wv.audioStreamGeneration.Add(1)
2362+ previous := wv.audioStream.Swap(nil)
2363+ wv.audioPlaying.Store(false)
2364+ return generation, previous, true
2365+}
2310 2366
2311- if wv.activeAudioStream != nil {2367+// publishAudioStream publishes stream only if no Stop, Destroy, or newer Start
2312- if err := wv.activeAudioStream.Close(); err != nil {2368+// invalidated its creation generation. A rejected stream is owned by the
2313- if wv.ctx != nil {2369+// caller and must be closed by it.
2314- logging.FromContext(wv.ctx).Debug().2370+func (wv *WebView) publishAudioStream(generation uint64, stream port.AudioOutputStream) (*audioStreamHandle, bool) {
2315- Err(err).2371+ wv.audioStreamMu.Lock()
2316- Msg("cef: error closing audio stream")2372+ defer wv.audioStreamMu.Unlock()
2317- }2373+ if wv.destroyed.Load() || wv.audioStreamGeneration.Load() != generation {
2374+ return nil, false
2375+ }
2376+ previous := wv.audioStream.Swap(&audioStreamHandle{stream: stream, generation: generation})
2377+ wv.audioPlaying.Store(true)
2378+ return previous, true
2379+}
2380+
2381+// closeAudioStream invalidates creation in progress, atomically detaches the
2382+// published handle, then closes it outside the lifecycle mutex. The port
2383+// contract permits Close concurrently with a packet Write.
2384+func (wv *WebView) closeAudioStream() {
2385+ wv.audioStreamMu.Lock()
2386+ wv.audioStreamGeneration.Add(1)
2387+ stream := wv.audioStream.Swap(nil)
2388+ wv.audioPlaying.Store(false)
2389+ wv.audioStreamMu.Unlock()
2390+ wv.closeDetachedAudioStream(stream)
2391+}
2392+
2393+func (wv *WebView) closeDetachedAudioStream(handle *audioStreamHandle) {
2394+ if handle != nil && handle.stream != nil {
2395+ if err := handle.stream.Close(); err != nil && wv.ctx != nil {
2396+ logging.FromContext(wv.ctx).Debug().Err(err).Msg("cef: error closing audio stream")
2318 }2397 }
2319- wv.activeAudioStream = nil
2320 }2398 }
2321 }2399 }
M internal/infrastructure/cef/webview_audio_test.go +141 -58
@@ -4,6 +4,7 @@ import (
4 "bytes"4 "bytes"
5 "context"5 "context"
6 "errors"6 "errors"
7+ "sync/atomic"
7 "testing"8 "testing"
8 "time"9 "time"
9 10
@@ -83,10 +84,10 @@ func TestWebView_AudioStreamLifecycleFieldsExist(t *testing.T) {
83 }84 }
84 85
85 // Verify the fields exist and can be set86 // Verify the fields exist and can be set
86- wv.activeAudioStream = &mockAudioStream{}87+ setAudioStream(wv, &mockAudioStream{})
87 88
88 assert.NotNil(t, wv.audioOutputFactory)89 assert.NotNil(t, wv.audioOutputFactory)
89- assert.NotNil(t, wv.activeAudioStream)90+ assert.NotNil(t, activeAudioStream(wv))
90 }91 }
91 92
92 // mockAudioStream is a test double for port.AudioOutputStream93 // mockAudioStream is a test double for port.AudioOutputStream
@@ -110,6 +111,18 @@ func (m *mockAudioStream) Close() error {
110 // Verify mock implements the interface111 // Verify mock implements the interface
111 var _ port.AudioOutputStream = (*mockAudioStream)(nil)112 var _ port.AudioOutputStream = (*mockAudioStream)(nil)
112 113
114+func setAudioStream(wv *WebView, stream port.AudioOutputStream) {
115+ wv.audioStream.Store(&audioStreamHandle{stream: stream, generation: wv.audioStreamGeneration.Load()})
116+}
117+
118+func activeAudioStream(wv *WebView) port.AudioOutputStream {
119+ handle := wv.audioStream.Load()
120+ if handle == nil {
121+ return nil
122+ }
123+ return handle.stream
124+}
125+
113 // racyMockAudioStream blocks inside Write until doneCh is closed, allowing126 // racyMockAudioStream blocks inside Write until doneCh is closed, allowing
114 // tests to verify that the caller holds a lock across the Write call.127 // tests to verify that the caller holds a lock across the Write call.
115 type racyMockAudioStream struct {128 type racyMockAudioStream struct {
@@ -131,6 +144,26 @@ func (m *racyMockAudioStream) Close() error {
131 144
132 var _ port.AudioOutputStream = (*racyMockAudioStream)(nil)145 var _ port.AudioOutputStream = (*racyMockAudioStream)(nil)
133 146
147+type countedAudioStream struct{ closes atomic.Int32 }
148+
149+func (*countedAudioStream) Write([][]float32) error { return nil }
150+func (s *countedAudioStream) Close() error {
151+ s.closes.Add(1)
152+ return nil
153+}
154+
155+type controlledAudioFactory struct {
156+ entered chan struct{}
157+ release chan struct{}
158+ stream port.AudioOutputStream
159+}
160+
161+func (f *controlledAudioFactory) NewStream(context.Context, port.AudioStreamFormat) (port.AudioOutputStream, error) {
162+ close(f.entered)
163+ <-f.release
164+ return f.stream, nil
165+}
166+
134 // ============================================================================167 // ============================================================================
135 // Task 6: Audio Stream Lifecycle Handling Tests168 // Task 6: Audio Stream Lifecycle Handling Tests
136 // ============================================================================169 // ============================================================================
@@ -159,8 +192,8 @@ func TestHandlerSet_OnAudioStreamStarted_CreatesStream(t *testing.T) {
159 handlers.OnAudioStreamStarted(nil, params, 2)192 handlers.OnAudioStreamStarted(nil, params, 2)
160 193
161 // Assert194 // Assert
162- require.NotNil(t, wv.activeAudioStream)195+ require.NotNil(t, activeAudioStream(wv))
163- assert.Equal(t, mockStream, wv.activeAudioStream)196+ assert.Equal(t, mockStream, activeAudioStream(wv))
164 assert.True(t, wv.audioPlaying.Load())197 assert.True(t, wv.audioPlaying.Load())
165 198
166 // Verify factory was called with correct format199 // Verify factory was called with correct format
@@ -205,8 +238,8 @@ func TestHandlerSet_OnAudioStreamStarted_ClosesExistingStream(t *testing.T) {
205 wv := &WebView{238 wv := &WebView{
206 ctx: ctx,239 ctx: ctx,
207 audioOutputFactory: mockFactory,240 audioOutputFactory: mockFactory,
208- activeAudioStream: oldStream,
209 }241 }
242+ setAudioStream(wv, oldStream)
210 243
211 handlers := &handlerSet{wv: wv}244 handlers := &handlerSet{wv: wv}
212 245
@@ -221,7 +254,7 @@ func TestHandlerSet_OnAudioStreamStarted_ClosesExistingStream(t *testing.T) {
221 254
222 // Assert255 // Assert
223 assert.True(t, oldStream.closeCalled)256 assert.True(t, oldStream.closeCalled)
224- assert.Equal(t, newStream, wv.activeAudioStream)257+ assert.Equal(t, newStream, activeAudioStream(wv))
225 }258 }
226 259
227 // TestHandlerSet_OnAudioStreamStarted_FactoryError_HandlesGracefully verifies260 // TestHandlerSet_OnAudioStreamStarted_FactoryError_HandlesGracefully verifies
@@ -251,23 +284,23 @@ func TestHandlerSet_OnAudioStreamStarted_FactoryError_HandlesGracefully(t *testi
251 // audioPlaying must be false when NewStream fails — if it stays true,284 // audioPlaying must be false when NewStream fails — if it stays true,
252 // the UI/state layer incorrectly believes audio is active.285 // the UI/state layer incorrectly believes audio is active.
253 assert.False(t, wv.audioPlaying.Load(), "audioPlaying must be false after NewStream failure")286 assert.False(t, wv.audioPlaying.Load(), "audioPlaying must be false after NewStream failure")
254- assert.Nil(t, wv.activeAudioStream, "activeAudioStream must be nil after NewStream failure")287+ assert.Nil(t, activeAudioStream(wv), "activeAudioStream must be nil after NewStream failure")
255 }288 }
256 289
257 // ============================================================================290 // ============================================================================
258 // Task 7: PCM Packet Forwarding Tests291 // Task 7: PCM Packet Forwarding Tests
259 // ============================================================================292 // ============================================================================
260 293
261-// TestHandlerSet_OnAudioStreamPacket_CopiesAndWrites verifies that294+// TestHandlerSet_OnAudioStreamPacket_ForwardsCallbackData verifies that CEF
262-// OnAudioStreamPacket copies samples before writing to avoid data races.295+// does not allocate a staging copy; the audio output port owns that handoff.
263-func TestHandlerSet_OnAudioStreamPacket_CopiesAndWrites(t *testing.T) {296+func TestHandlerSet_OnAudioStreamPacket_ForwardsCallbackData(t *testing.T) {
264 ctx := context.Background()297 ctx := context.Background()
265 mockStream := &mockAudioStream{}298 mockStream := &mockAudioStream{}
266 299
267 wv := &WebView{300 wv := &WebView{
268- ctx: ctx,301+ ctx: ctx,
269- activeAudioStream: mockStream,
270 }302 }
303+ setAudioStream(wv, mockStream)
271 304
272 handlers := &handlerSet{wv: wv}305 handlers := &handlerSet{wv: wv}
273 306
@@ -287,20 +320,16 @@ func TestHandlerSet_OnAudioStreamPacket_CopiesAndWrites(t *testing.T) {
287 require.Len(t, mockStream.writeSamples, 2)320 require.Len(t, mockStream.writeSamples, 2)
288 require.Len(t, mockStream.writeSamples[0], 512)321 require.Len(t, mockStream.writeSamples[0], 512)
289 322
290- // Verify data was copied (values match)
291 assert.InDelta(t, 0.5, mockStream.writeSamples[0][0], 0.000001)323 assert.InDelta(t, 0.5, mockStream.writeSamples[0][0], 0.000001)
292 assert.InDelta(t, -0.5, mockStream.writeSamples[1][0], 0.000001)324 assert.InDelta(t, -0.5, mockStream.writeSamples[1][0], 0.000001)
293-325+ assert.Same(t, &originalData[0][0], &mockStream.writeSamples[0][0])
294- // Verify it's a copy (different underlying array)
295- assert.NotSame(t, &originalData[0][0], &mockStream.writeSamples[0][0])
296 }326 }
297 327
298 // TestHandlerSet_OnAudioStreamPacket_NoActiveStream_DoesNothing verifies328 // TestHandlerSet_OnAudioStreamPacket_NoActiveStream_DoesNothing verifies
299 // that packets are silently discarded when no stream is active.329 // that packets are silently discarded when no stream is active.
300 func TestHandlerSet_OnAudioStreamPacket_NoActiveStream_DoesNothing(t *testing.T) {330 func TestHandlerSet_OnAudioStreamPacket_NoActiveStream_DoesNothing(t *testing.T) {
301 wv := &WebView{331 wv := &WebView{
302- ctx: context.Background(),332+ ctx: context.Background(),
303- activeAudioStream: nil,
304 }333 }
305 334
306 handlers := &handlerSet{wv: wv}335 handlers := &handlerSet{wv: wv}
@@ -319,10 +348,8 @@ func TestHandlerSet_OnAudioStreamPacket_NoActiveStream_DoesNothing(t *testing.T)
319 // TestHandlerSet_OnAudioStreamPacket_NilData_DoesNotPanic verifies348 // TestHandlerSet_OnAudioStreamPacket_NilData_DoesNotPanic verifies
320 // that nil data is handled gracefully.349 // that nil data is handled gracefully.
321 func TestHandlerSet_OnAudioStreamPacket_NilData_DoesNotPanic(t *testing.T) {350 func TestHandlerSet_OnAudioStreamPacket_NilData_DoesNotPanic(t *testing.T) {
322- mockStream := &mockAudioStream{}
323 wv := &WebView{351 wv := &WebView{
324- ctx: context.Background(),352+ ctx: context.Background(),
325- activeAudioStream: mockStream,
326 }353 }
327 354
328 handlers := &handlerSet{wv: wv}355 handlers := &handlerSet{wv: wv}
@@ -333,6 +360,19 @@ func TestHandlerSet_OnAudioStreamPacket_NilData_DoesNotPanic(t *testing.T) {
333 })360 })
334 }361 }
335 362
363+func BenchmarkHandlerSet_OnAudioStreamPacketStereo(b *testing.B) {
364+ stream := &mockAudioStream{}
365+ wv := &WebView{ctx: context.Background()}
366+ setAudioStream(wv, stream)
367+ handlers := &handlerSet{wv: wv}
368+ data := [][]float32{make([]float32, 512), make([]float32, 512)}
369+ b.ReportAllocs()
370+ b.SetBytes(2 * 512 * 4)
371+ for i := 0; i < b.N; i++ {
372+ handlers.OnAudioStreamPacket(nil, data, 512, 0)
373+ }
374+}
375+
336 // ============================================================================376 // ============================================================================
337 // Task 8: Stream Cleanup Tests377 // Task 8: Stream Cleanup Tests
338 // ============================================================================378 // ============================================================================
@@ -342,9 +382,9 @@ func TestHandlerSet_OnAudioStreamPacket_NilData_DoesNotPanic(t *testing.T) {
342 func TestHandlerSet_OnAudioStreamStopped_ClosesStream(t *testing.T) {382 func TestHandlerSet_OnAudioStreamStopped_ClosesStream(t *testing.T) {
343 mockStream := &mockAudioStream{}383 mockStream := &mockAudioStream{}
344 wv := &WebView{384 wv := &WebView{
345- ctx: context.Background(),385+ ctx: context.Background(),
346- activeAudioStream: mockStream,
347 }386 }
387+ setAudioStream(wv, mockStream)
348 wv.audioPlaying.Store(true)388 wv.audioPlaying.Store(true)
349 389
350 handlers := &handlerSet{wv: wv}390 handlers := &handlerSet{wv: wv}
@@ -354,7 +394,7 @@ func TestHandlerSet_OnAudioStreamStopped_ClosesStream(t *testing.T) {
354 394
355 // Assert395 // Assert
356 assert.True(t, mockStream.closeCalled)396 assert.True(t, mockStream.closeCalled)
357- assert.Nil(t, wv.activeAudioStream)397+ assert.Nil(t, activeAudioStream(wv))
358 assert.False(t, wv.audioPlaying.Load())398 assert.False(t, wv.audioPlaying.Load())
359 }399 }
360 400
@@ -362,8 +402,7 @@ func TestHandlerSet_OnAudioStreamStopped_ClosesStream(t *testing.T) {
362 // that stopping when no stream exists doesn't panic.402 // that stopping when no stream exists doesn't panic.
363 func TestHandlerSet_OnAudioStreamStopped_NoActiveStream_DoesNotPanic(t *testing.T) {403 func TestHandlerSet_OnAudioStreamStopped_NoActiveStream_DoesNotPanic(t *testing.T) {
364 wv := &WebView{404 wv := &WebView{
365- ctx: context.Background(),405+ ctx: context.Background(),
366- activeAudioStream: nil,
367 }406 }
368 wv.audioPlaying.Store(true)407 wv.audioPlaying.Store(true)
369 408
@@ -382,9 +421,9 @@ func TestHandlerSet_OnAudioStreamStopped_NoActiveStream_DoesNotPanic(t *testing.
382 func TestHandlerSet_OnAudioStreamError_ClosesStream(t *testing.T) {421 func TestHandlerSet_OnAudioStreamError_ClosesStream(t *testing.T) {
383 mockStream := &mockAudioStream{}422 mockStream := &mockAudioStream{}
384 wv := &WebView{423 wv := &WebView{
385- ctx: context.Background(),424+ ctx: context.Background(),
386- activeAudioStream: mockStream,
387 }425 }
426+ setAudioStream(wv, mockStream)
388 wv.audioPlaying.Store(true)427 wv.audioPlaying.Store(true)
389 428
390 handlers := &handlerSet{wv: wv}429 handlers := &handlerSet{wv: wv}
@@ -394,7 +433,7 @@ func TestHandlerSet_OnAudioStreamError_ClosesStream(t *testing.T) {
394 433
395 // Assert434 // Assert
396 assert.True(t, mockStream.closeCalled)435 assert.True(t, mockStream.closeCalled)
397- assert.Nil(t, wv.activeAudioStream)436+ assert.Nil(t, activeAudioStream(wv))
398 assert.False(t, wv.audioPlaying.Load())437 assert.False(t, wv.audioPlaying.Load())
399 }438 }
400 439
@@ -402,8 +441,7 @@ func TestHandlerSet_OnAudioStreamError_ClosesStream(t *testing.T) {
402 // that error handling when no stream exists doesn't panic.441 // that error handling when no stream exists doesn't panic.
403 func TestHandlerSet_OnAudioStreamError_NoActiveStream_DoesNotPanic(t *testing.T) {442 func TestHandlerSet_OnAudioStreamError_NoActiveStream_DoesNotPanic(t *testing.T) {
404 wv := &WebView{443 wv := &WebView{
405- ctx: context.Background(),444+ ctx: context.Background(),
406- activeAudioStream: nil,
407 }445 }
408 wv.audioPlaying.Store(true)446 wv.audioPlaying.Store(true)
409 447
@@ -456,9 +494,9 @@ func TestHandlerSet_OnAudioStreamError_ShutdownSocketCloseLogsDebug(t *testing.T
456 func TestWebView_Destroy_ClosesAudioStream(t *testing.T) {494 func TestWebView_Destroy_ClosesAudioStream(t *testing.T) {
457 mockStream := &mockAudioStream{}495 mockStream := &mockAudioStream{}
458 wv := &WebView{496 wv := &WebView{
459- ctx: context.Background(),497+ ctx: context.Background(),
460- activeAudioStream: mockStream,
461 }498 }
499+ setAudioStream(wv, mockStream)
462 wv.audioPlaying.Store(true)500 wv.audioPlaying.Store(true)
463 501
464 // Act — Destroy must close the audio stream as part of teardown.502 // Act — Destroy must close the audio stream as part of teardown.
@@ -466,7 +504,7 @@ func TestWebView_Destroy_ClosesAudioStream(t *testing.T) {
466 504
467 // Assert505 // Assert
468 assert.True(t, mockStream.closeCalled, "Destroy must close the active audio stream")506 assert.True(t, mockStream.closeCalled, "Destroy must close the active audio stream")
469- assert.Nil(t, wv.activeAudioStream)507+ assert.Nil(t, activeAudioStream(wv))
470 }508 }
471 509
472 // TestWebView_Destroy_NoAudioStream_DoesNotPanic verifies that Destroy510 // TestWebView_Destroy_NoAudioStream_DoesNotPanic verifies that Destroy
@@ -558,28 +596,81 @@ func TestOnAudioStreamStarted_CorrectParameterMapping(t *testing.T) {
558 assert.Equal(t, 1024, mockFactory.lastFormat.FramesPerBuffer, "frames_per_buffer should be 1024 (from params.FramesPerBuffer)")596 assert.Equal(t, 1024, mockFactory.lastFormat.FramesPerBuffer, "frames_per_buffer should be 1024 (from params.FramesPerBuffer)")
559 }597 }
560 598
599+func TestHandlerSet_OnAudioStreamStarted_StopRejectsInFlightCreation(t *testing.T) {
600+ stream := &countedAudioStream{}
601+ factory := &controlledAudioFactory{entered: make(chan struct{}), release: make(chan struct{}), stream: stream}
602+ wv := &WebView{ctx: context.Background(), audioOutputFactory: factory}
603+ handlers := &handlerSet{wv: wv}
604+ params := &purecef.AudioParameters{SampleRate: 48000, FramesPerBuffer: 512}
605+ done := make(chan struct{})
606+ go func() { handlers.OnAudioStreamStarted(nil, params, 2); close(done) }()
607+ <-factory.entered
608+ handlers.OnAudioStreamStopped(nil)
609+ close(factory.release)
610+ <-done
611+
612+ assert.Nil(t, activeAudioStream(wv))
613+ assert.False(t, wv.audioPlaying.Load())
614+ assert.Equal(t, int32(1), stream.closes.Load())
615+}
616+
617+func TestHandlerSet_OnAudioStreamStarted_DestroyRejectsInFlightCreation(t *testing.T) {
618+ stream := &countedAudioStream{}
619+ factory := &controlledAudioFactory{entered: make(chan struct{}), release: make(chan struct{}), stream: stream}
620+ wv := &WebView{ctx: context.Background(), audioOutputFactory: factory}
621+ handlers := &handlerSet{wv: wv}
622+ params := &purecef.AudioParameters{SampleRate: 48000, FramesPerBuffer: 512}
623+ done := make(chan struct{})
624+ go func() { handlers.OnAudioStreamStarted(nil, params, 2); close(done) }()
625+ <-factory.entered
626+ wv.Destroy()
627+ close(factory.release)
628+ <-done
629+
630+ assert.Nil(t, activeAudioStream(wv))
631+ assert.True(t, wv.destroyed.Load())
632+ assert.Equal(t, int32(1), stream.closes.Load())
633+}
634+
635+func TestHandlerSet_OnAudioStreamStarted_ReplacesAndClosesEachStreamOnce(t *testing.T) {
636+ old, first, second := &countedAudioStream{}, &countedAudioStream{}, &countedAudioStream{}
637+ factory := &mockAudioFactory{stream: first}
638+ wv := &WebView{ctx: context.Background(), audioOutputFactory: factory}
639+ setAudioStream(wv, old)
640+ handlers := &handlerSet{wv: wv}
641+ params := &purecef.AudioParameters{SampleRate: 48000, FramesPerBuffer: 512}
642+
643+ handlers.OnAudioStreamStarted(nil, params, 2)
644+ factory.stream = second
645+ handlers.OnAudioStreamStarted(nil, params, 2)
646+ wv.closeAudioStream()
647+
648+ assert.Equal(t, int32(1), old.closes.Load())
649+ assert.Equal(t, int32(1), first.closes.Load())
650+ assert.Equal(t, int32(1), second.closes.Load())
651+ assert.Nil(t, activeAudioStream(wv))
652+}
653+
561 // ============================================================================654 // ============================================================================
562 // Race-window tests (write/close interleave)655 // Race-window tests (write/close interleave)
563 // ============================================================================656 // ============================================================================
564 657
565-// TestOnAudioStreamPacket_WriteHoldsLock verifies that closeAudioStream cannot658+// TestOnAudioStreamPacket_CloseDoesNotWaitForWrite verifies that lifecycle
566-// interleave between the stream snapshot and Write. If the lock is NOT held659+// shutdown is not serialized behind a potentially slow callback write. The
567-// across Write, the goroutine calling closeAudioStream will Close the stream660+// port contract makes Close/Write concurrency safe.
568-// while Write is in-flight, producing a write-after-close.661+func TestOnAudioStreamPacket_CloseDoesNotWaitForWrite(t *testing.T) {
569-func TestOnAudioStreamPacket_WriteHoldsLock(t *testing.T) {
570 ctx := context.Background()662 ctx := context.Background()
571 663
572- // racyStream blocks inside Write until we signal, giving closeAudioStream664+ // racyStream blocks inside Write until we signal.
573- // a window to race if the mutex isn't held.
574 stream := &racyMockAudioStream{665 stream := &racyMockAudioStream{
575 writeCh: make(chan struct{}),666 writeCh: make(chan struct{}),
576 doneCh: make(chan struct{}),667 doneCh: make(chan struct{}),
577 }668 }
578 669
579 wv := &WebView{670 wv := &WebView{
580- ctx: ctx,671+ ctx: ctx,
581- activeAudioStream: stream,
582 }672 }
673+ setAudioStream(wv, stream)
583 handlers := &handlerSet{wv: wv}674 handlers := &handlerSet{wv: wv}
584 675
585 data := [][]float32{{0.1, 0.2}, {0.3, 0.4}}676 data := [][]float32{{0.1, 0.2}, {0.3, 0.4}}
@@ -590,32 +681,24 @@ func TestOnAudioStreamPacket_WriteHoldsLock(t *testing.T) {
590 // Wait for Write to be entered.681 // Wait for Write to be entered.
591 <-stream.writeCh682 <-stream.writeCh
592 683
593- // Now try to close. If the implementation holds the lock across Write,
594- // this will block until Write returns. If not, Close races with Write.
595 closeDone := make(chan struct{})684 closeDone := make(chan struct{})
596 go func() {685 go func() {
597 wv.closeAudioStream()686 wv.closeAudioStream()
598 close(closeDone)687 close(closeDone)
599 }()688 }()
600 689
601- // Close should NOT have completed yet because Write holds the lock.
602- // Give the goroutine time to acquire the lock if it can.
603- time.Sleep(50 * time.Millisecond)
604 select {690 select {
605 case <-closeDone:691 case <-closeDone:
606- t.Fatal("closeAudioStream completed while Write was in-flight — lock not held across Write")692+ // expected: only the short stream snapshot takes the mutex
607- default:693+ case <-time.After(time.Second):
608- // expected: closeAudioStream is blocked694+ t.Fatal("closeAudioStream waited for an in-flight Write")
609 }695 }
610 696
611- // Unblock Write.697+ // The write can finish after shutdown without holding the lifecycle mutex.
612 close(stream.doneCh)698 close(stream.doneCh)
613 699
614- // Now closeAudioStream should complete.
615- <-closeDone
616-
617 assert.True(t, stream.closeCalled)700 assert.True(t, stream.closeCalled)
618- assert.Nil(t, wv.activeAudioStream)701+ assert.Nil(t, activeAudioStream(wv))
619 }702 }
620 703
621 // TestOnAudioStreamPacket_StreamClosedBetweenPackets verifies that when a704 // TestOnAudioStreamPacket_StreamClosedBetweenPackets verifies that when a
@@ -626,9 +709,9 @@ func TestOnAudioStreamPacket_StreamClosedBetweenPackets(t *testing.T) {
626 stream := &mockAudioStream{}709 stream := &mockAudioStream{}
627 710
628 wv := &WebView{711 wv := &WebView{
629- ctx: ctx,712+ ctx: ctx,
630- activeAudioStream: stream,
631 }713 }
714+ setAudioStream(wv, stream)
632 handlers := &handlerSet{wv: wv}715 handlers := &handlerSet{wv: wv}
633 data := [][]float32{{0.1, 0.2}, {0.3, 0.4}}716 data := [][]float32{{0.1, 0.2}, {0.3, 0.4}}
634 717
A internal/infrastructure/cef/webview_begin_frame_cleanup_test.go +32 -0
@@ -0,0 +1,32 @@
1+package cef
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/puregotk/v4/gtk"
7+)
8+
9+func TestStopBeginFrameLoopReleasesCallbackSlotWithoutWidget(t *testing.T) {
10+ callback := new(gtk.TickCallback)
11+ wv := &WebView{beginFrameTick: callback, beginFrameTickID: 17}
12+ oldUnref := unrefTickCallback
13+ defer func() { unrefTickCallback = oldUnref }()
14+ var unrefs int
15+ unrefTickCallback = func(got any) error {
16+ if got != callback {
17+ t.Fatalf("unref callback = %p, want %p", got, callback)
18+ }
19+ unrefs++
20+ return nil
21+ }
22+
23+ wv.stopBeginFrameLoop()
24+ wv.stopBeginFrameLoop()
25+
26+ if wv.beginFrameTick != nil || wv.beginFrameTickID != 0 {
27+ t.Fatal("stop must release the BeginFrame callback slot even after the GTK widget is gone")
28+ }
29+ if unrefs != 1 {
30+ t.Fatalf("callback unrefs = %d, want 1", unrefs)
31+ }
32+}
M internal/infrastructure/cef/webview_handlers.go +29 -69
@@ -819,9 +819,8 @@ func (h *handlerSet) attachAfterCreatedBrowser(
819 })819 })
820 }820 }
821 821
822- // Mark browser as visible — CEF OSR starts in hidden state and suppresses822+ // CEF OSR starts hidden. SyncViewport reads GTK's mapped, ancestor-aware
823- // painting/caret updates until explicitly told the browser is shown.823+ // effective state on the main context and reports that truthful initial state.
824- host.WasHidden(0)
825 if h.wv != nil {824 if h.wv != nil {
826 if h.wv.ctx != nil {825 if h.wv.ctx != nil {
827 logging.FromContext(h.wv.ctx).Info().826 logging.FromContext(h.wv.ctx).Info().
@@ -1135,48 +1134,44 @@ func (h *handlerSet) OnAudioStreamStarted(_ purecef.Browser, params *purecef.Aud
1135 Msg("cef: OnAudioStreamStarted")1134 Msg("cef: OnAudioStreamStarted")
1136 }1135 }
1137 1136
1138- // Reset packet counters for the new stream1137+ // Starting a stream invalidates both a previous publication and any older
1138+ // creation already in flight. Close detached streams outside the mutex.
1139+ generation, previous, accepted := h.wv.beginAudioStreamStart()
1140+ if !accepted {
1141+ return
1142+ }
1143+ h.wv.closeDetachedAudioStream(previous)
1139 h.wv.audioPacketCount.Store(0)1144 h.wv.audioPacketCount.Store(0)
1140 h.wv.audioWriteCount.Store(0)1145 h.wv.audioWriteCount.Store(0)
1141 1146
1142- // Close any existing stream first
1143- h.wv.closeAudioStream()
1144-
1145- // Create new stream
1146 ctx := h.wv.ctx1147 ctx := h.wv.ctx
1147 if ctx == nil {1148 if ctx == nil {
1148 ctx = context.Background()1149 ctx = context.Background()
1149 }1150 }
1150-
1151 stream, err := h.wv.audioOutputFactory.NewStream(ctx, format)1151 stream, err := h.wv.audioOutputFactory.NewStream(ctx, format)
1152 if err != nil {1152 if err != nil {
1153- // Log error but don't panic - audio is non-critical.
1154- // Do NOT set audioPlaying — the stream was never created.
1155 if h.wv.ctx != nil {1153 if h.wv.ctx != nil {
1156- logging.FromContext(h.wv.ctx).Warn().1154+ logging.FromContext(h.wv.ctx).Warn().Err(err).
1157- Err(err).1155+ Int("sample_rate", format.SampleRate).Int("channels", format.ChannelCount).
1158- Int("sample_rate", format.SampleRate).
1159- Int("channels", format.ChannelCount).
1160 Msg("cef: failed to create audio output stream")1156 Msg("cef: failed to create audio output stream")
1161 }1157 }
1162 return1158 return
1163 }1159 }
1164 1160
1165- // Set playing state only after stream creation succeeds1161+ replaced, published := h.wv.publishAudioStream(generation, stream)
1166- h.wv.setAudioPlaying(true)1162+ if !published {
1167-1163+ // Stop, Destroy, or a newer Start won the race. This callback owns the
1164+ // unpublishable stream and closes it exactly once.
1165+ h.wv.closeDetachedAudioStream(&audioStreamHandle{stream: stream, generation: generation})
1166+ return
1167+ }
1168+ h.wv.closeDetachedAudioStream(replaced)
1168 if h.wv.ctx != nil {1169 if h.wv.ctx != nil {
1169 logging.FromContext(h.wv.ctx).Info().1170 logging.FromContext(h.wv.ctx).Info().
1170- Int("sample_rate", format.SampleRate).1171+ Int("sample_rate", format.SampleRate).Int("channels", format.ChannelCount).
1171- Int("channels", format.ChannelCount).
1172 Int("frames_per_buffer", format.FramesPerBuffer).1172 Int("frames_per_buffer", format.FramesPerBuffer).
1173 Msg("cef: audio output stream created successfully")1173 Msg("cef: audio output stream created successfully")
1174 }1174 }
1175-
1176- // Store the new stream
1177- h.wv.audioStreamMu.Lock()
1178- h.wv.activeAudioStream = stream
1179- h.wv.audioStreamMu.Unlock()
1180 }1175 }
1181 1176
1182 // buildAudioStreamFormat converts CEF AudioParameters to port.AudioStreamFormat.1177 // buildAudioStreamFormat converts CEF AudioParameters to port.AudioStreamFormat.
@@ -1190,56 +1185,21 @@ func (h *handlerSet) buildAudioStreamFormat(params *purecef.AudioParameters, cha
1190 }1185 }
1191 }1186 }
1192 1187
1193-// OnAudioStreamPacket receives audio packets and forwards them to the output stream.1188+// OnAudioStreamPacket is a real-time callback: it atomically snapshots an
1194-// The mutex is held across both the stream snapshot and Write to prevent1189+// immutable handle and performs only allocation-free atomic diagnostics. It
1195-// closeAudioStream from closing the stream mid-write.1190+// never takes a lifecycle mutex or emits synchronous logs.
1196-func (h *handlerSet) OnAudioStreamPacket(_ purecef.Browser, data [][]float32, frames int32, pts int64) {1191+func (h *handlerSet) OnAudioStreamPacket(_ purecef.Browser, data [][]float32, frames int32, _ int64) {
1197 if len(data) == 0 || frames <= 0 {1192 if len(data) == 0 || frames <= 0 {
1198 return1193 return
1199 }1194 }
1200-1195+ handle := h.wv.audioStream.Load()
1201- // Copy the data before acquiring the stream lock because CEF can reuse the1196+ if handle == nil || handle.stream == nil {
1202- // buffer as soon as this callback returns.
1203- copiedData := make([][]float32, len(data))
1204- for i, channel := range data {
1205- if len(channel) < int(frames) {
1206- continue
1207- }
1208- copiedData[i] = make([]float32, frames)
1209- copy(copiedData[i], channel[:frames])
1210- }
1211-
1212- // Hold the lock across the stream read and Write so closeAudioStream
1213- // cannot close the stream between snapshot and write.
1214- h.wv.audioStreamMu.Lock()
1215- stream := h.wv.activeAudioStream
1216- if stream == nil {
1217- h.wv.audioStreamMu.Unlock()
1218 return1197 return
1219 }1198 }
1220-1199+ h.wv.audioPacketCount.Add(1)
1221- pktNum := h.wv.audioPacketCount.Add(1)1200+ if handle.stream.Write(data) == nil {
1222- if pktNum == 1 && h.wv.ctx != nil {1201+ h.wv.audioWriteCount.Add(1)
1223- logging.FromContext(h.wv.ctx).Info().
1224- Int("channels", len(data)).
1225- Int32("frames", frames).
1226- Int64("pts", pts).
1227- Msg("cef: first audio packet received")
1228- }
1229-
1230- err := stream.Write(copiedData)
1231- h.wv.audioStreamMu.Unlock()
1232-
1233- if err != nil {
1234- if h.wv.ctx != nil {
1235- logging.FromContext(h.wv.ctx).Debug().
1236- Err(err).
1237- Uint64("packets_received", pktNum).
1238- Msg("cef: audio stream write failed")
1239- }
1240- return
1241 }1202 }
1242- h.wv.audioWriteCount.Add(1)
1243 }1203 }
1244 1204
1245 // OnAudioStreamStopped handles stream stop.1205 // OnAudioStreamStopped handles stream stop.
M internal/infrastructure/cef/webview_handlers_test.go +2 -2
@@ -380,14 +380,14 @@ func TestConfigureNativePopupWindow_UsesSharedTextureWindowlessDefaults(t *testi
380 windowInfo := purecef.NewWindowInfo()380 windowInfo := purecef.NewWindowInfo()
381 settings := purecef.NewBrowserSettings()381 settings := purecef.NewBrowserSettings()
382 382
383- configureNativePopupWindow(&windowInfo, &settings, 72, 0xFF112233)383+ configureNativePopupWindow(&windowInfo, &settings, 72)
384 384
385 require.Equal(t, purecef.WindowHandle(0), windowInfo.ParentWindow)385 require.Equal(t, purecef.WindowHandle(0), windowInfo.ParentWindow)
386 require.Equal(t, int32(1), windowInfo.WindowlessRenderingEnabled)386 require.Equal(t, int32(1), windowInfo.WindowlessRenderingEnabled)
387 require.Equal(t, int32(1), windowInfo.SharedTextureEnabled)387 require.Equal(t, int32(1), windowInfo.SharedTextureEnabled)
388 require.Equal(t, int32(72), settings.WindowlessFrameRate)388 require.Equal(t, int32(72), settings.WindowlessFrameRate)
389 require.Equal(t, int32(1), settings.LocalStorage)389 require.Equal(t, int32(1), settings.LocalStorage)
390- require.Equal(t, uint32(0xFF112233), settings.BackgroundColor)390+ require.Equal(t, opaqueWhiteBackground, settings.BackgroundColor)
391 }391 }
392 392
393 func TestOnLoadStartFiresCommittedAndUpdatesURI(t *testing.T) {393 func TestOnLoadStartFiresCommittedAndUpdatesURI(t *testing.T) {
M internal/infrastructure/cef/webview_viewport.go +75 -2
@@ -24,6 +24,38 @@ type pendingBrowserCreateObservedSizeBridge interface {
24 RefreshObservedSizeOnGTKThread() (int32, int32)24 RefreshObservedSizeOnGTKThread() (int32, int32)
25 }25 }
26 26
27+// viewportWidgetVisibility is the effective GTK state of a widget in its
28+// hierarchy. Gtk.Widget.IsVisible already includes ancestor visibility; mapped
29+// is checked separately because a visible widget can still be unmapped.
30+type viewportWidgetVisibility struct {
31+ visible bool
32+ mapped bool
33+}
34+
35+func effectiveViewportVisibility(states []viewportWidgetVisibility) bool {
36+ if len(states) == 0 {
37+ return false
38+ }
39+ for _, state := range states {
40+ if !state.visible || !state.mapped {
41+ return false
42+ }
43+ }
44+ return true
45+}
46+
47+func effectiveViewportWidgetVisibility(widget *gtk.Widget) bool {
48+ if widget == nil {
49+ return false
50+ }
51+ // IsVisible is GTK's ancestor-aware visibility predicate. Pairing it with
52+ // GetMapped makes hidden/unmapped ancestors effective CEF-hidden states.
53+ return effectiveViewportVisibility([]viewportWidgetVisibility{{
54+ visible: widget.IsVisible(),
55+ mapped: widget.GetMapped(),
56+ }})
57+}
58+
27 const (59 const (
28 // pendingBrowserCreateObservedSizeRetryDelay spaces retries while waiting60 // pendingBrowserCreateObservedSizeRetryDelay spaces retries while waiting
29 // for the GTK bridge to observe the widget's allocated size.61 // for the GTK bridge to observe the widget's allocated size.
@@ -129,7 +161,7 @@ func (wv *WebView) syncViewportNowOnGTK(ctx context.Context, reason string) bool
129 }161 }
130 162
131 widget := wv.viewBridge.Widget()163 widget := wv.viewBridge.Widget()
132- visible := widget != nil && widget.IsVisible()164+ visible := effectiveViewportWidgetVisibility(widget)
133 wv.notifyViewportSyncOnCEFUIThread(host, visible)165 wv.notifyViewportSyncOnCEFUIThread(host, visible)
134 wv.syncZoomForBackingScaleOnCEFUIThread(host, reason)166 wv.syncZoomForBackingScaleOnCEFUIThread(host, reason)
135 logging.FromContext(ctx).Debug().167 logging.FromContext(ctx).Debug().
@@ -437,12 +469,20 @@ func (wv *WebView) installViewportSyncHooksOnGTKThread() {
437 wv.viewportShowFunc = func(gtk.Widget) {469 wv.viewportShowFunc = func(gtk.Widget) {
438 wv.SyncViewport(wv.ctx, "gtk-show")470 wv.SyncViewport(wv.ctx, "gtk-show")
439 }471 }
472+ wv.viewportHideFunc = func(gtk.Widget) {
473+ wv.SyncViewport(wv.ctx, "gtk-hide")
474+ }
475+ wv.viewportUnmapFunc = func(gtk.Widget) {
476+ wv.SyncViewport(wv.ctx, "gtk-unmap")
477+ }
440 wv.viewportRealizeFunc = func(gtk.Widget) {478 wv.viewportRealizeFunc = func(gtk.Widget) {
441 wv.SyncViewport(wv.ctx, "gtk-realize")479 wv.SyncViewport(wv.ctx, "gtk-realize")
442 }480 }
443 481
444 wv.viewportMapSignalID = widget.ConnectMap(&wv.viewportMapFunc)482 wv.viewportMapSignalID = widget.ConnectMap(&wv.viewportMapFunc)
445 wv.viewportShowSignalID = widget.ConnectShow(&wv.viewportShowFunc)483 wv.viewportShowSignalID = widget.ConnectShow(&wv.viewportShowFunc)
484+ wv.viewportHideSignalID = widget.ConnectHide(&wv.viewportHideFunc)
485+ wv.viewportUnmapSignalID = widget.ConnectUnmap(&wv.viewportUnmapFunc)
446 wv.viewportRealizeSignalID = widget.ConnectRealize(&wv.viewportRealizeFunc)486 wv.viewportRealizeSignalID = widget.ConnectRealize(&wv.viewportRealizeFunc)
447 }487 }
448 488
@@ -463,6 +503,14 @@ func (wv *WebView) disconnectViewportSyncHooksOnGTKThread() {
463 gobject.SignalHandlerDisconnect(obj, wv.viewportShowSignalID)503 gobject.SignalHandlerDisconnect(obj, wv.viewportShowSignalID)
464 wv.viewportShowSignalID = 0504 wv.viewportShowSignalID = 0
465 }505 }
506+ if wv.viewportHideSignalID != 0 {
507+ gobject.SignalHandlerDisconnect(obj, wv.viewportHideSignalID)
508+ wv.viewportHideSignalID = 0
509+ }
510+ if wv.viewportUnmapSignalID != 0 {
511+ gobject.SignalHandlerDisconnect(obj, wv.viewportUnmapSignalID)
512+ wv.viewportUnmapSignalID = 0
513+ }
466 if wv.viewportRealizeSignalID != 0 {514 if wv.viewportRealizeSignalID != 0 {
467 gobject.SignalHandlerDisconnect(obj, wv.viewportRealizeSignalID)515 gobject.SignalHandlerDisconnect(obj, wv.viewportRealizeSignalID)
468 wv.viewportRealizeSignalID = 0516 wv.viewportRealizeSignalID = 0
@@ -471,6 +519,8 @@ func (wv *WebView) disconnectViewportSyncHooksOnGTKThread() {
471 }519 }
472 wv.viewportMapFunc = nil520 wv.viewportMapFunc = nil
473 wv.viewportShowFunc = nil521 wv.viewportShowFunc = nil
522+ wv.viewportHideFunc = nil
523+ wv.viewportUnmapFunc = nil
474 wv.viewportRealizeFunc = nil524 wv.viewportRealizeFunc = nil
475 }525 }
476 526
@@ -488,7 +538,8 @@ func (wv *WebView) notifyViewportSyncOnCEFUIThread(host viewportSyncBrowserHost,
488 if currentHost != host {538 if currentHost != host {
489 return539 return
490 }540 }
491- notifyBrowserViewportSync(host, visible)541+ wv.applyEffectiveVisibility(host, visible)
542+ notifyBrowserViewportResize(host)
492 }))543 }))
493 if task == nil {544 if task == nil {
494 return545 return
@@ -526,6 +577,28 @@ func (wv *WebView) syncZoomForBackingScaleOnCEFUIThread(host purecef.BrowserHost
526 cefPostDelayedTask(purecef.ThreadIDTidUi, task, 0)577 cefPostDelayedTask(purecef.ThreadIDTidUi, task, 0)
527 }578 }
528 579
580+// applyEffectiveVisibility reports the initial effective visibility and later
581+// transitions exactly once. CEF OSR begins hidden, so even an initially hidden
582+// GTK widget must be explicitly reported to keep both lifecycles aligned.
583+func (wv *WebView) applyEffectiveVisibility(host viewportSyncBrowserHost, visible bool) {
584+ if wv == nil || host == nil || wv.destroyed.Load() {
585+ return
586+ }
587+ wv.mu.Lock()
588+ if wv.effectiveVisibilityKnown && wv.effectiveVisible == visible {
589+ wv.mu.Unlock()
590+ return
591+ }
592+ wv.effectiveVisibilityKnown = true
593+ wv.effectiveVisible = visible
594+ wv.mu.Unlock()
595+ if visible {
596+ host.WasHidden(0)
597+ return
598+ }
599+ host.WasHidden(1)
600+}
601+
529 func notifyBrowserViewportSync(host viewportSyncBrowserHost, visible bool) {602 func notifyBrowserViewportSync(host viewportSyncBrowserHost, visible bool) {
530 if host == nil {603 if host == nil {
531 return604 return
M internal/infrastructure/cef/webview_viewport_test.go +0 -1
@@ -149,7 +149,6 @@ func TestNotifyViewportSyncOnCEFUIThread_RemainsViewportOnlyAcrossRepeatedResize
149 scheduled.Execute()149 scheduled.Execute()
150 150
151 require.Equal(t, []string{151 require.Equal(t, []string{
152- "WasHidden",
153 "NotifyScreenInfoChanged",152 "NotifyScreenInfoChanged",
154 "WasResized",153 "WasResized",
155 "Invalidate",154 "Invalidate",
A internal/infrastructure/cef/webview_visibility_test.go +45 -0
@@ -0,0 +1,45 @@
1+package cef
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+)
8+
9+func TestEffectiveViewportVisibilityRequiresMappedVisibleAncestors(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ states []viewportWidgetVisibility
13+ visible bool
14+ }{
15+ {name: "mapped visible widget", states: []viewportWidgetVisibility{{visible: true, mapped: true}}, visible: true},
16+ {name: "unmapped widget", states: []viewportWidgetVisibility{{visible: true, mapped: false}}},
17+ {name: "hidden widget", states: []viewportWidgetVisibility{{visible: false, mapped: true}}},
18+ {name: "hidden ancestor", states: []viewportWidgetVisibility{{visible: true, mapped: true}, {visible: false, mapped: true}}},
19+ {name: "unmapped ancestor", states: []viewportWidgetVisibility{{visible: true, mapped: true}, {visible: true, mapped: false}}},
20+ }
21+ for _, tt := range tests {
22+ t.Run(tt.name, func(t *testing.T) {
23+ require.Equal(t, tt.visible, effectiveViewportVisibility(tt.states))
24+ })
25+ }
26+}
27+
28+func TestWebViewEffectiveVisibilitySendsInitialStateAndDeduplicatesTransitions(t *testing.T) {
29+ host := &viewportSyncOrderHost{}
30+ wv := &WebView{}
31+
32+ wv.applyEffectiveVisibility(host, false)
33+ require.Equal(t, []string{"WasHidden", "WasHidden(nonzero)"}, host.calls)
34+
35+ host.calls = nil
36+ wv.applyEffectiveVisibility(host, false)
37+ require.Empty(t, host.calls, "unchanged hidden state must not be resent")
38+
39+ wv.applyEffectiveVisibility(host, true)
40+ require.Equal(t, []string{"WasHidden"}, host.calls)
41+
42+ host.calls = nil
43+ wv.applyEffectiveVisibility(host, true)
44+ require.Empty(t, host.calls, "unchanged visible state must not be resent")
45+}
M internal/infrastructure/favicon/converter.go +19 -7
@@ -16,17 +16,25 @@ import (
16 )16 )
17 17
18 // ImageConverter converts safe favicon image formats into normalized PNGs.18 // ImageConverter converts safe favicon image formats into normalized PNGs.
19-type ImageConverter struct{}19+type ImageConverter struct {
20+ webp *webPDecoder
21+}
22+
23+func NewImageConverter() *ImageConverter { return &ImageConverter{webp: newDefaultWebPDecoder()} }
20 24
21-func NewImageConverter() *ImageConverter { return &ImageConverter{} }25+// newImageConverterWithWebPRuntime provides an infrastructure test seam while
26+// application ports remain independent of the libwebp implementation.
27+func newImageConverterWithWebPRuntime(runtime webpRuntime) *ImageConverter {
28+ return &ImageConverter{webp: newWebPDecoder(runtime)}
29+}
22 30
23-func (*ImageConverter) Convert(ctx context.Context, original []byte, contentType string, sizes []int) (*appport.ConvertedFavicon, error) {31+func (c *ImageConverter) Convert(ctx context.Context, original []byte, contentType string, sizes []int) (*appport.ConvertedFavicon, error) {
24 _ = ctx32 _ = ctx
25 if len(original) == 0 {33 if len(original) == 0 {
26 return nil, fmt.Errorf("%w: empty image", appport.ErrFaviconMiss)34 return nil, fmt.Errorf("%w: empty image", appport.ErrFaviconMiss)
27 }35 }
28 36
29- img, err := decodeFaviconImage(original, normalizeContentType(contentType))37+ img, err := decodeFaviconImage(original, normalizeContentType(contentType), c.webp)
30 if err != nil {38 if err != nil {
31 return nil, err39 return nil, err
32 }40 }
@@ -48,7 +56,7 @@ func (*ImageConverter) Convert(ctx context.Context, original []byte, contentType
48 return out, nil56 return out, nil
49 }57 }
50 58
51-func decodeFaviconImage(data []byte, ct string) (image.Image, error) {59+func decodeFaviconImage(data []byte, ct string, webp *webPDecoder) (image.Image, error) {
52 switch ct {60 switch ct {
53 case "image/svg+xml":61 case "image/svg+xml":
54 return nil, fmt.Errorf("%w: svg favicon conversion is disabled", appport.ErrFaviconMiss)62 return nil, fmt.Errorf("%w: svg favicon conversion is disabled", appport.ErrFaviconMiss)
@@ -59,9 +67,13 @@ func decodeFaviconImage(data []byte, ct string) (image.Image, error) {
59 }67 }
60 return img, nil68 return img, nil
61 case "image/webp":69 case "image/webp":
62- return nil, fmt.Errorf("%w: webp favicon conversion is disabled", appport.ErrFaviconMiss)70+ img, err := webp.Decode(data)
71+ if err != nil {
72+ return nil, fmt.Errorf("%w: decode webp favicon: %w", appport.ErrFaviconMiss, err)
73+ }
74+ return img, nil
63 }75 }
64- if ct != "" && ct != "image/png" && ct != "image/jpeg" && ct != "image/gif" && ct != "image/webp" {76+ if ct != "" && ct != "image/png" && ct != "image/jpeg" && ct != "image/gif" {
65 return nil, fmt.Errorf("%w: unsupported favicon content type %s", appport.ErrFaviconMiss, ct)77 return nil, fmt.Errorf("%w: unsupported favicon content type %s", appport.ErrFaviconMiss, ct)
66 }78 }
67 img, _, err := image.Decode(bytes.NewReader(data))79 img, _, err := image.Decode(bytes.NewReader(data))
A internal/infrastructure/favicon/testdata/alpha.webp +0 -0 binary file: no text diff

binary file: no text diff

A internal/infrastructure/favicon/webp_decoder.go +93 -0
@@ -0,0 +1,93 @@
1+package favicon
2+
3+import (
4+ "errors"
5+ "image"
6+ "math"
7+
8+ "github.com/bnema/purego-webp/libwebp"
9+)
10+
11+const (
12+ // maxWebPInputBytes bounds untrusted compressed favicon input before it is
13+ // handed to libwebp.
14+ maxWebPInputBytes = 5 << 20
15+ // maxWebPDimension and maxWebPPixels bound the final RGBA allocation.
16+ maxWebPDimension = 4096
17+ maxWebPPixels = 4 << 20
18+)
19+
20+var (
21+ errWebPInputTooLarge = errors.New("webp favicon input exceeds size limit")
22+ errWebPImageTooLarge = errors.New("webp favicon dimensions exceed size limit")
23+ errWebPInvalidMetadata = errors.New("invalid webp favicon metadata")
24+)
25+
26+// webpRuntime is the narrow infrastructure seam around the dynamically loaded
27+// libwebp binding. It deliberately exposes only Go values, keeping backend
28+// types out of ports and the domain.
29+type webpRuntime interface {
30+ Info(data []byte) (width, height int, err error)
31+ DecodeRGBAInto(data, pix []byte, stride, width, height int) error
32+}
33+
34+type libwebpRuntime struct{}
35+
36+func (libwebpRuntime) Info(data []byte) (int, int, error) {
37+ width, height, ok, err := libwebp.WebPGetInfo(data)
38+ if err != nil {
39+ return 0, 0, err
40+ }
41+ if !ok {
42+ return 0, 0, errWebPInvalidMetadata
43+ }
44+ return width, height, nil
45+}
46+
47+func (libwebpRuntime) DecodeRGBAInto(data, pix []byte, stride, width, height int) error {
48+ return libwebp.WebPDecodeRGBAIntoWithInfo(data, pix, stride, width, height)
49+}
50+
51+// webPDecoder owns the failure boundary for the optional libwebp runtime.
52+type webPDecoder struct{ runtime webpRuntime }
53+
54+func newWebPDecoder(runtime webpRuntime) *webPDecoder { return &webPDecoder{runtime: runtime} }
55+
56+func newDefaultWebPDecoder() *webPDecoder { return newWebPDecoder(libwebpRuntime{}) }
57+
58+func (d *webPDecoder) Decode(data []byte) (*image.NRGBA, error) {
59+ if len(data) == 0 {
60+ return nil, errWebPInvalidMetadata
61+ }
62+ if len(data) > maxWebPInputBytes {
63+ return nil, errWebPInputTooLarge
64+ }
65+ if d == nil || d.runtime == nil {
66+ return nil, errors.New("webp decoder unavailable")
67+ }
68+ width, height, err := d.runtime.Info(data)
69+ if err != nil {
70+ return nil, err
71+ }
72+ if width <= 0 || height <= 0 {
73+ return nil, errWebPInvalidMetadata
74+ }
75+ if width > maxWebPDimension || height > maxWebPDimension || width > maxWebPPixels/height {
76+ return nil, errWebPImageTooLarge
77+ }
78+ if width > math.MaxInt/4 {
79+ return nil, errWebPImageTooLarge
80+ }
81+ stride := width * 4
82+ if height > math.MaxInt/stride {
83+ return nil, errWebPImageTooLarge
84+ }
85+
86+ // Allocate exactly the destination buffer used by libwebp; no native image
87+ // buffer or second decoded copy crosses this adapter boundary.
88+ img := image.NewNRGBA(image.Rect(0, 0, width, height))
89+ if err := d.runtime.DecodeRGBAInto(data, img.Pix, img.Stride, width, height); err != nil {
90+ return nil, err
91+ }
92+ return img, nil
93+}
A internal/infrastructure/favicon/webp_decoder_test.go +96 -0
@@ -0,0 +1,96 @@
1+package favicon
2+
3+import (
4+ "bytes"
5+ "context"
6+ "errors"
7+ "image"
8+ "image/color"
9+ "image/png"
10+ "os"
11+ "testing"
12+
13+ appport "github.com/bnema/dumber/internal/application/port"
14+)
15+
16+func TestConverterWebPConvertsAlphaFixture(t *testing.T) {
17+ data, err := os.ReadFile("testdata/alpha.webp")
18+ if err != nil {
19+ t.Fatal(err)
20+ }
21+ conv := NewImageConverter()
22+ out, err := conv.Convert(context.Background(), data, "image/webp", []int{32})
23+ if err != nil {
24+ t.Fatal(err)
25+ }
26+ img, err := png.Decode(bytes.NewReader(out.PNG))
27+ if err != nil {
28+ t.Fatal(err)
29+ }
30+ if got := color.NRGBAModel.Convert(img.At(0, 0)).(color.NRGBA); got.A != 64 || got.R != 255 {
31+ t.Fatalf("alpha pixel = %#v, want red alpha 64", got)
32+ }
33+ sized, err := png.Decode(bytes.NewReader(out.SizedPNG[32]))
34+ if err != nil {
35+ t.Fatal(err)
36+ }
37+ if sized.Bounds() != image.Rect(0, 0, 32, 32) {
38+ t.Fatalf("sized WebP bounds = %v, want 32x32", sized.Bounds())
39+ }
40+}
41+
42+func TestConverterWebPFailuresAreFaviconMisses(t *testing.T) {
43+ for _, tc := range []struct {
44+ name string
45+ data []byte
46+ dec webpRuntime
47+ }{
48+ {name: "truncated", data: []byte("RIFF\x04\x00\x00\x00WEBPVP8 "), dec: &fakeWebPRuntime{infoErr: errors.New("truncated")}},
49+ {name: "unavailable", data: []byte("webp"), dec: &fakeWebPRuntime{infoErr: errors.New("libwebp unavailable")}},
50+ } {
51+ t.Run(tc.name, func(t *testing.T) {
52+ conv := newImageConverterWithWebPRuntime(tc.dec)
53+ if _, err := conv.Convert(context.Background(), tc.data, "image/webp", nil); !errors.Is(err, appport.ErrFaviconMiss) {
54+ t.Fatalf("err = %v, want favicon miss", err)
55+ }
56+ })
57+ }
58+}
59+
60+func TestWebPDecoderRejectsInputAndDecodedLimitsBeforeAllocation(t *testing.T) {
61+ for _, tc := range []struct {
62+ name string
63+ data []byte
64+ width, height int
65+ }{
66+ {name: "input", data: bytes.Repeat([]byte("x"), maxWebPInputBytes+1), width: 1, height: 1},
67+ {name: "dimension", data: []byte("webp"), width: maxWebPDimension + 1, height: 1},
68+ {name: "pixels", data: []byte("webp"), width: maxWebPDimension, height: maxWebPDimension},
69+ } {
70+ t.Run(tc.name, func(t *testing.T) {
71+ runtime := &fakeWebPRuntime{width: tc.width, height: tc.height}
72+ _, err := newWebPDecoder(runtime).Decode(tc.data)
73+ if err == nil {
74+ t.Fatal("Decode succeeded")
75+ }
76+ if runtime.decodeCalls != 0 {
77+ t.Fatalf("decode calls = %d, want no allocation/decode", runtime.decodeCalls)
78+ }
79+ })
80+ }
81+}
82+
83+type fakeWebPRuntime struct {
84+ width, height int
85+ infoErr error
86+ decodeCalls int
87+}
88+
89+func (r *fakeWebPRuntime) Info(_ []byte) (int, int, error) { return r.width, r.height, r.infoErr }
90+func (r *fakeWebPRuntime) DecodeRGBAInto(_, pix []byte, _, _, _ int) error {
91+ r.decodeCalls++
92+ for i := range pix {
93+ pix[i] = 0xff
94+ }
95+ return nil
96+}
A internal/infrastructure/persistence/sqlite/history_repo_benchmark_test.go +71 -0
@@ -0,0 +1,71 @@
1+package sqlite_test
2+
3+import (
4+ "fmt"
5+ "path/filepath"
6+ "testing"
7+
8+ "github.com/bnema/dumber/internal/domain/entity"
9+ "github.com/bnema/dumber/internal/domain/repository"
10+ "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
11+)
12+
13+// newHistoryBenchmarkRepository creates a representative local history corpus
14+// outside the measured operation. Sidebar reads use GetRecent and omnibox
15+// history suggestions use Search.
16+func newHistoryBenchmarkRepository(b *testing.B) (repository.HistoryRepository, func()) {
17+ b.Helper()
18+
19+ ctx := historyTestCtx()
20+ db, err := sqlite.NewConnection(ctx, filepath.Join(b.TempDir(), "dumber.db"))
21+ if err != nil {
22+ b.Fatal(err)
23+ }
24+
25+ b.Cleanup(func() {
26+ if err := db.Close(); err != nil {
27+ b.Errorf("close benchmark database: %v", err)
28+ }
29+ })
30+
31+ repo := sqlite.NewHistoryRepository(db)
32+ for i := range 200 {
33+ entry := &entity.HistoryEntry{
34+ URL: fmt.Sprintf("https://example%d.test/dumber/history/%d", i%20, i),
35+ Title: fmt.Sprintf("Dumber history entry %d", i),
36+ }
37+ if err := repo.Save(ctx, entry); err != nil {
38+ b.Fatal(err)
39+ }
40+ }
41+
42+ return repo, func() {}
43+}
44+
45+func BenchmarkHistorySQLiteSidebarRecent(b *testing.B) {
46+ repo, closeDB := newHistoryBenchmarkRepository(b)
47+ defer closeDB()
48+ ctx := historyTestCtx()
49+
50+ b.ReportAllocs()
51+ b.ResetTimer()
52+ for range b.N {
53+ if _, err := repo.GetRecent(ctx, 50, 0); err != nil {
54+ b.Fatal(err)
55+ }
56+ }
57+}
58+
59+func BenchmarkHistorySQLiteOmniboxSearch(b *testing.B) {
60+ repo, closeDB := newHistoryBenchmarkRepository(b)
61+ defer closeDB()
62+ ctx := historyTestCtx()
63+
64+ b.ReportAllocs()
65+ b.ResetTimer()
66+ for range b.N {
67+ if _, err := repo.Search(ctx, "dumber history", 10); err != nil {
68+ b.Fatal(err)
69+ }
70+ }
71+}
M internal/infrastructure/webkit/engine_init.go +0 -7
@@ -40,7 +40,6 @@ func NewEngine(
40 40
41 // --- Configure rendering environment (must be before GTK/WebKit init) ---41 // --- Configure rendering environment (must be before GTK/WebKit init) ---
42 engineConfigureRenderingEnvironment(ctx, cfg, wkCfg, &perfSettings, logger)42 engineConfigureRenderingEnvironment(ctx, cfg, wkCfg, &perfSettings, logger)
43- logging.Trace().Mark("render_env")
44 43
45 // --- Build webKitContextOptions from opts + wkCfg + perfSettings ---44 // --- Build webKitContextOptions from opts + wkCfg + perfSettings ---
46 wkOpts := engineBuildContextOptions(opts, profile, wkCfg, &perfSettings)45 wkOpts := engineBuildContextOptions(opts, profile, wkCfg, &perfSettings)
@@ -53,7 +52,6 @@ func NewEngine(
53 if err != nil {52 if err != nil {
54 return nil, err53 return nil, err
55 }54 }
56- logging.Trace().Mark("webkit_context")
57 55
58 // --- Filter manager ---56 // --- Filter manager ---
59 filterManager := engineInitFilterManager(ctx, cfg, profile.Shared.DataDir, logger)57 filterManager := engineInitFilterManager(ctx, cfg, profile.Shared.DataDir, logger)
@@ -77,7 +75,6 @@ func NewEngine(
77 }75 }
78 76
79 messageRouter := NewMessageRouter(ctx)77 messageRouter := NewMessageRouter(ctx)
80- logging.Trace().Mark("settings_manager")
81 78
82 // --- WebView pool ---79 // --- WebView pool ---
83 poolCfg := DefaultPoolConfig()80 poolCfg := DefaultPoolConfig()
@@ -100,7 +97,6 @@ func NewEngine(
100 } else {97 } else {
101 logger.Warn().Msg("skipping first webview prewarm: no GDK display available yet")98 logger.Warn().Msg("skipping first webview prewarm: no GDK display available yet")
102 }99 }
103- logging.Trace().Mark("pool_prewarm_first")
104 100
105 // --- WebView factory ---101 // --- WebView factory ---
106 factory := NewWebViewFactory(wkCtx, settings, pool, injector, messageRouter)102 factory := NewWebViewFactory(wkCtx, settings, pool, injector, messageRouter)
@@ -147,7 +143,6 @@ func engineSurveyHardwareAndResolveProfile(
147 ) config.ResolvedPerformanceSettings {143 ) config.ResolvedPerformanceSettings {
148 hwSurveyor := env.NewHardwareSurveyor()144 hwSurveyor := env.NewHardwareSurveyor()
149 hwInfo := hwSurveyor.Survey(ctx)145 hwInfo := hwSurveyor.Survey(ctx)
150- logging.Trace().Mark("hardware_survey")
151 logger.Info().146 logger.Info().
152 Int("cpu_cores", hwInfo.CPUCores).147 Int("cpu_cores", hwInfo.CPUCores).
153 Int("cpu_threads", hwInfo.CPUThreads).148 Int("cpu_threads", hwInfo.CPUThreads).
@@ -158,7 +153,6 @@ func engineSurveyHardwareAndResolveProfile(
158 153
159 perfCfg := config.PerformanceConfigFromEngine(&cfg.Engine)154 perfCfg := config.PerformanceConfigFromEngine(&cfg.Engine)
160 perfSettings := config.ResolvePerformanceProfile(&perfCfg, &hwInfo)155 perfSettings := config.ResolvePerformanceProfile(&perfCfg, &hwInfo)
161- logging.Trace().Mark("performance_profile")
162 logger.Info().156 logger.Info().
163 Str("profile", string(cfg.Engine.Profile)).157 Str("profile", string(cfg.Engine.Profile)).
164 Int("skia_cpu_threads", perfSettings.SkiaCPUPaintingThreads).158 Int("skia_cpu_threads", perfSettings.SkiaCPUPaintingThreads).
@@ -284,7 +278,6 @@ func engineInitFilterManager(ctx context.Context, cfg *config.Config, dataDir st
284 if err := filterManager.Initialize(ctx); err != nil {278 if err := filterManager.Initialize(ctx); err != nil {
285 logger.Warn().Err(err).Msg("failed to initialize filters, will load async")279 logger.Warn().Err(err).Msg("failed to initialize filters, will load async")
286 }280 }
287- logging.Trace().Mark("filter_manager")
288 return filterManager281 return filterManager
289 }282 }
290 283
D internal/logging/startup_trace.go +0 -220
@@ -1,220 +0,0 @@
1-package logging
2-
3-import (
4- "fmt"
5- "strings"
6- "sync"
7- "time"
8-
9- "github.com/rs/zerolog"
10-)
11-
12-// StartupTrace tracks cold start milestones from process launch to first paint.
13-// Thread-safe for use across goroutines.
14-// Enabled only when log level is debug or trace.
15-type StartupTrace struct {
16- mu sync.Mutex
17- t0 time.Time
18- milestones []Milestone
19- enabled bool
20- logger *zerolog.Logger
21- buffered []Milestone // milestones recorded before logger is set
22- finished bool
23-}
24-
25-// Milestone represents a timing checkpoint during startup.
26-type Milestone struct {
27- Name string
28- Elapsed time.Duration // time since t0
29- Delta time.Duration // time since previous milestone
30-}
31-
32-var (
33- globalTrace *StartupTrace
34- globalTraceMu sync.Mutex
35- globalTraceOnce sync.Once
36-)
37-
38-// InitStartupTrace initializes the global startup trace.
39-// Call this as early as possible in main() to capture T0.
40-// The trace is enabled only if the given log level is debug or trace.
41-func InitStartupTrace(logLevel string) {
42- globalTraceOnce.Do(func() {
43- enabled := logLevel == "debug" || logLevel == "trace"
44- globalTraceMu.Lock()
45- globalTrace = &StartupTrace{
46- t0: time.Now(),
47- milestones: make([]Milestone, 0, 32),
48- enabled: enabled,
49- buffered: make([]Milestone, 0, 8),
50- }
51- globalTraceMu.Unlock()
52-
53- if enabled {
54- // Record process_start as first milestone
55- globalTrace.Mark("process_start")
56- }
57- })
58-}
59-
60-// Trace returns the global startup trace instance.
61-// Returns a no-op trace if not initialized or disabled.
62-func Trace() *StartupTrace {
63- globalTraceMu.Lock()
64- defer globalTraceMu.Unlock()
65- if globalTrace == nil {
66- // Return a disabled no-op trace
67- return &StartupTrace{enabled: false}
68- }
69- return globalTrace
70-}
71-
72-// SetLogger sets the logger for emitting milestone logs.
73-// Flushes any buffered milestones that were recorded before the logger was set.
74-func (st *StartupTrace) SetLogger(logger *zerolog.Logger) {
75- if st == nil || !st.enabled {
76- return
77- }
78-
79- st.mu.Lock()
80- defer st.mu.Unlock()
81-
82- st.logger = logger
83-
84- // Flush buffered milestones
85- for _, m := range st.buffered {
86- st.emitMilestone(m)
87- }
88- st.buffered = nil
89-}
90-
91-// UpdateLogger updates the logger and re-emits all milestones to the new logger.
92-// Call this after the session file logger is available to capture milestones in the log file.
93-func (st *StartupTrace) UpdateLogger(logger *zerolog.Logger) {
94- if st == nil || !st.enabled {
95- return
96- }
97-
98- st.mu.Lock()
99- defer st.mu.Unlock()
100-
101- st.logger = logger
102-
103- // Re-emit all milestones to the new logger
104- for _, m := range st.milestones {
105- st.emitMilestone(m)
106- }
107-}
108-
109-// Mark records a milestone with the given name.
110-// Emits a debug log line showing elapsed time and delta from previous milestone.
111-func (st *StartupTrace) Mark(name string) {
112- if st == nil || !st.enabled {
113- return
114- }
115-
116- st.mu.Lock()
117- defer st.mu.Unlock()
118-
119- if st.finished {
120- return
121- }
122-
123- now := time.Now()
124- elapsed := now.Sub(st.t0)
125-
126- var delta time.Duration
127- if len(st.milestones) > 0 {
128- delta = elapsed - st.milestones[len(st.milestones)-1].Elapsed
129- }
130-
131- m := Milestone{
132- Name: name,
133- Elapsed: elapsed,
134- Delta: delta,
135- }
136- st.milestones = append(st.milestones, m)
137-
138- if st.logger != nil {
139- st.emitMilestone(m)
140- } else {
141- // Buffer until logger is set
142- st.buffered = append(st.buffered, m)
143- }
144-}
145-
146-// emitMilestone logs a single milestone. Caller must hold mutex.
147-func (st *StartupTrace) emitMilestone(m Milestone) {
148- if st.logger == nil {
149- return
150- }
151-
152- elapsedMs := m.Elapsed.Milliseconds()
153-
154- event := st.logger.Debug().
155- Str("milestone", m.Name).
156- Int64("t_ms", elapsedMs)
157-
158- if m.Delta > 0 {
159- event = event.Int64("delta_ms", m.Delta.Milliseconds())
160- }
161-
162- // Format: startup_trace: milestone_name (T+123ms, +45ms)
163- if m.Delta > 0 {
164- event.Msgf("startup_trace: %s (T+%dms, +%dms)", m.Name, elapsedMs, m.Delta.Milliseconds())
165- } else {
166- event.Msgf("startup_trace: %s (T+%dms)", m.Name, elapsedMs)
167- }
168-}
169-
170-// Finish marks the startup trace as complete and emits a summary.
171-// Called on first_paint milestone.
172-func (st *StartupTrace) Finish() {
173- if st == nil || !st.enabled {
174- return
175- }
176-
177- st.mu.Lock()
178- defer st.mu.Unlock()
179-
180- if st.finished {
181- return
182- }
183- st.finished = true
184-
185- if st.logger == nil {
186- return
187- }
188-
189- // Build summary string
190- total := time.Since(st.t0)
191- var parts []string
192- for _, m := range st.milestones {
193- parts = append(parts, fmt.Sprintf("%s:%d", m.Name, m.Elapsed.Milliseconds()))
194- }
195- summary := strings.Join(parts, ",")
196-
197- st.logger.Info().
198- Int64("total_ms", total.Milliseconds()).
199- Str("milestones", summary).
200- Msg("startup_trace: cold start complete")
201-}
202-
203-// Enabled returns whether the trace is active.
204-func (st *StartupTrace) Enabled() bool {
205- if st == nil {
206- return false
207- }
208- return st.enabled
209-}
210-
211-// TotalElapsed returns the total time since T0.
212-func (st *StartupTrace) TotalElapsed() time.Duration {
213- if st == nil || !st.enabled {
214- return 0
215- }
216-
217- st.mu.Lock()
218- defer st.mu.Unlock()
219- return time.Since(st.t0)
220-}
M internal/ui/app.go +36 -17
@@ -57,7 +57,10 @@ func gtkApplicationFlags() gio.ApplicationFlags {
57 return gio.GApplicationNonUniqueValue57 return gio.GApplicationNonUniqueValue
58 }58 }
59 59
60-var adwaitaInitOnce sync.Once60+var (
61+ adwaitaInitOnce sync.Once
62+ unrefTickCallback = glib.UnrefCallback
63+)
61 64
62 // EnsureAdwaitaInitialized initializes libadwaita and GTK exactly once.65 // EnsureAdwaitaInitialized initializes libadwaita and GTK exactly once.
63 func EnsureAdwaitaInitialized() {66 func EnsureAdwaitaInitialized() {
@@ -167,9 +170,12 @@ type floatingWorkspaceSession struct {
167 omnibox *component.Omnibox170 omnibox *component.Omnibox
168 omniboxWidget layout.Widget171 omniboxWidget layout.Widget
169 resizeWatcherActive bool172 resizeWatcherActive bool
170- resizeTickID uint173+ // resizeTickCallback keeps the purego callback live until its GTK source is
171- appliedWidth int174+ // removed. It is cleared exactly once with resizeTickID.
172- appliedHeight int175+ resizeTickCallback *gtk.TickCallback
176+ resizeTickID uint
177+ appliedWidth int
178+ appliedHeight int
173 }179 }
174 180
175 type floatingSessionKey struct {181 type floatingSessionKey struct {
@@ -289,7 +295,6 @@ func (a *App) Run(ctx context.Context, args []string) int {
289 // Initialize libadwaita once (required before using StyleManager).295 // Initialize libadwaita once (required before using StyleManager).
290 // This also initializes GTK implicitly.296 // This also initializes GTK implicitly.
291 EnsureAdwaitaInitialized()297 EnsureAdwaitaInitialized()
292- logging.Trace().Mark("gtk_init")
293 298
294 // Mark adwaita detector as available now that adw.Init() is complete.299 // Mark adwaita detector as available now that adw.Init() is complete.
295 // This enables the highest-priority color scheme detector.300 // This enables the highest-priority color scheme detector.
@@ -311,7 +316,6 @@ func (a *App) Run(ctx context.Context, args []string) int {
311 return 1316 return 1
312 }317 }
313 defer a.gtkApp.Unref()318 defer a.gtkApp.Unref()
314- logging.Trace().Mark("gtk_app_created")
315 a.dispatchOnMainThread = a.runOnMainThread319 a.dispatchOnMainThread = a.runOnMainThread
316 320
317 // Connect activate signal321 // Connect activate signal
@@ -347,7 +351,6 @@ func (a *App) onActivate(ctx context.Context) {
347 log.Error().Err(err).Msg("failed to create main window")351 log.Error().Err(err).Msg("failed to create main window")
348 return352 return
349 }353 }
350- logging.Trace().Mark("window_created")
351 354
352 a.installCrashReportNotifier(ctx)355 a.installCrashReportNotifier(ctx)
353 a.initFocusManager()356 a.initFocusManager()
@@ -356,7 +359,6 @@ func (a *App) onActivate(ctx context.Context) {
356 359
357 a.initCoordinators(ctx)360 a.initCoordinators(ctx)
358 a.wireWebRTCPermissionIndicator()361 a.wireWebRTCPermissionIndicator()
359- logging.Trace().Mark("coordinators_init")
360 a.initKeyboardHandler(ctx)362 a.initKeyboardHandler(ctx)
361 a.initOmniboxConfig(ctx)363 a.initOmniboxConfig(ctx)
362 a.initFindBarConfig(ctx)364 a.initFindBarConfig(ctx)
@@ -4594,22 +4596,22 @@ func (a *App) startFloatingResizeWatcher(session *floatingWorkspaceSession) {
4594 4596
4595 session.resizeWatcherActive = true4597 session.resizeWatcherActive = true
4596 paneID := session.paneID4598 paneID := session.paneID
4597- tickCallback := gtk.TickCallback(func(_ uintptr, _ uintptr, _ uintptr) bool {4599+ callback := new(gtk.TickCallback)
4600+ *callback = func(_ uintptr, _ uintptr, _ uintptr) bool {
4598 liveSession := a.floatingSessionByPaneID(paneID)4601 liveSession := a.floatingSessionByPaneID(paneID)
4599 if liveSession == nil {4602 if liveSession == nil {
4600- session.resizeWatcherActive = false4603+ session.releaseResizeTickCallback()
4601- session.resizeTickID = 0
4602 return false4604 return false
4603 }4605 }
4604 4606
4605 keepRunning := a.handleFloatingViewportTick(liveSession)4607 keepRunning := a.handleFloatingViewportTick(liveSession)
4606 if !keepRunning {4608 if !keepRunning {
4607- liveSession.resizeWatcherActive = false4609+ liveSession.releaseResizeTickCallback()
4608- liveSession.resizeTickID = 0
4609 }4610 }
4610 return keepRunning4611 return keepRunning
4611- })4612+ }
4612- session.resizeTickID = overlayWidget.AddTickCallback(&tickCallback, 0, nil)4613+ session.resizeTickCallback = callback
4614+ session.resizeTickID = overlayWidget.AddTickCallback(callback, 0, nil)
4613 }4615 }
4614 4616
4615 func (a *App) stopFloatingResizeWatcher(session *floatingWorkspaceSession) {4617 func (a *App) stopFloatingResizeWatcher(session *floatingWorkspaceSession) {
@@ -4617,13 +4619,30 @@ func (a *App) stopFloatingResizeWatcher(session *floatingWorkspaceSession) {
4617 return4619 return
4618 }4620 }
4619 4621
4620- if session.resizeTickID != 0 && session.overlay != nil {4622+ tickID := session.resizeTickID
4623+ session.releaseResizeTickCallback()
4624+ if tickID != 0 && session.overlay != nil {
4621 if overlayWidget := session.overlay.GtkWidget(); overlayWidget != nil {4625 if overlayWidget := session.overlay.GtkWidget(); overlayWidget != nil {
4622- overlayWidget.RemoveTickCallback(session.resizeTickID)4626+ overlayWidget.RemoveTickCallback(tickID)
4623 }4627 }
4624 }4628 }
4629+}
4630+
4631+// releaseResizeTickCallback releases the raw purego callback slot. It is safe
4632+// to call after GTK has already removed a callback that returned false.
4633+func (session *floatingWorkspaceSession) releaseResizeTickCallback() {
4634+ if session == nil {
4635+ return
4636+ }
4637+ callback := session.resizeTickCallback
4625 session.resizeTickID = 04638 session.resizeTickID = 0
4639+ session.resizeTickCallback = nil
4626 session.resizeWatcherActive = false4640 session.resizeWatcherActive = false
4641+ if callback != nil {
4642+ // AddTickCallback creates a purego function-pointer slot. Clearing the Go
4643+ // pointer alone cannot return that finite runtime slot to the ledger.
4644+ _ = unrefTickCallback(callback)
4645+ }
4627 }4646 }
4628 4647
4629 func (a *App) hideFloatingSession(ctx context.Context, session *floatingWorkspaceSession) {4648 func (a *App) hideFloatingSession(ctx context.Context, session *floatingWorkspaceSession) {
M internal/ui/component/favorites_sidebar.go +15 -4
@@ -55,6 +55,7 @@ type FavoritesSidebar struct {
55 displayRows []favoriteSidebarDisplayRow55 displayRows []favoriteSidebarDisplayRow
56 currentQuery string56 currentQuery string
57 notice string57 notice string
58+ noticeLoadGen uint64
58 loadGen uint6459 loadGen uint64
59 destroyed bool60 destroyed bool
60 visible bool61 visible bool
@@ -263,6 +264,7 @@ func (fs *FavoritesSidebar) startLoad() {
263 ctx := fs.ctx264 ctx := fs.ctx
264 visible := fs.visible265 visible := fs.visible
265 fs.notice = "Loading favorites..."266 fs.notice = "Loading favorites..."
267+ fs.noticeLoadGen = gen
266 fs.mu.Unlock()268 fs.mu.Unlock()
267 if visible {269 if visible {
268 fs.scheduleIdle(glib.SourceFunc(func(uintptr) bool {270 fs.scheduleIdle(glib.SourceFunc(func(uintptr) bool {
@@ -311,10 +313,13 @@ func (fs *FavoritesSidebar) applyLoadedData(favorites []*entity.Favorite, tags [
311 }313 }
312 fs.allFavorites = favorites314 fs.allFavorites = favorites
313 fs.allTags = tags315 fs.allTags = tags
314- if err != nil {316+ if fs.noticeLoadGen == gen {
315- fs.notice = err.Error()317+ fs.noticeLoadGen = 0
316- } else {318+ if err != nil {
317- fs.notice = ""319+ fs.notice = err.Error()
320+ } else {
321+ fs.notice = ""
322+ }
318 }323 }
319 fs.rebuildDisplayRowsLocked()324 fs.rebuildDisplayRowsLocked()
320 fs.mu.Unlock()325 fs.mu.Unlock()
@@ -363,6 +368,12 @@ func (fs *FavoritesSidebar) rebuildDisplayRowsLocked() {
363 fs.displayRows = buildFavoriteSidebarDisplayRows(filtered)368 fs.displayRows = buildFavoriteSidebarDisplayRows(filtered)
364 }369 }
365 370
371+// setNoticeLocked records a notice not owned by an in-flight load. fs.mu must be held.
372+func (fs *FavoritesSidebar) setNoticeLocked(notice string) {
373+ fs.notice = notice
374+ fs.noticeLoadGen = 0
375+}
376+
366 func (fs *FavoritesSidebar) scheduleIdle(cb glib.SourceFunc) {377 func (fs *FavoritesSidebar) scheduleIdle(cb glib.SourceFunc) {
367 if fs != nil && fs.idleScheduler != nil {378 if fs != nil && fs.idleScheduler != nil {
368 fs.idleScheduler(cb)379 fs.idleScheduler(cb)
M internal/ui/component/favorites_sidebar_forms.go +6 -6
@@ -42,7 +42,7 @@ func (fs *FavoritesSidebar) beginAddForm() {
42 fs.editingID = 042 fs.editingID = 0
43 fs.confirmDelete = false43 fs.confirmDelete = false
44 fs.confirmDeleteID = 044 fs.confirmDeleteID = 0
45- fs.notice = "Add favorite: URL, title, comma-separated tag IDs, shortcut 1-9. Press Ctrl+Enter or Save."45+ fs.setNoticeLocked("Add favorite: URL, title, comma-separated tag IDs, shortcut 1-9. Press Ctrl+Enter or Save.")
46 fs.mu.Unlock()46 fs.mu.Unlock()
47 fs.renderForm(nil)47 fs.renderForm(nil)
48 fs.rebuildList()48 fs.rebuildList()
@@ -63,7 +63,7 @@ func (fs *FavoritesSidebar) beginEditForm() {
63 fs.editingID = fav.ID63 fs.editingID = fav.ID
64 fs.confirmDelete = false64 fs.confirmDelete = false
65 fs.confirmDeleteID = 065 fs.confirmDeleteID = 0
66- fs.notice = "Edit favorite: URL is read-only; use tag mode for tags. Press Ctrl+Enter or Save."66+ fs.setNoticeLocked("Edit favorite: URL is read-only; use tag mode for tags. Press Ctrl+Enter or Save.")
67 fs.mu.Unlock()67 fs.mu.Unlock()
68 fs.renderForm(fav)68 fs.renderForm(fav)
69 fs.rebuildList()69 fs.rebuildList()
@@ -170,7 +170,7 @@ func (fs *FavoritesSidebar) cancelManagement() bool {
170 fs.confirmDelete = false170 fs.confirmDelete = false
171 fs.confirmDeleteID = 0171 fs.confirmDeleteID = 0
172 if active {172 if active {
173- fs.notice = ""173+ fs.setNoticeLocked("")
174 }174 }
175 fs.mu.Unlock()175 fs.mu.Unlock()
176 if fs.formBox != nil {176 if fs.formBox != nil {
@@ -250,7 +250,7 @@ func (fs *FavoritesSidebar) setModeNotice(mode favoritesSidebarMode, notice stri
250 fs.mode = mode250 fs.mode = mode
251 fs.confirmDelete = false251 fs.confirmDelete = false
252 fs.confirmDeleteID = 0252 fs.confirmDeleteID = 0
253- fs.notice = notice253+ fs.setNoticeLocked(notice)
254 }254 }
255 fs.mu.Unlock()255 fs.mu.Unlock()
256 fs.rebuildList()256 fs.rebuildList()
@@ -324,7 +324,7 @@ func (fs *FavoritesSidebar) requestDeleteConfirmation() bool {
324 if !fs.destroyed {324 if !fs.destroyed {
325 fs.confirmDelete = true325 fs.confirmDelete = true
326 fs.confirmDeleteID = fav.ID326 fs.confirmDeleteID = fav.ID
327- fs.notice = "Delete selected favorite? Press Delete again to confirm, Esc to cancel."327+ fs.setNoticeLocked("Delete selected favorite? Press Delete again to confirm, Esc to cancel.")
328 }328 }
329 fs.mu.Unlock()329 fs.mu.Unlock()
330 fs.rebuildList()330 fs.rebuildList()
@@ -396,7 +396,7 @@ func (fs *FavoritesSidebar) selectedFavorite() *entity.Favorite {
396 func (fs *FavoritesSidebar) setNotice(notice string) {396 func (fs *FavoritesSidebar) setNotice(notice string) {
397 fs.mu.Lock()397 fs.mu.Lock()
398 if !fs.destroyed {398 if !fs.destroyed {
399- fs.notice = notice399+ fs.setNoticeLocked(notice)
400 }400 }
401 fs.mu.Unlock()401 fs.mu.Unlock()
402 fs.rebuildList()402 fs.rebuildList()
M internal/ui/component/favorites_sidebar_keyboard.go +1 -1
@@ -463,7 +463,7 @@ func (fs *FavoritesSidebar) handleNavigationError(err error) {
463 fs.mu.Unlock()463 fs.mu.Unlock()
464 return464 return
465 }465 }
466- fs.notice = err.Error()466+ fs.setNoticeLocked(err.Error())
467 fs.mu.Unlock()467 fs.mu.Unlock()
468 fs.rebuildList()468 fs.rebuildList()
469 }469 }
M internal/ui/component/favorites_sidebar_test.go +8 -0
@@ -486,6 +486,8 @@ func TestFavoritesSidebarAddShortcutFailureClosesFormAfterCreate(t *testing.T) {
486 uc := &fakeFavoritesSidebarUC{errOnSetShortcut: errors.New("shortcut failed")}486 uc := &fakeFavoritesSidebarUC{errOnSetShortcut: errors.New("shortcut failed")}
487 fs := newFavoritesSidebarHarness(nil, nil)487 fs := newFavoritesSidebarHarness(nil, nil)
488 fs.favoritesUC = uc488 fs.favoritesUC = uc
489+ loaded := make(chan glib.SourceFunc, 1)
490+ fs.idleScheduler = func(cb glib.SourceFunc) { loaded <- cb }
489 fs.formURL = "https://add.test"491 fs.formURL = "https://add.test"
490 fs.formTitle = "Added"492 fs.formTitle = "Added"
491 fs.formShortcut = "3"493 fs.formShortcut = "3"
@@ -493,6 +495,12 @@ func TestFavoritesSidebarAddShortcutFailureClosesFormAfterCreate(t *testing.T) {
493 495
494 assert.True(t, fs.submitForm())496 assert.True(t, fs.submitForm())
495 require.Len(t, uc.addInputs, 1)497 require.Len(t, uc.addInputs, 1)
498+ select {
499+ case cb := <-loaded:
500+ cb(0)
501+ case <-time.After(time.Second):
502+ t.Fatal("timed out waiting for favorites reload")
503+ }
496 assert.Equal(t, favoritesSidebarModeNone, fs.mode)504 assert.Equal(t, favoritesSidebarModeNone, fs.mode)
497 assert.Contains(t, fs.notice, "failed to set shortcut")505 assert.Contains(t, fs.notice, "failed to set shortcut")
498 }506 }
A internal/ui/component/omnibox_callback_pressure_stress_test.go +155 -0
@@ -0,0 +1,155 @@
1+package component
2+
3+import (
4+ "bufio"
5+ "encoding/json"
6+ "os"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/purego"
11+ "github.com/bnema/puregotk/v4/glib"
12+ "github.com/bnema/puregotk/v4/gobject"
13+ "github.com/bnema/puregotk/v4/gtk"
14+ "github.com/stretchr/testify/require"
15+)
16+
17+const gtkCallbackStressIterations = 3_200
18+
19+type callbackLedgerEvent struct {
20+ Event string `json:"event"`
21+ Type string `json:"type"`
22+}
23+
24+// runGTKCallbackLifecycleStress exercises real GTK motion controllers and raw
25+// tick callbacks while p3 and p0 are repeatedly removed/rebuilt. It is never
26+// opt-in: GTK init is skipped only when the host has no usable native display.
27+func runGTKCallbackLifecycleStress(t *testing.T) {
28+ t.Helper()
29+ ledgerPath := t.TempDir() + "/purego-callback-ledger.jsonl"
30+ t.Setenv("PUREGO_CALLBACK_LEDGER", "1")
31+ t.Setenv("PUREGO_CALLBACK_LEDGER_FILE", ledgerPath)
32+ if !gtk.InitCheck() {
33+ t.Skip("GTK native display prerequisite unavailable (gtk.InitCheck returned false)")
34+ }
35+
36+ mainContext := glib.MainContextDefault()
37+ require.NotNil(t, mainContext)
38+ done := make(chan struct{})
39+ stress := glib.SourceFunc(func(uintptr) bool {
40+ defer close(done)
41+ runGTKCallbackLifecycleStressOnMain(t, mainContext, ledgerPath)
42+ return false
43+ })
44+ // Invoke guarantees that the entire GTK mutation sequence is owned by the
45+ // default context, whether the test thread owns it or a native GTK loop does.
46+ mainContext.Invoke(&stress, 0)
47+ select {
48+ case <-done:
49+ case <-time.After(30 * time.Second):
50+ t.Fatal("GTK main context did not execute callback lifecycle stress")
51+ }
52+}
53+
54+func runGTKCallbackLifecycleStressOnMain(t *testing.T, mainContext *glib.MainContext, ledgerPath string) {
55+ t.Helper()
56+ assertMainContextOwner := func() {
57+ t.Helper()
58+ require.True(t, mainContext.IsOwner(), "GTK mutation must own the default GLib main context")
59+ }
60+
61+ assertMainContextOwner()
62+ root := gtk.NewBox(gtk.OrientationVerticalValue, 0)
63+ assertMainContextOwner()
64+ p0 := gtk.NewBox(gtk.OrientationVerticalValue, 0)
65+ assertMainContextOwner()
66+ p3 := gtk.NewBox(gtk.OrientationVerticalValue, 0)
67+ require.NotNil(t, root)
68+ require.NotNil(t, p0)
69+ require.NotNil(t, p3)
70+ assertMainContextOwner()
71+ root.Append(&p0.Widget)
72+ assertMainContextOwner()
73+ root.Append(&p3.Widget)
74+ ledgerStart := len(readCallbackLedgerEvents(t, ledgerPath))
75+
76+ enters, leaves := 0, 0
77+ for i := 0; i < gtkCallbackStressIterations; i++ {
78+ pane := p0
79+ if i%2 != 0 {
80+ pane = p3
81+ }
82+ assertMainContextOwner()
83+ controller := gtk.NewEventControllerMotion()
84+ require.NotNil(t, controller)
85+ enter := func(_ gtk.EventControllerMotion, _ float64, _ float64) { enters++ }
86+ leave := func(_ gtk.EventControllerMotion) { leaves++ }
87+ enterID := controller.ConnectEnter(&enter)
88+ leaveID := controller.ConnectLeave(&leave)
89+ assertMainContextOwner()
90+ pane.AddController(&controller.EventController)
91+
92+ // Invoke the real connected handler functions in GTK enter/leave order
93+ // while the alternate pane is closed and the workspace rebuilt.
94+ enter(*controller, 0, 0)
95+ leave(*controller)
96+
97+ tick := gtk.TickCallback(func(uintptr, uintptr, uintptr) bool { return false })
98+ tickCallback := glib.NewCallback(&tick)
99+ assertMainContextOwner()
100+ tickID := pane.AddTickCallback(&tick, 0, nil)
101+ require.NotZero(t, tickID)
102+ assertMainContextOwner()
103+ pane.RemoveTickCallback(tickID)
104+ require.NoError(t, purego.UnrefCallback(tickCallback), "raw tick slot must be released after GTK removal")
105+
106+ assertMainContextOwner()
107+ gobject.SignalHandlerDisconnect(gobject.ObjectNewFromInternalPtr(controller.GoPointer()), enterID)
108+ assertMainContextOwner()
109+ gobject.SignalHandlerDisconnect(gobject.ObjectNewFromInternalPtr(controller.GoPointer()), leaveID)
110+ assertMainContextOwner()
111+ pane.RemoveController(&controller.EventController)
112+
113+ // Alternate p3 <-> p0 close/rebuild mutations on the same main context.
114+ assertMainContextOwner()
115+ root.Remove(&pane.Widget)
116+ assertMainContextOwner()
117+ root.Append(&pane.Widget)
118+ }
119+ require.Equal(t, gtkCallbackStressIterations, enters)
120+ require.Equal(t, gtkCallbackStressIterations, leaves)
121+
122+ events := readCallbackLedgerEvents(t, ledgerPath)[ledgerStart:]
123+ var tickReleases, tickReuses int
124+ for _, event := range events {
125+ switch event.Event {
126+ case "release-fnptr", "release":
127+ tickReleases++
128+ case "reuse":
129+ tickReuses++
130+ case "release-miss", "release-fnptr-miss", "exhausted":
131+ t.Fatalf("callback ledger recorded %s for tick callback", event.Event)
132+ }
133+ }
134+ require.Equal(t, gtkCallbackStressIterations, tickReleases, "every raw GTK tick slot must be released exactly once")
135+ // The first motion-controller connection may initialize one shared GTK
136+ // trampoline after the baseline; every subsequent raw tick slot is reused.
137+ require.GreaterOrEqual(t, tickReuses, gtkCallbackStressIterations-2, "released raw tick slots must be reused")
138+}
139+
140+func readCallbackLedgerEvents(t *testing.T, path string) []callbackLedgerEvent {
141+ t.Helper()
142+ file, err := os.Open(path)
143+ require.NoError(t, err)
144+ defer file.Close()
145+
146+ var events []callbackLedgerEvent
147+ scanner := bufio.NewScanner(file)
148+ for scanner.Scan() {
149+ var event callbackLedgerEvent
150+ require.NoError(t, json.Unmarshal(scanner.Bytes(), &event))
151+ events = append(events, event)
152+ }
153+ require.NoError(t, scanner.Err())
154+ return events
155+}
M internal/ui/component/omnibox_callback_pressure_test.go +2 -8
@@ -1,13 +1,7 @@
1 package component1 package component
2 2
3-import (3+import "testing"
4- "os"
5- "testing"
6-)
7 4
8 func TestOmniboxCallbackPressure_NoCrashSignal(t *testing.T) {5 func TestOmniboxCallbackPressure_NoCrashSignal(t *testing.T) {
9- if os.Getenv("OMNIBOX_STRESS") != "1" {6+ runGTKCallbackLifecycleStress(t)
10- t.Skip("skipping: set OMNIBOX_STRESS=1 to run with GTK stress harness")
11- }
12- // TODO: wire GTK stress harness and assert no abrupt crash markers
13 }7 }
M internal/ui/component/pane_view.go +59 -39
@@ -135,58 +135,73 @@ func (pv *PaneView) WebViewWidget() layout.Widget {
135 return pv.webViewWidget135 return pv.webViewWidget
136 }136 }
137 137
138-// SetWebViewWidget replaces the WebView widget.138+// SetWebViewWidget replaces the WebView widget as not-yet-revealed content.
139+// Call AttachWebViewWidget when the content coordinator knows the WebView has
140+// already painted.
139 func (pv *PaneView) SetWebViewWidget(widget layout.Widget) {141 func (pv *PaneView) SetWebViewWidget(widget layout.Widget) {
142+ pv.AttachWebViewWidget(widget, false)
143+}
144+
145+// AttachWebViewWidget replaces the WebView widget and applies its explicit
146+// content reveal state. GTK parentage is used only to satisfy GTK's one-parent
147+// ownership rule; it must not be used to infer whether CEF has painted.
148+func (pv *PaneView) AttachWebViewWidget(widget layout.Widget, revealed bool) {
140 pv.mu.Lock()149 pv.mu.Lock()
141 defer pv.mu.Unlock()150 defer pv.mu.Unlock()
142 151
143- // Remove old widget from this overlay152+ // Remove old widget from this overlay.
144 if pv.webViewWidget != nil {153 if pv.webViewWidget != nil {
145 pv.overlay.SetChild(nil)154 pv.overlay.SetChild(nil)
146 }155 }
147 156
148 pv.webViewWidget = widget157 pv.webViewWidget = widget
158+ if widget == nil {
159+ return
160+ }
149 161
150- if widget != nil {162+ overlayAllocWidthBefore := pv.overlay.GetAllocatedWidth()
151- overlayAllocWidthBefore := pv.overlay.GetAllocatedWidth()163+ overlayAllocHeightBefore := pv.overlay.GetAllocatedHeight()
152- overlayAllocHeightBefore := pv.overlay.GetAllocatedHeight()164+ widgetAllocWidthBefore := widget.GetAllocatedWidth()
153- widgetAllocWidthBefore := widget.GetAllocatedWidth()165+ widgetAllocHeightBefore := widget.GetAllocatedHeight()
154- widgetAllocHeightBefore := widget.GetAllocatedHeight()166+ hadParent := widget.GetParent() != nil
155- hadParent := widget.GetParent() != nil167+ wasVisible := widget.IsVisible()
156- wasVisible := widget.IsVisible()168+
157-169+ // GTK4 widgets may have only one parent. Cleanup during a WorkspaceView
158- // If this widget already had a parent, we're reparenting an existing WebView170+ // rebuild leaves a reusable native widget unparented, which says nothing
159- // (e.g. after rebuilding/moving panes). In that case the WebView has likely171+ // about whether its content was previously revealed.
160- // already painted, so the loading skeleton should be hidden immediately.172+ if hadParent {
161-173+ widget.Unparent()
162- // Unparent widget from any previous parent (critical for rebuild scenarios)174+ }
163- // In GTK4, a widget can only have one parent at a time175+ pv.overlay.SetChild(widget)
164- if hadParent {
165- widget.Unparent()
166- }
167- pv.overlay.SetChild(widget)
168 176
169- if hadParent && wasVisible {177+ if hadParent && wasVisible {
170- widget.SetVisible(true)178+ widget.SetVisible(true)
171- }179+ }
172- if hadParent && pv.loading != nil {180+ // A replacement can follow an already-revealed WebView in the same pane.
173- pv.loading.SetVisible(false)181+ // Apply both states explicitly: an unrevealed current WebView must restart
174- }182+ // the skeleton rather than inheriting the previous view's hidden overlay.
183+ if pv.loading != nil {
184+ pv.loading.SetVisible(!revealed)
185+ }
175 186
176- logging.FromContext(pv.ctx).Debug().187+ ctx := pv.ctx
177- Str("pane_id", string(pv.paneID)).188+ if ctx == nil {
178- Bool("had_parent", hadParent).189+ ctx = context.Background()
179- Bool("was_visible", wasVisible).
180- Int("overlay_alloc_width_before", overlayAllocWidthBefore).
181- Int("overlay_alloc_height_before", overlayAllocHeightBefore).
182- Int("widget_alloc_width_before", widgetAllocWidthBefore).
183- Int("widget_alloc_height_before", widgetAllocHeightBefore).
184- Int("overlay_alloc_width_after", pv.overlay.GetAllocatedWidth()).
185- Int("overlay_alloc_height_after", pv.overlay.GetAllocatedHeight()).
186- Int("widget_alloc_width_after", widget.GetAllocatedWidth()).
187- Int("widget_alloc_height_after", widget.GetAllocatedHeight()).
188- Msg("pane view webview widget attached")
189 }190 }
191+ logging.FromContext(ctx).Debug().
192+ Str("pane_id", string(pv.paneID)).
193+ Bool("had_parent", hadParent).
194+ Bool("was_visible", wasVisible).
195+ Bool("revealed", revealed).
196+ Int("overlay_alloc_width_before", overlayAllocWidthBefore).
197+ Int("overlay_alloc_height_before", overlayAllocHeightBefore).
198+ Int("widget_alloc_width_before", widgetAllocWidthBefore).
199+ Int("widget_alloc_height_before", widgetAllocHeightBefore).
200+ Int("overlay_alloc_width_after", pv.overlay.GetAllocatedWidth()).
201+ Int("overlay_alloc_height_after", pv.overlay.GetAllocatedHeight()).
202+ Int("widget_alloc_width_after", widget.GetAllocatedWidth()).
203+ Int("widget_alloc_height_after", widget.GetAllocatedHeight()).
204+ Msg("pane view webview widget attached")
190 }205 }
191 206
192 // GrabFocus attempts to focus the WebView.207 // GrabFocus attempts to focus the WebView.
@@ -516,6 +531,7 @@ func (pv *PaneView) Cleanup() {
516 pv.onFocusIn = nil531 pv.onFocusIn = nil
517 pv.onFocusOut = nil532 pv.onFocusOut = nil
518 pv.onHover = nil533 pv.onHover = nil
534+ pv.onMouseMotion = nil
519 535
520 // Detach hover handler if present536 // Detach hover handler if present
521 if pv.hoverHandler != nil {537 if pv.hoverHandler != nil {
@@ -530,6 +546,10 @@ func (pv *PaneView) Cleanup() {
530 }546 }
531 547
532 // Clear other overlay children548 // Clear other overlay children
549+ if pv.loading != nil {
550+ pv.overlay.RemoveOverlay(pv.loading.Widget())
551+ pv.loading = nil
552+ }
533 if pv.progressBar != nil {553 if pv.progressBar != nil {
534 pv.overlay.RemoveOverlay(pv.progressBar.Widget())554 pv.overlay.RemoveOverlay(pv.progressBar.Widget())
535 pv.progressBar = nil555 pv.progressBar = nil
A internal/ui/component/pane_view_cleanup_test.go +13 -0
@@ -0,0 +1,13 @@
1+package component
2+
3+import "testing"
4+
5+func TestPaneViewCleanupClearsMouseMotionCallback(t *testing.T) {
6+ pv := &PaneView{onMouseMotion: func() { t.Fatal("stale callback invoked") }}
7+
8+ pv.Cleanup()
9+
10+ if pv.onMouseMotion != nil {
11+ t.Fatal("Cleanup must release the mouse-motion callback slot")
12+ }
13+}
M internal/ui/component/pane_view_internal_test.go +35 -0
@@ -3,6 +3,7 @@ package component
3 import (3 import (
4 "testing"4 "testing"
5 5
6+ "github.com/stretchr/testify/assert"
6 "github.com/stretchr/testify/mock"7 "github.com/stretchr/testify/mock"
7 8
8 "github.com/bnema/dumber/internal/ui/layout"9 "github.com/bnema/dumber/internal/ui/layout"
@@ -80,3 +81,37 @@ func TestPaneView_HideLoadingSkeleton_NoOpWhenNil(t *testing.T) {
80 pv := &PaneView{}81 pv := &PaneView{}
81 pv.HideLoadingSkeleton()82 pv.HideLoadingSkeleton()
82 }83 }
84+
85+func TestPaneView_AttachWebViewWidget_RevealedThenUnrevealedReplacementRestartsSkeleton(t *testing.T) {
86+ overlay := mocks.NewMockOverlayWidget(t)
87+ revealedWidget := mocks.NewMockWidget(t)
88+ unrevealedWidget := mocks.NewMockWidget(t)
89+ loadingContainer := mocks.NewMockBoxWidget(t)
90+ spinner := mocks.NewMockSpinnerWidget(t)
91+
92+ overlay.EXPECT().GetAllocatedWidth().Return(0).Times(4)
93+ overlay.EXPECT().GetAllocatedHeight().Return(0).Times(4)
94+ for _, widget := range []*mocks.MockWidget{revealedWidget, unrevealedWidget} {
95+ widget.EXPECT().GetAllocatedWidth().Return(0).Twice()
96+ widget.EXPECT().GetAllocatedHeight().Return(0).Twice()
97+ widget.EXPECT().GetParent().Return(nil).Once()
98+ widget.EXPECT().IsVisible().Return(true).Once()
99+ }
100+ overlay.EXPECT().SetChild(revealedWidget).Once()
101+ loadingContainer.EXPECT().SetVisible(false).Once()
102+ spinner.EXPECT().Stop().Once()
103+ overlay.EXPECT().SetChild(nil).Once()
104+ overlay.EXPECT().SetChild(unrevealedWidget).Once()
105+ loadingContainer.EXPECT().SetVisible(true).Once()
106+ spinner.EXPECT().Start().Once()
107+
108+ pv := &PaneView{
109+ overlay: overlay,
110+ loading: &LoadingSkeleton{container: loadingContainer, spinner: spinner},
111+ }
112+
113+ pv.AttachWebViewWidget(revealedWidget, true)
114+ pv.AttachWebViewWidget(unrevealedWidget, false)
115+
116+ assert.Same(t, unrevealedWidget, pv.WebViewWidget())
117+}
M internal/ui/component/workspace_view.go +18 -2
@@ -192,6 +192,14 @@ func (wv *WorkspaceView) SetWorkspace(ctx context.Context, ws *entity.Workspace)
192 wv.mu.Lock()192 wv.mu.Lock()
193 defer wv.mu.Unlock()193 defer wv.mu.Unlock()
194 194
195+ // Release previous views before dropping their ownership. This also detaches
196+ // hover controllers and callback closures before their widgets are rebuilt.
197+ for _, pv := range wv.paneViews {
198+ if pv != nil {
199+ pv.Cleanup()
200+ }
201+ }
202+
195 // Clear previous state203 // Clear previous state
196 wv.workspace = ws204 wv.workspace = ws
197 wv.paneViews = make(map[entity.PaneID]*PaneView)205 wv.paneViews = make(map[entity.PaneID]*PaneView)
@@ -474,8 +482,16 @@ func (wv *WorkspaceView) DeactivatePane(paneID entity.PaneID) {
474 }482 }
475 }483 }
476 484
477-// SetWebViewWidget attaches a WebView widget to a specific pane.485+// SetWebViewWidget attaches a not-yet-revealed WebView widget to a specific pane.
486+// Call AttachWebViewWidget when the content coordinator has explicit reveal state.
478 func (wv *WorkspaceView) SetWebViewWidget(paneID entity.PaneID, widget layout.Widget) error {487 func (wv *WorkspaceView) SetWebViewWidget(paneID entity.PaneID, widget layout.Widget) error {
488+ return wv.AttachWebViewWidget(paneID, widget, false)
489+}
490+
491+// AttachWebViewWidget attaches a WebView widget with the content coordinator's
492+// explicit reveal state. A WorkspaceView rebuild can unparent a live widget, so
493+// widget parentage cannot determine whether its loading skeleton is still needed.
494+func (wv *WorkspaceView) AttachWebViewWidget(paneID entity.PaneID, widget layout.Widget, revealed bool) error {
479 wv.mu.RLock()495 wv.mu.RLock()
480 pv, ok := wv.paneViews[paneID]496 pv, ok := wv.paneViews[paneID]
481 callback := wv.onWebViewAttached497 callback := wv.onWebViewAttached
@@ -485,7 +501,7 @@ func (wv *WorkspaceView) SetWebViewWidget(paneID entity.PaneID, widget layout.Wi
485 return ErrPaneNotFound501 return ErrPaneNotFound
486 }502 }
487 503
488- pv.SetWebViewWidget(widget)504+ pv.AttachWebViewWidget(widget, revealed)
489 if widget != nil && callback != nil {505 if widget != nil && callback != nil {
490 callback(paneID)506 callback(paneID)
491 }507 }
M internal/ui/coordinator/content/callbacks.go +9 -4
@@ -62,8 +62,13 @@ func buildCrashPageURI(originalURI string) string {
62 // setupWebViewCallbacks configures standard callbacks and popup handling.62 // setupWebViewCallbacks configures standard callbacks and popup handling.
63 func (c *Coordinator) setupWebViewCallbacks(ctx context.Context, paneID entity.PaneID, wv port.WebView) {63 func (c *Coordinator) setupWebViewCallbacks(ctx context.Context, paneID entity.PaneID, wv port.WebView) {
64 log := logging.FromContext(ctx)64 log := logging.FromContext(ctx)
65+ identity, ok := identityForWebView(wv)
66+ if !ok {
67+ return
68+ }
65 69
66- // Build port callbacks for standard events70+ // Capture the association at installation, rather than reading mutable
71+ // WebView identity from a callback that can outlive a pool reuse.
67 callbacks := &port.WebViewCallbacks{72 callbacks := &port.WebViewCallbacks{
68 OnTitleChanged: func(title string) {73 OnTitleChanged: func(title string) {
69 c.onTitleChanged(ctx, paneID, title)74 c.onTitleChanged(ctx, paneID, title)
@@ -73,13 +78,13 @@ func (c *Coordinator) setupWebViewCallbacks(ctx context.Context, paneID entity.P
73 case port.LoadStarted:78 case port.LoadStarted:
74 c.onLoadStarted(paneID)79 c.onLoadStarted(paneID)
75 case port.LoadCommitted:80 case port.LoadCommitted:
76- c.onLoadCommitted(ctx, paneID, wv)81+ c.onLoadCommitted(ctx, paneID, wv, identity)
77 case port.LoadFinished:82 case port.LoadFinished:
78- c.onLoadFinished(ctx, paneID, wv)83+ c.onLoadFinished(ctx, paneID, wv, identity)
79 }84 }
80 },85 },
81 OnProgressChanged: func(progress float64) {86 OnProgressChanged: func(progress float64) {
82- c.onProgressChanged(paneID, progress)87+ c.onProgressChanged(paneID, wv, identity, progress)
83 },88 },
84 OnURIChanged: func(uri string) {89 OnURIChanged: func(uri string) {
85 c.handleURIChanged(ctx, paneID, wv, uri)90 c.handleURIChanged(ctx, paneID, wv, uri)
M internal/ui/coordinator/content/content_reveal_test.go +166 -167
@@ -2,10 +2,10 @@ package content
2 2
3 import (3 import (
4 "context"4 "context"
5- "sync"
6 "testing"5 "testing"
7 6
8 "github.com/stretchr/testify/assert"7 "github.com/stretchr/testify/assert"
8+ "github.com/stretchr/testify/mock"
9 9
10 "github.com/bnema/dumber/internal/application/port"10 "github.com/bnema/dumber/internal/application/port"
11 "github.com/bnema/dumber/internal/application/port/mocks"11 "github.com/bnema/dumber/internal/application/port/mocks"
@@ -13,201 +13,200 @@ import (
13 "github.com/bnema/dumber/internal/ui/component"13 "github.com/bnema/dumber/internal/ui/component"
14 )14 )
15 15
16-// TestPendingReveal tests the pending reveal state management.16+func newRevealTestCoordinator() *Coordinator {
17-// These are internal package tests that verify the state transitions17+ return &Coordinator{
18-// without needing to mock WebKit dependencies.18+ webViews: make(map[entity.PaneID]port.WebView),
19-19+ webViewPaneIDs: make(map[port.WebViewID]entity.PaneID),
20-func TestMarkPendingReveal_SetsState(t *testing.T) {20+ paneTitles: make(map[entity.PaneID]string),
21- c := &Coordinator{21+ navOrigins: make(map[entity.PaneID]string),
22- pendingReveal: make(map[entity.PaneID]bool),
23 }22 }
24- paneID := entity.PaneID("pane-1")23+}
25-
26- c.markPendingReveal(paneID)
27 24
28- c.revealMu.Lock()25+func revealTestWebView(t *testing.T, id port.WebViewID, generation uint64) *mocks.MockWebView {
29- defer c.revealMu.Unlock()26+ t.Helper()
30- assert.True(t, c.pendingReveal[paneID])27+ wv := mocks.NewMockWebView(t)
28+ wv.EXPECT().ID().Return(id).Maybe()
29+ wv.EXPECT().Generation().Return(generation).Maybe()
30+ return wv
31 }31 }
32 32
33-func TestClearPendingReveal_RemovesState(t *testing.T) {33+func TestRevealState_IsBoundToCurrentWebViewAcrossReplacementPaths(t *testing.T) {
34- c := &Coordinator{
35- pendingReveal: make(map[entity.PaneID]bool),
36- }
37 paneID := entity.PaneID("pane-1")34 paneID := entity.PaneID("pane-1")
35+ first := revealTestWebView(t, 101, 1)
36+ // The pool may reuse the same native WebView ID; its reuse generation is
37+ // part of the presentation identity.
38+ directReplacement := revealTestWebView(t, 101, 2)
39+ popupReplacement := revealTestWebView(t, 103, 1)
40+ c := newRevealTestCoordinator()
41+
42+ c.setWebViewLocked(paneID, first)
43+ firstIdentity, _ := identityForWebView(first)
44+ c.markWebViewRevealed(paneID, firstIdentity)
45+ assert.True(t, c.WebViewRevealed(paneID))
46+
47+ // A direct replacement for the same pane must show the fresh loading state.
48+ c.setWebViewLocked(paneID, directReplacement)
49+ assert.False(t, c.WebViewRevealed(paneID))
50+
51+ directReplacementIdentity, _ := identityForWebView(directReplacement)
52+ c.markWebViewRevealed(paneID, directReplacementIdentity)
53+ c.RegisterPopupWebView(paneID, popupReplacement)
54+ assert.False(t, c.WebViewRevealed(paneID), "popup registration must not inherit a prior WebView reveal")
55+}
38 56
39- // First mark as pending57+func TestRevealState_StoresIdentityAsTheOnlyStateValue(t *testing.T) {
40- c.markPendingReveal(paneID)58+ paneID := entity.PaneID("pane-1")
59+ wv := revealTestWebView(t, 101, 1)
60+ c := newRevealTestCoordinator()
61+ c.setWebViewLocked(paneID, wv)
62+ identity, _ := identityForWebView(wv)
41 63
42- // Then clear64+ c.markPendingReveal(paneID, identity)
43- c.clearPendingReveal(paneID)65+ c.markWebViewRevealed(paneID, identity)
44 66
45 c.revealMu.Lock()67 c.revealMu.Lock()
46 defer c.revealMu.Unlock()68 defer c.revealMu.Unlock()
47- assert.False(t, c.pendingReveal[paneID])69+ assert.Equal(t, identity, c.pendingReveal[paneID])
70+ assert.Equal(t, identity, c.revealedWebViews[paneID])
48 }71 }
49 72
50-func TestRevealIfPending_NotPending_DoesNothing(t *testing.T) {73+func TestRevealState_ReleaseClearsStateWithoutWebViewMapping(t *testing.T) {
51- callbackCalled := false
52- c := &Coordinator{
53- pendingReveal: make(map[entity.PaneID]bool),
54- webViews: make(map[entity.PaneID]port.WebView),
55- onWebViewShown: func(paneID entity.PaneID) {
56- callbackCalled = true
57- },
58- }
59 paneID := entity.PaneID("pane-1")74 paneID := entity.PaneID("pane-1")
75+ wv := revealTestWebView(t, 101, 1)
76+ c := newRevealTestCoordinator()
77+ c.setWebViewLocked(paneID, wv)
78+ identity, _ := identityForWebView(wv)
79+ c.markWebViewRevealed(paneID, identity)
80+
81+ // Model an earlier partial cleanup that lost the WebView mapping. Release
82+ // remains responsible for clearing presentation state on its early return.
83+ c.webViewsMu.Lock()
84+ delete(c.webViews, paneID)
85+ c.webViewsMu.Unlock()
86+ c.ReleaseWebView(context.Background(), paneID)
60 87
61- // Call reveal without marking pending first88+ c.revealMu.Lock()
62- c.revealIfPending(context.Background(), paneID, "http://example.com", "test")89+ _, retained := c.revealedWebViews[paneID]
63-90+ c.revealMu.Unlock()
64- assert.False(t, callbackCalled, "callback should not be called when not pending")91+ assert.False(t, retained)
65 }92 }
66 93
67-func TestRevealIfPending_ClearsStateOnReveal(t *testing.T) {94+func TestRevealState_ReleaseMappedWebViewClearsRevealStateAndReturnsToPool(t *testing.T) {
68- c := &Coordinator{
69- pendingReveal: make(map[entity.PaneID]bool),
70- webViews: make(map[entity.PaneID]port.WebView),
71- }
72 paneID := entity.PaneID("pane-1")95 paneID := entity.PaneID("pane-1")
73-96+ wv := revealTestWebView(t, 101, 1)
74- // Mark as pending97+ pool := mocks.NewMockWebViewPool(t)
75- c.markPendingReveal(paneID)98+ pool.EXPECT().Release(wv).Once()
76-99+ c := newRevealTestCoordinator()
77- // Reveal (will return early since webview is nil, but should still clear state)100+ c.pool = pool
78- c.revealIfPending(context.Background(), paneID, "http://example.com", "test")101+ c.setWebViewLocked(paneID, wv)
79-102+ identity, _ := identityForWebView(wv)
80- c.revealMu.Lock()103+ c.markWebViewRevealed(paneID, identity)
81- defer c.revealMu.Unlock()104+
82- assert.False(t, c.pendingReveal[paneID], "pending state should be cleared after reveal attempt")105+ c.ReleaseWebView(context.Background(), paneID)
106+
107+ assert.Nil(t, c.GetWebView(paneID))
108+ assert.False(t, c.WebViewRevealed(paneID))
83 }109 }
84 110
85-func TestRevealIfPending_OnlyRevealsOnce(t *testing.T) {111+func TestRevealState_StaleCallbackCannotRevealReplacement(t *testing.T) {
86- revealCount := 0
87- c := &Coordinator{
88- pendingReveal: make(map[entity.PaneID]bool),
89- webViews: make(map[entity.PaneID]port.WebView),
90- onWebViewShown: func(paneID entity.PaneID) {
91- revealCount++
92- },
93- }
94 paneID := entity.PaneID("pane-1")112 paneID := entity.PaneID("pane-1")
95-113+ oldWV := revealTestWebView(t, 101, 1)
96- // Mark as pending114+ newWV := revealTestWebView(t, 102, 1)
97- c.markPendingReveal(paneID)115+ oldWV.EXPECT().IsDestroyed().Return(false).Maybe()
98-116+ c := newRevealTestCoordinator()
99- // Call reveal multiple times117+ c.setWebViewLocked(paneID, oldWV)
100- c.revealIfPending(context.Background(), paneID, "", "first")118+ oldIdentity, _ := identityForWebView(oldWV)
101- c.revealIfPending(context.Background(), paneID, "", "second")119+ c.markPendingReveal(paneID, oldIdentity)
102- c.revealIfPending(context.Background(), paneID, "", "third")120+ c.setWebViewLocked(paneID, newWV)
103-121+
104- // Callback should not be called since webview is nil, but state should be cleared122+ c.revealIfPending(context.Background(), paneID, oldWV, oldIdentity, "", "stale")
105- assert.Equal(t, 0, revealCount, "callback not called when webview is nil")123+ assert.False(t, c.WebViewRevealed(paneID))
106-
107- // Verify state is cleared
108- c.revealMu.Lock()
109- defer c.revealMu.Unlock()
110- assert.False(t, c.pendingReveal[paneID])
111 }124 }
112 125
113-func TestOnLoadCommitted_RevealsPendingWebViewForCommittedPage(t *testing.T) {126+func TestRevealState_RevealedCurrentWebViewPersistsForWorkspaceRebuild(t *testing.T) {
114 paneID := entity.PaneID("pane-1")127 paneID := entity.PaneID("pane-1")
115- shownCount := 0128+ wv := revealTestWebView(t, 101, 1)
116- var shownPane entity.PaneID129+ c := newRevealTestCoordinator()
130+ c.setWebViewLocked(paneID, wv)
131+ identity, _ := identityForWebView(wv)
132+ c.markWebViewRevealed(paneID, identity)
133+
134+ // AttachToWorkspace asks this identity-aware query when rebuilding a pane,
135+ // so the replacement PaneView hides its skeleton for this same WebView.
136+ assert.True(t, c.webViewRevealed(paneID, wv))
137+
138+ replacement := revealTestWebView(t, 102, 1)
139+ assert.False(t, c.webViewRevealed(paneID, replacement))
140+}
117 141
142+func TestRevealCallback_CapturesGenerationBeforeSameObjectPoolReuse(t *testing.T) {
143+ paneID := entity.PaneID("pane-1")
118 wv := mocks.NewMockWebView(t)144 wv := mocks.NewMockWebView(t)
119- wv.EXPECT().URI().Return("https://example.com")145+ generation := uint64(1)
120- wv.EXPECT().ResetBackgroundToDefault()146+ wv.EXPECT().ID().Return(port.WebViewID(101)).Maybe()
121- wv.EXPECT().Title().Return("")147+ wv.EXPECT().Generation().RunAndReturn(func() uint64 { return generation }).Maybe()
122 wv.EXPECT().IsDestroyed().Return(false).Maybe()148 wv.EXPECT().IsDestroyed().Return(false).Maybe()
123-149+ var callbacks *port.WebViewCallbacks
124- c := &Coordinator{150+ wv.EXPECT().SetCallbacks(mock.Anything).RunAndReturn(func(value *port.WebViewCallbacks) {
125- pendingReveal: make(map[entity.PaneID]bool),151+ callbacks = value
126- webViews: map[entity.PaneID]port.WebView{152+ }).Once()
127- paneID: wv,153+
128- },154+ c := newRevealTestCoordinator()
129- paneTitles: make(map[entity.PaneID]string),155+ c.getActiveWS = func() (*entity.Workspace, *component.WorkspaceView) { return nil, nil }
130- navOrigins: make(map[entity.PaneID]string),156+ c.setWebViewLocked(paneID, wv)
131- getActiveWS: func() (*entity.Workspace, *component.WorkspaceView) { return nil, nil },157+ installedIdentity, _ := identityForWebView(wv)
132- onWebViewShown: func(id entity.PaneID) {158+ c.markPendingReveal(paneID, installedIdentity)
133- shownCount++159+ c.setupWebViewCallbacks(context.Background(), paneID, wv)
134- shownPane = id160+ revealed := false
135- },161+ c.setWebViewVisible = func(port.WebView) { revealed = true }
136- }162+
137- c.pendingReveal[paneID] = true163+ // The callback was installed at generation 1. A pool reuse can advance the
138-164+ // same Go WebView object before its old callback is delivered.
139- c.onLoadCommitted(context.Background(), paneID, wv)165+ generation = 2
140-166+ callbacks.OnProgressChanged(1)
141- assert.Equal(t, 1, shownCount)167+
142- assert.Equal(t, paneID, shownPane)168+ assert.False(t, revealed, "the generation-1 callback must not reveal generation 2")
143-}169+ assert.False(t, c.WebViewRevealed(paneID))
144-
145-func TestPendingReveal_ConcurrentAccess(t *testing.T) {
146- c := &Coordinator{
147- pendingReveal: make(map[entity.PaneID]bool),
148- webViews: make(map[entity.PaneID]port.WebView),
149- }
150-
151- var wg sync.WaitGroup
152- paneIDs := []entity.PaneID{"pane-1", "pane-2", "pane-3", "pane-4", "pane-5"}
153-
154- // Concurrently mark and clear panes
155- for range 100 {
156- for _, paneID := range paneIDs {
157- wg.Add(3)
158-
159- go func(id entity.PaneID) {
160- defer wg.Done()
161- c.markPendingReveal(id)
162- }(paneID)
163-
164- go func(id entity.PaneID) {
165- defer wg.Done()
166- c.clearPendingReveal(id)
167- }(paneID)
168-
169- go func(id entity.PaneID) {
170- defer wg.Done()
171- c.revealIfPending(context.Background(), id, "", "concurrent")
172- }(paneID)
173- }
174- }
175-
176- wg.Wait()
177- // Test passes if no race conditions occur
178 }170 }
179 171
180-func TestMarkPendingReveal_MultiplePanes(t *testing.T) {172+func TestRevealIfPending_SerializesReplacementUntilNativeVisibility(t *testing.T) {
181- c := &Coordinator{173+ paneID := entity.PaneID("pane-1")
182- pendingReveal: make(map[entity.PaneID]bool),174+ oldWV := revealTestWebView(t, 101, 1)
175+ oldWV.EXPECT().IsDestroyed().Return(false).Once()
176+ newWV := revealTestWebView(t, 102, 1)
177+ c := newRevealTestCoordinator()
178+ c.setWebViewLocked(paneID, oldWV)
179+ identity, _ := identityForWebView(oldWV)
180+ c.markPendingReveal(paneID, identity)
181+
182+ nativeStarted := make(chan struct{})
183+ allowNativeReturn := make(chan struct{})
184+ c.setWebViewVisible = func(wv port.WebView) {
185+ assert.Same(t, oldWV, wv)
186+ close(nativeStarted)
187+ <-allowNativeReturn
183 }188 }
184-189+ revealed := make(chan struct{})
185- pane1 := entity.PaneID("pane-1")190+ go func() {
186- pane2 := entity.PaneID("pane-2")191+ c.revealIfPending(context.Background(), paneID, oldWV, identity, "", "test")
187-192+ close(revealed)
188- c.markPendingReveal(pane1)193+ }()
189- c.markPendingReveal(pane2)194+ <-nativeStarted
190-195+
191- c.revealMu.Lock()196+ replaced := make(chan struct{})
192- defer c.revealMu.Unlock()197+ go func() {
193- assert.True(t, c.pendingReveal[pane1])198+ c.setWebViewLocked(paneID, newWV)
194- assert.True(t, c.pendingReveal[pane2])199+ close(replaced)
195-}200+ }()
196-201+ select {
197-func TestClearPendingReveal_OnlyAffectsTargetPane(t *testing.T) {202+ case <-replaced:
198- c := &Coordinator{203+ t.Fatal("replacement interleaved before native visibility completed")
199- pendingReveal: make(map[entity.PaneID]bool),204+ default:
200 }205 }
201 206
202- pane1 := entity.PaneID("pane-1")207+ close(allowNativeReturn)
203- pane2 := entity.PaneID("pane-2")208+ <-revealed
204-209+ <-replaced
205- c.markPendingReveal(pane1)210+ assert.Same(t, newWV, c.GetWebView(paneID))
206- c.markPendingReveal(pane2)211+ assert.False(t, c.WebViewRevealed(paneID), "replacement must not inherit old reveal state")
207- c.clearPendingReveal(pane1)
208-
209- c.revealMu.Lock()
210- defer c.revealMu.Unlock()
211- assert.False(t, c.pendingReveal[pane1], "pane1 should be cleared")
212- assert.True(t, c.pendingReveal[pane2], "pane2 should still be pending")
213 }212 }
M internal/ui/coordinator/content/coordinator.go +55 -10
@@ -70,8 +70,19 @@ type Coordinator struct {
70 // Callback when the WebView becomes visible (first real commit)70 // Callback when the WebView becomes visible (first real commit)
71 onWebViewShown func(paneID entity.PaneID)71 onWebViewShown func(paneID entity.PaneID)
72 72
73- revealMu sync.Mutex73+ // setWebViewVisible is test-only injection for deterministic lifecycle
74- pendingReveal map[entity.PaneID]bool74+ // serialization tests. Production uses the native widget provider directly.
75+ setWebViewVisible func(port.WebView)
76+
77+ // revealMutationMu serializes mapping replacement/release with the native
78+ // visibility transition. It is acquired before webViewsMu and revealMu.
79+ revealMutationMu sync.Mutex
80+
81+ // revealMu is always acquired after webViewsMu. Reveal state is keyed by the
82+ // WebView identity (ID plus pool-reuse generation), never by pane alone.
83+ revealMu sync.Mutex
84+ pendingReveal map[entity.PaneID]webViewIdentity
85+ revealedWebViews map[entity.PaneID]webViewIdentity
75 86
76 appearanceMu sync.Mutex87 appearanceMu sync.Mutex
77 pendingScriptRefresh map[entity.PaneID]bool88 pendingScriptRefresh map[entity.PaneID]bool
@@ -147,7 +158,8 @@ func NewCoordinator(
147 webViewPaneIDs: make(map[port.WebViewID]entity.PaneID),158 webViewPaneIDs: make(map[port.WebViewID]entity.PaneID),
148 paneTitles: make(map[entity.PaneID]string),159 paneTitles: make(map[entity.PaneID]string),
149 navOrigins: make(map[entity.PaneID]string),160 navOrigins: make(map[entity.PaneID]string),
150- pendingReveal: make(map[entity.PaneID]bool),161+ pendingReveal: make(map[entity.PaneID]webViewIdentity),
162+ revealedWebViews: make(map[entity.PaneID]webViewIdentity),
151 pendingScriptRefresh: make(map[entity.PaneID]bool),163 pendingScriptRefresh: make(map[entity.PaneID]bool),
152 pendingThemePanes: make(map[entity.PaneID]bool),164 pendingThemePanes: make(map[entity.PaneID]bool),
153 getActiveWS: getActiveWS,165 getActiveWS: getActiveWS,
@@ -298,20 +310,42 @@ func (c *Coordinator) activePaneOverrideID() (entity.PaneID, bool) {
298 return c.activePaneOverride, true310 return c.activePaneOverride, true
299 }311 }
300 312
301-func (c *Coordinator) webViewCount() int {
302- c.webViewsMu.RLock()
303- defer c.webViewsMu.RUnlock()
304- return len(c.webViews)
305-}
306-
307 func (c *Coordinator) getWebViewLocked(paneID entity.PaneID) port.WebView {313 func (c *Coordinator) getWebViewLocked(paneID entity.PaneID) port.WebView {
308 c.webViewsMu.RLock()314 c.webViewsMu.RLock()
309 defer c.webViewsMu.RUnlock()315 defer c.webViewsMu.RUnlock()
310 return c.webViews[paneID]316 return c.webViews[paneID]
311 }317 }
312 318
319+type webViewIdentity struct {
320+ id port.WebViewID
321+ generation uint64
322+}
323+
324+func identityForWebView(wv port.WebView) (webViewIdentity, bool) {
325+ if wv == nil {
326+ return webViewIdentity{}, false
327+ }
328+ return webViewIdentity{id: wv.ID(), generation: wv.Generation()}, true
329+}
330+
331+func (c *Coordinator) ensureRevealMapsLocked() {
332+ if c.pendingReveal == nil {
333+ c.pendingReveal = make(map[entity.PaneID]webViewIdentity)
334+ }
335+ if c.revealedWebViews == nil {
336+ c.revealedWebViews = make(map[entity.PaneID]webViewIdentity)
337+ }
338+}
339+
340+// setWebViewLocked replaces the pane mapping and resets all reveal state in
341+// one critical section. It is used by normal acquisition and popup paths.
313 func (c *Coordinator) setWebViewLocked(paneID entity.PaneID, wv port.WebView) {342 func (c *Coordinator) setWebViewLocked(paneID entity.PaneID, wv port.WebView) {
343+ c.revealMutationMu.Lock()
344+ defer c.revealMutationMu.Unlock()
314 c.webViewsMu.Lock()345 c.webViewsMu.Lock()
346+ c.revealMu.Lock()
347+ defer c.revealMu.Unlock()
348+ defer c.webViewsMu.Unlock()
315 if c.webViews == nil {349 if c.webViews == nil {
316 c.webViews = make(map[entity.PaneID]port.WebView)350 c.webViews = make(map[entity.PaneID]port.WebView)
317 }351 }
@@ -322,17 +356,28 @@ func (c *Coordinator) setWebViewLocked(paneID entity.PaneID, wv port.WebView) {
322 if wv != nil && c.webViewPaneIDs != nil {356 if wv != nil && c.webViewPaneIDs != nil {
323 c.webViewPaneIDs[wv.ID()] = paneID357 c.webViewPaneIDs[wv.ID()] = paneID
324 }358 }
325- c.webViewsMu.Unlock()359+ c.ensureRevealMapsLocked()
360+ delete(c.pendingReveal, paneID)
361+ delete(c.revealedWebViews, paneID)
326 }362 }
327 363
364+// deleteWebViewLocked removes both the mapping and any presentation state,
365+// including callers which release after another path already removed the map.
328 func (c *Coordinator) deleteWebViewLocked(paneID entity.PaneID) port.WebView {366 func (c *Coordinator) deleteWebViewLocked(paneID entity.PaneID) port.WebView {
367+ c.revealMutationMu.Lock()
368+ defer c.revealMutationMu.Unlock()
329 c.webViewsMu.Lock()369 c.webViewsMu.Lock()
370+ c.revealMu.Lock()
371+ defer c.revealMu.Unlock()
330 defer c.webViewsMu.Unlock()372 defer c.webViewsMu.Unlock()
331 wv := c.webViews[paneID]373 wv := c.webViews[paneID]
332 delete(c.webViews, paneID)374 delete(c.webViews, paneID)
333 if wv != nil && c.webViewPaneIDs != nil {375 if wv != nil && c.webViewPaneIDs != nil {
334 delete(c.webViewPaneIDs, wv.ID())376 delete(c.webViewPaneIDs, wv.ID())
335 }377 }
378+ c.ensureRevealMapsLocked()
379+ delete(c.pendingReveal, paneID)
380+ delete(c.revealedWebViews, paneID)
336 return wv381 return wv
337 }382 }
338 383
M internal/ui/coordinator/content/lifecycle.go +5 -8
@@ -28,17 +28,13 @@ func (c *Coordinator) EnsureWebView(ctx context.Context, paneID entity.PaneID) (
28 return nil, fmt.Errorf("webview pool not configured")28 return nil, fmt.Errorf("webview pool not configured")
29 }29 }
30 30
31- // Mark tab_created on first webview (first tab)
32- if c.webViewCount() == 0 {
33- logging.Trace().Mark("tab_created")
34- }
35-
36 wv, err := c.pool.Acquire(ctx)31 wv, err := c.pool.Acquire(ctx)
37 if err != nil {32 if err != nil {
38 return nil, err33 return nil, err
39 }34 }
40- logging.Trace().Mark("webview_acquired")
41 35
36+ // setWebViewLocked atomically resets presentation state for the acquired
37+ // WebView, including a pooled instance previously revealed in another pane.
42 c.setWebViewLocked(paneID, wv)38 c.setWebViewLocked(paneID, wv)
43 c.setupWebViewCallbacks(ctx, paneID, wv)39 c.setupWebViewCallbacks(ctx, paneID, wv)
44 40
@@ -53,6 +49,8 @@ func (c *Coordinator) EnsureWebView(ctx context.Context, paneID entity.PaneID) (
53 func (c *Coordinator) ReleaseWebView(ctx context.Context, paneID entity.PaneID) {49 func (c *Coordinator) ReleaseWebView(ctx context.Context, paneID entity.PaneID) {
54 log := logging.FromContext(ctx)50 log := logging.FromContext(ctx)
55 51
52+ // deleteWebViewLocked clears reveal state before returning, even when a
53+ // previous cleanup already removed this pane's mapping.
56 wv := c.deleteWebViewLocked(paneID)54 wv := c.deleteWebViewLocked(paneID)
57 if wv == nil {55 if wv == nil {
58 return56 return
@@ -136,11 +134,10 @@ func (c *Coordinator) AttachToWorkspace(ctx context.Context, ws *entity.Workspac
136 Int("wrapped_widget_alloc_height", widget.GetAllocatedHeight()).134 Int("wrapped_widget_alloc_height", widget.GetAllocatedHeight()).
137 Msg("wrapped webview widget prepared for pane")135 Msg("wrapped webview widget prepared for pane")
138 136
139- if err := wsView.SetWebViewWidget(pane.ID, widget); err != nil {137+ if err := wsView.AttachWebViewWidget(pane.ID, widget, c.webViewRevealed(pane.ID, wv)); err != nil {
140 log.Warn().Err(err).Str("pane_id", string(pane.ID)).Msg("failed to attach webview widget")138 log.Warn().Err(err).Str("pane_id", string(pane.ID)).Msg("failed to attach webview widget")
141 continue139 continue
142 }140 }
143- logging.Trace().Mark("webview_attached")
144 }141 }
145 }142 }
146 143
M internal/ui/coordinator/content/lifecycle_test.go +204 -2
@@ -58,7 +58,7 @@ func TestLifecycle_EnsureWebView_AcquiresFromPoolWhenNoneExists(t *testing.T) {
58 newWV := mocks.NewMockWebView(t)58 newWV := mocks.NewMockWebView(t)
59 newWV.EXPECT().SetCallbacks(mock.Anything).Maybe()59 newWV.EXPECT().SetCallbacks(mock.Anything).Maybe()
60 newWV.EXPECT().Generation().Return(uint64(0)).Maybe()60 newWV.EXPECT().Generation().Return(uint64(0)).Maybe()
61- newWV.EXPECT().ID().Return(port.WebViewID(102)).Once()61+ newWV.EXPECT().ID().Return(port.WebViewID(102)).Maybe()
62 62
63 pool := mocks.NewMockWebViewPool(t)63 pool := mocks.NewMockWebViewPool(t)
64 pool.EXPECT().Acquire(mock.Anything).Return(newWV, nil)64 pool.EXPECT().Acquire(mock.Anything).Return(newWV, nil)
@@ -82,7 +82,7 @@ func TestLifecycle_EnsureWebView_AcquiresFromPoolWhenExistingIsDestroyed(t *test
82 newWV := mocks.NewMockWebView(t)82 newWV := mocks.NewMockWebView(t)
83 newWV.EXPECT().SetCallbacks(mock.Anything).Maybe()83 newWV.EXPECT().SetCallbacks(mock.Anything).Maybe()
84 newWV.EXPECT().Generation().Return(uint64(0)).Maybe()84 newWV.EXPECT().Generation().Return(uint64(0)).Maybe()
85- newWV.EXPECT().ID().Return(port.WebViewID(103)).Once()85+ newWV.EXPECT().ID().Return(port.WebViewID(103)).Maybe()
86 86
87 pool := mocks.NewMockWebViewPool(t)87 pool := mocks.NewMockWebViewPool(t)
88 pool.EXPECT().Acquire(mock.Anything).Return(newWV, nil)88 pool.EXPECT().Acquire(mock.Anything).Return(newWV, nil)
@@ -458,3 +458,205 @@ func TestLifecycle_SyncWebViewViewport_NoopWithoutCapability(t *testing.T) {
458 458
459 c.SyncWebViewViewport(context.Background(), "pane-1", "unit-test")459 c.SyncWebViewViewport(context.Background(), "pane-1", "unit-test")
460 }460 }
461+
462+type nativeLifecycleWebView struct {
463+ port.WebView
464+ widget uintptr
465+}
466+
467+func (w *nativeLifecycleWebView) NativeWidget() uintptr { return w.widget }
468+
469+func TestLifecycle_AttachToWorkspace_RebuildPropagatesRevealStateToFreshPaneView(t *testing.T) {
470+ for _, tc := range []struct {
471+ name string
472+ revealed bool
473+ }{
474+ {name: "revealed current identity hides and stops skeleton", revealed: true},
475+ {name: "unrevealed current identity shows and starts skeleton", revealed: false},
476+ } {
477+ t.Run(tc.name, func(t *testing.T) {
478+ paneID := entity.PaneID("pane-1")
479+ ctx := context.Background()
480+ factory := layoutmocks.NewMockWidgetFactory(t)
481+ wsView, workspace, paneOverlay, loadingContainer, spinner := newLifecycleRebuildWorkspaceView(t, ctx, factory, paneID)
482+ wrappedWidget := layoutmocks.NewMockWidget(t)
483+ webViewMock := mocks.NewMockWebView(t)
484+ webViewMock.EXPECT().ID().Return(port.WebViewID(201)).Maybe()
485+ webViewMock.EXPECT().Generation().Return(uint64(3)).Maybe()
486+ webViewMock.EXPECT().IsDestroyed().Return(false).Once()
487+ webView := &nativeLifecycleWebView{WebView: webViewMock, widget: 0x351}
488+
489+ factory.EXPECT().WrapNativeWidget(uintptr(0x351)).Return(wrappedWidget).Once()
490+ wrappedWidget.EXPECT().GtkWidget().Return(nil).Once()
491+ wrappedWidget.EXPECT().GetAllocatedWidth().Return(0).Times(3)
492+ wrappedWidget.EXPECT().GetAllocatedHeight().Return(0).Times(3)
493+ wrappedWidget.EXPECT().GetParent().Return(nil).Once()
494+ wrappedWidget.EXPECT().IsVisible().Return(true).Once()
495+ paneOverlay.EXPECT().GetAllocatedWidth().Return(0).Twice()
496+ paneOverlay.EXPECT().GetAllocatedHeight().Return(0).Twice()
497+ paneOverlay.EXPECT().SetChild(wrappedWidget).Once()
498+ loadingContainer.EXPECT().SetVisible(!tc.revealed).Once()
499+ if tc.revealed {
500+ spinner.EXPECT().Stop().Once()
501+ } else {
502+ spinner.EXPECT().Start().Once()
503+ }
504+
505+ coordinator := newRevealTestCoordinator()
506+ coordinator.widgetFactory = factory
507+ coordinator.setWebViewLocked(paneID, webView)
508+ if tc.revealed {
509+ identity, ok := identityForWebView(webView)
510+ require.True(t, ok)
511+ coordinator.markWebViewRevealed(paneID, identity)
512+ }
513+
514+ coordinator.AttachToWorkspace(ctx, workspace, wsView)
515+
516+ assert.Same(t, wrappedWidget, wsView.GetPaneView(paneID).WebViewWidget())
517+ })
518+ }
519+}
520+
521+func newLifecycleRebuildWorkspaceView(
522+ t *testing.T,
523+ ctx context.Context,
524+ factory *layoutmocks.MockWidgetFactory,
525+ paneID entity.PaneID,
526+) (*component.WorkspaceView, *entity.Workspace, *layoutmocks.MockOverlayWidget, *layoutmocks.MockBoxWidget, *layoutmocks.MockSpinnerWidget) {
527+ t.Helper()
528+ workspaceContainer := layoutmocks.NewMockBoxWidget(t)
529+ workspaceOverlay := layoutmocks.NewMockOverlayWidget(t)
530+ factory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(workspaceContainer).Once()
531+ workspaceContainer.EXPECT().SetHexpand(true).Once()
532+ workspaceContainer.EXPECT().SetVexpand(true).Once()
533+ workspaceContainer.EXPECT().SetVisible(true).Once()
534+ factory.EXPECT().NewOverlay().Return(workspaceOverlay).Once()
535+ workspaceOverlay.EXPECT().SetHexpand(true).Once()
536+ workspaceOverlay.EXPECT().SetVexpand(true).Once()
537+ workspaceOverlay.EXPECT().SetChild(workspaceContainer).Once()
538+ workspaceOverlay.EXPECT().SetVisible(true).Once()
539+ wsView := component.NewWorkspaceView(ctx, factory)
540+
541+ paneOverlay := layoutmocks.NewMockOverlayWidget(t)
542+ loadingContainer := layoutmocks.NewMockBoxWidget(t)
543+ loadingContent := layoutmocks.NewMockBoxWidget(t)
544+ spinner := layoutmocks.NewMockSpinnerWidget(t)
545+ logo := layoutmocks.NewMockImageWidget(t)
546+ version := layoutmocks.NewMockLabelWidget(t)
547+ border := layoutmocks.NewMockBoxWidget(t)
548+
549+ factory.EXPECT().NewOverlay().Return(paneOverlay).Once()
550+ paneOverlay.EXPECT().SetHexpand(true).Once()
551+ paneOverlay.EXPECT().SetVexpand(true).Once()
552+ paneOverlay.EXPECT().SetVisible(true).Once()
553+ paneOverlay.EXPECT().AddCssClass("pane-overlay").Once()
554+ factory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(loadingContainer).Once()
555+ loadingContainer.EXPECT().SetHexpand(true).Once()
556+ loadingContainer.EXPECT().SetVexpand(true).Once()
557+ loadingContainer.EXPECT().SetHalign(mock.Anything).Once()
558+ loadingContainer.EXPECT().SetValign(mock.Anything).Once()
559+ loadingContainer.EXPECT().SetCanFocus(false).Times(2)
560+ loadingContainer.EXPECT().SetCanTarget(false).Times(2)
561+ loadingContainer.EXPECT().AddCssClass("loading-skeleton").Once()
562+ factory.EXPECT().NewBox(layout.OrientationVertical, 6).Return(loadingContent).Once()
563+ loadingContent.EXPECT().SetHalign(mock.Anything).Once()
564+ loadingContent.EXPECT().SetValign(mock.Anything).Once()
565+ loadingContent.EXPECT().SetCanFocus(false).Once()
566+ loadingContent.EXPECT().SetCanTarget(false).Once()
567+ loadingContent.EXPECT().AddCssClass("loading-skeleton-content").Once()
568+ factory.EXPECT().NewImage().Return(logo).Once()
569+ logo.EXPECT().SetHalign(mock.Anything).Once()
570+ logo.EXPECT().SetValign(mock.Anything).Once()
571+ logo.EXPECT().SetCanFocus(false).Once()
572+ logo.EXPECT().SetCanTarget(false).Once()
573+ logo.EXPECT().SetSizeRequest(256, 256).Once()
574+ logo.EXPECT().SetPixelSize(256).Once()
575+ logo.EXPECT().AddCssClass("loading-skeleton-logo").Once()
576+ logo.EXPECT().SetFromPaintable(mock.Anything).Maybe()
577+ factory.EXPECT().NewSpinner().Return(spinner).Once()
578+ spinner.EXPECT().SetHalign(mock.Anything).Once()
579+ spinner.EXPECT().SetValign(mock.Anything).Once()
580+ spinner.EXPECT().SetCanFocus(false).Once()
581+ spinner.EXPECT().SetCanTarget(false).Once()
582+ spinner.EXPECT().SetSizeRequest(32, 32).Once()
583+ spinner.EXPECT().AddCssClass("loading-skeleton-spinner").Once()
584+ factory.EXPECT().NewLabel(mock.Anything).Return(version).Once()
585+ version.EXPECT().SetHalign(mock.Anything).Once()
586+ version.EXPECT().SetValign(mock.Anything).Once()
587+ version.EXPECT().SetCanFocus(false).Once()
588+ version.EXPECT().SetCanTarget(false).Once()
589+ version.EXPECT().SetMaxWidthChars(mock.Anything).Once()
590+ version.EXPECT().SetEllipsize(mock.Anything).Once()
591+ version.EXPECT().AddCssClass("loading-skeleton-version").Once()
592+ loadingContent.EXPECT().Append(logo).Once()
593+ loadingContent.EXPECT().Append(spinner).Once()
594+ loadingContent.EXPECT().Append(version).Once()
595+ loadingContainer.EXPECT().Append(loadingContent).Once()
596+ loadingContainer.EXPECT().SetVisible(true).Once()
597+ spinner.EXPECT().Start().Once()
598+ paneOverlay.EXPECT().AddOverlay(loadingContainer).Once()
599+ paneOverlay.EXPECT().SetClipOverlay(loadingContainer, false).Once()
600+ paneOverlay.EXPECT().SetMeasureOverlay(loadingContainer, false).Once()
601+ factory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(border).Once()
602+ border.EXPECT().SetCanFocus(false).Once()
603+ border.EXPECT().SetCanTarget(false).Once()
604+ border.EXPECT().AddCssClass("pane-border").Once()
605+ border.EXPECT().SetHexpand(true).Once()
606+ border.EXPECT().SetVexpand(true).Once()
607+ paneOverlay.EXPECT().AddOverlay(border).Once()
608+ paneOverlay.EXPECT().SetClipOverlay(border, false).Once()
609+ paneOverlay.EXPECT().SetMeasureOverlay(border, false).Once()
610+
611+ stackBox := layoutmocks.NewMockBoxWidget(t)
612+ titleBar := layoutmocks.NewMockBoxWidget(t)
613+ favicon := layoutmocks.NewMockImageWidget(t)
614+ title := layoutmocks.NewMockLabelWidget(t)
615+ closeButton := layoutmocks.NewMockButtonWidget(t)
616+ factory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(stackBox).Once()
617+ stackBox.EXPECT().SetHexpand(true).Once()
618+ stackBox.EXPECT().SetVexpand(true).Once()
619+ factory.EXPECT().NewBox(layout.OrientationHorizontal, 4).Return(titleBar).Once()
620+ titleBar.EXPECT().AddCssClass("stacked-pane-titlebar").Once()
621+ titleBar.EXPECT().AddCssClass("stacked-pane-title-clickable").Once()
622+ titleBar.EXPECT().SetVexpand(false).Once()
623+ titleBar.EXPECT().SetHexpand(true).Once()
624+ factory.EXPECT().NewImage().Return(favicon).Once()
625+ favicon.EXPECT().SetFromIconName(mock.Anything).Once()
626+ favicon.EXPECT().SetPixelSize(16).Once()
627+ titleBar.EXPECT().Append(favicon).Once()
628+ factory.EXPECT().NewLabel(mock.Anything).Return(title).Once()
629+ title.EXPECT().SetEllipsize(layout.EllipsizeEnd).Once()
630+ title.EXPECT().SetMaxWidthChars(30).Once()
631+ title.EXPECT().SetHexpand(true).Once()
632+ title.EXPECT().SetXalign(float32(0)).Once()
633+ titleBar.EXPECT().Append(title).Once()
634+ factory.EXPECT().NewButton().Return(closeButton).Once()
635+ closeButton.EXPECT().SetIconName("window-close-symbolic").Once()
636+ closeButton.EXPECT().AddCssClass("stacked-pane-close-button").Once()
637+ closeButton.EXPECT().SetFocusOnClick(false).Once()
638+ closeButton.EXPECT().SetVexpand(false).Once()
639+ closeButton.EXPECT().SetHexpand(false).Once()
640+ titleBar.EXPECT().Append(closeButton).Once()
641+ titleBar.EXPECT().AddController(mock.Anything).Once()
642+ closeButton.EXPECT().ConnectClicked(mock.Anything).Return(uint(2)).Once()
643+ closeButton.EXPECT().GtkWidget().Return(nil).Maybe()
644+ stackBox.EXPECT().Append(titleBar).Once()
645+ stackBox.EXPECT().Append(paneOverlay).Once()
646+ titleBar.EXPECT().SetVisible(false).Once()
647+ paneOverlay.EXPECT().SetVisible(true).Once()
648+ titleBar.EXPECT().AddCssClass("active").Once()
649+ paneOverlay.EXPECT().GtkWidget().Return(nil).Maybe()
650+ stackBox.EXPECT().SetVisible(true).Once()
651+ workspaceContainer.EXPECT().Append(stackBox).Once()
652+ workspaceContainer.EXPECT().AddCssClass("single-pane").Once()
653+ border.EXPECT().AddCssClass("pane-active").Once()
654+
655+ workspace := &entity.Workspace{
656+ ID: "workspace-1",
657+ Root: &entity.PaneNode{ID: "node-1", Pane: entity.NewPane(paneID)},
658+ ActivePaneID: paneID,
659+ }
660+ require.NoError(t, wsView.SetWorkspace(ctx, workspace))
661+ return wsView, workspace, paneOverlay, loadingContainer, spinner
662+}
M internal/ui/coordinator/content/navigation.go +102 -31
@@ -17,9 +17,8 @@ import (
17 // WebKit may reset zoom during document transitions, so we reapply after LoadCommitted.17 // WebKit may reset zoom during document transitions, so we reapply after LoadCommitted.
18 // History is recorded here because the URI is guaranteed to be correct after commit.18 // History is recorded here because the URI is guaranteed to be correct after commit.
19 // Also shows the WebView widget (it's hidden during creation to avoid white flash).19 // Also shows the WebView widget (it's hidden during creation to avoid white flash).
20-func (c *Coordinator) onLoadCommitted(ctx context.Context, paneID entity.PaneID, wv port.WebView) {20+func (c *Coordinator) onLoadCommitted(ctx context.Context, paneID entity.PaneID, wv port.WebView, identity webViewIdentity) {
21 log := logging.FromContext(ctx)21 log := logging.FromContext(ctx)
22- logging.Trace().Mark("load_committed")
23 22
24 uri := wv.URI()23 uri := wv.URI()
25 if uri == "" {24 if uri == "" {
@@ -69,7 +68,7 @@ func (c *Coordinator) onLoadCommitted(ctx context.Context, paneID entity.PaneID,
69 // Avoid updating UI/domain state to about:blank when we know the pane is68 // Avoid updating UI/domain state to about:blank when we know the pane is
70 // navigating to a different URL. This prevents the omnibox/window title from69 // navigating to a different URL. This prevents the omnibox/window title from
71 // briefly showing about:blank on cold start.70 // briefly showing about:blank on cold start.
72- c.clearPendingReveal(paneID)71+ c.clearPendingReveal(paneID, identity)
73 return72 return
74 }73 }
75 74
@@ -77,8 +76,8 @@ func (c *Coordinator) onLoadCommitted(ctx context.Context, paneID entity.PaneID,
77 c.applyCurrentTheme(ctx, paneID, wv)76 c.applyCurrentTheme(ctx, paneID, wv)
78 }77 }
79 78
80- c.markPendingReveal(paneID)79+ c.markPendingReveal(paneID, identity)
81- c.revealIfPending(ctx, paneID, uri, "progress-after-commit")80+ c.revealIfPending(ctx, paneID, wv, identity, uri, "progress-after-commit")
82 81
83 // Update domain model with current URI for session snapshots82 // Update domain model with current URI for session snapshots
84 c.updatePaneURI(paneID, uri)83 c.updatePaneURI(paneID, uri)
@@ -177,8 +176,6 @@ func (c *Coordinator) updatePaneURI(paneID entity.PaneID, uri string) {
177 176
178 // onLoadStarted shows the progress bar when page loading begins.177 // onLoadStarted shows the progress bar when page loading begins.
179 func (c *Coordinator) onLoadStarted(paneID entity.PaneID) {178 func (c *Coordinator) onLoadStarted(paneID entity.PaneID) {
180- logging.Trace().Mark("load_started")
181-
182 // Trigger deferred initialization on first load_started.179 // Trigger deferred initialization on first load_started.
183 // This ensures non-critical init runs after initial navigation starts.180 // This ensures non-critical init runs after initial navigation starts.
184 c.loadStartedOnce.Do(func() {181 c.loadStartedOnce.Do(func() {
@@ -202,7 +199,7 @@ func (c *Coordinator) onLoadStarted(paneID entity.PaneID) {
202 }199 }
203 200
204 // onLoadFinished hides the progress bar when page loading completes.201 // onLoadFinished hides the progress bar when page loading completes.
205-func (c *Coordinator) onLoadFinished(ctx context.Context, paneID entity.PaneID, wv port.WebView) {202+func (c *Coordinator) onLoadFinished(ctx context.Context, paneID entity.PaneID, wv port.WebView, identity webViewIdentity) {
206 _, wsView := c.getActiveWS()203 _, wsView := c.getActiveWS()
207 var paneView *component.PaneView204 var paneView *component.PaneView
208 if wsView != nil {205 if wsView != nil {
@@ -216,7 +213,7 @@ func (c *Coordinator) onLoadFinished(ctx context.Context, paneID entity.PaneID,
216 paneView.SetLoading(false)213 paneView.SetLoading(false)
217 }214 }
218 215
219- c.revealIfPending(ctx, paneID, "", "load-finished")216+ c.revealIfPending(ctx, paneID, wv, identity, "", "load-finished")
220 if c.shouldSkipAboutBlankAppearance(paneID, wv) {217 if c.shouldSkipAboutBlankAppearance(paneID, wv) {
221 return218 return
222 }219 }
@@ -225,9 +222,9 @@ func (c *Coordinator) onLoadFinished(ctx context.Context, paneID entity.PaneID,
225 }222 }
226 223
227 // onProgressChanged updates the progress bar with current load progress.224 // onProgressChanged updates the progress bar with current load progress.
228-func (c *Coordinator) onProgressChanged(paneID entity.PaneID, progress float64) {225+func (c *Coordinator) onProgressChanged(paneID entity.PaneID, wv port.WebView, identity webViewIdentity, progress float64) {
229 if progress > 0 {226 if progress > 0 {
230- c.revealIfPending(context.Background(), paneID, "", "progress")227+ c.revealIfPending(context.Background(), paneID, wv, identity, "", "progress")
231 }228 }
232 229
233 _, wsView := c.getActiveWS()230 _, wsView := c.getActiveWS()
@@ -244,37 +241,111 @@ func (c *Coordinator) onProgressChanged(paneID entity.PaneID, progress float64)
244 }241 }
245 }242 }
246 243
247-func (c *Coordinator) markPendingReveal(paneID entity.PaneID) {244+func (c *Coordinator) markPendingReveal(paneID entity.PaneID, identity webViewIdentity) {
245+ c.webViewsMu.RLock()
248 c.revealMu.Lock()246 c.revealMu.Lock()
249- c.pendingReveal[paneID] = true247+ defer c.revealMu.Unlock()
250- c.revealMu.Unlock()248+ defer c.webViewsMu.RUnlock()
249+ currentWebView := c.webViews[paneID]
250+ if currentWebView == nil {
251+ return
252+ }
253+ currentIdentity, ok := identityForWebView(currentWebView)
254+ if !ok || currentIdentity != identity {
255+ return
256+ }
257+ c.ensureRevealMapsLocked()
258+ c.pendingReveal[paneID] = identity
251 }259 }
252 260
253-func (c *Coordinator) clearPendingReveal(paneID entity.PaneID) {261+func (c *Coordinator) clearPendingReveal(paneID entity.PaneID, identity webViewIdentity) {
262+ c.webViewsMu.RLock()
254 c.revealMu.Lock()263 c.revealMu.Lock()
264+ defer c.revealMu.Unlock()
265+ defer c.webViewsMu.RUnlock()
266+ pendingIdentity, pending := c.pendingReveal[paneID]
267+ if !pending || pendingIdentity != identity {
268+ return
269+ }
255 delete(c.pendingReveal, paneID)270 delete(c.pendingReveal, paneID)
256- c.revealMu.Unlock()
257 }271 }
258 272
259-func (c *Coordinator) revealIfPending(ctx context.Context, paneID entity.PaneID, uri, reason string) {273+// WebViewRevealed reports whether this pane's current WebView has completed a
274+// reveal. State is identity-bound so a replacement cannot inherit it.
275+func (c *Coordinator) WebViewRevealed(paneID entity.PaneID) bool {
276+ return c.webViewRevealed(paneID, nil)
277+}
278+
279+// webViewRevealed verifies the identity being attached when one is supplied.
280+// This prevents a rebuild from applying a replacement's reveal state to a
281+// previously read pooled WebView.
282+func (c *Coordinator) webViewRevealed(paneID entity.PaneID, expected port.WebView) bool {
283+ c.webViewsMu.RLock()
260 c.revealMu.Lock()284 c.revealMu.Lock()
261- pending := c.pendingReveal[paneID]285+ defer c.revealMu.Unlock()
262- if pending {286+ defer c.webViewsMu.RUnlock()
263- delete(c.pendingReveal, paneID)287+ current := c.webViews[paneID]
288+ identity, ok := identityForWebView(current)
289+ if !ok || expected != nil {
290+ expectedIdentity, expectedOK := identityForWebView(expected)
291+ if !expectedOK || expectedIdentity != identity {
292+ return false
293+ }
264 }294 }
265- c.revealMu.Unlock()295+ revealedIdentity, revealed := c.revealedWebViews[paneID]
296+ return revealed && revealedIdentity == identity
297+}
266 298
267- if !pending {299+func (c *Coordinator) markWebViewRevealed(paneID entity.PaneID, identity webViewIdentity) bool {
300+ c.webViewsMu.RLock()
301+ c.revealMu.Lock()
302+ defer c.revealMu.Unlock()
303+ defer c.webViewsMu.RUnlock()
304+ currentIdentity, current := identityForWebView(c.webViews[paneID])
305+ if !current || currentIdentity != identity {
306+ return false
307+ }
308+ c.ensureRevealMapsLocked()
309+ c.revealedWebViews[paneID] = identity
310+ return true
311+}
312+
313+func (c *Coordinator) revealIfPending(
314+ ctx context.Context, paneID entity.PaneID, wv port.WebView, identity webViewIdentity, uri, reason string,
315+) {
316+ // Mapping replacement and release acquire this gate too. No ordinary map
317+ // lock is held across the native call, but the gate keeps the validated
318+ // identity current until its widget visibility has been changed.
319+ c.revealMutationMu.Lock()
320+
321+ c.webViewsMu.RLock()
322+ c.revealMu.Lock()
323+ pendingIdentity, pending := c.pendingReveal[paneID]
324+ if !pending || pendingIdentity != identity {
325+ c.revealMu.Unlock()
326+ c.webViewsMu.RUnlock()
327+ c.revealMutationMu.Unlock()
268 return328 return
269 }329 }
330+ currentIdentity, current := identityForWebView(c.webViews[paneID])
331+ if !current || currentIdentity != identity {
332+ c.revealMu.Unlock()
333+ c.webViewsMu.RUnlock()
334+ c.revealMutationMu.Unlock()
335+ return
336+ }
337+ delete(c.pendingReveal, paneID)
338+ c.revealMu.Unlock()
339+ c.webViewsMu.RUnlock()
270 340
271- wv := c.getWebViewLocked(paneID)341+ if wv.IsDestroyed() {
272- if wv == nil || wv.IsDestroyed() {342+ c.revealMutationMu.Unlock()
273 return343 return
274 }344 }
275 345
276- // Use NativeWidgetProvider to make the widget visible (engine-agnostic)346+ if c.setWebViewVisible != nil {
277- if nwp, ok := wv.(port.NativeWidgetProvider); ok {347+ c.setWebViewVisible(wv)
348+ } else if nwp, ok := wv.(port.NativeWidgetProvider); ok {
278 if ptr := nwp.NativeWidget(); ptr != 0 {349 if ptr := nwp.NativeWidget(); ptr != 0 {
279 gtkWidget := &gtk.Widget{}350 gtkWidget := &gtk.Widget{}
280 gtkWidget.Ptr = ptr351 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 trace362+ // Keep the presentation record in the same serialized operation as the
292- logging.Trace().Mark("first_paint")363+ // native mutation. User callbacks run only after the gate is released.
293- logging.Trace().Finish()364+ revealed := c.markWebViewRevealed(paneID, identity)
294-365+ c.revealMutationMu.Unlock()
295- if c.onWebViewShown != nil {366+ if revealed && c.onWebViewShown != nil {
296 c.onWebViewShown(paneID)367 c.onWebViewShown(paneID)
297 }368 }
298 }369 }
M internal/ui/coordinator/content/popup_test.go +7 -7
@@ -137,7 +137,7 @@ func newPopupCreateCoordinatorForTest(t *testing.T, popupID port.WebViewID) (con
137 parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once()137 parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once()
138 138
139 popupWV := mocks.NewMockWebView(t)139 popupWV := mocks.NewMockWebView(t)
140- popupWV.EXPECT().ID().Return(popupID).Once()140+ popupWV.EXPECT().ID().Return(popupID).Maybe()
141 141
142 factory := mocks.NewMockWebViewFactory(t)142 factory := mocks.NewMockWebViewFactory(t)
143 factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once()143 factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once()
@@ -158,8 +158,8 @@ func TestHandlePopupCreate_PrimesPopupNavigationCapability(t *testing.T) {
158 parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once()158 parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once()
159 159
160 popupWV := &popupNavigationWebViewStub{MockWebView: mocks.NewMockWebView(t)}160 popupWV := &popupNavigationWebViewStub{MockWebView: mocks.NewMockWebView(t)}
161- popupWV.EXPECT().ID().Return(port.WebViewID(149)).Once()161+ popupWV.EXPECT().ID().Return(port.WebViewID(149)).Maybe()
162- popupWV.EXPECT().Generation().Return(uint64(1)).Once()162+ popupWV.EXPECT().Generation().Return(uint64(1)).Maybe()
163 popupWV.EXPECT().SetCallbacks(mock.Anything).Once()163 popupWV.EXPECT().SetCallbacks(mock.Anything).Once()
164 164
165 factory := mocks.NewMockWebViewFactory(t)165 factory := mocks.NewMockWebViewFactory(t)
@@ -187,7 +187,7 @@ func TestHandlePopupCreate_PrimesPopupNavigationCapability(t *testing.T) {
187 187
188 func TestHandlePopupCreate_RegistersPopupWebViewBeforeWorkspaceInsertion(t *testing.T) {188 func TestHandlePopupCreate_RegistersPopupWebViewBeforeWorkspaceInsertion(t *testing.T) {
189 ctx, parentPaneID, parentWV, popupWV, c := newPopupCreateCoordinatorForTest(t, port.WebViewID(150))189 ctx, parentPaneID, parentWV, popupWV, c := newPopupCreateCoordinatorForTest(t, port.WebViewID(150))
190- popupWV.EXPECT().Generation().Return(uint64(1)).Once()190+ popupWV.EXPECT().Generation().Return(uint64(1)).Maybe()
191 popupWV.EXPECT().SetCallbacks(mock.Anything).Once()191 popupWV.EXPECT().SetCallbacks(mock.Anything).Once()
192 popupWV.EXPECT().IsLoading().Return(false).Once()192 popupWV.EXPECT().IsLoading().Return(false).Once()
193 popupWV.EXPECT().URI().Return("").Once()193 popupWV.EXPECT().URI().Return("").Once()
@@ -211,7 +211,7 @@ func TestHandlePopupCreate_RegistersPopupWebViewBeforeWorkspaceInsertion(t *test
211 211
212 func TestHandlePopupCreate_CleansUpPreRegisteredPopupWebViewWhenInsertionFails(t *testing.T) {212 func TestHandlePopupCreate_CleansUpPreRegisteredPopupWebViewWhenInsertionFails(t *testing.T) {
213 ctx, parentPaneID, parentWV, popupWV, c := newPopupCreateCoordinatorForTest(t, port.WebViewID(151))213 ctx, parentPaneID, parentWV, popupWV, c := newPopupCreateCoordinatorForTest(t, port.WebViewID(151))
214- popupWV.EXPECT().Generation().Return(uint64(1)).Once()214+ popupWV.EXPECT().Generation().Return(uint64(1)).Maybe()
215 popupWV.EXPECT().SetCallbacks(mock.Anything).Once()215 popupWV.EXPECT().SetCallbacks(mock.Anything).Once()
216 popupWV.EXPECT().Destroy().Once()216 popupWV.EXPECT().Destroy().Once()
217 217
@@ -238,8 +238,8 @@ func TestHandlePopupCreate_UsesRelatedWebViewWhenPopupDisablesJavaScriptAccess(t
238 parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once()238 parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once()
239 239
240 popupWV := mocks.NewMockWebView(t)240 popupWV := mocks.NewMockWebView(t)
241- popupWV.EXPECT().ID().Return(port.WebViewID(150)).Once()241+ popupWV.EXPECT().ID().Return(port.WebViewID(150)).Maybe()
242- popupWV.EXPECT().Generation().Return(uint64(1)).Once()242+ popupWV.EXPECT().Generation().Return(uint64(1)).Maybe()
243 popupWV.EXPECT().SetCallbacks(mock.Anything).Once()243 popupWV.EXPECT().SetCallbacks(mock.Anything).Once()
244 popupWV.EXPECT().IsLoading().Return(false).Once()244 popupWV.EXPECT().IsLoading().Return(false).Once()
245 popupWV.EXPECT().URI().Return("").Once()245 popupWV.EXPECT().URI().Return("").Once()
A internal/ui/floating_pane_callback_cleanup_test.go +32 -0
@@ -0,0 +1,32 @@
1+package ui
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/puregotk/v4/gtk"
7+)
8+
9+func TestStopFloatingResizeWatcherReleasesRawCallbackSlot(t *testing.T) {
10+ callback := new(gtk.TickCallback)
11+ session := &floatingWorkspaceSession{resizeTickCallback: callback, resizeTickID: 17, resizeWatcherActive: true}
12+ oldUnref := unrefTickCallback
13+ defer func() { unrefTickCallback = oldUnref }()
14+ var unrefs int
15+ unrefTickCallback = func(got any) error {
16+ if got != callback {
17+ t.Fatalf("unref callback = %p, want %p", got, callback)
18+ }
19+ unrefs++
20+ return nil
21+ }
22+
23+ (&App{}).stopFloatingResizeWatcher(session)
24+ (&App{}).stopFloatingResizeWatcher(session)
25+
26+ if session.resizeTickCallback != nil || session.resizeTickID != 0 || session.resizeWatcherActive {
27+ t.Fatal("stop must release the floating resize callback slot exactly once")
28+ }
29+ if unrefs != 1 {
30+ t.Fatalf("callback unrefs = %d, want 1", unrefs)
31+ }
32+}
M internal/ui/input/hover.go +119 -41
@@ -7,6 +7,8 @@ import (
7 7
8 "github.com/bnema/dumber/internal/domain/entity"8 "github.com/bnema/dumber/internal/domain/entity"
9 "github.com/bnema/dumber/internal/logging"9 "github.com/bnema/dumber/internal/logging"
10+ "github.com/bnema/puregotk/v4/glib"
11+ "github.com/bnema/puregotk/v4/gobject"
10 "github.com/bnema/puregotk/v4/gtk"12 "github.com/bnema/puregotk/v4/gtk"
11 )13 )
12 14
@@ -24,19 +26,28 @@ type MotionCallback func()
24 // HoverHandler handles mouse hover events for focus-follows-mouse behavior.26 // HoverHandler handles mouse hover events for focus-follows-mouse behavior.
25 // It uses a debounce timer to avoid rapid focus switches.27 // It uses a debounce timer to avoid rapid focus switches.
26 type HoverHandler struct {28 type HoverHandler struct {
27- motionCtrl *gtk.EventControllerMotion29+ attachedWidget *gtk.Widget
28- paneID entity.PaneID30+ motionCtrl *gtk.EventControllerMotion
29- onEnter HoverCallback31+ enterSignalID uint
30- onMotion MotionCallback32+ leaveSignalID uint
33+ motionSignalID uint
34+ paneID entity.PaneID
35+ onEnter HoverCallback
36+ onMotion MotionCallback
31 37
32 // Callback retention: must stay reachable by Go GC.38 // Callback retention: must stay reachable by Go GC.
33 enterCb func(gtk.EventControllerMotion, float64, float64)39 enterCb func(gtk.EventControllerMotion, float64, float64)
34 leaveCb func(gtk.EventControllerMotion)40 leaveCb func(gtk.EventControllerMotion)
35 motionCb func(gtk.EventControllerMotion, float64, float64)41 motionCb func(gtk.EventControllerMotion, float64, float64)
36 42
37- timer *time.Timer43+ timerMu sync.Mutex
38- timerMu sync.Mutex44+ sourceID uint
39- canceled bool45+ generation uint64
46+ detached bool
47+ schedule func(uint, func(uintptr) bool) uint
48+ removeSource func(uint) bool
49+ disconnectSignal func(*gtk.EventControllerMotion, uint)
50+ removeController func(*gtk.Widget, *gtk.EventController)
40 51
41 ctx context.Context52 ctx context.Context
42 }53 }
@@ -47,8 +58,12 @@ func NewHoverHandler(ctx context.Context, paneID entity.PaneID) *HoverHandler {
47 log.Debug().Str("pane_id", string(paneID)).Msg("creating hover handler")58 log.Debug().Str("pane_id", string(paneID)).Msg("creating hover handler")
48 59
49 return &HoverHandler{60 return &HoverHandler{
50- ctx: ctx,61+ ctx: ctx,
51- paneID: paneID,62+ paneID: paneID,
63+ schedule: scheduleHoverSource,
64+ removeSource: glib.SourceRemove,
65+ disconnectSignal: disconnectHoverSignal,
66+ removeController: removeHoverController,
52 }67 }
53 }68 }
54 69
@@ -73,6 +88,15 @@ func (h *HoverHandler) AttachTo(widget *gtk.Widget) {
73 return88 return
74 }89 }
75 90
91+ // Reattachment must not leave a controller owned by the previous widget.
92+ if h.motionCtrl != nil || h.attachedWidget != nil {
93+ h.Detach()
94+ }
95+ h.timerMu.Lock()
96+ h.detached = false
97+ h.attachedWidget = widget
98+ h.timerMu.Unlock()
99+
76 h.motionCtrl = gtk.NewEventControllerMotion()100 h.motionCtrl = gtk.NewEventControllerMotion()
77 if h.motionCtrl == nil {101 if h.motionCtrl == nil {
78 log.Error().Msg("failed to create motion controller")102 log.Error().Msg("failed to create motion controller")
@@ -83,13 +107,13 @@ func (h *HoverHandler) AttachTo(widget *gtk.Widget) {
83 h.enterCb = func(_ gtk.EventControllerMotion, _ float64, _ float64) {107 h.enterCb = func(_ gtk.EventControllerMotion, _ float64, _ float64) {
84 h.handleEnter()108 h.handleEnter()
85 }109 }
86- h.motionCtrl.ConnectEnter(&h.enterCb)110+ h.enterSignalID = h.motionCtrl.ConnectEnter(&h.enterCb)
87 111
88 // Connect leave handler to cancel pending focus112 // Connect leave handler to cancel pending focus
89 h.leaveCb = func(_ gtk.EventControllerMotion) {113 h.leaveCb = func(_ gtk.EventControllerMotion) {
90 h.handleLeave()114 h.handleLeave()
91 }115 }
92- h.motionCtrl.ConnectLeave(&h.leaveCb)116+ h.leaveSignalID = h.motionCtrl.ConnectLeave(&h.leaveCb)
93 117
94 // Connect motion handler to detect intentional mouse movement.118 // Connect motion handler to detect intentional mouse movement.
95 // Unlike enter/leave, motion only fires when the cursor physically moves119 // Unlike enter/leave, motion only fires when the cursor physically moves
@@ -99,7 +123,7 @@ func (h *HoverHandler) AttachTo(widget *gtk.Widget) {
99 h.onMotion()123 h.onMotion()
100 }124 }
101 }125 }
102- h.motionCtrl.ConnectMotion(&h.motionCb)126+ h.motionSignalID = h.motionCtrl.ConnectMotion(&h.motionCb)
103 127
104 // Add controller to widget128 // Add controller to widget
105 widget.AddController(&h.motionCtrl.EventController)129 widget.AddController(&h.motionCtrl.EventController)
@@ -107,32 +131,49 @@ func (h *HoverHandler) AttachTo(widget *gtk.Widget) {
107 log.Debug().Str("pane_id", string(h.paneID)).Msg("hover handler attached to widget")131 log.Debug().Str("pane_id", string(h.paneID)).Msg("hover handler attached to widget")
108 }132 }
109 133
134+// scheduleHoverSource schedules a one-shot callback on GTK's owning GLib main
135+// context. GTK state must never be touched by a Go timer goroutine.
136+func scheduleHoverSource(delay uint, fn func(uintptr) bool) uint {
137+ cb := glib.SourceFunc(fn)
138+ return glib.TimeoutAdd(delay, &cb, 0)
139+}
140+
110 // handleEnter processes the mouse enter event with debouncing.141 // handleEnter processes the mouse enter event with debouncing.
111 func (h *HoverHandler) handleEnter() {142 func (h *HoverHandler) handleEnter() {
112 h.timerMu.Lock()143 h.timerMu.Lock()
113- defer h.timerMu.Unlock()144+ if h.detached {
114-145+ h.timerMu.Unlock()
115- // Cancel any existing timer146+ return
116- if h.timer != nil {
117- h.timer.Stop()
118 }147 }
119-148+ h.cancelSourceLocked()
120- h.canceled = false149+ h.generation++
121-150+ generation := h.generation
122- // Start new debounce timer151+ schedule := h.schedule
123- h.timer = time.AfterFunc(HoverFocusDelay, func() {152+ h.sourceID = schedule(uint(HoverFocusDelay.Milliseconds()), func(_ uintptr) bool {
124 h.timerMu.Lock()153 h.timerMu.Lock()
125- canceled := h.canceled154+ if h.detached || h.generation != generation {
126- h.timerMu.Unlock()155+ h.timerMu.Unlock()
127-156+ return false
128- if canceled {
129- return
130 }157 }
158+ h.sourceID = 0
159+ callback := h.onEnter
160+ paneID := h.paneID
161+ h.timerMu.Unlock()
131 162
132- if h.onEnter != nil {163+ if callback != nil {
133- h.onEnter(h.paneID)164+ callback(paneID)
134 }165 }
166+ return false
135 })167 })
168+ h.timerMu.Unlock()
169+}
170+
171+func (h *HoverHandler) cancelSourceLocked() {
172+ if h.sourceID == 0 {
173+ return
174+ }
175+ h.removeSource(h.sourceID)
176+ h.sourceID = 0
136 }177 }
137 178
138 // handleLeave cancels any pending focus switch.179 // handleLeave cancels any pending focus switch.
@@ -146,24 +187,61 @@ func (h *HoverHandler) Cancel() {
146 h.timerMu.Lock()187 h.timerMu.Lock()
147 defer h.timerMu.Unlock()188 defer h.timerMu.Unlock()
148 189
149- h.canceled = true190+ h.generation++
150- if h.timer != nil {191+ h.cancelSourceLocked()
151- h.timer.Stop()
152- h.timer = nil
153- }
154 }192 }
155 193
156-// Detach removes the hover handler.194+// Detach removes the hover handler and invalidates callbacks already queued on
195+// the GLib main context. The generation check is required because removing a
196+// source can race with dispatch.
157 func (h *HoverHandler) Detach() {197 func (h *HoverHandler) Detach() {
158 h.timerMu.Lock()198 h.timerMu.Lock()
159- if h.timer != nil {199+ h.detached = true
160- h.timer.Stop()200+ h.generation++
161- h.timer = nil201+ h.cancelSourceLocked()
162- }202+ h.onEnter = nil
163- h.timerMu.Unlock()203+ h.onMotion = nil
164-204+ widget := h.attachedWidget
205+ controller := h.motionCtrl
206+ enterID := h.enterSignalID
207+ leaveID := h.leaveSignalID
208+ motionID := h.motionSignalID
209+ h.attachedWidget = nil
165 h.motionCtrl = nil210 h.motionCtrl = nil
211+ h.enterSignalID = 0
212+ h.leaveSignalID = 0
213+ h.motionSignalID = 0
166 h.enterCb = nil214 h.enterCb = nil
167 h.leaveCb = nil215 h.leaveCb = nil
168 h.motionCb = nil216 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 := &gtk.Widget{}
44+ controller := &gtk.EventControllerMotion{}
45+ h := NewHoverHandler(context.Background(), entity.PaneID("pane-1"))
46+ h.attachedWidget = widget
47+ h.motionCtrl = controller
48+ h.enterSignalID = 1
49+ h.leaveSignalID = 2
50+ h.motionSignalID = 3
51+ var disconnected []uint
52+ var removed int
53+ h.disconnectSignal = func(_ *gtk.EventControllerMotion, id uint) { disconnected = append(disconnected, id) }
54+ h.removeController = func(gotWidget *gtk.Widget, gotController *gtk.EventController) {
55+ if gotWidget != widget || gotController != &controller.EventController {
56+ t.Fatal("detach removed the wrong native controller")
57+ }
58+ removed++
59+ }
60+
61+ h.Detach()
62+ h.Detach()
63+
64+ if got, want := disconnected, []uint{1, 2, 3}; !reflect.DeepEqual(got, want) {
65+ t.Fatalf("disconnected signals = %v, want %v", got, want)
66+ }
67+ if removed != 1 {
68+ t.Fatalf("native controllers removed = %d, want 1", removed)
69+ }
70+ if h.attachedWidget != nil || h.motionCtrl != nil || h.enterCb != nil || h.leaveCb != nil || h.motionCb != nil {
71+ t.Fatal("detach must clear native and callback ownership")
72+ }
73+}
74+
75+func TestHoverHandlerDetachInvalidatesScheduledCallbackAndRemovesSource(t *testing.T) {
76+ h := NewHoverHandler(context.Background(), entity.PaneID("pane-1"))
77+ var scheduled func(uintptr) bool
78+ var removed []uint
79+ h.schedule = func(_ uint, fn func(uintptr) bool) uint { scheduled = fn; return 9 }
80+ h.removeSource = func(id uint) bool { removed = append(removed, id); return true }
81+ h.SetOnEnter(func(entity.PaneID) { t.Fatal("detached hover callback ran") })
82+ h.handleEnter()
83+ h.Detach()
84+ if len(removed) != 1 || removed[0] != 9 {
85+ t.Fatalf("removed sources = %v, want [9]", removed)
86+ }
87+ if scheduled == nil || scheduled(0) {
88+ t.Fatal("detached callback must be rejected and one-shot")
89+ }
90+}
M internal/ui/layout/gtk_widgets.go +49 -5
@@ -1,7 +1,10 @@
1 package layout1 package layout
2 2
3 import (3 import (
4+ "sync"
5+
4 "github.com/bnema/puregotk/v4/gdk"6 "github.com/bnema/puregotk/v4/gdk"
7+ "github.com/bnema/puregotk/v4/glib"
5 "github.com/bnema/puregotk/v4/gobject"8 "github.com/bnema/puregotk/v4/gobject"
6 "github.com/bnema/puregotk/v4/graphene"9 "github.com/bnema/puregotk/v4/graphene"
7 "github.com/bnema/puregotk/v4/gtk"10 "github.com/bnema/puregotk/v4/gtk"
@@ -82,8 +85,14 @@ func (w *gtkWidget) ComputePoint(target Widget) (x, y float64, ok bool) {
82 // gtkPaned wraps gtk.Paned to implement PanedWidget.85 // gtkPaned wraps gtk.Paned to implement PanedWidget.
83 type gtkPaned struct {86 type gtkPaned struct {
84 inner *gtk.Paned87 inner *gtk.Paned
88+
89+ tickMu sync.Mutex
90+ tickCallbacks map[uint]*gtk.TickCallback
85 }91 }
86 92
93+// unrefTickCallback is the canonical puregotk/purego callback-slot release.
94+var unrefTickCallback = glib.UnrefCallback
95+
87 func (p *gtkPaned) Show() { p.inner.Show() }96 func (p *gtkPaned) Show() { p.inner.Show() }
88 func (p *gtkPaned) Hide() { p.inner.Hide() }97 func (p *gtkPaned) Hide() { p.inner.Hide() }
89 func (p *gtkPaned) SetVisible(visible bool) { p.inner.SetVisible(visible) }98 func (p *gtkPaned) SetVisible(visible bool) { p.inner.SetVisible(visible) }
@@ -176,10 +185,45 @@ func (p *gtkPaned) ConnectNotifyPosition(callback func()) uint {
176 }185 }
177 186
178 func (p *gtkPaned) AddTickCallback(callback func() bool) uint {187 func (p *gtkPaned) AddTickCallback(callback func() bool) uint {
179- cb := gtk.TickCallback(func(widget uintptr, frameClock uintptr, userData uintptr) bool {188+ cb := new(gtk.TickCallback)
180- return callback()189+ var id uint
181- })190+ *cb = func(_ uintptr, _ uintptr, _ uintptr) bool {
182- return p.inner.AddTickCallback(&cb, 0, nil)191+ keepRunning := callback()
192+ if !keepRunning {
193+ // GTK removes a false-returning source itself; release its matching
194+ // purego slot exactly once rather than merely dropping the Go pointer.
195+ p.releaseTickCallback(id)
196+ }
197+ return keepRunning
198+ }
199+ id = p.inner.AddTickCallback(cb, 0, nil)
200+ if id != 0 {
201+ p.tickMu.Lock()
202+ if p.tickCallbacks == nil {
203+ p.tickCallbacks = make(map[uint]*gtk.TickCallback)
204+ }
205+ p.tickCallbacks[id] = cb
206+ p.tickMu.Unlock()
207+ }
208+ return id
209+}
210+
211+func (p *gtkPaned) RemoveTickCallback(id uint) {
212+ p.inner.RemoveTickCallback(id)
213+ p.releaseTickCallback(id)
214+}
215+
216+func (p *gtkPaned) releaseTickCallback(id uint) {
217+ if id == 0 {
218+ return
219+ }
220+ p.tickMu.Lock()
221+ callback := p.tickCallbacks[id]
222+ delete(p.tickCallbacks, id)
223+ p.tickMu.Unlock()
224+ if callback != nil {
225+ _ = unrefTickCallback(callback)
226+ }
183 }227 }
184 228
185 func (p *gtkPaned) GetAllocatedWidth() int { return p.inner.GetAllocatedWidth() }229 func (p *gtkPaned) GetAllocatedWidth() int { return p.inner.GetAllocatedWidth() }
@@ -779,7 +823,7 @@ func (f *GtkWidgetFactory) NewPaned(orientation Orientation) PanedWidget {
779 paned := gtk.NewPaned(orientation)823 paned := gtk.NewPaned(orientation)
780 paned.SetHexpand(true)824 paned.SetHexpand(true)
781 paned.SetVexpand(true)825 paned.SetVexpand(true)
782- return &gtkPaned{inner: paned}826+ return &gtkPaned{inner: paned, tickCallbacks: make(map[uint]*gtk.TickCallback)}
783 }827 }
784 828
785 func (f *GtkWidgetFactory) NewBox(orientation Orientation, spacing int) BoxWidget {829 func (f *GtkWidgetFactory) NewBox(orientation Orientation, spacing int) BoxWidget {
A internal/ui/layout/gtk_widgets_tick_cleanup_test.go +29 -0
@@ -0,0 +1,29 @@
1+package layout
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/puregotk/v4/gtk"
7+)
8+
9+func TestGtkPanedReleaseTickCallbackUnrefsRawSlotExactlyOnce(t *testing.T) {
10+ callback := new(gtk.TickCallback)
11+ paned := &gtkPaned{tickCallbacks: map[uint]*gtk.TickCallback{7: callback}}
12+ oldUnref := unrefTickCallback
13+ defer func() { unrefTickCallback = oldUnref }()
14+ var unrefs int
15+ unrefTickCallback = func(got any) error {
16+ if got != callback {
17+ t.Fatalf("unref callback = %p, want %p", got, callback)
18+ }
19+ unrefs++
20+ return nil
21+ }
22+
23+ paned.releaseTickCallback(7)
24+ paned.releaseTickCallback(7)
25+
26+ if unrefs != 1 {
27+ t.Fatalf("callback unrefs = %d, want 1", unrefs)
28+ }
29+}
M internal/ui/layout/mocks/widgets.go +40 -0
@@ -2425,6 +2425,46 @@ func (_c *MockPanedWidget_RemoveCssClass_Call) RunAndReturn(run func(cssClass st
2425 return _c2425 return _c
2426 }2426 }
2427 2427
2428+// RemoveTickCallback provides a mock function for the type MockPanedWidget
2429+func (_mock *MockPanedWidget) RemoveTickCallback(id uint) {
2430+ _mock.Called(id)
2431+ return
2432+}
2433+
2434+// MockPanedWidget_RemoveTickCallback_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RemoveTickCallback'
2435+type MockPanedWidget_RemoveTickCallback_Call struct {
2436+ *mock.Call
2437+}
2438+
2439+// RemoveTickCallback is a helper method to define mock.On call
2440+// - id uint
2441+func (_e *MockPanedWidget_Expecter) RemoveTickCallback(id any) *MockPanedWidget_RemoveTickCallback_Call {
2442+ return &MockPanedWidget_RemoveTickCallback_Call{Call: _e.mock.On("RemoveTickCallback", id)}
2443+}
2444+
2445+func (_c *MockPanedWidget_RemoveTickCallback_Call) Run(run func(id uint)) *MockPanedWidget_RemoveTickCallback_Call {
2446+ _c.Call.Run(func(args mock.Arguments) {
2447+ var arg0 uint
2448+ if args[0] != nil {
2449+ arg0 = args[0].(uint)
2450+ }
2451+ run(
2452+ arg0,
2453+ )
2454+ })
2455+ return _c
2456+}
2457+
2458+func (_c *MockPanedWidget_RemoveTickCallback_Call) Return() *MockPanedWidget_RemoveTickCallback_Call {
2459+ _c.Call.Return()
2460+ return _c
2461+}
2462+
2463+func (_c *MockPanedWidget_RemoveTickCallback_Call) RunAndReturn(run func(id uint)) *MockPanedWidget_RemoveTickCallback_Call {
2464+ _c.Run(run)
2465+ return _c
2466+}
2467+
2428 // SetCanFocus provides a mock function for the type MockPanedWidget2468 // SetCanFocus provides a mock function for the type MockPanedWidget
2429 func (_mock *MockPanedWidget) SetCanFocus(canFocus bool) {2469 func (_mock *MockPanedWidget) SetCanFocus(canFocus bool) {
2430 _mock.Called(canFocus)2470 _mock.Called(canFocus)
M internal/ui/layout/split.go +51 -8
@@ -21,8 +21,11 @@ type SplitView struct {
21 logger zerolog.Logger21 logger zerolog.Logger
22 22
23 onRatioChanged func(ratio float64)23 onRatioChanged func(ratio float64)
24- pendingNotifyRatio float6424+ idleAdd func(*glib.SourceFunc, uintptr) uint
25 notifyDebounceTimer *time.Timer25 notifyDebounceTimer *time.Timer
26+ notifyGeneration uint64
27+ tickCallbackID uint
28+ cleanedUp bool
26 29
27 hasAppliedRatio bool30 hasAppliedRatio bool
28 suppressNotifyUntil time.Time31 suppressNotifyUntil time.Time
@@ -109,6 +112,7 @@ func NewSplitView(
109 endChild: endChild,112 endChild: endChild,
110 ratio: clampRatio(ratio),113 ratio: clampRatio(ratio),
111 logger: log.With().Str("component", "split-view").Logger(),114 logger: log.With().Str("component", "split-view").Logger(),
115+ idleAdd: glib.IdleAdd,
112 }116 }
113 117
114 // Set children118 // Set children
@@ -151,25 +155,40 @@ func NewSplitView(
151 ratio := clampRatio(float64(position) / float64(totalSize))155 ratio := clampRatio(float64(position) / float64(totalSize))
152 156
153 sv.mu.Lock()157 sv.mu.Lock()
158+ if sv.cleanedUp {
159+ sv.mu.Unlock()
160+ return
161+ }
154 sv.ratio = ratio162 sv.ratio = ratio
155- sv.pendingNotifyRatio = ratio163+ sv.notifyGeneration++
156- onRatioChanged := sv.onRatioChanged164+ generation := sv.notifyGeneration
157 165
158 if sv.notifyDebounceTimer != nil {166 if sv.notifyDebounceTimer != nil {
159 sv.notifyDebounceTimer.Stop()167 sv.notifyDebounceTimer.Stop()
160 }168 }
161 sv.notifyDebounceTimer = time.AfterFunc(notifyPositionDebounceDelay, func() {169 sv.notifyDebounceTimer = time.AfterFunc(notifyPositionDebounceDelay, func() {
162- if onRatioChanged == nil {170+ sv.mu.RLock()
171+ valid := !sv.cleanedUp && generation == sv.notifyGeneration
172+ sv.mu.RUnlock()
173+ if !valid {
163 return174 return
164 }175 }
176+
165 cb := glib.SourceFunc(func(_ uintptr) bool {177 cb := glib.SourceFunc(func(_ uintptr) bool {
166 sv.mu.RLock()178 sv.mu.RLock()
167- pending := sv.pendingNotifyRatio179+ if sv.cleanedUp || generation != sv.notifyGeneration {
180+ sv.mu.RUnlock()
181+ return false
182+ }
183+ onRatioChanged := sv.onRatioChanged
168 sv.mu.RUnlock()184 sv.mu.RUnlock()
169- onRatioChanged(pending)185+
186+ if onRatioChanged != nil {
187+ onRatioChanged(ratio)
188+ }
170 return false189 return false
171 })190 })
172- glib.IdleAdd(&cb, 0)191+ sv.idleAdd(&cb, 0)
173 })192 })
174 193
175 sv.mu.Unlock()194 sv.mu.Unlock()
@@ -188,7 +207,7 @@ func NewSplitView(
188 // Add tick callback to retry applying ratio every frame until successful.207 // Add tick callback to retry applying ratio every frame until successful.
189 // This handles cases where allocation isn't ready even after Map signal.208 // This handles cases where allocation isn't ready even after Map signal.
190 frames := 0209 frames := 0
191- paned.AddTickCallback(func() bool {210+ sv.tickCallbackID = paned.AddTickCallback(func() bool {
192 frames++211 frames++
193 if sv.ApplyRatio() {212 if sv.ApplyRatio() {
194 sv.logger.Debug().213 sv.logger.Debug().
@@ -213,6 +232,30 @@ func NewSplitView(
213 // The ratio is clamped to the range [0.0, 1.0].232 // The ratio is clamped to the range [0.0, 1.0].
214 // Note: This sets the position based on ratio; actual pixel position233 // Note: This sets the position based on ratio; actual pixel position
215 // depends on the allocated size of the paned widget.234 // depends on the allocated size of the paned widget.
235+// Cleanup releases callbacks owned by this split. It is idempotent because
236+// tree rebuild and GTK teardown can both reach the same view.
237+func (sv *SplitView) Cleanup() {
238+ sv.mu.Lock()
239+ if sv.cleanedUp {
240+ sv.mu.Unlock()
241+ return
242+ }
243+ sv.cleanedUp = true
244+ sv.notifyGeneration++
245+ sv.onRatioChanged = nil
246+ if sv.notifyDebounceTimer != nil {
247+ sv.notifyDebounceTimer.Stop()
248+ sv.notifyDebounceTimer = nil
249+ }
250+ tickCallbackID := sv.tickCallbackID
251+ sv.tickCallbackID = 0
252+ sv.mu.Unlock()
253+
254+ if tickCallbackID != 0 {
255+ sv.paned.RemoveTickCallback(tickCallbackID)
256+ }
257+}
258+
216 func (sv *SplitView) SetRatio(ratio float64) {259 func (sv *SplitView) SetRatio(ratio float64) {
217 sv.mu.Lock()260 sv.mu.Lock()
218 defer sv.mu.Unlock()261 defer sv.mu.Unlock()
A internal/ui/layout/split_cleanup_test.go +29 -0
@@ -0,0 +1,29 @@
1+package layout_test
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/stretchr/testify/mock"
8+
9+ "github.com/bnema/dumber/internal/ui/layout"
10+ "github.com/bnema/dumber/internal/ui/layout/mocks"
11+)
12+
13+func TestSplitViewCleanupReleasesTickCallbackExactlyOnce(t *testing.T) {
14+ factory := mocks.NewMockWidgetFactory(t)
15+ paned := mocks.NewMockPanedWidget(t)
16+ factory.EXPECT().NewPaned(layout.OrientationHorizontal).Return(paned).Once()
17+ paned.EXPECT().SetResizeStartChild(true).Once()
18+ paned.EXPECT().SetResizeEndChild(true).Once()
19+ paned.EXPECT().SetVisible(true).Once()
20+ paned.EXPECT().GetAllocatedWidth().Return(0).Once()
21+ paned.EXPECT().ConnectNotifyPosition(mock.Anything).Return(uint(0)).Once()
22+ paned.EXPECT().ConnectMap(mock.Anything).Return(uint(0)).Once()
23+ paned.EXPECT().AddTickCallback(mock.Anything).Return(uint(42)).Once()
24+ paned.EXPECT().RemoveTickCallback(uint(42)).Once()
25+
26+ sv := layout.NewSplitView(context.Background(), factory, layout.OrientationHorizontal, nil, nil, 0.5)
27+ sv.Cleanup()
28+ sv.Cleanup()
29+}
A internal/ui/layout/split_notify_cleanup_test.go +123 -0
@@ -0,0 +1,123 @@
1+package layout
2+
3+import (
4+ "context"
5+ "sync/atomic"
6+ "testing"
7+ "time"
8+
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/puregotk/v4/glib"
12+)
13+
14+// notifyingPaned only implements the SplitView interactions exercised below.
15+// Embedding the interface supplies the unused methods.
16+type notifyingPaned struct {
17+ PanedWidget
18+ position int
19+ notify func()
20+}
21+
22+func (p *notifyingPaned) SetResizeStartChild(bool) {}
23+func (p *notifyingPaned) SetResizeEndChild(bool) {}
24+func (p *notifyingPaned) SetVisible(bool) {}
25+func (p *notifyingPaned) ConnectNotifyPosition(fn func()) uint { p.notify = fn; return 0 }
26+func (p *notifyingPaned) GetAllocatedWidth() int { return 100 }
27+func (p *notifyingPaned) GetPosition() int { return p.position }
28+func (p *notifyingPaned) SetPosition(position int) { p.position = position }
29+
30+type notifyingFactory struct {
31+ WidgetFactory
32+ paned PanedWidget
33+}
34+
35+func (f notifyingFactory) NewPaned(Orientation) PanedWidget { return f.paned }
36+
37+func newNotifyingSplitView(t *testing.T) (*SplitView, func(int)) {
38+ t.Helper()
39+
40+ paned := &notifyingPaned{}
41+ sv := NewSplitView(context.Background(), notifyingFactory{paned: paned}, OrientationHorizontal, nil, nil, 0)
42+ sv.mu.Lock()
43+ sv.suppressNotifyUntil = time.Time{}
44+ sv.mu.Unlock()
45+ require.NotNil(t, paned.notify)
46+ return sv, func(nextPosition int) {
47+ paned.position = nextPosition
48+ paned.notify()
49+ }
50+}
51+
52+func receiveQueuedIdle(t *testing.T, queued <-chan *glib.SourceFunc) *glib.SourceFunc {
53+ t.Helper()
54+ select {
55+ case source := <-queued:
56+ return source
57+ case <-time.After(time.Second):
58+ t.Fatal("timed out waiting for debounce to queue idle callback")
59+ return nil
60+ }
61+}
62+
63+func setIdleScheduler(sv *SplitView, queued chan<- *glib.SourceFunc) {
64+ sv.idleAdd = func(source *glib.SourceFunc, _ uintptr) uint {
65+ queued <- source
66+ return 1
67+ }
68+}
69+
70+func TestSplitViewQueuedRatioCallbackDoesNotRunAfterCleanup(t *testing.T) {
71+ queued := make(chan *glib.SourceFunc, 1)
72+ sv, notify := newNotifyingSplitView(t)
73+ setIdleScheduler(sv, queued)
74+ var calls atomic.Int32
75+ sv.SetOnRatioChanged(func(float64) { calls.Add(1) })
76+
77+ notify(25)
78+ source := receiveQueuedIdle(t, queued) // Debounce has fired and queued this work.
79+ sv.Cleanup()
80+ (*source)(0) // Run the queued GLib closure after cleanup.
81+
82+ require.Zero(t, calls.Load(), "cleanup must invalidate queued ratio callbacks")
83+}
84+
85+func TestSplitViewSupersededRatioCallbackDeliversOnlyLatestValueOnce(t *testing.T) {
86+ queued := make(chan *glib.SourceFunc, 2)
87+ sv, notify := newNotifyingSplitView(t)
88+ setIdleScheduler(sv, queued)
89+ var ratios []float64
90+ sv.SetOnRatioChanged(func(ratio float64) { ratios = append(ratios, ratio) })
91+
92+ notify(20)
93+ first := receiveQueuedIdle(t, queued)
94+ notify(80)
95+ second := receiveQueuedIdle(t, queued)
96+ (*first)(0)
97+ (*second)(0)
98+
99+ require.Equal(t, []float64{0.8}, ratios)
100+}
101+
102+func TestSplitViewIdleSchedulersAreInstanceScoped(t *testing.T) {
103+ firstQueued := make(chan *glib.SourceFunc, 1)
104+ secondQueued := make(chan *glib.SourceFunc, 1)
105+ first, firstNotify := newNotifyingSplitView(t)
106+ second, secondNotify := newNotifyingSplitView(t)
107+ setIdleScheduler(first, firstQueued)
108+ setIdleScheduler(second, secondQueued)
109+
110+ firstNotify(20)
111+ secondNotify(80)
112+
113+ firstSource := receiveQueuedIdle(t, firstQueued)
114+ secondSource := receiveQueuedIdle(t, secondQueued)
115+ var firstCalls, secondCalls atomic.Int32
116+ first.SetOnRatioChanged(func(float64) { firstCalls.Add(1) })
117+ second.SetOnRatioChanged(func(float64) { secondCalls.Add(1) })
118+ (*firstSource)(0)
119+ (*secondSource)(0)
120+
121+ require.Equal(t, int32(1), firstCalls.Load())
122+ require.Equal(t, int32(1), secondCalls.Load())
123+}
M internal/ui/layout/tree.go +24 -5
@@ -33,6 +33,9 @@ type TreeRenderer struct {
33 logger zerolog.Logger33 logger zerolog.Logger
34 nodeToWidget map[string]Widget34 nodeToWidget map[string]Widget
35 splitOrientation map[string]Orientation35 splitOrientation map[string]Orientation
36+ // splitViews owns the SplitView callback registrations created while
37+ // rendering. Widgets alone cannot release their tick callbacks.
38+ splitViews map[string]*SplitView
36 // paneToStack maps pane IDs to their containing StackedView.39 // paneToStack maps pane IDs to their containing StackedView.
37 // Every leaf pane is wrapped in a StackedView for easy stacking later.40 // Every leaf pane is wrapped in a StackedView for easy stacking later.
38 paneToStack map[string]*StackedView41 paneToStack map[string]*StackedView
@@ -52,6 +55,7 @@ func NewTreeRenderer(ctx context.Context, factory WidgetFactory, paneViewFactory
52 logger: log.With().Str("component", "tree-renderer").Logger(),55 logger: log.With().Str("component", "tree-renderer").Logger(),
53 nodeToWidget: make(map[string]Widget),56 nodeToWidget: make(map[string]Widget),
54 splitOrientation: make(map[string]Orientation),57 splitOrientation: make(map[string]Orientation),
58+ splitViews: make(map[string]*SplitView),
55 paneToStack: make(map[string]*StackedView),59 paneToStack: make(map[string]*StackedView),
56 }60 }
57 }61 }
@@ -73,10 +77,7 @@ func (tr *TreeRenderer) Build(ctx context.Context, root *entity.PaneNode) (Widge
73 tr.mu.Lock()77 tr.mu.Lock()
74 defer tr.mu.Unlock()78 defer tr.mu.Unlock()
75 79
76- // Clear previous mappings80+ tr.clearInternal()
77- tr.nodeToWidget = make(map[string]Widget)
78- tr.splitOrientation = make(map[string]Orientation)
79- tr.paneToStack = make(map[string]*StackedView)
80 81
81 return tr.renderNode(ctx, root)82 return tr.renderNode(ctx, root)
82 }83 }
@@ -189,6 +190,7 @@ func (tr *TreeRenderer) renderSplit(ctx context.Context, node *entity.PaneNode)
189 }190 }
190 })191 })
191 tr.splitOrientation[node.ID] = orientation192 tr.splitOrientation[node.ID] = orientation
193+ tr.splitViews[node.ID] = splitView
192 194
193 return splitView.Widget(), nil195 return splitView.Widget(), nil
194 }196 }
@@ -264,13 +266,26 @@ func (tr *TreeRenderer) NodeCount() int {
264 return len(tr.nodeToWidget)266 return len(tr.nodeToWidget)
265 }267 }
266 268
267-// Clear removes all node-to-widget mappings.269+// Clear releases all renderer-owned split callbacks and removes mappings.
268 func (tr *TreeRenderer) Clear() {270 func (tr *TreeRenderer) Clear() {
269 tr.mu.Lock()271 tr.mu.Lock()
270 defer tr.mu.Unlock()272 defer tr.mu.Unlock()
271 273
274+ tr.clearInternal()
275+}
276+
277+// clearInternal releases renderer-owned views and resets the lookup maps.
278+// The caller must hold tr.mu.
279+func (tr *TreeRenderer) clearInternal() {
280+ for _, splitView := range tr.splitViews {
281+ if splitView != nil {
282+ splitView.Cleanup()
283+ }
284+ }
272 tr.nodeToWidget = make(map[string]Widget)285 tr.nodeToWidget = make(map[string]Widget)
273 tr.splitOrientation = make(map[string]Orientation)286 tr.splitOrientation = make(map[string]Orientation)
287+ tr.splitViews = make(map[string]*SplitView)
288+ tr.paneToStack = make(map[string]*StackedView)
274 }289 }
275 290
276 // RegisterWidget adds or updates a node-to-widget mapping.291 // RegisterWidget adds or updates a node-to-widget mapping.
@@ -297,8 +312,12 @@ func (tr *TreeRenderer) UnregisterWidget(nodeID string) {
297 tr.mu.Lock()312 tr.mu.Lock()
298 defer tr.mu.Unlock()313 defer tr.mu.Unlock()
299 314
315+ if splitView := tr.splitViews[nodeID]; splitView != nil {
316+ splitView.Cleanup()
317+ }
300 delete(tr.nodeToWidget, nodeID)318 delete(tr.nodeToWidget, nodeID)
301 delete(tr.splitOrientation, nodeID)319 delete(tr.splitOrientation, nodeID)
320+ delete(tr.splitViews, nodeID)
302 }321 }
303 322
304 // UpdateSplitRatio updates the ratio of a split view by node ID.323 // UpdateSplitRatio updates the ratio of a split view by node ID.
A internal/ui/layout/tree_cleanup_test.go +48 -0
@@ -0,0 +1,48 @@
1+package layout_test
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/domain/entity"
8+ "github.com/bnema/dumber/internal/ui/layout"
9+ "github.com/bnema/dumber/internal/ui/layout/mocks"
10+ "github.com/stretchr/testify/mock"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+func TestTreeRendererClearReleasesOwnedSplitTickExactlyOnce(t *testing.T) {
15+ ctx := context.Background()
16+ factory := mocks.NewMockWidgetFactory(t)
17+ paneFactory := mocks.NewMockPaneViewFactory(t)
18+ paned := mocks.NewMockPanedWidget(t)
19+ leftStack, leftContainer := setupStackedLeafMocks(t, factory)
20+ rightStack, rightContainer := setupStackedLeafMocks(t, factory)
21+
22+ left := &entity.PaneNode{ID: "left", Pane: entity.NewPane("left")}
23+ right := &entity.PaneNode{ID: "right", Pane: entity.NewPane("right")}
24+ root := &entity.PaneNode{ID: "split", SplitDir: entity.SplitHorizontal, SplitRatio: 0.5, Children: []*entity.PaneNode{left, right}}
25+
26+ paneFactory.EXPECT().CreatePaneView(left).Return(leftContainer).Once()
27+ paneFactory.EXPECT().CreatePaneView(right).Return(rightContainer).Once()
28+ factory.EXPECT().NewPaned(layout.OrientationHorizontal).Return(paned).Once()
29+ paned.EXPECT().SetResizeStartChild(true).Once()
30+ paned.EXPECT().SetResizeEndChild(true).Once()
31+ paned.EXPECT().SetVisible(true).Once()
32+ leftStack.EXPECT().SetVisible(true).Once()
33+ rightStack.EXPECT().SetVisible(true).Once()
34+ paned.EXPECT().SetStartChild(leftStack).Once()
35+ paned.EXPECT().SetEndChild(rightStack).Once()
36+ paned.EXPECT().ConnectNotifyPosition(mock.Anything).Return(uint(0)).Once()
37+ paned.EXPECT().GetAllocatedWidth().Return(0).Once()
38+ paned.EXPECT().ConnectMap(mock.Anything).Return(uint(0)).Once()
39+ paned.EXPECT().AddTickCallback(mock.Anything).Return(uint(42)).Once()
40+ paned.EXPECT().RemoveTickCallback(uint(42)).Once()
41+
42+ renderer := layout.NewTreeRenderer(ctx, factory, paneFactory)
43+ _, err := renderer.Build(ctx, root)
44+ require.NoError(t, err)
45+
46+ renderer.Clear()
47+ renderer.Clear()
48+}
M internal/ui/layout/widgets.go +5 -2
@@ -99,9 +99,12 @@ type PanedWidget interface {
99 ConnectMap(callback func()) uint99 ConnectMap(callback func()) uint
100 ConnectNotifyPosition(callback func()) uint100 ConnectNotifyPosition(callback func()) uint
101 101
102- // Tick callback for frame-based updates (returns signal ID)102+ // Tick callback for frame-based updates (returns signal ID).
103- // Callback returns true to continue, false to stop103+ // Callback returns true to continue, false to stop.
104 AddTickCallback(callback func() bool) uint104 AddTickCallback(callback func() bool) uint
105+ // RemoveTickCallback releases an active callback slot. It is safe to call
106+ // once after the callback has stopped itself.
107+ RemoveTickCallback(id uint)
105 108
106 // Handle appearance109 // Handle appearance
107 SetWideHandle(wide bool)110 SetWideHandle(wide bool)
A scripts/collect_first_presentation.sh +253 -0
@@ -0,0 +1,253 @@
1+#!/usr/bin/env bash
2+# Collect exactly five isolated CEF/DMABUF cold starts. The application is
3+# deliberately terminated after the bounded observation window; a completed
4+# first-presentation summary, rather than process exit, is the success signal.
5+set -euo pipefail
6+
7+readonly runs=5
8+readonly timeout_seconds="${DUMBER_FIRST_PRESENTATION_TIMEOUT_SECONDS:-45}"
9+readonly runtime="${DUMBER_CEF_DIR:-$HOME/.local/share/cef-147-runtime}"
10+readonly binary="${DUMBER_FIRST_PRESENTATION_BIN:-$PWD/dist/dumber}"
11+
12+fail_unsafe_output() {
13+ echo "first-presentation: unsafe output path: $1" >&2
14+ exit 2
15+}
16+
17+if [[ -v DUMBER_FIRST_PRESENTATION_OUTPUT ]]; then
18+ output="$DUMBER_FIRST_PRESENTATION_OUTPUT"
19+else
20+ # Evidence is state, not source: keep the default outside the checkout and
21+ # make every invocation a fresh child of the external evidence directory.
22+ readonly state_home="${XDG_STATE_HOME:-$HOME/.local/state}"
23+ [[ "$state_home" == /* ]] || fail_unsafe_output "XDG_STATE_HOME must be absolute"
24+ readonly evidence_root="$state_home/dumber/roadmap-evidence"
25+ mkdir -p -- "$evidence_root" || fail_unsafe_output "could not create XDG state directory"
26+ canonical_evidence_root="$(realpath -e -- "$evidence_root")" || \
27+ fail_unsafe_output "XDG state directory cannot be canonicalized"
28+ [[ "$canonical_evidence_root" == "$evidence_root" && ! -L "$evidence_root" ]] || \
29+ fail_unsafe_output "XDG state directory must not contain symbolic links"
30+ output="$evidence_root/first-presentation-$(date -u +%Y%m%dT%H%M%S)-$$"
31+fi
32+readonly output
33+readonly upstream_module="github.com/bnema/purego-cef2gtk"
34+
35+# Resolve the version selected by this checkout, then obtain its immutable VCS
36+# origin from Go's cached module metadata. Do not infer a revision from a tag,
37+# a branch, or a truncated pseudo-version suffix.
38+resolve_upstream_provenance() {
39+ local selected_metadata selected_version downloaded_metadata
40+
41+ selected_metadata="$(GOFLAGS=-mod=mod go list -m -json "$upstream_module" 2>/dev/null)" || {
42+ echo "first-presentation: immutable module provenance is unavailable" >&2
43+ exit 2
44+ }
45+ selected_version="$(python3 - "$upstream_module" "$selected_metadata" <<'PY'
46+import json, sys
47+try:
48+ metadata = json.loads(sys.argv[2])
49+ if metadata.get("Path") != sys.argv[1] or not isinstance(metadata.get("Version"), str):
50+ raise ValueError
51+ print(metadata["Version"])
52+except (AttributeError, TypeError, ValueError, json.JSONDecodeError):
53+ raise SystemExit("first-presentation: immutable module provenance is unavailable")
54+PY
55+)" || exit $?
56+ downloaded_metadata="$(go mod download -json "$upstream_module@$selected_version" 2>/dev/null)" || {
57+ echo "first-presentation: immutable module provenance is unavailable" >&2
58+ exit 2
59+ }
60+
61+ python3 - "$upstream_module" "$selected_metadata" "$downloaded_metadata" <<'PY'
62+import json, re, sys
63+
64+module, selected_raw, downloaded_raw = sys.argv[1:]
65+
66+def fail():
67+ # Do not expose Go cache locations or other machine-local values.
68+ raise SystemExit("first-presentation: immutable module provenance is unavailable")
69+
70+try:
71+ selected = json.loads(selected_raw)
72+ downloaded = json.loads(downloaded_raw)
73+ version = selected["Version"]
74+ if selected.get("Path") != module or downloaded.get("Path") != module:
75+ fail()
76+ if downloaded.get("Version") != version:
77+ fail()
78+ # A pseudo-version identifies an immutable commit; a released semver tag is
79+ # accepted only when Go records that exact tag and its immutable hash.
80+ pseudo_match = re.fullmatch(r"(v\d+\.\d+\.\d+)-0\.\d{14}-([0-9a-f]{12})", version)
81+ tag_match = re.fullmatch(r"v\d+\.\d+\.\d+", version)
82+ if not pseudo_match and not tag_match:
83+ fail()
84+ info_path = downloaded.get("Info")
85+ if not isinstance(info_path, str) or not info_path:
86+ fail()
87+ with open(info_path, encoding="utf-8") as info_file:
88+ info = json.load(info_file)
89+ origin = info.get("Origin") or downloaded.get("Origin")
90+ if info.get("Version") != version or not isinstance(origin, dict):
91+ fail()
92+ revision = origin.get("Hash")
93+ if origin.get("VCS") != "git" or origin.get("URL") != "https://github.com/bnema/purego-cef2gtk":
94+ fail()
95+ if not isinstance(revision, str) or not re.fullmatch(r"[0-9a-f]{40}", revision):
96+ fail()
97+ if pseudo_match:
98+ if not revision.startswith(pseudo_match.group(2)):
99+ fail()
100+ # A named ref can move. Only the immutable hash itself is acceptable.
101+ if origin.get("Ref") not in (None, "", revision):
102+ fail()
103+ tag = pseudo_match.group(1)
104+ else:
105+ if origin.get("Ref") != "refs/tags/" + version:
106+ fail()
107+ tag = version
108+except (AttributeError, KeyError, OSError, TypeError, ValueError, json.JSONDecodeError):
109+ fail()
110+
111+print("\t".join((version, tag, revision)))
112+PY
113+}
114+
115+# The artifact destination is caller-controlled. Never empty or recursively
116+# clear it: collection only writes to a newly-created directory whose parent is
117+# already canonical and contains no symbolic-link hop.
118+prepare_output() {
119+ local parent name canonical_parent canonical_output
120+
121+ [[ -n "$output" ]] || fail_unsafe_output "path must not be empty"
122+ [[ "$output" == /* ]] || fail_unsafe_output "path must be absolute"
123+ [[ "$output" != "/" ]] || fail_unsafe_output "path must not be /"
124+ [[ "${output%/}" != "${HOME%/}" ]] || fail_unsafe_output "path must not be HOME"
125+ [[ "$output" != .. && "$output" != ../* && "$output" != */.. && "$output" != */../* ]] || \
126+ fail_unsafe_output "path must not contain parent traversal"
127+ [[ ! -e "$output" && ! -L "$output" ]] || fail_unsafe_output "path must be a fresh directory"
128+
129+ parent="$(dirname -- "$output")"
130+ name="$(basename -- "$output")"
131+ [[ -n "$name" && "$name" != "." && "$name" != ".." ]] || fail_unsafe_output "invalid directory name"
132+ [[ -d "$parent" && ! -L "$parent" ]] || fail_unsafe_output "parent must be an existing directory"
133+ canonical_parent="$(realpath -e -- "$parent")" || fail_unsafe_output "parent cannot be canonicalized"
134+ [[ "$parent" == "$canonical_parent" ]] || fail_unsafe_output "parent must not contain symbolic links"
135+
136+ mkdir -- "$output" || fail_unsafe_output "could not create fresh directory"
137+ canonical_output="$(realpath -e -- "$output")" || fail_unsafe_output "created directory cannot be canonicalized"
138+ [[ -d "$output" && ! -L "$output" && "$canonical_output" == "$canonical_parent/$name" ]] || \
139+ fail_unsafe_output "created directory changed unexpectedly"
140+}
141+
142+prepare_output
143+
144+[[ -x "$binary" ]] || { echo "first-presentation: executable not found: $binary" >&2; exit 2; }
145+[[ -d "$runtime" ]] || { echo "first-presentation: CEF runtime not found: $runtime" >&2; exit 2; }
146+[[ -n "${WAYLAND_DISPLAY:-}${DISPLAY:-}" ]] || { echo "first-presentation: a current Wayland/X11 display is required" >&2; exit 2; }
147+upstream_provenance="$(resolve_upstream_provenance)" || exit $?
148+IFS=$'\t' read -r upstream_version upstream_tag upstream_revision <<<"$upstream_provenance"
149+readonly upstream_version upstream_tag upstream_revision
150+
151+# Raw logs and temporary XDG homes may contain machine-local paths. Keep them
152+# outside the committed artifact directory and always remove them.
153+work_root="$(mktemp -d "${TMPDIR:-/tmp}/dumber-first-presentation.XXXXXX")"
154+readonly work_root
155+cleanup_work_root() {
156+ [[ -n "${work_root:-}" && -d "$work_root" && ! -L "$work_root" ]] || return
157+ rm -rf -- "$work_root"
158+}
159+trap cleanup_work_root EXIT
160+python3 - "$output/metadata.json" "$binary" "$timeout_seconds" "$upstream_module" "$upstream_version" "$upstream_tag" "$upstream_revision" <<'PY'
161+import hashlib, json, os, platform, subprocess, sys
162+path, binary, timeout, upstream_module, upstream_version, upstream_tag, upstream_revision = sys.argv[1:]
163+with open(binary, "rb") as candidate:
164+ binary_sha256 = hashlib.file_digest(candidate, "sha256").hexdigest()
165+
166+# These deliberately coarse labels support comparison without exposing a host,
167+# device name, driver version, path, or environment dump.
168+architecture = {"x86_64": "amd64", "amd64": "amd64", "aarch64": "arm64", "arm64": "arm64"}.get(platform.machine().lower(), "other")
169+os_label = {"linux": "linux", "darwin": "darwin", "windows": "windows"}.get(platform.system().lower(), "other")
170+display_protocol = "wayland" if os.environ.get("WAYLAND_DISPLAY") else "x11"
171+gpu_profile = os.environ.get("DUMBER_MACHINE_GPU_PROFILE", "generic-gpu")
172+allowed_gpu_profiles = {"generic-gpu", "integrated-gpu", "discrete-gpu", "hybrid-gpu", "virtual-gpu", "unknown-gpu"}
173+if gpu_profile not in allowed_gpu_profiles:
174+ raise SystemExit("DUMBER_MACHINE_GPU_PROFILE must be a non-identifying profile label")
175+
176+json.dump({
177+ "runs": 5,
178+ "runtime": {"label": "cef", "version": "147"},
179+ "binary": {"label": "dumber", "sha256": binary_sha256},
180+ "timeout_seconds": int(timeout),
181+ "measured_source_revision": subprocess.check_output(["git", "-c", f"safe.directory={os.getcwd()}", "rev-parse", "HEAD"], text=True).strip(),
182+ "upstream": {"module": upstream_module, "version": upstream_version, "tag": upstream_tag, "revision": upstream_revision},
183+ "comparison": {"os": os_label, "architecture": architecture, "display_protocol": display_protocol, "machine_gpu_profile": gpu_profile},
184+ "render_configuration": {"backend": "gdk-dmabuf", "buffer_sharing": "dmabuf", "renderer": "vulkan"}
185+}, open(path, "w"), indent=2, sort_keys=True)
186+PY
187+
188+for number in $(seq 1 "$runs"); do
189+ run="$(printf 'run-%02d' "$number")"
190+ root="$work_root/$run"
191+ mkdir -p "$root"/{config,data,state,cache}
192+ mkdir -p "$root/config/dumber"
193+ cat >"$root/config/dumber/config.toml" <<EOF
194+[logging]
195+level = "debug"
196+format = "json"
197+enable_file_log = false
198+
199+[engine.cef]
200+cef_dir = "$runtime"
201+EOF
202+ # Every directory below is newly created for this one launch. Do not inherit
203+ # a profile, shader cache, CEF root cache, or mutable app configuration.
204+ set +e
205+ env -i \
206+ HOME="$HOME" PATH="$PATH" LANG="${LANG:-C.UTF-8}" \
207+ WAYLAND_DISPLAY="${WAYLAND_DISPLAY:-}" DISPLAY="${DISPLAY:-}" XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-}" \
208+ DBUS_SESSION_BUS_ADDRESS="${DBUS_SESSION_BUS_ADDRESS:-}" XAUTHORITY="${XAUTHORITY:-}" \
209+ XDG_CONFIG_HOME="$root/config" XDG_DATA_HOME="$root/data" XDG_STATE_HOME="$root/state" XDG_CACHE_HOME="$root/cache" \
210+ DUMBER_CEF_ROOT_CACHE_PATH="$root/cef-root-cache" DUMBER_RENDER_STACK="vulkan-dmabuf" \
211+ PUREGO_CEF2GTK_BACKEND="gdk-dmabuf" PUREGO_CEF2GTK_ANGLE_BACKEND="vulkan" GSK_RENDERER="vulkan" \
212+ timeout --signal=TERM --kill-after=5s "${timeout_seconds}s" "$binary" browse about:blank >"$root/process.log" 2>&1
213+ set -e
214+ # A completed, valid first-presentation summary is the observation success
215+ # signal. The GUI may later be stopped by timeout or terminate independently.
216+ python3 - "$root/process.log" "$output/$run.json" "$run" <<'PY'
217+import json, sys
218+log, destination, run = sys.argv[1:]
219+order = ["process_entry", "config_complete", "cef_library_load_begin", "cef_initialized", "browser_create_requested", "first_accelerated_paint_received", "first_dmabuf_texture_swap", "first_gtk_presentation"]
220+records, summary = [], None
221+for line in open(log, errors="replace"):
222+ try: event = json.loads(line)
223+ except json.JSONDecodeError: continue
224+ if event.get("message") == "startup_trace: milestone":
225+ records.append({key: event.get(key) for key in ("milestone", "t_ms", "delta_ms")})
226+ if event.get("message") == "startup_trace: first presentation":
227+ summary = {key: event.get(key) for key in ("backend", "incomplete_reason", "total_ms")}
228+names = [event.get("milestone") for event in records]
229+times = [event.get("t_ms") for event in records]
230+deltas = [event.get("delta_ms") for event in records]
231+valid = names == order and all(type(t) is int for t in times) and times == sorted(times)
232+valid = valid and all(type(delta) is int for delta in deltas)
233+valid = valid and all(delta == current - previous for previous, current, delta in zip([0] + times, times, deltas))
234+valid = valid and summary is not None and summary.get("backend") == "gdk-dmabuf" and not summary.get("incomplete_reason")
235+valid = valid and type(summary.get("total_ms")) is int and summary["total_ms"] == times[-1]
236+result = {"run": run, "valid": valid, "milestones": records, "summary": summary}
237+json.dump(result, open(destination, "w"), indent=2)
238+if not valid:
239+ raise SystemExit(f"{run}: invalid or incomplete non-DMABUF timeline")
240+PY
241+done
242+
243+python3 - "$output" <<'PY'
244+import json, math, statistics, sys
245+root = sys.argv[1]
246+runs = [json.load(open(f"{root}/run-{n:02d}.json")) for n in range(1, 6)]
247+if len(runs) != 5 or not all(run["valid"] for run in runs): raise SystemExit("expected exactly five valid runs")
248+totals = sorted(run["summary"]["total_ms"] for run in runs)
249+p95 = totals[max(0, math.ceil(.95 * len(totals)) - 1)]
250+json.dump({"runs": 5, "total_ms": {"min": totals[0], "median": statistics.median(totals), "max": totals[-1], "p95": p95}}, open(f"{root}/baseline.json", "w"), indent=2)
251+PY
252+
253+echo "first-presentation artifacts: $output"

Keyboard shortcuts

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

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

Branches 8

Tags 42

↑↓ moveenter openesc close