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
1616 # Output of the go coverage tool
1717 *.out
1818
19-# Dependency directories (remove the comment below to include it)
20-# vendor/
19+# Dependency directories
20+vendor/
2121
2222 # Go workspace file
2323 go.work
@@ -94,7 +94,6 @@ templates/
9494 .serena/
9595 config.yaml
9696 .dev/
97-vendor/
9897
9998 # WebKit runtime data
10099 serviceworkers/
@@ -119,6 +118,7 @@ aur/*/LICENSE
119118 aur/*/dev.bnema.Dumber.desktop
120119 aur/*/logo-512.png
121120
122-# Planning/runbook notes stored outside product docs
121+# Planning/runbook notes and generated roadmap evidence stored outside the repository
123122 docs/reference/stability-performance-multi-agent-runbook-*.md
123+/phase1/
124124 perf.data
M Makefile +2 -2
@@ -239,13 +239,13 @@ flatpak-deps: ## Install Flatpak build dependencies (Arch)
239239
240240 flatpak-build: ## Build Flatpak bundle locally
241241 @echo "Building Flatpak bundle..."
242- flatpak-builder --force-clean --user --repo=flatpak-repo flatpak-build dev.bnema.Dumber.yml
242+ flatpak-builder --force-clean --user --repo=flatpak-repo flatpak-build flatpak/dev.bnema.Dumber.yml
243243 flatpak build-bundle flatpak-repo dumber.flatpak dev.bnema.Dumber
244244 @echo "Flatpak bundle created: dumber.flatpak"
245245
246246 flatpak-install: ## Install Flatpak locally for testing
247247 @echo "Installing Flatpak locally..."
248- flatpak-builder --force-clean --user --install flatpak-build dev.bnema.Dumber.yml
248+ flatpak-builder --force-clean --user --install flatpak-build flatpak/dev.bnema.Dumber.yml
249249 @echo "Flatpak installed! Run with: make flatpak-run"
250250
251251 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
134134 On Arch Linux, install the CEF runtime with:
135135
136136 ```bash
137-sudo pacman -S cef
137+sudo pacman -S cef libwebp
138138 ```
139139
140140 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 `
179179 **Arch Linux:**
180180
181181 ```bash
182-sudo pacman -S cef webkitgtk-6.0 gtk4 gst-plugins-base gst-plugins-good gst-plugins-bad gst-plugin-va
182+sudo pacman -S cef webkitgtk-6.0 gtk4 libwebp gst-plugins-base gst-plugins-good gst-plugins-bad gst-plugin-va
183183 ```
184184
185185 **Debian/Ubuntu:**
@@ -213,6 +213,7 @@ Set `ENV=dev` to use `.dev/dumber/` for config and data instead of XDG paths.
213213 - CEF runtime (default backend)
214214 - WebKitGTK 6.0 development/runtime packages (fallback backend and runtime checks)
215215 - Brotli for compressed systemviews assets
216+- libwebp runtime for WebP favicon decoding (for example `libwebp` on Arch)
216217
217218 Systemviews assets are generated with `go tool templ` and Go's `js/wasm` toolchain; no root Node toolchain is required.
218219
M aur/dumber-browser-bin/.SRCINFO +2 -1
@@ -1,13 +1,14 @@
11 pkgbase = dumber-browser-bin
22 pkgdesc = A minimal keyboard-driven browser for tiling WMs
33 pkgver = 0.23.2
4- pkgrel = 2
4+ pkgrel = 3
55 url = https://github.com/bnema/dumber
66 arch = x86_64
77 license = MIT
88 depends = gtk4
99 depends = cef
1010 depends = webkitgtk-6.0
11+ depends = libwebp
1112 optdepends = gst-plugins-base: Base media codecs
1213 optdepends = gst-plugins-good: Additional media codecs
1314 optdepends = gst-plugins-bad: Extended media codecs
M aur/dumber-browser-bin/PKGBUILD +2 -2
@@ -1,12 +1,12 @@
11 # Maintainer: bnema <b at bnema dot dev>
22 pkgname=dumber-browser-bin
33 pkgver=0.23.2
4-pkgrel=2
4+pkgrel=3
55 pkgdesc="A minimal keyboard-driven browser for tiling WMs"
66 arch=('x86_64')
77 url="https://github.com/bnema/dumber"
88 license=('MIT')
9-depends=('gtk4' 'cef' 'webkitgtk-6.0')
9+depends=('gtk4' 'cef' 'webkitgtk-6.0' 'libwebp')
1010 optdepends=(
1111 'gst-plugins-base: Base media codecs'
1212 'gst-plugins-good: Additional media codecs'
M aur/dumber-browser-git/.SRCINFO +1 -0
@@ -11,6 +11,7 @@ pkgbase = dumber-browser-git
1111 depends = gtk4
1212 depends = cef
1313 depends = webkitgtk-6.0
14+ depends = libwebp
1415 optdepends = gst-plugins-base: Base media codecs
1516 optdepends = gst-plugins-good: Additional media codecs
1617 optdepends = gst-plugins-bad: Extended media codecs
M aur/dumber-browser-git/PKGBUILD +1 -1
@@ -7,7 +7,7 @@ arch=('x86_64')
77 url="https://github.com/bnema/dumber"
88 license=('MIT')
99 makedepends=('go>=1.26' 'git' 'brotli')
10-depends=('gtk4' 'cef' 'webkitgtk-6.0')
10+depends=('gtk4' 'cef' 'webkitgtk-6.0' 'libwebp')
1111 optdepends=(
1212 'gst-plugins-base: Base media codecs'
1313 'gst-plugins-good: Additional media codecs'
M cmd/dumber/main.go +28 -18
@@ -10,6 +10,7 @@ import (
1010 "runtime"
1111 "strings"
1212 "syscall"
13+ "time"
1314
1415 "github.com/bnema/dumber/internal/application/port"
1516 "github.com/bnema/dumber/internal/application/usecase"
@@ -140,7 +141,17 @@ func configureBrowserLaunchRelay(cfg *config.Config) {
140141 browserLaunchRelay = relay
141142 }
142143
144+type startupTiming struct {
145+ processEntry time.Time
146+ configComplete time.Time
147+}
148+
143149 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+
144155 // CEF subprocess handling: when CEF re-launches this binary with
145156 // --type=renderer/gpu/etc, we must call ExecuteProcess before anything
146157 // else (Cobra, config, arg stripping). We only treat a leading Chromium
@@ -170,6 +181,7 @@ func main() {
170181 // Run GUI mode for browse command
171182 if mode == launchModeBrowse {
172183 cfg := initConfig()
184+ timing.configComplete = time.Now()
173185 configureBrowserLaunchRelay(cfg)
174186 startupURL := domainurl.ResolveBrowserStartupURL(browseURL)
175187 if forwarded, err := tryForwardBrowseURLToRunningInstance(context.Background(), browserLaunchRelay, startupURL); err != nil {
@@ -186,7 +198,7 @@ func main() {
186198 initialURL = startupURL
187199 restoreSessionID = os.Getenv("DUMBER_RESTORE_SESSION")
188200 os.Args = os.Args[:1]
189- os.Exit(runGUI(cfg))
201+ os.Exit(runGUI(cfg, timing))
190202 return
191203 }
192204
@@ -207,7 +219,7 @@ func main() {
207219 cmd.Execute()
208220 }
209221
210-func runGUI(cfg *config.Config) int {
222+func runGUI(cfg *config.Config, timing startupTiming) int {
211223 bootstrap.ApplyGTKIMModuleFallbackDefault(os.Stderr)
212224
213225 runtime.LockOSThread()
@@ -216,11 +228,13 @@ func runGUI(cfg *config.Config) int {
216228
217229 if cfg == nil {
218230 cfg = initConfig()
231+ timing.configComplete = time.Now()
219232 configureBrowserLaunchRelay(cfg)
220233 }
221234 timer.Mark("config")
222235
223- ctx := initStartupContextWithTrace(cfg)
236+ ctx := initStartupContext(cfg)
237+ activateCEFStartupTraceForGUI(cfg, timing, logging.FromContext(ctx), infracef.ActivateStartupTrace)
224238 applyCEFRenderStackDefault(ctx, cfg)
225239 timer.Mark("logger")
226240 bootstrapLog := logging.FromContext(ctx)
@@ -253,7 +267,6 @@ func runGUI(cfg *config.Config) int {
253267 timer.Mark("session")
254268
255269 log := logging.FromContext(ctx)
256- logging.Trace().UpdateLogger(log)
257270 logCoreDumpLimits(ctx)
258271
259272 engine.SetHandlerContext(ctx)
@@ -297,7 +310,7 @@ func runStandaloneOmnibox() int {
297310
298311 cfg := initConfig()
299312 configureBrowserLaunchRelay(cfg)
300- ctx := initStartupContextWithTrace(cfg)
313+ ctx := initStartupContext(cfg)
301314 applyCEFRenderStackDefault(ctx, cfg)
302315
303316 initResult, err := runParallelInitPhase(ctx, cfg)
@@ -412,21 +425,19 @@ func resolveCurrentExecutable(executable func() (string, error)) (string, error)
412425 return executable()
413426 }
414427
415-func initStartupContextWithTrace(cfg *config.Config) context.Context {
416- logging.InitStartupTrace(cfg.Logging.Level)
417- logging.Trace().Mark("config_loaded")
418-
419- ctx := initStartupContext(cfg)
420- bootstrapLog := logging.FromContext(ctx)
421-
422- logging.Trace().SetLogger(bootstrapLog)
423- logging.Trace().Mark("logger_init")
424-
425- return ctx
428+func activateCEFStartupTraceForGUI(
429+ cfg *config.Config,
430+ timing startupTiming,
431+ logger *zerolog.Logger,
432+ activate func(time.Time, time.Time, *zerolog.Logger),
433+) {
434+ if cfg == nil || cfg.Engine.ResolveEngineType() != config.EngineTypeCEF {
435+ return
436+ }
437+ activate(timing.processEntry, timing.configComplete, logger)
426438 }
427439
428440 func runParallelInitPhase(ctx context.Context, cfg *config.Config) (*bootstrap.ParallelInitResult, error) {
429- logging.Trace().Mark("parallel_start")
430441 initResult, err := bootstrap.RunParallelInit(bootstrap.ParallelInitInput{
431442 Ctx: ctx,
432443 Config: cfg,
@@ -435,7 +446,6 @@ func runParallelInitPhase(ctx context.Context, cfg *config.Config) (*bootstrap.P
435446 handleParallelInitError(ctx, err)
436447 return nil, err
437448 }
438- logging.Trace().Mark("parallel_done")
439449 return initResult, nil
440450 }
441451
M cmd/dumber/main_test.go +16 -0
@@ -4,12 +4,14 @@ import (
44 "context"
55 "errors"
66 "testing"
7+ "time"
78
89 "github.com/bnema/dumber/internal/application/port/mocks"
910 "github.com/bnema/dumber/internal/bootstrap"
1011 "github.com/bnema/dumber/internal/infrastructure/colorscheme"
1112 "github.com/bnema/dumber/internal/infrastructure/config"
1213 "github.com/bnema/dumber/internal/infrastructure/desktop"
14+ "github.com/rs/zerolog"
1315 )
1416
1517 func TestLaunchModeFromArgs_DetectsStandaloneOmnibox(t *testing.T) {
@@ -172,6 +174,20 @@ func TestResolveCurrentExecutable_PropagatesError(t *testing.T) {
172174 }
173175 }
174176
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+
175191 func TestPreInitializeAdwaitaForCEF_InitializesAndMarksDetector(t *testing.T) {
176192 initResult := &bootstrap.ParallelInitResult{
177193 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 @@
11 id: dev.bnema.Dumber
22 runtime: org.gnome.Platform
33 runtime-version: '49'
4+# org.gnome.Platform provides libwebp at runtime for purego WebP favicon decoding.
45 sdk: org.gnome.Sdk
56 command: dumber
67
@@ -53,10 +54,10 @@ modules:
5354 path: dev.bnema.Dumber.desktop
5455 # Icons
5556 - type: file
56- path: assets/logo.svg
57+ path: ../assets/logo.svg
5758 dest-filename: logo.svg
5859 - type: file
59- path: assets/logo-512.png
60+ path: ../assets/logo-512.png
6061 dest-filename: logo-512.png
6162 # Metainfo
6263 - type: file
M go.mod +2 -1
@@ -6,10 +6,11 @@ require (
66 github.com/a-h/templ v0.3.1020
77 github.com/andybalholm/brotli v1.2.2
88 github.com/bnema/purego v0.11.0-bnema.4
9- github.com/bnema/purego-cef v0.13.4
9+ github.com/bnema/purego-cef v0.14.1
1010 github.com/bnema/purego-cef2gtk v0.8.5
1111 github.com/bnema/purego-pipewire v0.1.5
1212 github.com/bnema/purego-sqlite v0.1.4
13+ github.com/bnema/purego-webp v0.2.1
1314 github.com/bnema/puregotk v0.7.1
1415 github.com/charmbracelet/bubbles v1.0.0
1516 github.com/charmbracelet/bubbletea v1.3.10
M go.sum +4 -2
@@ -12,14 +12,16 @@ github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ
1212 github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
1313 github.com/bnema/purego v0.11.0-bnema.4 h1:uQjx9QtM945rD6HkwYzPGuVN7wKr5HrFKIWE3kRprbs=
1414 github.com/bnema/purego v0.11.0-bnema.4/go.mod h1:LHW8Sb4QO18dQaLVRU8tZCGM0SjsC+agFW/3M3nCUtg=
15-github.com/bnema/purego-cef v0.13.4 h1:Q5A88Z0qVTarFbHNv8Tl+kj/UhmyGFiL52z/mo9o5EE=
16-github.com/bnema/purego-cef v0.13.4/go.mod h1:r+qCMwzuI+wq3xo189x3NAWWpALWoWwjtk+8wVCK1Eg=
15+github.com/bnema/purego-cef v0.14.1 h1:ZAlrGfFyk3Eel0Gct+ipzJnnNfpXfDX8NuoZfRKAKco=
16+github.com/bnema/purego-cef v0.14.1/go.mod h1:r+qCMwzuI+wq3xo189x3NAWWpALWoWwjtk+8wVCK1Eg=
1717 github.com/bnema/purego-cef2gtk v0.8.5 h1:Qwt9DkvCVBNN52dp1jikFU5B2Sg3dB16ayi/T4+2Bhg=
1818 github.com/bnema/purego-cef2gtk v0.8.5/go.mod h1:w/5Bjkm+ZaZbDIDS+ysC5qTBcpb8/WOeGDva8i39ocM=
1919 github.com/bnema/purego-pipewire v0.1.5 h1:wQQF2uHt/kVV3qOkdSE1CV0noQYkHS2+v9MXkgdjxEw=
2020 github.com/bnema/purego-pipewire v0.1.5/go.mod h1:dn9RGRtNteqIarb8gTv97VD/FQgur7ZJ1BE/Ub9NYSk=
2121 github.com/bnema/purego-sqlite v0.1.4 h1:h2DCsYT1/Ps13Ua4knUNQmT5Xgj6eRx+DrywNcnFNEU=
2222 github.com/bnema/purego-sqlite v0.1.4/go.mod h1:a15W2PgD/3b9W9HytbmPXz3pBAbjwf7dhNv997fSbhI=
23+github.com/bnema/purego-webp v0.2.1 h1:/4TU7++xyc6WnHzqRPg5bYYiIoKnxYaf/7haau0q5Kc=
24+github.com/bnema/purego-webp v0.2.1/go.mod h1:TYOQvozzcPup3zJQ58YmiCB4MkroeiyvvTP/MGWm+zI=
2325 github.com/bnema/puregotk v0.7.1 h1:a/gv0auomO2EnqZgWEP508C1Pi4p9bVfDh+xycYJIUc=
2426 github.com/bnema/puregotk v0.7.1/go.mod h1:neNov5avH++/0PJzodv9Rg2zH87GfZX7KfRcpdUA1U8=
2527 github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
M internal/application/port/audio_output.go +4 -3
@@ -18,9 +18,10 @@ type AudioStreamFormat struct {
1818 // AudioOutputStream represents an active audio output stream.
1919 // Implementations handle the low-level audio playback.
2020 type AudioOutputStream interface {
21- // Write sends audio samples to the output device.
22- // Samples are provided as a 2D slice: [channel][frame]float32.
23- // This matches CEF's planar float32 audio format.
21+ // Write sends callback-owned audio samples to the output device.
22+ // Samples are provided as [channel][frame]float32 matching CEF's planar
23+ // format. Implementations must not retain the slices and must be safe to
24+ // call concurrently with Close; audio callbacks must not block.
2425 Write(samples [][]float32) error
2526 // Close releases the audio stream and associated resources.
2627 Close() error
M internal/application/usecase/favicon_test.go +19 -0
@@ -404,6 +404,25 @@ func TestFaviconObserveConversionErrorDoesNotMutateExistingAssets(t *testing.T)
404404 }
405405 }
406406
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+
407426 func TestFaviconEnsureSizedPropagatesReadSizedErrors(t *testing.T) {
408427 fx := newFaviconFixture(t)
409428 fx.seedOriginal([]byte("old"))
M internal/infrastructure/audio/pipewire/output.go +123 -123
@@ -15,7 +15,6 @@ import (
1515 "github.com/bnema/dumber/internal/logging"
1616 )
1717
18-// Compile-time interface checks.
1918 var (
2019 _ port.AudioOutputFactory = (*Factory)(nil)
2120 _ port.AudioOutputStream = (*playerStreamAdapter)(nil)
@@ -27,6 +26,8 @@ var ErrInvalidFormat = errors.New("invalid audio stream format")
2726 // ErrStreamClosed is returned when writing to a closed stream.
2827 var ErrStreamClosed = errors.New("audio stream closed")
2928
29+const packetQueueCapacity = 4
30+
3031 // playerCreator is a function type that creates a Player from config and callbacks.
3132 // This exists to allow dependency injection in tests.
3233 type playerCreator func(config pwpipewire.PlayerConfig, callbacks pwpipewire.PlayerCallbacks) (pwpipewire.Player, error)
@@ -38,24 +39,15 @@ type Factory struct {
3839 }
3940
4041 // NewFactory creates a new PipeWire audio factory.
41-// The factory is stateless; players are created per-stream.
4242 func NewFactory() (*Factory, error) {
43- return &Factory{
44- createPlayer: pwpipewire.NewPlayer,
45- }, nil
43+ return &Factory{createPlayer: pwpipewire.NewPlayer}, nil
4644 }
4745
48-// NewStream creates a new audio output stream with the given format.
49-// It validates the Dumber format, creates a purego-pipewire Player configured
50-// from the format, and returns a push-to-pull adapter that bridges Write()
51-// calls into the Player's Fill callback.
46+// NewStream creates a push-to-pull adapter for a PipeWire player.
5247 func (f *Factory) NewStream(ctx context.Context, format port.AudioStreamFormat) (port.AudioOutputStream, error) {
53- // Check context cancellation
5448 if ctx.Err() != nil {
5549 return nil, ctx.Err()
5650 }
57-
58- // Validate format
5951 if err := validateFormat(format); err != nil {
6052 return nil, err
6153 }
@@ -67,182 +59,195 @@ func (f *Factory) NewStream(ctx context.Context, format port.AudioStreamFormat)
6759 Int("frames_per_buffer", format.FramesPerBuffer).
6860 Msg("pipewire: creating player")
6961
70- // Create the adapter with its ring buffer before creating the player,
71- // because the Fill callback captures the adapter.
72- adapter := &playerStreamAdapter{
73- ctx: ctx,
74- buf: make(chan [][]float32, 4), // small buffer for push→pull bridging
75- closed: make(chan struct{}),
76- }
77-
62+ // Every slot owns storage sized from the negotiated CEF format. Write copies
63+ // directly into an available slot; Fill copies that same slot into PipeWire.
64+ // Callback slices are therefore never queued or retained.
65+ adapter := newPlayerStreamAdapter(ctx, format.ChannelCount, format.FramesPerBuffer)
7866 config := pwpipewire.PlayerConfig{
79- SampleRate: format.SampleRate,
80- Channels: format.ChannelCount,
81- FramesPerBuffer: format.FramesPerBuffer,
82- SampleFormat: pwpipewire.SampleFormatF32,
83- UnderrunPolicy: pwpipewire.UnderrunFillSilence,
67+ SampleRate: format.SampleRate, Channels: format.ChannelCount, FramesPerBuffer: format.FramesPerBuffer,
68+ SampleFormat: pwpipewire.SampleFormatF32, UnderrunPolicy: pwpipewire.UnderrunFillSilence,
8469 }
85-
86- callbacks := pwpipewire.PlayerCallbacks{
87- Fill: adapter.fill,
88- }
89-
90- player, err := f.createPlayer(config, callbacks)
70+ player, err := f.createPlayer(config, pwpipewire.PlayerCallbacks{Fill: adapter.fill})
9171 if err != nil {
9272 log.Warn().Err(err).Msg("pipewire: failed to create player")
9373 return nil, fmt.Errorf("failed to create pipewire player: %w", err)
9474 }
9575 adapter.player = player
96-
97- // Start the player so the Fill callback begins being called.
9876 if err := player.Start(); err != nil {
9977 _ = player.Close()
10078 log.Warn().Err(err).Msg("pipewire: failed to start player")
10179 return nil, fmt.Errorf("failed to start pipewire player: %w", err)
10280 }
103-
10481 log.Info().Msg("pipewire: player started successfully")
105-
10682 return adapter, nil
10783 }
10884
109-// validateFormat checks if the audio format parameters are valid.
11085 func validateFormat(format port.AudioStreamFormat) error {
111- // Sample rate must be reasonable (8kHz to 384kHz)
11286 if format.SampleRate < 8000 || format.SampleRate > 384000 {
113- return fmt.Errorf("%w: sample rate %d Hz out of range [8000, 384000]",
114- ErrInvalidFormat, format.SampleRate)
87+ return fmt.Errorf("%w: sample rate %d Hz out of range [8000, 384kHz]", ErrInvalidFormat, format.SampleRate)
11588 }
116-
117- // Channel count must be 1 to 1024 (reasonable upper limit)
11889 if format.ChannelCount < 1 || format.ChannelCount > 1024 {
119- return fmt.Errorf("%w: channel count %d out of range [1, 1024]",
120- ErrInvalidFormat, format.ChannelCount)
90+ return fmt.Errorf("%w: channel count %d out of range [1, 1024]", ErrInvalidFormat, format.ChannelCount)
12191 }
122-
123- // Frames per buffer must be positive and reasonable
12492 if format.FramesPerBuffer < 1 || format.FramesPerBuffer > 8192 {
125- return fmt.Errorf("%w: frames per buffer %d out of range [1, 8192]",
126- ErrInvalidFormat, format.FramesPerBuffer)
93+ return fmt.Errorf("%w: frames per buffer %d out of range [1, 8192]", ErrInvalidFormat, format.FramesPerBuffer)
12794 }
128-
12995 return nil
13096 }
13197
132-// playerStreamAdapter bridges Dumber's push-style Write(samples) interface
133-// to purego-pipewire's pull-style Fill callback.
134-//
135-// Write() sends sample buffers into a channel. The Fill callback drains them
136-// into the PCMBuffer that purego-pipewire passes in. If no data is available,
137-// the player's UnderrunFillSilence policy handles it.
98+// audioPacket is a reusable, adapter-owned packet slot. Its backing arrays are
99+// allocated only when the stream is created and never alias CEF callback data.
100+type audioPacket struct {
101+ samples [][]float32
102+ channels int
103+ frames int
104+}
105+
106+// playerStreamAdapter bridges callback-owned CEF audio to PipeWire's Fill
107+// callback. available and ready form a bounded reusable packet ring.
138108 type playerStreamAdapter struct {
139109 player pwpipewire.Player
140110 ctx context.Context
141- buf chan [][]float32 // push→pull handoff channel
111+
112+ available chan *audioPacket
113+ ready chan *audioPacket
114+ // TryLock serializes only simultaneous producers. The CEF callback never
115+ // waits: contention and saturation both drop the packet. Fill never takes it.
116+ writeMu sync.Mutex
117+ closed atomic.Bool
142118
143119 closeOnce sync.Once
144- closed chan struct{} // closed when Close is called
145120
146- // Diagnostic counters (atomic, no mutex needed).
147- fillCount atomic.Uint64 // total Fill callbacks
148- underrunCount atomic.Uint64 // Fill callbacks with no data available
149- dropCount atomic.Uint64 // Write calls that dropped data (buffer full)
121+ fillCount atomic.Uint64
122+ underrunCount atomic.Uint64
123+ dropCount atomic.Uint64
124+}
125+
126+func newPlayerStreamAdapter(ctx context.Context, channels, frames int) *playerStreamAdapter {
127+ a := &playerStreamAdapter{
128+ ctx: ctx, available: make(chan *audioPacket, packetQueueCapacity), ready: make(chan *audioPacket, packetQueueCapacity),
129+ }
130+ for range packetQueueCapacity {
131+ packet := &audioPacket{samples: make([][]float32, channels)}
132+ for ch := range packet.samples {
133+ packet.samples[ch] = make([]float32, frames)
134+ }
135+ a.available <- packet
136+ }
137+ return a
150138 }
151139
152-// Write sends audio samples to the PipeWire output.
153-// Samples are provided as [channel][frame]float32 matching CEF's planar format.
154-// If the internal buffer is full the packet is dropped silently so the caller
155-// (CEF audio callback thread) is never blocked.
156-// Safe for concurrent use with Close.
140+// Write makes exactly one owned copy for each accepted packet. It never blocks:
141+// full queues or another producer cause a silent drop, preserving CEF timing.
142+// The supplied slices are callback-owned and are not retained after return.
157143 func (a *playerStreamAdapter) Write(samples [][]float32) error {
158- // Fast path: already closed.
159- select {
160- case <-a.closed:
144+ if a.closed.Load() {
161145 return ErrStreamClosed
162- default:
163146 }
164- // Non-blocking send: drop packet when buffer is full.
165- select {
166- case a.buf <- samples:
147+ if !a.writeMu.TryLock() {
148+ a.recordDrop()
167149 return nil
168- case <-a.closed:
150+ }
151+ defer a.writeMu.Unlock()
152+ if a.closed.Load() {
169153 return ErrStreamClosed
170- default:
171- // Buffer full — drop to avoid blocking the CEF audio thread.
172- n := a.dropCount.Add(1)
173- if n == 1 || n&(n-1) == 0 { // first drop, then powers of two
174- if a.ctx != nil {
175- logging.FromContext(a.ctx).Warn().
176- Uint64("total_drops", n).
177- Msg("pipewire: dropped audio packet (buffer full)")
178- }
154+ }
155+
156+ select {
157+ case packet := <-a.available:
158+ copyPacket(packet, samples)
159+ // A close racing after the first check must not publish a packet to a
160+ // stopped player. Returning the slot keeps the ring bounded.
161+ if a.closed.Load() {
162+ a.available <- packet
163+ return ErrStreamClosed
164+ }
165+ select {
166+ case a.ready <- packet:
167+ return nil
168+ default:
169+ // This cannot occur while ring invariants hold, but keep Write
170+ // non-blocking if an implementation change violates them.
171+ a.available <- packet
172+ a.recordDrop()
173+ return nil
179174 }
175+ default:
176+ a.recordDrop()
180177 return nil
181178 }
182179 }
183180
184-// fill is the PlayerCallbacks.Fill function called by purego-pipewire
185-// when it needs audio data. It pulls from the internal buffer channel.
181+func copyPacket(packet *audioPacket, samples [][]float32) {
182+ packet.channels = min(len(packet.samples), len(samples))
183+ packet.frames = 0
184+ if packet.channels == 0 {
185+ return
186+ }
187+ frames := len(packet.samples[0])
188+ for ch := 0; ch < packet.channels; ch++ {
189+ n := min(len(packet.samples[ch]), len(samples[ch]))
190+ if n < frames {
191+ frames = n
192+ }
193+ }
194+ packet.frames = frames
195+ for ch := 0; ch < packet.channels; ch++ {
196+ copy(packet.samples[ch][:frames], samples[ch][:frames])
197+ }
198+}
199+
200+func (a *playerStreamAdapter) recordDrop() {
201+ // Write runs on CEF's real-time callback thread. Diagnostics must stay
202+ // allocation-free and must not synchronously emit logs from that path.
203+ a.dropCount.Add(1)
204+}
205+
206+// fill is the PlayerCallbacks.Fill function. It performs the only post-handoff
207+// full-packet copy, directly into PipeWire's destination buffer.
186208 func (a *playerStreamAdapter) fill(pcm *pwpipewire.PCMBuffer) (int, error) {
187209 select {
188- case samples := <-a.buf:
210+ case packet := <-a.ready:
189211 fillNum := a.fillCount.Add(1)
190212 if fillNum == 1 && a.ctx != nil {
191- logging.FromContext(a.ctx).Info().
192- Msg("pipewire: first Fill callback with data")
213+ logging.FromContext(a.ctx).Info().Msg("pipewire: first Fill callback with data")
193214 }
194- // Copy available samples into the PCM buffer
195- copied := copyToPCM(pcm, samples)
215+ copied := copyToPCM(pcm, packet.samples[:packet.channels], packet.frames)
216+ a.available <- packet
196217 return copied, nil
197218 default:
198- // No data available — return 0 frames, let underrun policy handle it
199219 underruns := a.underrunCount.Add(1)
200220 if underruns == 1 && a.ctx != nil {
201- logging.FromContext(a.ctx).Debug().
202- Msg("pipewire: first Fill underrun (no data available)")
221+ logging.FromContext(a.ctx).Debug().Msg("pipewire: first Fill underrun (no data available)")
203222 }
204223 return 0, nil
205224 }
206225 }
207226
208-// copyToPCM copies planar samples into the PCMBuffer.
209-// Returns the number of frames actually copied.
210-func copyToPCM(pcm *pwpipewire.PCMBuffer, samples [][]float32) int {
211- if pcm == nil || len(samples) == 0 {
227+func copyToPCM(pcm *pwpipewire.PCMBuffer, samples [][]float32, packetFrames int) int {
228+ if pcm == nil || len(samples) == 0 || packetFrames == 0 {
212229 return 0
213230 }
214-
215- frames := pcm.Frames
216231 channels := min(min(len(samples), pcm.Channels), len(pcm.Samples))
217232 if channels == 0 {
218233 return 0
219234 }
220-
221- minFrames := frames
222-
223- for ch := 0; ch < channels && ch < len(samples); ch++ {
224- dst := pcm.Samples[ch]
225- src := samples[ch]
226- if len(dst) == 0 || len(src) == 0 {
227- minFrames = 0
228- continue
229- }
230- srcFrames := len(src)
231- n := min(len(dst), min(srcFrames, frames))
232- if n < minFrames {
233- minFrames = n
234- }
235- copy(dst[:n], src[:n])
235+ frames := min(pcm.Frames, packetFrames)
236+ for ch := 0; ch < channels; ch++ {
237+ frames = min(frames, min(len(pcm.Samples[ch]), len(samples[ch])))
236238 }
237-
238- return minFrames
239+ for ch := 0; ch < channels; ch++ {
240+ copy(pcm.Samples[ch][:frames], samples[ch][:frames])
241+ }
242+ return frames
239243 }
240244
241-// Close stops the player and releases resources.
242-// Safe to call multiple times.
245+// Close stops the player. It is safe with Write: Write observes closed before
246+// publishing, and no channel is closed while a callback may be selecting on it.
243247 func (a *playerStreamAdapter) Close() error {
244248 var err error
245249 a.closeOnce.Do(func() {
250+ a.closed.Store(true)
246251 if a.ctx != nil {
247252 logging.FromContext(a.ctx).Info().
248253 Uint64("fills", a.fillCount.Load()).
@@ -250,12 +255,7 @@ func (a *playerStreamAdapter) Close() error {
250255 Uint64("drops", a.dropCount.Load()).
251256 Msg("pipewire: closing player")
252257 }
253-
254- // Signal writers to stop
255- close(a.closed)
256-
257258 if a.player != nil {
258- // Stop playback then close the player
259259 if stopErr := a.player.Stop(); stopErr != nil {
260260 err = stopErr
261261 }
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.
31 package pipewire
42
53 import (
64 "context"
75 "errors"
6+ "sync"
87 "testing"
8+ "time"
99
1010 "github.com/bnema/dumber/internal/application/port"
1111 pipewiremocks "github.com/bnema/dumber/internal/infrastructure/audio/pipewire/mocks"
@@ -16,548 +16,193 @@ import (
1616
1717 type playerRecorder struct {
1818 *pipewiremocks.MockPlayer
19-
20- started bool
21- paused bool
22- stopped bool
23- closedFlag bool
24- state pwpipewire.PlayerState
25-
26- startErr error
27- stopErr error
28- closeErr error
19+ started, stopped, closedFlag bool
20+ startErr, stopErr, closeErr error
2921 }
3022
3123 func newPlayerRecorder(t *testing.T) *playerRecorder {
3224 t.Helper()
33-
34- recorder := &playerRecorder{MockPlayer: pipewiremocks.NewMockPlayer(t)}
35- recorder.EXPECT().Start().RunAndReturn(recorder.start).Maybe()
36- recorder.EXPECT().Pause().RunAndReturn(recorder.pause).Maybe()
37- recorder.EXPECT().Stop().RunAndReturn(recorder.stop).Maybe()
38- recorder.EXPECT().Close().RunAndReturn(recorder.close).Maybe()
39- recorder.EXPECT().State().RunAndReturn(recorder.currentState).Maybe()
40- return recorder
41-}
42-
43-func (p *playerRecorder) start() error {
44- if p.startErr != nil {
45- return p.startErr
46- }
47- p.started = true
48- p.state = pwpipewire.PlayerStatePlaying
49- return nil
50-}
51-
52-func (p *playerRecorder) pause() error {
53- p.paused = true
54- p.state = pwpipewire.PlayerStatePaused
55- return nil
56-}
57-
58-func (p *playerRecorder) stop() error {
59- if p.stopErr != nil {
60- return p.stopErr
61- }
62- p.stopped = true
63- p.state = pwpipewire.PlayerStateStopped
64- return nil
25+ p := &playerRecorder{MockPlayer: pipewiremocks.NewMockPlayer(t)}
26+ p.EXPECT().Start().RunAndReturn(func() error {
27+ if p.startErr != nil {
28+ return p.startErr
29+ }
30+ p.started = true
31+ return nil
32+ }).Maybe()
33+ p.EXPECT().Stop().RunAndReturn(func() error {
34+ if p.stopErr != nil {
35+ return p.stopErr
36+ }
37+ p.stopped = true
38+ return nil
39+ }).Maybe()
40+ p.EXPECT().Close().RunAndReturn(func() error {
41+ if p.closeErr != nil {
42+ return p.closeErr
43+ }
44+ p.closedFlag = true
45+ return nil
46+ }).Maybe()
47+ return p
6548 }
6649
67-func (p *playerRecorder) close() error {
68- if p.closeErr != nil {
69- return p.closeErr
70- }
71- p.closedFlag = true
72- p.state = pwpipewire.PlayerStateClosed
73- return nil
50+func testAdapter(frames int) *playerStreamAdapter {
51+ return newPlayerStreamAdapter(context.Background(), 2, frames)
7452 }
7553
76-func (p *playerRecorder) currentState() pwpipewire.PlayerState {
77- return p.state
54+func stereoPCM(frames int) *pwpipewire.PCMBuffer {
55+ return &pwpipewire.PCMBuffer{Frames: frames, Channels: 2, Samples: [][]float32{make([]float32, frames), make([]float32, frames)}}
7856 }
7957
80-// --- Factory tests ---
81-
82-func TestFactory_NewStream_ValidFormat_CreatesStream(t *testing.T) {
83- var capturedConfig pwpipewire.PlayerConfig
58+func TestFactory_NewStreamValidatesAndStartsPlayer(t *testing.T) {
8459 player := newPlayerRecorder(t)
85-
86- factory := &Factory{
87- createPlayer: func(config pwpipewire.PlayerConfig, _ pwpipewire.PlayerCallbacks) (pwpipewire.Player, error) {
88- capturedConfig = config
89- return player, nil
90- },
91- }
92-
93- format := port.AudioStreamFormat{
94- SampleRate: 48000,
95- ChannelCount: 2,
96- FramesPerBuffer: 512,
97- }
98-
99- stream, err := factory.NewStream(context.Background(), format)
60+ factory := &Factory{createPlayer: func(config pwpipewire.PlayerConfig, callbacks pwpipewire.PlayerCallbacks) (pwpipewire.Player, error) {
61+ assert.Equal(t, 48000, config.SampleRate)
62+ assert.Equal(t, 2, config.Channels)
63+ assert.NotNil(t, callbacks.Fill)
64+ return player, nil
65+ }}
66+ stream, err := factory.NewStream(context.Background(), port.AudioStreamFormat{SampleRate: 48000, ChannelCount: 2, FramesPerBuffer: 512})
10067 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
11168 assert.True(t, player.started)
112-
113- // Clean up
11469 require.NoError(t, stream.Close())
11570 }
11671
117-func TestFactory_NewStream_InvalidFormat_ReturnsError(t *testing.T) {
118- factory := &Factory{
119- createPlayer: func(_ pwpipewire.PlayerConfig, _ pwpipewire.PlayerCallbacks) (pwpipewire.Player, error) {
120- t.Fatal("createPlayer should not be called for invalid format")
121- return nil, nil
122- },
123- }
124-
125- tests := []struct {
126- name string
127- format port.AudioStreamFormat
128- }{
129- {
130- name: "zero sample rate",
131- format: port.AudioStreamFormat{SampleRate: 0, ChannelCount: 2, FramesPerBuffer: 512},
132- },
133- {
134- name: "zero channels",
135- format: port.AudioStreamFormat{SampleRate: 48000, ChannelCount: 0, FramesPerBuffer: 512},
136- },
137- {
138- name: "negative sample rate",
139- format: port.AudioStreamFormat{SampleRate: -48000, ChannelCount: 2, FramesPerBuffer: 512},
140- },
141- {
142- name: "negative channels",
143- format: port.AudioStreamFormat{SampleRate: 48000, ChannelCount: -2, FramesPerBuffer: 512},
144- },
145- {
146- name: "excessive sample rate",
147- format: port.AudioStreamFormat{SampleRate: 1000000, ChannelCount: 2, FramesPerBuffer: 512},
148- },
149- {
150- name: "excessive channels",
151- format: port.AudioStreamFormat{SampleRate: 48000, ChannelCount: 1025, FramesPerBuffer: 512},
152- },
153- {
154- name: "zero frames per buffer",
155- format: port.AudioStreamFormat{SampleRate: 48000, ChannelCount: 2, FramesPerBuffer: 0},
156- },
157- {
158- name: "excessive frames per buffer",
159- format: port.AudioStreamFormat{SampleRate: 48000, ChannelCount: 2, FramesPerBuffer: 10000},
160- },
161- }
162-
163- for _, tt := range tests {
164- t.Run(tt.name, func(t *testing.T) {
165- stream, err := factory.NewStream(context.Background(), tt.format)
166- require.Error(t, err)
167- assert.Nil(t, stream)
168- assert.ErrorIs(t, err, ErrInvalidFormat)
169- })
170- }
171-}
172-
173-func TestFactory_NewStream_PlayerCreationFails_ReturnsError(t *testing.T) {
174- expectedErr := errors.New("pipewire not available")
175- factory := &Factory{
176- createPlayer: func(_ pwpipewire.PlayerConfig, _ pwpipewire.PlayerCallbacks) (pwpipewire.Player, error) {
177- return nil, expectedErr
178- },
72+func TestFactory_NewStreamRejectsInvalidFormat(t *testing.T) {
73+ factory := &Factory{createPlayer: func(pwpipewire.PlayerConfig, pwpipewire.PlayerCallbacks) (pwpipewire.Player, error) {
74+ t.Fatal("unexpected player")
75+ return nil, nil
76+ }}
77+ for _, format := range []port.AudioStreamFormat{
78+ {SampleRate: 0, ChannelCount: 2, FramesPerBuffer: 512},
79+ {SampleRate: 48000, ChannelCount: 0, FramesPerBuffer: 512},
80+ {SampleRate: 48000, ChannelCount: 2, FramesPerBuffer: 0},
81+ } {
82+ stream, err := factory.NewStream(context.Background(), format)
83+ require.ErrorIs(t, err, ErrInvalidFormat)
84+ assert.Nil(t, stream)
17985 }
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)
18786 }
18887
189-func TestFactory_NewStream_PlayerStartFails_ClosesAndReturnsError(t *testing.T) {
88+func TestFactory_NewStreamClosesPlayerWhenStartFails(t *testing.T) {
19089 player := newPlayerRecorder(t)
19190 player.startErr = errors.New("start failed")
192- factory := &Factory{
193- createPlayer: func(_ pwpipewire.PlayerConfig, _ pwpipewire.PlayerCallbacks) (pwpipewire.Player, error) {
194- return player, nil
195- },
196- }
197-
198- format := port.AudioStreamFormat{SampleRate: 48000, ChannelCount: 2, FramesPerBuffer: 512}
199- stream, err := factory.NewStream(context.Background(), format)
200-
201- require.Error(t, err)
202- assert.Nil(t, stream)
203- assert.True(t, player.closedFlag, "player should be closed after start failure")
204-}
205-
206-func TestFactory_NewStream_ContextCancelled_ReturnsError(t *testing.T) {
207- factory := &Factory{
208- createPlayer: func(_ pwpipewire.PlayerConfig, _ pwpipewire.PlayerCallbacks) (pwpipewire.Player, error) {
209- t.Fatal("createPlayer should not be called when context is canceled")
210- return nil, nil
211- },
212- }
213-
214- ctx, cancel := context.WithCancel(context.Background())
215- cancel()
216-
217- format := port.AudioStreamFormat{SampleRate: 48000, ChannelCount: 2, FramesPerBuffer: 512}
218- stream, err := factory.NewStream(ctx, format)
219-
91+ factory := &Factory{createPlayer: func(pwpipewire.PlayerConfig, pwpipewire.PlayerCallbacks) (pwpipewire.Player, error) {
92+ return player, nil
93+ }}
94+ stream, err := factory.NewStream(context.Background(), port.AudioStreamFormat{SampleRate: 48000, ChannelCount: 2, FramesPerBuffer: 512})
22095 require.Error(t, err)
22196 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)
30697 assert.True(t, player.closedFlag)
30798 }
30899
309-func TestPlayerStreamAdapter_Close_IsIdempotent(t *testing.T) {
310- player := newPlayerRecorder(t)
311- adapter := &playerStreamAdapter{
312- player: player,
313- buf: make(chan [][]float32, 4),
314- closed: make(chan struct{}),
315- }
316-
317- require.NoError(t, adapter.Close())
318- require.NoError(t, adapter.Close())
319-}
320-
321-func TestPlayerStreamAdapter_Close_PropagatesStopError(t *testing.T) {
322- player := newPlayerRecorder(t)
323- player.stopErr = errors.New("stop error")
324- adapter := &playerStreamAdapter{
325- player: player,
326- buf: make(chan [][]float32, 4),
327- closed: make(chan struct{}),
328- }
329-
330- err := adapter.Close()
331- require.Error(t, err)
332- assert.Contains(t, err.Error(), "stop error")
333-}
334-
335-func TestPlayerStreamAdapter_Close_PropagatesCloseError(t *testing.T) {
336- player := newPlayerRecorder(t)
337- player.closeErr = errors.New("close error")
338- adapter := &playerStreamAdapter{
339- player: player,
340- buf: make(chan [][]float32, 4),
341- closed: make(chan struct{}),
342- }
343-
344- err := adapter.Close()
345- require.Error(t, err)
346- assert.Contains(t, err.Error(), "close error")
347-}
348-
349-// --- Fill callback tests ---
350-
351-func TestFill_PullsFromBuffer(t *testing.T) {
352- adapter := &playerStreamAdapter{
353- buf: make(chan [][]float32, 4),
354- }
355-
356- // Push samples
357- samples := [][]float32{
358- {0.1, 0.2, 0.3},
359- {0.4, 0.5, 0.6},
360- }
361- adapter.buf <- samples
362-
363- // Simulate Fill call
364- pcm := &pwpipewire.PCMBuffer{
365- Frames: 3,
366- Channels: 2,
367- Stride: 1,
368- Samples: [][]float32{
369- make([]float32, 3),
370- make([]float32, 3),
371- },
372- }
373-
100+func TestPlayerStreamAdapter_FillGetsOwnedPacketAfterCallbackMutation(t *testing.T) {
101+ adapter := testAdapter(3)
102+ samples := [][]float32{{1, 2, 3}, {4, 5, 6}}
103+ require.NoError(t, adapter.Write(samples))
104+ samples[0][0], samples[1][0] = 99, 99
105+ pcm := stereoPCM(3)
374106 frames, err := adapter.fill(pcm)
375107 require.NoError(t, err)
376108 assert.Equal(t, 3, frames)
377- assert.InEpsilon(t, 0.1, pcm.Samples[0][0], 0.0001)
378- assert.InEpsilon(t, 0.4, pcm.Samples[1][0], 0.0001)
379-}
380-
381-func TestFill_ReturnsZeroWhenEmpty(t *testing.T) {
382- adapter := &playerStreamAdapter{
383- buf: make(chan [][]float32, 4),
384- }
385-
386- pcm := &pwpipewire.PCMBuffer{
387- Frames: 256,
388- Channels: 2,
389- Stride: 1,
390- Samples: [][]float32{
391- make([]float32, 256),
392- make([]float32, 256),
393- },
394- }
395-
396- frames, err := adapter.fill(pcm)
397- require.NoError(t, err)
398- assert.Equal(t, 0, frames)
399-}
400-
401-func TestCopyToPCM_HandlesChannelMismatch(t *testing.T) {
402- // More source channels than PCM channels — truncates
403- pcm := &pwpipewire.PCMBuffer{
404- Frames: 2,
405- Channels: 1,
406- Samples: [][]float32{make([]float32, 2)},
407- }
408- samples := [][]float32{
409- {1.0, 2.0},
410- {3.0, 4.0}, // should be ignored
411- }
412- frames := copyToPCM(pcm, samples)
413- assert.Equal(t, 2, frames)
414- assert.InEpsilon(t, 1.0, pcm.Samples[0][0], 0.0001)
109+ assert.InEpsilon(t, 1, pcm.Samples[0][0], 0.0001)
110+ assert.InEpsilon(t, 4, pcm.Samples[1][0], 0.0001)
415111 }
416112
417-func TestCopyToPCM_HandlesFrameMismatch(t *testing.T) {
418- // Fewer source frames than PCM expects
419- pcm := &pwpipewire.PCMBuffer{
420- Frames: 4,
421- Channels: 1,
422- Samples: [][]float32{make([]float32, 4)},
113+func TestPlayerStreamAdapter_SaturationDropsWithoutBlocking(t *testing.T) {
114+ adapter := testAdapter(2)
115+ for i := 0; i < packetQueueCapacity; i++ {
116+ require.NoError(t, adapter.Write([][]float32{{float32(i)}, {float32(i)}}))
423117 }
424- samples := [][]float32{
425- {1.0, 2.0}, // only 2 frames
118+ done := make(chan error, 1)
119+ go func() { done <- adapter.Write([][]float32{{99}, {99}}) }()
120+ select {
121+ case err := <-done:
122+ require.NoError(t, err)
123+ case <-time.After(time.Second):
124+ t.Fatal("Write blocked while the bounded queue was saturated")
426125 }
427- frames := copyToPCM(pcm, samples)
428- assert.Equal(t, 2, frames) // reports partial fill
429-}
430-
431-func TestCopyToPCM_NilPCM_ReturnsZero(t *testing.T) {
432- assert.Equal(t, 0, copyToPCM(nil, [][]float32{{1.0}}))
126+ assert.Equal(t, uint64(1), adapter.dropCount.Load())
127+ assert.Len(t, adapter.ready, packetQueueCapacity)
433128 }
434129
435-func TestCopyToPCM_EmptySamples_ReturnsZero(t *testing.T) {
436- pcm := &pwpipewire.PCMBuffer{Frames: 1, Channels: 1, Samples: [][]float32{make([]float32, 1)}}
437- assert.Equal(t, 0, copyToPCM(pcm, nil))
438- assert.Equal(t, 0, copyToPCM(pcm, [][]float32{}))
130+func TestPlayerStreamAdapter_EmptyFillCountsUnderrun(t *testing.T) {
131+ adapter := testAdapter(2)
132+ frames, err := adapter.fill(stereoPCM(2))
133+ require.NoError(t, err)
134+ assert.Zero(t, frames)
135+ assert.Equal(t, uint64(1), adapter.underrunCount.Load())
439136 }
440137
441-// --- Non-blocking Write tests ---
442-
443-func TestPlayerStreamAdapter_Write_DropsWhenBufferFull(t *testing.T) {
444- adapter := &playerStreamAdapter{
445- player: newPlayerRecorder(t),
446- buf: make(chan [][]float32, 4),
447- closed: make(chan struct{}),
448- }
449-
450- // Fill the buffer to capacity
451- for i := range 4 {
452- require.NoError(t, adapter.Write([][]float32{{float32(i)}}))
453- }
454-
455- // The 5th write must NOT block — it should drop the packet and return nil.
456- err := adapter.Write([][]float32{{99.0}})
457- require.NoError(t, err, "Write must not return an error when dropping due to full buffer")
458-
459- // Verify the drop was counted
460- assert.Equal(t, uint64(1), adapter.dropCount.Load(), "one packet should have been dropped")
461-
462- // Verify buffer still contains the original 4 packets (not the dropped one)
463- assert.Len(t, adapter.buf, 4)
138+func TestPlayerStreamAdapter_CopyToPCMHandlesMismatches(t *testing.T) {
139+ pcm := &pwpipewire.PCMBuffer{Frames: 4, Channels: 1, Samples: [][]float32{make([]float32, 4)}}
140+ assert.Equal(t, 2, copyToPCM(pcm, [][]float32{{1, 2}, {3, 4}}, 2))
141+ assert.InEpsilon(t, 1, pcm.Samples[0][0], 0.0001)
142+ assert.Zero(t, copyToPCM(nil, [][]float32{{1}}, 1))
464143 }
465144
466-func TestPlayerStreamAdapter_Write_DropsLogOnlyOnFirstAndPowerOfTwo(t *testing.T) {
467- adapter := &playerStreamAdapter{
468- player: newPlayerRecorder(t),
469- buf: make(chan [][]float32, 1), // tiny buffer to force drops
470- closed: make(chan struct{}),
471- }
472-
473- // Fill buffer
474- require.NoError(t, adapter.Write([][]float32{{0.0}}))
475-
476- // Drop 10 packets
477- for i := range 10 {
478- _ = adapter.Write([][]float32{{float32(i)}})
479- }
480-
481- // Verify all drops were counted
482- assert.Equal(t, uint64(10), adapter.dropCount.Load())
145+func TestPlayerStreamAdapter_CloseVsWrite(t *testing.T) {
146+ adapter := testAdapter(2)
147+ player := newPlayerRecorder(t)
148+ adapter.player = player
149+ require.NoError(t, adapter.Close())
150+ require.ErrorIs(t, adapter.Write([][]float32{{1}, {2}}), ErrStreamClosed)
151+ assert.True(t, player.stopped)
152+ assert.True(t, player.closedFlag)
483153 }
484154
485-// --- Concurrent Write / Close tests ---
486-
487155 func TestPlayerStreamAdapter_ConcurrentWriteClose_NoRace(t *testing.T) {
488- player := newPlayerRecorder(t)
489- adapter := &playerStreamAdapter{
490- player: player,
491- buf: make(chan [][]float32, 4),
492- closed: make(chan struct{}),
493- }
494-
495- done := make(chan struct{})
496- go func() {
497- defer close(done)
498- for i := range 100 {
499- err := adapter.Write([][]float32{{float32(i)}})
500- if err != nil {
501- assert.ErrorIs(t, err, ErrStreamClosed)
502- return
156+ adapter := testAdapter(4)
157+ var writers sync.WaitGroup
158+ for range 4 {
159+ writers.Add(1)
160+ go func() {
161+ defer writers.Done()
162+ for range 1000 {
163+ err := adapter.Write([][]float32{{1, 2}, {3, 4}})
164+ if err != nil {
165+ assert.ErrorIs(t, err, ErrStreamClosed)
166+ return
167+ }
503168 }
504- }
505- }()
506-
507- // Let a few writes land, then close.
169+ }()
170+ }
508171 adapter.Close()
509- <-done
510-}
511-
512-// --- NewFactory tests ---
513-
514-func TestNewFactory_Succeeds(t *testing.T) {
515- factory, err := NewFactory()
516- require.NoError(t, err)
517- require.NotNil(t, factory)
518- assert.NotNil(t, factory.createPlayer)
172+ writers.Wait()
519173 }
520174
521-// --- Integration-style test: full write-through ---
522-
523175 func TestStream_WriteFlowsThroughToFill(t *testing.T) {
524- var capturedCallbacks pwpipewire.PlayerCallbacks
176+ var callbacks pwpipewire.PlayerCallbacks
525177 player := newPlayerRecorder(t)
526-
527- factory := &Factory{
528- createPlayer: func(_ pwpipewire.PlayerConfig, callbacks pwpipewire.PlayerCallbacks) (pwpipewire.Player, error) {
529- capturedCallbacks = callbacks
530- return player, nil
531- },
532- }
533-
534- format := port.AudioStreamFormat{SampleRate: 48000, ChannelCount: 2, FramesPerBuffer: 4}
535- stream, err := factory.NewStream(context.Background(), format)
178+ factory := &Factory{createPlayer: func(_ pwpipewire.PlayerConfig, got pwpipewire.PlayerCallbacks) (pwpipewire.Player, error) {
179+ callbacks = got
180+ return player, nil
181+ }}
182+ stream, err := factory.NewStream(context.Background(), port.AudioStreamFormat{SampleRate: 48000, ChannelCount: 2, FramesPerBuffer: 4})
536183 require.NoError(t, err)
537-
538- // Write samples through the stream
539- samples := [][]float32{
540- {0.1, 0.2, 0.3, 0.4},
541- {0.5, 0.6, 0.7, 0.8},
542- }
543- require.NoError(t, stream.Write(samples))
544-
545- // Simulate the Fill callback being called by the player
546- pcm := &pwpipewire.PCMBuffer{
547- Frames: 4,
548- Channels: 2,
549- Stride: 1,
550- Samples: [][]float32{
551- make([]float32, 4),
552- make([]float32, 4),
553- },
554- }
555-
556- frames, err := capturedCallbacks.Fill(pcm)
184+ require.NoError(t, stream.Write([][]float32{{.1, .2, .3, .4}, {.5, .6, .7, .8}}))
185+ pcm := stereoPCM(4)
186+ frames, err := callbacks.Fill(pcm)
557187 require.NoError(t, err)
558188 assert.Equal(t, 4, frames)
559- assert.InEpsilon(t, 0.1, pcm.Samples[0][0], 0.0001)
560- assert.InEpsilon(t, 0.5, pcm.Samples[1][0], 0.0001)
561-
189+ assert.InEpsilon(t, .1, pcm.Samples[0][0], .0001)
190+ assert.InEpsilon(t, .5, pcm.Samples[1][0], .0001)
562191 require.NoError(t, stream.Close())
563192 }
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 {
448448 return len(args) >= 2 && args[1] == "browse"
449449 }
450450
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+
451463 func parseBrowseURLFromRelaunchCommandLine(commandLine purecef.CommandLine) string {
452464 browseURL, _ := parseBrowseRelaunchCommandLine(commandLine)
453465 return browseURL
454466 }
455467
456468 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+ }
457475 if browseURL, ok := parseBrowseRelaunchCommandLine(commandLine); ok {
458476 if h != nil && h.engine != nil {
459477 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 {
3131 pool *WebViewPool
3232 profileLogDir string
3333 runtimeCEFDir string
34+ stateRoot string
3435 renderStackPlan cef2gtk.RenderStackPlan
3536 applicationScaleMu sync.RWMutex
3637 applicationScale float64
@@ -54,6 +55,7 @@ type Engine struct {
5455 downloadHandler *downloadHandler
5556 alreadyRunningAppRelaunchMu sync.RWMutex
5657 alreadyRunningAppRelaunchHandler func(string)
58+ gpuRelaunchCount atomic.Int32
5759
5860 // activeWebViews tracks all live webviews for CSS broadcast.
5961 activeWebViews sync.Map // map[port.WebViewID]*WebView
@@ -145,6 +147,17 @@ func (e *Engine) alreadyRunningAppRelaunchCallback() func(string) {
145147 return e.alreadyRunningAppRelaunchHandler
146148 }
147149
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+
148161 // registerWebView adds a webview to the active tracking map.
149162 func (e *Engine) registerWebView(wv *WebView) {
150163 e.activeWebViews.Store(wv.id, wv)
M internal/infrastructure/cef/engine_init.go +29 -3
@@ -45,6 +45,10 @@ func NewEngine(
4545 ) (*Engine, error) {
4646 logger := logging.FromContext(ctx)
4747 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+ }
4852 cleanStaleSingletonLocks(logger, stateRoot)
4953 windowlessFrameRate := normalizedWindowlessFrameRate(cfg.WindowlessFrameRate)
5054 renderStackPlan, err := resolveCEFRenderStackPlan(cfg.RenderStack)
@@ -52,6 +56,7 @@ func NewEngine(
5256 return nil, err
5357 }
5458 cef2gtk.ConfigureRenderStackEnvironment(renderStackPlan)
59+ activeStartupTrace().SetBackend(renderStackPlan.Backend.String())
5560
5661 settings, err := prepareCEFSettings(opts, paths, cfg, logger)
5762 if err != nil {
@@ -67,6 +72,7 @@ func NewEngine(
6772 ctx: ctx,
6873 profileLogDir: paths.ProfileLogDir,
6974 runtimeCEFDir: settings.CEFDir,
75+ stateRoot: stateRoot,
7076 renderStackPlan: renderStackPlan,
7177 applicationScale: normalizedApplicationScale(cfg.ApplicationScale),
7278 registerHandlers: deps.RegisterHandlers,
@@ -141,6 +147,24 @@ func resolvedStateRoot(defaultStateRoot string, opts port.EngineOptions) string
141147 return defaultStateRoot
142148 }
143149
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+
144168 // prepareCEFSettings builds purecef.Settings from the engine config.
145169 func prepareCEFSettings(
146170 opts port.EngineOptions,
@@ -164,9 +188,7 @@ func prepareCEFSettings(
164188 settings.MultiThreadedMessageLoop = true
165189 settings.ExternalMessagePump = false
166190 settings.RootCachePath = resolvedStateRoot(paths.StateRoot, opts)
167- if cfg.CEFDir != "" {
168- settings.CEFDir = cfg.CEFDir
169- }
191+ settings.CEFDir = configureCEFSubprocessRuntime(cfg.CEFDir)
170192 if runtimeLogFile, err := prepareCEFLogFile(paths.LogFile, cfg.LogFile); err != nil {
171193 logger.Warn().Err(err).Msg("cef: failed to prepare runtime log file")
172194 } else {
@@ -194,9 +216,13 @@ func initializeCEF(eng *Engine, settings purecef.Settings, logger *zerolog.Logge
194216 Str("env_cef_dir", os.Getenv("CEF_DIR")).
195217 Msg("cef: runtime selection")
196218 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")
197222 if err := purecef.InitWithApp(settings, app); err != nil {
198223 return fmt.Errorf("cef.InitWithApp: %w", err)
199224 }
225+ activeStartupTrace().Mark("cef_initialized")
200226 if libcefPath := loadedLibCEFPath(); libcefPath != "" {
201227 logger.Info().Str("libcef_path", libcefPath).Msg("cef: runtime library loaded")
202228 } else {
M internal/infrastructure/cef/engine_init_test.go +16 -0
@@ -36,6 +36,22 @@ func TestResolvedStateRoot_UsesProfileDefault(t *testing.T) {
3636 require.Equal(t, profile.CEFUserDataDir(), root)
3737 }
3838
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+
3955 func TestPrepareCEFSettings_UsesResolvedProfilePaths(t *testing.T) {
4056 logger := zerolog.Nop()
4157 profile := testCEFDevProfile(t)
M internal/infrastructure/cef/factory.go +14 -8
@@ -27,7 +27,7 @@ type WebViewFactory struct {
2727 windowlessFrameRate int32
2828 windowlessFrameRateMax int32
2929 inputConfig RuntimeInputConfig
30- bgColor atomic.Uint32 // packed ARGB for BrowserSettings.BackgroundColor
30+ bgColor atomic.Uint32 // packed ARGB theme color for the presentation-layer flash guard
3131 audioOutputFactory port.AudioOutputFactory
3232 }
3333
@@ -69,7 +69,9 @@ func newWebViewFactory(engine *Engine, opts webViewFactoryOptions) *WebViewFacto
6969 }
7070 }
7171
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.
7375 func (f *WebViewFactory) setDefaultBackgroundColor(r, g, b, a float64) {
7476 const s = colorScale
7577 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) {
9698 settings := purecef.NewBrowserSettings()
9799 cef2gtk.ConfigureBrowserSettings(&settings, cef2gtk.BrowserSettingsOptions{WindowlessFrameRate: f.windowlessFrameRate})
98100 settings.LocalStorage = 1 // CEF_STATE_ENABLED
99- if bg := f.bgColor.Load(); bg != 0 {
100- settings.BackgroundColor = bg
101- }
101+ // Always opaque white: Chromium's standard canvas fallback. The theme
102+ // background is applied at the presentation layer as a flash guard instead.
103+ settings.BackgroundColor = opaqueWhiteBackground
102104
103105 // Defer browser creation until the bridge view has a non-zero size.
104106 // 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) {
142144 wv.nativeWidget = popupRoot
143145 }
144146 }
147+ // Guard the render widget with the theme background until the first frame.
148+ wv.applyBackgroundFlashGuard(nativeWidget)
145149 })
146150 if !result.Completed() {
147151 wv.destroyViewBridgeOnGTKAsync()
@@ -278,6 +282,8 @@ func (f *WebViewFactory) postPendingBrowserCreate(ctx context.Context, wv *WebVi
278282 // renderer without an initial paint. We replay pending navigation from
279283 // OnAfterCreated once the host is fully wired.
280284 initialURL := "about:blank"
285+ // This is the request boundary, not the later OnAfterCreated callback.
286+ activeStartupTrace().Mark("browser_create_requested")
281287 result := cefBrowserHostCreateBrowser(
282288 pc.windowInfo,
283289 pc.client,
@@ -381,9 +387,9 @@ func (f *WebViewFactory) CreateRelated(ctx context.Context, parentID port.WebVie
381387 settings := purecef.NewBrowserSettings()
382388 cef2gtk.ConfigureBrowserSettings(&settings, cef2gtk.BrowserSettingsOptions{WindowlessFrameRate: f.windowlessFrameRate})
383389 settings.LocalStorage = 1 // CEF_STATE_ENABLED
384- if bg := f.bgColor.Load(); bg != 0 {
385- settings.BackgroundColor = bg
386- }
390+ // Always opaque white: the theme background is applied as a presentation-layer
391+ // flash guard, not baked into Chromium's canvas.
392+ settings.BackgroundColor = opaqueWhiteBackground
387393
388394 if err := f.configureInitialBrowserCreation(ctx, popupWV, popupWV.client, &windowInfo, &settings, func(w, h int32) {
389395 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 {
2323
2424 var _ purecef.RenderHandler = (*dumberRenderHandler)(nil)
2525
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+
2646 func newDumberRenderHandler(wv *WebView) purecef.RenderHandler {
2747 if wv == nil || wv.viewBridge == nil {
2848 return nil
@@ -30,23 +50,32 @@ func newDumberRenderHandler(wv *WebView) purecef.RenderHandler {
3050 h := &dumberRenderHandler{wv: wv}
3151
3252 var unsupportedPaintOnce sync.Once
33- h.main = wv.viewBridge.RenderHandler(cef2gtk.Hooks{
34- OnUnsupportedPaint: func() {
35- unsupportedPaintOnce.Do(func() {
36- if wv.ctx != nil {
37- logging.FromContext(wv.ctx).Warn().Msg("cef: unsupported CPU paint from accelerated bridge")
38- }
39- })
40- },
41- OnError: func(err error) {
53+ hooks := startupPresentationHooks(activeStartupTrace())
54+ // Tear down the theme background flash guard once the first frame is
55+ // presented, without dropping the startup trace mark the bridge already set.
56+ firstAcceleratedPaint := hooks.OnFirstAcceleratedPaint
57+ hooks.OnFirstAcceleratedPaint = func() {
58+ if firstAcceleratedPaint != nil {
59+ firstAcceleratedPaint()
60+ }
61+ wv.markFirstFramePainted()
62+ }
63+ hooks.OnUnsupportedPaint = func() {
64+ unsupportedPaintOnce.Do(func() {
4265 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")
4467 }
45- },
46- OnTextSelectionChanged: func(selectedText string, _ *purecef.Range) {
47- handleRenderTextSelectionChanged(wv, selectedText)
48- },
49- })
68+ })
69+ }
70+ hooks.OnError = func(err error) {
71+ if wv.ctx != nil {
72+ logging.FromContext(wv.ctx).Warn().Err(err).Msg("cef: accelerated render bridge error")
73+ }
74+ }
75+ hooks.OnTextSelectionChanged = func(selectedText string, _ *purecef.Range) {
76+ handleRenderTextSelectionChanged(wv, selectedText)
77+ }
78+ h.main = wv.viewBridge.RenderHandler(hooks)
5079
5180 if wv.popupSurface != nil {
5281 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 (
3939 // errDestroyed is returned when an operation is attempted on a destroyed WebView.
4040 var errDestroyed = errors.New("cef: webview is destroyed")
4141
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+
4246 // errNoBrowser is returned when the browser has not been created yet.
4347 var errNoBrowser = errors.New("cef: browser not yet created")
4448
@@ -107,11 +111,19 @@ type WebView struct {
107111 viewportSyncReason string
108112 viewportMapFunc func(gtk.Widget)
109113 viewportShowFunc func(gtk.Widget)
114+ viewportHideFunc func(gtk.Widget)
115+ viewportUnmapFunc func(gtk.Widget)
110116 viewportRealizeFunc func(gtk.Widget)
111117 viewportMapSignalID uint
112118 viewportShowSignalID uint
119+ viewportHideSignalID uint
120+ viewportUnmapSignalID uint
113121 viewportRealizeSignalID uint
114122 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
115127
116128 adaptiveWindowlessFrameRate bool
117129 windowlessFrameRateMax int32
@@ -227,6 +239,18 @@ type WebView struct {
227239 backgroundColor uint32
228240 inputConfig RuntimeInputConfig
229241
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+
230254 // Touchpad input diagnostics are debounced to avoid flooding logs while
231255 // debugging continuous scroll streams.
232256 touchpadNavigation *touchpadNavigationRecognizer
@@ -238,16 +262,25 @@ type WebView struct {
238262 inputDiagDeltaXSum int64
239263 inputDiagDeltaYSum int64
240264
241- // Audio output factory and active stream.
242- audioOutputFactory port.AudioOutputFactory
243- audioStreamMu sync.Mutex
244- activeAudioStream port.AudioOutputStream
265+ // Audio output lifecycle. Packet callbacks load audioStream directly: its
266+ // immutable handle is atomically published and never requires a mutex.
267+ audioOutputFactory port.AudioOutputFactory
268+ audioStreamMu sync.Mutex
269+ audioStreamGeneration atomic.Uint64
270+ audioStream atomic.Pointer[audioStreamHandle]
245271
246272 // Audio instrumentation counters (diagnostic only).
247273 audioPacketCount atomic.Uint64 // total OnAudioStreamPacket calls
248274 audioWriteCount atomic.Uint64 // successful Write calls to stream
249275 }
250276
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+
251284 // pendingBrowserCreate holds the parameters needed to create a CEF browser,
252285 // deferred until the GL area has a non-zero size.
253286 type pendingBrowserCreate struct {
@@ -971,7 +1004,7 @@ func (wv *WebView) prepareNativePopup(
9711004 prep.parent.trackPendingNativePopup(popupID, wv)
9721005 }
9731006
974- configureNativePopupWindow(windowInfo, settings, prep.frameRate, prep.backgroundColor)
1007+ configureNativePopupWindow(windowInfo, settings, prep.frameRate)
9751008 clientSlot.Set(client)
9761009 return true
9771010 }
@@ -986,9 +1019,8 @@ func (wv *WebView) nativePopupPreparationSnapshot() (nativePopupPreparation, boo
9861019 return nativePopupPreparation{}, false
9871020 }
9881021 return nativePopupPreparation{
989- parent: wv.nativePopupParent,
990- frameRate: wv.windowlessFrameRate,
991- backgroundColor: wv.backgroundColor,
1022+ parent: wv.nativePopupParent,
1023+ frameRate: wv.windowlessFrameRate,
9921024 }, true
9931025 }
9941026
@@ -1024,7 +1056,6 @@ func configureNativePopupWindow(
10241056 windowInfo *purecef.WindowInfo,
10251057 settings *purecef.BrowserSettings,
10261058 frameRate int32,
1027- backgroundColor uint32,
10281059 ) {
10291060 cef2gtk.ConfigureWindowInfo(windowInfo, cef2gtk.WindowInfoOptions{})
10301061 if externalBeginFrameEnabled() {
@@ -1035,15 +1066,13 @@ func configureNativePopupWindow(
10351066 }
10361067 cef2gtk.ConfigureBrowserSettings(settings, cef2gtk.BrowserSettingsOptions{WindowlessFrameRate: frameRate})
10371068 settings.LocalStorage = 1
1038- if backgroundColor != 0 {
1039- settings.BackgroundColor = backgroundColor
1040- }
1069+ // Always opaque white; the popup shell WebView carries the theme flash guard.
1070+ settings.BackgroundColor = opaqueWhiteBackground
10411071 }
10421072
10431073 type nativePopupPreparation struct {
1044- parent *WebView
1045- frameRate int32
1046- backgroundColor uint32
1074+ parent *WebView
1075+ frameRate int32
10471076 }
10481077
10491078 func (wv *WebView) handleNativePopupAborted() {
@@ -1223,6 +1252,7 @@ func (wv *WebView) destroyViewBridgeOnGTKThread() {
12231252 if wv == nil || wv.viewBridge == nil {
12241253 return
12251254 }
1255+ wv.removeBackgroundFlashGuard()
12261256 if wv.removeSizeObserver != nil {
12271257 wv.removeSizeObserver()
12281258 wv.removeSizeObserver = nil
@@ -2067,12 +2097,14 @@ func (wv *WebView) startBeginFrameLoop() {
20672097 cb := new(gtk.TickCallback)
20682098 *cb = func(_, _, _ uintptr) bool {
20692099 if wv.destroyed.Load() {
2100+ wv.releaseBeginFrameTickCallback()
20702101 return false
20712102 }
20722103 wv.mu.RLock()
20732104 host := wv.host
20742105 wv.mu.RUnlock()
20752106 if host == nil {
2107+ wv.releaseBeginFrameTickCallback()
20762108 return false
20772109 }
20782110 host.SendExternalBeginFrame()
@@ -2095,19 +2127,34 @@ func (wv *WebView) startBeginFrameLoop() {
20952127 }
20962128
20972129 func (wv *WebView) stopBeginFrameLoop() {
2098- if wv.viewBridge == nil || wv.viewBridge.Widget() == nil {
2099- return
2100- }
2101-
21022130 wv.mu.Lock()
21032131 tickID := wv.beginFrameTickID
2132+ callback := wv.beginFrameTick
21042133 wv.beginFrameTickID = 0
21052134 wv.beginFrameTick = nil
2106- widget := wv.viewBridge.Widget()
2135+ bridge := wv.viewBridge
21072136 wv.mu.Unlock()
21082137
2109- if tickID != 0 {
2110- widget.RemoveTickCallback(tickID)
2138+ if tickID != 0 && bridge != nil {
2139+ if widget := bridge.Widget(); widget != nil {
2140+ widget.RemoveTickCallback(tickID)
2141+ }
2142+ }
2143+ if callback != nil {
2144+ _ = unrefTickCallback(callback)
2145+ }
2146+}
2147+
2148+// releaseBeginFrameTickCallback releases the purego callback slot after GTK
2149+// removes a source because its callback returned false.
2150+func (wv *WebView) releaseBeginFrameTickCallback() {
2151+ wv.mu.Lock()
2152+ callback := wv.beginFrameTick
2153+ wv.beginFrameTickID = 0
2154+ wv.beginFrameTick = nil
2155+ wv.mu.Unlock()
2156+ if callback != nil {
2157+ _ = unrefTickCallback(callback)
21112158 }
21122159 }
21132160
@@ -2302,20 +2349,51 @@ func (wv *WebView) takePendingCreate() *pendingBrowserCreate {
23022349 return pc
23032350 }
23042351
2305-// closeAudioStream closes and clears the active audio output stream.
2306-// This is safe to call even if no stream is active.
2307-func (wv *WebView) closeAudioStream() {
2352+// beginAudioStreamStart invalidates any prior stream before creating a new
2353+// one. A stream created for the returned generation may only be published if
2354+// that generation is still current.
2355+func (wv *WebView) beginAudioStreamStart() (uint64, *audioStreamHandle, bool) {
23082356 wv.audioStreamMu.Lock()
23092357 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+}
23102366
2311- if wv.activeAudioStream != nil {
2312- if err := wv.activeAudioStream.Close(); err != nil {
2313- if wv.ctx != nil {
2314- logging.FromContext(wv.ctx).Debug().
2315- Err(err).
2316- Msg("cef: error closing audio stream")
2317- }
2367+// publishAudioStream publishes stream only if no Stop, Destroy, or newer Start
2368+// invalidated its creation generation. A rejected stream is owned by the
2369+// caller and must be closed by it.
2370+func (wv *WebView) publishAudioStream(generation uint64, stream port.AudioOutputStream) (*audioStreamHandle, bool) {
2371+ wv.audioStreamMu.Lock()
2372+ defer wv.audioStreamMu.Unlock()
2373+ if wv.destroyed.Load() || wv.audioStreamGeneration.Load() != generation {
2374+ return nil, false
2375+ }
2376+ previous := wv.audioStream.Swap(&audioStreamHandle{stream: stream, generation: generation})
2377+ wv.audioPlaying.Store(true)
2378+ return previous, true
2379+}
2380+
2381+// closeAudioStream invalidates creation in progress, atomically detaches the
2382+// published handle, then closes it outside the lifecycle mutex. The port
2383+// contract permits Close concurrently with a packet Write.
2384+func (wv *WebView) closeAudioStream() {
2385+ wv.audioStreamMu.Lock()
2386+ wv.audioStreamGeneration.Add(1)
2387+ stream := wv.audioStream.Swap(nil)
2388+ wv.audioPlaying.Store(false)
2389+ wv.audioStreamMu.Unlock()
2390+ wv.closeDetachedAudioStream(stream)
2391+}
2392+
2393+func (wv *WebView) closeDetachedAudioStream(handle *audioStreamHandle) {
2394+ if handle != nil && handle.stream != nil {
2395+ if err := handle.stream.Close(); err != nil && wv.ctx != nil {
2396+ logging.FromContext(wv.ctx).Debug().Err(err).Msg("cef: error closing audio stream")
23182397 }
2319- wv.activeAudioStream = nil
23202398 }
23212399 }
M internal/infrastructure/cef/webview_audio_test.go +141 -58
@@ -4,6 +4,7 @@ import (
44 "bytes"
55 "context"
66 "errors"
7+ "sync/atomic"
78 "testing"
89 "time"
910
@@ -83,10 +84,10 @@ func TestWebView_AudioStreamLifecycleFieldsExist(t *testing.T) {
8384 }
8485
8586 // Verify the fields exist and can be set
86- wv.activeAudioStream = &mockAudioStream{}
87+ setAudioStream(wv, &mockAudioStream{})
8788
8889 assert.NotNil(t, wv.audioOutputFactory)
89- assert.NotNil(t, wv.activeAudioStream)
90+ assert.NotNil(t, activeAudioStream(wv))
9091 }
9192
9293 // mockAudioStream is a test double for port.AudioOutputStream
@@ -110,6 +111,18 @@ func (m *mockAudioStream) Close() error {
110111 // Verify mock implements the interface
111112 var _ port.AudioOutputStream = (*mockAudioStream)(nil)
112113
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+
113126 // racyMockAudioStream blocks inside Write until doneCh is closed, allowing
114127 // tests to verify that the caller holds a lock across the Write call.
115128 type racyMockAudioStream struct {
@@ -131,6 +144,26 @@ func (m *racyMockAudioStream) Close() error {
131144
132145 var _ port.AudioOutputStream = (*racyMockAudioStream)(nil)
133146
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+
134167 // ============================================================================
135168 // Task 6: Audio Stream Lifecycle Handling Tests
136169 // ============================================================================
@@ -159,8 +192,8 @@ func TestHandlerSet_OnAudioStreamStarted_CreatesStream(t *testing.T) {
159192 handlers.OnAudioStreamStarted(nil, params, 2)
160193
161194 // Assert
162- require.NotNil(t, wv.activeAudioStream)
163- assert.Equal(t, mockStream, wv.activeAudioStream)
195+ require.NotNil(t, activeAudioStream(wv))
196+ assert.Equal(t, mockStream, activeAudioStream(wv))
164197 assert.True(t, wv.audioPlaying.Load())
165198
166199 // Verify factory was called with correct format
@@ -205,8 +238,8 @@ func TestHandlerSet_OnAudioStreamStarted_ClosesExistingStream(t *testing.T) {
205238 wv := &WebView{
206239 ctx: ctx,
207240 audioOutputFactory: mockFactory,
208- activeAudioStream: oldStream,
209241 }
242+ setAudioStream(wv, oldStream)
210243
211244 handlers := &handlerSet{wv: wv}
212245
@@ -221,7 +254,7 @@ func TestHandlerSet_OnAudioStreamStarted_ClosesExistingStream(t *testing.T) {
221254
222255 // Assert
223256 assert.True(t, oldStream.closeCalled)
224- assert.Equal(t, newStream, wv.activeAudioStream)
257+ assert.Equal(t, newStream, activeAudioStream(wv))
225258 }
226259
227260 // TestHandlerSet_OnAudioStreamStarted_FactoryError_HandlesGracefully verifies
@@ -251,23 +284,23 @@ func TestHandlerSet_OnAudioStreamStarted_FactoryError_HandlesGracefully(t *testi
251284 // audioPlaying must be false when NewStream fails — if it stays true,
252285 // the UI/state layer incorrectly believes audio is active.
253286 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")
255288 }
256289
257290 // ============================================================================
258291 // Task 7: PCM Packet Forwarding Tests
259292 // ============================================================================
260293
261-// TestHandlerSet_OnAudioStreamPacket_CopiesAndWrites verifies that
262-// OnAudioStreamPacket copies samples before writing to avoid data races.
263-func TestHandlerSet_OnAudioStreamPacket_CopiesAndWrites(t *testing.T) {
294+// TestHandlerSet_OnAudioStreamPacket_ForwardsCallbackData verifies that CEF
295+// does not allocate a staging copy; the audio output port owns that handoff.
296+func TestHandlerSet_OnAudioStreamPacket_ForwardsCallbackData(t *testing.T) {
264297 ctx := context.Background()
265298 mockStream := &mockAudioStream{}
266299
267300 wv := &WebView{
268- ctx: ctx,
269- activeAudioStream: mockStream,
301+ ctx: ctx,
270302 }
303+ setAudioStream(wv, mockStream)
271304
272305 handlers := &handlerSet{wv: wv}
273306
@@ -287,20 +320,16 @@ func TestHandlerSet_OnAudioStreamPacket_CopiesAndWrites(t *testing.T) {
287320 require.Len(t, mockStream.writeSamples, 2)
288321 require.Len(t, mockStream.writeSamples[0], 512)
289322
290- // Verify data was copied (values match)
291323 assert.InDelta(t, 0.5, mockStream.writeSamples[0][0], 0.000001)
292324 assert.InDelta(t, -0.5, mockStream.writeSamples[1][0], 0.000001)
293-
294- // Verify it's a copy (different underlying array)
295- assert.NotSame(t, &originalData[0][0], &mockStream.writeSamples[0][0])
325+ assert.Same(t, &originalData[0][0], &mockStream.writeSamples[0][0])
296326 }
297327
298328 // TestHandlerSet_OnAudioStreamPacket_NoActiveStream_DoesNothing verifies
299329 // that packets are silently discarded when no stream is active.
300330 func TestHandlerSet_OnAudioStreamPacket_NoActiveStream_DoesNothing(t *testing.T) {
301331 wv := &WebView{
302- ctx: context.Background(),
303- activeAudioStream: nil,
332+ ctx: context.Background(),
304333 }
305334
306335 handlers := &handlerSet{wv: wv}
@@ -319,10 +348,8 @@ func TestHandlerSet_OnAudioStreamPacket_NoActiveStream_DoesNothing(t *testing.T)
319348 // TestHandlerSet_OnAudioStreamPacket_NilData_DoesNotPanic verifies
320349 // that nil data is handled gracefully.
321350 func TestHandlerSet_OnAudioStreamPacket_NilData_DoesNotPanic(t *testing.T) {
322- mockStream := &mockAudioStream{}
323351 wv := &WebView{
324- ctx: context.Background(),
325- activeAudioStream: mockStream,
352+ ctx: context.Background(),
326353 }
327354
328355 handlers := &handlerSet{wv: wv}
@@ -333,6 +360,19 @@ func TestHandlerSet_OnAudioStreamPacket_NilData_DoesNotPanic(t *testing.T) {
333360 })
334361 }
335362
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+
336376 // ============================================================================
337377 // Task 8: Stream Cleanup Tests
338378 // ============================================================================
@@ -342,9 +382,9 @@ func TestHandlerSet_OnAudioStreamPacket_NilData_DoesNotPanic(t *testing.T) {
342382 func TestHandlerSet_OnAudioStreamStopped_ClosesStream(t *testing.T) {
343383 mockStream := &mockAudioStream{}
344384 wv := &WebView{
345- ctx: context.Background(),
346- activeAudioStream: mockStream,
385+ ctx: context.Background(),
347386 }
387+ setAudioStream(wv, mockStream)
348388 wv.audioPlaying.Store(true)
349389
350390 handlers := &handlerSet{wv: wv}
@@ -354,7 +394,7 @@ func TestHandlerSet_OnAudioStreamStopped_ClosesStream(t *testing.T) {
354394
355395 // Assert
356396 assert.True(t, mockStream.closeCalled)
357- assert.Nil(t, wv.activeAudioStream)
397+ assert.Nil(t, activeAudioStream(wv))
358398 assert.False(t, wv.audioPlaying.Load())
359399 }
360400
@@ -362,8 +402,7 @@ func TestHandlerSet_OnAudioStreamStopped_ClosesStream(t *testing.T) {
362402 // that stopping when no stream exists doesn't panic.
363403 func TestHandlerSet_OnAudioStreamStopped_NoActiveStream_DoesNotPanic(t *testing.T) {
364404 wv := &WebView{
365- ctx: context.Background(),
366- activeAudioStream: nil,
405+ ctx: context.Background(),
367406 }
368407 wv.audioPlaying.Store(true)
369408
@@ -382,9 +421,9 @@ func TestHandlerSet_OnAudioStreamStopped_NoActiveStream_DoesNotPanic(t *testing.
382421 func TestHandlerSet_OnAudioStreamError_ClosesStream(t *testing.T) {
383422 mockStream := &mockAudioStream{}
384423 wv := &WebView{
385- ctx: context.Background(),
386- activeAudioStream: mockStream,
424+ ctx: context.Background(),
387425 }
426+ setAudioStream(wv, mockStream)
388427 wv.audioPlaying.Store(true)
389428
390429 handlers := &handlerSet{wv: wv}
@@ -394,7 +433,7 @@ func TestHandlerSet_OnAudioStreamError_ClosesStream(t *testing.T) {
394433
395434 // Assert
396435 assert.True(t, mockStream.closeCalled)
397- assert.Nil(t, wv.activeAudioStream)
436+ assert.Nil(t, activeAudioStream(wv))
398437 assert.False(t, wv.audioPlaying.Load())
399438 }
400439
@@ -402,8 +441,7 @@ func TestHandlerSet_OnAudioStreamError_ClosesStream(t *testing.T) {
402441 // that error handling when no stream exists doesn't panic.
403442 func TestHandlerSet_OnAudioStreamError_NoActiveStream_DoesNotPanic(t *testing.T) {
404443 wv := &WebView{
405- ctx: context.Background(),
406- activeAudioStream: nil,
444+ ctx: context.Background(),
407445 }
408446 wv.audioPlaying.Store(true)
409447
@@ -456,9 +494,9 @@ func TestHandlerSet_OnAudioStreamError_ShutdownSocketCloseLogsDebug(t *testing.T
456494 func TestWebView_Destroy_ClosesAudioStream(t *testing.T) {
457495 mockStream := &mockAudioStream{}
458496 wv := &WebView{
459- ctx: context.Background(),
460- activeAudioStream: mockStream,
497+ ctx: context.Background(),
461498 }
499+ setAudioStream(wv, mockStream)
462500 wv.audioPlaying.Store(true)
463501
464502 // Act — Destroy must close the audio stream as part of teardown.
@@ -466,7 +504,7 @@ func TestWebView_Destroy_ClosesAudioStream(t *testing.T) {
466504
467505 // Assert
468506 assert.True(t, mockStream.closeCalled, "Destroy must close the active audio stream")
469- assert.Nil(t, wv.activeAudioStream)
507+ assert.Nil(t, activeAudioStream(wv))
470508 }
471509
472510 // TestWebView_Destroy_NoAudioStream_DoesNotPanic verifies that Destroy
@@ -558,28 +596,81 @@ func TestOnAudioStreamStarted_CorrectParameterMapping(t *testing.T) {
558596 assert.Equal(t, 1024, mockFactory.lastFormat.FramesPerBuffer, "frames_per_buffer should be 1024 (from params.FramesPerBuffer)")
559597 }
560598
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+
561654 // ============================================================================
562655 // Race-window tests (write/close interleave)
563656 // ============================================================================
564657
565-// TestOnAudioStreamPacket_WriteHoldsLock verifies that closeAudioStream cannot
566-// interleave between the stream snapshot and Write. If the lock is NOT held
567-// across Write, the goroutine calling closeAudioStream will Close the stream
568-// while Write is in-flight, producing a write-after-close.
569-func TestOnAudioStreamPacket_WriteHoldsLock(t *testing.T) {
658+// TestOnAudioStreamPacket_CloseDoesNotWaitForWrite verifies that lifecycle
659+// shutdown is not serialized behind a potentially slow callback write. The
660+// port contract makes Close/Write concurrency safe.
661+func TestOnAudioStreamPacket_CloseDoesNotWaitForWrite(t *testing.T) {
570662 ctx := context.Background()
571663
572- // racyStream blocks inside Write until we signal, giving closeAudioStream
573- // a window to race if the mutex isn't held.
664+ // racyStream blocks inside Write until we signal.
574665 stream := &racyMockAudioStream{
575666 writeCh: make(chan struct{}),
576667 doneCh: make(chan struct{}),
577668 }
578669
579670 wv := &WebView{
580- ctx: ctx,
581- activeAudioStream: stream,
671+ ctx: ctx,
582672 }
673+ setAudioStream(wv, stream)
583674 handlers := &handlerSet{wv: wv}
584675
585676 data := [][]float32{{0.1, 0.2}, {0.3, 0.4}}
@@ -590,32 +681,24 @@ func TestOnAudioStreamPacket_WriteHoldsLock(t *testing.T) {
590681 // Wait for Write to be entered.
591682 <-stream.writeCh
592683
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.
595684 closeDone := make(chan struct{})
596685 go func() {
597686 wv.closeAudioStream()
598687 close(closeDone)
599688 }()
600689
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)
604690 select {
605691 case <-closeDone:
606- t.Fatal("closeAudioStream completed while Write was in-flight — lock not held across Write")
607- default:
608- // expected: closeAudioStream is blocked
692+ // expected: only the short stream snapshot takes the mutex
693+ case <-time.After(time.Second):
694+ t.Fatal("closeAudioStream waited for an in-flight Write")
609695 }
610696
611- // Unblock Write.
697+ // The write can finish after shutdown without holding the lifecycle mutex.
612698 close(stream.doneCh)
613699
614- // Now closeAudioStream should complete.
615- <-closeDone
616-
617700 assert.True(t, stream.closeCalled)
618- assert.Nil(t, wv.activeAudioStream)
701+ assert.Nil(t, activeAudioStream(wv))
619702 }
620703
621704 // TestOnAudioStreamPacket_StreamClosedBetweenPackets verifies that when a
@@ -626,9 +709,9 @@ func TestOnAudioStreamPacket_StreamClosedBetweenPackets(t *testing.T) {
626709 stream := &mockAudioStream{}
627710
628711 wv := &WebView{
629- ctx: ctx,
630- activeAudioStream: stream,
712+ ctx: ctx,
631713 }
714+ setAudioStream(wv, stream)
632715 handlers := &handlerSet{wv: wv}
633716 data := [][]float32{{0.1, 0.2}, {0.3, 0.4}}
634717
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(
819819 })
820820 }
821821
822- // Mark browser as visible — CEF OSR starts in hidden state and suppresses
823- // painting/caret updates until explicitly told the browser is shown.
824- host.WasHidden(0)
822+ // CEF OSR starts hidden. SyncViewport reads GTK's mapped, ancestor-aware
823+ // effective state on the main context and reports that truthful initial state.
825824 if h.wv != nil {
826825 if h.wv.ctx != nil {
827826 logging.FromContext(h.wv.ctx).Info().
@@ -1135,48 +1134,44 @@ func (h *handlerSet) OnAudioStreamStarted(_ purecef.Browser, params *purecef.Aud
11351134 Msg("cef: OnAudioStreamStarted")
11361135 }
11371136
1138- // Reset packet counters for the new stream
1137+ // Starting a stream invalidates both a previous publication and any older
1138+ // creation already in flight. Close detached streams outside the mutex.
1139+ generation, previous, accepted := h.wv.beginAudioStreamStart()
1140+ if !accepted {
1141+ return
1142+ }
1143+ h.wv.closeDetachedAudioStream(previous)
11391144 h.wv.audioPacketCount.Store(0)
11401145 h.wv.audioWriteCount.Store(0)
11411146
1142- // Close any existing stream first
1143- h.wv.closeAudioStream()
1144-
1145- // Create new stream
11461147 ctx := h.wv.ctx
11471148 if ctx == nil {
11481149 ctx = context.Background()
11491150 }
1150-
11511151 stream, err := h.wv.audioOutputFactory.NewStream(ctx, format)
11521152 if err != nil {
1153- // Log error but don't panic - audio is non-critical.
1154- // Do NOT set audioPlaying — the stream was never created.
11551153 if h.wv.ctx != nil {
1156- logging.FromContext(h.wv.ctx).Warn().
1157- Err(err).
1158- Int("sample_rate", format.SampleRate).
1159- Int("channels", format.ChannelCount).
1154+ logging.FromContext(h.wv.ctx).Warn().Err(err).
1155+ Int("sample_rate", format.SampleRate).Int("channels", format.ChannelCount).
11601156 Msg("cef: failed to create audio output stream")
11611157 }
11621158 return
11631159 }
11641160
1165- // Set playing state only after stream creation succeeds
1166- h.wv.setAudioPlaying(true)
1167-
1161+ replaced, published := h.wv.publishAudioStream(generation, stream)
1162+ if !published {
1163+ // Stop, Destroy, or a newer Start won the race. This callback owns the
1164+ // unpublishable stream and closes it exactly once.
1165+ h.wv.closeDetachedAudioStream(&audioStreamHandle{stream: stream, generation: generation})
1166+ return
1167+ }
1168+ h.wv.closeDetachedAudioStream(replaced)
11681169 if h.wv.ctx != nil {
11691170 logging.FromContext(h.wv.ctx).Info().
1170- Int("sample_rate", format.SampleRate).
1171- Int("channels", format.ChannelCount).
1171+ Int("sample_rate", format.SampleRate).Int("channels", format.ChannelCount).
11721172 Int("frames_per_buffer", format.FramesPerBuffer).
11731173 Msg("cef: audio output stream created successfully")
11741174 }
1175-
1176- // Store the new stream
1177- h.wv.audioStreamMu.Lock()
1178- h.wv.activeAudioStream = stream
1179- h.wv.audioStreamMu.Unlock()
11801175 }
11811176
11821177 // buildAudioStreamFormat converts CEF AudioParameters to port.AudioStreamFormat.
@@ -1190,56 +1185,21 @@ func (h *handlerSet) buildAudioStreamFormat(params *purecef.AudioParameters, cha
11901185 }
11911186 }
11921187
1193-// OnAudioStreamPacket receives audio packets and forwards them to the output stream.
1194-// The mutex is held across both the stream snapshot and Write to prevent
1195-// closeAudioStream from closing the stream mid-write.
1196-func (h *handlerSet) OnAudioStreamPacket(_ purecef.Browser, data [][]float32, frames int32, pts int64) {
1188+// OnAudioStreamPacket is a real-time callback: it atomically snapshots an
1189+// immutable handle and performs only allocation-free atomic diagnostics. It
1190+// never takes a lifecycle mutex or emits synchronous logs.
1191+func (h *handlerSet) OnAudioStreamPacket(_ purecef.Browser, data [][]float32, frames int32, _ int64) {
11971192 if len(data) == 0 || frames <= 0 {
11981193 return
11991194 }
1200-
1201- // Copy the data before acquiring the stream lock because CEF can reuse the
1202- // buffer as soon as this callback returns.
1203- copiedData := make([][]float32, len(data))
1204- for i, channel := range data {
1205- if len(channel) < int(frames) {
1206- continue
1207- }
1208- copiedData[i] = make([]float32, frames)
1209- copy(copiedData[i], channel[:frames])
1210- }
1211-
1212- // Hold the lock across the stream read and Write so closeAudioStream
1213- // cannot close the stream between snapshot and write.
1214- h.wv.audioStreamMu.Lock()
1215- stream := h.wv.activeAudioStream
1216- if stream == nil {
1217- h.wv.audioStreamMu.Unlock()
1195+ handle := h.wv.audioStream.Load()
1196+ if handle == nil || handle.stream == nil {
12181197 return
12191198 }
1220-
1221- pktNum := h.wv.audioPacketCount.Add(1)
1222- if pktNum == 1 && h.wv.ctx != nil {
1223- logging.FromContext(h.wv.ctx).Info().
1224- Int("channels", len(data)).
1225- Int32("frames", frames).
1226- Int64("pts", pts).
1227- Msg("cef: first audio packet received")
1228- }
1229-
1230- err := stream.Write(copiedData)
1231- h.wv.audioStreamMu.Unlock()
1232-
1233- if err != nil {
1234- if h.wv.ctx != nil {
1235- logging.FromContext(h.wv.ctx).Debug().
1236- Err(err).
1237- Uint64("packets_received", pktNum).
1238- Msg("cef: audio stream write failed")
1239- }
1240- return
1199+ h.wv.audioPacketCount.Add(1)
1200+ if handle.stream.Write(data) == nil {
1201+ h.wv.audioWriteCount.Add(1)
12411202 }
1242- h.wv.audioWriteCount.Add(1)
12431203 }
12441204
12451205 // OnAudioStreamStopped handles stream stop.
M internal/infrastructure/cef/webview_handlers_test.go +2 -2
@@ -380,14 +380,14 @@ func TestConfigureNativePopupWindow_UsesSharedTextureWindowlessDefaults(t *testi
380380 windowInfo := purecef.NewWindowInfo()
381381 settings := purecef.NewBrowserSettings()
382382
383- configureNativePopupWindow(&windowInfo, &settings, 72, 0xFF112233)
383+ configureNativePopupWindow(&windowInfo, &settings, 72)
384384
385385 require.Equal(t, purecef.WindowHandle(0), windowInfo.ParentWindow)
386386 require.Equal(t, int32(1), windowInfo.WindowlessRenderingEnabled)
387387 require.Equal(t, int32(1), windowInfo.SharedTextureEnabled)
388388 require.Equal(t, int32(72), settings.WindowlessFrameRate)
389389 require.Equal(t, int32(1), settings.LocalStorage)
390- require.Equal(t, uint32(0xFF112233), settings.BackgroundColor)
390+ require.Equal(t, opaqueWhiteBackground, settings.BackgroundColor)
391391 }
392392
393393 func TestOnLoadStartFiresCommittedAndUpdatesURI(t *testing.T) {
M internal/infrastructure/cef/webview_viewport.go +75 -2
@@ -24,6 +24,38 @@ type pendingBrowserCreateObservedSizeBridge interface {
2424 RefreshObservedSizeOnGTKThread() (int32, int32)
2525 }
2626
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+
2759 const (
2860 // pendingBrowserCreateObservedSizeRetryDelay spaces retries while waiting
2961 // 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
129161 }
130162
131163 widget := wv.viewBridge.Widget()
132- visible := widget != nil && widget.IsVisible()
164+ visible := effectiveViewportWidgetVisibility(widget)
133165 wv.notifyViewportSyncOnCEFUIThread(host, visible)
134166 wv.syncZoomForBackingScaleOnCEFUIThread(host, reason)
135167 logging.FromContext(ctx).Debug().
@@ -437,12 +469,20 @@ func (wv *WebView) installViewportSyncHooksOnGTKThread() {
437469 wv.viewportShowFunc = func(gtk.Widget) {
438470 wv.SyncViewport(wv.ctx, "gtk-show")
439471 }
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+ }
440478 wv.viewportRealizeFunc = func(gtk.Widget) {
441479 wv.SyncViewport(wv.ctx, "gtk-realize")
442480 }
443481
444482 wv.viewportMapSignalID = widget.ConnectMap(&wv.viewportMapFunc)
445483 wv.viewportShowSignalID = widget.ConnectShow(&wv.viewportShowFunc)
484+ wv.viewportHideSignalID = widget.ConnectHide(&wv.viewportHideFunc)
485+ wv.viewportUnmapSignalID = widget.ConnectUnmap(&wv.viewportUnmapFunc)
446486 wv.viewportRealizeSignalID = widget.ConnectRealize(&wv.viewportRealizeFunc)
447487 }
448488
@@ -463,6 +503,14 @@ func (wv *WebView) disconnectViewportSyncHooksOnGTKThread() {
463503 gobject.SignalHandlerDisconnect(obj, wv.viewportShowSignalID)
464504 wv.viewportShowSignalID = 0
465505 }
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+ }
466514 if wv.viewportRealizeSignalID != 0 {
467515 gobject.SignalHandlerDisconnect(obj, wv.viewportRealizeSignalID)
468516 wv.viewportRealizeSignalID = 0
@@ -471,6 +519,8 @@ func (wv *WebView) disconnectViewportSyncHooksOnGTKThread() {
471519 }
472520 wv.viewportMapFunc = nil
473521 wv.viewportShowFunc = nil
522+ wv.viewportHideFunc = nil
523+ wv.viewportUnmapFunc = nil
474524 wv.viewportRealizeFunc = nil
475525 }
476526
@@ -488,7 +538,8 @@ func (wv *WebView) notifyViewportSyncOnCEFUIThread(host viewportSyncBrowserHost,
488538 if currentHost != host {
489539 return
490540 }
491- notifyBrowserViewportSync(host, visible)
541+ wv.applyEffectiveVisibility(host, visible)
542+ notifyBrowserViewportResize(host)
492543 }))
493544 if task == nil {
494545 return
@@ -526,6 +577,28 @@ func (wv *WebView) syncZoomForBackingScaleOnCEFUIThread(host purecef.BrowserHost
526577 cefPostDelayedTask(purecef.ThreadIDTidUi, task, 0)
527578 }
528579
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+
529602 func notifyBrowserViewportSync(host viewportSyncBrowserHost, visible bool) {
530603 if host == nil {
531604 return
M internal/infrastructure/cef/webview_viewport_test.go +0 -1
@@ -149,7 +149,6 @@ func TestNotifyViewportSyncOnCEFUIThread_RemainsViewportOnlyAcrossRepeatedResize
149149 scheduled.Execute()
150150
151151 require.Equal(t, []string{
152- "WasHidden",
153152 "NotifyScreenInfoChanged",
154153 "WasResized",
155154 "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 (
1616 )
1717
1818 // 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()} }
2024
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+}
2230
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) {
2432 _ = ctx
2533 if len(original) == 0 {
2634 return nil, fmt.Errorf("%w: empty image", appport.ErrFaviconMiss)
2735 }
2836
29- img, err := decodeFaviconImage(original, normalizeContentType(contentType))
37+ img, err := decodeFaviconImage(original, normalizeContentType(contentType), c.webp)
3038 if err != nil {
3139 return nil, err
3240 }
@@ -48,7 +56,7 @@ func (*ImageConverter) Convert(ctx context.Context, original []byte, contentType
4856 return out, nil
4957 }
5058
51-func decodeFaviconImage(data []byte, ct string) (image.Image, error) {
59+func decodeFaviconImage(data []byte, ct string, webp *webPDecoder) (image.Image, error) {
5260 switch ct {
5361 case "image/svg+xml":
5462 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) {
5967 }
6068 return img, nil
6169 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
6375 }
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" {
6577 return nil, fmt.Errorf("%w: unsupported favicon content type %s", appport.ErrFaviconMiss, ct)
6678 }
6779 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(
4040
4141 // --- Configure rendering environment (must be before GTK/WebKit init) ---
4242 engineConfigureRenderingEnvironment(ctx, cfg, wkCfg, &perfSettings, logger)
43- logging.Trace().Mark("render_env")
4443
4544 // --- Build webKitContextOptions from opts + wkCfg + perfSettings ---
4645 wkOpts := engineBuildContextOptions(opts, profile, wkCfg, &perfSettings)
@@ -53,7 +52,6 @@ func NewEngine(
5352 if err != nil {
5453 return nil, err
5554 }
56- logging.Trace().Mark("webkit_context")
5755
5856 // --- Filter manager ---
5957 filterManager := engineInitFilterManager(ctx, cfg, profile.Shared.DataDir, logger)
@@ -77,7 +75,6 @@ func NewEngine(
7775 }
7876
7977 messageRouter := NewMessageRouter(ctx)
80- logging.Trace().Mark("settings_manager")
8178
8279 // --- WebView pool ---
8380 poolCfg := DefaultPoolConfig()
@@ -100,7 +97,6 @@ func NewEngine(
10097 } else {
10198 logger.Warn().Msg("skipping first webview prewarm: no GDK display available yet")
10299 }
103- logging.Trace().Mark("pool_prewarm_first")
104100
105101 // --- WebView factory ---
106102 factory := NewWebViewFactory(wkCtx, settings, pool, injector, messageRouter)
@@ -147,7 +143,6 @@ func engineSurveyHardwareAndResolveProfile(
147143 ) config.ResolvedPerformanceSettings {
148144 hwSurveyor := env.NewHardwareSurveyor()
149145 hwInfo := hwSurveyor.Survey(ctx)
150- logging.Trace().Mark("hardware_survey")
151146 logger.Info().
152147 Int("cpu_cores", hwInfo.CPUCores).
153148 Int("cpu_threads", hwInfo.CPUThreads).
@@ -158,7 +153,6 @@ func engineSurveyHardwareAndResolveProfile(
158153
159154 perfCfg := config.PerformanceConfigFromEngine(&cfg.Engine)
160155 perfSettings := config.ResolvePerformanceProfile(&perfCfg, &hwInfo)
161- logging.Trace().Mark("performance_profile")
162156 logger.Info().
163157 Str("profile", string(cfg.Engine.Profile)).
164158 Int("skia_cpu_threads", perfSettings.SkiaCPUPaintingThreads).
@@ -284,7 +278,6 @@ func engineInitFilterManager(ctx context.Context, cfg *config.Config, dataDir st
284278 if err := filterManager.Initialize(ctx); err != nil {
285279 logger.Warn().Err(err).Msg("failed to initialize filters, will load async")
286280 }
287- logging.Trace().Mark("filter_manager")
288281 return filterManager
289282 }
290283
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 {
5757 return gio.GApplicationNonUniqueValue
5858 }
5959
60-var adwaitaInitOnce sync.Once
60+var (
61+ adwaitaInitOnce sync.Once
62+ unrefTickCallback = glib.UnrefCallback
63+)
6164
6265 // EnsureAdwaitaInitialized initializes libadwaita and GTK exactly once.
6366 func EnsureAdwaitaInitialized() {
@@ -167,9 +170,12 @@ type floatingWorkspaceSession struct {
167170 omnibox *component.Omnibox
168171 omniboxWidget layout.Widget
169172 resizeWatcherActive bool
170- resizeTickID uint
171- appliedWidth int
172- appliedHeight int
173+ // resizeTickCallback keeps the purego callback live until its GTK source is
174+ // removed. It is cleared exactly once with resizeTickID.
175+ resizeTickCallback *gtk.TickCallback
176+ resizeTickID uint
177+ appliedWidth int
178+ appliedHeight int
173179 }
174180
175181 type floatingSessionKey struct {
@@ -289,7 +295,6 @@ func (a *App) Run(ctx context.Context, args []string) int {
289295 // Initialize libadwaita once (required before using StyleManager).
290296 // This also initializes GTK implicitly.
291297 EnsureAdwaitaInitialized()
292- logging.Trace().Mark("gtk_init")
293298
294299 // Mark adwaita detector as available now that adw.Init() is complete.
295300 // This enables the highest-priority color scheme detector.
@@ -311,7 +316,6 @@ func (a *App) Run(ctx context.Context, args []string) int {
311316 return 1
312317 }
313318 defer a.gtkApp.Unref()
314- logging.Trace().Mark("gtk_app_created")
315319 a.dispatchOnMainThread = a.runOnMainThread
316320
317321 // Connect activate signal
@@ -347,7 +351,6 @@ func (a *App) onActivate(ctx context.Context) {
347351 log.Error().Err(err).Msg("failed to create main window")
348352 return
349353 }
350- logging.Trace().Mark("window_created")
351354
352355 a.installCrashReportNotifier(ctx)
353356 a.initFocusManager()
@@ -356,7 +359,6 @@ func (a *App) onActivate(ctx context.Context) {
356359
357360 a.initCoordinators(ctx)
358361 a.wireWebRTCPermissionIndicator()
359- logging.Trace().Mark("coordinators_init")
360362 a.initKeyboardHandler(ctx)
361363 a.initOmniboxConfig(ctx)
362364 a.initFindBarConfig(ctx)
@@ -4594,22 +4596,22 @@ func (a *App) startFloatingResizeWatcher(session *floatingWorkspaceSession) {
45944596
45954597 session.resizeWatcherActive = true
45964598 paneID := session.paneID
4597- tickCallback := gtk.TickCallback(func(_ uintptr, _ uintptr, _ uintptr) bool {
4599+ callback := new(gtk.TickCallback)
4600+ *callback = func(_ uintptr, _ uintptr, _ uintptr) bool {
45984601 liveSession := a.floatingSessionByPaneID(paneID)
45994602 if liveSession == nil {
4600- session.resizeWatcherActive = false
4601- session.resizeTickID = 0
4603+ session.releaseResizeTickCallback()
46024604 return false
46034605 }
46044606
46054607 keepRunning := a.handleFloatingViewportTick(liveSession)
46064608 if !keepRunning {
4607- liveSession.resizeWatcherActive = false
4608- liveSession.resizeTickID = 0
4609+ liveSession.releaseResizeTickCallback()
46094610 }
46104611 return keepRunning
4611- })
4612- session.resizeTickID = overlayWidget.AddTickCallback(&tickCallback, 0, nil)
4612+ }
4613+ session.resizeTickCallback = callback
4614+ session.resizeTickID = overlayWidget.AddTickCallback(callback, 0, nil)
46134615 }
46144616
46154617 func (a *App) stopFloatingResizeWatcher(session *floatingWorkspaceSession) {
@@ -4617,13 +4619,30 @@ func (a *App) stopFloatingResizeWatcher(session *floatingWorkspaceSession) {
46174619 return
46184620 }
46194621
4620- if session.resizeTickID != 0 && session.overlay != nil {
4622+ tickID := session.resizeTickID
4623+ session.releaseResizeTickCallback()
4624+ if tickID != 0 && session.overlay != nil {
46214625 if overlayWidget := session.overlay.GtkWidget(); overlayWidget != nil {
4622- overlayWidget.RemoveTickCallback(session.resizeTickID)
4626+ overlayWidget.RemoveTickCallback(tickID)
46234627 }
46244628 }
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
46254638 session.resizeTickID = 0
4639+ session.resizeTickCallback = nil
46264640 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+ }
46274646 }
46284647
46294648 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 {
5555 displayRows []favoriteSidebarDisplayRow
5656 currentQuery string
5757 notice string
58+ noticeLoadGen uint64
5859 loadGen uint64
5960 destroyed bool
6061 visible bool
@@ -263,6 +264,7 @@ func (fs *FavoritesSidebar) startLoad() {
263264 ctx := fs.ctx
264265 visible := fs.visible
265266 fs.notice = "Loading favorites..."
267+ fs.noticeLoadGen = gen
266268 fs.mu.Unlock()
267269 if visible {
268270 fs.scheduleIdle(glib.SourceFunc(func(uintptr) bool {
@@ -311,10 +313,13 @@ func (fs *FavoritesSidebar) applyLoadedData(favorites []*entity.Favorite, tags [
311313 }
312314 fs.allFavorites = favorites
313315 fs.allTags = tags
314- if err != nil {
315- fs.notice = err.Error()
316- } else {
317- fs.notice = ""
316+ if fs.noticeLoadGen == gen {
317+ fs.noticeLoadGen = 0
318+ if err != nil {
319+ fs.notice = err.Error()
320+ } else {
321+ fs.notice = ""
322+ }
318323 }
319324 fs.rebuildDisplayRowsLocked()
320325 fs.mu.Unlock()
@@ -363,6 +368,12 @@ func (fs *FavoritesSidebar) rebuildDisplayRowsLocked() {
363368 fs.displayRows = buildFavoriteSidebarDisplayRows(filtered)
364369 }
365370
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+
366377 func (fs *FavoritesSidebar) scheduleIdle(cb glib.SourceFunc) {
367378 if fs != nil && fs.idleScheduler != nil {
368379 fs.idleScheduler(cb)
M internal/ui/component/favorites_sidebar_forms.go +6 -6
@@ -42,7 +42,7 @@ func (fs *FavoritesSidebar) beginAddForm() {
4242 fs.editingID = 0
4343 fs.confirmDelete = false
4444 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.")
4646 fs.mu.Unlock()
4747 fs.renderForm(nil)
4848 fs.rebuildList()
@@ -63,7 +63,7 @@ func (fs *FavoritesSidebar) beginEditForm() {
6363 fs.editingID = fav.ID
6464 fs.confirmDelete = false
6565 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.")
6767 fs.mu.Unlock()
6868 fs.renderForm(fav)
6969 fs.rebuildList()
@@ -170,7 +170,7 @@ func (fs *FavoritesSidebar) cancelManagement() bool {
170170 fs.confirmDelete = false
171171 fs.confirmDeleteID = 0
172172 if active {
173- fs.notice = ""
173+ fs.setNoticeLocked("")
174174 }
175175 fs.mu.Unlock()
176176 if fs.formBox != nil {
@@ -250,7 +250,7 @@ func (fs *FavoritesSidebar) setModeNotice(mode favoritesSidebarMode, notice stri
250250 fs.mode = mode
251251 fs.confirmDelete = false
252252 fs.confirmDeleteID = 0
253- fs.notice = notice
253+ fs.setNoticeLocked(notice)
254254 }
255255 fs.mu.Unlock()
256256 fs.rebuildList()
@@ -324,7 +324,7 @@ func (fs *FavoritesSidebar) requestDeleteConfirmation() bool {
324324 if !fs.destroyed {
325325 fs.confirmDelete = true
326326 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.")
328328 }
329329 fs.mu.Unlock()
330330 fs.rebuildList()
@@ -396,7 +396,7 @@ func (fs *FavoritesSidebar) selectedFavorite() *entity.Favorite {
396396 func (fs *FavoritesSidebar) setNotice(notice string) {
397397 fs.mu.Lock()
398398 if !fs.destroyed {
399- fs.notice = notice
399+ fs.setNoticeLocked(notice)
400400 }
401401 fs.mu.Unlock()
402402 fs.rebuildList()
M internal/ui/component/favorites_sidebar_keyboard.go +1 -1
@@ -463,7 +463,7 @@ func (fs *FavoritesSidebar) handleNavigationError(err error) {
463463 fs.mu.Unlock()
464464 return
465465 }
466- fs.notice = err.Error()
466+ fs.setNoticeLocked(err.Error())
467467 fs.mu.Unlock()
468468 fs.rebuildList()
469469 }
M internal/ui/component/favorites_sidebar_test.go +8 -0
@@ -486,6 +486,8 @@ func TestFavoritesSidebarAddShortcutFailureClosesFormAfterCreate(t *testing.T) {
486486 uc := &fakeFavoritesSidebarUC{errOnSetShortcut: errors.New("shortcut failed")}
487487 fs := newFavoritesSidebarHarness(nil, nil)
488488 fs.favoritesUC = uc
489+ loaded := make(chan glib.SourceFunc, 1)
490+ fs.idleScheduler = func(cb glib.SourceFunc) { loaded <- cb }
489491 fs.formURL = "https://add.test"
490492 fs.formTitle = "Added"
491493 fs.formShortcut = "3"
@@ -493,6 +495,12 @@ func TestFavoritesSidebarAddShortcutFailureClosesFormAfterCreate(t *testing.T) {
493495
494496 assert.True(t, fs.submitForm())
495497 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+ }
496504 assert.Equal(t, favoritesSidebarModeNone, fs.mode)
497505 assert.Contains(t, fs.notice, "failed to set shortcut")
498506 }
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 @@
11 package component
22
3-import (
4- "os"
5- "testing"
6-)
3+import "testing"
74
85 func TestOmniboxCallbackPressure_NoCrashSignal(t *testing.T) {
9- if os.Getenv("OMNIBOX_STRESS") != "1" {
10- t.Skip("skipping: set OMNIBOX_STRESS=1 to run with GTK stress harness")
11- }
12- // TODO: wire GTK stress harness and assert no abrupt crash markers
6+ runGTKCallbackLifecycleStress(t)
137 }
M internal/ui/component/pane_view.go +59 -39
@@ -135,58 +135,73 @@ func (pv *PaneView) WebViewWidget() layout.Widget {
135135 return pv.webViewWidget
136136 }
137137
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.
139141 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) {
140149 pv.mu.Lock()
141150 defer pv.mu.Unlock()
142151
143- // Remove old widget from this overlay
152+ // Remove old widget from this overlay.
144153 if pv.webViewWidget != nil {
145154 pv.overlay.SetChild(nil)
146155 }
147156
148157 pv.webViewWidget = widget
158+ if widget == nil {
159+ return
160+ }
149161
150- if widget != nil {
151- overlayAllocWidthBefore := pv.overlay.GetAllocatedWidth()
152- overlayAllocHeightBefore := pv.overlay.GetAllocatedHeight()
153- widgetAllocWidthBefore := widget.GetAllocatedWidth()
154- widgetAllocHeightBefore := widget.GetAllocatedHeight()
155- hadParent := widget.GetParent() != nil
156- wasVisible := widget.IsVisible()
157-
158- // If this widget already had a parent, we're reparenting an existing WebView
159- // (e.g. after rebuilding/moving panes). In that case the WebView has likely
160- // already painted, so the loading skeleton should be hidden immediately.
161-
162- // Unparent widget from any previous parent (critical for rebuild scenarios)
163- // In GTK4, a widget can only have one parent at a time
164- if hadParent {
165- widget.Unparent()
166- }
167- pv.overlay.SetChild(widget)
162+ overlayAllocWidthBefore := pv.overlay.GetAllocatedWidth()
163+ overlayAllocHeightBefore := pv.overlay.GetAllocatedHeight()
164+ widgetAllocWidthBefore := widget.GetAllocatedWidth()
165+ widgetAllocHeightBefore := widget.GetAllocatedHeight()
166+ hadParent := widget.GetParent() != nil
167+ wasVisible := widget.IsVisible()
168+
169+ // GTK4 widgets may have only one parent. Cleanup during a WorkspaceView
170+ // rebuild leaves a reusable native widget unparented, which says nothing
171+ // about whether its content was previously revealed.
172+ if hadParent {
173+ widget.Unparent()
174+ }
175+ pv.overlay.SetChild(widget)
168176
169- if hadParent && wasVisible {
170- widget.SetVisible(true)
171- }
172- if hadParent && pv.loading != nil {
173- pv.loading.SetVisible(false)
174- }
177+ if hadParent && wasVisible {
178+ widget.SetVisible(true)
179+ }
180+ // A replacement can follow an already-revealed WebView in the same pane.
181+ // Apply both states explicitly: an unrevealed current WebView must restart
182+ // the skeleton rather than inheriting the previous view's hidden overlay.
183+ if pv.loading != nil {
184+ pv.loading.SetVisible(!revealed)
185+ }
175186
176- logging.FromContext(pv.ctx).Debug().
177- Str("pane_id", string(pv.paneID)).
178- Bool("had_parent", hadParent).
179- Bool("was_visible", wasVisible).
180- Int("overlay_alloc_width_before", overlayAllocWidthBefore).
181- Int("overlay_alloc_height_before", overlayAllocHeightBefore).
182- Int("widget_alloc_width_before", widgetAllocWidthBefore).
183- Int("widget_alloc_height_before", widgetAllocHeightBefore).
184- Int("overlay_alloc_width_after", pv.overlay.GetAllocatedWidth()).
185- Int("overlay_alloc_height_after", pv.overlay.GetAllocatedHeight()).
186- Int("widget_alloc_width_after", widget.GetAllocatedWidth()).
187- Int("widget_alloc_height_after", widget.GetAllocatedHeight()).
188- Msg("pane view webview widget attached")
187+ ctx := pv.ctx
188+ if ctx == nil {
189+ ctx = context.Background()
189190 }
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")
190205 }
191206
192207 // GrabFocus attempts to focus the WebView.
@@ -516,6 +531,7 @@ func (pv *PaneView) Cleanup() {
516531 pv.onFocusIn = nil
517532 pv.onFocusOut = nil
518533 pv.onHover = nil
534+ pv.onMouseMotion = nil
519535
520536 // Detach hover handler if present
521537 if pv.hoverHandler != nil {
@@ -530,6 +546,10 @@ func (pv *PaneView) Cleanup() {
530546 }
531547
532548 // Clear other overlay children
549+ if pv.loading != nil {
550+ pv.overlay.RemoveOverlay(pv.loading.Widget())
551+ pv.loading = nil
552+ }
533553 if pv.progressBar != nil {
534554 pv.overlay.RemoveOverlay(pv.progressBar.Widget())
535555 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
33 import (
44 "testing"
55
6+ "github.com/stretchr/testify/assert"
67 "github.com/stretchr/testify/mock"
78
89 "github.com/bnema/dumber/internal/ui/layout"
@@ -80,3 +81,37 @@ func TestPaneView_HideLoadingSkeleton_NoOpWhenNil(t *testing.T) {
8081 pv := &PaneView{}
8182 pv.HideLoadingSkeleton()
8283 }
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)
192192 wv.mu.Lock()
193193 defer wv.mu.Unlock()
194194
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+
195203 // Clear previous state
196204 wv.workspace = ws
197205 wv.paneViews = make(map[entity.PaneID]*PaneView)
@@ -474,8 +482,16 @@ func (wv *WorkspaceView) DeactivatePane(paneID entity.PaneID) {
474482 }
475483 }
476484
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.
478487 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 {
479495 wv.mu.RLock()
480496 pv, ok := wv.paneViews[paneID]
481497 callback := wv.onWebViewAttached
@@ -485,7 +501,7 @@ func (wv *WorkspaceView) SetWebViewWidget(paneID entity.PaneID, widget layout.Wi
485501 return ErrPaneNotFound
486502 }
487503
488- pv.SetWebViewWidget(widget)
504+ pv.AttachWebViewWidget(widget, revealed)
489505 if widget != nil && callback != nil {
490506 callback(paneID)
491507 }
M internal/ui/coordinator/content/callbacks.go +9 -4
@@ -62,8 +62,13 @@ func buildCrashPageURI(originalURI string) string {
6262 // setupWebViewCallbacks configures standard callbacks and popup handling.
6363 func (c *Coordinator) setupWebViewCallbacks(ctx context.Context, paneID entity.PaneID, wv port.WebView) {
6464 log := logging.FromContext(ctx)
65+ identity, ok := identityForWebView(wv)
66+ if !ok {
67+ return
68+ }
6569
66- // Build port callbacks for standard events
70+ // Capture the association at installation, rather than reading mutable
71+ // WebView identity from a callback that can outlive a pool reuse.
6772 callbacks := &port.WebViewCallbacks{
6873 OnTitleChanged: func(title string) {
6974 c.onTitleChanged(ctx, paneID, title)
@@ -73,13 +78,13 @@ func (c *Coordinator) setupWebViewCallbacks(ctx context.Context, paneID entity.P
7378 case port.LoadStarted:
7479 c.onLoadStarted(paneID)
7580 case port.LoadCommitted:
76- c.onLoadCommitted(ctx, paneID, wv)
81+ c.onLoadCommitted(ctx, paneID, wv, identity)
7782 case port.LoadFinished:
78- c.onLoadFinished(ctx, paneID, wv)
83+ c.onLoadFinished(ctx, paneID, wv, identity)
7984 }
8085 },
8186 OnProgressChanged: func(progress float64) {
82- c.onProgressChanged(paneID, progress)
87+ c.onProgressChanged(paneID, wv, identity, progress)
8388 },
8489 OnURIChanged: func(uri string) {
8590 c.handleURIChanged(ctx, paneID, wv, uri)
M internal/ui/coordinator/content/content_reveal_test.go +166 -167
@@ -2,10 +2,10 @@ package content
22
33 import (
44 "context"
5- "sync"
65 "testing"
76
87 "github.com/stretchr/testify/assert"
8+ "github.com/stretchr/testify/mock"
99
1010 "github.com/bnema/dumber/internal/application/port"
1111 "github.com/bnema/dumber/internal/application/port/mocks"
@@ -13,201 +13,200 @@ import (
1313 "github.com/bnema/dumber/internal/ui/component"
1414 )
1515
16-// TestPendingReveal tests the pending reveal state management.
17-// These are internal package tests that verify the state transitions
18-// without needing to mock WebKit dependencies.
19-
20-func TestMarkPendingReveal_SetsState(t *testing.T) {
21- c := &Coordinator{
22- pendingReveal: make(map[entity.PaneID]bool),
16+func newRevealTestCoordinator() *Coordinator {
17+ return &Coordinator{
18+ webViews: make(map[entity.PaneID]port.WebView),
19+ webViewPaneIDs: make(map[port.WebViewID]entity.PaneID),
20+ paneTitles: make(map[entity.PaneID]string),
21+ navOrigins: make(map[entity.PaneID]string),
2322 }
24- paneID := entity.PaneID("pane-1")
25-
26- c.markPendingReveal(paneID)
23+}
2724
28- c.revealMu.Lock()
29- defer c.revealMu.Unlock()
30- assert.True(t, c.pendingReveal[paneID])
25+func revealTestWebView(t *testing.T, id port.WebViewID, generation uint64) *mocks.MockWebView {
26+ t.Helper()
27+ wv := mocks.NewMockWebView(t)
28+ wv.EXPECT().ID().Return(id).Maybe()
29+ wv.EXPECT().Generation().Return(generation).Maybe()
30+ return wv
3131 }
3232
33-func TestClearPendingReveal_RemovesState(t *testing.T) {
34- c := &Coordinator{
35- pendingReveal: make(map[entity.PaneID]bool),
36- }
33+func TestRevealState_IsBoundToCurrentWebViewAcrossReplacementPaths(t *testing.T) {
3734 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+}
3856
39- // First mark as pending
40- c.markPendingReveal(paneID)
57+func TestRevealState_StoresIdentityAsTheOnlyStateValue(t *testing.T) {
58+ paneID := entity.PaneID("pane-1")
59+ wv := revealTestWebView(t, 101, 1)
60+ c := newRevealTestCoordinator()
61+ c.setWebViewLocked(paneID, wv)
62+ identity, _ := identityForWebView(wv)
4163
42- // Then clear
43- c.clearPendingReveal(paneID)
64+ c.markPendingReveal(paneID, identity)
65+ c.markWebViewRevealed(paneID, identity)
4466
4567 c.revealMu.Lock()
4668 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])
4871 }
4972
50-func TestRevealIfPending_NotPending_DoesNothing(t *testing.T) {
51- callbackCalled := false
52- c := &Coordinator{
53- pendingReveal: make(map[entity.PaneID]bool),
54- webViews: make(map[entity.PaneID]port.WebView),
55- onWebViewShown: func(paneID entity.PaneID) {
56- callbackCalled = true
57- },
58- }
73+func TestRevealState_ReleaseClearsStateWithoutWebViewMapping(t *testing.T) {
5974 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)
6087
61- // Call reveal without marking pending first
62- c.revealIfPending(context.Background(), paneID, "http://example.com", "test")
63-
64- assert.False(t, callbackCalled, "callback should not be called when not pending")
88+ c.revealMu.Lock()
89+ _, retained := c.revealedWebViews[paneID]
90+ c.revealMu.Unlock()
91+ assert.False(t, retained)
6592 }
6693
67-func TestRevealIfPending_ClearsStateOnReveal(t *testing.T) {
68- c := &Coordinator{
69- pendingReveal: make(map[entity.PaneID]bool),
70- webViews: make(map[entity.PaneID]port.WebView),
71- }
94+func TestRevealState_ReleaseMappedWebViewClearsRevealStateAndReturnsToPool(t *testing.T) {
7295 paneID := entity.PaneID("pane-1")
73-
74- // Mark as pending
75- c.markPendingReveal(paneID)
76-
77- // Reveal (will return early since webview is nil, but should still clear state)
78- c.revealIfPending(context.Background(), paneID, "http://example.com", "test")
79-
80- c.revealMu.Lock()
81- defer c.revealMu.Unlock()
82- assert.False(t, c.pendingReveal[paneID], "pending state should be cleared after reveal attempt")
96+ wv := revealTestWebView(t, 101, 1)
97+ pool := mocks.NewMockWebViewPool(t)
98+ pool.EXPECT().Release(wv).Once()
99+ c := newRevealTestCoordinator()
100+ c.pool = pool
101+ c.setWebViewLocked(paneID, wv)
102+ identity, _ := identityForWebView(wv)
103+ c.markWebViewRevealed(paneID, identity)
104+
105+ c.ReleaseWebView(context.Background(), paneID)
106+
107+ assert.Nil(t, c.GetWebView(paneID))
108+ assert.False(t, c.WebViewRevealed(paneID))
83109 }
84110
85-func TestRevealIfPending_OnlyRevealsOnce(t *testing.T) {
86- revealCount := 0
87- c := &Coordinator{
88- pendingReveal: make(map[entity.PaneID]bool),
89- webViews: make(map[entity.PaneID]port.WebView),
90- onWebViewShown: func(paneID entity.PaneID) {
91- revealCount++
92- },
93- }
111+func TestRevealState_StaleCallbackCannotRevealReplacement(t *testing.T) {
94112 paneID := entity.PaneID("pane-1")
95-
96- // Mark as pending
97- c.markPendingReveal(paneID)
98-
99- // Call reveal multiple times
100- c.revealIfPending(context.Background(), paneID, "", "first")
101- c.revealIfPending(context.Background(), paneID, "", "second")
102- c.revealIfPending(context.Background(), paneID, "", "third")
103-
104- // Callback should not be called since webview is nil, but state should be cleared
105- assert.Equal(t, 0, revealCount, "callback not called when webview is nil")
106-
107- // Verify state is cleared
108- c.revealMu.Lock()
109- defer c.revealMu.Unlock()
110- assert.False(t, c.pendingReveal[paneID])
113+ oldWV := revealTestWebView(t, 101, 1)
114+ newWV := revealTestWebView(t, 102, 1)
115+ oldWV.EXPECT().IsDestroyed().Return(false).Maybe()
116+ c := newRevealTestCoordinator()
117+ c.setWebViewLocked(paneID, oldWV)
118+ oldIdentity, _ := identityForWebView(oldWV)
119+ c.markPendingReveal(paneID, oldIdentity)
120+ c.setWebViewLocked(paneID, newWV)
121+
122+ c.revealIfPending(context.Background(), paneID, oldWV, oldIdentity, "", "stale")
123+ assert.False(t, c.WebViewRevealed(paneID))
111124 }
112125
113-func TestOnLoadCommitted_RevealsPendingWebViewForCommittedPage(t *testing.T) {
126+func TestRevealState_RevealedCurrentWebViewPersistsForWorkspaceRebuild(t *testing.T) {
114127 paneID := entity.PaneID("pane-1")
115- shownCount := 0
116- var shownPane entity.PaneID
128+ wv := revealTestWebView(t, 101, 1)
129+ c := newRevealTestCoordinator()
130+ c.setWebViewLocked(paneID, wv)
131+ identity, _ := identityForWebView(wv)
132+ c.markWebViewRevealed(paneID, identity)
133+
134+ // AttachToWorkspace asks this identity-aware query when rebuilding a pane,
135+ // so the replacement PaneView hides its skeleton for this same WebView.
136+ assert.True(t, c.webViewRevealed(paneID, wv))
137+
138+ replacement := revealTestWebView(t, 102, 1)
139+ assert.False(t, c.webViewRevealed(paneID, replacement))
140+}
117141
142+func TestRevealCallback_CapturesGenerationBeforeSameObjectPoolReuse(t *testing.T) {
143+ paneID := entity.PaneID("pane-1")
118144 wv := mocks.NewMockWebView(t)
119- wv.EXPECT().URI().Return("https://example.com")
120- wv.EXPECT().ResetBackgroundToDefault()
121- wv.EXPECT().Title().Return("")
145+ generation := uint64(1)
146+ wv.EXPECT().ID().Return(port.WebViewID(101)).Maybe()
147+ wv.EXPECT().Generation().RunAndReturn(func() uint64 { return generation }).Maybe()
122148 wv.EXPECT().IsDestroyed().Return(false).Maybe()
123-
124- c := &Coordinator{
125- pendingReveal: make(map[entity.PaneID]bool),
126- webViews: map[entity.PaneID]port.WebView{
127- paneID: wv,
128- },
129- paneTitles: make(map[entity.PaneID]string),
130- navOrigins: make(map[entity.PaneID]string),
131- getActiveWS: func() (*entity.Workspace, *component.WorkspaceView) { return nil, nil },
132- onWebViewShown: func(id entity.PaneID) {
133- shownCount++
134- shownPane = id
135- },
136- }
137- c.pendingReveal[paneID] = true
138-
139- c.onLoadCommitted(context.Background(), paneID, wv)
140-
141- assert.Equal(t, 1, shownCount)
142- assert.Equal(t, paneID, shownPane)
143-}
144-
145-func TestPendingReveal_ConcurrentAccess(t *testing.T) {
146- c := &Coordinator{
147- pendingReveal: make(map[entity.PaneID]bool),
148- webViews: make(map[entity.PaneID]port.WebView),
149- }
150-
151- var wg sync.WaitGroup
152- paneIDs := []entity.PaneID{"pane-1", "pane-2", "pane-3", "pane-4", "pane-5"}
153-
154- // Concurrently mark and clear panes
155- for range 100 {
156- for _, paneID := range paneIDs {
157- wg.Add(3)
158-
159- go func(id entity.PaneID) {
160- defer wg.Done()
161- c.markPendingReveal(id)
162- }(paneID)
163-
164- go func(id entity.PaneID) {
165- defer wg.Done()
166- c.clearPendingReveal(id)
167- }(paneID)
168-
169- go func(id entity.PaneID) {
170- defer wg.Done()
171- c.revealIfPending(context.Background(), id, "", "concurrent")
172- }(paneID)
173- }
174- }
175-
176- wg.Wait()
177- // Test passes if no race conditions occur
149+ var callbacks *port.WebViewCallbacks
150+ wv.EXPECT().SetCallbacks(mock.Anything).RunAndReturn(func(value *port.WebViewCallbacks) {
151+ callbacks = value
152+ }).Once()
153+
154+ c := newRevealTestCoordinator()
155+ c.getActiveWS = func() (*entity.Workspace, *component.WorkspaceView) { return nil, nil }
156+ c.setWebViewLocked(paneID, wv)
157+ installedIdentity, _ := identityForWebView(wv)
158+ c.markPendingReveal(paneID, installedIdentity)
159+ c.setupWebViewCallbacks(context.Background(), paneID, wv)
160+ revealed := false
161+ c.setWebViewVisible = func(port.WebView) { revealed = true }
162+
163+ // The callback was installed at generation 1. A pool reuse can advance the
164+ // same Go WebView object before its old callback is delivered.
165+ generation = 2
166+ callbacks.OnProgressChanged(1)
167+
168+ assert.False(t, revealed, "the generation-1 callback must not reveal generation 2")
169+ assert.False(t, c.WebViewRevealed(paneID))
178170 }
179171
180-func TestMarkPendingReveal_MultiplePanes(t *testing.T) {
181- c := &Coordinator{
182- pendingReveal: make(map[entity.PaneID]bool),
172+func TestRevealIfPending_SerializesReplacementUntilNativeVisibility(t *testing.T) {
173+ paneID := entity.PaneID("pane-1")
174+ oldWV := revealTestWebView(t, 101, 1)
175+ oldWV.EXPECT().IsDestroyed().Return(false).Once()
176+ newWV := revealTestWebView(t, 102, 1)
177+ c := newRevealTestCoordinator()
178+ c.setWebViewLocked(paneID, oldWV)
179+ identity, _ := identityForWebView(oldWV)
180+ c.markPendingReveal(paneID, identity)
181+
182+ nativeStarted := make(chan struct{})
183+ allowNativeReturn := make(chan struct{})
184+ c.setWebViewVisible = func(wv port.WebView) {
185+ assert.Same(t, oldWV, wv)
186+ close(nativeStarted)
187+ <-allowNativeReturn
183188 }
184-
185- pane1 := entity.PaneID("pane-1")
186- pane2 := entity.PaneID("pane-2")
187-
188- c.markPendingReveal(pane1)
189- c.markPendingReveal(pane2)
190-
191- c.revealMu.Lock()
192- defer c.revealMu.Unlock()
193- assert.True(t, c.pendingReveal[pane1])
194- assert.True(t, c.pendingReveal[pane2])
195-}
196-
197-func TestClearPendingReveal_OnlyAffectsTargetPane(t *testing.T) {
198- c := &Coordinator{
199- pendingReveal: make(map[entity.PaneID]bool),
189+ revealed := make(chan struct{})
190+ go func() {
191+ c.revealIfPending(context.Background(), paneID, oldWV, identity, "", "test")
192+ close(revealed)
193+ }()
194+ <-nativeStarted
195+
196+ replaced := make(chan struct{})
197+ go func() {
198+ c.setWebViewLocked(paneID, newWV)
199+ close(replaced)
200+ }()
201+ select {
202+ case <-replaced:
203+ t.Fatal("replacement interleaved before native visibility completed")
204+ default:
200205 }
201206
202- pane1 := entity.PaneID("pane-1")
203- pane2 := entity.PaneID("pane-2")
204-
205- c.markPendingReveal(pane1)
206- c.markPendingReveal(pane2)
207- c.clearPendingReveal(pane1)
208-
209- c.revealMu.Lock()
210- defer c.revealMu.Unlock()
211- assert.False(t, c.pendingReveal[pane1], "pane1 should be cleared")
212- assert.True(t, c.pendingReveal[pane2], "pane2 should still be pending")
207+ close(allowNativeReturn)
208+ <-revealed
209+ <-replaced
210+ assert.Same(t, newWV, c.GetWebView(paneID))
211+ assert.False(t, c.WebViewRevealed(paneID), "replacement must not inherit old reveal state")
213212 }
M internal/ui/coordinator/content/coordinator.go +55 -10
@@ -70,8 +70,19 @@ type Coordinator struct {
7070 // Callback when the WebView becomes visible (first real commit)
7171 onWebViewShown func(paneID entity.PaneID)
7272
73- revealMu sync.Mutex
74- pendingReveal map[entity.PaneID]bool
73+ // setWebViewVisible is test-only injection for deterministic lifecycle
74+ // serialization tests. Production uses the native widget provider directly.
75+ setWebViewVisible func(port.WebView)
76+
77+ // revealMutationMu serializes mapping replacement/release with the native
78+ // visibility transition. It is acquired before webViewsMu and revealMu.
79+ revealMutationMu sync.Mutex
80+
81+ // revealMu is always acquired after webViewsMu. Reveal state is keyed by the
82+ // WebView identity (ID plus pool-reuse generation), never by pane alone.
83+ revealMu sync.Mutex
84+ pendingReveal map[entity.PaneID]webViewIdentity
85+ revealedWebViews map[entity.PaneID]webViewIdentity
7586
7687 appearanceMu sync.Mutex
7788 pendingScriptRefresh map[entity.PaneID]bool
@@ -147,7 +158,8 @@ func NewCoordinator(
147158 webViewPaneIDs: make(map[port.WebViewID]entity.PaneID),
148159 paneTitles: make(map[entity.PaneID]string),
149160 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),
151163 pendingScriptRefresh: make(map[entity.PaneID]bool),
152164 pendingThemePanes: make(map[entity.PaneID]bool),
153165 getActiveWS: getActiveWS,
@@ -298,20 +310,42 @@ func (c *Coordinator) activePaneOverrideID() (entity.PaneID, bool) {
298310 return c.activePaneOverride, true
299311 }
300312
301-func (c *Coordinator) webViewCount() int {
302- c.webViewsMu.RLock()
303- defer c.webViewsMu.RUnlock()
304- return len(c.webViews)
305-}
306-
307313 func (c *Coordinator) getWebViewLocked(paneID entity.PaneID) port.WebView {
308314 c.webViewsMu.RLock()
309315 defer c.webViewsMu.RUnlock()
310316 return c.webViews[paneID]
311317 }
312318
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.
313342 func (c *Coordinator) setWebViewLocked(paneID entity.PaneID, wv port.WebView) {
343+ c.revealMutationMu.Lock()
344+ defer c.revealMutationMu.Unlock()
314345 c.webViewsMu.Lock()
346+ c.revealMu.Lock()
347+ defer c.revealMu.Unlock()
348+ defer c.webViewsMu.Unlock()
315349 if c.webViews == nil {
316350 c.webViews = make(map[entity.PaneID]port.WebView)
317351 }
@@ -322,17 +356,28 @@ func (c *Coordinator) setWebViewLocked(paneID entity.PaneID, wv port.WebView) {
322356 if wv != nil && c.webViewPaneIDs != nil {
323357 c.webViewPaneIDs[wv.ID()] = paneID
324358 }
325- c.webViewsMu.Unlock()
359+ c.ensureRevealMapsLocked()
360+ delete(c.pendingReveal, paneID)
361+ delete(c.revealedWebViews, paneID)
326362 }
327363
364+// deleteWebViewLocked removes both the mapping and any presentation state,
365+// including callers which release after another path already removed the map.
328366 func (c *Coordinator) deleteWebViewLocked(paneID entity.PaneID) port.WebView {
367+ c.revealMutationMu.Lock()
368+ defer c.revealMutationMu.Unlock()
329369 c.webViewsMu.Lock()
370+ c.revealMu.Lock()
371+ defer c.revealMu.Unlock()
330372 defer c.webViewsMu.Unlock()
331373 wv := c.webViews[paneID]
332374 delete(c.webViews, paneID)
333375 if wv != nil && c.webViewPaneIDs != nil {
334376 delete(c.webViewPaneIDs, wv.ID())
335377 }
378+ c.ensureRevealMapsLocked()
379+ delete(c.pendingReveal, paneID)
380+ delete(c.revealedWebViews, paneID)
336381 return wv
337382 }
338383
M internal/ui/coordinator/content/lifecycle.go +5 -8
@@ -28,17 +28,13 @@ func (c *Coordinator) EnsureWebView(ctx context.Context, paneID entity.PaneID) (
2828 return nil, fmt.Errorf("webview pool not configured")
2929 }
3030
31- // Mark tab_created on first webview (first tab)
32- if c.webViewCount() == 0 {
33- logging.Trace().Mark("tab_created")
34- }
35-
3631 wv, err := c.pool.Acquire(ctx)
3732 if err != nil {
3833 return nil, err
3934 }
40- logging.Trace().Mark("webview_acquired")
4135
36+ // setWebViewLocked atomically resets presentation state for the acquired
37+ // WebView, including a pooled instance previously revealed in another pane.
4238 c.setWebViewLocked(paneID, wv)
4339 c.setupWebViewCallbacks(ctx, paneID, wv)
4440
@@ -53,6 +49,8 @@ func (c *Coordinator) EnsureWebView(ctx context.Context, paneID entity.PaneID) (
5349 func (c *Coordinator) ReleaseWebView(ctx context.Context, paneID entity.PaneID) {
5450 log := logging.FromContext(ctx)
5551
52+ // deleteWebViewLocked clears reveal state before returning, even when a
53+ // previous cleanup already removed this pane's mapping.
5654 wv := c.deleteWebViewLocked(paneID)
5755 if wv == nil {
5856 return
@@ -136,11 +134,10 @@ func (c *Coordinator) AttachToWorkspace(ctx context.Context, ws *entity.Workspac
136134 Int("wrapped_widget_alloc_height", widget.GetAllocatedHeight()).
137135 Msg("wrapped webview widget prepared for pane")
138136
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 {
140138 log.Warn().Err(err).Str("pane_id", string(pane.ID)).Msg("failed to attach webview widget")
141139 continue
142140 }
143- logging.Trace().Mark("webview_attached")
144141 }
145142 }
146143
M internal/ui/coordinator/content/lifecycle_test.go +204 -2
@@ -58,7 +58,7 @@ func TestLifecycle_EnsureWebView_AcquiresFromPoolWhenNoneExists(t *testing.T) {
5858 newWV := mocks.NewMockWebView(t)
5959 newWV.EXPECT().SetCallbacks(mock.Anything).Maybe()
6060 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()
6262
6363 pool := mocks.NewMockWebViewPool(t)
6464 pool.EXPECT().Acquire(mock.Anything).Return(newWV, nil)
@@ -82,7 +82,7 @@ func TestLifecycle_EnsureWebView_AcquiresFromPoolWhenExistingIsDestroyed(t *test
8282 newWV := mocks.NewMockWebView(t)
8383 newWV.EXPECT().SetCallbacks(mock.Anything).Maybe()
8484 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()
8686
8787 pool := mocks.NewMockWebViewPool(t)
8888 pool.EXPECT().Acquire(mock.Anything).Return(newWV, nil)
@@ -458,3 +458,205 @@ func TestLifecycle_SyncWebViewViewport_NoopWithoutCapability(t *testing.T) {
458458
459459 c.SyncWebViewViewport(context.Background(), "pane-1", "unit-test")
460460 }
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 (
1717 // WebKit may reset zoom during document transitions, so we reapply after LoadCommitted.
1818 // History is recorded here because the URI is guaranteed to be correct after commit.
1919 // 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) {
2121 log := logging.FromContext(ctx)
22- logging.Trace().Mark("load_committed")
2322
2423 uri := wv.URI()
2524 if uri == "" {
@@ -69,7 +68,7 @@ func (c *Coordinator) onLoadCommitted(ctx context.Context, paneID entity.PaneID,
6968 // Avoid updating UI/domain state to about:blank when we know the pane is
7069 // navigating to a different URL. This prevents the omnibox/window title from
7170 // briefly showing about:blank on cold start.
72- c.clearPendingReveal(paneID)
71+ c.clearPendingReveal(paneID, identity)
7372 return
7473 }
7574
@@ -77,8 +76,8 @@ func (c *Coordinator) onLoadCommitted(ctx context.Context, paneID entity.PaneID,
7776 c.applyCurrentTheme(ctx, paneID, wv)
7877 }
7978
80- c.markPendingReveal(paneID)
81- c.revealIfPending(ctx, paneID, uri, "progress-after-commit")
79+ c.markPendingReveal(paneID, identity)
80+ c.revealIfPending(ctx, paneID, wv, identity, uri, "progress-after-commit")
8281
8382 // Update domain model with current URI for session snapshots
8483 c.updatePaneURI(paneID, uri)
@@ -177,8 +176,6 @@ func (c *Coordinator) updatePaneURI(paneID entity.PaneID, uri string) {
177176
178177 // onLoadStarted shows the progress bar when page loading begins.
179178 func (c *Coordinator) onLoadStarted(paneID entity.PaneID) {
180- logging.Trace().Mark("load_started")
181-
182179 // Trigger deferred initialization on first load_started.
183180 // This ensures non-critical init runs after initial navigation starts.
184181 c.loadStartedOnce.Do(func() {
@@ -202,7 +199,7 @@ func (c *Coordinator) onLoadStarted(paneID entity.PaneID) {
202199 }
203200
204201 // 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) {
206203 _, wsView := c.getActiveWS()
207204 var paneView *component.PaneView
208205 if wsView != nil {
@@ -216,7 +213,7 @@ func (c *Coordinator) onLoadFinished(ctx context.Context, paneID entity.PaneID,
216213 paneView.SetLoading(false)
217214 }
218215
219- c.revealIfPending(ctx, paneID, "", "load-finished")
216+ c.revealIfPending(ctx, paneID, wv, identity, "", "load-finished")
220217 if c.shouldSkipAboutBlankAppearance(paneID, wv) {
221218 return
222219 }
@@ -225,9 +222,9 @@ func (c *Coordinator) onLoadFinished(ctx context.Context, paneID entity.PaneID,
225222 }
226223
227224 // 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) {
229226 if progress > 0 {
230- c.revealIfPending(context.Background(), paneID, "", "progress")
227+ c.revealIfPending(context.Background(), paneID, wv, identity, "", "progress")
231228 }
232229
233230 _, wsView := c.getActiveWS()
@@ -244,37 +241,111 @@ func (c *Coordinator) onProgressChanged(paneID entity.PaneID, progress float64)
244241 }
245242 }
246243
247-func (c *Coordinator) markPendingReveal(paneID entity.PaneID) {
244+func (c *Coordinator) markPendingReveal(paneID entity.PaneID, identity webViewIdentity) {
245+ c.webViewsMu.RLock()
248246 c.revealMu.Lock()
249- c.pendingReveal[paneID] = true
250- c.revealMu.Unlock()
247+ defer c.revealMu.Unlock()
248+ defer c.webViewsMu.RUnlock()
249+ currentWebView := c.webViews[paneID]
250+ if currentWebView == nil {
251+ return
252+ }
253+ currentIdentity, ok := identityForWebView(currentWebView)
254+ if !ok || currentIdentity != identity {
255+ return
256+ }
257+ c.ensureRevealMapsLocked()
258+ c.pendingReveal[paneID] = identity
251259 }
252260
253-func (c *Coordinator) clearPendingReveal(paneID entity.PaneID) {
261+func (c *Coordinator) clearPendingReveal(paneID entity.PaneID, identity webViewIdentity) {
262+ c.webViewsMu.RLock()
254263 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+ }
255270 delete(c.pendingReveal, paneID)
256- c.revealMu.Unlock()
257271 }
258272
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()
260284 c.revealMu.Lock()
261- pending := c.pendingReveal[paneID]
262- if pending {
263- delete(c.pendingReveal, paneID)
285+ defer c.revealMu.Unlock()
286+ defer c.webViewsMu.RUnlock()
287+ current := c.webViews[paneID]
288+ identity, ok := identityForWebView(current)
289+ if !ok || expected != nil {
290+ expectedIdentity, expectedOK := identityForWebView(expected)
291+ if !expectedOK || expectedIdentity != identity {
292+ return false
293+ }
264294 }
265- c.revealMu.Unlock()
295+ revealedIdentity, revealed := c.revealedWebViews[paneID]
296+ return revealed && revealedIdentity == identity
297+}
266298
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()
268328 return
269329 }
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()
270340
271- wv := c.getWebViewLocked(paneID)
272- if wv == nil || wv.IsDestroyed() {
341+ if wv.IsDestroyed() {
342+ c.revealMutationMu.Unlock()
273343 return
274344 }
275345
276- // Use NativeWidgetProvider to make the widget visible (engine-agnostic)
277- if nwp, ok := wv.(port.NativeWidgetProvider); ok {
346+ if c.setWebViewVisible != nil {
347+ c.setWebViewVisible(wv)
348+ } else if nwp, ok := wv.(port.NativeWidgetProvider); ok {
278349 if ptr := nwp.NativeWidget(); ptr != 0 {
279350 gtkWidget := &gtk.Widget{}
280351 gtkWidget.Ptr = ptr
@@ -288,11 +359,11 @@ func (c *Coordinator) revealIfPending(ctx context.Context, paneID entity.PaneID,
288359 }
289360 }
290361
291- // Mark first_paint and finish startup trace
292- logging.Trace().Mark("first_paint")
293- logging.Trace().Finish()
294-
295- if c.onWebViewShown != nil {
362+ // Keep the presentation record in the same serialized operation as the
363+ // native mutation. User callbacks run only after the gate is released.
364+ revealed := c.markWebViewRevealed(paneID, identity)
365+ c.revealMutationMu.Unlock()
366+ if revealed && c.onWebViewShown != nil {
296367 c.onWebViewShown(paneID)
297368 }
298369 }
M internal/ui/coordinator/content/popup_test.go +7 -7
@@ -137,7 +137,7 @@ func newPopupCreateCoordinatorForTest(t *testing.T, popupID port.WebViewID) (con
137137 parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once()
138138
139139 popupWV := mocks.NewMockWebView(t)
140- popupWV.EXPECT().ID().Return(popupID).Once()
140+ popupWV.EXPECT().ID().Return(popupID).Maybe()
141141
142142 factory := mocks.NewMockWebViewFactory(t)
143143 factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once()
@@ -158,8 +158,8 @@ func TestHandlePopupCreate_PrimesPopupNavigationCapability(t *testing.T) {
158158 parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once()
159159
160160 popupWV := &popupNavigationWebViewStub{MockWebView: mocks.NewMockWebView(t)}
161- popupWV.EXPECT().ID().Return(port.WebViewID(149)).Once()
162- popupWV.EXPECT().Generation().Return(uint64(1)).Once()
161+ popupWV.EXPECT().ID().Return(port.WebViewID(149)).Maybe()
162+ popupWV.EXPECT().Generation().Return(uint64(1)).Maybe()
163163 popupWV.EXPECT().SetCallbacks(mock.Anything).Once()
164164
165165 factory := mocks.NewMockWebViewFactory(t)
@@ -187,7 +187,7 @@ func TestHandlePopupCreate_PrimesPopupNavigationCapability(t *testing.T) {
187187
188188 func TestHandlePopupCreate_RegistersPopupWebViewBeforeWorkspaceInsertion(t *testing.T) {
189189 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()
191191 popupWV.EXPECT().SetCallbacks(mock.Anything).Once()
192192 popupWV.EXPECT().IsLoading().Return(false).Once()
193193 popupWV.EXPECT().URI().Return("").Once()
@@ -211,7 +211,7 @@ func TestHandlePopupCreate_RegistersPopupWebViewBeforeWorkspaceInsertion(t *test
211211
212212 func TestHandlePopupCreate_CleansUpPreRegisteredPopupWebViewWhenInsertionFails(t *testing.T) {
213213 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()
215215 popupWV.EXPECT().SetCallbacks(mock.Anything).Once()
216216 popupWV.EXPECT().Destroy().Once()
217217
@@ -238,8 +238,8 @@ func TestHandlePopupCreate_UsesRelatedWebViewWhenPopupDisablesJavaScriptAccess(t
238238 parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once()
239239
240240 popupWV := mocks.NewMockWebView(t)
241- popupWV.EXPECT().ID().Return(port.WebViewID(150)).Once()
242- popupWV.EXPECT().Generation().Return(uint64(1)).Once()
241+ popupWV.EXPECT().ID().Return(port.WebViewID(150)).Maybe()
242+ popupWV.EXPECT().Generation().Return(uint64(1)).Maybe()
243243 popupWV.EXPECT().SetCallbacks(mock.Anything).Once()
244244 popupWV.EXPECT().IsLoading().Return(false).Once()
245245 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 (
77
88 "github.com/bnema/dumber/internal/domain/entity"
99 "github.com/bnema/dumber/internal/logging"
10+ "github.com/bnema/puregotk/v4/glib"
11+ "github.com/bnema/puregotk/v4/gobject"
1012 "github.com/bnema/puregotk/v4/gtk"
1113 )
1214
@@ -24,19 +26,28 @@ type MotionCallback func()
2426 // HoverHandler handles mouse hover events for focus-follows-mouse behavior.
2527 // It uses a debounce timer to avoid rapid focus switches.
2628 type HoverHandler struct {
27- motionCtrl *gtk.EventControllerMotion
28- paneID entity.PaneID
29- onEnter HoverCallback
30- onMotion MotionCallback
29+ attachedWidget *gtk.Widget
30+ motionCtrl *gtk.EventControllerMotion
31+ enterSignalID uint
32+ leaveSignalID uint
33+ motionSignalID uint
34+ paneID entity.PaneID
35+ onEnter HoverCallback
36+ onMotion MotionCallback
3137
3238 // Callback retention: must stay reachable by Go GC.
3339 enterCb func(gtk.EventControllerMotion, float64, float64)
3440 leaveCb func(gtk.EventControllerMotion)
3541 motionCb func(gtk.EventControllerMotion, float64, float64)
3642
37- timer *time.Timer
38- timerMu sync.Mutex
39- canceled bool
43+ timerMu sync.Mutex
44+ sourceID uint
45+ generation uint64
46+ detached bool
47+ schedule func(uint, func(uintptr) bool) uint
48+ removeSource func(uint) bool
49+ disconnectSignal func(*gtk.EventControllerMotion, uint)
50+ removeController func(*gtk.Widget, *gtk.EventController)
4051
4152 ctx context.Context
4253 }
@@ -47,8 +58,12 @@ func NewHoverHandler(ctx context.Context, paneID entity.PaneID) *HoverHandler {
4758 log.Debug().Str("pane_id", string(paneID)).Msg("creating hover handler")
4859
4960 return &HoverHandler{
50- ctx: ctx,
51- paneID: paneID,
61+ ctx: ctx,
62+ paneID: paneID,
63+ schedule: scheduleHoverSource,
64+ removeSource: glib.SourceRemove,
65+ disconnectSignal: disconnectHoverSignal,
66+ removeController: removeHoverController,
5267 }
5368 }
5469
@@ -73,6 +88,15 @@ func (h *HoverHandler) AttachTo(widget *gtk.Widget) {
7388 return
7489 }
7590
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+
76100 h.motionCtrl = gtk.NewEventControllerMotion()
77101 if h.motionCtrl == nil {
78102 log.Error().Msg("failed to create motion controller")
@@ -83,13 +107,13 @@ func (h *HoverHandler) AttachTo(widget *gtk.Widget) {
83107 h.enterCb = func(_ gtk.EventControllerMotion, _ float64, _ float64) {
84108 h.handleEnter()
85109 }
86- h.motionCtrl.ConnectEnter(&h.enterCb)
110+ h.enterSignalID = h.motionCtrl.ConnectEnter(&h.enterCb)
87111
88112 // Connect leave handler to cancel pending focus
89113 h.leaveCb = func(_ gtk.EventControllerMotion) {
90114 h.handleLeave()
91115 }
92- h.motionCtrl.ConnectLeave(&h.leaveCb)
116+ h.leaveSignalID = h.motionCtrl.ConnectLeave(&h.leaveCb)
93117
94118 // Connect motion handler to detect intentional mouse movement.
95119 // Unlike enter/leave, motion only fires when the cursor physically moves
@@ -99,7 +123,7 @@ func (h *HoverHandler) AttachTo(widget *gtk.Widget) {
99123 h.onMotion()
100124 }
101125 }
102- h.motionCtrl.ConnectMotion(&h.motionCb)
126+ h.motionSignalID = h.motionCtrl.ConnectMotion(&h.motionCb)
103127
104128 // Add controller to widget
105129 widget.AddController(&h.motionCtrl.EventController)
@@ -107,32 +131,49 @@ func (h *HoverHandler) AttachTo(widget *gtk.Widget) {
107131 log.Debug().Str("pane_id", string(h.paneID)).Msg("hover handler attached to widget")
108132 }
109133
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+
110141 // handleEnter processes the mouse enter event with debouncing.
111142 func (h *HoverHandler) handleEnter() {
112143 h.timerMu.Lock()
113- defer h.timerMu.Unlock()
114-
115- // Cancel any existing timer
116- if h.timer != nil {
117- h.timer.Stop()
144+ if h.detached {
145+ h.timerMu.Unlock()
146+ return
118147 }
119-
120- h.canceled = false
121-
122- // Start new debounce timer
123- h.timer = time.AfterFunc(HoverFocusDelay, func() {
148+ h.cancelSourceLocked()
149+ h.generation++
150+ generation := h.generation
151+ schedule := h.schedule
152+ h.sourceID = schedule(uint(HoverFocusDelay.Milliseconds()), func(_ uintptr) bool {
124153 h.timerMu.Lock()
125- canceled := h.canceled
126- h.timerMu.Unlock()
127-
128- if canceled {
129- return
154+ if h.detached || h.generation != generation {
155+ h.timerMu.Unlock()
156+ return false
130157 }
158+ h.sourceID = 0
159+ callback := h.onEnter
160+ paneID := h.paneID
161+ h.timerMu.Unlock()
131162
132- if h.onEnter != nil {
133- h.onEnter(h.paneID)
163+ if callback != nil {
164+ callback(paneID)
134165 }
166+ return false
135167 })
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
136177 }
137178
138179 // handleLeave cancels any pending focus switch.
@@ -146,24 +187,61 @@ func (h *HoverHandler) Cancel() {
146187 h.timerMu.Lock()
147188 defer h.timerMu.Unlock()
148189
149- h.canceled = true
150- if h.timer != nil {
151- h.timer.Stop()
152- h.timer = nil
153- }
190+ h.generation++
191+ h.cancelSourceLocked()
154192 }
155193
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.
157197 func (h *HoverHandler) Detach() {
158198 h.timerMu.Lock()
159- if h.timer != nil {
160- h.timer.Stop()
161- h.timer = nil
162- }
163- h.timerMu.Unlock()
164-
199+ h.detached = true
200+ h.generation++
201+ h.cancelSourceLocked()
202+ h.onEnter = nil
203+ h.onMotion = nil
204+ widget := h.attachedWidget
205+ controller := h.motionCtrl
206+ enterID := h.enterSignalID
207+ leaveID := h.leaveSignalID
208+ motionID := h.motionSignalID
209+ h.attachedWidget = nil
165210 h.motionCtrl = nil
211+ h.enterSignalID = 0
212+ h.leaveSignalID = 0
213+ h.motionSignalID = 0
166214 h.enterCb = nil
167215 h.leaveCb = nil
168216 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+ }
169247 }
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 @@
11 package layout
22
33 import (
4+ "sync"
5+
46 "github.com/bnema/puregotk/v4/gdk"
7+ "github.com/bnema/puregotk/v4/glib"
58 "github.com/bnema/puregotk/v4/gobject"
69 "github.com/bnema/puregotk/v4/graphene"
710 "github.com/bnema/puregotk/v4/gtk"
@@ -82,8 +85,14 @@ func (w *gtkWidget) ComputePoint(target Widget) (x, y float64, ok bool) {
8285 // gtkPaned wraps gtk.Paned to implement PanedWidget.
8386 type gtkPaned struct {
8487 inner *gtk.Paned
88+
89+ tickMu sync.Mutex
90+ tickCallbacks map[uint]*gtk.TickCallback
8591 }
8692
93+// unrefTickCallback is the canonical puregotk/purego callback-slot release.
94+var unrefTickCallback = glib.UnrefCallback
95+
8796 func (p *gtkPaned) Show() { p.inner.Show() }
8897 func (p *gtkPaned) Hide() { p.inner.Hide() }
8998 func (p *gtkPaned) SetVisible(visible bool) { p.inner.SetVisible(visible) }
@@ -176,10 +185,45 @@ func (p *gtkPaned) ConnectNotifyPosition(callback func()) uint {
176185 }
177186
178187 func (p *gtkPaned) AddTickCallback(callback func() bool) uint {
179- cb := gtk.TickCallback(func(widget uintptr, frameClock uintptr, userData uintptr) bool {
180- return callback()
181- })
182- return p.inner.AddTickCallback(&cb, 0, nil)
188+ cb := new(gtk.TickCallback)
189+ var id uint
190+ *cb = func(_ uintptr, _ uintptr, _ uintptr) bool {
191+ keepRunning := callback()
192+ if !keepRunning {
193+ // GTK removes a false-returning source itself; release its matching
194+ // purego slot exactly once rather than merely dropping the Go pointer.
195+ p.releaseTickCallback(id)
196+ }
197+ return keepRunning
198+ }
199+ id = p.inner.AddTickCallback(cb, 0, nil)
200+ if id != 0 {
201+ p.tickMu.Lock()
202+ if p.tickCallbacks == nil {
203+ p.tickCallbacks = make(map[uint]*gtk.TickCallback)
204+ }
205+ p.tickCallbacks[id] = cb
206+ p.tickMu.Unlock()
207+ }
208+ return id
209+}
210+
211+func (p *gtkPaned) RemoveTickCallback(id uint) {
212+ p.inner.RemoveTickCallback(id)
213+ p.releaseTickCallback(id)
214+}
215+
216+func (p *gtkPaned) releaseTickCallback(id uint) {
217+ if id == 0 {
218+ return
219+ }
220+ p.tickMu.Lock()
221+ callback := p.tickCallbacks[id]
222+ delete(p.tickCallbacks, id)
223+ p.tickMu.Unlock()
224+ if callback != nil {
225+ _ = unrefTickCallback(callback)
226+ }
183227 }
184228
185229 func (p *gtkPaned) GetAllocatedWidth() int { return p.inner.GetAllocatedWidth() }
@@ -779,7 +823,7 @@ func (f *GtkWidgetFactory) NewPaned(orientation Orientation) PanedWidget {
779823 paned := gtk.NewPaned(orientation)
780824 paned.SetHexpand(true)
781825 paned.SetVexpand(true)
782- return &gtkPaned{inner: paned}
826+ return &gtkPaned{inner: paned, tickCallbacks: make(map[uint]*gtk.TickCallback)}
783827 }
784828
785829 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
24252425 return _c
24262426 }
24272427
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+
24282468 // SetCanFocus provides a mock function for the type MockPanedWidget
24292469 func (_mock *MockPanedWidget) SetCanFocus(canFocus bool) {
24302470 _mock.Called(canFocus)
M internal/ui/layout/split.go +51 -8
@@ -21,8 +21,11 @@ type SplitView struct {
2121 logger zerolog.Logger
2222
2323 onRatioChanged func(ratio float64)
24- pendingNotifyRatio float64
24+ idleAdd func(*glib.SourceFunc, uintptr) uint
2525 notifyDebounceTimer *time.Timer
26+ notifyGeneration uint64
27+ tickCallbackID uint
28+ cleanedUp bool
2629
2730 hasAppliedRatio bool
2831 suppressNotifyUntil time.Time
@@ -109,6 +112,7 @@ func NewSplitView(
109112 endChild: endChild,
110113 ratio: clampRatio(ratio),
111114 logger: log.With().Str("component", "split-view").Logger(),
115+ idleAdd: glib.IdleAdd,
112116 }
113117
114118 // Set children
@@ -151,25 +155,40 @@ func NewSplitView(
151155 ratio := clampRatio(float64(position) / float64(totalSize))
152156
153157 sv.mu.Lock()
158+ if sv.cleanedUp {
159+ sv.mu.Unlock()
160+ return
161+ }
154162 sv.ratio = ratio
155- sv.pendingNotifyRatio = ratio
156- onRatioChanged := sv.onRatioChanged
163+ sv.notifyGeneration++
164+ generation := sv.notifyGeneration
157165
158166 if sv.notifyDebounceTimer != nil {
159167 sv.notifyDebounceTimer.Stop()
160168 }
161169 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 {
163174 return
164175 }
176+
165177 cb := glib.SourceFunc(func(_ uintptr) bool {
166178 sv.mu.RLock()
167- pending := sv.pendingNotifyRatio
179+ if sv.cleanedUp || generation != sv.notifyGeneration {
180+ sv.mu.RUnlock()
181+ return false
182+ }
183+ onRatioChanged := sv.onRatioChanged
168184 sv.mu.RUnlock()
169- onRatioChanged(pending)
185+
186+ if onRatioChanged != nil {
187+ onRatioChanged(ratio)
188+ }
170189 return false
171190 })
172- glib.IdleAdd(&cb, 0)
191+ sv.idleAdd(&cb, 0)
173192 })
174193
175194 sv.mu.Unlock()
@@ -188,7 +207,7 @@ func NewSplitView(
188207 // Add tick callback to retry applying ratio every frame until successful.
189208 // This handles cases where allocation isn't ready even after Map signal.
190209 frames := 0
191- paned.AddTickCallback(func() bool {
210+ sv.tickCallbackID = paned.AddTickCallback(func() bool {
192211 frames++
193212 if sv.ApplyRatio() {
194213 sv.logger.Debug().
@@ -213,6 +232,30 @@ func NewSplitView(
213232 // The ratio is clamped to the range [0.0, 1.0].
214233 // Note: This sets the position based on ratio; actual pixel position
215234 // 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+
216259 func (sv *SplitView) SetRatio(ratio float64) {
217260 sv.mu.Lock()
218261 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 {
3333 logger zerolog.Logger
3434 nodeToWidget map[string]Widget
3535 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
3639 // paneToStack maps pane IDs to their containing StackedView.
3740 // Every leaf pane is wrapped in a StackedView for easy stacking later.
3841 paneToStack map[string]*StackedView
@@ -52,6 +55,7 @@ func NewTreeRenderer(ctx context.Context, factory WidgetFactory, paneViewFactory
5255 logger: log.With().Str("component", "tree-renderer").Logger(),
5356 nodeToWidget: make(map[string]Widget),
5457 splitOrientation: make(map[string]Orientation),
58+ splitViews: make(map[string]*SplitView),
5559 paneToStack: make(map[string]*StackedView),
5660 }
5761 }
@@ -73,10 +77,7 @@ func (tr *TreeRenderer) Build(ctx context.Context, root *entity.PaneNode) (Widge
7377 tr.mu.Lock()
7478 defer tr.mu.Unlock()
7579
76- // Clear previous mappings
77- tr.nodeToWidget = make(map[string]Widget)
78- tr.splitOrientation = make(map[string]Orientation)
79- tr.paneToStack = make(map[string]*StackedView)
80+ tr.clearInternal()
8081
8182 return tr.renderNode(ctx, root)
8283 }
@@ -189,6 +190,7 @@ func (tr *TreeRenderer) renderSplit(ctx context.Context, node *entity.PaneNode)
189190 }
190191 })
191192 tr.splitOrientation[node.ID] = orientation
193+ tr.splitViews[node.ID] = splitView
192194
193195 return splitView.Widget(), nil
194196 }
@@ -264,13 +266,26 @@ func (tr *TreeRenderer) NodeCount() int {
264266 return len(tr.nodeToWidget)
265267 }
266268
267-// Clear removes all node-to-widget mappings.
269+// Clear releases all renderer-owned split callbacks and removes mappings.
268270 func (tr *TreeRenderer) Clear() {
269271 tr.mu.Lock()
270272 defer tr.mu.Unlock()
271273
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+ }
272285 tr.nodeToWidget = make(map[string]Widget)
273286 tr.splitOrientation = make(map[string]Orientation)
287+ tr.splitViews = make(map[string]*SplitView)
288+ tr.paneToStack = make(map[string]*StackedView)
274289 }
275290
276291 // RegisterWidget adds or updates a node-to-widget mapping.
@@ -297,8 +312,12 @@ func (tr *TreeRenderer) UnregisterWidget(nodeID string) {
297312 tr.mu.Lock()
298313 defer tr.mu.Unlock()
299314
315+ if splitView := tr.splitViews[nodeID]; splitView != nil {
316+ splitView.Cleanup()
317+ }
300318 delete(tr.nodeToWidget, nodeID)
301319 delete(tr.splitOrientation, nodeID)
320+ delete(tr.splitViews, nodeID)
302321 }
303322
304323 // 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 {
9999 ConnectMap(callback func()) uint
100100 ConnectNotifyPosition(callback func()) uint
101101
102- // Tick callback for frame-based updates (returns signal ID)
103- // Callback returns true to continue, false to stop
102+ // Tick callback for frame-based updates (returns signal ID).
103+ // Callback returns true to continue, false to stop.
104104 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)
105108
106109 // Handle appearance
107110 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