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

+ + + + + + + +
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..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:]) +}