diff --git a/v3/pkg/application/application.go b/v3/pkg/application/application.go
index 8097a4b7aa9..7f82c94cbe5 100644
--- a/v3/pkg/application/application.go
+++ b/v3/pkg/application/application.go
@@ -72,6 +72,10 @@ func New(appOptions Options) *App {
result.logPlatformInfo()
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
@@ -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)
@@ -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)
+ }
+ }
+
+ 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"
@@ -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)
@@ -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 {
diff --git a/v3/pkg/application/event_payload_store.go b/v3/pkg/application/event_payload_store.go
new file mode 100644
index 00000000000..87e5d66904d
--- /dev/null
+++ b/v3/pkg/application/event_payload_store.go
@@ -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) })
+}
diff --git a/v3/pkg/application/webview_window.go b/v3/pkg/application/webview_window.go
index 28a715a2fe2..25dbf5e6335 100644
--- a/v3/pkg/application/webview_window.go
+++ b/v3/pkg/application/webview_window.go
@@ -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() {
@@ -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))
+ 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) {
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
+
+ | epoch | 0 |
+ | received | 0 |
+ | drops | 0 |
+ | reorders | 0 |
+ | long frames (>20ms) | 0 |
+ | jitter p99 | 0 |
+
+
+
+
+
+
+
+
diff --git a/v3/tests/event-performance/main.go b/v3/tests/event-performance/main.go
new file mode 100644
index 00000000000..f4b9ac46f84
--- /dev/null
+++ b/v3/tests/event-performance/main.go
@@ -0,0 +1,490 @@
+// 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{}
+ 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))
+ }
+
+ 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 {
+ pids, err := webContentPids()
+ if err != nil {
+ return fmt.Errorf("process enumeration failed: %w", err)
+ }
+ var ours []int
+ for _, p := range pids {
+ 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..798d900e906
--- /dev/null
+++ b/v3/tests/event-performance/report.go
@@ -0,0 +1,448 @@
+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 {
+ // 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:
+ // 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..20c78658f9b
--- /dev/null
+++ b/v3/tests/event-performance/sampler_darwin.go
@@ -0,0 +1,89 @@
+//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 "fmt"
+
+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.
+//
+// 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)
+ n := int(C.hp_listpids(&buf[0], C.int(maxPids)))
+ if n <= 0 {
+ return nil, fmt.Errorf("proc_listallpids failed (returned %d)", n)
+ }
+ 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, nil
+}
+
+// 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..373dbd41f15
--- /dev/null
+++ b/v3/tests/event-performance/sampler_linux.go
@@ -0,0 +1,92 @@
+//go:build linux
+
+package main
+
+import (
+ "fmt"
+ "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, error) {
+ entries, err := os.ReadDir("/proc")
+ if err != nil {
+ return nil, fmt.Errorf("read /proc: %w", err)
+ }
+
+ 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, nil
+}
+
+// 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..4a929b00ae1
--- /dev/null
+++ b/v3/tests/event-performance/sampler_other.go
@@ -0,0 +1,17 @@
+//go:build !darwin && !linux && !windows
+
+package main
+
+import "errors"
+
+// 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
+
+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
new file mode 100644
index 00000000000..b66e89915b9
--- /dev/null
+++ b/v3/tests/event-performance/sampler_windows.go
@@ -0,0 +1,102 @@
+//go:build windows
+
+package main
+
+import (
+ "fmt"
+ "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, error) {
+ snap, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0)
+ if err != 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, fmt.Errorf("Process32First: %w", err)
+ }
+
+ 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, nil
+}
+
+// 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
+}
diff --git a/v3/tests/event-performance/scenarios.go b/v3/tests/event-performance/scenarios.go
new file mode 100644
index 00000000000..03770073f91
--- /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:])
+}