Skip to content

Commit a24b141

Browse files
committed
add: TaskDumps UI to viewer
1 parent 5cc1eee commit a24b141

5 files changed

Lines changed: 236 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/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/trace_analysis.js

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -431,22 +431,26 @@
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: an array entry means multiple frames at one address
435+
const frames = Array.isArray(entry) ? entry : [entry];
436+
for (let fi = frames.length - 1; fi >= 0; fi--) {
437+
const resolved = frames[fi];
438+
const key = resolved ? resolved.symbol : addr || "??";
439+
const formatted = resolved ? formatFrame(resolved) : formatFrame(addr, callframeSymbols);
440+
if (!node.children.has(key)) {
441+
node.children.set(key, {
442+
name: formatted.text,
443+
fullName: key,
444+
location: resolved ? resolved.location : null,
445+
docsUrl: formatted.docsUrl,
446+
children: new Map(),
447+
count: 0,
448+
self: 0,
449+
});
450+
}
451+
node = node.children.get(key);
452+
node.count++;
447453
}
448-
node = node.children.get(key);
449-
node.count++;
450454
}
451455
node.self++;
452456
}

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;

dial9-viewer/ui/viewer.html

Lines changed: 192 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2594,7 +2594,24 @@ <h3>⌨ Keyboard</h3>
25942594
if (!isInstrumented) {
25952595
labelHtml += ` · <a class="uninstrumented-badge" href="https://docs.rs/dial9-tokio-telemetry/latest/dial9_tokio_telemetry/telemetry/struct.TelemetryHandle.html#method.spawn" target="_blank" title="Task spawned via raw tokio::spawn() — click for docs on TelemetryHandle::spawn">no wake data ⓘ</a>`;
25962596
}
2597+
const taskHasDumps = trace.taskDumps && trace.taskDumps.has(selectedTaskId) && trace.taskDumps.get(selectedTaskId).length > 0;
2598+
if (taskHasDumps) {
2599+
const dumpCount = trace.taskDumps.get(selectedTaskId).length;
2600+
labelHtml += ` · <span id="btn-idle-flamegraph" style="cursor:pointer;color:#b388ff;text-decoration:underline;position:relative;z-index:10" title="Show time-weighted flamegraph of idle periods">🔥 idle flamegraph (${dumpCount})</span>`;
2601+
}
25972602
document.getElementById("task-detail-label").innerHTML = labelHtml;
2603+
if (taskHasDumps) {
2604+
document.getElementById("btn-idle-flamegraph").onclick = (e) => {
2605+
e.stopPropagation();
2606+
e.preventDefault();
2607+
try {
2608+
showIdleTimeFlamegraph();
2609+
} catch (err) {
2610+
console.error("showIdleTimeFlamegraph error:", err);
2611+
showToast("idle-fg-err", "Error: " + err.message, "error", 5000);
2612+
}
2613+
};
2614+
}
25982615

25992616
const parent = c.parentElement;
26002617
const dpr = devicePixelRatio || 1;
@@ -2783,6 +2800,9 @@ <h3>⌨ Keyboard</h3>
27832800
}
27842801

