Skip to content

fix(v3): deliver events in emit order via a per-window queue - #5934

Merged
leaanthony merged 3 commits into
masterfrom
fix/event-ordering-queue
Aug 10, 2026
Merged

fix(v3): deliver events in emit order via a per-window queue#5934
leaanthony merged 3 commits into
masterfrom
fix/event-ordering-queue

Conversation

@leaanthony

@leaanthony leaanthony commented Aug 9, 2026

Copy link
Copy Markdown
Member

Description

App.dispatchOnMainThread runs work inline when the caller is already on the UI thread. So an event emitted from the UI thread executed its eval immediately, while an event emitted moments earlier from a goroutine was still sitting in the dispatch queue — and the later event arrived first.

DispatchWailsEvent now appends to a per-window queue instead of calling ExecJS directly, and a single drain on the UI thread empties it in order. Appending under a mutex makes the queue the one ordering authority: the order events are added is the order they are delivered, whichever thread each came from.

Follow-up to #5930, which found this but deliberately left it alone. Independent of it — that one was about payload size, this is about threading.

Prior art, and how this relates

Event ordering has been reported before and this PR is a third layer, not a competing fix:

layer fixed by
Emit → window dispatch (a goroutine per dispatch) #5851, merged — reported as #5850 / #4914
window dispatch → ExecJS (the UI-thread inline fast path) this PR
ExecJS → JS execution on GTK3 investigated below

@DevLumuz raised the ordering problem first in #5757 (6 July), before #5850 was filed, and is credited as co-author on #5851 for the first layer.

#5757 also argued a third cause: GTK3's webkit_web_view_evaluate_javascript with a NULL callback confirms only that WebKit accepted the script, so back-to-back calls might execute out of order. Measured on stock master built with -tags gtk3: 186,047 events at up to 5000/s across three scenarios, 0 reorders and 0 drops. WebKit's per-page IPC is FIFO, so asynchronous does not imply unordered here. On that evidence the synchronous nested-main-loop change in #5757 is not needed, and it would carry real costs — re-entering the UI thread while an eval is pending, and a full JS round trip per event.

Why not simply lock around the dispatch

The obvious fix — a per-window mutex around choosing and enqueuing — deadlocks. It would have to be held across the ExecJS main-thread hop, so if the UI thread then emits, it blocks on the mutex and the holder's InvokeSync can never complete. That is the same shape as the deadlock documented at transport_event_ipc.go:9-14.

The queue avoids it because the bound applies to goroutine emitters only. A UI-thread emitter is never made to wait — it is the drainer, so blocking it could not be relieved by anything. In practice it never queues at all: the drain scheduled via InvokeAsync runs inline when already on the UI thread, so a main-thread emit enqueues and immediately drains.

What happens when the queue is full

Nothing is dropped, and nothing deadlocks. The bound behaves differently depending on which thread is emitting, which is the whole reason it is safe:

emitter at the bound
goroutine DispatchWailsEvent blocks until the drain frees a slot.
UI thread Never blocks. It is the drainer, so waiting could not be relieved by anything. It appends past the bound and the drain it schedules runs inline immediately, so the excess is transient.
window destroyed while blocked The waiter is released and its event discarded, rather than being parked forever on a window that no longer exists.

So the bound is a throttle on goroutine emitters, not a discard policy — nothing is lost.

One honest caveat about what that bound does not achieve. Since #5851, Emit feeds an unbounded mailbox whose drain goroutine is what calls DispatchWailsEvent. So when this queue fills it blocks the mailbox drainer, not the application: Emit still returns immediately and the excess accumulates one layer up. This bound therefore limits the per-window queue, not total in-flight memory, and does not surface backpressure to app code. Ordering — the point of this PR — is unaffected either way, but the bound should not be read as system-wide backpressure.

The two cases are covered by EventQueueDoesNotBlockMainThreadWhenFull and EventQueueCloseReleasesBlockedProducer.

Sizing

Picked by measurement. Ordering held and 5000 ev/s was sustained at every size, so the only thing that moved was tail latency, which grows with depth as events wait behind a longer backlog:

capacity 5000 ev/s mixed-source p99
16 5000/s, 0 inversions 179 µs
64 4991/s, 0 inversions 257 µs
256 4995/s, 0 inversions 329 µs
1024 5000/s, 0 inversions 427 µs

