From e02173801c9731f64070a49d5c557fe4183974c2 Mon Sep 17 00:00:00 2001 From: Lea Anthony Date: Sun, 9 Aug 2026 21:53:45 +1000 Subject: [PATCH 1/8] fix(v3): keep oversized event payloads out of evaluateJavaScript Go->JS events are delivered by splicing the event JSON into a JavaScript source string and evaluating it in the webview. Above a size threshold, WebKit switches from inline IPC message data to an out-of-line shared-memory transfer, and those regions are retained for as long as the app keeps emitting. Measured with v3/tests/event-performance at 100 events/sec of 1MB: macOS 26.4.1 host process -> 11,629 MB Ubuntu 26.04 web process -> 6,262 MB The two ports differ in which process holds the memory, and in where the threshold sits (macOS 8-16KB, WebKitGTK 64-128KB), but the mechanism is the same. On macOS vmmap attributes all of it to "owned unmapped", at exactly one region per oversized eval. Below the threshold there is no retention at all: 1.19M events at 64B left the content process at its own floor, so this is a function of per-event payload size, not call rate. Events whose marshalled JSON exceeds maxInlineEventPayload are now parked host-side under a random 128-bit id and fetched by the webview over the asset server, which delivers via a URL scheme task and never touches the eval IPC path. Small events keep the existing inline path unchanged. macOS size-1MB 11,629 MB -> 65.8 MB (177x) macOS iso-64KB 1,004 MB -> 56.5 MB (18x) Linux size-1MB 6,262 MB -> 225 MB (28x) vmmap confirms "owned unmapped" disappears from the region table entirely and host footprint holds flat at 33.8 MB across the run. Ordering is preserved: once a window has sent one event by reference it routes all subsequent events through the same JS promise chain, so a small event cannot overtake a large one still being fetched. Verified with an interleave scenario alternating 1KB and 64KB payloads - consecutive sequence numbers travelling by different mechanisms - at 0 reorders and 0 drops on both platforms. The payload store is one-shot on read, bound to the dispatching window via the existing x-wails-window-id header, TTL-reaped at 30s, capped at 64MB with fallback to inline on overflow, and released on window destroy. No public API change, and no runtime JS change: all three delivery templates call the same window._wails.dispatchWailsEvent entry point and keep the existing guard for the runtime not yet being mounted. Co-authored-by: superDingda <89772770+superDingda@users.noreply.github.com> --- v3/pkg/application/application.go | 46 +++++++ v3/pkg/application/event_payload_store.go | 153 ++++++++++++++++++++++ v3/pkg/application/webview_window.go | 70 +++++++++- 3 files changed, 264 insertions(+), 5 deletions(-) create mode 100644 v3/pkg/application/event_payload_store.go diff --git a/v3/pkg/application/application.go b/v3/pkg/application/application.go index 8097a4b7aa9..c218cc71f90 100644 --- a/v3/pkg/application/application.go +++ b/v3/pkg/application/application.go @@ -72,6 +72,7 @@ func New(appOptions Options) *App { result.logPlatformInfo() result.customEventProcessor = NewWailsEventProcessor(result.Event.dispatch) + result.eventPayloads = newEventPayloadStore() messageProc := NewMessageProcessor(result.Logger) result.messageProcessor = messageProc @@ -113,6 +114,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) @@ -281,6 +288,41 @@ 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 + } + + id := strings.TrimPrefix(req.URL.Path, eventPayloadPath) + if id == "" || strings.Contains(id, "/") { + http.NotFound(rw, req) + return + } + + // Bind to the requesting window where the platform tags the request. + var windowID uint + if raw := req.Header.Get(webViewRequestHeaderWindowId); raw != "" { + if parsed, err := strconv.ParseUint(raw, 10, 64); err == nil { + windowID = uint(parsed) + } + } + + 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" @@ -398,6 +440,10 @@ type App struct { contextMenusLock sync.RWMutex 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 diff --git a/v3/pkg/application/event_payload_store.go b/v3/pkg/application/event_payload_store.go new file mode 100644 index 00000000000..3432cfaa306 --- /dev/null +++ b/v3/pkg/application/event_payload_store.go @@ -0,0 +1,153 @@ +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. +// +// 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, whose knee (8-16 KB) is the lower of the two measured + // platforms; WebKitGTK's is 64-128 KB, so this is correct there too, just + // conservative. WebView2 is unmeasured — run the iso-* scenarios in + // v3/tests/event-performance before assuming this value transfers. + 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 + + // 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/" +) + +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 + janitor sync.Once + 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() + + 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(eventPayloadTTL) + defer ticker.Stop() + for { + select { + case <-s.stop: + return + case now := <-ticker.C: + s.mu.Lock() + for id, item := range s.items { + if now.Sub(item.created) > eventPayloadTTL { + delete(s.items, id) + s.bytes -= len(item.data) + } + } + s.mu.Unlock() + } + } +} + +func (s *eventPayloadStore) close() { + s.janitor.Do(func() {}) // ensure reap is never started after close + select { + case <-s.stop: + default: + close(s.stop) + } +} diff --git a/v3/pkg/application/webview_window.go b/v3/pkg/application/webview_window.go index 51ef265edd9..c8368004f84 100644 --- a/v3/pkg/application/webview_window.go +++ b/v3/pkg/application/webview_window.go @@ -189,6 +189,11 @@ type WebviewWindow struct { menuBindings map[string]*MenuItem menuBindingsLock sync.RWMutex + // Set once an event has been delivered by reference to this window. From + // then on every event is appended to the same JS promise chain, so a small + // event cannot overtake a large one that is still being fetched. + eventRefMode atomic.Bool + // Indicates that the window is destroyed destroyed bool destroyedLock sync.RWMutex @@ -270,6 +275,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() { @@ -1369,15 +1380,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. +const ( + // inline: the payload is small enough to splice directly. Synchronous, and + // byte-for-byte the historical behaviour. + inlineEventJS = "if(window._wails&&window._wails.dispatchWailsEvent){window._wails.dispatchWailsEvent(%s);}" + + // chained: same as inline but appended to the window's delivery chain. + // Used once a window has sent an out-of-line event, so that small events + // cannot overtake a large one still being fetched. + chainedEventJS = "if(window._wails&&window._wails.dispatchWailsEvent){var w=window._wails;" + + "w.__eq=(w.__eq||Promise.resolve()).then(function(){w.dispatchWailsEvent(%s);});}" + + // ref: the payload is parked host-side; fetch it over the asset server + // rather than passing it through evaluateJavaScript. + 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 { + // Every subsequent event for this window goes through the same + // promise chain. Once one event is in flight asynchronously, + // letting later small events dispatch synchronously would + // reorder them past it. + w.eventRefMode.Store(true) + w.ExecJS(fmt.Sprintf(refEventJS, eventPayloadPath+id)) + return + } + } + // Store unavailable or full: fall back to inline. This event pays the + // out-of-line retention cost, which beats dropping it. + } + + if w.eventRefMode.Load() { + w.ExecJS(fmt.Sprintf(chainedEventJS, payload)) + return + } + w.ExecJS(fmt.Sprintf(inlineEventJS, payload)) } func (w *WebviewWindow) dispatchWindowEvent(id uint) { From a8070633a34026f2547dd349ff53348656d9d0b0 Mon Sep 17 00:00:00 2001 From: Lea Anthony Date: Sun, 9 Aug 2026 21:54:06 +1000 Subject: [PATCH 2/8] test(v3): add event dispatch performance harness A standalone Wails app under v3/tests/event-performance that measures the Go->JS event transport and produces a repeatable before/after number. It drives events straight at WebviewWindow.DispatchWailsEvent, the exact entry point that performs the eval, so the measurement is of the transport rather than the runtime's listener plumbing or the app-level fanout. The page intercepts window._wails.dispatchWailsEvent by pre-creating the _wails object with an accessor, which survives whichever runtime module mounts first. Samples both the host process and the web content process every 250ms. Watching only one of them misses the bug: on macOS the retention shows up in the host, on Linux in the web process. sampler_darwin.go proc_pid_rusage ri_phys_footprint; content process located by diffing the pid set across window creation (an empty diff aborts rather than summing every WebContent on the machine) sampler_linux.go Pss via smaps_rollup; WebKitWebProcess located via /proc/*/cmdline, since comm truncates at 15 chars and the name is 16 sampler_other.go stubs, so timing and ordering still run elsewhere Verdicts grade on floor rise - the minimum of the window's second half minus the minimum of its first - not on a regression slope. Memory under GC is a sawtooth, so a slope mostly reports which phase the window opened in, and it produced false leak labels on the first pass. A process that is genuinely leaking cannot return to its earlier floor. Go MemStats are recorded alongside, so native growth can be told apart from Go heap. Scenarios: idle baseline drift with a webview open and no events rate-* 10..5000 ev/s at 64B - isolates call count size-* 1KB..1MB at 100 ev/s - isolates payload size iso-* ~4MB/s held constant, 1KB..64KB - locates the threshold iso2-* ~32MB/s held constant, 32KB..1MB - for higher knees interleave alternating 1KB/64KB - ordering across delivery modes mixedsource goroutine + main-thread emitters on one sequence burst mean 1000 ev/s in bursts of 20 pathological 10 ev/s at 8MB Outputs a CSV per scenario, summary.json, and REPORT.md. Known result: mixedsource reports ~4.4% inversions on master. That is a pre-existing hazard - dispatchOnMainThread runs inline when already on the main thread, so a main-thread emit overtakes a queued goroutine emit - and is unrelated to event payload size. Recorded here as a regression test for whoever fixes it. --- v3/tests/event-performance/assets/index.html | 165 +++++++ v3/tests/event-performance/main.go | 477 +++++++++++++++++++ v3/tests/event-performance/report.go | 441 +++++++++++++++++ v3/tests/event-performance/sampler_darwin.go | 86 ++++ v3/tests/event-performance/sampler_linux.go | 91 ++++ v3/tests/event-performance/sampler_other.go | 12 + v3/tests/event-performance/scenarios.go | 182 +++++++ 7 files changed, 1454 insertions(+) create mode 100644 v3/tests/event-performance/assets/index.html create mode 100644 v3/tests/event-performance/main.go create mode 100644 v3/tests/event-performance/report.go create mode 100644 v3/tests/event-performance/sampler_darwin.go create mode 100644 v3/tests/event-performance/sampler_linux.go create mode 100644 v3/tests/event-performance/sampler_other.go create mode 100644 v3/tests/event-performance/scenarios.go diff --git a/v3/tests/event-performance/assets/index.html b/v3/tests/event-performance/assets/index.html new file mode 100644 index 00000000000..b19f87f1569 --- /dev/null +++ b/v3/tests/event-performance/assets/index.html @@ -0,0 +1,165 @@ + + + + +eventperf + + + +

