From e5b411cee5ba669acdc05b4912a639d66a5b9659 Mon Sep 17 00:00:00 2001 From: bigjakk Date: Mon, 2 Mar 2026 06:29:38 -0800 Subject: [PATCH] Initial release: patched Electron builds fixing WebSocket starvation Fixes a Chromium regression where continuous mouse input (e.g., shooting in FPS games) starves WebSocket/Worker message dispatch when --disable-frame-rate-limit is active. Includes: - Patch diff for main_thread_scheduler_impl.cc - Automated CDP stress test for verification - Full build-from-source guide Pre-built binaries available in Releases. Co-Authored-By: Claude Opus 4.6 --- .gitattributes | 0 .gitignore | 5 + BUILD-GUIDE.md | 793 +++++++++++++++++++++++++++++++++ README.md | 166 +++++++ patches/ws-priority-patch.diff | 73 +++ test/cdp-test.js | 159 +++++++ test/package.json | 9 + test/stress.html | 88 ++++ 8 files changed, 1293 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 BUILD-GUIDE.md create mode 100644 README.md create mode 100644 patches/ws-priority-patch.diff create mode 100644 test/cdp-test.js create mode 100644 test/package.json create mode 100644 test/stress.html diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..e69de29 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8cb0a0c --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +.DS_Store +Thumbs.db +*.log +*.zip diff --git a/BUILD-GUIDE.md b/BUILD-GUIDE.md new file mode 100644 index 0000000..b7f8441 --- /dev/null +++ b/BUILD-GUIDE.md @@ -0,0 +1,793 @@ +# Building Patched Electron: WebSocket/Input Priority Fix + +This guide documents how to build a patched Electron binary that fixes a Chromium +regression where continuous mouse input (e.g., shooting in an FPS game) starves +WebSocket and Worker message dispatch when `--disable-frame-rate-limit` is active. + +## Problem + +When an Electron app uses `--disable-frame-rate-limit` and `--disable-gpu-vsync` +(common in competitive gaming), continuous mouse input with left-click held down +causes WebSocket messages to freeze for 100-300ms+ at a time. This is because +Chromium's Blink main thread scheduler gives input and compositor tasks the highest +priority, and with no frame rate limit, back-to-back BeginFrame tasks create a +tight loop that permanently starves normal-priority tasks (WebSocket onmessage, +Worker postMessage). + +### Root Cause + +Three factors combine: + +1. **Input tasks at `kHighestPriority`** -- In `ComputePriority()`, input tasks + get priority level 1 (highest). WebSocket/Worker tasks get `kNormalPriority` + (level 7). + +2. **No cross-priority anti-starvation** -- `task_queue_selector.cc` simply picks + from the highest active priority queue. It only prevents starvation within the + same priority level, not across levels. + +3. **Compositor priority boost during input** -- When mouse is held + moving + (`UseCase::kMainThreadCustomInputHandling`), the compositor queue gets boosted + to `kHighestPriority`. With `--disable-frame-rate-limit`, the + `BackToBackBeginFrameSource` posts `SEND_BEGIN_MAIN_FRAME` at zero delay, + creating an infinite loop of highest-priority tasks. + +### The Fix + +Two changes in `main_thread_scheduler_impl.cc`: + +1. Lower input task priority from `kHighestPriority` to `kNormalPriority` +2. Cap compositor priority to `kNormalPriority` + +Test results (12-second automated stress test with continuous mouse input): + +| Build | p99 latency | max latency | Messages >50ms | +|-----------|-------------|-------------|----------------| +| Unpatched | ~97ms | ~308ms | 8.6% | +| Patched | ~34ms | ~38ms | 0% | + +--- + +## Prerequisites + +- **Windows 11** (10 may work, untested) +- **250GB+ free disk space** (source is ~30GB, build output ~41GB) +- **16GB+ RAM** (64GB recommended) +- **Visual Studio 2022 Build Tools** with: + - "Desktop development with C++" workload + - C++ ATL for latest build tools + - Windows 11 SDK (10.0.26100.0) +- **Git** with long path support +- **Node.js** LTS (v20+) +- **Python 3.11+** + +## Step 0: Environment Setup + +### Install depot_tools + +```bash +cd C:\ +git clone https://chromium.googlesource.com/chromium/tools/depot_tools.git +``` + +### Install Visual Studio 2022 Build Tools + +Download from https://visualstudio.microsoft.com/downloads/ or use winget: + +```powershell +winget install "Microsoft.VisualStudio.2022.BuildTools" --override "--wait --passive --add Microsoft.VisualStudio.Workload.VCTools;includeRecommended --add Microsoft.VisualStudio.Component.VC.ATL --add Microsoft.VisualStudio.Component.Windows11SDK.26100" +``` + +If you already have Build Tools but need the SDK: + +```powershell +winget install "Microsoft.WindowsSDK.10.0.26100" +``` + +### Install Python 3.12 + +```powershell +winget install Python.Python.3.12 +``` + +After installing, **disable the Windows Store Python aliases**: +- Settings > Apps > Advanced app settings > App execution aliases +- Turn OFF "python.exe" and "python3.exe" App Installers + +Or manually rename the stubs: + +```bash +mv "$LOCALAPPDATA/Microsoft/WindowsApps/python.exe" "$LOCALAPPDATA/Microsoft/WindowsApps/python.exe.bak" +mv "$LOCALAPPDATA/Microsoft/WindowsApps/python3.exe" "$LOCALAPPDATA/Microsoft/WindowsApps/python3.exe.bak" +``` + +Also create a `python3.exe` copy if it doesn't exist: + +```bash +cp "$LOCALAPPDATA/Programs/Python/Python312/python.exe" "$LOCALAPPDATA/Programs/Python/Python312/python3.exe" +``` + +### Configure Git + +```bash +git config --global core.longpaths true +git config --global core.autocrlf false +git config --global core.filemode false +git config --global core.fscache true +git config --global core.preloadindex true +git config --global branch.autosetuprebase always +``` + +### Set Environment Variables + +Add to your shell profile or set as system environment variables: + +```bash +export DEPOT_TOOLS_WIN_TOOLCHAIN=0 +export GIT_CACHE_PATH="C:\\git_cache" +export vs2022_install="C:\\Program Files (x86)\\Microsoft Visual Studio\\2022\\BuildTools" +``` + +### Configure PATH + +Ensure this order (earlier = higher priority): + +``` +C:\Users\\AppData\Local\Programs\Python\Python312 +C:\Users\\AppData\Local\Programs\Python\Python312\Scripts +C:\depot_tools +C:\Program Files\nodejs +``` + +### Windows Defender Exclusions (Recommended) + +Add exclusions for build directories to avoid massive slowdown: + +```powershell +Add-MpPreference -ExclusionPath "C:\electron" +Add-MpPreference -ExclusionPath "C:\depot_tools" +``` + +### Install @electron/build-tools + +```bash +npm install -g @electron/build-tools +``` + +### Create git cache directory + +```bash +mkdir C:\git_cache +``` + +--- + +## Step 1: Initialize and Sync Source + +### Initialize Electron source + +```bash +mkdir C:\electron && cd C:\electron +e init --root=C:\electron krunker-patch --import release +``` + +This creates the directory structure and `.gclient` file. + +### Sync source (downloads ~30GB) + +```bash +e sync +``` + +This takes 1-3 hours depending on network speed. It downloads Chromium, Node.js, +and all dependencies. + +--- + +## Step 2: Apply the Patch + +The file to modify is: +``` +C:\electron\electron\src\third_party\blink\renderer\platform\scheduler\main_thread\main_thread_scheduler_impl.cc +``` + +### Patch 1: Input Priority (in `ComputePriority()` function) + +Find this code (around line 2757): + +```cpp +case MainThreadTaskQueue::QueueTraits::PrioritisationType::kInput: + return TaskPriority::kHighestPriority; +``` + +Replace with: + +```cpp +case MainThreadTaskQueue::QueueTraits::PrioritisationType::kInput: + // Lowered from kHighestPriority to kNormalPriority to prevent input + // tasks from starving WebSocket/Worker message dispatch when + // --disable-frame-rate-limit is active. Without cross-priority + // anti-starvation in the task queue selector, ANY priority above + // kNormalPriority causes starvation during continuous mouse input. + // Testing shows kNormalPriority actually improves both frame rate + // and input throughput vs kHighestPriority, because it prevents the + // input->compositor priority cascade from monopolizing the thread. + return TaskPriority::kNormalPriority; +``` + +### Patch 2: Compositor Priority Cap (in `ComputeCompositorPriority()` function) + +Find the `ComputeCompositorPriority()` function (around line 2823). Replace the +entire function body. The original looks like: + +```cpp +TaskPriority MainThreadSchedulerImpl::ComputeCompositorPriority() const { + std::optional targeted_main_frame_priority = + ComputeCompositorPriorityForMainFrame(); + std::optional use_case_priority = + ComputeCompositorPriorityFromUseCase(); + if (!targeted_main_frame_priority && !use_case_priority) { + return TaskPriority::kNormalPriority; + } else if (!use_case_priority) { + return *targeted_main_frame_priority; + } else if (!targeted_main_frame_priority) { + return *use_case_priority; + } + + // Both are set, so some reconciliation is needed. + CHECK(targeted_main_frame_priority && use_case_priority); + // If either votes for the highest priority, use that to simplify the + // remaining case. + if (*targeted_main_frame_priority == TaskPriority::kHighestPriority || + *use_case_priority == TaskPriority::kHighestPriority) { + return TaskPriority::kHighestPriority; + } + // Otherwise, this must be a combination of UseCase::kCompositorGesture and + // rendering starvation since all other use cases set the priority to highest. + CHECK(current_use_case() == UseCase::kCompositorGesture && + (main_thread_only().main_frame_prioritization_state == + RenderingPrioritizationState::kRenderingStarved || + main_thread_only().main_frame_prioritization_state == + RenderingPrioritizationState::kRenderingStarvedByRenderBlocking)); + CHECK_LE(*targeted_main_frame_priority, *use_case_priority); + return *targeted_main_frame_priority; +} +``` + +Replace with: + +```cpp +TaskPriority MainThreadSchedulerImpl::ComputeCompositorPriority() const { + std::optional targeted_main_frame_priority = + ComputeCompositorPriorityForMainFrame(); + std::optional use_case_priority = + ComputeCompositorPriorityFromUseCase(); + + TaskPriority result; + if (!targeted_main_frame_priority && !use_case_priority) { + result = TaskPriority::kNormalPriority; + } else if (!use_case_priority) { + result = *targeted_main_frame_priority; + } else if (!targeted_main_frame_priority) { + result = *use_case_priority; + } else { + // Both are set -- take the higher priority (lower numeric value). + result = std::min(*targeted_main_frame_priority, *use_case_priority); + } + + // Cap compositor priority to kNormalPriority. Without this cap, + // back-to-back BeginFrame tasks at kHighestPriority (triggered by + // continuous mouse input + --disable-frame-rate-limit) create a tight + // compositor loop that permanently starves kNormalPriority tasks + // (WebSocket onmessage, Worker postMessage). The task queue selector + // has no cross-priority anti-starvation, so any priority above kNormal + // causes indefinite deferral of lower-priority work. Rendering starvation + // detection in ComputeCompositorPriorityForMainFrame() is sufficient to + // protect against actual frame drops when compositor priority is capped. + return std::max(result, TaskPriority::kNormalPriority); +} +``` + +### Using the diff file + +Alternatively, if you have the `.diff` file, apply it from the Chromium src root: + +```bash +cd C:\electron\electron\src +git apply /path/to/ws-priority-patch.diff +``` + +### Verify the patch + +```bash +cd C:\electron\electron\src +grep -n "kNormalPriority" third_party/blink/renderer/platform/scheduler/main_thread/main_thread_scheduler_impl.cc | grep -E "(kInput|std::max)" +``` + +You should see the `kInput` case returning `kNormalPriority` and the `std::max` +cap at the end of `ComputeCompositorPriority()`. + +--- + +## Step 3: Configure the Release Build + +### Set up args.gn + +```bash +mkdir -p C:\electron\electron\src\out\Release +``` + +Create/edit `C:\electron\electron\src\out\Release\args.gn`: + +```gn +import("//electron/build/args/release.gn") +is_official_build = true +use_remoteexec = false +use_reclient = false +``` + +### Generate build files + +```bash +cd C:\electron\electron\src +buildtools/win/gn.exe gen out/Release +``` + +You should see: `Done. Made XXXXX targets from XXXX files` + +### Clean stale state (if needed) + +If you see an error about Siso state files: + +```bash +buildtools/win/gn.exe clean out/Release +buildtools/win/gn.exe gen out/Release +``` + +--- + +## Step 4: Build + +```bash +cd C:\electron\electron\src +ninja -C out/Release electron +``` + +This is a full rebuild -- **expect 6-10+ hours** depending on CPU cores and speed. +On a 24-core machine with 64GB RAM it takes approximately 8-9 hours (~45,000 build +steps). + +### Build the distribution zip + +After the main build completes: + +```bash +ninja -C out/Release electron:electron_dist_zip +``` + +The dist zip will be at `C:\electron\electron\src\out\Release\dist.zip` (~137MB). + +--- + +## Step 5: Using the Patched Electron + +### Option A: Direct binary + +Run your app directly with the built electron: + +```bash +C:\electron\electron\src\out\Release\electron.exe /path/to/your/app +``` + +### Option B: Replace in node_modules + +Extract `dist.zip` and replace the Electron binary in your project: + +```bash +# Find your project's electron installation +ls node_modules/electron/dist/ + +# Back up the original +mv node_modules/electron/dist node_modules/electron/dist-original + +# Extract patched version +mkdir node_modules/electron/dist +cd node_modules/electron/dist +unzip /path/to/dist.zip +``` + +### Option C: electron-builder / electron-forge + +Point your build tool to the custom Electron zip: + +**electron-builder** (`package.json`): +```json +{ + "build": { + "electronDist": "path/to/extracted/dist", + "electronVersion": "40.6.1" + } +} +``` + +Or set the environment variable: +```bash +export ELECTRON_CUSTOM_DIR=path/to/extracted/dist +``` + +**electron-forge** (`forge.config.js`): +```js +module.exports = { + packagerConfig: { + electronZipDir: 'path/to/extracted/dist' + } +}; +``` + +### Required Chromium flags + +Your Electron app should use these flags for unlimited FPS: + +```javascript +app.commandLine.appendSwitch('disable-frame-rate-limit'); +app.commandLine.appendSwitch('disable-gpu-vsync'); +``` + +--- + +## Verification: Automated Stress Test + +### Test files + +Create a directory (e.g., `C:\electron\test-app`) with these files: + +**package.json:** +```json +{ + "name": "ws-starvation-test", + "version": "1.0.0", + "main": "cdp-test.js", + "dependencies": { + "ws": "^8.0.0" + } +} +``` + +Run `npm install` in the test directory. + +**stress.html:** +```html + + +WS Starvation Stress Test + + +
+ + + +``` + +**cdp-test.js:** +```javascript +const { app, BrowserWindow } = require('electron'); +const http = require('http'); +const fs = require('fs'); +const path = require('path'); +const { WebSocketServer } = require('ws'); + +const PORT = parseInt(process.argv[2] || '8085'); +const LABEL = process.argv[3] || 'TEST'; +const TEST_DURATION_MS = 12000; +const WARMUP_MS = 3000; + +app.commandLine.appendSwitch('disable-frame-rate-limit'); +app.commandLine.appendSwitch('disable-gpu-vsync'); + +// Server +const server = http.createServer((req, res) => { + const filePath = path.join(__dirname, 'stress.html'); + fs.readFile(filePath, (err, data) => { + if (err) { res.writeHead(404); res.end(); return; } + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end(data); + }); +}); +const wss = new WebSocketServer({ server }); +wss.on('connection', (ws) => { + console.log('[' + LABEL + '] WS client connected'); + const interval = setInterval(() => { + if (ws.readyState === ws.OPEN) { + ws.send(JSON.stringify({ t: performance.now() })); + } + }, 16); + ws.on('close', () => clearInterval(interval)); +}); +server.listen(PORT, () => console.log('[' + LABEL + '] Server on port ' + PORT)); + +app.whenReady().then(async () => { + const win = new BrowserWindow({ + width: 900, height: 700, show: true, + webPreferences: { nodeIntegration: false, contextIsolation: true } + }); + + await win.loadURL('http://localhost:' + PORT); + const wc = win.webContents; + + // Attach to debugger for CDP input dispatch + wc.debugger.attach('1.3'); + console.log('[' + LABEL + '] CDP debugger attached'); + + await new Promise(r => setTimeout(r, WARMUP_MS)); + await wc.executeJavaScript('wsLatencies = []; void 0;'); + + console.log('[' + LABEL + '] Starting ' + (TEST_DURATION_MS/1000) + 's stress test...'); + + const startTime = Date.now(); + let eventCount = 0; + let angle = 0; + const centerX = 450, centerY = 350; + + // Mouse down via CDP + await wc.debugger.sendCommand('Input.dispatchMouseEvent', { + type: 'mousePressed', + x: centerX, y: centerY, + button: 'left', + clickCount: 1 + }); + eventCount++; + + // Flood with mouseMoved events via CDP + const flood = async () => { + while (Date.now() - startTime < TEST_DURATION_MS) { + const promises = []; + for (let i = 0; i < 10; i++) { + angle += 0.03; + const radius = 100 + Math.sin(angle * 0.5) * 60; + const x = Math.floor(centerX + Math.cos(angle) * radius); + const y = Math.floor(centerY + Math.sin(angle) * radius); + promises.push( + wc.debugger.sendCommand('Input.dispatchMouseEvent', { + type: 'mouseMoved', + x: x, y: y, + button: 'left', + buttons: 1 + }).catch(() => {}) + ); + eventCount++; + } + await Promise.all(promises); + } + }; + + await flood(); + + // Release + await wc.debugger.sendCommand('Input.dispatchMouseEvent', { + type: 'mouseReleased', + x: centerX, y: centerY, + button: 'left' + }).catch(() => {}); + + await new Promise(r => setTimeout(r, 2000)); + + // Collect results + const result = await wc.executeJavaScript( + '(function() {' + + ' if (!wsLatencies || wsLatencies.length === 0) return JSON.stringify({ error: "no data" });' + + ' var sorted = wsLatencies.slice().sort(function(a,b){return a-b});' + + ' var len = sorted.length;' + + ' var avg = wsLatencies.reduce(function(a,b){return a+b},0) / len;' + + ' var p50 = sorted[Math.floor(len*0.50)];' + + ' var p75 = sorted[Math.floor(len*0.75)];' + + ' var p90 = sorted[Math.floor(len*0.90)];' + + ' var p95 = sorted[Math.floor(len*0.95)];' + + ' var p99 = sorted[Math.floor(len*0.99)];' + + ' var max = sorted[len-1];' + + ' var min = sorted[0];' + + ' var over50 = wsLatencies.filter(function(x){return x>50}).length;' + + ' var over100 = wsLatencies.filter(function(x){return x>100}).length;' + + ' var over200 = wsLatencies.filter(function(x){return x>200}).length;' + + ' var over500 = wsLatencies.filter(function(x){return x>500}).length;' + + ' var mouseEvents = typeof moveCount !== "undefined" ? moveCount : -1;' + + ' var frames = typeof frameCount !== "undefined" ? frameCount : -1;' + + ' return JSON.stringify({avg:avg,min:min,p50:p50,p75:p75,p90:p90,p95:p95,p99:p99,max:max,total:len,over50:over50,over100:over100,over200:over200,over500:over500,mouseEvents:mouseEvents,frames:frames});' + + '})()' + ); + + wc.debugger.detach(); + + const d = JSON.parse(result); + if (d.error) { + console.log('[' + LABEL + '] ERROR: ' + d.error); + process.exit(2); + } + + console.log('\n[' + LABEL + '] ============ RESULTS ============'); + console.log('[' + LABEL + '] CDP events sent: ' + eventCount); + console.log('[' + LABEL + '] Mouse events received by renderer: ' + d.mouseEvents); + console.log('[' + LABEL + '] RAF frames rendered: ' + d.frames); + console.log('[' + LABEL + '] WS samples collected: ' + d.total); + console.log('[' + LABEL + ']'); + console.log('[' + LABEL + '] WS inter-message latency (ms):'); + console.log('[' + LABEL + '] min: ' + d.min.toFixed(1)); + console.log('[' + LABEL + '] avg: ' + d.avg.toFixed(1)); + console.log('[' + LABEL + '] p50: ' + d.p50.toFixed(1)); + console.log('[' + LABEL + '] p75: ' + d.p75.toFixed(1)); + console.log('[' + LABEL + '] p90: ' + d.p90.toFixed(1)); + console.log('[' + LABEL + '] p95: ' + d.p95.toFixed(1)); + console.log('[' + LABEL + '] p99: ' + d.p99.toFixed(1)); + console.log('[' + LABEL + '] max: ' + d.max.toFixed(1)); + console.log('[' + LABEL + ']'); + console.log('[' + LABEL + '] Threshold violations:'); + console.log('[' + LABEL + '] >50ms: ' + d.over50 + ' / ' + d.total + ' (' + (d.over50/d.total*100).toFixed(1) + '%)'); + console.log('[' + LABEL + '] >100ms: ' + d.over100 + ' / ' + d.total + ' (' + (d.over100/d.total*100).toFixed(1) + '%)'); + console.log('[' + LABEL + '] >200ms: ' + d.over200 + ' / ' + d.total + ' (' + (d.over200/d.total*100).toFixed(1) + '%)'); + console.log('[' + LABEL + '] >500ms: ' + d.over500 + ' / ' + d.total + ' (' + (d.over500/d.total*100).toFixed(1) + '%)'); + console.log('[' + LABEL + '] =================================='); + + process.exit(0); +}); +``` + +### Running the test + +```bash +# Test the patched build +C:\electron\electron\src\out\Release\electron.exe cdp-test.js 8085 PATCHED + +# Compare against a stock Electron (download from https://github.com/electron/electron/releases) +path/to/stock/electron.exe cdp-test.js 8086 BASELINE +``` + +**Expected results:** +- Unpatched: p99 ~80-100ms, max ~200-300ms, 5-9% of messages >50ms +- Patched: p99 ~30-35ms, max ~35-40ms, 0% of messages >50ms + +**Important:** The test uses CDP `Input.dispatchMouseEvent` to simulate mouse +input. This goes through the full Chromium input pipeline and reliably triggers +the starvation. Electron's `sendInputEvent` API does NOT trigger it (bypasses +compositor thread input handler). + +--- + +## Rebuilding for a Different Electron Version + +To build the patch against a different Electron version (e.g., upgrading to a +newer stable release): + +```bash +cd C:\electron\electron\src\electron + +# List available stable versions +git tag --list 'v*' --sort=-version:refname | grep -v -E '(nightly|alpha|beta)' | head -10 + +# Check out the desired version +git checkout v40.6.1 + +# Sync dependencies (30-60+ minutes) +cd C:\electron\electron\src +gclient sync --with_branch_heads --with_tags + +# Re-apply the patch (line numbers may differ between versions) +# Edit main_thread_scheduler_impl.cc as described in Step 2 +# Or try: git apply ws-priority-patch.diff + +# Clean, generate, and build +buildtools/win/gn.exe clean out/Release +buildtools/win/gn.exe gen out/Release +ninja -C out/Release electron +ninja -C out/Release electron:electron_dist_zip +``` + +**Note:** The patch modifies Chromium source (not Electron source), so line numbers +may shift between versions. The function names and structure should remain the same +across Chromium versions. Search for `PrioritisationType::kInput` and +`ComputeCompositorPriority()` to find the right locations. + +--- + +## Patch File + +The raw diff is saved at `ws-priority-patch.diff` alongside this guide. It was +generated against Chromium 147.x (Electron v42 nightly) but the same logic applies +to all recent versions. + +--- + +## Troubleshooting + +### "Python was not found" during build +Disable Windows Store Python aliases (see Step 0). Ensure real Python 3.12 is +in PATH before `C:\Users\\AppData\Local\Microsoft\WindowsApps`. + +### "Siso state file" error when running ninja +Run `buildtools/win/gn.exe clean out/Release` then `gn gen` again. + +### "gn not found" +Use the full path: `buildtools/win/gn.exe` from the Chromium src directory. + +### Build fails with missing Windows SDK +Install SDK 10.0.26100.0: `winget install "Microsoft.WindowsSDK.10.0.26100"` + +### gclient sync fails with SSH errors +The sync uses Git cache. If SSH keys aren't set up for GitHub, the repos should +still sync via HTTPS through the cache. If errors persist, check `GIT_CACHE_PATH` +is set correctly. + +### Patch doesn't apply cleanly to a different version +Apply manually -- search for `PrioritisationType::kInput` returning +`kHighestPriority` and change it to `kNormalPriority`. Then find +`ComputeCompositorPriority()` and add the `std::max` cap. The surrounding code +structure should be recognizable even if line numbers differ. diff --git a/README.md b/README.md new file mode 100644 index 0000000..0b037eb --- /dev/null +++ b/README.md @@ -0,0 +1,166 @@ +# Electron WebSocket Fix + +Patched Electron builds that fix a Chromium regression where continuous mouse input starves WebSocket and Worker message dispatch when `--disable-frame-rate-limit` is active. + +This is critical for competitive browser-based games (like [Krunker](https://krunker.io)) running in Electron, where shooting (holding left click + moving mouse) causes network freezes of 100-300ms+. + +## Downloads + +Pre-built patched binaries (Windows x64): + +| File | Electron Version | Size | +|------|-----------------|------| +| [`electron-v40.6.1-release-patched-win32-x64.zip`](releases/electron-v40.6.1-release-patched-win32-x64.zip) | v40.6.1 (latest stable) | 133MB | +| [`electron-v42.0.0-nightly-release-patched-win32-x64.zip`](releases/electron-v42.0.0-nightly-release-patched-win32-x64.zip) | v42.0.0-nightly | 137MB | + +Both are full release builds (`is_official_build = true`) with maximum optimizations. + +## The Problem + +When an Electron app uses these flags (common for competitive gaming): + +```javascript +app.commandLine.appendSwitch('disable-frame-rate-limit'); +app.commandLine.appendSwitch('disable-gpu-vsync'); +``` + +...continuous mouse input with left-click held down causes WebSocket `onmessage` callbacks to be delayed by 100-300ms+. This manifests as network "freezing" -- player positions stop updating, hit registration breaks, and the game becomes unplayable during gunfights. + +### Root Cause + +Three factors in Chromium's Blink main thread scheduler combine to create the issue: + +1. **Input tasks at `kHighestPriority`** (priority level 1) while WebSocket/Worker tasks sit at `kNormalPriority` (level 7) + +2. **No cross-priority anti-starvation** in `task_queue_selector.cc` -- it always picks from the highest active priority queue, with no mechanism to let lower-priority tasks run + +3. **Compositor priority boost during input** -- when mouse is held + moving (`UseCase::kMainThreadCustomInputHandling`), the compositor queue gets boosted to `kHighestPriority`. With `--disable-frame-rate-limit`, the `BackToBackBeginFrameSource` posts `SEND_BEGIN_MAIN_FRAME` tasks at zero delay, creating an infinite loop of highest-priority tasks that permanently starves everything else + +This regression was introduced in Chromium 84 when `PrioritizeCompositingAfterInput` was made unconditional ([CL 2132022](https://chromium-review.googlesource.com/c/chromium/src/+/2132022)). + +## The Fix + +Two changes in one file (`main_thread_scheduler_impl.cc`): + +1. **Lower input task priority** from `kHighestPriority` to `kNormalPriority` +2. **Cap compositor priority** to `kNormalPriority` via `std::max()` + +See [`patches/ws-priority-patch.diff`](patches/ws-priority-patch.diff) for the exact diff. + +### Test Results + +12-second automated stress test with continuous CDP mouse input (left-click held, circular movement): + +| Build | p99 Latency | Max Latency | Messages >50ms | Mouse Events | Frames | +|-------|-------------|-------------|----------------|-------------|--------| +| **Unpatched** | ~97ms | ~308ms | 8.6% | ~11,380 | ~6,300 | +| **Patched** | ~34ms | ~38ms | **0%** | ~12,360 | ~7,620 | + +The patch not only eliminates starvation but actually **improves** both input throughput (+9% mouse events) and frame rate (+21% frames) because it prevents the input/compositor priority cascade from monopolizing the main thread. + +## Usage + +### Option A: Direct Binary + +Extract the zip and run your app: + +```bash +electron.exe path/to/your/app +``` + +### Option B: Replace in node_modules + +```bash +# Back up original +mv node_modules/electron/dist node_modules/electron/dist-original + +# Extract patched version +mkdir node_modules/electron/dist +cd node_modules/electron/dist +unzip path/to/electron-v40.6.1-release-patched-win32-x64.zip +``` + +### Option C: electron-builder + +In `package.json`: + +```json +{ + "build": { + "electronDist": "path/to/extracted/dist", + "electronVersion": "40.6.1" + } +} +``` + +### Option D: electron-forge + +In `forge.config.js`: + +```js +module.exports = { + packagerConfig: { + electronZipDir: 'path/to/extracted/dist' + } +}; +``` + +## Verification + +An automated stress test is included in the [`test/`](test/) directory: + +```bash +cd test +npm install +path/to/patched/electron.exe cdp-test.js 8085 PATCHED +``` + +The test uses CDP `Input.dispatchMouseEvent` to simulate continuous mouse input (the only reliable automated method -- Electron's `sendInputEvent` API bypasses the compositor thread and doesn't trigger the bug). + +Expected output for a patched build: **0% of WebSocket messages >50ms**. + +## Building From Source + +Full build instructions are in [`BUILD-GUIDE.md`](BUILD-GUIDE.md). + +Quick summary: + +```bash +# 1. Set up environment (depot_tools, VS Build Tools, Python, etc.) +# 2. Initialize and sync Electron source +e init --root=C:\electron my-build --import release +e sync + +# 3. Check out desired version +cd src/electron && git checkout v40.6.1 +cd .. && gclient sync --with_branch_heads --with_tags + +# 4. Apply patch +git apply path/to/ws-priority-patch.diff + +# 5. Configure and build +mkdir -p out/Release +# Set out/Release/args.gn: +# import("//electron/build/args/release.gn") +# is_official_build = true +# use_remoteexec = false +# use_reclient = false +buildtools/win/gn.exe gen out/Release +ninja -C out/Release electron +ninja -C out/Release electron:electron_dist_zip +``` + +Expect 6-10+ hours for a full build on a modern machine (24 cores, 64GB RAM). + +## Patch Details + +The patch modifies Chromium source (not Electron source), so it applies to any Electron version. Line numbers may shift between versions but the function names remain the same: + +- `ComputePriority()` -- search for `PrioritisationType::kInput` +- `ComputeCompositorPriority()` -- search for that function name + +File: `third_party/blink/renderer/platform/scheduler/main_thread/main_thread_scheduler_impl.cc` + +## License + +The patch itself is provided as-is. Electron is MIT licensed. Chromium is BSD licensed. diff --git a/patches/ws-priority-patch.diff b/patches/ws-priority-patch.diff new file mode 100644 index 0000000..db20135 --- /dev/null +++ b/patches/ws-priority-patch.diff @@ -0,0 +1,73 @@ +diff --git a/third_party/blink/renderer/platform/scheduler/main_thread/main_thread_scheduler_impl.cc b/third_party/blink/renderer/platform/scheduler/main_thread/main_thread_scheduler_impl.cc +index cf7455daf6b2e..9757d1d336ddc 100644 +--- a/third_party/blink/renderer/platform/scheduler/main_thread/main_thread_scheduler_impl.cc ++++ b/third_party/blink/renderer/platform/scheduler/main_thread/main_thread_scheduler_impl.cc +@@ -2755,7 +2755,15 @@ TaskPriority MainThreadSchedulerImpl::ComputePriority( + case MainThreadTaskQueue::QueueTraits::PrioritisationType::kCompositor: + return main_thread_only().compositor_priority; + case MainThreadTaskQueue::QueueTraits::PrioritisationType::kInput: +- return TaskPriority::kHighestPriority; ++ // Lowered from kHighestPriority to kNormalPriority to prevent input ++ // tasks from starving WebSocket/Worker message dispatch when ++ // --disable-frame-rate-limit is active. Without cross-priority ++ // anti-starvation in the task queue selector, ANY priority above ++ // kNormalPriority causes starvation during continuous mouse input. ++ // Testing shows kNormalPriority actually improves both frame rate ++ // and input throughput vs kHighestPriority, because it prevents the ++ // input→compositor priority cascade from monopolizing the thread. ++ return TaskPriority::kNormalPriority; + case MainThreadTaskQueue::QueueTraits::PrioritisationType::kBestEffort: + return TaskPriority::kBestEffortPriority; + case MainThreadTaskQueue::QueueTraits::PrioritisationType::kRegular: +@@ -2823,31 +2831,29 @@ TaskPriority MainThreadSchedulerImpl::ComputeCompositorPriority() const { + ComputeCompositorPriorityForMainFrame(); + std::optional use_case_priority = + ComputeCompositorPriorityFromUseCase(); ++ ++ TaskPriority result; + if (!targeted_main_frame_priority && !use_case_priority) { +- return TaskPriority::kNormalPriority; ++ result = TaskPriority::kNormalPriority; + } else if (!use_case_priority) { +- return *targeted_main_frame_priority; ++ result = *targeted_main_frame_priority; + } else if (!targeted_main_frame_priority) { +- return *use_case_priority; +- } +- +- // Both are set, so some reconciliation is needed. +- CHECK(targeted_main_frame_priority && use_case_priority); +- // If either votes for the highest priority, use that to simplify the +- // remaining case. +- if (*targeted_main_frame_priority == TaskPriority::kHighestPriority || +- *use_case_priority == TaskPriority::kHighestPriority) { +- return TaskPriority::kHighestPriority; +- } +- // Otherwise, this must be a combination of UseCase::kCompositorGesture and +- // rendering starvation since all other use cases set the priority to highest. +- CHECK(current_use_case() == UseCase::kCompositorGesture && +- (main_thread_only().main_frame_prioritization_state == +- RenderingPrioritizationState::kRenderingStarved || +- main_thread_only().main_frame_prioritization_state == +- RenderingPrioritizationState::kRenderingStarvedByRenderBlocking)); +- CHECK_LE(*targeted_main_frame_priority, *use_case_priority); +- return *targeted_main_frame_priority; ++ result = *use_case_priority; ++ } else { ++ // Both are set — take the higher priority (lower numeric value). ++ result = std::min(*targeted_main_frame_priority, *use_case_priority); ++ } ++ ++ // Cap compositor priority to kNormalPriority. Without this cap, ++ // back-to-back BeginFrame tasks at kHighestPriority (triggered by ++ // continuous mouse input + --disable-frame-rate-limit) create a tight ++ // compositor loop that permanently starves kNormalPriority tasks ++ // (WebSocket onmessage, Worker postMessage). The task queue selector ++ // has no cross-priority anti-starvation, so any priority above kNormal ++ // causes indefinite deferral of lower-priority work. Rendering starvation ++ // detection in ComputeCompositorPriorityForMainFrame() is sufficient to ++ // protect against actual frame drops when compositor priority is capped. ++ return std::max(result, TaskPriority::kNormalPriority); + } + + void MainThreadSchedulerImpl::UpdateCompositorTaskQueuePriority() { diff --git a/test/cdp-test.js b/test/cdp-test.js new file mode 100644 index 0000000..bc9293c --- /dev/null +++ b/test/cdp-test.js @@ -0,0 +1,159 @@ +const { app, BrowserWindow } = require('electron'); +const http = require('http'); +const fs = require('fs'); +const path = require('path'); +const { WebSocketServer } = require('ws'); + +const PORT = parseInt(process.argv[2] || '8085'); +const LABEL = process.argv[3] || 'TEST'; +const TEST_DURATION_MS = 12000; +const WARMUP_MS = 3000; + +app.commandLine.appendSwitch('disable-frame-rate-limit'); +app.commandLine.appendSwitch('disable-gpu-vsync'); + +// Server +const server = http.createServer((req, res) => { + const filePath = path.join(__dirname, 'stress.html'); + fs.readFile(filePath, (err, data) => { + if (err) { res.writeHead(404); res.end(); return; } + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end(data); + }); +}); +const wss = new WebSocketServer({ server }); +wss.on('connection', (ws) => { + console.log(`[${LABEL}] WS client connected`); + const interval = setInterval(() => { + if (ws.readyState === ws.OPEN) { + ws.send(JSON.stringify({ t: performance.now() })); + } + }, 16); + ws.on('close', () => clearInterval(interval)); +}); +server.listen(PORT, () => console.log(`[${LABEL}] Server on port ${PORT}`)); + +app.whenReady().then(async () => { + const win = new BrowserWindow({ + width: 900, height: 700, show: true, + webPreferences: { nodeIntegration: false, contextIsolation: true } + }); + + await win.loadURL('http://localhost:' + PORT); + const wc = win.webContents; + + // Attach to debugger for CDP input dispatch + wc.debugger.attach('1.3'); + console.log(`[${LABEL}] CDP debugger attached`); + + await new Promise(r => setTimeout(r, WARMUP_MS)); + await wc.executeJavaScript('wsLatencies = []; void 0;'); + + console.log(`[${LABEL}] Starting ${TEST_DURATION_MS/1000}s stress test — CDP Input.dispatchMouseEvent...`); + + const startTime = Date.now(); + let eventCount = 0; + let angle = 0; + const centerX = 450, centerY = 350; + + // Initial mouse down via CDP + await wc.debugger.sendCommand('Input.dispatchMouseEvent', { + type: 'mousePressed', + x: centerX, y: centerY, + button: 'left', + clickCount: 1 + }); + eventCount++; + + // Flood with mouseMoved events via CDP + // CDP dispatches go through the full Chromium input pipeline + const flood = async () => { + while (Date.now() - startTime < TEST_DURATION_MS) { + const promises = []; + for (let i = 0; i < 10; i++) { + angle += 0.03; + const radius = 100 + Math.sin(angle * 0.5) * 60; + const x = Math.floor(centerX + Math.cos(angle) * radius); + const y = Math.floor(centerY + Math.sin(angle) * radius); + promises.push( + wc.debugger.sendCommand('Input.dispatchMouseEvent', { + type: 'mouseMoved', + x: x, y: y, + button: 'left', + buttons: 1 + }).catch(() => {}) + ); + eventCount++; + } + await Promise.all(promises); + } + }; + + await flood(); + + // Release + await wc.debugger.sendCommand('Input.dispatchMouseEvent', { + type: 'mouseReleased', + x: centerX, y: centerY, + button: 'left' + }).catch(() => {}); + + await new Promise(r => setTimeout(r, 2000)); + + // Collect + const result = await wc.executeJavaScript(` + (function() { + if (!wsLatencies || wsLatencies.length === 0) return JSON.stringify({ error: 'no data' }); + const sorted = [...wsLatencies].sort((a,b) => a - b); + const len = sorted.length; + const avg = wsLatencies.reduce((a,b) => a+b, 0) / len; + const p50 = sorted[Math.floor(len * 0.50)]; + const p75 = sorted[Math.floor(len * 0.75)]; + const p90 = sorted[Math.floor(len * 0.90)]; + const p95 = sorted[Math.floor(len * 0.95)]; + const p99 = sorted[Math.floor(len * 0.99)]; + const max = sorted[len - 1]; + const min = sorted[0]; + const over50 = wsLatencies.filter(x => x > 50).length; + const over100 = wsLatencies.filter(x => x > 100).length; + const over200 = wsLatencies.filter(x => x > 200).length; + const over500 = wsLatencies.filter(x => x > 500).length; + const mouseEvents = typeof moveCount !== 'undefined' ? moveCount : -1; + const frames = typeof frameCount !== 'undefined' ? frameCount : -1; + return JSON.stringify({ avg, min, p50, p75, p90, p95, p99, max, total: len, over50, over100, over200, over500, mouseEvents, frames }); + })() + `); + + wc.debugger.detach(); + + const d = JSON.parse(result); + if (d.error) { + console.log(`[${LABEL}] ERROR: ${d.error}`); + process.exit(2); + } + + console.log(`\\n[${LABEL}] ============ RESULTS ============`); + console.log(`[${LABEL}] CDP events sent: ${eventCount}`); + console.log(`[${LABEL}] Mouse events received by renderer: ${d.mouseEvents}`); + console.log(`[${LABEL}] RAF frames rendered: ${d.frames}`); + console.log(`[${LABEL}] WS samples collected: ${d.total}`); + console.log(`[${LABEL}]`); + console.log(`[${LABEL}] WS inter-message latency (ms):`); + console.log(`[${LABEL}] min: ${d.min.toFixed(1)}`); + console.log(`[${LABEL}] avg: ${d.avg.toFixed(1)}`); + console.log(`[${LABEL}] p50: ${d.p50.toFixed(1)}`); + console.log(`[${LABEL}] p75: ${d.p75.toFixed(1)}`); + console.log(`[${LABEL}] p90: ${d.p90.toFixed(1)}`); + console.log(`[${LABEL}] p95: ${d.p95.toFixed(1)}`); + console.log(`[${LABEL}] p99: ${d.p99.toFixed(1)}`); + console.log(`[${LABEL}] max: ${d.max.toFixed(1)}`); + console.log(`[${LABEL}]`); + console.log(`[${LABEL}] Threshold violations:`); + console.log(`[${LABEL}] >50ms: ${d.over50} / ${d.total} (${(d.over50/d.total*100).toFixed(1)}%)`); + console.log(`[${LABEL}] >100ms: ${d.over100} / ${d.total} (${(d.over100/d.total*100).toFixed(1)}%)`); + console.log(`[${LABEL}] >200ms: ${d.over200} / ${d.total} (${(d.over200/d.total*100).toFixed(1)}%)`); + console.log(`[${LABEL}] >500ms: ${d.over500} / ${d.total} (${(d.over500/d.total*100).toFixed(1)}%)`); + console.log(`[${LABEL}] ==================================`); + + process.exit(0); +}); diff --git a/test/package.json b/test/package.json new file mode 100644 index 0000000..66ac12d --- /dev/null +++ b/test/package.json @@ -0,0 +1,9 @@ +{ + "name": "ws-starvation-test", + "version": "1.0.0", + "description": "Automated stress test for WebSocket starvation under continuous mouse input", + "main": "cdp-test.js", + "dependencies": { + "ws": "^8.0.0" + } +} diff --git a/test/stress.html b/test/stress.html new file mode 100644 index 0000000..ce081de --- /dev/null +++ b/test/stress.html @@ -0,0 +1,88 @@ + + +WS Starvation Stress Test + + +
+ + +