Skip to content

Commit 4e716cc

Browse files
web3dev1337claude
andcommitted
feat: fix HeadlessClient connection + A/B benchmark PR #2 blob shadows
- Fix HeadlessClient: ignoreHTTPSErrors, CDP cert bypass, SwiftShader GL - Patch fetch() to strip unsupported targetAddressSpace (Chrome PNA API) - Add warmCert step for self-signed HTTPS certs - Fix BaselineComparer: null-safe operations, nested JSON format support - Add console log forwarding from headless browser for debugging - A/B results: blob shadows add +1 draw call, +6.7% frame time, +0.9% triangles Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent aac6de2 commit 4e716cc

6 files changed

Lines changed: 309 additions & 6 deletions

File tree

ai-memory/feature/perf-external-notes-verification-20260305-2249094/progress.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,13 @@
2323
- [x] ConsoleReporter — rich client section (FPS, draw calls, triangles, entities, chunks, heap)
2424
- [x] Client thresholds — fps_min, fps_avg, draw_calls_max, triangles_max, frame_time_ms_max
2525
- [x] CLI — --with-client, --client-dev-url flags
26+
- [x] HeadlessClient fix — waitUntil 'load' instead of 'networkidle2' (game keeps persistent connections)
27+
- [x] BenchmarkRunner — graceful HeadlessClient error handling (try-catch around launch)
28+
- [x] Verified: client metrics pipeline — FPS avg=22.6, draw calls avg=11, triangles avg=22, JS heap avg=22.9MB (idle scene, no entities/chunks in headless)
29+
- [x] Fixed: HeadlessClient ignoreHTTPSErrors + CDP Security.setIgnoreCertificateErrors
30+
- [x] Fixed: Patched fetch() to strip unsupported targetAddressSpace (Chrome PNA API)
31+
- [x] Fixed: warmCert step to pre-accept self-signed HTTPS cert
32+
- [x] Fixed: Replaced --disable-gpu with --use-gl=swiftshader for WebGL rendering
33+
- [x] Fixed: BaselineComparer operations null-safety + loadBaseline nested format support
34+
- [x] A/B Benchmark PR #2 (blob shadows) — COMPLETED with real client metrics
35+
- [x] All pushed to origin
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
{
2+
"timestamp": "2026-03-06T04:26:31.503Z",
3+
"scenario": "stress-test",
4+
"durationMs": 72899,
5+
"baseline": {
6+
"avgTickMs": 0.8349998947705856,
7+
"maxTickMs": 12.012511000000814,
8+
"p95TickMs": 2.072938316667266,
9+
"p99TickMs": 4.238928266666274,
10+
"ticksOverBudgetPct": 0,
11+
"avgMemoryMb": 50.96484451293945,
12+
"avgFps": 27.766666666666666,
13+
"client": {
14+
"avgFps": 27.766666666666666,
15+
"minFps": 3,
16+
"avgFrameTimeMs": 78.53666666746139,
17+
"avgDrawCalls": 20.55,
18+
"maxDrawCalls": 34,
19+
"avgTriangles": 15433.466666666667,
20+
"maxTriangles": 15947,
21+
"avgGeometries": 18.983333333333334,
22+
"avgEntities": 160,
23+
"avgVisibleChunks": 6,
24+
"avgUsedMemoryMb": 43.06479082107544
25+
},
26+
"operations": {
27+
"entities_tick": {
28+
"avgMs": 0.23867919862251075,
29+
"p95Ms": 0.5874405166665383
30+
},
31+
"physics_step": {
32+
"avgMs": 0.17312055879655402,
33+
"p95Ms": 0.2684500000008484
34+
},
35+
"physics_cleanup": {
36+
"avgMs": 0.007628609808494264,
37+
"p95Ms": 0.007743966666870013
38+
},
39+
"simulation_step": {
40+
"avgMs": 0.18813084050636156,
41+
"p95Ms": 0.2958727833336828
42+
},
43+
"entities_emit_updates": {
44+
"avgMs": 0.12927259610653663,
45+
"p95Ms": 0.20604898333376695
46+
},
47+
"world_tick": {
48+
"avgMs": 0.8291072575136396,
49+
"p95Ms": 2.0896416499992787
50+
},
51+
"ticker_tick": {
52+
"avgMs": 0.8990740486842234,
53+
"p95Ms": 2.161601066666359
54+
},
55+
"serialize_packets": {
56+
"avgMs": 0.05005582133096542,
57+
"p95Ms": 0.06791426666650295
58+
},
59+
"send_packets": {
60+
"avgMs": 0.2031820219195983,
61+
"p95Ms": 0.3274495666664734
62+
},
63+
"send_all_packets": {
64+
"avgMs": 0.4511723627023677,
65+
"p95Ms": 0.9337175666661399
66+
},
67+
"network_synchronize_cleanup": {
68+
"avgMs": 0.01083250275368758,
69+
"p95Ms": 0.010753250000016123
70+
},
71+
"network_synchronize": {
72+
"avgMs": 0.5143490774603025,
73+
"p95Ms": 1.066514683332995
74+
}
75+
},
76+
"network": {
77+
"totalBytesSent": 5032278,
78+
"totalBytesReceived": 381,
79+
"maxConnectedPlayers": 3,
80+
"avgBytesSentPerSecond": 74639.01842524527,
81+
"maxBytesSentPerSecond": 112504.69959902228,
82+
"avgBytesReceivedPerSecond": 5.6677308601674525,
83+
"maxBytesReceivedPerSecond": 12.614810201718903,
84+
"avgPacketsSentPerSecond": 82.37081361141468,
85+
"maxPacketsSentPerSecond": 99.33494328345606,
86+
"avgPacketsReceivedPerSecond": 1.7251234329295149,
87+
"maxPacketsReceivedPerSecond": 3.881480062067355,
88+
"avgSerializationMs": 0.04555379460987558,
89+
"compressionCountTotal": 2
90+
}
91+
},
92+
"phases": [
93+
{
94+
"name": "spawn-entities",
95+
"durationMs": 283,
96+
"collected": false
97+
},
98+
{
99+
"name": "stabilize",
100+
"durationMs": 2079,
101+
"collected": false
102+
},
103+
{
104+
"name": "measure",
105+
"durationMs": 62049,
106+
"collected": true
107+
}
108+
],
109+
"metrics": {
110+
"tickReportCount": 0,
111+
"spikeCount": 0,
112+
"serverSnapshotCount": 60,
113+
"clientSnapshotCount": 60
114+
}
115+
}
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
{
2+
"timestamp": "2026-03-06T04:27:59.625Z",
3+
"scenario": "stress-test",
4+
"durationMs": 73358,
5+
"baseline": {
6+
"avgTickMs": 0.7359029286488398,
7+
"maxTickMs": 13.290815000000293,
8+
"p95TickMs": 1.829545699999653,
9+
"p99TickMs": 3.758559049999955,
10+
"ticksOverBudgetPct": 0,
11+
"avgMemoryMb": 65.29272626241048,
12+
"avgFps": 26.133333333333333,
13+
"client": {
14+
"avgFps": 26.133333333333333,
15+
"minFps": 3,
16+
"avgFrameTimeMs": 83.7683333337307,
17+
"avgDrawCalls": 21.933333333333334,
18+
"maxDrawCalls": 34,
19+
"avgTriangles": 15564.7,
20+
"maxTriangles": 16717,
21+
"avgGeometries": 18.983333333333334,
22+
"avgEntities": 160,
23+
"avgVisibleChunks": 6,
24+
"avgUsedMemoryMb": 42.87554248174032
25+
},
26+
"operations": {
27+
"entities_tick": {
28+
"avgMs": 0.23253740997715816,
29+
"p95Ms": 0.6180427999991783
30+
},
31+
"physics_step": {
32+
"avgMs": 0.17034304841306572,
33+
"p95Ms": 0.28114455000083277
34+
},
35+
"physics_cleanup": {
36+
"avgMs": 0.005363463158733647,
37+
"p95Ms": 0.007633716666593197
38+
},
39+
"simulation_step": {
40+
"avgMs": 0.18412275955659105,
41+
"p95Ms": 0.31188256666694847
42+
},
43+
"entities_emit_updates": {
44+
"avgMs": 0.13922205929656295,
45+
"p95Ms": 0.22354569999891585
46+
},
47+
"world_tick": {
48+
"avgMs": 0.7279635695853309,
49+
"p95Ms": 1.7226242000005791
50+
},
51+
"ticker_tick": {
52+
"avgMs": 0.7914679062679743,
53+
"p95Ms": 1.8152888833331418
54+
},
55+
"serialize_packets": {
56+
"avgMs": 0.04350473671018177,
57+
"p95Ms": 0.06910131666694118
58+
},
59+
"send_packets": {
60+
"avgMs": 0.2378755490273082,
61+
"p95Ms": 0.3898857000008017
62+
},
63+
"send_all_packets": {
64+
"avgMs": 0.25720112163234377,
65+
"p95Ms": 0.42160113333417637
66+
},
67+
"network_synchronize_cleanup": {
68+
"avgMs": 0.007394239075581019,
69+
"p95Ms": 0.011449733333120094
70+
},
71+
"network_synchronize": {
72+
"avgMs": 0.3093806637599563,
73+
"p95Ms": 0.49408704999987096
74+
}
75+
},
76+
"network": {
77+
"totalBytesSent": 1968736,
78+
"totalBytesReceived": 158,
79+
"maxConnectedPlayers": 1,
80+
"avgBytesSentPerSecond": 28946.035070796195,
81+
"maxBytesSentPerSecond": 39833.57688983931,
82+
"avgBytesReceivedPerSecond": 2.3142748083937827,
83+
"maxBytesReceivedPerSecond": 6.7732385632630665,
84+
"avgPacketsSentPerSecond": 31.198995516308436,
85+
"maxPacketsSentPerSecond": 33.44922483163599,
86+
"avgPacketsReceivedPerSecond": 0.7040805818582495,
87+
"maxPacketsReceivedPerSecond": 1.9352110180751618,
88+
"avgSerializationMs": 0.039125146425009125,
89+
"compressionCountTotal": 0
90+
}
91+
},
92+
"phases": [
93+
{
94+
"name": "spawn-entities",
95+
"durationMs": 268,
96+
"collected": false
97+
},
98+
{
99+
"name": "stabilize",
100+
"durationMs": 2045,
101+
"collected": false
102+
},
103+
{
104+
"name": "measure",
105+
"durationMs": 62098,
106+
"collected": true
107+
}
108+
],
109+
"metrics": {
110+
"tickReportCount": 0,
111+
"spikeCount": 0,
112+
"serverSnapshotCount": 60,
113+
"clientSnapshotCount": 60
114+
}
115+
}

