Skip to content

Commit 7d38ac8

Browse files
committed
fix(diagnosis): bound repeated profile display text before serialization
Signed-off-by: huangruiteng <huangrt01@163.com>
1 parent 8cb7b3c commit 7d38ac8

5 files changed

Lines changed: 33 additions & 4 deletions

File tree

‎examples/personal-workspace-browser/performance-diagnosis.mjs‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,11 @@ export const performanceDiagnosisScenario = {
4343
await input.setInputFiles({ name: "broken.json", mimeType: "application/json", buffer: Buffer.from('{"profiles":[]}') });
4444
await panel.getByRole("alert").waitFor();
4545
if (await panel.getByRole("table").count()) throw new Error("Failed capture retained the previous success");
46+
const amplified = { ...capture, shared: { frames: [{ name: "x".repeat(8192) }] },
47+
profiles: Array.from({ length: 65 }, () => ({ type: "sampled", name: "thread", unit: "milliseconds", startValue: 0, endValue: 1, samples: [[0]], weights: [1] })) };
48+
await input.setInputFiles({ name: "many-threads.json", mimeType: "application/json", buffer: Buffer.from(JSON.stringify(amplified)) });
49+
await panel.getByRole("alert").filter({ hasText: "summary text exceeds" }).waitFor();
50+
if (await panel.getByRole("table").count()) throw new Error("Rejected summary was silently truncated");
4651
await input.setInputFiles({ name: "too-large.json", mimeType: "application/json", buffer: Buffer.alloc(16 * 1024 * 1024 + 1, 32) });
4752
await panel.getByRole("alert").filter({ hasText: "文件超过 16 MiB" }).waitFor();
4853
const clear = panel.getByRole("button", { name: "清除 / 取消", exact: true });

‎loopx/capabilities/performance_diagnosis/README.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,9 @@ an empty successful diagnosis. Speedscope sampled/evented and V8 CPU time units
7878
are normalized to milliseconds. Threads/profiles stay separate, recursion is
7979
counted once per stack observation, and self and inclusive hotspots are distinct.
8080
Do not sum inclusive rows or thread weights to infer elapsed time.
81-
Input files are limited to 16 MiB; shorten captures beyond that limit. Larger
81+
Input files are limited to 16 MiB; shorten captures beyond that limit. Repeated
82+
profile names and hotspot labels share a 1 Mi-character display-text budget;
83+
oversized summaries are rejected explicitly, never silently truncated. Larger
8284
recordings reuse Effect's private local snapshot transport without raising the
8385
ordinary 2 MiB message limit or returning the raw profile in the summary.
8486

‎loopx/capabilities/performance_diagnosis/README.zh-CN.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,8 @@ Memray/V8 heap 用工具自己的 allocation reporter。其余工具是后续诊
4848
统一时间单位,分别返回 self/inclusive 热点。线程和 profile 保持独立,递归
4949
不会让同一个栈样本重复计时。不要把 inclusive 行相加,也不要把线程权重当作
5050
进程执行时长。
51-
输入限 16 MiB,超出时缩短采集;较大的录制复用 Effect 的私有本地快照传输,
51+
输入限 16 MiB,超出时缩短采集;重复展示的 profile 名称与热点标签共用 1 Mi 字符
52+
上限,超出时明确拒绝,不静默截断。较大的录制复用 Effect 的私有本地快照传输,
5253
不会提高普通命令的 2 MiB 传输上限,也不会把原始 profile 返回到展示结果。
5354

5455
## 验证结论

‎loopx/control_plane/capabilities/performance_profile.ts‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,15 @@ export function summarizePerformanceProfile(input: unknown) {
3333
const raw = object(request.profile, "profile");
3434
const top = number(request.top ?? 15, "top");
3535
if (!Number.isInteger(top) || top < 1 || top > 50) throw new Error("top must be an integer from 1 to 50");
36+
// Shared frames may appear in many profiles and both rankings. Bound their
37+
// repeated display text before serializing or posting the complete summary.
38+
let displayedCharacters = 0;
39+
const reserveText = (value: string) => {
40+
displayedCharacters += value.length;
41+
if (displayedCharacters > 1_048_576) throw new Error("profile summary text exceeds 1 Mi characters; select fewer profiles or a smaller top count");
42+
};
3643
const summarize = (name: string, frames: Frame[], unit: string, build: (add: (stack: number[], weight: number) => void) => void) => {
44+
reserveText(name);
3745
const scale = ({nanoseconds: 1e-6, microseconds: 1e-3, milliseconds: 1, seconds: 1000} as Record<string, number>)[unit];
3846
if (scale === undefined) throw new Error(`unsupported time unit: ${unit}`);
3947
if (!frames.length || frames.length > 100_000) throw new Error("profile requires 1..100000 frames");
@@ -53,8 +61,10 @@ export function summarizePerformanceProfile(input: unknown) {
5361
if (!observationsCount || !weightTotal) throw new Error("profile contains no positive-duration observations");
5462
if (!Number.isFinite(weightTotal)) throw new Error("profile time overflows finite milliseconds");
5563
const rows = (kind: "self" | "inclusive") => observations.filter(row => row[kind] > 0)
56-
.sort((a, b) => b[kind] - a[kind]).slice(0, top).map(row => ({...row.frame,
57-
self_ms: row.self, inclusive_ms: row.inclusive}));
64+
.sort((a, b) => b[kind] - a[kind]).slice(0, top).map(row => {
65+
reserveText(row.frame.name); reserveText(row.frame.file ?? "");
66+
return {...row.frame, self_ms: row.self, inclusive_ms: row.inclusive};
67+
});
5868
return {name, observations: observationsCount, observed_weight_ms: weightTotal,
5969
stack_weight_ms: stackWeight, self_hotspots: rows("self"), inclusive_hotspots: rows("inclusive")};
6070
};

‎tests/control_plane_ts/performance_diagnosis.test.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,17 @@ test("evented profiles integrate stack intervals rather than counting opens", ()
6363
assert.equal(main.inclusive_hotspots[0]!.inclusive_ms, 6);
6464
});
6565

66+
test("shared long names cannot multiply a small capture into an unbounded summary", () => {
67+
const name = "x".repeat(8192);
68+
const profile = {$schema: sampled().$schema, shared: {frames: [{name}]},
69+
profiles: Array.from({length: 65}, () => ({type: "sampled", name: "thread",
70+
unit: "milliseconds", startValue: 0, endValue: 1, samples: [[0]], weights: [1]}))};
71+
assert.ok(JSON.stringify(profile).length < 20_000);
72+
assert.throws(() => inspect({profile}), /summary text exceeds/);
73+
profile.profiles.length = 1;
74+
assert.equal(inspect({profile}).profiles[0]!.self_hotspots[0]!.name, name);
75+
});
76+
6677
test("event ordering, balanced exits and temporal units remain required", () => {
6778
const profile = {$schema: sampled().$schema, shared: sampled().shared,
6879
profiles: [{type: "evented", unit: "milliseconds", startValue: 0, endValue: 8,

0 commit comments

Comments
 (0)