27852802
// Draw idle gaps between consecutive polls (where no wake→poll delay is shown)
2803+
// Look up task dumps for this task (sorted by timestamp)
2804+
const dumps = trace.taskDumps ? (trace.taskDumps.get(selectedTaskId) || []) : [];
2805+
let dumpIdx = 0; // cursor into dumps array for efficient lookup
27862806
for (let i = 0; i < polls.length - 1; i++) {
27872807
const gapStart = polls[i].end;
27882808
const gapEnd = polls[i + 1].start;
@@ -2796,9 +2816,43 @@ <h3>⌨ Keyboard</h3>
27962816
const x2 = Math.min(wakeX, LABEL_W + Math.min(drawW, nsToX(gapEnd, drawW)));
27972817
const w = Math.max(x2 - x1, 0);
27982818
if (w < 1) continue;
2799-
ctx.fillStyle = "#2a2a4a";
2819+
2820+
// Find task dump(s) for this idle period.
2821+
// A dump captured during poll[i-1] has ts in [poll[i-1].start, poll[i-1].end].
2822+
// The capture triggers a spurious re-wake, so poll[i] is the spurious poll.
2823+
// The dump represents what the task is waiting on during THIS gap (after
2824+
// the spurious poll[i]). So we collect dumps with ts within poll[i-1].
2825+
const gapDumps = [];
2826+
const prevPollStart = i > 0 ? polls[i - 1].start : polls[i].start;
2827+
while (dumpIdx < dumps.length && dumps[dumpIdx].timestamp < prevPollStart) dumpIdx++;
2828+
for (let di = dumpIdx; di < dumps.length && dumps[di].timestamp <= polls[i].start; di++) {
2829+
gapDumps.push(dumps[di]);
2830+
}
2831+
const hasDump = gapDumps.length > 0;
2832+
2833+
ctx.fillStyle = hasDump ? "#2a2a5a" : "#2a2a4a";
28002834
ctx.fillRect(x1, bandTop, w, bandH);
2801-
ctx.strokeStyle = "#444";
2835+
2836+
// Cross-hatch pattern for idle periods with task dumps
2837+
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();
2853+
}
2854+
2855+
ctx.strokeStyle = hasDump ? "#7c6cff" : "#444";
28022856
ctx.lineWidth = 1;
28032857
ctx.setLineDash([3, 3]);
28042858
ctx.strokeRect(x1, bandTop, w, bandH);
@@ -2812,7 +2866,10 @@ <h3>⌨ Keyboard</h3>
28122866
taskDetailHitRegions.push({
28132867
x1, x2, y1: bandTop, y2: bandTop + bandH,
28142868
type: "idle",
2815-
detail: `Idle — waiting ${fmtDur(dur)} for waker (no wake received yet)`,
2869+
detail: hasDump
2870+
? `Idle — waiting ${fmtDur(dur)} (click for async stack trace)`
2871+
: `Idle — waiting ${fmtDur(dur)} for waker (no wake received yet)`,
2872+
taskDumps: hasDump ? gapDumps : null,
28162873
});
28172874
}
28182875

@@ -4346,6 +4403,9 @@ <h3>⌨ Keyboard</h3>
43464403
break;
43474404
}
43484405
}
4406+
if (!found) {
4407+
document.getElementById("task-detail").style.cursor = (hit && hit.taskDumps) ? "pointer" : "";
4408+
}
43494409
const icon = hit ? (hit.type === "polling" ? "⚡" : hit.type === "scheduled" ? "⏳" : "💤") : "";
43504410
statusEl.textContent = hit ? `${icon} ${hit.detail}` : "";
43514411
});
@@ -4356,6 +4416,27 @@ <h3>⌨ Keyboard</h3>
43564416
renderAll();
43574417
}
43584418
});
4419+
document.getElementById("task-detail").addEventListener("click", (e) => {
4420+
const c = document.getElementById("task-detail-canvas");
4421+
const rect = c.getBoundingClientRect();
4422+
const mx = e.clientX - rect.left;
4423+
const my = e.clientY - rect.top;
4424+
// Check if clicking a waker region (existing behavior: select waker task)
4425+
for (const r of taskDetailWakeRegions) {
4426+
if (mx >= r.x1 && mx <= r.x2 && my >= r.y1 && my <= r.y2) {
4427+
selectedTaskId = r.wakerTaskId;
4428+
renderAll();
4429+
return;
4430+
}
4431+
}
4432+
// Check if clicking an idle region with task dumps
4433+
for (const r of taskDetailHitRegions) {
4434+
if (mx >= r.x1 && mx <= r.x2 && my >= r.y1 && my <= r.y2 && r.taskDumps) {
4435+
showTaskDumpStack(r.taskDumps);
4436+
return;
4437+
}
4438+
}
4439+
});
43594440

