detached b889462

perf(cef): cold-start critical path (Plan 01) (#410)

* test(cef): isolate collector fixtures with fake Go/Git provenance

* feat(cef): bind collector provenance to measured binary and probe runtime

* feat(cef): add isolated startup harness with scenario fixtures

* perf(cef): decode packaged PNG for loading logo

* fix(cef): submit one navigation per intent

* fix(cef): share safety merge and combine omnibox re-exec

* fix(cef): resolve lint for startup lot

* fix(cef): correlate replay browsers by identifier

* fix(cef): split navigation claim to satisfy complexity gate

* fix(cef): address Copilot review on startup lot

* fix(cef): address second Copilot review on startup lot

* fix(cef): address CodeRabbit review on startup lot

Claim navigation against the attached browser instance so a swap between the identifier check and the claim retries instead of submitting to a stale frame. Merge every GODEBUG environ entry before normalization so duplicates collapse without dropping unrelated keys. Replace hashlib.file_digest with a 3.x-compatible streaming hash in all four checksum paths. Require exact redirect document counts, abort process-warm on failed warm-up, and bind the manifest to embedded VCS metadata from a stamped build. Fix the collector fake to emit the real go version -m format.

* fix(cef): resubmit pending navigation when browser is replaced after claim
commit
b88946207abbf178d4dee153a06bc7b94e180762
author
bnema · · 2026-09-08 08:16 UTC
committer
GitHub · · 2026-09-08 08:16 UTC
parent
b7de353
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
21 files · +2379 -231
M .gitignore +6 -0
@@ -89,6 +89,12 @@ scripts/*
8989 !scripts/verify_purego_only.sh
9090 !scripts/stress_omnibox_callbacks.go
9191 !scripts/cef-gpu-diagnostics.sh
92+!scripts/collect_first_presentation.sh
93+!scripts/cef_runtime_probe.py
94+!scripts/measure_cef_startup.py
95+!scripts/test_measure_cef_startup.py
96+!scripts/generate_build_manifest.py
97+!scripts/test_systemview_startup.py
9298 specs/
9399 templates/
94100 .serena/
@@ -89,6 +89,12 @@ scripts/*
89 !scripts/verify_purego_only.sh89 !scripts/verify_purego_only.sh
90 !scripts/stress_omnibox_callbacks.go90 !scripts/stress_omnibox_callbacks.go
91 !scripts/cef-gpu-diagnostics.sh91 !scripts/cef-gpu-diagnostics.sh
92+!scripts/collect_first_presentation.sh
93+!scripts/cef_runtime_probe.py
94+!scripts/measure_cef_startup.py
95+!scripts/test_measure_cef_startup.py
96+!scripts/generate_build_manifest.py
97+!scripts/test_systemview_startup.py
92 specs/98 specs/
93 templates/99 templates/
94 .serena/100 .serena/
M Makefile +12 -1
@@ -42,6 +42,11 @@ LDFLAGS=-ldflags "-s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X m
4242 # package boundaries. Both are required — -l alone is insufficient.
4343 GCFLAGS=-gcflags 'all=-N -l'
4444
45+# VCS stamping for measured binaries. build-quick stays unstamped for fast
46+# iteration; build-manifest forces stamping so the manifest binds the exact
47+# built tree instead of the ambient checkout HEAD.
48+BUILDVCS?=false
49+
4550 # Default target
4651 help: ## Show this help message
4752 @echo "Available targets:"
@@ -72,10 +77,16 @@ build-systemviews: generate-systemviews ## Build the WASM systemviews runtime
7277 build-quick: ## Build quickly for backend development
7378 @echo "Building $(BINARY_NAME) $(VERSION) (quick)..."
7479 @mkdir -p $(DIST_DIR)
75- GOFLAGS="$(NATIVE_GOFLAGS)" CGO_ENABLED=0 go build -buildvcs=false -p $(NPROCS) $(GCFLAGS) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH)
80+ GOFLAGS="$(NATIVE_GOFLAGS)" CGO_ENABLED=0 go build -buildvcs=$(BUILDVCS) -p $(NPROCS) $(GCFLAGS) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH)
7681 @rm -f $(DIST_DIR)/cef-helper
7782 @echo "Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)"
7883
84+build-manifest: ## Generate the collector build manifest for dist/dumber (VCS-stamped build)
85+ @$(MAKE) build-quick BUILDVCS=true
86+ @echo "Generating build manifest..."
87+ python3 scripts/generate_build_manifest.py --binary $(DIST_DIR)/$(BINARY_NAME)
88+ @echo "Manifest: $(DIST_DIR)/$(BINARY_NAME).manifest.json"
89+
7990 install-local: build-quick ## Install dumber to ~/.local/bin atomically
8091 @echo "Installing $(BINARY_NAME) to $(LOCAL_BIN_DIR)..."
8192 @mkdir -p $(LOCAL_BIN_DIR)
@@ -42,6 +42,11 @@ LDFLAGS=-ldflags "-s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X m
42 # package boundaries. Both are required — -l alone is insufficient.42 # package boundaries. Both are required — -l alone is insufficient.
43 GCFLAGS=-gcflags 'all=-N -l'43 GCFLAGS=-gcflags 'all=-N -l'
44 44
45+# VCS stamping for measured binaries. build-quick stays unstamped for fast
46+# iteration; build-manifest forces stamping so the manifest binds the exact
47+# built tree instead of the ambient checkout HEAD.
48+BUILDVCS?=false
49+
45 # Default target50 # Default target
46 help: ## Show this help message51 help: ## Show this help message
47 @echo "Available targets:"52 @echo "Available targets:"
@@ -72,10 +77,16 @@ build-systemviews: generate-systemviews ## Build the WASM systemviews runtime
72 build-quick: ## Build quickly for backend development77 build-quick: ## Build quickly for backend development
73 @echo "Building $(BINARY_NAME) $(VERSION) (quick)..."78 @echo "Building $(BINARY_NAME) $(VERSION) (quick)..."
74 @mkdir -p $(DIST_DIR)79 @mkdir -p $(DIST_DIR)
75- GOFLAGS="$(NATIVE_GOFLAGS)" CGO_ENABLED=0 go build -buildvcs=false -p $(NPROCS) $(GCFLAGS) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH)80+ GOFLAGS="$(NATIVE_GOFLAGS)" CGO_ENABLED=0 go build -buildvcs=$(BUILDVCS) -p $(NPROCS) $(GCFLAGS) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH)
76 @rm -f $(DIST_DIR)/cef-helper81 @rm -f $(DIST_DIR)/cef-helper
77 @echo "Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)"82 @echo "Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)"
78 83
84+build-manifest: ## Generate the collector build manifest for dist/dumber (VCS-stamped build)
85+ @$(MAKE) build-quick BUILDVCS=true
86+ @echo "Generating build manifest..."
87+ python3 scripts/generate_build_manifest.py --binary $(DIST_DIR)/$(BINARY_NAME)
88+ @echo "Manifest: $(DIST_DIR)/$(BINARY_NAME).manifest.json"
89+
79 install-local: build-quick ## Install dumber to ~/.local/bin atomically90 install-local: build-quick ## Install dumber to ~/.local/bin atomically
80 @echo "Installing $(BINARY_NAME) to $(LOCAL_BIN_DIR)..."91 @echo "Installing $(BINARY_NAME) to $(LOCAL_BIN_DIR)..."
81 @mkdir -p $(LOCAL_BIN_DIR)92 @mkdir -p $(LOCAL_BIN_DIR)
M assets/webui_embed.go +8 -0
@@ -21,3 +21,11 @@ var LogoSVG []byte
2121 //
2222 //go:embed logo-32.png
2323 var LogoPNG32 []byte
24+
25+// LogoPNG512 contains the dumber logo as 512x512 PNG for the loading
26+// placeholder. The initial pane decodes this raster asset instead of SVG to
27+// avoid XML parsing and rasterization on the critical path. LogoSVG remains
28+// the source for desktop icon installation.
29+//
30+//go:embed logo-512.png
31+var LogoPNG512 []byte
@@ -21,3 +21,11 @@ var LogoSVG []byte
21 //21 //
22 //go:embed logo-32.png22 //go:embed logo-32.png
23 var LogoPNG32 []byte23 var LogoPNG32 []byte
24+
25+// LogoPNG512 contains the dumber logo as 512x512 PNG for the loading
26+// placeholder. The initial pane decodes this raster asset instead of SVG to
27+// avoid XML parsing and rasterization on the critical path. LogoSVG remains
28+// the source for desktop icon installation.
29+//
30+//go:embed logo-512.png
31+var LogoPNG512 []byte
M cmd/dumber/main.go +86 -1
@@ -31,6 +31,7 @@ import (
3131 "github.com/bnema/dumber/internal/infrastructure/filesystem"
3232 "github.com/bnema/dumber/internal/infrastructure/idle"
3333 "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
34+ "github.com/bnema/dumber/internal/infrastructure/process"
3435 "github.com/bnema/dumber/internal/infrastructure/runtimeprofile"
3536 "github.com/bnema/dumber/internal/infrastructure/snapshot"
3637 "github.com/bnema/dumber/internal/infrastructure/textinput"
@@ -173,7 +174,14 @@ func main() {
173174 // invalidate frames retained across those boundaries and make the GC abort
174175 // with "traceback did not unwind completely". Re-exec once so the runtime
175176 // sees the safety setting before it creates any goroutine or CEF subprocess.
176- if err := ensureRuntimeSafety(); err != nil {
177+ // The standalone omnibox may additionally need a layer-shell preload; both
178+ // changes are computed together there so they cost at most one re-exec.
179+ if omniboxCombinedLaunchEnvironment(os.Args) {
180+ if err := ensureOmniboxLaunchEnvironment(); err != nil {
181+ _, _ = fmt.Fprintf(os.Stderr, "dumber: %v\n", err)
182+ os.Exit(1)
183+ }
184+ } else if err := ensureRuntimeSafety(); err != nil {
177185 _, _ = fmt.Fprintf(os.Stderr, "dumber: %v\n", err)
178186 os.Exit(1)
179187 }
@@ -430,7 +438,84 @@ func preInitializeAdwaitaForCEF(cfg *config.Config, initResult *bootstrap.Parall
430438 }
431439 }
432440
441+// omniboxCombinedLaunchEnvironment reports whether argv selects the
442+// standalone omnibox outside a classified CEF helper subprocess. CEF helpers
443+// keep safety-only handling and the early helper routing below; they are
444+// excluded from omnibox preload handling.
445+func omniboxCombinedLaunchEnvironment(args []string) bool {
446+ if isCEFSubprocess(args) {
447+ return false
448+ }
449+ mode, _ := launchModeFromArgs(args)
450+ return mode == launchModeStandaloneOmnibox
451+}
452+
453+// ensureOmniboxLaunchEnvironment computes safety (GODEBUG) and layer-shell
454+// preload (LD_PRELOAD) changes together and applies at most one self-exec.
455+// A safety-required exec failure is fatal; a preload-only exec failure keeps
456+// the existing best-effort behavior and continues without preload.
457+func ensureOmniboxLaunchEnvironment() error {
458+ if isCEFSubprocess(os.Args) {
459+ return ensureRuntimeSafety()
460+ }
461+ environ, safetyChange := process.MergeRuntimeSafety(os.Environ())
462+ envMap := environSliceToMap(environ)
463+ preloadChange := false
464+ if bootstrap.ShouldPreloadLayerShell(envMap) {
465+ if libraryPath := bootstrap.LayerShellLibraryPath(); libraryPath != "" {
466+ envMap = bootstrap.LayerShellPreloadEnv(envMap, libraryPath)
467+ environ = environMapToSlice(envMap)
468+ preloadChange = true
469+ }
470+ }
471+ if !safetyChange && !preloadChange {
472+ return nil
473+ }
474+ execPath, err := resolveCurrentExecutable(os.Executable)
475+ if err != nil {
476+ if safetyChange {
477+ return fmt.Errorf("resolve executable for launch environment re-exec: %w", err)
478+ }
479+ _, _ = fmt.Fprintf(os.Stderr, "Failed to resolve standalone omnibox executable path: %v\n", err)
480+ return nil
481+ }
482+ // #nosec G702 -- execPath comes from os.Executable(), not user input.
483+ if err := syscall.Exec(execPath, os.Args, environ); err != nil {
484+ if safetyChange {
485+ return fmt.Errorf("launch environment re-exec: %w", err)
486+ }
487+ _, _ = fmt.Fprintf(os.Stderr, "Failed to re-exec standalone omnibox with layer-shell preload: %v\n", err)
488+ return nil
489+ }
490+ return nil
491+}
492+
493+func environSliceToMap(environ []string) map[string]string {
494+ env := make(map[string]string, len(environ))
495+ for _, entry := range environ {
496+ key, value, ok := strings.Cut(entry, "=")
497+ if !ok {
498+ continue
499+ }
500+ env[key] = value
501+ }
502+ return env
503+}
504+
505+func environMapToSlice(env map[string]string) []string {
506+ envSlice := make([]string, 0, len(env))
507+ for key, value := range env {
508+ envSlice = append(envSlice, key+"="+value)
509+ }
510+ return envSlice
511+}
512+
433513 func maybeReexecStandaloneOmniboxWithLayerShell() {
514+ // Classified CEF helpers never take the omnibox preload path; early
515+ // helper routing in main owns those processes.
516+ if isCEFSubprocess(os.Args) {
517+ return
518+ }
434519 env := bootstrap.CurrentEnvMap()
435520 if !bootstrap.ShouldPreloadLayerShell(env) {
436521 return
@@ -31,6 +31,7 @@ import (
31 "github.com/bnema/dumber/internal/infrastructure/filesystem"31 "github.com/bnema/dumber/internal/infrastructure/filesystem"
32 "github.com/bnema/dumber/internal/infrastructure/idle"32 "github.com/bnema/dumber/internal/infrastructure/idle"
33 "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"33 "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
34+ "github.com/bnema/dumber/internal/infrastructure/process"
34 "github.com/bnema/dumber/internal/infrastructure/runtimeprofile"35 "github.com/bnema/dumber/internal/infrastructure/runtimeprofile"
35 "github.com/bnema/dumber/internal/infrastructure/snapshot"36 "github.com/bnema/dumber/internal/infrastructure/snapshot"
36 "github.com/bnema/dumber/internal/infrastructure/textinput"37 "github.com/bnema/dumber/internal/infrastructure/textinput"
@@ -173,7 +174,14 @@ func main() {
173 // invalidate frames retained across those boundaries and make the GC abort174 // invalidate frames retained across those boundaries and make the GC abort
174 // with "traceback did not unwind completely". Re-exec once so the runtime175 // with "traceback did not unwind completely". Re-exec once so the runtime
175 // sees the safety setting before it creates any goroutine or CEF subprocess.176 // sees the safety setting before it creates any goroutine or CEF subprocess.
176- if err := ensureRuntimeSafety(); err != nil {177+ // The standalone omnibox may additionally need a layer-shell preload; both
178+ // changes are computed together there so they cost at most one re-exec.
179+ if omniboxCombinedLaunchEnvironment(os.Args) {
180+ if err := ensureOmniboxLaunchEnvironment(); err != nil {
181+ _, _ = fmt.Fprintf(os.Stderr, "dumber: %v\n", err)
182+ os.Exit(1)
183+ }
184+ } else if err := ensureRuntimeSafety(); err != nil {
177 _, _ = fmt.Fprintf(os.Stderr, "dumber: %v\n", err)185 _, _ = fmt.Fprintf(os.Stderr, "dumber: %v\n", err)
178 os.Exit(1)186 os.Exit(1)
179 }187 }
@@ -430,7 +438,84 @@ func preInitializeAdwaitaForCEF(cfg *config.Config, initResult *bootstrap.Parall
430 }438 }
431 }439 }
432 440
441+// omniboxCombinedLaunchEnvironment reports whether argv selects the
442+// standalone omnibox outside a classified CEF helper subprocess. CEF helpers
443+// keep safety-only handling and the early helper routing below; they are
444+// excluded from omnibox preload handling.
445+func omniboxCombinedLaunchEnvironment(args []string) bool {
446+ if isCEFSubprocess(args) {
447+ return false
448+ }
449+ mode, _ := launchModeFromArgs(args)
450+ return mode == launchModeStandaloneOmnibox
451+}
452+
453+// ensureOmniboxLaunchEnvironment computes safety (GODEBUG) and layer-shell
454+// preload (LD_PRELOAD) changes together and applies at most one self-exec.
455+// A safety-required exec failure is fatal; a preload-only exec failure keeps
456+// the existing best-effort behavior and continues without preload.
457+func ensureOmniboxLaunchEnvironment() error {
458+ if isCEFSubprocess(os.Args) {
459+ return ensureRuntimeSafety()
460+ }
461+ environ, safetyChange := process.MergeRuntimeSafety(os.Environ())
462+ envMap := environSliceToMap(environ)
463+ preloadChange := false
464+ if bootstrap.ShouldPreloadLayerShell(envMap) {
465+ if libraryPath := bootstrap.LayerShellLibraryPath(); libraryPath != "" {
466+ envMap = bootstrap.LayerShellPreloadEnv(envMap, libraryPath)
467+ environ = environMapToSlice(envMap)
468+ preloadChange = true
469+ }
470+ }
471+ if !safetyChange && !preloadChange {
472+ return nil
473+ }
474+ execPath, err := resolveCurrentExecutable(os.Executable)
475+ if err != nil {
476+ if safetyChange {
477+ return fmt.Errorf("resolve executable for launch environment re-exec: %w", err)
478+ }
479+ _, _ = fmt.Fprintf(os.Stderr, "Failed to resolve standalone omnibox executable path: %v\n", err)
480+ return nil
481+ }
482+ // #nosec G702 -- execPath comes from os.Executable(), not user input.
483+ if err := syscall.Exec(execPath, os.Args, environ); err != nil {
484+ if safetyChange {
485+ return fmt.Errorf("launch environment re-exec: %w", err)
486+ }
487+ _, _ = fmt.Fprintf(os.Stderr, "Failed to re-exec standalone omnibox with layer-shell preload: %v\n", err)
488+ return nil
489+ }
490+ return nil
491+}
492+
493+func environSliceToMap(environ []string) map[string]string {
494+ env := make(map[string]string, len(environ))
495+ for _, entry := range environ {
496+ key, value, ok := strings.Cut(entry, "=")
497+ if !ok {
498+ continue
499+ }
500+ env[key] = value
501+ }
502+ return env
503+}
504+
505+func environMapToSlice(env map[string]string) []string {
506+ envSlice := make([]string, 0, len(env))
507+ for key, value := range env {
508+ envSlice = append(envSlice, key+"="+value)
509+ }
510+ return envSlice
511+}
512+
433 func maybeReexecStandaloneOmniboxWithLayerShell() {513 func maybeReexecStandaloneOmniboxWithLayerShell() {
514+ // Classified CEF helpers never take the omnibox preload path; early
515+ // helper routing in main owns those processes.
516+ if isCEFSubprocess(os.Args) {
517+ return
518+ }
434 env := bootstrap.CurrentEnvMap()519 env := bootstrap.CurrentEnvMap()
435 if !bootstrap.ShouldPreloadLayerShell(env) {520 if !bootstrap.ShouldPreloadLayerShell(env) {
436 return521 return
M cmd/dumber/main_test.go +22 -0
@@ -334,3 +334,25 @@ func TestTryForwardBrowseURLToRunningInstance_PropagatesError(t *testing.T) {
334334 t.Fatal("expected forwarded to be false on error")
335335 }
336336 }
337+
338+func TestOmniboxCombinedLaunchEnvironment(t *testing.T) {
339+ tests := []struct {
340+ name string
341+ args []string
342+ want bool
343+ }{
344+ {name: "omnibox", args: []string{"dumber", "omnibox"}, want: true},
345+ {name: "browse", args: []string{"dumber", "browse", "https://example.com"}, want: false},
346+ {name: "cli", args: []string{"dumber"}, want: false},
347+ {name: "cli with flags", args: []string{"dumber", "browse", "--help"}, want: false},
348+ {name: "helper excluded", args: []string{"dumber", "--type=renderer"}, want: false},
349+ {name: "helper with omnibox-like tail excluded", args: []string{"dumber", "--type", "renderer", "omnibox"}, want: false},
350+ }
351+ for _, tt := range tests {
352+ t.Run(tt.name, func(t *testing.T) {
353+ if got := omniboxCombinedLaunchEnvironment(tt.args); got != tt.want {
354+ t.Fatalf("omniboxCombinedLaunchEnvironment(%q) = %v, want %v", tt.args, got, tt.want)
355+ }
356+ })
357+ }
358+}
@@ -334,3 +334,25 @@ func TestTryForwardBrowseURLToRunningInstance_PropagatesError(t *testing.T) {
334 t.Fatal("expected forwarded to be false on error")334 t.Fatal("expected forwarded to be false on error")
335 }335 }
336 }336 }
337+
338+func TestOmniboxCombinedLaunchEnvironment(t *testing.T) {
339+ tests := []struct {
340+ name string
341+ args []string
342+ want bool
343+ }{
344+ {name: "omnibox", args: []string{"dumber", "omnibox"}, want: true},
345+ {name: "browse", args: []string{"dumber", "browse", "https://example.com"}, want: false},
346+ {name: "cli", args: []string{"dumber"}, want: false},
347+ {name: "cli with flags", args: []string{"dumber", "browse", "--help"}, want: false},
348+ {name: "helper excluded", args: []string{"dumber", "--type=renderer"}, want: false},
349+ {name: "helper with omnibox-like tail excluded", args: []string{"dumber", "--type", "renderer", "omnibox"}, want: false},
350+ }
351+ for _, tt := range tests {
352+ t.Run(tt.name, func(t *testing.T) {
353+ if got := omniboxCombinedLaunchEnvironment(tt.args); got != tt.want {
354+ t.Fatalf("omniboxCombinedLaunchEnvironment(%q) = %v, want %v", tt.args, got, tt.want)
355+ }
356+ })
357+ }
358+}
M cmd/dumber/runtime_safety.go +4 -42
@@ -3,55 +3,17 @@ package main
33 import (
44 "fmt"
55 "os"
6- "strings"
76 "syscall"
7+
8+ "github.com/bnema/dumber/internal/infrastructure/process"
89 )
910
1011 func environmentValue(environ []string, name string) string {
11- prefix := name + "="
12- for _, entry := range environ {
13- if strings.HasPrefix(entry, prefix) {
14- return strings.TrimPrefix(entry, prefix)
15- }
16- }
17- return ""
18-}
19-
20-func replaceEnvironmentValue(environ []string, name, value string) []string {
21- prefix := name + "="
22- updated := make([]string, 0, len(environ)+1)
23- for _, entry := range environ {
24- if !strings.HasPrefix(entry, prefix) {
25- updated = append(updated, entry)
26- }
27- }
28- return append(updated, prefix+value)
12+ return process.EnvironmentValue(environ, name)
2913 }
3014
3115 func runtimeSafetyEnvironment(environ []string) ([]string, bool) {
32- updated := append([]string(nil), environ...)
33- const setting = "gcshrinkstackoff"
34- parts := strings.Split(environmentValue(updated, "GODEBUG"), ",")
35- kept := make([]string, 0, len(parts)+1)
36- alreadySafe := false
37- for _, part := range parts {
38- part = strings.TrimSpace(part)
39- if part == "" {
40- continue
41- }
42- key, value, found := strings.Cut(part, "=")
43- if key == setting {
44- alreadySafe = found && value == "1"
45- continue
46- }
47- kept = append(kept, part)
48- }
49- if alreadySafe {
50- return updated, false
51- }
52- kept = append(kept, setting+"=1")
53- updated = replaceEnvironmentValue(updated, "GODEBUG", strings.Join(kept, ","))
54- return updated, true
16+ return process.MergeRuntimeSafety(environ)
5517 }
5618
5719 func ensureRuntimeSafety() error {
@@ -3,55 +3,17 @@ package main
3 import (3 import (
4 "fmt"4 "fmt"
5 "os"5 "os"
6- "strings"
7 "syscall"6 "syscall"
7+
8+ "github.com/bnema/dumber/internal/infrastructure/process"
8 )9 )
9 10
10 func environmentValue(environ []string, name string) string {11 func environmentValue(environ []string, name string) string {
11- prefix := name + "="12+ return process.EnvironmentValue(environ, name)
12- for _, entry := range environ {
13- if strings.HasPrefix(entry, prefix) {
14- return strings.TrimPrefix(entry, prefix)
15- }
16- }
17- return ""
18-}
19-
20-func replaceEnvironmentValue(environ []string, name, value string) []string {
21- prefix := name + "="
22- updated := make([]string, 0, len(environ)+1)
23- for _, entry := range environ {
24- if !strings.HasPrefix(entry, prefix) {
25- updated = append(updated, entry)
26- }
27- }
28- return append(updated, prefix+value)
29 }13 }
30 14
31 func runtimeSafetyEnvironment(environ []string) ([]string, bool) {15 func runtimeSafetyEnvironment(environ []string) ([]string, bool) {
32- updated := append([]string(nil), environ...)16+ return process.MergeRuntimeSafety(environ)
33- const setting = "gcshrinkstackoff"
34- parts := strings.Split(environmentValue(updated, "GODEBUG"), ",")
35- kept := make([]string, 0, len(parts)+1)
36- alreadySafe := false
37- for _, part := range parts {
38- part = strings.TrimSpace(part)
39- if part == "" {
40- continue
41- }
42- key, value, found := strings.Cut(part, "=")
43- if key == setting {
44- alreadySafe = found && value == "1"
45- continue
46- }
47- kept = append(kept, part)
48- }
49- if alreadySafe {
50- return updated, false
51- }
52- kept = append(kept, setting+"=1")
53- updated = replaceEnvironmentValue(updated, "GODEBUG", strings.Join(kept, ","))
54- return updated, true
55 }17 }
56 18
57 func ensureRuntimeSafety() error {19 func ensureRuntimeSafety() error {
M docs/first-presentation.md +51 -6
@@ -36,14 +36,49 @@ contract rather than this CEF trace.
3636
3737 ## Reproducible collection
3838
39-Build the candidate, then run:
39+Build the candidate and its build manifest, then run:
4040
4141 ```bash
42+DUMBER_CEF_DIR=/path/to/selected-cef-runtime \
4243 DUMBER_FIRST_PRESENTATION_BIN="$PWD/dist/dumber" \
44+DUMBER_BUILD_MANIFEST="$PWD/dist/dumber.manifest.json" \
4345 DUMBER_MACHINE_GPU_PROFILE=integrated-gpu \
4446 scripts/collect_first_presentation.sh
4547 ```
4648
49+The manifest records the measured binary SHA-256, the 40-character source
50+revision, and the selected `github.com/bnema/purego-cef2gtk` version plus its
51+full 40-character revision. Generate it at build time from the same tree
52+that produced the binary; collection verifies the manifest against
53+`go version -m` output for the measured binary and fails closed on missing
54+or mismatched attribution.
55+
56+```bash
57+make build-manifest
58+# or, equivalently:
59+python3 scripts/generate_build_manifest.py --binary dist/dumber
60+```
61+
62+Manifest schema (JSON, sorted keys):
63+
64+```json
65+{
66+ "binary_sha256": "<hex sha256 of the measured binary>",
67+ "source_revision": "<40-hex git revision of the built source>",
68+ "modules": {
69+ "github.com/bnema/purego-cef2gtk": {
70+ "version": "<exact version from go version -m>",
71+ "revision": "<40-hex git hash from go mod download origin>"
72+ }
73+ }
74+}
75+```
76+
77+The generator refuses binaries built with a `replace` directive, non-tag
78+dependency versions, and missing source revisions. The default manifest
79+path is `<binary>.manifest.json`, which is also the collector default
80+(`DUMBER_BUILD_MANIFEST` overrides it).
81+
4782 The collector is for the accelerated CEF/DMABUF/GTK contract above. By default
4883 each collection is a fresh directory below
4984 `$XDG_STATE_HOME/dumber/roadmap-evidence` (or
@@ -52,8 +87,14 @@ not in the repository. `DUMBER_FIRST_PRESENTATION_OUTPUT` may override it only
5287 with a new absolute directory below an existing non-symlink parent. The
5388 collector never clears or reuses a caller-supplied path.
5489
55-The script requires the current display and the CEF 147 runtime at
56-`~/.local/share/cef-147-runtime` (override with `DUMBER_CEF_DIR`). It performs
90+The script requires the current display and an explicit `DUMBER_CEF_DIR`
91+selecting the measured runtime; there is no default path or version.
92+`scripts/cef_runtime_probe.py` runs in a separate bounded subprocess, opens
93+only the selected `libcef.so` with standard-library ctypes, calls the
94+header-verified `cef_version_info(int)` entries (0-7), and reports numeric
95+version fields plus the library hash, never its path. The candidate must still
96+pass its normal loader ABI/version validation; the probe never bypasses it.
97+Runtimes below Chrome major 150 fail collection. It performs
5798 exactly five bounded launches with fresh XDG homes and CEF root cache, fixes the
5899 DMABUF/Vulkan renderer, and writes exactly `metadata.json`, `run-01.json`
59100 through `run-05.json`, and `baseline.json`. Metadata includes only coarse,
@@ -62,8 +103,12 @@ labels. Set `DUMBER_MACHINE_GPU_PROFILE` to one of `generic-gpu`,
62103 `integrated-gpu`, `discrete-gpu`, `hybrid-gpu`, `virtual-gpu`, or `unknown-gpu`;
63104 do not use a device model or other identifier. Publish only the seven reviewed
64105 JSON files to an external Gist. Raw logs and temporary XDG homes are removed and
65-must not be published. `metadata.json` derives the selected
66-`github.com/bnema/purego-cef2gtk` pseudo-version and its full immutable Git
67-origin hash from Go module metadata; branch selectors or missing origin metadata
106+must not be published. `metadata.json` binds the measured binary SHA-256 to
107+its source revision via `go version -m` plus the build manifest, and records
108+the selected `github.com/bnema/purego-cef2gtk` version, tag, and full revision
109+from that same binary-bound evidence; it never derives provenance from the
110+collection checkout. The child processes receive exactly the selected runtime
111+via `CEF_DIR`, never a conflicting inherited override. Branch selectors,
112+replacement contamination, version/manifest mismatches, or a missing manifest
68113 fail collection. A missing or incomplete timeline, non-DMABUF backend, or invalid
69114 run also fails collection.
@@ -36,14 +36,49 @@ contract rather than this CEF trace.
36 36
37 ## Reproducible collection37 ## Reproducible collection
38 38
39-Build the candidate, then run:39+Build the candidate and its build manifest, then run:
40 40
41 ```bash41 ```bash
42+DUMBER_CEF_DIR=/path/to/selected-cef-runtime \
42 DUMBER_FIRST_PRESENTATION_BIN="$PWD/dist/dumber" \43 DUMBER_FIRST_PRESENTATION_BIN="$PWD/dist/dumber" \
44+DUMBER_BUILD_MANIFEST="$PWD/dist/dumber.manifest.json" \
43 DUMBER_MACHINE_GPU_PROFILE=integrated-gpu \45 DUMBER_MACHINE_GPU_PROFILE=integrated-gpu \
44 scripts/collect_first_presentation.sh46 scripts/collect_first_presentation.sh
45 ```47 ```
46 48
49+The manifest records the measured binary SHA-256, the 40-character source
50+revision, and the selected `github.com/bnema/purego-cef2gtk` version plus its
51+full 40-character revision. Generate it at build time from the same tree
52+that produced the binary; collection verifies the manifest against
53+`go version -m` output for the measured binary and fails closed on missing
54+or mismatched attribution.
55+
56+```bash
57+make build-manifest
58+# or, equivalently:
59+python3 scripts/generate_build_manifest.py --binary dist/dumber
60+```
61+
62+Manifest schema (JSON, sorted keys):
63+
64+```json
65+{
66+ "binary_sha256": "<hex sha256 of the measured binary>",
67+ "source_revision": "<40-hex git revision of the built source>",
68+ "modules": {
69+ "github.com/bnema/purego-cef2gtk": {
70+ "version": "<exact version from go version -m>",
71+ "revision": "<40-hex git hash from go mod download origin>"
72+ }
73+ }
74+}
75+```
76+
77+The generator refuses binaries built with a `replace` directive, non-tag
78+dependency versions, and missing source revisions. The default manifest
79+path is `<binary>.manifest.json`, which is also the collector default
80+(`DUMBER_BUILD_MANIFEST` overrides it).
81+
47 The collector is for the accelerated CEF/DMABUF/GTK contract above. By default82 The collector is for the accelerated CEF/DMABUF/GTK contract above. By default
48 each collection is a fresh directory below83 each collection is a fresh directory below
49 `$XDG_STATE_HOME/dumber/roadmap-evidence` (or84 `$XDG_STATE_HOME/dumber/roadmap-evidence` (or
@@ -52,8 +87,14 @@ not in the repository. `DUMBER_FIRST_PRESENTATION_OUTPUT` may override it only
52 with a new absolute directory below an existing non-symlink parent. The87 with a new absolute directory below an existing non-symlink parent. The
53 collector never clears or reuses a caller-supplied path.88 collector never clears or reuses a caller-supplied path.
54 89
55-The script requires the current display and the CEF 147 runtime at90+The script requires the current display and an explicit `DUMBER_CEF_DIR`
56-`~/.local/share/cef-147-runtime` (override with `DUMBER_CEF_DIR`). It performs91+selecting the measured runtime; there is no default path or version.
92+`scripts/cef_runtime_probe.py` runs in a separate bounded subprocess, opens
93+only the selected `libcef.so` with standard-library ctypes, calls the
94+header-verified `cef_version_info(int)` entries (0-7), and reports numeric
95+version fields plus the library hash, never its path. The candidate must still
96+pass its normal loader ABI/version validation; the probe never bypasses it.
97+Runtimes below Chrome major 150 fail collection. It performs
57 exactly five bounded launches with fresh XDG homes and CEF root cache, fixes the98 exactly five bounded launches with fresh XDG homes and CEF root cache, fixes the
58 DMABUF/Vulkan renderer, and writes exactly `metadata.json`, `run-01.json`99 DMABUF/Vulkan renderer, and writes exactly `metadata.json`, `run-01.json`
59 through `run-05.json`, and `baseline.json`. Metadata includes only coarse,100 through `run-05.json`, and `baseline.json`. Metadata includes only coarse,
@@ -62,8 +103,12 @@ labels. Set `DUMBER_MACHINE_GPU_PROFILE` to one of `generic-gpu`,
62 `integrated-gpu`, `discrete-gpu`, `hybrid-gpu`, `virtual-gpu`, or `unknown-gpu`;103 `integrated-gpu`, `discrete-gpu`, `hybrid-gpu`, `virtual-gpu`, or `unknown-gpu`;
63 do not use a device model or other identifier. Publish only the seven reviewed104 do not use a device model or other identifier. Publish only the seven reviewed
64 JSON files to an external Gist. Raw logs and temporary XDG homes are removed and105 JSON files to an external Gist. Raw logs and temporary XDG homes are removed and
65-must not be published. `metadata.json` derives the selected106+must not be published. `metadata.json` binds the measured binary SHA-256 to
66-`github.com/bnema/purego-cef2gtk` pseudo-version and its full immutable Git107+its source revision via `go version -m` plus the build manifest, and records
67-origin hash from Go module metadata; branch selectors or missing origin metadata108+the selected `github.com/bnema/purego-cef2gtk` version, tag, and full revision
109+from that same binary-bound evidence; it never derives provenance from the
110+collection checkout. The child processes receive exactly the selected runtime
111+via `CEF_DIR`, never a conflicting inherited override. Branch selectors,
112+replacement contamination, version/manifest mismatches, or a missing manifest
68 fail collection. A missing or incomplete timeline, non-DMABUF backend, or invalid113 fail collection. A missing or incomplete timeline, non-DMABUF backend, or invalid
69 run also fails collection.114 run also fails collection.
M internal/infrastructure/cef/first_presentation_collector_test.go +328 -69
@@ -1,6 +1,8 @@
11 package cef
22
33 import (
4+ "crypto/sha256"
5+ "encoding/hex"
46 "encoding/json"
57 "fmt"
68 "os"
@@ -22,6 +24,12 @@ const validFirstPresentationLog = `{"message":"startup_trace: milestone","milest
2224 {"message":"startup_trace: milestone","milestone":"first_gtk_presentation","t_ms":7,"delta_ms":1}
2325 {"message":"startup_trace: first presentation","backend":"gdk-dmabuf","incomplete_reason":"","total_ms":7,"host":"alice"}`
2426
27+const (
28+ testUpstreamVersion = "v0.8.5-0.20300102030405-bbd397409ebe"
29+ testUpstreamRevision = "bbd397409ebed75a5979c1e4566a2ef319f6a484"
30+ testSourceRevision = "0123456789abcdef0123456789abcdef01234567"
31+)
32+
2533 func TestFirstPresentationCollectorNeverRecursivelyDeletesCallerOutput(t *testing.T) {
2634 repoRoot, err := filepath.Abs(filepath.Join("..", "..", ".."))
2735 require.NoError(t, err)
@@ -31,12 +39,26 @@ func TestFirstPresentationCollectorNeverRecursivelyDeletesCallerOutput(t *testin
3139 require.NotContains(t, string(script), "rm -rf -- \"$output\"")
3240 }
3341
42+func TestFirstPresentationCollectorHasNoHardcodedRuntimeDefaults(t *testing.T) {
43+ repoRoot, err := filepath.Abs(filepath.Join("..", "..", ".."))
44+ require.NoError(t, err)
45+ script, err := os.ReadFile(filepath.Join(repoRoot, "scripts", "collect_first_presentation.sh"))
46+ require.NoError(t, err)
47+ require.NotContains(t, string(script), "cef-147")
48+ require.NotContains(t, string(script), `"version": "147"`)
49+ require.NotContains(t, string(script), "go list -m")
50+ require.NotContains(t, string(script), "go mod download")
51+ require.NotContains(t, string(script), "-mod=mod")
52+ require.Contains(t, string(script), "DUMBER_CEF_DIR must be set")
53+ require.Contains(t, string(script), "cef_runtime_probe.py")
54+ require.Contains(t, string(script), `CEF_DIR="$selected_cef_dir"`)
55+}
56+
3457 func TestFirstPresentationCollectorRejectsUnsafeOutputPaths(t *testing.T) {
3558 repoRoot, err := filepath.Abs(filepath.Join("..", "..", ".."))
3659 require.NoError(t, err)
3760 temp := t.TempDir()
38- runtime := filepath.Join(temp, "cef-147-runtime")
39- require.NoError(t, os.Mkdir(runtime, 0o755))
61+ runtime := collectorFakeCEFRuntime(t, 150)
4062 binary := filepath.Join(temp, "dumber")
4163 require.NoError(t, os.WriteFile(binary, collectorTestBinary(validFirstPresentationLog), 0o755))
4264 home := filepath.Join(temp, "home")
@@ -112,15 +134,12 @@ func TestFirstPresentationCollectorSanitizesMachineLocalValues(t *testing.T) {
112134 repoRoot, err := filepath.Abs(filepath.Join("..", "..", ".."))
113135 require.NoError(t, err)
114136 temp := t.TempDir()
115- runtime := filepath.Join(temp, "cef-147-runtime")
116- require.NoError(t, os.Mkdir(runtime, 0o755))
117-
137+ runtime := collectorFakeCEFRuntime(t, 150)
118138 binary := filepath.Join(temp, "dumber")
119139 require.NoError(t, os.WriteFile(binary, collectorTestBinary(validFirstPresentationLog), 0o755))
120140
121- const upstreamVersion = "v0.8.5-0.20300102030405-bbd397409ebe"
122- const upstreamRevision = "bbd397409ebed75a5979c1e4566a2ef319f6a484"
123- goBin := collectorProvenanceGo(t, temp, upstreamVersion, upstreamRevision, "")
141+ goBin := collectorFakeGo(t, temp, testUpstreamVersion, testSourceRevision, false)
142+ manifest := collectorManifest(t, temp, binary, testSourceRevision, testUpstreamVersion, testUpstreamRevision)
124143 output := filepath.Join(temp, "artifacts")
125144 cmd := exec.Command(filepath.Join(repoRoot, "scripts", "collect_first_presentation.sh"))
126145 cmd.Dir = repoRoot
@@ -129,6 +148,7 @@ func TestFirstPresentationCollectorSanitizesMachineLocalValues(t *testing.T) {
129148 "WAYLAND_DISPLAY=",
130149 "DUMBER_CEF_DIR="+runtime,
131150 "DUMBER_FIRST_PRESENTATION_BIN="+binary,
151+ "DUMBER_BUILD_MANIFEST="+manifest,
132152 "DUMBER_FIRST_PRESENTATION_OUTPUT="+output,
133153 "DUMBER_FIRST_PRESENTATION_TIMEOUT_SECONDS=1",
134154 "DUMBER_MACHINE_GPU_PROFILE=integrated-gpu",
@@ -143,14 +163,16 @@ func TestFirstPresentationCollectorSanitizesMachineLocalValues(t *testing.T) {
143163 require.NoError(t, readErr)
144164 artifacts.Write(artifactContents)
145165 }
146- for _, forbidden := range []string{"/home/", temp, "alice", "machine_path", `"time"`} {
166+ for _, forbidden := range []string{"/home/", temp, "alice", "machine_path", `"time"`, runtime, "libcef.so"} {
147167 require.NotContainsf(t, artifacts.String(), forbidden, "committed artifact leaked %q", forbidden)
148168 }
149169 for _, required := range []string{
150170 `"measured_source_revision"`,
151- `"version": "` + upstreamVersion + `"`,
171+ `"version": "` + testUpstreamVersion + `"`,
152172 `"tag": "v0.8.5"`,
153- `"revision": "` + upstreamRevision + `"`,
173+ `"revision": "` + testUpstreamRevision + `"`,
174+ `"chrome_major": 150`,
175+ `"libcef_sha256"`,
154176 } {
155177 require.Contains(t, artifacts.String(), required)
156178 }
@@ -176,16 +198,12 @@ func TestFirstPresentationCollectorDerivesSelectedImmutableModuleProvenance(t *t
176198 repoRoot, err := filepath.Abs(filepath.Join("..", "..", ".."))
177199 require.NoError(t, err)
178200 temp := t.TempDir()
179- runtime := filepath.Join(temp, "cef-147-runtime")
180- require.NoError(t, os.Mkdir(runtime, 0o755))
201+ runtime := collectorFakeCEFRuntime(t, 150)
181202 binary := filepath.Join(temp, "dumber")
182203 require.NoError(t, os.WriteFile(binary, collectorTestBinary(validFirstPresentationLog), 0o755))
183204
184- // This exact pseudo-version is deliberately newer than the former v0.8.4
185- // hardcode; the collector must report what the selected module says.
186- const version = "v0.8.5-0.20300102030405-bbd397409ebe"
187- const revision = "bbd397409ebed75a5979c1e4566a2ef319f6a484"
188- goBin := collectorProvenanceGo(t, temp, version, revision, "")
205+ goBin := collectorFakeGo(t, temp, testUpstreamVersion, testSourceRevision, false)
206+ manifest := collectorManifest(t, temp, binary, testSourceRevision, testUpstreamVersion, testUpstreamRevision)
189207 output := filepath.Join(temp, "artifacts")
190208 cmd := exec.Command(filepath.Join(repoRoot, "scripts", "collect_first_presentation.sh"))
191209 cmd.Dir = repoRoot
@@ -193,6 +211,7 @@ func TestFirstPresentationCollectorDerivesSelectedImmutableModuleProvenance(t *t
193211 "DISPLAY=:test",
194212 "DUMBER_CEF_DIR="+runtime,
195213 "DUMBER_FIRST_PRESENTATION_BIN="+binary,
214+ "DUMBER_BUILD_MANIFEST="+manifest,
196215 "DUMBER_FIRST_PRESENTATION_OUTPUT="+output,
197216 "DUMBER_FIRST_PRESENTATION_TIMEOUT_SECONDS=1",
198217 "PATH="+filepath.Dir(goBin)+":"+os.Getenv("PATH"),
@@ -207,14 +226,16 @@ func TestFirstPresentationCollectorDerivesSelectedImmutableModuleProvenance(t *t
207226 Tag string `json:"tag"`
208227 Revision string `json:"revision"`
209228 } `json:"upstream"`
229+ MeasuredSourceRevision string `json:"measured_source_revision"`
210230 }
211231 contents, err := os.ReadFile(filepath.Join(output, "metadata.json"))
212232 require.NoError(t, err)
213233 require.NoError(t, json.Unmarshal(contents, &metadata))
214234 require.Equal(t, "github.com/bnema/purego-cef2gtk", metadata.Upstream.Module)
215- require.Equal(t, version, metadata.Upstream.Version)
235+ require.Equal(t, testUpstreamVersion, metadata.Upstream.Version)
216236 require.Equal(t, "v0.8.5", metadata.Upstream.Tag)
217- require.Equal(t, revision, metadata.Upstream.Revision)
237+ require.Equal(t, testUpstreamRevision, metadata.Upstream.Revision)
238+ require.Equal(t, testSourceRevision, metadata.MeasuredSourceRevision)
218239
219240 script, err := os.ReadFile(filepath.Join(repoRoot, "scripts", "collect_first_presentation.sh"))
220241 require.NoError(t, err)
@@ -226,20 +247,20 @@ func TestFirstPresentationCollectorRejectsNonImmutableModuleProvenanceWithoutLea
226247 require.NoError(t, err)
227248
228249 for _, test := range []struct {
229- name, version, revision, ref string
250+ name, version string
230251 }{
231- {name: "branch selector", version: "main", ref: "main"},
232- {name: "missing origin", version: "v0.8.5-0.20300102030405-bbd397409ebe"},
233- {name: "named origin ref", version: "v0.8.5-0.20300102030405-bbd397409ebe", revision: "bbd397409ebed75a5979c1e4566a2ef319f6a484", ref: "main"},
252+ {name: "branch selector", version: "main"},
253+ {name: "devel version", version: "(devel)"},
254+ {name: "missing patch", version: "v0.8"},
234255 } {
235256 t.Run(test.name, func(t *testing.T) {
236257 temp := t.TempDir()
237- runtime := filepath.Join(temp, "cef-147-runtime")
238- require.NoError(t, os.Mkdir(runtime, 0o755))
258+ runtime := collectorFakeCEFRuntime(t, 150)
239259 binary := filepath.Join(temp, "dumber")
240260 require.NoError(t, os.WriteFile(binary, collectorTestBinary(validFirstPresentationLog), 0o755))
241261
242- goBin := collectorProvenanceGo(t, temp, test.version, test.revision, test.ref)
262+ goBin := collectorFakeGo(t, temp, test.version, testSourceRevision, false)
263+ manifest := collectorManifest(t, temp, binary, testSourceRevision, testUpstreamVersion, testUpstreamRevision)
243264 output := filepath.Join(temp, "artifacts")
244265 cmd := exec.Command(filepath.Join(repoRoot, "scripts", "collect_first_presentation.sh"))
245266 cmd.Dir = repoRoot
@@ -247,6 +268,7 @@ func TestFirstPresentationCollectorRejectsNonImmutableModuleProvenanceWithoutLea
247268 "DISPLAY=:test",
248269 "DUMBER_CEF_DIR="+runtime,
249270 "DUMBER_FIRST_PRESENTATION_BIN="+binary,
271+ "DUMBER_BUILD_MANIFEST="+manifest,
250272 "DUMBER_FIRST_PRESENTATION_OUTPUT="+output,
251273 "PATH="+filepath.Dir(goBin)+":"+os.Getenv("PATH"),
252274 )
@@ -262,54 +284,289 @@ func TestFirstPresentationCollectorRejectsNonImmutableModuleProvenanceWithoutLea
262284 }
263285 }
264286
265-func collectorProvenanceGo(t *testing.T, temp, version, revision, ref string) string {
266- t.Helper()
267- infoPath := filepath.Join(temp, "module.info")
268- info := map[string]any{"Version": version}
269- if revision != "" {
270- info["Origin"] = map[string]string{
271- "VCS": "git", "URL": "https://github.com/bnema/purego-cef2gtk", "Hash": revision, "Ref": ref,
272- }
273- }
274- infoJSON, err := json.Marshal(info)
287+func TestFirstPresentationCollectorRequiresExplicitRuntimeDir(t *testing.T) {
288+ repoRoot, err := filepath.Abs(filepath.Join("..", "..", ".."))
275289 require.NoError(t, err)
276- require.NoError(t, os.WriteFile(infoPath, infoJSON, 0o600))
290+ temp := t.TempDir()
291+ binary := filepath.Join(temp, "dumber")
292+ require.NoError(t, os.WriteFile(binary, collectorTestBinary(validFirstPresentationLog), 0o755))
293+
294+ cmd := exec.Command(filepath.Join(repoRoot, "scripts", "collect_first_presentation.sh"))
295+ cmd.Dir = repoRoot
296+ env := envWithout("DUMBER_CEF_DIR")
297+ env = append(env,
298+ "DISPLAY=:test",
299+ "DUMBER_FIRST_PRESENTATION_BIN="+binary,
300+ "DUMBER_FIRST_PRESENTATION_OUTPUT="+filepath.Join(temp, "artifacts"),
301+ )
302+ cmd.Env = env
303+ result, err := cmd.CombinedOutput()
304+ require.Errorf(t, err, "collector accepted missing runtime dir: %s", result)
305+ require.Contains(t, string(result), "DUMBER_CEF_DIR must be set")
306+}
307+
308+func TestFirstPresentationCollectorIgnoresConflictingParentCEFDir(t *testing.T) {
309+ repoRoot, err := filepath.Abs(filepath.Join("..", "..", ".."))
310+ require.NoError(t, err)
311+ temp := t.TempDir()
312+ runtime := collectorFakeCEFRuntime(t, 150)
313+ bogus := filepath.Join(temp, "bogus-runtime")
314+ require.NoError(t, os.Mkdir(bogus, 0o755))
315+ binary := filepath.Join(temp, "dumber")
316+ require.NoError(t, os.WriteFile(binary, collectorTestBinary(validFirstPresentationLog), 0o755))
317+
318+ goBin := collectorFakeGo(t, temp, testUpstreamVersion, testSourceRevision, false)
319+ manifest := collectorManifest(t, temp, binary, testSourceRevision, testUpstreamVersion, testUpstreamRevision)
320+ output := filepath.Join(temp, "artifacts")
321+ cmd := exec.Command(filepath.Join(repoRoot, "scripts", "collect_first_presentation.sh"))
322+ cmd.Dir = repoRoot
323+ cmd.Env = append(os.Environ(),
324+ "DISPLAY=:test",
325+ "CEF_DIR="+bogus,
326+ "DUMBER_CEF_DIR="+runtime,
327+ "DUMBER_FIRST_PRESENTATION_BIN="+binary,
328+ "DUMBER_BUILD_MANIFEST="+manifest,
329+ "DUMBER_FIRST_PRESENTATION_OUTPUT="+output,
330+ "DUMBER_FIRST_PRESENTATION_TIMEOUT_SECONDS=1",
331+ "PATH="+filepath.Dir(goBin)+":"+os.Getenv("PATH"),
332+ )
333+ result, err := cmd.CombinedOutput()
334+ require.NoErrorf(t, err, "collector failed with conflicting parent CEF_DIR: %s", result)
335+ require.FileExists(t, filepath.Join(output, "metadata.json"))
336+}
277337
278- selectedJSON, err := json.Marshal(map[string]string{
279- "Path": "github.com/bnema/purego-cef2gtk", "Version": version,
280- })
338+func TestFirstPresentationCollectorRejectsProbeFailure(t *testing.T) {
339+ repoRoot, err := filepath.Abs(filepath.Join("..", "..", ".."))
281340 require.NoError(t, err)
282- downloadedJSON, err := json.Marshal(map[string]string{
283- "Path": "github.com/bnema/purego-cef2gtk", "Version": version, "Info": infoPath,
284- })
341+ temp := t.TempDir()
342+ runtime := filepath.Join(temp, "empty-runtime")
343+ require.NoError(t, os.Mkdir(runtime, 0o755))
344+ binary := filepath.Join(temp, "dumber")
345+ require.NoError(t, os.WriteFile(binary, collectorTestBinary(validFirstPresentationLog), 0o755))
346+
347+ goBin := collectorFakeGo(t, temp, testUpstreamVersion, testSourceRevision, false)
348+ manifest := collectorManifest(t, temp, binary, testSourceRevision, testUpstreamVersion, testUpstreamRevision)
349+ cmd := exec.Command(filepath.Join(repoRoot, "scripts", "collect_first_presentation.sh"))
350+ cmd.Dir = repoRoot
351+ cmd.Env = append(os.Environ(),
352+ "DISPLAY=:test",
353+ "DUMBER_CEF_DIR="+runtime,
354+ "DUMBER_FIRST_PRESENTATION_BIN="+binary,
355+ "DUMBER_BUILD_MANIFEST="+manifest,
356+ "DUMBER_FIRST_PRESENTATION_OUTPUT="+filepath.Join(temp, "artifacts"),
357+ "PATH="+filepath.Dir(goBin)+":"+os.Getenv("PATH"),
358+ )
359+ result, err := cmd.CombinedOutput()
360+ require.Errorf(t, err, "collector accepted missing runtime library: %s", result)
361+ require.Contains(t, string(result), "CEF runtime probe failed")
362+}
363+
364+func TestFirstPresentationCollectorRejectsUnsupportedRuntime(t *testing.T) {
365+ repoRoot, err := filepath.Abs(filepath.Join("..", "..", ".."))
285366 require.NoError(t, err)
286- binDir := filepath.Join(temp, "bin")
287- require.NoError(t, os.Mkdir(binDir, 0o755))
367+ temp := t.TempDir()
368+ runtime := collectorFakeCEFRuntime(t, 140)
369+ binary := filepath.Join(temp, "dumber")
370+ require.NoError(t, os.WriteFile(binary, collectorTestBinary(validFirstPresentationLog), 0o755))
371+
372+ goBin := collectorFakeGo(t, temp, testUpstreamVersion, testSourceRevision, false)
373+ manifest := collectorManifest(t, temp, binary, testSourceRevision, testUpstreamVersion, testUpstreamRevision)
374+ cmd := exec.Command(filepath.Join(repoRoot, "scripts", "collect_first_presentation.sh"))
375+ cmd.Dir = repoRoot
376+ cmd.Env = append(os.Environ(),
377+ "DISPLAY=:test",
378+ "DUMBER_CEF_DIR="+runtime,
379+ "DUMBER_FIRST_PRESENTATION_BIN="+binary,
380+ "DUMBER_BUILD_MANIFEST="+manifest,
381+ "DUMBER_FIRST_PRESENTATION_OUTPUT="+filepath.Join(temp, "artifacts"),
382+ "PATH="+filepath.Dir(goBin)+":"+os.Getenv("PATH"),
383+ )
384+ result, err := cmd.CombinedOutput()
385+ require.Errorf(t, err, "collector accepted unsupported runtime: %s", result)
386+ require.Contains(t, string(result), "unsupported CEF runtime")
387+}
388+
389+func TestFirstPresentationCollectorRequiresManifestWithoutEmbeddedVCS(t *testing.T) {
390+ repoRoot, err := filepath.Abs(filepath.Join("..", "..", ".."))
391+ require.NoError(t, err)
392+ temp := t.TempDir()
393+ runtime := collectorFakeCEFRuntime(t, 150)
394+ binary := filepath.Join(temp, "dumber")
395+ require.NoError(t, os.WriteFile(binary, collectorTestBinary(validFirstPresentationLog), 0o755))
396+
397+ goBin := collectorFakeGo(t, temp, testUpstreamVersion, "", false)
398+ cmd := exec.Command(filepath.Join(repoRoot, "scripts", "collect_first_presentation.sh"))
399+ cmd.Dir = repoRoot
400+ cmd.Env = append(os.Environ(),
401+ "DISPLAY=:test",
402+ "DUMBER_CEF_DIR="+runtime,
403+ "DUMBER_FIRST_PRESENTATION_BIN="+binary,
404+ "DUMBER_BUILD_MANIFEST="+filepath.Join(temp, "missing-manifest.json"),
405+ "DUMBER_FIRST_PRESENTATION_OUTPUT="+filepath.Join(temp, "artifacts"),
406+ "PATH="+filepath.Dir(goBin)+":"+os.Getenv("PATH"),
407+ )
408+ result, err := cmd.CombinedOutput()
409+ require.Errorf(t, err, "collector accepted binary without VCS or manifest: %s", result)
410+ require.Contains(t, string(result), "build manifest is required")
411+}
412+
413+func TestFirstPresentationCollectorRejectsManifestMismatch(t *testing.T) {
414+ repoRoot, err := filepath.Abs(filepath.Join("..", "..", ".."))
415+ require.NoError(t, err)
416+
417+ for _, test := range []struct {
418+ name string
419+ sourceRevision string
420+ depVersion string
421+ depRevision string
422+ mutateBinary bool
423+ }{
424+ {name: "binary checkout mismatch", sourceRevision: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", depVersion: testUpstreamVersion, depRevision: testUpstreamRevision},
425+ {name: "dependency version mismatch", sourceRevision: testSourceRevision, depVersion: "v0.9.3", depRevision: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"},
426+ {name: "dependency revision mismatch", sourceRevision: testSourceRevision, depVersion: testUpstreamVersion, depRevision: "cccccccccccccccccccccccccccccccccccccccc"},
427+ } {
428+ t.Run(test.name, func(t *testing.T) {
429+ temp := t.TempDir()
430+ runtime := collectorFakeCEFRuntime(t, 150)
431+ binary := filepath.Join(temp, "dumber")
432+ require.NoError(t, os.WriteFile(binary, collectorTestBinary(validFirstPresentationLog), 0o755))
433+
434+ goBin := collectorFakeGo(t, temp, testUpstreamVersion, testSourceRevision, false)
435+ manifest := collectorManifest(t, temp, binary, test.sourceRevision, test.depVersion, test.depRevision)
436+ if test.mutateBinary {
437+ require.NoError(t, os.WriteFile(binary, collectorTestBinary(validFirstPresentationLog+"extra"), 0o755))
438+ }
439+ cmd := exec.Command(filepath.Join(repoRoot, "scripts", "collect_first_presentation.sh"))
440+ cmd.Dir = repoRoot
441+ cmd.Env = append(os.Environ(),
442+ "DISPLAY=:test",
443+ "DUMBER_CEF_DIR="+runtime,
444+ "DUMBER_FIRST_PRESENTATION_BIN="+binary,
445+ "DUMBER_BUILD_MANIFEST="+manifest,
446+ "DUMBER_FIRST_PRESENTATION_OUTPUT="+filepath.Join(temp, "artifacts"),
447+ "PATH="+filepath.Dir(goBin)+":"+os.Getenv("PATH"),
448+ )
449+ result, err := cmd.CombinedOutput()
450+ require.Errorf(t, err, "collector accepted mismatched manifest: %s", result)
451+ require.Contains(t, string(result), "build manifest does not match")
452+ })
453+ }
454+}
455+
456+func TestFirstPresentationCollectorRejectsReplacementContamination(t *testing.T) {
457+ repoRoot, err := filepath.Abs(filepath.Join("..", "..", ".."))
458+ require.NoError(t, err)
459+ temp := t.TempDir()
460+ runtime := collectorFakeCEFRuntime(t, 150)
461+ binary := filepath.Join(temp, "dumber")
462+ require.NoError(t, os.WriteFile(binary, collectorTestBinary(validFirstPresentationLog), 0o755))
463+
464+ goBin := collectorFakeGo(t, temp, testUpstreamVersion, testSourceRevision, true)
465+ manifest := collectorManifest(t, temp, binary, testSourceRevision, testUpstreamVersion, testUpstreamRevision)
466+ cmd := exec.Command(filepath.Join(repoRoot, "scripts", "collect_first_presentation.sh"))
467+ cmd.Dir = repoRoot
468+ cmd.Env = append(os.Environ(),
469+ "DISPLAY=:test",
470+ "DUMBER_CEF_DIR="+runtime,
471+ "DUMBER_FIRST_PRESENTATION_BIN="+binary,
472+ "DUMBER_BUILD_MANIFEST="+manifest,
473+ "DUMBER_FIRST_PRESENTATION_OUTPUT="+filepath.Join(temp, "artifacts"),
474+ "PATH="+filepath.Dir(goBin)+":"+os.Getenv("PATH"),
475+ )
476+ result, err := cmd.CombinedOutput()
477+ require.Errorf(t, err, "collector accepted replacement contamination: %s", result)
478+ require.Contains(t, string(result), "immutable module provenance is unavailable")
479+}
480+
481+// collectorFakeGo fakes `go version -m <binary>` for the measured binary.
482+// It never consults the current checkout module graph.
483+func collectorFakeGo(t *testing.T, temp, depVersion, vcsRevision string, replacement bool) string {
484+ t.Helper()
485+ binDir := filepath.Join(temp, "gobin")
486+ require.NoError(t, os.MkdirAll(binDir, 0o755))
288487 goBin := filepath.Join(binDir, "go")
488+ depLine := fmt.Sprintf("dep\tgithub.com/bnema/purego-cef2gtk\t%s", depVersion)
489+ if replacement {
490+ depLine += " => ./local-override"
491+ }
492+ buildLine := ""
493+ if vcsRevision != "" {
494+ // Real `go version -m` format is "build vcs.revision=<hex>".
495+ buildLine = fmt.Sprintf("build\tvcs.revision=%s", vcsRevision)
496+ }
289497 script := fmt.Sprintf(`#!/bin/sh
290-if [ "$1" = "list" ] && [ "$2" = "-m" ] && [ "$3" = "-json" ] && [ "$4" = "github.com/bnema/purego-cef2gtk" ]; then
291- printf '%%s\n' '%s'
292- exit 0
293-fi
294-if [ "$1" = "mod" ] && [ "$2" = "download" ] && [ "$3" = "-json" ] && [ "$4" = "github.com/bnema/purego-cef2gtk@%s" ]; then
295- printf '%%s\n' '%s'
498+if [ "$1" = "version" ] && [ "$2" = "-m" ]; then
499+ printf '%%s\n' 'path\tgithub.com/bnema/dumber/cmd/dumber' 'mod\tgithub.com/bnema/dumber\t(devel)' '%s' '%s'
296500 exit 0
297501 fi
298502 exit 1
299-`, selectedJSON, version, downloadedJSON)
503+`, depLine, buildLine)
300504 require.NoError(t, os.WriteFile(goBin, []byte(script), 0o755))
301505 return goBin
302506 }
303507
508+func collectorManifest(t *testing.T, temp, binary, sourceRevision, depVersion, depRevision string) string {
509+ t.Helper()
510+ contents, err := os.ReadFile(binary)
511+ require.NoError(t, err)
512+ sum := sha256.Sum256(contents)
513+ manifest := map[string]any{
514+ "binary_sha256": hex.EncodeToString(sum[:]),
515+ "source_revision": sourceRevision,
516+ "modules": map[string]any{
517+ "github.com/bnema/purego-cef2gtk": map[string]string{
518+ "version": depVersion,
519+ "revision": depRevision,
520+ },
521+ },
522+ }
523+ raw, err := json.Marshal(manifest)
524+ require.NoError(t, err)
525+ path := filepath.Join(temp, "build-manifest.json")
526+ require.NoError(t, os.WriteFile(path, raw, 0o600))
527+ return path
528+}
529+
530+// collectorFakeCEFRuntime builds a tiny shared library exposing the
531+// header-verified cef_version_info(int) entries with a controlled Chrome
532+// major. It exercises the real ctypes probe path without copying a full
533+// multi-hundred-megabyte runtime per test.
534+func collectorFakeCEFRuntime(t *testing.T, chromeMajor int) string {
535+ t.Helper()
536+ runtime := t.TempDir()
537+ source := fmt.Sprintf(`int cef_version_info(int entry) {
538+ switch (entry) {
539+ case 0: return 150;
540+ case 1: return 0;
541+ case 2: return 0;
542+ case 3: return 0;
543+ case 4: return %d;
544+ case 5: return 0;
545+ case 6: return 0;
546+ case 7: return 0;
547+ default: return 0;
548+ }
549+}
550+`, chromeMajor)
551+ src := filepath.Join(runtime, "fake_cef.c")
552+ require.NoError(t, os.WriteFile(src, []byte(source), 0o600))
553+ lib := filepath.Join(runtime, "libcef.so")
554+ cmd := exec.Command("gcc", "-shared", "-fPIC", "-o", lib, src)
555+ out, err := cmd.CombinedOutput()
556+ require.NoErrorf(t, err, "failed to build fake libcef: %s", out)
557+ return runtime
558+}
559+
304560 func TestFirstPresentationCollectorDefaultsToXDGStateEvidenceDirectory(t *testing.T) {
305561 repoRoot, err := filepath.Abs(filepath.Join("..", "..", ".."))
306562 require.NoError(t, err)
307563 temp := t.TempDir()
308- runtime := filepath.Join(temp, "cef-147-runtime")
309- require.NoError(t, os.Mkdir(runtime, 0o755))
564+ runtime := collectorFakeCEFRuntime(t, 150)
310565 binary := filepath.Join(temp, "dumber")
311566 require.NoError(t, os.WriteFile(binary, collectorTestBinary(validFirstPresentationLog), 0o755))
312567 stateHome := filepath.Join(temp, "state")
568+ goBin := collectorFakeGo(t, temp, testUpstreamVersion, testSourceRevision, false)
569+ manifest := collectorManifest(t, temp, binary, testSourceRevision, testUpstreamVersion, testUpstreamRevision)
313570
314571 cmd := exec.Command(filepath.Join(repoRoot, "scripts", "collect_first_presentation.sh"))
315572 cmd.Dir = repoRoot
@@ -318,9 +575,11 @@ func TestFirstPresentationCollectorDefaultsToXDGStateEvidenceDirectory(t *testin
318575 "WAYLAND_DISPLAY=",
319576 "DUMBER_CEF_DIR="+runtime,
320577 "DUMBER_FIRST_PRESENTATION_BIN="+binary,
578+ "DUMBER_BUILD_MANIFEST="+manifest,
321579 "DUMBER_FIRST_PRESENTATION_TIMEOUT_SECONDS=1",
322580 "DUMBER_MACHINE_GPU_PROFILE=integrated-gpu",
323581 "XDG_STATE_HOME="+stateHome,
582+ "PATH="+filepath.Dir(goBin)+":"+os.Getenv("PATH"),
324583 )
325584 result, err := cmd.CombinedOutput()
326585 require.NoErrorf(t, err, "collector failed: %s", result)
@@ -345,22 +604,19 @@ func envWithout(name string) []string {
345604 return environment
346605 }
347606
348-func TestFirstPresentationCollectorReadsProvenanceWithScopedGitSafeDirectory(t *testing.T) {
607+func TestFirstPresentationCollectorBindsBinaryNotCheckoutGit(t *testing.T) {
349608 repoRoot, err := filepath.Abs(filepath.Join("..", "..", ".."))
350609 require.NoError(t, err)
351610 temp := t.TempDir()
352- runtime := filepath.Join(temp, "cef-147-runtime")
353- require.NoError(t, os.Mkdir(runtime, 0o755))
611+ runtime := collectorFakeCEFRuntime(t, 150)
354612 binary := filepath.Join(temp, "dumber")
355613 require.NoError(t, os.WriteFile(binary, collectorTestBinary(validFirstPresentationLog), 0o755))
356- gitDir := filepath.Join(temp, "bin")
614+ goBin := collectorFakeGo(t, temp, testUpstreamVersion, testSourceRevision, false)
615+ manifest := collectorManifest(t, temp, binary, testSourceRevision, testUpstreamVersion, testUpstreamRevision)
616+ gitDir := filepath.Join(temp, "gitbin")
357617 require.NoError(t, os.Mkdir(gitDir, 0o755))
358618 git := filepath.Join(gitDir, "git")
359619 require.NoError(t, os.WriteFile(git, []byte(`#!/bin/sh
360-if [ "$1" = "-c" ] && [ "$2" = "safe.directory=$EXPECTED_SAFE_DIRECTORY" ] && [ "$3" = "rev-parse" ] && [ "$4" = "HEAD" ]; then
361- echo 0123456789abcdef0123456789abcdef01234567
362- exit 0
363-fi
364620 echo "fatal: detected dubious ownership in repository" >&2
365621 exit 128
366622 `), 0o755))
@@ -372,13 +628,13 @@ exit 128
372628 "DISPLAY=:test",
373629 "DUMBER_CEF_DIR="+runtime,
374630 "DUMBER_FIRST_PRESENTATION_BIN="+binary,
631+ "DUMBER_BUILD_MANIFEST="+manifest,
375632 "DUMBER_FIRST_PRESENTATION_OUTPUT="+output,
376633 "DUMBER_FIRST_PRESENTATION_TIMEOUT_SECONDS=1",
377- "EXPECTED_SAFE_DIRECTORY="+repoRoot,
378- "PATH="+gitDir+":"+os.Getenv("PATH"),
634+ "PATH="+gitDir+":"+filepath.Dir(goBin)+":"+os.Getenv("PATH"),
379635 )
380636 result, err := cmd.CombinedOutput()
381- require.NoErrorf(t, err, "collector failed: %s", result)
637+ require.NoErrorf(t, err, "collector depends on checkout git: %s", result)
382638
383639 var metadata struct {
384640 MeasuredSourceRevision string `json:"measured_source_revision"`
@@ -386,7 +642,7 @@ exit 128
386642 contents, err := os.ReadFile(filepath.Join(output, "metadata.json"))
387643 require.NoError(t, err)
388644 require.NoError(t, json.Unmarshal(contents, &metadata))
389- require.Equal(t, "0123456789abcdef0123456789abcdef01234567", metadata.MeasuredSourceRevision)
645+ require.Equal(t, testSourceRevision, metadata.MeasuredSourceRevision)
390646 }
391647
392648 func TestFirstPresentationCollectorRejectsInconsistentTiming(t *testing.T) {
@@ -405,11 +661,12 @@ func TestFirstPresentationCollectorRejectsInconsistentTiming(t *testing.T) {
405661 } {
406662 t.Run(test.name, func(t *testing.T) {
407663 temp := t.TempDir()
408- runtime := filepath.Join(temp, "cef-147-runtime")
409- require.NoError(t, os.Mkdir(runtime, 0o755))
664+ runtime := collectorFakeCEFRuntime(t, 150)
410665 binary := filepath.Join(temp, "dumber")
411666 log := strings.Replace(validFirstPresentationLog, test.old, test.new, 1)
412667 require.NoError(t, os.WriteFile(binary, collectorTestBinary(log), 0o755))
668+ goBin := collectorFakeGo(t, temp, testUpstreamVersion, testSourceRevision, false)
669+ manifest := collectorManifest(t, temp, binary, testSourceRevision, testUpstreamVersion, testUpstreamRevision)
413670
414671 cmd := exec.Command(filepath.Join(repoRoot, "scripts", "collect_first_presentation.sh"))
415672 cmd.Dir = repoRoot
@@ -417,8 +674,10 @@ func TestFirstPresentationCollectorRejectsInconsistentTiming(t *testing.T) {
417674 "DISPLAY=:test",
418675 "DUMBER_CEF_DIR="+runtime,
419676 "DUMBER_FIRST_PRESENTATION_BIN="+binary,
677+ "DUMBER_BUILD_MANIFEST="+manifest,
420678 "DUMBER_FIRST_PRESENTATION_OUTPUT="+filepath.Join(temp, "artifacts"),
421679 "DUMBER_FIRST_PRESENTATION_TIMEOUT_SECONDS=1",
680+ "PATH="+filepath.Dir(goBin)+":"+os.Getenv("PATH"),
422681 )
423682 result, err := cmd.CombinedOutput()
424683 require.Errorf(t, err, "collector accepted malformed timing artifact: %s", result)
@@ -1,6 +1,8 @@
1 package cef1 package cef
2 2
3 import (3 import (
4+ "crypto/sha256"
5+ "encoding/hex"
4 "encoding/json"6 "encoding/json"
5 "fmt"7 "fmt"
6 "os"8 "os"
@@ -22,6 +24,12 @@ const validFirstPresentationLog = `{"message":"startup_trace: milestone","milest
22 {"message":"startup_trace: milestone","milestone":"first_gtk_presentation","t_ms":7,"delta_ms":1}24 {"message":"startup_trace: milestone","milestone":"first_gtk_presentation","t_ms":7,"delta_ms":1}
23 {"message":"startup_trace: first presentation","backend":"gdk-dmabuf","incomplete_reason":"","total_ms":7,"host":"alice"}`25 {"message":"startup_trace: first presentation","backend":"gdk-dmabuf","incomplete_reason":"","total_ms":7,"host":"alice"}`
24 26
27+const (
28+ testUpstreamVersion = "v0.8.5-0.20300102030405-bbd397409ebe"
29+ testUpstreamRevision = "bbd397409ebed75a5979c1e4566a2ef319f6a484"
30+ testSourceRevision = "0123456789abcdef0123456789abcdef01234567"
31+)
32+
25 func TestFirstPresentationCollectorNeverRecursivelyDeletesCallerOutput(t *testing.T) {33 func TestFirstPresentationCollectorNeverRecursivelyDeletesCallerOutput(t *testing.T) {
26 repoRoot, err := filepath.Abs(filepath.Join("..", "..", ".."))34 repoRoot, err := filepath.Abs(filepath.Join("..", "..", ".."))
27 require.NoError(t, err)35 require.NoError(t, err)
@@ -31,12 +39,26 @@ func TestFirstPresentationCollectorNeverRecursivelyDeletesCallerOutput(t *testin
31 require.NotContains(t, string(script), "rm -rf -- \"$output\"")39 require.NotContains(t, string(script), "rm -rf -- \"$output\"")
32 }40 }
33 41
42+func TestFirstPresentationCollectorHasNoHardcodedRuntimeDefaults(t *testing.T) {
43+ repoRoot, err := filepath.Abs(filepath.Join("..", "..", ".."))
44+ require.NoError(t, err)
45+ script, err := os.ReadFile(filepath.Join(repoRoot, "scripts", "collect_first_presentation.sh"))
46+ require.NoError(t, err)
47+ require.NotContains(t, string(script), "cef-147")
48+ require.NotContains(t, string(script), `"version": "147"`)
49+ require.NotContains(t, string(script), "go list -m")
50+ require.NotContains(t, string(script), "go mod download")
51+ require.NotContains(t, string(script), "-mod=mod")
52+ require.Contains(t, string(script), "DUMBER_CEF_DIR must be set")
53+ require.Contains(t, string(script), "cef_runtime_probe.py")
54+ require.Contains(t, string(script), `CEF_DIR="$selected_cef_dir"`)
55+}
56+
34 func TestFirstPresentationCollectorRejectsUnsafeOutputPaths(t *testing.T) {57 func TestFirstPresentationCollectorRejectsUnsafeOutputPaths(t *testing.T) {
35 repoRoot, err := filepath.Abs(filepath.Join("..", "..", ".."))58 repoRoot, err := filepath.Abs(filepath.Join("..", "..", ".."))
36 require.NoError(t, err)59 require.NoError(t, err)
37 temp := t.TempDir()60 temp := t.TempDir()
38- runtime := filepath.Join(temp, "cef-147-runtime")61+ runtime := collectorFakeCEFRuntime(t, 150)
39- require.NoError(t, os.Mkdir(runtime, 0o755))
40 binary := filepath.Join(temp, "dumber")62 binary := filepath.Join(temp, "dumber")
41 require.NoError(t, os.WriteFile(binary, collectorTestBinary(validFirstPresentationLog), 0o755))63 require.NoError(t, os.WriteFile(binary, collectorTestBinary(validFirstPresentationLog), 0o755))
42 home := filepath.Join(temp, "home")64 home := filepath.Join(temp, "home")
@@ -112,15 +134,12 @@ func TestFirstPresentationCollectorSanitizesMachineLocalValues(t *testing.T) {
112 repoRoot, err := filepath.Abs(filepath.Join("..", "..", ".."))134 repoRoot, err := filepath.Abs(filepath.Join("..", "..", ".."))
113 require.NoError(t, err)135 require.NoError(t, err)
114 temp := t.TempDir()136 temp := t.TempDir()
115- runtime := filepath.Join(temp, "cef-147-runtime")137+ runtime := collectorFakeCEFRuntime(t, 150)
116- require.NoError(t, os.Mkdir(runtime, 0o755))
117-
118 binary := filepath.Join(temp, "dumber")138 binary := filepath.Join(temp, "dumber")
119 require.NoError(t, os.WriteFile(binary, collectorTestBinary(validFirstPresentationLog), 0o755))139 require.NoError(t, os.WriteFile(binary, collectorTestBinary(validFirstPresentationLog), 0o755))
120 140
121- const upstreamVersion = "v0.8.5-0.20300102030405-bbd397409ebe"141+ goBin := collectorFakeGo(t, temp, testUpstreamVersion, testSourceRevision, false)
122- const upstreamRevision = "bbd397409ebed75a5979c1e4566a2ef319f6a484"142+ manifest := collectorManifest(t, temp, binary, testSourceRevision, testUpstreamVersion, testUpstreamRevision)
123- goBin := collectorProvenanceGo(t, temp, upstreamVersion, upstreamRevision, "")
124 output := filepath.Join(temp, "artifacts")143 output := filepath.Join(temp, "artifacts")
125 cmd := exec.Command(filepath.Join(repoRoot, "scripts", "collect_first_presentation.sh"))144 cmd := exec.Command(filepath.Join(repoRoot, "scripts", "collect_first_presentation.sh"))
126 cmd.Dir = repoRoot145 cmd.Dir = repoRoot
@@ -129,6 +148,7 @@ func TestFirstPresentationCollectorSanitizesMachineLocalValues(t *testing.T) {
129 "WAYLAND_DISPLAY=",148 "WAYLAND_DISPLAY=",
130 "DUMBER_CEF_DIR="+runtime,149 "DUMBER_CEF_DIR="+runtime,
131 "DUMBER_FIRST_PRESENTATION_BIN="+binary,150 "DUMBER_FIRST_PRESENTATION_BIN="+binary,
151+ "DUMBER_BUILD_MANIFEST="+manifest,
132 "DUMBER_FIRST_PRESENTATION_OUTPUT="+output,152 "DUMBER_FIRST_PRESENTATION_OUTPUT="+output,
133 "DUMBER_FIRST_PRESENTATION_TIMEOUT_SECONDS=1",153 "DUMBER_FIRST_PRESENTATION_TIMEOUT_SECONDS=1",
134 "DUMBER_MACHINE_GPU_PROFILE=integrated-gpu",154 "DUMBER_MACHINE_GPU_PROFILE=integrated-gpu",
@@ -143,14 +163,16 @@ func TestFirstPresentationCollectorSanitizesMachineLocalValues(t *testing.T) {
143 require.NoError(t, readErr)163 require.NoError(t, readErr)
144 artifacts.Write(artifactContents)164 artifacts.Write(artifactContents)
145 }165 }
146- for _, forbidden := range []string{"/home/", temp, "alice", "machine_path", `"time"`} {166+ for _, forbidden := range []string{"/home/", temp, "alice", "machine_path", `"time"`, runtime, "libcef.so"} {
147 require.NotContainsf(t, artifacts.String(), forbidden, "committed artifact leaked %q", forbidden)167 require.NotContainsf(t, artifacts.String(), forbidden, "committed artifact leaked %q", forbidden)
148 }168 }
149 for _, required := range []string{169 for _, required := range []string{
150 `"measured_source_revision"`,170 `"measured_source_revision"`,
151- `"version": "` + upstreamVersion + `"`,171+ `"version": "` + testUpstreamVersion + `"`,
152 `"tag": "v0.8.5"`,172 `"tag": "v0.8.5"`,
153- `"revision": "` + upstreamRevision + `"`,173+ `"revision": "` + testUpstreamRevision + `"`,
174+ `"chrome_major": 150`,
175+ `"libcef_sha256"`,
154 } {176 } {
155 require.Contains(t, artifacts.String(), required)177 require.Contains(t, artifacts.String(), required)
156 }178 }
@@ -176,16 +198,12 @@ func TestFirstPresentationCollectorDerivesSelectedImmutableModuleProvenance(t *t
176 repoRoot, err := filepath.Abs(filepath.Join("..", "..", ".."))198 repoRoot, err := filepath.Abs(filepath.Join("..", "..", ".."))
177 require.NoError(t, err)199 require.NoError(t, err)
178 temp := t.TempDir()200 temp := t.TempDir()
179- runtime := filepath.Join(temp, "cef-147-runtime")201+ runtime := collectorFakeCEFRuntime(t, 150)
180- require.NoError(t, os.Mkdir(runtime, 0o755))
181 binary := filepath.Join(temp, "dumber")202 binary := filepath.Join(temp, "dumber")
182 require.NoError(t, os.WriteFile(binary, collectorTestBinary(validFirstPresentationLog), 0o755))203 require.NoError(t, os.WriteFile(binary, collectorTestBinary(validFirstPresentationLog), 0o755))
183 204
184- // This exact pseudo-version is deliberately newer than the former v0.8.4205+ goBin := collectorFakeGo(t, temp, testUpstreamVersion, testSourceRevision, false)
185- // hardcode; the collector must report what the selected module says.206+ manifest := collectorManifest(t, temp, binary, testSourceRevision, testUpstreamVersion, testUpstreamRevision)
186- const version = "v0.8.5-0.20300102030405-bbd397409ebe"
187- const revision = "bbd397409ebed75a5979c1e4566a2ef319f6a484"
188- goBin := collectorProvenanceGo(t, temp, version, revision, "")
189 output := filepath.Join(temp, "artifacts")207 output := filepath.Join(temp, "artifacts")
190 cmd := exec.Command(filepath.Join(repoRoot, "scripts", "collect_first_presentation.sh"))208 cmd := exec.Command(filepath.Join(repoRoot, "scripts", "collect_first_presentation.sh"))
191 cmd.Dir = repoRoot209 cmd.Dir = repoRoot
@@ -193,6 +211,7 @@ func TestFirstPresentationCollectorDerivesSelectedImmutableModuleProvenance(t *t
193 "DISPLAY=:test",211 "DISPLAY=:test",
194 "DUMBER_CEF_DIR="+runtime,212 "DUMBER_CEF_DIR="+runtime,
195 "DUMBER_FIRST_PRESENTATION_BIN="+binary,213 "DUMBER_FIRST_PRESENTATION_BIN="+binary,
214+ "DUMBER_BUILD_MANIFEST="+manifest,
196 "DUMBER_FIRST_PRESENTATION_OUTPUT="+output,215 "DUMBER_FIRST_PRESENTATION_OUTPUT="+output,
197 "DUMBER_FIRST_PRESENTATION_TIMEOUT_SECONDS=1",216 "DUMBER_FIRST_PRESENTATION_TIMEOUT_SECONDS=1",
198 "PATH="+filepath.Dir(goBin)+":"+os.Getenv("PATH"),217 "PATH="+filepath.Dir(goBin)+":"+os.Getenv("PATH"),
@@ -207,14 +226,16 @@ func TestFirstPresentationCollectorDerivesSelectedImmutableModuleProvenance(t *t
207 Tag string `json:"tag"`226 Tag string `json:"tag"`
208 Revision string `json:"revision"`227 Revision string `json:"revision"`
209 } `json:"upstream"`228 } `json:"upstream"`
229+ MeasuredSourceRevision string `json:"measured_source_revision"`
210 }230 }
211 contents, err := os.ReadFile(filepath.Join(output, "metadata.json"))231 contents, err := os.ReadFile(filepath.Join(output, "metadata.json"))
212 require.NoError(t, err)232 require.NoError(t, err)
213 require.NoError(t, json.Unmarshal(contents, &metadata))233 require.NoError(t, json.Unmarshal(contents, &metadata))
214 require.Equal(t, "github.com/bnema/purego-cef2gtk", metadata.Upstream.Module)234 require.Equal(t, "github.com/bnema/purego-cef2gtk", metadata.Upstream.Module)
215- require.Equal(t, version, metadata.Upstream.Version)235+ require.Equal(t, testUpstreamVersion, metadata.Upstream.Version)
216 require.Equal(t, "v0.8.5", metadata.Upstream.Tag)236 require.Equal(t, "v0.8.5", metadata.Upstream.Tag)
217- require.Equal(t, revision, metadata.Upstream.Revision)237+ require.Equal(t, testUpstreamRevision, metadata.Upstream.Revision)
238+ require.Equal(t, testSourceRevision, metadata.MeasuredSourceRevision)
218 239
219 script, err := os.ReadFile(filepath.Join(repoRoot, "scripts", "collect_first_presentation.sh"))240 script, err := os.ReadFile(filepath.Join(repoRoot, "scripts", "collect_first_presentation.sh"))
220 require.NoError(t, err)241 require.NoError(t, err)
@@ -226,20 +247,20 @@ func TestFirstPresentationCollectorRejectsNonImmutableModuleProvenanceWithoutLea
226 require.NoError(t, err)247 require.NoError(t, err)
227 248
228 for _, test := range []struct {249 for _, test := range []struct {
229- name, version, revision, ref string250+ name, version string
230 }{251 }{
231- {name: "branch selector", version: "main", ref: "main"},252+ {name: "branch selector", version: "main"},
232- {name: "missing origin", version: "v0.8.5-0.20300102030405-bbd397409ebe"},253+ {name: "devel version", version: "(devel)"},
233- {name: "named origin ref", version: "v0.8.5-0.20300102030405-bbd397409ebe", revision: "bbd397409ebed75a5979c1e4566a2ef319f6a484", ref: "main"},254+ {name: "missing patch", version: "v0.8"},
234 } {255 } {
235 t.Run(test.name, func(t *testing.T) {256 t.Run(test.name, func(t *testing.T) {
236 temp := t.TempDir()257 temp := t.TempDir()
237- runtime := filepath.Join(temp, "cef-147-runtime")258+ runtime := collectorFakeCEFRuntime(t, 150)
238- require.NoError(t, os.Mkdir(runtime, 0o755))
239 binary := filepath.Join(temp, "dumber")259 binary := filepath.Join(temp, "dumber")
240 require.NoError(t, os.WriteFile(binary, collectorTestBinary(validFirstPresentationLog), 0o755))260 require.NoError(t, os.WriteFile(binary, collectorTestBinary(validFirstPresentationLog), 0o755))
241 261
242- goBin := collectorProvenanceGo(t, temp, test.version, test.revision, test.ref)262+ goBin := collectorFakeGo(t, temp, test.version, testSourceRevision, false)
263+ manifest := collectorManifest(t, temp, binary, testSourceRevision, testUpstreamVersion, testUpstreamRevision)
243 output := filepath.Join(temp, "artifacts")264 output := filepath.Join(temp, "artifacts")
244 cmd := exec.Command(filepath.Join(repoRoot, "scripts", "collect_first_presentation.sh"))265 cmd := exec.Command(filepath.Join(repoRoot, "scripts", "collect_first_presentation.sh"))
245 cmd.Dir = repoRoot266 cmd.Dir = repoRoot
@@ -247,6 +268,7 @@ func TestFirstPresentationCollectorRejectsNonImmutableModuleProvenanceWithoutLea
247 "DISPLAY=:test",268 "DISPLAY=:test",
248 "DUMBER_CEF_DIR="+runtime,269 "DUMBER_CEF_DIR="+runtime,
249 "DUMBER_FIRST_PRESENTATION_BIN="+binary,270 "DUMBER_FIRST_PRESENTATION_BIN="+binary,
271+ "DUMBER_BUILD_MANIFEST="+manifest,
250 "DUMBER_FIRST_PRESENTATION_OUTPUT="+output,272 "DUMBER_FIRST_PRESENTATION_OUTPUT="+output,
251 "PATH="+filepath.Dir(goBin)+":"+os.Getenv("PATH"),273 "PATH="+filepath.Dir(goBin)+":"+os.Getenv("PATH"),
252 )274 )
@@ -262,54 +284,289 @@ func TestFirstPresentationCollectorRejectsNonImmutableModuleProvenanceWithoutLea
262 }284 }
263 }285 }
264 286
265-func collectorProvenanceGo(t *testing.T, temp, version, revision, ref string) string {287+func TestFirstPresentationCollectorRequiresExplicitRuntimeDir(t *testing.T) {
266- t.Helper()288+ repoRoot, err := filepath.Abs(filepath.Join("..", "..", ".."))
267- infoPath := filepath.Join(temp, "module.info")
268- info := map[string]any{"Version": version}
269- if revision != "" {
270- info["Origin"] = map[string]string{
271- "VCS": "git", "URL": "https://github.com/bnema/purego-cef2gtk", "Hash": revision, "Ref": ref,
272- }
273- }
274- infoJSON, err := json.Marshal(info)
275 require.NoError(t, err)289 require.NoError(t, err)
276- require.NoError(t, os.WriteFile(infoPath, infoJSON, 0o600))290+ temp := t.TempDir()
291+ binary := filepath.Join(temp, "dumber")
292+ require.NoError(t, os.WriteFile(binary, collectorTestBinary(validFirstPresentationLog), 0o755))
293+
294+ cmd := exec.Command(filepath.Join(repoRoot, "scripts", "collect_first_presentation.sh"))
295+ cmd.Dir = repoRoot
296+ env := envWithout("DUMBER_CEF_DIR")
297+ env = append(env,
298+ "DISPLAY=:test",
299+ "DUMBER_FIRST_PRESENTATION_BIN="+binary,
300+ "DUMBER_FIRST_PRESENTATION_OUTPUT="+filepath.Join(temp, "artifacts"),
301+ )
302+ cmd.Env = env
303+ result, err := cmd.CombinedOutput()
304+ require.Errorf(t, err, "collector accepted missing runtime dir: %s", result)
305+ require.Contains(t, string(result), "DUMBER_CEF_DIR must be set")
306+}
307+
308+func TestFirstPresentationCollectorIgnoresConflictingParentCEFDir(t *testing.T) {
309+ repoRoot, err := filepath.Abs(filepath.Join("..", "..", ".."))
310+ require.NoError(t, err)
311+ temp := t.TempDir()
312+ runtime := collectorFakeCEFRuntime(t, 150)
313+ bogus := filepath.Join(temp, "bogus-runtime")
314+ require.NoError(t, os.Mkdir(bogus, 0o755))
315+ binary := filepath.Join(temp, "dumber")
316+ require.NoError(t, os.WriteFile(binary, collectorTestBinary(validFirstPresentationLog), 0o755))
317+
318+ goBin := collectorFakeGo(t, temp, testUpstreamVersion, testSourceRevision, false)
319+ manifest := collectorManifest(t, temp, binary, testSourceRevision, testUpstreamVersion, testUpstreamRevision)
320+ output := filepath.Join(temp, "artifacts")
321+ cmd := exec.Command(filepath.Join(repoRoot, "scripts", "collect_first_presentation.sh"))
322+ cmd.Dir = repoRoot
323+ cmd.Env = append(os.Environ(),
324+ "DISPLAY=:test",
325+ "CEF_DIR="+bogus,
326+ "DUMBER_CEF_DIR="+runtime,
327+ "DUMBER_FIRST_PRESENTATION_BIN="+binary,
328+ "DUMBER_BUILD_MANIFEST="+manifest,
329+ "DUMBER_FIRST_PRESENTATION_OUTPUT="+output,
330+ "DUMBER_FIRST_PRESENTATION_TIMEOUT_SECONDS=1",
331+ "PATH="+filepath.Dir(goBin)+":"+os.Getenv("PATH"),
332+ )
333+ result, err := cmd.CombinedOutput()
334+ require.NoErrorf(t, err, "collector failed with conflicting parent CEF_DIR: %s", result)
335+ require.FileExists(t, filepath.Join(output, "metadata.json"))
336+}
277 337
278- selectedJSON, err := json.Marshal(map[string]string{338+func TestFirstPresentationCollectorRejectsProbeFailure(t *testing.T) {
279- "Path": "github.com/bnema/purego-cef2gtk", "Version": version,339+ repoRoot, err := filepath.Abs(filepath.Join("..", "..", ".."))
280- })
281 require.NoError(t, err)340 require.NoError(t, err)
282- downloadedJSON, err := json.Marshal(map[string]string{341+ temp := t.TempDir()
283- "Path": "github.com/bnema/purego-cef2gtk", "Version": version, "Info": infoPath,342+ runtime := filepath.Join(temp, "empty-runtime")
284- })343+ require.NoError(t, os.Mkdir(runtime, 0o755))
344+ binary := filepath.Join(temp, "dumber")
345+ require.NoError(t, os.WriteFile(binary, collectorTestBinary(validFirstPresentationLog), 0o755))
346+
347+ goBin := collectorFakeGo(t, temp, testUpstreamVersion, testSourceRevision, false)
348+ manifest := collectorManifest(t, temp, binary, testSourceRevision, testUpstreamVersion, testUpstreamRevision)
349+ cmd := exec.Command(filepath.Join(repoRoot, "scripts", "collect_first_presentation.sh"))
350+ cmd.Dir = repoRoot
351+ cmd.Env = append(os.Environ(),
352+ "DISPLAY=:test",
353+ "DUMBER_CEF_DIR="+runtime,
354+ "DUMBER_FIRST_PRESENTATION_BIN="+binary,
355+ "DUMBER_BUILD_MANIFEST="+manifest,
356+ "DUMBER_FIRST_PRESENTATION_OUTPUT="+filepath.Join(temp, "artifacts"),
357+ "PATH="+filepath.Dir(goBin)+":"+os.Getenv("PATH"),
358+ )
359+ result, err := cmd.CombinedOutput()
360+ require.Errorf(t, err, "collector accepted missing runtime library: %s", result)
361+ require.Contains(t, string(result), "CEF runtime probe failed")
362+}
363+
364+func TestFirstPresentationCollectorRejectsUnsupportedRuntime(t *testing.T) {
365+ repoRoot, err := filepath.Abs(filepath.Join("..", "..", ".."))
285 require.NoError(t, err)366 require.NoError(t, err)
286- binDir := filepath.Join(temp, "bin")367+ temp := t.TempDir()
287- require.NoError(t, os.Mkdir(binDir, 0o755))368+ runtime := collectorFakeCEFRuntime(t, 140)
369+ binary := filepath.Join(temp, "dumber")
370+ require.NoError(t, os.WriteFile(binary, collectorTestBinary(validFirstPresentationLog), 0o755))
371+
372+ goBin := collectorFakeGo(t, temp, testUpstreamVersion, testSourceRevision, false)
373+ manifest := collectorManifest(t, temp, binary, testSourceRevision, testUpstreamVersion, testUpstreamRevision)
374+ cmd := exec.Command(filepath.Join(repoRoot, "scripts", "collect_first_presentation.sh"))
375+ cmd.Dir = repoRoot
376+ cmd.Env = append(os.Environ(),
377+ "DISPLAY=:test",
378+ "DUMBER_CEF_DIR="+runtime,
379+ "DUMBER_FIRST_PRESENTATION_BIN="+binary,
380+ "DUMBER_BUILD_MANIFEST="+manifest,
381+ "DUMBER_FIRST_PRESENTATION_OUTPUT="+filepath.Join(temp, "artifacts"),
382+ "PATH="+filepath.Dir(goBin)+":"+os.Getenv("PATH"),
383+ )
384+ result, err := cmd.CombinedOutput()
385+ require.Errorf(t, err, "collector accepted unsupported runtime: %s", result)
386+ require.Contains(t, string(result), "unsupported CEF runtime")
387+}
388+
389+func TestFirstPresentationCollectorRequiresManifestWithoutEmbeddedVCS(t *testing.T) {
390+ repoRoot, err := filepath.Abs(filepath.Join("..", "..", ".."))
391+ require.NoError(t, err)
392+ temp := t.TempDir()
393+ runtime := collectorFakeCEFRuntime(t, 150)
394+ binary := filepath.Join(temp, "dumber")
395+ require.NoError(t, os.WriteFile(binary, collectorTestBinary(validFirstPresentationLog), 0o755))
396+
397+ goBin := collectorFakeGo(t, temp, testUpstreamVersion, "", false)
398+ cmd := exec.Command(filepath.Join(repoRoot, "scripts", "collect_first_presentation.sh"))
399+ cmd.Dir = repoRoot
400+ cmd.Env = append(os.Environ(),
401+ "DISPLAY=:test",
402+ "DUMBER_CEF_DIR="+runtime,
403+ "DUMBER_FIRST_PRESENTATION_BIN="+binary,
404+ "DUMBER_BUILD_MANIFEST="+filepath.Join(temp, "missing-manifest.json"),
405+ "DUMBER_FIRST_PRESENTATION_OUTPUT="+filepath.Join(temp, "artifacts"),
406+ "PATH="+filepath.Dir(goBin)+":"+os.Getenv("PATH"),
407+ )
408+ result, err := cmd.CombinedOutput()
409+ require.Errorf(t, err, "collector accepted binary without VCS or manifest: %s", result)
410+ require.Contains(t, string(result), "build manifest is required")
411+}
412+
413+func TestFirstPresentationCollectorRejectsManifestMismatch(t *testing.T) {
414+ repoRoot, err := filepath.Abs(filepath.Join("..", "..", ".."))
415+ require.NoError(t, err)
416+
417+ for _, test := range []struct {
418+ name string
419+ sourceRevision string
420+ depVersion string
421+ depRevision string
422+ mutateBinary bool
423+ }{
424+ {name: "binary checkout mismatch", sourceRevision: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", depVersion: testUpstreamVersion, depRevision: testUpstreamRevision},
425+ {name: "dependency version mismatch", sourceRevision: testSourceRevision, depVersion: "v0.9.3", depRevision: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"},
426+ {name: "dependency revision mismatch", sourceRevision: testSourceRevision, depVersion: testUpstreamVersion, depRevision: "cccccccccccccccccccccccccccccccccccccccc"},
427+ } {
428+ t.Run(test.name, func(t *testing.T) {
429+ temp := t.TempDir()
430+ runtime := collectorFakeCEFRuntime(t, 150)
431+ binary := filepath.Join(temp, "dumber")
432+ require.NoError(t, os.WriteFile(binary, collectorTestBinary(validFirstPresentationLog), 0o755))
433+
434+ goBin := collectorFakeGo(t, temp, testUpstreamVersion, testSourceRevision, false)
435+ manifest := collectorManifest(t, temp, binary, test.sourceRevision, test.depVersion, test.depRevision)
436+ if test.mutateBinary {
437+ require.NoError(t, os.WriteFile(binary, collectorTestBinary(validFirstPresentationLog+"extra"), 0o755))
438+ }
439+ cmd := exec.Command(filepath.Join(repoRoot, "scripts", "collect_first_presentation.sh"))
440+ cmd.Dir = repoRoot
441+ cmd.Env = append(os.Environ(),
442+ "DISPLAY=:test",
443+ "DUMBER_CEF_DIR="+runtime,
444+ "DUMBER_FIRST_PRESENTATION_BIN="+binary,
445+ "DUMBER_BUILD_MANIFEST="+manifest,
446+ "DUMBER_FIRST_PRESENTATION_OUTPUT="+filepath.Join(temp, "artifacts"),
447+ "PATH="+filepath.Dir(goBin)+":"+os.Getenv("PATH"),
448+ )
449+ result, err := cmd.CombinedOutput()
450+ require.Errorf(t, err, "collector accepted mismatched manifest: %s", result)
451+ require.Contains(t, string(result), "build manifest does not match")
452+ })
453+ }
454+}
455+
456+func TestFirstPresentationCollectorRejectsReplacementContamination(t *testing.T) {
457+ repoRoot, err := filepath.Abs(filepath.Join("..", "..", ".."))
458+ require.NoError(t, err)
459+ temp := t.TempDir()
460+ runtime := collectorFakeCEFRuntime(t, 150)
461+ binary := filepath.Join(temp, "dumber")
462+ require.NoError(t, os.WriteFile(binary, collectorTestBinary(validFirstPresentationLog), 0o755))
463+
464+ goBin := collectorFakeGo(t, temp, testUpstreamVersion, testSourceRevision, true)
465+ manifest := collectorManifest(t, temp, binary, testSourceRevision, testUpstreamVersion, testUpstreamRevision)
466+ cmd := exec.Command(filepath.Join(repoRoot, "scripts", "collect_first_presentation.sh"))
467+ cmd.Dir = repoRoot
468+ cmd.Env = append(os.Environ(),
469+ "DISPLAY=:test",
470+ "DUMBER_CEF_DIR="+runtime,
471+ "DUMBER_FIRST_PRESENTATION_BIN="+binary,
472+ "DUMBER_BUILD_MANIFEST="+manifest,
473+ "DUMBER_FIRST_PRESENTATION_OUTPUT="+filepath.Join(temp, "artifacts"),
474+ "PATH="+filepath.Dir(goBin)+":"+os.Getenv("PATH"),
475+ )
476+ result, err := cmd.CombinedOutput()
477+ require.Errorf(t, err, "collector accepted replacement contamination: %s", result)
478+ require.Contains(t, string(result), "immutable module provenance is unavailable")
479+}
480+
481+// collectorFakeGo fakes `go version -m <binary>` for the measured binary.
482+// It never consults the current checkout module graph.
483+func collectorFakeGo(t *testing.T, temp, depVersion, vcsRevision string, replacement bool) string {
484+ t.Helper()
485+ binDir := filepath.Join(temp, "gobin")
486+ require.NoError(t, os.MkdirAll(binDir, 0o755))
288 goBin := filepath.Join(binDir, "go")487 goBin := filepath.Join(binDir, "go")
488+ depLine := fmt.Sprintf("dep\tgithub.com/bnema/purego-cef2gtk\t%s", depVersion)
489+ if replacement {
490+ depLine += " => ./local-override"
491+ }
492+ buildLine := ""
493+ if vcsRevision != "" {
494+ // Real `go version -m` format is "build vcs.revision=<hex>".
495+ buildLine = fmt.Sprintf("build\tvcs.revision=%s", vcsRevision)
496+ }
289 script := fmt.Sprintf(`#!/bin/sh497 script := fmt.Sprintf(`#!/bin/sh
290-if [ "$1" = "list" ] && [ "$2" = "-m" ] && [ "$3" = "-json" ] && [ "$4" = "github.com/bnema/purego-cef2gtk" ]; then498+if [ "$1" = "version" ] && [ "$2" = "-m" ]; then
291- printf '%%s\n' '%s'499+ printf '%%s\n' 'path\tgithub.com/bnema/dumber/cmd/dumber' 'mod\tgithub.com/bnema/dumber\t(devel)' '%s' '%s'
292- exit 0
293-fi
294-if [ "$1" = "mod" ] && [ "$2" = "download" ] && [ "$3" = "-json" ] && [ "$4" = "github.com/bnema/purego-cef2gtk@%s" ]; then
295- printf '%%s\n' '%s'
296 exit 0500 exit 0
297 fi501 fi
298 exit 1502 exit 1
299-`, selectedJSON, version, downloadedJSON)503+`, depLine, buildLine)
300 require.NoError(t, os.WriteFile(goBin, []byte(script), 0o755))504 require.NoError(t, os.WriteFile(goBin, []byte(script), 0o755))
301 return goBin505 return goBin
302 }506 }
303 507
508+func collectorManifest(t *testing.T, temp, binary, sourceRevision, depVersion, depRevision string) string {
509+ t.Helper()
510+ contents, err := os.ReadFile(binary)
511+ require.NoError(t, err)
512+ sum := sha256.Sum256(contents)
513+ manifest := map[string]any{
514+ "binary_sha256": hex.EncodeToString(sum[:]),
515+ "source_revision": sourceRevision,
516+ "modules": map[string]any{
517+ "github.com/bnema/purego-cef2gtk": map[string]string{
518+ "version": depVersion,
519+ "revision": depRevision,
520+ },
521+ },
522+ }
523+ raw, err := json.Marshal(manifest)
524+ require.NoError(t, err)
525+ path := filepath.Join(temp, "build-manifest.json")
526+ require.NoError(t, os.WriteFile(path, raw, 0o600))
527+ return path
528+}
529+
530+// collectorFakeCEFRuntime builds a tiny shared library exposing the
531+// header-verified cef_version_info(int) entries with a controlled Chrome
532+// major. It exercises the real ctypes probe path without copying a full
533+// multi-hundred-megabyte runtime per test.
534+func collectorFakeCEFRuntime(t *testing.T, chromeMajor int) string {
535+ t.Helper()
536+ runtime := t.TempDir()
537+ source := fmt.Sprintf(`int cef_version_info(int entry) {
538+ switch (entry) {
539+ case 0: return 150;
540+ case 1: return 0;
541+ case 2: return 0;
542+ case 3: return 0;
543+ case 4: return %d;
544+ case 5: return 0;
545+ case 6: return 0;
546+ case 7: return 0;
547+ default: return 0;
548+ }
549+}
550+`, chromeMajor)
551+ src := filepath.Join(runtime, "fake_cef.c")
552+ require.NoError(t, os.WriteFile(src, []byte(source), 0o600))
553+ lib := filepath.Join(runtime, "libcef.so")
554+ cmd := exec.Command("gcc", "-shared", "-fPIC", "-o", lib, src)
555+ out, err := cmd.CombinedOutput()
556+ require.NoErrorf(t, err, "failed to build fake libcef: %s", out)
557+ return runtime
558+}
559+
304 func TestFirstPresentationCollectorDefaultsToXDGStateEvidenceDirectory(t *testing.T) {560 func TestFirstPresentationCollectorDefaultsToXDGStateEvidenceDirectory(t *testing.T) {
305 repoRoot, err := filepath.Abs(filepath.Join("..", "..", ".."))561 repoRoot, err := filepath.Abs(filepath.Join("..", "..", ".."))
306 require.NoError(t, err)562 require.NoError(t, err)
307 temp := t.TempDir()563 temp := t.TempDir()
308- runtime := filepath.Join(temp, "cef-147-runtime")564+ runtime := collectorFakeCEFRuntime(t, 150)
309- require.NoError(t, os.Mkdir(runtime, 0o755))
310 binary := filepath.Join(temp, "dumber")565 binary := filepath.Join(temp, "dumber")
311 require.NoError(t, os.WriteFile(binary, collectorTestBinary(validFirstPresentationLog), 0o755))566 require.NoError(t, os.WriteFile(binary, collectorTestBinary(validFirstPresentationLog), 0o755))
312 stateHome := filepath.Join(temp, "state")567 stateHome := filepath.Join(temp, "state")
568+ goBin := collectorFakeGo(t, temp, testUpstreamVersion, testSourceRevision, false)
569+ manifest := collectorManifest(t, temp, binary, testSourceRevision, testUpstreamVersion, testUpstreamRevision)
313 570
314 cmd := exec.Command(filepath.Join(repoRoot, "scripts", "collect_first_presentation.sh"))571 cmd := exec.Command(filepath.Join(repoRoot, "scripts", "collect_first_presentation.sh"))
315 cmd.Dir = repoRoot572 cmd.Dir = repoRoot
@@ -318,9 +575,11 @@ func TestFirstPresentationCollectorDefaultsToXDGStateEvidenceDirectory(t *testin
318 "WAYLAND_DISPLAY=",575 "WAYLAND_DISPLAY=",
319 "DUMBER_CEF_DIR="+runtime,576 "DUMBER_CEF_DIR="+runtime,
320 "DUMBER_FIRST_PRESENTATION_BIN="+binary,577 "DUMBER_FIRST_PRESENTATION_BIN="+binary,
578+ "DUMBER_BUILD_MANIFEST="+manifest,
321 "DUMBER_FIRST_PRESENTATION_TIMEOUT_SECONDS=1",579 "DUMBER_FIRST_PRESENTATION_TIMEOUT_SECONDS=1",
322 "DUMBER_MACHINE_GPU_PROFILE=integrated-gpu",580 "DUMBER_MACHINE_GPU_PROFILE=integrated-gpu",
323 "XDG_STATE_HOME="+stateHome,581 "XDG_STATE_HOME="+stateHome,
582+ "PATH="+filepath.Dir(goBin)+":"+os.Getenv("PATH"),
324 )583 )
325 result, err := cmd.CombinedOutput()584 result, err := cmd.CombinedOutput()
326 require.NoErrorf(t, err, "collector failed: %s", result)585 require.NoErrorf(t, err, "collector failed: %s", result)
@@ -345,22 +604,19 @@ func envWithout(name string) []string {
345 return environment604 return environment
346 }605 }
347 606
348-func TestFirstPresentationCollectorReadsProvenanceWithScopedGitSafeDirectory(t *testing.T) {607+func TestFirstPresentationCollectorBindsBinaryNotCheckoutGit(t *testing.T) {
349 repoRoot, err := filepath.Abs(filepath.Join("..", "..", ".."))608 repoRoot, err := filepath.Abs(filepath.Join("..", "..", ".."))
350 require.NoError(t, err)609 require.NoError(t, err)
351 temp := t.TempDir()610 temp := t.TempDir()
352- runtime := filepath.Join(temp, "cef-147-runtime")611+ runtime := collectorFakeCEFRuntime(t, 150)
353- require.NoError(t, os.Mkdir(runtime, 0o755))
354 binary := filepath.Join(temp, "dumber")612 binary := filepath.Join(temp, "dumber")
355 require.NoError(t, os.WriteFile(binary, collectorTestBinary(validFirstPresentationLog), 0o755))613 require.NoError(t, os.WriteFile(binary, collectorTestBinary(validFirstPresentationLog), 0o755))
356- gitDir := filepath.Join(temp, "bin")614+ goBin := collectorFakeGo(t, temp, testUpstreamVersion, testSourceRevision, false)
615+ manifest := collectorManifest(t, temp, binary, testSourceRevision, testUpstreamVersion, testUpstreamRevision)
616+ gitDir := filepath.Join(temp, "gitbin")
357 require.NoError(t, os.Mkdir(gitDir, 0o755))617 require.NoError(t, os.Mkdir(gitDir, 0o755))
358 git := filepath.Join(gitDir, "git")618 git := filepath.Join(gitDir, "git")
359 require.NoError(t, os.WriteFile(git, []byte(`#!/bin/sh619 require.NoError(t, os.WriteFile(git, []byte(`#!/bin/sh
360-if [ "$1" = "-c" ] && [ "$2" = "safe.directory=$EXPECTED_SAFE_DIRECTORY" ] && [ "$3" = "rev-parse" ] && [ "$4" = "HEAD" ]; then
361- echo 0123456789abcdef0123456789abcdef01234567
362- exit 0
363-fi
364 echo "fatal: detected dubious ownership in repository" >&2620 echo "fatal: detected dubious ownership in repository" >&2
365 exit 128621 exit 128
366 `), 0o755))622 `), 0o755))
@@ -372,13 +628,13 @@ exit 128
372 "DISPLAY=:test",628 "DISPLAY=:test",
373 "DUMBER_CEF_DIR="+runtime,629 "DUMBER_CEF_DIR="+runtime,
374 "DUMBER_FIRST_PRESENTATION_BIN="+binary,630 "DUMBER_FIRST_PRESENTATION_BIN="+binary,
631+ "DUMBER_BUILD_MANIFEST="+manifest,
375 "DUMBER_FIRST_PRESENTATION_OUTPUT="+output,632 "DUMBER_FIRST_PRESENTATION_OUTPUT="+output,
376 "DUMBER_FIRST_PRESENTATION_TIMEOUT_SECONDS=1",633 "DUMBER_FIRST_PRESENTATION_TIMEOUT_SECONDS=1",
377- "EXPECTED_SAFE_DIRECTORY="+repoRoot,634+ "PATH="+gitDir+":"+filepath.Dir(goBin)+":"+os.Getenv("PATH"),
378- "PATH="+gitDir+":"+os.Getenv("PATH"),
379 )635 )
380 result, err := cmd.CombinedOutput()636 result, err := cmd.CombinedOutput()
381- require.NoErrorf(t, err, "collector failed: %s", result)637+ require.NoErrorf(t, err, "collector depends on checkout git: %s", result)
382 638
383 var metadata struct {639 var metadata struct {
384 MeasuredSourceRevision string `json:"measured_source_revision"`640 MeasuredSourceRevision string `json:"measured_source_revision"`
@@ -386,7 +642,7 @@ exit 128
386 contents, err := os.ReadFile(filepath.Join(output, "metadata.json"))642 contents, err := os.ReadFile(filepath.Join(output, "metadata.json"))
387 require.NoError(t, err)643 require.NoError(t, err)
388 require.NoError(t, json.Unmarshal(contents, &metadata))644 require.NoError(t, json.Unmarshal(contents, &metadata))
389- require.Equal(t, "0123456789abcdef0123456789abcdef01234567", metadata.MeasuredSourceRevision)645+ require.Equal(t, testSourceRevision, metadata.MeasuredSourceRevision)
390 }646 }
391 647
392 func TestFirstPresentationCollectorRejectsInconsistentTiming(t *testing.T) {648 func TestFirstPresentationCollectorRejectsInconsistentTiming(t *testing.T) {
@@ -405,11 +661,12 @@ func TestFirstPresentationCollectorRejectsInconsistentTiming(t *testing.T) {
405 } {661 } {
406 t.Run(test.name, func(t *testing.T) {662 t.Run(test.name, func(t *testing.T) {
407 temp := t.TempDir()663 temp := t.TempDir()
408- runtime := filepath.Join(temp, "cef-147-runtime")664+ runtime := collectorFakeCEFRuntime(t, 150)
409- require.NoError(t, os.Mkdir(runtime, 0o755))
410 binary := filepath.Join(temp, "dumber")665 binary := filepath.Join(temp, "dumber")
411 log := strings.Replace(validFirstPresentationLog, test.old, test.new, 1)666 log := strings.Replace(validFirstPresentationLog, test.old, test.new, 1)
412 require.NoError(t, os.WriteFile(binary, collectorTestBinary(log), 0o755))667 require.NoError(t, os.WriteFile(binary, collectorTestBinary(log), 0o755))
668+ goBin := collectorFakeGo(t, temp, testUpstreamVersion, testSourceRevision, false)
669+ manifest := collectorManifest(t, temp, binary, testSourceRevision, testUpstreamVersion, testUpstreamRevision)
413 670
414 cmd := exec.Command(filepath.Join(repoRoot, "scripts", "collect_first_presentation.sh"))671 cmd := exec.Command(filepath.Join(repoRoot, "scripts", "collect_first_presentation.sh"))
415 cmd.Dir = repoRoot672 cmd.Dir = repoRoot
@@ -417,8 +674,10 @@ func TestFirstPresentationCollectorRejectsInconsistentTiming(t *testing.T) {
417 "DISPLAY=:test",674 "DISPLAY=:test",
418 "DUMBER_CEF_DIR="+runtime,675 "DUMBER_CEF_DIR="+runtime,
419 "DUMBER_FIRST_PRESENTATION_BIN="+binary,676 "DUMBER_FIRST_PRESENTATION_BIN="+binary,
677+ "DUMBER_BUILD_MANIFEST="+manifest,
420 "DUMBER_FIRST_PRESENTATION_OUTPUT="+filepath.Join(temp, "artifacts"),678 "DUMBER_FIRST_PRESENTATION_OUTPUT="+filepath.Join(temp, "artifacts"),
421 "DUMBER_FIRST_PRESENTATION_TIMEOUT_SECONDS=1",679 "DUMBER_FIRST_PRESENTATION_TIMEOUT_SECONDS=1",
680+ "PATH="+filepath.Dir(goBin)+":"+os.Getenv("PATH"),
422 )681 )
423 result, err := cmd.CombinedOutput()682 result, err := cmd.CombinedOutput()
424 require.Errorf(t, err, "collector accepted malformed timing artifact: %s", result)683 require.Errorf(t, err, "collector accepted malformed timing artifact: %s", result)
M internal/infrastructure/cef/webview.go +135 -14
@@ -179,9 +179,17 @@ type WebView struct {
179179 initialBrowserCreateResizeHandled bool
180180
181181 // pendingURI is set when LoadURI is called before the browser exists.
182+ // Each explicit navigation request installs a new monotonically increasing
183+ // pendingIntentID (including repeated same-URL requests); lifecycle replay
184+ // never creates an intent. pendingIssued tracks whether the current intent
185+ // has been submitted via LoadURL; duplicate scheduled tasks for an issued
186+ // intent return without resubmitting.
182187 pendingURI string
183188 pendingURISetAt time.Time
184189 pendingURIStartedAt time.Time
190+ pendingIntentID uint64
191+ pendingIssued bool
192+ pendingIntentNext uint64
185193
186194 // GTK sync dispatch hooks are injectable for tests. Production uses the GTK
187195 // default main context through runOnGTK and isOnGTKThread.
@@ -1905,11 +1913,17 @@ func (wv *WebView) pendingNavigationURI() string {
19051913 func (wv *WebView) setPendingNavigationLocked(uri string, at time.Time) {
19061914 wv.pendingURI = uri
19071915 wv.pendingURIStartedAt = time.Time{}
1916+ wv.pendingIssued = false
19081917 if uri == "" {
19091918 wv.pendingURISetAt = time.Time{}
1919+ wv.pendingIntentID = 0
19101920 return
19111921 }
19121922 wv.pendingURISetAt = at
1923+ // Every explicit request installs a new intent, including repeated
1924+ // same-URL requests; lifecycle replay never calls this.
1925+ wv.pendingIntentNext++
1926+ wv.pendingIntentID = wv.pendingIntentNext
19131927 }
19141928
19151929 func (wv *WebView) markPendingNavigationStartedLocked(uri string, at time.Time) {
@@ -1924,6 +1938,8 @@ func (wv *WebView) clearPendingNavigationLocked() string {
19241938 wv.pendingURI = ""
19251939 wv.pendingURISetAt = time.Time{}
19261940 wv.pendingURIStartedAt = time.Time{}
1941+ wv.pendingIntentID = 0
1942+ wv.pendingIssued = false
19271943 return cleared
19281944 }
19291945
@@ -1949,8 +1965,16 @@ func (wv *WebView) schedulePendingNavigationReplay(attempt int) {
19491965 if wv == nil || wv.destroyed.Load() {
19501966 return
19511967 }
1968+ // Capture the intent at schedule time; a rapid replacement installs a new
1969+ // intent and stale tasks for the old ID must return without submitting.
1970+ wv.mu.RLock()
1971+ intentID := wv.pendingIntentID
1972+ wv.mu.RUnlock()
1973+ if intentID == 0 {
1974+ return
1975+ }
19521976 task := cefNewTask(cefTaskFunc(func() {
1953- wv.replayPendingNavigation(attempt)
1977+ wv.replayPendingNavigationForIntent(attempt, intentID)
19541978 }))
19551979 if task == nil {
19561980 return
@@ -1982,15 +2006,26 @@ func (wv *WebView) schedulePendingNavigationReplay(attempt int) {
19822006 })
19832007 }
19842008
1985-func (wv *WebView) replayPendingNavigation(attempt int) {
2009+// replayPendingNavigationForIntent submits at most one LoadURL per navigation
2010+// intent. Duplicate scheduled tasks for an already-issued intent return
2011+// without resubmitting; stale tasks for a replaced intent return as well.
2012+// Missing-frame and task-post failures retry only that same intent ID.
2013+func (wv *WebView) replayPendingNavigationForIntent(attempt int, intentID uint64) {
19862014 if wv == nil || wv.destroyed.Load() {
19872015 return
19882016 }
19892017 wv.mu.RLock()
19902018 uri := wv.pendingURI
19912019 browser := wv.browser
2020+ currentID := wv.pendingIntentID
2021+ issued := wv.pendingIssued
19922022 wv.mu.RUnlock()
1993- if uri == "" || browser == nil {
2023+ if uri == "" || browser == nil || currentID == 0 || intentID != currentID {
2024+ return
2025+ }
2026+ if issued {
2027+ // Already submitted for this intent; a blank OnLoadEnd scheduling a
2028+ // second replay must not resubmit an in-flight navigation.
19942029 return
19952030 }
19962031 frame := browser.GetMainFrame()
@@ -2016,20 +2051,38 @@ func (wv *WebView) replayPendingNavigation(attempt int) {
20162051 currentURL := frame.GetURL()
20172052 if pendingURIEquivalent(currentURL, uri) {
20182053 wv.mu.Lock()
2019- wv.clearPendingNavigationIfEquivalentLocked(uri)
2054+ submitted := wv.pendingIntentID == intentID && wv.pendingIssued
2055+ if submitted {
2056+ wv.clearPendingNavigationIfEquivalentLocked(uri)
2057+ }
20202058 wv.mu.Unlock()
2021- if wv.ctx != nil {
2022- logging.FromContext(wv.ctx).Debug().
2023- Int("attempt", attempt).
2024- Str("uri", logging.TruncateURL(uri, logging.PermissionLogURLMaxLen)).
2025- Msg("cef: pending navigation already active")
2059+ if submitted {
2060+ if wv.ctx != nil {
2061+ logging.FromContext(wv.ctx).Debug().
2062+ Int("attempt", attempt).
2063+ Str("uri", logging.TruncateURL(uri, logging.PermissionLogURLMaxLen)).
2064+ Msg("cef: pending navigation already active")
2065+ }
2066+ return
20262067 }
2027- return
2068+ // Unissued explicit intent for the current URL (e.g. a same-URL
2069+ // reload): browsers always bootstrap on about:blank, so this is a
2070+ // fresh request that must proceed to the claim below, not be
2071+ // cleared as already active.
2072+ }
2073+ // Claim submission only for the current unissued intent with a valid
2074+ // frame; see claimPendingNavigationSubmission. Mark issued before the
2075+ // foreign LoadURL, outside the lock.
2076+ submitURI, claim := wv.claimPendingNavigationSubmission(intentID, browser.GetIdentifier())
2077+ switch claim {
2078+ case pendingClaimReplaced:
2079+ // Browser was replaced between frame acquisition and claim; retry
2080+ // the same intent so the new browser is used at execution time.
2081+ wv.schedulePendingNavigationReplay(attempt + 1)
2082+ case pendingClaimReady:
2083+ frame.LoadURL(submitURI)
2084+ wv.resubmitIfBrowserReplaced(attempt, intentID, browser)
20282085 }
2029- wv.mu.Lock()
2030- wv.markPendingNavigationStartedLocked(uri, time.Now())
2031- wv.mu.Unlock()
2032- frame.LoadURL(uri)
20332086 if wv.ctx != nil {
20342087 logging.FromContext(wv.ctx).Debug().
20352088 Int("attempt", attempt).
@@ -2038,6 +2091,74 @@ func (wv *WebView) replayPendingNavigation(attempt int) {
20382091 }
20392092 }
20402093
2094+// resubmitIfBrowserReplaced re-checks the attached browser after the
2095+// claim's foreign LoadURL: a replacement in between leaves the navigation on
2096+// a stale frame while the intent reads issued. Clearing the issuance while
2097+// the intent is still current keeps it replayable, and the retry goes to
2098+// the current browser instead of dropping the navigation. Instance
2099+// comparison follows the same pointer-wrapper convention as the claim.
2100+func (wv *WebView) resubmitIfBrowserReplaced(attempt int, intentID uint64, submitted purecef.Browser) {
2101+ wv.mu.Lock()
2102+ replaced := wv.pendingIntentID == intentID && wv.pendingIssued && wv.browser != submitted
2103+ if replaced {
2104+ wv.pendingIssued = false
2105+ }
2106+ wv.mu.Unlock()
2107+ if replaced {
2108+ wv.schedulePendingNavigationReplay(attempt + 1)
2109+ }
2110+}
2111+
2112+type pendingClaimResult int
2113+
2114+const (
2115+ // pendingClaimStale means the intent changed, was issued, or was cleared:
2116+ // the caller must return without submitting.
2117+ pendingClaimStale pendingClaimResult = iota
2118+ // pendingClaimReplaced means the browser was replaced between frame
2119+ // acquisition and claim: the caller must retry the same intent.
2120+ pendingClaimReplaced
2121+ // pendingClaimReady means the intent was claimed: the caller submits uri.
2122+ pendingClaimReady
2123+)
2124+
2125+// claimPendingNavigationSubmission re-reads state and claims submission for
2126+// intentID only when it is still the current unissued intent. Browsers are
2127+// correlated by identifier, matching codebase convention; all foreign calls
2128+// happen outside the state lock. The final claim additionally requires the
2129+// attached browser to still be the instance observed before the lock: a
2130+// replacement between the identifier check and the claim is reported as
2131+// replaced so the caller retries against the current browser instead of
2132+// marking the intent issued and submitting to a stale frame. The issued
2133+// mark is set before the caller performs the foreign LoadURL.
2134+func (wv *WebView) claimPendingNavigationSubmission(intentID uint64, browserID int32) (string, pendingClaimResult) {
2135+ wv.mu.RLock()
2136+ if wv.pendingIntentID != intentID || wv.pendingIssued || wv.browser == nil {
2137+ wv.mu.RUnlock()
2138+ return "", pendingClaimStale
2139+ }
2140+ currentBrowser := wv.browser
2141+ wv.mu.RUnlock()
2142+ if currentBrowser.GetIdentifier() != browserID {
2143+ return "", pendingClaimReplaced
2144+ }
2145+ wv.mu.Lock()
2146+ defer wv.mu.Unlock()
2147+ if wv.pendingIntentID != intentID || wv.pendingIssued || wv.browser == nil {
2148+ return "", pendingClaimStale
2149+ }
2150+ // Interface identity comparison is safe here: attached browsers are
2151+ // always the pointer wrappers delivered by CEF callbacks (mocks are
2152+ // pointers as well), and no foreign call happens under the lock.
2153+ if wv.browser != currentBrowser {
2154+ return "", pendingClaimReplaced
2155+ }
2156+ wv.pendingIssued = true
2157+ uri := wv.pendingURI
2158+ wv.markPendingNavigationStartedLocked(uri, time.Now())
2159+ return uri, pendingClaimReady
2160+}
2161+
20412162 func pendingURIEquivalent(a, b string) bool {
20422163 if a == "" || b == "" {
20432164 return false
@@ -179,9 +179,17 @@ type WebView struct {
179 initialBrowserCreateResizeHandled bool179 initialBrowserCreateResizeHandled bool
180 180
181 // pendingURI is set when LoadURI is called before the browser exists.181 // pendingURI is set when LoadURI is called before the browser exists.
182+ // Each explicit navigation request installs a new monotonically increasing
183+ // pendingIntentID (including repeated same-URL requests); lifecycle replay
184+ // never creates an intent. pendingIssued tracks whether the current intent
185+ // has been submitted via LoadURL; duplicate scheduled tasks for an issued
186+ // intent return without resubmitting.
182 pendingURI string187 pendingURI string
183 pendingURISetAt time.Time188 pendingURISetAt time.Time
184 pendingURIStartedAt time.Time189 pendingURIStartedAt time.Time
190+ pendingIntentID uint64
191+ pendingIssued bool
192+ pendingIntentNext uint64
185 193
186 // GTK sync dispatch hooks are injectable for tests. Production uses the GTK194 // GTK sync dispatch hooks are injectable for tests. Production uses the GTK
187 // default main context through runOnGTK and isOnGTKThread.195 // default main context through runOnGTK and isOnGTKThread.
@@ -1905,11 +1913,17 @@ func (wv *WebView) pendingNavigationURI() string {
1905 func (wv *WebView) setPendingNavigationLocked(uri string, at time.Time) {1913 func (wv *WebView) setPendingNavigationLocked(uri string, at time.Time) {
1906 wv.pendingURI = uri1914 wv.pendingURI = uri
1907 wv.pendingURIStartedAt = time.Time{}1915 wv.pendingURIStartedAt = time.Time{}
1916+ wv.pendingIssued = false
1908 if uri == "" {1917 if uri == "" {
1909 wv.pendingURISetAt = time.Time{}1918 wv.pendingURISetAt = time.Time{}
1919+ wv.pendingIntentID = 0
1910 return1920 return
1911 }1921 }
1912 wv.pendingURISetAt = at1922 wv.pendingURISetAt = at
1923+ // Every explicit request installs a new intent, including repeated
1924+ // same-URL requests; lifecycle replay never calls this.
1925+ wv.pendingIntentNext++
1926+ wv.pendingIntentID = wv.pendingIntentNext
1913 }1927 }
1914 1928
1915 func (wv *WebView) markPendingNavigationStartedLocked(uri string, at time.Time) {1929 func (wv *WebView) markPendingNavigationStartedLocked(uri string, at time.Time) {
@@ -1924,6 +1938,8 @@ func (wv *WebView) clearPendingNavigationLocked() string {
1924 wv.pendingURI = ""1938 wv.pendingURI = ""
1925 wv.pendingURISetAt = time.Time{}1939 wv.pendingURISetAt = time.Time{}
1926 wv.pendingURIStartedAt = time.Time{}1940 wv.pendingURIStartedAt = time.Time{}
1941+ wv.pendingIntentID = 0
1942+ wv.pendingIssued = false
1927 return cleared1943 return cleared
1928 }1944 }
1929 1945
@@ -1949,8 +1965,16 @@ func (wv *WebView) schedulePendingNavigationReplay(attempt int) {
1949 if wv == nil || wv.destroyed.Load() {1965 if wv == nil || wv.destroyed.Load() {
1950 return1966 return
1951 }1967 }
1968+ // Capture the intent at schedule time; a rapid replacement installs a new
1969+ // intent and stale tasks for the old ID must return without submitting.
1970+ wv.mu.RLock()
1971+ intentID := wv.pendingIntentID
1972+ wv.mu.RUnlock()
1973+ if intentID == 0 {
1974+ return
1975+ }
1952 task := cefNewTask(cefTaskFunc(func() {1976 task := cefNewTask(cefTaskFunc(func() {
1953- wv.replayPendingNavigation(attempt)1977+ wv.replayPendingNavigationForIntent(attempt, intentID)
1954 }))1978 }))
1955 if task == nil {1979 if task == nil {
1956 return1980 return
@@ -1982,15 +2006,26 @@ func (wv *WebView) schedulePendingNavigationReplay(attempt int) {
1982 })2006 })
1983 }2007 }
1984 2008
1985-func (wv *WebView) replayPendingNavigation(attempt int) {2009+// replayPendingNavigationForIntent submits at most one LoadURL per navigation
2010+// intent. Duplicate scheduled tasks for an already-issued intent return
2011+// without resubmitting; stale tasks for a replaced intent return as well.
2012+// Missing-frame and task-post failures retry only that same intent ID.
2013+func (wv *WebView) replayPendingNavigationForIntent(attempt int, intentID uint64) {
1986 if wv == nil || wv.destroyed.Load() {2014 if wv == nil || wv.destroyed.Load() {
1987 return2015 return
1988 }2016 }
1989 wv.mu.RLock()2017 wv.mu.RLock()
1990 uri := wv.pendingURI2018 uri := wv.pendingURI
1991 browser := wv.browser2019 browser := wv.browser
2020+ currentID := wv.pendingIntentID
2021+ issued := wv.pendingIssued
1992 wv.mu.RUnlock()2022 wv.mu.RUnlock()
1993- if uri == "" || browser == nil {2023+ if uri == "" || browser == nil || currentID == 0 || intentID != currentID {
2024+ return
2025+ }
2026+ if issued {
2027+ // Already submitted for this intent; a blank OnLoadEnd scheduling a
2028+ // second replay must not resubmit an in-flight navigation.
1994 return2029 return
1995 }2030 }
1996 frame := browser.GetMainFrame()2031 frame := browser.GetMainFrame()
@@ -2016,20 +2051,38 @@ func (wv *WebView) replayPendingNavigation(attempt int) {
2016 currentURL := frame.GetURL()2051 currentURL := frame.GetURL()
2017 if pendingURIEquivalent(currentURL, uri) {2052 if pendingURIEquivalent(currentURL, uri) {
2018 wv.mu.Lock()2053 wv.mu.Lock()
2019- wv.clearPendingNavigationIfEquivalentLocked(uri)2054+ submitted := wv.pendingIntentID == intentID && wv.pendingIssued
2055+ if submitted {
2056+ wv.clearPendingNavigationIfEquivalentLocked(uri)
2057+ }
2020 wv.mu.Unlock()2058 wv.mu.Unlock()
2021- if wv.ctx != nil {2059+ if submitted {
2022- logging.FromContext(wv.ctx).Debug().2060+ if wv.ctx != nil {
2023- Int("attempt", attempt).2061+ logging.FromContext(wv.ctx).Debug().
2024- Str("uri", logging.TruncateURL(uri, logging.PermissionLogURLMaxLen)).2062+ Int("attempt", attempt).
2025- Msg("cef: pending navigation already active")2063+ Str("uri", logging.TruncateURL(uri, logging.PermissionLogURLMaxLen)).
2064+ Msg("cef: pending navigation already active")
2065+ }
2066+ return
2026 }2067 }
2027- return2068+ // Unissued explicit intent for the current URL (e.g. a same-URL
2069+ // reload): browsers always bootstrap on about:blank, so this is a
2070+ // fresh request that must proceed to the claim below, not be
2071+ // cleared as already active.
2072+ }
2073+ // Claim submission only for the current unissued intent with a valid
2074+ // frame; see claimPendingNavigationSubmission. Mark issued before the
2075+ // foreign LoadURL, outside the lock.
2076+ submitURI, claim := wv.claimPendingNavigationSubmission(intentID, browser.GetIdentifier())
2077+ switch claim {
2078+ case pendingClaimReplaced:
2079+ // Browser was replaced between frame acquisition and claim; retry
2080+ // the same intent so the new browser is used at execution time.
2081+ wv.schedulePendingNavigationReplay(attempt + 1)
2082+ case pendingClaimReady:
2083+ frame.LoadURL(submitURI)
2084+ wv.resubmitIfBrowserReplaced(attempt, intentID, browser)
2028 }2085 }
2029- wv.mu.Lock()
2030- wv.markPendingNavigationStartedLocked(uri, time.Now())
2031- wv.mu.Unlock()
2032- frame.LoadURL(uri)
2033 if wv.ctx != nil {2086 if wv.ctx != nil {
2034 logging.FromContext(wv.ctx).Debug().2087 logging.FromContext(wv.ctx).Debug().
2035 Int("attempt", attempt).2088 Int("attempt", attempt).
@@ -2038,6 +2091,74 @@ func (wv *WebView) replayPendingNavigation(attempt int) {
2038 }2091 }
2039 }2092 }
2040 2093
2094+// resubmitIfBrowserReplaced re-checks the attached browser after the
2095+// claim's foreign LoadURL: a replacement in between leaves the navigation on
2096+// a stale frame while the intent reads issued. Clearing the issuance while
2097+// the intent is still current keeps it replayable, and the retry goes to
2098+// the current browser instead of dropping the navigation. Instance
2099+// comparison follows the same pointer-wrapper convention as the claim.
2100+func (wv *WebView) resubmitIfBrowserReplaced(attempt int, intentID uint64, submitted purecef.Browser) {
2101+ wv.mu.Lock()
2102+ replaced := wv.pendingIntentID == intentID && wv.pendingIssued && wv.browser != submitted
2103+ if replaced {
2104+ wv.pendingIssued = false
2105+ }
2106+ wv.mu.Unlock()
2107+ if replaced {
2108+ wv.schedulePendingNavigationReplay(attempt + 1)
2109+ }
2110+}
2111+
2112+type pendingClaimResult int
2113+
2114+const (
2115+ // pendingClaimStale means the intent changed, was issued, or was cleared:
2116+ // the caller must return without submitting.
2117+ pendingClaimStale pendingClaimResult = iota
2118+ // pendingClaimReplaced means the browser was replaced between frame
2119+ // acquisition and claim: the caller must retry the same intent.
2120+ pendingClaimReplaced
2121+ // pendingClaimReady means the intent was claimed: the caller submits uri.
2122+ pendingClaimReady
2123+)
2124+
2125+// claimPendingNavigationSubmission re-reads state and claims submission for
2126+// intentID only when it is still the current unissued intent. Browsers are
2127+// correlated by identifier, matching codebase convention; all foreign calls
2128+// happen outside the state lock. The final claim additionally requires the
2129+// attached browser to still be the instance observed before the lock: a
2130+// replacement between the identifier check and the claim is reported as
2131+// replaced so the caller retries against the current browser instead of
2132+// marking the intent issued and submitting to a stale frame. The issued
2133+// mark is set before the caller performs the foreign LoadURL.
2134+func (wv *WebView) claimPendingNavigationSubmission(intentID uint64, browserID int32) (string, pendingClaimResult) {
2135+ wv.mu.RLock()
2136+ if wv.pendingIntentID != intentID || wv.pendingIssued || wv.browser == nil {
2137+ wv.mu.RUnlock()
2138+ return "", pendingClaimStale
2139+ }
2140+ currentBrowser := wv.browser
2141+ wv.mu.RUnlock()
2142+ if currentBrowser.GetIdentifier() != browserID {
2143+ return "", pendingClaimReplaced
2144+ }
2145+ wv.mu.Lock()
2146+ defer wv.mu.Unlock()
2147+ if wv.pendingIntentID != intentID || wv.pendingIssued || wv.browser == nil {
2148+ return "", pendingClaimStale
2149+ }
2150+ // Interface identity comparison is safe here: attached browsers are
2151+ // always the pointer wrappers delivered by CEF callbacks (mocks are
2152+ // pointers as well), and no foreign call happens under the lock.
2153+ if wv.browser != currentBrowser {
2154+ return "", pendingClaimReplaced
2155+ }
2156+ wv.pendingIssued = true
2157+ uri := wv.pendingURI
2158+ wv.markPendingNavigationStartedLocked(uri, time.Now())
2159+ return uri, pendingClaimReady
2160+}
2161+
2041 func pendingURIEquivalent(a, b string) bool {2162 func pendingURIEquivalent(a, b string) bool {
2042 if a == "" || b == "" {2163 if a == "" || b == "" {
2043 return false2164 return false
M internal/infrastructure/cef/webview_navigation_test.go +294 -6
@@ -2,6 +2,7 @@ package cef
22
33 import (
44 "context"
5+ "sync"
56 "testing"
67 "time"
78
@@ -16,15 +17,72 @@ func TestWebViewReplayPendingNavigation_LoadsQueuedURIWhenMainFrameAvailable(t *
1617 frame.EXPECT().GetURL().Return("").Once()
1718 frame.EXPECT().LoadURL("https://github.com/bnema").Once()
1819 browser.EXPECT().GetMainFrame().Return(frame).Once()
20+ browser.EXPECT().GetIdentifier().Return(int32(1)).Twice()
1921
20- wv := &WebView{ctx: context.Background(), browser: browser, pendingURI: "https://github.com/bnema"}
21- wv.replayPendingNavigation(0)
22+ wv := &WebView{ctx: context.Background(), browser: browser}
23+ wv.setPendingNavigationLocked("https://github.com/bnema", time.Now())
24+ wv.replayPendingNavigationForIntent(0, wv.pendingIntentID)
2225
2326 wv.mu.RLock()
2427 defer wv.mu.RUnlock()
2528 require.Equal(t, "https://github.com/bnema", wv.pendingURI)
2629 }
2730
31+// TestWebViewClaimPendingNavigationSubmission_RejectsInstanceReplacement
32+// reproduces a browser swap landing between the identifier check and the
33+// final claim: the replacement carries the same identifier, so only the
34+// instance-identity guard can catch it. The claim must report replaced
35+// without marking the intent issued, leaving the retry path able to submit
36+// against the current browser.
37+func TestWebViewClaimPendingNavigationSubmission_RejectsInstanceReplacement(t *testing.T) {
38+ browserA := cefmocks.NewMockBrowser(t)
39+ browserB := cefmocks.NewMockBrowser(t)
40+ entered := make(chan struct{})
41+ var enterOnce sync.Once
42+ proceed := make(chan struct{})
43+ browserA.EXPECT().GetIdentifier().RunAndReturn(func() int32 {
44+ enterOnce.Do(func() { close(entered) })
45+ <-proceed
46+ return int32(7)
47+ }).Once()
48+
49+ wv := &WebView{ctx: context.Background(), browser: browserA}
50+ wv.setPendingNavigationLocked("https://example.com", time.Now())
51+ intentID := wv.pendingIntentID
52+ require.NotZero(t, intentID)
53+
54+ claimed := make(chan pendingClaimResult, 1)
55+ go func() {
56+ _, result := wv.claimPendingNavigationSubmission(intentID, int32(7))
57+ claimed <- result
58+ }()
59+
60+ select {
61+ case <-entered:
62+ case <-time.After(10 * time.Second):
63+ t.Fatal("claim did not reach the identifier check")
64+ }
65+
66+ // Swap the attached browser while the claim is past the snapshot: same
67+ // identifier, different instance.
68+ wv.mu.Lock()
69+ wv.browser = browserB
70+ wv.mu.Unlock()
71+ close(proceed)
72+
73+ select {
74+ case result := <-claimed:
75+ require.Equal(t, pendingClaimReplaced, result, "instance replacement must not claim the intent")
76+ case <-time.After(10 * time.Second):
77+ t.Fatal("claim did not return after the swap")
78+ }
79+
80+ wv.mu.RLock()
81+ defer wv.mu.RUnlock()
82+ require.False(t, wv.pendingIssued, "replaced claim must leave the intent unissued for retry")
83+ require.Equal(t, intentID, wv.pendingIntentID)
84+}
85+
2886 func TestWebViewReplayPendingNavigation_RetriesWhenMainFrameUnavailable(t *testing.T) {
2987 browser := cefmocks.NewMockBrowser(t)
3088 browser.EXPECT().GetMainFrame().Return(purecef.Frame(nil)).Once()
@@ -46,8 +104,9 @@ func TestWebViewReplayPendingNavigation_RetriesWhenMainFrameUnavailable(t *testi
46104 return 1
47105 }
48106
49- wv := &WebView{ctx: context.Background(), browser: browser, pendingURI: "https://github.com/bnema"}
50- wv.replayPendingNavigation(0)
107+ wv := &WebView{ctx: context.Background(), browser: browser}
108+ wv.setPendingNavigationLocked("https://github.com/bnema", time.Now())
109+ wv.replayPendingNavigationForIntent(0, wv.pendingIntentID)
51110
52111 require.True(t, scheduled)
53112 wv.mu.RLock()
@@ -88,7 +147,8 @@ func TestWebViewSchedulePendingNavigationReplay_RetriesWhenTaskPostFails(t *test
88147 return 1
89148 }
90149
91- wv := &WebView{ctx: context.Background(), browser: browser, pendingURI: "https://github.com/bnema"}
150+ wv := &WebView{ctx: context.Background(), browser: browser}
151+ wv.setPendingNavigationLocked("https://github.com/bnema", time.Now())
92152 wv.schedulePendingNavigationReplay(0)
93153
94154 require.True(t, retried)
@@ -101,6 +161,7 @@ func TestWebViewReplayPendingNavigation_UsesCurrentBrowserAtExecutionTime(t *tes
101161 frame.EXPECT().GetURL().Return("").Once()
102162 frame.EXPECT().LoadURL("https://github.com/bnema").Once()
103163 activeBrowser.EXPECT().GetMainFrame().Return(frame).Once()
164+ activeBrowser.EXPECT().GetIdentifier().Return(int32(2)).Twice()
104165
105166 oldTask := cefNewTask
106167 oldPost := cefPostTask
@@ -116,7 +177,8 @@ func TestWebViewReplayPendingNavigation_UsesCurrentBrowserAtExecutionTime(t *tes
116177 return 1
117178 }
118179
119- wv := &WebView{ctx: context.Background(), browser: staleBrowser, pendingURI: "https://github.com/bnema"}
180+ wv := &WebView{ctx: context.Background(), browser: staleBrowser}
181+ wv.setPendingNavigationLocked("https://github.com/bnema", time.Now())
120182 wv.schedulePendingNavigationReplay(0)
121183 wv.mu.Lock()
122184 wv.browser = activeBrowser
@@ -130,6 +192,7 @@ func TestWebViewLoadURI_QueuesPendingNavigationReplayForExistingBrowser(t *testi
130192 browser := cefmocks.NewMockBrowser(t)
131193 frame := cefmocks.NewMockFrame(t)
132194 browser.EXPECT().GetMainFrame().Return(frame).Once()
195+ browser.EXPECT().GetIdentifier().Return(int32(1)).Twice()
133196 frame.EXPECT().GetURL().Return("").Once()
134197 frame.EXPECT().LoadURL("github.com/bnema").Once()
135198
@@ -221,3 +284,228 @@ func TestWebViewUpdateLoadState_KeepsPendingNavigationWhileLoading(t *testing.T)
221284
222285 require.Equal(t, pending, wv.pendingNavigationURI())
223286 }
287+
288+// TestWebViewReplayPendingNavigation_SubmitsOncePerIntent reproduces the
289+// fresh-window race: queue an intent, run OnAfterCreated replay with the
290+// frame URL still blank, then deliver blank OnLoadEnd before commit. Exactly
291+// one LoadURL must be issued for the intent.
292+func TestWebViewReplayPendingNavigation_SubmitsOncePerIntent(t *testing.T) {
293+ browser := cefmocks.NewMockBrowser(t)
294+ frame := cefmocks.NewMockFrame(t)
295+ frame.EXPECT().GetURL().Return("about:blank")
296+ frame.EXPECT().LoadURL("https://example.com/target").Once()
297+ browser.EXPECT().GetMainFrame().Return(frame)
298+ browser.EXPECT().GetIdentifier().Return(int32(1)).Twice()
299+
300+ oldTask := cefNewTask
301+ oldPost := cefPostTask
302+ defer func() {
303+ cefNewTask = oldTask
304+ cefPostTask = oldPost
305+ }()
306+ cefNewTask = func(task purecef.Task) purecef.Task { return task }
307+ var scheduled []purecef.Task
308+ cefPostTask = func(_ purecef.ThreadID, task purecef.Task) int32 {
309+ scheduled = append(scheduled, task)
310+ return 1
311+ }
312+
313+ wv := &WebView{ctx: context.Background(), browser: browser}
314+ wv.setPendingNavigationLocked("https://example.com/target", time.Now())
315+ // OnAfterCreated schedules replay...
316+ wv.schedulePendingNavigationReplay(0)
317+ // ...and blank OnLoadEnd schedules a second replay before commit.
318+ wv.schedulePendingNavigationReplay(0)
319+ require.Len(t, scheduled, 2)
320+ for _, task := range scheduled {
321+ task.Execute()
322+ }
323+}
324+
325+// TestWebViewReplayPendingNavigation_StaleIntentDoesNotResubmit verifies a
326+// rapid replacement URL invalidates the previously scheduled task.
327+func TestWebViewReplayPendingNavigation_StaleIntentDoesNotResubmit(t *testing.T) {
328+ browser := cefmocks.NewMockBrowser(t)
329+ frame := cefmocks.NewMockFrame(t)
330+ frame.EXPECT().GetURL().Return("").Once()
331+ frame.EXPECT().LoadURL("https://example.com/second").Once()
332+ browser.EXPECT().GetMainFrame().Return(frame).Once()
333+ browser.EXPECT().GetIdentifier().Return(int32(1)).Twice()
334+
335+ oldTask := cefNewTask
336+ oldPost := cefPostTask
337+ defer func() {
338+ cefNewTask = oldTask
339+ cefPostTask = oldPost
340+ }()
341+ cefNewTask = func(task purecef.Task) purecef.Task { return task }
342+ var scheduled []purecef.Task
343+ cefPostTask = func(_ purecef.ThreadID, task purecef.Task) int32 {
344+ scheduled = append(scheduled, task)
345+ return 1
346+ }
347+
348+ wv := &WebView{ctx: context.Background(), browser: browser}
349+ wv.setPendingNavigationLocked("https://example.com/first", time.Now())
350+ wv.schedulePendingNavigationReplay(0)
351+ require.Len(t, scheduled, 1)
352+ stale := scheduled[0]
353+ // Replacement installs a new intent; the stale task must not submit.
354+ wv.setPendingNavigationLocked("https://example.com/second", time.Now())
355+ stale.Execute()
356+ // Current intent still unissued; a fresh replay submits it once.
357+ wv.replayPendingNavigationForIntent(0, wv.pendingIntentID)
358+}
359+
360+// TestWebViewReplayPendingNavigation_RetriesSameIntentOnFailedPost ensures a
361+// failed task post retries without consuming or duplicating the intent.
362+func TestWebViewReplayPendingNavigation_RetriesSameIntentOnFailedPost(t *testing.T) {
363+ browser := cefmocks.NewMockBrowser(t)
364+
365+ oldTask := cefNewTask
366+ oldPost := cefPostTask
367+ oldAfter := cefScheduleAfter
368+ defer func() {
369+ cefNewTask = oldTask
370+ cefPostTask = oldPost
371+ cefScheduleAfter = oldAfter
372+ }()
373+ cefNewTask = func(task purecef.Task) purecef.Task { return task }
374+ cefPostTask = func(_ purecef.ThreadID, _ purecef.Task) int32 {
375+ return 0
376+ }
377+ scheduledAfter := false
378+ cefScheduleAfter = func(delay time.Duration, _ func()) {
379+ require.Equal(t, pendingNavigationRetryDelay, delay)
380+ scheduledAfter = true
381+ }
382+
383+ wv := &WebView{ctx: context.Background(), browser: browser}
384+ wv.setPendingNavigationLocked("https://example.com/target", time.Now())
385+ wv.mu.RLock()
386+ intent := wv.pendingIntentID
387+ wv.mu.RUnlock()
388+ wv.schedulePendingNavigationReplay(0)
389+ require.True(t, scheduledAfter)
390+ wv.mu.RLock()
391+ defer wv.mu.RUnlock()
392+ require.Equal(t, intent, wv.pendingIntentID)
393+ require.False(t, wv.pendingIssued)
394+ require.Equal(t, "https://example.com/target", wv.pendingURI)
395+}
396+
397+// TestWebViewSetPendingNavigation_RepeatedSameURLInstallsNewIntent ensures
398+// explicit reloads and repeated navigations still work: each request is a
399+// new intent even for an identical URL.
400+func TestWebViewSetPendingNavigation_RepeatedSameURLInstallsNewIntent(t *testing.T) {
401+ wv := &WebView{ctx: context.Background()}
402+ wv.setPendingNavigationLocked("https://example.com/page", time.Now())
403+ first := wv.pendingIntentID
404+ require.NotZero(t, first)
405+ wv.mu.Lock()
406+ wv.pendingIssued = true
407+ wv.mu.Unlock()
408+ wv.setPendingNavigationLocked("https://example.com/page", time.Now())
409+ require.NotEqual(t, first, wv.pendingIntentID)
410+ require.False(t, wv.pendingIssued)
411+}
412+
413+// TestWebViewReplayPendingNavigation_SameURLReloadSubmits verifies an
414+// unissued explicit intent for the currently displayed URL proceeds to
415+// LoadURL instead of being cleared as already active.
416+func TestWebViewReplayPendingNavigation_SameURLReloadSubmits(t *testing.T) {
417+ browser := cefmocks.NewMockBrowser(t)
418+ frame := cefmocks.NewMockFrame(t)
419+ frame.EXPECT().GetURL().Return("https://example.com/page").Once()
420+ frame.EXPECT().LoadURL("https://example.com/page").Once()
421+ browser.EXPECT().GetMainFrame().Return(frame).Once()
422+ browser.EXPECT().GetIdentifier().Return(int32(1)).Twice()
423+
424+ wv := &WebView{ctx: context.Background(), browser: browser}
425+ wv.setPendingNavigationLocked("https://example.com/page", time.Now())
426+ wv.replayPendingNavigationForIntent(0, wv.pendingIntentID)
427+
428+ wv.mu.RLock()
429+ defer wv.mu.RUnlock()
430+ require.True(t, wv.pendingIssued)
431+}
432+
433+func TestWebViewReplayPendingNavigation_ClearsIssuanceWhenBrowserReplacedAfterClaim(t *testing.T) {
434+ const uri = "https://example.com/replaced-after-claim"
435+ browserA := cefmocks.NewMockBrowser(t)
436+ browserB := cefmocks.NewMockBrowser(t)
437+ frameA := cefmocks.NewMockFrame(t)
438+ frameB := cefmocks.NewMockFrame(t)
439+
440+ browserA.EXPECT().GetIdentifier().Return(int32(7)).Twice()
441+ browserA.EXPECT().GetMainFrame().Return(frameA).Once()
442+ frameA.EXPECT().GetURL().Return("").Once()
443+ entered := make(chan struct{})
444+ var enterOnce sync.Once
445+ proceed := make(chan struct{})
446+ frameA.EXPECT().LoadURL(uri).RunAndReturn(func(string) {
447+ enterOnce.Do(func() { close(entered) })
448+ <-proceed
449+ }).Once()
450+
451+ browserB.EXPECT().GetIdentifier().Return(int32(7)).Twice()
452+ browserB.EXPECT().GetMainFrame().Return(frameB).Once()
453+ frameB.EXPECT().GetURL().Return("").Once()
454+ frameB.EXPECT().LoadURL(uri).Once()
455+
456+ oldTask := cefNewTask
457+ oldDelayed := cefPostDelayedTask
458+ defer func() {
459+ cefNewTask = oldTask
460+ cefPostDelayedTask = oldDelayed
461+ }()
462+ cefNewTask = func(task purecef.Task) purecef.Task { return task }
463+ var scheduled purecef.Task
464+ // The post-claim retry schedules with attempt+1, which takes the
465+ // delayed-task path; capture it to drive the retry synchronously.
466+ cefPostDelayedTask = func(_ purecef.ThreadID, task purecef.Task, _ int64) int32 {
467+ scheduled = task
468+ return 1
469+ }
470+
471+ wv := &WebView{ctx: context.Background(), browser: browserA}
472+ wv.setPendingNavigationLocked(uri, time.Now())
473+ intentID := wv.pendingIntentID
474+
475+ done := make(chan struct{})
476+ go func() {
477+ defer close(done)
478+ wv.replayPendingNavigationForIntent(0, intentID)
479+ }()
480+
481+ select {
482+ case <-entered:
483+ case <-time.After(10 * time.Second):
484+ t.Fatal("replay did not reach LoadURL")
485+ }
486+
487+ // Replace the attached browser while LoadURL is in flight on the stale
488+ // frame: the claim already marked the intent issued.
489+ wv.mu.Lock()
490+ wv.browser = browserB
491+ wv.mu.Unlock()
492+ close(proceed)
493+
494+ select {
495+ case <-done:
496+ case <-time.After(10 * time.Second):
497+ t.Fatal("replay did not return after LoadURL")
498+ }
499+
500+ wv.mu.RLock()
501+ require.False(t, wv.pendingIssued, "post-claim replacement must reopen the issuance for retry")
502+ require.Equal(t, intentID, wv.pendingIntentID, "the intent itself must survive the replacement")
503+ wv.mu.RUnlock()
504+ require.NotNil(t, scheduled, "a retry against the current browser must be scheduled")
505+
506+ // Driving the retry submits the surviving intent to the new browser.
507+ scheduled.Execute()
508+ wv.mu.RLock()
509+ defer wv.mu.RUnlock()
510+ require.True(t, wv.pendingIssued, "retry must issue the intent on the current browser")
511+}
@@ -2,6 +2,7 @@ package cef
2 2
3 import (3 import (
4 "context"4 "context"
5+ "sync"
5 "testing"6 "testing"
6 "time"7 "time"
7 8
@@ -16,15 +17,72 @@ func TestWebViewReplayPendingNavigation_LoadsQueuedURIWhenMainFrameAvailable(t *
16 frame.EXPECT().GetURL().Return("").Once()17 frame.EXPECT().GetURL().Return("").Once()
17 frame.EXPECT().LoadURL("https://github.com/bnema").Once()18 frame.EXPECT().LoadURL("https://github.com/bnema").Once()
18 browser.EXPECT().GetMainFrame().Return(frame).Once()19 browser.EXPECT().GetMainFrame().Return(frame).Once()
20+ browser.EXPECT().GetIdentifier().Return(int32(1)).Twice()
19 21
20- wv := &WebView{ctx: context.Background(), browser: browser, pendingURI: "https://github.com/bnema"}22+ wv := &WebView{ctx: context.Background(), browser: browser}
21- wv.replayPendingNavigation(0)23+ wv.setPendingNavigationLocked("https://github.com/bnema", time.Now())
24+ wv.replayPendingNavigationForIntent(0, wv.pendingIntentID)
22 25
23 wv.mu.RLock()26 wv.mu.RLock()
24 defer wv.mu.RUnlock()27 defer wv.mu.RUnlock()
25 require.Equal(t, "https://github.com/bnema", wv.pendingURI)28 require.Equal(t, "https://github.com/bnema", wv.pendingURI)
26 }29 }
27 30
31+// TestWebViewClaimPendingNavigationSubmission_RejectsInstanceReplacement
32+// reproduces a browser swap landing between the identifier check and the
33+// final claim: the replacement carries the same identifier, so only the
34+// instance-identity guard can catch it. The claim must report replaced
35+// without marking the intent issued, leaving the retry path able to submit
36+// against the current browser.
37+func TestWebViewClaimPendingNavigationSubmission_RejectsInstanceReplacement(t *testing.T) {
38+ browserA := cefmocks.NewMockBrowser(t)
39+ browserB := cefmocks.NewMockBrowser(t)
40+ entered := make(chan struct{})
41+ var enterOnce sync.Once
42+ proceed := make(chan struct{})
43+ browserA.EXPECT().GetIdentifier().RunAndReturn(func() int32 {
44+ enterOnce.Do(func() { close(entered) })
45+ <-proceed
46+ return int32(7)
47+ }).Once()
48+
49+ wv := &WebView{ctx: context.Background(), browser: browserA}
50+ wv.setPendingNavigationLocked("https://example.com", time.Now())
51+ intentID := wv.pendingIntentID
52+ require.NotZero(t, intentID)
53+
54+ claimed := make(chan pendingClaimResult, 1)
55+ go func() {
56+ _, result := wv.claimPendingNavigationSubmission(intentID, int32(7))
57+ claimed <- result
58+ }()
59+
60+ select {
61+ case <-entered:
62+ case <-time.After(10 * time.Second):
63+ t.Fatal("claim did not reach the identifier check")
64+ }
65+
66+ // Swap the attached browser while the claim is past the snapshot: same
67+ // identifier, different instance.
68+ wv.mu.Lock()
69+ wv.browser = browserB
70+ wv.mu.Unlock()
71+ close(proceed)
72+
73+ select {
74+ case result := <-claimed:
75+ require.Equal(t, pendingClaimReplaced, result, "instance replacement must not claim the intent")
76+ case <-time.After(10 * time.Second):
77+ t.Fatal("claim did not return after the swap")
78+ }
79+
80+ wv.mu.RLock()
81+ defer wv.mu.RUnlock()
82+ require.False(t, wv.pendingIssued, "replaced claim must leave the intent unissued for retry")
83+ require.Equal(t, intentID, wv.pendingIntentID)
84+}
85+
28 func TestWebViewReplayPendingNavigation_RetriesWhenMainFrameUnavailable(t *testing.T) {86 func TestWebViewReplayPendingNavigation_RetriesWhenMainFrameUnavailable(t *testing.T) {
29 browser := cefmocks.NewMockBrowser(t)87 browser := cefmocks.NewMockBrowser(t)
30 browser.EXPECT().GetMainFrame().Return(purecef.Frame(nil)).Once()88 browser.EXPECT().GetMainFrame().Return(purecef.Frame(nil)).Once()
@@ -46,8 +104,9 @@ func TestWebViewReplayPendingNavigation_RetriesWhenMainFrameUnavailable(t *testi
46 return 1104 return 1
47 }105 }
48 106
49- wv := &WebView{ctx: context.Background(), browser: browser, pendingURI: "https://github.com/bnema"}107+ wv := &WebView{ctx: context.Background(), browser: browser}
50- wv.replayPendingNavigation(0)108+ wv.setPendingNavigationLocked("https://github.com/bnema", time.Now())
109+ wv.replayPendingNavigationForIntent(0, wv.pendingIntentID)
51 110
52 require.True(t, scheduled)111 require.True(t, scheduled)
53 wv.mu.RLock()112 wv.mu.RLock()
@@ -88,7 +147,8 @@ func TestWebViewSchedulePendingNavigationReplay_RetriesWhenTaskPostFails(t *test
88 return 1147 return 1
89 }148 }
90 149
91- wv := &WebView{ctx: context.Background(), browser: browser, pendingURI: "https://github.com/bnema"}150+ wv := &WebView{ctx: context.Background(), browser: browser}
151+ wv.setPendingNavigationLocked("https://github.com/bnema", time.Now())
92 wv.schedulePendingNavigationReplay(0)152 wv.schedulePendingNavigationReplay(0)
93 153
94 require.True(t, retried)154 require.True(t, retried)
@@ -101,6 +161,7 @@ func TestWebViewReplayPendingNavigation_UsesCurrentBrowserAtExecutionTime(t *tes
101 frame.EXPECT().GetURL().Return("").Once()161 frame.EXPECT().GetURL().Return("").Once()
102 frame.EXPECT().LoadURL("https://github.com/bnema").Once()162 frame.EXPECT().LoadURL("https://github.com/bnema").Once()
103 activeBrowser.EXPECT().GetMainFrame().Return(frame).Once()163 activeBrowser.EXPECT().GetMainFrame().Return(frame).Once()
164+ activeBrowser.EXPECT().GetIdentifier().Return(int32(2)).Twice()
104 165
105 oldTask := cefNewTask166 oldTask := cefNewTask
106 oldPost := cefPostTask167 oldPost := cefPostTask
@@ -116,7 +177,8 @@ func TestWebViewReplayPendingNavigation_UsesCurrentBrowserAtExecutionTime(t *tes
116 return 1177 return 1
117 }178 }
118 179
119- wv := &WebView{ctx: context.Background(), browser: staleBrowser, pendingURI: "https://github.com/bnema"}180+ wv := &WebView{ctx: context.Background(), browser: staleBrowser}
181+ wv.setPendingNavigationLocked("https://github.com/bnema", time.Now())
120 wv.schedulePendingNavigationReplay(0)182 wv.schedulePendingNavigationReplay(0)
121 wv.mu.Lock()183 wv.mu.Lock()
122 wv.browser = activeBrowser184 wv.browser = activeBrowser
@@ -130,6 +192,7 @@ func TestWebViewLoadURI_QueuesPendingNavigationReplayForExistingBrowser(t *testi
130 browser := cefmocks.NewMockBrowser(t)192 browser := cefmocks.NewMockBrowser(t)
131 frame := cefmocks.NewMockFrame(t)193 frame := cefmocks.NewMockFrame(t)
132 browser.EXPECT().GetMainFrame().Return(frame).Once()194 browser.EXPECT().GetMainFrame().Return(frame).Once()
195+ browser.EXPECT().GetIdentifier().Return(int32(1)).Twice()
133 frame.EXPECT().GetURL().Return("").Once()196 frame.EXPECT().GetURL().Return("").Once()
134 frame.EXPECT().LoadURL("github.com/bnema").Once()197 frame.EXPECT().LoadURL("github.com/bnema").Once()
135 198
@@ -221,3 +284,228 @@ func TestWebViewUpdateLoadState_KeepsPendingNavigationWhileLoading(t *testing.T)
221 284
222 require.Equal(t, pending, wv.pendingNavigationURI())285 require.Equal(t, pending, wv.pendingNavigationURI())
223 }286 }
287+
288+// TestWebViewReplayPendingNavigation_SubmitsOncePerIntent reproduces the
289+// fresh-window race: queue an intent, run OnAfterCreated replay with the
290+// frame URL still blank, then deliver blank OnLoadEnd before commit. Exactly
291+// one LoadURL must be issued for the intent.
292+func TestWebViewReplayPendingNavigation_SubmitsOncePerIntent(t *testing.T) {
293+ browser := cefmocks.NewMockBrowser(t)
294+ frame := cefmocks.NewMockFrame(t)
295+ frame.EXPECT().GetURL().Return("about:blank")
296+ frame.EXPECT().LoadURL("https://example.com/target").Once()
297+ browser.EXPECT().GetMainFrame().Return(frame)
298+ browser.EXPECT().GetIdentifier().Return(int32(1)).Twice()
299+
300+ oldTask := cefNewTask
301+ oldPost := cefPostTask
302+ defer func() {
303+ cefNewTask = oldTask
304+ cefPostTask = oldPost
305+ }()
306+ cefNewTask = func(task purecef.Task) purecef.Task { return task }
307+ var scheduled []purecef.Task
308+ cefPostTask = func(_ purecef.ThreadID, task purecef.Task) int32 {
309+ scheduled = append(scheduled, task)
310+ return 1
311+ }
312+
313+ wv := &WebView{ctx: context.Background(), browser: browser}
314+ wv.setPendingNavigationLocked("https://example.com/target", time.Now())
315+ // OnAfterCreated schedules replay...
316+ wv.schedulePendingNavigationReplay(0)
317+ // ...and blank OnLoadEnd schedules a second replay before commit.
318+ wv.schedulePendingNavigationReplay(0)
319+ require.Len(t, scheduled, 2)
320+ for _, task := range scheduled {
321+ task.Execute()
322+ }
323+}
324+
325+// TestWebViewReplayPendingNavigation_StaleIntentDoesNotResubmit verifies a
326+// rapid replacement URL invalidates the previously scheduled task.
327+func TestWebViewReplayPendingNavigation_StaleIntentDoesNotResubmit(t *testing.T) {
328+ browser := cefmocks.NewMockBrowser(t)
329+ frame := cefmocks.NewMockFrame(t)
330+ frame.EXPECT().GetURL().Return("").Once()
331+ frame.EXPECT().LoadURL("https://example.com/second").Once()
332+ browser.EXPECT().GetMainFrame().Return(frame).Once()
333+ browser.EXPECT().GetIdentifier().Return(int32(1)).Twice()
334+
335+ oldTask := cefNewTask
336+ oldPost := cefPostTask
337+ defer func() {
338+ cefNewTask = oldTask
339+ cefPostTask = oldPost
340+ }()
341+ cefNewTask = func(task purecef.Task) purecef.Task { return task }
342+ var scheduled []purecef.Task
343+ cefPostTask = func(_ purecef.ThreadID, task purecef.Task) int32 {
344+ scheduled = append(scheduled, task)
345+ return 1
346+ }
347+
348+ wv := &WebView{ctx: context.Background(), browser: browser}
349+ wv.setPendingNavigationLocked("https://example.com/first", time.Now())
350+ wv.schedulePendingNavigationReplay(0)
351+ require.Len(t, scheduled, 1)
352+ stale := scheduled[0]
353+ // Replacement installs a new intent; the stale task must not submit.
354+ wv.setPendingNavigationLocked("https://example.com/second", time.Now())
355+ stale.Execute()
356+ // Current intent still unissued; a fresh replay submits it once.
357+ wv.replayPendingNavigationForIntent(0, wv.pendingIntentID)
358+}
359+
360+// TestWebViewReplayPendingNavigation_RetriesSameIntentOnFailedPost ensures a
361+// failed task post retries without consuming or duplicating the intent.
362+func TestWebViewReplayPendingNavigation_RetriesSameIntentOnFailedPost(t *testing.T) {
363+ browser := cefmocks.NewMockBrowser(t)
364+
365+ oldTask := cefNewTask
366+ oldPost := cefPostTask
367+ oldAfter := cefScheduleAfter
368+ defer func() {
369+ cefNewTask = oldTask
370+ cefPostTask = oldPost
371+ cefScheduleAfter = oldAfter
372+ }()
373+ cefNewTask = func(task purecef.Task) purecef.Task { return task }
374+ cefPostTask = func(_ purecef.ThreadID, _ purecef.Task) int32 {
375+ return 0
376+ }
377+ scheduledAfter := false
378+ cefScheduleAfter = func(delay time.Duration, _ func()) {
379+ require.Equal(t, pendingNavigationRetryDelay, delay)
380+ scheduledAfter = true
381+ }
382+
383+ wv := &WebView{ctx: context.Background(), browser: browser}
384+ wv.setPendingNavigationLocked("https://example.com/target", time.Now())
385+ wv.mu.RLock()
386+ intent := wv.pendingIntentID
387+ wv.mu.RUnlock()
388+ wv.schedulePendingNavigationReplay(0)
389+ require.True(t, scheduledAfter)
390+ wv.mu.RLock()
391+ defer wv.mu.RUnlock()
392+ require.Equal(t, intent, wv.pendingIntentID)
393+ require.False(t, wv.pendingIssued)
394+ require.Equal(t, "https://example.com/target", wv.pendingURI)
395+}
396+
397+// TestWebViewSetPendingNavigation_RepeatedSameURLInstallsNewIntent ensures
398+// explicit reloads and repeated navigations still work: each request is a
399+// new intent even for an identical URL.
400+func TestWebViewSetPendingNavigation_RepeatedSameURLInstallsNewIntent(t *testing.T) {
401+ wv := &WebView{ctx: context.Background()}
402+ wv.setPendingNavigationLocked("https://example.com/page", time.Now())
403+ first := wv.pendingIntentID
404+ require.NotZero(t, first)
405+ wv.mu.Lock()
406+ wv.pendingIssued = true
407+ wv.mu.Unlock()
408+ wv.setPendingNavigationLocked("https://example.com/page", time.Now())
409+ require.NotEqual(t, first, wv.pendingIntentID)
410+ require.False(t, wv.pendingIssued)
411+}
412+
413+// TestWebViewReplayPendingNavigation_SameURLReloadSubmits verifies an
414+// unissued explicit intent for the currently displayed URL proceeds to
415+// LoadURL instead of being cleared as already active.
416+func TestWebViewReplayPendingNavigation_SameURLReloadSubmits(t *testing.T) {
417+ browser := cefmocks.NewMockBrowser(t)
418+ frame := cefmocks.NewMockFrame(t)
419+ frame.EXPECT().GetURL().Return("https://example.com/page").Once()
420+ frame.EXPECT().LoadURL("https://example.com/page").Once()
421+ browser.EXPECT().GetMainFrame().Return(frame).Once()
422+ browser.EXPECT().GetIdentifier().Return(int32(1)).Twice()
423+
424+ wv := &WebView{ctx: context.Background(), browser: browser}
425+ wv.setPendingNavigationLocked("https://example.com/page", time.Now())
426+ wv.replayPendingNavigationForIntent(0, wv.pendingIntentID)
427+
428+ wv.mu.RLock()
429+ defer wv.mu.RUnlock()
430+ require.True(t, wv.pendingIssued)
431+}
432+
433+func TestWebViewReplayPendingNavigation_ClearsIssuanceWhenBrowserReplacedAfterClaim(t *testing.T) {
434+ const uri = "https://example.com/replaced-after-claim"
435+ browserA := cefmocks.NewMockBrowser(t)
436+ browserB := cefmocks.NewMockBrowser(t)
437+ frameA := cefmocks.NewMockFrame(t)
438+ frameB := cefmocks.NewMockFrame(t)
439+
440+ browserA.EXPECT().GetIdentifier().Return(int32(7)).Twice()
441+ browserA.EXPECT().GetMainFrame().Return(frameA).Once()
442+ frameA.EXPECT().GetURL().Return("").Once()
443+ entered := make(chan struct{})
444+ var enterOnce sync.Once
445+ proceed := make(chan struct{})
446+ frameA.EXPECT().LoadURL(uri).RunAndReturn(func(string) {
447+ enterOnce.Do(func() { close(entered) })
448+ <-proceed
449+ }).Once()
450+
451+ browserB.EXPECT().GetIdentifier().Return(int32(7)).Twice()
452+ browserB.EXPECT().GetMainFrame().Return(frameB).Once()
453+ frameB.EXPECT().GetURL().Return("").Once()
454+ frameB.EXPECT().LoadURL(uri).Once()
455+
456+ oldTask := cefNewTask
457+ oldDelayed := cefPostDelayedTask
458+ defer func() {
459+ cefNewTask = oldTask
460+ cefPostDelayedTask = oldDelayed
461+ }()
462+ cefNewTask = func(task purecef.Task) purecef.Task { return task }
463+ var scheduled purecef.Task
464+ // The post-claim retry schedules with attempt+1, which takes the
465+ // delayed-task path; capture it to drive the retry synchronously.
466+ cefPostDelayedTask = func(_ purecef.ThreadID, task purecef.Task, _ int64) int32 {
467+ scheduled = task
468+ return 1
469+ }
470+
471+ wv := &WebView{ctx: context.Background(), browser: browserA}
472+ wv.setPendingNavigationLocked(uri, time.Now())
473+ intentID := wv.pendingIntentID
474+
475+ done := make(chan struct{})
476+ go func() {
477+ defer close(done)
478+ wv.replayPendingNavigationForIntent(0, intentID)
479+ }()
480+
481+ select {
482+ case <-entered:
483+ case <-time.After(10 * time.Second):
484+ t.Fatal("replay did not reach LoadURL")
485+ }
486+
487+ // Replace the attached browser while LoadURL is in flight on the stale
488+ // frame: the claim already marked the intent issued.
489+ wv.mu.Lock()
490+ wv.browser = browserB
491+ wv.mu.Unlock()
492+ close(proceed)
493+
494+ select {
495+ case <-done:
496+ case <-time.After(10 * time.Second):
497+ t.Fatal("replay did not return after LoadURL")
498+ }
499+
500+ wv.mu.RLock()
501+ require.False(t, wv.pendingIssued, "post-claim replacement must reopen the issuance for retry")
502+ require.Equal(t, intentID, wv.pendingIntentID, "the intent itself must survive the replacement")
503+ wv.mu.RUnlock()
504+ require.NotNil(t, scheduled, "a retry against the current browser must be scheduled")
505+
506+ // Driving the retry submits the surviving intent to the new browser.
507+ scheduled.Execute()
508+ wv.mu.RLock()
509+ defer wv.mu.RUnlock()
510+ require.True(t, wv.pendingIssued, "retry must issue the intent on the current browser")
511+}
M internal/infrastructure/desktop/adapter.go +7 -2
@@ -11,6 +11,7 @@ import (
1111
1212 "github.com/bnema/dumber/internal/application/port"
1313 "github.com/bnema/dumber/internal/domain/entity"
14+ "github.com/bnema/dumber/internal/infrastructure/process"
1415 "github.com/bnema/dumber/internal/logging"
1516 )
1617
@@ -500,7 +501,10 @@ func launchBrowserURL(
500501 }
501502
502503 cmd := exec.Command(execPath, "browse", uri)
503- cmd.Env = withoutEnvKeys(sanitizedChildEnv(os.Environ()), FreshWindowLaunchEnvVar)
504+ // Prepare the child environment with the shared safety merge after
505+ // sanitization so application-spawned browser processes start safe.
506+ childEnv, _ := process.MergeRuntimeSafety(sanitizedChildEnv(os.Environ()))
507+ cmd.Env = withoutEnvKeys(childEnv, FreshWindowLaunchEnvVar)
504508 if freshWindow {
505509 cmd.Env = append(cmd.Env, FreshWindowLaunchEnvVar+"=1")
506510 }
@@ -599,7 +603,8 @@ func (s *SessionSpawner) SpawnWithSession(sessionID entity.SessionID) error {
599603 // Start dumber browse with session ID in environment
600604 cmd := exec.Command(execPath, "browse")
601605
602- sanitizedEnv := withoutEnvKeys(sanitizedChildEnv(os.Environ()), RestoreSessionEnvVar)
606+ sanitizedEnv, _ := process.MergeRuntimeSafety(sanitizedChildEnv(os.Environ()))
607+ sanitizedEnv = withoutEnvKeys(sanitizedEnv, RestoreSessionEnvVar)
603608 overrides := []string{RestoreSessionEnvVar + "=" + string(sessionID)}
604609 if s.spawnEnv != nil {
605610 sanitizedEnv = withoutEnvKeys(sanitizedEnv, s.spawnEnv.RootCacheEnvVar())
@@ -11,6 +11,7 @@ import (
11 11
12 "github.com/bnema/dumber/internal/application/port"12 "github.com/bnema/dumber/internal/application/port"
13 "github.com/bnema/dumber/internal/domain/entity"13 "github.com/bnema/dumber/internal/domain/entity"
14+ "github.com/bnema/dumber/internal/infrastructure/process"
14 "github.com/bnema/dumber/internal/logging"15 "github.com/bnema/dumber/internal/logging"
15 )16 )
16 17
@@ -500,7 +501,10 @@ func launchBrowserURL(
500 }501 }
501 502
502 cmd := exec.Command(execPath, "browse", uri)503 cmd := exec.Command(execPath, "browse", uri)
503- cmd.Env = withoutEnvKeys(sanitizedChildEnv(os.Environ()), FreshWindowLaunchEnvVar)504+ // Prepare the child environment with the shared safety merge after
505+ // sanitization so application-spawned browser processes start safe.
506+ childEnv, _ := process.MergeRuntimeSafety(sanitizedChildEnv(os.Environ()))
507+ cmd.Env = withoutEnvKeys(childEnv, FreshWindowLaunchEnvVar)
504 if freshWindow {508 if freshWindow {
505 cmd.Env = append(cmd.Env, FreshWindowLaunchEnvVar+"=1")509 cmd.Env = append(cmd.Env, FreshWindowLaunchEnvVar+"=1")
506 }510 }
@@ -599,7 +603,8 @@ func (s *SessionSpawner) SpawnWithSession(sessionID entity.SessionID) error {
599 // Start dumber browse with session ID in environment603 // Start dumber browse with session ID in environment
600 cmd := exec.Command(execPath, "browse")604 cmd := exec.Command(execPath, "browse")
601 605
602- sanitizedEnv := withoutEnvKeys(sanitizedChildEnv(os.Environ()), RestoreSessionEnvVar)606+ sanitizedEnv, _ := process.MergeRuntimeSafety(sanitizedChildEnv(os.Environ()))
607+ sanitizedEnv = withoutEnvKeys(sanitizedEnv, RestoreSessionEnvVar)
603 overrides := []string{RestoreSessionEnvVar + "=" + string(sessionID)}608 overrides := []string{RestoreSessionEnvVar + "=" + string(sessionID)}
604 if s.spawnEnv != nil {609 if s.spawnEnv != nil {
605 sanitizedEnv = withoutEnvKeys(sanitizedEnv, s.spawnEnv.RootCacheEnvVar())610 sanitizedEnv = withoutEnvKeys(sanitizedEnv, s.spawnEnv.RootCacheEnvVar())
M internal/infrastructure/desktop/adapter_test.go +21 -0
@@ -292,3 +292,24 @@ func requireNoError(t *testing.T, err error) {
292292 t.Fatal(err)
293293 }
294294 }
295+
296+func TestLaunchBrowserBrowseURL_ChildEnvMergesRuntimeSafety(t *testing.T) {
297+ t.Setenv("GODEBUG", "gctrace=1")
298+
299+ err := launchBrowserBrowseURL(
300+ "https://example.com",
301+ func() (string, error) { return "/usr/bin/dumber", nil },
302+ func(cmd *exec.Cmd) error {
303+ found := false
304+ for _, entry := range cmd.Env {
305+ if entry == "GODEBUG=gctrace=1,gcshrinkstackoff=1" {
306+ found = true
307+ }
308+ }
309+ assert.True(t, found, "child env must merge safety after sanitization: %#v", cmd.Env)
310+ return nil
311+ },
312+ )
313+
314+ require.NoError(t, err)
315+}
@@ -292,3 +292,24 @@ func requireNoError(t *testing.T, err error) {
292 t.Fatal(err)292 t.Fatal(err)
293 }293 }
294 }294 }
295+
296+func TestLaunchBrowserBrowseURL_ChildEnvMergesRuntimeSafety(t *testing.T) {
297+ t.Setenv("GODEBUG", "gctrace=1")
298+
299+ err := launchBrowserBrowseURL(
300+ "https://example.com",
301+ func() (string, error) { return "/usr/bin/dumber", nil },
302+ func(cmd *exec.Cmd) error {
303+ found := false
304+ for _, entry := range cmd.Env {
305+ if entry == "GODEBUG=gctrace=1,gcshrinkstackoff=1" {
306+ found = true
307+ }
308+ }
309+ assert.True(t, found, "child env must merge safety after sanitization: %#v", cmd.Env)
310+ return nil
311+ },
312+ )
313+
314+ require.NoError(t, err)
315+}
A internal/infrastructure/process/runtime_safety.go +86 -0
@@ -0,0 +1,86 @@
1+// Package process provides dependency-leaf process-environment helpers.
2+//
3+// This file must not import bootstrap, desktop, or any other internal
4+// package: both cmd/dumber and internal/infrastructure/desktop import it, and
5+// bootstrap already imports desktop. Keep it to the standard library so the
6+// merge rule can be shared without an import cycle.
7+package process
8+
9+import "strings"
10+
11+// RuntimeSafetySetting is the GODEBUG key that must be set before any
12+// goroutine or CEF subprocess is created. Go stack shrinking can invalidate
13+// frames retained across foreign CEF/GTK stacks.
14+const RuntimeSafetySetting = "gcshrinkstackoff"
15+
16+// EnvironmentValue returns the value for name in an environ slice.
17+func EnvironmentValue(environ []string, name string) string {
18+ prefix := name + "="
19+ for _, entry := range environ {
20+ if strings.HasPrefix(entry, prefix) {
21+ return strings.TrimPrefix(entry, prefix)
22+ }
23+ }
24+ return ""
25+}
26+
27+// ReplaceEnvironmentValue returns a copy of environ with name set to value.
28+func ReplaceEnvironmentValue(environ []string, name, value string) []string {
29+ prefix := name + "="
30+ updated := make([]string, 0, len(environ)+1)
31+ for _, entry := range environ {
32+ if !strings.HasPrefix(entry, prefix) {
33+ updated = append(updated, entry)
34+ }
35+ }
36+ return append(updated, prefix+value)
37+}
38+
39+// MergeRuntimeSafety returns a copy of environ with RuntimeSafetySetting=1
40+// merged into GODEBUG, preserving all unrelated entries (including other
41+// GODEBUG keys) and their relative order. It reports whether a change was
42+// applied. An already-effective single setting entry is detected
43+// independently of its position, so direct invocation never performs an
44+// unnecessary self-exec; duplicate or conflicting entries still collapse to
45+// one normalized trailing entry.
46+func MergeRuntimeSafety(environ []string) ([]string, bool) {
47+ updated := append([]string(nil), environ...)
48+ // Collect every GODEBUG entry: duplicates are legal in an environ
49+ // slice and the first entry alone never describes the effective
50+ // setting, so normalization must merge them all before deciding.
51+ godebugValues := make([]string, 0, 1)
52+ for _, entry := range updated {
53+ if strings.HasPrefix(entry, "GODEBUG=") {
54+ godebugValues = append(godebugValues, strings.TrimPrefix(entry, "GODEBUG="))
55+ }
56+ }
57+ current := strings.Join(godebugValues, ",")
58+ parts := strings.Split(current, ",")
59+ kept := make([]string, 0, len(parts)+1)
60+ var settingParts []string
61+ for _, part := range parts {
62+ part = strings.TrimSpace(part)
63+ if part == "" {
64+ continue
65+ }
66+ key, _, _ := strings.Cut(part, "=")
67+ if key == RuntimeSafetySetting {
68+ settingParts = append(settingParts, part)
69+ continue
70+ }
71+ kept = append(kept, part)
72+ }
73+ if len(godebugValues) == 1 && len(settingParts) == 1 && settingParts[0] == RuntimeSafetySetting+"=1" {
74+ // Exactly one entry with one effective setting: already safe in
75+ // place, independent of its position among the other keys. Leave
76+ // the order untouched so direct invocation performs no self-exec.
77+ return updated, false
78+ }
79+ // Zero, duplicate, conflicting, or valueless entries collapse to one
80+ // normalized trailing entry.
81+ merged := strings.Join(append(kept, RuntimeSafetySetting+"=1"), ",")
82+ if current == merged {
83+ return updated, false
84+ }
85+ return ReplaceEnvironmentValue(updated, "GODEBUG", merged), true
86+}
@@ -0,0 +1,86 @@
1+// Package process provides dependency-leaf process-environment helpers.
2+//
3+// This file must not import bootstrap, desktop, or any other internal
4+// package: both cmd/dumber and internal/infrastructure/desktop import it, and
5+// bootstrap already imports desktop. Keep it to the standard library so the
6+// merge rule can be shared without an import cycle.
7+package process
8+
9+import "strings"
10+
11+// RuntimeSafetySetting is the GODEBUG key that must be set before any
12+// goroutine or CEF subprocess is created. Go stack shrinking can invalidate
13+// frames retained across foreign CEF/GTK stacks.
14+const RuntimeSafetySetting = "gcshrinkstackoff"
15+
16+// EnvironmentValue returns the value for name in an environ slice.
17+func EnvironmentValue(environ []string, name string) string {
18+ prefix := name + "="
19+ for _, entry := range environ {
20+ if strings.HasPrefix(entry, prefix) {
21+ return strings.TrimPrefix(entry, prefix)
22+ }
23+ }
24+ return ""
25+}
26+
27+// ReplaceEnvironmentValue returns a copy of environ with name set to value.
28+func ReplaceEnvironmentValue(environ []string, name, value string) []string {
29+ prefix := name + "="
30+ updated := make([]string, 0, len(environ)+1)
31+ for _, entry := range environ {
32+ if !strings.HasPrefix(entry, prefix) {
33+ updated = append(updated, entry)
34+ }
35+ }
36+ return append(updated, prefix+value)
37+}
38+
39+// MergeRuntimeSafety returns a copy of environ with RuntimeSafetySetting=1
40+// merged into GODEBUG, preserving all unrelated entries (including other
41+// GODEBUG keys) and their relative order. It reports whether a change was
42+// applied. An already-effective single setting entry is detected
43+// independently of its position, so direct invocation never performs an
44+// unnecessary self-exec; duplicate or conflicting entries still collapse to
45+// one normalized trailing entry.
46+func MergeRuntimeSafety(environ []string) ([]string, bool) {
47+ updated := append([]string(nil), environ...)
48+ // Collect every GODEBUG entry: duplicates are legal in an environ
49+ // slice and the first entry alone never describes the effective
50+ // setting, so normalization must merge them all before deciding.
51+ godebugValues := make([]string, 0, 1)
52+ for _, entry := range updated {
53+ if strings.HasPrefix(entry, "GODEBUG=") {
54+ godebugValues = append(godebugValues, strings.TrimPrefix(entry, "GODEBUG="))
55+ }
56+ }
57+ current := strings.Join(godebugValues, ",")
58+ parts := strings.Split(current, ",")
59+ kept := make([]string, 0, len(parts)+1)
60+ var settingParts []string
61+ for _, part := range parts {
62+ part = strings.TrimSpace(part)
63+ if part == "" {
64+ continue
65+ }
66+ key, _, _ := strings.Cut(part, "=")
67+ if key == RuntimeSafetySetting {
68+ settingParts = append(settingParts, part)
69+ continue
70+ }
71+ kept = append(kept, part)
72+ }
73+ if len(godebugValues) == 1 && len(settingParts) == 1 && settingParts[0] == RuntimeSafetySetting+"=1" {
74+ // Exactly one entry with one effective setting: already safe in
75+ // place, independent of its position among the other keys. Leave
76+ // the order untouched so direct invocation performs no self-exec.
77+ return updated, false
78+ }
79+ // Zero, duplicate, conflicting, or valueless entries collapse to one
80+ // normalized trailing entry.
81+ merged := strings.Join(append(kept, RuntimeSafetySetting+"=1"), ",")
82+ if current == merged {
83+ return updated, false
84+ }
85+ return ReplaceEnvironmentValue(updated, "GODEBUG", merged), true
86+}
A internal/infrastructure/process/runtime_safety_test.go +55 -0
@@ -0,0 +1,55 @@
1+package process
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestMergeRuntimeSafety(t *testing.T) {
11+ tests := []struct {
12+ name string
13+ environ []string
14+ wantGODEBUG string
15+ wantChanged bool
16+ }{
17+ {name: "missing", environ: []string{"HOME=/tmp"}, wantGODEBUG: "gcshrinkstackoff=1", wantChanged: true},
18+ {name: "preserves settings", environ: []string{"GODEBUG=gctrace=1"}, wantGODEBUG: "gctrace=1,gcshrinkstackoff=1", wantChanged: true},
19+ {name: "overrides unsafe value", environ: []string{"GODEBUG=gcshrinkstackoff=0,gctrace=1"}, wantGODEBUG: "gctrace=1,gcshrinkstackoff=1", wantChanged: true},
20+ {name: "already safe", environ: []string{"GODEBUG=gctrace=1,gcshrinkstackoff=1"}, wantGODEBUG: "gctrace=1,gcshrinkstackoff=1", wantChanged: false},
21+ {name: "safe first keeps position without exec", environ: []string{"GODEBUG=gcshrinkstackoff=1,gctrace=1"}, wantGODEBUG: "gcshrinkstackoff=1,gctrace=1", wantChanged: false},
22+ {name: "collapses duplicates", environ: []string{"GODEBUG=gcshrinkstackoff=0,gcshrinkstackoff=1"}, wantGODEBUG: "gcshrinkstackoff=1", wantChanged: true},
23+ {name: "bare key without value is unsafe", environ: []string{"GODEBUG=gcshrinkstackoff"}, wantGODEBUG: "gcshrinkstackoff=1", wantChanged: true},
24+ {name: "preserves other debug keys", environ: []string{"GODEBUG=tracebacklabels=0,x509sslcertoverrideplatform=0"}, wantGODEBUG: "tracebacklabels=0,x509sslcertoverrideplatform=0,gcshrinkstackoff=1", wantChanged: true},
25+ {name: "merges separate duplicate entries", environ: []string{"GODEBUG=gcshrinkstackoff=1", "GODEBUG=gctrace=1"}, wantGODEBUG: "gctrace=1,gcshrinkstackoff=1", wantChanged: true},
26+ {name: "safe first entry does not hide second entry keys", environ: []string{"GODEBUG=gcshrinkstackoff=1", "GODEBUG=cpu.all=off"}, wantGODEBUG: "cpu.all=off,gcshrinkstackoff=1", wantChanged: true},
27+ {name: "unsafe first entry preserves second entry keys", environ: []string{"GODEBUG=cpu.all=off", "GODEBUG=gcshrinkstackoff=1,foo=bar"}, wantGODEBUG: "cpu.all=off,foo=bar,gcshrinkstackoff=1", wantChanged: true},
28+ }
29+ for _, tt := range tests {
30+ t.Run(tt.name, func(t *testing.T) {
31+ got, changed := MergeRuntimeSafety(tt.environ)
32+ require.Equal(t, tt.wantChanged, changed)
33+ require.Equal(t, tt.wantGODEBUG, EnvironmentValue(got, "GODEBUG"))
34+ if changed {
35+ // Normalization always collapses to a single trailing
36+ // entry; no duplicate GODEBUG may survive a change.
37+ count := 0
38+ for _, entry := range got {
39+ if strings.HasPrefix(entry, "GODEBUG=") {
40+ count++
41+ }
42+ }
43+ require.Equal(t, 1, count, "changed environ must hold exactly one GODEBUG entry")
44+ }
45+ })
46+ }
47+}
48+
49+func TestMergeRuntimeSafetyDoesNotMutateInput(t *testing.T) {
50+ input := []string{"GODEBUG=gctrace=1"}
51+ got, changed := MergeRuntimeSafety(input)
52+ require.True(t, changed)
53+ require.Equal(t, []string{"GODEBUG=gctrace=1"}, input)
54+ require.Equal(t, "gctrace=1,gcshrinkstackoff=1", EnvironmentValue(got, "GODEBUG"))
55+}
@@ -0,0 +1,55 @@
1+package process
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestMergeRuntimeSafety(t *testing.T) {
11+ tests := []struct {
12+ name string
13+ environ []string
14+ wantGODEBUG string
15+ wantChanged bool
16+ }{
17+ {name: "missing", environ: []string{"HOME=/tmp"}, wantGODEBUG: "gcshrinkstackoff=1", wantChanged: true},
18+ {name: "preserves settings", environ: []string{"GODEBUG=gctrace=1"}, wantGODEBUG: "gctrace=1,gcshrinkstackoff=1", wantChanged: true},
19+ {name: "overrides unsafe value", environ: []string{"GODEBUG=gcshrinkstackoff=0,gctrace=1"}, wantGODEBUG: "gctrace=1,gcshrinkstackoff=1", wantChanged: true},
20+ {name: "already safe", environ: []string{"GODEBUG=gctrace=1,gcshrinkstackoff=1"}, wantGODEBUG: "gctrace=1,gcshrinkstackoff=1", wantChanged: false},
21+ {name: "safe first keeps position without exec", environ: []string{"GODEBUG=gcshrinkstackoff=1,gctrace=1"}, wantGODEBUG: "gcshrinkstackoff=1,gctrace=1", wantChanged: false},
22+ {name: "collapses duplicates", environ: []string{"GODEBUG=gcshrinkstackoff=0,gcshrinkstackoff=1"}, wantGODEBUG: "gcshrinkstackoff=1", wantChanged: true},
23+ {name: "bare key without value is unsafe", environ: []string{"GODEBUG=gcshrinkstackoff"}, wantGODEBUG: "gcshrinkstackoff=1", wantChanged: true},
24+ {name: "preserves other debug keys", environ: []string{"GODEBUG=tracebacklabels=0,x509sslcertoverrideplatform=0"}, wantGODEBUG: "tracebacklabels=0,x509sslcertoverrideplatform=0,gcshrinkstackoff=1", wantChanged: true},
25+ {name: "merges separate duplicate entries", environ: []string{"GODEBUG=gcshrinkstackoff=1", "GODEBUG=gctrace=1"}, wantGODEBUG: "gctrace=1,gcshrinkstackoff=1", wantChanged: true},
26+ {name: "safe first entry does not hide second entry keys", environ: []string{"GODEBUG=gcshrinkstackoff=1", "GODEBUG=cpu.all=off"}, wantGODEBUG: "cpu.all=off,gcshrinkstackoff=1", wantChanged: true},
27+ {name: "unsafe first entry preserves second entry keys", environ: []string{"GODEBUG=cpu.all=off", "GODEBUG=gcshrinkstackoff=1,foo=bar"}, wantGODEBUG: "cpu.all=off,foo=bar,gcshrinkstackoff=1", wantChanged: true},
28+ }
29+ for _, tt := range tests {
30+ t.Run(tt.name, func(t *testing.T) {
31+ got, changed := MergeRuntimeSafety(tt.environ)
32+ require.Equal(t, tt.wantChanged, changed)
33+ require.Equal(t, tt.wantGODEBUG, EnvironmentValue(got, "GODEBUG"))
34+ if changed {
35+ // Normalization always collapses to a single trailing
36+ // entry; no duplicate GODEBUG may survive a change.
37+ count := 0
38+ for _, entry := range got {
39+ if strings.HasPrefix(entry, "GODEBUG=") {
40+ count++
41+ }
42+ }
43+ require.Equal(t, 1, count, "changed environ must hold exactly one GODEBUG entry")
44+ }
45+ })
46+ }
47+}
48+
49+func TestMergeRuntimeSafetyDoesNotMutateInput(t *testing.T) {
50+ input := []string{"GODEBUG=gctrace=1"}
51+ got, changed := MergeRuntimeSafety(input)
52+ require.True(t, changed)
53+ require.Equal(t, []string{"GODEBUG=gctrace=1"}, input)
54+ require.Equal(t, "gctrace=1,gcshrinkstackoff=1", EnvironmentValue(got, "GODEBUG"))
55+}
M internal/ui/component/logo.go +27 -7
@@ -2,6 +2,7 @@
22 package component
33
44 import (
5+ "errors"
56 "sync"
67
78 "github.com/bnema/dumber/assets"
@@ -9,26 +10,45 @@ import (
910 "github.com/bnema/puregotk/v4/glib"
1011 )
1112
13+var errLogoDecode = errors.New("logo decode failed")
14+
1215 var (
1316 logoTexture *gdk.Texture
1417 logoTextureOnce sync.Once
18+ // logoAssetBytes supplies the raster logo bytes. Overridden in tests to
19+ // count requests without native decoding.
20+ logoAssetBytes = func() []byte { return assets.LogoPNG512 }
21+ // logoDecodeTexture converts raster bytes to a texture. Overridden in
22+ // tests with a fake; production keeps GBytes/texture lifetime by
23+ // releasing the temporary byte reference after the native constructor
24+ // no longer needs it.
25+ logoDecodeTexture = func(data []byte) (*gdk.Texture, error) {
26+ bytes := glib.NewBytes(data, uint(len(data)))
27+ if bytes == nil {
28+ return nil, errLogoDecode
29+ }
30+ texture, err := gdk.NewTextureFromBytes(bytes)
31+ bytes.Unref()
32+ if err != nil {
33+ return nil, err
34+ }
35+ return texture, nil
36+ }
1537 )
1638
1739 // GetLogoTexture returns a cached GDK texture of the dumber logo.
1840 // Safe to call from any goroutine; lazily initializes on first call.
41+// Decodes the packaged 512-pixel PNG raster asset; the displayed size and
42+// one-texture-per-process lifetime are unchanged.
1943 // Returns nil if the logo cannot be loaded.
2044 func GetLogoTexture() *gdk.Texture {
2145 logoTextureOnce.Do(func() {
22- data := assets.LogoSVG
46+ data := logoAssetBytes()
2347 if len(data) == 0 {
2448 return
2549 }
26- bytes := glib.NewBytes(data, uint(len(data)))
27- if bytes == nil {
28- return
29- }
30- texture, err := gdk.NewTextureFromBytes(bytes)
31- if err != nil {
50+ texture, err := logoDecodeTexture(data)
51+ if err != nil || texture == nil {
3252 return
3353 }
3454 logoTexture = texture
@@ -2,6 +2,7 @@
2 package component2 package component
3 3
4 import (4 import (
5+ "errors"
5 "sync"6 "sync"
6 7
7 "github.com/bnema/dumber/assets"8 "github.com/bnema/dumber/assets"
@@ -9,26 +10,45 @@ import (
9 "github.com/bnema/puregotk/v4/glib"10 "github.com/bnema/puregotk/v4/glib"
10 )11 )
11 12
13+var errLogoDecode = errors.New("logo decode failed")
14+
12 var (15 var (
13 logoTexture *gdk.Texture16 logoTexture *gdk.Texture
14 logoTextureOnce sync.Once17 logoTextureOnce sync.Once
18+ // logoAssetBytes supplies the raster logo bytes. Overridden in tests to
19+ // count requests without native decoding.
20+ logoAssetBytes = func() []byte { return assets.LogoPNG512 }
21+ // logoDecodeTexture converts raster bytes to a texture. Overridden in
22+ // tests with a fake; production keeps GBytes/texture lifetime by
23+ // releasing the temporary byte reference after the native constructor
24+ // no longer needs it.
25+ logoDecodeTexture = func(data []byte) (*gdk.Texture, error) {
26+ bytes := glib.NewBytes(data, uint(len(data)))
27+ if bytes == nil {
28+ return nil, errLogoDecode
29+ }
30+ texture, err := gdk.NewTextureFromBytes(bytes)
31+ bytes.Unref()
32+ if err != nil {
33+ return nil, err
34+ }
35+ return texture, nil
36+ }
15 )37 )
16 38
17 // GetLogoTexture returns a cached GDK texture of the dumber logo.39 // GetLogoTexture returns a cached GDK texture of the dumber logo.
18 // Safe to call from any goroutine; lazily initializes on first call.40 // Safe to call from any goroutine; lazily initializes on first call.
41+// Decodes the packaged 512-pixel PNG raster asset; the displayed size and
42+// one-texture-per-process lifetime are unchanged.
19 // Returns nil if the logo cannot be loaded.43 // Returns nil if the logo cannot be loaded.
20 func GetLogoTexture() *gdk.Texture {44 func GetLogoTexture() *gdk.Texture {
21 logoTextureOnce.Do(func() {45 logoTextureOnce.Do(func() {
22- data := assets.LogoSVG46+ data := logoAssetBytes()
23 if len(data) == 0 {47 if len(data) == 0 {
24 return48 return
25 }49 }
26- bytes := glib.NewBytes(data, uint(len(data)))50+ texture, err := logoDecodeTexture(data)
27- if bytes == nil {51+ if err != nil || texture == nil {
28- return
29- }
30- texture, err := gdk.NewTextureFromBytes(bytes)
31- if err != nil {
32 return52 return
33 }53 }
34 logoTexture = texture54 logoTexture = texture
A internal/ui/component/logo_test.go +97 -0
@@ -0,0 +1,97 @@
1+package component
2+
3+import (
4+ "encoding/binary"
5+ "errors"
6+ "sync"
7+ "sync/atomic"
8+ "testing"
9+
10+ "github.com/bnema/dumber/assets"
11+ "github.com/bnema/puregotk/v4/gdk"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+func resetLogoForTest() {
16+ logoTexture = nil
17+ logoTextureOnce = sync.Once{}
18+}
19+
20+func TestLogoPNG512AssetSignatureAndDimensions(t *testing.T) {
21+ data := assets.LogoPNG512
22+ require.NotEmpty(t, data)
23+ require.Equal(t, []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}, data[:8])
24+ require.GreaterOrEqual(t, len(data), 33)
25+ require.Equal(t, "IHDR", string(data[12:16]))
26+ width := binary.BigEndian.Uint32(data[16:20])
27+ height := binary.BigEndian.Uint32(data[20:24])
28+ require.Equal(t, uint32(512), width)
29+ require.Equal(t, uint32(512), height)
30+ require.NotEmpty(t, assets.LogoSVG, "SVG source for desktop icons must remain")
31+}
32+
33+func TestGetLogoTextureRequestsRasterOnce(t *testing.T) {
34+ resetLogoForTest()
35+ origAsset, origDecode := logoAssetBytes, logoDecodeTexture
36+ defer func() {
37+ logoAssetBytes, logoDecodeTexture = origAsset, origDecode
38+ resetLogoForTest()
39+ }()
40+ var calls atomic.Int32
41+ logoAssetBytes = func() []byte {
42+ calls.Add(1)
43+ return assets.LogoPNG512
44+ }
45+ logoDecodeTexture = func(data []byte) (*gdk.Texture, error) {
46+ require.Equal(t, assets.LogoPNG512, data)
47+ return nil, errors.New("native unavailable in unit test")
48+ }
49+ require.NotPanics(t, func() {
50+ require.Nil(t, GetLogoTexture())
51+ require.Nil(t, GetLogoTexture())
52+ })
53+ require.Equal(t, int32(1), calls.Load(), "raster bytes must be requested once")
54+}
55+
56+func TestGetLogoTextureConcurrentCallersShareResult(t *testing.T) {
57+ resetLogoForTest()
58+ origAsset, origDecode := logoAssetBytes, logoDecodeTexture
59+ defer func() {
60+ logoAssetBytes, logoDecodeTexture = origAsset, origDecode
61+ resetLogoForTest()
62+ }()
63+ var calls atomic.Int32
64+ logoAssetBytes = func() []byte {
65+ calls.Add(1)
66+ return assets.LogoPNG512
67+ }
68+ logoDecodeTexture = func([]byte) (*gdk.Texture, error) {
69+ return nil, errors.New("native unavailable in unit test")
70+ }
71+ var wg sync.WaitGroup
72+ for range 16 {
73+ wg.Add(1)
74+ go func() {
75+ defer wg.Done()
76+ _ = GetLogoTexture()
77+ }()
78+ }
79+ wg.Wait()
80+ require.Equal(t, int32(1), calls.Load())
81+}
82+
83+func TestGetLogoTextureFailureDoesNotPanic(t *testing.T) {
84+ resetLogoForTest()
85+ origAsset, origDecode := logoAssetBytes, logoDecodeTexture
86+ defer func() {
87+ logoAssetBytes, logoDecodeTexture = origAsset, origDecode
88+ resetLogoForTest()
89+ }()
90+ logoAssetBytes = func() []byte { return nil }
91+ logoDecodeTexture = func([]byte) (*gdk.Texture, error) {
92+ return nil, errors.New("must not be called with empty asset")
93+ }
94+ require.NotPanics(t, func() {
95+ require.Nil(t, GetLogoTexture())
96+ })
97+}
@@ -0,0 +1,97 @@
1+package component
2+
3+import (
4+ "encoding/binary"
5+ "errors"
6+ "sync"
7+ "sync/atomic"
8+ "testing"
9+
10+ "github.com/bnema/dumber/assets"
11+ "github.com/bnema/puregotk/v4/gdk"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+func resetLogoForTest() {
16+ logoTexture = nil
17+ logoTextureOnce = sync.Once{}
18+}
19+
20+func TestLogoPNG512AssetSignatureAndDimensions(t *testing.T) {
21+ data := assets.LogoPNG512
22+ require.NotEmpty(t, data)
23+ require.Equal(t, []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}, data[:8])
24+ require.GreaterOrEqual(t, len(data), 33)
25+ require.Equal(t, "IHDR", string(data[12:16]))
26+ width := binary.BigEndian.Uint32(data[16:20])
27+ height := binary.BigEndian.Uint32(data[20:24])
28+ require.Equal(t, uint32(512), width)
29+ require.Equal(t, uint32(512), height)
30+ require.NotEmpty(t, assets.LogoSVG, "SVG source for desktop icons must remain")
31+}
32+
33+func TestGetLogoTextureRequestsRasterOnce(t *testing.T) {
34+ resetLogoForTest()
35+ origAsset, origDecode := logoAssetBytes, logoDecodeTexture
36+ defer func() {
37+ logoAssetBytes, logoDecodeTexture = origAsset, origDecode
38+ resetLogoForTest()
39+ }()
40+ var calls atomic.Int32
41+ logoAssetBytes = func() []byte {
42+ calls.Add(1)
43+ return assets.LogoPNG512
44+ }
45+ logoDecodeTexture = func(data []byte) (*gdk.Texture, error) {
46+ require.Equal(t, assets.LogoPNG512, data)
47+ return nil, errors.New("native unavailable in unit test")
48+ }
49+ require.NotPanics(t, func() {
50+ require.Nil(t, GetLogoTexture())
51+ require.Nil(t, GetLogoTexture())
52+ })
53+ require.Equal(t, int32(1), calls.Load(), "raster bytes must be requested once")
54+}
55+
56+func TestGetLogoTextureConcurrentCallersShareResult(t *testing.T) {
57+ resetLogoForTest()
58+ origAsset, origDecode := logoAssetBytes, logoDecodeTexture
59+ defer func() {
60+ logoAssetBytes, logoDecodeTexture = origAsset, origDecode
61+ resetLogoForTest()
62+ }()
63+ var calls atomic.Int32
64+ logoAssetBytes = func() []byte {
65+ calls.Add(1)
66+ return assets.LogoPNG512
67+ }
68+ logoDecodeTexture = func([]byte) (*gdk.Texture, error) {
69+ return nil, errors.New("native unavailable in unit test")
70+ }
71+ var wg sync.WaitGroup
72+ for range 16 {
73+ wg.Add(1)
74+ go func() {
75+ defer wg.Done()
76+ _ = GetLogoTexture()
77+ }()
78+ }
79+ wg.Wait()
80+ require.Equal(t, int32(1), calls.Load())
81+}
82+
83+func TestGetLogoTextureFailureDoesNotPanic(t *testing.T) {
84+ resetLogoForTest()
85+ origAsset, origDecode := logoAssetBytes, logoDecodeTexture
86+ defer func() {
87+ logoAssetBytes, logoDecodeTexture = origAsset, origDecode
88+ resetLogoForTest()
89+ }()
90+ logoAssetBytes = func() []byte { return nil }
91+ logoDecodeTexture = func([]byte) (*gdk.Texture, error) {
92+ return nil, errors.New("must not be called with empty asset")
93+ }
94+ require.NotPanics(t, func() {
95+ require.Nil(t, GetLogoTexture())
96+ })
97+}
A scripts/cef_runtime_probe.py +100 -0
@@ -0,0 +1,100 @@
1+#!/usr/bin/env python3
2+"""Report numeric CEF runtime version fields plus library hash.
3+
4+Opens only the explicitly selected trusted libcef.so using the standard
5+library ctypes module and calls the header-verified cef_version_info(int)
6+entries from /usr/include/cef/include/cef_version_info.h:
7+
8+ 0 - CEF_VERSION_MAJOR
9+ 1 - CEF_VERSION_MINOR
10+ 2 - CEF_VERSION_PATCH
11+ 3 - CEF_COMMIT_NUMBER
12+ 4 - CHROME_VERSION_MAJOR
13+ 5 - CHROME_VERSION_MINOR
14+ 6 - CHROME_VERSION_BUILD
15+ 7 - CHROME_VERSION_PATCH
16+
17+Outputs JSON to stdout with numeric fields and the library SHA-256. Never
18+outputs filesystem paths. This probe is not an ABI bypass: the candidate must
19+still pass its normal loader ABI/version validation.
20+"""
21+
22+import argparse
23+import ctypes
24+import hashlib
25+import json
26+import os
27+import sys
28+
29+
30+ENTRIES = (
31+ ("cef_version_major", 0),
32+ ("cef_version_minor", 1),
33+ ("cef_version_patch", 2),
34+ ("cef_commit_number", 3),
35+ ("chrome_version_major", 4),
36+ ("chrome_version_minor", 5),
37+ ("chrome_version_build", 6),
38+ ("chrome_version_patch", 7),
39+)
40+
41+
42+def fail(message):
43+ print("cef-runtime-probe: {}".format(message), file=sys.stderr)
44+ raise SystemExit(2)
45+
46+
47+def main():
48+ parser = argparse.ArgumentParser(description="Probe selected libcef.so version fields.")
49+ parser.add_argument("--cef-dir", required=True, help="Selected CEF runtime directory.")
50+ args = parser.parse_args()
51+
52+ cef_dir = args.cef_dir
53+ if not cef_dir or not os.path.isabs(cef_dir):
54+ fail("cef dir must be an absolute path")
55+ lib_name = os.path.join(cef_dir, "libcef.so")
56+ try:
57+ is_file = os.path.isfile(lib_name) and not os.path.islink(lib_name)
58+ except OSError:
59+ fail("could not stat runtime library")
60+ if not is_file:
61+ fail("runtime library not found")
62+
63+ try:
64+ with open(lib_name, "rb") as candidate:
65+ # Chunked streaming hash: hashlib.file_digest needs 3.11+,
66+ # this stays compatible with older 3.x interpreters.
67+ digest = hashlib.sha256()
68+ for chunk in iter(lambda: candidate.read(65536), b""):
69+ digest.update(chunk)
70+ library_sha256 = digest.hexdigest()
71+ except OSError:
72+ fail("could not hash runtime library")
73+
74+ try:
75+ lib = ctypes.CDLL(lib_name)
76+ except OSError:
77+ fail("could not open runtime library")
78+ try:
79+ version_info = lib.cef_version_info
80+ except AttributeError:
81+ fail("cef_version_info symbol is unavailable")
82+ version_info.argtypes = (ctypes.c_int,)
83+ version_info.restype = ctypes.c_int
84+
85+ result = {}
86+ for field, entry in ENTRIES:
87+ try:
88+ value = version_info(entry)
89+ except (ctypes.ArgumentError, OSError, ValueError):
90+ fail("version query failed")
91+ if not isinstance(value, int) or value < 0:
92+ fail("version query failed")
93+ result[field] = value
94+ result["libcef_sha256"] = library_sha256
95+ json.dump(result, sys.stdout, indent=2, sort_keys=True)
96+ sys.stdout.write("\n")
97+
98+
99+if __name__ == "__main__":
100+ main()
@@ -0,0 +1,100 @@
1+#!/usr/bin/env python3
2+"""Report numeric CEF runtime version fields plus library hash.
3+
4+Opens only the explicitly selected trusted libcef.so using the standard
5+library ctypes module and calls the header-verified cef_version_info(int)
6+entries from /usr/include/cef/include/cef_version_info.h:
7+
8+ 0 - CEF_VERSION_MAJOR
9+ 1 - CEF_VERSION_MINOR
10+ 2 - CEF_VERSION_PATCH
11+ 3 - CEF_COMMIT_NUMBER
12+ 4 - CHROME_VERSION_MAJOR
13+ 5 - CHROME_VERSION_MINOR
14+ 6 - CHROME_VERSION_BUILD
15+ 7 - CHROME_VERSION_PATCH
16+
17+Outputs JSON to stdout with numeric fields and the library SHA-256. Never
18+outputs filesystem paths. This probe is not an ABI bypass: the candidate must
19+still pass its normal loader ABI/version validation.
20+"""
21+
22+import argparse
23+import ctypes
24+import hashlib
25+import json
26+import os
27+import sys
28+
29+
30+ENTRIES = (
31+ ("cef_version_major", 0),
32+ ("cef_version_minor", 1),
33+ ("cef_version_patch", 2),
34+ ("cef_commit_number", 3),
35+ ("chrome_version_major", 4),
36+ ("chrome_version_minor", 5),
37+ ("chrome_version_build", 6),
38+ ("chrome_version_patch", 7),
39+)
40+
41+
42+def fail(message):
43+ print("cef-runtime-probe: {}".format(message), file=sys.stderr)
44+ raise SystemExit(2)
45+
46+
47+def main():
48+ parser = argparse.ArgumentParser(description="Probe selected libcef.so version fields.")
49+ parser.add_argument("--cef-dir", required=True, help="Selected CEF runtime directory.")
50+ args = parser.parse_args()
51+
52+ cef_dir = args.cef_dir
53+ if not cef_dir or not os.path.isabs(cef_dir):
54+ fail("cef dir must be an absolute path")
55+ lib_name = os.path.join(cef_dir, "libcef.so")
56+ try:
57+ is_file = os.path.isfile(lib_name) and not os.path.islink(lib_name)
58+ except OSError:
59+ fail("could not stat runtime library")
60+ if not is_file:
61+ fail("runtime library not found")
62+
63+ try:
64+ with open(lib_name, "rb") as candidate:
65+ # Chunked streaming hash: hashlib.file_digest needs 3.11+,
66+ # this stays compatible with older 3.x interpreters.
67+ digest = hashlib.sha256()
68+ for chunk in iter(lambda: candidate.read(65536), b""):
69+ digest.update(chunk)
70+ library_sha256 = digest.hexdigest()
71+ except OSError:
72+ fail("could not hash runtime library")
73+
74+ try:
75+ lib = ctypes.CDLL(lib_name)
76+ except OSError:
77+ fail("could not open runtime library")
78+ try:
79+ version_info = lib.cef_version_info
80+ except AttributeError:
81+ fail("cef_version_info symbol is unavailable")
82+ version_info.argtypes = (ctypes.c_int,)
83+ version_info.restype = ctypes.c_int
84+
85+ result = {}
86+ for field, entry in ENTRIES:
87+ try:
88+ value = version_info(entry)
89+ except (ctypes.ArgumentError, OSError, ValueError):
90+ fail("version query failed")
91+ if not isinstance(value, int) or value < 0:
92+ fail("version query failed")
93+ result[field] = value
94+ result["libcef_sha256"] = library_sha256
95+ json.dump(result, sys.stdout, indent=2, sort_keys=True)
96+ sys.stdout.write("\n")
97+
98+
99+if __name__ == "__main__":
100+ main()
M scripts/collect_first_presentation.sh +131 -83
@@ -6,14 +6,25 @@ set -euo pipefail
66
77 readonly runs=5
88 readonly timeout_seconds="${DUMBER_FIRST_PRESENTATION_TIMEOUT_SECONDS:-45}"
9-readonly runtime="${DUMBER_CEF_DIR:-$HOME/.local/share/cef-147-runtime}"
109 readonly binary="${DUMBER_FIRST_PRESENTATION_BIN:-$PWD/dist/dumber}"
10+readonly manifest="${DUMBER_BUILD_MANIFEST:-${binary}.manifest.json}"
1111
1212 fail_unsafe_output() {
1313 echo "first-presentation: unsafe output path: $1" >&2
1414 exit 2
1515 }
1616
17+# The measured runtime is always explicit. Never default to a hardcoded path
18+# or version: collection fails closed when the caller does not select one.
19+if [[ -z "${DUMBER_CEF_DIR:-}" ]]; then
20+ echo "first-presentation: DUMBER_CEF_DIR must be set to the selected CEF runtime directory" >&2
21+ exit 2
22+fi
23+readonly runtime="${DUMBER_CEF_DIR}"
24+# Never inherit a conflicting CEF_DIR override: the child environment below is
25+# built with env -i and receives exactly this selected directory.
26+readonly selected_cef_dir="${DUMBER_CEF_DIR}"
27+
1728 if [[ -v DUMBER_FIRST_PRESENTATION_OUTPUT ]]; then
1829 output="$DUMBER_FIRST_PRESENTATION_OUTPUT"
1930 else
@@ -31,87 +42,112 @@ else
3142 fi
3243 readonly output
3344 readonly upstream_module="github.com/bnema/purego-cef2gtk"
45+readonly script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
46+readonly runtime_probe="$script_dir/cef_runtime_probe.py"
3447
35-# Resolve the version selected by this checkout, then obtain its immutable VCS
36-# origin from Go's cached module metadata. Do not infer a revision from a tag,
37-# a branch, or a truncated pseudo-version suffix.
38-resolve_upstream_provenance() {
39- local selected_metadata selected_version downloaded_metadata
48+# Resolve provenance from the measured binary, not the collection checkout.
49+# Reads embedded build info via `go version -m` and requires a build manifest
50+# tied to the candidate SHA-256 when VCS data is absent (including
51+# buildvcs=false builds). Never queries the checkout module graph.
52+resolve_binary_provenance() {
53+ python3 - "$binary" "$manifest" "$upstream_module" <<'PY'
54+import hashlib, json, os, re, subprocess, sys
55+binary, manifest_path, module = sys.argv[1:]
4056
41- selected_metadata="$(GOFLAGS=-mod=mod go list -m -json "$upstream_module" 2>/dev/null)" || {
42- echo "first-presentation: immutable module provenance is unavailable" >&2
43- exit 2
44- }
45- selected_version="$(python3 - "$upstream_module" "$selected_metadata" <<'PY'
46-import json, sys
47-try:
48- metadata = json.loads(sys.argv[2])
49- if metadata.get("Path") != sys.argv[1] or not isinstance(metadata.get("Version"), str):
50- raise ValueError
51- print(metadata["Version"])
52-except (AttributeError, TypeError, ValueError, json.JSONDecodeError):
57+def fail():
58+ # Do not expose cache locations or other machine-local values.
5359 raise SystemExit("first-presentation: immutable module provenance is unavailable")
54-PY
55-)" || exit $?
56- downloaded_metadata="$(go mod download -json "$upstream_module@$selected_version" 2>/dev/null)" || {
57- echo "first-presentation: immutable module provenance is unavailable" >&2
58- exit 2
59- }
60-
61- python3 - "$upstream_module" "$selected_metadata" "$downloaded_metadata" <<'PY'
62-import json, re, sys
6360
64-module, selected_raw, downloaded_raw = sys.argv[1:]
61+with open(binary, "rb") as candidate:
62+ # Chunked streaming hash: hashlib.file_digest needs 3.11+, this stays
63+ # compatible with older 3.x interpreters.
64+ digest = hashlib.sha256()
65+ for chunk in iter(lambda: candidate.read(65536), b""):
66+ digest.update(chunk)
67+ binary_sha256 = digest.hexdigest()
6568
66-def fail():
67- # Do not expose Go cache locations or other machine-local values.
69+try:
70+ buildinfo = subprocess.check_output(["go", "version", "-m", binary], text=True, stderr=subprocess.DEVNULL)
71+except (OSError, subprocess.CalledProcessError):
72+ raise SystemExit("first-presentation: immutable module provenance is unavailable")
73+if "=>" in buildinfo:
6874 raise SystemExit("first-presentation: immutable module provenance is unavailable")
6975
70-try:
71- selected = json.loads(selected_raw)
72- downloaded = json.loads(downloaded_raw)
73- version = selected["Version"]
74- if selected.get("Path") != module or downloaded.get("Path") != module:
75- fail()
76- if downloaded.get("Version") != version:
77- fail()
78- # A pseudo-version identifies an immutable commit; a released semver tag is
79- # accepted only when Go records that exact tag and its immutable hash.
80- pseudo_match = re.fullmatch(r"(v\d+\.\d+\.\d+)-0\.\d{14}-([0-9a-f]{12})", version)
81- tag_match = re.fullmatch(r"v\d+\.\d+\.\d+", version)
82- if not pseudo_match and not tag_match:
83- fail()
84- info_path = downloaded.get("Info")
85- if not isinstance(info_path, str) or not info_path:
86- fail()
87- with open(info_path, encoding="utf-8") as info_file:
88- info = json.load(info_file)
89- origin = info.get("Origin") or downloaded.get("Origin")
90- if info.get("Version") != version or not isinstance(origin, dict):
91- fail()
92- revision = origin.get("Hash")
93- if origin.get("VCS") != "git" or origin.get("URL") != "https://github.com/bnema/purego-cef2gtk":
94- fail()
95- if not isinstance(revision, str) or not re.fullmatch(r"[0-9a-f]{40}", revision):
96- fail()
97- if pseudo_match:
98- if not revision.startswith(pseudo_match.group(2)):
99- fail()
100- # A named ref can move. Only the immutable hash itself is acceptable.
101- if origin.get("Ref") not in (None, "", revision):
102- fail()
103- tag = pseudo_match.group(1)
104- else:
105- if origin.get("Ref") != "refs/tags/" + version:
106- fail()
107- tag = version
108-except (AttributeError, KeyError, OSError, TypeError, ValueError, json.JSONDecodeError):
76+mod_version = None
77+dep_version = None
78+vcs_revision = None
79+for line in buildinfo.splitlines():
80+ parts = line.split()
81+ if len(parts) < 2:
82+ continue
83+ if parts[0] == "mod" and len(parts) >= 3 and parts[1] == "github.com/bnema/dumber":
84+ mod_version = parts[2]
85+ if parts[0] == "dep" and len(parts) >= 3 and parts[1] == module:
86+ dep_version = parts[2]
87+ if parts[0] == "build" and len(parts) >= 2 and parts[1].startswith("vcs.revision="):
88+ vcs_revision = parts[1].split("=", 1)[1]
89+if dep_version is None:
90+ fail()
91+pseudo_match = re.fullmatch(r"(v\d+\.\d+\.\d+)-0\.\d{14}-([0-9a-f]{12})", dep_version)
92+tag_match = re.fullmatch(r"v\d+\.\d+\.\d+", dep_version)
93+if not pseudo_match and not tag_match:
94+ fail()
95+if vcs_revision is not None and not re.fullmatch(r"[0-9a-f]{40}", vcs_revision):
10996 fail()
11097
111-print("\t".join((version, tag, revision)))
98+try:
99+ with open(manifest_path, encoding="utf-8") as manifest_file:
100+ manifest = json.load(manifest_file)
101+except (OSError, ValueError):
102+ manifest = None
103+
104+if manifest is None:
105+ # Without embedded VCS data the binary cannot be attributed; fail closed.
106+ if vcs_revision is None:
107+ raise SystemExit("first-presentation: build manifest is required for binaries without embedded VCS data")
108+ # Embedded VCS revision attributes the binary itself, but the full
109+ # upstream revision is not in build info; the manifest must still bind it.
110+ raise SystemExit("first-presentation: build manifest is required to bind dependency revisions")
111+
112+if not isinstance(manifest, dict):
113+ raise SystemExit("first-presentation: build manifest is required for binaries without embedded VCS data")
114+if manifest.get("binary_sha256") != binary_sha256:
115+ raise SystemExit("first-presentation: build manifest does not match the measured binary")
116+source_revision = manifest.get("source_revision")
117+if not isinstance(source_revision, str) or not re.fullmatch(r"[0-9a-f]{40}", source_revision):
118+ raise SystemExit("first-presentation: build manifest does not match the measured binary")
119+if vcs_revision is not None and vcs_revision != source_revision:
120+ raise SystemExit("first-presentation: build manifest does not match the measured binary")
121+modules = manifest.get("modules")
122+if not isinstance(modules, dict) or module not in modules:
123+ raise SystemExit("first-presentation: build manifest does not match the measured binary")
124+entry = modules[module]
125+if not isinstance(entry, dict) or entry.get("version") != dep_version:
126+ raise SystemExit("first-presentation: build manifest does not match the measured binary")
127+revision = entry.get("revision")
128+if not isinstance(revision, str) or not re.fullmatch(r"[0-9a-f]{40}", revision):
129+ raise SystemExit("first-presentation: build manifest does not match the measured binary")
130+if pseudo_match:
131+ if not revision.startswith(pseudo_match.group(2)):
132+ raise SystemExit("first-presentation: build manifest does not match the measured binary")
133+ tag = pseudo_match.group(1)
134+else:
135+ tag = dep_version
136+
137+print("\t".join((binary_sha256, source_revision, dep_version, tag, revision)))
112138 PY
113139 }
114140
141+# Probe the explicitly selected runtime in a separate bounded subprocess.
142+# Reports numeric version fields plus library hash, never a path. The
143+# candidate must still pass its normal loader ABI/version validation.
144+probe_runtime() {
145+ timeout --signal=TERM --kill-after=5s 30s python3 "$runtime_probe" --cef-dir "$selected_cef_dir" 2>/dev/null || {
146+ echo "first-presentation: CEF runtime probe failed" >&2
147+ exit 2
148+ }
149+}
150+
115151 # The artifact destination is caller-controlled. Never empty or recursively
116152 # clear it: collection only writes to a newly-created directory whose parent is
117153 # already canonical and contains no symbolic-link hop.
@@ -141,12 +177,22 @@ prepare_output() {
141177
142178 prepare_output
143179
144-[[ -x "$binary" ]] || { echo "first-presentation: executable not found: $binary" >&2; exit 2; }
145-[[ -d "$runtime" ]] || { echo "first-presentation: CEF runtime not found: $runtime" >&2; exit 2; }
180+[[ -x "$binary" ]] || { echo "first-presentation: executable not found" >&2; exit 2; }
181+[[ -d "$runtime" ]] || { echo "first-presentation: CEF runtime not found" >&2; exit 2; }
146182 [[ -n "${WAYLAND_DISPLAY:-}${DISPLAY:-}" ]] || { echo "first-presentation: a current Wayland/X11 display is required" >&2; exit 2; }
147-upstream_provenance="$(resolve_upstream_provenance)" || exit $?
148-IFS=$'\t' read -r upstream_version upstream_tag upstream_revision <<<"$upstream_provenance"
149-readonly upstream_version upstream_tag upstream_revision
183+binary_provenance="$(resolve_binary_provenance)" || exit $?
184+IFS=$'\t' read -r binary_sha256 measured_source_revision upstream_version upstream_tag upstream_revision <<<"$binary_provenance"
185+readonly binary_sha256 measured_source_revision upstream_version upstream_tag upstream_revision
186+runtime_probe_json="$(probe_runtime)" || exit $?
187+readonly runtime_probe_json
188+# Enforce the loader minimum (CHROME_VERSION_MAJOR >= 150) at collection time.
189+# This records the observed runtime; it never bypasses the candidate ABI check.
190+python3 - "$runtime_probe_json" <<'PY'
191+import json, sys
192+probe = json.loads(sys.argv[1])
193+if not isinstance(probe.get("chrome_version_major"), int) or probe["chrome_version_major"] < 150:
194+ raise SystemExit("first-presentation: unsupported CEF runtime")
195+PY
150196
151197 # Raw logs and temporary XDG homes may contain machine-local paths. Keep them
152198 # outside the committed artifact directory and always remove them.
@@ -157,11 +203,10 @@ cleanup_work_root() {
157203 rm -rf -- "$work_root"
158204 }
159205 trap cleanup_work_root EXIT
160-python3 - "$output/metadata.json" "$binary" "$timeout_seconds" "$upstream_module" "$upstream_version" "$upstream_tag" "$upstream_revision" <<'PY'
161-import hashlib, json, os, platform, subprocess, sys
162-path, binary, timeout, upstream_module, upstream_version, upstream_tag, upstream_revision = sys.argv[1:]
163-with open(binary, "rb") as candidate:
164- binary_sha256 = hashlib.file_digest(candidate, "sha256").hexdigest()
206+python3 - "$output/metadata.json" "$binary_sha256" "$timeout_seconds" "$upstream_module" "$upstream_version" "$upstream_tag" "$upstream_revision" "$measured_source_revision" "$runtime_probe_json" <<'PY'
207+import json, os, platform, sys
208+path, binary_sha256, timeout, upstream_module, upstream_version, upstream_tag, upstream_revision, measured_source_revision, probe_raw = sys.argv[1:]
209+probe = json.loads(probe_raw)
165210
166211 # These deliberately coarse labels support comparison without exposing a host,
167212 # device name, driver version, path, or environment dump.
@@ -175,10 +220,10 @@ if gpu_profile not in allowed_gpu_profiles:
175220
176221 json.dump({
177222 "runs": 5,
178- "runtime": {"label": "cef", "version": "147"},
223+ "runtime": {"label": "cef", "chrome_major": probe["chrome_version_major"], "cef_version_major": probe["cef_version_major"], "libcef_sha256": probe["libcef_sha256"]},
179224 "binary": {"label": "dumber", "sha256": binary_sha256},
180225 "timeout_seconds": int(timeout),
181- "measured_source_revision": subprocess.check_output(["git", "-c", f"safe.directory={os.getcwd()}", "rev-parse", "HEAD"], text=True).strip(),
226+ "measured_source_revision": measured_source_revision,
182227 "upstream": {"module": upstream_module, "version": upstream_version, "tag": upstream_tag, "revision": upstream_revision},
183228 "comparison": {"os": os_label, "architecture": architecture, "display_protocol": display_protocol, "machine_gpu_profile": gpu_profile},
184229 "render_configuration": {"backend": "gdk-dmabuf", "buffer_sharing": "dmabuf", "renderer": "vulkan"}
@@ -197,16 +242,19 @@ format = "json"
197242 enable_file_log = false
198243
199244 [engine.cef]
200-cef_dir = "$runtime"
245+cef_dir = "$selected_cef_dir"
201246 EOF
202247 # Every directory below is newly created for this one launch. Do not inherit
203248 # a profile, shader cache, CEF root cache, or mutable app configuration.
249+ # CEF_DIR is set to exactly the selected runtime, never a conflicting
250+ # inherited override.
204251 set +e
205252 env -i \
206253 HOME="$HOME" PATH="$PATH" LANG="${LANG:-C.UTF-8}" \
207254 WAYLAND_DISPLAY="${WAYLAND_DISPLAY:-}" DISPLAY="${DISPLAY:-}" XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-}" \
208255 DBUS_SESSION_BUS_ADDRESS="${DBUS_SESSION_BUS_ADDRESS:-}" XAUTHORITY="${XAUTHORITY:-}" \
209256 XDG_CONFIG_HOME="$root/config" XDG_DATA_HOME="$root/data" XDG_STATE_HOME="$root/state" XDG_CACHE_HOME="$root/cache" \
257+ CEF_DIR="$selected_cef_dir" DUMBER_CEF_DIR="$selected_cef_dir" \
210258 DUMBER_CEF_ROOT_CACHE_PATH="$root/cef-root-cache" DUMBER_RENDER_STACK="vulkan-dmabuf" \
211259 PUREGO_CEF2GTK_BACKEND="gdk-dmabuf" PUREGO_CEF2GTK_ANGLE_BACKEND="vulkan" GSK_RENDERER="vulkan" \
212260 timeout --signal=TERM --kill-after=5s "${timeout_seconds}s" "$binary" browse about:blank >"$root/process.log" 2>&1
@@ -6,14 +6,25 @@ set -euo pipefail
6 6
7 readonly runs=57 readonly runs=5
8 readonly timeout_seconds="${DUMBER_FIRST_PRESENTATION_TIMEOUT_SECONDS:-45}"8 readonly timeout_seconds="${DUMBER_FIRST_PRESENTATION_TIMEOUT_SECONDS:-45}"
9-readonly runtime="${DUMBER_CEF_DIR:-$HOME/.local/share/cef-147-runtime}"
10 readonly binary="${DUMBER_FIRST_PRESENTATION_BIN:-$PWD/dist/dumber}"9 readonly binary="${DUMBER_FIRST_PRESENTATION_BIN:-$PWD/dist/dumber}"
10+readonly manifest="${DUMBER_BUILD_MANIFEST:-${binary}.manifest.json}"
11 11
12 fail_unsafe_output() {12 fail_unsafe_output() {
13 echo "first-presentation: unsafe output path: $1" >&213 echo "first-presentation: unsafe output path: $1" >&2
14 exit 214 exit 2
15 }15 }
16 16
17+# The measured runtime is always explicit. Never default to a hardcoded path
18+# or version: collection fails closed when the caller does not select one.
19+if [[ -z "${DUMBER_CEF_DIR:-}" ]]; then
20+ echo "first-presentation: DUMBER_CEF_DIR must be set to the selected CEF runtime directory" >&2
21+ exit 2
22+fi
23+readonly runtime="${DUMBER_CEF_DIR}"
24+# Never inherit a conflicting CEF_DIR override: the child environment below is
25+# built with env -i and receives exactly this selected directory.
26+readonly selected_cef_dir="${DUMBER_CEF_DIR}"
27+
17 if [[ -v DUMBER_FIRST_PRESENTATION_OUTPUT ]]; then28 if [[ -v DUMBER_FIRST_PRESENTATION_OUTPUT ]]; then
18 output="$DUMBER_FIRST_PRESENTATION_OUTPUT"29 output="$DUMBER_FIRST_PRESENTATION_OUTPUT"
19 else30 else
@@ -31,87 +42,112 @@ else
31 fi42 fi
32 readonly output43 readonly output
33 readonly upstream_module="github.com/bnema/purego-cef2gtk"44 readonly upstream_module="github.com/bnema/purego-cef2gtk"
45+readonly script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
46+readonly runtime_probe="$script_dir/cef_runtime_probe.py"
34 47
35-# Resolve the version selected by this checkout, then obtain its immutable VCS48+# Resolve provenance from the measured binary, not the collection checkout.
36-# origin from Go's cached module metadata. Do not infer a revision from a tag,49+# Reads embedded build info via `go version -m` and requires a build manifest
37-# a branch, or a truncated pseudo-version suffix.50+# tied to the candidate SHA-256 when VCS data is absent (including
38-resolve_upstream_provenance() {51+# buildvcs=false builds). Never queries the checkout module graph.
39- local selected_metadata selected_version downloaded_metadata52+resolve_binary_provenance() {
53+ python3 - "$binary" "$manifest" "$upstream_module" <<'PY'
54+import hashlib, json, os, re, subprocess, sys
55+binary, manifest_path, module = sys.argv[1:]
40 56
41- selected_metadata="$(GOFLAGS=-mod=mod go list -m -json "$upstream_module" 2>/dev/null)" || {57+def fail():
42- echo "first-presentation: immutable module provenance is unavailable" >&258+ # Do not expose cache locations or other machine-local values.
43- exit 2
44- }
45- selected_version="$(python3 - "$upstream_module" "$selected_metadata" <<'PY'
46-import json, sys
47-try:
48- metadata = json.loads(sys.argv[2])
49- if metadata.get("Path") != sys.argv[1] or not isinstance(metadata.get("Version"), str):
50- raise ValueError
51- print(metadata["Version"])
52-except (AttributeError, TypeError, ValueError, json.JSONDecodeError):
53 raise SystemExit("first-presentation: immutable module provenance is unavailable")59 raise SystemExit("first-presentation: immutable module provenance is unavailable")
54-PY
55-)" || exit $?
56- downloaded_metadata="$(go mod download -json "$upstream_module@$selected_version" 2>/dev/null)" || {
57- echo "first-presentation: immutable module provenance is unavailable" >&2
58- exit 2
59- }
60-
61- python3 - "$upstream_module" "$selected_metadata" "$downloaded_metadata" <<'PY'
62-import json, re, sys
63 60
64-module, selected_raw, downloaded_raw = sys.argv[1:]61+with open(binary, "rb") as candidate:
62+ # Chunked streaming hash: hashlib.file_digest needs 3.11+, this stays
63+ # compatible with older 3.x interpreters.
64+ digest = hashlib.sha256()
65+ for chunk in iter(lambda: candidate.read(65536), b""):
66+ digest.update(chunk)
67+ binary_sha256 = digest.hexdigest()
65 68
66-def fail():69+try:
67- # Do not expose Go cache locations or other machine-local values.70+ buildinfo = subprocess.check_output(["go", "version", "-m", binary], text=True, stderr=subprocess.DEVNULL)
71+except (OSError, subprocess.CalledProcessError):
72+ raise SystemExit("first-presentation: immutable module provenance is unavailable")
73+if "=>" in buildinfo:
68 raise SystemExit("first-presentation: immutable module provenance is unavailable")74 raise SystemExit("first-presentation: immutable module provenance is unavailable")
69 75
70-try:76+mod_version = None
71- selected = json.loads(selected_raw)77+dep_version = None
72- downloaded = json.loads(downloaded_raw)78+vcs_revision = None
73- version = selected["Version"]79+for line in buildinfo.splitlines():
74- if selected.get("Path") != module or downloaded.get("Path") != module:80+ parts = line.split()
75- fail()81+ if len(parts) < 2:
76- if downloaded.get("Version") != version:82+ continue
77- fail()83+ if parts[0] == "mod" and len(parts) >= 3 and parts[1] == "github.com/bnema/dumber":
78- # A pseudo-version identifies an immutable commit; a released semver tag is84+ mod_version = parts[2]
79- # accepted only when Go records that exact tag and its immutable hash.85+ if parts[0] == "dep" and len(parts) >= 3 and parts[1] == module:
80- pseudo_match = re.fullmatch(r"(v\d+\.\d+\.\d+)-0\.\d{14}-([0-9a-f]{12})", version)86+ dep_version = parts[2]
81- tag_match = re.fullmatch(r"v\d+\.\d+\.\d+", version)87+ if parts[0] == "build" and len(parts) >= 2 and parts[1].startswith("vcs.revision="):
82- if not pseudo_match and not tag_match:88+ vcs_revision = parts[1].split("=", 1)[1]
83- fail()89+if dep_version is None:
84- info_path = downloaded.get("Info")90+ fail()
85- if not isinstance(info_path, str) or not info_path:91+pseudo_match = re.fullmatch(r"(v\d+\.\d+\.\d+)-0\.\d{14}-([0-9a-f]{12})", dep_version)
86- fail()92+tag_match = re.fullmatch(r"v\d+\.\d+\.\d+", dep_version)
87- with open(info_path, encoding="utf-8") as info_file:93+if not pseudo_match and not tag_match:
88- info = json.load(info_file)94+ fail()
89- origin = info.get("Origin") or downloaded.get("Origin")95+if vcs_revision is not None and not re.fullmatch(r"[0-9a-f]{40}", vcs_revision):
90- if info.get("Version") != version or not isinstance(origin, dict):
91- fail()
92- revision = origin.get("Hash")
93- if origin.get("VCS") != "git" or origin.get("URL") != "https://github.com/bnema/purego-cef2gtk":
94- fail()
95- if not isinstance(revision, str) or not re.fullmatch(r"[0-9a-f]{40}", revision):
96- fail()
97- if pseudo_match:
98- if not revision.startswith(pseudo_match.group(2)):
99- fail()
100- # A named ref can move. Only the immutable hash itself is acceptable.
101- if origin.get("Ref") not in (None, "", revision):
102- fail()
103- tag = pseudo_match.group(1)
104- else:
105- if origin.get("Ref") != "refs/tags/" + version:
106- fail()
107- tag = version
108-except (AttributeError, KeyError, OSError, TypeError, ValueError, json.JSONDecodeError):
109 fail()96 fail()
110 97
111-print("\t".join((version, tag, revision)))98+try:
99+ with open(manifest_path, encoding="utf-8") as manifest_file:
100+ manifest = json.load(manifest_file)
101+except (OSError, ValueError):
102+ manifest = None
103+
104+if manifest is None:
105+ # Without embedded VCS data the binary cannot be attributed; fail closed.
106+ if vcs_revision is None:
107+ raise SystemExit("first-presentation: build manifest is required for binaries without embedded VCS data")
108+ # Embedded VCS revision attributes the binary itself, but the full
109+ # upstream revision is not in build info; the manifest must still bind it.
110+ raise SystemExit("first-presentation: build manifest is required to bind dependency revisions")
111+
112+if not isinstance(manifest, dict):
113+ raise SystemExit("first-presentation: build manifest is required for binaries without embedded VCS data")
114+if manifest.get("binary_sha256") != binary_sha256:
115+ raise SystemExit("first-presentation: build manifest does not match the measured binary")
116+source_revision = manifest.get("source_revision")
117+if not isinstance(source_revision, str) or not re.fullmatch(r"[0-9a-f]{40}", source_revision):
118+ raise SystemExit("first-presentation: build manifest does not match the measured binary")
119+if vcs_revision is not None and vcs_revision != source_revision:
120+ raise SystemExit("first-presentation: build manifest does not match the measured binary")
121+modules = manifest.get("modules")
122+if not isinstance(modules, dict) or module not in modules:
123+ raise SystemExit("first-presentation: build manifest does not match the measured binary")
124+entry = modules[module]
125+if not isinstance(entry, dict) or entry.get("version") != dep_version:
126+ raise SystemExit("first-presentation: build manifest does not match the measured binary")
127+revision = entry.get("revision")
128+if not isinstance(revision, str) or not re.fullmatch(r"[0-9a-f]{40}", revision):
129+ raise SystemExit("first-presentation: build manifest does not match the measured binary")
130+if pseudo_match:
131+ if not revision.startswith(pseudo_match.group(2)):
132+ raise SystemExit("first-presentation: build manifest does not match the measured binary")
133+ tag = pseudo_match.group(1)
134+else:
135+ tag = dep_version
136+
137+print("\t".join((binary_sha256, source_revision, dep_version, tag, revision)))
112 PY138 PY
113 }139 }
114 140
141+# Probe the explicitly selected runtime in a separate bounded subprocess.
142+# Reports numeric version fields plus library hash, never a path. The
143+# candidate must still pass its normal loader ABI/version validation.
144+probe_runtime() {
145+ timeout --signal=TERM --kill-after=5s 30s python3 "$runtime_probe" --cef-dir "$selected_cef_dir" 2>/dev/null || {
146+ echo "first-presentation: CEF runtime probe failed" >&2
147+ exit 2
148+ }
149+}
150+
115 # The artifact destination is caller-controlled. Never empty or recursively151 # The artifact destination is caller-controlled. Never empty or recursively
116 # clear it: collection only writes to a newly-created directory whose parent is152 # clear it: collection only writes to a newly-created directory whose parent is
117 # already canonical and contains no symbolic-link hop.153 # already canonical and contains no symbolic-link hop.
@@ -141,12 +177,22 @@ prepare_output() {
141 177
142 prepare_output178 prepare_output
143 179
144-[[ -x "$binary" ]] || { echo "first-presentation: executable not found: $binary" >&2; exit 2; }180+[[ -x "$binary" ]] || { echo "first-presentation: executable not found" >&2; exit 2; }
145-[[ -d "$runtime" ]] || { echo "first-presentation: CEF runtime not found: $runtime" >&2; exit 2; }181+[[ -d "$runtime" ]] || { echo "first-presentation: CEF runtime not found" >&2; exit 2; }
146 [[ -n "${WAYLAND_DISPLAY:-}${DISPLAY:-}" ]] || { echo "first-presentation: a current Wayland/X11 display is required" >&2; exit 2; }182 [[ -n "${WAYLAND_DISPLAY:-}${DISPLAY:-}" ]] || { echo "first-presentation: a current Wayland/X11 display is required" >&2; exit 2; }
147-upstream_provenance="$(resolve_upstream_provenance)" || exit $?183+binary_provenance="$(resolve_binary_provenance)" || exit $?
148-IFS=$'\t' read -r upstream_version upstream_tag upstream_revision <<<"$upstream_provenance"184+IFS=$'\t' read -r binary_sha256 measured_source_revision upstream_version upstream_tag upstream_revision <<<"$binary_provenance"
149-readonly upstream_version upstream_tag upstream_revision185+readonly binary_sha256 measured_source_revision upstream_version upstream_tag upstream_revision
186+runtime_probe_json="$(probe_runtime)" || exit $?
187+readonly runtime_probe_json
188+# Enforce the loader minimum (CHROME_VERSION_MAJOR >= 150) at collection time.
189+# This records the observed runtime; it never bypasses the candidate ABI check.
190+python3 - "$runtime_probe_json" <<'PY'
191+import json, sys
192+probe = json.loads(sys.argv[1])
193+if not isinstance(probe.get("chrome_version_major"), int) or probe["chrome_version_major"] < 150:
194+ raise SystemExit("first-presentation: unsupported CEF runtime")
195+PY
150 196
151 # Raw logs and temporary XDG homes may contain machine-local paths. Keep them197 # Raw logs and temporary XDG homes may contain machine-local paths. Keep them
152 # outside the committed artifact directory and always remove them.198 # outside the committed artifact directory and always remove them.
@@ -157,11 +203,10 @@ cleanup_work_root() {
157 rm -rf -- "$work_root"203 rm -rf -- "$work_root"
158 }204 }
159 trap cleanup_work_root EXIT205 trap cleanup_work_root EXIT
160-python3 - "$output/metadata.json" "$binary" "$timeout_seconds" "$upstream_module" "$upstream_version" "$upstream_tag" "$upstream_revision" <<'PY'206+python3 - "$output/metadata.json" "$binary_sha256" "$timeout_seconds" "$upstream_module" "$upstream_version" "$upstream_tag" "$upstream_revision" "$measured_source_revision" "$runtime_probe_json" <<'PY'
161-import hashlib, json, os, platform, subprocess, sys207+import json, os, platform, sys
162-path, binary, timeout, upstream_module, upstream_version, upstream_tag, upstream_revision = sys.argv[1:]208+path, binary_sha256, timeout, upstream_module, upstream_version, upstream_tag, upstream_revision, measured_source_revision, probe_raw = sys.argv[1:]
163-with open(binary, "rb") as candidate:209+probe = json.loads(probe_raw)
164- binary_sha256 = hashlib.file_digest(candidate, "sha256").hexdigest()
165 210
166 # These deliberately coarse labels support comparison without exposing a host,211 # These deliberately coarse labels support comparison without exposing a host,
167 # device name, driver version, path, or environment dump.212 # device name, driver version, path, or environment dump.
@@ -175,10 +220,10 @@ if gpu_profile not in allowed_gpu_profiles:
175 220
176 json.dump({221 json.dump({
177 "runs": 5,222 "runs": 5,
178- "runtime": {"label": "cef", "version": "147"},223+ "runtime": {"label": "cef", "chrome_major": probe["chrome_version_major"], "cef_version_major": probe["cef_version_major"], "libcef_sha256": probe["libcef_sha256"]},
179 "binary": {"label": "dumber", "sha256": binary_sha256},224 "binary": {"label": "dumber", "sha256": binary_sha256},
180 "timeout_seconds": int(timeout),225 "timeout_seconds": int(timeout),
181- "measured_source_revision": subprocess.check_output(["git", "-c", f"safe.directory={os.getcwd()}", "rev-parse", "HEAD"], text=True).strip(),226+ "measured_source_revision": measured_source_revision,
182 "upstream": {"module": upstream_module, "version": upstream_version, "tag": upstream_tag, "revision": upstream_revision},227 "upstream": {"module": upstream_module, "version": upstream_version, "tag": upstream_tag, "revision": upstream_revision},
183 "comparison": {"os": os_label, "architecture": architecture, "display_protocol": display_protocol, "machine_gpu_profile": gpu_profile},228 "comparison": {"os": os_label, "architecture": architecture, "display_protocol": display_protocol, "machine_gpu_profile": gpu_profile},
184 "render_configuration": {"backend": "gdk-dmabuf", "buffer_sharing": "dmabuf", "renderer": "vulkan"}229 "render_configuration": {"backend": "gdk-dmabuf", "buffer_sharing": "dmabuf", "renderer": "vulkan"}
@@ -197,16 +242,19 @@ format = "json"
197 enable_file_log = false242 enable_file_log = false
198 243
199 [engine.cef]244 [engine.cef]
200-cef_dir = "$runtime"245+cef_dir = "$selected_cef_dir"
201 EOF246 EOF
202 # Every directory below is newly created for this one launch. Do not inherit247 # Every directory below is newly created for this one launch. Do not inherit
203 # a profile, shader cache, CEF root cache, or mutable app configuration.248 # a profile, shader cache, CEF root cache, or mutable app configuration.
249+ # CEF_DIR is set to exactly the selected runtime, never a conflicting
250+ # inherited override.
204 set +e251 set +e
205 env -i \252 env -i \
206 HOME="$HOME" PATH="$PATH" LANG="${LANG:-C.UTF-8}" \253 HOME="$HOME" PATH="$PATH" LANG="${LANG:-C.UTF-8}" \
207 WAYLAND_DISPLAY="${WAYLAND_DISPLAY:-}" DISPLAY="${DISPLAY:-}" XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-}" \254 WAYLAND_DISPLAY="${WAYLAND_DISPLAY:-}" DISPLAY="${DISPLAY:-}" XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-}" \
208 DBUS_SESSION_BUS_ADDRESS="${DBUS_SESSION_BUS_ADDRESS:-}" XAUTHORITY="${XAUTHORITY:-}" \255 DBUS_SESSION_BUS_ADDRESS="${DBUS_SESSION_BUS_ADDRESS:-}" XAUTHORITY="${XAUTHORITY:-}" \
209 XDG_CONFIG_HOME="$root/config" XDG_DATA_HOME="$root/data" XDG_STATE_HOME="$root/state" XDG_CACHE_HOME="$root/cache" \256 XDG_CONFIG_HOME="$root/config" XDG_DATA_HOME="$root/data" XDG_STATE_HOME="$root/state" XDG_CACHE_HOME="$root/cache" \
257+ CEF_DIR="$selected_cef_dir" DUMBER_CEF_DIR="$selected_cef_dir" \
210 DUMBER_CEF_ROOT_CACHE_PATH="$root/cef-root-cache" DUMBER_RENDER_STACK="vulkan-dmabuf" \258 DUMBER_CEF_ROOT_CACHE_PATH="$root/cef-root-cache" DUMBER_RENDER_STACK="vulkan-dmabuf" \
211 PUREGO_CEF2GTK_BACKEND="gdk-dmabuf" PUREGO_CEF2GTK_ANGLE_BACKEND="vulkan" GSK_RENDERER="vulkan" \259 PUREGO_CEF2GTK_BACKEND="gdk-dmabuf" PUREGO_CEF2GTK_ANGLE_BACKEND="vulkan" GSK_RENDERER="vulkan" \
212 timeout --signal=TERM --kill-after=5s "${timeout_seconds}s" "$binary" browse about:blank >"$root/process.log" 2>&1260 timeout --signal=TERM --kill-after=5s "${timeout_seconds}s" "$binary" browse about:blank >"$root/process.log" 2>&1
A scripts/generate_build_manifest.py +135 -0
@@ -0,0 +1,135 @@
1+#!/usr/bin/env python3
2+"""Generate the build manifest required by scripts/collect_first_presentation.sh.
3+
4+The collector binds the measured binary to its source and dependency
5+revisions via `go version -m` plus this manifest, and fails closed when
6+attribution is missing or mismatched. Generate the manifest at build time
7+from the same tree that produced the binary:
8+
9+ python3 scripts/generate_build_manifest.py --binary dist/dumber
10+ DUMBER_CEF_DIR=/path/to/cef-runtime scripts/collect_first_presentation.sh
11+
12+Manifest schema (JSON, sorted keys):
13+ {
14+ "binary_sha256": "<hex sha256 of the measured binary>",
15+ "source_revision": "<40-hex git revision of the built source>",
16+ "modules": {
17+ "<module path>": {"version": "<exact version>", "revision": "<40-hex>"},
18+ ...
19+ }
20+ }
21+"""
22+
23+import argparse
24+import hashlib
25+import json
26+import os
27+import re
28+import subprocess
29+import sys
30+
31+UPSTREAM_MODULE = "github.com/bnema/purego-cef2gtk"
32+VERSION_RE = r"(v\d+\.\d+\.\d+)-0\.\d{14}-([0-9a-f]{12})"
33+TAG_RE = r"v\d+\.\d+\.\d+"
34+
35+
36+def run(args, **kwargs):
37+ return subprocess.check_output(args, text=True, **kwargs).strip()
38+
39+
40+def fail(message):
41+ print("generate-build-manifest: {}".format(message), file=sys.stderr)
42+ raise SystemExit(2)
43+
44+
45+def main():
46+ parser = argparse.ArgumentParser(description="Generate a collector build manifest.")
47+ parser.add_argument("--binary", required=True, help="Measured binary to attribute.")
48+ parser.add_argument("--output", default=None, help="Manifest path (default: <binary>.manifest.json).")
49+ parser.add_argument("--source-revision", default=None,
50+ help="40-hex source revision (default: git rev-parse HEAD).")
51+ parser.add_argument("--module", default=UPSTREAM_MODULE, help="Dependency module to bind.")
52+ args = parser.parse_args()
53+
54+ if not os.path.isfile(args.binary):
55+ fail("binary not found")
56+ output = args.output or (args.binary + ".manifest.json")
57+
58+ with open(args.binary, "rb") as candidate:
59+ # Chunked streaming hash: hashlib.file_digest needs 3.11+, this
60+ # stays compatible with older 3.x interpreters.
61+ digest = hashlib.sha256()
62+ for chunk in iter(lambda: candidate.read(65536), b""):
63+ digest.update(chunk)
64+ binary_sha256 = digest.hexdigest()
65+
66+ try:
67+ buildinfo = run(["go", "version", "-m", args.binary], stderr=subprocess.DEVNULL)
68+ except (OSError, subprocess.CalledProcessError):
69+ fail("could not read embedded build info")
70+ if "=>" in buildinfo:
71+ fail("binary was built with a replacement; refusing to attribute it")
72+ dep_version = None
73+ vcs_revision = None
74+ vcs_modified = None
75+ for line in buildinfo.splitlines():
76+ parts = line.split()
77+ if len(parts) >= 3 and parts[0] == "dep" and parts[1] == args.module:
78+ dep_version = parts[2]
79+ # `go version -m` reports stamping as "build vcs.revision=<hex>"
80+ # (two tab-separated fields, key=value form).
81+ elif len(parts) >= 2 and parts[0] == "build" and parts[1].startswith("vcs."):
82+ key, _, value = parts[1].partition("=")
83+ if key == "vcs.revision":
84+ vcs_revision = value
85+ elif key == "vcs.modified":
86+ vcs_modified = value
87+ # The measured binary, not the ambient checkout, is the provenance
88+ # source: build-manifest builds with VCS stamping enabled, so the
89+ # embedded revision describes the exact built tree. Binaries without
90+ # stamping (for example -buildvcs=false quick builds) or built from a
91+ # dirty tree are rejected instead of inheriting the checkout HEAD.
92+ if vcs_revision is None or not re.fullmatch(r"[0-9a-f]{40}", vcs_revision):
93+ fail("binary lacks embedded VCS revision; rebuild with VCS stamping enabled")
94+ if vcs_modified != "false":
95+ fail("binary was built from a dirty checkout; refusing to attribute it")
96+ if dep_version is None:
97+ fail("dependency {} not found in build info".format(args.module))
98+ pseudo = re.fullmatch(VERSION_RE, dep_version)
99+ tag = re.fullmatch(TAG_RE, dep_version)
100+ if not pseudo and not tag:
101+ fail("dependency version is not an immutable tag or pseudo-version")
102+
103+ try:
104+ downloaded = json.loads(run(["go", "mod", "download", "-json", "{}@{}".format(args.module, dep_version)],
105+ stderr=subprocess.DEVNULL))
106+ except (OSError, subprocess.CalledProcessError, ValueError):
107+ fail("could not resolve dependency origin")
108+ origin = downloaded.get("Origin") or {}
109+ revision = origin.get("Hash", "")
110+ if origin.get("VCS") != "git" or not re.fullmatch(r"[0-9a-f]{40}", revision):
111+ fail("dependency origin is not an immutable git hash")
112+ if pseudo and not revision.startswith(pseudo.group(2)):
113+ fail("dependency revision does not match its pseudo-version")
114+
115+ source_revision = args.source_revision or vcs_revision
116+ if not re.fullmatch(r"[0-9a-f]{40}", source_revision or ""):
117+ fail("source revision must be a 40-hex git revision")
118+ if source_revision != vcs_revision:
119+ fail("source revision does not match the revision embedded in the binary")
120+
121+ manifest = {
122+ "binary_sha256": binary_sha256,
123+ "source_revision": source_revision,
124+ "modules": {
125+ args.module: {"version": dep_version, "revision": revision},
126+ },
127+ }
128+ with open(output, "w", encoding="utf-8") as handle:
129+ json.dump(manifest, handle, indent=2, sort_keys=True)
130+ handle.write("\n")
131+ print("manifest: {}".format(output))
132+
133+
134+if __name__ == "__main__":
135+ main()
@@ -0,0 +1,135 @@
1+#!/usr/bin/env python3
2+"""Generate the build manifest required by scripts/collect_first_presentation.sh.
3+
4+The collector binds the measured binary to its source and dependency
5+revisions via `go version -m` plus this manifest, and fails closed when
6+attribution is missing or mismatched. Generate the manifest at build time
7+from the same tree that produced the binary:
8+
9+ python3 scripts/generate_build_manifest.py --binary dist/dumber
10+ DUMBER_CEF_DIR=/path/to/cef-runtime scripts/collect_first_presentation.sh
11+
12+Manifest schema (JSON, sorted keys):
13+ {
14+ "binary_sha256": "<hex sha256 of the measured binary>",
15+ "source_revision": "<40-hex git revision of the built source>",
16+ "modules": {
17+ "<module path>": {"version": "<exact version>", "revision": "<40-hex>"},
18+ ...
19+ }
20+ }
21+"""
22+
23+import argparse
24+import hashlib
25+import json
26+import os
27+import re
28+import subprocess
29+import sys
30+
31+UPSTREAM_MODULE = "github.com/bnema/purego-cef2gtk"
32+VERSION_RE = r"(v\d+\.\d+\.\d+)-0\.\d{14}-([0-9a-f]{12})"
33+TAG_RE = r"v\d+\.\d+\.\d+"
34+
35+
36+def run(args, **kwargs):
37+ return subprocess.check_output(args, text=True, **kwargs).strip()
38+
39+
40+def fail(message):
41+ print("generate-build-manifest: {}".format(message), file=sys.stderr)
42+ raise SystemExit(2)
43+
44+
45+def main():
46+ parser = argparse.ArgumentParser(description="Generate a collector build manifest.")
47+ parser.add_argument("--binary", required=True, help="Measured binary to attribute.")
48+ parser.add_argument("--output", default=None, help="Manifest path (default: <binary>.manifest.json).")
49+ parser.add_argument("--source-revision", default=None,
50+ help="40-hex source revision (default: git rev-parse HEAD).")
51+ parser.add_argument("--module", default=UPSTREAM_MODULE, help="Dependency module to bind.")
52+ args = parser.parse_args()
53+
54+ if not os.path.isfile(args.binary):
55+ fail("binary not found")
56+ output = args.output or (args.binary + ".manifest.json")
57+
58+ with open(args.binary, "rb") as candidate:
59+ # Chunked streaming hash: hashlib.file_digest needs 3.11+, this
60+ # stays compatible with older 3.x interpreters.
61+ digest = hashlib.sha256()
62+ for chunk in iter(lambda: candidate.read(65536), b""):
63+ digest.update(chunk)
64+ binary_sha256 = digest.hexdigest()
65+
66+ try:
67+ buildinfo = run(["go", "version", "-m", args.binary], stderr=subprocess.DEVNULL)
68+ except (OSError, subprocess.CalledProcessError):
69+ fail("could not read embedded build info")
70+ if "=>" in buildinfo:
71+ fail("binary was built with a replacement; refusing to attribute it")
72+ dep_version = None
73+ vcs_revision = None
74+ vcs_modified = None
75+ for line in buildinfo.splitlines():
76+ parts = line.split()
77+ if len(parts) >= 3 and parts[0] == "dep" and parts[1] == args.module:
78+ dep_version = parts[2]
79+ # `go version -m` reports stamping as "build vcs.revision=<hex>"
80+ # (two tab-separated fields, key=value form).
81+ elif len(parts) >= 2 and parts[0] == "build" and parts[1].startswith("vcs."):
82+ key, _, value = parts[1].partition("=")
83+ if key == "vcs.revision":
84+ vcs_revision = value
85+ elif key == "vcs.modified":
86+ vcs_modified = value
87+ # The measured binary, not the ambient checkout, is the provenance
88+ # source: build-manifest builds with VCS stamping enabled, so the
89+ # embedded revision describes the exact built tree. Binaries without
90+ # stamping (for example -buildvcs=false quick builds) or built from a
91+ # dirty tree are rejected instead of inheriting the checkout HEAD.
92+ if vcs_revision is None or not re.fullmatch(r"[0-9a-f]{40}", vcs_revision):
93+ fail("binary lacks embedded VCS revision; rebuild with VCS stamping enabled")
94+ if vcs_modified != "false":
95+ fail("binary was built from a dirty checkout; refusing to attribute it")
96+ if dep_version is None:
97+ fail("dependency {} not found in build info".format(args.module))
98+ pseudo = re.fullmatch(VERSION_RE, dep_version)
99+ tag = re.fullmatch(TAG_RE, dep_version)
100+ if not pseudo and not tag:
101+ fail("dependency version is not an immutable tag or pseudo-version")
102+
103+ try:
104+ downloaded = json.loads(run(["go", "mod", "download", "-json", "{}@{}".format(args.module, dep_version)],
105+ stderr=subprocess.DEVNULL))
106+ except (OSError, subprocess.CalledProcessError, ValueError):
107+ fail("could not resolve dependency origin")
108+ origin = downloaded.get("Origin") or {}
109+ revision = origin.get("Hash", "")
110+ if origin.get("VCS") != "git" or not re.fullmatch(r"[0-9a-f]{40}", revision):
111+ fail("dependency origin is not an immutable git hash")
112+ if pseudo and not revision.startswith(pseudo.group(2)):
113+ fail("dependency revision does not match its pseudo-version")
114+
115+ source_revision = args.source_revision or vcs_revision
116+ if not re.fullmatch(r"[0-9a-f]{40}", source_revision or ""):
117+ fail("source revision must be a 40-hex git revision")
118+ if source_revision != vcs_revision:
119+ fail("source revision does not match the revision embedded in the binary")
120+
121+ manifest = {
122+ "binary_sha256": binary_sha256,
123+ "source_revision": source_revision,
124+ "modules": {
125+ args.module: {"version": dep_version, "revision": revision},
126+ },
127+ }
128+ with open(output, "w", encoding="utf-8") as handle:
129+ json.dump(manifest, handle, indent=2, sort_keys=True)
130+ handle.write("\n")
131+ print("manifest: {}".format(output))
132+
133+
134+if __name__ == "__main__":
135+ main()
A scripts/measure_cef_startup.py +559 -0
@@ -0,0 +1,559 @@
1+#!/usr/bin/env python3
2+"""Local CEF startup harness with synthetic fixtures (stdlib only).
3+
4+Scenarios:
5+ process-warm one unrecorded warm-up, then new processes sharing an
6+ isolated previously initialized CEF root-cache profile
7+ profile-fresh new process, new profile each sample
8+ relay-window a real long-lived owner browser holds the relay; samples
9+ launch browse with DUMBER_BROWSER_FRESH_WINDOW=1 in that
10+ same isolated profile
11+
12+Fixtures (loopback only): static, delayed, redirect, cache.
13+
14+Protocol: fixture pages report PerformanceObserver results through a
15+token-authenticated loopback beacon (/__beacon with run/nav/event/value
16+parameters) served by the harness HTTP server. Beacon arrivals carry the
17+harness monotonic timestamp. External spawn-to-arrival is recorded as an
18+explicitly named observation duration, including output-delivery overhead;
19+it is never presented as an exact child event timestamp.
20+
21+Clock domains are kept separate: child t_ms values are never subtracted from
22+the harness wall clock. GTK after-paint is labeled GTK paint, never compositor
23+presentation. Missing target FCP remains incomplete; the harness never stops
24+on a blank GTK summary.
25+"""
26+
27+import argparse
28+import hashlib
29+import json
30+import os
31+import re
32+import secrets
33+import shutil
34+import socket
35+import subprocess
36+import sys
37+import tempfile
38+import threading
39+import time
40+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
41+from urllib.parse import parse_qs, urlparse
42+
43+TOKEN_RE = re.compile(r"^[0-9a-f]{16}$")
44+NAV_RE = re.compile(r"^[0-9a-f]{16}$")
45+BEACON_EVENTS = ("first-contentful-paint", "largest-contentful-paint", "fixture-load")
46+DEFAULT_POST_NAV_CUTOFF_SECONDS = 5.0
47+
48+# Deterministic 1x1 RGBA PNG so image endpoints exercise a successful decode
49+# and paint instead of the decode-failure path.
50+FIXTURE_PNG = bytes.fromhex(
51+ "89504e470d0a1a0a"
52+ "0000000d49484452000000010000000108060000001f15c489"
53+ "0000000b4944415478da6360000300000700012122db13"
54+ "0000000049454e44ae426082"
55+)
56+
57+
58+def post_nav_cutoff_seconds():
59+ try:
60+ value = float(os.environ.get("DUMBER_MEASURE_CUTOFF_SECONDS", DEFAULT_POST_NAV_CUTOFF_SECONDS))
61+ except ValueError:
62+ return DEFAULT_POST_NAV_CUTOFF_SECONDS
63+ return min(30.0, max(0.1, value))
64+
65+
66+class FixtureState:
67+ def __init__(self):
68+ self.lock = threading.Lock()
69+ self.document_requests = []
70+ self.subresource_requests = []
71+ self.beacons = []
72+ self.first_document_monotonic = None
73+
74+
75+def build_fixture_html(kind, run_token, nav_token):
76+ observer = (
77+ "<script>(function(){"
78+ "function beacon(event, value){"
79+ "fetch('/__beacon?run=%s&nav=%s&event='+event+'&value='+value,"
80+ "{cache:'no-store'}).catch(function(){});"
81+ "}"
82+ "try{"
83+ "var po=new PerformanceObserver(function(list){"
84+ "list.getEntries().forEach(function(en){"
85+ "if(en.name==='first-contentful-paint'){beacon('first-contentful-paint',Math.round(en.startTime));}"
86+ "else if(en.entryType==='largest-contentful-paint'){beacon('largest-contentful-paint',Math.round(en.startTime));}"
87+ "}});"
88+ "po.observe({type:'paint',buffered:true});"
89+ "po.observe({type:'largest-contentful-paint',buffered:true});"
90+ "}catch(e){}"
91+ "window.addEventListener('load',function(){"
92+ "setTimeout(function(){beacon('fixture-load',0);},100);});"
93+ "})();</script>" % (run_token, nav_token)
94+ )
95+ if kind == "static":
96+ return (
97+ "<!doctype html><html><head><title>static</title>"
98+ '<link rel="stylesheet" href="/app.css">'
99+ "</head><body><h1>static</h1>"
100+ '<img src="/pixel.png">'
101+ + observer
102+ + "</body></html>"
103+ )
104+ if kind == "delayed":
105+ return (
106+ "<!doctype html><html><head><title>delayed</title></head>"
107+ "<body><h1>delayed</h1>" + observer + "</body></html>"
108+ )
109+ if kind == "redirect":
110+ return (
111+ "<!doctype html><html><head><title>target</title></head>"
112+ "<body><h1>target</h1>" + observer + "</body></html>"
113+ )
114+ if kind == "cache":
115+ return (
116+ "<!doctype html><html><head><title>cache</title>"
117+ '<script src="/cacheable.js"></script>'
118+ "</head><body><h1>cache</h1>"
119+ '<img src="/cacheable.png">'
120+ + observer
121+ + "</body></html>"
122+ )
123+ raise ValueError("unknown fixture: " + kind)
124+
125+
126+class Handler(BaseHTTPRequestHandler):
127+ state = None
128+ fixture = "static"
129+ run_token = ""
130+ nav_token = ""
131+ delay_seconds = 0.3
132+
133+ def log_message(self, *args):
134+ pass
135+
136+ def _record(self, document):
137+ with self.state.lock:
138+ entry = {"path": self.path, "time": time.monotonic()}
139+ if document:
140+ self.state.document_requests.append(entry)
141+ if self.state.first_document_monotonic is None:
142+ self.state.first_document_monotonic = entry["time"]
143+ else:
144+ self.state.subresource_requests.append(entry)
145+
146+ def do_GET(self):
147+ parsed = urlparse(self.path)
148+ path = parsed.path
149+ if path == "/__beacon":
150+ self._handle_beacon(parsed)
151+ return
152+ if path in ("/", "/index.html", "/target"):
153+ self._record(True)
154+ if self.fixture == "delayed" and path == "/":
155+ time.sleep(self.delay_seconds)
156+ body = build_fixture_html(
157+ "static" if self.fixture == "redirect" and path == "/" else self.fixture,
158+ self.run_token,
159+ self.nav_token,
160+ ).encode()
161+ self.send_response(200)
162+ self.send_header("Content-Type", "text/html")
163+ self.send_header("Content-Length", str(len(body)))
164+ self.end_headers()
165+ self.wfile.write(body)
166+ return
167+ if path == "/redirect":
168+ self._record(True)
169+ target = "/target?run=%s&nav=%s" % (self.run_token, self.nav_token)
170+ self.send_response(302)
171+ self.send_header("Location", target)
172+ self.end_headers()
173+ return
174+ if path in ("/app.css", "/pixel.png", "/cacheable.js", "/cacheable.png"):
175+ self._record(False)
176+ if path.endswith(".png"):
177+ body = FIXTURE_PNG
178+ else:
179+ body = b"/* fixture */"
180+ self.send_response(200)
181+ if path.endswith(".css"):
182+ self.send_header("Content-Type", "text/css")
183+ elif path.endswith(".js"):
184+ self.send_header("Content-Type", "application/javascript")
185+ else:
186+ self.send_header("Content-Type", "image/png")
187+ if self.fixture == "cache":
188+ self.send_header("Cache-Control", "private, max-age=3600")
189+ else:
190+ self.send_header("Cache-Control", "no-store")
191+ self.send_header("Content-Length", str(len(body)))
192+ self.end_headers()
193+ self.wfile.write(body)
194+ return
195+ self._record(False)
196+ self.send_response(404)
197+ self.end_headers()
198+
199+ def _handle_beacon(self, parsed):
200+ query = parse_qs(parsed.query)
201+ run = query.get("run", [None])[0]
202+ nav = query.get("nav", [None])[0]
203+ event = query.get("event", [None])[0]
204+ value_raw = query.get("value", [None])[0]
205+ valid = (
206+ isinstance(run, str) and TOKEN_RE.fullmatch(run) and run == self.run_token
207+ and isinstance(nav, str) and NAV_RE.fullmatch(nav) and nav == self.nav_token
208+ and event in BEACON_EVENTS
209+ )
210+ try:
211+ value = int(value_raw) if valid else None
212+ valid = valid and value is not None and value >= 0
213+ except (TypeError, ValueError):
214+ valid = False
215+ if not valid:
216+ self.send_response(400)
217+ self.end_headers()
218+ return
219+ with self.state.lock:
220+ self.state.beacons.append({
221+ "event": event,
222+ "value_ms": value,
223+ "arrival_monotonic": time.monotonic(),
224+ })
225+ self.send_response(204)
226+ self.end_headers()
227+
228+
229+def parse_child_output(lines):
230+ milestones = []
231+ for line in lines:
232+ try:
233+ event = json.loads(line)
234+ except (json.JSONDecodeError, ValueError):
235+ continue
236+ if isinstance(event, dict) and event.get("message") == "startup_trace: milestone":
237+ milestones.append(event)
238+ return milestones
239+
240+
241+def scenario_env(cef_dir, dirs, extra=None):
242+ env = {
243+ "HOME": os.environ.get("HOME", ""),
244+ "PATH": os.environ.get("PATH", ""),
245+ "LANG": os.environ.get("LANG", "C.UTF-8"),
246+ "DISPLAY": os.environ.get("DISPLAY", ""),
247+ "WAYLAND_DISPLAY": os.environ.get("WAYLAND_DISPLAY", ""),
248+ "XDG_CONFIG_HOME": dirs["config"],
249+ "XDG_DATA_HOME": dirs["data"],
250+ "XDG_STATE_HOME": dirs["state"],
251+ "XDG_CACHE_HOME": dirs["cache"],
252+ "XDG_RUNTIME_DIR": dirs["runtime"],
253+ "CEF_DIR": cef_dir,
254+ "DUMBER_CEF_DIR": cef_dir,
255+ "DUMBER_CEF_ROOT_CACHE_PATH": dirs["root_cache"],
256+ }
257+ if extra:
258+ env.update(extra)
259+ for key in ("config", "data", "state", "cache", "runtime"):
260+ os.makedirs(dirs[key], exist_ok=True)
261+ os.makedirs(dirs["root_cache"], exist_ok=True)
262+ return env
263+
264+
265+def wait_for_relay_socket(runtime_dir, timeout_seconds=30.0):
266+ end = time.monotonic() + timeout_seconds
267+ while time.monotonic() < end:
268+ for root, _, files in os.walk(runtime_dir):
269+ if "browser-launch.sock" in files:
270+ path = os.path.join(root, "browser-launch.sock")
271+ sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
272+ sock.settimeout(2.0)
273+ try:
274+ sock.connect(path)
275+ return path
276+ except OSError:
277+ pass
278+ finally:
279+ sock.close()
280+ time.sleep(0.2)
281+ return None
282+
283+
284+def start_relay_owner(binary, cef_dir, scenario_dir):
285+ dirs = {
286+ "config": os.path.join(scenario_dir, "owner-config"),
287+ "data": os.path.join(scenario_dir, "owner-data"),
288+ "state": os.path.join(scenario_dir, "owner-state"),
289+ "cache": os.path.join(scenario_dir, "owner-cache"),
290+ "runtime": os.path.join(scenario_dir, "owner-runtime"),
291+ "root_cache": os.path.join(scenario_dir, "shared-profile"),
292+ }
293+ env = scenario_env(cef_dir, dirs)
294+ try:
295+ proc = subprocess.Popen(
296+ [binary, "browse", "about:blank"],
297+ stdout=subprocess.DEVNULL,
298+ stderr=subprocess.DEVNULL,
299+ env=env,
300+ )
301+ except OSError:
302+ return None, None
303+ time.sleep(0.5)
304+ if proc.poll() is not None:
305+ return None, None
306+ if wait_for_relay_socket(dirs["runtime"]) is None:
307+ proc.terminate()
308+ try:
309+ proc.wait(timeout=5)
310+ except subprocess.TimeoutExpired:
311+ proc.kill()
312+ return None, None
313+ return proc, dirs
314+
315+
316+def stop_proc(proc):
317+ if proc is None or proc.poll() is not None:
318+ return
319+ proc.terminate()
320+ try:
321+ proc.wait(timeout=5)
322+ except subprocess.TimeoutExpired:
323+ proc.kill()
324+ proc.wait(timeout=5)
325+
326+
327+def run_sample(binary, cef_dir, scenario, fixture, run_index, owner=None, owner_dirs=None,
328+ shared_root_cache=None):
329+ run_token = secrets.token_hex(8)
330+ nav_token = secrets.token_hex(8)
331+ cutoff = post_nav_cutoff_seconds()
332+ state = FixtureState()
333+ Handler.state = state
334+ Handler.fixture = fixture
335+ Handler.run_token = run_token
336+ Handler.nav_token = nav_token
337+ server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
338+ port = server.server_address[1]
339+ thread = threading.Thread(target=server.serve_forever, kwargs={"poll_interval": 0.05})
340+ thread.daemon = True
341+ thread.start()
342+ run_dir = tempfile.mkdtemp(prefix="cef-startup-run-%02d-" % run_index)
343+ try:
344+ if fixture == "redirect":
345+ url = "http://127.0.0.1:%d/redirect?run=%s&nav=%s" % (port, run_token, nav_token)
346+ else:
347+ url = "http://127.0.0.1:%d/?run=%s&nav=%s" % (port, run_token, nav_token)
348+ if scenario == "relay-window" and owner_dirs is not None:
349+ dirs = dict(owner_dirs)
350+ extra = {"DUMBER_BROWSER_FRESH_WINDOW": "1"}
351+ else:
352+ dirs = {
353+ "config": os.path.join(run_dir, "config"),
354+ "data": os.path.join(run_dir, "data"),
355+ "state": os.path.join(run_dir, "state"),
356+ "cache": os.path.join(run_dir, "cache"),
357+ "runtime": os.path.join(run_dir, "runtime"),
358+ "root_cache": shared_root_cache or os.path.join(run_dir, "cef-root-cache"),
359+ }
360+ extra = {}
361+ env = scenario_env(cef_dir, dirs, extra)
362+ owner_alive_at_start = owner is not None and owner.poll() is None
363+ spawn_ts = time.monotonic()
364+ proc = subprocess.Popen(
365+ [binary, "browse", url],
366+ stdout=subprocess.PIPE,
367+ stderr=subprocess.STDOUT,
368+ text=True,
369+ bufsize=1,
370+ env=env,
371+ )
372+ arrivals = []
373+
374+ def pump():
375+ try:
376+ for line in proc.stdout:
377+ arrivals.append((time.monotonic(), line))
378+ except ValueError:
379+ pass
380+
381+ pump_thread = threading.Thread(target=pump)
382+ pump_thread.daemon = True
383+ pump_thread.start()
384+ hard_end = spawn_ts + cutoff + 25.0
385+ while True:
386+ now = time.monotonic()
387+ with state.lock:
388+ navigated_at = state.first_document_monotonic
389+ fixture_done = any(
390+ b["event"] == "fixture-load" for b in state.beacons
391+ )
392+ exited = proc.poll() is not None
393+ if navigated_at is not None:
394+ if fixture_done and now >= navigated_at + cutoff:
395+ break
396+ if now >= navigated_at + cutoff + 5.0:
397+ break
398+ elif now >= spawn_ts + cutoff + 5.0 or exited:
399+ break
400+ if now >= hard_end:
401+ break
402+ time.sleep(0.05)
403+ self_exited = proc.poll() is not None
404+ if proc.poll() is None:
405+ proc.terminate()
406+ try:
407+ proc.wait(timeout=5)
408+ except subprocess.TimeoutExpired:
409+ proc.kill()
410+ proc.wait(timeout=5)
411+ pump_thread.join(timeout=5)
412+ try:
413+ proc.stdout.close()
414+ except (AttributeError, ValueError):
415+ pass
416+ decision_ts = time.monotonic()
417+ milestones = parse_child_output([line for _, line in arrivals])
418+ with state.lock:
419+ beacons = list(state.beacons)
420+ document_count = len(state.document_requests)
421+ subresource_count = len(state.subresource_requests)
422+ fcp = [b for b in beacons if b["event"] == "first-contentful-paint"]
423+ lcp = [b for b in beacons if b["event"] == "largest-contentful-paint"]
424+ if fcp:
425+ observation_ms = int((fcp[0]["arrival_monotonic"] - spawn_ts) * 1000)
426+ elif beacons:
427+ observation_ms = int((beacons[-1]["arrival_monotonic"] - spawn_ts) * 1000)
428+ else:
429+ observation_ms = int((decision_ts - spawn_ts) * 1000)
430+ expected_document_requests = 2 if fixture == "redirect" else 1
431+ complete = document_count == expected_document_requests and len(fcp) > 0
432+ if scenario == "relay-window":
433+ relayed = bool(owner_alive_at_start and self_exited)
434+ else:
435+ relayed = False
436+ return {
437+ "run": run_index,
438+ "scenario": scenario,
439+ "fixture": fixture,
440+ "run_token": run_token,
441+ "nav_token": nav_token,
442+ "observation_spawn_to_summary_ms": observation_ms,
443+ "document_requests": document_count,
444+ "subresource_requests": subresource_count,
445+ "target_fcp_ms": fcp[0]["value_ms"] if fcp else None,
446+ "target_lcp_through_cutoff_ms": lcp[-1]["value_ms"] if lcp else None,
447+ "startup_milestones": len(milestones),
448+ "complete": complete,
449+ "relayed": relayed,
450+ "fallback_spawn": scenario == "relay-window" and not relayed,
451+ }
452+ finally:
453+ server.shutdown()
454+ server.server_close()
455+ thread.join(timeout=5)
456+ shutil.rmtree(run_dir, ignore_errors=True)
457+
458+
459+def main():
460+ parser = argparse.ArgumentParser(description="Measure CEF startup against synthetic fixtures.")
461+ parser.add_argument("--binary", required=True)
462+ parser.add_argument("--cef-dir", required=True)
463+ parser.add_argument(
464+ "--scenario",
465+ required=True,
466+ choices=("process-warm", "profile-fresh", "relay-window"),
467+ )
468+ parser.add_argument(
469+ "--fixture",
470+ required=True,
471+ choices=("static", "delayed", "redirect", "cache"),
472+ )
473+ parser.add_argument("--runs", type=int, default=30)
474+ parser.add_argument("--output", required=True)
475+ args = parser.parse_args()
476+
477+ if args.runs < 1 or args.runs > 100:
478+ print("measure: --runs must be 1..100", file=sys.stderr)
479+ raise SystemExit(2)
480+ if not os.path.isabs(args.output) or os.path.exists(args.output) or os.path.islink(args.output):
481+ print("measure: --output must be a fresh absolute directory", file=sys.stderr)
482+ raise SystemExit(2)
483+ parent = os.path.dirname(args.output)
484+ if not os.path.isdir(parent) or os.path.islink(parent):
485+ print("measure: --output parent must be an existing directory", file=sys.stderr)
486+ raise SystemExit(2)
487+ if not (os.path.isfile(args.binary) and os.access(args.binary, os.X_OK)):
488+ print("measure: --binary must be executable", file=sys.stderr)
489+ raise SystemExit(2)
490+ if not os.path.isdir(args.cef_dir):
491+ print("measure: --cef-dir must be a directory", file=sys.stderr)
492+ raise SystemExit(2)
493+ with open(args.binary, "rb") as candidate:
494+ # Chunked streaming hash: hashlib.file_digest needs 3.11+, this
495+ # stays compatible with older 3.x interpreters.
496+ digest = hashlib.sha256()
497+ for chunk in iter(lambda: candidate.read(65536), b""):
498+ digest.update(chunk)
499+ binary_sha256 = digest.hexdigest()
500+
501+ os.makedirs(args.output)
502+ owner = None
503+ owner_dirs = None
504+ shared_root_cache = None
505+ warmup_discarded = False
506+ if args.scenario == "process-warm":
507+ shared_root_cache = os.path.join(args.output, "warm-profile", "cef-root-cache")
508+ os.makedirs(shared_root_cache)
509+ if args.scenario == "relay-window":
510+ scenario_dir = os.path.join(args.output, "relay-scenario")
511+ os.makedirs(scenario_dir)
512+ owner, owner_dirs = start_relay_owner(args.binary, args.cef_dir, scenario_dir)
513+
514+ results = []
515+ try:
516+ if args.scenario == "process-warm":
517+ # One unrecorded warm-up initializes the shared profile; only the
518+ # recorded samples below count toward the report. A failed
519+ # warm-up aborts the scenario: later runs must never use an
520+ # uninitialized profile.
521+ warmup = run_sample(args.binary, args.cef_dir, args.scenario, args.fixture,
522+ 0, shared_root_cache=shared_root_cache)
523+ if not warmup["complete"]:
524+ print("measure: process-warm warm-up was incomplete", file=sys.stderr)
525+ raise SystemExit(1)
526+ warmup_discarded = True
527+ for index in range(1, args.runs + 1):
528+ result = run_sample(
529+ args.binary, args.cef_dir, args.scenario, args.fixture, index,
530+ owner=owner, owner_dirs=owner_dirs, shared_root_cache=shared_root_cache,
531+ )
532+ results.append(result)
533+ with open(os.path.join(args.output, "run-%02d.json" % index), "w") as handle:
534+ json.dump(result, handle, indent=2, sort_keys=True)
535+ finally:
536+ stop_proc(owner)
537+
538+ summary = {
539+ "binary_sha256": binary_sha256,
540+ "scenario": args.scenario,
541+ "fixture": args.fixture,
542+ "runs": len(results),
543+ "complete_runs": sum(1 for r in results if r["complete"]),
544+ "incomplete_runs": sum(1 for r in results if not r["complete"]),
545+ "duplicate_document_runs": sum(
546+ 1
547+ for r in results
548+ if r["document_requests"] != (2 if args.fixture == "redirect" else 1)
549+ ),
550+ "warmup_discarded": warmup_discarded,
551+ "relay_owner_ready": owner is not None,
552+ }
553+ with open(os.path.join(args.output, "summary.json"), "w") as handle:
554+ json.dump(summary, handle, indent=2, sort_keys=True)
555+ print("measure artifacts: %s" % args.output)
556+
557+
558+if __name__ == "__main__":
559+ main()
@@ -0,0 +1,559 @@
1+#!/usr/bin/env python3
2+"""Local CEF startup harness with synthetic fixtures (stdlib only).
3+
4+Scenarios:
5+ process-warm one unrecorded warm-up, then new processes sharing an
6+ isolated previously initialized CEF root-cache profile
7+ profile-fresh new process, new profile each sample
8+ relay-window a real long-lived owner browser holds the relay; samples
9+ launch browse with DUMBER_BROWSER_FRESH_WINDOW=1 in that
10+ same isolated profile
11+
12+Fixtures (loopback only): static, delayed, redirect, cache.
13+
14+Protocol: fixture pages report PerformanceObserver results through a
15+token-authenticated loopback beacon (/__beacon with run/nav/event/value
16+parameters) served by the harness HTTP server. Beacon arrivals carry the
17+harness monotonic timestamp. External spawn-to-arrival is recorded as an
18+explicitly named observation duration, including output-delivery overhead;
19+it is never presented as an exact child event timestamp.
20+
21+Clock domains are kept separate: child t_ms values are never subtracted from
22+the harness wall clock. GTK after-paint is labeled GTK paint, never compositor
23+presentation. Missing target FCP remains incomplete; the harness never stops
24+on a blank GTK summary.
25+"""
26+
27+import argparse
28+import hashlib
29+import json
30+import os
31+import re
32+import secrets
33+import shutil
34+import socket
35+import subprocess
36+import sys
37+import tempfile
38+import threading
39+import time
40+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
41+from urllib.parse import parse_qs, urlparse
42+
43+TOKEN_RE = re.compile(r"^[0-9a-f]{16}$")
44+NAV_RE = re.compile(r"^[0-9a-f]{16}$")
45+BEACON_EVENTS = ("first-contentful-paint", "largest-contentful-paint", "fixture-load")
46+DEFAULT_POST_NAV_CUTOFF_SECONDS = 5.0
47+
48+# Deterministic 1x1 RGBA PNG so image endpoints exercise a successful decode
49+# and paint instead of the decode-failure path.
50+FIXTURE_PNG = bytes.fromhex(
51+ "89504e470d0a1a0a"
52+ "0000000d49484452000000010000000108060000001f15c489"
53+ "0000000b4944415478da6360000300000700012122db13"
54+ "0000000049454e44ae426082"
55+)
56+
57+
58+def post_nav_cutoff_seconds():
59+ try:
60+ value = float(os.environ.get("DUMBER_MEASURE_CUTOFF_SECONDS", DEFAULT_POST_NAV_CUTOFF_SECONDS))
61+ except ValueError:
62+ return DEFAULT_POST_NAV_CUTOFF_SECONDS
63+ return min(30.0, max(0.1, value))
64+
65+
66+class FixtureState:
67+ def __init__(self):
68+ self.lock = threading.Lock()
69+ self.document_requests = []
70+ self.subresource_requests = []
71+ self.beacons = []
72+ self.first_document_monotonic = None
73+
74+
75+def build_fixture_html(kind, run_token, nav_token):
76+ observer = (
77+ "<script>(function(){"
78+ "function beacon(event, value){"
79+ "fetch('/__beacon?run=%s&nav=%s&event='+event+'&value='+value,"
80+ "{cache:'no-store'}).catch(function(){});"
81+ "}"
82+ "try{"
83+ "var po=new PerformanceObserver(function(list){"
84+ "list.getEntries().forEach(function(en){"
85+ "if(en.name==='first-contentful-paint'){beacon('first-contentful-paint',Math.round(en.startTime));}"
86+ "else if(en.entryType==='largest-contentful-paint'){beacon('largest-contentful-paint',Math.round(en.startTime));}"
87+ "}});"
88+ "po.observe({type:'paint',buffered:true});"
89+ "po.observe({type:'largest-contentful-paint',buffered:true});"
90+ "}catch(e){}"
91+ "window.addEventListener('load',function(){"
92+ "setTimeout(function(){beacon('fixture-load',0);},100);});"
93+ "})();</script>" % (run_token, nav_token)
94+ )
95+ if kind == "static":
96+ return (
97+ "<!doctype html><html><head><title>static</title>"
98+ '<link rel="stylesheet" href="/app.css">'
99+ "</head><body><h1>static</h1>"
100+ '<img src="/pixel.png">'
101+ + observer
102+ + "</body></html>"
103+ )
104+ if kind == "delayed":
105+ return (
106+ "<!doctype html><html><head><title>delayed</title></head>"
107+ "<body><h1>delayed</h1>" + observer + "</body></html>"
108+ )
109+ if kind == "redirect":
110+ return (
111+ "<!doctype html><html><head><title>target</title></head>"
112+ "<body><h1>target</h1>" + observer + "</body></html>"
113+ )
114+ if kind == "cache":
115+ return (
116+ "<!doctype html><html><head><title>cache</title>"
117+ '<script src="/cacheable.js"></script>'
118+ "</head><body><h1>cache</h1>"
119+ '<img src="/cacheable.png">'
120+ + observer
121+ + "</body></html>"
122+ )
123+ raise ValueError("unknown fixture: " + kind)
124+
125+
126+class Handler(BaseHTTPRequestHandler):
127+ state = None
128+ fixture = "static"
129+ run_token = ""
130+ nav_token = ""
131+ delay_seconds = 0.3
132+
133+ def log_message(self, *args):
134+ pass
135+
136+ def _record(self, document):
137+ with self.state.lock:
138+ entry = {"path": self.path, "time": time.monotonic()}
139+ if document:
140+ self.state.document_requests.append(entry)
141+ if self.state.first_document_monotonic is None:
142+ self.state.first_document_monotonic = entry["time"]
143+ else:
144+ self.state.subresource_requests.append(entry)
145+
146+ def do_GET(self):
147+ parsed = urlparse(self.path)
148+ path = parsed.path
149+ if path == "/__beacon":
150+ self._handle_beacon(parsed)
151+ return
152+ if path in ("/", "/index.html", "/target"):
153+ self._record(True)
154+ if self.fixture == "delayed" and path == "/":
155+ time.sleep(self.delay_seconds)
156+ body = build_fixture_html(
157+ "static" if self.fixture == "redirect" and path == "/" else self.fixture,
158+ self.run_token,
159+ self.nav_token,
160+ ).encode()
161+ self.send_response(200)
162+ self.send_header("Content-Type", "text/html")
163+ self.send_header("Content-Length", str(len(body)))
164+ self.end_headers()
165+ self.wfile.write(body)
166+ return
167+ if path == "/redirect":
168+ self._record(True)
169+ target = "/target?run=%s&nav=%s" % (self.run_token, self.nav_token)
170+ self.send_response(302)
171+ self.send_header("Location", target)
172+ self.end_headers()
173+ return
174+ if path in ("/app.css", "/pixel.png", "/cacheable.js", "/cacheable.png"):
175+ self._record(False)
176+ if path.endswith(".png"):
177+ body = FIXTURE_PNG
178+ else:
179+ body = b"/* fixture */"
180+ self.send_response(200)
181+ if path.endswith(".css"):
182+ self.send_header("Content-Type", "text/css")
183+ elif path.endswith(".js"):
184+ self.send_header("Content-Type", "application/javascript")
185+ else:
186+ self.send_header("Content-Type", "image/png")
187+ if self.fixture == "cache":
188+ self.send_header("Cache-Control", "private, max-age=3600")
189+ else:
190+ self.send_header("Cache-Control", "no-store")
191+ self.send_header("Content-Length", str(len(body)))
192+ self.end_headers()
193+ self.wfile.write(body)
194+ return
195+ self._record(False)
196+ self.send_response(404)
197+ self.end_headers()
198+
199+ def _handle_beacon(self, parsed):
200+ query = parse_qs(parsed.query)
201+ run = query.get("run", [None])[0]
202+ nav = query.get("nav", [None])[0]
203+ event = query.get("event", [None])[0]
204+ value_raw = query.get("value", [None])[0]
205+ valid = (
206+ isinstance(run, str) and TOKEN_RE.fullmatch(run) and run == self.run_token
207+ and isinstance(nav, str) and NAV_RE.fullmatch(nav) and nav == self.nav_token
208+ and event in BEACON_EVENTS
209+ )
210+ try:
211+ value = int(value_raw) if valid else None
212+ valid = valid and value is not None and value >= 0
213+ except (TypeError, ValueError):
214+ valid = False
215+ if not valid:
216+ self.send_response(400)
217+ self.end_headers()
218+ return
219+ with self.state.lock:
220+ self.state.beacons.append({
221+ "event": event,
222+ "value_ms": value,
223+ "arrival_monotonic": time.monotonic(),
224+ })
225+ self.send_response(204)
226+ self.end_headers()
227+
228+
229+def parse_child_output(lines):
230+ milestones = []
231+ for line in lines:
232+ try:
233+ event = json.loads(line)
234+ except (json.JSONDecodeError, ValueError):
235+ continue
236+ if isinstance(event, dict) and event.get("message") == "startup_trace: milestone":
237+ milestones.append(event)
238+ return milestones
239+
240+
241+def scenario_env(cef_dir, dirs, extra=None):
242+ env = {
243+ "HOME": os.environ.get("HOME", ""),
244+ "PATH": os.environ.get("PATH", ""),
245+ "LANG": os.environ.get("LANG", "C.UTF-8"),
246+ "DISPLAY": os.environ.get("DISPLAY", ""),
247+ "WAYLAND_DISPLAY": os.environ.get("WAYLAND_DISPLAY", ""),
248+ "XDG_CONFIG_HOME": dirs["config"],
249+ "XDG_DATA_HOME": dirs["data"],
250+ "XDG_STATE_HOME": dirs["state"],
251+ "XDG_CACHE_HOME": dirs["cache"],
252+ "XDG_RUNTIME_DIR": dirs["runtime"],
253+ "CEF_DIR": cef_dir,
254+ "DUMBER_CEF_DIR": cef_dir,
255+ "DUMBER_CEF_ROOT_CACHE_PATH": dirs["root_cache"],
256+ }
257+ if extra:
258+ env.update(extra)
259+ for key in ("config", "data", "state", "cache", "runtime"):
260+ os.makedirs(dirs[key], exist_ok=True)
261+ os.makedirs(dirs["root_cache"], exist_ok=True)
262+ return env
263+
264+
265+def wait_for_relay_socket(runtime_dir, timeout_seconds=30.0):
266+ end = time.monotonic() + timeout_seconds
267+ while time.monotonic() < end:
268+ for root, _, files in os.walk(runtime_dir):
269+ if "browser-launch.sock" in files:
270+ path = os.path.join(root, "browser-launch.sock")
271+ sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
272+ sock.settimeout(2.0)
273+ try:
274+ sock.connect(path)
275+ return path
276+ except OSError:
277+ pass
278+ finally:
279+ sock.close()
280+ time.sleep(0.2)
281+ return None
282+
283+
284+def start_relay_owner(binary, cef_dir, scenario_dir):
285+ dirs = {
286+ "config": os.path.join(scenario_dir, "owner-config"),
287+ "data": os.path.join(scenario_dir, "owner-data"),
288+ "state": os.path.join(scenario_dir, "owner-state"),
289+ "cache": os.path.join(scenario_dir, "owner-cache"),
290+ "runtime": os.path.join(scenario_dir, "owner-runtime"),
291+ "root_cache": os.path.join(scenario_dir, "shared-profile"),
292+ }
293+ env = scenario_env(cef_dir, dirs)
294+ try:
295+ proc = subprocess.Popen(
296+ [binary, "browse", "about:blank"],
297+ stdout=subprocess.DEVNULL,
298+ stderr=subprocess.DEVNULL,
299+ env=env,
300+ )
301+ except OSError:
302+ return None, None
303+ time.sleep(0.5)
304+ if proc.poll() is not None:
305+ return None, None
306+ if wait_for_relay_socket(dirs["runtime"]) is None:
307+ proc.terminate()
308+ try:
309+ proc.wait(timeout=5)
310+ except subprocess.TimeoutExpired:
311+ proc.kill()
312+ return None, None
313+ return proc, dirs
314+
315+
316+def stop_proc(proc):
317+ if proc is None or proc.poll() is not None:
318+ return
319+ proc.terminate()
320+ try:
321+ proc.wait(timeout=5)
322+ except subprocess.TimeoutExpired:
323+ proc.kill()
324+ proc.wait(timeout=5)
325+
326+
327+def run_sample(binary, cef_dir, scenario, fixture, run_index, owner=None, owner_dirs=None,
328+ shared_root_cache=None):
329+ run_token = secrets.token_hex(8)
330+ nav_token = secrets.token_hex(8)
331+ cutoff = post_nav_cutoff_seconds()
332+ state = FixtureState()
333+ Handler.state = state
334+ Handler.fixture = fixture
335+ Handler.run_token = run_token
336+ Handler.nav_token = nav_token
337+ server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
338+ port = server.server_address[1]
339+ thread = threading.Thread(target=server.serve_forever, kwargs={"poll_interval": 0.05})
340+ thread.daemon = True
341+ thread.start()
342+ run_dir = tempfile.mkdtemp(prefix="cef-startup-run-%02d-" % run_index)
343+ try:
344+ if fixture == "redirect":
345+ url = "http://127.0.0.1:%d/redirect?run=%s&nav=%s" % (port, run_token, nav_token)
346+ else:
347+ url = "http://127.0.0.1:%d/?run=%s&nav=%s" % (port, run_token, nav_token)
348+ if scenario == "relay-window" and owner_dirs is not None:
349+ dirs = dict(owner_dirs)
350+ extra = {"DUMBER_BROWSER_FRESH_WINDOW": "1"}
351+ else:
352+ dirs = {
353+ "config": os.path.join(run_dir, "config"),
354+ "data": os.path.join(run_dir, "data"),
355+ "state": os.path.join(run_dir, "state"),
356+ "cache": os.path.join(run_dir, "cache"),
357+ "runtime": os.path.join(run_dir, "runtime"),
358+ "root_cache": shared_root_cache or os.path.join(run_dir, "cef-root-cache"),
359+ }
360+ extra = {}
361+ env = scenario_env(cef_dir, dirs, extra)
362+ owner_alive_at_start = owner is not None and owner.poll() is None
363+ spawn_ts = time.monotonic()
364+ proc = subprocess.Popen(
365+ [binary, "browse", url],
366+ stdout=subprocess.PIPE,
367+ stderr=subprocess.STDOUT,
368+ text=True,
369+ bufsize=1,
370+ env=env,
371+ )
372+ arrivals = []
373+
374+ def pump():
375+ try:
376+ for line in proc.stdout:
377+ arrivals.append((time.monotonic(), line))
378+ except ValueError:
379+ pass
380+
381+ pump_thread = threading.Thread(target=pump)
382+ pump_thread.daemon = True
383+ pump_thread.start()
384+ hard_end = spawn_ts + cutoff + 25.0
385+ while True:
386+ now = time.monotonic()
387+ with state.lock:
388+ navigated_at = state.first_document_monotonic
389+ fixture_done = any(
390+ b["event"] == "fixture-load" for b in state.beacons
391+ )
392+ exited = proc.poll() is not None
393+ if navigated_at is not None:
394+ if fixture_done and now >= navigated_at + cutoff:
395+ break
396+ if now >= navigated_at + cutoff + 5.0:
397+ break
398+ elif now >= spawn_ts + cutoff + 5.0 or exited:
399+ break
400+ if now >= hard_end:
401+ break
402+ time.sleep(0.05)
403+ self_exited = proc.poll() is not None
404+ if proc.poll() is None:
405+ proc.terminate()
406+ try:
407+ proc.wait(timeout=5)
408+ except subprocess.TimeoutExpired:
409+ proc.kill()
410+ proc.wait(timeout=5)
411+ pump_thread.join(timeout=5)
412+ try:
413+ proc.stdout.close()
414+ except (AttributeError, ValueError):
415+ pass
416+ decision_ts = time.monotonic()
417+ milestones = parse_child_output([line for _, line in arrivals])
418+ with state.lock:
419+ beacons = list(state.beacons)
420+ document_count = len(state.document_requests)
421+ subresource_count = len(state.subresource_requests)
422+ fcp = [b for b in beacons if b["event"] == "first-contentful-paint"]
423+ lcp = [b for b in beacons if b["event"] == "largest-contentful-paint"]
424+ if fcp:
425+ observation_ms = int((fcp[0]["arrival_monotonic"] - spawn_ts) * 1000)
426+ elif beacons:
427+ observation_ms = int((beacons[-1]["arrival_monotonic"] - spawn_ts) * 1000)
428+ else:
429+ observation_ms = int((decision_ts - spawn_ts) * 1000)
430+ expected_document_requests = 2 if fixture == "redirect" else 1
431+ complete = document_count == expected_document_requests and len(fcp) > 0
432+ if scenario == "relay-window":
433+ relayed = bool(owner_alive_at_start and self_exited)
434+ else:
435+ relayed = False
436+ return {
437+ "run": run_index,
438+ "scenario": scenario,
439+ "fixture": fixture,
440+ "run_token": run_token,
441+ "nav_token": nav_token,
442+ "observation_spawn_to_summary_ms": observation_ms,
443+ "document_requests": document_count,
444+ "subresource_requests": subresource_count,
445+ "target_fcp_ms": fcp[0]["value_ms"] if fcp else None,
446+ "target_lcp_through_cutoff_ms": lcp[-1]["value_ms"] if lcp else None,
447+ "startup_milestones": len(milestones),
448+ "complete": complete,
449+ "relayed": relayed,
450+ "fallback_spawn": scenario == "relay-window" and not relayed,
451+ }
452+ finally:
453+ server.shutdown()
454+ server.server_close()
455+ thread.join(timeout=5)
456+ shutil.rmtree(run_dir, ignore_errors=True)
457+
458+
459+def main():
460+ parser = argparse.ArgumentParser(description="Measure CEF startup against synthetic fixtures.")
461+ parser.add_argument("--binary", required=True)
462+ parser.add_argument("--cef-dir", required=True)
463+ parser.add_argument(
464+ "--scenario",
465+ required=True,
466+ choices=("process-warm", "profile-fresh", "relay-window"),
467+ )
468+ parser.add_argument(
469+ "--fixture",
470+ required=True,
471+ choices=("static", "delayed", "redirect", "cache"),
472+ )
473+ parser.add_argument("--runs", type=int, default=30)
474+ parser.add_argument("--output", required=True)
475+ args = parser.parse_args()
476+
477+ if args.runs < 1 or args.runs > 100:
478+ print("measure: --runs must be 1..100", file=sys.stderr)
479+ raise SystemExit(2)
480+ if not os.path.isabs(args.output) or os.path.exists(args.output) or os.path.islink(args.output):
481+ print("measure: --output must be a fresh absolute directory", file=sys.stderr)
482+ raise SystemExit(2)
483+ parent = os.path.dirname(args.output)
484+ if not os.path.isdir(parent) or os.path.islink(parent):
485+ print("measure: --output parent must be an existing directory", file=sys.stderr)
486+ raise SystemExit(2)
487+ if not (os.path.isfile(args.binary) and os.access(args.binary, os.X_OK)):
488+ print("measure: --binary must be executable", file=sys.stderr)
489+ raise SystemExit(2)
490+ if not os.path.isdir(args.cef_dir):
491+ print("measure: --cef-dir must be a directory", file=sys.stderr)
492+ raise SystemExit(2)
493+ with open(args.binary, "rb") as candidate:
494+ # Chunked streaming hash: hashlib.file_digest needs 3.11+, this
495+ # stays compatible with older 3.x interpreters.
496+ digest = hashlib.sha256()
497+ for chunk in iter(lambda: candidate.read(65536), b""):
498+ digest.update(chunk)
499+ binary_sha256 = digest.hexdigest()
500+
501+ os.makedirs(args.output)
502+ owner = None
503+ owner_dirs = None
504+ shared_root_cache = None
505+ warmup_discarded = False
506+ if args.scenario == "process-warm":
507+ shared_root_cache = os.path.join(args.output, "warm-profile", "cef-root-cache")
508+ os.makedirs(shared_root_cache)
509+ if args.scenario == "relay-window":
510+ scenario_dir = os.path.join(args.output, "relay-scenario")
511+ os.makedirs(scenario_dir)
512+ owner, owner_dirs = start_relay_owner(args.binary, args.cef_dir, scenario_dir)
513+
514+ results = []
515+ try:
516+ if args.scenario == "process-warm":
517+ # One unrecorded warm-up initializes the shared profile; only the
518+ # recorded samples below count toward the report. A failed
519+ # warm-up aborts the scenario: later runs must never use an
520+ # uninitialized profile.
521+ warmup = run_sample(args.binary, args.cef_dir, args.scenario, args.fixture,
522+ 0, shared_root_cache=shared_root_cache)
523+ if not warmup["complete"]:
524+ print("measure: process-warm warm-up was incomplete", file=sys.stderr)
525+ raise SystemExit(1)
526+ warmup_discarded = True
527+ for index in range(1, args.runs + 1):
528+ result = run_sample(
529+ args.binary, args.cef_dir, args.scenario, args.fixture, index,
530+ owner=owner, owner_dirs=owner_dirs, shared_root_cache=shared_root_cache,
531+ )
532+ results.append(result)
533+ with open(os.path.join(args.output, "run-%02d.json" % index), "w") as handle:
534+ json.dump(result, handle, indent=2, sort_keys=True)
535+ finally:
536+ stop_proc(owner)
537+
538+ summary = {
539+ "binary_sha256": binary_sha256,
540+ "scenario": args.scenario,
541+ "fixture": args.fixture,
542+ "runs": len(results),
543+ "complete_runs": sum(1 for r in results if r["complete"]),
544+ "incomplete_runs": sum(1 for r in results if not r["complete"]),
545+ "duplicate_document_runs": sum(
546+ 1
547+ for r in results
548+ if r["document_requests"] != (2 if args.fixture == "redirect" else 1)
549+ ),
550+ "warmup_discarded": warmup_discarded,
551+ "relay_owner_ready": owner is not None,
552+ }
553+ with open(os.path.join(args.output, "summary.json"), "w") as handle:
554+ json.dump(summary, handle, indent=2, sort_keys=True)
555+ print("measure artifacts: %s" % args.output)
556+
557+
558+if __name__ == "__main__":
559+ main()
A scripts/test_measure_cef_startup.py +215 -0
@@ -0,0 +1,215 @@
1+#!/usr/bin/env python3
2+"""Unit tests for measure_cef_startup.py (stdlib only).
3+
4+Run headless: python3 -m unittest discover -s scripts -p 'test_measure_cef_startup.py'
5+Set DUMBER_MEASURE_CUTOFF_SECONDS=0.2 for fast streaming tests (default 5.0).
6+"""
7+
8+import os
9+import subprocess
10+import sys
11+import tempfile
12+import threading
13+import unittest
14+import urllib.error
15+import urllib.request
16+from http.server import ThreadingHTTPServer
17+
18+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
19+
20+import measure_cef_startup as harness
21+
22+
23+class BeaconTest(unittest.TestCase):
24+ def setUp(self):
25+ self.state = harness.FixtureState()
26+ harness.Handler.state = self.state
27+ harness.Handler.fixture = "static"
28+ harness.Handler.run_token = "a" * 16
29+ harness.Handler.nav_token = "b" * 16
30+ self.server = ThreadingHTTPServer(("127.0.0.1", 0), harness.Handler)
31+ self.port = self.server.server_address[1]
32+ self.thread = threading.Thread(target=self.server.serve_forever, kwargs={"poll_interval": 0.05})
33+ self.thread.daemon = True
34+ self.thread.start()
35+
36+ def tearDown(self):
37+ self.server.shutdown()
38+ self.server.server_close()
39+ self.thread.join(timeout=5)
40+
41+ def get(self, query):
42+ url = "http://127.0.0.1:%d/__beacon?%s" % (self.port, query)
43+ try:
44+ with urllib.request.urlopen(url, timeout=5) as response:
45+ return response.status
46+ except urllib.error.HTTPError as exc:
47+ exc.read()
48+ return exc.code
49+
50+ def test_accepts_valid_beacon(self):
51+ status = self.get("run=%s&nav=%s&event=%s&value=12" % ("a" * 16, "b" * 16, "first-contentful-paint"))
52+ self.assertEqual(status, 204)
53+ self.assertEqual(len(self.state.beacons), 1)
54+ self.assertEqual(self.state.beacons[0]["value_ms"], 12)
55+
56+ def test_rejects_wrong_token(self):
57+ status = self.get("run=%s&nav=%s&event=%s&value=12" % ("c" * 16, "d" * 16, "first-contentful-paint"))
58+ self.assertEqual(status, 400)
59+ self.assertEqual(self.state.beacons, [])
60+
61+ def test_rejects_malformed_token(self):
62+ status = self.get("run=short&nav=%s&event=%s&value=12" % ("b" * 16, "first-contentful-paint"))
63+ self.assertEqual(status, 400)
64+ self.assertEqual(self.state.beacons, [])
65+
66+ def test_rejects_non_numeric_value(self):
67+ status = self.get("run=%s&nav=%s&event=%s&value=abc" % ("a" * 16, "b" * 16, "first-contentful-paint"))
68+ self.assertEqual(status, 400)
69+ self.assertEqual(self.state.beacons, [])
70+
71+ def test_rejects_unknown_event(self):
72+ status = self.get("run=%s&nav=%s&event=%s&value=1" % ("a" * 16, "b" * 16, "bogus"))
73+ self.assertEqual(status, 400)
74+ self.assertEqual(self.state.beacons, [])
75+
76+ def test_milestones_still_parsed(self):
77+ lines = ['{"message":"startup_trace: milestone","milestone":"process_entry"}', "not json"]
78+ self.assertEqual(len(harness.parse_child_output(lines)), 1)
79+
80+ def test_image_endpoint_serves_decodable_png(self):
81+ import struct
82+ import zlib
83+ url = "http://127.0.0.1:%d/pixel.png" % self.port
84+ with urllib.request.urlopen(url, timeout=5) as response:
85+ self.assertEqual(response.status, 200)
86+ self.assertEqual(response.headers.get_content_type(), "image/png")
87+ body = response.read()
88+ self.assertEqual(body[:8], bytes.fromhex("89504e470d0a1a0a"))
89+ width, height = struct.unpack(">II", body[16:24])
90+ self.assertEqual((width, height), (1, 1))
91+ idat_len = struct.unpack(">I", body[33:37])[0]
92+ self.assertEqual(body[37:41], b"IDAT")
93+ zlib.decompress(body[41:41 + idat_len])
94+ self.assertEqual(body[-8:-4], b"IEND")
95+
96+
97+class CliValidationTest(unittest.TestCase):
98+ def setUp(self):
99+ self.script = os.path.join(os.path.dirname(os.path.abspath(__file__)), "measure_cef_startup.py")
100+ self.temp = tempfile.mkdtemp()
101+ self.binary = os.path.join(self.temp, "fakebin")
102+ with open(self.binary, "w") as handle:
103+ handle.write("#!/bin/sh\nexit 0\n")
104+ os.chmod(self.binary, 0o755)
105+ self.cef_dir = os.path.join(self.temp, "cef")
106+ os.makedirs(self.cef_dir)
107+
108+ def test_rejects_existing_output(self):
109+ existing = os.path.join(self.temp, "exists")
110+ os.makedirs(existing)
111+ result = subprocess.run(
112+ [sys.executable, self.script, "--binary", self.binary, "--cef-dir", self.cef_dir,
113+ "--scenario", "profile-fresh", "--fixture", "static",
114+ "--runs", "1", "--output", existing],
115+ capture_output=True, text=True)
116+ self.assertNotEqual(result.returncode, 0)
117+ self.assertIn("fresh absolute directory", result.stderr)
118+
119+ def test_rejects_relative_output(self):
120+ result = subprocess.run(
121+ [sys.executable, self.script, "--binary", self.binary, "--cef-dir", self.cef_dir,
122+ "--scenario", "profile-fresh", "--fixture", "static",
123+ "--runs", "1", "--output", "relative/path"],
124+ capture_output=True, text=True)
125+ self.assertNotEqual(result.returncode, 0)
126+
127+ def test_rejects_missing_binary(self):
128+ result = subprocess.run(
129+ [sys.executable, self.script, "--binary", os.path.join(self.temp, "missing"),
130+ "--cef-dir", self.cef_dir, "--scenario", "profile-fresh", "--fixture", "static",
131+ "--runs", "1", "--output", os.path.join(self.temp, "out")],
132+ capture_output=True, text=True)
133+ self.assertNotEqual(result.returncode, 0)
134+
135+ def test_rejects_bad_runs(self):
136+ result = subprocess.run(
137+ [sys.executable, self.script, "--binary", self.binary, "--cef-dir", self.cef_dir,
138+ "--scenario", "profile-fresh", "--fixture", "static",
139+ "--runs", "0", "--output", os.path.join(self.temp, "out2")],
140+ capture_output=True, text=True)
141+ self.assertNotEqual(result.returncode, 0)
142+
143+
144+class FakeBinaryLifetimeTest(unittest.TestCase):
145+ def setUp(self):
146+ self.temp = tempfile.mkdtemp()
147+ self.cef_dir = os.path.join(self.temp, "cef")
148+ os.makedirs(self.cef_dir)
149+ self.counter = 0
150+
151+ def make_binary(self, body):
152+ self.counter += 1
153+ path = os.path.join(self.temp, "fake-%d" % self.counter)
154+ with open(path, "w") as handle:
155+ handle.write("#!/bin/sh\n" + body + "\n")
156+ os.chmod(path, 0o755)
157+ return path
158+
159+ def make_fetch_binary(self):
160+ # Fetches the fixture URL passed as argv[2] (argv: browse <url>),
161+ # then exits: exercises document counting without a real browser.
162+ path = os.path.join(self.temp, "fetch-%d" % self.counter)
163+ self.counter += 1
164+ with open(path, "w") as handle:
165+ handle.write("#!/usr/bin/env python3\nimport sys, urllib.request\n"
166+ "urllib.request.urlopen(sys.argv[2], timeout=10).read()\n")
167+ os.chmod(path, 0o755)
168+ return path
169+
170+ def test_duplicate_documents_marked_incomplete(self):
171+ binary = self.make_binary("exit 0")
172+ result = harness.run_sample(binary, self.cef_dir, "profile-fresh", "static", 1)
173+ self.assertFalse(result["complete"])
174+ self.assertIsNone(result["target_fcp_ms"])
175+
176+ def test_absent_fcp_is_incomplete(self):
177+ binary = self.make_fetch_binary()
178+ result = harness.run_sample(binary, self.cef_dir, "profile-fresh", "static", 1)
179+ self.assertEqual(result["document_requests"], 1)
180+ self.assertIsNone(result["target_fcp_ms"])
181+ self.assertFalse(result["complete"])
182+
183+ def test_relay_fallback_recorded_without_owner(self):
184+ binary = self.make_binary("exit 0")
185+ result = harness.run_sample(binary, self.cef_dir, "relay-window", "static", 1)
186+ self.assertTrue(result["fallback_spawn"])
187+ self.assertFalse(result["relayed"])
188+
189+ def test_second_window_uses_observation_not_process_summary(self):
190+ binary = self.make_binary("exit 0")
191+ first = harness.run_sample(binary, self.cef_dir, "profile-fresh", "static", 1)
192+ second = harness.run_sample(binary, self.cef_dir, "relay-window", "static", 2)
193+ self.assertIn("observation_spawn_to_summary_ms", first)
194+ self.assertIn("observation_spawn_to_summary_ms", second)
195+ self.assertNotIn("total_ms", second)
196+
197+ def test_run_dir_cleaned_up(self):
198+ before = set(os.listdir(tempfile.gettempdir()))
199+ binary = self.make_binary("exit 0")
200+ harness.run_sample(binary, self.cef_dir, "profile-fresh", "static", 1)
201+ after = set(os.listdir(tempfile.gettempdir()))
202+ leaked = [name for name in (after - before) if name.startswith("cef-startup-run-")]
203+ self.assertEqual(leaked, [])
204+
205+ def test_streaming_returns_without_process_exit(self):
206+ # A hanging GUI must not pin the harness: with a short cutoff the
207+ # sample decides from streamed output, then terminates the child.
208+ binary = self.make_binary("exec sleep 30")
209+ result = harness.run_sample(binary, self.cef_dir, "profile-fresh", "static", 1)
210+ self.assertFalse(result["complete"])
211+ self.assertLess(result["observation_spawn_to_summary_ms"], 15000)
212+
213+
214+if __name__ == "__main__":
215+ unittest.main()
@@ -0,0 +1,215 @@
1+#!/usr/bin/env python3
2+"""Unit tests for measure_cef_startup.py (stdlib only).
3+
4+Run headless: python3 -m unittest discover -s scripts -p 'test_measure_cef_startup.py'
5+Set DUMBER_MEASURE_CUTOFF_SECONDS=0.2 for fast streaming tests (default 5.0).
6+"""
7+
8+import os
9+import subprocess
10+import sys
11+import tempfile
12+import threading
13+import unittest
14+import urllib.error
15+import urllib.request
16+from http.server import ThreadingHTTPServer
17+
18+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
19+
20+import measure_cef_startup as harness
21+
22+
23+class BeaconTest(unittest.TestCase):
24+ def setUp(self):
25+ self.state = harness.FixtureState()
26+ harness.Handler.state = self.state
27+ harness.Handler.fixture = "static"
28+ harness.Handler.run_token = "a" * 16
29+ harness.Handler.nav_token = "b" * 16
30+ self.server = ThreadingHTTPServer(("127.0.0.1", 0), harness.Handler)
31+ self.port = self.server.server_address[1]
32+ self.thread = threading.Thread(target=self.server.serve_forever, kwargs={"poll_interval": 0.05})
33+ self.thread.daemon = True
34+ self.thread.start()
35+
36+ def tearDown(self):
37+ self.server.shutdown()
38+ self.server.server_close()
39+ self.thread.join(timeout=5)
40+
41+ def get(self, query):
42+ url = "http://127.0.0.1:%d/__beacon?%s" % (self.port, query)
43+ try:
44+ with urllib.request.urlopen(url, timeout=5) as response:
45+ return response.status
46+ except urllib.error.HTTPError as exc:
47+ exc.read()
48+ return exc.code
49+
50+ def test_accepts_valid_beacon(self):
51+ status = self.get("run=%s&nav=%s&event=%s&value=12" % ("a" * 16, "b" * 16, "first-contentful-paint"))
52+ self.assertEqual(status, 204)
53+ self.assertEqual(len(self.state.beacons), 1)
54+ self.assertEqual(self.state.beacons[0]["value_ms"], 12)
55+
56+ def test_rejects_wrong_token(self):
57+ status = self.get("run=%s&nav=%s&event=%s&value=12" % ("c" * 16, "d" * 16, "first-contentful-paint"))
58+ self.assertEqual(status, 400)
59+ self.assertEqual(self.state.beacons, [])
60+
61+ def test_rejects_malformed_token(self):
62+ status = self.get("run=short&nav=%s&event=%s&value=12" % ("b" * 16, "first-contentful-paint"))
63+ self.assertEqual(status, 400)
64+ self.assertEqual(self.state.beacons, [])
65+
66+ def test_rejects_non_numeric_value(self):
67+ status = self.get("run=%s&nav=%s&event=%s&value=abc" % ("a" * 16, "b" * 16, "first-contentful-paint"))
68+ self.assertEqual(status, 400)
69+ self.assertEqual(self.state.beacons, [])
70+
71+ def test_rejects_unknown_event(self):
72+ status = self.get("run=%s&nav=%s&event=%s&value=1" % ("a" * 16, "b" * 16, "bogus"))
73+ self.assertEqual(status, 400)
74+ self.assertEqual(self.state.beacons, [])
75+
76+ def test_milestones_still_parsed(self):
77+ lines = ['{"message":"startup_trace: milestone","milestone":"process_entry"}', "not json"]
78+ self.assertEqual(len(harness.parse_child_output(lines)), 1)
79+
80+ def test_image_endpoint_serves_decodable_png(self):
81+ import struct
82+ import zlib
83+ url = "http://127.0.0.1:%d/pixel.png" % self.port
84+ with urllib.request.urlopen(url, timeout=5) as response:
85+ self.assertEqual(response.status, 200)
86+ self.assertEqual(response.headers.get_content_type(), "image/png")
87+ body = response.read()
88+ self.assertEqual(body[:8], bytes.fromhex("89504e470d0a1a0a"))
89+ width, height = struct.unpack(">II", body[16:24])
90+ self.assertEqual((width, height), (1, 1))
91+ idat_len = struct.unpack(">I", body[33:37])[0]
92+ self.assertEqual(body[37:41], b"IDAT")
93+ zlib.decompress(body[41:41 + idat_len])
94+ self.assertEqual(body[-8:-4], b"IEND")
95+
96+
97+class CliValidationTest(unittest.TestCase):
98+ def setUp(self):
99+ self.script = os.path.join(os.path.dirname(os.path.abspath(__file__)), "measure_cef_startup.py")
100+ self.temp = tempfile.mkdtemp()
101+ self.binary = os.path.join(self.temp, "fakebin")
102+ with open(self.binary, "w") as handle:
103+ handle.write("#!/bin/sh\nexit 0\n")
104+ os.chmod(self.binary, 0o755)
105+ self.cef_dir = os.path.join(self.temp, "cef")
106+ os.makedirs(self.cef_dir)
107+
108+ def test_rejects_existing_output(self):
109+ existing = os.path.join(self.temp, "exists")
110+ os.makedirs(existing)
111+ result = subprocess.run(
112+ [sys.executable, self.script, "--binary", self.binary, "--cef-dir", self.cef_dir,
113+ "--scenario", "profile-fresh", "--fixture", "static",
114+ "--runs", "1", "--output", existing],
115+ capture_output=True, text=True)
116+ self.assertNotEqual(result.returncode, 0)
117+ self.assertIn("fresh absolute directory", result.stderr)
118+
119+ def test_rejects_relative_output(self):
120+ result = subprocess.run(
121+ [sys.executable, self.script, "--binary", self.binary, "--cef-dir", self.cef_dir,
122+ "--scenario", "profile-fresh", "--fixture", "static",
123+ "--runs", "1", "--output", "relative/path"],
124+ capture_output=True, text=True)
125+ self.assertNotEqual(result.returncode, 0)
126+
127+ def test_rejects_missing_binary(self):
128+ result = subprocess.run(
129+ [sys.executable, self.script, "--binary", os.path.join(self.temp, "missing"),
130+ "--cef-dir", self.cef_dir, "--scenario", "profile-fresh", "--fixture", "static",
131+ "--runs", "1", "--output", os.path.join(self.temp, "out")],
132+ capture_output=True, text=True)
133+ self.assertNotEqual(result.returncode, 0)
134+
135+ def test_rejects_bad_runs(self):
136+ result = subprocess.run(
137+ [sys.executable, self.script, "--binary", self.binary, "--cef-dir", self.cef_dir,
138+ "--scenario", "profile-fresh", "--fixture", "static",
139+ "--runs", "0", "--output", os.path.join(self.temp, "out2")],
140+ capture_output=True, text=True)
141+ self.assertNotEqual(result.returncode, 0)
142+
143+
144+class FakeBinaryLifetimeTest(unittest.TestCase):
145+ def setUp(self):
146+ self.temp = tempfile.mkdtemp()
147+ self.cef_dir = os.path.join(self.temp, "cef")
148+ os.makedirs(self.cef_dir)
149+ self.counter = 0
150+
151+ def make_binary(self, body):
152+ self.counter += 1
153+ path = os.path.join(self.temp, "fake-%d" % self.counter)
154+ with open(path, "w") as handle:
155+ handle.write("#!/bin/sh\n" + body + "\n")
156+ os.chmod(path, 0o755)
157+ return path
158+
159+ def make_fetch_binary(self):
160+ # Fetches the fixture URL passed as argv[2] (argv: browse <url>),
161+ # then exits: exercises document counting without a real browser.
162+ path = os.path.join(self.temp, "fetch-%d" % self.counter)
163+ self.counter += 1
164+ with open(path, "w") as handle:
165+ handle.write("#!/usr/bin/env python3\nimport sys, urllib.request\n"
166+ "urllib.request.urlopen(sys.argv[2], timeout=10).read()\n")
167+ os.chmod(path, 0o755)
168+ return path
169+
170+ def test_duplicate_documents_marked_incomplete(self):
171+ binary = self.make_binary("exit 0")
172+ result = harness.run_sample(binary, self.cef_dir, "profile-fresh", "static", 1)
173+ self.assertFalse(result["complete"])
174+ self.assertIsNone(result["target_fcp_ms"])
175+
176+ def test_absent_fcp_is_incomplete(self):
177+ binary = self.make_fetch_binary()
178+ result = harness.run_sample(binary, self.cef_dir, "profile-fresh", "static", 1)
179+ self.assertEqual(result["document_requests"], 1)
180+ self.assertIsNone(result["target_fcp_ms"])
181+ self.assertFalse(result["complete"])
182+
183+ def test_relay_fallback_recorded_without_owner(self):
184+ binary = self.make_binary("exit 0")
185+ result = harness.run_sample(binary, self.cef_dir, "relay-window", "static", 1)
186+ self.assertTrue(result["fallback_spawn"])
187+ self.assertFalse(result["relayed"])
188+
189+ def test_second_window_uses_observation_not_process_summary(self):
190+ binary = self.make_binary("exit 0")
191+ first = harness.run_sample(binary, self.cef_dir, "profile-fresh", "static", 1)
192+ second = harness.run_sample(binary, self.cef_dir, "relay-window", "static", 2)
193+ self.assertIn("observation_spawn_to_summary_ms", first)
194+ self.assertIn("observation_spawn_to_summary_ms", second)
195+ self.assertNotIn("total_ms", second)
196+
197+ def test_run_dir_cleaned_up(self):
198+ before = set(os.listdir(tempfile.gettempdir()))
199+ binary = self.make_binary("exit 0")
200+ harness.run_sample(binary, self.cef_dir, "profile-fresh", "static", 1)
201+ after = set(os.listdir(tempfile.gettempdir()))
202+ leaked = [name for name in (after - before) if name.startswith("cef-startup-run-")]
203+ self.assertEqual(leaked, [])
204+
205+ def test_streaming_returns_without_process_exit(self):
206+ # A hanging GUI must not pin the harness: with a short cutoff the
207+ # sample decides from streamed output, then terminates the child.
208+ binary = self.make_binary("exec sleep 30")
209+ result = harness.run_sample(binary, self.cef_dir, "profile-fresh", "static", 1)
210+ self.assertFalse(result["complete"])
211+ self.assertLess(result["observation_spawn_to_summary_ms"], 15000)
212+
213+
214+if __name__ == "__main__":
215+ unittest.main()

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