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 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
*.log
|
||||
*.zip
|
||||
+793
@@ -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\<you>\AppData\Local\Programs\Python\Python312
|
||||
C:\Users\<you>\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<TaskPriority> targeted_main_frame_priority =
|
||||
ComputeCompositorPriorityForMainFrame();
|
||||
std::optional<TaskPriority> 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<TaskPriority> targeted_main_frame_priority =
|
||||
ComputeCompositorPriorityForMainFrame();
|
||||
std::optional<TaskPriority> 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
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>WS Starvation Stress Test</title></head>
|
||||
<body style="margin:0; overflow:hidden; background:#111;">
|
||||
<canvas id="c" style="width:100vw;height:100vh;display:block;"></canvas>
|
||||
<div id="hud" style="position:fixed;top:10px;left:10px;color:#0f0;font:14px monospace;background:rgba(0,0,0,0.8);padding:10px;z-index:10;pointer-events:none;"></div>
|
||||
<script>
|
||||
const canvas = document.getElementById('c');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const hud = document.getElementById('hud');
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = window.innerHeight;
|
||||
|
||||
let mx = canvas.width/2, my = canvas.height/2;
|
||||
let mouseDown = false;
|
||||
let moveCount = 0;
|
||||
let frameCount = 0;
|
||||
|
||||
document.addEventListener('mousedown', (e) => {
|
||||
mouseDown = true;
|
||||
mx = e.clientX; my = e.clientY;
|
||||
ctx.fillStyle = '#ff0';
|
||||
ctx.beginPath();
|
||||
ctx.arc(mx, my, 30, 0, Math.PI*2);
|
||||
ctx.fill();
|
||||
});
|
||||
document.addEventListener('mouseup', () => { mouseDown = false; });
|
||||
document.addEventListener('mousemove', (e) => {
|
||||
mx = e.clientX; my = e.clientY;
|
||||
moveCount++;
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.strokeStyle = mouseDown ? '#f00' : '#0f0';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(mx - 20, my); ctx.lineTo(mx + 20, my);
|
||||
ctx.moveTo(mx, my - 20); ctx.lineTo(mx, my + 20);
|
||||
ctx.stroke();
|
||||
if (mouseDown) {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const hue = Math.floor(Math.random()*60);
|
||||
ctx.fillStyle = 'hsl(' + hue + ', 100%, 50%)';
|
||||
ctx.fillRect(mx + Math.random()*60-30, my + Math.random()*60-30, 3, 3);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function frame() {
|
||||
frameCount++;
|
||||
requestAnimationFrame(frame);
|
||||
}
|
||||
requestAnimationFrame(frame);
|
||||
|
||||
let wsLatencies = [];
|
||||
const ws = new WebSocket('ws://' + location.host);
|
||||
ws.onmessage = function(e) {
|
||||
const now = performance.now();
|
||||
if (ws._lastReceive) {
|
||||
wsLatencies.push(now - ws._lastReceive);
|
||||
}
|
||||
ws._lastReceive = now;
|
||||
};
|
||||
|
||||
setInterval(function() {
|
||||
if (wsLatencies.length < 2) return;
|
||||
const recent = wsLatencies.slice(-300);
|
||||
const sorted = recent.slice().sort(function(a,b) { return a - b; });
|
||||
const avg = recent.reduce(function(a,b) { return a+b; }, 0) / recent.length;
|
||||
const p95 = sorted[Math.floor(sorted.length * 0.95)];
|
||||
const p99 = sorted[Math.floor(sorted.length * 0.99)];
|
||||
const max = sorted[sorted.length - 1];
|
||||
const over50 = recent.filter(function(x) { return x > 50; }).length;
|
||||
hud.textContent =
|
||||
'Mouse: ' + (mouseDown ? 'SHOOTING' : 'idle') + ' (' + moveCount + ' moves)\n' +
|
||||
'Frames: ' + frameCount + '\n' +
|
||||
'WS: avg=' + avg.toFixed(1) + ' p95=' + p95.toFixed(1) + ' p99=' + p99.toFixed(1) + ' max=' + max.toFixed(1) + '\n' +
|
||||
'WS >50ms: ' + over50 + '/' + recent.length + ' (' + (over50/recent.length*100).toFixed(1) + '%)';
|
||||
}, 250);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
**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\<you>\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.
|
||||
@@ -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.
|
||||
@@ -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<TaskPriority> 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() {
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>WS Starvation Stress Test</title></head>
|
||||
<body style="margin:0; overflow:hidden; background:#111;">
|
||||
<canvas id="c" style="width:100vw;height:100vh;display:block;"></canvas>
|
||||
<div id="hud" style="position:fixed;top:10px;left:10px;color:#0f0;font:14px monospace;background:rgba(0,0,0,0.8);padding:10px;z-index:10;pointer-events:none;"></div>
|
||||
<script>
|
||||
const canvas = document.getElementById('c');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const hud = document.getElementById('hud');
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = window.innerHeight;
|
||||
|
||||
// Draw crosshair at mouse position (forces compositor work on every mousemove)
|
||||
let mx = canvas.width/2, my = canvas.height/2;
|
||||
let mouseDown = false;
|
||||
let moveCount = 0;
|
||||
let frameCount = 0;
|
||||
|
||||
document.addEventListener('mousedown', (e) => {
|
||||
mouseDown = true;
|
||||
mx = e.clientX; my = e.clientY;
|
||||
// Simulate shooting — draw muzzle flash (forces paint)
|
||||
ctx.fillStyle = '#ff0';
|
||||
ctx.beginPath();
|
||||
ctx.arc(mx, my, 30, 0, Math.PI*2);
|
||||
ctx.fill();
|
||||
});
|
||||
document.addEventListener('mouseup', () => { mouseDown = false; });
|
||||
document.addEventListener('mousemove', (e) => {
|
||||
mx = e.clientX; my = e.clientY;
|
||||
moveCount++;
|
||||
// Force layout/paint on every single mousemove
|
||||
// This is what triggers the compositor priority boost
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
// Draw crosshair
|
||||
ctx.strokeStyle = mouseDown ? '#f00' : '#0f0';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(mx - 20, my); ctx.lineTo(mx + 20, my);
|
||||
ctx.moveTo(mx, my - 20); ctx.lineTo(mx, my + 20);
|
||||
ctx.stroke();
|
||||
// Draw particles when "shooting"
|
||||
if (mouseDown) {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
ctx.fillStyle = `hsl(${Math.random()*60}, 100%, 50%)`;
|
||||
ctx.fillRect(mx + Math.random()*60-30, my + Math.random()*60-30, 3, 3);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// requestAnimationFrame loop (keeps compositor busy at unlimited FPS)
|
||||
function frame() {
|
||||
frameCount++;
|
||||
requestAnimationFrame(frame);
|
||||
}
|
||||
requestAnimationFrame(frame);
|
||||
|
||||
// WebSocket latency tracking
|
||||
let wsLatencies = [];
|
||||
const ws = new WebSocket('ws://' + location.host);
|
||||
ws.onmessage = (e) => {
|
||||
const now = performance.now();
|
||||
if (ws._lastReceive) {
|
||||
wsLatencies.push(now - ws._lastReceive);
|
||||
}
|
||||
ws._lastReceive = now;
|
||||
};
|
||||
|
||||
// Periodic HUD update
|
||||
setInterval(() => {
|
||||
if (wsLatencies.length < 2) return;
|
||||
const recent = wsLatencies.slice(-300); // last 5 seconds at 60Hz
|
||||
const sorted = [...recent].sort((a,b) => a - b);
|
||||
const avg = recent.reduce((a,b) => a+b, 0) / recent.length;
|
||||
const p95 = sorted[Math.floor(sorted.length * 0.95)];
|
||||
const p99 = sorted[Math.floor(sorted.length * 0.99)];
|
||||
const max = sorted[sorted.length - 1];
|
||||
const over50 = recent.filter(x => x > 50).length;
|
||||
hud.innerHTML =
|
||||
`Mouse: ${mouseDown ? 'SHOOTING' : 'idle'} (${moveCount} moves)<br>` +
|
||||
`Frames: ${frameCount}<br>` +
|
||||
`WS: avg=${avg.toFixed(1)} p95=${p95.toFixed(1)} p99=${p99.toFixed(1)} max=${max.toFixed(1)}<br>` +
|
||||
`WS >50ms: ${over50}/${recent.length} (${(over50/recent.length*100).toFixed(1)}%)`;
|
||||
}, 250);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user