64 keeps it shallow while leaving room to absorb a burst. Most apps will never reach it. Raise it only with a measurement showing a producer being throttled.

Emitting also got cheaper

Emit no longer blocks on a main-thread round trip — it is just an append. At 5000 ev/s, emit p50 falls from ~45 µs to 1–2 µs.

A correction to #5930

The scenario that first reported this took the sequence number outside the InvokeAsync callback and dispatched later, so sequence order differed from call order by construction. The reorders it counted were the harness racing itself, and the ~4.4% figure quoted in #5930 is not a valid measure of framework ordering. It's fixed here.

With the sequence number taken at the point of dispatch, and emit order serialised so that it is defined at all, the unfixed code does not merely reorder — it deadlocks, because a goroutine holds the emit lock while parked in InvokeSync waiting for the UI thread. The same run completes in ~35 s with the queue. So the old design cannot express ordered cross-thread emission at all, which is a stronger statement than the retracted percentage.

The deterministic proof that the inversion is real is the unit test, which drives DispatchWailsEvent and fails without the queue by delivering "second" before "first".

Type of change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • WEP (proposal only; no implementation)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • This change requires a documentation update

How Has This Been Tested?

Four unit tests, no GUI needed so they run in CI:

test covers
EventFromMainThreadDoesNotOvertakeQueuedEvent the bug — fails without the queue
EventQueuePreservesSingleProducerOrder 256 events through a queue 4× smaller, drained concurrently
EventQueueDoesNotBlockMainThreadWhenFull a UI-thread emitter never waits, and drains inline
EventQueueCloseReleasesBlockedProducer destroying a window wakes a blocked emitter

Plus v3/tests/event-performance on all three platforms — mixedsource, interleave and rate-5000, 30 s each:

platform mixed-source inversions
macOS 26.4.1 29,834 events @ 994/s 0
Ubuntu 26.04 (WebKitGTK 2.52.3) 30,015 events @ 1000/s 0
Windows 11 (WebView2 151) 30,001 events @ 1000/s 0

rate-5000 also 0 inversions on all three, confirming no throughput regression.

  • Windows
  • macOS
  • Linux

Linux: Ubuntu 26.04 LTS x86_64, headless under Xvfb, both backends —
default GTK4 (libgtk-4 / libwebkitgtk-6.0) and legacy -tags gtk3
(libgtk-3 / libwebkit2gtk-4.1), confirmed by the linked libraries rather than the build tag alone. mixedsource is 0 inversions on both; unit tests pass under both.
Windows: Windows 11 Pro 26200 x86_64, WebView2 151.0.4129.72, interactive console session.

Test Configuration

Wails CLI    : v3.0.0-dev
Go           : go1.26.2
Platform     : darwin/arm64, macOS 26.4.1 (25E253)

Checklist:

  • (v2 only) I have updated website/src/pages/changelog.mdx with details of this PR (v3 changelog entries are added automatically)
  • My code follows the general coding style of this project
  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes

Summary by CodeRabbit

  • Improvements
    • Event delivery now preserves emission order across UI-thread and background callers.
    • Event dispatch remains responsive when the queue reaches capacity.
    • Closing a window safely releases pending event producers.
    • Added queue usage monitoring through a high-water mark.
    • Improved reliability for mixed-source event dispatch and large event payloads.
  • Tests
    • Added coverage for ordering, concurrent emitters, queue saturation, shutdown behavior, payload cleanup, and mixed-source dispatch performance.

App.dispatchOnMainThread runs work inline when the caller is already on the
UI thread. So an event emitted from the UI thread executed its eval
immediately while an event emitted moments earlier from a goroutine was
still sitting in the dispatch queue, and the later event arrived first.

DispatchWailsEvent now appends to a per-window queue instead of calling
ExecJS directly, and a single drain on the UI thread empties it in order.
Appending under a mutex makes the queue the one ordering authority: the
order events are added is the order they are delivered, whichever thread
each came from.

The queue is bounded, and the bound applies to goroutine emitters only. A
UI-thread emitter is never made to wait, because it is the drainer -
blocking it could not be relieved by anything and would be a guaranteed
deadlock rather than a slow path. In practice it never queues at all: the
drain scheduled via InvokeAsync runs inline when already on the UI thread,
so a main-thread emit enqueues and immediately drains.

