2b126ffb80
Always-on LoopWatchdog (armed in TUI.start/stop) logs ui.loop-blocked with blockedMs and the current loop phase on the rising edge of a late probe tick. New pushLoopPhase/popLoopPhase/currentLoopPhase stack in pi-utils feeds it; breadcrumbs at in-process subagent dispatch (subagent:<id>) and the SelectList fuzzy filter (ui.select-filter) attribute residual main-thread stalls.
50 lines
2.0 KiB
TypeScript
50 lines
2.0 KiB
TypeScript
/**
|
|
* Live event-loop phase breadcrumb. Hot synchronous paths push a short label
|
|
* before running and pop it after (via `try`/`finally`); the loop watchdog
|
|
* reads {@link takeRecentLoopPhase} when it detects a block, so a stall is
|
|
* logged with the work that caused it instead of an opaque "unknown".
|
|
*
|
|
* This is deliberately a process-global stack and not part of the logger span
|
|
* machinery: `main.ts` ends timing spans before the interactive TUI starts, so
|
|
* `logger.openSpanPath()` is empty in a live session.
|
|
*
|
|
* Correctness constraint: each `pushLoopPhase` must be balanced by a
|
|
* `popLoopPhase` within the SAME synchronous execution (always via `try`/
|
|
* `finally`). The stack is global and shared, so a label held across an
|
|
* `await`/async boundary — or interleaved between concurrent tasks — would
|
|
* misattribute or leak phases. Instrument only synchronous spans; for async
|
|
* work, push/pop around each synchronous chunk, not across the await.
|
|
*/
|
|
const stack: string[] = [];
|
|
// The most recent label pushed, retained after it is popped. A hot path pushes
|
|
// and pops a phase entirely within one synchronous macrotask, so by the time
|
|
// the watchdog's delayed tick runs the stack is already empty; this slot keeps
|
|
// the culprit available for that one tick. Consumed (cleared) on read so it
|
|
// only attributes the just-elapsed interval.
|
|
let recentPhase: string | undefined;
|
|
|
|
export function pushLoopPhase(label: string): void {
|
|
stack.push(label);
|
|
recentPhase = label;
|
|
}
|
|
|
|
export function popLoopPhase(): void {
|
|
stack.pop();
|
|
}
|
|
|
|
export function currentLoopPhase(): string | undefined {
|
|
return stack[stack.length - 1];
|
|
}
|
|
|
|
/**
|
|
* Phase to blame for a just-detected loop block: the live top phase if one is
|
|
* still held, else the most recent phase pushed since the last call. Clears the
|
|
* recent slot so a block in a later, phase-less interval is not misattributed
|
|
* to a phase that already finished.
|
|
*/
|
|
export function takeRecentLoopPhase(): string | undefined {
|
|
const phase = stack[stack.length - 1] ?? recentPhase;
|
|
recentPhase = undefined;
|
|
return phase;
|
|
}
|