fix(desktop): anchor the switch trace at the input event, not handler dispatch

Blind-review round 17 (Fable high) finding: startedAt sampled
performance.now() at click-handler dispatch, silently excluding input
delay — a click queued behind a long task under-reported by the whole
queueing time, unbounded, in exactly the contention regime the tracer
exists to expose. begin() now anchors at the dispatching event's
timeStamp when one is present (window.event is set only during
synchronous dispatch, so stale timestamps cannot leak in from async
continuations; min() guards skewed clocks).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Max Lampert <maxwell@squareup.com>
This commit is contained in:
Max Lampert
2026-08-25 03:35:15 -07:00
parent 5ea02312f9
commit a355a0b1d3
2 changed files with 39 additions and 1 deletions

View File

@@ -458,6 +458,32 @@ test("a scheduled route-exit abandon canceled in the same task keeps the trace",
);
});
test("the trace anchors at the input event, not handler dispatch", async () => {
await withSettleHarness(async ({ begin, settle, flush }) => {
// Real-clock gap: earlier tests fired visibilitychange listeners, and a
// back-dated anchor overlapping those timestamps is (correctly) dropped
// by the hidden-window guard. Let them age out first.
await new Promise((resolve) => setTimeout(resolve, 600));
// A click can sit queued behind a long task before its handler runs;
// that input delay is felt switch latency and must be inside totalMs.
// window.event is set only during synchronous dispatch, so this anchor
// can never leak in from async continuations.
globalThis.window.event = { timeStamp: performance.now() - 550 };
begin("aaaa1111aaaa1111");
delete globalThis.window.event;
settle("aaaa1111aaaa1111");
flush();
const measure = performance
.getEntriesByName("buzz:channel-switch:click-to-settled")
.at(-1);
assert.ok(measure, "measure recorded");
assert.ok(
measure.duration >= 550,
`input delay must be inside the measure (got ${measure.duration})`,
);
});
});
test("beginning a switch revokes a pending route-exit abandon for that channel", async () => {
await withSettleHarness(
async ({ begin, scheduleAbandon, settle, flush, measures }) => {

View File

@@ -252,9 +252,21 @@ export function beginChannelSwitchTrace(channelId: string): void {
// cleanup's scheduled abandon pending; it must not kill the fresh trace
// when its microtask drains.
cancelRouteExitAbandon(channelId);
// Anchor at the triggering input event when one is dispatching: a click
// can sit queued behind a long task before its handler runs, and that
// input delay is felt switch latency. window.event is set only during
// synchronous dispatch, so a stale timestamp can never leak in from async
// continuations; min() guards against skewed event clocks.
const now = performance.now();
const dispatchingEvent =
typeof window === "undefined" ? undefined : window.event;
const startedAt =
dispatchingEvent && typeof dispatchingEvent.timeStamp === "number"
? Math.min(dispatchingEvent.timeStamp, now)
: now;
activeTrace = {
channelId,
startedAt: performance.now(),
startedAt,
routeCommitAt: null,
windowFetch: null,
membersFetch: null,