packages/perf-tools/src/runners/BaselineComparer.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -97,10 +97,10 @@ export default class BaselineComparer {
9797
entries.push(this._compareMetric('net.avgSerializationMs', baseline.network.avgSerializationMs, current.network.avgSerializationMs));
9898
}
9999

100-
const allBaselineOps = new Set([...Object.keys(baseline.operations), ...Object.keys(current.operations)]);
100+
const allBaselineOps = new Set([...Object.keys(baseline.operations ?? {}), ...Object.keys(current.operations ?? {})]);
101101

102102
for (const op of allBaselineOps) {
103-
if (baseline.operations[op] && current.operations[op]) {
103+
if (baseline.operations?.[op] && current.operations?.[op]) {
104104
entries.push(this._compareMetric(`ops.${op}.avgMs`, baseline.operations[op].avgMs, current.operations[op].avgMs));
105105
entries.push(this._compareMetric(`ops.${op}.p95Ms`, baseline.operations[op].p95Ms, current.operations[op].p95Ms));
106106
}
@@ -123,8 +123,14 @@ export default class BaselineComparer {
123123

124124
public static loadBaseline(filePath: string): BaselineResult {
125125
const content = fs.readFileSync(filePath, 'utf-8');
126+
const data = JSON.parse(content);
126127

127-
return JSON.parse(content) as BaselineResult;
128+
// Support both raw baseline files and full report files (which have a .baseline field)
129+
if (data.baseline && typeof data.baseline === 'object' && 'avgTickMs' in data.baseline) {
130+
return data.baseline as BaselineResult;
131+
}
132+
133+
return data as BaselineResult;
128134
}
129135

130136
public static saveBaseline(filePath: string, baseline: BaselineResult): void {

packages/perf-tools/src/runners/BenchmarkRunner.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,10 @@ export default class BenchmarkRunner {
9999

100100
await this._headlessClient.launch();
101101

102+
// Warm up the self-signed HTTPS cert by visiting the server URL first
103+
this._log('[bench] Warming up server HTTPS cert in headless browser...');
104+
await this._headlessClient.warmCert(this._options.clientUrl);
105+
102106
// Navigate with ?join=<server host> and ?perf=1 (auto-appended by HeadlessClient)
103107
const serverUrl = new URL(this._options.clientUrl);
104108
const clientNavUrl = new URL(this._options.clientDevUrl);

packages/perf-tools/src/runners/HeadlessClient.ts

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,14 +40,18 @@ export default class HeadlessClient {
4040

4141
this._browser = await puppeteer.default.launch({
4242
headless: this._options.headless ? 'new' : false,
43+
ignoreHTTPSErrors: true,
4344
args: [
4445
'--no-sandbox',
4546
'--disable-setuid-sandbox',
46-
'--disable-gpu',
4747
'--ignore-certificate-errors',
48+
'--allow-insecure-localhost',
49+
'--disable-web-security',
50+
'--disable-features=PrivateNetworkAccessSendPreflights',
4851
'--enable-precise-memory-info',
4952
'--disable-notifications',
5053
'--autoplay-policy=no-user-gesture-required',
54+
'--use-gl=swiftshader',
5155
`--window-size=${this._options.width},${this._options.height}`,
5256
],
5357
});
@@ -64,11 +68,60 @@ export default class HeadlessClient {
6468
deviceScaleFactor: this._options.deviceScaleFactor,
6569
});
6670

67-
if (this._options.collectPerformance) {
68-
const cdp = await page.createCDPSession();
71+
// Forward browser console to Node stdout for debugging
72+
page.on('console', (msg: any) => {
73+
const type = msg.type();
74+
const text = msg.text();
75+
76+
if (type === 'error' || type === 'warning') {
77+
console.log(`[client:${type}] ${text}`);
78+
}
79+
});
80+
81+
page.on('pageerror', (err: any) => {
82+
console.log(`[client:error] ${err.message ?? err}`);
83+
});
84+
85+
const cdp = await page.createCDPSession();
6986

87+
// Bypass certificate errors via CDP (--ignore-certificate-errors doesn't work in headless: 'new')
88+
await cdp.send('Security.setIgnoreCertificateErrors', { ignore: true });
89+
90+
if (this._options.collectPerformance) {
7091
await cdp.send('Performance.enable');
7192
}
93+
94+
// Patch fetch() to strip unsupported targetAddressSpace option
95+
// (Chrome's Private Network Access API is not available in all Chrome versions)
96+
await page.evaluateOnNewDocument(() => {
97+
const originalFetch = window.fetch.bind(window);
98+
99+
(window as any).fetch = function(input: any, init?: any) {
100+
if (init && 'targetAddressSpace' in init) {
101+
const { targetAddressSpace: _, ...rest } = init;
102+
103+
return originalFetch(input, rest);
104+
}
105+
106+
return originalFetch(input, init);
107+
};
108+
});
109+
}
110+
111+
/**
112+
* Visit the game server URL once to warm up the self-signed HTTPS cert
113+
* in Chrome's cert cache. Without this, in-page fetch() to the server fails.
114+
*/
115+
public async warmCert(serverUrl: string): Promise<void> {
116+
const page = this._page as any;
117+
118+
if (!page) return;
119+
120+
try {
121+
await page.goto(serverUrl, { waitUntil: 'load', timeout: 15000 });
122+
} catch {
123+
// Expected — self-signed cert page may fail but Chrome records the exception
124+
}
72125
}
73126

74127
public async navigate(url?: string): Promise<void> {

0 commit comments

Comments
 (0)