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)) }) }