Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 67 additions & 9 deletions v3/pkg/application/application.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@ func New(appOptions Options) *App {
result.logPlatformInfo()

result.customEventProcessor = NewWailsEventProcessor(result.Event.dispatch)
result.eventPayloads = newEventPayloadStore()
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// The store owns a reaper goroutine; window close only drops that window's
// entries, so the store itself has to be shut down with the app.
result.OnShutdown(result.eventPayloads.close)

messageProc := NewMessageProcessor(result.Logger)
result.messageProcessor = messageProc
Expand Down Expand Up @@ -113,6 +117,12 @@ func New(appOptions Options) *App {
func(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
path := req.URL.Path
// Oversized events are parked host-side and fetched here rather
// than being spliced into an evaluateJavaScript source string.
if strings.HasPrefix(path, eventPayloadPath) {
result.serveEventPayload(rw, req)
return
}
switch path {
case "/wails/runtime.js":
err := assetserver.ServeFile(rw, path, bundledassets.RuntimeJS)
Expand Down Expand Up @@ -281,6 +291,51 @@ func addDragAndDropMessage(windowId uint, filenames []string, dropTarget *DropTa

var _ webview.Request = &webViewAssetRequest{}

// serveEventPayload delivers an oversized event body that was parked by
// DispatchWailsEvent. Payloads are one-shot and bound to the window they were
// dispatched to, so a stale or cross-window id simply 404s.
func (a *App) serveEventPayload(rw http.ResponseWriter, req *http.Request) {
if a.eventPayloads == nil {
http.NotFound(rw, req)
return
}

// Read-only endpoint; anything else is not something we serve.
if req.Method != http.MethodGet && req.Method != http.MethodHead {
rw.Header().Set("Allow", "GET, HEAD")
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
return
}

// Ids are always 32 hex chars. Checking the shape first keeps a stream of
// junk requests from doing map work on arbitrarily long keys.
id := strings.TrimPrefix(req.URL.Path, eventPayloadPath)
if len(id) != eventPayloadIDLen || !isHexString(id) {
http.NotFound(rw, req)
return
}

// Bind to the requesting window where the platform tags the request.
// Parsed at uint width so the conversion cannot truncate on 32-bit builds.
var windowID uint
if raw := req.Header.Get(webViewRequestHeaderWindowId); raw != "" {
if parsed, err := strconv.ParseUint(raw, 10, strconv.IntSize); err == nil {
windowID = uint(parsed)
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
}
}

data, ok := a.eventPayloads.take(id, windowID)
if !ok {
http.NotFound(rw, req)
return
}

rw.Header().Set("Content-Type", "application/json")
rw.Header().Set("Cache-Control", "no-store")
rw.Header().Set("Content-Length", strconv.Itoa(len(data)))
_, _ = rw.Write(data)
}

const webViewRequestHeaderWindowId = "x-wails-window-id"
const webViewRequestHeaderWindowName = "x-wails-window-name"

Expand Down Expand Up @@ -397,8 +452,12 @@ type App struct {
contextMenus map[string]*ContextMenu
contextMenusLock sync.RWMutex

assets *assetserver.AssetServer
startURL string
assets *assetserver.AssetServer

// eventPayloads holds oversized Go→JS event bodies awaiting a one-shot
// fetch from the webview, keeping them out of evaluateJavaScript source.
eventPayloads *eventPayloadStore
startURL string

// Hooks
windowCreatedCallbacks []func(window Window)
Expand Down Expand Up @@ -602,13 +661,12 @@ func (a *App) Run() error {
a.options.Services = services[:i+1]
}


// Start the MCP server when the application is built with -tags mcp.
// All configuration is read from environment variables (WAILS_MCP_HOST,
// WAILS_MCP_PORT, WAILS_MCP_TIMEOUT, WAILS_MCP_HIDE_CURSOR).
if err := startMCPServer(a); err != nil {
return fmt.Errorf("mcp: %w", err)
}
// Start the MCP server when the application is built with -tags mcp.
// All configuration is read from environment variables (WAILS_MCP_HOST,
// WAILS_MCP_PORT, WAILS_MCP_TIMEOUT, WAILS_MCP_HIDE_CURSOR).
if err := startMCPServer(a); err != nil {
return fmt.Errorf("mcp: %w", err)
}

go func() {
for {
Expand Down
192 changes: 192 additions & 0 deletions v3/pkg/application/event_payload_store.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
package application

import (
"crypto/rand"
"encoding/hex"
"sync"
"time"
)

// Oversized Go→JS events are not spliced into the JavaScript source passed to
// the webview's eval. Above a platform-specific size WebKit switches from inline
// IPC message data to an out-of-line shared-memory transfer, and one of the two
// processes then retains those regions for as long as the app keeps emitting.
// Measured with v3/tests/event-performance:
//
// - macOS 26.4.1 / WebKit-Cocoa: the retention lands in the HOST process,
// visible in vmmap as "owned unmapped" — one region per oversized eval.
// At a constant 4 MB/s, 8 KB payloads held the host flat at ~36 MB while
// 16 KB payloads climbed to 363 MB. 100 ev/s of 1 MB reached 11.5 GB.
// - Ubuntu 26.04 / WebKitGTK 2.52.3: the mirror image — the host stays flat
// and the WEB process grows instead, reaching 6.2 GB on the same scenario.
// Its switchover sits higher, between 64 KB and 128 KB.
// - Windows 11 / WebView2 151: not affected. The host holds flat at 65-77 MB
// and the content process sawtooths under V8 GC in patched and unpatched
// builds alike, so there is no transport retention to avoid. Routing large
// payloads off the eval path still helps there — it delivers more bytes per
// second while putting roughly half the pressure on the content process,
// since an eval materialises both the source string and the parsed object.
//
// Instead, the payload is parked here under an unguessable id and the webview
// is told to fetch it from the asset server, which delivers via a URL scheme
// task and never touches the eval IPC path.
const (
// maxInlineEventPayload is the largest marshalled event JSON that may be
// spliced directly into an eval. 8192 is the largest size measured at 0%
// retention on macOS, which has the lowest knee of the three engines
// (8-16 KB vs WebKitGTK's 64-128 KB; WebView2 has none). A single value is
// therefore correct everywhere, just conservative off macOS: on Linux it
// routes 8-128 KB payloads through HTTP that could have stayed inline,
// costing a round trip rather than correctness.
maxInlineEventPayload = 8192

// eventPayloadTTL bounds how long an unfetched payload is held. A page
// reload or a window close between dispatch and fetch would otherwise
// strand it forever — which would just move the leak into Go.
eventPayloadTTL = 30 * time.Second

// eventPayloadSweep is deliberately shorter than the TTL. Sweeping once per
// TTL would let an entry created just after a sweep survive until the one
// after that, holding it for nearly twice the documented bound.
eventPayloadSweep = eventPayloadTTL / 4

// eventPayloadStoreMaxBytes caps total parked bytes. On overflow the caller
// falls back to inline delivery: that event then pays the out-of-line
// retention cost, which is strictly better than dropping it or letting the
// store grow without bound.
eventPayloadStoreMaxBytes = 64 * 1024 * 1024

eventPayloadPath = "/wails/eventpayload/"

// eventPayloadIDLen is the hex length of an id: 16 random bytes.
eventPayloadIDLen = 32
)

// isHexString reports whether s is entirely lowercase hex, the shape produced
// by hex.EncodeToString in put.
func isHexString(s string) bool {
for i := 0; i < len(s); i++ {
c := s[i]
if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
return false
}
}
return true
}

type parkedEventPayload struct {
data []byte
windowID uint
created time.Time
}

// eventPayloadStore holds oversized event payloads awaiting a one-shot fetch
// from the webview.
type eventPayloadStore struct {
mu sync.Mutex
items map[string]parkedEventPayload
bytes int
closed bool // guarded by mu; put refuses once shutdown has begun
janitor sync.Once
once sync.Once // guards close of stop, so repeated close is safe
stop chan struct{}
}

func newEventPayloadStore() *eventPayloadStore {
return &eventPayloadStore{
items: make(map[string]parkedEventPayload),
stop: make(chan struct{}),
}
}

// put parks a payload and returns its id. ok is false when the store is full,
// in which case the caller must fall back to inline delivery.
func (s *eventPayloadStore) put(windowID uint, data []byte) (id string, ok bool) {
var buf [16]byte
if _, err := rand.Read(buf[:]); err != nil {
return "", false
}
id = hex.EncodeToString(buf[:])

s.mu.Lock()
defer s.mu.Unlock()

// Refuse once shutdown has begun. Storing here afterwards would strand the
// payload: the reaper is gone, so nothing would ever expire it.
if s.closed {
return "", false
}
if s.bytes+len(data) > eventPayloadStoreMaxBytes {
return "", false
}
s.items[id] = parkedEventPayload{data: data, windowID: windowID, created: time.Now()}
s.bytes += len(data)

s.janitor.Do(func() { go s.reap() })
return id, true
}

// take returns the payload for id exactly once. windowID must match the window
// the payload was dispatched to; a zero windowID skips the check, for platforms
// that do not tag asset requests with a window id.
func (s *eventPayloadStore) take(id string, windowID uint) ([]byte, bool) {
s.mu.Lock()
defer s.mu.Unlock()

item, found := s.items[id]
if !found {
return nil, false
}
if windowID != 0 && item.windowID != windowID {
return nil, false
}
delete(s.items, id)
s.bytes -= len(item.data)
return item.data, true
}

// dropWindow discards anything parked for a window that is going away.
func (s *eventPayloadStore) dropWindow(windowID uint) {
s.mu.Lock()
defer s.mu.Unlock()
for id, item := range s.items {
if item.windowID == windowID {
delete(s.items, id)
s.bytes -= len(item.data)
}
}
}

func (s *eventPayloadStore) reap() {
ticker := time.NewTicker(eventPayloadSweep)
defer ticker.Stop()
for {
select {
case <-s.stop:
return
case now := <-ticker.C:
s.mu.Lock()
for id, item := range s.items {
// Expired once the deadline is reached, not strictly past it.
if !item.created.Add(eventPayloadTTL).After(now) {
delete(s.items, id)
s.bytes -= len(item.data)
}
}
s.mu.Unlock()
}
}
}

// close stops the reaper and refuses further payloads. Safe to call more than
// once and concurrently with put.
func (s *eventPayloadStore) close() {
s.mu.Lock()
s.closed = true
s.items = map[string]parkedEventPayload{}
s.bytes = 0
s.mu.Unlock()

s.janitor.Do(func() {}) // consume the Once so reap can never start later
s.once.Do(func() { close(s.stop) })
}
65 changes: 60 additions & 5 deletions v3/pkg/application/webview_window.go
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,12 @@ func (w *WebviewWindow) markAsDestroyed() {
w.destroyedLock.Lock()
defer w.destroyedLock.Unlock()
w.destroyed = true

// Anything parked for this window will never be fetched now. TTL would
// eventually reclaim it, but the window is gone, so release immediately.
if globalApplication != nil && globalApplication.eventPayloads != nil {
globalApplication.eventPayloads.dropWindow(w.id)
}
}

func (w *WebviewWindow) setupEventMapping() {
Expand Down Expand Up @@ -1370,15 +1376,64 @@ func (w *WebviewWindow) SetFrameless(frameless bool) Window {
return w
}

// Event delivery templates.
//
// All three keep the existing guard against the runtime not being mounted yet
// (during page reload WindowLoadFinished can fire before dispatchWailsEvent
// exists), and all three call the same public dispatchWailsEvent entry point,
// so no runtime JS or third-party page code has to change.
// Whether an event can be delivered synchronously depends on whether an
// earlier event for the same window is still being fetched. That decision is
// made in JavaScript rather than in Go, deliberately: evals execute serially on
// the UI thread in the order they were enqueued, so `w.__eq` is observed in
// delivery order and needs no locking. Deciding it in Go would require holding
// a lock across the ExecJS main-thread hop, which is the same shape as the
// deadlock documented in transport_event_ipc.go.
//
// Both templates terminate the chain with a catch. Without it a single throwing
// listener would leave `w.__eq` permanently rejected and silently stop every
// later event for that window.
const (
// inline: small enough to splice directly. Dispatches synchronously unless
// a fetch is already outstanding, in which case it queues behind it so it
// cannot overtake.
inlineEventJS = "if(window._wails&&window._wails.dispatchWailsEvent){var w=window._wails;" +
"if(w.__eq){w.__eq=w.__eq.then(function(){w.dispatchWailsEvent(%s);}).catch(function(){});}" +
"else{w.dispatchWailsEvent(%[1]s);}}"

// ref: the payload is parked host-side; fetch it over the asset server
// rather than passing it through evaluateJavaScript. Creating w.__eq is
// what makes every subsequent event for this window queue behind it.
refEventJS = "if(window._wails&&window._wails.dispatchWailsEvent){var w=window._wails;" +
"w.__eq=(w.__eq||Promise.resolve()).then(function(){" +
"return fetch(%q,{cache:'no-store'}).then(function(r){return r.json();})" +
".then(function(e){w.dispatchWailsEvent(e);});}).catch(function(){});}"
)

func (w *WebviewWindow) DispatchWailsEvent(event *CustomEvent) {
if w.impl == nil || w.isDestroyed() {
return
}
// Guard against race condition where event fires before runtime is initialized
// This can happen during page reload when WindowLoadFinished fires before
// the JavaScript runtime has mounted dispatchWailsEvent on window._wails
msg := fmt.Sprintf("if(window._wails&&window._wails.dispatchWailsEvent){window._wails.dispatchWailsEvent(%s);}", event.ToJSON())
w.ExecJS(msg)

payload := event.ToJSON()

// Large payloads are delivered by reference. Splicing them into the eval
// source makes WebKit transfer them out-of-line via shared memory, and the
// host process retains ownership of those regions while the app keeps
// emitting — 100 ev/s of 1 MB events reached 11.5 GB before this change.
// See event_payload_store.go for the measurements.
if len(payload) > maxInlineEventPayload {
if store := globalApplication.eventPayloads; store != nil {
if id, ok := store.put(w.id, []byte(payload)); ok {
w.ExecJS(fmt.Sprintf(refEventJS, eventPayloadPath+id))
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return
}
}
// Store unavailable, full, or shutting down: fall back to inline. This
// event pays the out-of-line retention cost, which beats dropping it.
}

w.ExecJS(fmt.Sprintf(inlineEventJS, payload))
}

func (w *WebviewWindow) dispatchWindowEvent(id uint) {
Expand Down
Loading
Loading