Capacity picked by measurement rather than taste. Ordering held and
5000 ev/s was sustained at every size tried, so only tail latency moved,
growing with depth as events wait behind a longer backlog:

    capacity   5000 ev/s        mixed-source p99
          16   5000/s, 0 inv    179us
          64   4991/s, 0 inv    257us
         256   4995/s, 0 inv    329us
        1024   5000/s, 0 inv    427us

64 keeps it shallow while leaving room to absorb a burst.

Emitting also got cheaper. It no longer blocks on a main-thread round trip,
just an append, so emit p50 at 5000 ev/s falls from ~45us to 1-2us.

Verified on macOS 26.4.1, Ubuntu 26.04 (WebKitGTK) and Windows 11
(WebView2): 0 inversions across mixed-source, interleaved-size and
5000 ev/s runs on all three.

Four unit tests cover it, and they need no GUI so they run in CI. The
headline one drives DispatchWailsEvent itself and fails without the queue,
delivering "second" before "first".

Also corrects the harness scenario that first reported this. It took the
sequence number outside the InvokeAsync callback and dispatched later, so
sequence order differed from call order by construction and the reorders it
counted were the harness racing itself. The number it produced (~4.4%) was
not a valid measure of framework ordering and should not be relied on. With
the sequence number taken at the point of dispatch, and emit order
serialised so it is defined at all, the unfixed code does not merely
reorder - it deadlocks, because a goroutine holds the emit lock while
parked in InvokeSync waiting for the UI thread.
Copilot AI lite review requested due to automatic review settings August 9, 2026 23:37
@github-actions github-actions Bot added the v3 label Aug 9, 2026
@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

WebviewWindow now queues per-window event JavaScript in order. The queue applies bounded backpressure, drains on the UI thread, closes during destruction, and releases blocked producers. Tests cover ordering and cleanup. Performance scenarios serialize mixed-source emission.

Changes

Event ordering and dispatch

Layer / File(s) Summary
Bounded WebviewWindow event queue
v3/pkg/application/webview_window.go
Event JavaScript uses a synchronized, capacity-64 queue. UI-thread draining preserves order. Non-UI producers wait when full. Destruction closes the queue and releases pending producers.
Queue ordering and shutdown tests
v3/pkg/application/event_ordering_test.go
Controlled dispatch tests verify cross-thread ordering, single-producer ordering, non-blocking main-thread emission, queue limits, producer release, and payload reclamation.
Mixed-source emission synchronization
v3/tests/event-performance/main.go, v3/tests/event-performance/scenarios.go
Mixed-source scenarios serialize sequence assignment and dispatch across asynchronous and regular emitters. Panic cleanup releases the emission mutex.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Producer as Event producer
  participant Window as WebviewWindow
  participant UI as UI thread
  participant Webview
  Producer->>Window: enqueueEventJS
  Window->>UI: schedule one queue drain
  UI->>Window: drain events in order
  Window->>Webview: ExecJS for each event
Loading

Possibly related PRs

Poem

A rabbit guards the event queue,
Each message hops in order true.
Full queues pause the waiting stream,
UI drains each JavaScript dream.
Closed windows wake the crew—
Payloads vanish cleanly too.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely summarizes the main change: ordered event delivery through a per-window queue.
Description check ✅ Passed The description thoroughly explains the fix, motivation, testing, platforms, configuration, and checklist, with only minor template fields incomplete.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/event-ordering-queue

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes cross-thread event reordering in Wails v3 by ensuring WebviewWindow.DispatchWailsEvent delivers events in the same order they are emitted, even when some emits originate on the UI thread (where dispatchOnMainThread runs inline) and others originate from goroutines (where work is queued).

Changes:

  • Route event delivery through a per-window FIFO queue and drain it on the UI thread to preserve emit order and avoid the known deadlock shape of locking across the main-thread hop.
  • Update the event-performance harness to take sequence numbers at the moment of dispatch (and serialize seq+dispatch for mixed-source scenarios).
  • Add unit tests that deterministically reproduce the inversion and validate ordering/backpressure/close semantics.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.

