detached c811fd0

Merge pull request #354 from bnema/fix/startup-trace-contract

fix(startup): scope trace to accelerated CEF
commit
c811fd01b598658155acfdb6b2a7a19fc52a81d5
author
bnema · · 2026-07-15 08:57 UTC
committer
GitHub · · 2026-07-15 08:57 UTC
merge of
80795e3 + 6949e93 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
16 files · +392 -301
M cmd/dumber/main.go +27 -22
@@ -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,11 +141,16 @@ 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() {
144- // This is intentionally before subprocess detection: each process records
145- // its own first observable main event before CEF can take over execution.
146- logging.InitStartupTrace("")
147- logging.Trace().Mark("process_entry")
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()}
148154
149155 // CEF subprocess handling: when CEF re-launches this binary with
150156 // --type=renderer/gpu/etc, we must call ExecuteProcess before anything
@@ -175,6 +181,7 @@ func main() {
175181 // Run GUI mode for browse command
176182 if mode == launchModeBrowse {
177183 cfg := initConfig()
184+ timing.configComplete = time.Now()
178185 configureBrowserLaunchRelay(cfg)
179186 startupURL := domainurl.ResolveBrowserStartupURL(browseURL)
180187 if forwarded, err := tryForwardBrowseURLToRunningInstance(context.Background(), browserLaunchRelay, startupURL); err != nil {
@@ -191,7 +198,7 @@ func main() {
191198 initialURL = startupURL
192199 restoreSessionID = os.Getenv("DUMBER_RESTORE_SESSION")
193200 os.Args = os.Args[:1]
194- os.Exit(runGUI(cfg))
201+ os.Exit(runGUI(cfg, timing))
195202 return
196203 }
197204
@@ -212,7 +219,7 @@ func main() {
212219 cmd.Execute()
213220 }
214221
215-func runGUI(cfg *config.Config) int {
222+func runGUI(cfg *config.Config, timing startupTiming) int {
216223 bootstrap.ApplyGTKIMModuleFallbackDefault(os.Stderr)
217224
218225 runtime.LockOSThread()
@@ -221,12 +228,13 @@ func runGUI(cfg *config.Config) int {
221228
222229 if cfg == nil {
223230 cfg = initConfig()
231+ timing.configComplete = time.Now()
224232 configureBrowserLaunchRelay(cfg)
225233 }
226- logging.Trace().Mark("config_complete")
227234 timer.Mark("config")
228235
229- ctx := initStartupContextWithTrace(cfg)
236+ ctx := initStartupContext(cfg)
237+ activateCEFStartupTraceForGUI(cfg, timing, logging.FromContext(ctx), infracef.ActivateStartupTrace)
230238 applyCEFRenderStackDefault(ctx, cfg)
231239 timer.Mark("logger")
232240 bootstrapLog := logging.FromContext(ctx)
@@ -259,7 +267,6 @@ func runGUI(cfg *config.Config) int {
259267 timer.Mark("session")
260268
261269 log := logging.FromContext(ctx)
262- logging.Trace().UpdateLogger(log)
263270 logCoreDumpLimits(ctx)
264271
265272 engine.SetHandlerContext(ctx)
@@ -303,7 +310,7 @@ func runStandaloneOmnibox() int {
303310
304311 cfg := initConfig()
305312 configureBrowserLaunchRelay(cfg)
306- ctx := initStartupContextWithTrace(cfg)
313+ ctx := initStartupContext(cfg)
307314 applyCEFRenderStackDefault(ctx, cfg)
308315
309316 initResult, err := runParallelInitPhase(ctx, cfg)
@@ -418,20 +425,19 @@ func resolveCurrentExecutable(executable func() (string, error)) (string, error)
418425 return executable()
419426 }
420427
421-func initStartupContextWithTrace(cfg *config.Config) context.Context {
422- logging.InitStartupTrace(cfg.Logging.Level)
423-
424- ctx := initStartupContext(cfg)
425- bootstrapLog := logging.FromContext(ctx)
426-
427- logging.Trace().SetLogger(bootstrapLog)
428- logging.Trace().Mark("logger_init")
429-
430- 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)
431438 }
432439
433440 func runParallelInitPhase(ctx context.Context, cfg *config.Config) (*bootstrap.ParallelInitResult, error) {
434- logging.Trace().Mark("parallel_start")
435441 initResult, err := bootstrap.RunParallelInit(bootstrap.ParallelInitInput{
436442 Ctx: ctx,
437443 Config: cfg,
@@ -440,7 +446,6 @@ func runParallelInitPhase(ctx context.Context, cfg *config.Config) (*bootstrap.P
440446 handleParallelInitError(ctx, err)
441447 return nil, err
442448 }
443- logging.Trace().Mark("parallel_done")
444449 return initResult, nil
445450 }
446451
@@ -10,6 +10,7 @@ import (
10 "runtime"10 "runtime"
11 "strings"11 "strings"
12 "syscall"12 "syscall"
13+ "time"
13 14
14 "github.com/bnema/dumber/internal/application/port"15 "github.com/bnema/dumber/internal/application/port"
15 "github.com/bnema/dumber/internal/application/usecase"16 "github.com/bnema/dumber/internal/application/usecase"
@@ -140,11 +141,16 @@ func configureBrowserLaunchRelay(cfg *config.Config) {
140 browserLaunchRelay = relay141 browserLaunchRelay = relay
141 }142 }
142 143
144+type startupTiming struct {
145+ processEntry time.Time
146+ configComplete time.Time
147+}
148+
143 func main() {149 func main() {
144- // This is intentionally before subprocess detection: each process records150+ // CEF is not known until configuration is complete. Keep these neutral
145- // its own first observable main event before CEF can take over execution.151+ // timestamps so a CEF GUI trace can be seeded truthfully without creating
146- logging.InitStartupTrace("")152+ // one for WebKit, the standalone omnibox, CLI, or CEF helper processes.
147- logging.Trace().Mark("process_entry")153+ timing := startupTiming{processEntry: time.Now()}
148 154
149 // CEF subprocess handling: when CEF re-launches this binary with155 // CEF subprocess handling: when CEF re-launches this binary with
150 // --type=renderer/gpu/etc, we must call ExecuteProcess before anything156 // --type=renderer/gpu/etc, we must call ExecuteProcess before anything
@@ -175,6 +181,7 @@ func main() {
175 // Run GUI mode for browse command181 // Run GUI mode for browse command
176 if mode == launchModeBrowse {182 if mode == launchModeBrowse {
177 cfg := initConfig()183 cfg := initConfig()
184+ timing.configComplete = time.Now()
178 configureBrowserLaunchRelay(cfg)185 configureBrowserLaunchRelay(cfg)
179 startupURL := domainurl.ResolveBrowserStartupURL(browseURL)186 startupURL := domainurl.ResolveBrowserStartupURL(browseURL)
180 if forwarded, err := tryForwardBrowseURLToRunningInstance(context.Background(), browserLaunchRelay, startupURL); err != nil {187 if forwarded, err := tryForwardBrowseURLToRunningInstance(context.Background(), browserLaunchRelay, startupURL); err != nil {
@@ -191,7 +198,7 @@ func main() {
191 initialURL = startupURL198 initialURL = startupURL
192 restoreSessionID = os.Getenv("DUMBER_RESTORE_SESSION")199 restoreSessionID = os.Getenv("DUMBER_RESTORE_SESSION")
193 os.Args = os.Args[:1]200 os.Args = os.Args[:1]
194- os.Exit(runGUI(cfg))201+ os.Exit(runGUI(cfg, timing))
195 return202 return
196 }203 }
197 204
@@ -212,7 +219,7 @@ func main() {
212 cmd.Execute()219 cmd.Execute()
213 }220 }
214 221
215-func runGUI(cfg *config.Config) int {222+func runGUI(cfg *config.Config, timing startupTiming) int {
216 bootstrap.ApplyGTKIMModuleFallbackDefault(os.Stderr)223 bootstrap.ApplyGTKIMModuleFallbackDefault(os.Stderr)
217 224
218 runtime.LockOSThread()225 runtime.LockOSThread()
@@ -221,12 +228,13 @@ func runGUI(cfg *config.Config) int {
221 228
222 if cfg == nil {229 if cfg == nil {
223 cfg = initConfig()230 cfg = initConfig()
231+ timing.configComplete = time.Now()
224 configureBrowserLaunchRelay(cfg)232 configureBrowserLaunchRelay(cfg)
225 }233 }
226- logging.Trace().Mark("config_complete")
227 timer.Mark("config")234 timer.Mark("config")
228 235
229- ctx := initStartupContextWithTrace(cfg)236+ ctx := initStartupContext(cfg)
237+ activateCEFStartupTraceForGUI(cfg, timing, logging.FromContext(ctx), infracef.ActivateStartupTrace)
230 applyCEFRenderStackDefault(ctx, cfg)238 applyCEFRenderStackDefault(ctx, cfg)
231 timer.Mark("logger")239 timer.Mark("logger")
232 bootstrapLog := logging.FromContext(ctx)240 bootstrapLog := logging.FromContext(ctx)
@@ -259,7 +267,6 @@ func runGUI(cfg *config.Config) int {
259 timer.Mark("session")267 timer.Mark("session")
260 268
261 log := logging.FromContext(ctx)269 log := logging.FromContext(ctx)
262- logging.Trace().UpdateLogger(log)
263 logCoreDumpLimits(ctx)270 logCoreDumpLimits(ctx)
264 271
265 engine.SetHandlerContext(ctx)272 engine.SetHandlerContext(ctx)
@@ -303,7 +310,7 @@ func runStandaloneOmnibox() int {
303 310
304 cfg := initConfig()311 cfg := initConfig()
305 configureBrowserLaunchRelay(cfg)312 configureBrowserLaunchRelay(cfg)
306- ctx := initStartupContextWithTrace(cfg)313+ ctx := initStartupContext(cfg)
307 applyCEFRenderStackDefault(ctx, cfg)314 applyCEFRenderStackDefault(ctx, cfg)
308 315
309 initResult, err := runParallelInitPhase(ctx, cfg)316 initResult, err := runParallelInitPhase(ctx, cfg)
@@ -418,20 +425,19 @@ func resolveCurrentExecutable(executable func() (string, error)) (string, error)
418 return executable()425 return executable()
419 }426 }
420 427
421-func initStartupContextWithTrace(cfg *config.Config) context.Context {428+func activateCEFStartupTraceForGUI(
422- logging.InitStartupTrace(cfg.Logging.Level)429+ cfg *config.Config,
423-430+ timing startupTiming,
424- ctx := initStartupContext(cfg)431+ logger *zerolog.Logger,
425- bootstrapLog := logging.FromContext(ctx)432+ activate func(time.Time, time.Time, *zerolog.Logger),
426-433+) {
427- logging.Trace().SetLogger(bootstrapLog)434+ if cfg == nil || cfg.Engine.ResolveEngineType() != config.EngineTypeCEF {
428- logging.Trace().Mark("logger_init")435+ return
429-436+ }
430- return ctx437+ activate(timing.processEntry, timing.configComplete, logger)
431 }438 }
432 439
433 func runParallelInitPhase(ctx context.Context, cfg *config.Config) (*bootstrap.ParallelInitResult, error) {440 func runParallelInitPhase(ctx context.Context, cfg *config.Config) (*bootstrap.ParallelInitResult, error) {
434- logging.Trace().Mark("parallel_start")
435 initResult, err := bootstrap.RunParallelInit(bootstrap.ParallelInitInput{441 initResult, err := bootstrap.RunParallelInit(bootstrap.ParallelInitInput{
436 Ctx: ctx,442 Ctx: ctx,
437 Config: cfg,443 Config: cfg,
@@ -440,7 +446,6 @@ func runParallelInitPhase(ctx context.Context, cfg *config.Config) (*bootstrap.P
440 handleParallelInitError(ctx, err)446 handleParallelInitError(ctx, err)
441 return nil, err447 return nil, err
442 }448 }
443- logging.Trace().Mark("parallel_done")
444 return initResult, nil449 return initResult, nil
445 }450 }
446 451
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(),
@@ -4,12 +4,14 @@ import (
4 "context"4 "context"
5 "errors"5 "errors"
6 "testing"6 "testing"
7+ "time"
7 8
8 "github.com/bnema/dumber/internal/application/port/mocks"9 "github.com/bnema/dumber/internal/application/port/mocks"
9 "github.com/bnema/dumber/internal/bootstrap"10 "github.com/bnema/dumber/internal/bootstrap"
10 "github.com/bnema/dumber/internal/infrastructure/colorscheme"11 "github.com/bnema/dumber/internal/infrastructure/colorscheme"
11 "github.com/bnema/dumber/internal/infrastructure/config"12 "github.com/bnema/dumber/internal/infrastructure/config"
12 "github.com/bnema/dumber/internal/infrastructure/desktop"13 "github.com/bnema/dumber/internal/infrastructure/desktop"
14+ "github.com/rs/zerolog"
13 )15 )
14 16
15 func TestLaunchModeFromArgs_DetectsStandaloneOmnibox(t *testing.T) {17 func TestLaunchModeFromArgs_DetectsStandaloneOmnibox(t *testing.T) {
@@ -172,6 +174,20 @@ func TestResolveCurrentExecutable_PropagatesError(t *testing.T) {
172 }174 }
173 }175 }
174 176
177+func TestWebKitGUIStartupDoesNotActivateCEFTrace(t *testing.T) {
178+ cfg := &config.Config{}
179+ cfg.Engine.Type = config.EngineTypeWebKit
180+ activationCalls := 0
181+
182+ activateCEFStartupTraceForGUI(cfg, startupTiming{}, nil, func(time.Time, time.Time, *zerolog.Logger) {
183+ activationCalls++
184+ })
185+
186+ if activationCalls != 0 {
187+ t.Fatal("WebKit GUI startup must not initialize, record, or summarize the CEF first-presentation trace")
188+ }
189+}
190+
175 func TestPreInitializeAdwaitaForCEF_InitializesAndMarksDetector(t *testing.T) {191 func TestPreInitializeAdwaitaForCEF_InitializesAndMarksDetector(t *testing.T) {
176 initResult := &bootstrap.ParallelInitResult{192 initResult := &bootstrap.ParallelInitResult{
177 AdwaitaDetector: colorscheme.NewAdwaitaDetector(),193 AdwaitaDetector: colorscheme.NewAdwaitaDetector(),
M docs/first-presentation.md +23 -7
@@ -1,7 +1,14 @@
1-# First presentation startup trace
1+# CEF accelerated first-presentation startup trace
22
3-Dumber records one cold-start timeline for each GUI process. The trace begins
4-at `process_entry`, before CEF subprocess handling, and accepts these one-shot
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
512 milestones only in order:
613
714 1. `process_entry`
@@ -13,11 +20,19 @@ milestones only in order:
1320 7. `first_dmabuf_texture_swap` (after `GtkPicture.SetPaintable` succeeds)
1421 8. `first_gtk_presentation` (the subsequent GTK frame-clock after-paint)
1522
16-CEF library-load completion is intentionally not recorded: `InitWithApp` is an
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
1727 opaque operation. Duplicate, unknown, and out-of-order transitions are
1828 rejected. At the last milestone Dumber emits exactly one normal-level JSON
19-summary (`startup_trace: first presentation`) with the selected `backend`, an
20-`incomplete_reason`, total milliseconds, and monotonic milestones.
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.
2136
2237 ## Reproducible collection
2338
@@ -29,7 +44,8 @@ DUMBER_MACHINE_GPU_PROFILE=integrated-gpu \
2944 scripts/collect_first_presentation.sh
3045 ```
3146
32-By default each collection is a fresh directory below
47+The collector is for the accelerated CEF/DMABUF/GTK contract above. By default
48+each collection is a fresh directory below
3349 `$XDG_STATE_HOME/dumber/roadmap-evidence` (or
3450 `$HOME/.local/state/dumber/roadmap-evidence` when `XDG_STATE_HOME` is unset),
3551 not in the repository. `DUMBER_FIRST_PRESENTATION_OUTPUT` may override it only
@@ -1,7 +1,14 @@
1-# First presentation startup trace1+# CEF accelerated first-presentation startup trace
2 2
3-Dumber records one cold-start timeline for each GUI process. The trace begins3+Dumber records this cold-start timeline **only for the accelerated CEF → DMABUF
4-at `process_entry`, before CEF subprocess handling, and accepts these one-shot4+→ 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
5 milestones only in order:12 milestones only in order:
6 13
7 1. `process_entry`14 1. `process_entry`
@@ -13,11 +20,19 @@ milestones only in order:
13 7. `first_dmabuf_texture_swap` (after `GtkPicture.SetPaintable` succeeds)20 7. `first_dmabuf_texture_swap` (after `GtkPicture.SetPaintable` succeeds)
14 8. `first_gtk_presentation` (the subsequent GTK frame-clock after-paint)21 8. `first_gtk_presentation` (the subsequent GTK frame-clock after-paint)
15 22
16-CEF library-load completion is intentionally not recorded: `InitWithApp` is an23+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
17 opaque operation. Duplicate, unknown, and out-of-order transitions are27 opaque operation. Duplicate, unknown, and out-of-order transitions are
18 rejected. At the last milestone Dumber emits exactly one normal-level JSON28 rejected. At the last milestone Dumber emits exactly one normal-level JSON
19-summary (`startup_trace: first presentation`) with the selected `backend`, an29+summary (`startup_trace: first presentation`) with the selected CEF render
20-`incomplete_reason`, total milliseconds, and monotonic milestones.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.
21 36
22 ## Reproducible collection37 ## Reproducible collection
23 38
@@ -29,7 +44,8 @@ DUMBER_MACHINE_GPU_PROFILE=integrated-gpu \
29 scripts/collect_first_presentation.sh44 scripts/collect_first_presentation.sh
30 ```45 ```
31 46
32-By default each collection is a fresh directory below47+The collector is for the accelerated CEF/DMABUF/GTK contract above. By default
48+each collection is a fresh directory below
33 `$XDG_STATE_HOME/dumber/roadmap-evidence` (or49 `$XDG_STATE_HOME/dumber/roadmap-evidence` (or
34 `$HOME/.local/state/dumber/roadmap-evidence` when `XDG_STATE_HOME` is unset),50 `$HOME/.local/state/dumber/roadmap-evidence` when `XDG_STATE_HOME` is unset),
35 not in the repository. `DUMBER_FIRST_PRESENTATION_OUTPUT` may override it only51 not in the repository. `DUMBER_FIRST_PRESENTATION_OUTPUT` may override it only
M internal/infrastructure/cef/engine_init.go +3 -3
@@ -56,7 +56,7 @@ func NewEngine(
5656 return nil, err
5757 }
5858 cef2gtk.ConfigureRenderStackEnvironment(renderStackPlan)
59- logging.Trace().SetBackend(renderStackPlan.Backend.String())
59+ activeStartupTrace().SetBackend(renderStackPlan.Backend.String())
6060
6161 settings, err := prepareCEFSettings(opts, paths, cfg, logger)
6262 if err != nil {
@@ -218,11 +218,11 @@ func initializeCEF(eng *Engine, settings purecef.Settings, logger *zerolog.Logge
218218 logger.Debug().Msg("cef: calling InitWithApp")
219219 // InitWithApp encapsulates loading libcef, so only the begin boundary is
220220 // observable here; do not invent a separate library-load completion event.
221- logging.Trace().Mark("cef_library_load_begin")
221+ activeStartupTrace().Mark("cef_library_load_begin")
222222 if err := purecef.InitWithApp(settings, app); err != nil {
223223 return fmt.Errorf("cef.InitWithApp: %w", err)
224224 }
225- logging.Trace().Mark("cef_initialized")
225+ activeStartupTrace().Mark("cef_initialized")
226226 if libcefPath := loadedLibCEFPath(); libcefPath != "" {
227227 logger.Info().Str("libcef_path", libcefPath).Msg("cef: runtime library loaded")
228228 } else {
@@ -56,7 +56,7 @@ func NewEngine(
56 return nil, err56 return nil, err
57 }57 }
58 cef2gtk.ConfigureRenderStackEnvironment(renderStackPlan)58 cef2gtk.ConfigureRenderStackEnvironment(renderStackPlan)
59- logging.Trace().SetBackend(renderStackPlan.Backend.String())59+ activeStartupTrace().SetBackend(renderStackPlan.Backend.String())
60 60
61 settings, err := prepareCEFSettings(opts, paths, cfg, logger)61 settings, err := prepareCEFSettings(opts, paths, cfg, logger)
62 if err != nil {62 if err != nil {
@@ -218,11 +218,11 @@ func initializeCEF(eng *Engine, settings purecef.Settings, logger *zerolog.Logge
218 logger.Debug().Msg("cef: calling InitWithApp")218 logger.Debug().Msg("cef: calling InitWithApp")
219 // InitWithApp encapsulates loading libcef, so only the begin boundary is219 // InitWithApp encapsulates loading libcef, so only the begin boundary is
220 // observable here; do not invent a separate library-load completion event.220 // observable here; do not invent a separate library-load completion event.
221- logging.Trace().Mark("cef_library_load_begin")221+ activeStartupTrace().Mark("cef_library_load_begin")
222 if err := purecef.InitWithApp(settings, app); err != nil {222 if err := purecef.InitWithApp(settings, app); err != nil {
223 return fmt.Errorf("cef.InitWithApp: %w", err)223 return fmt.Errorf("cef.InitWithApp: %w", err)
224 }224 }
225- logging.Trace().Mark("cef_initialized")225+ activeStartupTrace().Mark("cef_initialized")
226 if libcefPath := loadedLibCEFPath(); libcefPath != "" {226 if libcefPath := loadedLibCEFPath(); libcefPath != "" {
227 logger.Info().Str("libcef_path", libcefPath).Msg("cef: runtime library loaded")227 logger.Info().Str("libcef_path", libcefPath).Msg("cef: runtime library loaded")
228 } else {228 } else {
M internal/infrastructure/cef/factory.go +1 -1
@@ -279,7 +279,7 @@ func (f *WebViewFactory) postPendingBrowserCreate(ctx context.Context, wv *WebVi
279279 // OnAfterCreated once the host is fully wired.
280280 initialURL := "about:blank"
281281 // This is the request boundary, not the later OnAfterCreated callback.
282- logging.Trace().Mark("browser_create_requested")
282+ activeStartupTrace().Mark("browser_create_requested")
283283 result := cefBrowserHostCreateBrowser(
284284 pc.windowInfo,
285285 pc.client,
@@ -279,7 +279,7 @@ func (f *WebViewFactory) postPendingBrowserCreate(ctx context.Context, wv *WebVi
279 // OnAfterCreated once the host is fully wired.279 // OnAfterCreated once the host is fully wired.
280 initialURL := "about:blank"280 initialURL := "about:blank"
281 // This is the request boundary, not the later OnAfterCreated callback.281 // This is the request boundary, not the later OnAfterCreated callback.
282- logging.Trace().Mark("browser_create_requested")282+ activeStartupTrace().Mark("browser_create_requested")
283 result := cefBrowserHostCreateBrowser(283 result := cefBrowserHostCreateBrowser(
284 pc.windowInfo,284 pc.windowInfo,
285 pc.client,285 pc.client,
R internal/logging/first_presentation_collector_test.go → internal/infrastructure/cef/first_presentation_collector_test.go +9 -9 renamed, 96% similar
@@ -1,4 +1,4 @@
1-package logging
1+package cef
22
33 import (
44 "encoding/json"
@@ -23,7 +23,7 @@ const validFirstPresentationLog = `{"message":"startup_trace: milestone","milest
2323 {"message":"startup_trace: first presentation","backend":"gdk-dmabuf","incomplete_reason":"","total_ms":7,"host":"alice"}`
2424
2525 func TestFirstPresentationCollectorNeverRecursivelyDeletesCallerOutput(t *testing.T) {
26- repoRoot, err := filepath.Abs(filepath.Join("..", ".."))
26+ repoRoot, err := filepath.Abs(filepath.Join("..", "..", ".."))
2727 require.NoError(t, err)
2828 script, err := os.ReadFile(filepath.Join(repoRoot, "scripts", "collect_first_presentation.sh"))
2929 require.NoError(t, err)
@@ -32,7 +32,7 @@ func TestFirstPresentationCollectorNeverRecursivelyDeletesCallerOutput(t *testin
3232 }
3333
3434 func TestFirstPresentationCollectorRejectsUnsafeOutputPaths(t *testing.T) {
35- repoRoot, err := filepath.Abs(filepath.Join("..", ".."))
35+ repoRoot, err := filepath.Abs(filepath.Join("..", "..", ".."))
3636 require.NoError(t, err)
3737 temp := t.TempDir()
3838 runtime := filepath.Join(temp, "cef-147-runtime")
@@ -109,7 +109,7 @@ func requireFileContents(t *testing.T, path, want string) {
109109 }
110110
111111 func TestFirstPresentationCollectorSanitizesMachineLocalValues(t *testing.T) {
112- repoRoot, err := filepath.Abs(filepath.Join("..", ".."))
112+ repoRoot, err := filepath.Abs(filepath.Join("..", "..", ".."))
113113 require.NoError(t, err)
114114 temp := t.TempDir()
115115 runtime := filepath.Join(temp, "cef-147-runtime")
@@ -169,7 +169,7 @@ func TestFirstPresentationCollectorSanitizesMachineLocalValues(t *testing.T) {
169169 }
170170
171171 func TestFirstPresentationCollectorDerivesSelectedImmutableModuleProvenance(t *testing.T) {
172- repoRoot, err := filepath.Abs(filepath.Join("..", ".."))
172+ repoRoot, err := filepath.Abs(filepath.Join("..", "..", ".."))
173173 require.NoError(t, err)
174174 temp := t.TempDir()
175175 runtime := filepath.Join(temp, "cef-147-runtime")
@@ -218,7 +218,7 @@ func TestFirstPresentationCollectorDerivesSelectedImmutableModuleProvenance(t *t
218218 }
219219
220220 func TestFirstPresentationCollectorRejectsNonImmutableModuleProvenanceWithoutLeaks(t *testing.T) {
221- repoRoot, err := filepath.Abs(filepath.Join("..", ".."))
221+ repoRoot, err := filepath.Abs(filepath.Join("..", "..", ".."))
222222 require.NoError(t, err)
223223
224224 for _, test := range []struct {
@@ -298,7 +298,7 @@ exit 1
298298 }
299299
300300 func TestFirstPresentationCollectorDefaultsToXDGStateEvidenceDirectory(t *testing.T) {
301- repoRoot, err := filepath.Abs(filepath.Join("..", ".."))
301+ repoRoot, err := filepath.Abs(filepath.Join("..", "..", ".."))
302302 require.NoError(t, err)
303303 temp := t.TempDir()
304304 runtime := filepath.Join(temp, "cef-147-runtime")
@@ -342,7 +342,7 @@ func envWithout(name string) []string {
342342 }
343343
344344 func TestFirstPresentationCollectorReadsProvenanceWithScopedGitSafeDirectory(t *testing.T) {
345- repoRoot, err := filepath.Abs(filepath.Join("..", ".."))
345+ repoRoot, err := filepath.Abs(filepath.Join("..", "..", ".."))
346346 require.NoError(t, err)
347347 temp := t.TempDir()
348348 runtime := filepath.Join(temp, "cef-147-runtime")
@@ -386,7 +386,7 @@ exit 128
386386 }
387387
388388 func TestFirstPresentationCollectorRejectsInconsistentTiming(t *testing.T) {
389- repoRoot, err := filepath.Abs(filepath.Join("..", ".."))
389+ repoRoot, err := filepath.Abs(filepath.Join("..", "..", ".."))
390390 require.NoError(t, err)
391391
392392 for _, test := range []struct {
@@ -1,4 +1,4 @@
1-package logging1+package cef
2 2
3 import (3 import (
4 "encoding/json"4 "encoding/json"
@@ -23,7 +23,7 @@ const validFirstPresentationLog = `{"message":"startup_trace: milestone","milest
23 {"message":"startup_trace: first presentation","backend":"gdk-dmabuf","incomplete_reason":"","total_ms":7,"host":"alice"}`23 {"message":"startup_trace: first presentation","backend":"gdk-dmabuf","incomplete_reason":"","total_ms":7,"host":"alice"}`
24 24
25 func TestFirstPresentationCollectorNeverRecursivelyDeletesCallerOutput(t *testing.T) {25 func TestFirstPresentationCollectorNeverRecursivelyDeletesCallerOutput(t *testing.T) {
26- repoRoot, err := filepath.Abs(filepath.Join("..", ".."))26+ repoRoot, err := filepath.Abs(filepath.Join("..", "..", ".."))
27 require.NoError(t, err)27 require.NoError(t, err)
28 script, err := os.ReadFile(filepath.Join(repoRoot, "scripts", "collect_first_presentation.sh"))28 script, err := os.ReadFile(filepath.Join(repoRoot, "scripts", "collect_first_presentation.sh"))
29 require.NoError(t, err)29 require.NoError(t, err)
@@ -32,7 +32,7 @@ func TestFirstPresentationCollectorNeverRecursivelyDeletesCallerOutput(t *testin
32 }32 }
33 33
34 func TestFirstPresentationCollectorRejectsUnsafeOutputPaths(t *testing.T) {34 func TestFirstPresentationCollectorRejectsUnsafeOutputPaths(t *testing.T) {
35- repoRoot, err := filepath.Abs(filepath.Join("..", ".."))35+ repoRoot, err := filepath.Abs(filepath.Join("..", "..", ".."))
36 require.NoError(t, err)36 require.NoError(t, err)
37 temp := t.TempDir()37 temp := t.TempDir()
38 runtime := filepath.Join(temp, "cef-147-runtime")38 runtime := filepath.Join(temp, "cef-147-runtime")
@@ -109,7 +109,7 @@ func requireFileContents(t *testing.T, path, want string) {
109 }109 }
110 110
111 func TestFirstPresentationCollectorSanitizesMachineLocalValues(t *testing.T) {111 func TestFirstPresentationCollectorSanitizesMachineLocalValues(t *testing.T) {
112- repoRoot, err := filepath.Abs(filepath.Join("..", ".."))112+ repoRoot, err := filepath.Abs(filepath.Join("..", "..", ".."))
113 require.NoError(t, err)113 require.NoError(t, err)
114 temp := t.TempDir()114 temp := t.TempDir()
115 runtime := filepath.Join(temp, "cef-147-runtime")115 runtime := filepath.Join(temp, "cef-147-runtime")
@@ -169,7 +169,7 @@ func TestFirstPresentationCollectorSanitizesMachineLocalValues(t *testing.T) {
169 }169 }
170 170
171 func TestFirstPresentationCollectorDerivesSelectedImmutableModuleProvenance(t *testing.T) {171 func TestFirstPresentationCollectorDerivesSelectedImmutableModuleProvenance(t *testing.T) {
172- repoRoot, err := filepath.Abs(filepath.Join("..", ".."))172+ repoRoot, err := filepath.Abs(filepath.Join("..", "..", ".."))
173 require.NoError(t, err)173 require.NoError(t, err)
174 temp := t.TempDir()174 temp := t.TempDir()
175 runtime := filepath.Join(temp, "cef-147-runtime")175 runtime := filepath.Join(temp, "cef-147-runtime")
@@ -218,7 +218,7 @@ func TestFirstPresentationCollectorDerivesSelectedImmutableModuleProvenance(t *t
218 }218 }
219 219
220 func TestFirstPresentationCollectorRejectsNonImmutableModuleProvenanceWithoutLeaks(t *testing.T) {220 func TestFirstPresentationCollectorRejectsNonImmutableModuleProvenanceWithoutLeaks(t *testing.T) {
221- repoRoot, err := filepath.Abs(filepath.Join("..", ".."))221+ repoRoot, err := filepath.Abs(filepath.Join("..", "..", ".."))
222 require.NoError(t, err)222 require.NoError(t, err)
223 223
224 for _, test := range []struct {224 for _, test := range []struct {
@@ -298,7 +298,7 @@ exit 1
298 }298 }
299 299
300 func TestFirstPresentationCollectorDefaultsToXDGStateEvidenceDirectory(t *testing.T) {300 func TestFirstPresentationCollectorDefaultsToXDGStateEvidenceDirectory(t *testing.T) {
301- repoRoot, err := filepath.Abs(filepath.Join("..", ".."))301+ repoRoot, err := filepath.Abs(filepath.Join("..", "..", ".."))
302 require.NoError(t, err)302 require.NoError(t, err)
303 temp := t.TempDir()303 temp := t.TempDir()
304 runtime := filepath.Join(temp, "cef-147-runtime")304 runtime := filepath.Join(temp, "cef-147-runtime")
@@ -342,7 +342,7 @@ func envWithout(name string) []string {
342 }342 }
343 343
344 func TestFirstPresentationCollectorReadsProvenanceWithScopedGitSafeDirectory(t *testing.T) {344 func TestFirstPresentationCollectorReadsProvenanceWithScopedGitSafeDirectory(t *testing.T) {
345- repoRoot, err := filepath.Abs(filepath.Join("..", ".."))345+ repoRoot, err := filepath.Abs(filepath.Join("..", "..", ".."))
346 require.NoError(t, err)346 require.NoError(t, err)
347 temp := t.TempDir()347 temp := t.TempDir()
348 runtime := filepath.Join(temp, "cef-147-runtime")348 runtime := filepath.Join(temp, "cef-147-runtime")
@@ -386,7 +386,7 @@ exit 128
386 }386 }
387 387
388 func TestFirstPresentationCollectorRejectsInconsistentTiming(t *testing.T) {388 func TestFirstPresentationCollectorRejectsInconsistentTiming(t *testing.T) {
389- repoRoot, err := filepath.Abs(filepath.Join("..", ".."))389+ repoRoot, err := filepath.Abs(filepath.Join("..", "..", ".."))
390 require.NoError(t, err)390 require.NoError(t, err)
391 391
392 for _, test := range []struct {392 for _, test := range []struct {
M internal/infrastructure/cef/render_handler_adapter.go +6 -6
@@ -26,19 +26,19 @@ var _ purecef.RenderHandler = (*dumberRenderHandler)(nil)
2626 // startupPresentationHooks consumes the one-shot facts emitted by
2727 // purego-cef2gtk. The bridge owns the native DMABUF and frame-clock boundaries;
2828 // Dumber only records their ordered application-level timeline.
29-func startupPresentationHooks() cef2gtk.Hooks {
29+func startupPresentationHooks(trace *startupTrace) cef2gtk.Hooks {
3030 return cef2gtk.Hooks{
3131 OnFirstAcceleratedPaint: func() {
32- logging.Trace().Mark("first_accelerated_paint_received")
32+ trace.Mark("first_accelerated_paint_received")
3333 },
3434 OnFirstDMABUFTextureSwap: func() {
35- logging.Trace().Mark("first_dmabuf_texture_swap")
35+ trace.Mark("first_dmabuf_texture_swap")
3636 },
3737 OnFirstPresentation: func() {
38- logging.Trace().MarkGTKAfterPaint()
38+ trace.MarkGTKAfterPaint()
3939 },
4040 OnDMABUFUnsupported: func() {
41- logging.Trace().SetIncompleteReason("dmabuf_texture_swap_unavailable")
41+ trace.SetIncompleteReason("dmabuf_texture_swap_unavailable")
4242 },
4343 }
4444 }
@@ -50,7 +50,7 @@ func newDumberRenderHandler(wv *WebView) purecef.RenderHandler {
5050 h := &dumberRenderHandler{wv: wv}
5151
5252 var unsupportedPaintOnce sync.Once
53- hooks := startupPresentationHooks()
53+ hooks := startupPresentationHooks(activeStartupTrace())
5454 hooks.OnUnsupportedPaint = func() {
5555 unsupportedPaintOnce.Do(func() {
5656 if wv.ctx != nil {
@@ -26,19 +26,19 @@ var _ purecef.RenderHandler = (*dumberRenderHandler)(nil)
26 // startupPresentationHooks consumes the one-shot facts emitted by26 // startupPresentationHooks consumes the one-shot facts emitted by
27 // purego-cef2gtk. The bridge owns the native DMABUF and frame-clock boundaries;27 // purego-cef2gtk. The bridge owns the native DMABUF and frame-clock boundaries;
28 // Dumber only records their ordered application-level timeline.28 // Dumber only records their ordered application-level timeline.
29-func startupPresentationHooks() cef2gtk.Hooks {29+func startupPresentationHooks(trace *startupTrace) cef2gtk.Hooks {
30 return cef2gtk.Hooks{30 return cef2gtk.Hooks{
31 OnFirstAcceleratedPaint: func() {31 OnFirstAcceleratedPaint: func() {
32- logging.Trace().Mark("first_accelerated_paint_received")32+ trace.Mark("first_accelerated_paint_received")
33 },33 },
34 OnFirstDMABUFTextureSwap: func() {34 OnFirstDMABUFTextureSwap: func() {
35- logging.Trace().Mark("first_dmabuf_texture_swap")35+ trace.Mark("first_dmabuf_texture_swap")
36 },36 },
37 OnFirstPresentation: func() {37 OnFirstPresentation: func() {
38- logging.Trace().MarkGTKAfterPaint()38+ trace.MarkGTKAfterPaint()
39 },39 },
40 OnDMABUFUnsupported: func() {40 OnDMABUFUnsupported: func() {
41- logging.Trace().SetIncompleteReason("dmabuf_texture_swap_unavailable")41+ trace.SetIncompleteReason("dmabuf_texture_swap_unavailable")
42 },42 },
43 }43 }
44 }44 }
@@ -50,7 +50,7 @@ func newDumberRenderHandler(wv *WebView) purecef.RenderHandler {
50 h := &dumberRenderHandler{wv: wv}50 h := &dumberRenderHandler{wv: wv}
51 51
52 var unsupportedPaintOnce sync.Once52 var unsupportedPaintOnce sync.Once
53- hooks := startupPresentationHooks()53+ hooks := startupPresentationHooks(activeStartupTrace())
54 hooks.OnUnsupportedPaint = func() {54 hooks.OnUnsupportedPaint = func() {
55 unsupportedPaintOnce.Do(func() {55 unsupportedPaintOnce.Do(func() {
56 if wv.ctx != nil {56 if wv.ctx != nil {
M internal/infrastructure/cef/startup_presentation_hooks_test.go +64 -1
@@ -1,16 +1,79 @@
11 package cef
22
33 import (
4+ "bytes"
5+ "encoding/json"
6+ "strings"
47 "testing"
8+ "time"
59
10+ "github.com/rs/zerolog"
611 "github.com/stretchr/testify/require"
712 )
813
914 func TestStartupPresentationHooksWireEveryUpstreamFirstPresentationCallback(t *testing.T) {
10- hooks := startupPresentationHooks()
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+ }
1129
30+ hooks := startupPresentationHooks(trace)
1231 require.NotNil(t, hooks.OnFirstAcceleratedPaint)
1332 require.NotNil(t, hooks.OnFirstDMABUFTextureSwap)
1433 require.NotNil(t, hooks.OnFirstPresentation)
1534 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)
1679 }
@@ -1,16 +1,79 @@
1 package cef1 package cef
2 2
3 import (3 import (
4+ "bytes"
5+ "encoding/json"
6+ "strings"
4 "testing"7 "testing"
8+ "time"
5 9
10+ "github.com/rs/zerolog"
6 "github.com/stretchr/testify/require"11 "github.com/stretchr/testify/require"
7 )12 )
8 13
9 func TestStartupPresentationHooksWireEveryUpstreamFirstPresentationCallback(t *testing.T) {14 func TestStartupPresentationHooksWireEveryUpstreamFirstPresentationCallback(t *testing.T) {
10- hooks := startupPresentationHooks()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+ }
11 29
30+ hooks := startupPresentationHooks(trace)
12 require.NotNil(t, hooks.OnFirstAcceleratedPaint)31 require.NotNil(t, hooks.OnFirstAcceleratedPaint)
13 require.NotNil(t, hooks.OnFirstDMABUFTextureSwap)32 require.NotNil(t, hooks.OnFirstDMABUFTextureSwap)
14 require.NotNil(t, hooks.OnFirstPresentation)33 require.NotNil(t, hooks.OnFirstPresentation)
15 require.NotNil(t, hooks.OnDMABUFUnsupported)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)
16 }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+}
@@ -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+}
R internal/logging/startup_trace_test.go → internal/infrastructure/cef/startup_trace_test.go +44 -17 renamed, 50% similar
@@ -1,4 +1,4 @@
1-package logging
1+package cef
22
33 import (
44 "bytes"
@@ -9,9 +9,37 @@ import (
99 "github.com/stretchr/testify/require"
1010 )
1111
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+
1240 func TestStartupTraceAcceptsOnlyOrderedOneShotMilestones(t *testing.T) {
1341 now := time.Unix(100, 0)
14- trace := newStartupTrace(func() time.Time { return now })
42+ trace := newStartupTrace(func() time.Time { return now }, now)
1543
1644 require.True(t, trace.Mark("process_entry"))
1745 require.False(t, trace.Mark("cef_initialized"), "out-of-order transition must be rejected")
@@ -36,33 +64,32 @@ func TestStartupTraceAcceptsOnlyOrderedOneShotMilestones(t *testing.T) {
3664 require.False(t, trace.Mark("first_gtk_presentation"))
3765 }
3866
39-func TestStartupTraceQuantizesDeltaToPublishedMilliseconds(t *testing.T) {
40- now := time.Unix(100, 0)
41- trace := newStartupTrace(func() time.Time { return now })
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)
4272
43- now = now.Add(1500 * time.Microsecond)
44- require.True(t, trace.Mark("process_entry"))
45- now = now.Add(900 * time.Microsecond)
46- require.True(t, trace.Mark("config_complete"))
73+ require.True(t, trace.markAt("process_entry", processEntry))
74+ require.True(t, trace.markAt("config_complete", configComplete))
4775
48- require.Equal(t, int64(1), trace.milestones[0].Elapsed.Milliseconds())
49- require.Equal(t, int64(1), trace.milestones[0].Delta.Milliseconds())
50- require.Equal(t, int64(2), trace.milestones[1].Elapsed.Milliseconds())
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())
5179 require.Equal(t, int64(1), trace.milestones[1].Delta.Milliseconds())
5280 }
5381
54-func TestStartupTraceFinishCannotFabricateFirstGTKPresentation(t *testing.T) {
82+func TestStartupTraceReservesFirstGTKPresentationForAfterPaint(t *testing.T) {
5583 now := time.Unix(100, 0)
56- trace := newStartupTrace(func() time.Time { return now })
84+ trace := newStartupTrace(func() time.Time { return now }, now)
5785
5886 for _, name := range startupMilestoneOrder[:len(startupMilestoneOrder)-1] {
5987 now = now.Add(time.Millisecond)
6088 require.True(t, trace.Mark(name))
6189 }
6290
63- trace.Finish() // Legacy callers do not observe the upstream GTK after-paint.
6491 require.Len(t, trace.milestones, len(startupMilestoneOrder)-1)
65- require.False(t, trace.Mark("first_gtk_presentation"), "generic callers cannot record the reserved milestone")
92+ require.False(t, trace.Mark("first_gtk_presentation"), "only the CEF-to-GTK after-paint hook can record the reserved milestone")
6693 require.False(t, trace.summaryEmitted)
6794 require.True(t, trace.MarkGTKAfterPaint(), "only the after-paint hook may record this milestone")
6895 }
@@ -71,7 +98,7 @@ func TestStartupTraceEmitsOneNormalSummaryAtFirstPresentation(t *testing.T) {
7198 var output bytes.Buffer
7299 logger := zerolog.New(&output)
73100 now := time.Unix(100, 0)
74- trace := newStartupTrace(func() time.Time { return now })
101+ trace := newStartupTrace(func() time.Time { return now }, now)
75102 trace.SetBackend("gdk-dmabuf")
76103 trace.SetLogger(&logger)
77104
@@ -1,4 +1,4 @@
1-package logging1+package cef
2 2
3 import (3 import (
4 "bytes"4 "bytes"
@@ -9,9 +9,37 @@ import (
9 "github.com/stretchr/testify/require"9 "github.com/stretchr/testify/require"
10 )10 )
11 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+
12 func TestStartupTraceAcceptsOnlyOrderedOneShotMilestones(t *testing.T) {40 func TestStartupTraceAcceptsOnlyOrderedOneShotMilestones(t *testing.T) {
13 now := time.Unix(100, 0)41 now := time.Unix(100, 0)
14- trace := newStartupTrace(func() time.Time { return now })42+ trace := newStartupTrace(func() time.Time { return now }, now)
15 43
16 require.True(t, trace.Mark("process_entry"))44 require.True(t, trace.Mark("process_entry"))
17 require.False(t, trace.Mark("cef_initialized"), "out-of-order transition must be rejected")45 require.False(t, trace.Mark("cef_initialized"), "out-of-order transition must be rejected")
@@ -36,33 +64,32 @@ func TestStartupTraceAcceptsOnlyOrderedOneShotMilestones(t *testing.T) {
36 require.False(t, trace.Mark("first_gtk_presentation"))64 require.False(t, trace.Mark("first_gtk_presentation"))
37 }65 }
38 66
39-func TestStartupTraceQuantizesDeltaToPublishedMilliseconds(t *testing.T) {67+func TestStartupTracePreservesProcessAndConfigTimestamps(t *testing.T) {
40- now := time.Unix(100, 0)68+ processEntry := time.Unix(100, 0)
41- trace := newStartupTrace(func() time.Time { return now })69+ configComplete := processEntry.Add(1500 * time.Microsecond)
70+ now := configComplete.Add(900 * time.Microsecond)
71+ trace := newStartupTrace(func() time.Time { return now }, processEntry)
42 72
43- now = now.Add(1500 * time.Microsecond)73+ require.True(t, trace.markAt("process_entry", processEntry))
44- require.True(t, trace.Mark("process_entry"))74+ require.True(t, trace.markAt("config_complete", configComplete))
45- now = now.Add(900 * time.Microsecond)
46- require.True(t, trace.Mark("config_complete"))
47 75
48- require.Equal(t, int64(1), trace.milestones[0].Elapsed.Milliseconds())76+ require.Equal(t, int64(0), trace.milestones[0].Elapsed.Milliseconds())
49- require.Equal(t, int64(1), trace.milestones[0].Delta.Milliseconds())77+ require.Equal(t, int64(0), trace.milestones[0].Delta.Milliseconds())
50- require.Equal(t, int64(2), trace.milestones[1].Elapsed.Milliseconds())78+ require.Equal(t, int64(1), trace.milestones[1].Elapsed.Milliseconds())
51 require.Equal(t, int64(1), trace.milestones[1].Delta.Milliseconds())79 require.Equal(t, int64(1), trace.milestones[1].Delta.Milliseconds())
52 }80 }
53 81
54-func TestStartupTraceFinishCannotFabricateFirstGTKPresentation(t *testing.T) {82+func TestStartupTraceReservesFirstGTKPresentationForAfterPaint(t *testing.T) {
55 now := time.Unix(100, 0)83 now := time.Unix(100, 0)
56- trace := newStartupTrace(func() time.Time { return now })84+ trace := newStartupTrace(func() time.Time { return now }, now)
57 85
58 for _, name := range startupMilestoneOrder[:len(startupMilestoneOrder)-1] {86 for _, name := range startupMilestoneOrder[:len(startupMilestoneOrder)-1] {
59 now = now.Add(time.Millisecond)87 now = now.Add(time.Millisecond)
60 require.True(t, trace.Mark(name))88 require.True(t, trace.Mark(name))
61 }89 }
62 90
63- trace.Finish() // Legacy callers do not observe the upstream GTK after-paint.
64 require.Len(t, trace.milestones, len(startupMilestoneOrder)-1)91 require.Len(t, trace.milestones, len(startupMilestoneOrder)-1)
65- require.False(t, trace.Mark("first_gtk_presentation"), "generic callers cannot record the reserved milestone")92+ require.False(t, trace.Mark("first_gtk_presentation"), "only the CEF-to-GTK after-paint hook can record the reserved milestone")
66 require.False(t, trace.summaryEmitted)93 require.False(t, trace.summaryEmitted)
67 require.True(t, trace.MarkGTKAfterPaint(), "only the after-paint hook may record this milestone")94 require.True(t, trace.MarkGTKAfterPaint(), "only the after-paint hook may record this milestone")
68 }95 }
@@ -71,7 +98,7 @@ func TestStartupTraceEmitsOneNormalSummaryAtFirstPresentation(t *testing.T) {
71 var output bytes.Buffer98 var output bytes.Buffer
72 logger := zerolog.New(&output)99 logger := zerolog.New(&output)
73 now := time.Unix(100, 0)100 now := time.Unix(100, 0)
74- trace := newStartupTrace(func() time.Time { return now })101+ trace := newStartupTrace(func() time.Time { return now }, now)
75 trace.SetBackend("gdk-dmabuf")102 trace.SetBackend("gdk-dmabuf")
76 trace.SetLogger(&logger)103 trace.SetLogger(&logger)
77 104
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
@@ -40,7 +40,6 @@ func NewEngine(
40 40
41 // --- Configure rendering environment (must be before GTK/WebKit init) ---41 // --- Configure rendering environment (must be before GTK/WebKit init) ---
42 engineConfigureRenderingEnvironment(ctx, cfg, wkCfg, &perfSettings, logger)42 engineConfigureRenderingEnvironment(ctx, cfg, wkCfg, &perfSettings, logger)
43- logging.Trace().Mark("render_env")
44 43
45 // --- Build webKitContextOptions from opts + wkCfg + perfSettings ---44 // --- Build webKitContextOptions from opts + wkCfg + perfSettings ---
46 wkOpts := engineBuildContextOptions(opts, profile, wkCfg, &perfSettings)45 wkOpts := engineBuildContextOptions(opts, profile, wkCfg, &perfSettings)
@@ -53,7 +52,6 @@ func NewEngine(
53 if err != nil {52 if err != nil {
54 return nil, err53 return nil, err
55 }54 }
56- logging.Trace().Mark("webkit_context")
57 55
58 // --- Filter manager ---56 // --- Filter manager ---
59 filterManager := engineInitFilterManager(ctx, cfg, profile.Shared.DataDir, logger)57 filterManager := engineInitFilterManager(ctx, cfg, profile.Shared.DataDir, logger)
@@ -77,7 +75,6 @@ func NewEngine(
77 }75 }
78 76
79 messageRouter := NewMessageRouter(ctx)77 messageRouter := NewMessageRouter(ctx)
80- logging.Trace().Mark("settings_manager")
81 78
82 // --- WebView pool ---79 // --- WebView pool ---
83 poolCfg := DefaultPoolConfig()80 poolCfg := DefaultPoolConfig()
@@ -100,7 +97,6 @@ func NewEngine(
100 } else {97 } else {
101 logger.Warn().Msg("skipping first webview prewarm: no GDK display available yet")98 logger.Warn().Msg("skipping first webview prewarm: no GDK display available yet")
102 }99 }
103- logging.Trace().Mark("pool_prewarm_first")
104 100
105 // --- WebView factory ---101 // --- WebView factory ---
106 factory := NewWebViewFactory(wkCtx, settings, pool, injector, messageRouter)102 factory := NewWebViewFactory(wkCtx, settings, pool, injector, messageRouter)
@@ -147,7 +143,6 @@ func engineSurveyHardwareAndResolveProfile(
147 ) config.ResolvedPerformanceSettings {143 ) config.ResolvedPerformanceSettings {
148 hwSurveyor := env.NewHardwareSurveyor()144 hwSurveyor := env.NewHardwareSurveyor()
149 hwInfo := hwSurveyor.Survey(ctx)145 hwInfo := hwSurveyor.Survey(ctx)
150- logging.Trace().Mark("hardware_survey")
151 logger.Info().146 logger.Info().
152 Int("cpu_cores", hwInfo.CPUCores).147 Int("cpu_cores", hwInfo.CPUCores).
153 Int("cpu_threads", hwInfo.CPUThreads).148 Int("cpu_threads", hwInfo.CPUThreads).
@@ -158,7 +153,6 @@ func engineSurveyHardwareAndResolveProfile(
158 153
159 perfCfg := config.PerformanceConfigFromEngine(&cfg.Engine)154 perfCfg := config.PerformanceConfigFromEngine(&cfg.Engine)
160 perfSettings := config.ResolvePerformanceProfile(&perfCfg, &hwInfo)155 perfSettings := config.ResolvePerformanceProfile(&perfCfg, &hwInfo)
161- logging.Trace().Mark("performance_profile")
162 logger.Info().156 logger.Info().
163 Str("profile", string(cfg.Engine.Profile)).157 Str("profile", string(cfg.Engine.Profile)).
164 Int("skia_cpu_threads", perfSettings.SkiaCPUPaintingThreads).158 Int("skia_cpu_threads", perfSettings.SkiaCPUPaintingThreads).
@@ -284,7 +278,6 @@ func engineInitFilterManager(ctx context.Context, cfg *config.Config, dataDir st
284 if err := filterManager.Initialize(ctx); err != nil {278 if err := filterManager.Initialize(ctx); err != nil {
285 logger.Warn().Err(err).Msg("failed to initialize filters, will load async")279 logger.Warn().Err(err).Msg("failed to initialize filters, will load async")
286 }280 }
287- logging.Trace().Mark("filter_manager")
288 return filterManager281 return filterManager
289 }282 }
290 283
D internal/logging/startup_trace.go +0 -208
@@ -1,208 +0,0 @@
1-package logging
2-
3-import (
4- "sync"
5- "time"
6-
7- "github.com/rs/zerolog"
8-)
9-
10-// StartupTrace records the single truthful path from process entry to the first
11-// GTK presentation. It deliberately rejects unknown, duplicate, and out of
12-// order milestones: accepting a convenient timestamp would make the result
13-// unsuitable for cold-start measurement.
14-type StartupTrace struct {
15- mu sync.Mutex
16- now func() time.Time
17- t0 time.Time
18- milestones []Milestone
19- logger *zerolog.Logger
20- buffered []Milestone
21- backend string
22- incompleteReason string
23- summaryEmitted bool
24-}
25-
26-// Milestone represents a timing checkpoint measured from process entry.
27-type Milestone struct {
28- Name string
29- Elapsed time.Duration
30- Delta time.Duration
31-}
32-
33-var startupMilestoneOrder = []string{
34- "process_entry",
35- "config_complete",
36- "cef_library_load_begin",
37- "cef_initialized",
38- "browser_create_requested",
39- "first_accelerated_paint_received",
40- "first_dmabuf_texture_swap",
41- "first_gtk_presentation",
42-}
43-
44-var (
45- globalTrace *StartupTrace
46- globalTraceMu sync.Mutex
47- globalTraceOnce sync.Once
48-)
49-
50-func newStartupTrace(now func() time.Time) *StartupTrace {
51- start := now()
52- return &StartupTrace{
53- now: now,
54- t0: start,
55- milestones: make([]Milestone, 0, len(startupMilestoneOrder)),
56- buffered: make([]Milestone, 0, len(startupMilestoneOrder)),
57- }
58-}
59-
60-// InitStartupTrace must be called at the first instruction in main. logLevel is
61-// retained for source compatibility; this measurement is always collected so
62-// its final structured summary is available at normal log level.
63-func InitStartupTrace(_ string) {
64- globalTraceOnce.Do(func() {
65- trace := newStartupTrace(time.Now)
66- globalTraceMu.Lock()
67- globalTrace = trace
68- globalTraceMu.Unlock()
69- })
70-}
71-
72-// Trace returns the process startup trace. Callers before initialization get a
73-// harmless trace which rejects all transitions.
74-func Trace() *StartupTrace {
75- globalTraceMu.Lock()
76- defer globalTraceMu.Unlock()
77- if globalTrace == nil {
78- return &StartupTrace{}
79- }
80- return globalTrace
81-}
82-
83-// SetLogger makes buffered milestones available to the process logger.
84-func (st *StartupTrace) SetLogger(logger *zerolog.Logger) {
85- if st == nil || logger == nil {
86- return
87- }
88- st.mu.Lock()
89- defer st.mu.Unlock()
90- st.logger = logger
91- for _, milestone := range st.buffered {
92- st.emitMilestone(milestone)
93- }
94- st.buffered = nil
95- st.emitSummaryLocked()
96-}
97-
98-// UpdateLogger switches the destination without replaying events. Replaying
99-// would violate the one-shot property in session logs.
100-func (st *StartupTrace) UpdateLogger(logger *zerolog.Logger) { st.SetLogger(logger) }
101-
102-// SetBackend records the selected presentation backend for the final summary.
103-func (st *StartupTrace) SetBackend(backend string) {
104- if st == nil {
105- return
106- }
107- st.mu.Lock()
108- defer st.mu.Unlock()
109- st.backend = backend
110-}
111-
112-// SetIncompleteReason records why a trace cannot reach a valid DMABUF result.
113-func (st *StartupTrace) SetIncompleteReason(reason string) {
114- if st == nil {
115- return
116- }
117- st.mu.Lock()
118- defer st.mu.Unlock()
119- if st.incompleteReason == "" {
120- st.incompleteReason = reason
121- }
122-}
123-
124-// Mark accepts exactly the next non-presentation startup milestone and returns
125-// whether it was recorded. first_gtk_presentation is reserved for the GTK
126-// frame-clock after-paint hook through MarkGTKAfterPaint.
127-func (st *StartupTrace) Mark(name string) bool {
128- if name == "first_gtk_presentation" {
129- return false
130- }
131- return st.mark(name)
132-}
133-
134-// MarkGTKAfterPaint records the final milestone. It is called only from the
135-// upstream CEF-to-GTK bridge's first frame-clock after-paint callback.
136-func (st *StartupTrace) MarkGTKAfterPaint() bool {
137- return st.mark("first_gtk_presentation")
138-}
139-
140-// mark accepts the next milestone and is safe for callbacks from CEF and GTK
141-// threads.
142-func (st *StartupTrace) mark(name string) bool {
143- if st == nil || st.now == nil {
144- return false
145- }
146- st.mu.Lock()
147- defer st.mu.Unlock()
148- if len(st.milestones) >= len(startupMilestoneOrder) || name != startupMilestoneOrder[len(st.milestones)] {
149- return false
150- }
151-
152- now := st.now()
153- milestone := Milestone{Name: name, Elapsed: now.Sub(st.t0)}
154- // Published fields are integer milliseconds. Compute delta on that same
155- // scale so delta_ms always exactly reconstructs t_ms in collected evidence.
156- previousMilliseconds := int64(0)
157- if len(st.milestones) > 0 {
158- previousMilliseconds = st.milestones[len(st.milestones)-1].Elapsed.Milliseconds()
159- }
160- milestone.Delta = time.Duration(milestone.Elapsed.Milliseconds()-previousMilliseconds) * time.Millisecond
161- st.milestones = append(st.milestones, milestone)
162- if st.logger == nil {
163- st.buffered = append(st.buffered, milestone)
164- } else {
165- st.emitMilestone(milestone)
166- }
167- st.emitSummaryLocked()
168- return true
169-}
170-
171-func (st *StartupTrace) emitMilestone(m Milestone) {
172- if st.logger == nil {
173- return
174- }
175- st.logger.Debug().
176- Str("milestone", m.Name).
177- Int64("t_ms", m.Elapsed.Milliseconds()).
178- Int64("delta_ms", m.Delta.Milliseconds()).
179- Msg("startup_trace: milestone")
180-}
181-
182-func (st *StartupTrace) emitSummaryLocked() {
183- if st.summaryEmitted || st.logger == nil || len(st.milestones) != len(startupMilestoneOrder) {
184- return
185- }
186- st.summaryEmitted = true
187- st.logger.Info().
188- Str("backend", st.backend).
189- Str("incomplete_reason", st.incompleteReason).
190- Int64("total_ms", st.milestones[len(st.milestones)-1].Elapsed.Milliseconds()).
191- Interface("milestones", st.milestones).
192- Msg("startup_trace: first presentation")
193-}
194-
195-// Finish is retained as a harmless compatibility no-op. The only valid source
196-// for first_gtk_presentation is the upstream GTK frame-clock after-paint hook.
197-func (*StartupTrace) Finish() {}
198-
199-func (st *StartupTrace) Enabled() bool { return st != nil && st.now != nil }
200-
201-func (st *StartupTrace) TotalElapsed() time.Duration {
202- if st == nil || st.now == nil {
203- return 0
204- }
205- st.mu.Lock()
206- defer st.mu.Unlock()
207- return st.now().Sub(st.t0)
208-}
@@ -1,208 +0,0 @@
1-package logging
2-
3-import (
4- "sync"
5- "time"
6-
7- "github.com/rs/zerolog"
8-)
9-
10-// StartupTrace records the single truthful path from process entry to the first
11-// GTK presentation. It deliberately rejects unknown, duplicate, and out of
12-// order milestones: accepting a convenient timestamp would make the result
13-// unsuitable for cold-start measurement.
14-type StartupTrace struct {
15- mu sync.Mutex
16- now func() time.Time
17- t0 time.Time
18- milestones []Milestone
19- logger *zerolog.Logger
20- buffered []Milestone
21- backend string
22- incompleteReason string
23- summaryEmitted bool
24-}
25-
26-// Milestone represents a timing checkpoint measured from process entry.
27-type Milestone struct {
28- Name string
29- Elapsed time.Duration
30- Delta time.Duration
31-}
32-
33-var startupMilestoneOrder = []string{
34- "process_entry",
35- "config_complete",
36- "cef_library_load_begin",
37- "cef_initialized",
38- "browser_create_requested",
39- "first_accelerated_paint_received",
40- "first_dmabuf_texture_swap",
41- "first_gtk_presentation",
42-}
43-
44-var (
45- globalTrace *StartupTrace
46- globalTraceMu sync.Mutex
47- globalTraceOnce sync.Once
48-)
49-
50-func newStartupTrace(now func() time.Time) *StartupTrace {
51- start := now()
52- return &StartupTrace{
53- now: now,
54- t0: start,
55- milestones: make([]Milestone, 0, len(startupMilestoneOrder)),
56- buffered: make([]Milestone, 0, len(startupMilestoneOrder)),
57- }
58-}
59-
60-// InitStartupTrace must be called at the first instruction in main. logLevel is
61-// retained for source compatibility; this measurement is always collected so
62-// its final structured summary is available at normal log level.
63-func InitStartupTrace(_ string) {
64- globalTraceOnce.Do(func() {
65- trace := newStartupTrace(time.Now)
66- globalTraceMu.Lock()
67- globalTrace = trace
68- globalTraceMu.Unlock()
69- })
70-}
71-
72-// Trace returns the process startup trace. Callers before initialization get a
73-// harmless trace which rejects all transitions.
74-func Trace() *StartupTrace {
75- globalTraceMu.Lock()
76- defer globalTraceMu.Unlock()
77- if globalTrace == nil {
78- return &StartupTrace{}
79- }
80- return globalTrace
81-}
82-
83-// SetLogger makes buffered milestones available to the process logger.
84-func (st *StartupTrace) SetLogger(logger *zerolog.Logger) {
85- if st == nil || logger == nil {
86- return
87- }
88- st.mu.Lock()
89- defer st.mu.Unlock()
90- st.logger = logger
91- for _, milestone := range st.buffered {
92- st.emitMilestone(milestone)
93- }
94- st.buffered = nil
95- st.emitSummaryLocked()
96-}
97-
98-// UpdateLogger switches the destination without replaying events. Replaying
99-// would violate the one-shot property in session logs.
100-func (st *StartupTrace) UpdateLogger(logger *zerolog.Logger) { st.SetLogger(logger) }
101-
102-// SetBackend records the selected presentation backend for the final summary.
103-func (st *StartupTrace) SetBackend(backend string) {
104- if st == nil {
105- return
106- }
107- st.mu.Lock()
108- defer st.mu.Unlock()
109- st.backend = backend
110-}
111-
112-// SetIncompleteReason records why a trace cannot reach a valid DMABUF result.
113-func (st *StartupTrace) SetIncompleteReason(reason string) {
114- if st == nil {
115- return
116- }
117- st.mu.Lock()
118- defer st.mu.Unlock()
119- if st.incompleteReason == "" {
120- st.incompleteReason = reason
121- }
122-}
123-
124-// Mark accepts exactly the next non-presentation startup milestone and returns
125-// whether it was recorded. first_gtk_presentation is reserved for the GTK
126-// frame-clock after-paint hook through MarkGTKAfterPaint.
127-func (st *StartupTrace) Mark(name string) bool {
128- if name == "first_gtk_presentation" {
129- return false
130- }
131- return st.mark(name)
132-}
133-
134-// MarkGTKAfterPaint records the final milestone. It is called only from the
135-// upstream CEF-to-GTK bridge's first frame-clock after-paint callback.
136-func (st *StartupTrace) MarkGTKAfterPaint() bool {
137- return st.mark("first_gtk_presentation")
138-}
139-
140-// mark accepts the next milestone and is safe for callbacks from CEF and GTK
141-// threads.
142-func (st *StartupTrace) mark(name string) bool {
143- if st == nil || st.now == nil {
144- return false
145- }
146- st.mu.Lock()
147- defer st.mu.Unlock()
148- if len(st.milestones) >= len(startupMilestoneOrder) || name != startupMilestoneOrder[len(st.milestones)] {
149- return false
150- }
151-
152- now := st.now()
153- milestone := Milestone{Name: name, Elapsed: now.Sub(st.t0)}
154- // Published fields are integer milliseconds. Compute delta on that same
155- // scale so delta_ms always exactly reconstructs t_ms in collected evidence.
156- previousMilliseconds := int64(0)
157- if len(st.milestones) > 0 {
158- previousMilliseconds = st.milestones[len(st.milestones)-1].Elapsed.Milliseconds()
159- }
160- milestone.Delta = time.Duration(milestone.Elapsed.Milliseconds()-previousMilliseconds) * time.Millisecond
161- st.milestones = append(st.milestones, milestone)
162- if st.logger == nil {
163- st.buffered = append(st.buffered, milestone)
164- } else {
165- st.emitMilestone(milestone)
166- }
167- st.emitSummaryLocked()
168- return true
169-}
170-
171-func (st *StartupTrace) emitMilestone(m Milestone) {
172- if st.logger == nil {
173- return
174- }
175- st.logger.Debug().
176- Str("milestone", m.Name).
177- Int64("t_ms", m.Elapsed.Milliseconds()).
178- Int64("delta_ms", m.Delta.Milliseconds()).
179- Msg("startup_trace: milestone")
180-}
181-
182-func (st *StartupTrace) emitSummaryLocked() {
183- if st.summaryEmitted || st.logger == nil || len(st.milestones) != len(startupMilestoneOrder) {
184- return
185- }
186- st.summaryEmitted = true
187- st.logger.Info().
188- Str("backend", st.backend).
189- Str("incomplete_reason", st.incompleteReason).
190- Int64("total_ms", st.milestones[len(st.milestones)-1].Elapsed.Milliseconds()).
191- Interface("milestones", st.milestones).
192- Msg("startup_trace: first presentation")
193-}
194-
195-// Finish is retained as a harmless compatibility no-op. The only valid source
196-// for first_gtk_presentation is the upstream GTK frame-clock after-paint hook.
197-func (*StartupTrace) Finish() {}
198-
199-func (st *StartupTrace) Enabled() bool { return st != nil && st.now != nil }
200-
201-func (st *StartupTrace) TotalElapsed() time.Duration {
202- if st == nil || st.now == nil {
203- return 0
204- }
205- st.mu.Lock()
206- defer st.mu.Unlock()
207- return st.now().Sub(st.t0)
208-}
M internal/ui/app.go +0 -4
@@ -295,7 +295,6 @@ func (a *App) Run(ctx context.Context, args []string) int {
295295 // Initialize libadwaita once (required before using StyleManager).
296296 // This also initializes GTK implicitly.
297297 EnsureAdwaitaInitialized()
298- logging.Trace().Mark("gtk_init")
299298
300299 // Mark adwaita detector as available now that adw.Init() is complete.
301300 // This enables the highest-priority color scheme detector.
@@ -317,7 +316,6 @@ func (a *App) Run(ctx context.Context, args []string) int {
317316 return 1
318317 }
319318 defer a.gtkApp.Unref()
320- logging.Trace().Mark("gtk_app_created")
321319 a.dispatchOnMainThread = a.runOnMainThread
322320
323321 // Connect activate signal
@@ -353,7 +351,6 @@ func (a *App) onActivate(ctx context.Context) {
353351 log.Error().Err(err).Msg("failed to create main window")
354352 return
355353 }
356- logging.Trace().Mark("window_created")
357354
358355 a.installCrashReportNotifier(ctx)
359356 a.initFocusManager()
@@ -362,7 +359,6 @@ func (a *App) onActivate(ctx context.Context) {
362359
363360 a.initCoordinators(ctx)
364361 a.wireWebRTCPermissionIndicator()
365- logging.Trace().Mark("coordinators_init")
366362 a.initKeyboardHandler(ctx)
367363 a.initOmniboxConfig(ctx)
368364 a.initFindBarConfig(ctx)
@@ -295,7 +295,6 @@ func (a *App) Run(ctx context.Context, args []string) int {
295 // Initialize libadwaita once (required before using StyleManager).295 // Initialize libadwaita once (required before using StyleManager).
296 // This also initializes GTK implicitly.296 // This also initializes GTK implicitly.
297 EnsureAdwaitaInitialized()297 EnsureAdwaitaInitialized()
298- logging.Trace().Mark("gtk_init")
299 298
300 // Mark adwaita detector as available now that adw.Init() is complete.299 // Mark adwaita detector as available now that adw.Init() is complete.
301 // This enables the highest-priority color scheme detector.300 // This enables the highest-priority color scheme detector.
@@ -317,7 +316,6 @@ func (a *App) Run(ctx context.Context, args []string) int {
317 return 1316 return 1
318 }317 }
319 defer a.gtkApp.Unref()318 defer a.gtkApp.Unref()
320- logging.Trace().Mark("gtk_app_created")
321 a.dispatchOnMainThread = a.runOnMainThread319 a.dispatchOnMainThread = a.runOnMainThread
322 320
323 // Connect activate signal321 // Connect activate signal
@@ -353,7 +351,6 @@ func (a *App) onActivate(ctx context.Context) {
353 log.Error().Err(err).Msg("failed to create main window")351 log.Error().Err(err).Msg("failed to create main window")
354 return352 return
355 }353 }
356- logging.Trace().Mark("window_created")
357 354
358 a.installCrashReportNotifier(ctx)355 a.installCrashReportNotifier(ctx)
359 a.initFocusManager()356 a.initFocusManager()
@@ -362,7 +359,6 @@ func (a *App) onActivate(ctx context.Context) {
362 359
363 a.initCoordinators(ctx)360 a.initCoordinators(ctx)
364 a.wireWebRTCPermissionIndicator()361 a.wireWebRTCPermissionIndicator()
365- logging.Trace().Mark("coordinators_init")
366 a.initKeyboardHandler(ctx)362 a.initKeyboardHandler(ctx)
367 a.initOmniboxConfig(ctx)363 a.initOmniboxConfig(ctx)
368 a.initFindBarConfig(ctx)364 a.initFindBarConfig(ctx)
M internal/ui/coordinator/content/coordinator.go +0 -6
@@ -314,12 +314,6 @@ func (c *Coordinator) activePaneOverrideID() (entity.PaneID, bool) {
314314 return c.activePaneOverride, true
315315 }
316316
317-func (c *Coordinator) webViewCount() int {
318- c.webViewsMu.RLock()
319- defer c.webViewsMu.RUnlock()
320- return len(c.webViews)
321-}
322-
323317 func (c *Coordinator) getWebViewLocked(paneID entity.PaneID) port.WebView {
324318 c.webViewsMu.RLock()
325319 defer c.webViewsMu.RUnlock()
@@ -314,12 +314,6 @@ func (c *Coordinator) activePaneOverrideID() (entity.PaneID, bool) {
314 return c.activePaneOverride, true314 return c.activePaneOverride, true
315 }315 }
316 316
317-func (c *Coordinator) webViewCount() int {
318- c.webViewsMu.RLock()
319- defer c.webViewsMu.RUnlock()
320- return len(c.webViews)
321-}
322-
323 func (c *Coordinator) getWebViewLocked(paneID entity.PaneID) port.WebView {317 func (c *Coordinator) getWebViewLocked(paneID entity.PaneID) port.WebView {
324 c.webViewsMu.RLock()318 c.webViewsMu.RLock()
325 defer c.webViewsMu.RUnlock()319 defer c.webViewsMu.RUnlock()
M internal/ui/coordinator/content/lifecycle.go +0 -7
@@ -28,16 +28,10 @@ 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
4236 // setWebViewLocked atomically resets presentation state for the acquired
4337 // WebView, including a pooled instance previously revealed in another pane.
@@ -144,7 +138,6 @@ func (c *Coordinator) AttachToWorkspace(ctx context.Context, ws *entity.Workspac
144138 log.Warn().Err(err).Str("pane_id", string(pane.ID)).Msg("failed to attach webview widget")
145139 continue
146140 }
147- logging.Trace().Mark("webview_attached")
148141 }
149142 }
150143
@@ -28,16 +28,10 @@ func (c *Coordinator) EnsureWebView(ctx context.Context, paneID entity.PaneID) (
28 return nil, fmt.Errorf("webview pool not configured")28 return nil, fmt.Errorf("webview pool not configured")
29 }29 }
30 30
31- // Mark tab_created on first webview (first tab)
32- if c.webViewCount() == 0 {
33- logging.Trace().Mark("tab_created")
34- }
35-
36 wv, err := c.pool.Acquire(ctx)31 wv, err := c.pool.Acquire(ctx)
37 if err != nil {32 if err != nil {
38 return nil, err33 return nil, err
39 }34 }
40- logging.Trace().Mark("webview_acquired")
41 35
42 // setWebViewLocked atomically resets presentation state for the acquired36 // setWebViewLocked atomically resets presentation state for the acquired
43 // WebView, including a pooled instance previously revealed in another pane.37 // WebView, including a pooled instance previously revealed in another pane.
@@ -144,7 +138,6 @@ func (c *Coordinator) AttachToWorkspace(ctx context.Context, ws *entity.Workspac
144 log.Warn().Err(err).Str("pane_id", string(pane.ID)).Msg("failed to attach webview widget")138 log.Warn().Err(err).Str("pane_id", string(pane.ID)).Msg("failed to attach webview widget")
145 continue139 continue
146 }140 }
147- logging.Trace().Mark("webview_attached")
148 }141 }
149 }142 }
150 143
M internal/ui/coordinator/content/navigation.go +0 -3
@@ -19,7 +19,6 @@ import (
1919 // Also shows the WebView widget (it's hidden during creation to avoid white flash).
2020 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 == "" {
@@ -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() {
@@ -19,7 +19,6 @@ import (
19 // Also shows the WebView widget (it's hidden during creation to avoid white flash).19 // Also shows the WebView widget (it's hidden during creation to avoid white flash).
20 func (c *Coordinator) onLoadCommitted(ctx context.Context, paneID entity.PaneID, wv port.WebView, identity webViewIdentity) {20 func (c *Coordinator) onLoadCommitted(ctx context.Context, paneID entity.PaneID, wv port.WebView, identity webViewIdentity) {
21 log := logging.FromContext(ctx)21 log := logging.FromContext(ctx)
22- logging.Trace().Mark("load_committed")
23 22
24 uri := wv.URI()23 uri := wv.URI()
25 if uri == "" {24 if uri == "" {
@@ -177,8 +176,6 @@ func (c *Coordinator) updatePaneURI(paneID entity.PaneID, uri string) {
177 176
178 // onLoadStarted shows the progress bar when page loading begins.177 // onLoadStarted shows the progress bar when page loading begins.
179 func (c *Coordinator) onLoadStarted(paneID entity.PaneID) {178 func (c *Coordinator) onLoadStarted(paneID entity.PaneID) {
180- logging.Trace().Mark("load_started")
181-
182 // Trigger deferred initialization on first load_started.179 // Trigger deferred initialization on first load_started.
183 // This ensures non-critical init runs after initial navigation starts.180 // This ensures non-critical init runs after initial navigation starts.
184 c.loadStartedOnce.Do(func() {181 c.loadStartedOnce.Do(func() {

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