eventperf — transport measurement

+ + + + + + + +
epoch0
received0
drops0
reorders0
long frames (>20ms)0
jitter p990
+ + + + + + + diff --git a/v3/tests/event-performance/main.go b/v3/tests/event-performance/main.go new file mode 100644 index 00000000000..3bffed76590 --- /dev/null +++ b/v3/tests/event-performance/main.go @@ -0,0 +1,477 @@ +// Command eventperf is the Phase -1 measurement harness for Wails v3 Go→JS +// event dispatch. +// +// It exists to answer one question with numbers instead of argument: does +// high-frequency event dispatch leak memory in the WebKit content process, +// and if so is the growth driven by call COUNT or by payload BYTES? +// +// It drives events straight at WebviewWindow.DispatchWailsEvent — the exact +// entry point that performs the evaluateJavaScript call — so the measurement +// is of the transport, not of the runtime's listener plumbing or the +// application-level fanout. +// +// Build/run (macOS): +// +// go run ./tests/event-performance -duration 30s +package main + +import ( + _ "embed" + "encoding/json" + "flag" + "fmt" + "log" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + "time" + + "github.com/wailsapp/wails/v3/pkg/application" + "github.com/wailsapp/wails/v3/pkg/events" +) + +//go:embed assets/index.html +var indexHTML []byte + +var ( + flagDuration = flag.Duration("duration", 30*time.Second, "measurement window per scenario") + flagSettle = flag.Duration("settle", 3*time.Second, "settle period before each scenario (discarded)") + flagOut = flag.String("out", "", "output directory (default: ./eventperf-results)") + flagOnly = flag.String("only", "", "comma-separated scenario names to run (default: all)") + flagSample = flag.Duration("sample", 250*time.Millisecond, "footprint sample interval") +) + +// perfPayload is the event body. Pad controls payload size; Seq and TMS let the +// JS side detect drops/reorders and compute delivery latency. +type perfPayload struct { + Seq int64 `json:"seq"` + TMS float64 `json:"tms"` // Go monotonic ms since process start + Pad string `json:"pad,omitempty"` +} + +// ctlPayload resets the JS-side counters at a scenario boundary. +type ctlPayload struct { + Ctl string `json:"ctl"` + Epoch int `json:"epoch"` +} + +// jsReport is what the page POSTs to /harness/report every 250ms. +// Latency values are raw deltas (jsPerformanceNow - goTMS); the clock offset +// between the two domains is removed later by subtracting DeltaMin. +type jsReport struct { + Epoch int `json:"epoch"` + Received int64 `json:"received"` + Drops int64 `json:"drops"` + Reorders int64 `json:"reorders"` + Frames int64 `json:"frames"` + LongFrames int64 `json:"longFrames"` + DeltaMin float64 `json:"deltaMin"` + P50 float64 `json:"p50"` + P95 float64 `json:"p95"` + P99 float64 `json:"p99"` + Max float64 `json:"max"` +} + +type harness struct { + start time.Time + + basePids map[int]bool // WebContent pids that existed before we launched + ourPids []int // WebContent pids attributable to us + hostPid int + + mu sync.Mutex + latestJS jsReport + emitUS []float64 // emit wall times for the current sample interval + epoch int + readyOnce sync.Once + + results []*scenarioResult +} + +func main() { + flag.Parse() + + if !samplerSupported { + fmt.Println("NOTE: memory sampling is unsupported on this platform;") + fmt.Println("timing/ordering metrics will still be collected.") + } + + h := &harness{ + start: time.Now(), + hostPid: os.Getpid(), + } + + // Snapshot WebContent pids BEFORE anything WebKit-related exists, so the + // differential later attributes only our own content process. + h.basePids = map[int]bool{} + for _, p := range webContentPids() { + h.basePids[p] = true + } + log.Printf("pre-launch WebContent processes on this machine: %d", len(h.basePids)) + + app := application.New(application.Options{ + Name: "eventperf", + Description: "Wails v3 Go→JS event dispatch measurement harness", + Assets: application.AssetOptions{ + Handler: h.handler(), + DisableLogging: true, + }, + Mac: application.MacOptions{ + ApplicationShouldTerminateAfterLastWindowClosed: true, + }, + }) + + win := app.Window.NewWithOptions(application.WebviewWindowOptions{ + Name: "eventperf", + Title: "eventperf", + Width: 520, + Height: 360, + URL: "/", + }) + + win.OnWindowEvent(events.Common.WindowRuntimeReady, func(*application.WindowEvent) { + h.readyOnce.Do(func() { + go h.runAll(app, win) + }) + }) + + if err := app.Run(); err != nil { + log.Fatalf("app.Run: %v", err) + } +} + +// handler serves the harness page and receives JS-side reports. +func (h *harness) handler() http.Handler { + mux := http.NewServeMux() + + mux.HandleFunc("/harness/report", func(w http.ResponseWriter, r *http.Request) { + var rep jsReport + if err := json.NewDecoder(r.Body).Decode(&rep); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + h.mu.Lock() + // Ignore reports from a previous scenario's epoch. + if rep.Epoch == h.epoch { + h.latestJS = rep + } + h.mu.Unlock() + w.WriteHeader(http.StatusNoContent) + }) + + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write(indexHTML) + }) + + return mux +} + +// nowMS is the Go-side monotonic clock in milliseconds, stamped into every event. +func (h *harness) nowMS() float64 { + return float64(time.Since(h.start).Nanoseconds()) / 1e6 +} + +// runAll discovers the content process, runs every scenario, writes results. +func (h *harness) runAll(app *application.App, win *application.WebviewWindow) { + defer func() { + if r := recover(); r != nil { + log.Printf("harness panic: %v", r) + } + app.Quit() + }() + + // Give WebKit a moment to finish bringing up the content process. + time.Sleep(2 * time.Second) + + if samplerSupported { + if err := h.discoverWebContent(); err != nil { + // Per the plan: fail loudly rather than emit a plausible-looking CSV + // built from some other application's WebContent processes. + log.Printf("FATAL: %v", err) + fmt.Println() + fmt.Println("Could not attribute a WebContent process to this app.") + fmt.Println("WebKit may have adopted a prewarmed process that predates our snapshot.") + fmt.Println("Aborting rather than reporting another app's memory.") + return + } + log.Printf("our WebContent pid(s): %v (host pid %d)", h.ourPids, h.hostPid) + } + + outDir := *flagOut + if outDir == "" { + outDir = "eventperf-results" + } + if err := os.MkdirAll(outDir, 0o755); err != nil { + log.Printf("mkdir %s: %v", outDir, err) + return + } + abs, _ := filepath.Abs(outDir) + + scenarios := selectedScenarios(*flagOnly) + log.Printf("running %d scenario(s), %s each, results → %s", len(scenarios), *flagDuration, abs) + + for i, sc := range scenarios { + log.Printf("[%d/%d] %s", i+1, len(scenarios), sc.Name) + res := h.runScenario(win, sc) + h.results = append(h.results, res) + + if err := writeScenarioCSV(outDir, res); err != nil { + log.Printf(" csv: %v", err) + } + log.Printf(" → %s", res.oneLine()) + + if res.Crashed { + log.Printf(" web process died — stopping run (later scenarios would measure a dead webview)") + break + } + } + + if err := writeSummary(outDir, h.results); err != nil { + log.Printf("summary.json: %v", err) + } + if err := writeReport(outDir, h.results); err != nil { + log.Printf("REPORT.md: %v", err) + } + fmt.Printf("\nResults written to %s\n", abs) + fmt.Println(renderConsoleTable(h.results)) +} + +// runScenario executes one scenario: reset JS state, settle, then measure. +func (h *harness) runScenario(win *application.WebviewWindow, sc Scenario) *scenarioResult { + dur := *flagDuration + if sc.Duration > 0 { + dur = sc.Duration + } + + // New epoch: clears JS counters so this scenario measures only itself. + h.mu.Lock() + h.epoch++ + epoch := h.epoch + h.latestJS = jsReport{Epoch: epoch} + h.emitUS = nil + h.mu.Unlock() + + win.DispatchWailsEvent(&application.CustomEvent{ + Name: "perfctl", + Data: ctlPayload{Ctl: "reset", Epoch: epoch}, + }) + + // Settle: run the load but discard measurements, so we measure steady state. + stopSettle := h.startEmitter(win, sc) + time.Sleep(*flagSettle) + stopSettle() + sc.resetCounter() // settle traffic must not count toward the measured totals + + // Reset counters again after settle so the measurement window is clean. + h.mu.Lock() + h.epoch++ + epoch = h.epoch + h.latestJS = jsReport{Epoch: epoch} + h.emitUS = nil + h.mu.Unlock() + win.DispatchWailsEvent(&application.CustomEvent{ + Name: "perfctl", + Data: ctlPayload{Ctl: "reset", Epoch: epoch}, + }) + time.Sleep(300 * time.Millisecond) // let the reset land before sampling + + res := &scenarioResult{Scenario: sc, Duration: dur} + res.StartFootprintWeb, _ = h.webFootprint() + res.StartFootprintHost, _ = footprint(h.hostPid) + + stop := h.startEmitter(win, sc) + deadline := time.Now().Add(dur) + ticker := time.NewTicker(*flagSample) + defer ticker.Stop() + t0 := time.Now() + + for time.Now().Before(deadline) { + <-ticker.C + + webFP, alive := h.webFootprint() + hostFP, _ := footprint(h.hostPid) + + h.mu.Lock() + js := h.latestJS + emits := h.emitUS + h.emitUS = nil + h.mu.Unlock() + + p50, p99 := percentile(emits, 50), percentile(emits, 99) + + var ms runtime.MemStats + runtime.ReadMemStats(&ms) + + res.Samples = append(res.Samples, sample{ + TSms: time.Since(t0).Milliseconds(), + HostFP: hostFP, + WebFP: webFP, + Sent: sc.sentSoFar(), + EvalCalls: sc.sentSoFar(), // 1 eval per event on master; recorded so batching factor is visible later + Received: js.Received, + EmitP50us: p50, + EmitP99us: p99, + + GoHeapAlloc: ms.HeapAlloc, + GoHeapSys: ms.HeapSys, + GoHeapIdle: ms.HeapIdle, + GoHeapReleased: ms.HeapReleased, + GoNumGC: ms.NumGC, + }) + + if !alive { + res.Crashed = true + break + } + } + stop() + + // Let the last in-flight events land before reading final JS state. + time.Sleep(500 * time.Millisecond) + + h.mu.Lock() + res.JS = h.latestJS + h.mu.Unlock() + + res.Sent = sc.sentSoFar() + res.EndFootprintWeb, _ = h.webFootprint() + res.EndFootprintHost, _ = footprint(h.hostPid) + res.finalise() + sc.resetCounter() + return res +} + +// startEmitter drives events at the scenario's rate until the returned stop is called. +// It paces in 2ms groups because a per-event ticker cannot hold 5000 ev/s. +func (h *harness) startEmitter(win *application.WebviewWindow, sc Scenario) func() { + if sc.Rate <= 0 { + return func() {} // idle scenario: no events at all + } + + done := make(chan struct{}) + var wg sync.WaitGroup + wg.Add(1) + + pad := strings.Repeat("x", sc.PayloadBytes) + altPad := pad + if sc.AltPayloadBytes > 0 { + altPad = strings.Repeat("x", sc.AltPayloadBytes) + } + + // Second emitter running on the main thread, concurrent with the goroutine + // emitter below. Both take sequence numbers from the same counter, so if + // the inline main-thread path lets a later event overtake an earlier queued + // one, the JS side records a reorder. + if sc.MixedSource { + wg.Add(1) + go func() { + defer wg.Done() + ticker := time.NewTicker(2 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-done: + return + case <-ticker.C: + seq := sc.nextSeq() + ev := &application.CustomEvent{ + Name: "perf", + Data: perfPayload{Seq: seq, TMS: h.nowMS(), Pad: pad}, + } + application.InvokeAsync(func() { win.DispatchWailsEvent(ev) }) + } + } + }() + } + + go func() { + defer wg.Done() + + groupInterval := 2 * time.Millisecond + perGroup := float64(sc.Rate) * groupInterval.Seconds() + if sc.Burst > 0 { + // Deliver the same mean rate but in bursts of sc.Burst. + groupInterval = time.Duration(float64(sc.Burst) / float64(sc.Rate) * float64(time.Second)) + perGroup = float64(sc.Burst) + } + + ticker := time.NewTicker(groupInterval) + defer ticker.Stop() + var credit float64 + + for { + select { + case <-done: + return + case <-ticker.C: + credit += perGroup + n := int(credit) + credit -= float64(n) + for i := 0; i < n; i++ { + seq := sc.nextSeq() + body := pad + if seq%2 == 1 { + body = altPad + } + ev := &application.CustomEvent{ + Name: "perf", + Data: perfPayload{Seq: seq, TMS: h.nowMS(), Pad: body}, + } + t := time.Now() + win.DispatchWailsEvent(ev) + us := float64(time.Since(t).Nanoseconds()) / 1e3 + + h.mu.Lock() + h.emitUS = append(h.emitUS, us) + h.mu.Unlock() + + select { + case <-done: + return + default: + } + } + } + } + }() + + return func() { close(done); wg.Wait() } +} + +// webFootprint sums our WebContent processes. alive=false means the process +// set changed or a read failed — i.e. the content process was replaced. +func (h *harness) webFootprint() (uint64, bool) { + if !samplerSupported || len(h.ourPids) == 0 { + return 0, true + } + var total uint64 + for _, pid := range h.ourPids { + fp, ok := footprint(pid) + if !ok { + return total, false + } + total += fp + } + return total, true +} + +// discoverWebContent diffs the current WebContent pid set against the +// pre-launch snapshot. An empty diff is a hard error, never a silent fallback. +func (h *harness) discoverWebContent() error { + var ours []int + for _, p := range webContentPids() { + if !h.basePids[p] { + ours = append(ours, p) + } + } + if len(ours) == 0 { + return fmt.Errorf("no new com.apple.WebKit.WebContent process appeared after window creation "+ + "(%d existed before launch); cannot attribute one to this app", len(h.basePids)) + } + h.ourPids = ours + return nil +} diff --git a/v3/tests/event-performance/report.go b/v3/tests/event-performance/report.go new file mode 100644 index 00000000000..63ec98f3ac4 --- /dev/null +++ b/v3/tests/event-performance/report.go @@ -0,0 +1,441 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "sort" + "strings" + "time" +) + +type sample struct { + TSms int64 + HostFP uint64 + WebFP uint64 + Sent int64 + EvalCalls int64 + Received int64 + EmitP50us float64 + EmitP99us float64 + + // Go runtime memory, to separate Go-heap growth from native/cgo growth + // when the host process footprint moves. + GoHeapAlloc uint64 + GoHeapSys uint64 + GoHeapIdle uint64 + GoHeapReleased uint64 + GoNumGC uint32 +} + +type scenarioResult struct { + Name string `json:"name"` + Note string `json:"note"` + Rate int `json:"rate_per_sec"` + Payload int `json:"payload_bytes"` + Burst int `json:"burst"` + Duration time.Duration `json:"-"` + + Scenario Scenario `json:"-"` + Samples []sample `json:"-"` + JS jsReport `json:"js"` + + Sent int64 `json:"events_sent"` + Crashed bool `json:"web_process_died"` + + StartFootprintWeb uint64 `json:"start_footprint_web"` + EndFootprintWeb uint64 `json:"end_footprint_web"` + StartFootprintHost uint64 `json:"start_footprint_host"` + EndFootprintHost uint64 `json:"end_footprint_host"` + + WebSlopeBps float64 `json:"web_slope_bytes_per_sec"` + HostSlopeBps float64 `json:"host_slope_bytes_per_sec"` + BytesPerEvent float64 `json:"web_bytes_per_event"` + + // Footprint under GC is a sawtooth, so a least-squares slope over a short + // window mostly measures which phase of the cycle the window happened to + // start and end in. The floor is the robust signal: a process that is + // leaking cannot return to its earlier minimum. + NetDeltaBytes int64 `json:"net_delta_bytes"` + SwingBytes int64 `json:"swing_bytes"` + FloorRiseBytes int64 `json:"floor_rise_bytes"` + FloorRiseBps float64 `json:"floor_rise_bytes_per_sec"` + FloorRisePerEvt float64 `json:"floor_rise_bytes_per_event"` + + // The host process matters as much as WebContent: pending eval payloads + // queue natively on the host side with no backpressure, so a byte-heavy + // workload grows the HOST, not the web process. Grading only WebContent + // misses it completely. + HostPeakBytes int64 `json:"host_peak_bytes"` + HostRiseBytes int64 `json:"host_rise_bytes"` + ByteRatePerSec float64 `json:"emitted_byte_rate_per_sec"` + GoHeapSysPeakBytes int64 `json:"go_heap_sys_peak_bytes"` + AchievedRate float64 `json:"achieved_rate_per_sec"` + EmitP50us float64 `json:"emit_p50_us"` + EmitP99us float64 `json:"emit_p99_us"` + LatP50ms float64 `json:"delivery_p50_ms"` + LatP99ms float64 `json:"delivery_p99_ms"` + Valid bool `json:"valid"` + Verdict string `json:"verdict"` +} + +func (r *scenarioResult) finalise() { + sc := r.Scenario + r.Name, r.Note, r.Rate, r.Payload, r.Burst = sc.Name, sc.Note, sc.Rate, sc.PayloadBytes, sc.Burst + + secs := r.Duration.Seconds() + if secs > 0 { + r.AchievedRate = float64(r.Sent) / secs + } + + xs := make([]float64, 0, len(r.Samples)) + web := make([]float64, 0, len(r.Samples)) + host := make([]float64, 0, len(r.Samples)) + var emit50, emit99 []float64 + for _, s := range r.Samples { + xs = append(xs, float64(s.TSms)/1000) + web = append(web, float64(s.WebFP)) + host = append(host, float64(s.HostFP)) + if s.EmitP50us > 0 { + emit50 = append(emit50, s.EmitP50us) + } + if s.EmitP99us > 0 { + emit99 = append(emit99, s.EmitP99us) + } + } + r.WebSlopeBps = slope(xs, web) + r.HostSlopeBps = slope(xs, host) + r.EmitP50us = percentile(emit50, 50) + r.EmitP99us = percentile(emit99, 99) + + if r.Sent > 0 { + r.BytesPerEvent = r.WebSlopeBps * secs / float64(r.Sent) + } + + // Floor analysis: compare the minimum footprint of the first half of the + // window against the minimum of the second half. Sawtooth returns to the + // same floor; a leak raises it. + if n := len(web); n >= 4 { + half := n / 2 + firstMin, secondMin := web[0], web[half] + for _, v := range web[:half] { + if v < firstMin { + firstMin = v + } + } + for _, v := range web[half:] { + if v < secondMin { + secondMin = v + } + } + lo, hi := web[0], web[0] + for _, v := range web { + if v < lo { + lo = v + } + if v > hi { + hi = v + } + } + r.SwingBytes = int64(hi - lo) + r.NetDeltaBytes = int64(web[n-1] - web[0]) + r.FloorRiseBytes = int64(secondMin - firstMin) + if secs > 0 { + r.FloorRiseBps = float64(r.FloorRiseBytes) / (secs / 2) + } + if r.Sent > 0 { + r.FloorRisePerEvt = float64(r.FloorRiseBytes) / float64(r.Sent) + } + } + + // Host-side native growth, and the Go heap alongside it so the two can be + // told apart: flat Go heap + rising host footprint means native buffering. + if len(host) > 0 { + hlo, hhi := host[0], host[0] + for _, v := range host { + if v < hlo { + hlo = v + } + if v > hhi { + hhi = v + } + } + r.HostPeakBytes = int64(hhi) + r.HostRiseBytes = int64(hhi - hlo) + } + for _, s := range r.Samples { + if int64(s.GoHeapSys) > r.GoHeapSysPeakBytes { + r.GoHeapSysPeakBytes = int64(s.GoHeapSys) + } + } + if secs > 0 { + r.ByteRatePerSec = float64(r.Sent) * float64(r.Payload) / secs + } + + // Clock domains differ but both are monotonic, so a constant offset cancels: + // subtract the smallest observed transit from the percentiles. + if r.JS.Received > 0 { + r.LatP50ms = r.JS.P50 - r.JS.DeltaMin + r.LatP99ms = r.JS.P99 - r.JS.DeltaMin + } + + // A scenario that sent events but received none measured nothing. Reporting + // that as "flat" would be a false negative. + r.Valid = r.Rate == 0 || r.JS.Received > 0 +} + +func (r *scenarioResult) oneLine() string { + switch { + case r.Crashed: + return fmt.Sprintf("CRASH after %d events (web process died)", r.Sent) + case !r.Valid: + return fmt.Sprintf("INVALID — sent %d, received 0 (runtime not mounted?)", r.Sent) + } + return fmt.Sprintf("sent=%d recv=%d achieved=%.0f/s floor=%s swing=%s emit_p50=%.0fµs emit_p99=%.0fµs drops=%d", + r.Sent, r.JS.Received, r.AchievedRate, signedBytes(r.FloorRiseBytes), + humanBytes(r.SwingBytes), r.EmitP50us, r.EmitP99us, r.JS.Drops) +} + +// applyVerdict grades a scenario against the idle baseline slope. +func (r *scenarioResult) applyVerdict(baselineBps float64) { + switch { + case r.Crashed: + r.Verdict = "CRASH — web process terminated (reproduces #215729's terminal symptom)" + return + case !r.Valid: + r.Verdict = "INVALID — zero events received; do not read as flat" + return + } + // Graded on floor rise, not slope. Requires BOTH a rate above the idle + // baseline AND an absolute magnitude, so a short window full of sawtooth + // cannot produce a leak verdict on its own. + const ( + leakBps = 50 * 1024 // 50 KB/s sustained floor rise + leakAbsBytes = 4 << 20 // and at least 4 MB of it + elevBps = 25 * 1024 + elevAbsBytes = 1 << 20 + ) + rise, riseBps := r.FloorRiseBytes, r.FloorRiseBps + ctx := fmt.Sprintf("floor %s over %ds (swing %s, net %s)", + signedBytes(rise), int(r.Duration.Seconds()), humanBytes(r.SwingBytes), signedBytes(r.NetDeltaBytes)) + + switch { + case riseBps > baselineBps+leakBps && rise > leakAbsBytes: + r.Verdict = "WEBKIT LEAK — " + ctx + case riseBps > baselineBps+elevBps && rise > elevAbsBytes: + r.Verdict = "WEBKIT ELEVATED — " + ctx + default: + r.Verdict = "webkit flat — " + ctx + } + + // Host-side (UI process) retention inside evaluateJavaScript, proportional + // to JS source bytes. Attributed by experiment: with the eval call removed + // but the CString malloc/free and NSString construction kept, the same 1 MB + // workload holds the host flat at ~50 MB. Go heap stays flat throughout, so + // this is native memory, not Go allocation. + if r.HostRiseBytes > 64<<20 && r.GoHeapSysPeakBytes < r.HostRiseBytes/4 { + r.Verdict += fmt.Sprintf(" || HOST RETENTION — host +%s (peak %s, Go heap only %s) at %s/s of JS source", + humanBytes(r.HostRiseBytes), humanBytes(r.HostPeakBytes), + humanBytes(r.GoHeapSysPeakBytes), humanBytes(int64(r.ByteRatePerSec))) + } +} + +func writeScenarioCSV(dir string, r *scenarioResult) error { + var b strings.Builder + b.WriteString("timestamp_ms,footprint_web_bytes,footprint_host_bytes,events_sent,events_received,eval_calls,emit_p50_us,emit_p99_us," + + "go_heap_alloc,go_heap_sys,go_heap_idle,go_heap_released,go_num_gc\n") + for _, s := range r.Samples { + fmt.Fprintf(&b, "%d,%d,%d,%d,%d,%d,%.1f,%.1f,%d,%d,%d,%d,%d\n", + s.TSms, s.WebFP, s.HostFP, s.Sent, s.Received, s.EvalCalls, s.EmitP50us, s.EmitP99us, + s.GoHeapAlloc, s.GoHeapSys, s.GoHeapIdle, s.GoHeapReleased, s.GoNumGC) + } + return os.WriteFile(filepath.Join(dir, r.Scenario.Name+".csv"), []byte(b.String()), 0o644) +} + +type summary struct { + GeneratedAt string `json:"generated_at"` + Environment map[string]string `json:"environment"` + Scenarios []*scenarioResult `json:"scenarios"` +} + +func writeSummary(dir string, results []*scenarioResult) error { + grade(results) + s := summary{ + GeneratedAt: time.Now().Format(time.RFC3339), + Environment: environment(), + Scenarios: results, + } + b, err := json.MarshalIndent(s, "", " ") + if err != nil { + return err + } + return os.WriteFile(filepath.Join(dir, "summary.json"), b, 0o644) +} + +func grade(results []*scenarioResult) { + var baseline float64 + for _, r := range results { + if r.Scenario.Name == "idle" { + baseline = r.FloorRiseBps + } + } + for _, r := range results { + r.applyVerdict(baseline) + } +} + +func writeReport(dir string, results []*scenarioResult) error { + grade(results) + env := environment() + + var b strings.Builder + b.WriteString("# Wails v3 Go→JS event dispatch — Phase -1 results\n\n") + b.WriteString("Measured at the `DispatchWailsEvent` → `evaluateJavaScript` boundary.\n") + b.WriteString("`footprint` is `ri_phys_footprint` of the WebKit content process — what\n") + b.WriteString("Activity Monitor shows and what jetsam acts on.\n\n") + + b.WriteString("## Environment\n\n") + keys := make([]string, 0, len(env)) + for k := range env { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + fmt.Fprintf(&b, "- **%s:** %s\n", k, env[k]) + } + + b.WriteString("\n## Results\n\n") + b.WriteString("| scenario | rate | payload | byte rate | sent | recv | web floor | web swing | **host peak** | **host rise** | go heap | emit p50 | emit p99 | drops |\n") + b.WriteString("|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|\n") + for _, r := range results { + fmt.Fprintf(&b, "| `%s` | %d/s | %s | %s/s | %d | %d | %s | %s | **%s** | **%s** | %s | %.0f µs | %.0f µs | %d |\n", + r.Scenario.Name, r.Rate, humanBytes(int64(r.Payload)), humanBytes(int64(r.ByteRatePerSec)), + r.Sent, r.JS.Received, signedBytes(r.FloorRiseBytes), humanBytes(r.SwingBytes), + humanBytes(r.HostPeakBytes), signedBytes(r.HostRiseBytes), humanBytes(r.GoHeapSysPeakBytes), + r.EmitP50us, r.EmitP99us, r.JS.Drops) + } + + b.WriteString("\n## Verdicts\n\n") + for _, r := range results { + fmt.Fprintf(&b, "- **`%s`** — %s\n", r.Scenario.Name, r.Verdict) + } + + b.WriteString("\n## Reading this\n\n") + b.WriteString("- **`floor rise`** is the leak signal: the minimum footprint of the second half\n") + b.WriteString(" of the window minus the minimum of the first half. Memory under GC is a\n") + b.WriteString(" sawtooth, so `net Δ` and any regression slope mostly report which phase of\n") + b.WriteString(" the cycle the window happened to start and end in. A process that is\n") + b.WriteString(" genuinely leaking cannot return to its earlier floor.\n") + b.WriteString("- `swing` is the peak-to-trough range: read `floor rise` against it. A floor\n") + b.WriteString(" rise much smaller than the swing is noise.\n") + b.WriteString("- Floor rise concentrated in the **rate sweep** implicates a per-call leak;\n") + b.WriteString(" in the **size sweep**, payload retention (WebKit #215729).\n") + b.WriteString("- An `INVALID` row received zero events and must not be read as \"flat\".\n") + b.WriteString("\nAll scenarios shared one window; footprint is therefore cumulative across\n") + b.WriteString("rows and only the per-scenario *slope* is comparable, not absolute values.\n") + + return os.WriteFile(filepath.Join(dir, "REPORT.md"), []byte(b.String()), 0o644) +} + +func renderConsoleTable(results []*scenarioResult) string { + grade(results) + var b strings.Builder + b.WriteString(fmt.Sprintf("%-14s %9s %9s %10s %11s %10s %9s\n", + "SCENARIO", "SENT", "RECV", "ACHIEVED", "FLOOR RISE", "SWING", "EMIT p99")) + for _, r := range results { + b.WriteString(fmt.Sprintf("%-14s %9d %9d %9.0f/s %11s %10s %8.0fµs %s\n", + r.Scenario.Name, r.Sent, r.JS.Received, r.AchievedRate, + signedBytes(r.FloorRiseBytes), humanBytes(r.SwingBytes), r.EmitP99us, r.Verdict)) + } + return b.String() +} + +func environment() map[string]string { + env := map[string]string{ + "go": runtime.Version(), + "platform": runtime.GOOS + "/" + runtime.GOARCH, + } + if out, err := exec.Command("sw_vers", "-productVersion").Output(); err == nil { + env["macos"] = strings.TrimSpace(string(out)) + } + if out, err := exec.Command("sysctl", "-n", "machdep.cpu.brand_string").Output(); err == nil { + env["cpu"] = strings.TrimSpace(string(out)) + } + if out, err := exec.Command("git", "rev-parse", "--short", "HEAD").Output(); err == nil { + env["wails_commit"] = strings.TrimSpace(string(out)) + } + if out, err := exec.Command("defaults", "read", + "/System/Library/Frameworks/WebKit.framework/Resources/Info.plist", "CFBundleVersion").Output(); err == nil { + env["webkit"] = strings.TrimSpace(string(out)) + } + return env +} + +// slope is a least-squares fit of y over x, in y-units per x-unit. +func slope(xs, ys []float64) float64 { + n := float64(len(xs)) + if n < 2 { + return 0 + } + var sx, sy, sxy, sxx float64 + for i := range xs { + sx += xs[i] + sy += ys[i] + sxy += xs[i] * ys[i] + sxx += xs[i] * xs[i] + } + den := n*sxx - sx*sx + if den == 0 { + return 0 + } + return (n*sxy - sx*sy) / den +} + +func percentile(v []float64, p float64) float64 { + if len(v) == 0 { + return 0 + } + s := append([]float64(nil), v...) + sort.Float64s(s) + idx := int(p / 100 * float64(len(s)-1)) + if idx < 0 { + idx = 0 + } + if idx >= len(s) { + idx = len(s) - 1 + } + return s[idx] +} + +// signedBytes always shows a sign, so a falling floor is unmistakable. +func signedBytes(b int64) string { + if b >= 0 { + return "+" + humanBytes(b) + } + return humanBytes(b) +} + +func humanBytes(b int64) string { + neg := b < 0 + if neg { + b = -b + } + var s string + switch { + case b >= 1<<30: + s = fmt.Sprintf("%.2f GB", float64(b)/(1<<30)) + case b >= 1<<20: + s = fmt.Sprintf("%.2f MB", float64(b)/(1<<20)) + case b >= 1<<10: + s = fmt.Sprintf("%.1f KB", float64(b)/(1<<10)) + default: + s = fmt.Sprintf("%d B", b) + } + if neg { + return "-" + s + } + return s +} diff --git a/v3/tests/event-performance/sampler_darwin.go b/v3/tests/event-performance/sampler_darwin.go new file mode 100644 index 00000000000..d70075dc690 --- /dev/null +++ b/v3/tests/event-performance/sampler_darwin.go @@ -0,0 +1,86 @@ +//go:build darwin + +package main + +/* +#include +#include +#include +#include +#include + +static int hp_listpids(int *buf, int count) { + return proc_listallpids((void *)buf, count * (int)sizeof(int)); +} + +static int hp_name(int pid, char *out, int outlen) { + struct proc_bsdinfo bsd; + int r = proc_pidinfo(pid, PROC_PIDTBSDINFO, 0, &bsd, PROC_PIDTBSDINFO_SIZE); + if (r != (int)PROC_PIDTBSDINFO_SIZE) return -1; + strncpy(out, bsd.pbi_name, outlen - 1); + out[outlen - 1] = '\0'; + return 0; +} + +// ri_phys_footprint is what Activity Monitor shows and what jetsam acts on. +// The harness's own RSS is not the signal; this is. +static unsigned long long hp_footprint(int pid, int *ok) { + rusage_info_current ri; + if (proc_pid_rusage(pid, RUSAGE_INFO_CURRENT, (rusage_info_t *)&ri) != 0) { + *ok = 0; + return 0ULL; + } + *ok = 1; + return (unsigned long long)ri.ri_phys_footprint; +} +*/ +import "C" + +import "unsafe" + +const samplerSupported = true + +const webContentProcName = "com.apple.WebKit.WebContent" + +// webContentPids enumerates every WebContent process on the machine. Callers +// must diff against a pre-launch snapshot — summing all of them would fold in +// other applications' Safari tabs. +func webContentPids() []int { + const maxPids = 16384 + buf := make([]C.int, maxPids) + nbytes := C.hp_listpids(&buf[0], C.int(maxPids)) + if nbytes <= 0 { + return nil + } + n := int(nbytes) / int(unsafe.Sizeof(C.int(0))) + if n > maxPids { + n = maxPids + } + + name := make([]C.char, 64) + var out []int + for i := 0; i < n; i++ { + pid := int(buf[i]) + if pid <= 0 { + continue + } + if C.hp_name(C.int(pid), &name[0], C.int(len(name))) != 0 { + continue + } + if C.GoString(&name[0]) == webContentProcName { + out = append(out, pid) + } + } + return out +} + +// footprint returns ri_phys_footprint for pid. ok=false means the process is +// gone or unreadable — for a tracked WebContent pid that means it was replaced. +func footprint(pid int) (uint64, bool) { + var ok C.int + v := C.hp_footprint(C.int(pid), &ok) + if ok == 0 { + return 0, false + } + return uint64(v), true +} diff --git a/v3/tests/event-performance/sampler_linux.go b/v3/tests/event-performance/sampler_linux.go new file mode 100644 index 00000000000..740ebc0075d --- /dev/null +++ b/v3/tests/event-performance/sampler_linux.go @@ -0,0 +1,91 @@ +//go:build linux + +package main + +import ( + "os" + "path/filepath" + "strconv" + "strings" +) + +const samplerSupported = true + +// WebKitGTK runs page content in a separate WebKitWebProcess, the analogue of +// macOS's com.apple.WebKit.WebContent. +const webContentProcName = "WebKitWebProcess" + +// webContentPids enumerates every WebKitGTK web process on the machine. +// Callers diff against a pre-launch snapshot; summing all of them would fold +// in any other GTK/WebKit app running here. +func webContentPids() []int { + entries, err := os.ReadDir("/proc") + if err != nil { + return nil + } + + var out []int + for _, e := range entries { + pid, err := strconv.Atoi(e.Name()) + if err != nil { + continue // not a pid directory + } + // /proc//comm is capped at 15 chars (TASK_COMM_LEN-1), and + // "WebKitWebProcess" is 16, so it always arrives truncated. Match on + // argv[0] instead, which is not. + raw, err := os.ReadFile(filepath.Join("/proc", e.Name(), "cmdline")) + if err != nil || len(raw) == 0 { + continue + } + argv0 := string(raw) + if i := strings.IndexByte(argv0, 0); i >= 0 { + argv0 = argv0[:i] + } + if filepath.Base(argv0) == webContentProcName { + out = append(out, pid) + } + } + return out +} + +// footprint reports Pss for pid — the closest Linux analogue to macOS +// phys_footprint, since it apportions shared pages rather than double-counting +// them. Falls back to VmRSS where smaps_rollup is unavailable. +// +// Caveat worth knowing when reading Linux results: memory that a process +// allocates, hands to another process via an fd, and then unmaps does not +// appear in Pss or RSS at all. macOS charges that to the owner and shows it as +// "owned unmapped"; Linux does not. So a memfd-based equivalent of the macOS +// retention would be invisible here — check open fd counts and Shmem in +// /proc/meminfo alongside these numbers. +func footprint(pid int) (uint64, bool) { + if kb, ok := fieldKB(filepath.Join("/proc", strconv.Itoa(pid), "smaps_rollup"), "Pss:"); ok { + return kb * 1024, true + } + if kb, ok := fieldKB(filepath.Join("/proc", strconv.Itoa(pid), "status"), "VmRSS:"); ok { + return kb * 1024, true + } + return 0, false +} + +func fieldKB(path, prefix string) (uint64, bool) { + b, err := os.ReadFile(path) + if err != nil { + return 0, false + } + for _, line := range strings.Split(string(b), "\n") { + if !strings.HasPrefix(line, prefix) { + continue + } + f := strings.Fields(line) + if len(f) < 2 { + return 0, false + } + kb, err := strconv.ParseUint(f[1], 10, 64) + if err != nil { + return 0, false + } + return kb, true + } + return 0, false +} diff --git a/v3/tests/event-performance/sampler_other.go b/v3/tests/event-performance/sampler_other.go new file mode 100644 index 00000000000..5ff4b50f0b6 --- /dev/null +++ b/v3/tests/event-performance/sampler_other.go @@ -0,0 +1,12 @@ +//go:build !darwin && !linux + +package main + +// Memory metrics are unsupported off darwin, but the timing and ordering half +// of the harness still runs so engines can be compared later. + +const samplerSupported = false + +func webContentPids() []int { return nil } + +func footprint(pid int) (uint64, bool) { return 0, false } diff --git a/v3/tests/event-performance/scenarios.go b/v3/tests/event-performance/scenarios.go new file mode 100644 index 00000000000..ecc530dd4b6 --- /dev/null +++ b/v3/tests/event-performance/scenarios.go @@ -0,0 +1,182 @@ +package main + +import ( + "strings" + "sync/atomic" + "time" +) + +// Scenario sweeps the two axes independently — that separation is the whole +// point of the harness. Growth on the rate sweep implicates a per-call leak; +// growth on the size sweep implicates payload retention. +type Scenario struct { + Name string + Rate int // events/sec; 0 = idle + PayloadBytes int // bytes of filler per event + Burst int // if >0, deliver the mean rate in bursts of this size + Duration time.Duration + Note string + + // MixedSource runs a second emitter on the main thread concurrently with + // the goroutine emitter. dispatchOnMainThread has an inline fast path, so a + // main-thread emit executes its eval immediately while a goroutine emit is + // still sitting in the dispatch queue — the two can invert. Both emitters + // draw from the same monotonic counter, so any inversion shows as a reorder. + MixedSource bool + + // AltPayloadBytes, when >0, alternates every other event to this size. + // This is what exercises mixed inline/by-reference delivery: small events + // dispatch synchronously, large ones go through an async fetch, and the + // sequence numbers prove nothing overtakes anything. + AltPayloadBytes int + + counter *atomic.Int64 // shared across copies of the struct +} + +func (s Scenario) nextSeq() int64 { return s.counter.Add(1) - 1 } +func (s Scenario) sentSoFar() int64 { return s.counter.Load() } +func (s Scenario) resetCounter() { s.counter.Store(0) } + +func newScenario(s Scenario) Scenario { + s.counter = &atomic.Int64{} + return s +} + +// allScenarios is ordered deliberately: idle first (it is the baseline every +// other result is read against), pathological LAST because it is expected to +// kill the web process and everything after it would measure a corpse. +func allScenarios() []Scenario { + var out []Scenario + add := func(s Scenario) { out = append(out, newScenario(s)) } + + add(Scenario{ + Name: "idle", Rate: 0, + Note: "baseline: footprint drift from merely having a webview open", + }) + + // Rate sweep — fixed 64B payload. Growth here implicates a per-call leak. + for _, r := range []int{10, 100, 500, 1000, 2500, 5000} { + add(Scenario{ + Name: "rate-" + itoa(r), Rate: r, PayloadBytes: 64, + Note: "rate sweep @64B", + }) + } + + // Size sweep — fixed 100 ev/s. Growth here implicates payload retention. + for _, sz := range []struct { + label string + bytes int + }{{"1KB", 1 << 10}, {"16KB", 16 << 10}, {"256KB", 256 << 10}, {"1MB", 1 << 20}} { + add(Scenario{ + Name: "size-" + sz.label, Rate: 100, PayloadBytes: sz.bytes, + Note: "size sweep @100 ev/s", + }) + } + + // Iso-byte-rate sweep: hold ~4 MB/s constant and vary only the payload size. + // Retention appears to be a step function of payload size rather than a + // smooth function of bytes, so this is what locates the knee — at a fixed + // byte rate, any difference between these rows is attributable to size alone. + for _, iso := range []struct { + label string + bytes int + rate int + }{ + {"1KB", 1 << 10, 4096}, + {"2KB", 2 << 10, 2048}, + {"4KB", 4 << 10, 1024}, + {"8KB", 8 << 10, 512}, + {"16KB", 16 << 10, 256}, + {"32KB", 32 << 10, 128}, + {"64KB", 64 << 10, 64}, + } { + add(Scenario{ + Name: "iso-" + iso.label, Rate: iso.rate, PayloadBytes: iso.bytes, + Note: "iso-byte-rate ~4 MB/s", + }) + } + + // Second iso family at ~32 MB/s, covering 32 KB → 1 MB. The switchover size + // is platform-specific: macOS/WebKit-Cocoa flips between 8 KB and 16 KB, + // while WebKitGTK is still flat at 64 KB, so locating its knee needs both + // larger payloads and enough throughput to make retention obvious. + for _, iso := range []struct { + label string + bytes int + rate int + }{ + {"32KB", 32 << 10, 1024}, + {"64KB", 64 << 10, 512}, + {"128KB", 128 << 10, 256}, + {"256KB", 256 << 10, 128}, + {"512KB", 512 << 10, 64}, + {"1MB", 1 << 20, 32}, + } { + add(Scenario{ + Name: "iso2-" + iso.label, Rate: iso.rate, PayloadBytes: iso.bytes, + Note: "iso-byte-rate ~32 MB/s", + }) + } + + // Ordering under mixed delivery modes. Alternating a well-under-threshold + // payload with a well-over-threshold one means consecutive sequence numbers + // travel by different mechanisms; any reorder shows up immediately. + add(Scenario{ + Name: "interleave", Rate: 200, PayloadBytes: 1 << 10, AltPayloadBytes: 64 << 10, + Note: "alternating 1KB/64KB — proves ordering across inline vs by-reference", + }) + + // Two concurrent emitters, one per thread-of-origin. This is the ordering + // case the single-emitter scenarios cannot see. + add(Scenario{ + Name: "mixedsource", Rate: 500, PayloadBytes: 256, MixedSource: true, + Note: "goroutine + main-thread emitters sharing one sequence", + }) + + // The shape real apps actually produce. + add(Scenario{ + Name: "burst", Rate: 1000, PayloadBytes: 256, Burst: 20, + Note: "mean 1000 ev/s in bursts of 20 @256B", + }) + + // Direct reproduction of WebKit #215729's shape. Expected to terminate the + // web process; that is a recorded result, not an error. + add(Scenario{ + Name: "pathological", Rate: 10, PayloadBytes: 8 << 20, + Note: "10 ev/s @8MB — expect web process termination", + }) + + return out +} + +func selectedScenarios(only string) []Scenario { + all := allScenarios() + if strings.TrimSpace(only) == "" { + return all + } + want := map[string]bool{} + for _, n := range strings.Split(only, ",") { + want[strings.TrimSpace(n)] = true + } + var out []Scenario + for _, s := range all { + if want[s.Name] { + out = append(out, s) + } + } + return out +} + +func itoa(i int) string { + if i == 0 { + return "0" + } + var b [20]byte + p := len(b) + for i > 0 { + p-- + b[p] = byte('0' + i%10) + i /= 10 + } + return string(b[p:]) +} From 04d41e7509da3864f417040d54f679e4eb062db3 Mon Sep 17 00:00:00 2001 From: Lea Anthony Date: Sun, 9 Aug 2026 22:04:06 +1000 Subject: [PATCH 3/8] fix(v3): address review feedback on event payload store Three findings from automated review: - CodeQL flagged the ParseUint -> uint conversion for the window id header as truncating on 32-bit builds. Parse at strconv.IntSize so the value cannot exceed uint width. - The store owns a reaper goroutine, but nothing stopped it: window close only calls dropWindow, and close was never wired up. Register it via OnShutdown alongside store initialisation. - The reaper swept once per TTL, so an entry created just after a sweep survived until the following one - holding it for nearly 60s against a documented 30s bound. Sweep at TTL/4 and treat an entry as expired when its deadline is reached rather than strictly passed. --- v3/pkg/application/application.go | 6 +++++- v3/pkg/application/event_payload_store.go | 10 ++++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/v3/pkg/application/application.go b/v3/pkg/application/application.go index c218cc71f90..5fde7b4268b 100644 --- a/v3/pkg/application/application.go +++ b/v3/pkg/application/application.go @@ -73,6 +73,9 @@ func New(appOptions Options) *App { result.customEventProcessor = NewWailsEventProcessor(result.Event.dispatch) result.eventPayloads = newEventPayloadStore() + // 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 @@ -304,9 +307,10 @@ func (a *App) serveEventPayload(rw http.ResponseWriter, req *http.Request) { } // 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, 64); err == nil { + if parsed, err := strconv.ParseUint(raw, 10, strconv.IntSize); err == nil { windowID = uint(parsed) } } diff --git a/v3/pkg/application/event_payload_store.go b/v3/pkg/application/event_payload_store.go index 3432cfaa306..860088b12fd 100644 --- a/v3/pkg/application/event_payload_store.go +++ b/v3/pkg/application/event_payload_store.go @@ -38,6 +38,11 @@ const ( // 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 @@ -124,7 +129,7 @@ func (s *eventPayloadStore) dropWindow(windowID uint) { } func (s *eventPayloadStore) reap() { - ticker := time.NewTicker(eventPayloadTTL) + ticker := time.NewTicker(eventPayloadSweep) defer ticker.Stop() for { select { @@ -133,7 +138,8 @@ func (s *eventPayloadStore) reap() { case now := <-ticker.C: s.mu.Lock() for id, item := range s.items { - if now.Sub(item.created) > eventPayloadTTL { + // 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) } From 475be8ca60c1dc599933de16abb448d9703f024e Mon Sep 17 00:00:00 2001 From: Lea Anthony Date: Sun, 9 Aug 2026 22:18:07 +1000 Subject: [PATCH 4/8] test(v3): add Windows sampler to the event performance harness WebView2 has no single content process: an app gets a browser process plus renderer, GPU and utility children, all named msedgewebview2.exe, and a dev box typically has a dozen belonging to other apps already running. The existing pre-launch pid diff handles that - every pid appearing after our window is created is ours - so the sampler only has to enumerate and measure. Memory is reported as PrivateUsage from GetProcessMemoryInfo, the closest Windows analogue to phys_footprint: private committed bytes, excluding the mapped images every WebView2 child shares, which would otherwise be counted once per process. --- v3/tests/event-performance/sampler_other.go | 2 +- v3/tests/event-performance/sampler_windows.go | 101 ++++++++++++++++++ 2 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 v3/tests/event-performance/sampler_windows.go diff --git a/v3/tests/event-performance/sampler_other.go b/v3/tests/event-performance/sampler_other.go index 5ff4b50f0b6..c8356678813 100644 --- a/v3/tests/event-performance/sampler_other.go +++ b/v3/tests/event-performance/sampler_other.go @@ -1,4 +1,4 @@ -//go:build !darwin && !linux +//go:build !darwin && !linux && !windows package main diff --git a/v3/tests/event-performance/sampler_windows.go b/v3/tests/event-performance/sampler_windows.go new file mode 100644 index 00000000000..e7ad22ffe22 --- /dev/null +++ b/v3/tests/event-performance/sampler_windows.go @@ -0,0 +1,101 @@ +//go:build windows + +package main + +import ( + "path/filepath" + "strings" + "unsafe" + + "golang.org/x/sys/windows" +) + +const samplerSupported = true + +// WebView2 runs page content in msedgewebview2.exe. Unlike WKWebView and +// WebKitGTK there is not a single content process: an app gets a browser +// process plus renderer, GPU and utility children, all with this name. The +// pre-launch diff in main.go handles that — every pid that appears after our +// window is created belongs to us, and they are summed. +const webContentProcName = "msedgewebview2.exe" + +// PROCESS_MEMORY_COUNTERS_EX. PrivateUsage (the commit charge) is the closest +// Windows analogue to macOS phys_footprint: it counts private committed bytes +// and excludes shared pages, so it does not double count the mapped images +// every WebView2 child shares. +type processMemoryCountersEx struct { + CB uint32 + PageFaultCount uint32 + PeakWorkingSetSize uintptr + WorkingSetSize uintptr + QuotaPeakPagedPoolUsage uintptr + QuotaPagedPoolUsage uintptr + QuotaPeakNonPagedPoolUsage uintptr + QuotaNonPagedPoolUsage uintptr + PagefileUsage uintptr + PeakPagefileUsage uintptr + PrivateUsage uintptr +} + +var ( + modPsapi = windows.NewLazySystemDLL("psapi.dll") + procGetProcessMemoryInfo = modPsapi.NewProc("GetProcessMemoryInfo") +) + +// webContentPids enumerates every WebView2 process on the machine. Callers +// diff against a pre-launch snapshot; a dev box typically has a dozen of these +// already running for other apps. +func webContentPids() []int { + snap, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0) + if err != nil { + return nil + } + defer windows.CloseHandle(snap) + + var entry windows.ProcessEntry32 + entry.Size = uint32(unsafe.Sizeof(entry)) + if err := windows.Process32First(snap, &entry); err != nil { + return nil + } + + var out []int + for { + name := windows.UTF16ToString(entry.ExeFile[:]) + if strings.EqualFold(filepath.Base(name), webContentProcName) { + out = append(out, int(entry.ProcessID)) + } + if err := windows.Process32Next(snap, &entry); err != nil { + break // ERROR_NO_MORE_FILES + } + } + return out +} + +// footprint reports PrivateUsage for pid. ok=false means the process is gone or +// unreadable — for a tracked content process that means it was replaced. +func footprint(pid int) (uint64, bool) { + h, err := windows.OpenProcess( + windows.PROCESS_QUERY_LIMITED_INFORMATION|windows.PROCESS_VM_READ, + false, uint32(pid)) + if err != nil { + // Fall back to the narrower right; VM_READ is refused for some + // protected processes even at the same integrity level. + h, err = windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid)) + if err != nil { + return 0, false + } + } + defer windows.CloseHandle(h) + + var counters processMemoryCountersEx + counters.CB = uint32(unsafe.Sizeof(counters)) + r, _, _ := procGetProcessMemoryInfo.Call( + uintptr(h), + uintptr(unsafe.Pointer(&counters)), + uintptr(counters.CB), + ) + if r == 0 { + return 0, false + } + return uint64(counters.PrivateUsage), true +} From b0862661afb5b92e36f9b2bcb5473c3b70f6ca4f Mon Sep 17 00:00:00 2001 From: Lea Anthony Date: Sun, 9 Aug 2026 23:09:39 +1000 Subject: [PATCH 5/8] docs(v3): record the WebView2 measurement in the threshold comment Windows 11 / WebView2 151 does not have the retention: the host process holds flat at 65-77 MB where macOS reached 11.6 GB, and the content process sawtooths under V8 GC identically in patched and unpatched builds. So 8192 is correct on all three engines and conservative off macOS rather than unverified. --- v3/pkg/application/event_payload_store.go | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/v3/pkg/application/event_payload_store.go b/v3/pkg/application/event_payload_store.go index 860088b12fd..ff0b7df8719 100644 --- a/v3/pkg/application/event_payload_store.go +++ b/v3/pkg/application/event_payload_store.go @@ -20,6 +20,12 @@ import ( // - 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 @@ -27,10 +33,11 @@ import ( 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, whose knee (8-16 KB) is the lower of the two measured - // platforms; WebKitGTK's is 64-128 KB, so this is correct there too, just - // conservative. WebView2 is unmeasured — run the iso-* scenarios in - // v3/tests/event-performance before assuming this value transfers. + // 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 From 283141790167d6c5eb96df55de74788ee33c4b58 Mon Sep 17 00:00:00 2001 From: Lea Anthony Date: Sun, 9 Aug 2026 23:50:19 +1000 Subject: [PATCH 6/8] fix(v3): address review round two Ordering race (CodeRabbit): a small event could read eventRefMode as false while a large event sat between store.put and the flag being set, then dispatch inline and overtake it. Fixed, but not with a per-window mutex as suggested - that would have to be held across the ExecJS main-thread hop, which is the same shape as the deadlock documented in transport_event_ipc.go. Instead the decision moves into JavaScript: evals execute serially on the UI thread in enqueue order, so testing w.__eq there is race-free by construction and needs no Go-side state. eventRefMode is gone. Unterminated promise chain (Copilot): the chained template had no catch, so one throwing listener would leave w.__eq permanently rejected and silently stop all later events for that window. Both templates now terminate with catch. close/put race (CodeRabbit): put could store a payload after close, leaving it unreapable, and concurrent close could panic on a double channel close. Added a mutex-guarded closed flag that makes put refuse, and a dedicated sync.Once around the channel close. Endpoint hardening (Copilot): restrict to GET/HEAD and validate the id is exactly 32 hex characters before touching the map. proc_listallpids returns a PID COUNT, not a byte count (CodeRabbit and Copilot, independently). Verified empirically on macOS: 907 returned against 907 pids present. Dividing by sizeof(int) meant scanning a quarter of the process table, which could have missed our own content process and produced a spurious "no new WebContent process" abort. It found the pid anyway in every run so far, by luck. Enumeration failures no longer look like an empty process list (CodeRabbit): webContentPids returns an error on all platforms, a failed pre-launch enumeration is now fatal rather than silently classifying every pre-existing content process as ours, and discovery propagates the error. ByteRatePerSec ignored AltPayloadBytes (CodeRabbit), understating the interleave scenario's byte rate ~32x in the column that carries the attribution signal. Now averages the two sizes. Verified after the changes: 224,115 events at 4980/s with 0 drops and 0 reorders, emit p50 42us (unchanged); interleave 0 reorders across mixed inline and by-reference delivery; size-1MB host peak 54 MB. --- v3/pkg/application/application.go | 11 ++++- v3/pkg/application/event_payload_store.go | 38 ++++++++++++--- v3/pkg/application/webview_window.go | 47 +++++++++---------- v3/tests/event-performance/main.go | 21 +++++++-- v3/tests/event-performance/report.go | 27 +++++++---- v3/tests/event-performance/sampler_darwin.go | 19 ++++---- v3/tests/event-performance/sampler_linux.go | 7 +-- v3/tests/event-performance/sampler_other.go | 6 ++- v3/tests/event-performance/sampler_windows.go | 9 ++-- v3/tests/event-performance/scenarios.go | 12 ++--- 10 files changed, 128 insertions(+), 69 deletions(-) diff --git a/v3/pkg/application/application.go b/v3/pkg/application/application.go index 5fde7b4268b..46c08cbc8aa 100644 --- a/v3/pkg/application/application.go +++ b/v3/pkg/application/application.go @@ -300,8 +300,17 @@ func (a *App) serveEventPayload(rw http.ResponseWriter, req *http.Request) { 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 id == "" || strings.Contains(id, "/") { + if len(id) != eventPayloadIDLen || !isHexString(id) { http.NotFound(rw, req) return } diff --git a/v3/pkg/application/event_payload_store.go b/v3/pkg/application/event_payload_store.go index ff0b7df8719..87e5d66904d 100644 --- a/v3/pkg/application/event_payload_store.go +++ b/v3/pkg/application/event_payload_store.go @@ -57,8 +57,23 @@ const ( 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 @@ -71,7 +86,9 @@ 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{} } @@ -94,6 +111,11 @@ func (s *eventPayloadStore) put(windowID uint, data []byte) (id string, ok bool) 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 } @@ -156,11 +178,15 @@ func (s *eventPayloadStore) reap() { } } +// close stops the reaper and refuses further payloads. Safe to call more than +// once and concurrently with put. func (s *eventPayloadStore) close() { - s.janitor.Do(func() {}) // ensure reap is never started after close - select { - case <-s.stop: - default: - close(s.stop) - } + 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) }) } diff --git a/v3/pkg/application/webview_window.go b/v3/pkg/application/webview_window.go index c8368004f84..f4f5f3855a7 100644 --- a/v3/pkg/application/webview_window.go +++ b/v3/pkg/application/webview_window.go @@ -189,11 +189,6 @@ type WebviewWindow struct { menuBindings map[string]*MenuItem menuBindingsLock sync.RWMutex - // Set once an event has been delivered by reference to this window. From - // then on every event is appended to the same JS promise chain, so a small - // event cannot overtake a large one that is still being fetched. - eventRefMode atomic.Bool - // Indicates that the window is destroyed destroyed bool destroyedLock sync.RWMutex @@ -1386,19 +1381,28 @@ func (w *WebviewWindow) SetFrameless(frameless bool) Window { // (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: the payload is small enough to splice directly. Synchronous, and - // byte-for-byte the historical behaviour. - inlineEventJS = "if(window._wails&&window._wails.dispatchWailsEvent){window._wails.dispatchWailsEvent(%s);}" - - // chained: same as inline but appended to the window's delivery chain. - // Used once a window has sent an out-of-line event, so that small events - // cannot overtake a large one still being fetched. - chainedEventJS = "if(window._wails&&window._wails.dispatchWailsEvent){var w=window._wails;" + - "w.__eq=(w.__eq||Promise.resolve()).then(function(){w.dispatchWailsEvent(%s);});}" + // 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. + // 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();})" + @@ -1420,23 +1424,14 @@ func (w *WebviewWindow) DispatchWailsEvent(event *CustomEvent) { if len(payload) > maxInlineEventPayload { if store := globalApplication.eventPayloads; store != nil { if id, ok := store.put(w.id, []byte(payload)); ok { - // Every subsequent event for this window goes through the same - // promise chain. Once one event is in flight asynchronously, - // letting later small events dispatch synchronously would - // reorder them past it. - w.eventRefMode.Store(true) w.ExecJS(fmt.Sprintf(refEventJS, eventPayloadPath+id)) return } } - // Store unavailable or full: fall back to inline. This event pays the - // out-of-line retention cost, which beats dropping it. + // Store unavailable, full, or shutting down: fall back to inline. This + // event pays the out-of-line retention cost, which beats dropping it. } - if w.eventRefMode.Load() { - w.ExecJS(fmt.Sprintf(chainedEventJS, payload)) - return - } w.ExecJS(fmt.Sprintf(inlineEventJS, payload)) } diff --git a/v3/tests/event-performance/main.go b/v3/tests/event-performance/main.go index 3bffed76590..f4b9ac46f84 100644 --- a/v3/tests/event-performance/main.go +++ b/v3/tests/event-performance/main.go @@ -107,10 +107,19 @@ func main() { // Snapshot WebContent pids BEFORE anything WebKit-related exists, so the // differential later attributes only our own content process. h.basePids = map[int]bool{} - for _, p := range webContentPids() { - h.basePids[p] = true + if samplerSupported { + pids, err := webContentPids() + if err != nil { + // A failed pre-launch enumeration would classify every pre-existing + // content process as ours, so refuse to run rather than report + // someone else's memory. + log.Fatalf("pre-launch process enumeration failed: %v", err) + } + for _, p := range pids { + h.basePids[p] = true + } + log.Printf("pre-launch content processes on this machine: %d", len(h.basePids)) } - log.Printf("pre-launch WebContent processes on this machine: %d", len(h.basePids)) app := application.New(application.Options{ Name: "eventperf", @@ -462,8 +471,12 @@ func (h *harness) webFootprint() (uint64, bool) { // discoverWebContent diffs the current WebContent pid set against the // pre-launch snapshot. An empty diff is a hard error, never a silent fallback. func (h *harness) discoverWebContent() error { + pids, err := webContentPids() + if err != nil { + return fmt.Errorf("process enumeration failed: %w", err) + } var ours []int - for _, p := range webContentPids() { + for _, p := range pids { if !h.basePids[p] { ours = append(ours, p) } diff --git a/v3/tests/event-performance/report.go b/v3/tests/event-performance/report.go index 63ec98f3ac4..798d900e906 100644 --- a/v3/tests/event-performance/report.go +++ b/v3/tests/event-performance/report.go @@ -73,13 +73,13 @@ type scenarioResult struct { HostRiseBytes int64 `json:"host_rise_bytes"` ByteRatePerSec float64 `json:"emitted_byte_rate_per_sec"` GoHeapSysPeakBytes int64 `json:"go_heap_sys_peak_bytes"` - AchievedRate float64 `json:"achieved_rate_per_sec"` - EmitP50us float64 `json:"emit_p50_us"` - EmitP99us float64 `json:"emit_p99_us"` - LatP50ms float64 `json:"delivery_p50_ms"` - LatP99ms float64 `json:"delivery_p99_ms"` - Valid bool `json:"valid"` - Verdict string `json:"verdict"` + AchievedRate float64 `json:"achieved_rate_per_sec"` + EmitP50us float64 `json:"emit_p50_us"` + EmitP99us float64 `json:"emit_p99_us"` + LatP50ms float64 `json:"delivery_p50_ms"` + LatP99ms float64 `json:"delivery_p99_ms"` + Valid bool `json:"valid"` + Verdict string `json:"verdict"` } func (r *scenarioResult) finalise() { @@ -172,7 +172,14 @@ func (r *scenarioResult) finalise() { } } if secs > 0 { - r.ByteRatePerSec = float64(r.Sent) * float64(r.Payload) / secs + // interleave alternates two payload sizes, so a single-size figure + // would understate its byte rate by ~32x and mislead the attribution + // column, which is the main signal in REPORT.md. + avg := float64(r.Payload) + if alt := sc.AltPayloadBytes; alt > 0 { + avg = (float64(r.Payload) + float64(alt)) / 2 + } + r.ByteRatePerSec = float64(r.Sent) * avg / secs } // Clock domains differ but both are monotonic, so a constant offset cancels: @@ -213,8 +220,8 @@ func (r *scenarioResult) applyVerdict(baselineBps float64) { // baseline AND an absolute magnitude, so a short window full of sawtooth // cannot produce a leak verdict on its own. const ( - leakBps = 50 * 1024 // 50 KB/s sustained floor rise - leakAbsBytes = 4 << 20 // and at least 4 MB of it + leakBps = 50 * 1024 // 50 KB/s sustained floor rise + leakAbsBytes = 4 << 20 // and at least 4 MB of it elevBps = 25 * 1024 elevAbsBytes = 1 << 20 ) diff --git a/v3/tests/event-performance/sampler_darwin.go b/v3/tests/event-performance/sampler_darwin.go index d70075dc690..20c78658f9b 100644 --- a/v3/tests/event-performance/sampler_darwin.go +++ b/v3/tests/event-performance/sampler_darwin.go @@ -36,7 +36,7 @@ static unsigned long long hp_footprint(int pid, int *ok) { */ import "C" -import "unsafe" +import "fmt" const samplerSupported = true @@ -45,14 +45,17 @@ const webContentProcName = "com.apple.WebKit.WebContent" // webContentPids enumerates every WebContent process on the machine. Callers // must diff against a pre-launch snapshot — summing all of them would fold in // other applications' Safari tabs. -func webContentPids() []int { - const maxPids = 16384 +// +// proc_listallpids returns the number of PIDS written, not a byte count. +// (Verified: 907 returned against 907 pids present.) Dividing by sizeof(int) +// here would scan only a quarter of the table and could miss our own process. +func webContentPids() ([]int, error) { + const maxPids = 32768 buf := make([]C.int, maxPids) - nbytes := C.hp_listpids(&buf[0], C.int(maxPids)) - if nbytes <= 0 { - return nil + n := int(C.hp_listpids(&buf[0], C.int(maxPids))) + if n <= 0 { + return nil, fmt.Errorf("proc_listallpids failed (returned %d)", n) } - n := int(nbytes) / int(unsafe.Sizeof(C.int(0))) if n > maxPids { n = maxPids } @@ -71,7 +74,7 @@ func webContentPids() []int { out = append(out, pid) } } - return out + return out, nil } // footprint returns ri_phys_footprint for pid. ok=false means the process is diff --git a/v3/tests/event-performance/sampler_linux.go b/v3/tests/event-performance/sampler_linux.go index 740ebc0075d..373dbd41f15 100644 --- a/v3/tests/event-performance/sampler_linux.go +++ b/v3/tests/event-performance/sampler_linux.go @@ -3,6 +3,7 @@ package main import ( + "fmt" "os" "path/filepath" "strconv" @@ -18,10 +19,10 @@ const webContentProcName = "WebKitWebProcess" // webContentPids enumerates every WebKitGTK web process on the machine. // Callers diff against a pre-launch snapshot; summing all of them would fold // in any other GTK/WebKit app running here. -func webContentPids() []int { +func webContentPids() ([]int, error) { entries, err := os.ReadDir("/proc") if err != nil { - return nil + return nil, fmt.Errorf("read /proc: %w", err) } var out []int @@ -45,7 +46,7 @@ func webContentPids() []int { out = append(out, pid) } } - return out + return out, nil } // footprint reports Pss for pid — the closest Linux analogue to macOS diff --git a/v3/tests/event-performance/sampler_other.go b/v3/tests/event-performance/sampler_other.go index c8356678813..d5a38995ced 100644 --- a/v3/tests/event-performance/sampler_other.go +++ b/v3/tests/event-performance/sampler_other.go @@ -2,11 +2,15 @@ package main +import "errors" + // Memory metrics are unsupported off darwin, but the timing and ordering half // of the harness still runs so engines can be compared later. const samplerSupported = false -func webContentPids() []int { return nil } +func webContentPids() ([]int, error) { + return nil, errors.New("process enumeration unsupported on this platform") +} func footprint(pid int) (uint64, bool) { return 0, false } diff --git a/v3/tests/event-performance/sampler_windows.go b/v3/tests/event-performance/sampler_windows.go index e7ad22ffe22..b66e89915b9 100644 --- a/v3/tests/event-performance/sampler_windows.go +++ b/v3/tests/event-performance/sampler_windows.go @@ -3,6 +3,7 @@ package main import ( + "fmt" "path/filepath" "strings" "unsafe" @@ -45,17 +46,17 @@ var ( // webContentPids enumerates every WebView2 process on the machine. Callers // diff against a pre-launch snapshot; a dev box typically has a dozen of these // already running for other apps. -func webContentPids() []int { +func webContentPids() ([]int, error) { snap, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0) if err != nil { - return nil + return nil, fmt.Errorf("CreateToolhelp32Snapshot: %w", err) } defer windows.CloseHandle(snap) var entry windows.ProcessEntry32 entry.Size = uint32(unsafe.Sizeof(entry)) if err := windows.Process32First(snap, &entry); err != nil { - return nil + return nil, fmt.Errorf("Process32First: %w", err) } var out []int @@ -68,7 +69,7 @@ func webContentPids() []int { break // ERROR_NO_MORE_FILES } } - return out + return out, nil } // footprint reports PrivateUsage for pid. ok=false means the process is gone or diff --git a/v3/tests/event-performance/scenarios.go b/v3/tests/event-performance/scenarios.go index ecc530dd4b6..03770073f91 100644 --- a/v3/tests/event-performance/scenarios.go +++ b/v3/tests/event-performance/scenarios.go @@ -11,9 +11,9 @@ import ( // growth on the size sweep implicates payload retention. type Scenario struct { Name string - Rate int // events/sec; 0 = idle - PayloadBytes int // bytes of filler per event - Burst int // if >0, deliver the mean rate in bursts of this size + Rate int // events/sec; 0 = idle + PayloadBytes int // bytes of filler per event + Burst int // if >0, deliver the mean rate in bursts of this size Duration time.Duration Note string @@ -33,9 +33,9 @@ type Scenario struct { counter *atomic.Int64 // shared across copies of the struct } -func (s Scenario) nextSeq() int64 { return s.counter.Add(1) - 1 } -func (s Scenario) sentSoFar() int64 { return s.counter.Load() } -func (s Scenario) resetCounter() { s.counter.Store(0) } +func (s Scenario) nextSeq() int64 { return s.counter.Add(1) - 1 } +func (s Scenario) sentSoFar() int64 { return s.counter.Load() } +func (s Scenario) resetCounter() { s.counter.Store(0) } func newScenario(s Scenario) Scenario { s.counter = &atomic.Int64{} From d52af650ed32d07578d244c9f1d81def05516221 Mon Sep 17 00:00:00 2001 From: Lea Anthony Date: Sun, 9 Aug 2026 23:54:02 +1000 Subject: [PATCH 7/8] style(v3): gofmt application.go Two things. My eventPayloads field broke the alignment of the surrounding struct fields, which is mine to fix. The rest is a pre-existing mis-indent of the MCP server block that gofmt corrects; whitespace only, no semantic change. --- v3/pkg/application/application.go | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/v3/pkg/application/application.go b/v3/pkg/application/application.go index 46c08cbc8aa..7f82c94cbe5 100644 --- a/v3/pkg/application/application.go +++ b/v3/pkg/application/application.go @@ -452,12 +452,12 @@ type App struct { contextMenus map[string]*ContextMenu contextMenusLock sync.RWMutex - assets *assetserver.AssetServer + 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 + startURL string // Hooks windowCreatedCallbacks []func(window Window) @@ -661,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 { From 27f674bc086fe176c716b8cba50aefdee0ba8f3d Mon Sep 17 00:00:00 2001 From: Lea Anthony Date: Mon, 10 Aug 2026 00:11:14 +1000 Subject: [PATCH 8/8] docs(v3): correct the platform scope in the fallback sampler comment It said metrics are unsupported off darwin, which stopped being true once linux and windows samplers were added. It is the fallback for platforms that have no sampler, not for everything that is not darwin. --- v3/tests/event-performance/sampler_other.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/v3/tests/event-performance/sampler_other.go b/v3/tests/event-performance/sampler_other.go index d5a38995ced..4a929b00ae1 100644 --- a/v3/tests/event-performance/sampler_other.go +++ b/v3/tests/event-performance/sampler_other.go @@ -4,8 +4,9 @@ package main import "errors" -// Memory metrics are unsupported off darwin, but the timing and ordering half -// of the harness still runs so engines can be compared later. +// Fallback for platforms with no sampler of their own — darwin, linux and +// windows each have one. Memory metrics are unavailable here, but the timing +// and ordering half of the harness still runs, so engines can be compared. const samplerSupported = false