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 Unverified
B5690EEEBB952194The key is unknown
21 files · +2379 -231
M .gitignore +6 -0
| @@ -89,6 +89,12 @@ scripts/* | |||
| 89 | 89 | !scripts/verify_purego_only.sh | |
| 90 | 90 | !scripts/stress_omnibox_callbacks.go | |
| 91 | 91 | !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 | 98 | specs/ | |
| 93 | 99 | templates/ | |
| 94 | 100 | .serena/ | |
| @@ -89,6 +89,12 @@ scripts/* | |||||
| 89 | !scripts/verify_purego_only.sh | 89 | !scripts/verify_purego_only.sh | ||
| 90 | !scripts/stress_omnibox_callbacks.go | 90 | !scripts/stress_omnibox_callbacks.go | ||
| 91 | !scripts/cef-gpu-diagnostics.sh | 91 | !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 | |||
| 42 | 42 | # package boundaries. Both are required — -l alone is insufficient. | |
| 43 | 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 | 50 | # Default target | |
| 46 | 51 | help: ## Show this help message | |
| 47 | 52 | @echo "Available targets:" | |
| @@ -72,10 +77,16 @@ build-systemviews: generate-systemviews ## Build the WASM systemviews runtime | |||
| 72 | 77 | build-quick: ## Build quickly for backend development | |
| 73 | 78 | @echo "Building $(BINARY_NAME) $(VERSION) (quick)..." | |
| 74 | 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 | 81 | @rm -f $(DIST_DIR)/cef-helper | |
| 77 | 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 | 90 | install-local: build-quick ## Install dumber to ~/.local/bin atomically | |
| 80 | 91 | @echo "Installing $(BINARY_NAME) to $(LOCAL_BIN_DIR)..." | |
| 81 | 92 | @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 target | 50 | # Default target | ||
| 46 | help: ## Show this help message | 51 | 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 development | 77 | 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-helper | 81 | @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 atomically | 90 | 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 | |||
| 21 | 21 | // | |
| 22 | 22 | //go:embed logo-32.png | |
| 23 | 23 | 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.png | 22 | //go:embed logo-32.png | ||
| 23 | var LogoPNG32 []byte | 23 | 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 ( | |||
| 31 | 31 | "github.com/bnema/dumber/internal/infrastructure/filesystem" | |
| 32 | 32 | "github.com/bnema/dumber/internal/infrastructure/idle" | |
| 33 | 33 | "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite" | |
| 34 | + | "github.com/bnema/dumber/internal/infrastructure/process" | |
| 34 | 35 | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |
| 35 | 36 | "github.com/bnema/dumber/internal/infrastructure/snapshot" | |
| 36 | 37 | "github.com/bnema/dumber/internal/infrastructure/textinput" | |
| @@ -173,7 +174,14 @@ func main() { | |||
| 173 | 174 | // invalidate frames retained across those boundaries and make the GC abort | |
| 174 | 175 | // with "traceback did not unwind completely". Re-exec once so the runtime | |
| 175 | 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 | 185 | _, _ = fmt.Fprintf(os.Stderr, "dumber: %v\n", err) | |
| 178 | 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 | 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 | 519 | env := bootstrap.CurrentEnvMap() | |
| 435 | 520 | if !bootstrap.ShouldPreloadLayerShell(env) { | |
| 436 | 521 | 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 abort | 174 | // invalidate frames retained across those boundaries and make the GC abort | ||
| 174 | // with "traceback did not unwind completely". Re-exec once so the runtime | 175 | // 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 | return | 521 | return | ||
M cmd/dumber/main_test.go +22 -0
| @@ -334,3 +334,25 @@ func TestTryForwardBrowseURLToRunningInstance_PropagatesError(t *testing.T) { | |||
| 334 | 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 | + | } | |
| @@ -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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "fmt" | |
| 5 | 5 | "os" | |
| 6 | - | "strings" | |
| 7 | 6 | "syscall" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/dumber/internal/infrastructure/process" | |
| 8 | 9 | ) | |
| 9 | 10 | ||
| 10 | 11 | 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) | |
| 29 | 13 | } | |
| 30 | 14 | ||
| 31 | 15 | 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) | |
| 55 | 17 | } | |
| 56 | 18 | ||
| 57 | 19 | 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. | |||
| 36 | 36 | ||
| 37 | 37 | ## Reproducible collection | |
| 38 | 38 | ||
| 39 | - | Build the candidate, then run: | |
| 39 | + | Build the candidate and its build manifest, then run: | |
| 40 | 40 | ||
| 41 | 41 | ```bash | |
| 42 | + | DUMBER_CEF_DIR=/path/to/selected-cef-runtime \ | |
| 42 | 43 | DUMBER_FIRST_PRESENTATION_BIN="$PWD/dist/dumber" \ | |
| 44 | + | DUMBER_BUILD_MANIFEST="$PWD/dist/dumber.manifest.json" \ | |
| 43 | 45 | DUMBER_MACHINE_GPU_PROFILE=integrated-gpu \ | |
| 44 | 46 | 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 | 82 | The collector is for the accelerated CEF/DMABUF/GTK contract above. By default | |
| 48 | 83 | each collection is a fresh directory below | |
| 49 | 84 | `$XDG_STATE_HOME/dumber/roadmap-evidence` (or | |
| @@ -52,8 +87,14 @@ not in the repository. `DUMBER_FIRST_PRESENTATION_OUTPUT` may override it only | |||
| 52 | 87 | with a new absolute directory below an existing non-symlink parent. The | |
| 53 | 88 | collector never clears or reuses a caller-supplied path. | |
| 54 | 89 | ||
| 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 | |
| 57 | 98 | exactly five bounded launches with fresh XDG homes and CEF root cache, fixes the | |
| 58 | 99 | DMABUF/Vulkan renderer, and writes exactly `metadata.json`, `run-01.json` | |
| 59 | 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 | 103 | `integrated-gpu`, `discrete-gpu`, `hybrid-gpu`, `virtual-gpu`, or `unknown-gpu`; | |
| 63 | 104 | do not use a device model or other identifier. Publish only the seven reviewed | |
| 64 | 105 | 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 | |
| 68 | 113 | fail collection. A missing or incomplete timeline, non-DMABUF backend, or invalid | |
| 69 | 114 | run also fails collection. | |
| @@ -36,14 +36,49 @@ contract rather than this CEF trace. | |||||
| 36 | 36 | ||||
| 37 | ## Reproducible collection | 37 | ## Reproducible collection | ||
| 38 | 38 | ||||
| 39 | - | Build the candidate, then run: | 39 | + | Build the candidate and its build manifest, then run: |
| 40 | 40 | ||||
| 41 | ```bash | 41 | ```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.sh | 46 | 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 default | 82 | The collector is for the accelerated CEF/DMABUF/GTK contract above. By default | ||
| 48 | each collection is a fresh directory below | 83 | each collection is a fresh directory below | ||
| 49 | `$XDG_STATE_HOME/dumber/roadmap-evidence` (or | 84 | `$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. The | 87 | 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 at | 90 | + | The script requires the current display and an explicit `DUMBER_CEF_DIR` |
| 56 | - | `~/.local/share/cef-147-runtime` (override with `DUMBER_CEF_DIR`). It performs | 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 | |||
| 57 | exactly five bounded launches with fresh XDG homes and CEF root cache, fixes the | 98 | 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 reviewed | 104 | do not use a device model or other identifier. Publish only the seven reviewed | ||
| 64 | JSON files to an external Gist. Raw logs and temporary XDG homes are removed and | 105 | 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 | 106 | + | 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 Git | 107 | + | 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 metadata | 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 | |||
| 68 | fail collection. A missing or incomplete timeline, non-DMABUF backend, or invalid | 113 | 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 @@ | |||
| 1 | 1 | package cef | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "crypto/sha256" | |
| 5 | + | "encoding/hex" | |
| 4 | 6 | "encoding/json" | |
| 5 | 7 | "fmt" | |
| 6 | 8 | "os" | |
| @@ -22,6 +24,12 @@ const validFirstPresentationLog = `{"message":"startup_trace: milestone","milest | |||
| 22 | 24 | {"message":"startup_trace: milestone","milestone":"first_gtk_presentation","t_ms":7,"delta_ms":1} | |
| 23 | 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 | 33 | func TestFirstPresentationCollectorNeverRecursivelyDeletesCallerOutput(t *testing.T) { | |
| 26 | 34 | repoRoot, err := filepath.Abs(filepath.Join("..", "..", "..")) | |
| 27 | 35 | require.NoError(t, err) | |
| @@ -31,12 +39,26 @@ func TestFirstPresentationCollectorNeverRecursivelyDeletesCallerOutput(t *testin | |||
| 31 | 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 | 57 | func TestFirstPresentationCollectorRejectsUnsafeOutputPaths(t *testing.T) { | |
| 35 | 58 | repoRoot, err := filepath.Abs(filepath.Join("..", "..", "..")) | |
| 36 | 59 | require.NoError(t, err) | |
| 37 | 60 | temp := t.TempDir() | |
| 38 | - | runtime := filepath.Join(temp, "cef-147-runtime") | |
| 39 | - | require.NoError(t, os.Mkdir(runtime, 0o755)) | |
| 61 | + | runtime := collectorFakeCEFRuntime(t, 150) | |
| 40 | 62 | binary := filepath.Join(temp, "dumber") | |
| 41 | 63 | require.NoError(t, os.WriteFile(binary, collectorTestBinary(validFirstPresentationLog), 0o755)) | |
| 42 | 64 | home := filepath.Join(temp, "home") | |
| @@ -112,15 +134,12 @@ func TestFirstPresentationCollectorSanitizesMachineLocalValues(t *testing.T) { | |||
| 112 | 134 | repoRoot, err := filepath.Abs(filepath.Join("..", "..", "..")) | |
| 113 | 135 | require.NoError(t, err) | |
| 114 | 136 | 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) | |
| 118 | 138 | binary := filepath.Join(temp, "dumber") | |
| 119 | 139 | require.NoError(t, os.WriteFile(binary, collectorTestBinary(validFirstPresentationLog), 0o755)) | |
| 120 | 140 | ||
| 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) | |
| 124 | 143 | output := filepath.Join(temp, "artifacts") | |
| 125 | 144 | cmd := exec.Command(filepath.Join(repoRoot, "scripts", "collect_first_presentation.sh")) | |
| 126 | 145 | cmd.Dir = repoRoot | |
| @@ -129,6 +148,7 @@ func TestFirstPresentationCollectorSanitizesMachineLocalValues(t *testing.T) { | |||
| 129 | 148 | "WAYLAND_DISPLAY=", | |
| 130 | 149 | "DUMBER_CEF_DIR="+runtime, | |
| 131 | 150 | "DUMBER_FIRST_PRESENTATION_BIN="+binary, | |
| 151 | + | "DUMBER_BUILD_MANIFEST="+manifest, | |
| 132 | 152 | "DUMBER_FIRST_PRESENTATION_OUTPUT="+output, | |
| 133 | 153 | "DUMBER_FIRST_PRESENTATION_TIMEOUT_SECONDS=1", | |
| 134 | 154 | "DUMBER_MACHINE_GPU_PROFILE=integrated-gpu", | |
| @@ -143,14 +163,16 @@ func TestFirstPresentationCollectorSanitizesMachineLocalValues(t *testing.T) { | |||
| 143 | 163 | require.NoError(t, readErr) | |
| 144 | 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 | 167 | require.NotContainsf(t, artifacts.String(), forbidden, "committed artifact leaked %q", forbidden) | |
| 148 | 168 | } | |
| 149 | 169 | for _, required := range []string{ | |
| 150 | 170 | `"measured_source_revision"`, | |
| 151 | - | `"version": "` + upstreamVersion + `"`, | |
| 171 | + | `"version": "` + testUpstreamVersion + `"`, | |
| 152 | 172 | `"tag": "v0.8.5"`, | |
| 153 | - | `"revision": "` + upstreamRevision + `"`, | |
| 173 | + | `"revision": "` + testUpstreamRevision + `"`, | |
| 174 | + | `"chrome_major": 150`, | |
| 175 | + | `"libcef_sha256"`, | |
| 154 | 176 | } { | |
| 155 | 177 | require.Contains(t, artifacts.String(), required) | |
| 156 | 178 | } | |
| @@ -176,16 +198,12 @@ func TestFirstPresentationCollectorDerivesSelectedImmutableModuleProvenance(t *t | |||
| 176 | 198 | repoRoot, err := filepath.Abs(filepath.Join("..", "..", "..")) | |
| 177 | 199 | require.NoError(t, err) | |
| 178 | 200 | temp := t.TempDir() | |
| 179 | - | runtime := filepath.Join(temp, "cef-147-runtime") | |
| 180 | - | require.NoError(t, os.Mkdir(runtime, 0o755)) | |
| 201 | + | runtime := collectorFakeCEFRuntime(t, 150) | |
| 181 | 202 | binary := filepath.Join(temp, "dumber") | |
| 182 | 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.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) | |
| 189 | 207 | output := filepath.Join(temp, "artifacts") | |
| 190 | 208 | cmd := exec.Command(filepath.Join(repoRoot, "scripts", "collect_first_presentation.sh")) | |
| 191 | 209 | cmd.Dir = repoRoot | |
| @@ -193,6 +211,7 @@ func TestFirstPresentationCollectorDerivesSelectedImmutableModuleProvenance(t *t | |||
| 193 | 211 | "DISPLAY=:test", | |
| 194 | 212 | "DUMBER_CEF_DIR="+runtime, | |
| 195 | 213 | "DUMBER_FIRST_PRESENTATION_BIN="+binary, | |
| 214 | + | "DUMBER_BUILD_MANIFEST="+manifest, | |
| 196 | 215 | "DUMBER_FIRST_PRESENTATION_OUTPUT="+output, | |
| 197 | 216 | "DUMBER_FIRST_PRESENTATION_TIMEOUT_SECONDS=1", | |
| 198 | 217 | "PATH="+filepath.Dir(goBin)+":"+os.Getenv("PATH"), | |
| @@ -207,14 +226,16 @@ func TestFirstPresentationCollectorDerivesSelectedImmutableModuleProvenance(t *t | |||
| 207 | 226 | Tag string `json:"tag"` | |
| 208 | 227 | Revision string `json:"revision"` | |
| 209 | 228 | } `json:"upstream"` | |
| 229 | + | MeasuredSourceRevision string `json:"measured_source_revision"` | |
| 210 | 230 | } | |
| 211 | 231 | contents, err := os.ReadFile(filepath.Join(output, "metadata.json")) | |
| 212 | 232 | require.NoError(t, err) | |
| 213 | 233 | require.NoError(t, json.Unmarshal(contents, &metadata)) | |
| 214 | 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 | 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 | 240 | script, err := os.ReadFile(filepath.Join(repoRoot, "scripts", "collect_first_presentation.sh")) | |
| 220 | 241 | require.NoError(t, err) | |
| @@ -226,20 +247,20 @@ func TestFirstPresentationCollectorRejectsNonImmutableModuleProvenanceWithoutLea | |||
| 226 | 247 | require.NoError(t, err) | |
| 227 | 248 | ||
| 228 | 249 | for _, test := range []struct { | |
| 229 | - | name, version, revision, ref string | |
| 250 | + | name, version string | |
| 230 | 251 | }{ | |
| 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"}, | |
| 234 | 255 | } { | |
| 235 | 256 | t.Run(test.name, func(t *testing.T) { | |
| 236 | 257 | temp := t.TempDir() | |
| 237 | - | runtime := filepath.Join(temp, "cef-147-runtime") | |
| 238 | - | require.NoError(t, os.Mkdir(runtime, 0o755)) | |
| 258 | + | runtime := collectorFakeCEFRuntime(t, 150) | |
| 239 | 259 | binary := filepath.Join(temp, "dumber") | |
| 240 | 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 | 264 | output := filepath.Join(temp, "artifacts") | |
| 244 | 265 | cmd := exec.Command(filepath.Join(repoRoot, "scripts", "collect_first_presentation.sh")) | |
| 245 | 266 | cmd.Dir = repoRoot | |
| @@ -247,6 +268,7 @@ func TestFirstPresentationCollectorRejectsNonImmutableModuleProvenanceWithoutLea | |||
| 247 | 268 | "DISPLAY=:test", | |
| 248 | 269 | "DUMBER_CEF_DIR="+runtime, | |
| 249 | 270 | "DUMBER_FIRST_PRESENTATION_BIN="+binary, | |
| 271 | + | "DUMBER_BUILD_MANIFEST="+manifest, | |
| 250 | 272 | "DUMBER_FIRST_PRESENTATION_OUTPUT="+output, | |
| 251 | 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 { | |
| 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("..", "..", "..")) | |
| 275 | 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{ | |
| 279 | - | "Path": "github.com/bnema/purego-cef2gtk", "Version": version, | |
| 280 | - | }) | |
| 338 | + | func TestFirstPresentationCollectorRejectsProbeFailure(t *testing.T) { | |
| 339 | + | repoRoot, err := filepath.Abs(filepath.Join("..", "..", "..")) | |
| 281 | 340 | 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("..", "..", "..")) | |
| 285 | 366 | 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)) | |
| 288 | 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 | 497 | 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' | |
| 296 | 500 | exit 0 | |
| 297 | 501 | fi | |
| 298 | 502 | exit 1 | |
| 299 | - | `, selectedJSON, version, downloadedJSON) | |
| 503 | + | `, depLine, buildLine) | |
| 300 | 504 | require.NoError(t, os.WriteFile(goBin, []byte(script), 0o755)) | |
| 301 | 505 | 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 | 560 | func TestFirstPresentationCollectorDefaultsToXDGStateEvidenceDirectory(t *testing.T) { | |
| 305 | 561 | repoRoot, err := filepath.Abs(filepath.Join("..", "..", "..")) | |
| 306 | 562 | require.NoError(t, err) | |
| 307 | 563 | temp := t.TempDir() | |
| 308 | - | runtime := filepath.Join(temp, "cef-147-runtime") | |
| 309 | - | require.NoError(t, os.Mkdir(runtime, 0o755)) | |
| 564 | + | runtime := collectorFakeCEFRuntime(t, 150) | |
| 310 | 565 | binary := filepath.Join(temp, "dumber") | |
| 311 | 566 | require.NoError(t, os.WriteFile(binary, collectorTestBinary(validFirstPresentationLog), 0o755)) | |
| 312 | 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 | 571 | cmd := exec.Command(filepath.Join(repoRoot, "scripts", "collect_first_presentation.sh")) | |
| 315 | 572 | cmd.Dir = repoRoot | |
| @@ -318,9 +575,11 @@ func TestFirstPresentationCollectorDefaultsToXDGStateEvidenceDirectory(t *testin | |||
| 318 | 575 | "WAYLAND_DISPLAY=", | |
| 319 | 576 | "DUMBER_CEF_DIR="+runtime, | |
| 320 | 577 | "DUMBER_FIRST_PRESENTATION_BIN="+binary, | |
| 578 | + | "DUMBER_BUILD_MANIFEST="+manifest, | |
| 321 | 579 | "DUMBER_FIRST_PRESENTATION_TIMEOUT_SECONDS=1", | |
| 322 | 580 | "DUMBER_MACHINE_GPU_PROFILE=integrated-gpu", | |
| 323 | 581 | "XDG_STATE_HOME="+stateHome, | |
| 582 | + | "PATH="+filepath.Dir(goBin)+":"+os.Getenv("PATH"), | |
| 324 | 583 | ) | |
| 325 | 584 | result, err := cmd.CombinedOutput() | |
| 326 | 585 | require.NoErrorf(t, err, "collector failed: %s", result) | |
| @@ -345,22 +604,19 @@ func envWithout(name string) []string { | |||
| 345 | 604 | return environment | |
| 346 | 605 | } | |
| 347 | 606 | ||
| 348 | - | func TestFirstPresentationCollectorReadsProvenanceWithScopedGitSafeDirectory(t *testing.T) { | |
| 607 | + | func TestFirstPresentationCollectorBindsBinaryNotCheckoutGit(t *testing.T) { | |
| 349 | 608 | repoRoot, err := filepath.Abs(filepath.Join("..", "..", "..")) | |
| 350 | 609 | require.NoError(t, err) | |
| 351 | 610 | temp := t.TempDir() | |
| 352 | - | runtime := filepath.Join(temp, "cef-147-runtime") | |
| 353 | - | require.NoError(t, os.Mkdir(runtime, 0o755)) | |
| 611 | + | runtime := collectorFakeCEFRuntime(t, 150) | |
| 354 | 612 | binary := filepath.Join(temp, "dumber") | |
| 355 | 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 | 617 | require.NoError(t, os.Mkdir(gitDir, 0o755)) | |
| 358 | 618 | git := filepath.Join(gitDir, "git") | |
| 359 | 619 | 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 | 620 | echo "fatal: detected dubious ownership in repository" >&2 | |
| 365 | 621 | exit 128 | |
| 366 | 622 | `), 0o755)) | |
| @@ -372,13 +628,13 @@ exit 128 | |||
| 372 | 628 | "DISPLAY=:test", | |
| 373 | 629 | "DUMBER_CEF_DIR="+runtime, | |
| 374 | 630 | "DUMBER_FIRST_PRESENTATION_BIN="+binary, | |
| 631 | + | "DUMBER_BUILD_MANIFEST="+manifest, | |
| 375 | 632 | "DUMBER_FIRST_PRESENTATION_OUTPUT="+output, | |
| 376 | 633 | "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"), | |
| 379 | 635 | ) | |
| 380 | 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 | 639 | var metadata struct { | |
| 384 | 640 | MeasuredSourceRevision string `json:"measured_source_revision"` | |
| @@ -386,7 +642,7 @@ exit 128 | |||
| 386 | 642 | contents, err := os.ReadFile(filepath.Join(output, "metadata.json")) | |
| 387 | 643 | require.NoError(t, err) | |
| 388 | 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 | 648 | func TestFirstPresentationCollectorRejectsInconsistentTiming(t *testing.T) { | |
| @@ -405,11 +661,12 @@ func TestFirstPresentationCollectorRejectsInconsistentTiming(t *testing.T) { | |||
| 405 | 661 | } { | |
| 406 | 662 | t.Run(test.name, func(t *testing.T) { | |
| 407 | 663 | temp := t.TempDir() | |
| 408 | - | runtime := filepath.Join(temp, "cef-147-runtime") | |
| 409 | - | require.NoError(t, os.Mkdir(runtime, 0o755)) | |
| 664 | + | runtime := collectorFakeCEFRuntime(t, 150) | |
| 410 | 665 | binary := filepath.Join(temp, "dumber") | |
| 411 | 666 | log := strings.Replace(validFirstPresentationLog, test.old, test.new, 1) | |
| 412 | 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 | 671 | cmd := exec.Command(filepath.Join(repoRoot, "scripts", "collect_first_presentation.sh")) | |
| 415 | 672 | cmd.Dir = repoRoot | |
| @@ -417,8 +674,10 @@ func TestFirstPresentationCollectorRejectsInconsistentTiming(t *testing.T) { | |||
| 417 | 674 | "DISPLAY=:test", | |
| 418 | 675 | "DUMBER_CEF_DIR="+runtime, | |
| 419 | 676 | "DUMBER_FIRST_PRESENTATION_BIN="+binary, | |
| 677 | + | "DUMBER_BUILD_MANIFEST="+manifest, | |
| 420 | 678 | "DUMBER_FIRST_PRESENTATION_OUTPUT="+filepath.Join(temp, "artifacts"), | |
| 421 | 679 | "DUMBER_FIRST_PRESENTATION_TIMEOUT_SECONDS=1", | |
| 680 | + | "PATH="+filepath.Dir(goBin)+":"+os.Getenv("PATH"), | |
| 422 | 681 | ) | |
| 423 | 682 | result, err := cmd.CombinedOutput() | |
| 424 | 683 | require.Errorf(t, err, "collector accepted malformed timing artifact: %s", result) | |
| @@ -1,6 +1,8 @@ | |||||
| 1 | package cef | 1 | 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 = repoRoot | 145 | 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.4 | 205 | + | 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 = repoRoot | 209 | 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 string | 250 | + | 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 = repoRoot | 266 | 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/sh | 497 | script := fmt.Sprintf(`#!/bin/sh | ||
| 290 | - | if [ "$1" = "list" ] && [ "$2" = "-m" ] && [ "$3" = "-json" ] && [ "$4" = "github.com/bnema/purego-cef2gtk" ]; then | 498 | + | 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 0 | 500 | exit 0 | ||
| 297 | fi | 501 | fi | ||
| 298 | exit 1 | 502 | 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 goBin | 505 | 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 = repoRoot | 572 | 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 environment | 604 | 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/sh | 619 | 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" >&2 | 620 | echo "fatal: detected dubious ownership in repository" >&2 | ||
| 365 | exit 128 | 621 | 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 = repoRoot | 672 | 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 { | |||
| 179 | 179 | initialBrowserCreateResizeHandled bool | |
| 180 | 180 | ||
| 181 | 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 | 187 | pendingURI string | |
| 183 | 188 | pendingURISetAt time.Time | |
| 184 | 189 | pendingURIStartedAt time.Time | |
| 190 | + | pendingIntentID uint64 | |
| 191 | + | pendingIssued bool | |
| 192 | + | pendingIntentNext uint64 | |
| 185 | 193 | ||
| 186 | 194 | // GTK sync dispatch hooks are injectable for tests. Production uses the GTK | |
| 187 | 195 | // default main context through runOnGTK and isOnGTKThread. | |
| @@ -1905,11 +1913,17 @@ func (wv *WebView) pendingNavigationURI() string { | |||
| 1905 | 1913 | func (wv *WebView) setPendingNavigationLocked(uri string, at time.Time) { | |
| 1906 | 1914 | wv.pendingURI = uri | |
| 1907 | 1915 | wv.pendingURIStartedAt = time.Time{} | |
| 1916 | + | wv.pendingIssued = false | |
| 1908 | 1917 | if uri == "" { | |
| 1909 | 1918 | wv.pendingURISetAt = time.Time{} | |
| 1919 | + | wv.pendingIntentID = 0 | |
| 1910 | 1920 | return | |
| 1911 | 1921 | } | |
| 1912 | 1922 | 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 | 1929 | func (wv *WebView) markPendingNavigationStartedLocked(uri string, at time.Time) { | |
| @@ -1924,6 +1938,8 @@ func (wv *WebView) clearPendingNavigationLocked() string { | |||
| 1924 | 1938 | wv.pendingURI = "" | |
| 1925 | 1939 | wv.pendingURISetAt = time.Time{} | |
| 1926 | 1940 | wv.pendingURIStartedAt = time.Time{} | |
| 1941 | + | wv.pendingIntentID = 0 | |
| 1942 | + | wv.pendingIssued = false | |
| 1927 | 1943 | return cleared | |
| 1928 | 1944 | } | |
| 1929 | 1945 | ||
| @@ -1949,8 +1965,16 @@ func (wv *WebView) schedulePendingNavigationReplay(attempt int) { | |||
| 1949 | 1965 | if wv == nil || wv.destroyed.Load() { | |
| 1950 | 1966 | 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 | 1976 | task := cefNewTask(cefTaskFunc(func() { | |
| 1953 | - | wv.replayPendingNavigation(attempt) | |
| 1977 | + | wv.replayPendingNavigationForIntent(attempt, intentID) | |
| 1954 | 1978 | })) | |
| 1955 | 1979 | if task == nil { | |
| 1956 | 1980 | 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 | 2014 | if wv == nil || wv.destroyed.Load() { | |
| 1987 | 2015 | return | |
| 1988 | 2016 | } | |
| 1989 | 2017 | wv.mu.RLock() | |
| 1990 | 2018 | uri := wv.pendingURI | |
| 1991 | 2019 | browser := wv.browser | |
| 2020 | + | currentID := wv.pendingIntentID | |
| 2021 | + | issued := wv.pendingIssued | |
| 1992 | 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 | 2029 | return | |
| 1995 | 2030 | } | |
| 1996 | 2031 | frame := browser.GetMainFrame() | |
| @@ -2016,20 +2051,38 @@ func (wv *WebView) replayPendingNavigation(attempt int) { | |||
| 2016 | 2051 | currentURL := frame.GetURL() | |
| 2017 | 2052 | if pendingURIEquivalent(currentURL, uri) { | |
| 2018 | 2053 | wv.mu.Lock() | |
| 2019 | - | wv.clearPendingNavigationIfEquivalentLocked(uri) | |
| 2054 | + | submitted := wv.pendingIntentID == intentID && wv.pendingIssued | |
| 2055 | + | if submitted { | |
| 2056 | + | wv.clearPendingNavigationIfEquivalentLocked(uri) | |
| 2057 | + | } | |
| 2020 | 2058 | 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 | |
| 2026 | 2067 | } | |
| 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) | |
| 2028 | 2085 | } | |
| 2029 | - | wv.mu.Lock() | |
| 2030 | - | wv.markPendingNavigationStartedLocked(uri, time.Now()) | |
| 2031 | - | wv.mu.Unlock() | |
| 2032 | - | frame.LoadURL(uri) | |
| 2033 | 2086 | if wv.ctx != nil { | |
| 2034 | 2087 | logging.FromContext(wv.ctx).Debug(). | |
| 2035 | 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 | 2162 | func pendingURIEquivalent(a, b string) bool { | |
| 2042 | 2163 | if a == "" || b == "" { | |
| 2043 | 2164 | return false | |
| @@ -179,9 +179,17 @@ type WebView struct { | |||||
| 179 | initialBrowserCreateResizeHandled bool | 179 | 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 string | 187 | pendingURI string | ||
| 183 | pendingURISetAt time.Time | 188 | pendingURISetAt time.Time | ||
| 184 | pendingURIStartedAt time.Time | 189 | 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 GTK | 194 | // 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 = uri | 1914 | 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 | return | 1920 | return | ||
| 1911 | } | 1921 | } | ||
| 1912 | wv.pendingURISetAt = at | 1922 | 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 cleared | 1943 | 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 | return | 1966 | 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 | return | 1980 | 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 | return | 2015 | return | ||
| 1988 | } | 2016 | } | ||
| 1989 | wv.mu.RLock() | 2017 | wv.mu.RLock() | ||
| 1990 | uri := wv.pendingURI | 2018 | uri := wv.pendingURI | ||
| 1991 | browser := wv.browser | 2019 | 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 | return | 2029 | 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 | - | 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) | |||
| 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 false | 2164 | return false | ||
M internal/infrastructure/cef/webview_navigation_test.go +294 -6
| @@ -2,6 +2,7 @@ package cef | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "sync" | |
| 5 | 6 | "testing" | |
| 6 | 7 | "time" | |
| 7 | 8 | ||
| @@ -16,15 +17,72 @@ func TestWebViewReplayPendingNavigation_LoadsQueuedURIWhenMainFrameAvailable(t * | |||
| 16 | 17 | frame.EXPECT().GetURL().Return("").Once() | |
| 17 | 18 | frame.EXPECT().LoadURL("https://github.com/bnema").Once() | |
| 18 | 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"} | |
| 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) | |
| 22 | 25 | ||
| 23 | 26 | wv.mu.RLock() | |
| 24 | 27 | defer wv.mu.RUnlock() | |
| 25 | 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 | 86 | func TestWebViewReplayPendingNavigation_RetriesWhenMainFrameUnavailable(t *testing.T) { | |
| 29 | 87 | browser := cefmocks.NewMockBrowser(t) | |
| 30 | 88 | browser.EXPECT().GetMainFrame().Return(purecef.Frame(nil)).Once() | |
| @@ -46,8 +104,9 @@ func TestWebViewReplayPendingNavigation_RetriesWhenMainFrameUnavailable(t *testi | |||
| 46 | 104 | return 1 | |
| 47 | 105 | } | |
| 48 | 106 | ||
| 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) | |
| 51 | 110 | ||
| 52 | 111 | require.True(t, scheduled) | |
| 53 | 112 | wv.mu.RLock() | |
| @@ -88,7 +147,8 @@ func TestWebViewSchedulePendingNavigationReplay_RetriesWhenTaskPostFails(t *test | |||
| 88 | 147 | 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 | 152 | wv.schedulePendingNavigationReplay(0) | |
| 93 | 153 | ||
| 94 | 154 | require.True(t, retried) | |
| @@ -101,6 +161,7 @@ func TestWebViewReplayPendingNavigation_UsesCurrentBrowserAtExecutionTime(t *tes | |||
| 101 | 161 | frame.EXPECT().GetURL().Return("").Once() | |
| 102 | 162 | frame.EXPECT().LoadURL("https://github.com/bnema").Once() | |
| 103 | 163 | activeBrowser.EXPECT().GetMainFrame().Return(frame).Once() | |
| 164 | + | activeBrowser.EXPECT().GetIdentifier().Return(int32(2)).Twice() | |
| 104 | 165 | ||
| 105 | 166 | oldTask := cefNewTask | |
| 106 | 167 | oldPost := cefPostTask | |
| @@ -116,7 +177,8 @@ func TestWebViewReplayPendingNavigation_UsesCurrentBrowserAtExecutionTime(t *tes | |||
| 116 | 177 | 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 | 182 | wv.schedulePendingNavigationReplay(0) | |
| 121 | 183 | wv.mu.Lock() | |
| 122 | 184 | wv.browser = activeBrowser | |
| @@ -130,6 +192,7 @@ func TestWebViewLoadURI_QueuesPendingNavigationReplayForExistingBrowser(t *testi | |||
| 130 | 192 | browser := cefmocks.NewMockBrowser(t) | |
| 131 | 193 | frame := cefmocks.NewMockFrame(t) | |
| 132 | 194 | browser.EXPECT().GetMainFrame().Return(frame).Once() | |
| 195 | + | browser.EXPECT().GetIdentifier().Return(int32(1)).Twice() | |
| 133 | 196 | frame.EXPECT().GetURL().Return("").Once() | |
| 134 | 197 | frame.EXPECT().LoadURL("github.com/bnema").Once() | |
| 135 | 198 | ||
| @@ -221,3 +284,228 @@ func TestWebViewUpdateLoadState_KeepsPendingNavigationWhileLoading(t *testing.T) | |||
| 221 | 284 | ||
| 222 | 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 | + | } | |
| @@ -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 1 | 104 | 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 1 | 147 | 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 := cefNewTask | 166 | oldTask := cefNewTask | ||
| 106 | oldPost := cefPostTask | 167 | oldPost := cefPostTask | ||
| @@ -116,7 +177,8 @@ func TestWebViewReplayPendingNavigation_UsesCurrentBrowserAtExecutionTime(t *tes | |||||
| 116 | return 1 | 177 | 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 = activeBrowser | 184 | 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 ( | |||
| 11 | 11 | ||
| 12 | 12 | "github.com/bnema/dumber/internal/application/port" | |
| 13 | 13 | "github.com/bnema/dumber/internal/domain/entity" | |
| 14 | + | "github.com/bnema/dumber/internal/infrastructure/process" | |
| 14 | 15 | "github.com/bnema/dumber/internal/logging" | |
| 15 | 16 | ) | |
| 16 | 17 | ||
| @@ -500,7 +501,10 @@ func launchBrowserURL( | |||
| 500 | 501 | } | |
| 501 | 502 | ||
| 502 | 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 | 508 | if freshWindow { | |
| 505 | 509 | cmd.Env = append(cmd.Env, FreshWindowLaunchEnvVar+"=1") | |
| 506 | 510 | } | |
| @@ -599,7 +603,8 @@ func (s *SessionSpawner) SpawnWithSession(sessionID entity.SessionID) error { | |||
| 599 | 603 | // Start dumber browse with session ID in environment | |
| 600 | 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 | 608 | overrides := []string{RestoreSessionEnvVar + "=" + string(sessionID)} | |
| 604 | 609 | if s.spawnEnv != nil { | |
| 605 | 610 | 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 environment | 603 | // 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) { | |||
| 292 | 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 | + | } | |
| @@ -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 @@ | |||
| 2 | 2 | package component | |
| 3 | 3 | ||
| 4 | 4 | import ( | |
| 5 | + | "errors" | |
| 5 | 6 | "sync" | |
| 6 | 7 | ||
| 7 | 8 | "github.com/bnema/dumber/assets" | |
| @@ -9,26 +10,45 @@ import ( | |||
| 9 | 10 | "github.com/bnema/puregotk/v4/glib" | |
| 10 | 11 | ) | |
| 11 | 12 | ||
| 13 | + | var errLogoDecode = errors.New("logo decode failed") | |
| 14 | + | ||
| 12 | 15 | var ( | |
| 13 | 16 | logoTexture *gdk.Texture | |
| 14 | 17 | 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 | 39 | // GetLogoTexture returns a cached GDK texture of the dumber logo. | |
| 18 | 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 | 43 | // Returns nil if the logo cannot be loaded. | |
| 20 | 44 | func GetLogoTexture() *gdk.Texture { | |
| 21 | 45 | logoTextureOnce.Do(func() { | |
| 22 | - | data := assets.LogoSVG | |
| 46 | + | data := logoAssetBytes() | |
| 23 | 47 | if len(data) == 0 { | |
| 24 | 48 | return | |
| 25 | 49 | } | |
| 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 { | |
| 32 | 52 | return | |
| 33 | 53 | } | |
| 34 | 54 | logoTexture = texture | |
| @@ -2,6 +2,7 @@ | |||||
| 2 | package component | 2 | 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.Texture | 16 | logoTexture *gdk.Texture | ||
| 14 | logoTextureOnce sync.Once | 17 | 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.LogoSVG | 46 | + | data := logoAssetBytes() |
| 23 | if len(data) == 0 { | 47 | if len(data) == 0 { | ||
| 24 | return | 48 | 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 | return | 52 | return | ||
| 33 | } | 53 | } | ||
| 34 | logoTexture = texture | 54 | 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 | |||
| 6 | 6 | ||
| 7 | 7 | readonly runs=5 | |
| 8 | 8 | readonly timeout_seconds="${DUMBER_FIRST_PRESENTATION_TIMEOUT_SECONDS:-45}" | |
| 9 | - | readonly runtime="${DUMBER_CEF_DIR:-$HOME/.local/share/cef-147-runtime}" | |
| 10 | 9 | readonly binary="${DUMBER_FIRST_PRESENTATION_BIN:-$PWD/dist/dumber}" | |
| 10 | + | readonly manifest="${DUMBER_BUILD_MANIFEST:-${binary}.manifest.json}" | |
| 11 | 11 | ||
| 12 | 12 | fail_unsafe_output() { | |
| 13 | 13 | echo "first-presentation: unsafe output path: $1" >&2 | |
| 14 | 14 | 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 | 28 | if [[ -v DUMBER_FIRST_PRESENTATION_OUTPUT ]]; then | |
| 18 | 29 | output="$DUMBER_FIRST_PRESENTATION_OUTPUT" | |
| 19 | 30 | else | |
| @@ -31,87 +42,112 @@ else | |||
| 31 | 42 | fi | |
| 32 | 43 | readonly output | |
| 33 | 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 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:] | |
| 40 | 56 | ||
| 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. | |
| 53 | 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(): | |
| 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: | |
| 68 | 74 | raise SystemExit("first-presentation: immutable module provenance is unavailable") | |
| 69 | 75 | ||
| 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): | |
| 109 | 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 | 138 | 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 | 151 | # The artifact destination is caller-controlled. Never empty or recursively | |
| 116 | 152 | # clear it: collection only writes to a newly-created directory whose parent is | |
| 117 | 153 | # already canonical and contains no symbolic-link hop. | |
| @@ -141,12 +177,22 @@ prepare_output() { | |||
| 141 | 177 | ||
| 142 | 178 | prepare_output | |
| 143 | 179 | ||
| 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; } | |
| 146 | 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 $? | |
| 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 | |
| 150 | 196 | ||
| 151 | 197 | # Raw logs and temporary XDG homes may contain machine-local paths. Keep them | |
| 152 | 198 | # outside the committed artifact directory and always remove them. | |
| @@ -157,11 +203,10 @@ cleanup_work_root() { | |||
| 157 | 203 | rm -rf -- "$work_root" | |
| 158 | 204 | } | |
| 159 | 205 | 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) | |
| 165 | 210 | ||
| 166 | 211 | # These deliberately coarse labels support comparison without exposing a host, | |
| 167 | 212 | # device name, driver version, path, or environment dump. | |
| @@ -175,10 +220,10 @@ if gpu_profile not in allowed_gpu_profiles: | |||
| 175 | 220 | ||
| 176 | 221 | json.dump({ | |
| 177 | 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 | 224 | "binary": {"label": "dumber", "sha256": binary_sha256}, | |
| 180 | 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 | 227 | "upstream": {"module": upstream_module, "version": upstream_version, "tag": upstream_tag, "revision": upstream_revision}, | |
| 183 | 228 | "comparison": {"os": os_label, "architecture": architecture, "display_protocol": display_protocol, "machine_gpu_profile": gpu_profile}, | |
| 184 | 229 | "render_configuration": {"backend": "gdk-dmabuf", "buffer_sharing": "dmabuf", "renderer": "vulkan"} | |
| @@ -197,16 +242,19 @@ format = "json" | |||
| 197 | 242 | enable_file_log = false | |
| 198 | 243 | ||
| 199 | 244 | [engine.cef] | |
| 200 | - | cef_dir = "$runtime" | |
| 245 | + | cef_dir = "$selected_cef_dir" | |
| 201 | 246 | EOF | |
| 202 | 247 | # Every directory below is newly created for this one launch. Do not inherit | |
| 203 | 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 | 251 | set +e | |
| 205 | 252 | env -i \ | |
| 206 | 253 | HOME="$HOME" PATH="$PATH" LANG="${LANG:-C.UTF-8}" \ | |
| 207 | 254 | WAYLAND_DISPLAY="${WAYLAND_DISPLAY:-}" DISPLAY="${DISPLAY:-}" XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-}" \ | |
| 208 | 255 | DBUS_SESSION_BUS_ADDRESS="${DBUS_SESSION_BUS_ADDRESS:-}" XAUTHORITY="${XAUTHORITY:-}" \ | |
| 209 | 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 | 258 | DUMBER_CEF_ROOT_CACHE_PATH="$root/cef-root-cache" DUMBER_RENDER_STACK="vulkan-dmabuf" \ | |
| 211 | 259 | PUREGO_CEF2GTK_BACKEND="gdk-dmabuf" PUREGO_CEF2GTK_ANGLE_BACKEND="vulkan" GSK_RENDERER="vulkan" \ | |
| 212 | 260 | 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=5 | 7 | 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" >&2 | 13 | echo "first-presentation: unsafe output path: $1" >&2 | ||
| 14 | exit 2 | 14 | 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 ]]; then | 28 | if [[ -v DUMBER_FIRST_PRESENTATION_OUTPUT ]]; then | ||
| 18 | output="$DUMBER_FIRST_PRESENTATION_OUTPUT" | 29 | output="$DUMBER_FIRST_PRESENTATION_OUTPUT" | ||
| 19 | else | 30 | else | ||
| @@ -31,87 +42,112 @@ else | |||||
| 31 | fi | 42 | fi | ||
| 32 | readonly output | 43 | 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 VCS | 48 | + | # 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_metadata | 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:] | |||
| 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" >&2 | 58 | + | # 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 is | 84 | + | 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 | PY | 138 | 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 recursively | 151 | # The artifact destination is caller-controlled. Never empty or recursively | ||
| 116 | # clear it: collection only writes to a newly-created directory whose parent is | 152 | # 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_output | 178 | 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_revision | 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 | |||
| 150 | 196 | ||||
| 151 | # Raw logs and temporary XDG homes may contain machine-local paths. Keep them | 197 | # 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 EXIT | 205 | 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, sys | 207 | + | 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 = false | 242 | enable_file_log = false | ||
| 198 | 243 | ||||
| 199 | [engine.cef] | 244 | [engine.cef] | ||
| 200 | - | cef_dir = "$runtime" | 245 | + | cef_dir = "$selected_cef_dir" |
| 201 | EOF | 246 | EOF | ||
| 202 | # Every directory below is newly created for this one launch. Do not inherit | 247 | # 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 +e | 251 | 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>&1 | 260 | 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() | |||