From 6825ea2fcefddf410f928659ea81f39f97e71484 Mon Sep 17 00:00:00 2001 From: DevLumuz Date: Sun, 5 Jul 2026 17:58:10 -0600 Subject: [PATCH] fix(v3): guarantee event dispatch order under rapid Emit() bursts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related fixes for Wails events reaching the page out of order when Emit() is called rapidly in succession (e.g. streaming many small events back-to-back): 1. EventProcessor.Emit spawned two bare goroutines per call for dispatch (listeners, windows), with no ordering guarantee between goroutines from successive calls. Replaced with one buffered, ordered worker per target — Emit() stays non-blocking, dispatch is now strict FIFO. Also bounds in-flight dispatch (dispatchQueueSize), unlike the old unbounded fan-out. 2. linux/gtk3 execJS passed callback=NULL to webkit_web_view_evaluate_javascript ("fire and forget"): WebKit confirms the request was accepted, not that it finished executing, so nothing guaranteed successive evaluate_javascript calls ran in the order they were dispatched. execJS now waits for the real completion callback, via the same nested-main-loop technique already used by clipboard_get_text_sync (linux_cgo.c). Verified with an isolated repro (500 Emit() calls in a tight loop, no AI/LLM or app-specific code involved): before, 478/500 (95.6%) arrived out of order in the page. After, 0/500. Also faster to issue: 2.16ms -> 244µs total for the 500 calls, since Emit() no longer spawns two goroutines per call. go test -race ./pkg/application/... passes clean (also fixed two pre-existing latent races in events_test.go's mock that -race now catches reliably against the new, more deterministic dispatch timing). --- v3/pkg/application/events.go | 52 +++++++++++++++++++++++----- v3/pkg/application/events_test.go | 19 +++++++--- v3/pkg/application/linux_cgo_gtk3.go | 40 ++++++++++++++++----- 3 files changed, 89 insertions(+), 22 deletions(-) diff --git a/v3/pkg/application/events.go b/v3/pkg/application/events.go index 577001dbe03..e2ef56640b1 100644 --- a/v3/pkg/application/events.go +++ b/v3/pkg/application/events.go @@ -96,6 +96,11 @@ type eventListener struct { delete bool // Flag to indicate that this listener should be deleted } +// dispatchQueueSize buffers EventProcessor's dispatch queues (see Emit) well +// above realistic emit bursts, so Emit() stays non-blocking in practice. Also +// bounds in-flight dispatch, unlike the old unbounded goroutine-per-Emit. +const dispatchQueueSize = 4096 + // EventProcessor handles custom events type EventProcessor struct { // Go event listeners @@ -104,13 +109,48 @@ type EventProcessor struct { dispatchEventToWindows func(*CustomEvent) hooks map[string][]*hook hookLock sync.RWMutex + + // Feed the two single-consumer dispatch workers below. Emit() used to hand + // each dispatch to its own bare goroutine, and Go gives no ordering + // guarantee between them — two events emitted back-to-back could reach + // ExecJS in either order. A single ordered worker per queue fixes that, + // while keeping listeners and windows independent of each other. + listenerQueue chan *CustomEvent + windowQueue chan *CustomEvent } func NewWailsEventProcessor(dispatchEventToWindows func(*CustomEvent)) *EventProcessor { - return &EventProcessor{ + e := &EventProcessor{ listeners: make(map[string][]*eventListener), dispatchEventToWindows: dispatchEventToWindows, hooks: make(map[string][]*hook), + listenerQueue: make(chan *CustomEvent, dispatchQueueSize), + windowQueue: make(chan *CustomEvent, dispatchQueueSize), + } + go e.runListenerDispatch() + go e.runWindowDispatch() + return e +} + +// runListenerDispatch drains listenerQueue in order for the process lifetime. +// Each event gets its own recover so one panic can't kill the worker and +// stop delivery to everything emitted after it. +func (e *EventProcessor) runListenerDispatch() { + for event := range e.listenerQueue { + func() { + defer handlePanic() + e.dispatchEventToListeners(event) + }() + } +} + +// runWindowDispatch: same as runListenerDispatch, for the window/IPC side. +func (e *EventProcessor) runWindowDispatch() { + for event := range e.windowQueue { + func() { + defer handlePanic() + e.dispatchEventToWindows(event) + }() } } @@ -157,14 +197,8 @@ func (e *EventProcessor) Emit(thisEvent *CustomEvent) error { } } - go func() { - defer handlePanic() - e.dispatchEventToListeners(thisEvent) - }() - go func() { - defer handlePanic() - e.dispatchEventToWindows(thisEvent) - }() + e.listenerQueue <- thisEvent + e.windowQueue <- thisEvent return nil } diff --git a/v3/pkg/application/events_test.go b/v3/pkg/application/events_test.go index c1e621d9c31..08b9eae076e 100644 --- a/v3/pkg/application/events_test.go +++ b/v3/pkg/application/events_test.go @@ -2,6 +2,7 @@ package application_test import ( "sync" + "sync/atomic" "testing" "github.com/wailsapp/wails/v3/pkg/application" @@ -10,14 +11,21 @@ import ( ) type mockNotifier struct { + mu sync.Mutex Events []*application.CustomEvent } +// mu: dispatch now runs on a persistent worker (see runWindowDispatch), so it +// can race with a test's Reset() right after Emit(). Mutex makes that safe. func (m *mockNotifier) dispatchEventToWindows(event *application.CustomEvent) { + m.mu.Lock() + defer m.mu.Unlock() m.Events = append(m.Events, event) } func (m *mockNotifier) Reset() { + m.mu.Lock() + defer m.mu.Unlock() m.Events = []*application.CustomEvent{} } @@ -99,12 +107,13 @@ func Test_EventsOnMultiple(t *testing.T) { // Test OnApplicationEvent eventName := "test" - counter := 0 + // atomic: 2 of the 3 Emit()s below can invoke this callback concurrently. + var counter atomic.Int32 var wg sync.WaitGroup wg.Add(2) unregisterFn := eventProcessor.OnMultiple(eventName, func(event *application.CustomEvent) { // This is called in a goroutine - counter++ + counter.Add(1) wg.Done() }, 2) _ = eventProcessor.Emit(&application.CustomEvent{ @@ -120,16 +129,16 @@ func Test_EventsOnMultiple(t *testing.T) { Data: "test payload", }) wg.Wait() - i.Equal(2, counter) + i.Equal(int32(2), counter.Load()) // Unregister notifier.Reset() unregisterFn() - counter = 0 + counter.Store(0) _ = eventProcessor.Emit(&application.CustomEvent{ Name: "test", Data: "test payload", }) - i.Equal(0, counter) + i.Equal(int32(0), counter.Load()) } diff --git a/v3/pkg/application/linux_cgo_gtk3.go b/v3/pkg/application/linux_cgo_gtk3.go index 3e4f80e685d..a087740eb56 100644 --- a/v3/pkg/application/linux_cgo_gtk3.go +++ b/v3/pkg/application/linux_cgo_gtk3.go @@ -107,6 +107,37 @@ static WebKitWebView* webkit_web_view(GtkWidget *webview) { return WEBKIT_WEB_VIEW(webview); } +// Discards the result/error; execute_js_sync's callers never need one. +static void on_evaluate_javascript_finish(GObject *source, GAsyncResult *result, gpointer user_data) { + gboolean *done = (gboolean *)user_data; + GError *error = NULL; + JSCValue *value = webkit_web_view_evaluate_javascript_finish(WEBKIT_WEB_VIEW(source), result, &error); + if (error != NULL) { + g_error_free(error); + } + if (value != NULL) { + g_object_unref(value); + } + *done = TRUE; +} + +// Blocks until WebKit confirms `script` actually finished — callback=NULL +// (the old behavior) only confirms WebKit accepted the request, not that +// back-to-back calls execute in the order they were issued. Same nested +// main-loop technique as clipboard_get_text_sync (linux_cgo.c): blocking on +// a condvar instead would freeze the loop our own completion callback needs +// to run on. `done` is stack-local, so concurrent calls stay independent. +static void execute_js_sync(WebKitWebView *web_view, const char *script, gssize length, + const char *world_name, const char *source_uri) { + gboolean done = FALSE; + webkit_web_view_evaluate_javascript(web_view, script, length, world_name, source_uri, + NULL, on_evaluate_javascript_finish, &done); + GMainContext *ctx = g_main_context_default(); + while (!done) { + g_main_context_iteration(ctx, TRUE); + } +} + // Wrapper for the WEBKIT_IS_USER_MEDIA_PERMISSION_REQUEST macro static int is_user_media_permission_request(WebKitPermissionRequest *request) { return WEBKIT_IS_USER_MEDIA_PERMISSION_REQUEST(request) ? 1 : 0; @@ -1151,14 +1182,7 @@ func (w *linuxWebviewWindow) disableDND() { func (w *linuxWebviewWindow) execJS(js string) { InvokeAsync(func() { value := C.CString(js) - C.webkit_web_view_evaluate_javascript(w.webKitWebView(), - value, - C.long(len(js)), - nil, - emptyWorldName, - nil, - nil, - nil) + C.execute_js_sync(w.webKitWebView(), value, C.long(len(js)), nil, emptyWorldName) C.free(unsafe.Pointer(value)) }) }