From 9b2b02bb3bcbaec48e3d117a7a8594cf367630f4 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sat, 12 Sep 2026 18:13:57 -0700 Subject: [PATCH] perf(mobile): reuse UTF-8 prefix truncation for diagnostics (#20358) Co-authored-by: m4air --- .../mobile-diagnostics-prefix-benchmark.mjs | 236 ++++++++++++++++++ .../connection-diagnostics-report.test.ts | 62 ++++- .../connection-diagnostics-report.ts | 13 +- .../connection-diagnostics-submission.test.ts | 30 +++ .../connection-diagnostics-submission.ts | 26 +- 5 files changed, 336 insertions(+), 31 deletions(-) create mode 100644 config/scripts/mobile-diagnostics-prefix-benchmark.mjs diff --git a/config/scripts/mobile-diagnostics-prefix-benchmark.mjs b/config/scripts/mobile-diagnostics-prefix-benchmark.mjs new file mode 100644 index 00000000000..c7beec3eba1 --- /dev/null +++ b/config/scripts/mobile-diagnostics-prefix-benchmark.mjs @@ -0,0 +1,236 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import path from 'node:path' +import { performance } from 'node:perf_hooks' +import { build } from 'esbuild' + +// Pipe baseline report then submission modules on stdin, in that order. No device/network I/O. +const baseline = readFileSync(0, 'utf8') +const divider = '\nconst CONNECTION_DIAGNOSTICS_ENDPOINT = ' +assert.equal(baseline.split(divider).length, 2) +const split = baseline.indexOf(divider) + 1 +const files = ['report', 'submission'].map((name) => + path.resolve(`mobile/src/diagnostics/connection-diagnostics-${name}.ts`) +) +const sources = [ + [baseline.slice(0, split), baseline.slice(split)], + files.map((file) => readFileSync(file, 'utf8')) +] +const modules = await Promise.all( + sources.map(async (contents) => { + const result = await build({ + stdin: { + contents: files.map((file) => `export * from ${JSON.stringify(file)};`).join('\n'), + resolveDir: process.cwd() + }, + bundle: true, + platform: 'node', + format: 'esm', + write: false, + plugins: [ + { + name: 'actual-mobile-diagnostics', + setup(builder) { + builder.onLoad( + { filter: /connection-diagnostics-(report|submission)\.ts$/ }, + (args) => ({ + contents: contents[files.indexOf(args.path)], + loader: 'ts', + resolveDir: path.dirname(args.path) + }) + ) + builder.onResolve({ filter: /^@react-native-async-storage\/async-storage$/ }, () => ({ + path: 'forbidden-device-storage', + namespace: 'fixture' + })) + builder.onLoad({ filter: /.*/, namespace: 'fixture' }, () => ({ + contents: `function forbidden() { throw new Error('Device storage is forbidden'); } + export default { getItem: forbidden, setItem: forbidden };` + })) + } + } + ] + }) + const code = `${result.outputFiles[0].text}\n//# sourceURL=mobile-diagnostics-prefix-bundle.js` + return import(`data:text/javascript;base64,${Buffer.from(code).toString('base64')}`) + }) +) + +const base = { + hostName: 'fixture', + endpoint: 'ws://192.168.1.2:6768', + state: 'reconnecting', + reconnectAttempts: 2, + lastConnectedAt: null, + platform: 'android', + appVersion: 'fixture', + nowMs: 1700000000000 +} +let seed = 0x20d1a6 +function random(max) { + seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0 + return (seed >>> 8) % max +} +const tokens = ['a', 'é', '界', '😀', '\ud800', '\udc00', '\n', '\r\n', '\0', 'e\u0301'] +const limits = [ + Number.NEGATIVE_INFINITY, + -1, + 0, + 1, + 2, + 3, + 4, + 15, + 16, + 17, + 100, + 511, + 2048, + 65536, + Number.POSITIVE_INFINITY, + Number.NaN, + 2.5 +] +for (let trace = 0; trace < 3000; trace++) { + const lines = Array.from({ length: 1 + random(12) }, () => + Array.from({ length: 1 + random(5) }, () => + tokens[random(tokens.length)].repeat(random(100)) + ).join('') + ) + if (trace % 2) { + lines.splice(random(lines.length), 0, 'Recent connection history (fixture):') + } + const report = lines.join('\n') + const limit = limits[random(limits.length)] + assert.equal( + modules[1].boundConnectionDiagnosticsReport(report, limit), + modules[0].boundConnectionDiagnosticsReport(report, limit) + ) +} +console.log('3,000 report-bound differentials match, including nonfinite/fractional limits') + +for (let trace = 0; trace < 600; trace++) { + const entries = Object.freeze( + Array.from({ length: random(12) }, (_, index) => + Object.freeze({ + id: String(index), + ts: base.nowMs + index, + level: ['info', 'error', 'warn'][random(3)], + message: ['Authenticated', 'relay director resolve failed (503)', 'fixture'][random(3)], + detail: `${tokens[random(tokens.length)].repeat(random(4000))} token=fixture-secret`, + code: ['client-session-started', 'liveness-timeout', undefined][random(3)], + path: ['relay', 'lan', 'tailscale'][random(3)] + }) + ) + ) + const args = Object.freeze({ + ...base, + hostName: 'fixture token=host-fixture-secret', + endpoint: trace % 2 ? base.endpoint : 'invalid?token=endpoint-fixture-secret', + desktopAppVersion: trace % 2 ? '1.2.3' : '\ninvalid', + state: ['connected', 'reconnecting', 'connecting'][random(3)], + activePath: ['relay', 'lan', 'tailscale'][random(3)], + pendingPath: trace % 3 ? null : 'relay', + entries + }) + const reports = modules.map((module) => module.buildConnectionDiagnosticsReport(args)) + assert.equal(reports[1], reports[0]) + assert(!reports[1].includes('fixture-secret')) + const limit = limits[random(limits.length)] + assert.equal( + modules[1].boundConnectionDiagnosticsReport(reports[1], limit), + modules[0].boundConnectionDiagnosticsReport(reports[0], limit) + ) +} +console.log('600 frozen report-build + bound journeys preserve redaction, diagnosis and exact text') + +function runSample(run, repeats) { + let value + const start = performance.now() + for (let index = 0; index < repeats; index++) { + value = run() + } + return { value, elapsed: (performance.now() - start) / repeats } +} +function benchmark(name, arms) { + const expected = arms[0]() + assert.equal(arms[1](), expected) + for (const arm of arms) { + const until = performance.now() + 200 + do { + assert.equal(arm(), expected) + } while (performance.now() < until) + } + const repeats = Math.max(3, Math.min(10000, Math.ceil(40 / runSample(arms[0], 1).elapsed))) + /** @type {number[][]} */ + const times = [[], []] + for (let pair = 0; pair < 8; pair++) { + for (const index of pair % 2 ? [1, 0] : [0, 1]) { + const result = runSample(arms[index], repeats) + assert.equal(result.value, expected) + times[index].push(result.elapsed) + } + } + const median = times.map((values) => { + const sorted = values.toSorted((a, b) => a - b) + return (sorted[3] + sorted[4]) / 2 + }) + console.log(JSON.stringify({ name, repeats, median, times })) +} +console.log( + JSON.stringify({ + node: process.version, + platform: process.platform, + arch: process.arch, + unit: 'ms' + }) +) +for (const [events, length, token] of [ + [0, 0, 'a'], + [20, 80, 'a'], + [200, 80, 'a'], + [200, 1000, 'a'], + [200, 4000, 'a'], + [200, 2000, '😀'] +]) { + const args = { + ...base, + entries: Array.from({ length: events }, (_, i) => ({ + id: String(i), + ts: base.nowMs + i, + level: 'error', + message: `fixture-${i} ${token.repeat(length)}` + })) + } + const reports = modules.map((module) => module.buildConnectionDiagnosticsReport(args)) + assert.equal(reports[1], reports[0]) + const label = `${events} events / ${length} ${token}` + benchmark( + `${label}: build`, + modules.map((module) => () => module.buildConnectionDiagnosticsReport(args)) + ) + benchmark( + `${label}: bound`, + modules.map((module) => () => module.boundConnectionDiagnosticsReport(reports[0])) + ) +} + +for (const token of ['a', '😀', '\ud800']) { + const report = token.repeat(100000) + const results = await Promise.all( + modules.map(async (module) => { + let request + const result = await module.submitConnectionDiagnostics( + { report, platform: 'android', appVersion: 'fixture' }, + async (url, options) => { + assert.equal(options.signal.aborted, false) + request = { url, method: options.method, headers: options.headers, body: options.body } + return { ok: true } + } + ) + return { result, request } + }) + ) + assert.deepEqual(results[1], results[0]) +} +console.log('Three fake-fetch submission journeys preserve complete request bytes and results') diff --git a/mobile/src/diagnostics/connection-diagnostics-report.test.ts b/mobile/src/diagnostics/connection-diagnostics-report.test.ts index 35a1425165b..991ec01f549 100644 --- a/mobile/src/diagnostics/connection-diagnostics-report.test.ts +++ b/mobile/src/diagnostics/connection-diagnostics-report.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { buildConnectionDiagnosticsReport } from './connection-diagnostics-report' const NOW = Date.UTC(2026, 6, 9, 22, 0, 0) @@ -142,4 +142,64 @@ describe('buildConnectionDiagnosticsReport', () => { 2048 ) }) + + it.each([2047, 2048, 2049])('keeps the existing event boundary at %i bytes', (bytes) => { + const prefix = `${new Date(NOW).toISOString()} [error] ` + const marker = ' … [truncated]' + const message = 'a'.repeat(bytes - prefix.length) + const report = buildConnectionDiagnosticsReport({ + hostName: 'fixture', + endpoint: 'ws://192.168.1.2:6768', + state: 'connected', + reconnectAttempts: 0, + lastConnectedAt: null, + platform: 'ios', + appVersion: 'fixture', + entries: [{ id: 'boundary', ts: NOW, level: 'error', message }], + nowMs: NOW + }) + const available = 2048 - prefix.length - new TextEncoder().encode(marker).byteLength + expect(report.split('\n').at(-1)).toBe( + bytes <= 2048 ? `${prefix}${message}` : `${prefix}${'a'.repeat(available)}${marker}` + ) + }) + + it.each(['a', 'é', '界', '😀', '\ud800', '\udc00'])( + 'truncates %j without per-character encoding', + (token) => { + const encode = vi.spyOn(TextEncoder.prototype, 'encode') + const entry = Object.freeze({ + id: 'bounded', + ts: NOW, + level: 'error' as const, + message: token.repeat(3000) + }) + let report: string + let calls: number + try { + report = buildConnectionDiagnosticsReport({ + hostName: 'fixture', + endpoint: 'ws://192.168.1.2:6768', + state: 'reconnecting', + reconnectAttempts: 1, + lastConnectedAt: null, + platform: 'android', + appVersion: 'fixture', + entries: Object.freeze([entry]), + nowMs: NOW + }) + calls = encode.mock.calls.length + } finally { + encode.mockRestore() + } + const prefix = `${new Date(NOW).toISOString()} [error] ` + const marker = ' … [truncated]' + const available = 2048 - new TextEncoder().encode(prefix + marker).byteLength + const tokenBytes = new TextEncoder().encode(token).byteLength + expect(report.split('\n').at(-1)).toBe( + `${prefix}${token.repeat(Math.floor(available / tokenBytes))}${marker}` + ) + expect(calls).toBeLessThanOrEqual(2) + } + ) }) diff --git a/mobile/src/diagnostics/connection-diagnostics-report.ts b/mobile/src/diagnostics/connection-diagnostics-report.ts index 0507b44349a..c49eb0b01f7 100644 --- a/mobile/src/diagnostics/connection-diagnostics-report.ts +++ b/mobile/src/diagnostics/connection-diagnostics-report.ts @@ -1,4 +1,5 @@ import { isTailscaleEndpoint } from '../../../src/shared/remote-runtime-tailscale-hint' +import { clampUtf8TextPrefix } from '../../../src/shared/utf8-byte-limits' import type { ConnectionLogEntry, ConnectionState, @@ -87,17 +88,7 @@ function truncateUtf8WithMarker(value: string, maxBytes: number, marker: string) return value } const markerBytes = new TextEncoder().encode(marker).byteLength - const characters: string[] = [] - let bytes = 0 - for (const character of value) { - const characterBytes = new TextEncoder().encode(character).byteLength - if (bytes + characterBytes + markerBytes > maxBytes) { - break - } - characters.push(character) - bytes += characterBytes - } - return `${characters.join('')}${marker}` + return `${clampUtf8TextPrefix(value, maxBytes - markerBytes)}${marker}` } function formatAgo(ms: number): string { diff --git a/mobile/src/diagnostics/connection-diagnostics-submission.test.ts b/mobile/src/diagnostics/connection-diagnostics-submission.test.ts index f2977b6055c..6f244fc9de2 100644 --- a/mobile/src/diagnostics/connection-diagnostics-submission.test.ts +++ b/mobile/src/diagnostics/connection-diagnostics-submission.test.ts @@ -5,6 +5,36 @@ import { } from './connection-diagnostics-submission' describe('submitConnectionDiagnostics', () => { + it('bounds a long report without encoding each retained character', () => { + const encode = vi.spyOn(TextEncoder.prototype, 'encode') + let output: string + let calls: number + try { + output = boundConnectionDiagnosticsReport('x'.repeat(100_000)) + calls = encode.mock.calls.length + } finally { + encode.mockRestore() + } + expect(output).toBe('x'.repeat(64 * 1024)) + expect(calls).toBeLessThanOrEqual(1) + }) + + it.each([ + ['a'.repeat(1000), 511, 'a'.repeat(511)], + ['é'.repeat(1000), 511, 'é'.repeat(255)], + ['界'.repeat(1000), 511, '界'.repeat(170)], + ['😀'.repeat(1000), 511, '😀'.repeat(127)], + ['\ud800'.repeat(1000), 511, '\ud800'.repeat(170)], + ['a\udc00b'.repeat(1000), 8, 'a\udc00ba'], + ['a'.repeat(1000), 2.5, 'aa'], + ['a'.repeat(1000), -1, ''], + ['a'.repeat(1000), Number.NaN, 'a'.repeat(1000)], + ['a'.repeat(1000), Number.NEGATIVE_INFINITY, ''], + ['a'.repeat(1000), Number.POSITIVE_INFINITY, 'a'.repeat(1000)] + ])('preserves UTF-8 prefix case %#', (input, limit, expected) => { + expect(boundConnectionDiagnosticsReport(input, limit)).toBe(expected) + }) + it('sends a bounded report through the diagnostics lane', async () => { const fetchImpl = vi.fn().mockResolvedValue(new Response(null, { status: 204 })) const result = await submitConnectionDiagnostics( diff --git a/mobile/src/diagnostics/connection-diagnostics-submission.ts b/mobile/src/diagnostics/connection-diagnostics-submission.ts index 67e220f86c7..4952fd8c881 100644 --- a/mobile/src/diagnostics/connection-diagnostics-submission.ts +++ b/mobile/src/diagnostics/connection-diagnostics-submission.ts @@ -1,3 +1,5 @@ +import { clampUtf8TextPrefix } from '../../../src/shared/utf8-byte-limits' + const CONNECTION_DIAGNOSTICS_ENDPOINT = 'https://www.onorca.dev/v1/feedback' const SUBMISSION_TIMEOUT_MS = 10_000 const MAX_SUBMISSION_BYTES = 64 * 1024 @@ -59,7 +61,7 @@ export function boundConnectionDiagnosticsReport( const lines = report.split('\n') const historyIndex = lines.findIndex((line) => line.startsWith('Recent connection history (')) if (historyIndex === -1) { - return truncateUtf8(report, maxBytes) + return clampUtf8TextPrefix(report, maxBytes) } const header = lines.slice(0, historyIndex) const events = lines.slice(historyIndex + 1) @@ -75,7 +77,7 @@ export function boundConnectionDiagnosticsReport( if (kept.length === 0 && events.length > 0) { return formatReportWithTruncatedNewestEvent(header, events, maxBytes) } - return truncateUtf8(formatBoundedReport(header, kept, events.length), maxBytes) + return clampUtf8TextPrefix(formatBoundedReport(header, kept, events.length), maxBytes) } function formatReportWithTruncatedNewestEvent( @@ -87,14 +89,14 @@ function formatReportWithTruncatedNewestEvent( const prefix = [...header, history].join('\n') + '\n' const availableBytes = maxBytes - utf8Bytes(prefix) if (availableBytes <= 0) { - return truncateUtf8(prefix, maxBytes) + return clampUtf8TextPrefix(prefix, maxBytes) } const marker = ' … [truncated]' const markerBytes = utf8Bytes(marker) if (availableBytes <= markerBytes) { - return truncateUtf8(prefix, maxBytes) + return clampUtf8TextPrefix(prefix, maxBytes) } - return `${prefix}${truncateUtf8(events.at(-1)!, availableBytes - markerBytes)}${marker}` + return `${prefix}${clampUtf8TextPrefix(events.at(-1)!, availableBytes - markerBytes)}${marker}` } function formatBoundedReport(header: string[], events: string[], totalEvents: number): string { @@ -106,20 +108,6 @@ function formatBoundedReport(header: string[], events: string[], totalEvents: nu ].join('\n') } -function truncateUtf8(value: string, maxBytes: number): string { - const characters: string[] = [] - let bytes = 0 - for (const character of value) { - const characterBytes = utf8Bytes(character) - if (bytes + characterBytes > maxBytes) { - break - } - characters.push(character) - bytes += characterBytes - } - return characters.join('') -} - function utf8Bytes(value: string): number { return new TextEncoder().encode(value).byteLength }