Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
187 changes: 183 additions & 4 deletions packages/web-shell/client/index-html.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,7 @@ import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it, vi } from 'vitest';

function installMeasureGuard(
measure: (...args: unknown[]) => unknown,
): Performance {
function extractMeasureScript(): string {
const html = readFileSync(resolve(__dirname, 'index.html'), 'utf8');
const script = Array.from(
html.matchAll(/<script(?:\s[^>]*)?>([\s\S]*?)<\/script>/g),
Expand All @@ -13,8 +11,15 @@ function installMeasureGuard(
.find((source) => source.includes('performance.measure ='));

if (!script) throw new Error('Performance measure guard not found');
return script;
}

const performance = { measure };
function installMeasureGuard(
measure: (...args: unknown[]) => unknown,
clearMeasures?: () => void,
): Performance {
const script = extractMeasureScript();
const performance = { measure, clearMeasures };
Function('performance', 'DOMException', script)(performance, DOMException);
return performance as Performance;
}
Expand Down Expand Up @@ -59,4 +64,178 @@ describe('React performance measure guard', () => {

expect(measure).toHaveBeenCalledWith('custom-measure', options);
});

it('strips detail from any React devtools track, not just Components', () => {
const measure = vi.fn(() => 'measure');
const performance = installMeasureGuard(measure);
const options = {
start: 1,
end: 2,
detail: { devtools: { track: 'Blocking', properties: [['k', 'v']] } },
};

performance.measure('React', options);

expect(
(measure.mock.calls[0]?.[1] as PerformanceMeasureOptions).detail,
).toBeNull();
});

it('clears the measure timeline on a budget so entries cannot accumulate', () => {
const measureThis: unknown[] = [];
const measure = vi.fn(function (this: unknown): string {
measureThis.push(this);
return 'measure';
});
const clearThis: unknown[] = [];
const clearMeasures = vi.fn(function (this: unknown) {
clearThis.push(this);
});
const fakePerformance = installMeasureGuard(measure, clearMeasures);
// The real flood mixes lane/scheduler tracks and measure names, so drive
// a mixed flood: the budget must count every React devtools measure
// regardless of track or name.
const tracks = ['Blocking', 'Transition', 'Suspense', 'Components ⚛'];
const names = ['⏱ lane', '⏱ render', '⏱ commit'];
const reactName = (index: number): string => names[index % names.length];
let reactDriven = 0;
const driveReact = (count: number): void => {
for (let i = 0; i < count; i += 1) {
fakePerformance.measure(reactName(reactDriven), {
start: 1,
end: 2,
detail: { devtools: { track: tracks[reactDriven % tracks.length] } },
});
reactDriven += 1;
}
};

// The clear fires at exactly the budget, not one measure early.
driveReact(16383);
expect(clearMeasures).not.toHaveBeenCalled();
driveReact(1);
expect(clearMeasures).toHaveBeenCalledTimes(1);
// The timeline is cleared with no name filter (React never names its
// measures) and with the performance object as receiver (a detached
// brand-checked clearMeasures throws Illegal invocation).
expect(clearMeasures).toHaveBeenCalledWith();
expect(clearThis).toEqual([fakePerformance]);
// Every React measure is still forwarded with its name preserved and its
// detail stripped — including the one that triggers the clear.
expect(measure).toHaveBeenCalledTimes(16384);
expect(measure.mock.calls[16383]).toEqual([
reactName(16383),
expect.objectContaining({ detail: null }),
]);

// The clear is not latched: a second full window clears again, and
// forwarding + stripping survive past the first clear.
driveReact(16384);
expect(clearMeasures).toHaveBeenCalledTimes(2);
expect(measure).toHaveBeenCalledTimes(32768);
expect(measure.mock.calls[32767]).toEqual([
reactName(32767),
expect.objectContaining({ detail: null }),
]);

// A full window of non-React measures neither counts toward the budget
// nor is dropped or stripped after a clear.
const customOptions = { start: 1, end: 2, detail: { source: 'web-shell' } };
for (let i = 0; i < 16384 - 1; i += 1) {
fakePerformance.measure('custom-measure', customOptions);
}
// The wrapper's return value passes through like the native call's.
const customResult = fakePerformance.measure(
'custom-measure',
customOptions,
);
expect(clearMeasures).toHaveBeenCalledTimes(2);
expect(customResult).toBe('measure');
expect(measure).toHaveBeenCalledTimes(49152);
// Identity: the non-React options object is forwarded as-is.
expect(measure.mock.calls[49151]?.[0]).toBe('custom-measure');
expect(measure.mock.calls[49151]?.[1]).toBe(customOptions);
Comment on lines +155 to +157

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R2-1: The measure-guard test's mutant surface is still not closed — this round's probe verified another survivor under the committed suite, so the round-3 commit's closure claim ("33/33 mutants killed; remaining mutants are genuinely equivalent") has not reached its own stated fixpoint ("iterated until no mutant survives"). The demonstrated entrance: a mutant that strips a non-React measure's detail by mutating the caller's options object in place (e.g. options.detail = null before forwarding — the allocation-free variant of the existing strip, plausible under the HTML comment's own "~16k measures/s" framing) passes the entire suite, because the "not stripped" half of this contract is pinned only by identity comparisons — toBe(customOptions) still passes on the same object once mutated, and the earlier toHaveBeenCalledWith('custom-measure', options) compares the recorded argument against that same mutated reference (self-equality). A future guard edit shaped like that would silently corrupt a caller-owned options object (web-shell code reusing one options object across measures loses its detail) while this comment claims the case is pinned. The React path IS protected against the same mutant shape (the first test reads options.detail.devtools.properties after the call); the non-React path has no equivalent post-call value read. Witness (probe in a scratch tree at this commit): the mutant survives with the suite green (7/7); adding the value-survival assertion below flips it red (expected null to deeply equal { source: 'web-shell' }); the same in-place strip on the React branch fails the existing post-call read (Cannot read properties of null (reading 'devtools')); and the correct guard with the proposed assertion stays green (7/7). Fix: fold this mutant into the mutation pass, re-run it to fixpoint, and keep the killing assertion below, which closes the demonstrated entrance.

Suggested change
// Identity: the non-React options object is forwarded as-is.
expect(measure.mock.calls[49151]?.[0]).toBe('custom-measure');
expect(measure.mock.calls[49151]?.[1]).toBe(customOptions);
// Identity: the non-React options object is forwarded as-is.
expect(measure.mock.calls[49151]?.[0]).toBe('custom-measure');
expect(measure.mock.calls[49151]?.[1]).toBe(customOptions);
// Value survival, not just reference: an in-place strip of the caller's
// options object must fail here.
expect(
(measure.mock.calls[49151]?.[1] as PerformanceMeasureOptions).detail,
).toEqual({ source: 'web-shell' });
中文说明

[Suggestion] measure-guard 测试的变异面仍未收口——本轮探针在已提交的套件下又验证了一个幸存变异体,因此第 3 轮提交的收口声明("33/33 变异体全部被杀死;其余变异体均为等价变异")并未达到其自身设定的收敛点("迭代到没有变异体幸存")。已验证的入口:在转发非 React measure 之前,通过原地修改调用方 options 对象来剥离 detail 的变异体(例如转发前 options.detail = null——现有剥离的零分配变体,在 HTML 注释自身 "~16k measures/s" 的语境下相当合理)能通过整套测试,因为该契约中"未被剥离"这半边仅由引用相等断言钉住——对象被原地修改后 toBe(customOptions) 仍然通过,而前面的 toHaveBeenCalledWith('custom-measure', options) 是 recorded 参数对同一被修改引用的自比较。未来若 guard 按此形态修改,将悄悄破坏调用方自有的 options 对象(复用同一 options 对象的 web-shell 代码会丢失其 detail),而此处注释却声称该情形已被钉住。React 路径对同形变异体有防护(第一个测试在调用后读取 options.detail.devtools.properties);非 React 路径缺少等价的调用后取值断言。见证(在本提交的 scratch tree 中探针验证):变异体幸存、套件保持绿色(7/7);补充下方的取值存活断言后由绿变红(expected null to deeply equal { source: 'web-shell' });同形变异体作用于 React 分支时会被现有的调用后读取杀死(Cannot read properties of null (reading 'devtools'));正确 guard 加上该断言保持绿色(7/7)。修复:将该变异体并入变异测试通道,重新迭代至无幸存者,并保留下方断言——它可杀死已验证的这个入口。

— qwen3.8-max via Qwen Code /review (v0.22.0)

// Value survival, not just reference: an in-place strip of the caller's
// options object must fail here.
expect(
(measure.mock.calls[49151]?.[1] as PerformanceMeasureOptions).detail,
).toEqual({ source: 'web-shell' });

// Mixed app + React traffic still reaches the budget: interleaved
// non-React measures must not reset the counter.
for (let i = 0; i < 16384; i += 1) {
driveReact(1);
fakePerformance.measure('custom-measure', customOptions);
}
expect(clearMeasures).toHaveBeenCalledTimes(3);

// A detached wrapper call still reaches the native measure bound to the
// performance object (both captures keep their .bind(performance)).
const detachedMeasure = fakePerformance.measure as (
...args: unknown[]
) => unknown;
detachedMeasure('detached', {
start: 1,
end: 2,
detail: { devtools: { track: 'Blocking' } },
});
expect(measure).toHaveBeenCalledTimes(81921);
expect(measureThis.every((receiver) => receiver === fakePerformance)).toBe(
true,
);
});

it('keeps measuring when clearMeasures is unavailable', () => {
const measure = vi.fn(() => 'measure');
const performance = installMeasureGuard(measure);
const options = {
start: 1,
end: 2,
detail: { devtools: { track: 'Blocking' } },
};

expect(() => {
for (let i = 0; i < 16384 + 1; i += 1) {
performance.measure('⏱ lane', options);
}
}).not.toThrow();
expect(measure).toHaveBeenCalledTimes(16384 + 1);
});

it('forwards standard measure shapes untouched', () => {
const measure = vi.fn(() => 'measure');
const performance = installMeasureGuard(measure);
const bareMeasure = performance.measure as (
name: string,
options?: unknown,
) => unknown;
const options = { start: 1, end: 2 };

expect(() => {
bareMeasure('plain');
bareMeasure('null-options', null);
bareMeasure('string-mark', 'start-mark');
bareMeasure('detail-less', options);
}).not.toThrow();

expect(bareMeasure('return-check', options)).toBe('measure');
expect(measure.mock.calls[0]).toEqual(['plain']);
expect(measure.mock.calls[1]).toEqual(['null-options', null]);
expect(measure.mock.calls[2]).toEqual(['string-mark', 'start-mark']);
expect(measure.mock.calls[3]).toEqual(['detail-less', options]);
});

it('does not touch environments without performance.measure', () => {
const script = extractMeasureScript();
const install = (performance: unknown): void => {
Function(
'performance',
'DOMException',
script,
)(performance, DOMException);
};

expect(() => install(undefined)).not.toThrow();
expect(() => install({})).not.toThrow();
});
});
19 changes: 17 additions & 2 deletions packages/web-shell/client/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -51,20 +51,35 @@
Prevent React 19 dev-mode OOM crashes. React records component prop
details through performance.measure(), which structured-clones and retains
those details. Strip the detail before large transcripts reach the native
call while preserving the component timing and non-React measures.
call while preserving the component timing and non-React measures. The
stripped entries themselves still accumulate in the browser's unbounded
user-timing buffer — an idle dev page emits ~16k measures/s — so the
timeline is also cleared on a budget, or long-lived dev tabs exhaust the
renderer's PartitionAlloc address space and die with SIGABRT.
-->
<script>
!(function () {
if (typeof performance === 'undefined' || !performance.measure) return;
var m = performance.measure.bind(performance);
var clear =
typeof performance.clearMeasures === 'function'
? performance.clearMeasures.bind(performance)
: null;
var reactMeasures = 0;
var CLEAR_EVERY = 16384;
performance.measure = function () {
var options = arguments[1];
var devtools =
options &&
typeof options === 'object' &&
options.detail &&
options.detail.devtools;
if (devtools && devtools.track === 'Components ⚛') {
if (devtools) {
reactMeasures += 1;
if (clear && reactMeasures >= CLEAR_EVERY) {
reactMeasures = 0;
clear();
}
var args = Array.prototype.slice.call(arguments);
args[1] = Object.assign({}, options, { detail: null });
return m.apply(this, args);
Expand Down
Loading