File Description
v3/pkg/application/webview_window.go Implements per-window event queueing/draining and closes the queue on window destroy.
v3/pkg/application/event_ordering_test.go Adds regression + behavioral tests for ordered delivery and queue semantics without requiring a GUI.
v3/tests/event-performance/scenarios.go Adds a mutex to serialize seq assignment with dispatch in mixed-source scenarios (harness correctness).
v3/tests/event-performance/main.go Moves seq assignment into the actual dispatch callback and uses the new harness mutex to prevent self-racing.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread v3/pkg/application/webview_window.go Outdated
Comment thread v3/pkg/application/webview_window.go Outdated
Comment thread v3/tests/event-performance/main.go Outdated
Comment thread v3/pkg/application/webview_window.go Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@v3/pkg/application/event_ordering_test.go`:
- Around line 284-290: Make the blocked producer state deterministic in the test
around win.enqueueEventJS by adding a test-only synchronization signal
immediately before the producer enters eventQueueCond.Wait, then wait for that
signal before calling win.closeEventQueue. Preserve the existing
blocked-producer and completion assertions so the test verifies Broadcast
releases the waiting producer.

In `@v3/pkg/application/webview_window.go`:
- Around line 1441-1450: The event payload path in the dispatcher around
enqueueEventJS and eventPayloads.put can leak when queue closure causes enqueue
rejection. Ensure payload insertion and queue acceptance are atomic, or remove
the specific payload immediately whenever enqueueEventJS rejects it, including
the corresponding logic in the alternate path around the referenced range.

In `@v3/tests/event-performance/main.go`:
- Around line 395-403: Move the emitMu acquisition from the UI callback passed
to application.InvokeAsync into the ticker goroutine before scheduling the
callback, and hold it until the callback finishes win.DispatchWailsEvent. Update
both emitter paths, including the corresponding logic around the second
InvokeAsync occurrence, so the UI thread never waits on emitMu while preserving
sequence generation and unlocking on completion.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: b393f961-db71-41f1-9c11-452b9c397e3c

📥 Commits

Reviewing files that changed from the base of the PR and between 114eef7 and b97e988.

📒 Files selected for processing (4)
  • v3/pkg/application/event_ordering_test.go
  • v3/pkg/application/webview_window.go
  • v3/tests/event-performance/main.go
  • v3/tests/event-performance/scenarios.go

Comment thread v3/pkg/application/event_ordering_test.go
Comment thread v3/pkg/application/webview_window.go Outdated
Comment thread v3/tests/event-performance/main.go Outdated
A parked payload could be stranded (CodeRabbit, major). A dispatcher can
pass the destroyed check, markAsDestroyed can then close the queue and run
dropWindow, and only after that does put succeed - so dropWindow cannot see
the entry and the queue refuses the event that would have fetched it,
leaving it for the TTL sweep. enqueueEventJS now reports whether it
accepted, and a rejected by-reference event reclaims its payload. A
regression test covers it and strands 8217 bytes without the fix.

The harness's own emit lock could deadlock (CodeRabbit, major), which is
the exact hazard this PR is about. A goroutine emitter held emitMu while
DispatchWailsEvent waited for queue capacity, and the UI callback then
blocked on emitMu - so the drainer could not drain. The lock is now taken
on the ticker goroutine and released by the UI callback, so only a
goroutine ever waits for it. The goroutine emitter releases via defer, so a
panic in dispatch cannot wedge the scenario either.

The blocked-producer test was racy (CodeRabbit): close could win before the
producer reached Wait, so it could pass without exercising the Broadcast it
exists to test. It now waits for the queue to be full and the producer to
still be in flight before closing.

Also from Copilot: the retracted ~4.4% figure is gone from the code comment
now that it is known to be a harness artifact, EventQueueHighWater is
unexported since only package tests use it, and a "resliceing" typo.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.

@leaanthony
leaanthony merged commit 0076e9b into master Aug 10, 2026
51 checks passed
@leaanthony
leaanthony deleted the fix/event-ordering-queue branch August 10, 2026 01:27
leaanthony pushed a commit that referenced this pull request Aug 10, 2026
@taliesin-ai taliesin-ai added this to the v3.0.0-beta.3 milestone Aug 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

Archived in project

Development

Successfully merging this pull request may close these issues.

3 participants