Skip to content

Commit bff470d

Browse files
committed
Address PR #377 review feedback
1. Fix inlined-frame iteration order in buildFlamegraphTree. Per blazesym, an array entry in callframeSymbols is [outermost, ..., innermost] — the real function at the address is at [0] and [i>0] are inlined callees. The previous implementation iterated N→0, which inverted the call graph in the flamegraph (inner inlined functions appeared as parents of the outer function they were inlined into). Now iterates 0→N, matching caller→callee. Skip nullish slots so sparse arrays (from out-of-order SymbolTableEntry events) no longer produce phantom (unknown) tree levels. 2. Wrap showTaskDumpStack click in try/catch matching the showIdleTimeFlamegraph pattern — errors now surface as a toast instead of silently failing. 3. Extract renderFlamegraphInSidebar({title, subtitle, samples}) helper. Both showTaskDumpStack and showIdleTimeFlamegraph now delegate to it, removing ~40 lines of duplicated sidebar setup. 4. Extract drawCrossHatch(x, y, w, h) helper for the diagonal stripe pattern used on idle periods that have a task dump. 5. Document taskDumps in the ParsedTrace schema (dial9-trace-loading/SKILL.md) so agent skills can find it. Extend the schema validator to understand Map<K, [{obj}]> (array-of-objects map values). 6. Add unit tests in test_trace_analysis.js covering: - Flamegraph inline frame ordering (catches the bug fixed in #1) - Sparse inline arrays with undefined slots - Unresolved address handling - TaskDumpEvent parsing shape, sort order, task-id integrity
1 parent ec3b1fc commit bff470d

5 files changed

Lines changed: 189 additions & 47 deletions

File tree

dial9-viewer/skills/dial9-trace-loading/SKILL.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ description: Parse and load dial9 Tokio runtime trace files. Covers the ParsedTr
3535
hasSchedWait: boolean, // trace includes kernel scheduling wait data
3636
hasTaskTracking: boolean, // trace includes task spawn/terminate events
3737
taskInstrumented: Map<number, boolean>, // task ID → whether task has tracing instrumentation
38+
taskDumps: Map<number, [{timestamp, callchain}]>, // task ID → async stack captures (sorted by timestamp); see dial9-tokio-telemetry `taskdump` feature
3839
}
3940
```
4041

dial9-viewer/ui/test_all_skills_snippets.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,14 @@ async function main() {
214214
.replace(/\[\w+\]:/g, '_dynamic_:')
215215
.replace(/:\s*Map<[^,]+,\s*([^>]+)>/g, (_, valType) => {
216216
const v = valType.trim();
217+
// Array of objects: Map<K, [{a, b}]> → values are arrays whose elements have those keys.
218+
// Emit a marker string that the later [{...}] pass won't double-process;
219+
// a separate pass below expands it back to a JS array literal.
220+
const arrObjMatch = v.match(/^\[\{([^}]+)\}\]$/);
221+
if (arrObjMatch) {
222+
const keys = arrObjMatch[1].split(',').map(k => k.trim()).filter(Boolean);
223+
return ': {"_map_":"__ARR_OBJ__' + keys.join('|') + '__"}';
224+
}
217225
const objMatch = v.match(/\{([^}]+)\}/);
218226
if (objMatch) {
219227
const keys = objMatch[1].split(',').map(k => k.trim()).filter(Boolean);
@@ -231,6 +239,11 @@ async function main() {
231239
.replace(/\[\{([^}]+)\}\]/g, (_, inner) => {
232240
const keys = inner.split(',').map(k => k.trim()).filter(Boolean);
233241
return '[{' + keys.map(k => `"${k}":"_any_"`).join(',') + '}]';
242+
})
243+
// Expand Map<K, [{...}]> placeholder from earlier pass into a real array literal.
244+
.replace(/"__ARR_OBJ__([^_"]+)__"/g, (_, keysPipe) => {
245+
const keys = keysPipe.split('|');
246+
return '[{' + keys.map(k => `"${k}":"_any_"`).join(',') + '}]';
234247
});
235248
let docSkeleton;
236249
try { docSkeleton = (new Function('return {' + schemaJs + '}'))(); }

dial9-viewer/ui/test_trace_analysis.js

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,115 @@ async function main() {
367367
pass("buildFgData returns null for empty samples");
368368
}
369369

370+
// Inlined frames: when callframeSymbols.get(addr) returns an array, per
371+
// blazesym the array is ordered [outermost, ..., innermost]. entry[0] is the
372+
// real function at the address; entry[i>0] are inlined callees so the call
373+
// chain goes entry[0] -> entry[1] -> entry[2]. The flamegraph tree must
374+
// descend in that same order (outermost as parent, innermost as leaf).
375+
function testFlamegraphInlineOrder() {
376+
const callframeSymbols = new Map([
377+
["0x1000", [
378+
{ symbol: "outer_fn", location: "outer.rs:10" },
379+
{ symbol: "mid_fn", location: "mid.rs:20" },
380+
{ symbol: "leaf_fn", location: "leaf.rs:30" },
381+
]],
382+
]);
383+
const samples = [{ callchain: ["0x1000"], workerId: 0 }];
384+
const tree = buildFlamegraphTree(samples, callframeSymbols);
385+
if (tree.children.size !== 1) fail(`root has ${tree.children.size} children, expected 1`);
386+
const outer = [...tree.children.values()][0];
387+
if (!outer.fullName.includes("outer_fn")) fail(`child of root is "${outer.fullName}", expected "outer_fn"`);
388+
if (outer.children.size !== 1) fail(`outer has ${outer.children.size} children, expected 1`);
389+
const mid = [...outer.children.values()][0];
390+
if (!mid.fullName.includes("mid_fn")) fail(`child of outer is "${mid.fullName}", expected "mid_fn"`);
391+
const leaf = [...mid.children.values()][0];
392+
if (!leaf.fullName.includes("leaf_fn")) fail(`child of mid is "${leaf.fullName}", expected "leaf_fn"`);
393+
if (leaf.self !== 1) fail(`leaf.self = ${leaf.self}, expected 1 (innermost frame is where the sample lands)`);
394+
pass("Inlined frames expand outermost→innermost as parent→child in the flamegraph");
395+
}
396+
397+
// The inline-expansion code must not crash when an address maps to an array
398+
// with nullish elements (can happen with sparse SymbolTableEntry events or
399+
// when a child inline is resolved before its parent frame).
400+
function testFlamegraphInlineTolerantOfNullSlots() {
401+
// arr[0] present, arr[1] undefined, arr[2] present. The iteration should
402+
// skip the undefined slot rather than creating a "(unknown)" level.
403+
const sparse = new Array(3);
404+
sparse[0] = { symbol: "outer_fn", location: null };
405+
sparse[2] = { symbol: "leaf_fn", location: null };
406+
const callframeSymbols = new Map([["0x2000", sparse]]);
407+
const samples = [{ callchain: ["0x2000"], workerId: 0 }];
408+
const tree = buildFlamegraphTree(samples, callframeSymbols);
409+
// Expected: (all) -> outer_fn -> leaf_fn (sparse slot skipped)
410+
const outer = [...tree.children.values()][0];
411+
if (!outer || !outer.fullName.includes("outer_fn")) fail(`expected outer_fn child, got ${outer && outer.fullName}`);
412+
if (outer.children.size !== 1) fail(`outer has ${outer.children.size} children, expected 1 (sparse slot should be skipped)`);
413+
const leaf = [...outer.children.values()][0];
414+
if (!leaf.fullName.includes("leaf_fn")) fail(`expected leaf_fn after outer_fn, got ${leaf.fullName}`);
415+
pass("Sparse inline arrays do not produce phantom tree levels");
416+
}
417+
418+
// An address that is not present in callframeSymbols should still produce
419+
// a single-level child using the raw address as the key (so unresolved
420+
// traces remain visible rather than collapsing).
421+
function testFlamegraphUnknownAddress() {
422+
const callframeSymbols = new Map(); // empty — address resolves to undefined
423+
const samples = [{ callchain: ["0x3000"], workerId: 0 }];
424+
const tree = buildFlamegraphTree(samples, callframeSymbols);
425+
if (tree.children.size !== 1) fail(`root has ${tree.children.size} children for single unresolved address`);
426+
const node = [...tree.children.values()][0];
427+
if (node.self !== 1) fail(`unresolved node.self = ${node.self}, expected 1`);
428+
pass("Unresolved addresses still produce a single tree level");
429+
}
430+
431+
// ── TaskDumpEvent parsing (verified against the demo trace) ──
432+
433+
function testTaskDumpsParsed() {
434+
if (!trace.taskDumps) fail("trace.taskDumps should be a Map");
435+
if (!(trace.taskDumps instanceof Map)) fail("trace.taskDumps should be an instance of Map");
436+
pass(`trace.taskDumps is a Map with ${trace.taskDumps.size} task IDs`);
437+
}
438+
439+
function testTaskDumpsSortedByTimestamp() {
440+
// Every value in taskDumps is an array sorted by timestamp — the renderer
441+
// relies on this for its O(n) sweep across idle gaps.
442+
for (const [tid, dumps] of trace.taskDumps) {
443+
for (let i = 1; i < dumps.length; i++) {
444+
if (dumps[i].timestamp < dumps[i - 1].timestamp) {
445+
fail(`taskDumps for task ${tid} not sorted (index ${i})`);
446+
}
447+
}
448+
}
449+
pass("All taskDumps arrays are sorted by timestamp");
450+
}
451+
452+
function testTaskDumpsShape() {
453+
// Each dump is {timestamp, callchain} where callchain is an array of hex address strings.
454+
for (const [tid, dumps] of trace.taskDumps) {
455+
for (const d of dumps) {
456+
if (typeof d.timestamp !== "number") fail(`dump.timestamp for task ${tid} is ${typeof d.timestamp}`);
457+
if (!Array.isArray(d.callchain)) fail(`dump.callchain for task ${tid} is not an array`);
458+
for (const addr of d.callchain) {
459+
if (typeof addr !== "string" || !addr.startsWith("0x")) {
460+
fail(`dump.callchain entry ${addr} not a hex string`);
461+
}
462+
}
463+
break; // sample one per task is enough
464+
}
465+
}
466+
pass("TaskDumps have expected {timestamp, callchain} shape with hex-string addresses");
467+
}
468+
469+
function testTaskDumpsTaskIdsKnown() {
470+
// Every task ID that has a dump should be a known spawned task (no orphans).
471+
for (const tid of trace.taskDumps.keys()) {
472+
if (!trace.taskSpawnTimes.has(tid)) {
473+
fail(`task ${tid} has taskDumps but is not in taskSpawnTimes`);
474+
}
475+
}
476+
pass("All taskDump task IDs refer to tasks that appear in taskSpawnTimes");
477+
}
478+
370479
// ── buildSpanData ──
371480

372481
function testBuildSpanDataPairing() {
@@ -822,6 +931,15 @@ async function main() {
822931
testFlattenFlamegraph();
823932
testBuildFgData();
824933
testBuildFgDataEmpty();
934+
testFlamegraphInlineOrder();
935+
testFlamegraphInlineTolerantOfNullSlots();
936+
testFlamegraphUnknownAddress();
937+
938+
console.log("\ntaskDumps:");
939+
testTaskDumpsParsed();
940+
testTaskDumpsSortedByTimestamp();
941+
testTaskDumpsShape();
942+
testTaskDumpsTaskIdsKnown();
825943

826944
console.log("\nbuildSpanData:");
827945
testBuildSpanDataPairing();

dial9-viewer/ui/trace_analysis.js

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -431,10 +431,17 @@
431431
node.count++;
432432
for (const addr of chain) {
433433
const entry = callframeSymbols.get(addr);
434-
// Expand inlined frames: an array entry means multiple frames at one address
434+
// Expand inlined frames. Per blazesym, an array entry is ordered
435+
// [outermost, ..., innermost]: entry[0] is the real function at this
436+
// address, and entry[i>0] are inlined callees (entry[0] calls entry[1]
437+
// calls entry[2], etc.). To walk the call graph caller→callee while
438+
// descending the flamegraph tree, iterate 0 → N. Skip nullish slots
439+
// that can appear in sparse arrays (rare, but can happen if inline
440+
// SymbolTableEntry events arrive before their depth=0 sibling).
435441
const frames = Array.isArray(entry) ? entry : [entry];
436-
for (let fi = frames.length - 1; fi >= 0; fi--) {
442+
for (let fi = 0; fi < frames.length; fi++) {
437443
const resolved = frames[fi];
444+
if (fi > 0 && !resolved) continue;
438445
const key = resolved ? resolved.symbol : addr || "??";
439446
const formatted = resolved ? formatFrame(resolved) : formatFrame(addr, callframeSymbols);
440447
if (!node.children.has(key)) {

dial9-viewer/ui/viewer.html

Lines changed: 48 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -2634,6 +2634,25 @@ <h3>⌨ Keyboard</h3>
26342634
return formatHumanDuration(ns);
26352635
}
26362636

2637+
// Diagonal cross-hatch fill inside the rect (x, y, w, h), clipped.
2638+
// Used to visually flag idle periods that have an associated task dump.
2639+
function drawCrossHatch(x, y, w, h, { stroke = "rgba(140,120,255,0.35)", step = 8 } = {}) {
2640+
ctx.save();
2641+
ctx.beginPath();
2642+
ctx.rect(x, y, w, h);
2643+
ctx.clip();
2644+
ctx.strokeStyle = stroke;
2645+
ctx.lineWidth = 1;
2646+
ctx.setLineDash([]);
2647+
for (let hx = x - h; hx < x + w; hx += step) {
2648+
ctx.beginPath();
2649+
ctx.moveTo(hx, y + h);
2650+
ctx.lineTo(hx + h, y);
2651+
ctx.stroke();
2652+
}
2653+
ctx.restore();
2654+
}
2655+
26372656
const bandTop = 50, bandH = 30;
26382657
ctx.font = "9px monospace";
26392658
ctx.textAlign = "center";
@@ -2835,21 +2854,7 @@ <h3>⌨ Keyboard</h3>
28352854

28362855
// Cross-hatch pattern for idle periods with task dumps
28372856
if (hasDump) {
2838-
ctx.save();
2839-
ctx.beginPath();
2840-
ctx.rect(x1, bandTop, w, bandH);
2841-
ctx.clip();
2842-
ctx.strokeStyle = "rgba(140, 120, 255, 0.35)";
2843-
ctx.lineWidth = 1;
2844-
ctx.setLineDash([]);
2845-
const step = 8;
2846-
for (let hx = x1 - bandH; hx < x2; hx += step) {
2847-
ctx.beginPath();
2848-
ctx.moveTo(hx, bandTop + bandH);
2849-
ctx.lineTo(hx + bandH, bandTop);
2850-
ctx.stroke();
2851-
}
2852-
ctx.restore();
2857+
drawCrossHatch(x1, bandTop, w, bandH);
28532858
}
28542859

28552860
ctx.strokeStyle = hasDump ? "#7c6cff" : "#444";
@@ -4432,7 +4437,12 @@ <h3>⌨ Keyboard</h3>
44324437
// Check if clicking an idle region with task dumps
44334438
for (const r of taskDetailHitRegions) {
44344439
if (mx >= r.x1 && mx <= r.x2 && my >= r.y1 && my <= r.y2 && r.taskDumps) {
4435-
showTaskDumpStack(r.taskDumps);
4440+
try {
4441+
showTaskDumpStack(r.taskDumps);
4442+
} catch (err) {
4443+
console.error("showTaskDumpStack error:", err);
4444+
showToast("task-dump-err", "Error: " + err.message, "error", 5000);
4445+
}
44364446
return;
44374447
}
44384448
}
@@ -4493,21 +4503,24 @@ <h3>⌨ Keyboard</h3>
44934503
const fgContainer = document.getElementById("fg-container");
44944504
const fgInstance = FlamegraphRenderer.createFlamegraph(fgContainer);
44954505

4496-
function showTaskDumpStack(dumps) {
4497-
const samples = dumps.map(d => ({ callchain: d.callchain, workerId: 0 }));
4506+
// Open the stack sidebar showing a flamegraph built from `samples`.
4507+
// Used by `showTaskDumpStack`, `showIdleTimeFlamegraph`, and any
4508+
// other caller that just wants "here's a pile of callchains, render
4509+
// them as a flamegraph in the sidebar".
4510+
function renderFlamegraphInSidebar({ title, subtitle, samples }) {
44984511
fgActive = true;
44994512
schedActive = false;
45004513
const sidebar = document.getElementById("stack-sidebar");
4501-
const title = document.getElementById("stack-sidebar-title");
4514+
const titleEl = document.getElementById("stack-sidebar-title");
45024515
const body = document.getElementById("stack-sidebar-body");
45034516
document.getElementById("sidebar-tabs").style.display = "none";
4504-
title.textContent = `Waiting on — ${dumps.length} capture${dumps.length > 1 ? "s" : ""}`;
4517+
titleEl.textContent = title;
45054518
body.innerHTML = "";
45064519
body.style.display = "flex";
45074520
body.style.flexDirection = "column";
45084521
const actions = document.createElement("div");
45094522
actions.style.cssText = "display:flex;gap:8px;margin-bottom:6px;flex-shrink:0;align-items:center";
4510-
actions.innerHTML = `<span style="color:#888;font-size:0.85em">${dumps.length} async stack capture${dumps.length > 1 ? "s" : ""}</span>`;
4523+
actions.innerHTML = `<span style="color:#888;font-size:0.85em">${subtitle}</span>`;
45114524
body.appendChild(actions);
45124525
fgContainer.style.flex = "1";
45134526
fgContainer.style.minHeight = "0";
@@ -4521,6 +4534,16 @@ <h3>⌨ Keyboard</h3>
45214534
});
45224535
}
45234536

4537+
function showTaskDumpStack(dumps) {
4538+
const samples = dumps.map(d => ({ callchain: d.callchain, workerId: 0 }));
4539+
const s = dumps.length > 1 ? "s" : "";
4540+
renderFlamegraphInSidebar({
4541+
title: `Waiting on — ${dumps.length} capture${s}`,
4542+
subtitle: `${dumps.length} async stack capture${s}`,
4543+
samples,
4544+
});
4545+
}
4546+
45244547
function showIdleTimeFlamegraph() {
45254548
if (!selectedTaskId || !trace.taskDumps) return;
45264549
const dumps = trace.taskDumps.get(selectedTaskId);
@@ -4574,30 +4597,10 @@ <h3>⌨ Keyboard</h3>
45744597
}
45754598
}
45764599

4577-
// Show in sidebar using the flamegraph renderer
4578-
fgActive = true;
4579-
schedActive = false;
4580-
const sidebar = document.getElementById("stack-sidebar");
4581-
const title = document.getElementById("stack-sidebar-title");
4582-
const body = document.getElementById("stack-sidebar-body");
4583-
document.getElementById("sidebar-tabs").style.display = "none";
4584-
title.textContent = `Idle time flamegraph — ${dumps.length} samples`;
4585-
body.innerHTML = "";
4586-
body.style.display = "flex";
4587-
body.style.flexDirection = "column";
4588-
const actions = document.createElement("div");
4589-
actions.style.cssText = "display:flex;gap:8px;margin-bottom:6px;flex-shrink:0;align-items:center";
4590-
actions.innerHTML = `<span style="color:#888;font-size:0.85em">${dumps.length} task dumps, time-weighted</span>`;
4591-
body.appendChild(actions);
4592-
fgContainer.style.flex = "1";
4593-
fgContainer.style.minHeight = "0";
4594-
body.appendChild(fgContainer);
4595-
const wasHidden = sidebar.style.display !== "flex";
4596-
sidebar.style.display = "flex";
4597-
if (wasHidden && trace) requestAnimationFrame(renderAll);
4598-
requestAnimationFrame(() => {
4599-
fgInstance.setData(expandedSamples, trace.callframeSymbols);
4600-
fgInstance.resize();
4600+
renderFlamegraphInSidebar({
4601+
title: `Idle time flamegraph — ${dumps.length} samples`,
4602+
subtitle: `${dumps.length} task dumps, time-weighted`,
4603+
samples: expandedSamples,
46014604
});
46024605
}
46034606

0 commit comments

Comments
 (0)