@@ -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