Skip to content

Commit 89b7403

Browse files
authored
feat: Taskdump viewer & expand inline frames in flamegraphs (dial9-rs#378)
* add: TaskDumps UI to viewer * Address PR dial9-rs#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 dial9-rs#1) - Sparse inline arrays with undefined slots - Unresolved address handling - TaskDumpEvent parsing shape, sort order, task-id integrity
1 parent d84c042 commit 89b7403

8 files changed

Lines changed: 378 additions & 26 deletions

File tree

dial9-viewer/skills/dial9-toolkit/scripts/analyze.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -677,6 +677,7 @@ async function parseWorkerMain(traceFile, cachePath) {
677677
callframeSymbols: mapToEntries(trace.callframeSymbols),
678678
threadNames: mapToEntries(trace.threadNames),
679679
runtimeWorkers: mapToEntries(trace.runtimeWorkers),
680+
taskDumps: mapToEntries(trace.taskDumps),
680681
clockSyncAnchors: trace.clockSyncAnchors, clockOffsetNs: trace.clockOffsetNs,
681682
}});
682683
for (const e of trace.events) writeLine({ t: 'e', d: e });
@@ -703,7 +704,7 @@ function loadCacheFile(cachePath) {
703704
const rec = JSON.parse(line);
704705
switch (rec.t) {
705706
case 'm': raw = rec.d;
706-
for (const k of ['spawnLocations','taskSpawnLocs','taskSpawnTimes','taskTerminateTimes','callframeSymbols','threadNames','runtimeWorkers'])
707+
for (const k of ['spawnLocations','taskSpawnLocs','taskSpawnTimes','taskTerminateTimes','callframeSymbols','threadNames','runtimeWorkers','taskDumps'])
707708
if (raw[k]) raw[k] = new Map(raw[k]);
708709
break;
709710
case 'e': events.push(rec.d); break;

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/flamegraph.js

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@
9393
let selfCount = 0;
9494
let frameCount = 0;
9595
function walk(node) {
96-
if (node.name.toLowerCase().includes(queryLower)) {
96+
if (node.name.toLowerCase().includes(queryLower) || (node.fullName && node.fullName.toLowerCase().includes(queryLower))) {
9797
selfCount += node.self;
9898
frameCount++;
9999
}
@@ -244,7 +244,7 @@
244244
if (w < 0.5) continue;
245245

246246
const isAncestor = !!node.isAncestor;
247-
const searchMatch = !searching || node.name.toLowerCase().includes(qLower);
247+
const searchMatch = !searching || node.name.toLowerCase().includes(qLower) || (node.treeNode && node.treeNode.fullName && node.treeNode.fullName.toLowerCase().includes(qLower));
248248
const highlighted = highlightName != null && node.name === highlightName;
249249
const dimmed = (searching && !searchMatch) || (highlightName != null && !highlighted);
250250
let alpha = 1.0;
@@ -498,12 +498,10 @@
498498
tooltip.innerHTML = buildTooltipHtml(hit, pinned);
499499
tooltip.style.pointerEvents = pinned ? "auto" : "none";
500500
tooltip.style.display = "block";
501-
// Clamp to viewport
501+
// Position at top of the flamegraph container so it never covers hovered frames
502+
const containerRect = container.getBoundingClientRect();
502503
const tipX = Math.min(x + 12, window.innerWidth - tooltip.offsetWidth - 8);
503-
let tipY = Math.max(8, y - 50);
504-
if (tipY + tooltip.offsetHeight > window.innerHeight - 8) {
505-
tipY = window.innerHeight - tooltip.offsetHeight - 8;
506-
}
504+
const tipY = Math.max(8, containerRect.top);
507505
tooltip.style.left = tipX + "px";
508506
tooltip.style.top = tipY + "px";
509507
if (pinned) {

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: 26 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -431,22 +431,33 @@
431431
node.count++;
432432
for (const addr of chain) {
433433
const entry = callframeSymbols.get(addr);
434-
const resolved = Array.isArray(entry) ? entry[0] : entry;
435-
const key = resolved ? resolved.symbol : addr || "??";
436-
const formatted = formatFrame(addr, callframeSymbols);
437-
if (!node.children.has(key)) {
438-
node.children.set(key, {
439-
name: formatted.text,
440-
fullName: key,
441-
location: resolved ? resolved.location : null,
442-
docsUrl: formatted.docsUrl,
443-
children: new Map(),
444-
count: 0,
445-
self: 0,
446-
});
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).
441+
const frames = Array.isArray(entry) ? entry : [entry];
442+
for (let fi = 0; fi < frames.length; fi++) {
443+
const resolved = frames[fi];
444+
if (fi > 0 && !resolved) continue;
445+
const key = resolved ? resolved.symbol : addr || "??";
446+
const formatted = resolved ? formatFrame(resolved) : formatFrame(addr, callframeSymbols);
447+
if (!node.children.has(key)) {
448+
node.children.set(key, {
449+
name: formatted.text,
450+
fullName: key,
451+
location: resolved ? resolved.location : null,
452+
docsUrl: formatted.docsUrl,
453+
children: new Map(),
454+
count: 0,
455+
self: 0,
456+
});
457+
}
458+
node = node.children.get(key);
459+
node.count++;
447460
}
448-
node = node.children.get(key);
449-
node.count++;
450461
}
451462
node.self++;
452463
}

dial9-viewer/ui/trace_parser.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,7 @@
198198
const cpuSamples = [];
199199
const threadNames = new Map();
200200
const runtimeWorkers = new Map(); // runtime name → [workerId, ...]
201+
const taskDumps = new Map(); // taskId → [{timestamp, callchain}] sorted by timestamp
201202
const customEvents = []; // unrecognized event types: {name, timestamp, fields}
202203
// { monotonicNs, realtimeNs } anchors used to recover wall clock.
203204
const clockSyncAnchors = [];
@@ -214,6 +215,7 @@
214215
"TaskSpawnEvent",
215216
"TaskTerminateEvent",
216217
"CpuSampleEvent",
218+
"TaskDumpEvent",
217219
"SymbolTableEntry",
218220
"SegmentMetadataEvent",
219221
"ClockSyncEvent",
@@ -380,6 +382,15 @@
380382
}
381383
break;
382384
}
385+
case "TaskDumpEvent": {
386+
const taskId = num(v.task_id);
387+
const chain = (v.callchain || []).map(
388+
(addr) => "0x" + BigInt(addr).toString(16)
389+
);
390+
if (!taskDumps.has(taskId)) taskDumps.set(taskId, []);
391+
taskDumps.get(taskId).push({ timestamp: ts, callchain: chain });
392+
break;
393+
}
383394
case "ClockSyncEvent": {
384395
const real = num(v.realtime_ns);
385396
if (real > 0) {
@@ -468,6 +479,11 @@
468479
return 0;
469480
});
470481

482+
// Sort task dumps by timestamp for efficient lookup during rendering
483+
for (const arr of taskDumps.values()) {
484+
arr.sort((a, b) => a.timestamp - b.timestamp);
485+
}
486+
471487
let clockOffsetNs = null;
472488
if (clockSyncAnchors.length > 0) {
473489
const a0 = clockSyncAnchors[0];
@@ -506,6 +522,7 @@
506522
taskTerminateTimes,
507523
runtimeWorkers,
508524
customEvents,
525+
taskDumps,
509526
clockSyncAnchors,
510527
clockOffsetNs,
511528
};
@@ -553,6 +570,7 @@
553570
if (raw.callframeSymbols) raw.callframeSymbols = entriesToMap(raw.callframeSymbols);
554571
if (raw.threadNames) raw.threadNames = entriesToMap(raw.threadNames);
555572
if (raw.runtimeWorkers) raw.runtimeWorkers = entriesToMap(raw.runtimeWorkers);
573+
if (raw.taskDumps) raw.taskDumps = entriesToMap(raw.taskDumps);
556574
break;
557575
case 'e': events.push(rec.d); break;
558576
case 'c': cpuSamples.push(rec.d); break;

0 commit comments

Comments
 (0)