43604441
window.addEventListener("resize", () => {
43614442
if (trace) renderAll();
@@ -4412,6 +4493,114 @@ <h3>⌨ Keyboard</h3>
44124493
const fgContainer = document.getElementById("fg-container");
44134494
const fgInstance = FlamegraphRenderer.createFlamegraph(fgContainer);
44144495

4496+
function showTaskDumpStack(dumps) {
4497+
const samples = dumps.map(d => ({ callchain: d.callchain, workerId: 0 }));
4498+
fgActive = true;
4499+
schedActive = false;
4500+
const sidebar = document.getElementById("stack-sidebar");
4501+
const title = document.getElementById("stack-sidebar-title");
4502+
const body = document.getElementById("stack-sidebar-body");
4503+
document.getElementById("sidebar-tabs").style.display = "none";
4504+
title.textContent = `Waiting on — ${dumps.length} capture${dumps.length > 1 ? "s" : ""}`;
4505+
body.innerHTML = "";
4506+
body.style.display = "flex";
4507+
body.style.flexDirection = "column";
4508+
const actions = document.createElement("div");
4509+
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>`;
4511+
body.appendChild(actions);
4512+
fgContainer.style.flex = "1";
4513+
fgContainer.style.minHeight = "0";
4514+
body.appendChild(fgContainer);
4515+
const wasHidden = sidebar.style.display !== "flex";
4516+
sidebar.style.display = "flex";
4517+
if (wasHidden && trace) requestAnimationFrame(renderAll);
4518+
requestAnimationFrame(() => {
4519+
fgInstance.setData(samples, trace.callframeSymbols);
4520+
fgInstance.resize();
4521+
});
4522+
}
4523+
4524+
function showIdleTimeFlamegraph() {
4525+
if (!selectedTaskId || !trace.taskDumps) return;
4526+
const dumps = trace.taskDumps.get(selectedTaskId);
4527+
if (!dumps || dumps.length === 0) return;
4528+
4529+
// Collect polls for this task to compute idle durations
4530+
const polls = [];
4531+
for (const w of workerIds) {
4532+
for (const s of workerSpans[w].polls) {
4533+
if (s.taskId === selectedTaskId) polls.push(s);
4534+
}
4535+
}
4536+
polls.sort((a, b) => a.start - b.start);
4537+
4538+
// For each dump, find the idle period it belongs to and compute weight (duration in µs)
4539+
const weightedSamples = [];
4540+
let di = 0;
4541+
for (let i = 0; i < polls.length - 1 && di < dumps.length; i++) {
4542+
const gapStart = polls[i].end;
4543+
const gapEnd = polls[i + 1].start;
4544+
const dur = gapEnd - gapStart;
4545+
while (di < dumps.length && dumps[di].timestamp <= gapStart) di++;
4546+
for (let j = di; j < dumps.length && dumps[j].timestamp <= gapEnd; j++) {
4547+
// Weight = idle duration in µs (minimum 1)
4548+
const weight = Math.max(1, Math.round(dur / 1000));
4549+
weightedSamples.push({ callchain: dumps[j].callchain, weight });
4550+
}
4551+
}
4552+
// Also include dumps after the last poll (task still idle at trace end)
4553+
if (polls.length > 0) {
4554+
const lastEnd = polls[polls.length - 1].end;
4555+
const traceEnd = trace.maxTs || lastEnd;
4556+
while (di < dumps.length) {
4557+
const dur = traceEnd - lastEnd;
4558+
const weight = Math.max(1, Math.round(dur / 1000));
4559+
weightedSamples.push({ callchain: dumps[di].callchain, weight });
4560+
di++;
4561+
}
4562+
}
4563+
4564+
if (weightedSamples.length === 0) return;
4565+
4566+
// Expand weighted samples: repeat each sample proportional to weight
4567+
const totalWeight = weightedSamples.reduce((s, x) => s + x.weight, 0);
4568+
const scale = totalWeight > 10000 ? 10000 / totalWeight : 1;
4569+
const expandedSamples = [];
4570+
for (const ws of weightedSamples) {
4571+
const count = Math.max(1, Math.round(ws.weight * scale));
4572+
for (let k = 0; k < count; k++) {
4573+
expandedSamples.push({ callchain: ws.callchain, workerId: 0 });
4574+
}
4575+
}
4576+
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();
4601+
});
4602+
}
4603+
44154604
function showFlamegraph(selStart, selEnd) {
44164605
const samples = FlamegraphRenderer.filterCpuSamples(trace.cpuSamples, selStart, selEnd);
44174606
if (!samples.length) {

0 commit comments

Comments
 (0)