Merge pull request #169 from bnema/feat/purego-callback-lifecycle
feat(lifecycle): coalesce idle callbacks and bump puregotk dependencies
14 files · +321 -50
M .github/workflows/ci.yml +4 -0
| @@ -109,3 +109,7 @@ jobs: | |||
| 109 | 109 | GDK_BACKEND: wayland | |
| 110 | 110 | WAYLAND_DISPLAY: wayland-0 | |
| 111 | 111 | run: go test -v ./... | |
| 112 | + | - name: Verify purego-only constraints | |
| 113 | + | run: make verify-purego | |
| 114 | + | - name: Verify CGO-disabled build | |
| 115 | + | run: CGO_ENABLED=0 go build -buildvcs=false -o /tmp/dumber ./cmd/dumber && rm -f /tmp/dumber | |
| @@ -109,3 +109,7 @@ jobs: | |||||
| 109 | GDK_BACKEND: wayland | 109 | GDK_BACKEND: wayland | ||
| 110 | WAYLAND_DISPLAY: wayland-0 | 110 | WAYLAND_DISPLAY: wayland-0 | ||
| 111 | run: go test -v ./... | 111 | run: go test -v ./... | ||
| 112 | + | - name: Verify purego-only constraints | |||
| 113 | + | run: make verify-purego | |||
| 114 | + | - name: Verify CGO-disabled build | |||
| 115 | + | run: CGO_ENABLED=0 go build -buildvcs=false -o /tmp/dumber ./cmd/dumber && rm -f /tmp/dumber | |||
M .gitignore +2 -0
| @@ -79,6 +79,8 @@ notes/ | |||
| 79 | 79 | docs/plans/ | |
| 80 | 80 | scripts/ | |
| 81 | 81 | !scripts/generate-types.js | |
| 82 | + | !scripts/verify_purego_only.sh | |
| 83 | + | !scripts/stress_omnibox_callbacks.go | |
| 82 | 84 | specs/ | |
| 83 | 85 | templates/ | |
| 84 | 86 | .serena/ | |
| @@ -79,6 +79,8 @@ notes/ | |||||
| 79 | docs/plans/ | 79 | docs/plans/ | ||
| 80 | scripts/ | 80 | scripts/ | ||
| 81 | !scripts/generate-types.js | 81 | !scripts/generate-types.js | ||
| 82 | + | !scripts/verify_purego_only.sh | |||
| 83 | + | !scripts/stress_omnibox_callbacks.go | |||
| 82 | specs/ | 84 | specs/ | ||
| 83 | templates/ | 85 | templates/ | ||
| 84 | .serena/ | 86 | .serena/ | ||
M Makefile +9 -1
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | # Makefile for dumber (Clean Architecture - puregotk) | |
| 2 | 2 | ||
| 3 | - | .PHONY: build build-frontend test lint clean install-tools dev generate help init check-docs man flatpak-deps flatpak-build flatpak-install flatpak-run flatpak-clean | |
| 3 | + | .PHONY: build build-frontend test lint clean install-tools dev generate help init check-docs man flatpak-deps flatpak-build flatpak-install flatpak-run flatpak-clean stress-omnibox-callbacks verify-purego | |
| 4 | 4 | ||
| 5 | 5 | # Load local overrides from .env.local if present (Makefile syntax) | |
| 6 | 6 | ifneq (,$(wildcard .env.local)) | |
| @@ -84,6 +84,12 @@ test-cover: ## Run tests with coverage | |||
| 84 | 84 | go tool cover -html=coverage.out -o coverage.html | |
| 85 | 85 | @echo "Coverage report: coverage.html" | |
| 86 | 86 | ||
| 87 | + | stress-omnibox-callbacks: ## Run placeholder omnibox callback stress harness | |
| 88 | + | GOFLAGS=-mod=mod CGO_ENABLED=0 go run ./scripts/stress_omnibox_callbacks.go | |
| 89 | + | ||
| 90 | + | verify-purego: ## Ensure callback path stays cgo/export free | |
| 91 | + | bash ./scripts/verify_purego_only.sh | |
| 92 | + | ||
| 87 | 93 | # Linting | |
| 88 | 94 | lint: ## Run golangci-lint | |
| 89 | 95 | @echo "Running golangci-lint..." | |
| @@ -135,6 +141,8 @@ check: ## Check that all tools and dependencies are working | |||
| 135 | 141 | @$(MAKE) build-quick | |
| 136 | 142 | @echo "\nRunning tests..." | |
| 137 | 143 | @$(MAKE) test | |
| 144 | + | @echo "\nVerifying purego-only constraints..." | |
| 145 | + | @$(MAKE) verify-purego | |
| 138 | 146 | @echo "\nAll checks passed!" | |
| 139 | 147 | ||
| 140 | 148 | # Documentation | |
| @@ -1,6 +1,6 @@ | |||||
| 1 | # Makefile for dumber (Clean Architecture - puregotk) | 1 | # Makefile for dumber (Clean Architecture - puregotk) | ||
| 2 | 2 | ||||
| 3 | - | .PHONY: build build-frontend test lint clean install-tools dev generate help init check-docs man flatpak-deps flatpak-build flatpak-install flatpak-run flatpak-clean | 3 | + | .PHONY: build build-frontend test lint clean install-tools dev generate help init check-docs man flatpak-deps flatpak-build flatpak-install flatpak-run flatpak-clean stress-omnibox-callbacks verify-purego |
| 4 | 4 | ||||
| 5 | # Load local overrides from .env.local if present (Makefile syntax) | 5 | # Load local overrides from .env.local if present (Makefile syntax) | ||
| 6 | ifneq (,$(wildcard .env.local)) | 6 | ifneq (,$(wildcard .env.local)) | ||
| @@ -84,6 +84,12 @@ test-cover: ## Run tests with coverage | |||||
| 84 | go tool cover -html=coverage.out -o coverage.html | 84 | go tool cover -html=coverage.out -o coverage.html | ||
| 85 | @echo "Coverage report: coverage.html" | 85 | @echo "Coverage report: coverage.html" | ||
| 86 | 86 | ||||
| 87 | + | stress-omnibox-callbacks: ## Run placeholder omnibox callback stress harness | |||
| 88 | + | GOFLAGS=-mod=mod CGO_ENABLED=0 go run ./scripts/stress_omnibox_callbacks.go | |||
| 89 | + | ||||
| 90 | + | verify-purego: ## Ensure callback path stays cgo/export free | |||
| 91 | + | bash ./scripts/verify_purego_only.sh | |||
| 92 | + | ||||
| 87 | # Linting | 93 | # Linting | ||
| 88 | lint: ## Run golangci-lint | 94 | lint: ## Run golangci-lint | ||
| 89 | @echo "Running golangci-lint..." | 95 | @echo "Running golangci-lint..." | ||
| @@ -135,6 +141,8 @@ check: ## Check that all tools and dependencies are working | |||||
| 135 | @$(MAKE) build-quick | 141 | @$(MAKE) build-quick | ||
| 136 | @echo "\nRunning tests..." | 142 | @echo "\nRunning tests..." | ||
| 137 | @$(MAKE) test | 143 | @$(MAKE) test | ||
| 144 | + | @echo "\nVerifying purego-only constraints..." | |||
| 145 | + | @$(MAKE) verify-purego | |||
| 138 | @echo "\nAll checks passed!" | 146 | @echo "\nAll checks passed!" | ||
| 139 | 147 | ||||
| 140 | # Documentation | 148 | # Documentation | ||
M go.mod +3 -3
| @@ -3,14 +3,14 @@ module github.com/bnema/dumber | |||
| 3 | 3 | go 1.25.3 | |
| 4 | 4 | ||
| 5 | 5 | require ( | |
| 6 | - | github.com/bnema/puregotk-webkit v0.0.12 | |
| 6 | + | github.com/bnema/puregotk-webkit v0.0.13 | |
| 7 | 7 | github.com/charmbracelet/bubbles v1.0.0 | |
| 8 | 8 | github.com/charmbracelet/bubbletea v1.3.10 | |
| 9 | 9 | github.com/charmbracelet/lipgloss v1.1.0 | |
| 10 | 10 | github.com/fsnotify/fsnotify v1.9.0 | |
| 11 | 11 | github.com/godbus/dbus/v5 v5.2.2 | |
| 12 | 12 | github.com/jwijenbergh/purego v0.0.0-20251017112123-b71757b9ba42 | |
| 13 | - | github.com/jwijenbergh/puregotk v0.0.0-20260115100645-c78e1521129b | |
| 13 | + | github.com/jwijenbergh/puregotk v0.0.0-20260212181341-fb13c245f9f0 | |
| 14 | 14 | github.com/ncruces/go-sqlite3 v0.30.5 | |
| 15 | 15 | github.com/pelletier/go-toml/v2 v2.2.4 | |
| 16 | 16 | github.com/pressly/goose/v3 v3.26.0 | |
| @@ -23,7 +23,7 @@ require ( | |||
| 23 | 23 | gopkg.in/yaml.v3 v3.0.1 | |
| 24 | 24 | ) | |
| 25 | 25 | ||
| 26 | - | replace github.com/jwijenbergh/puregotk => github.com/bnema/puregotk v0.0.0-20260208062357-b1825488ed92 | |
| 26 | + | replace github.com/jwijenbergh/puregotk => github.com/bnema/puregotk v0.0.0-20260212181341-fb13c245f9f0 | |
| 27 | 27 | ||
| 28 | 28 | require ( | |
| 29 | 29 | github.com/atotto/clipboard v0.1.4 // indirect | |
| @@ -3,14 +3,14 @@ module github.com/bnema/dumber | |||||
| 3 | go 1.25.3 | 3 | go 1.25.3 | ||
| 4 | 4 | ||||
| 5 | require ( | 5 | require ( | ||
| 6 | - | github.com/bnema/puregotk-webkit v0.0.12 | 6 | + | github.com/bnema/puregotk-webkit v0.0.13 |
| 7 | github.com/charmbracelet/bubbles v1.0.0 | 7 | github.com/charmbracelet/bubbles v1.0.0 | ||
| 8 | github.com/charmbracelet/bubbletea v1.3.10 | 8 | github.com/charmbracelet/bubbletea v1.3.10 | ||
| 9 | github.com/charmbracelet/lipgloss v1.1.0 | 9 | github.com/charmbracelet/lipgloss v1.1.0 | ||
| 10 | github.com/fsnotify/fsnotify v1.9.0 | 10 | github.com/fsnotify/fsnotify v1.9.0 | ||
| 11 | github.com/godbus/dbus/v5 v5.2.2 | 11 | github.com/godbus/dbus/v5 v5.2.2 | ||
| 12 | github.com/jwijenbergh/purego v0.0.0-20251017112123-b71757b9ba42 | 12 | github.com/jwijenbergh/purego v0.0.0-20251017112123-b71757b9ba42 | ||
| 13 | - | github.com/jwijenbergh/puregotk v0.0.0-20260115100645-c78e1521129b | 13 | + | github.com/jwijenbergh/puregotk v0.0.0-20260212181341-fb13c245f9f0 |
| 14 | github.com/ncruces/go-sqlite3 v0.30.5 | 14 | github.com/ncruces/go-sqlite3 v0.30.5 | ||
| 15 | github.com/pelletier/go-toml/v2 v2.2.4 | 15 | github.com/pelletier/go-toml/v2 v2.2.4 | ||
| 16 | github.com/pressly/goose/v3 v3.26.0 | 16 | github.com/pressly/goose/v3 v3.26.0 | ||
| @@ -23,7 +23,7 @@ require ( | |||||
| 23 | gopkg.in/yaml.v3 v3.0.1 | 23 | gopkg.in/yaml.v3 v3.0.1 | ||
| 24 | ) | 24 | ) | ||
| 25 | 25 | ||||
| 26 | - | replace github.com/jwijenbergh/puregotk => github.com/bnema/puregotk v0.0.0-20260208062357-b1825488ed92 | 26 | + | replace github.com/jwijenbergh/puregotk => github.com/bnema/puregotk v0.0.0-20260212181341-fb13c245f9f0 |
| 27 | 27 | ||||
| 28 | require ( | 28 | require ( | ||
| 29 | github.com/atotto/clipboard v0.1.4 // indirect | 29 | github.com/atotto/clipboard v0.1.4 // indirect | ||
M go.sum +4 -4 generated file: collapsed
M internal/domain/download/filename.go +20 -0
| @@ -46,6 +46,22 @@ func SanitizeFilenameWithExtension(name, mimeType string) string { | |||
| 46 | 46 | return clean | |
| 47 | 47 | } | |
| 48 | 48 | ||
| 49 | + | // preferredExtensions maps MIME types whose stdlib extension list is | |
| 50 | + | // platform-dependent (alphabetical order) to the canonical extension. | |
| 51 | + | // Without this, mime.ExtensionsByType("text/html") may return ".ehtml" | |
| 52 | + | // instead of ".html" depending on the system MIME database. | |
| 53 | + | var preferredExtensions = map[string]string{ | |
| 54 | + | "text/html": ".html", | |
| 55 | + | "text/plain": ".txt", | |
| 56 | + | "text/xml": ".xml", | |
| 57 | + | "application/xhtml+xml": ".xhtml", | |
| 58 | + | "image/jpeg": ".jpg", | |
| 59 | + | "image/svg+xml": ".svg", | |
| 60 | + | "audio/mpeg": ".mp3", | |
| 61 | + | "video/mp4": ".mp4", | |
| 62 | + | "application/octet-stream": ".bin", | |
| 63 | + | } | |
| 64 | + | ||
| 49 | 65 | // GetExtensionFromMimeType returns a file extension for a given MIME type. | |
| 50 | 66 | // Returns empty string if MIME type is unknown or empty. | |
| 51 | 67 | // Handles MIME types with parameters (e.g., "application/pdf; charset=binary"). | |
| @@ -61,6 +77,10 @@ func GetExtensionFromMimeType(mimeType string) string { | |||
| 61 | 77 | return "" | |
| 62 | 78 | } | |
| 63 | 79 | ||
| 80 | + | if ext, ok := preferredExtensions[mediaType]; ok { | |
| 81 | + | return ext | |
| 82 | + | } | |
| 83 | + | ||
| 64 | 84 | exts, err := mime.ExtensionsByType(mediaType) | |
| 65 | 85 | if err != nil || len(exts) == 0 { | |
| 66 | 86 | return "" | |
| @@ -46,6 +46,22 @@ func SanitizeFilenameWithExtension(name, mimeType string) string { | |||||
| 46 | return clean | 46 | return clean | ||
| 47 | } | 47 | } | ||
| 48 | 48 | ||||
| 49 | + | // preferredExtensions maps MIME types whose stdlib extension list is | |||
| 50 | + | // platform-dependent (alphabetical order) to the canonical extension. | |||
| 51 | + | // Without this, mime.ExtensionsByType("text/html") may return ".ehtml" | |||
| 52 | + | // instead of ".html" depending on the system MIME database. | |||
| 53 | + | var preferredExtensions = map[string]string{ | |||
| 54 | + | "text/html": ".html", | |||
| 55 | + | "text/plain": ".txt", | |||
| 56 | + | "text/xml": ".xml", | |||
| 57 | + | "application/xhtml+xml": ".xhtml", | |||
| 58 | + | "image/jpeg": ".jpg", | |||
| 59 | + | "image/svg+xml": ".svg", | |||
| 60 | + | "audio/mpeg": ".mp3", | |||
| 61 | + | "video/mp4": ".mp4", | |||
| 62 | + | "application/octet-stream": ".bin", | |||
| 63 | + | } | |||
| 64 | + | ||||
| 49 | // GetExtensionFromMimeType returns a file extension for a given MIME type. | 65 | // GetExtensionFromMimeType returns a file extension for a given MIME type. | ||
| 50 | // Returns empty string if MIME type is unknown or empty. | 66 | // Returns empty string if MIME type is unknown or empty. | ||
| 51 | // Handles MIME types with parameters (e.g., "application/pdf; charset=binary"). | 67 | // Handles MIME types with parameters (e.g., "application/pdf; charset=binary"). | ||
| @@ -61,6 +77,10 @@ func GetExtensionFromMimeType(mimeType string) string { | |||||
| 61 | return "" | 77 | return "" | ||
| 62 | } | 78 | } | ||
| 63 | 79 | ||||
| 80 | + | if ext, ok := preferredExtensions[mediaType]; ok { | |||
| 81 | + | return ext | |||
| 82 | + | } | |||
| 83 | + | ||||
| 64 | exts, err := mime.ExtensionsByType(mediaType) | 84 | exts, err := mime.ExtensionsByType(mediaType) | ||
| 65 | if err != nil || len(exts) == 0 { | 85 | if err != nil || len(exts) == 0 { | ||
| 66 | return "" | 86 | return "" | ||
M internal/domain/download/filename_test.go +1 -1
| @@ -147,7 +147,7 @@ func TestGetExtensionFromMimeType(t *testing.T) { | |||
| 147 | 147 | { | |
| 148 | 148 | name: "text html with charset", | |
| 149 | 149 | input: "text/html; charset=utf-8", | |
| 150 | - | expected: ".htm", | |
| 150 | + | expected: ".html", | |
| 151 | 151 | }, | |
| 152 | 152 | { | |
| 153 | 153 | name: "mime type with multiple params", | |
| @@ -147,7 +147,7 @@ func TestGetExtensionFromMimeType(t *testing.T) { | |||||
| 147 | { | 147 | { | ||
| 148 | name: "text html with charset", | 148 | name: "text html with charset", | ||
| 149 | input: "text/html; charset=utf-8", | 149 | input: "text/html; charset=utf-8", | ||
| 150 | - | expected: ".htm", | 150 | + | expected: ".html", |
| 151 | }, | 151 | }, | ||
| 152 | { | 152 | { | ||
| 153 | name: "mime type with multiple params", | 153 | name: "mime type with multiple params", | ||
M internal/ui/component/link_status.go +57 -22
| @@ -4,6 +4,7 @@ package component | |||
| 4 | 4 | import ( | |
| 5 | 5 | "sync" | |
| 6 | 6 | ||
| 7 | + | "github.com/bnema/dumber/internal/ui/mainloop" | |
| 7 | 8 | "github.com/jwijenbergh/puregotk/v4/glib" | |
| 8 | 9 | "github.com/jwijenbergh/puregotk/v4/gtk" | |
| 9 | 10 | ||
| @@ -30,6 +31,9 @@ type LinkStatusOverlay struct { | |||
| 30 | 31 | showTimer uint // GLib timer for delayed show | |
| 31 | 32 | autoHideTimer uint // GLib timer for auto-hide after timeout | |
| 32 | 33 | visible bool // Whether overlay is currently visible (has .visible class) | |
| 34 | + | showTimerCb glib.SourceFunc | |
| 35 | + | autoHideCb glib.SourceFunc | |
| 36 | + | coalescer *mainloop.Coalescer | |
| 33 | 37 | mu sync.Mutex | |
| 34 | 38 | } | |
| 35 | 39 | ||
| @@ -60,12 +64,44 @@ func NewLinkStatusOverlay(factory layout.WidgetFactory) *LinkStatusOverlay { | |||
| 60 | 64 | label.SetMaxWidthChars(linkStatusMaxChars) | |
| 61 | 65 | container.Append(label) | |
| 62 | 66 | ||
| 63 | - | return &LinkStatusOverlay{ | |
| 67 | + | overlay := &LinkStatusOverlay{ | |
| 64 | 68 | factory: factory, | |
| 65 | 69 | container: container, | |
| 66 | 70 | label: label, | |
| 67 | 71 | visible: false, | |
| 68 | 72 | } | |
| 73 | + | overlay.coalescer = mainloop.NewCoalescer(func(fn func()) { | |
| 74 | + | var cb glib.SourceFunc = func(uintptr) bool { | |
| 75 | + | fn() | |
| 76 | + | return false | |
| 77 | + | } | |
| 78 | + | glib.IdleAdd(&cb, 0) | |
| 79 | + | }) | |
| 80 | + | overlay.showTimerCb = func(_ uintptr) bool { | |
| 81 | + | overlay.mu.Lock() | |
| 82 | + | defer overlay.mu.Unlock() | |
| 83 | + | ||
| 84 | + | if overlay.pendingURI != "" { | |
| 85 | + | overlay.label.SetText(overlay.pendingURI) | |
| 86 | + | if !overlay.visible { | |
| 87 | + | overlay.visible = true | |
| 88 | + | overlay.container.AddCssClass("visible") | |
| 89 | + | } | |
| 90 | + | overlay.resetAutoHideTimerLocked() | |
| 91 | + | } | |
| 92 | + | overlay.showTimer = 0 | |
| 93 | + | return false | |
| 94 | + | } | |
| 95 | + | overlay.autoHideCb = func(_ uintptr) bool { | |
| 96 | + | overlay.mu.Lock() | |
| 97 | + | defer overlay.mu.Unlock() | |
| 98 | + | ||
| 99 | + | overlay.autoHideTimer = 0 | |
| 100 | + | overlay.hide() | |
| 101 | + | return false | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | return overlay | |
| 69 | 105 | } | |
| 70 | 106 | ||
| 71 | 107 | // Show displays the link status overlay with the given URI. | |
| @@ -88,25 +124,19 @@ func (l *LinkStatusOverlay) Show(uri string) { | |||
| 88 | 124 | ||
| 89 | 125 | l.pendingURI = uri | |
| 90 | 126 | ||
| 91 | - | // Start delay timer before showing | |
| 92 | - | cb := glib.SourceFunc(func(_ uintptr) bool { | |
| 127 | + | // Coalesce rapid hover events before scheduling delay timer. | |
| 128 | + | l.coalescer.Post("link-status-show-delay", func() { | |
| 93 | 129 | l.mu.Lock() | |
| 94 | 130 | defer l.mu.Unlock() | |
| 95 | 131 | ||
| 96 | - | if l.pendingURI != "" { | |
| 97 | - | l.label.SetText(l.pendingURI) | |
| 98 | - | if !l.visible { | |
| 99 | - | l.visible = true | |
| 100 | - | l.container.AddCssClass("visible") | |
| 101 | - | } | |
| 102 | - | // Start or reset auto-hide timer | |
| 103 | - | l.resetAutoHideTimerLocked() | |
| 132 | + | if l.pendingURI == "" { | |
| 133 | + | return | |
| 104 | 134 | } | |
| 105 | - | l.showTimer = 0 | |
| 106 | - | return false // Don't repeat | |
| 135 | + | if l.showTimer != 0 { | |
| 136 | + | glib.SourceRemove(l.showTimer) | |
| 137 | + | } | |
| 138 | + | l.showTimer = glib.TimeoutAdd(linkStatusShowDelayMs, &l.showTimerCb, 0) | |
| 107 | 139 | }) | |
| 108 | - | ||
| 109 | - | l.showTimer = glib.TimeoutAdd(linkStatusShowDelayMs, &cb, 0) | |
| 110 | 140 | } | |
| 111 | 141 | ||
| 112 | 142 | // Hide manually hides the link status overlay. | |
| @@ -144,17 +174,19 @@ func (l *LinkStatusOverlay) resetAutoHideTimerLocked() { | |||
| 144 | 174 | l.autoHideTimer = 0 | |
| 145 | 175 | } | |
| 146 | 176 | ||
| 147 | - | // Start new auto-hide timer | |
| 148 | - | cb := glib.SourceFunc(func(_ uintptr) bool { | |
| 177 | + | // Coalesce timer resets while pointer moves quickly across links. | |
| 178 | + | l.coalescer.Post("link-status-auto-hide", func() { | |
| 149 | 179 | l.mu.Lock() | |
| 150 | 180 | defer l.mu.Unlock() | |
| 151 | 181 | ||
| 152 | - | l.autoHideTimer = 0 | |
| 153 | - | l.hide() | |
| 154 | - | return false // Don't repeat | |
| 182 | + | if l.pendingURI == "" { | |
| 183 | + | return | |
| 184 | + | } | |
| 185 | + | if l.autoHideTimer != 0 { | |
| 186 | + | glib.SourceRemove(l.autoHideTimer) | |
| 187 | + | } | |
| 188 | + | l.autoHideTimer = glib.TimeoutAdd(linkStatusAutoHideMs, &l.autoHideCb, 0) | |
| 155 | 189 | }) | |
| 156 | - | ||
| 157 | - | l.autoHideTimer = glib.TimeoutAdd(linkStatusAutoHideMs, &cb, 0) | |
| 158 | 190 | } | |
| 159 | 191 | ||
| 160 | 192 | // Widget returns the underlying widget for embedding in overlays. | |
| @@ -185,6 +217,9 @@ func (l *LinkStatusOverlay) Cleanup() { | |||
| 185 | 217 | glib.SourceRemove(l.autoHideTimer) | |
| 186 | 218 | l.autoHideTimer = 0 | |
| 187 | 219 | } | |
| 220 | + | if l.coalescer != nil { | |
| 221 | + | l.coalescer.Destroy() | |
| 222 | + | } | |
| 188 | 223 | l.pendingURI = "" | |
| 189 | 224 | l.visible = false | |
| 190 | 225 | } | |
| @@ -4,6 +4,7 @@ package component | |||||
| 4 | import ( | 4 | import ( | ||
| 5 | "sync" | 5 | "sync" | ||
| 6 | 6 | ||||
| 7 | + | "github.com/bnema/dumber/internal/ui/mainloop" | |||
| 7 | "github.com/jwijenbergh/puregotk/v4/glib" | 8 | "github.com/jwijenbergh/puregotk/v4/glib" | ||
| 8 | "github.com/jwijenbergh/puregotk/v4/gtk" | 9 | "github.com/jwijenbergh/puregotk/v4/gtk" | ||
| 9 | 10 | ||||
| @@ -30,6 +31,9 @@ type LinkStatusOverlay struct { | |||||
| 30 | showTimer uint // GLib timer for delayed show | 31 | showTimer uint // GLib timer for delayed show | ||
| 31 | autoHideTimer uint // GLib timer for auto-hide after timeout | 32 | autoHideTimer uint // GLib timer for auto-hide after timeout | ||
| 32 | visible bool // Whether overlay is currently visible (has .visible class) | 33 | visible bool // Whether overlay is currently visible (has .visible class) | ||
| 34 | + | showTimerCb glib.SourceFunc | |||
| 35 | + | autoHideCb glib.SourceFunc | |||
| 36 | + | coalescer *mainloop.Coalescer | |||
| 33 | mu sync.Mutex | 37 | mu sync.Mutex | ||
| 34 | } | 38 | } | ||
| 35 | 39 | ||||
| @@ -60,12 +64,44 @@ func NewLinkStatusOverlay(factory layout.WidgetFactory) *LinkStatusOverlay { | |||||
| 60 | label.SetMaxWidthChars(linkStatusMaxChars) | 64 | label.SetMaxWidthChars(linkStatusMaxChars) | ||
| 61 | container.Append(label) | 65 | container.Append(label) | ||
| 62 | 66 | ||||
| 63 | - | return &LinkStatusOverlay{ | 67 | + | overlay := &LinkStatusOverlay{ |
| 64 | factory: factory, | 68 | factory: factory, | ||
| 65 | container: container, | 69 | container: container, | ||
| 66 | label: label, | 70 | label: label, | ||
| 67 | visible: false, | 71 | visible: false, | ||
| 68 | } | 72 | } | ||
| 73 | + | overlay.coalescer = mainloop.NewCoalescer(func(fn func()) { | |||
| 74 | + | var cb glib.SourceFunc = func(uintptr) bool { | |||
| 75 | + | fn() | |||
| 76 | + | return false | |||
| 77 | + | } | |||
| 78 | + | glib.IdleAdd(&cb, 0) | |||
| 79 | + | }) | |||
| 80 | + | overlay.showTimerCb = func(_ uintptr) bool { | |||
| 81 | + | overlay.mu.Lock() | |||
| 82 | + | defer overlay.mu.Unlock() | |||
| 83 | + | ||||
| 84 | + | if overlay.pendingURI != "" { | |||
| 85 | + | overlay.label.SetText(overlay.pendingURI) | |||
| 86 | + | if !overlay.visible { | |||
| 87 | + | overlay.visible = true | |||
| 88 | + | overlay.container.AddCssClass("visible") | |||
| 89 | + | } | |||
| 90 | + | overlay.resetAutoHideTimerLocked() | |||
| 91 | + | } | |||
| 92 | + | overlay.showTimer = 0 | |||
| 93 | + | return false | |||
| 94 | + | } | |||
| 95 | + | overlay.autoHideCb = func(_ uintptr) bool { | |||
| 96 | + | overlay.mu.Lock() | |||
| 97 | + | defer overlay.mu.Unlock() | |||
| 98 | + | ||||
| 99 | + | overlay.autoHideTimer = 0 | |||
| 100 | + | overlay.hide() | |||
| 101 | + | return false | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | return overlay | |||
| 69 | } | 105 | } | ||
| 70 | 106 | ||||
| 71 | // Show displays the link status overlay with the given URI. | 107 | // Show displays the link status overlay with the given URI. | ||
| @@ -88,25 +124,19 @@ func (l *LinkStatusOverlay) Show(uri string) { | |||||
| 88 | 124 | ||||
| 89 | l.pendingURI = uri | 125 | l.pendingURI = uri | ||
| 90 | 126 | ||||
| 91 | - | // Start delay timer before showing | 127 | + | // Coalesce rapid hover events before scheduling delay timer. |
| 92 | - | cb := glib.SourceFunc(func(_ uintptr) bool { | 128 | + | l.coalescer.Post("link-status-show-delay", func() { |
| 93 | l.mu.Lock() | 129 | l.mu.Lock() | ||
| 94 | defer l.mu.Unlock() | 130 | defer l.mu.Unlock() | ||
| 95 | 131 | ||||
| 96 | - | if l.pendingURI != "" { | 132 | + | if l.pendingURI == "" { |
| 97 | - | l.label.SetText(l.pendingURI) | 133 | + | return |
| 98 | - | if !l.visible { | |||
| 99 | - | l.visible = true | |||
| 100 | - | l.container.AddCssClass("visible") | |||
| 101 | - | } | |||
| 102 | - | // Start or reset auto-hide timer | |||
| 103 | - | l.resetAutoHideTimerLocked() | |||
| 104 | } | 134 | } | ||
| 105 | - | l.showTimer = 0 | 135 | + | if l.showTimer != 0 { |
| 106 | - | return false // Don't repeat | 136 | + | glib.SourceRemove(l.showTimer) |
| 137 | + | } | |||
| 138 | + | l.showTimer = glib.TimeoutAdd(linkStatusShowDelayMs, &l.showTimerCb, 0) | |||
| 107 | }) | 139 | }) | ||
| 108 | - | ||||
| 109 | - | l.showTimer = glib.TimeoutAdd(linkStatusShowDelayMs, &cb, 0) | |||
| 110 | } | 140 | } | ||
| 111 | 141 | ||||
| 112 | // Hide manually hides the link status overlay. | 142 | // Hide manually hides the link status overlay. | ||
| @@ -144,17 +174,19 @@ func (l *LinkStatusOverlay) resetAutoHideTimerLocked() { | |||||
| 144 | l.autoHideTimer = 0 | 174 | l.autoHideTimer = 0 | ||
| 145 | } | 175 | } | ||
| 146 | 176 | ||||
| 147 | - | // Start new auto-hide timer | 177 | + | // Coalesce timer resets while pointer moves quickly across links. |
| 148 | - | cb := glib.SourceFunc(func(_ uintptr) bool { | 178 | + | l.coalescer.Post("link-status-auto-hide", func() { |
| 149 | l.mu.Lock() | 179 | l.mu.Lock() | ||
| 150 | defer l.mu.Unlock() | 180 | defer l.mu.Unlock() | ||
| 151 | 181 | ||||
| 152 | - | l.autoHideTimer = 0 | 182 | + | if l.pendingURI == "" { |
| 153 | - | l.hide() | 183 | + | return |
| 154 | - | return false // Don't repeat | 184 | + | } |
| 185 | + | if l.autoHideTimer != 0 { | |||
| 186 | + | glib.SourceRemove(l.autoHideTimer) | |||
| 187 | + | } | |||
| 188 | + | l.autoHideTimer = glib.TimeoutAdd(linkStatusAutoHideMs, &l.autoHideCb, 0) | |||
| 155 | }) | 189 | }) | ||
| 156 | - | ||||
| 157 | - | l.autoHideTimer = glib.TimeoutAdd(linkStatusAutoHideMs, &cb, 0) | |||
| 158 | } | 190 | } | ||
| 159 | 191 | ||||
| 160 | // Widget returns the underlying widget for embedding in overlays. | 192 | // Widget returns the underlying widget for embedding in overlays. | ||
| @@ -185,6 +217,9 @@ func (l *LinkStatusOverlay) Cleanup() { | |||||
| 185 | glib.SourceRemove(l.autoHideTimer) | 217 | glib.SourceRemove(l.autoHideTimer) | ||
| 186 | l.autoHideTimer = 0 | 218 | l.autoHideTimer = 0 | ||
| 187 | } | 219 | } | ||
| 220 | + | if l.coalescer != nil { | |||
| 221 | + | l.coalescer.Destroy() | |||
| 222 | + | } | |||
| 188 | l.pendingURI = "" | 223 | l.pendingURI = "" | ||
| 189 | l.visible = false | 224 | l.visible = false | ||
| 190 | } | 225 | } | ||
M internal/ui/component/omnibox.go +22 -19
| @@ -17,6 +17,7 @@ import ( | |||
| 17 | 17 | "github.com/bnema/dumber/internal/ui/adapter" | |
| 18 | 18 | "github.com/bnema/dumber/internal/ui/input" | |
| 19 | 19 | "github.com/bnema/dumber/internal/ui/layout" | |
| 20 | + | "github.com/bnema/dumber/internal/ui/mainloop" | |
| 20 | 21 | "github.com/jwijenbergh/puregotk/v4/gdk" | |
| 21 | 22 | "github.com/jwijenbergh/puregotk/v4/glib" | |
| 22 | 23 | "github.com/jwijenbergh/puregotk/v4/graphene" | |
| @@ -138,6 +139,7 @@ type Omnibox struct { | |||
| 138 | 139 | // Click outside handler | |
| 139 | 140 | clickOutsideController *gtk.GestureClick | |
| 140 | 141 | clickOutsideCb func(gtk.GestureClick, int, float64, float64) | |
| 142 | + | idleCoalescer *mainloop.Coalescer | |
| 141 | 143 | } | |
| 142 | 144 | ||
| 143 | 145 | // OmniboxConfig holds configuration for creating an Omnibox. | |
| @@ -182,6 +184,13 @@ func NewOmnibox(ctx context.Context, cfg OmniboxConfig) *Omnibox { | |||
| 182 | 184 | ctx: ctx, | |
| 183 | 185 | uiScale: uiScale, | |
| 184 | 186 | } | |
| 187 | + | o.idleCoalescer = mainloop.NewCoalescer(func(fn func()) { | |
| 188 | + | var cb glib.SourceFunc = func(uintptr) bool { | |
| 189 | + | fn() | |
| 190 | + | return false | |
| 191 | + | } | |
| 192 | + | glib.IdleAdd(&cb, 0) | |
| 193 | + | }) | |
| 185 | 194 | ||
| 186 | 195 | if err := o.createWidgets(); err != nil { | |
| 187 | 196 | log.Error().Err(err).Msg("failed to create omnibox widgets") | |
| @@ -817,12 +826,7 @@ func (o *Omnibox) onEntryChanged() { | |||
| 817 | 826 | o.debounceTimer.Stop() | |
| 818 | 827 | } | |
| 819 | 828 | o.debounceTimer = time.AfterFunc(debounceDelayMs*time.Millisecond, func() { | |
| 820 | - | // Schedule on GTK main thread — performSearch reads GTK widget state | |
| 821 | - | var cb glib.SourceFunc = func(uintptr) bool { | |
| 822 | - | o.performSearch() | |
| 823 | - | return false | |
| 824 | - | } | |
| 825 | - | glib.IdleAdd(&cb, 0) | |
| 829 | + | o.idleCoalescer.Post("omnibox-search", o.performSearch) | |
| 826 | 830 | }) | |
| 827 | 831 | o.debounceMu.Unlock() | |
| 828 | 832 | } | |
| @@ -879,9 +883,9 @@ func (o *Omnibox) setGhostText(originalInput, suffix, fullText string) { | |||
| 879 | 883 | capturedToken := o.ghostToken | |
| 880 | 884 | o.mu.Unlock() | |
| 881 | 885 | ||
| 882 | - | var cb glib.SourceFunc = func(uintptr) bool { | |
| 886 | + | o.idleCoalescer.Post("omnibox-ghost-set", func() { | |
| 883 | 887 | if o.entry == nil { | |
| 884 | - | return false | |
| 888 | + | return | |
| 885 | 889 | } | |
| 886 | 890 | ||
| 887 | 891 | o.mu.RLock() | |
| @@ -889,10 +893,10 @@ func (o *Omnibox) setGhostText(originalInput, suffix, fullText string) { | |||
| 889 | 893 | currentToken := o.ghostToken | |
| 890 | 894 | o.mu.RUnlock() | |
| 891 | 895 | if currentInput != originalInput { | |
| 892 | - | return false | |
| 896 | + | return | |
| 893 | 897 | } | |
| 894 | 898 | if capturedToken != currentToken { | |
| 895 | - | return false | |
| 899 | + | return | |
| 896 | 900 | } | |
| 897 | 901 | o.mu.Lock() | |
| 898 | 902 | o.isAcceptingGhost = true | |
| @@ -905,9 +909,7 @@ func (o *Omnibox) setGhostText(originalInput, suffix, fullText string) { | |||
| 905 | 909 | o.mu.Lock() | |
| 906 | 910 | o.isAcceptingGhost = false | |
| 907 | 911 | o.mu.Unlock() | |
| 908 | - | return false | |
| 909 | - | } | |
| 910 | - | glib.IdleAdd(&cb, 0) | |
| 912 | + | }) | |
| 911 | 913 | } | |
| 912 | 914 | ||
| 913 | 915 | // clearGhostText hides the ghost completion text. | |
| @@ -927,15 +929,15 @@ func (o *Omnibox) clearGhostText() { | |||
| 927 | 929 | return | |
| 928 | 930 | } | |
| 929 | 931 | ||
| 930 | - | var cb glib.SourceFunc = func(uintptr) bool { | |
| 932 | + | o.idleCoalescer.Post("omnibox-ghost-clear", func() { | |
| 931 | 933 | if o.entry == nil { | |
| 932 | - | return false | |
| 934 | + | return | |
| 933 | 935 | } | |
| 934 | 936 | o.mu.RLock() | |
| 935 | 937 | currentToken := o.ghostToken | |
| 936 | 938 | o.mu.RUnlock() | |
| 937 | 939 | if capturedToken != currentToken { | |
| 938 | - | return false | |
| 940 | + | return | |
| 939 | 941 | } | |
| 940 | 942 | o.mu.Lock() | |
| 941 | 943 | o.isAcceptingGhost = true | |
| @@ -950,9 +952,7 @@ func (o *Omnibox) clearGhostText() { | |||
| 950 | 952 | o.mu.Lock() | |
| 951 | 953 | o.isAcceptingGhost = false | |
| 952 | 954 | o.mu.Unlock() | |
| 953 | - | return false | |
| 954 | - | } | |
| 955 | - | glib.IdleAdd(&cb, 0) | |
| 955 | + | }) | |
| 956 | 956 | } | |
| 957 | 957 | ||
| 958 | 958 | // clearGhostTextIfInput clears ghost text only if the entry text matches expectedInput. | |
| @@ -2337,6 +2337,9 @@ func (o *Omnibox) Destroy() { | |||
| 2337 | 2337 | o.retainedCallbacks = nil | |
| 2338 | 2338 | o.clickOutsideController = nil | |
| 2339 | 2339 | o.clickOutsideCb = nil | |
| 2340 | + | if o.idleCoalescer != nil { | |
| 2341 | + | o.idleCoalescer.Destroy() | |
| 2342 | + | } | |
| 2340 | 2343 | ||
| 2341 | 2344 | if o.outerBox != nil { | |
| 2342 | 2345 | o.outerBox.Unparent() | |
| @@ -17,6 +17,7 @@ import ( | |||||
| 17 | "github.com/bnema/dumber/internal/ui/adapter" | 17 | "github.com/bnema/dumber/internal/ui/adapter" | ||
| 18 | "github.com/bnema/dumber/internal/ui/input" | 18 | "github.com/bnema/dumber/internal/ui/input" | ||
| 19 | "github.com/bnema/dumber/internal/ui/layout" | 19 | "github.com/bnema/dumber/internal/ui/layout" | ||
| 20 | + | "github.com/bnema/dumber/internal/ui/mainloop" | |||
| 20 | "github.com/jwijenbergh/puregotk/v4/gdk" | 21 | "github.com/jwijenbergh/puregotk/v4/gdk" | ||
| 21 | "github.com/jwijenbergh/puregotk/v4/glib" | 22 | "github.com/jwijenbergh/puregotk/v4/glib" | ||
| 22 | "github.com/jwijenbergh/puregotk/v4/graphene" | 23 | "github.com/jwijenbergh/puregotk/v4/graphene" | ||
| @@ -138,6 +139,7 @@ type Omnibox struct { | |||||
| 138 | // Click outside handler | 139 | // Click outside handler | ||
| 139 | clickOutsideController *gtk.GestureClick | 140 | clickOutsideController *gtk.GestureClick | ||
| 140 | clickOutsideCb func(gtk.GestureClick, int, float64, float64) | 141 | clickOutsideCb func(gtk.GestureClick, int, float64, float64) | ||
| 142 | + | idleCoalescer *mainloop.Coalescer | |||
| 141 | } | 143 | } | ||
| 142 | 144 | ||||
| 143 | // OmniboxConfig holds configuration for creating an Omnibox. | 145 | // OmniboxConfig holds configuration for creating an Omnibox. | ||
| @@ -182,6 +184,13 @@ func NewOmnibox(ctx context.Context, cfg OmniboxConfig) *Omnibox { | |||||
| 182 | ctx: ctx, | 184 | ctx: ctx, | ||
| 183 | uiScale: uiScale, | 185 | uiScale: uiScale, | ||
| 184 | } | 186 | } | ||
| 187 | + | o.idleCoalescer = mainloop.NewCoalescer(func(fn func()) { | |||
| 188 | + | var cb glib.SourceFunc = func(uintptr) bool { | |||
| 189 | + | fn() | |||
| 190 | + | return false | |||
| 191 | + | } | |||
| 192 | + | glib.IdleAdd(&cb, 0) | |||
| 193 | + | }) | |||
| 185 | 194 | ||||
| 186 | if err := o.createWidgets(); err != nil { | 195 | if err := o.createWidgets(); err != nil { | ||
| 187 | log.Error().Err(err).Msg("failed to create omnibox widgets") | 196 | log.Error().Err(err).Msg("failed to create omnibox widgets") | ||
| @@ -817,12 +826,7 @@ func (o *Omnibox) onEntryChanged() { | |||||
| 817 | o.debounceTimer.Stop() | 826 | o.debounceTimer.Stop() | ||
| 818 | } | 827 | } | ||
| 819 | o.debounceTimer = time.AfterFunc(debounceDelayMs*time.Millisecond, func() { | 828 | o.debounceTimer = time.AfterFunc(debounceDelayMs*time.Millisecond, func() { | ||
| 820 | - | // Schedule on GTK main thread — performSearch reads GTK widget state | 829 | + | o.idleCoalescer.Post("omnibox-search", o.performSearch) |
| 821 | - | var cb glib.SourceFunc = func(uintptr) bool { | |||
| 822 | - | o.performSearch() | |||
| 823 | - | return false | |||
| 824 | - | } | |||
| 825 | - | glib.IdleAdd(&cb, 0) | |||
| 826 | }) | 830 | }) | ||
| 827 | o.debounceMu.Unlock() | 831 | o.debounceMu.Unlock() | ||
| 828 | } | 832 | } | ||
| @@ -879,9 +883,9 @@ func (o *Omnibox) setGhostText(originalInput, suffix, fullText string) { | |||||
| 879 | capturedToken := o.ghostToken | 883 | capturedToken := o.ghostToken | ||
| 880 | o.mu.Unlock() | 884 | o.mu.Unlock() | ||
| 881 | 885 | ||||
| 882 | - | var cb glib.SourceFunc = func(uintptr) bool { | 886 | + | o.idleCoalescer.Post("omnibox-ghost-set", func() { |
| 883 | if o.entry == nil { | 887 | if o.entry == nil { | ||
| 884 | - | return false | 888 | + | return |
| 885 | } | 889 | } | ||
| 886 | 890 | ||||
| 887 | o.mu.RLock() | 891 | o.mu.RLock() | ||
| @@ -889,10 +893,10 @@ func (o *Omnibox) setGhostText(originalInput, suffix, fullText string) { | |||||
| 889 | currentToken := o.ghostToken | 893 | currentToken := o.ghostToken | ||
| 890 | o.mu.RUnlock() | 894 | o.mu.RUnlock() | ||
| 891 | if currentInput != originalInput { | 895 | if currentInput != originalInput { | ||
| 892 | - | return false | 896 | + | return |
| 893 | } | 897 | } | ||
| 894 | if capturedToken != currentToken { | 898 | if capturedToken != currentToken { | ||
| 895 | - | return false | 899 | + | return |
| 896 | } | 900 | } | ||
| 897 | o.mu.Lock() | 901 | o.mu.Lock() | ||
| 898 | o.isAcceptingGhost = true | 902 | o.isAcceptingGhost = true | ||
| @@ -905,9 +909,7 @@ func (o *Omnibox) setGhostText(originalInput, suffix, fullText string) { | |||||
| 905 | o.mu.Lock() | 909 | o.mu.Lock() | ||
| 906 | o.isAcceptingGhost = false | 910 | o.isAcceptingGhost = false | ||
| 907 | o.mu.Unlock() | 911 | o.mu.Unlock() | ||
| 908 | - | return false | 912 | + | }) |
| 909 | - | } | |||
| 910 | - | glib.IdleAdd(&cb, 0) | |||
| 911 | } | 913 | } | ||
| 912 | 914 | ||||
| 913 | // clearGhostText hides the ghost completion text. | 915 | // clearGhostText hides the ghost completion text. | ||
| @@ -927,15 +929,15 @@ func (o *Omnibox) clearGhostText() { | |||||
| 927 | return | 929 | return | ||
| 928 | } | 930 | } | ||
| 929 | 931 | ||||
| 930 | - | var cb glib.SourceFunc = func(uintptr) bool { | 932 | + | o.idleCoalescer.Post("omnibox-ghost-clear", func() { |
| 931 | if o.entry == nil { | 933 | if o.entry == nil { | ||
| 932 | - | return false | 934 | + | return |
| 933 | } | 935 | } | ||
| 934 | o.mu.RLock() | 936 | o.mu.RLock() | ||
| 935 | currentToken := o.ghostToken | 937 | currentToken := o.ghostToken | ||
| 936 | o.mu.RUnlock() | 938 | o.mu.RUnlock() | ||
| 937 | if capturedToken != currentToken { | 939 | if capturedToken != currentToken { | ||
| 938 | - | return false | 940 | + | return |
| 939 | } | 941 | } | ||
| 940 | o.mu.Lock() | 942 | o.mu.Lock() | ||
| 941 | o.isAcceptingGhost = true | 943 | o.isAcceptingGhost = true | ||
| @@ -950,9 +952,7 @@ func (o *Omnibox) clearGhostText() { | |||||
| 950 | o.mu.Lock() | 952 | o.mu.Lock() | ||
| 951 | o.isAcceptingGhost = false | 953 | o.isAcceptingGhost = false | ||
| 952 | o.mu.Unlock() | 954 | o.mu.Unlock() | ||
| 953 | - | return false | 955 | + | }) |
| 954 | - | } | |||
| 955 | - | glib.IdleAdd(&cb, 0) | |||
| 956 | } | 956 | } | ||
| 957 | 957 | ||||
| 958 | // clearGhostTextIfInput clears ghost text only if the entry text matches expectedInput. | 958 | // clearGhostTextIfInput clears ghost text only if the entry text matches expectedInput. | ||
| @@ -2337,6 +2337,9 @@ func (o *Omnibox) Destroy() { | |||||
| 2337 | o.retainedCallbacks = nil | 2337 | o.retainedCallbacks = nil | ||
| 2338 | o.clickOutsideController = nil | 2338 | o.clickOutsideController = nil | ||
| 2339 | o.clickOutsideCb = nil | 2339 | o.clickOutsideCb = nil | ||
| 2340 | + | if o.idleCoalescer != nil { | |||
| 2341 | + | o.idleCoalescer.Destroy() | |||
| 2342 | + | } | |||
| 2340 | 2343 | ||||
| 2341 | if o.outerBox != nil { | 2344 | if o.outerBox != nil { | ||
| 2342 | o.outerBox.Unparent() | 2345 | o.outerBox.Unparent() | ||
A internal/ui/component/omnibox_callback_pressure_test.go +13 -0
| @@ -0,0 +1,13 @@ | |||
| 1 | + | package component | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "os" | |
| 5 | + | "testing" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | func TestOmniboxCallbackPressure_NoCrashSignal(t *testing.T) { | |
| 9 | + | if os.Getenv("OMNIBOX_STRESS") != "1" { | |
| 10 | + | t.Skip("skipping: set OMNIBOX_STRESS=1 to run with GTK stress harness") | |
| 11 | + | } | |
| 12 | + | // TODO: wire GTK stress harness and assert no abrupt crash markers | |
| 13 | + | } | |
| @@ -0,0 +1,13 @@ | |||||
| 1 | + | package component | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "os" | |||
| 5 | + | "testing" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | func TestOmniboxCallbackPressure_NoCrashSignal(t *testing.T) { | |||
| 9 | + | if os.Getenv("OMNIBOX_STRESS") != "1" { | |||
| 10 | + | t.Skip("skipping: set OMNIBOX_STRESS=1 to run with GTK stress harness") | |||
| 11 | + | } | |||
| 12 | + | // TODO: wire GTK stress harness and assert no abrupt crash markers | |||
| 13 | + | } | |||
A internal/ui/mainloop/coalescer.go +70 -0
| @@ -0,0 +1,70 @@ | |||
| 1 | + | package mainloop | |
| 2 | + | ||
| 3 | + | import "sync" | |
| 4 | + | ||
| 5 | + | // Coalescer merges bursts of same-key main-loop tasks. | |
| 6 | + | type Coalescer struct { | |
| 7 | + | mu sync.Mutex | |
| 8 | + | pending map[string]bool | |
| 9 | + | callbacks map[string]func() | |
| 10 | + | post func(func()) | |
| 11 | + | destroyed bool | |
| 12 | + | } | |
| 13 | + | ||
| 14 | + | func NewCoalescer(post func(func())) *Coalescer { | |
| 15 | + | if post == nil { | |
| 16 | + | panic("mainloop.NewCoalescer: post function cannot be nil") | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | return &Coalescer{ | |
| 20 | + | pending: make(map[string]bool), | |
| 21 | + | callbacks: make(map[string]func()), | |
| 22 | + | post: post, | |
| 23 | + | } | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | func (c *Coalescer) Post(key string, fn func()) { | |
| 27 | + | if fn == nil || key == "" { | |
| 28 | + | return | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | c.mu.Lock() | |
| 32 | + | if c.destroyed { | |
| 33 | + | c.mu.Unlock() | |
| 34 | + | return | |
| 35 | + | } | |
| 36 | + | c.callbacks[key] = fn | |
| 37 | + | if c.pending[key] { | |
| 38 | + | c.mu.Unlock() | |
| 39 | + | return | |
| 40 | + | } | |
| 41 | + | c.pending[key] = true | |
| 42 | + | post := c.post | |
| 43 | + | c.mu.Unlock() | |
| 44 | + | ||
| 45 | + | post(func() { | |
| 46 | + | c.mu.Lock() | |
| 47 | + | if c.destroyed { | |
| 48 | + | delete(c.pending, key) | |
| 49 | + | delete(c.callbacks, key) | |
| 50 | + | c.mu.Unlock() | |
| 51 | + | return | |
| 52 | + | } | |
| 53 | + | fn := c.callbacks[key] | |
| 54 | + | delete(c.pending, key) | |
| 55 | + | delete(c.callbacks, key) | |
| 56 | + | c.mu.Unlock() | |
| 57 | + | ||
| 58 | + | if fn != nil { | |
| 59 | + | fn() | |
| 60 | + | } | |
| 61 | + | }) | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | func (c *Coalescer) Destroy() { | |
| 65 | + | c.mu.Lock() | |
| 66 | + | c.destroyed = true | |
| 67 | + | c.pending = map[string]bool{} | |
| 68 | + | c.callbacks = map[string]func(){} | |
| 69 | + | c.mu.Unlock() | |
| 70 | + | } | |
| @@ -0,0 +1,70 @@ | |||||
| 1 | + | package mainloop | |||
| 2 | + | ||||
| 3 | + | import "sync" | |||
| 4 | + | ||||
| 5 | + | // Coalescer merges bursts of same-key main-loop tasks. | |||
| 6 | + | type Coalescer struct { | |||
| 7 | + | mu sync.Mutex | |||
| 8 | + | pending map[string]bool | |||
| 9 | + | callbacks map[string]func() | |||
| 10 | + | post func(func()) | |||
| 11 | + | destroyed bool | |||
| 12 | + | } | |||
| 13 | + | ||||
| 14 | + | func NewCoalescer(post func(func())) *Coalescer { | |||
| 15 | + | if post == nil { | |||
| 16 | + | panic("mainloop.NewCoalescer: post function cannot be nil") | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | return &Coalescer{ | |||
| 20 | + | pending: make(map[string]bool), | |||
| 21 | + | callbacks: make(map[string]func()), | |||
| 22 | + | post: post, | |||
| 23 | + | } | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | func (c *Coalescer) Post(key string, fn func()) { | |||
| 27 | + | if fn == nil || key == "" { | |||
| 28 | + | return | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | c.mu.Lock() | |||
| 32 | + | if c.destroyed { | |||
| 33 | + | c.mu.Unlock() | |||
| 34 | + | return | |||
| 35 | + | } | |||
| 36 | + | c.callbacks[key] = fn | |||
| 37 | + | if c.pending[key] { | |||
| 38 | + | c.mu.Unlock() | |||
| 39 | + | return | |||
| 40 | + | } | |||
| 41 | + | c.pending[key] = true | |||
| 42 | + | post := c.post | |||
| 43 | + | c.mu.Unlock() | |||
| 44 | + | ||||
| 45 | + | post(func() { | |||
| 46 | + | c.mu.Lock() | |||
| 47 | + | if c.destroyed { | |||
| 48 | + | delete(c.pending, key) | |||
| 49 | + | delete(c.callbacks, key) | |||
| 50 | + | c.mu.Unlock() | |||
| 51 | + | return | |||
| 52 | + | } | |||
| 53 | + | fn := c.callbacks[key] | |||
| 54 | + | delete(c.pending, key) | |||
| 55 | + | delete(c.callbacks, key) | |||
| 56 | + | c.mu.Unlock() | |||
| 57 | + | ||||
| 58 | + | if fn != nil { | |||
| 59 | + | fn() | |||
| 60 | + | } | |||
| 61 | + | }) | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | func (c *Coalescer) Destroy() { | |||
| 65 | + | c.mu.Lock() | |||
| 66 | + | c.destroyed = true | |||
| 67 | + | c.pending = map[string]bool{} | |||
| 68 | + | c.callbacks = map[string]func(){} | |||
| 69 | + | c.mu.Unlock() | |||
| 70 | + | } | |||
A internal/ui/mainloop/coalescer_test.go +56 -0
| @@ -0,0 +1,56 @@ | |||
| 1 | + | package mainloop | |
| 2 | + | ||
| 3 | + | import "testing" | |
| 4 | + | ||
| 5 | + | func TestCoalescerMergesBurstIntoSingleIdle(t *testing.T) { | |
| 6 | + | queue := make([]func(), 0, 8) | |
| 7 | + | c := NewCoalescer(func(fn func()) { queue = append(queue, fn) }) | |
| 8 | + | ||
| 9 | + | value := 0 | |
| 10 | + | for i := 1; i <= 5; i++ { | |
| 11 | + | v := i | |
| 12 | + | c.Post("omnibox-search", func() { value = v }) | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | if len(queue) != 1 { | |
| 16 | + | t.Fatalf("expected 1 scheduled callback, got %d", len(queue)) | |
| 17 | + | } | |
| 18 | + | queue[0]() | |
| 19 | + | ||
| 20 | + | if value != 5 { | |
| 21 | + | t.Fatalf("expected latest callback to run, got %d", value) | |
| 22 | + | } | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | func TestCoalescerDropsWorkAfterDestroy(t *testing.T) { | |
| 26 | + | queue := make([]func(), 0, 4) | |
| 27 | + | c := NewCoalescer(func(fn func()) { queue = append(queue, fn) }) | |
| 28 | + | ||
| 29 | + | ran := false | |
| 30 | + | c.Post("ghost-clear", func() { ran = true }) | |
| 31 | + | c.Destroy() | |
| 32 | + | ||
| 33 | + | if len(queue) != 1 { | |
| 34 | + | t.Fatalf("expected one queued callback before destroy, got %d", len(queue)) | |
| 35 | + | } | |
| 36 | + | queue[0]() | |
| 37 | + | ||
| 38 | + | if ran { | |
| 39 | + | t.Fatalf("expected queued work to be dropped after destroy") | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | c.Post("ghost-clear", func() { ran = true }) | |
| 43 | + | if len(queue) != 1 { | |
| 44 | + | t.Fatalf("expected no new callback after destroy, got %d", len(queue)) | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | func TestNewCoalescerPanicsOnNilPost(t *testing.T) { | |
| 49 | + | defer func() { | |
| 50 | + | if r := recover(); r == nil { | |
| 51 | + | t.Fatalf("expected NewCoalescer to panic when post is nil") | |
| 52 | + | } | |
| 53 | + | }() | |
| 54 | + | ||
| 55 | + | _ = NewCoalescer(nil) | |
| 56 | + | } | |
| @@ -0,0 +1,56 @@ | |||||
| 1 | + | package mainloop | |||
| 2 | + | ||||
| 3 | + | import "testing" | |||
| 4 | + | ||||
| 5 | + | func TestCoalescerMergesBurstIntoSingleIdle(t *testing.T) { | |||
| 6 | + | queue := make([]func(), 0, 8) | |||
| 7 | + | c := NewCoalescer(func(fn func()) { queue = append(queue, fn) }) | |||
| 8 | + | ||||
| 9 | + | value := 0 | |||
| 10 | + | for i := 1; i <= 5; i++ { | |||
| 11 | + | v := i | |||
| 12 | + | c.Post("omnibox-search", func() { value = v }) | |||
| 13 | + | } | |||
| 14 | + | ||||
| 15 | + | if len(queue) != 1 { | |||
| 16 | + | t.Fatalf("expected 1 scheduled callback, got %d", len(queue)) | |||
| 17 | + | } | |||
| 18 | + | queue[0]() | |||
| 19 | + | ||||
| 20 | + | if value != 5 { | |||
| 21 | + | t.Fatalf("expected latest callback to run, got %d", value) | |||
| 22 | + | } | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | func TestCoalescerDropsWorkAfterDestroy(t *testing.T) { | |||
| 26 | + | queue := make([]func(), 0, 4) | |||
| 27 | + | c := NewCoalescer(func(fn func()) { queue = append(queue, fn) }) | |||
| 28 | + | ||||
| 29 | + | ran := false | |||
| 30 | + | c.Post("ghost-clear", func() { ran = true }) | |||
| 31 | + | c.Destroy() | |||
| 32 | + | ||||
| 33 | + | if len(queue) != 1 { | |||
| 34 | + | t.Fatalf("expected one queued callback before destroy, got %d", len(queue)) | |||
| 35 | + | } | |||
| 36 | + | queue[0]() | |||
| 37 | + | ||||
| 38 | + | if ran { | |||
| 39 | + | t.Fatalf("expected queued work to be dropped after destroy") | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | c.Post("ghost-clear", func() { ran = true }) | |||
| 43 | + | if len(queue) != 1 { | |||
| 44 | + | t.Fatalf("expected no new callback after destroy, got %d", len(queue)) | |||
| 45 | + | } | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | func TestNewCoalescerPanicsOnNilPost(t *testing.T) { | |||
| 49 | + | defer func() { | |||
| 50 | + | if r := recover(); r == nil { | |||
| 51 | + | t.Fatalf("expected NewCoalescer to panic when post is nil") | |||
| 52 | + | } | |||
| 53 | + | }() | |||
| 54 | + | ||||
| 55 | + | _ = NewCoalescer(nil) | |||
| 56 | + | } | |||
A scripts/stress_omnibox_callbacks.go +18 -0
| @@ -0,0 +1,18 @@ | |||
| 1 | + | package main | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "flag" | |
| 5 | + | "fmt" | |
| 6 | + | "os" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | const defaultIterations = 5000 | |
| 10 | + | ||
| 11 | + | func main() { | |
| 12 | + | iterations := flag.Int("iterations", defaultIterations, "number of synthetic omnibox updates") | |
| 13 | + | flag.Parse() | |
| 14 | + | ||
| 15 | + | fmt.Printf("stress harness placeholder: iterations=%d\n", *iterations) | |
| 16 | + | fmt.Println("TODO: attach to a live dumber session and drive omnibox updates") | |
| 17 | + | os.Exit(0) | |
| 18 | + | } | |
| @@ -0,0 +1,18 @@ | |||||
| 1 | + | package main | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "flag" | |||
| 5 | + | "fmt" | |||
| 6 | + | "os" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | const defaultIterations = 5000 | |||
| 10 | + | ||||
| 11 | + | func main() { | |||
| 12 | + | iterations := flag.Int("iterations", defaultIterations, "number of synthetic omnibox updates") | |||
| 13 | + | flag.Parse() | |||
| 14 | + | ||||
| 15 | + | fmt.Printf("stress harness placeholder: iterations=%d\n", *iterations) | |||
| 16 | + | fmt.Println("TODO: attach to a live dumber session and drive omnibox updates") | |||
| 17 | + | os.Exit(0) | |||
| 18 | + | } | |||
A scripts/verify_purego_only.sh +42 -0
| @@ -0,0 +1,42 @@ | |||
| 1 | + | #!/usr/bin/env bash | |
| 2 | + | set -euo pipefail | |
| 3 | + | ||
| 4 | + | # Use the directory containing this script's parent as repo root, | |
| 5 | + | # avoiding git which fails in CI with "dubious ownership" errors. | |
| 6 | + | repo_root="$(cd "$(dirname "$0")/.." && pwd)" | |
| 7 | + | cd "$repo_root" | |
| 8 | + | ||
| 9 | + | denied_patterns=( | |
| 10 | + | 'import "C"' | |
| 11 | + | '^//export ' | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | all_paths=(cmd internal pkg scripts) | |
| 15 | + | search_paths=() | |
| 16 | + | for p in "${all_paths[@]}"; do | |
| 17 | + | [ -d "$p" ] && search_paths+=("$p") | |
| 18 | + | done | |
| 19 | + | ||
| 20 | + | if [ ${#search_paths[@]} -eq 0 ]; then | |
| 21 | + | echo "verify_purego_only: no source directories found" | |
| 22 | + | exit 1 | |
| 23 | + | fi | |
| 24 | + | ||
| 25 | + | for pattern in "${denied_patterns[@]}"; do | |
| 26 | + | if command -v rg >/dev/null 2>&1; then | |
| 27 | + | if rg -n --glob '*.go' "$pattern" "${search_paths[@]}" >/dev/null; then | |
| 28 | + | echo "Forbidden pattern detected: $pattern" | |
| 29 | + | rg -n --glob '*.go' "$pattern" "${search_paths[@]}" | |
| 30 | + | exit 1 | |
| 31 | + | fi | |
| 32 | + | continue | |
| 33 | + | fi | |
| 34 | + | ||
| 35 | + | if grep -RInE --include='*.go' "$pattern" "${search_paths[@]}" >/dev/null; then | |
| 36 | + | echo "Forbidden pattern detected: $pattern" | |
| 37 | + | grep -RInE --include='*.go' "$pattern" "${search_paths[@]}" | |
| 38 | + | exit 1 | |
| 39 | + | fi | |
| 40 | + | done | |
| 41 | + | ||
| 42 | + | echo "verify_purego_only: OK" | |
| @@ -0,0 +1,42 @@ | |||||
| 1 | + | #!/usr/bin/env bash | |||
| 2 | + | set -euo pipefail | |||
| 3 | + | ||||
| 4 | + | # Use the directory containing this script's parent as repo root, | |||
| 5 | + | # avoiding git which fails in CI with "dubious ownership" errors. | |||
| 6 | + | repo_root="$(cd "$(dirname "$0")/.." && pwd)" | |||
| 7 | + | cd "$repo_root" | |||
| 8 | + | ||||
| 9 | + | denied_patterns=( | |||
| 10 | + | 'import "C"' | |||
| 11 | + | '^//export ' | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | all_paths=(cmd internal pkg scripts) | |||
| 15 | + | search_paths=() | |||
| 16 | + | for p in "${all_paths[@]}"; do | |||
| 17 | + | [ -d "$p" ] && search_paths+=("$p") | |||
| 18 | + | done | |||
| 19 | + | ||||
| 20 | + | if [ ${#search_paths[@]} -eq 0 ]; then | |||
| 21 | + | echo "verify_purego_only: no source directories found" | |||
| 22 | + | exit 1 | |||
| 23 | + | fi | |||
| 24 | + | ||||
| 25 | + | for pattern in "${denied_patterns[@]}"; do | |||
| 26 | + | if command -v rg >/dev/null 2>&1; then | |||
| 27 | + | if rg -n --glob '*.go' "$pattern" "${search_paths[@]}" >/dev/null; then | |||
| 28 | + | echo "Forbidden pattern detected: $pattern" | |||
| 29 | + | rg -n --glob '*.go' "$pattern" "${search_paths[@]}" | |||
| 30 | + | exit 1 | |||
| 31 | + | fi | |||
| 32 | + | continue | |||
| 33 | + | fi | |||
| 34 | + | ||||
| 35 | + | if grep -RInE --include='*.go' "$pattern" "${search_paths[@]}" >/dev/null; then | |||
| 36 | + | echo "Forbidden pattern detected: $pattern" | |||
| 37 | + | grep -RInE --include='*.go' "$pattern" "${search_paths[@]}" | |||
| 38 | + | exit 1 | |||
| 39 | + | fi | |||
| 40 | + | done | |||
| 41 | + | ||||
| 42 | + | echo "verify_purego_only: OK" | |||