From 370c6687cf4b356ac2e9af4d4aa998fd43ee810a Mon Sep 17 00:00:00 2001 From: Max Lampert Date: Thu, 20 Aug 2026 13:47:13 -0700 Subject: [PATCH 01/27] perf(desktop): trace channel switches from click to settled paint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a single-active-trace instrument for channel navigation: goChannel opens the trace, ChannelScreen marks route commit and settles it after the timeline loading latch clears (double rAF so the frame painted). The two relay fetches that can sit on the switch path — the message window and the member roster — are attributed to the trace when they run during it; cache-served switches log "cache". Each switch emits one [switch-perf] console line plus User Timing marks/measures (buzz:channel-switch:*) so before/after comparisons work identically in a dev build, the Performance panel, and Playwright perf specs. Signed-off-by: Max Lampert --- desktop/playwright.config.ts | 1 + desktop/src-tauri/build.rs | 28 ++ desktop/src-tauri/src/commands/mod.rs | 2 + desktop/src-tauri/src/commands/perf_log.rs | 268 ++++++++++++++++ desktop/src-tauri/src/lib.rs | 1 + .../src/app/navigation/useAppNavigation.ts | 13 +- desktop/src/features/channels/hooks.ts | 82 +---- desktop/src/features/channels/sidebarPerf.ts | 74 +++++ .../features/channels/ui/ChannelScreen.tsx | 6 + .../channels/useChannelSwitchTraceMarks.ts | 54 ++++ .../features/communities/useCommunityInit.ts | 5 + desktop/src/features/messages/hooks.ts | 16 +- .../lib/projectChannelWindow.test.mjs | 82 +++++ .../features/messages/ui/MessageTimeline.tsx | 18 +- .../src/shared/lib/channelSwitchPerf.test.mjs | 216 +++++++++++++ desktop/src/shared/lib/channelSwitchPerf.ts | 297 ++++++++++++++++++ desktop/src/testing/e2eBridge.ts | 3 + .../e2e/switch-settle-after-paint.spec.ts | 58 ++++ 18 files changed, 1146 insertions(+), 78 deletions(-) create mode 100644 desktop/src-tauri/src/commands/perf_log.rs create mode 100644 desktop/src/features/channels/sidebarPerf.ts create mode 100644 desktop/src/features/channels/useChannelSwitchTraceMarks.ts create mode 100644 desktop/src/shared/lib/channelSwitchPerf.test.mjs create mode 100644 desktop/src/shared/lib/channelSwitchPerf.ts create mode 100644 desktop/tests/e2e/switch-settle-after-paint.spec.ts diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index be15c75587d..9e1a75929d3 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -109,6 +109,7 @@ export default defineConfig({ "**/overscroll-boundary.spec.ts", "**/terminal-wheel.spec.ts", "**/cold-switch-longtask.perf.ts", + "**/switch-settle-after-paint.spec.ts", "**/timeline-no-shift.spec.ts", "**/human-edit-agent-content.spec.ts", "**/empty-edit-delete.spec.ts", diff --git a/desktop/src-tauri/build.rs b/desktop/src-tauri/build.rs index 2cdd785c735..63dfa91dc6b 100644 --- a/desktop/src-tauri/build.rs +++ b/desktop/src-tauri/build.rs @@ -8,6 +8,34 @@ include!("src/managed_agents/reserved_env_keys.rs"); use base64::Engine as _; fn main() { + // Bake the source git revision into the binary so diagnostics (the + // switch-perf JSONL sink) can attribute records to the build that wrote + // them. Reruns key off the reflog, which updates on every checkout, + // commit, and rebase. `--dirty` marks uncommitted worktrees but is only + // as fresh as the last build-script run: plain source edits between + // builds do not re-stamp it. Checkout-based A/B flows (the intended use) + // always update the reflog and re-stamp. + if let Ok(git_dir) = std::process::Command::new("git") + .args(["rev-parse", "--absolute-git-dir"]) + .output() + { + if git_dir.status.success() { + let dir = String::from_utf8_lossy(&git_dir.stdout).trim().to_string(); + println!("cargo:rerun-if-changed={dir}/HEAD"); + println!("cargo:rerun-if-changed={dir}/logs/HEAD"); + } + } + if let Some(git_sha) = std::process::Command::new("git") + .args(["describe", "--always", "--dirty", "--abbrev=12"]) + .output() + .ok() + .filter(|output| output.status.success()) + .map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string()) + .filter(|sha| !sha.is_empty()) + { + println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_GIT_SHA={git_sha}"); + } + println!("cargo:rerun-if-env-changed=BUZZ_RELAY_URL"); println!("cargo:rerun-if-env-changed=BUZZ_RELAY_HTTP"); println!("cargo:rerun-if-env-changed=BUZZ_UPDATER_PUBLIC_KEY"); diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 7cb2d8e3b83..0389c20ecea 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -44,6 +44,7 @@ mod notifications; mod observer_archive; mod os_idle; pub mod pairing; +mod perf_log; mod personas; mod prevent_sleep; mod profile; @@ -105,6 +106,7 @@ pub use notifications::*; pub use observer_archive::*; pub use os_idle::*; pub use pairing::*; +pub use perf_log::*; pub use personas::*; pub use prevent_sleep::*; pub use profile::*; diff --git a/desktop/src-tauri/src/commands/perf_log.rs b/desktop/src-tauri/src/commands/perf_log.rs new file mode 100644 index 00000000000..ff493ac413e --- /dev/null +++ b/desktop/src-tauri/src/commands/perf_log.rs @@ -0,0 +1,268 @@ +//! Append-only JSONL sink for channel-switch perf traces. +//! +//! The desktop's `[switch-perf]` console traces vanish with the session; this +//! sink persists one JSON line per settled switch to +//! `{app_log_dir}/switch-perf.jsonl` so before/after builds can be compared +//! offline. Every line is stamped with the build's git revision (baked by +//! build.rs) and, when set at launch, the `BUZZ_PERF_LOG_LABEL` run label — +//! e.g. `BUZZ_PERF_LOG_LABEL=before just production`. + +use std::io::Write; + +use tauri::Manager; + +const PERF_LOG_FILENAME: &str = "switch-perf.jsonl"; + +/// Defensive cap: one record is a small trace object; anything larger is a +/// caller bug and must not grow the log unbounded. +const MAX_RECORD_BYTES: usize = 4 * 1024; + +/// Rotation threshold. The sink is always on, so without a cap the JSONL +/// grows for the life of the install; one rotated generation preserves +/// enough history for before/after comparisons. +const MAX_LOG_BYTES: u64 = 10 * 1024 * 1024; + +/// Validates and shapes one JSONL line: the record must be a JSON object +/// (which also guarantees the stored line is newline-free), then the build +/// revision and optional run label are folded in. Pure for unit testing. +fn shape_perf_log_line( + record_json: &str, + git_sha: Option<&str>, + label: Option<&str>, +) -> Result { + if record_json.len() > MAX_RECORD_BYTES { + return Err("perf log record too large".to_string()); + } + let mut value: serde_json::Value = + serde_json::from_str(record_json).map_err(|e| format!("invalid perf log record: {e}"))?; + let object = value + .as_object_mut() + .ok_or_else(|| "perf log record must be a JSON object".to_string())?; + object.insert( + "gitSha".to_string(), + match git_sha { + Some(sha) => serde_json::Value::String(sha.to_string()), + None => serde_json::Value::Null, + }, + ); + if let Some(label) = label { + object.insert( + "label".to_string(), + serde_json::Value::String(label.to_string()), + ); + } + serde_json::to_string(&value).map_err(|e| e.to_string()) +} + +/// Serializes the whole metadata→rename→append transaction. Appends run on +/// independent `spawn_blocking` threads; without this, two writers at the +/// rotation boundary can both decide to rotate — the loser's rename fails and +/// its record is dropped. One global lock suffices: the app writes a single +/// log path. +static PERF_LOG_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +/// Appends one line, rotating the file to `.1` (replacing the previous +/// generation) once it exceeds `max_bytes`. Factored for unit testing. +fn append_line_rotating(path: &std::path::Path, line: &str, max_bytes: u64) -> Result<(), String> { + let _guard = PERF_LOG_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if let Ok(metadata) = std::fs::metadata(path) { + if metadata.len() >= max_bytes { + let mut rotated = path.as_os_str().to_owned(); + rotated.push(".1"); + let rotated = std::path::PathBuf::from(rotated); + // Remove the retained generation before renaming over it: on + // Windows, rename does not replace an existing destination, and a + // failed rotation here would silently drop every subsequent trace + // (the frontend deliberately swallows sink errors). Same platform + // rule as managed_agents::storage::start_install_log_session. + if rotated.exists() { + std::fs::remove_file(&rotated).map_err(|e| e.to_string())?; + } + std::fs::rename(path, &rotated).map_err(|e| e.to_string())?; + } + } + let mut file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + .map_err(|e| e.to_string())?; + writeln!(file, "{line}").map_err(|e| e.to_string()) +} + +/// Appends one switch-perf record to the app-log-dir JSONL file and returns +/// the file's path so the frontend can announce where the log lives. +/// +/// Async so Tauri runs it on the async runtime rather than the main thread: +/// a perf sink must not add main-thread filesystem stalls to the switches it +/// measures. +#[tauri::command] +pub async fn append_switch_perf_log( + app: tauri::AppHandle, + record_json: String, +) -> Result { + let label = std::env::var("BUZZ_PERF_LOG_LABEL").ok(); + let line = shape_perf_log_line( + &record_json, + option_env!("BUZZ_DESKTOP_BUILD_GIT_SHA"), + label.as_deref(), + )?; + let dir = app.path().app_log_dir().map_err(|e| e.to_string())?; + let path = dir.join(PERF_LOG_FILENAME); + let result = tauri::async_runtime::spawn_blocking(move || { + std::fs::create_dir_all(path.parent().unwrap_or(&path)).map_err(|e| e.to_string())?; + append_line_rotating(&path, &line, MAX_LOG_BYTES)?; + Ok::(path.display().to_string()) + }) + .await + .map_err(|e| e.to_string())?; + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn shape_folds_in_git_sha_and_label() { + let line = shape_perf_log_line(r#"{"totalMs":412}"#, Some("abc123-dirty"), Some("before")) + .expect("shape"); + let value: serde_json::Value = serde_json::from_str(&line).expect("parse"); + assert_eq!(value["totalMs"], 412); + assert_eq!(value["gitSha"], "abc123-dirty"); + assert_eq!(value["label"], "before"); + assert!(!line.contains('\n')); + } + + #[test] + fn shape_without_label_or_sha_keeps_record_and_null_sha() { + let line = shape_perf_log_line(r#"{"totalMs":1}"#, None, None).expect("shape"); + let value: serde_json::Value = serde_json::from_str(&line).expect("parse"); + assert_eq!(value["gitSha"], serde_json::Value::Null); + assert!(value.get("label").is_none()); + } + + #[test] + fn shape_rejects_non_objects_and_oversized_records() { + assert!(shape_perf_log_line("[1,2]", None, None).is_err()); + assert!(shape_perf_log_line("not json", None, None).is_err()); + let oversized = format!(r#"{{"pad":"{}"}}"#, "x".repeat(MAX_RECORD_BYTES)); + assert!(shape_perf_log_line(&oversized, None, None).is_err()); + } + + #[test] + fn append_rotates_once_over_the_cap_and_keeps_one_generation() { + let dir = std::env::temp_dir().join(format!("perf-log-test-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("tempdir"); + let path = dir.join("switch-perf.jsonl"); + let _ = std::fs::remove_file(&path); + let _ = std::fs::remove_file(dir.join("switch-perf.jsonl.1")); + + append_line_rotating(&path, "first", 16).expect("append"); + append_line_rotating(&path, "second", 16).expect("append"); + // 12 bytes so far — under the cap, same file. + assert_eq!( + std::fs::read_to_string(&path).expect("read"), + "first\nsecond\n" + ); + + // Push past the cap; the next append must rotate. + append_line_rotating(&path, "third-is-long", 16).expect("append"); + append_line_rotating(&path, "fresh", 16).expect("append"); + assert_eq!(std::fs::read_to_string(&path).expect("read"), "fresh\n"); + assert_eq!( + std::fs::read_to_string(dir.join("switch-perf.jsonl.1")).expect("read rotated"), + "first\nsecond\nthird-is-long\n" + ); + + // A second rotation replaces the previous generation, never a third file. + append_line_rotating(&path, "overflow-the-cap!", 16).expect("append"); + append_line_rotating(&path, "newest", 16).expect("append"); + assert_eq!(std::fs::read_to_string(&path).expect("read"), "newest\n"); + assert_eq!( + std::fs::read_to_string(dir.join("switch-perf.jsonl.1")).expect("read rotated"), + "fresh\noverflow-the-cap!\n" + ); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn rotation_replaces_an_existing_retained_generation() { + let dir = std::env::temp_dir().join(format!( + "perf-log-regen-{}-{:?}", + std::process::id(), + std::thread::current().id() + )); + std::fs::create_dir_all(&dir).expect("tempdir"); + let path = dir.join("switch-perf.jsonl"); + let rotated = dir.join("switch-perf.jsonl.1"); + // Seed BOTH generations, as after any prior rollover. On Windows a + // bare rename onto the existing `.1` fails, which used to kill every + // subsequent append. + std::fs::write(&path, "current-full\n").expect("seed current"); + std::fs::write(&rotated, "old-generation\n").expect("seed rotated"); + + append_line_rotating(&path, "fresh", 8).expect("rotation over existing .1 must succeed"); + + assert_eq!(std::fs::read_to_string(&path).expect("read"), "fresh\n"); + assert_eq!( + std::fs::read_to_string(&rotated).expect("read rotated"), + "current-full\n" + ); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn concurrent_boundary_appends_lose_no_line_and_rotate_once() { + let dir = std::env::temp_dir().join(format!( + "perf-log-concurrent-{}-{:?}", + std::process::id(), + std::thread::current().id() + )); + std::fs::create_dir_all(&dir).expect("tempdir"); + let path = dir.join("switch-perf.jsonl"); + let _ = std::fs::remove_file(&path); + let _ = std::fs::remove_file(dir.join("switch-perf.jsonl.1")); + + // 8 writers × 4 lines of 16 bytes = 512 bytes against a 384-byte cap: + // exactly one rotation boundary is crossed, so every line must land in + // either the live file or the single rotated generation. Unserialized + // metadata→rename→append interleavings drop lines or fail renames. + let threads: Vec<_> = (0..8) + .map(|writer| { + let path = path.clone(); + std::thread::spawn(move || { + for line_index in 0..4 { + append_line_rotating( + &path, + &format!("writer-{writer:02}-line-{line_index:02}"), + 384, + ) + .expect("append"); + } + }) + }) + .collect(); + for thread in threads { + thread.join().expect("join"); + } + + let mut lines: Vec = std::fs::read_to_string(&path) + .expect("read live") + .lines() + .map(str::to_string) + .collect(); + if let Ok(rotated) = std::fs::read_to_string(dir.join("switch-perf.jsonl.1")) { + lines.extend(rotated.lines().map(str::to_string)); + } + lines.sort(); + let expected: Vec = (0..8) + .flat_map(|writer| { + (0..4).map(move |line_index| format!("writer-{writer:02}-line-{line_index:02}")) + }) + .collect(); + assert_eq!(lines, expected, "every append must survive the boundary"); + std::fs::remove_dir_all(&dir).ok(); + } +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 613040b8095..ac13a22c847 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -591,6 +591,7 @@ pub fn run() { search_users, get_presence, get_os_idle_seconds, + append_switch_perf_log, get_default_relay_url, auto_connect_default_relay_enabled, get_legacy_workspace_storage, diff --git a/desktop/src/app/navigation/useAppNavigation.ts b/desktop/src/app/navigation/useAppNavigation.ts index ade8c9332c0..ac2b16a78ca 100644 --- a/desktop/src/app/navigation/useAppNavigation.ts +++ b/desktop/src/app/navigation/useAppNavigation.ts @@ -14,6 +14,7 @@ import { traverseHistory, } from "@/app/navigation/navigationGuard"; import type { SearchHit } from "@/shared/api/types"; +import { beginChannelSwitchTrace } from "@/shared/lib/channelSwitchPerf"; type NavigationBehavior = { force?: boolean; @@ -286,6 +287,16 @@ export function useAppNavigation() { threadRootId?: string | null; }, ) => { + // Every channel navigation entry point funnels through here, so this + // is the single click-time anchor for the switch trace. Re-selecting + // the already-active channel is a no-op navigation: the channel's + // effects never rerun, nothing would settle the trace, and it would + // squat on the singleton until timeout — so don't open one. (History + // back/forward bypasses goChannel entirely and is deliberately + // untraced.) + if (!location.pathname.endsWith(`/channels/${channelId}`)) { + beginChannelSwitchTrace(channelId); + } return commitNavigation( { to: "/channels/$channelId", @@ -327,7 +338,7 @@ export function useAppNavigation() { : undefined, ); }, - [commitNavigation], + [commitNavigation, location.pathname], ); const goNewMessage = React.useCallback( diff --git a/desktop/src/features/channels/hooks.ts b/desktop/src/features/channels/hooks.ts index 9069b052da4..f08842355b4 100644 --- a/desktop/src/features/channels/hooks.ts +++ b/desktop/src/features/channels/hooks.ts @@ -42,6 +42,7 @@ import type { } from "@/shared/api/tauriChannels"; import { mergeConcurrentChannelRecency } from "@/features/channels/lib/channelRecencyMerge"; import { useIdentityQuery } from "@/shared/api/hooks"; +import { traceChannelMembersFetch } from "@/shared/lib/channelSwitchPerf"; import { useFocusedRefetchInterval } from "@/shared/lib/useDocumentVisible"; import { useCommunities } from "@/features/communities/useCommunities"; import { @@ -49,6 +50,10 @@ import { type ChannelSnapshot, writeChannelSnapshot, } from "@/features/channels/channelSnapshot"; +import { + markSnapshotDiagnostic, + measureFullSidebarPaint, +} from "@/features/channels/sidebarPerf"; import { CHANNEL_MEMBERS_STALE_TIME_MS, channelMembersQueryKey, @@ -97,73 +102,6 @@ export function sortChannels(channels: Channel[]) { }); } -export const CHANNELS_SNAPSHOT_DIAGNOSTIC_MARK = - "buzz:sidebar:snapshot-diagnostic"; -export const CHANNELS_FULL_SIDEBAR_PAINT_MARK = - "buzz:sidebar:full-list-painted"; -export const CHANNELS_BOOT_TO_FULL_SIDEBAR_MEASURE = - "buzz:sidebar:boot-to-full-list-painted"; - -const markedSnapshotKeys = new Set(); -const measuredSidebarKeys = new Set(); -const scheduledSidebarKeys = new Set(); - -function sidebarMeasurementKey(relayUrl: string, ownerPubkey: string): string { - return `${relayUrl}\u0000${ownerPubkey.toLowerCase()}`; -} - -function markSnapshotDiagnostic( - relayUrl: string, - ownerPubkey: string, - diagnostics: ReturnType["diagnostics"], -): void { - if (typeof performance === "undefined") return; - const key = sidebarMeasurementKey(relayUrl, ownerPubkey); - if (markedSnapshotKeys.has(key)) return; - markedSnapshotKeys.add(key); - performance.mark(CHANNELS_SNAPSHOT_DIAGNOSTIC_MARK, { - detail: { ...diagnostics, relayUrl }, - }); - console.info("[sidebar-perf] snapshot", { ...diagnostics, relayUrl }); -} - -function measureFullSidebarPaint( - relayUrl: string, - ownerPubkey: string, - channelCount: number, -): void { - if (typeof performance === "undefined") return; - const key = sidebarMeasurementKey(relayUrl, ownerPubkey); - if (measuredSidebarKeys.has(key) || scheduledSidebarKeys.has(key)) return; - scheduledSidebarKeys.add(key); - - // The channels have committed to the shared query cache; two animation frames - // put the mark after React's sidebar DOM commit and the browser's next paint. - window.requestAnimationFrame(() => { - window.requestAnimationFrame(() => { - scheduledSidebarKeys.delete(key); - if (measuredSidebarKeys.has(key)) return; - measuredSidebarKeys.add(key); - performance.mark(CHANNELS_FULL_SIDEBAR_PAINT_MARK, { - detail: { channelCount, relayUrl }, - }); - performance.measure(CHANNELS_BOOT_TO_FULL_SIDEBAR_MEASURE, { - detail: { channelCount, relayUrl }, - duration: performance.now(), - start: 0, - }); - const measure = performance - .getEntriesByName(CHANNELS_BOOT_TO_FULL_SIDEBAR_MEASURE) - .at(-1); - console.info("[sidebar-perf] full list painted", { - channelCount, - durationMs: measure?.duration, - relayUrl, - }); - }); - }); -} - export type CachedChannelMember = { membershipAdded: boolean; name: string; @@ -627,7 +565,15 @@ export function useChannelMembersQuery( throw new Error("No channel selected."); } - return getChannelMembers(channelId); + const fetchStartedAt = performance.now(); + const members = await getChannelMembers(channelId); + traceChannelMembersFetch( + channelId, + members.length, + performance.now() - fetchStartedAt, + fetchStartedAt, + ); + return members; }, staleTime: CHANNEL_MEMBERS_STALE_TIME_MS, }); diff --git a/desktop/src/features/channels/sidebarPerf.ts b/desktop/src/features/channels/sidebarPerf.ts new file mode 100644 index 00000000000..e2904fcd096 --- /dev/null +++ b/desktop/src/features/channels/sidebarPerf.ts @@ -0,0 +1,74 @@ +/** + * Sidebar boot-paint measurement: marks the persisted-snapshot read and the + * first fully-painted channel list per relay+identity. Split from hooks.ts to + * keep that file under the per-file line cap; behavior unchanged. + */ + +import type { inspectChannelSnapshot } from "@/features/channels/channelSnapshot"; + +export const CHANNELS_SNAPSHOT_DIAGNOSTIC_MARK = + "buzz:sidebar:snapshot-diagnostic"; +export const CHANNELS_FULL_SIDEBAR_PAINT_MARK = + "buzz:sidebar:full-list-painted"; +export const CHANNELS_BOOT_TO_FULL_SIDEBAR_MEASURE = + "buzz:sidebar:boot-to-full-list-painted"; + +const markedSnapshotKeys = new Set(); +const measuredSidebarKeys = new Set(); +const scheduledSidebarKeys = new Set(); + +function sidebarMeasurementKey(relayUrl: string, ownerPubkey: string): string { + return `${relayUrl}\u0000${ownerPubkey.toLowerCase()}`; +} + +export function markSnapshotDiagnostic( + relayUrl: string, + ownerPubkey: string, + diagnostics: ReturnType["diagnostics"], +): void { + if (typeof performance === "undefined") return; + const key = sidebarMeasurementKey(relayUrl, ownerPubkey); + if (markedSnapshotKeys.has(key)) return; + markedSnapshotKeys.add(key); + performance.mark(CHANNELS_SNAPSHOT_DIAGNOSTIC_MARK, { + detail: { ...diagnostics, relayUrl }, + }); + console.info("[sidebar-perf] snapshot", { ...diagnostics, relayUrl }); +} + +export function measureFullSidebarPaint( + relayUrl: string, + ownerPubkey: string, + channelCount: number, +): void { + if (typeof performance === "undefined") return; + const key = sidebarMeasurementKey(relayUrl, ownerPubkey); + if (measuredSidebarKeys.has(key) || scheduledSidebarKeys.has(key)) return; + scheduledSidebarKeys.add(key); + + // The channels have committed to the shared query cache; two animation frames + // put the mark after React's sidebar DOM commit and the browser's next paint. + window.requestAnimationFrame(() => { + window.requestAnimationFrame(() => { + scheduledSidebarKeys.delete(key); + if (measuredSidebarKeys.has(key)) return; + measuredSidebarKeys.add(key); + performance.mark(CHANNELS_FULL_SIDEBAR_PAINT_MARK, { + detail: { channelCount, relayUrl }, + }); + performance.measure(CHANNELS_BOOT_TO_FULL_SIDEBAR_MEASURE, { + detail: { channelCount, relayUrl }, + duration: performance.now(), + start: 0, + }); + const measure = performance + .getEntriesByName(CHANNELS_BOOT_TO_FULL_SIDEBAR_MEASURE) + .at(-1); + console.info("[sidebar-perf] full list painted", { + channelCount, + durationMs: measure?.duration, + relayUrl, + }); + }); + }); +} diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 9c2d25dd7cf..f76768d73f4 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -71,6 +71,7 @@ import { useHuddleReadMarker } from "@/features/channels/ui/useHuddleReadMarker" import { useHuddleThreadIsolation } from "@/features/channels/ui/useHuddleThreadIsolation"; import { AgentSessionProvider } from "@/shared/context/AgentSessionContext"; import { ProfilePanelProvider } from "@/shared/context/ProfilePanelContext"; +import { useChannelSwitchTraceMarks } from "@/features/channels/useChannelSwitchTraceMarks"; import { useMainInsetRef } from "@/shared/layout/MainInsetContext"; import { channelContentTopPaddingMeasurement } from "@/shared/layout/chromeLayout"; import { useMeasuredCssVariable } from "@/shared/layout/useMeasuredCssVariable"; @@ -639,6 +640,11 @@ export function ChannelScreen({ timelineLoadingNow, ); settledChannelIdRef.current = settledChannelId; + useChannelSwitchTraceMarks({ + activeChannelId, + activeChannelType: activeChannel?.channelType ?? null, + isTimelineLoading, + }); const { welcomeKickoffStage, welcomeKickoffSettingUp } = useWelcomeKickoffStagePresence( activeChannel, diff --git a/desktop/src/features/channels/useChannelSwitchTraceMarks.ts b/desktop/src/features/channels/useChannelSwitchTraceMarks.ts new file mode 100644 index 00000000000..bc49f6d2d6e --- /dev/null +++ b/desktop/src/features/channels/useChannelSwitchTraceMarks.ts @@ -0,0 +1,54 @@ +import * as React from "react"; + +import { + abandonChannelSwitchTrace, + markChannelSwitchRouteCommit, + settleChannelSwitchTrace, +} from "@/shared/lib/channelSwitchPerf"; +import type { ChannelType } from "@/shared/api/types"; + +/** + * Switch-trace stage marks for the channel screen. Route commit fires on the + * first render for the target channel; settle fires once its timeline leaves + * the loading latch. Both are no-ops unless goChannel opened a trace for this + * channel. Forum readiness is owned by ForumView's own queries, which the + * timeline latch cannot observe — those traces are abandoned instead of + * underreported. + */ +export function useChannelSwitchTraceMarks({ + activeChannelId, + activeChannelType, + isTimelineLoading, +}: { + activeChannelId: string | null; + activeChannelType: ChannelType | null; + isTimelineLoading: boolean; +}): void { + React.useEffect(() => { + if (activeChannelId) markChannelSwitchRouteCommit(activeChannelId); + }, [activeChannelId]); + // Route-exit cancellation: leaving the channel surface before the trace + // settles (Projects, Home, … — none of which call goChannel) must drop the + // trace. Otherwise a history-back into the same channel within the trace + // timeout matches the stale singleton and records the time spent away as + // switch latency. Keyed per channel id: on an A→B switch this cleanup runs + // with A's id after B's trace already began, so it only ever abandons its + // own channel's trace. + React.useEffect(() => { + if (!activeChannelId) return; + const channelId = activeChannelId; + return () => { + abandonChannelSwitchTrace(channelId); + }; + }, [activeChannelId]); + React.useEffect(() => { + if (!activeChannelId) return; + if (activeChannelType === "forum") { + abandonChannelSwitchTrace(activeChannelId); + return; + } + if (!isTimelineLoading) { + settleChannelSwitchTrace(activeChannelId); + } + }, [activeChannelId, activeChannelType, isTimelineLoading]); +} diff --git a/desktop/src/features/communities/useCommunityInit.ts b/desktop/src/features/communities/useCommunityInit.ts index c565ee0f7b4..eb987378955 100644 --- a/desktop/src/features/communities/useCommunityInit.ts +++ b/desktop/src/features/communities/useCommunityInit.ts @@ -34,6 +34,7 @@ import { resetAgentObserverStore } from "@/features/agents/observerRelayStore"; import { resetAvatarPresentations } from "@/features/profile/avatarPresentationStore"; import { resetAvatarProfileSync } from "@/features/profile/avatarProfileSync"; import { resetSidebarRelayConnectionCardState } from "@/features/sidebar/ui/useSidebarRelayConnectionCard"; +import { resetChannelSwitchTrace } from "@/shared/lib/channelSwitchPerf"; import { clearMarkdownNodeCache } from "@/shared/ui/markdown/nodeCache"; import { resetMessageLinkMetadataCache } from "@/shared/ui/markdown/useMessageLinkMetadata"; import { resetVideoPlayerState } from "@/shared/ui/videoPlayerState"; @@ -57,6 +58,10 @@ async function resetCommunityState({ resetAvatarState: boolean; }): Promise { relayClient.disconnect(); + // Before the first await: the trace singleton must not survive into the + // async teardown window — queued frame callbacks could still record against + // it, and a rejection below would skip any reset placed after the await. + resetChannelSwitchTrace(); await resetNavigationDeepLinkDrain(); resetRateLimitGate(); clearAllDrafts(); diff --git a/desktop/src/features/messages/hooks.ts b/desktop/src/features/messages/hooks.ts index d28f2926081..9a2de67d97f 100644 --- a/desktop/src/features/messages/hooks.ts +++ b/desktop/src/features/messages/hooks.ts @@ -44,6 +44,7 @@ import { recordTimeoutFromRejection, } from "@/features/moderation/lib/timeoutStore"; import { relayClient, setVisibleChannel } from "@/shared/api/relayClient"; +import { traceChannelWindowFetch } from "@/shared/lib/channelSwitchPerf"; import { customEmojiQueryKey } from "@/features/custom-emoji/hooks"; import { channelsQueryKey } from "@/features/channels/hooks"; import { reactionEmojiUrl } from "@/shared/api/customEmoji"; @@ -301,14 +302,27 @@ export function useChannelMessagesQuery(channel: Channel | null) { } const previousMessages = queryClient.getQueryData(queryKey) ?? []; + const fetchStartedAt = performance.now(); const events = await getChannelWindowEvents(channel.id); - return reconcileFetchedChannelWindow( + const fetchDurationMs = performance.now() - fetchStartedAt; + const result = reconcileFetchedChannelWindow( queryClient, channel.id, events, previousMessages, signal, ); + // Attribute only ACCEPTED fetches: reconciliation throws for aborted + // requests, and a canceled fetch that claimed the trace's one-shot + // attribution slot would block the accepted replacement from being + // recorded. Duration still measures the fetch alone, captured above. + traceChannelWindowFetch( + channel.id, + events.length, + fetchDurationMs, + fetchStartedAt, + ); + return result; }, staleTime: 5 * 60 * 1_000, gcTime: 60 * 60 * 1_000, diff --git a/desktop/src/features/messages/lib/projectChannelWindow.test.mjs b/desktop/src/features/messages/lib/projectChannelWindow.test.mjs index 618f3fc9912..24ea75edc03 100644 --- a/desktop/src/features/messages/lib/projectChannelWindow.test.mjs +++ b/desktop/src/features/messages/lib/projectChannelWindow.test.mjs @@ -416,3 +416,85 @@ test("test_concurrent_refreshes_after_seeded_snapshot_share_one_authoritative_fe unsubscribe(); } }); + +test("canceled fetch never claims the switch trace's window slot; the accepted one does", async () => { + // Mirror of channelMessagesQueryOptions' queryFn contract: reconciliation + // throws for aborted requests BEFORE the fetch is attributed, so a canceled + // request cannot claim the trace's one-shot `windowFetch` slot and block + // the accepted replacement. + const frames = []; + const originalWindow = globalThis.window; + const originalDocument = globalThis.document; + globalThis.window = { + requestAnimationFrame: (cb) => frames.push(cb) && frames.length, + cancelAnimationFrame: () => {}, + }; + globalThis.document = { querySelector: () => null }; + const { + beginChannelSwitchTrace, + settleChannelSwitchTrace, + resetChannelSwitchTrace, + traceChannelWindowFetch, + CHANNEL_SWITCH_MEASURE, + } = await import("../../../shared/lib/channelSwitchPerf.ts"); + performance.clearMeasures?.(CHANNEL_SWITCH_MEASURE); + try { + const client = new QueryClient(); + const channelId = "channel"; // matches wirePage's bounds key + beginChannelSwitchTrace(channelId); + + // Canceled-first: the queryFn reconciles BEFORE attributing; the aborted + // signal throws, so trace attribution is never reached. + const canceled = new AbortController(); + canceled.abort(); + const canceledEvents = wirePage([event("stale", 100)]); + const startedAt = performance.now(); + assert.throws(() => { + reconcileFetchedChannelWindow( + client, + channelId, + canceledEvents, + [], + canceled.signal, + ); + traceChannelWindowFetch(channelId, canceledEvents.length, 1, startedAt); + }); + + // Accepted-second: reconciles cleanly, then claims the slot. + const acceptedEvents = wirePage([ + event("fresh-2", 120), + event("fresh-1", 110), + ]); + reconcileFetchedChannelWindow( + client, + channelId, + acceptedEvents, + [], + new AbortController().signal, + ); + traceChannelWindowFetch( + channelId, + acceptedEvents.length, + 2, + performance.now(), + ); + + settleChannelSwitchTrace(channelId); + for (let i = 0; i < 10 && frames.length > 0; i += 1) { + for (const cb of frames.splice(0, frames.length)) cb(); + } + const measure = performance.getEntriesByName(CHANNEL_SWITCH_MEASURE).at(-1); + assert.equal( + measure?.detail?.windowFetch?.eventCount, + acceptedEvents.length, + "the accepted fetch owns the attribution slot", + ); + resetChannelSwitchTrace(); + } finally { + performance.clearMeasures?.(CHANNEL_SWITCH_MEASURE); + if (originalWindow === undefined) delete globalThis.window; + else globalThis.window = originalWindow; + if (originalDocument === undefined) delete globalThis.document; + else globalThis.document = originalDocument; + } +}); diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index fa8bb4e9f6d..8222dcdd223 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -693,7 +693,15 @@ const MessageTimelineBase = React.forwardRef< return ( -
+ {/* The render-pending marker must live on this always-mounted wrapper: + during the skeleton→loaded transition the message-list branches (and + their own markers) are not mounted yet, and the switch tracer would + read "not pending" and record a settle before the heavy deferred + list ever committed or painted. */} +
{showUnreadPill ? (
{useTimelineVirtualizer && timelineList ? ( -
- {timelineList} -
+
{timelineList}
) : (
{timelineList}
diff --git a/desktop/src/shared/lib/channelSwitchPerf.test.mjs b/desktop/src/shared/lib/channelSwitchPerf.test.mjs new file mode 100644 index 00000000000..586ae70a324 --- /dev/null +++ b/desktop/src/shared/lib/channelSwitchPerf.test.mjs @@ -0,0 +1,216 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + shouldAttributeFetch, + buildSwitchPerfLogRecord, + resolveSettleAction, + summarizeChannelSwitchTrace, +} from "./channelSwitchPerf.ts"; + +function trace(overrides = {}) { + return { + channelId: "abcdef1234567890", + startedAt: 1_000, + routeCommitAt: null, + windowFetch: null, + membersFetch: null, + ...overrides, + }; +} + +test("summary reports total and cache-served fetches", () => { + const summary = summarizeChannelSwitchTrace(trace(), 1_412.4); + assert.equal( + summary, + "[switch-perf] channel=abcdef12 total=412ms commit=? window=cache members=cache", + ); +}); + +test("summary includes route commit offset and fetch timings", () => { + const summary = summarizeChannelSwitchTrace( + trace({ + routeCommitAt: 1_038, + windowFetch: { durationMs: 180.6, eventCount: 250 }, + membersFetch: { durationMs: 320.2, memberCount: 10_000 }, + }), + 1_912, + ); + assert.equal( + summary, + "[switch-perf] channel=abcdef12 total=912ms commit=+38ms " + + "window=250 events in 181ms members=10000 members in 320ms", + ); +}); + +test("log record carries rounded stage timings and fetch attributions", () => { + const record = buildSwitchPerfLogRecord( + trace({ + routeCommitAt: 1_038.4, + windowFetch: { durationMs: 180.6, eventCount: 250 }, + membersFetch: { durationMs: 320.2, memberCount: 10_000 }, + }), + 1_912.3, + ); + assert.equal(record.channelId, "abcdef1234567890"); + assert.equal(record.totalMs, 912); + assert.equal(record.commitOffsetMs, 38); + assert.deepEqual(record.windowFetch, { durationMs: 181, eventCount: 250 }); + assert.deepEqual(record.membersFetch, { + durationMs: 320, + memberCount: 10_000, + }); + assert.equal(typeof record.ts, "string"); +}); + +test("log record marks cache-served fetches and missing commit as null", () => { + const record = buildSwitchPerfLogRecord(trace(), 1_412); + assert.equal(record.commitOffsetMs, null); + assert.equal(record.windowFetch, null); + assert.equal(record.membersFetch, null); +}); + +test("settle resolves only the trace for the settled channel", () => { + const active = trace(); + assert.deepEqual(resolveSettleAction(active, "abcdef1234567890", 2_000), { + settledTrace: active, + clearActive: true, + }); + assert.deepEqual(resolveSettleAction(null, "abcdef1234567890", 2_000), { + settledTrace: null, + clearActive: false, + }); +}); + +test("a mismatched settle never clobbers a newer switch's trace", () => { + // Channel A settles after the user already clicked channel B: B's trace + // must survive so B still gets measured. + const nextSwitch = trace({ channelId: "bbbb0000bbbb0000" }); + assert.deepEqual(resolveSettleAction(nextSwitch, "abcdef1234567890", 2_000), { + settledTrace: null, + clearActive: false, + }); +}); + +test("settle drops a trace that has timed out", () => { + const stale = trace({ startedAt: 1_000 }); + assert.deepEqual(resolveSettleAction(stale, "abcdef1234567890", 31_001), { + settledTrace: null, + clearActive: true, + }); + assert.deepEqual( + resolveSettleAction(stale, "abcdef1234567890", 11_000).settledTrace, + stale, + ); +}); + +test("fetches attribute only when started after the switch began", () => { + const active = trace({ channelId: "abcdef1234567890", startedAt: 1_000 }); + // Started before the switch (stale A→B→A leg): not attributable. + assert.equal(shouldAttributeFetch(active, "abcdef1234567890", 999), false); + // Started at/after the switch: attributable. + assert.equal(shouldAttributeFetch(active, "abcdef1234567890", 1_000), true); + assert.equal(shouldAttributeFetch(active, "abcdef1234567890", 1_500), true); + // Other channel or no trace: never. + assert.equal(shouldAttributeFetch(active, "bbbb0000bbbb0000", 1_500), false); + assert.equal(shouldAttributeFetch(null, "abcdef1234567890", 1_500), false); +}); + +// --- Settle lifecycle: rapid switches and community resets ---------------- + +async function withSettleHarness(run) { + const frames = []; + const originalWindow = globalThis.window; + const originalDocument = globalThis.document; + globalThis.window = { + requestAnimationFrame: (cb) => frames.push(cb) && frames.length, + cancelAnimationFrame: () => {}, + }; + globalThis.document = { querySelector: () => null }; + const { + abandonChannelSwitchTrace, + beginChannelSwitchTrace, + settleChannelSwitchTrace, + resetChannelSwitchTrace, + CHANNEL_SWITCH_MEASURE, + } = await import("./channelSwitchPerf.ts"); + performance.clearMeasures?.(CHANNEL_SWITCH_MEASURE); + const flush = () => { + // Drain chained rAFs until quiescent. + for (let i = 0; i < 20 && frames.length > 0; i += 1) { + for (const cb of frames.splice(0, frames.length)) cb(); + } + }; + const measures = () => + performance + .getEntriesByName(CHANNEL_SWITCH_MEASURE) + .map((entry) => entry.detail?.channelId); + try { + await run({ + abandon: abandonChannelSwitchTrace, + begin: beginChannelSwitchTrace, + settle: settleChannelSwitchTrace, + reset: resetChannelSwitchTrace, + flush, + measures, + }); + } finally { + resetChannelSwitchTrace(); + performance.clearMeasures?.(CHANNEL_SWITCH_MEASURE); + if (originalWindow === undefined) delete globalThis.window; + else globalThis.window = originalWindow; + if (originalDocument === undefined) delete globalThis.document; + else globalThis.document = originalDocument; + } +} + +test("a switch begun during A's deferred wait drops A's record (no clock theft)", async () => { + await withSettleHarness(async ({ begin, settle, flush, measures }) => { + begin("aaaa1111aaaa1111"); + settle("aaaa1111aaaa1111"); // A's deferred-paint wait is now queued + begin("bbbb2222bbbb2222"); // rapid follow-up switch replaces the trace + flush(); + // A must NOT be recorded: its settledAt would be sampled from B's + // timeline, charging B's delay to A. + assert.deepEqual(measures(), []); + settle("bbbb2222bbbb2222"); + flush(); + assert.deepEqual(measures(), ["bbbb2222bbbb2222"]); + }); +}); + +test("a community reset during the deferred wait drops the record", async () => { + await withSettleHarness(async ({ begin, settle, reset, flush, measures }) => { + begin("aaaa1111aaaa1111"); + settle("aaaa1111aaaa1111"); + reset(); + flush(); + assert.deepEqual(measures(), []); + }); +}); + +test("an undisturbed settle records exactly one measure", async () => { + await withSettleHarness(async ({ begin, settle, flush, measures }) => { + begin("aaaa1111aaaa1111"); + settle("aaaa1111aaaa1111"); + flush(); + assert.deepEqual(measures(), ["aaaa1111aaaa1111"]); + }); +}); + +test("leaving the channel surface abandons the trace; history-back records nothing", async () => { + await withSettleHarness( + async ({ abandon, begin, settle, flush, measures }) => { + begin("aaaa1111aaaa1111"); + // Route exit (Projects/Home): the channel screen unmounts before the + // trace settled and abandons it. + abandon("aaaa1111aaaa1111"); + // History-back re-enters the channel without goChannel; its settle must + // find no trace — otherwise the time spent away would be recorded as + // switch latency. + settle("aaaa1111aaaa1111"); + flush(); + assert.deepEqual(measures(), []); + }, + ); +}); diff --git a/desktop/src/shared/lib/channelSwitchPerf.ts b/desktop/src/shared/lib/channelSwitchPerf.ts new file mode 100644 index 00000000000..015537179a0 --- /dev/null +++ b/desktop/src/shared/lib/channelSwitchPerf.ts @@ -0,0 +1,297 @@ +/** + * Channel-switch tracing: measures click → settled-paint for channel + * navigations, with the two relay fetches that can sit on that path + * (message window, member roster) attributed to the switch. + * + * One trace is active at a time; `beginChannelSwitchTrace` (called from + * `goChannel`) opens it and `settleChannelSwitchTrace` (called when the + * timeline settles for that channel) closes it after the next paint. Fetch + * traces and settles for non-active channels are ignored, so background + * refetches never pollute a switch measurement. + * + * Output per switch: a `[switch-perf]` console line plus User Timing + * marks/measures (`buzz:channel-switch:*`) so Playwright perf specs and the + * Performance panel can read the same numbers. + * + * Attribution window: fetches are credited to a switch only when they finish + * before the settled paint. A roster fetch that completes after settle is + * deliberately not part of the felt switch latency, so such switches report + * `members=cache` — by design, not omission. + */ + +export type ChannelSwitchFetchTrace = { + durationMs: number; + eventCount?: number; + memberCount?: number; +}; + +import { invoke, isTauri } from "@tauri-apps/api/core"; + +export type ChannelSwitchTrace = { + channelId: string; + startedAt: number; + routeCommitAt: number | null; + windowFetch: { durationMs: number; eventCount: number } | null; + membersFetch: { durationMs: number; memberCount: number } | null; +}; + +/** A switch that hasn't settled after this long is abandoned, not measured. */ +const SWITCH_TRACE_TIMEOUT_MS = 30_000; + +export const CHANNEL_SWITCH_START_MARK = "buzz:channel-switch:start"; +export const CHANNEL_SWITCH_SETTLED_MARK = "buzz:channel-switch:settled"; +export const CHANNEL_SWITCH_MEASURE = "buzz:channel-switch:click-to-settled"; + +let activeTrace: ChannelSwitchTrace | null = null; + +/** Formats one settled trace as the `[switch-perf]` console line. */ +export function summarizeChannelSwitchTrace( + trace: ChannelSwitchTrace, + settledAt: number, +): string { + const total = Math.round(settledAt - trace.startedAt); + const commit = + trace.routeCommitAt === null + ? "?" + : `+${Math.round(trace.routeCommitAt - trace.startedAt)}ms`; + const window = + trace.windowFetch === null + ? "cache" + : `${trace.windowFetch.eventCount} events in ${Math.round(trace.windowFetch.durationMs)}ms`; + const members = + trace.membersFetch === null + ? "cache" + : `${trace.membersFetch.memberCount} members in ${Math.round(trace.membersFetch.durationMs)}ms`; + return ( + `[switch-perf] channel=${trace.channelId.slice(0, 8)} total=${total}ms ` + + `commit=${commit} window=${window} members=${members}` + ); +} + +/** + * The JSONL record persisted per settled switch. The backend folds in the + * build's git revision and the optional BUZZ_PERF_LOG_LABEL run label, so + * before/after sessions are attributable offline. Pure for unit testing. + */ +export function buildSwitchPerfLogRecord( + trace: ChannelSwitchTrace, + settledAt: number, +): { + ts: string; + channelId: string; + totalMs: number; + commitOffsetMs: number | null; + windowFetch: { durationMs: number; eventCount: number } | null; + membersFetch: { durationMs: number; memberCount: number } | null; +} { + return { + ts: new Date().toISOString(), + channelId: trace.channelId, + totalMs: Math.round(settledAt - trace.startedAt), + commitOffsetMs: + trace.routeCommitAt === null + ? null + : Math.round(trace.routeCommitAt - trace.startedAt), + windowFetch: trace.windowFetch + ? { + durationMs: Math.round(trace.windowFetch.durationMs), + eventCount: trace.windowFetch.eventCount, + } + : null, + membersFetch: trace.membersFetch + ? { + durationMs: Math.round(trace.membersFetch.durationMs), + memberCount: trace.membersFetch.memberCount, + } + : null, + }; +} + +let hasAnnouncedLogPath = false; + +/** Fire-and-forget JSONL append; diagnostics must never surface failures. */ +function appendSwitchPerfLogRecord(record: Record): void { + if (!isTauri()) return; + void invoke("append_switch_perf_log", { + recordJson: JSON.stringify(record), + }) + .then((path) => { + if (!hasAnnouncedLogPath) { + hasAnnouncedLogPath = true; + console.info(`[switch-perf] logging to ${path}`); + } + }) + .catch(() => {}); +} + +/** + * Decides what a settle call does with the active trace. A settle for a + * different channel must leave the trace alone — a previous channel can + * finish loading after the next switch already began, and clobbering the + * newer trace would silently drop exactly the slow/rapid switches this + * instrumentation exists to capture. Only the settled channel's own trace is + * consumed (measured, or dropped when timed out). Pure so the attribution + * rules are unit-testable. + */ +export function resolveSettleAction( + trace: ChannelSwitchTrace | null, + channelId: string, + now: number, +): { settledTrace: ChannelSwitchTrace | null; clearActive: boolean } { + if (!trace || trace.channelId !== channelId) { + return { settledTrace: null, clearActive: false }; + } + if (now - trace.startedAt > SWITCH_TRACE_TIMEOUT_MS) { + return { settledTrace: null, clearActive: true }; + } + return { settledTrace: trace, clearActive: true }; +} + +/** + * Drops the active trace for surfaces whose readiness this instrument cannot + * observe (e.g. forum channels, whose loading is owned by ForumView's own + * queries). Better no measurement than a systematically underreported one. + */ +export function abandonChannelSwitchTrace(channelId: string): void { + if (activeTrace?.channelId === channelId) { + activeTrace = null; + } +} + +export function beginChannelSwitchTrace(channelId: string): void { + if (typeof performance === "undefined") return; + activeTrace = { + channelId, + startedAt: performance.now(), + routeCommitAt: null, + windowFetch: null, + membersFetch: null, + }; + performance.mark(CHANNEL_SWITCH_START_MARK, { detail: { channelId } }); +} + +export function markChannelSwitchRouteCommit(channelId: string): void { + if (typeof performance === "undefined") return; + if (!activeTrace || activeTrace.channelId !== channelId) return; + if (activeTrace.routeCommitAt !== null) return; + activeTrace.routeCommitAt = performance.now(); +} + +/** + * A fetch attributes to the active trace only when it targets the traced + * channel AND started after the switch began. A fetch that started before + * the switch (e.g. the first leg of a rapid A→B→A completing during the + * second A trace) is not this switch's cost; letting it claim the `??=` + * slot would also block the real fetch. Pure for unit testing. + */ +export function shouldAttributeFetch( + trace: ChannelSwitchTrace | null, + channelId: string, + fetchStartedAt: number, +): trace is ChannelSwitchTrace { + if (!trace || trace.channelId !== channelId) return false; + return fetchStartedAt >= trace.startedAt; +} + +export function traceChannelWindowFetch( + channelId: string, + eventCount: number, + durationMs: number, + fetchStartedAt: number, +): void { + if (!shouldAttributeFetch(activeTrace, channelId, fetchStartedAt)) return; + activeTrace.windowFetch ??= { durationMs, eventCount }; +} + +export function traceChannelMembersFetch( + channelId: string, + memberCount: number, + durationMs: number, + fetchStartedAt: number, +): void { + if (!shouldAttributeFetch(activeTrace, channelId, fetchStartedAt)) return; + activeTrace.membersFetch ??= { durationMs, memberCount }; +} + +/** + * Drops any active trace. Community switches remount the app shell but this + * module-level singleton survives; channel ids are community-scoped, so a + * stale trace could adopt the next community's fetches. Wired into + * resetCommunityState() like every community-scoped singleton. + */ +export function resetChannelSwitchTrace(): void { + activeTrace = null; +} + +/** Bound on waiting for the deferred timeline commit before recording. */ +const SETTLE_RENDER_WAIT_MS = 5_000; + +/** + * Closes the active trace once the settled frame has painted. The timeline + * renders rows through a deferred snapshot that exposes + * `data-render-pending` until the low-priority commit catches up — waiting + * for it (bounded) keeps `totalMs` honest on render-heavy switches; a final + * rAF pair then lands the mark after the browser paints. + */ +export function settleChannelSwitchTrace(channelId: string): void { + if (typeof performance === "undefined") return; + const { settledTrace, clearActive } = resolveSettleAction( + activeTrace, + channelId, + performance.now(), + ); + if (!settledTrace) { + if (clearActive) activeTrace = null; + return; + } + const trace = settledTrace; + if (typeof window === "undefined") { + activeTrace = null; + return; + } + // Keep the trace active through the deferred-commit wait so fetches that + // finish inside the measured window still attribute to it. It is released + // when the record lands; a newer switch's begin() simply replaces it. + const waitDeadline = performance.now() + SETTLE_RENDER_WAIT_MS; + const record = () => { + const settledAt = performance.now(); + if (activeTrace === trace) activeTrace = null; + performance.mark(CHANNEL_SWITCH_SETTLED_MARK, { + detail: { channelId }, + }); + performance.measure(CHANNEL_SWITCH_MEASURE, { + detail: { + channelId, + routeCommitAt: trace.routeCommitAt, + windowFetch: trace.windowFetch, + membersFetch: trace.membersFetch, + }, + start: trace.startedAt, + end: settledAt, + }); + console.info(summarizeChannelSwitchTrace(trace, settledAt)); + appendSwitchPerfLogRecord(buildSwitchPerfLogRecord(trace, settledAt)); + }; + const awaitDeferredCommit = () => { + if (activeTrace !== trace) { + // A newer switch replaced this trace, or a community reset dropped it. + // Either way the paint this callback would sample is not this switch's + // own — recording would charge the replacement's delay to the settled + // channel and could manufacture the very regression the tracer exists + // to diagnose. Better no measurement than a fabricated one. + return; + } + if ( + performance.now() < waitDeadline && + document.querySelector('[data-render-pending="true"]') !== null + ) { + window.requestAnimationFrame(awaitDeferredCommit); + return; + } + window.requestAnimationFrame(() => { + if (activeTrace !== trace) return; + record(); + }); + }; + window.requestAnimationFrame(awaitDeferredCommit); +} diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 94f6c1fcc4b..30f3b96746a 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -12040,6 +12040,9 @@ export function maybeInstallE2eTauriMocks() { }, activeConfig, ); + case "append_switch_perf_log": + // Perf-trace JSONL sink — a real file makes no sense in mock runs. + return "/mock/switch-perf.jsonl"; case "get_os_idle_seconds": // e2e runs headless with no OS idle API; the presence hook falls back // to in-app activity tracking. diff --git a/desktop/tests/e2e/switch-settle-after-paint.spec.ts b/desktop/tests/e2e/switch-settle-after-paint.spec.ts new file mode 100644 index 00000000000..ce8e223df65 --- /dev/null +++ b/desktop/tests/e2e/switch-settle-after-paint.spec.ts @@ -0,0 +1,58 @@ +import { expect, test } from "@playwright/test"; + +import { installMockBridge } from "../helpers/bridge"; + +/** + * The switch trace must settle AFTER the deferred timeline has committed and + * painted. During the skeleton→loaded transition the message-list branches + * (which used to own the `data-render-pending` marker) are not mounted, so a + * tracer polling only that marker would read "not pending" and record a + * settle while the heavy deferred list was still uncommitted — underreporting + * exactly the switches the tracer exists to measure. The marker now lives on + * the timeline's always-mounted wrapper; this spec pins the contract on a + * real empty→loaded cold switch into a deep channel. + */ + +const SWITCH_MEASURE = "buzz:channel-switch:click-to-settled"; + +test("cold-switch settle measure lands only after rows are painted", async ({ + page, +}) => { + await installMockBridge(page, { deepHistoryMessageCount: 600 }); + await page.goto("/"); + await expect(page.getByTestId("app-sidebar")).toBeVisible(); + + // Cold first entry: skeleton → deferred list commit → settled paint. + await page.getByTestId("channel-deep-history").click(); + + // Poll for the settle measure inside the page and — in the same synchronous + // evaluation turn — snapshot what the DOM shows at that moment. Reading the + // DOM from the test process after the fact would race further renders. + const atSettle = await page.evaluate(async (measureName) => { + const deadline = Date.now() + 15_000; + while (Date.now() < deadline) { + if (performance.getEntriesByName(measureName).length > 0) { + return { + renderPending: + document.querySelector('[data-render-pending="true"]') !== null, + rowCount: document.querySelectorAll( + '[data-message-id^="mock-deep-history-"]', + ).length, + settled: true, + }; + } + await new Promise((resolve) => setTimeout(resolve, 16)); + } + return { renderPending: true, rowCount: 0, settled: false }; + }, SWITCH_MEASURE); + + expect(atSettle.settled, "switch trace must settle").toBe(true); + expect( + atSettle.rowCount, + "settle must not be recorded before the deferred list painted", + ).toBeGreaterThan(0); + expect( + atSettle.renderPending, + "settle must not be recorded while a deferred commit is still pending", + ).toBe(false); +}); From 0cfabee5f74c00d2dc4e974d0fb78b6038f9c448 Mon Sep 17 00:00:00 2001 From: Max Lampert Date: Thu, 20 Aug 2026 13:59:09 -0700 Subject: [PATCH 02/27] test(desktop): high-membership channel-switch perf harness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an inflateChannelMembers mock-bridge knob (channel name → target member count; synthetic hex-pubkey members appended once, on first channel read) and a member-heavy-switch perf spec that measures warm switch wall time and longtasks for channel↔channel and channel↔Projects at baseline / 2k / 10k members per channel, holding message volume constant at 150 rows. Method mirrors warm-switch-markdown.perf.ts: in-page click + rAF polling, 4x CPU throttle, medians over 16 switches after an untimed warmup round-trip. Run from desktop/: pnpm build:e2e npx playwright test --config=playwright.perf.config.ts member-heavy-switch.perf.ts Signed-off-by: Max Lampert --- desktop/src/testing/e2eBridge.ts | 32 ++ desktop/tests/e2e/member-heavy-switch.perf.ts | 290 ++++++++++++++++++ desktop/tests/helpers/bridge.ts | 2 + 3 files changed, 324 insertions(+) create mode 100644 desktop/tests/e2e/member-heavy-switch.perf.ts diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 30f3b96746a..877f3c9a19c 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -345,6 +345,10 @@ type E2eConfig = { honorChannelsKnownHash?: boolean; /** Number of seeded rows in the deep-history fixture. Defaults to 600. */ deepHistoryMessageCount?: number; + /** Channel name → target member count. Appends synthetic members until + * each named channel reaches its target; perf specs use this to model + * high-membership channels. Applied once, on first channel read. */ + inflateChannelMembers?: Record; feedReadError?: string; canvasReadError?: string; /** Delay (ms) for `apply_workspace` so e2e tests can observe the @@ -2620,11 +2624,39 @@ function listMockProfiles(): RawProfile[] { .filter((profile): profile is RawProfile => profile !== null); } +let memberInflationApplied = false; + +/** + * One-shot high-membership inflation for perf specs. Reads + * `mock.inflateChannelMembers` (channel name → target member count) and + * appends synthetic hex-pubkey members until each named channel reaches its + * target. Runs lazily on the first channel read so it sees the final config. + */ +function ensureInflatedChannelMembers(): void { + if (memberInflationApplied) return; + const inflation = getConfig()?.mock?.inflateChannelMembers; + if (!inflation) return; + memberInflationApplied = true; + for (const [name, targetCount] of Object.entries(inflation)) { + const channel = mockChannels.find((candidate) => candidate.name === name); + if (!channel) continue; + for (let index = channel.members.length; index < targetCount; index += 1) { + // "ab" prefix + zero-padded hex index: unique, hex-valid, and disjoint + // from every fixture pubkey. + const pubkey = `ab${index.toString(16).padStart(62, "0")}`; + channel.members.push(createMockMember(pubkey, "member", 500)); + } + syncMockChannel(channel); + } +} + function listMockChannels(config?: E2eConfig): RawChannelWithMembership[] { + ensureInflatedChannelMembers(); return mockChannels.map((channel) => toRawChannel(channel, config)); } function getMockChannel(channelId: string): MockChannel { + ensureInflatedChannelMembers(); const channel = mockChannels.find((candidate) => candidate.id === channelId); if (!channel) { throw new Error(`Channel ${channelId} not found.`); diff --git a/desktop/tests/e2e/member-heavy-switch.perf.ts b/desktop/tests/e2e/member-heavy-switch.perf.ts new file mode 100644 index 00000000000..a12720b2638 --- /dev/null +++ b/desktop/tests/e2e/member-heavy-switch.perf.ts @@ -0,0 +1,290 @@ +import { expect, test } from "@playwright/test"; + +import { installMockBridge } from "../helpers/bridge"; + +/** + * High-membership channel-switch benchmark. + * + * Isolates how channel MEMBERSHIP SIZE scales the warm-switch cost, holding + * message volume constant. Every channel object embeds its full + * member-pubkey array, so membership size inflates (a) the get_channels + * payload parsed on every poll, (b) the per-switch get_channel_members + * response, and (c) every render-path pass over `channel.memberPubkeys` and + * the member list (profile merges, agent-flag merges, mention candidates). + * This spec is the instrument for that scaling: same channels, same rows, + * member count is the only variable. + * + * Two scenarios per member count: + * channel<->channel — general <-> deep-history (150 fixed rows). + * channel<->projects — general <-> the Projects overview (preview + * feature). Projects mounts its own query fan + * (project enumeration, work items, repo snapshots, + * activity summaries) on top of the shell, so this + * axis captures the cross-surface switch the felt + * 1-2s report singled out. + * + * Method mirrors warm-switch-markdown.perf.ts: in-page click + rAF polling + * (CDP latency never pollutes samples), longtask capture per switch, 4x CPU + * throttle, medians over repeated switches, untimed warmup round-trip first. + * `deep-history` is pinned to 150 rows so the message-mount cost is fixed + * and comparable across member counts. + * + * Run it (from desktop/): + * pnpm build:e2e + * npx playwright test --config=playwright.perf.config.ts member-heavy-switch.perf.ts + * + * Compare the MEDIAN wall ms / longtask lines across the member-count + * scenarios; a superlinear jump is membership-scaling cost on the switch + * path. + */ + +const MEASURED_SWITCHES = 8; +const THROTTLE_RATE = 4; +const DEEP_HISTORY_ROWS = 150; +const MEMBER_COUNTS = [0, 2_000, 10_000] as const; + +type SwitchSample = { + ms: number; + longtaskTotal: number; + longtaskMax: number; + longtaskCount: number; +}; + +function median(values: number[]): number { + const sorted = [...values].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + return sorted.length % 2 === 0 + ? (sorted[mid - 1] + sorted[mid]) / 2 + : sorted[mid]; +} + +/** Click the sidebar link and poll — all in-page — until the target channel's + * rows are committed, the deferred snapshot has caught up, and a frame + * painted. Returns wall-clock ms plus the longtasks observed in the window. */ +async function measureSwitch( + page: import("@playwright/test").Page, + input: { + targetTestId: string; + /** When set, chat-title must equal this before the switch counts. */ + targetTitle: string | null; + /** Selector that must be present before the switch counts. */ + readySelector: string; + }, +): Promise { + return page.evaluate(async (args) => { + const store = window as unknown as { __LONGTASKS__: number[] }; + store.__LONGTASKS__ = []; + const link = document.querySelector( + `[data-testid="${args.targetTestId}"]`, + ); + if (!link) throw new Error(`missing sidebar link ${args.targetTestId}`); + + const start = performance.now(); + link.click(); + + await new Promise((resolve, reject) => { + const deadline = start + 30_000; + const check = () => { + const titleReady = + args.targetTitle === null || + document.querySelector('[data-testid="chat-title"]')?.textContent === + args.targetTitle; + const ready = + titleReady && + document.querySelector(args.readySelector) !== null && + document.querySelector('[data-render-pending="true"]') === null; + if (ready) { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())); + return; + } + if (performance.now() > deadline) { + reject(new Error(`switch to ${args.targetTitle} timed out`)); + return; + } + requestAnimationFrame(check); + }; + requestAnimationFrame(check); + }); + + const elapsed = performance.now() - start; + const tasks = store.__LONGTASKS__ ?? []; + return { + ms: elapsed, + longtaskTotal: tasks.reduce((sum, duration) => sum + duration, 0), + longtaskMax: tasks.length ? Math.max(...tasks) : 0, + longtaskCount: tasks.length, + }; + }, input); +} + +type SwitchTarget = { + targetTestId: string; + targetTitle: string | null; + readySelector: string; +}; + +const GENERAL_TARGET: SwitchTarget = { + targetTestId: "channel-general", + targetTitle: "general", + readySelector: "[data-message-id]", +}; + +const DEEP_HISTORY_TARGET: SwitchTarget = { + targetTestId: "channel-deep-history", + targetTitle: "deep-history", + readySelector: '[data-message-id^="mock-deep-history-"]', +}; + +const PROJECTS_TARGET: SwitchTarget = { + targetTestId: "open-projects-view", + targetTitle: null, + // Rendered by every Projects view mode (Activity intro or section header). + readySelector: '[data-testid="projects-page-header"]', +}; + +async function runScenario( + page: import("@playwright/test").Page, + label: string, + target: SwitchTarget, + back: SwitchTarget, +): Promise { + // Untimed warmup round-trip: caches both surfaces' queries and jits the + // switch code paths. + await measureSwitch(page, target); + await measureSwitch(page, back); + + const samples: SwitchSample[] = []; + for (let run = 0; run < MEASURED_SWITCHES; run += 1) { + samples.push(await measureSwitch(page, target)); + samples.push(await measureSwitch(page, back)); + } + + const times = samples.map((sample) => sample.ms); + const longtaskTotals = samples.map((sample) => sample.longtaskTotal); + /* eslint-disable no-console */ + console.log(`\n=== MEMBER-HEAVY WARM SWITCH: ${label} ===`); + console.log(`CPU throttle: ${THROTTLE_RATE}x`); + console.log( + `per-switch wall ms: [${times.map((v) => v.toFixed(1)).join(", ")}]`, + ); + console.log( + `per-switch longtask ms: [${longtaskTotals.map((v) => v.toFixed(1)).join(", ")}]`, + ); + console.log(`MEDIAN wall ms: ${median(times).toFixed(1)}`); + console.log( + `MEDIAN longtask total: ${median(longtaskTotals).toFixed(1)}ms`, + ); + console.log( + `worst single longtask: ${Math.max(...samples.map((sample) => sample.longtaskMax)).toFixed(1)}ms`, + ); + /* eslint-enable no-console */ + return samples; +} + +for (const memberCount of MEMBER_COUNTS) { + const label = + memberCount === 0 + ? "baseline fixture membership" + : `${memberCount.toLocaleString("en-US")} members per channel`; + + test(`MEASURE: warm switch general<->deep-history and general<->projects with ${label}`, async ({ + page, + }) => { + test.setTimeout(300_000); + // Projects is a preview feature; seed the override BEFORE the bridge + // installs so the shell mounts with it enabled. + await page.addInitScript(() => { + window.localStorage.setItem( + "buzz-feature-overrides-v1", + JSON.stringify({ projects: true }), + ); + }); + await installMockBridge(page, { + deepHistoryMessageCount: DEEP_HISTORY_ROWS, + ...(memberCount > 0 + ? { + inflateChannelMembers: { + general: memberCount, + "deep-history": memberCount, + }, + } + : {}), + }); + await page.goto("/"); + await page.waitForFunction( + () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function", + ); + + // Arm the longtask observer; addInitScript applies on next navigation. + await page.addInitScript(() => { + const store = window as unknown as { __LONGTASKS__?: number[] }; + store.__LONGTASKS__ = []; + new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + store.__LONGTASKS__?.push(entry.duration); + } + }).observe({ type: "longtask", buffered: true }); + }); + await page.reload(); + await page.waitForFunction( + () => + typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function" && + Array.isArray( + (window as unknown as { __LONGTASKS__?: number[] }).__LONGTASKS__, + ), + ); + + // Verify the inflation actually landed before measuring anything. + if (memberCount > 0) { + await expect + .poll(() => + page.evaluate(async () => { + const invoke = ( + window as unknown as { + __TAURI_INTERNALS__: { + invoke: ( + cmd: string, + args: unknown, + ) => Promise<{ members: unknown[] }>; + }; + } + ).__TAURI_INTERNALS__.invoke; + const response = await invoke("get_channel_members", { + channelId: "feedf00d-0000-4000-8000-000000000007", + }); + return response.members.length; + }), + ) + .toBeGreaterThanOrEqual(memberCount); + } + + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + const client = await page.context().newCDPSession(page); + await client.send("Emulation.setCPUThrottlingRate", { + rate: THROTTLE_RATE, + }); + + const channelSamples = await runScenario( + page, + `channel<->channel, ${label}`, + DEEP_HISTORY_TARGET, + GENERAL_TARGET, + ); + const projectsSamples = await runScenario( + page, + `channel<->projects, ${label}`, + PROJECTS_TARGET, + GENERAL_TARGET, + ); + + await client.send("Emulation.setCPUThrottlingRate", { rate: 1 }); + + // Instrument, not a gate: assert the harness measured real work. + expect(channelSamples.length).toBe(MEASURED_SWITCHES * 2); + expect(channelSamples.every((sample) => sample.ms > 0)).toBe(true); + expect(projectsSamples.length).toBe(MEASURED_SWITCHES * 2); + expect(projectsSamples.every((sample) => sample.ms > 0)).toBe(true); + }); +} diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index 2637b94a808..485cae98efc 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -277,6 +277,8 @@ type MockBridgeOptions = { honorChannelsKnownHash?: boolean; /** Number of seeded rows in the deep-history fixture. Defaults to 600. */ deepHistoryMessageCount?: number; + /** Channel name → target member count for high-membership perf specs. */ + inflateChannelMembers?: Record; feedReadError?: string; canvasReadError?: string; /** Delay (ms) for `apply_workspace`; see e2eBridge mock config. */ From 89910c5e8223c4eb6332952b0cdeacf3f11857ba Mon Sep 17 00:00:00 2001 From: Max Lampert Date: Mon, 24 Aug 2026 13:50:39 -0700 Subject: [PATCH 03/27] fix(desktop): suspense-aware settle, StrictMode-safe abandon, log cap Address the three findings from the 2026-08-24 review of #6455: - The lazy ChannelPane's Suspense fallback now carries the data-render-pending marker, so a switch trace can no longer settle while the pane chunk is suspended. New Playwright regression holds the chunk and asserts no measure lands until rows paint. - Route-exit trace abandonment is deferred one microtask and canceled by the effect re-setup, so StrictMode's dev-only effect replay no longer kills a just-opened trace. New jsdom regressions run the real react-dom dev replay; the settle spec also passes against the Vite dev runtime. - The perf-log 4 KiB cap is enforced on the final serialized line, and BUZZ_PERF_LOG_LABEL is truncated to 128 bytes at a char boundary, so a runaway label can neither inflate the sink nor kill every record. Also extracts the timeline-loading latch + trace marks from ChannelScreen into useChannelTimelineLoading (file-size ratchet). Co-Authored-By: Claude Fable 5 Signed-off-by: Max Lampert --- desktop/src-tauri/src/commands/perf_log.rs | 72 ++++++++- .../features/channels/ui/ChannelScreen.tsx | 42 +----- .../ui/ChannelScreenLoadingFallback.tsx | 17 ++- .../useChannelSwitchTraceMarks.test.mjs | 137 ++++++++++++++++++ .../channels/useChannelSwitchTraceMarks.ts | 10 +- .../channels/useChannelTimelineLoading.ts | 61 ++++++++ .../src/shared/lib/channelSwitchPerf.test.mjs | 40 +++++ desktop/src/shared/lib/channelSwitchPerf.ts | 40 ++++- .../e2e/switch-settle-after-paint.spec.ts | 69 +++++++++ 9 files changed, 439 insertions(+), 49 deletions(-) create mode 100644 desktop/src/features/channels/useChannelSwitchTraceMarks.test.mjs create mode 100644 desktop/src/features/channels/useChannelTimelineLoading.ts diff --git a/desktop/src-tauri/src/commands/perf_log.rs b/desktop/src-tauri/src/commands/perf_log.rs index ff493ac413e..625e8db88c8 100644 --- a/desktop/src-tauri/src/commands/perf_log.rs +++ b/desktop/src-tauri/src/commands/perf_log.rs @@ -14,9 +14,29 @@ use tauri::Manager; const PERF_LOG_FILENAME: &str = "switch-perf.jsonl"; /// Defensive cap: one record is a small trace object; anything larger is a -/// caller bug and must not grow the log unbounded. +/// caller bug and must not grow the log unbounded. Enforced on the input +/// record and again on the final serialized line, so folded-in metadata can +/// never defeat it. const MAX_RECORD_BYTES: usize = 4 * 1024; +/// Upper bound on the operator-supplied `BUZZ_PERF_LOG_LABEL` run label. +/// Truncating (rather than erroring) keeps a fat-fingered label from +/// silently dropping every trace for the whole run — the frontend swallows +/// sink errors by design. +const MAX_LABEL_BYTES: usize = 128; + +/// Truncates to the last char boundary at or below `max_bytes`. +fn truncate_at_char_boundary(text: &str, max_bytes: usize) -> &str { + if text.len() <= max_bytes { + return text; + } + let mut end = max_bytes; + while !text.is_char_boundary(end) { + end -= 1; + } + &text[..end] +} + /// Rotation threshold. The sink is always on, so without a cap the JSONL /// grows for the life of the install; one rotated generation preserves /// enough history for before/after comparisons. @@ -48,10 +68,18 @@ fn shape_perf_log_line( if let Some(label) = label { object.insert( "label".to_string(), - serde_json::Value::String(label.to_string()), + serde_json::Value::String( + truncate_at_char_boundary(label, MAX_LABEL_BYTES).to_string(), + ), ); } - serde_json::to_string(&value).map_err(|e| e.to_string()) + let line = serde_json::to_string(&value).map_err(|e| e.to_string())?; + // The cap must hold for what actually reaches the disk: gitSha and label + // are folded in after the record-size check above. + if line.len() > MAX_RECORD_BYTES { + return Err("perf log line too large".to_string()); + } + Ok(line) } /// Serializes the whole metadata→rename→append transaction. Appends run on @@ -151,6 +179,44 @@ mod tests { assert!(shape_perf_log_line(&oversized, None, None).is_err()); } + #[test] + fn shape_truncates_an_unbounded_label_and_keeps_the_line_capped() { + // BUZZ_PERF_LOG_LABEL is operator-supplied; a runaway value must not + // defeat the record cap by being folded in after the size check. + let label = "l".repeat(1024 * 1024); + let line = + shape_perf_log_line(r#"{"totalMs":13}"#, Some("abc123"), Some(&label)).expect("shape"); + assert!(line.len() <= MAX_RECORD_BYTES, "line stays under the cap"); + let value: serde_json::Value = serde_json::from_str(&line).expect("parse"); + assert_eq!( + value["label"].as_str().expect("label").len(), + MAX_LABEL_BYTES + ); + assert_eq!(value["totalMs"], 13); + } + + #[test] + fn label_truncation_cuts_at_a_char_boundary() { + // '€' is 3 bytes; MAX_LABEL_BYTES (128) is not a multiple of 3, so a + // byte-index cut would split a char and panic (or emit invalid UTF-8). + let label = "€".repeat(MAX_LABEL_BYTES); + let line = shape_perf_log_line(r#"{"totalMs":1}"#, None, Some(&label)).expect("shape"); + let value: serde_json::Value = serde_json::from_str(&line).expect("parse"); + let stored = value["label"].as_str().expect("label"); + assert_eq!(stored.len(), MAX_LABEL_BYTES - (MAX_LABEL_BYTES % 3)); + assert!(stored.chars().all(|c| c == '€')); + } + + #[test] + fn shape_rejects_a_line_that_outgrows_the_cap_after_metadata() { + // The record alone passes the input check; the folded-in git sha + // pushes the serialized line over the cap. + let pad = "x".repeat(MAX_RECORD_BYTES - 20); + let record = format!(r#"{{"pad":"{pad}"}}"#); + assert!(record.len() <= MAX_RECORD_BYTES); + assert!(shape_perf_log_line(&record, Some(&"s".repeat(64)), None).is_err()); + } + #[test] fn append_rotates_once_over_the_cap_and_keeps_one_generation() { let dir = std::env::temp_dir().join(format!("perf-log-test-{}", std::process::id())); diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index f76768d73f4..243626ddce2 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -1,6 +1,5 @@ // biome-ignore-all format: line-count ratchet requires compact forwarding in this legacy component import * as React from "react"; -import { useQueryClient } from "@tanstack/react-query"; import { useAppShell } from "@/app/AppShellContext"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { useActiveChannelHeader } from "@/features/channels/useActiveChannelHeader"; @@ -49,11 +48,6 @@ import { getThreadReference, isThreadReply, } from "@/features/messages/lib/threading"; -import { hasPersistedHydratedChannel } from "@/features/messages/lib/channelHeadCache"; -import { - resolveTimelineLoadingLatch, - selectTimelineLoadingState, -} from "@/features/messages/lib/timelineLoadingState"; import { useFetchOlderMessages } from "@/features/messages/useFetchOlderMessages"; import { useIndependentThreadPanel } from "@/features/messages/useIndependentThreadPanel"; import { useThreadReplies } from "@/features/messages/useThreadReplies"; @@ -71,7 +65,7 @@ import { useHuddleReadMarker } from "@/features/channels/ui/useHuddleReadMarker" import { useHuddleThreadIsolation } from "@/features/channels/ui/useHuddleThreadIsolation"; import { AgentSessionProvider } from "@/shared/context/AgentSessionContext"; import { ProfilePanelProvider } from "@/shared/context/ProfilePanelContext"; -import { useChannelSwitchTraceMarks } from "@/features/channels/useChannelSwitchTraceMarks"; +import { useChannelTimelineLoading } from "@/features/channels/useChannelTimelineLoading"; import { useMainInsetRef } from "@/shared/layout/MainInsetContext"; import { channelContentTopPaddingMeasurement } from "@/shared/layout/chromeLayout"; import { useMeasuredCssVariable } from "@/shared/layout/useMeasuredCssVariable"; @@ -105,7 +99,6 @@ export function ChannelScreen({ targetMessageEvents, targetMessageId, ...searchTarget }: ChannelScreenProps) { - const queryClient = useQueryClient(); const { goHome } = useAppNavigation(); const { activeCommunity } = useCommunities(); const { @@ -616,35 +609,10 @@ export function ChannelScreen({ setThreadReplyTargetId, setThreadScrollTargetId, }); - const settledChannelIdRef = React.useRef(null); - const hasSettledThisChannel = - activeChannelId !== null && settledChannelIdRef.current === activeChannelId; - const timelineLoadingNow = - activeChannel !== null && - activeChannel.channelType !== "forum" && - selectTimelineLoadingState( - { - isPending: messagesQuery.isPending, - isFetching: messagesQuery.isFetching, - isPlaceholderData: messagesQuery.isPlaceholderData, - dataLength: messagesQuery.data?.length ?? null, - }, - hasSettledThisChannel || - (activeChannelId !== null && - hasPersistedHydratedChannel(queryClient, activeChannelId)), - ); - const { settledChannelId, isLoading: isTimelineLoading } = - resolveTimelineLoadingLatch( - settledChannelIdRef.current, - activeChannelId, - timelineLoadingNow, - ); - settledChannelIdRef.current = settledChannelId; - useChannelSwitchTraceMarks({ - activeChannelId, - activeChannelType: activeChannel?.channelType ?? null, - isTimelineLoading, - }); + const isTimelineLoading = useChannelTimelineLoading( + activeChannel, + messagesQuery, + ); const { welcomeKickoffStage, welcomeKickoffSettingUp } = useWelcomeKickoffStagePresence( activeChannel, diff --git a/desktop/src/features/channels/ui/ChannelScreenLoadingFallback.tsx b/desktop/src/features/channels/ui/ChannelScreenLoadingFallback.tsx index a566b71d906..f66cb694866 100644 --- a/desktop/src/features/channels/ui/ChannelScreenLoadingFallback.tsx +++ b/desktop/src/features/channels/ui/ChannelScreenLoadingFallback.tsx @@ -6,9 +6,18 @@ export function ChannelScreenLoadingFallback({ }: { isHuddleTranscript: boolean; }) { - return isHuddleTranscript ? ( - - ) : ( - + return ( + // While the lazy ChannelPane chunk is suspended, the timeline — and its + // own render-pending marker — is not mounted. The switch tracer polls + // that marker to defer its settle, so the fallback itself must read as + // pending or a settle could record before the pane ever painted. + // `contents` keeps the wrapper out of layout. +
+ {isHuddleTranscript ? ( + + ) : ( + + )} +
); } diff --git a/desktop/src/features/channels/useChannelSwitchTraceMarks.test.mjs b/desktop/src/features/channels/useChannelSwitchTraceMarks.test.mjs new file mode 100644 index 00000000000..c5f3e95b2f1 --- /dev/null +++ b/desktop/src/features/channels/useChannelSwitchTraceMarks.test.mjs @@ -0,0 +1,137 @@ +import assert from "node:assert/strict"; +import { afterEach, it } from "node:test"; + +import { JSDOM } from "jsdom"; +import React from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; + +import { + CHANNEL_SWITCH_MEASURE, + beginChannelSwitchTrace, + resetChannelSwitchTrace, + settleChannelSwitchTrace, +} from "../../shared/lib/channelSwitchPerf.ts"; +import { useChannelSwitchTraceMarks } from "./useChannelSwitchTraceMarks.ts"; + +// These tests run the hook under the real react-dom development build, whose +// StrictMode replays every effect (setup → cleanup → setup) on mount — the +// exact dev-runtime lifecycle that used to abandon a just-opened trace and +// break the Performance-panel workflow. + +const originalDocument = globalThis.document; +const originalWindow = globalThis.window; +const originalActEnvironment = globalThis.IS_REACT_ACT_ENVIRONMENT; + +afterEach(() => { + resetChannelSwitchTrace(); + performance.clearMeasures?.(CHANNEL_SWITCH_MEASURE); + if (originalDocument === undefined) delete globalThis.document; + else globalThis.document = originalDocument; + if (originalWindow === undefined) delete globalThis.window; + else globalThis.window = originalWindow; + if (originalActEnvironment === undefined) + delete globalThis.IS_REACT_ACT_ENVIRONMENT; + else globalThis.IS_REACT_ACT_ENVIRONMENT = originalActEnvironment; +}); + +function setupDom() { + const dom = new JSDOM( + "
", + ); + const frames = []; + dom.window.requestAnimationFrame = (cb) => frames.push(cb) && frames.length; + dom.window.cancelAnimationFrame = () => {}; + Object.assign(globalThis, { + document: dom.window.document, + IS_REACT_ACT_ENVIRONMENT: true, + window: dom.window, + }); + const flushFrames = () => { + // Drain chained rAFs until quiescent. + for (let i = 0; i < 20 && frames.length > 0; i += 1) { + for (const cb of frames.splice(0, frames.length)) cb(); + } + }; + return { dom, flushFrames }; +} + +function Harness({ channelId, isTimelineLoading }) { + useChannelSwitchTraceMarks({ + activeChannelId: channelId, + activeChannelType: "stream", + isTimelineLoading, + }); + return null; +} + +function renderHarness(root, props) { + return act(async () => + root.render( + React.createElement( + React.StrictMode, + null, + React.createElement(Harness, props), + ), + ), + ); +} + +const measures = () => + performance + .getEntriesByName(CHANNEL_SWITCH_MEASURE) + .map((entry) => entry.detail?.channelId); + +it("a trace survives StrictMode's effect replay and still settles", async () => { + const { dom, flushFrames } = setupDom(); + performance.clearMeasures?.(CHANNEL_SWITCH_MEASURE); + beginChannelSwitchTrace("chan-strict"); + const root = createRoot(document.getElementById("root")); + await renderHarness(root, { + channelId: "chan-strict", + isTimelineLoading: true, + }); + await renderHarness(root, { + channelId: "chan-strict", + isTimelineLoading: false, + }); + flushFrames(); + assert.deepEqual(measures(), ["chan-strict"]); + await act(async () => root.unmount()); + dom.window.close(); +}); + +it("a real route exit still abandons: a history-back settle records nothing", async () => { + const { dom, flushFrames } = setupDom(); + performance.clearMeasures?.(CHANNEL_SWITCH_MEASURE); + beginChannelSwitchTrace("chan-exit"); + const root = createRoot(document.getElementById("root")); + await renderHarness(root, { + channelId: "chan-exit", + isTimelineLoading: true, + }); + // Leaving the channel surface unmounts the hook; with no re-setup to + // cancel it, the scheduled abandon must fire. + await act(async () => root.unmount()); + await new Promise((resolve) => setImmediate(resolve)); + // History-back re-enters without goChannel; its settle must find no trace. + settleChannelSwitchTrace("chan-exit"); + flushFrames(); + assert.deepEqual(measures(), []); + dom.window.close(); +}); + +it("an A→B switch's deferred abandon of A never kills B's trace", async () => { + const { dom, flushFrames } = setupDom(); + performance.clearMeasures?.(CHANNEL_SWITCH_MEASURE); + beginChannelSwitchTrace("chan-a"); + const root = createRoot(document.getElementById("root")); + await renderHarness(root, { channelId: "chan-a", isTimelineLoading: true }); + beginChannelSwitchTrace("chan-b"); + await renderHarness(root, { channelId: "chan-b", isTimelineLoading: true }); + await renderHarness(root, { channelId: "chan-b", isTimelineLoading: false }); + flushFrames(); + assert.deepEqual(measures(), ["chan-b"]); + await act(async () => root.unmount()); + dom.window.close(); +}); diff --git a/desktop/src/features/channels/useChannelSwitchTraceMarks.ts b/desktop/src/features/channels/useChannelSwitchTraceMarks.ts index bc49f6d2d6e..e78bb518fe2 100644 --- a/desktop/src/features/channels/useChannelSwitchTraceMarks.ts +++ b/desktop/src/features/channels/useChannelSwitchTraceMarks.ts @@ -2,7 +2,9 @@ import * as React from "react"; import { abandonChannelSwitchTrace, + cancelRouteExitAbandon, markChannelSwitchRouteCommit, + scheduleRouteExitAbandon, settleChannelSwitchTrace, } from "@/shared/lib/channelSwitchPerf"; import type { ChannelType } from "@/shared/api/types"; @@ -33,12 +35,16 @@ export function useChannelSwitchTraceMarks({ // timeout matches the stale singleton and records the time spent away as // switch latency. Keyed per channel id: on an A→B switch this cleanup runs // with A's id after B's trace already began, so it only ever abandons its - // own channel's trace. + // own channel's trace. The abandon is scheduled (one microtask) rather + // than immediate so StrictMode's dev-only effect replay — whose re-setup + // runs synchronously right after this cleanup — cancels it instead of + // killing the just-opened trace. React.useEffect(() => { if (!activeChannelId) return; const channelId = activeChannelId; + cancelRouteExitAbandon(channelId); return () => { - abandonChannelSwitchTrace(channelId); + scheduleRouteExitAbandon(channelId); }; }, [activeChannelId]); React.useEffect(() => { diff --git a/desktop/src/features/channels/useChannelTimelineLoading.ts b/desktop/src/features/channels/useChannelTimelineLoading.ts new file mode 100644 index 00000000000..2263bd5075e --- /dev/null +++ b/desktop/src/features/channels/useChannelTimelineLoading.ts @@ -0,0 +1,61 @@ +import * as React from "react"; +import { useQueryClient } from "@tanstack/react-query"; + +import { useChannelSwitchTraceMarks } from "@/features/channels/useChannelSwitchTraceMarks"; +import { hasPersistedHydratedChannel } from "@/features/messages/lib/channelHeadCache"; +import { + resolveTimelineLoadingLatch, + selectTimelineLoadingState, +} from "@/features/messages/lib/timelineLoadingState"; +import type { Channel } from "@/shared/api/types"; + +/** + * Latches the timeline loading state per channel and drives the + * channel-switch trace marks from that same latch, so the tracer settles on + * exactly the loading state the screen renders from. + */ +export function useChannelTimelineLoading( + activeChannel: Channel | null, + messagesQuery: { + data: readonly unknown[] | undefined; + isFetching: boolean; + isPending: boolean; + isPlaceholderData: boolean; + }, +): boolean { + const queryClient = useQueryClient(); + const activeChannelId = activeChannel?.id ?? null; + const settledChannelIdRef = React.useRef(null); + const hasSettledThisChannel = + activeChannelId !== null && settledChannelIdRef.current === activeChannelId; + const timelineLoadingNow = + activeChannel !== null && + activeChannel.channelType !== "forum" && + selectTimelineLoadingState( + { + isPending: messagesQuery.isPending, + isFetching: messagesQuery.isFetching, + isPlaceholderData: messagesQuery.isPlaceholderData, + dataLength: messagesQuery.data?.length ?? null, + }, + // A persisted head only counts as hydrated when it has rows to paint + // (channelHeadCache.ts), so this bypass never settles onto an empty + // placeholder while the authoritative refresh is still in flight. + hasSettledThisChannel || + (activeChannelId !== null && + hasPersistedHydratedChannel(queryClient, activeChannelId)), + ); + const { settledChannelId, isLoading: isTimelineLoading } = + resolveTimelineLoadingLatch( + settledChannelIdRef.current, + activeChannelId, + timelineLoadingNow, + ); + settledChannelIdRef.current = settledChannelId; + useChannelSwitchTraceMarks({ + activeChannelId, + activeChannelType: activeChannel?.channelType ?? null, + isTimelineLoading, + }); + return isTimelineLoading; +} diff --git a/desktop/src/shared/lib/channelSwitchPerf.test.mjs b/desktop/src/shared/lib/channelSwitchPerf.test.mjs index 586ae70a324..46a364328fa 100644 --- a/desktop/src/shared/lib/channelSwitchPerf.test.mjs +++ b/desktop/src/shared/lib/channelSwitchPerf.test.mjs @@ -130,6 +130,8 @@ async function withSettleHarness(run) { const { abandonChannelSwitchTrace, beginChannelSwitchTrace, + cancelRouteExitAbandon, + scheduleRouteExitAbandon, settleChannelSwitchTrace, resetChannelSwitchTrace, CHANNEL_SWITCH_MEASURE, @@ -149,6 +151,8 @@ async function withSettleHarness(run) { await run({ abandon: abandonChannelSwitchTrace, begin: beginChannelSwitchTrace, + cancelAbandon: cancelRouteExitAbandon, + scheduleAbandon: scheduleRouteExitAbandon, settle: settleChannelSwitchTrace, reset: resetChannelSwitchTrace, flush, @@ -198,6 +202,42 @@ test("an undisturbed settle records exactly one measure", async () => { }); }); +test("a scheduled route-exit abandon canceled in the same task keeps the trace", async () => { + await withSettleHarness( + async ({ + begin, + cancelAbandon, + scheduleAbandon, + settle, + flush, + measures, + }) => { + begin("aaaa1111aaaa1111"); + // StrictMode's dev-only effect replay: cleanup schedules the abandon, + // the synchronous re-setup cancels it before the microtask runs. + scheduleAbandon("aaaa1111aaaa1111"); + cancelAbandon("aaaa1111aaaa1111"); + await Promise.resolve(); + settle("aaaa1111aaaa1111"); + flush(); + assert.deepEqual(measures(), ["aaaa1111aaaa1111"]); + }, + ); +}); + +test("an uncanceled route-exit abandon drops the trace before any frame fires", async () => { + await withSettleHarness( + async ({ begin, scheduleAbandon, settle, flush, measures }) => { + begin("aaaa1111aaaa1111"); + scheduleAbandon("aaaa1111aaaa1111"); + await Promise.resolve(); + settle("aaaa1111aaaa1111"); + flush(); + assert.deepEqual(measures(), []); + }, + ); +}); + test("leaving the channel surface abandons the trace; history-back records nothing", async () => { await withSettleHarness( async ({ abandon, begin, settle, flush, measures }) => { diff --git a/desktop/src/shared/lib/channelSwitchPerf.ts b/desktop/src/shared/lib/channelSwitchPerf.ts index 015537179a0..5007e7a76f0 100644 --- a/desktop/src/shared/lib/channelSwitchPerf.ts +++ b/desktop/src/shared/lib/channelSwitchPerf.ts @@ -158,6 +158,37 @@ export function abandonChannelSwitchTrace(channelId: string): void { } } +// Route-exit abandons currently deferred; see scheduleRouteExitAbandon. +const pendingRouteExitAbandons = new Set(); + +/** + * scheduleRouteExitAbandon abandons the channel's trace one microtask from + * now unless cancelRouteExitAbandon runs first. Call it from the route-exit + * effect cleanup: deferring lets React StrictMode's dev-only effect replay + * — cleanup + re-setup, synchronously within one commit — cancel the + * abandon, where abandoning synchronously would kill every just-opened + * trace in dev builds and break the Performance-panel workflow. A real + * route exit has no re-setup, so the scheduled abandon still fires — and + * microtasks run before any frame callback, so a queued settle cannot + * record in the gap. + */ +export function scheduleRouteExitAbandon(channelId: string): void { + pendingRouteExitAbandons.add(channelId); + queueMicrotask(() => { + if (pendingRouteExitAbandons.delete(channelId)) { + abandonChannelSwitchTrace(channelId); + } + }); +} + +/** + * cancelRouteExitAbandon revokes a pending scheduleRouteExitAbandon for the + * channel. Call it from the route-enter effect setup, before any work. + */ +export function cancelRouteExitAbandon(channelId: string): void { + pendingRouteExitAbandons.delete(channelId); +} + export function beginChannelSwitchTrace(channelId: string): void { if (typeof performance === "undefined") return; activeTrace = { @@ -221,6 +252,7 @@ export function traceChannelMembersFetch( */ export function resetChannelSwitchTrace(): void { activeTrace = null; + pendingRouteExitAbandons.clear(); } /** Bound on waiting for the deferred timeline commit before recording. */ @@ -229,9 +261,11 @@ const SETTLE_RENDER_WAIT_MS = 5_000; /** * Closes the active trace once the settled frame has painted. The timeline * renders rows through a deferred snapshot that exposes - * `data-render-pending` until the low-priority commit catches up — waiting - * for it (bounded) keeps `totalMs` honest on render-heavy switches; a final - * rAF pair then lands the mark after the browser paints. + * `data-render-pending` until the low-priority commit catches up, and the + * lazy channel pane's Suspense fallback carries the same marker while its + * chunk is still loading — waiting for both (bounded) keeps `totalMs` + * honest on render-heavy and cold-chunk switches; a final rAF pair then + * lands the mark after the browser paints. */ export function settleChannelSwitchTrace(channelId: string): void { if (typeof performance === "undefined") return; diff --git a/desktop/tests/e2e/switch-settle-after-paint.spec.ts b/desktop/tests/e2e/switch-settle-after-paint.spec.ts index ce8e223df65..a74826dfc9f 100644 --- a/desktop/tests/e2e/switch-settle-after-paint.spec.ts +++ b/desktop/tests/e2e/switch-settle-after-paint.spec.ts @@ -56,3 +56,72 @@ test("cold-switch settle measure lands only after rows are painted", async ({ "settle must not be recorded while a deferred commit is still pending", ).toBe(false); }); + +/** + * While the lazy ChannelPane chunk is still suspended, the timeline (and its + * render-pending marker) is not mounted — only the Suspense fallback is. The + * fallback must therefore read as pending itself, or the tracer would record + * a settle with zero rows while the loading skeleton was still visible. This + * spec holds the chunk to pin that contract. + */ +test("settle waits for a delayed lazy channel-pane chunk", async ({ page }) => { + let releaseChunk = () => {}; + const chunkHold = new Promise((resolve) => { + releaseChunk = resolve; + }); + let chunkRequested = false; + await page.route(/\/assets\/ChannelPane-[^/]+\.js(\?.*)?$/, async (route) => { + chunkRequested = true; + await chunkHold; + await route.continue(); + }); + + await installMockBridge(page, { deepHistoryMessageCount: 600 }); + await page.goto("/"); + await expect(page.getByTestId("app-sidebar")).toBeVisible(); + + await page.getByTestId("channel-deep-history").click(); + await expect + .poll(() => chunkRequested, { + message: "the ChannelPane chunk must load lazily on first channel entry", + }) + .toBe(true); + + // Give the tracer ample frames to (incorrectly) settle behind the held + // chunk. This wait must stay well under the tracer's 5s settle deadline. + await page.waitForTimeout(1_500); + const early = await page.evaluate( + (name) => performance.getEntriesByName(name).length, + SWITCH_MEASURE, + ); + expect( + early, + "no settle may be recorded while the pane chunk is suspended", + ).toBe(0); + + releaseChunk(); + + // Same in-page polling as above: snapshot the DOM in the evaluation turn + // where the measure first exists. + const atSettle = await page.evaluate(async (measureName) => { + const deadline = Date.now() + 15_000; + while (Date.now() < deadline) { + if (performance.getEntriesByName(measureName).length > 0) { + return { + rowCount: document.querySelectorAll( + '[data-message-id^="mock-deep-history-"]', + ).length, + settled: true, + }; + } + await new Promise((resolve) => setTimeout(resolve, 16)); + } + return { rowCount: 0, settled: false }; + }, SWITCH_MEASURE); + + expect(atSettle.settled, "switch trace must settle after release").toBe(true); + expect( + atSettle.rowCount, + "the settle must land only after the released pane painted rows", + ).toBeGreaterThan(0); +}); From 705899b6cbbf3fc0a5a5b805ea2697991215a783 Mon Sep 17 00:00:00 2001 From: Max Lampert Date: Mon, 24 Aug 2026 17:46:36 -0700 Subject: [PATCH 04/27] fix(desktop): guard-gated traces, on-disk log cap, honest projects benchmark MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the three findings from the 2026-08-24 re-review of #6455, plus one finding from a follow-up adversarial review: - The switch trace now opens inside commitGuardedNavigation, only after the navigation guard accepts — a refused click can no longer leave an orphan trace that a later history navigation would settle with inflated time. The commit flow is extracted and dependency-injected; new unit tests pin guard ordering and the refusal→history-back case. - The perf-log 4 KiB cap now bounds bytes on disk: one byte is reserved for the newline writeln! appends. New boundary test measures the file. - The channel↔Projects benchmark gates readiness on a new data-projects-hydrating marker (driven by the surface's query fan via isLoading, so disabled queries never wedge it) instead of the shell header, verifies member inflation on both inflated channels, uses a prefixed ready-selector for general, and reports each switch direction as its own median so a one-leg regression cannot hide. - A settle wait that hits its 5s deadline while the render is still pending now records with settleWaitTruncated instead of posing as an honest settled paint — the >deadline tail is what the tracer exists to expose. Also removes the dead ChannelSwitchFetchTrace type. Co-Authored-By: Claude Fable 5 Signed-off-by: Max Lampert --- desktop/src-tauri/src/commands/perf_log.rs | 36 +++- .../commitGuardedNavigation.test.mjs | 169 ++++++++++++++++++ .../app/navigation/commitGuardedNavigation.ts | 51 ++++++ .../src/app/navigation/useAppNavigation.ts | 66 ++++--- .../src/features/projects/ui/ProjectsView.tsx | 10 ++ .../src/shared/lib/channelSwitchPerf.test.mjs | 27 +++ desktop/src/shared/lib/channelSwitchPerf.ts | 53 ++++-- desktop/tests/e2e/member-heavy-switch.perf.ts | 114 ++++++++---- 8 files changed, 441 insertions(+), 85 deletions(-) create mode 100644 desktop/src/app/navigation/commitGuardedNavigation.test.mjs create mode 100644 desktop/src/app/navigation/commitGuardedNavigation.ts diff --git a/desktop/src-tauri/src/commands/perf_log.rs b/desktop/src-tauri/src/commands/perf_log.rs index 625e8db88c8..5126639f4a8 100644 --- a/desktop/src-tauri/src/commands/perf_log.rs +++ b/desktop/src-tauri/src/commands/perf_log.rs @@ -75,8 +75,9 @@ fn shape_perf_log_line( } let line = serde_json::to_string(&value).map_err(|e| e.to_string())?; // The cap must hold for what actually reaches the disk: gitSha and label - // are folded in after the record-size check above. - if line.len() > MAX_RECORD_BYTES { + // are folded in after the record-size check above, and writeln! appends + // a newline terminator — reserve one byte for it. + if line.len() + 1 > MAX_RECORD_BYTES { return Err("perf log line too large".to_string()); } Ok(line) @@ -217,6 +218,37 @@ mod tests { assert!(shape_perf_log_line(&record, Some(&"s".repeat(64)), None).is_err()); } + #[test] + fn the_cap_bounds_bytes_on_disk_including_the_newline() { + // Shaped line = {"pad":"…","gitSha":null} → pad length + 24 bytes. + // The largest accepted line is MAX_RECORD_BYTES - 1: writeln! appends + // a newline, and the cap bounds what reaches the disk. + let fits = format!(r#"{{"pad":"{}"}}"#, "x".repeat(MAX_RECORD_BYTES - 25)); + let line = shape_perf_log_line(&fits, None, None).expect("one byte reserved for newline"); + assert_eq!(line.len(), MAX_RECORD_BYTES - 1); + + let dir = std::env::temp_dir().join(format!( + "perf-log-newline-{}-{:?}", + std::process::id(), + std::thread::current().id() + )); + std::fs::create_dir_all(&dir).expect("tempdir"); + let path = dir.join("switch-perf.jsonl"); + let _ = std::fs::remove_file(&path); + append_line_rotating(&path, &line, MAX_LOG_BYTES).expect("append"); + assert_eq!( + std::fs::metadata(&path).expect("metadata").len(), + MAX_RECORD_BYTES as u64, + "on-disk record must not exceed the cap" + ); + std::fs::remove_dir_all(&dir).ok(); + + // One pad byte more serializes to exactly MAX_RECORD_BYTES, which + // would write MAX_RECORD_BYTES + 1 bytes — rejected. + let over = format!(r#"{{"pad":"{}"}}"#, "x".repeat(MAX_RECORD_BYTES - 24)); + assert!(shape_perf_log_line(&over, None, None).is_err()); + } + #[test] fn append_rotates_once_over_the_cap_and_keeps_one_generation() { let dir = std::env::temp_dir().join(format!("perf-log-test-{}", std::process::id())); diff --git a/desktop/src/app/navigation/commitGuardedNavigation.test.mjs b/desktop/src/app/navigation/commitGuardedNavigation.test.mjs new file mode 100644 index 00000000000..99ab2084bc3 --- /dev/null +++ b/desktop/src/app/navigation/commitGuardedNavigation.test.mjs @@ -0,0 +1,169 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { commitGuardedNavigation } from "./commitGuardedNavigation.ts"; +import { registerNavigationGuard } from "./navigationGuard.ts"; +import { + CHANNEL_SWITCH_MEASURE, + resetChannelSwitchTrace, + settleChannelSwitchTrace, +} from "../../shared/lib/channelSwitchPerf.ts"; + +const route = (href) => ({ kind: "route", href }); + +test("a refused navigation opens no trace; a later history settle records nothing", async () => { + // Frame-queue stub so the settle's rAF chain can be drained synchronously. + const frames = []; + const originalWindow = globalThis.window; + const originalDocument = globalThis.document; + globalThis.window = { + requestAnimationFrame: (cb) => frames.push(cb) && frames.length, + cancelAnimationFrame: () => {}, + }; + globalThis.document = { querySelector: () => null }; + performance.clearMeasures?.(CHANNEL_SWITCH_MEASURE); + const unregister = registerNavigationGuard(() => false); + try { + let navigated = false; + const committed = await commitGuardedNavigation({ + currentHref: "/channels/aaaa", + nextHref: "/channels/bbbb", + guardedTarget: route("/channels/bbbb"), + traceChannelId: "bbbb", + navigate: async () => { + navigated = true; + }, + }); + assert.equal(committed, false); + assert.equal(navigated, false); + + // Browser Back into the refused channel (history navigation is + // deliberately untraced): its mount settles, and must find NO orphan + // trace from the refused click — otherwise the measure would span the + // refusal and everything the user did in between. + settleChannelSwitchTrace("bbbb"); + for (let i = 0; i < 20 && frames.length > 0; i += 1) { + for (const cb of frames.splice(0, frames.length)) cb(); + } + assert.deepEqual( + performance + .getEntriesByName(CHANNEL_SWITCH_MEASURE) + .map((entry) => entry.detail?.channelId), + [], + ); + } finally { + unregister(); + resetChannelSwitchTrace(); + performance.clearMeasures?.(CHANNEL_SWITCH_MEASURE); + if (originalWindow === undefined) delete globalThis.window; + else globalThis.window = originalWindow; + if (originalDocument === undefined) delete globalThis.document; + else globalThis.document = originalDocument; + } +}); + +test("an accepted navigation opens the trace after the guard, before navigate", async () => { + const order = []; + const committed = await commitGuardedNavigation( + { + currentHref: "/channels/aaaa", + nextHref: "/channels/bbbb", + guardedTarget: route("/channels/bbbb"), + traceChannelId: "bbbb", + navigate: async () => { + order.push("navigate"); + }, + }, + { + allow: () => { + order.push("guard"); + return true; + }, + beginTrace: (channelId) => { + order.push(`begin:${channelId}`); + }, + }, + ); + assert.equal(committed, true); + assert.deepEqual(order, ["guard", "begin:bbbb", "navigate"]); +}); + +test("a same-destination no-op consults neither the guard nor the trace", async () => { + const order = []; + const committed = await commitGuardedNavigation( + { + currentHref: "/channels/aaaa", + nextHref: "/channels/aaaa", + guardedTarget: route("/channels/aaaa"), + traceChannelId: "aaaa", + navigate: async () => { + order.push("navigate"); + }, + }, + { + allow: () => { + order.push("guard"); + return true; + }, + beginTrace: () => { + order.push("begin"); + }, + }, + ); + assert.equal(committed, false); + assert.deepEqual(order, []); +}); + +test("force overrides the same-destination no-op but still runs the guard first", async () => { + const order = []; + const committed = await commitGuardedNavigation( + { + currentHref: "/channels/aaaa", + nextHref: "/channels/aaaa", + force: true, + guardedTarget: route("/channels/aaaa"), + navigate: async () => { + order.push("navigate"); + }, + }, + { + allow: () => { + order.push("guard"); + return true; + }, + beginTrace: () => { + order.push("begin"); + }, + }, + ); + assert.equal(committed, true); + // No traceChannelId: forced re-selection of the active channel stays + // untraced (nothing would settle it). + assert.deepEqual(order, ["guard", "navigate"]); +}); + +test("a same-destination navigation carrying router state still commits", async () => { + const order = []; + const committed = await commitGuardedNavigation( + { + currentHref: "/channels/aaaa", + nextHref: "/channels/aaaa", + guardedTarget: route("/channels/aaaa"), + hasStateUpdate: true, + navigate: async () => { + order.push("navigate"); + }, + }, + { + allow: () => { + order.push("guard"); + return true; + }, + beginTrace: () => { + order.push("begin"); + }, + }, + ); + assert.equal(committed, true); + assert.deepEqual(order, ["guard", "navigate"]); +}); diff --git a/desktop/src/app/navigation/commitGuardedNavigation.ts b/desktop/src/app/navigation/commitGuardedNavigation.ts new file mode 100644 index 00000000000..9b9f69d6a2f --- /dev/null +++ b/desktop/src/app/navigation/commitGuardedNavigation.ts @@ -0,0 +1,51 @@ +import { + allowNavigation, + type GuardedNavigation, +} from "@/app/navigation/navigationGuard"; +import { beginChannelSwitchTrace } from "@/shared/lib/channelSwitchPerf"; + +/** + * commitGuardedNavigation runs the shared commit flow for app navigations: + * skip same-destination no-ops (unless forced or carrying a router-state + * update), consult the navigation guard, then navigate. A same-href + * navigation that writes state — e.g. setting or clearing the search + * highlight — must still commit, or the state never lands. When + * `traceChannelId` is set, the channel-switch + * trace opens only after the guard accepts — a refused click must not leave + * an orphan trace that a later history navigation (deliberately untraced) + * would settle with the refused click's inflated wall time. Returns whether + * the navigation was performed. `deps` exists for unit tests. + */ +export async function commitGuardedNavigation( + input: { + currentHref: string; + nextHref: string; + force?: boolean; + guardedTarget: GuardedNavigation; + hasStateUpdate?: boolean; + traceChannelId?: string; + navigate: () => Promise; + }, + deps: { + allow?: typeof allowNavigation; + beginTrace?: typeof beginChannelSwitchTrace; + } = {}, +): Promise { + const allow = deps.allow ?? allowNavigation; + const beginTrace = deps.beginTrace ?? beginChannelSwitchTrace; + if ( + input.currentHref === input.nextHref && + !input.force && + !input.hasStateUpdate + ) { + return false; + } + if (!allow(input.guardedTarget)) { + return false; + } + if (input.traceChannelId !== undefined) { + beginTrace(input.traceChannelId); + } + await input.navigate(); + return true; +} diff --git a/desktop/src/app/navigation/useAppNavigation.ts b/desktop/src/app/navigation/useAppNavigation.ts index ac2b16a78ca..7ad42560d70 100644 --- a/desktop/src/app/navigation/useAppNavigation.ts +++ b/desktop/src/app/navigation/useAppNavigation.ts @@ -6,15 +6,14 @@ import { useRouter, } from "@tanstack/react-router"; +import { commitGuardedNavigation } from "@/app/navigation/commitGuardedNavigation"; import type { SearchHighlightNavigation } from "@/app/navigation/searchHighlightNavigation"; import { openSearchHitWithNavigation } from "@/app/navigation/searchHitNavigation"; import { - allowNavigation, type GuardedNavigation, traverseHistory, } from "@/app/navigation/navigationGuard"; import type { SearchHit } from "@/shared/api/types"; -import { beginChannelSwitchTrace } from "@/shared/lib/channelSwitchPerf"; type NavigationBehavior = { force?: boolean; @@ -42,32 +41,26 @@ export function useAppNavigation() { }, behavior: NavigationBehavior = {}, guardedTarget?: GuardedNavigation, + traceChannelId?: string, ) => { const nextLocation = router.buildLocation(next as never); - const hasStateUpdate = next.state !== undefined; - - if ( - location.href === nextLocation.href && - !behavior.force && - !hasStateUpdate - ) { - return false; - } - - if ( - !allowNavigation( - guardedTarget ?? { kind: "route", href: nextLocation.href }, - ) - ) { - return false; - } - - await navigate({ - ...next, - replace: behavior.replace, - resetScroll: behavior.resetScroll, - } as never); - return true; + return commitGuardedNavigation({ + currentHref: location.href, + force: behavior.force, + guardedTarget: guardedTarget ?? { + kind: "route", + href: nextLocation.href, + }, + hasStateUpdate: next.state !== undefined, + navigate: () => + navigate({ + ...next, + replace: behavior.replace, + resetScroll: behavior.resetScroll, + } as never), + nextHref: nextLocation.href, + traceChannelId, + }); }, [location.href, navigate, router], ); @@ -287,16 +280,6 @@ export function useAppNavigation() { threadRootId?: string | null; }, ) => { - // Every channel navigation entry point funnels through here, so this - // is the single click-time anchor for the switch trace. Re-selecting - // the already-active channel is a no-op navigation: the channel's - // effects never rerun, nothing would settle the trace, and it would - // squat on the singleton until timeout — so don't open one. (History - // back/forward bypasses goChannel entirely and is deliberately - // untraced.) - if (!location.pathname.endsWith(`/channels/${channelId}`)) { - beginChannelSwitchTrace(channelId); - } return commitNavigation( { to: "/channels/$channelId", @@ -336,6 +319,17 @@ export function useAppNavigation() { threadRootId: options.threadRootId ?? null, } : undefined, + // Every channel navigation entry point funnels through here, so this + // is the single click-time anchor for the switch trace; it opens + // inside commitGuardedNavigation only after the navigation guard + // accepts. Re-selecting the already-active channel is a no-op + // navigation: the channel's effects never rerun, nothing would + // settle the trace, and it would squat on the singleton until + // timeout — so don't open one. (History back/forward bypasses + // goChannel entirely and is deliberately untraced.) + location.pathname.endsWith(`/channels/${channelId}`) + ? undefined + : channelId, ); }, [commitNavigation, location.pathname], diff --git a/desktop/src/features/projects/ui/ProjectsView.tsx b/desktop/src/features/projects/ui/ProjectsView.tsx index f33659bcfc5..6b85ef46b49 100644 --- a/desktop/src/features/projects/ui/ProjectsView.tsx +++ b/desktop/src/features/projects/ui/ProjectsView.tsx @@ -172,6 +172,15 @@ export function ProjectsView() { snapshotProjects, activeCommunity?.reposDir, ); + // Hydration marker for the switch-perf harness: the shell commits long + // before the query fan loads. isLoading so disabled queries never wedge it. + const projectsHydrating = + projectsQuery.isLoading || + projectsWorkItemsQuery.isLoading || + repoSnapshotsQuery.isLoading || + activitySummariesQuery.isLoading || + repositoryActivitySummariesQuery.isLoading || + localRepositoriesQuery.isLoading; const memberChannelIds = useMemberChannelIds(); const repositoryUnavailableReasonFor = useRepositoryUnavailableReasonFor( repoSnapshotsQuery.data?.unavailable, @@ -722,6 +731,7 @@ export function ProjectsView() { data-project-context-detached={ isNarrowProjectsLayout ? undefined : "true" } + data-projects-hydrating={projectsHydrating ? "true" : undefined} data-testid="projects-overview-layout" > { ); }); +test("the settle wait records truncated — never as an honest settle — at deadline", () => { + // Still pending, before the deadline: keep waiting. + assert.equal(resolveSettleWait(4_999, 5_000, true), "wait"); + // Render caught up: record cleanly. + assert.deepEqual(resolveSettleWait(1_000, 5_000, false), { + settleWaitTruncated: false, + }); + // Deadline expired while still pending: the record must say so — a >5s + // switch reported as an ordinary settle would hide exactly the tail this + // tracer exists to expose. + assert.deepEqual(resolveSettleWait(5_000, 5_000, true), { + settleWaitTruncated: true, + }); +}); + +test("a truncated settle is flagged in the summary and the log record", () => { + const summary = summarizeChannelSwitchTrace(trace(), 1_412, true); + assert.ok(summary.endsWith(" settle=truncated"), summary); + const record = buildSwitchPerfLogRecord(trace(), 1_412, true); + assert.equal(record.settleWaitTruncated, true); + // Clean settles keep the field out of the line entirely. + assert.ok( + !("settleWaitTruncated" in buildSwitchPerfLogRecord(trace(), 1_412)), + ); +}); + test("fetches attribute only when started after the switch began", () => { const active = trace({ channelId: "abcdef1234567890", startedAt: 1_000 }); // Started before the switch (stale A→B→A leg): not attributable. diff --git a/desktop/src/shared/lib/channelSwitchPerf.ts b/desktop/src/shared/lib/channelSwitchPerf.ts index 5007e7a76f0..0daa46890ee 100644 --- a/desktop/src/shared/lib/channelSwitchPerf.ts +++ b/desktop/src/shared/lib/channelSwitchPerf.ts @@ -19,12 +19,6 @@ * `members=cache` — by design, not omission. */ -export type ChannelSwitchFetchTrace = { - durationMs: number; - eventCount?: number; - memberCount?: number; -}; - import { invoke, isTauri } from "@tauri-apps/api/core"; export type ChannelSwitchTrace = { @@ -48,6 +42,7 @@ let activeTrace: ChannelSwitchTrace | null = null; export function summarizeChannelSwitchTrace( trace: ChannelSwitchTrace, settledAt: number, + settleWaitTruncated = false, ): string { const total = Math.round(settledAt - trace.startedAt); const commit = @@ -64,7 +59,8 @@ export function summarizeChannelSwitchTrace( : `${trace.membersFetch.memberCount} members in ${Math.round(trace.membersFetch.durationMs)}ms`; return ( `[switch-perf] channel=${trace.channelId.slice(0, 8)} total=${total}ms ` + - `commit=${commit} window=${window} members=${members}` + `commit=${commit} window=${window} members=${members}` + + (settleWaitTruncated ? " settle=truncated" : "") ); } @@ -76,6 +72,7 @@ export function summarizeChannelSwitchTrace( export function buildSwitchPerfLogRecord( trace: ChannelSwitchTrace, settledAt: number, + settleWaitTruncated = false, ): { ts: string; channelId: string; @@ -83,8 +80,10 @@ export function buildSwitchPerfLogRecord( commitOffsetMs: number | null; windowFetch: { durationMs: number; eventCount: number } | null; membersFetch: { durationMs: number; memberCount: number } | null; + settleWaitTruncated?: true; } { return { + ...(settleWaitTruncated ? { settleWaitTruncated: true as const } : {}), ts: new Date().toISOString(), channelId: trace.channelId, totalMs: Math.round(settledAt - trace.startedAt), @@ -258,6 +257,23 @@ export function resetChannelSwitchTrace(): void { /** Bound on waiting for the deferred timeline commit before recording. */ const SETTLE_RENDER_WAIT_MS = 5_000; +/** + * Per-frame decision for the bounded settle wait: keep waiting only while + * the deferred render is still pending AND the deadline hasn't passed. When + * the wait ends with the render still pending, the record must say so — the + * >deadline tail is exactly what this tracer exists to expose, so the + * measurement is kept but flagged rather than posing as an honest settled + * paint. Pure for unit testing. + */ +export function resolveSettleWait( + now: number, + waitDeadline: number, + renderPending: boolean, +): "wait" | { settleWaitTruncated: boolean } { + if (renderPending && now < waitDeadline) return "wait"; + return { settleWaitTruncated: renderPending }; +} + /** * Closes the active trace once the settled frame has painted. The timeline * renders rows through a deferred snapshot that exposes @@ -287,7 +303,7 @@ export function settleChannelSwitchTrace(channelId: string): void { // finish inside the measured window still attribute to it. It is released // when the record lands; a newer switch's begin() simply replaces it. const waitDeadline = performance.now() + SETTLE_RENDER_WAIT_MS; - const record = () => { + const record = (settleWaitTruncated: boolean) => { const settledAt = performance.now(); if (activeTrace === trace) activeTrace = null; performance.mark(CHANNEL_SWITCH_SETTLED_MARK, { @@ -299,12 +315,17 @@ export function settleChannelSwitchTrace(channelId: string): void { routeCommitAt: trace.routeCommitAt, windowFetch: trace.windowFetch, membersFetch: trace.membersFetch, + ...(settleWaitTruncated ? { settleWaitTruncated: true } : {}), }, start: trace.startedAt, end: settledAt, }); - console.info(summarizeChannelSwitchTrace(trace, settledAt)); - appendSwitchPerfLogRecord(buildSwitchPerfLogRecord(trace, settledAt)); + console.info( + summarizeChannelSwitchTrace(trace, settledAt, settleWaitTruncated), + ); + appendSwitchPerfLogRecord( + buildSwitchPerfLogRecord(trace, settledAt, settleWaitTruncated), + ); }; const awaitDeferredCommit = () => { if (activeTrace !== trace) { @@ -315,16 +336,18 @@ export function settleChannelSwitchTrace(channelId: string): void { // to diagnose. Better no measurement than a fabricated one. return; } - if ( - performance.now() < waitDeadline && - document.querySelector('[data-render-pending="true"]') !== null - ) { + const decision = resolveSettleWait( + performance.now(), + waitDeadline, + document.querySelector('[data-render-pending="true"]') !== null, + ); + if (decision === "wait") { window.requestAnimationFrame(awaitDeferredCommit); return; } window.requestAnimationFrame(() => { if (activeTrace !== trace) return; - record(); + record(decision.settleWaitTruncated); }); }; window.requestAnimationFrame(awaitDeferredCommit); diff --git a/desktop/tests/e2e/member-heavy-switch.perf.ts b/desktop/tests/e2e/member-heavy-switch.perf.ts index a12720b2638..97096f37aac 100644 --- a/desktop/tests/e2e/member-heavy-switch.perf.ts +++ b/desktop/tests/e2e/member-heavy-switch.perf.ts @@ -21,13 +21,20 @@ import { installMockBridge } from "../helpers/bridge"; * (project enumeration, work items, repo snapshots, * activity summaries) on top of the shell, so this * axis captures the cross-surface switch the felt - * 1-2s report singled out. + * 1-2s report singled out. Readiness gates on the + * surface's data-projects-hydrating marker, not just + * the shell header — a sample whose query fan is + * still loading must not count as settled. After the + * untimed warmup both surfaces are cache-warm, so + * measured samples are warm-switch commits. * * Method mirrors warm-switch-markdown.perf.ts: in-page click + rAF polling * (CDP latency never pollutes samples), longtask capture per switch, 4x CPU * throttle, medians over repeated switches, untimed warmup round-trip first. * `deep-history` is pinned to 150 rows so the message-mount cost is fixed - * and comparable across member counts. + * and comparable across member counts. Each direction of a round-trip is + * reported as its own median — the two legs mount different surfaces, and a + * combined median could represent neither and hide a one-leg regression. * * Run it (from desktop/): * pnpm build:e2e @@ -42,6 +49,11 @@ const MEASURED_SWITCHES = 8; const THROTTLE_RATE = 4; const DEEP_HISTORY_ROWS = 150; const MEMBER_COUNTS = [0, 2_000, 10_000] as const; +/** general + deep-history — the two channels inflateChannelMembers targets. */ +const INFLATED_CHANNEL_IDS = [ + "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50", + "feedf00d-0000-4000-8000-000000000007", +]; type SwitchSample = { ms: number; @@ -69,6 +81,8 @@ async function measureSwitch( targetTitle: string | null; /** Selector that must be present before the switch counts. */ readySelector: string; + /** Selector that must be ABSENT before the switch counts. */ + pendingSelector: string | null; }, ): Promise { return page.evaluate(async (args) => { @@ -92,6 +106,8 @@ async function measureSwitch( const ready = titleReady && document.querySelector(args.readySelector) !== null && + (args.pendingSelector === null || + document.querySelector(args.pendingSelector) === null) && document.querySelector('[data-render-pending="true"]') === null; if (ready) { requestAnimationFrame(() => requestAnimationFrame(() => resolve())); @@ -121,18 +137,23 @@ type SwitchTarget = { targetTestId: string; targetTitle: string | null; readySelector: string; + pendingSelector: string | null; }; const GENERAL_TARGET: SwitchTarget = { targetTestId: "channel-general", targetTitle: "general", - readySelector: "[data-message-id]", + // Prefixed so the previous channel's still-mounted rows can never satisfy + // the gate. + readySelector: '[data-message-id^="mock-general-"]', + pendingSelector: null, }; const DEEP_HISTORY_TARGET: SwitchTarget = { targetTestId: "channel-deep-history", targetTitle: "deep-history", readySelector: '[data-message-id^="mock-deep-history-"]', + pendingSelector: null, }; const PROJECTS_TARGET: SwitchTarget = { @@ -140,25 +161,12 @@ const PROJECTS_TARGET: SwitchTarget = { targetTitle: null, // Rendered by every Projects view mode (Activity intro or section header). readySelector: '[data-testid="projects-page-header"]', + // The header is shell — the query fan (projects, work items, repo + // snapshots, activity summaries) must have settled too. + pendingSelector: '[data-projects-hydrating="true"]', }; -async function runScenario( - page: import("@playwright/test").Page, - label: string, - target: SwitchTarget, - back: SwitchTarget, -): Promise { - // Untimed warmup round-trip: caches both surfaces' queries and jits the - // switch code paths. - await measureSwitch(page, target); - await measureSwitch(page, back); - - const samples: SwitchSample[] = []; - for (let run = 0; run < MEASURED_SWITCHES; run += 1) { - samples.push(await measureSwitch(page, target)); - samples.push(await measureSwitch(page, back)); - } - +function reportDirection(label: string, samples: SwitchSample[]): void { const times = samples.map((sample) => sample.ms); const longtaskTotals = samples.map((sample) => sample.longtaskTotal); /* eslint-disable no-console */ @@ -178,7 +186,37 @@ async function runScenario( `worst single longtask: ${Math.max(...samples.map((sample) => sample.longtaskMax)).toFixed(1)}ms`, ); /* eslint-enable no-console */ - return samples; +} + +async function runScenario( + page: import("@playwright/test").Page, + label: string, + target: SwitchTarget, + back: SwitchTarget, +): Promise<{ toTarget: SwitchSample[]; toBack: SwitchSample[] }> { + // Untimed warmup round-trip: caches both surfaces' queries and jits the + // switch code paths. + await measureSwitch(page, target); + await measureSwitch(page, back); + + // The two legs mount different surfaces; report each as its own median so + // a one-leg regression can never hide in a combined number. + const toTarget: SwitchSample[] = []; + const toBack: SwitchSample[] = []; + for (let run = 0; run < MEASURED_SWITCHES; run += 1) { + toTarget.push(await measureSwitch(page, target)); + toBack.push(await measureSwitch(page, back)); + } + + reportDirection( + `${label}, ${back.targetTestId} -> ${target.targetTestId}`, + toTarget, + ); + reportDirection( + `${label}, ${target.targetTestId} -> ${back.targetTestId}`, + toBack, + ); + return { toTarget, toBack }; } for (const memberCount of MEMBER_COUNTS) { @@ -234,11 +272,13 @@ for (const memberCount of MEMBER_COUNTS) { ), ); - // Verify the inflation actually landed before measuring anything. + // Verify the inflation actually landed — on BOTH inflated channels. The + // bridge silently skips unknown channel names, so a rename/typo would + // otherwise run baseline membership under a "10,000 members" label. if (memberCount > 0) { await expect .poll(() => - page.evaluate(async () => { + page.evaluate(async (channelIds) => { const invoke = ( window as unknown as { __TAURI_INTERNALS__: { @@ -249,11 +289,16 @@ for (const memberCount of MEMBER_COUNTS) { }; } ).__TAURI_INTERNALS__.invoke; - const response = await invoke("get_channel_members", { - channelId: "feedf00d-0000-4000-8000-000000000007", - }); - return response.members.length; - }), + const counts = await Promise.all( + channelIds.map(async (channelId) => { + const response = await invoke("get_channel_members", { + channelId, + }); + return response.members.length; + }), + ); + return Math.min(...counts); + }, INFLATED_CHANNEL_IDS), ) .toBeGreaterThanOrEqual(memberCount); } @@ -282,9 +327,14 @@ for (const memberCount of MEMBER_COUNTS) { await client.send("Emulation.setCPUThrottlingRate", { rate: 1 }); // Instrument, not a gate: assert the harness measured real work. - expect(channelSamples.length).toBe(MEASURED_SWITCHES * 2); - expect(channelSamples.every((sample) => sample.ms > 0)).toBe(true); - expect(projectsSamples.length).toBe(MEASURED_SWITCHES * 2); - expect(projectsSamples.every((sample) => sample.ms > 0)).toBe(true); + for (const samples of [ + channelSamples.toTarget, + channelSamples.toBack, + projectsSamples.toTarget, + projectsSamples.toBack, + ]) { + expect(samples.length).toBe(MEASURED_SWITCHES); + expect(samples.every((sample) => sample.ms > 0)).toBe(true); + } }); } From 446174bc1de92c896dd06490dedbe858f492d4ed Mon Sep 17 00:00:00 2001 From: Max Lampert Date: Mon, 24 Aug 2026 20:27:32 -0700 Subject: [PATCH 05/27] fix(desktop): drop frame-starved settles; harden sink and harness honesty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Blind-review round 2 (Opus) findings: - P1: rAF suspends in hidden windows, so a queued settle fired only on the user's return and charged the whole absence to the switch as a clean record (reproduced at runtime). Settle now drops when the window is already hidden, poisons the wait on visibilitychange, and drops any trace older than the entry timeout plus the render wait — nothing legitimate can reach that age. - The JSONL append is a single write_all: writeln! issues two write syscalls and the lock is process-local while the log path is not, so a second process sharing the log dir could interleave mid-line. - The User Timing buffer keeps only the latest switch's mark/measure — desktop sessions run for weeks and the buffer is never GC'd. - The route-commit mark moved to a layout effect so it stamps commit time, not first-paint time. - Comment honesty: the member-heavy harness now states that mock-mode IPC hands live objects (parse cost not exercised) and that the Projects hydration marker guards cold/invalidated samples only; the goChannel trace-anchor comment describes what the same-channel guard actually skips; the Rust concurrency test's byte arithmetic corrected. - Longtask sampling drains PerformanceObserver.takeRecords() at sample time so a longtask ending just before the resolve frame isn't lost. Co-Authored-By: Claude Fable 5 Signed-off-by: Max Lampert --- desktop/src-tauri/src/commands/perf_log.rs | 14 ++-- .../commitGuardedNavigation.test.mjs | 7 +- .../src/app/navigation/useAppNavigation.ts | 17 ++--- .../channels/useChannelSwitchTraceMarks.ts | 9 ++- .../lib/projectChannelWindow.test.mjs | 7 +- .../src/features/projects/ui/ProjectsView.tsx | 4 +- .../src/shared/lib/channelSwitchPerf.test.mjs | 67 +++++++++++++++++-- desktop/src/shared/lib/channelSwitchPerf.ts | 64 +++++++++++++++++- desktop/tests/e2e/member-heavy-switch.perf.ts | 50 +++++++++----- 9 files changed, 196 insertions(+), 43 deletions(-) diff --git a/desktop/src-tauri/src/commands/perf_log.rs b/desktop/src-tauri/src/commands/perf_log.rs index 5126639f4a8..6ced5b34672 100644 --- a/desktop/src-tauri/src/commands/perf_log.rs +++ b/desktop/src-tauri/src/commands/perf_log.rs @@ -117,7 +117,12 @@ fn append_line_rotating(path: &std::path::Path, line: &str, max_bytes: u64) -> R .append(true) .open(path) .map_err(|e| e.to_string())?; - writeln!(file, "{line}").map_err(|e| e.to_string()) + // One write_all, not writeln!: writeln! issues two write syscalls (line, + // then newline), and PERF_LOG_LOCK is process-local while the path is + // not — a second Buzz process sharing the log dir could interleave + // between them. A single O_APPEND write keeps lines atomic. + file.write_all(format!("{line}\n").as_bytes()) + .map_err(|e| e.to_string()) } /// Appends one switch-perf record to the app-log-dir JSONL file and returns @@ -323,9 +328,10 @@ mod tests { let _ = std::fs::remove_file(&path); let _ = std::fs::remove_file(dir.join("switch-perf.jsonl.1")); - // 8 writers × 4 lines of 16 bytes = 512 bytes against a 384-byte cap: - // exactly one rotation boundary is crossed, so every line must land in - // either the live file or the single rotated generation. Unserialized + // 8 writers × 4 lines of 18 bytes on disk (17 chars + newline) = 576 + // bytes against a 384-byte cap: the rotation boundary is crossed + // exactly once (at the 22nd line), so every line must land in either + // the live file or the single rotated generation. Unserialized // metadata→rename→append interleavings drop lines or fail renames. let threads: Vec<_> = (0..8) .map(|writer| { diff --git a/desktop/src/app/navigation/commitGuardedNavigation.test.mjs b/desktop/src/app/navigation/commitGuardedNavigation.test.mjs index 99ab2084bc3..9ed897949d4 100644 --- a/desktop/src/app/navigation/commitGuardedNavigation.test.mjs +++ b/desktop/src/app/navigation/commitGuardedNavigation.test.mjs @@ -20,7 +20,12 @@ test("a refused navigation opens no trace; a later history settle records nothin requestAnimationFrame: (cb) => frames.push(cb) && frames.length, cancelAnimationFrame: () => {}, }; - globalThis.document = { querySelector: () => null }; + globalThis.document = { + addEventListener: () => {}, + querySelector: () => null, + removeEventListener: () => {}, + visibilityState: "visible", + }; performance.clearMeasures?.(CHANNEL_SWITCH_MEASURE); const unregister = registerNavigationGuard(() => false); try { diff --git a/desktop/src/app/navigation/useAppNavigation.ts b/desktop/src/app/navigation/useAppNavigation.ts index 7ad42560d70..b7452a7d060 100644 --- a/desktop/src/app/navigation/useAppNavigation.ts +++ b/desktop/src/app/navigation/useAppNavigation.ts @@ -319,14 +319,15 @@ export function useAppNavigation() { threadRootId: options.threadRootId ?? null, } : undefined, - // Every channel navigation entry point funnels through here, so this - // is the single click-time anchor for the switch trace; it opens - // inside commitGuardedNavigation only after the navigation guard - // accepts. Re-selecting the already-active channel is a no-op - // navigation: the channel's effects never rerun, nothing would - // settle the trace, and it would squat on the singleton until - // timeout — so don't open one. (History back/forward bypasses - // goChannel entirely and is deliberately untraced.) + // Every channel navigation entry point funnels through here, so + // this is the single click-time anchor for the switch trace; it + // opens inside commitGuardedNavigation only after the navigation + // guard accepts. Navigations that stay on the already-active + // channel (exact re-click is a no-op; jump-to-message/autoSend/ + // force change only search params) never re-run the channel's + // settle effects, so a trace could only time out — they stay + // untraced, as does history back/forward, which bypasses goChannel + // entirely. location.pathname.endsWith(`/channels/${channelId}`) ? undefined : channelId, diff --git a/desktop/src/features/channels/useChannelSwitchTraceMarks.ts b/desktop/src/features/channels/useChannelSwitchTraceMarks.ts index e78bb518fe2..dece8ab0aae 100644 --- a/desktop/src/features/channels/useChannelSwitchTraceMarks.ts +++ b/desktop/src/features/channels/useChannelSwitchTraceMarks.ts @@ -10,8 +10,9 @@ import { import type { ChannelType } from "@/shared/api/types"; /** - * Switch-trace stage marks for the channel screen. Route commit fires on the - * first render for the target channel; settle fires once its timeline leaves + * Switch-trace stage marks for the channel screen. Route commit fires in a + * layout effect — before the first paint — of the first commit where the + * target channel object has resolved; settle fires once its timeline leaves * the loading latch. Both are no-ops unless goChannel opened a trace for this * channel. Forum readiness is owned by ForumView's own queries, which the * timeline latch cannot observe — those traces are abandoned instead of @@ -26,7 +27,9 @@ export function useChannelSwitchTraceMarks({ activeChannelType: ChannelType | null; isTimelineLoading: boolean; }): void { - React.useEffect(() => { + // Layout effect: a passive effect flushes after paint, which would report + // "commit" as first-paint time rather than commit time. + React.useLayoutEffect(() => { if (activeChannelId) markChannelSwitchRouteCommit(activeChannelId); }, [activeChannelId]); // Route-exit cancellation: leaving the channel surface before the trace diff --git a/desktop/src/features/messages/lib/projectChannelWindow.test.mjs b/desktop/src/features/messages/lib/projectChannelWindow.test.mjs index 24ea75edc03..a54ad751e27 100644 --- a/desktop/src/features/messages/lib/projectChannelWindow.test.mjs +++ b/desktop/src/features/messages/lib/projectChannelWindow.test.mjs @@ -429,7 +429,12 @@ test("canceled fetch never claims the switch trace's window slot; the accepted o requestAnimationFrame: (cb) => frames.push(cb) && frames.length, cancelAnimationFrame: () => {}, }; - globalThis.document = { querySelector: () => null }; + globalThis.document = { + addEventListener: () => {}, + querySelector: () => null, + removeEventListener: () => {}, + visibilityState: "visible", + }; const { beginChannelSwitchTrace, settleChannelSwitchTrace, diff --git a/desktop/src/features/projects/ui/ProjectsView.tsx b/desktop/src/features/projects/ui/ProjectsView.tsx index 6b85ef46b49..ecf0671e905 100644 --- a/desktop/src/features/projects/ui/ProjectsView.tsx +++ b/desktop/src/features/projects/ui/ProjectsView.tsx @@ -172,8 +172,8 @@ export function ProjectsView() { snapshotProjects, activeCommunity?.reposDir, ); - // Hydration marker for the switch-perf harness: the shell commits long - // before the query fan loads. isLoading so disabled queries never wedge it. + // Switch-perf hydration marker (shell commits long before the fan loads). + // isLoading: first load only — warm refetches/disabled queries stay false. const projectsHydrating = projectsQuery.isLoading || projectsWorkItemsQuery.isLoading || diff --git a/desktop/src/shared/lib/channelSwitchPerf.test.mjs b/desktop/src/shared/lib/channelSwitchPerf.test.mjs index f590ac4fc65..957c86efed7 100644 --- a/desktop/src/shared/lib/channelSwitchPerf.test.mjs +++ b/desktop/src/shared/lib/channelSwitchPerf.test.mjs @@ -107,15 +107,28 @@ test("settle drops a trace that has timed out", () => { test("the settle wait records truncated — never as an honest settle — at deadline", () => { // Still pending, before the deadline: keep waiting. - assert.equal(resolveSettleWait(4_999, 5_000, true), "wait"); + assert.equal(resolveSettleWait(4_999, 5_000, true, 0), "wait"); // Render caught up: record cleanly. - assert.deepEqual(resolveSettleWait(1_000, 5_000, false), { + assert.deepEqual(resolveSettleWait(1_000, 5_000, false, 0), { settleWaitTruncated: false, }); // Deadline expired while still pending: the record must say so — a >5s // switch reported as an ordinary settle would hide exactly the tail this // tracer exists to expose. - assert.deepEqual(resolveSettleWait(5_000, 5_000, true), { + assert.deepEqual(resolveSettleWait(5_000, 5_000, true, 0), { + settleWaitTruncated: true, + }); +}); + +test("a frame-starved trace is dropped, not recorded as a clean settle", () => { + // rAF suspends in hidden windows, so a queued settle can fire minutes + // after the click with renderPending long since false — the absence must + // not be charged to the switch. Nothing legitimate can be older than the + // 30s settle-entry timeout plus the 5s render wait. + assert.equal(resolveSettleWait(35_001, 40_000, false, 0), "drop"); + assert.equal(resolveSettleWait(35_001, 40_000, true, 0), "drop"); + // At the bound (a 29.9s settle plus a truncated 5s wait) records survive. + assert.deepEqual(resolveSettleWait(35_000, 34_900, true, 0), { settleWaitTruncated: true, }); }); @@ -145,7 +158,7 @@ test("fetches attribute only when started after the switch began", () => { // --- Settle lifecycle: rapid switches and community resets ---------------- -async function withSettleHarness(run) { +async function withSettleHarness(run, documentOverrides = {}) { const frames = []; const originalWindow = globalThis.window; const originalDocument = globalThis.document; @@ -153,7 +166,13 @@ async function withSettleHarness(run) { requestAnimationFrame: (cb) => frames.push(cb) && frames.length, cancelAnimationFrame: () => {}, }; - globalThis.document = { querySelector: () => null }; + globalThis.document = { + addEventListener: () => {}, + querySelector: () => null, + removeEventListener: () => {}, + visibilityState: "visible", + ...documentOverrides, + }; const { abandonChannelSwitchTrace, beginChannelSwitchTrace, @@ -229,6 +248,44 @@ test("an undisturbed settle records exactly one measure", async () => { }); }); +test("a settle in a hidden window drops the trace instead of recording", async () => { + await withSettleHarness( + async ({ begin, settle, flush, measures }) => { + begin("aaaa1111aaaa1111"); + // rAF is suspended while hidden; the queued chain would only fire when + // the user returns, charging the whole absence to the switch. + settle("aaaa1111aaaa1111"); + flush(); + assert.deepEqual(measures(), []); + // The trace was released, not wedged: a later stale settle is a no-op. + settle("aaaa1111aaaa1111"); + flush(); + assert.deepEqual(measures(), []); + }, + { visibilityState: "hidden" }, + ); +}); + +test("a window hidden during the settle wait drops the record", async () => { + const visibilityListeners = []; + await withSettleHarness( + async ({ begin, settle, flush, measures }) => { + begin("aaaa1111aaaa1111"); + settle("aaaa1111aaaa1111"); + assert.equal(visibilityListeners.length, 1, "wait registers a listener"); + // The user cmd-tabs away mid-wait; frames resume only on return. + visibilityListeners[0](); + flush(); + assert.deepEqual(measures(), []); + }, + { + addEventListener: (type, listener) => { + if (type === "visibilitychange") visibilityListeners.push(listener); + }, + }, + ); +}); + test("a scheduled route-exit abandon canceled in the same task keeps the trace", async () => { await withSettleHarness( async ({ diff --git a/desktop/src/shared/lib/channelSwitchPerf.ts b/desktop/src/shared/lib/channelSwitchPerf.ts index 0daa46890ee..163658a05e6 100644 --- a/desktop/src/shared/lib/channelSwitchPerf.ts +++ b/desktop/src/shared/lib/channelSwitchPerf.ts @@ -17,6 +17,10 @@ * before the settled paint. A roster fetch that completes after settle is * deliberately not part of the felt switch latency, so such switches report * `members=cache` — by design, not omission. + * + * The settled timestamp lands one rAF after the paint, so `totalMs` includes + * up to one display refresh interval (~17ms at 60Hz, ~8ms at 120Hz) — + * compare before/after runs on the same display. */ import { invoke, isTauri } from "@tauri-apps/api/core"; @@ -263,13 +267,20 @@ const SETTLE_RENDER_WAIT_MS = 5_000; * the wait ends with the render still pending, the record must say so — the * >deadline tail is exactly what this tracer exists to expose, so the * measurement is kept but flagged rather than posing as an honest settled - * paint. Pure for unit testing. + * paint. A trace older than the settle-entry timeout plus the render wait + * is frame-starved (hidden window, display sleep — rAF suspends there) and + * is dropped: nothing legitimate can reach that age, and recording it would + * charge the whole absence to the switch. Pure for unit testing. */ export function resolveSettleWait( now: number, waitDeadline: number, renderPending: boolean, -): "wait" | { settleWaitTruncated: boolean } { + startedAt: number, +): "wait" | "drop" | { settleWaitTruncated: boolean } { + if (now - startedAt > SWITCH_TRACE_TIMEOUT_MS + SETTLE_RENDER_WAIT_MS) { + return "drop"; + } if (renderPending && now < waitDeadline) return "wait"; return { settleWaitTruncated: renderPending }; } @@ -299,6 +310,25 @@ export function settleChannelSwitchTrace(channelId: string): void { activeTrace = null; return; } + // rAF suspends entirely in hidden windows: a queued settle would fire + // only when the user returns, charging the whole absence to the switch as + // a clean record. Drop at settle when already hidden, and poison the wait + // if the window hides before the record lands — better no measurement + // than a fabricated one. + if (document.visibilityState === "hidden") { + activeTrace = null; + return; + } + let hiddenDuringWait = false; + const onVisibilityChange = () => { + hiddenDuringWait = true; + }; + document.addEventListener("visibilitychange", onVisibilityChange, { + once: true, + }); + const stopWatchingVisibility = () => { + document.removeEventListener("visibilitychange", onVisibilityChange); + }; // Keep the trace active through the deferred-commit wait so fetches that // finish inside the measured window still attribute to it. It is released // when the record lands; a newer switch's begin() simply replaces it. @@ -306,6 +336,12 @@ export function settleChannelSwitchTrace(channelId: string): void { const record = (settleWaitTruncated: boolean) => { const settledAt = performance.now(); if (activeTrace === trace) activeTrace = null; + // Keep only the latest switch in the User Timing buffer: desktop + // sessions run for weeks and the buffer is never GC'd. DevTools + // recordings capture entries at emit time, so clearing loses nothing. + performance.clearMarks(CHANNEL_SWITCH_START_MARK); + performance.clearMarks(CHANNEL_SWITCH_SETTLED_MARK); + performance.clearMeasures(CHANNEL_SWITCH_MEASURE); performance.mark(CHANNEL_SWITCH_SETTLED_MARK, { detail: { channelId }, }); @@ -327,6 +363,10 @@ export function settleChannelSwitchTrace(channelId: string): void { buildSwitchPerfLogRecord(trace, settledAt, settleWaitTruncated), ); }; + const dropTrace = () => { + stopWatchingVisibility(); + if (activeTrace === trace) activeTrace = null; + }; const awaitDeferredCommit = () => { if (activeTrace !== trace) { // A newer switch replaced this trace, or a community reset dropped it. @@ -334,19 +374,37 @@ export function settleChannelSwitchTrace(channelId: string): void { // own — recording would charge the replacement's delay to the settled // channel and could manufacture the very regression the tracer exists // to diagnose. Better no measurement than a fabricated one. + stopWatchingVisibility(); + return; + } + if (hiddenDuringWait) { + dropTrace(); return; } const decision = resolveSettleWait( performance.now(), waitDeadline, document.querySelector('[data-render-pending="true"]') !== null, + trace.startedAt, ); if (decision === "wait") { window.requestAnimationFrame(awaitDeferredCommit); return; } + if (decision === "drop") { + dropTrace(); + return; + } window.requestAnimationFrame(() => { - if (activeTrace !== trace) return; + if (activeTrace !== trace) { + stopWatchingVisibility(); + return; + } + if (hiddenDuringWait) { + dropTrace(); + return; + } + stopWatchingVisibility(); record(decision.settleWaitTruncated); }); }; diff --git a/desktop/tests/e2e/member-heavy-switch.perf.ts b/desktop/tests/e2e/member-heavy-switch.perf.ts index 97096f37aac..69efb094ab4 100644 --- a/desktop/tests/e2e/member-heavy-switch.perf.ts +++ b/desktop/tests/e2e/member-heavy-switch.perf.ts @@ -7,12 +7,13 @@ import { installMockBridge } from "../helpers/bridge"; * * Isolates how channel MEMBERSHIP SIZE scales the warm-switch cost, holding * message volume constant. Every channel object embeds its full - * member-pubkey array, so membership size inflates (a) the get_channels - * payload parsed on every poll, (b) the per-switch get_channel_members - * response, and (c) every render-path pass over `channel.memberPubkeys` and - * the member list (profile merges, agent-flag merges, mention candidates). - * This spec is the instrument for that scaling: same channels, same rows, - * member count is the only variable. + * member-pubkey array, so membership size inflates every render-path pass + * over `channel.memberPubkeys` and the member list (profile merges, + * agent-flag merges, mention candidates). NOTE: the mock bridge hands the + * app live JS objects — no IPC serialization or JSON parse — so the + * get_channels/get_channel_members parse cost that scales with membership + * in production is NOT exercised here; this instrument measures render-path + * scaling only. Same channels, same rows, member count the only variable. * * Two scenarios per member count: * channel<->channel — general <-> deep-history (150 fixed rows). @@ -21,12 +22,15 @@ import { installMockBridge } from "../helpers/bridge"; * (project enumeration, work items, repo snapshots, * activity summaries) on top of the shell, so this * axis captures the cross-surface switch the felt - * 1-2s report singled out. Readiness gates on the - * surface's data-projects-hydrating marker, not just - * the shell header — a sample whose query fan is - * still loading must not count as settled. After the - * untimed warmup both surfaces are cache-warm, so - * measured samples are warm-switch commits. + * 1-2s report singled out. Readiness requires the + * shell header AND the absence of the surface's + * data-projects-hydrating marker. The marker guards + * cold/invalidated samples whose query fan is still + * on first load; after the untimed warmup the fan is + * cached and renders synchronously, so measured + * samples are warm-switch commits (the marker never + * fires there — that is the warm contract, not a + * gap). * * Method mirrors warm-switch-markdown.perf.ts: in-page click + rAF polling * (CDP latency never pollutes samples), longtask capture per switch, 4x CPU @@ -86,7 +90,10 @@ async function measureSwitch( }, ): Promise { return page.evaluate(async (args) => { - const store = window as unknown as { __LONGTASKS__: number[] }; + const store = window as unknown as { + __LONGTASKS__: number[]; + __LONGTASK_OBSERVER__?: PerformanceObserver; + }; store.__LONGTASKS__ = []; const link = document.querySelector( `[data-testid="${args.targetTestId}"]`, @@ -123,6 +130,11 @@ async function measureSwitch( }); const elapsed = performance.now() - start; + // Observer callbacks are delivered in a later task; drain the queue so + // a longtask ending just before the resolve frame isn't dropped. + for (const entry of store.__LONGTASK_OBSERVER__?.takeRecords() ?? []) { + store.__LONGTASKS__.push(entry.duration); + } const tasks = store.__LONGTASKS__ ?? []; return { ms: elapsed, @@ -255,13 +267,19 @@ for (const memberCount of MEMBER_COUNTS) { // Arm the longtask observer; addInitScript applies on next navigation. await page.addInitScript(() => { - const store = window as unknown as { __LONGTASKS__?: number[] }; + const store = window as unknown as { + __LONGTASKS__?: number[]; + __LONGTASK_OBSERVER__?: PerformanceObserver; + }; store.__LONGTASKS__ = []; - new PerformanceObserver((list) => { + const observer = new PerformanceObserver((list) => { for (const entry of list.getEntries()) { store.__LONGTASKS__?.push(entry.duration); } - }).observe({ type: "longtask", buffered: true }); + }); + observer.observe({ type: "longtask", buffered: true }); + // Exposed so measureSwitch can drain takeRecords() at sample time. + store.__LONGTASK_OBSERVER__ = observer; }); await page.reload(); await page.waitForFunction( From f6d3388ecf59fad1f14a69e58b5f51a82d151e61 Mon Sep 17 00:00:00 2001 From: Max Lampert Date: Mon, 24 Aug 2026 20:46:52 -0700 Subject: [PATCH 06/27] fix(desktop): bound start marks for traces that never record Blind-review round 3 (Sonnet) finding: beginChannelSwitchTrace marked unconditionally but only record() cleared, so every abandoned/dropped trace (forum visits, route exits, hidden-window and frame-starved drops) leaked a permanent buzz:channel-switch:start entry. Clearing the previous start mark at begin bounds the buffer to one entry no matter how the prior trace ended. Co-Authored-By: Claude Fable 5 Signed-off-by: Max Lampert --- .../src/shared/lib/channelSwitchPerf.test.mjs | 21 +++++++++++++++++++ desktop/src/shared/lib/channelSwitchPerf.ts | 6 ++++++ 2 files changed, 27 insertions(+) diff --git a/desktop/src/shared/lib/channelSwitchPerf.test.mjs b/desktop/src/shared/lib/channelSwitchPerf.test.mjs index 957c86efed7..a83471a129a 100644 --- a/desktop/src/shared/lib/channelSwitchPerf.test.mjs +++ b/desktop/src/shared/lib/channelSwitchPerf.test.mjs @@ -248,6 +248,27 @@ test("an undisturbed settle records exactly one measure", async () => { }); }); +test("abandoned switches never accumulate start marks", async () => { + await withSettleHarness(async ({ abandon, begin }) => { + const { CHANNEL_SWITCH_START_MARK } = await import( + "./channelSwitchPerf.ts" + ); + performance.clearMarks?.(CHANNEL_SWITCH_START_MARK); + // Traces that die without recording (forum visits, route exits, drops) + // never reach record()'s buffer clearing — begin() must bound the + // buffer itself or weeks-long sessions accumulate a mark per abandon. + for (const channelId of ["aaaa", "bbbb", "cccc", "dddd"]) { + begin(channelId); + abandon(channelId); + } + assert.equal( + performance.getEntriesByName(CHANNEL_SWITCH_START_MARK).length, + 1, + ); + performance.clearMarks?.(CHANNEL_SWITCH_START_MARK); + }); +}); + test("a settle in a hidden window drops the trace instead of recording", async () => { await withSettleHarness( async ({ begin, settle, flush, measures }) => { diff --git a/desktop/src/shared/lib/channelSwitchPerf.ts b/desktop/src/shared/lib/channelSwitchPerf.ts index 163658a05e6..bbe09bd09a6 100644 --- a/desktop/src/shared/lib/channelSwitchPerf.ts +++ b/desktop/src/shared/lib/channelSwitchPerf.ts @@ -201,6 +201,12 @@ export function beginChannelSwitchTrace(channelId: string): void { windowFetch: null, membersFetch: null, }; + // Clear the previous start mark here, not only in record(): traces that + // die without recording (forum visits, route exits, drops) never reach + // record()'s buffer clearing, and weeks-long sessions would accumulate a + // stray mark per abandon. Clearing at begin bounds the buffer to one + // start mark no matter how the previous trace ended. + performance.clearMarks(CHANNEL_SWITCH_START_MARK); performance.mark(CHANNEL_SWITCH_START_MARK, { detail: { channelId } }); } From 96817abc2b9fda4158fede86543dcd1bcdd5976b Mon Sep 17 00:00:00 2001 From: Max Lampert Date: Mon, 24 Aug 2026 21:17:36 -0700 Subject: [PATCH 07/27] fix(desktop): drop starved settles and hidden-overlap traces; clean buffer at begin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Blind-review round 4 (Opus xhigh) findings: - A not-pending frame landing past the wait deadline is rAF starvation (system suspend, App Nap — no visibilitychange), not render time: a fast settle followed by a stall recorded up to 34s as a clean switch under the click-anchored 35s age guard. Such frames now drop. - Visibility accounting now spans the whole trace: one module-level visibilitychange watcher timestamps transitions, and any transition since the click (not just during the settle wait) drops the trace — a window minimized while the fetch was in flight no longer records its absence as switch time. Replaces the per-settle listener. - begin() now clears the previous switch's settled mark and measure too, so a consumer polling the User Timing buffer mid-switch can never read the prior switch's entries as the current one's. - The goChannel comment no longer claims every channel navigation funnels through it: Pulse startDm navigates to the channel route directly and is untraced. Co-Authored-By: Claude Fable 5 Signed-off-by: Max Lampert --- .../src/app/navigation/useAppNavigation.ts | 18 ++-- .../src/shared/lib/channelSwitchPerf.test.mjs | 58 +++++++++++++ desktop/src/shared/lib/channelSwitchPerf.ts | 87 ++++++++++++------- 3 files changed, 121 insertions(+), 42 deletions(-) diff --git a/desktop/src/app/navigation/useAppNavigation.ts b/desktop/src/app/navigation/useAppNavigation.ts index b7452a7d060..913f51a2dee 100644 --- a/desktop/src/app/navigation/useAppNavigation.ts +++ b/desktop/src/app/navigation/useAppNavigation.ts @@ -319,15 +319,15 @@ export function useAppNavigation() { threadRootId: options.threadRootId ?? null, } : undefined, - // Every channel navigation entry point funnels through here, so - // this is the single click-time anchor for the switch trace; it - // opens inside commitGuardedNavigation only after the navigation - // guard accepts. Navigations that stay on the already-active - // channel (exact re-click is a no-op; jump-to-message/autoSend/ - // force change only search params) never re-run the channel's - // settle effects, so a trace could only time out — they stay - // untraced, as does history back/forward, which bypasses goChannel - // entirely. + // goChannel is the click-time anchor for the switch trace; it opens + // inside commitGuardedNavigation only after the navigation guard + // accepts. Coverage: sidebar/search/notification navigations funnel + // through here — direct navigate() callers (Pulse startDm) and + // history back/forward are untraced. Navigations that stay on the + // already-active channel (exact re-click only rewrites router state; + // jump-to-message/autoSend/force change only search params) never + // re-run the channel's settle effects, so a trace could only time out + // — also untraced. location.pathname.endsWith(`/channels/${channelId}`) ? undefined : channelId, diff --git a/desktop/src/shared/lib/channelSwitchPerf.test.mjs b/desktop/src/shared/lib/channelSwitchPerf.test.mjs index a83471a129a..4a54c337638 100644 --- a/desktop/src/shared/lib/channelSwitchPerf.test.mjs +++ b/desktop/src/shared/lib/channelSwitchPerf.test.mjs @@ -133,6 +133,19 @@ test("a frame-starved trace is dropped, not recorded as a clean settle", () => { }); }); +test("a not-pending frame landing past the wait deadline is starvation, not a settle", () => { + // Settle entered at t=1s (deadline 6s), render caught up, then frames + // stalled (system suspend, App Nap — no visibilitychange): the next frame + // lands at t=20s with nothing pending. The gap is starvation; recording + // it would fabricate a clean 20s switch well under the 35s age guard. + assert.equal(resolveSettleWait(20_000, 6_000, false, 0), "drop"); + // Still-pending arrivals past the deadline remain truncated records: the + // render genuinely wasn't done, which is the tail the tracer must keep. + assert.deepEqual(resolveSettleWait(6_001, 6_000, true, 0), { + settleWaitTruncated: true, + }); +}); + test("a truncated settle is flagged in the summary and the log record", () => { const summary = summarizeChannelSwitchTrace(trace(), 1_412, true); assert.ok(summary.endsWith(" settle=truncated"), summary); @@ -248,6 +261,51 @@ test("an undisturbed settle records exactly one measure", async () => { }); }); +test("beginning a switch clears the previous switch's settled mark and measure", async () => { + await withSettleHarness(async ({ begin, settle, flush, measures }) => { + const { CHANNEL_SWITCH_SETTLED_MARK } = await import( + "./channelSwitchPerf.ts" + ); + begin("aaaa1111aaaa1111"); + settle("aaaa1111aaaa1111"); + flush(); + assert.deepEqual(measures(), ["aaaa1111aaaa1111"]); + // A consumer polling the buffer mid-switch (the Playwright specs, the + // Performance panel) must never read the PREVIOUS switch's entries as + // the current one's. + begin("bbbb2222bbbb2222"); + assert.deepEqual(measures(), []); + assert.equal( + performance.getEntriesByName(CHANNEL_SWITCH_SETTLED_MARK).length, + 0, + ); + }); +}); + +test("a window hidden between click and settle entry drops the trace", async () => { + const visibilityListeners = []; + await withSettleHarness( + async ({ begin, settle, flush, measures }) => { + begin("aaaa1111aaaa1111"); + assert.ok(visibilityListeners.length >= 1, "watcher armed at begin"); + // The window hides while the fetch is in flight (cmd-H / minimize), + // then the user returns and the settle runs with the window visible + // again: the absence sits inside totalMs, so the trace must drop. + globalThis.document.visibilityState = "hidden"; + for (const listener of visibilityListeners) listener(); + globalThis.document.visibilityState = "visible"; + settle("aaaa1111aaaa1111"); + flush(); + assert.deepEqual(measures(), []); + }, + { + addEventListener: (type, listener) => { + if (type === "visibilitychange") visibilityListeners.push(listener); + }, + }, + ); +}); + test("abandoned switches never accumulate start marks", async () => { await withSettleHarness(async ({ abandon, begin }) => { const { CHANNEL_SWITCH_START_MARK } = await import( diff --git a/desktop/src/shared/lib/channelSwitchPerf.ts b/desktop/src/shared/lib/channelSwitchPerf.ts index bbe09bd09a6..a5fa935a905 100644 --- a/desktop/src/shared/lib/channelSwitchPerf.ts +++ b/desktop/src/shared/lib/channelSwitchPerf.ts @@ -192,8 +192,35 @@ export function cancelRouteExitAbandon(channelId: string): void { pendingRouteExitAbandons.delete(channelId); } +/** + * Timestamp of the most recent visibilitychange. rAF suspends and network + * work throttles while the window is hidden, so any visibility transition + * inside a trace window means an off-screen interval overlaps the + * measurement — such traces are dropped rather than charged with the + * absence. One listener per document (tests swap documents). + */ +let lastVisibilityChangeAt = Number.NEGATIVE_INFINITY; +const watchedDocuments = new WeakSet(); + +function ensureVisibilityWatcher(): void { + if (typeof document === "undefined" || !document.addEventListener) return; + if (watchedDocuments.has(document)) return; + watchedDocuments.add(document); + document.addEventListener("visibilitychange", () => { + lastVisibilityChangeAt = performance.now(); + }); +} + +function traceOverlapsHiddenWindow(trace: ChannelSwitchTrace): boolean { + return ( + document.visibilityState === "hidden" || + lastVisibilityChangeAt >= trace.startedAt + ); +} + export function beginChannelSwitchTrace(channelId: string): void { if (typeof performance === "undefined") return; + ensureVisibilityWatcher(); activeTrace = { channelId, startedAt: performance.now(), @@ -201,12 +228,15 @@ export function beginChannelSwitchTrace(channelId: string): void { windowFetch: null, membersFetch: null, }; - // Clear the previous start mark here, not only in record(): traces that + // Clear the whole previous switch here, not only in record(): traces that // die without recording (forum visits, route exits, drops) never reach - // record()'s buffer clearing, and weeks-long sessions would accumulate a - // stray mark per abandon. Clearing at begin bounds the buffer to one - // start mark no matter how the previous trace ended. + // record()'s buffer clearing — weeks-long sessions would accumulate a + // stray start mark per abandon — and a consumer polling the buffer + // mid-switch (Playwright specs, Performance panel) must never read the + // previous switch's settled mark or measure as the current one's. performance.clearMarks(CHANNEL_SWITCH_START_MARK); + performance.clearMarks(CHANNEL_SWITCH_SETTLED_MARK); + performance.clearMeasures(CHANNEL_SWITCH_MEASURE); performance.mark(CHANNEL_SWITCH_START_MARK, { detail: { channelId } }); } @@ -273,10 +303,15 @@ const SETTLE_RENDER_WAIT_MS = 5_000; * the wait ends with the render still pending, the record must say so — the * >deadline tail is exactly what this tracer exists to expose, so the * measurement is kept but flagged rather than posing as an honest settled - * paint. A trace older than the settle-entry timeout plus the render wait - * is frame-starved (hidden window, display sleep — rAF suspends there) and - * is dropped: nothing legitimate can reach that age, and recording it would - * charge the whole absence to the switch. Pure for unit testing. + * paint. Frame starvation is dropped, not recorded: a healthy chain with + * nothing pending records within a frame or two of settle entry, so a + * not-pending frame landing past the wait deadline means the gap was rAF + * suspension (system suspend, App Nap — cases that fire no + * visibilitychange), not render time. A trace older than the settle-entry + * timeout plus the render wait is dropped on the same grounds regardless of + * pending state. The rare honest render that catches up within one frame of + * the deadline is sacrificed by the first rule — better no measurement than + * a fabricated one. Pure for unit testing. */ export function resolveSettleWait( now: number, @@ -287,6 +322,7 @@ export function resolveSettleWait( if (now - startedAt > SWITCH_TRACE_TIMEOUT_MS + SETTLE_RENDER_WAIT_MS) { return "drop"; } + if (!renderPending && now > waitDeadline) return "drop"; if (renderPending && now < waitDeadline) return "wait"; return { settleWaitTruncated: renderPending }; } @@ -316,25 +352,16 @@ export function settleChannelSwitchTrace(channelId: string): void { activeTrace = null; return; } - // rAF suspends entirely in hidden windows: a queued settle would fire - // only when the user returns, charging the whole absence to the switch as - // a clean record. Drop at settle when already hidden, and poison the wait - // if the window hides before the record lands — better no measurement - // than a fabricated one. - if (document.visibilityState === "hidden") { + // rAF suspends and network work throttles in hidden windows: a hidden + // interval anywhere between the click and the recorded settle would be + // charged to the switch as a clean record. Drop when the window is hidden + // now or any visibility transition happened since the click — better no + // measurement than a fabricated one. + ensureVisibilityWatcher(); + if (traceOverlapsHiddenWindow(trace)) { activeTrace = null; return; } - let hiddenDuringWait = false; - const onVisibilityChange = () => { - hiddenDuringWait = true; - }; - document.addEventListener("visibilitychange", onVisibilityChange, { - once: true, - }); - const stopWatchingVisibility = () => { - document.removeEventListener("visibilitychange", onVisibilityChange); - }; // Keep the trace active through the deferred-commit wait so fetches that // finish inside the measured window still attribute to it. It is released // when the record lands; a newer switch's begin() simply replaces it. @@ -370,7 +397,6 @@ export function settleChannelSwitchTrace(channelId: string): void { ); }; const dropTrace = () => { - stopWatchingVisibility(); if (activeTrace === trace) activeTrace = null; }; const awaitDeferredCommit = () => { @@ -380,10 +406,9 @@ export function settleChannelSwitchTrace(channelId: string): void { // own — recording would charge the replacement's delay to the settled // channel and could manufacture the very regression the tracer exists // to diagnose. Better no measurement than a fabricated one. - stopWatchingVisibility(); return; } - if (hiddenDuringWait) { + if (traceOverlapsHiddenWindow(trace)) { dropTrace(); return; } @@ -402,15 +427,11 @@ export function settleChannelSwitchTrace(channelId: string): void { return; } window.requestAnimationFrame(() => { - if (activeTrace !== trace) { - stopWatchingVisibility(); - return; - } - if (hiddenDuringWait) { + if (activeTrace !== trace) return; + if (traceOverlapsHiddenWindow(trace)) { dropTrace(); return; } - stopWatchingVisibility(); record(decision.settleWaitTruncated); }); }; From 083e9489d9943beaf624e77821beb09a8c41da5f Mon Sep 17 00:00:00 2001 From: Max Lampert Date: Mon, 24 Aug 2026 21:34:01 -0700 Subject: [PATCH 08/27] fix(desktop): discard stale longtask deliveries at perf-sample start Blind-review round 5 (Fable medium) finding: a longtask trailing switch N is delivered by the PerformanceObserver in a later task and landed in switch N+1's freshly reset array, inflating its longtask lines. Drain takeRecords() and discard before each sample's reset. Co-Authored-By: Claude Fable 5 Signed-off-by: Max Lampert --- desktop/tests/e2e/member-heavy-switch.perf.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/desktop/tests/e2e/member-heavy-switch.perf.ts b/desktop/tests/e2e/member-heavy-switch.perf.ts index 69efb094ab4..1fdff110af5 100644 --- a/desktop/tests/e2e/member-heavy-switch.perf.ts +++ b/desktop/tests/e2e/member-heavy-switch.perf.ts @@ -94,6 +94,10 @@ async function measureSwitch( __LONGTASKS__: number[]; __LONGTASK_OBSERVER__?: PerformanceObserver; }; + // Discard pending deliveries from BEFORE this sample: a longtask + // trailing the previous switch is delivered in a later task and would + // otherwise land in this sample's fresh array. + store.__LONGTASK_OBSERVER__?.takeRecords(); store.__LONGTASKS__ = []; const link = document.querySelector( `[data-testid="${args.targetTestId}"]`, From 46dd5d5ccf0e302901f9db135c94eedb9c14303d Mon Sep 17 00:00:00 2001 From: Max Lampert Date: Mon, 24 Aug 2026 22:01:01 -0700 Subject: [PATCH 09/27] fix(desktop): degrade to unrotated append when perf-log rotation fails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Blind-review round 6 (Sonnet xhigh) finding: a transient lock on the rotated generation (AV/EDR or an editor, chiefly Windows) failed the whole append — and since the live file stays oversized, every later append re-entered the same failing branch, silently dropping every record until the lock cleared. Rotation is now best-effort: on failure the line appends unrotated and the size cap re-applies once a later rotation succeeds. Unix regression locks the directory and asserts the line survives. Co-Authored-By: Claude Fable 5 Signed-off-by: Max Lampert --- desktop/src-tauri/src/commands/perf_log.rs | 59 +++++++++++++++++++--- 1 file changed, 52 insertions(+), 7 deletions(-) diff --git a/desktop/src-tauri/src/commands/perf_log.rs b/desktop/src-tauri/src/commands/perf_log.rs index 6ced5b34672..a2c8ce21a87 100644 --- a/desktop/src-tauri/src/commands/perf_log.rs +++ b/desktop/src-tauri/src/commands/perf_log.rs @@ -102,14 +102,24 @@ fn append_line_rotating(path: &std::path::Path, line: &str, max_bytes: u64) -> R rotated.push(".1"); let rotated = std::path::PathBuf::from(rotated); // Remove the retained generation before renaming over it: on - // Windows, rename does not replace an existing destination, and a - // failed rotation here would silently drop every subsequent trace - // (the frontend deliberately swallows sink errors). Same platform - // rule as managed_agents::storage::start_install_log_session. - if rotated.exists() { - std::fs::remove_file(&rotated).map_err(|e| e.to_string())?; + // Windows, rename does not replace an existing destination. Same + // platform rule as managed_agents::storage::start_install_log_session. + // + // Rotation itself is best-effort: an AV/EDR or editor holding a + // transient lock (again, chiefly Windows) would otherwise fail + // EVERY append until the lock clears — the frontend deliberately + // swallows sink errors, so records would vanish silently. + // Degrade to an unrotated append; the size cap re-applies once + // rotation succeeds on a later write. + let rotation = (|| -> std::io::Result<()> { + if rotated.exists() { + std::fs::remove_file(&rotated)?; + } + std::fs::rename(path, &rotated) + })(); + if let Err(e) = rotation { + eprintln!("buzz-desktop: perf-log rotation failed, appending unrotated: {e}"); } - std::fs::rename(path, &rotated).map_err(|e| e.to_string())?; } } let mut file = std::fs::OpenOptions::new() @@ -316,6 +326,41 @@ mod tests { std::fs::remove_dir_all(&dir).ok(); } + #[cfg(unix)] + #[test] + fn a_failed_rotation_degrades_to_an_unrotated_append() { + use std::os::unix::fs::PermissionsExt; + let dir = std::env::temp_dir().join(format!( + "perf-log-degrade-{}-{:?}", + std::process::id(), + std::thread::current().id() + )); + std::fs::create_dir_all(&dir).expect("tempdir"); + let path = dir.join("switch-perf.jsonl"); + let rotated = dir.join("switch-perf.jsonl.1"); + std::fs::write(&path, "oversized-live\n").expect("seed live"); + std::fs::write(&rotated, "old-generation\n").expect("seed rotated"); + + // A read-only directory makes remove/rename fail, like a transient + // AV/EDR hold would. The append must degrade to the unrotated file — + // dropping every record until an external lock clears would violate + // the sink's no-silent-loss contract. + std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o555)).expect("lock dir"); + let result = append_line_rotating(&path, "must-survive", 8); + std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).expect("unlock dir"); + result.expect("append must survive a failed rotation"); + + assert_eq!( + std::fs::read_to_string(&path).expect("read live"), + "oversized-live\nmust-survive\n" + ); + assert_eq!( + std::fs::read_to_string(&rotated).expect("read rotated"), + "old-generation\n" + ); + std::fs::remove_dir_all(&dir).ok(); + } + #[test] fn concurrent_boundary_appends_lose_no_line_and_rotate_once() { let dir = std::env::temp_dir().join(format!( From 087d9b7b903fcfd8415147986ec0a3c4d4ba5217 Mon Sep 17 00:00:00 2001 From: Max Lampert Date: Mon, 24 Aug 2026 22:25:28 -0700 Subject: [PATCH 10/27] fix(desktop): drop live traces on any exit from the channel surface Blind-review round 7 (Opus medium) findings: - P2: a trace begun while the channel route was still resolving never mounted ChannelScreen, so no route-exit cleanup existed; leaving for Home and history-backing into the channel within 30s settled the stale trace with the time spent away. Any committed non-channel navigation and any history traversal now drop the active trace at the navigation layer (dropActiveChannelSwitchTrace), which covers traces no component ever owned. Same-channel navigations keep the live trace. - The delayed-chunk spec now asserts no CLEAN measure while the chunk is held (the tracer honestly emits a truncated one if its 5s deadline passes) and guards its own timing budget explicitly so a slow CI box fails with the real reason. - The rotation-degradation test blocks rotation with a non-empty directory instead of chmod, so it also holds when tests run as root (containers), and now runs on Windows too. Co-Authored-By: Claude Fable 5 Signed-off-by: Max Lampert --- desktop/src-tauri/src/commands/perf_log.rs | 24 ++--- .../commitGuardedNavigation.test.mjs | 98 ++++++++++++++++++- .../app/navigation/commitGuardedNavigation.ts | 31 ++++-- desktop/src/app/navigation/navigationGuard.ts | 7 ++ .../src/app/navigation/useAppNavigation.ts | 4 + desktop/src/shared/lib/channelSwitchPerf.ts | 12 +++ .../e2e/switch-settle-after-paint.spec.ts | 29 ++++-- 7 files changed, 172 insertions(+), 33 deletions(-) diff --git a/desktop/src-tauri/src/commands/perf_log.rs b/desktop/src-tauri/src/commands/perf_log.rs index a2c8ce21a87..4ffbf81ef27 100644 --- a/desktop/src-tauri/src/commands/perf_log.rs +++ b/desktop/src-tauri/src/commands/perf_log.rs @@ -326,10 +326,8 @@ mod tests { std::fs::remove_dir_all(&dir).ok(); } - #[cfg(unix)] #[test] fn a_failed_rotation_degrades_to_an_unrotated_append() { - use std::os::unix::fs::PermissionsExt; let dir = std::env::temp_dir().join(format!( "perf-log-degrade-{}-{:?}", std::process::id(), @@ -339,25 +337,21 @@ mod tests { let path = dir.join("switch-perf.jsonl"); let rotated = dir.join("switch-perf.jsonl.1"); std::fs::write(&path, "oversized-live\n").expect("seed live"); - std::fs::write(&rotated, "old-generation\n").expect("seed rotated"); + // A non-empty DIRECTORY at the rotated path defeats remove_file and + // rename on every platform — including for root, where permission + // tricks no-op (containers often run tests as uid 0). It stands in + // for a transient AV/EDR hold: the append must degrade to the + // unrotated file, not drop records until the lock clears. + std::fs::create_dir_all(rotated.join("hold")).expect("seed blocker"); - // A read-only directory makes remove/rename fail, like a transient - // AV/EDR hold would. The append must degrade to the unrotated file — - // dropping every record until an external lock clears would violate - // the sink's no-silent-loss contract. - std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o555)).expect("lock dir"); - let result = append_line_rotating(&path, "must-survive", 8); - std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).expect("unlock dir"); - result.expect("append must survive a failed rotation"); + append_line_rotating(&path, "must-survive", 8) + .expect("append must survive a failed rotation"); assert_eq!( std::fs::read_to_string(&path).expect("read live"), "oversized-live\nmust-survive\n" ); - assert_eq!( - std::fs::read_to_string(&rotated).expect("read rotated"), - "old-generation\n" - ); + assert!(rotated.join("hold").exists(), "blocker untouched"); std::fs::remove_dir_all(&dir).ok(); } diff --git a/desktop/src/app/navigation/commitGuardedNavigation.test.mjs b/desktop/src/app/navigation/commitGuardedNavigation.test.mjs index 9ed897949d4..0b836ec20b2 100644 --- a/desktop/src/app/navigation/commitGuardedNavigation.test.mjs +++ b/desktop/src/app/navigation/commitGuardedNavigation.test.mjs @@ -2,15 +2,111 @@ import assert from "node:assert/strict"; import test from "node:test"; import { commitGuardedNavigation } from "./commitGuardedNavigation.ts"; -import { registerNavigationGuard } from "./navigationGuard.ts"; +import { registerNavigationGuard, traverseHistory } from "./navigationGuard.ts"; import { CHANNEL_SWITCH_MEASURE, + beginChannelSwitchTrace, resetChannelSwitchTrace, settleChannelSwitchTrace, } from "../../shared/lib/channelSwitchPerf.ts"; const route = (href) => ({ kind: "route", href }); +// Frame/document stubs so settle's rAF chain can be driven synchronously. +function withTraceHarness(run) { + const frames = []; + const originalWindow = globalThis.window; + const originalDocument = globalThis.document; + globalThis.window = { + requestAnimationFrame: (cb) => frames.push(cb) && frames.length, + cancelAnimationFrame: () => {}, + }; + globalThis.document = { + addEventListener: () => {}, + querySelector: () => null, + removeEventListener: () => {}, + visibilityState: "visible", + }; + performance.clearMeasures?.(CHANNEL_SWITCH_MEASURE); + const flush = () => { + for (let i = 0; i < 20 && frames.length > 0; i += 1) { + for (const cb of frames.splice(0, frames.length)) cb(); + } + }; + const measures = () => + performance + .getEntriesByName(CHANNEL_SWITCH_MEASURE) + .map((entry) => entry.detail?.channelId); + return (async () => { + try { + await run({ flush, measures }); + } finally { + resetChannelSwitchTrace(); + performance.clearMeasures?.(CHANNEL_SWITCH_MEASURE); + if (originalWindow === undefined) delete globalThis.window; + else globalThis.window = originalWindow; + if (originalDocument === undefined) delete globalThis.document; + else globalThis.document = originalDocument; + } + })(); +} + +test("a committed non-channel navigation drops the active trace", async () => { + await withTraceHarness(async ({ flush, measures }) => { + // A trace can be live with no channel screen mounted at all (the route + // still resolving), so no route-exit cleanup exists to abandon it — the + // navigation layer must drop it, or a history-back re-entry within the + // 30s timeout would settle it with the time spent away. + beginChannelSwitchTrace("bbbb"); + const committed = await commitGuardedNavigation({ + currentHref: "/channels/bbbb", + nextHref: "/", + guardedTarget: route("/"), + leavesChannelSurface: true, + navigate: async () => {}, + }); + assert.equal(committed, true); + settleChannelSwitchTrace("bbbb"); + flush(); + assert.deepEqual(measures(), []); + }); +}); + +test("a same-channel navigation never drops the channel's live trace", async () => { + await withTraceHarness(async ({ flush, measures }) => { + beginChannelSwitchTrace("bbbb"); + // Jump-to-message within the active channel: untraced, but must not + // kill the in-flight trace either. + await commitGuardedNavigation({ + currentHref: "/channels/bbbb", + nextHref: "/channels/bbbb?messageId=m1", + guardedTarget: route("/channels/bbbb?messageId=m1"), + leavesChannelSurface: false, + navigate: async () => {}, + }); + settleChannelSwitchTrace("bbbb"); + flush(); + assert.deepEqual(measures(), ["bbbb"]); + }); +}); + +test("history traversal drops the active trace", async () => { + await withTraceHarness(async ({ flush, measures }) => { + beginChannelSwitchTrace("bbbb"); + const calls = []; + // History navigation is deliberately untraced and its destination is + // unknowable here — a live trace must not survive into it. + traverseHistory( + { back: () => calls.push("back"), forward: () => {} }, + "back", + ); + assert.deepEqual(calls, ["back"]); + settleChannelSwitchTrace("bbbb"); + flush(); + assert.deepEqual(measures(), []); + }); +}); + test("a refused navigation opens no trace; a later history settle records nothing", async () => { // Frame-queue stub so the settle's rAF chain can be drained synchronously. const frames = []; diff --git a/desktop/src/app/navigation/commitGuardedNavigation.ts b/desktop/src/app/navigation/commitGuardedNavigation.ts index 9b9f69d6a2f..449442f5c94 100644 --- a/desktop/src/app/navigation/commitGuardedNavigation.ts +++ b/desktop/src/app/navigation/commitGuardedNavigation.ts @@ -2,19 +2,24 @@ import { allowNavigation, type GuardedNavigation, } from "@/app/navigation/navigationGuard"; -import { beginChannelSwitchTrace } from "@/shared/lib/channelSwitchPerf"; +import { + beginChannelSwitchTrace, + dropActiveChannelSwitchTrace, +} from "@/shared/lib/channelSwitchPerf"; /** * commitGuardedNavigation runs the shared commit flow for app navigations: - * skip same-destination no-ops (unless forced or carrying a router-state - * update), consult the navigation guard, then navigate. A same-href - * navigation that writes state — e.g. setting or clearing the search - * highlight — must still commit, or the state never lands. When - * `traceChannelId` is set, the channel-switch - * trace opens only after the guard accepts — a refused click must not leave - * an orphan trace that a later history navigation (deliberately untraced) - * would settle with the refused click's inflated wall time. Returns whether - * the navigation was performed. `deps` exists for unit tests. + * skip same-destination no-ops, consult the navigation guard, then navigate. + * `force` and `hasStateUpdate` both defeat the no-op skip — a same-href + * navigation that writes router state (setting or clearing the search + * highlight) must commit, or the state never lands. When `traceChannelId` is + * set, the channel-switch trace opens only after the guard accepts — a + * refused click must not leave an orphan trace that a later history + * navigation (deliberately untraced) would settle with the refused click's + * inflated wall time. When `leavesChannelSurface` is set, any active trace is + * dropped instead: the trace may be live with no channel screen mounted + * (route still resolving), so this is the only reliable exit hook. Returns + * whether the navigation was performed. `deps` exists for unit tests. */ export async function commitGuardedNavigation( input: { @@ -23,16 +28,19 @@ export async function commitGuardedNavigation( force?: boolean; guardedTarget: GuardedNavigation; hasStateUpdate?: boolean; + leavesChannelSurface?: boolean; traceChannelId?: string; navigate: () => Promise; }, deps: { allow?: typeof allowNavigation; beginTrace?: typeof beginChannelSwitchTrace; + dropActiveTrace?: typeof dropActiveChannelSwitchTrace; } = {}, ): Promise { const allow = deps.allow ?? allowNavigation; const beginTrace = deps.beginTrace ?? beginChannelSwitchTrace; + const dropActiveTrace = deps.dropActiveTrace ?? dropActiveChannelSwitchTrace; if ( input.currentHref === input.nextHref && !input.force && @@ -43,6 +51,9 @@ export async function commitGuardedNavigation( if (!allow(input.guardedTarget)) { return false; } + if (input.leavesChannelSurface) { + dropActiveTrace(); + } if (input.traceChannelId !== undefined) { beginTrace(input.traceChannelId); } diff --git a/desktop/src/app/navigation/navigationGuard.ts b/desktop/src/app/navigation/navigationGuard.ts index 5ff853720b4..8fc78f869d7 100644 --- a/desktop/src/app/navigation/navigationGuard.ts +++ b/desktop/src/app/navigation/navigationGuard.ts @@ -1,3 +1,5 @@ +import { dropActiveChannelSwitchTrace } from "@/shared/lib/channelSwitchPerf"; + export type GuardedNavigation = | { kind: "history"; @@ -40,6 +42,11 @@ export function traverseHistory( return false; } + // History navigation is deliberately untraced and its destination is + // unknowable here: a live switch trace must not survive into it, or an + // untraced re-entry into the traced channel would settle it with the time + // spent away. + dropActiveChannelSwitchTrace(); history[direction](); return true; } diff --git a/desktop/src/app/navigation/useAppNavigation.ts b/desktop/src/app/navigation/useAppNavigation.ts index 913f51a2dee..03b9b1aaf37 100644 --- a/desktop/src/app/navigation/useAppNavigation.ts +++ b/desktop/src/app/navigation/useAppNavigation.ts @@ -52,6 +52,10 @@ export function useAppNavigation() { href: nextLocation.href, }, hasStateUpdate: next.state !== undefined, + // Leaving the channel surface must drop any active switch trace — + // including one whose channel screen never mounted (route still + // resolving), which no component cleanup can cover. + leavesChannelSurface: !next.to.startsWith("/channels/"), navigate: () => navigate({ ...next, diff --git a/desktop/src/shared/lib/channelSwitchPerf.ts b/desktop/src/shared/lib/channelSwitchPerf.ts index a5fa935a905..78f9a52a785 100644 --- a/desktop/src/shared/lib/channelSwitchPerf.ts +++ b/desktop/src/shared/lib/channelSwitchPerf.ts @@ -161,6 +161,18 @@ export function abandonChannelSwitchTrace(channelId: string): void { } } +/** + * dropActiveChannelSwitchTrace abandons whatever trace is active, regardless + * of channel. Called when navigation leaves the channel surface (any + * committed non-channel destination, any history traversal): a trace can be + * live with no channel screen mounted at all — the route still resolving — + * so no route-exit cleanup exists to abandon it, and a later untraced + * re-entry within the timeout would settle it with the time spent away. + */ +export function dropActiveChannelSwitchTrace(): void { + activeTrace = null; +} + // Route-exit abandons currently deferred; see scheduleRouteExitAbandon. const pendingRouteExitAbandons = new Set(); diff --git a/desktop/tests/e2e/switch-settle-after-paint.spec.ts b/desktop/tests/e2e/switch-settle-after-paint.spec.ts index a74826dfc9f..192c171e168 100644 --- a/desktop/tests/e2e/switch-settle-after-paint.spec.ts +++ b/desktop/tests/e2e/switch-settle-after-paint.spec.ts @@ -88,15 +88,30 @@ test("settle waits for a delayed lazy channel-pane chunk", async ({ page }) => { .toBe(true); // Give the tracer ample frames to (incorrectly) settle behind the held - // chunk. This wait must stay well under the tracer's 5s settle deadline. + // chunk. The tracer's 5s render-wait deadline runs from settle entry + // (query readiness — immediate in mock mode), after which it honestly + // emits a TRUNCATED measure even while suspended; the contract under test + // is that no CLEAN measure appears. Guard the timing assumption + // explicitly so a slow CI box fails with the real reason. await page.waitForTimeout(1_500); - const early = await page.evaluate( - (name) => performance.getEntriesByName(name).length, - SWITCH_MEASURE, - ); + const early = await page.evaluate((name) => { + const start = performance.getEntriesByName("buzz:channel-switch:start")[0]; + return { + cleanMeasures: performance + .getEntriesByName(name) + .filter( + (entry) => !(entry as PerformanceMeasure).detail?.settleWaitTruncated, + ).length, + elapsedSinceClick: start ? performance.now() - start.startTime : null, + }; + }, SWITCH_MEASURE); + expect( + early.elapsedSinceClick, + "harness overhead consumed the tracer's settle deadline — timing, not a tracer bug", + ).toBeLessThan(4_500); expect( - early, - "no settle may be recorded while the pane chunk is suspended", + early.cleanMeasures, + "no clean settle may be recorded while the pane chunk is suspended", ).toBe(0); releaseChunk(); From 116b73bd5e9f4bddf7e8089669ec942323877f7a Mon Sep 17 00:00:00 2001 From: Max Lampert Date: Mon, 24 Aug 2026 23:31:23 -0700 Subject: [PATCH 11/27] fix(desktop): gate roster attribution on abort; warn on dead sink; drop starved frames MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Blind-review round 9 (Opus high) findings: - P2: the members queryFn attributed unconditionally, so a roster fetch superseded by a live join/leave invalidation could claim the trace's one-shot membersFetch slot with a stale count and duration. It now checks signal.throwIfAborted() before attributing — same rule as the window fetch's reconcile abort gate. - P2: a permanently dead sink (unwritable log dir, stale directory at the log path) was indistinguishable from no traced switches: console lines kept flowing while every append failed into .catch(() => {}). The first persistence failure now warns once. - P3: during a suspension that fires no visibilitychange, the deferred render-pending marker stays latched, so the settle wait recorded a truncated measure inflated by the whole stall (bounded only by the 35s age guard). A single inter-frame gap beyond 3s — beyond any real main-thread stall — now drops the sample. Co-Authored-By: Claude Fable 5 Signed-off-by: Max Lampert --- desktop/src/features/channels/hooks.ts | 7 ++- .../src/shared/lib/channelSwitchPerf.test.mjs | 17 +++++++ desktop/src/shared/lib/channelSwitchPerf.ts | 46 ++++++++++++++++--- 3 files changed, 63 insertions(+), 7 deletions(-) diff --git a/desktop/src/features/channels/hooks.ts b/desktop/src/features/channels/hooks.ts index f08842355b4..7490873e2a7 100644 --- a/desktop/src/features/channels/hooks.ts +++ b/desktop/src/features/channels/hooks.ts @@ -560,13 +560,18 @@ export function useChannelMembersQuery( return useQuery({ enabled: enabled && channelId !== null, queryKey: ["channels", channelId ?? "none", "members"], - queryFn: async () => { + queryFn: async ({ signal }) => { if (!channelId) { throw new Error("No channel selected."); } const fetchStartedAt = performance.now(); const members = await getChannelMembers(channelId); + // Attribute only accepted fetches: a live join/leave invalidation + // cancels and replaces an in-flight roster refetch, and the superseded + // fetch must not claim the trace's one-shot membersFetch slot with a + // stale count — same rule as the window fetch's reconcile abort gate. + signal.throwIfAborted(); traceChannelMembersFetch( channelId, members.length, diff --git a/desktop/src/shared/lib/channelSwitchPerf.test.mjs b/desktop/src/shared/lib/channelSwitchPerf.test.mjs index 4a54c337638..3b0c24909ad 100644 --- a/desktop/src/shared/lib/channelSwitchPerf.test.mjs +++ b/desktop/src/shared/lib/channelSwitchPerf.test.mjs @@ -146,6 +146,23 @@ test("a not-pending frame landing past the wait deadline is starvation, not a se }); }); +test("a starved frame gap drops even while the render-pending marker is latched", () => { + // During a suspension React can't flush the deferred commit, so the + // pending marker stays latched — its truth is NOT evidence the render was + // slow. A single inter-frame gap beyond any plausible main-thread stall + // means the process was suspended; recording a truncated 22s "switch" + // would fabricate the very regression the tracer hunts. + assert.equal(resolveSettleWait(22_300, 5_300, true, 0, 22_000), "drop"); + // Heavy-but-real frames (multi-hundred-ms long tasks) still record. + assert.deepEqual(resolveSettleWait(5_400, 5_300, true, 0, 900), { + settleWaitTruncated: true, + }); + // The first frame has no predecessor: no gap to judge. + assert.deepEqual(resolveSettleWait(1_000, 5_300, false, 0, null), { + settleWaitTruncated: false, + }); +}); + test("a truncated settle is flagged in the summary and the log record", () => { const summary = summarizeChannelSwitchTrace(trace(), 1_412, true); assert.ok(summary.endsWith(" settle=truncated"), summary); diff --git a/desktop/src/shared/lib/channelSwitchPerf.ts b/desktop/src/shared/lib/channelSwitchPerf.ts index 78f9a52a785..0ecb4778b67 100644 --- a/desktop/src/shared/lib/channelSwitchPerf.ts +++ b/desktop/src/shared/lib/channelSwitchPerf.ts @@ -111,8 +111,15 @@ export function buildSwitchPerfLogRecord( } let hasAnnouncedLogPath = false; +let hasWarnedSinkFailure = false; -/** Fire-and-forget JSONL append; diagnostics must never surface failures. */ +/** + * Fire-and-forget JSONL append; diagnostics must never throw into the app. + * A permanently dead sink must not be SILENT though — the console keeps + * printing per-switch lines that read as "tracing works", so warn once when + * persistence fails or an operator's before/after run yields an empty file + * with no way to tell why. + */ function appendSwitchPerfLogRecord(record: Record): void { if (!isTauri()) return; void invoke("append_switch_perf_log", { @@ -124,7 +131,15 @@ function appendSwitchPerfLogRecord(record: Record): void { console.info(`[switch-perf] logging to ${path}`); } }) - .catch(() => {}); + .catch((error) => { + if (!hasWarnedSinkFailure) { + hasWarnedSinkFailure = true; + console.warn( + "[switch-perf] failed to persist record; offline log may be incomplete:", + error, + ); + } + }); } /** @@ -309,6 +324,14 @@ export function resetChannelSwitchTrace(): void { /** Bound on waiting for the deferred timeline commit before recording. */ const SETTLE_RENDER_WAIT_MS = 5_000; +/** + * Largest inter-frame gap attributable to real main-thread work (heavy + * long tasks run a few hundred ms; 4x-throttled harness frames stay well + * under this). Anything larger is rAF starvation — suspension without a + * visibilitychange — and the sample drops. + */ +const MAX_SETTLE_FRAME_GAP_MS = 3_000; + /** * Per-frame decision for the bounded settle wait: keep waiting only while * the deferred render is still pending AND the deadline hasn't passed. When @@ -321,19 +344,25 @@ const SETTLE_RENDER_WAIT_MS = 5_000; * suspension (system suspend, App Nap — cases that fire no * visibilitychange), not render time. A trace older than the settle-entry * timeout plus the render wait is dropped on the same grounds regardless of - * pending state. The rare honest render that catches up within one frame of - * the deadline is sacrificed by the first rule — better no measurement than - * a fabricated one. Pure for unit testing. + * pending state, as is a single inter-frame gap beyond any plausible + * main-thread stall — a suspension latches the pending marker, so its truth + * is not evidence the render was slow. The rare honest render that catches + * up within one frame of the deadline is sacrificed by these rules — better + * no measurement than a fabricated one. Pure for unit testing. */ export function resolveSettleWait( now: number, waitDeadline: number, renderPending: boolean, startedAt: number, + frameGapMs: number | null = null, ): "wait" | "drop" | { settleWaitTruncated: boolean } { if (now - startedAt > SWITCH_TRACE_TIMEOUT_MS + SETTLE_RENDER_WAIT_MS) { return "drop"; } + if (frameGapMs !== null && frameGapMs > MAX_SETTLE_FRAME_GAP_MS) { + return "drop"; + } if (!renderPending && now > waitDeadline) return "drop"; if (renderPending && now < waitDeadline) return "wait"; return { settleWaitTruncated: renderPending }; @@ -411,6 +440,7 @@ export function settleChannelSwitchTrace(channelId: string): void { const dropTrace = () => { if (activeTrace === trace) activeTrace = null; }; + let lastFrameAt: number | null = null; const awaitDeferredCommit = () => { if (activeTrace !== trace) { // A newer switch replaced this trace, or a community reset dropped it. @@ -424,11 +454,15 @@ export function settleChannelSwitchTrace(channelId: string): void { dropTrace(); return; } + const now = performance.now(); + const frameGapMs = lastFrameAt === null ? null : now - lastFrameAt; + lastFrameAt = now; const decision = resolveSettleWait( - performance.now(), + now, waitDeadline, document.querySelector('[data-render-pending="true"]') !== null, trace.startedAt, + frameGapMs, ); if (decision === "wait") { window.requestAnimationFrame(awaitDeferredCommit); From b6f18d0cecbc2d1b0b29e51c2b50f255e5b3f70b Mon Sep 17 00:00:00 2001 From: Max Lampert Date: Mon, 24 Aug 2026 23:51:52 -0700 Subject: [PATCH 12/27] test(desktop): distinguish honest truncation from tracer bugs in settle specs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Blind-review round 10 (Fable high) finding: the specs' settle polls accepted a legitimately truncated measure — the tracer honestly hitting its 5s render-wait deadline on a slow box — and then failed the painted- rows assertion with a message blaming the tracer. Both tests now poll for clean measures only and fail truncated-only runs with an explicit harness-timing message. Co-Authored-By: Claude Fable 5 Signed-off-by: Max Lampert --- .../e2e/switch-settle-after-paint.spec.ts | 51 ++++++++++++++++--- 1 file changed, 45 insertions(+), 6 deletions(-) diff --git a/desktop/tests/e2e/switch-settle-after-paint.spec.ts b/desktop/tests/e2e/switch-settle-after-paint.spec.ts index 192c171e168..a6c04bf9902 100644 --- a/desktop/tests/e2e/switch-settle-after-paint.spec.ts +++ b/desktop/tests/e2e/switch-settle-after-paint.spec.ts @@ -27,11 +27,18 @@ test("cold-switch settle measure lands only after rows are painted", async ({ // Poll for the settle measure inside the page and — in the same synchronous // evaluation turn — snapshot what the DOM shows at that moment. Reading the - // DOM from the test process after the fact would race further renders. + // DOM from the test process after the fact would race further renders. Only + // CLEAN measures count: a truncated one means the tracer honestly hit its + // render-wait deadline (harness timing), which must fail with that reason + // rather than masquerading as a tracer regression. const atSettle = await page.evaluate(async (measureName) => { const deadline = Date.now() + 15_000; while (Date.now() < deadline) { - if (performance.getEntriesByName(measureName).length > 0) { + const entries = performance.getEntriesByName(measureName); + const clean = entries.filter( + (entry) => !(entry as PerformanceMeasure).detail?.settleWaitTruncated, + ); + if (clean.length > 0) { return { renderPending: document.querySelector('[data-render-pending="true"]') !== null, @@ -39,13 +46,31 @@ test("cold-switch settle measure lands only after rows are painted", async ({ '[data-message-id^="mock-deep-history-"]', ).length, settled: true, + truncatedOnly: false, + }; + } + if (entries.length > 0) { + return { + renderPending: true, + rowCount: 0, + settled: false, + truncatedOnly: true, }; } await new Promise((resolve) => setTimeout(resolve, 16)); } - return { renderPending: true, rowCount: 0, settled: false }; + return { + renderPending: true, + rowCount: 0, + settled: false, + truncatedOnly: false, + }; }, SWITCH_MEASURE); + expect( + atSettle.truncatedOnly, + "tracer truncated at its render-wait deadline — harness timing exhausted, not a tracer bug; rerun", + ).toBe(false); expect(atSettle.settled, "switch trace must settle").toBe(true); expect( atSettle.rowCount, @@ -117,23 +142,37 @@ test("settle waits for a delayed lazy channel-pane chunk", async ({ page }) => { releaseChunk(); // Same in-page polling as above: snapshot the DOM in the evaluation turn - // where the measure first exists. + // where the first CLEAN measure exists. A truncated measure here means the + // released chunk's mount outran the remaining render-wait budget — a + // harness timing exhaustion, and it must fail with that reason. const atSettle = await page.evaluate(async (measureName) => { const deadline = Date.now() + 15_000; while (Date.now() < deadline) { - if (performance.getEntriesByName(measureName).length > 0) { + const entries = performance.getEntriesByName(measureName); + const clean = entries.filter( + (entry) => !(entry as PerformanceMeasure).detail?.settleWaitTruncated, + ); + if (clean.length > 0) { return { rowCount: document.querySelectorAll( '[data-message-id^="mock-deep-history-"]', ).length, settled: true, + truncatedOnly: false, }; } + if (entries.length > 0) { + return { rowCount: 0, settled: false, truncatedOnly: true }; + } await new Promise((resolve) => setTimeout(resolve, 16)); } - return { rowCount: 0, settled: false }; + return { rowCount: 0, settled: false, truncatedOnly: false }; }, SWITCH_MEASURE); + expect( + atSettle.truncatedOnly, + "tracer truncated before the released chunk painted — harness timing exhausted, not a tracer bug; rerun", + ).toBe(false); expect(atSettle.settled, "switch trace must settle after release").toBe(true); expect( atSettle.rowCount, From ad981ff07cd6a4af67d588ffb6759760544a5708 Mon Sep 17 00:00:00 2001 From: Max Lampert Date: Tue, 25 Aug 2026 00:41:27 -0700 Subject: [PATCH 13/27] fix(desktop): signal-free roster attribution gate; seed the frame-gap guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Blind-review round 12 (Opus xhigh) findings, both reproduced: - P1: the round-9 abort gate destructured { signal } in the members queryFn — merely reading that getter sets React Query's abortSignalConsumed, switching the roster query to cancel-and-revert when its last observer unsubscribes mid-fetch. Interrupted switches discarded rosters that previously landed in cache (the Tauri call cannot be cancelled, so the work was paid and thrown away), and A->B->A warm switches repaid a full roster fetch. Replaced with a per-channel supersession token (openChannelMembersFetch) checked by traceChannelMembersFetch — stale fetches stay out of the one-shot slot without touching the signal. Sequences reset with community state. - P2: lastFrameAt started null, so the settle-entry -> first-frame window skipped the starvation guard: a no-visibilitychange suspension there recorded a truncated measure inflated by the whole stall (bounded only by the 35s age guard). The gap clock is now seeded at settle entry. Co-Authored-By: Claude Fable 5 Signed-off-by: Max Lampert --- desktop/src/features/channels/hooks.ts | 19 ++++--- .../src/shared/lib/channelSwitchPerf.test.mjs | 53 +++++++++++++++++++ desktop/src/shared/lib/channelSwitchPerf.ts | 32 ++++++++++- 3 files changed, 96 insertions(+), 8 deletions(-) diff --git a/desktop/src/features/channels/hooks.ts b/desktop/src/features/channels/hooks.ts index 7490873e2a7..beef0614a65 100644 --- a/desktop/src/features/channels/hooks.ts +++ b/desktop/src/features/channels/hooks.ts @@ -42,7 +42,10 @@ import type { } from "@/shared/api/tauriChannels"; import { mergeConcurrentChannelRecency } from "@/features/channels/lib/channelRecencyMerge"; import { useIdentityQuery } from "@/shared/api/hooks"; -import { traceChannelMembersFetch } from "@/shared/lib/channelSwitchPerf"; +import { + openChannelMembersFetch, + traceChannelMembersFetch, +} from "@/shared/lib/channelSwitchPerf"; import { useFocusedRefetchInterval } from "@/shared/lib/useDocumentVisible"; import { useCommunities } from "@/features/communities/useCommunities"; import { @@ -560,23 +563,25 @@ export function useChannelMembersQuery( return useQuery({ enabled: enabled && channelId !== null, queryKey: ["channels", channelId ?? "none", "members"], - queryFn: async ({ signal }) => { + queryFn: async () => { if (!channelId) { throw new Error("No channel selected."); } + // Supersession token, NOT the query's AbortSignal: reading the signal + // getter flips React Query to cancel-and-revert when the last observer + // unsubscribes mid-fetch, which would discard warm rosters on + // interrupted switches. The token keeps stale fetches (replaced by a + // live join/leave invalidation) out of the trace's one-shot slot. + const fetchAttempt = openChannelMembersFetch(channelId); const fetchStartedAt = performance.now(); const members = await getChannelMembers(channelId); - // Attribute only accepted fetches: a live join/leave invalidation - // cancels and replaces an in-flight roster refetch, and the superseded - // fetch must not claim the trace's one-shot membersFetch slot with a - // stale count — same rule as the window fetch's reconcile abort gate. - signal.throwIfAborted(); traceChannelMembersFetch( channelId, members.length, performance.now() - fetchStartedAt, fetchStartedAt, + fetchAttempt, ); return members; }, diff --git a/desktop/src/shared/lib/channelSwitchPerf.test.mjs b/desktop/src/shared/lib/channelSwitchPerf.test.mjs index 3b0c24909ad..307420ed37d 100644 --- a/desktop/src/shared/lib/channelSwitchPerf.test.mjs +++ b/desktop/src/shared/lib/channelSwitchPerf.test.mjs @@ -186,6 +186,59 @@ test("fetches attribute only when started after the switch began", () => { assert.equal(shouldAttributeFetch(null, "abcdef1234567890", 1_500), false); }); +test("a superseded members fetch never claims the trace's one-shot slot", async () => { + const { openChannelMembersFetch, traceChannelMembersFetch } = await import( + "./channelSwitchPerf.ts" + ); + await withSettleHarness(async ({ begin, settle, flush }) => { + begin("aaaa1111aaaa1111"); + const startedAt = performance.now(); + // Fetch #1 starts, then a live join/leave invalidation replaces it with + // fetch #2. #1 resolves first (the Tauri call can't be cancelled) but + // must not attribute: its roster is not the one rendered. The query's + // AbortSignal is deliberately not used for this — consuming it flips + // React Query to cancel-and-revert on last-observer unsubscribe, which + // discards warm rosters on interrupted switches. + const first = openChannelMembersFetch("aaaa1111aaaa1111"); + const second = openChannelMembersFetch("aaaa1111aaaa1111"); + traceChannelMembersFetch("aaaa1111aaaa1111", 9_999, 900, startedAt, first); + traceChannelMembersFetch( + "aaaa1111aaaa1111", + 10_002, + 120, + startedAt, + second, + ); + settle("aaaa1111aaaa1111"); + flush(); + const measure = performance + .getEntriesByName("buzz:channel-switch:click-to-settled") + .at(-1); + assert.equal(measure?.detail?.membersFetch?.memberCount, 10_002); + assert.equal(measure?.detail?.membersFetch?.durationMs, 120); + }); +}); + +test("a suspension before the first settle frame drops, not records truncated", async () => { + await withSettleHarness(async ({ begin, settle, flush, measures }) => { + const virtualClock = { now: 0 }; + performance.now = () => virtualClock.now; + try { + // The deferred marker stays latched during a suspension, so its truth + // is not evidence of slow rendering — the settle-entry → first-frame + // window must be starvation-guarded like every later frame. + globalThis.document.querySelector = () => ({}); + begin("aaaa1111aaaa1111"); + settle("aaaa1111aaaa1111"); + virtualClock.now = 20_000; + flush(); + assert.deepEqual(measures(), []); + } finally { + delete performance.now; + } + }); +}); + // --- Settle lifecycle: rapid switches and community resets ---------------- async function withSettleHarness(run, documentOverrides = {}) { diff --git a/desktop/src/shared/lib/channelSwitchPerf.ts b/desktop/src/shared/lib/channelSwitchPerf.ts index 0ecb4778b67..94322bfcbf7 100644 --- a/desktop/src/shared/lib/channelSwitchPerf.ts +++ b/desktop/src/shared/lib/channelSwitchPerf.ts @@ -300,12 +300,38 @@ export function traceChannelWindowFetch( activeTrace.windowFetch ??= { durationMs, eventCount }; } +/** + * Per-channel roster-fetch sequence numbers. A live join/leave invalidation + * cancels-and-replaces an in-flight roster refetch, but the underlying + * Tauri call cannot be cancelled — the superseded fetch still resolves and + * must not claim the trace's one-shot slot with a stale count. The query's + * AbortSignal is deliberately NOT used for this: merely reading + * `context.signal` flips React Query to cancel-and-revert when the last + * observer unsubscribes mid-fetch, discarding warm rosters on interrupted + * switches (a product-behavior change this instrumentation must not make). + */ +const channelMembersFetchSequences = new Map(); + +/** Registers a roster fetch attempt; pass the token to traceChannelMembersFetch. */ +export function openChannelMembersFetch(channelId: string): number { + const next = (channelMembersFetchSequences.get(channelId) ?? 0) + 1; + channelMembersFetchSequences.set(channelId, next); + return next; +} + export function traceChannelMembersFetch( channelId: string, memberCount: number, durationMs: number, fetchStartedAt: number, + fetchAttempt?: number, ): void { + if ( + fetchAttempt !== undefined && + channelMembersFetchSequences.get(channelId) !== fetchAttempt + ) { + return; + } if (!shouldAttributeFetch(activeTrace, channelId, fetchStartedAt)) return; activeTrace.membersFetch ??= { durationMs, memberCount }; } @@ -319,6 +345,7 @@ export function traceChannelMembersFetch( export function resetChannelSwitchTrace(): void { activeTrace = null; pendingRouteExitAbandons.clear(); + channelMembersFetchSequences.clear(); } /** Bound on waiting for the deferred timeline commit before recording. */ @@ -440,7 +467,10 @@ export function settleChannelSwitchTrace(channelId: string): void { const dropTrace = () => { if (activeTrace === trace) activeTrace = null; }; - let lastFrameAt: number | null = null; + // Seeded now, not on the first frame: the settle-entry → first-frame + // window must be starvation-guarded too, or a suspension there records a + // truncated measure inflated by the whole stall. + let lastFrameAt: number | null = performance.now(); const awaitDeferredCommit = () => { if (activeTrace !== trace) { // A newer switch replaced this trace, or a community reset dropped it. From e0f4768ec72aefbac65f6bcfd77051a371a362f0 Mon Sep 17 00:00:00 2001 From: Max Lampert Date: Tue, 25 Aug 2026 01:30:43 -0700 Subject: [PATCH 14/27] fix(desktop): only the exact channel route keeps a live switch trace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Blind-review round 13 (Sonnet xhigh) hardening: leavesChannelSurface used a /channels/ prefix check, so sibling routes (forum posts) counted as staying on the channel surface and kept a live trace alive. No currently reachable path turns that into a wrong record — every re-entry overwrites or drops the trace — but the invariant from 9b462328d should hold structurally, not incidentally. Anything other than the exact message-view route now drops the active trace. Co-Authored-By: Claude Fable 5 Signed-off-by: Max Lampert --- desktop/src/app/navigation/useAppNavigation.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/desktop/src/app/navigation/useAppNavigation.ts b/desktop/src/app/navigation/useAppNavigation.ts index 03b9b1aaf37..822621b5aa6 100644 --- a/desktop/src/app/navigation/useAppNavigation.ts +++ b/desktop/src/app/navigation/useAppNavigation.ts @@ -54,8 +54,10 @@ export function useAppNavigation() { hasStateUpdate: next.state !== undefined, // Leaving the channel surface must drop any active switch trace — // including one whose channel screen never mounted (route still - // resolving), which no component cleanup can cover. - leavesChannelSurface: !next.to.startsWith("/channels/"), + // resolving), which no component cleanup can cover. Only the exact + // channel message-view route keeps a live trace: sibling routes + // (forum posts) mount different, untraced screens. + leavesChannelSurface: next.to !== "/channels/$channelId", navigate: () => navigate({ ...next, From 83eb45c8eea3fa3a96fceb8225a5fa3640b69b89 Mon Sep 17 00:00:00 2001 From: Max Lampert Date: Tue, 25 Aug 2026 02:00:20 -0700 Subject: [PATCH 15/27] fix(desktop): guard document at settle entry; pin rotation in concurrent test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Blind-review round 14 (Opus medium) P3s: - settleChannelSwitchTrace guarded window but then read document (visibilityState, querySelector) unguarded — the document guard in ensureVisibilityWatcher was dead protection. The settle entry gate now covers both globals. - The concurrent boundary test folded the rotated generation in behind if-let, so a regression where rotation never fires under contention would pass green with all 32 lines in the live file. The read is now unconditional, and the boundary comment's arithmetic is corrected (rotation triggers before the 23rd append). Co-Authored-By: Claude Fable 5 Signed-off-by: Max Lampert --- desktop/src-tauri/src/commands/perf_log.rs | 19 ++++++++++++------- desktop/src/shared/lib/channelSwitchPerf.ts | 4 +++- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/desktop/src-tauri/src/commands/perf_log.rs b/desktop/src-tauri/src/commands/perf_log.rs index 4ffbf81ef27..51249be618a 100644 --- a/desktop/src-tauri/src/commands/perf_log.rs +++ b/desktop/src-tauri/src/commands/perf_log.rs @@ -368,10 +368,12 @@ mod tests { let _ = std::fs::remove_file(dir.join("switch-perf.jsonl.1")); // 8 writers × 4 lines of 18 bytes on disk (17 chars + newline) = 576 - // bytes against a 384-byte cap: the rotation boundary is crossed - // exactly once (at the 22nd line), so every line must land in either - // the live file or the single rotated generation. Unserialized - // metadata→rename→append interleavings drop lines or fail renames. + // bytes against a 384-byte cap: rotation triggers before the 23rd + // append (22 lines = 396 bytes ≥ 384) and the ≤10 lines that follow + // (≤180 bytes) cannot re-trigger it, so the boundary is crossed + // exactly once and every line must land in either the live file or + // the single rotated generation. Unserialized metadata→rename→append + // interleavings drop lines or fail renames. let threads: Vec<_> = (0..8) .map(|writer| { let path = path.clone(); @@ -396,9 +398,12 @@ mod tests { .lines() .map(str::to_string) .collect(); - if let Ok(rotated) = std::fs::read_to_string(dir.join("switch-perf.jsonl.1")) { - lines.extend(rotated.lines().map(str::to_string)); - } + // Unconditional: if rotation never fired under contention, the size + // cap is inoperative and this test must fail, not silently pass with + // all 32 lines in the live file. + let rotated = std::fs::read_to_string(dir.join("switch-perf.jsonl.1")) + .expect("rotation must have occurred under contention"); + lines.extend(rotated.lines().map(str::to_string)); lines.sort(); let expected: Vec = (0..8) .flat_map(|writer| { diff --git a/desktop/src/shared/lib/channelSwitchPerf.ts b/desktop/src/shared/lib/channelSwitchPerf.ts index 94322bfcbf7..550d3fc16ee 100644 --- a/desktop/src/shared/lib/channelSwitchPerf.ts +++ b/desktop/src/shared/lib/channelSwitchPerf.ts @@ -416,7 +416,9 @@ export function settleChannelSwitchTrace(channelId: string): void { return; } const trace = settledTrace; - if (typeof window === "undefined") { + // Both globals gate the whole settle path: the wait loop reads + // document.visibilityState and querySelector unguarded past this point. + if (typeof window === "undefined" || typeof document === "undefined") { activeTrace = null; return; } From 559e1e4ec701a36cb4bd40530a5fab226e4d98cd Mon Sep 17 00:00:00 2001 From: Max Lampert Date: Tue, 25 Aug 2026 02:29:45 -0700 Subject: [PATCH 16/27] test(desktop): assert the tracer contract before the timing budget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Blind-review round 15 (Fable xhigh) finding: a clean measure recorded behind the held chunk — the exact regression under test — clears the start mark and nulls elapsedSinceClick, so the timing-budget assertion failed first with a 'rerun, not a tracer bug' message stating the opposite of the truth. The contract assertion now runs first. Co-Authored-By: Claude Fable 5 Signed-off-by: Max Lampert --- desktop/tests/e2e/switch-settle-after-paint.spec.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/desktop/tests/e2e/switch-settle-after-paint.spec.ts b/desktop/tests/e2e/switch-settle-after-paint.spec.ts index a6c04bf9902..0edc9ae9319 100644 --- a/desktop/tests/e2e/switch-settle-after-paint.spec.ts +++ b/desktop/tests/e2e/switch-settle-after-paint.spec.ts @@ -130,14 +130,18 @@ test("settle waits for a delayed lazy channel-pane chunk", async ({ page }) => { elapsedSinceClick: start ? performance.now() - start.startTime : null, }; }, SWITCH_MEASURE); - expect( - early.elapsedSinceClick, - "harness overhead consumed the tracer's settle deadline — timing, not a tracer bug", - ).toBeLessThan(4_500); + // Order matters: a clean measure recorded behind the held chunk — the + // regression under test — clears the start mark, nulling elapsedSinceClick. + // Asserting the budget first would then fail with a "rerun, not a tracer + // bug" message that states the opposite of the truth. expect( early.cleanMeasures, "no clean settle may be recorded while the pane chunk is suspended", ).toBe(0); + expect( + early.elapsedSinceClick, + "harness overhead consumed the tracer's settle deadline — timing, not a tracer bug", + ).toBeLessThan(4_500); releaseChunk(); From 5ea02312f9442b8974a47e989829e4226558c759 Mon Sep 17 00:00:00 2001 From: Max Lampert Date: Tue, 25 Aug 2026 03:01:35 -0700 Subject: [PATCH 17/27] fix(desktop): begin revokes pending same-channel abandons; null-safe budget guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Blind-review round 16 (Opus high) P3s: - A same-task unmount-then-renavigate to the same channel left the exit cleanup's scheduled abandon pending, and its microtask killed the freshly opened trace (silent lost sample). beginChannelSwitchTrace now revokes any pending abandon for its channel. - The delayed-chunk spec's timing-budget guard hit expect(null) with a raw matcher error when a truncated record had already cleared the start mark — the one case its message exists for. Boolean form now covers the null. Co-Authored-By: Claude Fable 5 Signed-off-by: Max Lampert --- .../src/shared/lib/channelSwitchPerf.test.mjs | 17 +++++++++++++++++ desktop/src/shared/lib/channelSwitchPerf.ts | 4 ++++ .../tests/e2e/switch-settle-after-paint.spec.ts | 7 +++++-- 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/desktop/src/shared/lib/channelSwitchPerf.test.mjs b/desktop/src/shared/lib/channelSwitchPerf.test.mjs index 307420ed37d..b38ee5bad82 100644 --- a/desktop/src/shared/lib/channelSwitchPerf.test.mjs +++ b/desktop/src/shared/lib/channelSwitchPerf.test.mjs @@ -458,6 +458,23 @@ test("a scheduled route-exit abandon canceled in the same task keeps the trace", ); }); +test("beginning a switch revokes a pending route-exit abandon for that channel", async () => { + await withSettleHarness( + async ({ begin, scheduleAbandon, settle, flush, measures }) => { + // Same-task unmount-then-renavigate to the same channel: the cleanup + // schedules the abandon, then goChannel synchronously opens a fresh + // trace before the microtask drains. The stale abandon must not kill + // the new trace. + scheduleAbandon("aaaa1111aaaa1111"); + begin("aaaa1111aaaa1111"); + await Promise.resolve(); + settle("aaaa1111aaaa1111"); + flush(); + assert.deepEqual(measures(), ["aaaa1111aaaa1111"]); + }, + ); +}); + test("an uncanceled route-exit abandon drops the trace before any frame fires", async () => { await withSettleHarness( async ({ begin, scheduleAbandon, settle, flush, measures }) => { diff --git a/desktop/src/shared/lib/channelSwitchPerf.ts b/desktop/src/shared/lib/channelSwitchPerf.ts index 550d3fc16ee..e1a09ea51b5 100644 --- a/desktop/src/shared/lib/channelSwitchPerf.ts +++ b/desktop/src/shared/lib/channelSwitchPerf.ts @@ -248,6 +248,10 @@ function traceOverlapsHiddenWindow(trace: ChannelSwitchTrace): boolean { export function beginChannelSwitchTrace(channelId: string): void { if (typeof performance === "undefined") return; ensureVisibilityWatcher(); + // A same-task unmount-then-renavigate to this channel leaves the exit + // cleanup's scheduled abandon pending; it must not kill the fresh trace + // when its microtask drains. + cancelRouteExitAbandon(channelId); activeTrace = { channelId, startedAt: performance.now(), diff --git a/desktop/tests/e2e/switch-settle-after-paint.spec.ts b/desktop/tests/e2e/switch-settle-after-paint.spec.ts index 0edc9ae9319..8c902510973 100644 --- a/desktop/tests/e2e/switch-settle-after-paint.spec.ts +++ b/desktop/tests/e2e/switch-settle-after-paint.spec.ts @@ -138,10 +138,13 @@ test("settle waits for a delayed lazy channel-pane chunk", async ({ page }) => { early.cleanMeasures, "no clean settle may be recorded while the pane chunk is suspended", ).toBe(0); + // Boolean form: a truncated record already landing clears the start mark + // and nulls elapsedSinceClick — that too is budget exhaustion, and must + // fail with this message rather than a raw matcher error. expect( - early.elapsedSinceClick, + early.elapsedSinceClick !== null && early.elapsedSinceClick < 4_500, "harness overhead consumed the tracer's settle deadline — timing, not a tracer bug", - ).toBeLessThan(4_500); + ).toBe(true); releaseChunk(); From a355a0b1d3bdc32929a57629c9d2fd006eaf7d6f Mon Sep 17 00:00:00 2001 From: Max Lampert Date: Tue, 25 Aug 2026 03:35:15 -0700 Subject: [PATCH 18/27] fix(desktop): anchor the switch trace at the input event, not handler dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Signed-off-by: Max Lampert --- .../src/shared/lib/channelSwitchPerf.test.mjs | 26 +++++++++++++++++++ desktop/src/shared/lib/channelSwitchPerf.ts | 14 +++++++++- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/desktop/src/shared/lib/channelSwitchPerf.test.mjs b/desktop/src/shared/lib/channelSwitchPerf.test.mjs index b38ee5bad82..b5a3872880f 100644 --- a/desktop/src/shared/lib/channelSwitchPerf.test.mjs +++ b/desktop/src/shared/lib/channelSwitchPerf.test.mjs @@ -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 }) => { diff --git a/desktop/src/shared/lib/channelSwitchPerf.ts b/desktop/src/shared/lib/channelSwitchPerf.ts index e1a09ea51b5..9307daf19ac 100644 --- a/desktop/src/shared/lib/channelSwitchPerf.ts +++ b/desktop/src/shared/lib/channelSwitchPerf.ts @@ -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, From a0a7989022c56e98ab4f668e95707fe4d6556235 Mon Sep 17 00:00:00 2001 From: Max Lampert Date: Tue, 25 Aug 2026 04:06:06 -0700 Subject: [PATCH 19/27] fix(desktop): stamp the start mark at the anchored input event MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Blind-review round 18 (Opus xhigh) finding: 26ee8982b moved the measure's origin to the input event's timeStamp but left the start mark at handler-dispatch time, so the Performance panel showed the measure beginning before its own start mark by the input delay — two different switch durations from one instrument. The mark now carries startTime: startedAt, pinned by asserting mark and measure share the anchor. Co-Authored-By: Claude Fable 5 Signed-off-by: Max Lampert --- desktop/src/shared/lib/channelSwitchPerf.test.mjs | 6 ++++++ desktop/src/shared/lib/channelSwitchPerf.ts | 8 +++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/desktop/src/shared/lib/channelSwitchPerf.test.mjs b/desktop/src/shared/lib/channelSwitchPerf.test.mjs index b5a3872880f..90eadea58f6 100644 --- a/desktop/src/shared/lib/channelSwitchPerf.test.mjs +++ b/desktop/src/shared/lib/channelSwitchPerf.test.mjs @@ -471,6 +471,9 @@ test("the trace anchors at the input event, not handler dispatch", async () => { globalThis.window.event = { timeStamp: performance.now() - 550 }; begin("aaaa1111aaaa1111"); delete globalThis.window.event; + const startMark = performance + .getEntriesByName("buzz:channel-switch:start") + .at(-1); settle("aaaa1111aaaa1111"); flush(); const measure = performance @@ -481,6 +484,9 @@ test("the trace anchors at the input event, not handler dispatch", async () => { measure.duration >= 550, `input delay must be inside the measure (got ${measure.duration})`, ); + // The mark and the measure must share the anchor, or the Performance + // panel shows two different switch durations for the same switch. + assert.equal(startMark?.startTime, measure.startTime); }); }); diff --git a/desktop/src/shared/lib/channelSwitchPerf.ts b/desktop/src/shared/lib/channelSwitchPerf.ts index 9307daf19ac..d970e04bb01 100644 --- a/desktop/src/shared/lib/channelSwitchPerf.ts +++ b/desktop/src/shared/lib/channelSwitchPerf.ts @@ -280,7 +280,13 @@ export function beginChannelSwitchTrace(channelId: string): void { performance.clearMarks(CHANNEL_SWITCH_START_MARK); performance.clearMarks(CHANNEL_SWITCH_SETTLED_MARK); performance.clearMeasures(CHANNEL_SWITCH_MEASURE); - performance.mark(CHANNEL_SWITCH_START_MARK, { detail: { channelId } }); + // startTime keeps the mark on the same anchor as the measure — without it + // the Performance panel would show the measure starting before its own + // start mark by the input delay. + performance.mark(CHANNEL_SWITCH_START_MARK, { + detail: { channelId }, + startTime: startedAt, + }); } export function markChannelSwitchRouteCommit(channelId: string): void { From 6e9942f5253fa45aa7225b5b59ebac2cabb0d51e Mon Sep 17 00:00:00 2001 From: Max Lampert Date: Wed, 26 Aug 2026 15:31:32 -0700 Subject: [PATCH 20/27] fix(desktop): guard the final paint frame; trace DM entry paths Three review findings against the tracer's honesty contract: - The readiness -> paint frame was unguarded. `awaitDeferredCommit` checks age, frame gap, and visibility on every frame it drives, but the one rAF it queues before `record()` checked neither age nor gap, so a suspension in that seam (App Nap fires no visibilitychange) recorded the whole absence as a clean switch. - DM actions await `open_dm` before `goChannel`, so that relay round-trip sat outside the measurement; Pulse's note actions used raw `navigate` and produced no measurement at all. Callers now capture the click anchor and hand it to `goChannel`, and Pulse routes through `goChannel`. - A `navigate()` rejection left the trace it opened active, so an untraced re-entry could settle the failed attempt. Cancellation is by trace identity, so a newer same-channel attempt is never erased. Signed-off-by: Max Lampert --- .../commitGuardedNavigation.test.mjs | 86 ++++++++++++++++ .../app/navigation/commitGuardedNavigation.ts | 23 ++++- .../src/app/navigation/useAppNavigation.ts | 17 +++- .../channels/ui/useChannelProfilePanel.ts | 5 +- desktop/src/features/home/ui/HomeView.tsx | 5 +- .../ui/useProfileInteractionActions.ts | 6 +- .../src/features/pulse/lib/useNoteActions.ts | 16 +-- .../src/shared/lib/channelSwitchPerf.test.mjs | 99 +++++++++++++++++++ desktop/src/shared/lib/channelSwitchPerf.ts | 87 ++++++++++++++-- 9 files changed, 319 insertions(+), 25 deletions(-) diff --git a/desktop/src/app/navigation/commitGuardedNavigation.test.mjs b/desktop/src/app/navigation/commitGuardedNavigation.test.mjs index 0b836ec20b2..5895b9debc9 100644 --- a/desktop/src/app/navigation/commitGuardedNavigation.test.mjs +++ b/desktop/src/app/navigation/commitGuardedNavigation.test.mjs @@ -268,3 +268,89 @@ test("a same-destination navigation carrying router state still commits", async assert.equal(committed, true); assert.deepEqual(order, ["guard", "navigate"]); }); + +test("a rejected navigate cancels the trace it opened", async () => { + await withTraceHarness(async ({ flush, measures }) => { + // Router/loader rejection: no destination committed, so the trace must + // not survive for a later untraced re-entry to settle with the failed + // attempt plus everything in between. + await assert.rejects( + commitGuardedNavigation({ + currentHref: "/channels/aaaa", + nextHref: "/channels/bbbb", + guardedTarget: route("/channels/bbbb"), + traceChannelId: "bbbb", + navigate: async () => { + throw new Error("loader failed"); + }, + }), + /loader failed/, + ); + settleChannelSwitchTrace("bbbb"); + flush(); + assert.deepEqual(measures(), []); + }); +}); + +test("a rejected navigate never cancels a newer same-channel trace", async () => { + await withTraceHarness(async ({ flush, measures }) => { + // Cancel by identity, not by channel: the retry's trace is a different + // object and must keep its measurement when the first attempt rejects. + let releaseFirst; + const firstGate = new Promise((resolve) => { + releaseFirst = resolve; + }); + const failing = assert.rejects( + commitGuardedNavigation({ + currentHref: "/channels/aaaa", + nextHref: "/channels/bbbb", + guardedTarget: route("/channels/bbbb"), + traceChannelId: "bbbb", + navigate: async () => { + await firstGate; + throw new Error("loader failed"); + }, + }), + /loader failed/, + ); + // Retry lands while the first attempt is still in flight. + await commitGuardedNavigation({ + currentHref: "/channels/aaaa", + nextHref: "/channels/bbbb", + guardedTarget: route("/channels/bbbb"), + force: true, + traceChannelId: "bbbb", + navigate: async () => {}, + }); + releaseFirst(); + await failing; + settleChannelSwitchTrace("bbbb"); + flush(); + assert.deepEqual(measures(), ["bbbb"]); + }); +}); + +test("a DM caller's click anchor is carried into the trace", async () => { + await withTraceHarness(async () => { + const anchors = []; + await commitGuardedNavigation( + { + currentHref: "/", + nextHref: "/channels/bbbb", + guardedTarget: route("/channels/bbbb"), + traceChannelId: "bbbb", + traceStartedAt: 1234, + navigate: async () => {}, + }, + { + allow: () => true, + beginTrace: (channelId, anchoredAt) => { + anchors.push([channelId, anchoredAt]); + return null; + }, + }, + ); + // Without this the open_dm round-trip would sit outside the measurement. + assert.deepEqual(anchors, [["bbbb", 1234]]); + }); +}); diff --git a/desktop/src/app/navigation/commitGuardedNavigation.ts b/desktop/src/app/navigation/commitGuardedNavigation.ts index 449442f5c94..2565ed1efa5 100644 --- a/desktop/src/app/navigation/commitGuardedNavigation.ts +++ b/desktop/src/app/navigation/commitGuardedNavigation.ts @@ -4,6 +4,7 @@ import { } from "@/app/navigation/navigationGuard"; import { beginChannelSwitchTrace, + cancelChannelSwitchTrace, dropActiveChannelSwitchTrace, } from "@/shared/lib/channelSwitchPerf"; @@ -18,8 +19,12 @@ import { * navigation (deliberately untraced) would settle with the refused click's * inflated wall time. When `leavesChannelSurface` is set, any active trace is * dropped instead: the trace may be live with no channel screen mounted - * (route still resolving), so this is the only reliable exit hook. Returns - * whether the navigation was performed. `deps` exists for unit tests. + * (route still resolving), so this is the only reliable exit hook. A + * `navigate()` rejection cancels the trace this call opened — by identity, so + * a newer same-channel attempt is never erased — because no destination + * committed and an untraced re-entry would otherwise settle the failed + * attempt. Returns whether the navigation was performed. `deps` exists for + * unit tests. */ export async function commitGuardedNavigation( input: { @@ -30,16 +35,20 @@ export async function commitGuardedNavigation( hasStateUpdate?: boolean; leavesChannelSurface?: boolean; traceChannelId?: string; + /** Click-time anchor for callers that await before `goChannel`. */ + traceStartedAt?: number; navigate: () => Promise; }, deps: { allow?: typeof allowNavigation; beginTrace?: typeof beginChannelSwitchTrace; + cancelTrace?: typeof cancelChannelSwitchTrace; dropActiveTrace?: typeof dropActiveChannelSwitchTrace; } = {}, ): Promise { const allow = deps.allow ?? allowNavigation; const beginTrace = deps.beginTrace ?? beginChannelSwitchTrace; + const cancelTrace = deps.cancelTrace ?? cancelChannelSwitchTrace; const dropActiveTrace = deps.dropActiveTrace ?? dropActiveChannelSwitchTrace; if ( input.currentHref === input.nextHref && @@ -54,9 +63,15 @@ export async function commitGuardedNavigation( if (input.leavesChannelSurface) { dropActiveTrace(); } + let handle = null; if (input.traceChannelId !== undefined) { - beginTrace(input.traceChannelId); + handle = beginTrace(input.traceChannelId, input.traceStartedAt) ?? null; + } + try { + await input.navigate(); + } catch (error) { + cancelTrace(handle); + throw error; } - await input.navigate(); return true; } diff --git a/desktop/src/app/navigation/useAppNavigation.ts b/desktop/src/app/navigation/useAppNavigation.ts index 822621b5aa6..2f08b25925c 100644 --- a/desktop/src/app/navigation/useAppNavigation.ts +++ b/desktop/src/app/navigation/useAppNavigation.ts @@ -42,6 +42,7 @@ export function useAppNavigation() { behavior: NavigationBehavior = {}, guardedTarget?: GuardedNavigation, traceChannelId?: string, + traceStartedAt?: number, ) => { const nextLocation = router.buildLocation(next as never); return commitGuardedNavigation({ @@ -66,6 +67,7 @@ export function useAppNavigation() { } as never), nextHref: nextLocation.href, traceChannelId, + traceStartedAt, }); }, [location.href, navigate, router], @@ -281,6 +283,13 @@ export function useAppNavigation() { preserveSearchHighlight?: boolean; searchHighlight?: SearchHighlightNavigation; replace?: boolean; + /** + * Click-time anchor from `captureSwitchTraceAnchor()`, for callers + * that must await before they know the channel id (DM actions await + * `open_dm`). Without it the trace would start after that relay + * round-trip and exclude felt click latency. + */ + traceStartedAt?: number; /** Open this thread panel directly without waiting for a timeline row. */ thread?: string; threadRootId?: string | null; @@ -327,9 +336,10 @@ export function useAppNavigation() { : undefined, // goChannel is the click-time anchor for the switch trace; it opens // inside commitGuardedNavigation only after the navigation guard - // accepts. Coverage: sidebar/search/notification navigations funnel - // through here — direct navigate() callers (Pulse startDm) and - // history back/forward are untraced. Navigations that stay on the + // accepts. Coverage: sidebar, search, notification, and DM-open + // navigations all funnel through here; DM callers pass traceStartedAt + // so the open_dm round-trip stays inside the measurement. History + // back/forward is deliberately untraced. Navigations that stay on the // already-active channel (exact re-click only rewrites router state; // jump-to-message/autoSend/force change only search params) never // re-run the channel's settle effects, so a trace could only time out @@ -337,6 +347,7 @@ export function useAppNavigation() { location.pathname.endsWith(`/channels/${channelId}`) ? undefined : channelId, + options?.traceStartedAt, ); }, [commitNavigation, location.pathname], diff --git a/desktop/src/features/channels/ui/useChannelProfilePanel.ts b/desktop/src/features/channels/ui/useChannelProfilePanel.ts index 1a35666478a..5631926fe61 100644 --- a/desktop/src/features/channels/ui/useChannelProfilePanel.ts +++ b/desktop/src/features/channels/ui/useChannelProfilePanel.ts @@ -1,6 +1,7 @@ import * as React from "react"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; +import { captureSwitchTraceAnchor } from "@/shared/lib/channelSwitchPerf"; import { useOpenDmMutation } from "@/features/channels/hooks"; import type { ProfilePanelOpenOptions } from "@/shared/context/ProfilePanelContext"; @@ -60,8 +61,10 @@ export function useChannelProfilePanel({ const openDmMutateAsync = openDmMutation.mutateAsync; const handleOpenDm = React.useCallback( async (pubkeys: string[]) => { + // Anchor before awaiting open_dm; see captureSwitchTraceAnchor. + const traceStartedAt = captureSwitchTraceAnchor(); const dm = await openDmMutateAsync({ pubkeys }); - await goChannel(dm.id); + await goChannel(dm.id, { traceStartedAt }); }, [goChannel, openDmMutateAsync], ); diff --git a/desktop/src/features/home/ui/HomeView.tsx b/desktop/src/features/home/ui/HomeView.tsx index 893b3c309c6..6055ea5b65b 100644 --- a/desktop/src/features/home/ui/HomeView.tsx +++ b/desktop/src/features/home/ui/HomeView.tsx @@ -3,6 +3,7 @@ import { RefreshCcw } from "lucide-react"; import { useAppShell } from "@/app/AppShellContext"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; +import { captureSwitchTraceAnchor } from "@/shared/lib/channelSwitchPerf"; import { useKnownAgentPubkeys } from "@/features/agents/useKnownAgentPubkeys"; import { useChannelsQuery, useOpenDmMutation } from "@/features/channels/hooks"; import { RightAuxiliaryPane } from "@/features/channels/ui/RightAuxiliaryPane"; @@ -221,8 +222,10 @@ export function HomeView({ const [isSendingReply, setIsSendingReply] = React.useState(false); const handleOpenDm = React.useCallback( async (pubkeys: string[]) => { + // Anchor before awaiting open_dm; see captureSwitchTraceAnchor. + const traceStartedAt = captureSwitchTraceAnchor(); const dm = await openDm({ pubkeys }); - await goChannel(dm.id); + await goChannel(dm.id, { traceStartedAt }); }, [goChannel, openDm], ); diff --git a/desktop/src/features/profile/ui/useProfileInteractionActions.ts b/desktop/src/features/profile/ui/useProfileInteractionActions.ts index f5ee47a7702..e2af6fe5ecf 100644 --- a/desktop/src/features/profile/ui/useProfileInteractionActions.ts +++ b/desktop/src/features/profile/ui/useProfileInteractionActions.ts @@ -3,6 +3,7 @@ import * as React from "react"; import { toast } from "sonner"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; +import { captureSwitchTraceAnchor } from "@/shared/lib/channelSwitchPerf"; import { channelsQueryKey, useChannelsQuery, @@ -152,9 +153,12 @@ export function useProfileInteractionActions({ return; } + // Anchor before awaiting open_dm: that relay round-trip is felt click + // latency and belongs inside the switch measurement. + const traceStartedAt = captureSwitchTraceAnchor(); void runAction("message", async (targetPubkey) => { const dm = await openDm({ pubkeys: [targetPubkey] }); - await goChannel(dm.id); + await goChannel(dm.id, { traceStartedAt }); if (isMountedRef.current) { onClose(); } diff --git a/desktop/src/features/pulse/lib/useNoteActions.ts b/desktop/src/features/pulse/lib/useNoteActions.ts index 8178cdc53a3..aaac391190e 100644 --- a/desktop/src/features/pulse/lib/useNoteActions.ts +++ b/desktop/src/features/pulse/lib/useNoteActions.ts @@ -1,5 +1,6 @@ import * as React from "react"; -import { useNavigate } from "@tanstack/react-router"; +import { useAppNavigation } from "@/app/navigation/useAppNavigation"; +import { captureSwitchTraceAnchor } from "@/shared/lib/channelSwitchPerf"; import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; @@ -47,7 +48,7 @@ export function usePulseNoteActions({ const [pendingUpvoteNoteIds, setPendingUpvoteNoteIds] = React.useState< ReadonlySet >(() => new Set()); - const navigate = useNavigate(); + const { goChannel } = useAppNavigation(); const queryClient = useQueryClient(); const replyMutation = usePublishNoteMutation(currentPubkey); const toggleReactionMutation = useToggleReactionMutation(); @@ -154,21 +155,22 @@ export function usePulseNoteActions({ const startDm = React.useCallback( async (pubkey: string) => { + // goChannel, not raw navigate: this is a first-class channel entry and + // must produce a switch measurement like every other one. Anchor before + // awaiting open_dm; see captureSwitchTraceAnchor. + const traceStartedAt = captureSwitchTraceAnchor(); try { const directMessage = await openDmMutation.mutateAsync({ pubkeys: [pubkey], }); - await navigate({ - to: "/channels/$channelId", - params: { channelId: directMessage.id }, - }); + await goChannel(directMessage.id, { traceStartedAt }); } catch (error) { toast.error( error instanceof Error ? error.message : "Failed to open DM", ); } }, - [navigate, openDmMutation], + [goChannel, openDmMutation], ); return { diff --git a/desktop/src/shared/lib/channelSwitchPerf.test.mjs b/desktop/src/shared/lib/channelSwitchPerf.test.mjs index 90eadea58f6..a08277ce55f 100644 --- a/desktop/src/shared/lib/channelSwitchPerf.test.mjs +++ b/desktop/src/shared/lib/channelSwitchPerf.test.mjs @@ -5,7 +5,12 @@ import { shouldAttributeFetch, buildSwitchPerfLogRecord, resolveSettleAction, + CHANNEL_SWITCH_MEASURE, + beginChannelSwitchTrace, + resetChannelSwitchTrace, + resolveFinalFrame, resolveSettleWait, + settleChannelSwitchTrace, summarizeChannelSwitchTrace, } from "./channelSwitchPerf.ts"; @@ -536,3 +541,97 @@ test("leaving the channel surface abandons the trace; history-back records nothi }, ); }); + +test("suspension between readiness and the paint frame drops the record", () => { + // The reviewer's repro: readiness at t=10ms, final frame at t=20_000ms. + // Recording here would emit total=20000ms as an ordinary clean switch — + // App Nap fires no visibilitychange, so the hidden-window guard cannot see + // it and the frame-gap guard in awaitDeferredCommit has already run. + assert.equal(resolveFinalFrame(20_000, 10, 0), "drop"); + // A normal paint frame one refresh interval after readiness still records. + assert.equal(resolveFinalFrame(27, 10, 0), "record"); + // Boundary: exactly the frame-gap cap is still a record; one past it drops. + assert.equal(resolveFinalFrame(3_010, 10, 0), "record"); + assert.equal(resolveFinalFrame(3_011, 10, 0), "drop"); +}); + +test("the final frame also honors the overall trace age cap", () => { + // Readiness landed just under the age cap and the paint frame is prompt, + // so the frame gap is innocent — only the age check can catch this. + assert.equal(resolveFinalFrame(35_001, 35_000, 0), "drop"); + assert.equal(resolveFinalFrame(35_000, 34_999, 0), "record"); +}); + +// Drives the real settle lifecycle with a controllable clock and rAF queue so +// a stall can be injected at one exact seam. Offsets are rebased above the +// real clock: earlier tests in this file fire visibilitychange, and a trace +// back-dated below those timestamps is (correctly) dropped at settle entry — +// which would make every assertion here vacuous. +function withClockedFrames(run) { + const frames = []; + const originalWindow = globalThis.window; + const originalDocument = globalThis.document; + const originalNow = performance.now; + const base = originalNow.call(performance) + 1_000; + let clock = base; + performance.now = () => clock; + globalThis.window = { + requestAnimationFrame: (cb) => frames.push(cb) && frames.length, + cancelAnimationFrame: () => {}, + }; + globalThis.document = { + addEventListener: () => {}, + // No pending marker: readiness is reached on the first settle frame. + querySelector: () => null, + removeEventListener: () => {}, + visibilityState: "visible", + }; + performance.clearMeasures?.(CHANNEL_SWITCH_MEASURE); + const at = (offset) => { + clock = base + offset; + }; + const step = (offset) => { + at(offset); + for (const cb of frames.splice(0, frames.length)) cb(); + }; + const measures = () => + performance.getEntriesByName(CHANNEL_SWITCH_MEASURE).length; + try { + run({ at, step, measures }); + } finally { + performance.now = originalNow; + resetChannelSwitchTrace(); + performance.clearMeasures?.(CHANNEL_SWITCH_MEASURE); + if (originalWindow === undefined) delete globalThis.window; + else globalThis.window = originalWindow; + if (originalDocument === undefined) delete globalThis.document; + else globalThis.document = originalDocument; + } +} + +test("a stall between readiness and the paint frame records nothing", () => { + withClockedFrames(({ at, step, measures }) => { + at(0); + beginChannelSwitchTrace("aaaa"); + at(10); + settleChannelSwitchTrace("aaaa"); + // Readiness frame: no pending marker, inside the wait deadline. + step(10); + // Process suspended here (App Nap): no visibilitychange fires, so only + // the final-frame guard can catch it. + step(20_000); + assert.equal(measures(), 0, "a 20s suspension must not record a switch"); + }); +}); + +test("a prompt paint frame after readiness still records", () => { + withClockedFrames(({ at, step, measures }) => { + at(0); + beginChannelSwitchTrace("aaaa"); + at(10); + settleChannelSwitchTrace("aaaa"); + step(10); + step(26); + assert.equal(measures(), 1, "the guard must not drop healthy switches"); + }); +}); diff --git a/desktop/src/shared/lib/channelSwitchPerf.ts b/desktop/src/shared/lib/channelSwitchPerf.ts index d970e04bb01..97b5f9e9e11 100644 --- a/desktop/src/shared/lib/channelSwitchPerf.ts +++ b/desktop/src/shared/lib/channelSwitchPerf.ts @@ -9,6 +9,14 @@ * traces and settles for non-active channels are ignored, so background * refetches never pollute a switch measurement. * + * Traced entry paths: every navigation that reaches `goChannel` — sidebar, + * search, notification activation, and the DM actions, which capture + * `captureSwitchTraceAnchor()` at the click so their `open_dm` round-trip is + * inside the interval rather than before it. Deliberately untraced: history + * back/forward (no click to anchor on) and navigations that stay on the + * already-active channel (nothing re-runs the settle, so a trace could only + * time out). + * * Output per switch: a `[switch-perf]` console line plus User Timing * marks/measures (`buzz:channel-switch:*`) so Playwright perf specs and the * Performance panel can read the same numbers. @@ -245,8 +253,42 @@ function traceOverlapsHiddenWindow(trace: ChannelSwitchTrace): boolean { ); } -export function beginChannelSwitchTrace(channelId: string): void { - if (typeof performance === "undefined") return; +/** + * Timestamp to anchor a switch trace on, read during synchronous event + * dispatch. Callers that must await before they know the target channel (DM + * actions await `open_dm` for its id) capture this at the click and hand it to + * `goChannel`, so the relay round-trip stays inside the measured interval + * instead of silently preceding it. + */ +export function captureSwitchTraceAnchor(): number { + if (typeof performance === "undefined") return 0; + const now = performance.now(); + const dispatchingEvent = + typeof window === "undefined" ? undefined : window.event; + return dispatchingEvent && typeof dispatchingEvent.timeStamp === "number" + ? Math.min(dispatchingEvent.timeStamp, now) + : now; +} + +/** Opaque identity for one opened trace; see `cancelChannelSwitchTrace`. */ +export type ChannelSwitchTraceHandle = { readonly trace: ChannelSwitchTrace }; + +/** + * Cancels `handle`'s trace, and only that one. A later begin for the same + * channel installs a different trace object, so a failed navigation can never + * erase a newer attempt's measurement. + */ +export function cancelChannelSwitchTrace( + handle: ChannelSwitchTraceHandle | null, +): void { + if (handle && activeTrace === handle.trace) activeTrace = null; +} + +export function beginChannelSwitchTrace( + channelId: string, + anchoredAt?: number, +): ChannelSwitchTraceHandle | null { + if (typeof performance === "undefined") return null; ensureVisibilityWatcher(); // A same-task unmount-then-renavigate to this channel leaves the exit // cleanup's scheduled abandon pending; it must not kill the fresh trace @@ -256,14 +298,13 @@ export function beginChannelSwitchTrace(channelId: string): void { // 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. + // continuations; min() guards against skewed event clocks and against a + // caller-supplied anchor that a monotonic-clock skew put in the future. 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; + anchoredAt === undefined + ? captureSwitchTraceAnchor() + : Math.min(anchoredAt, now); activeTrace = { channelId, startedAt, @@ -271,6 +312,7 @@ export function beginChannelSwitchTrace(channelId: string): void { windowFetch: null, membersFetch: null, }; + const handle: ChannelSwitchTraceHandle = { trace: activeTrace }; // Clear the whole previous switch here, not only in record(): traces that // die without recording (forum visits, route exits, drops) never reach // record()'s buffer clearing — weeks-long sessions would accumulate a @@ -287,6 +329,7 @@ export function beginChannelSwitchTrace(channelId: string): void { detail: { channelId }, startTime: startedAt, }); + return handle; } export function markChannelSwitchRouteCommit(channelId: string): void { @@ -417,6 +460,26 @@ export function resolveSettleWait( return { settleWaitTruncated: renderPending }; } +/** + * Decides whether the post-readiness paint frame may still be recorded. + * `awaitDeferredCommit` guards every frame it drives, but the readiness → + * paint frame is a starvation seam of its own: process suspension (App Nap, + * a suspended VM) can land there with no `visibilitychange`, and the resumed + * frame would record the whole absence as an ordinary clean switch. Pure for + * unit testing. + */ +export function resolveFinalFrame( + now: number, + readyAt: number, + startedAt: number, +): "record" | "drop" { + if (now - readyAt > MAX_SETTLE_FRAME_GAP_MS) return "drop"; + if (now - startedAt > SWITCH_TRACE_TIMEOUT_MS + SETTLE_RENDER_WAIT_MS) { + return "drop"; + } + return "record"; +} + /** * Closes the active trace once the settled frame has painted. The timeline * renders rows through a deferred snapshot that exposes @@ -526,12 +589,20 @@ export function settleChannelSwitchTrace(channelId: string): void { dropTrace(); return; } + const readyAt = now; window.requestAnimationFrame(() => { if (activeTrace !== trace) return; if (traceOverlapsHiddenWindow(trace)) { dropTrace(); return; } + if ( + resolveFinalFrame(performance.now(), readyAt, trace.startedAt) === + "drop" + ) { + dropTrace(); + return; + } record(decision.settleWaitTruncated); }); }; From 2795754999a550a459a1976f75b95049c9764da8 Mon Sep 17 00:00:00 2001 From: Max Lampert Date: Wed, 26 Aug 2026 19:05:57 -0700 Subject: [PATCH 21/27] fix(desktop): terminate the starvation heartbeat; account for every drop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cycle-1 adversarial review findings. - The starvation heartbeat rescheduled forever for a trace that was never settled, dropped, or replaced. It hung the unit suite (a test stubs rAF as setTimeout, so the timer kept node's loop alive) and leaked a frame callback per frame in production. Bounded to the trace's own liveness budget. - `routeStillOnChannel` read `location.pathname`, but the app uses hash history, so it answered false for every real channel route and degraded route-exit handling to an unconditional abandon. - Supersession and community reset discarded traces with no record, censoring the impatient-second-click case — precisely the slow switches worth seeing. `community-reset` was a declared reason that nothing emitted. - Drop accounting had no test coverage at all; deleting it left the suite green. Covered now, verified by reverting each guard. - `settleChannelSwitchTrace` was not idempotent: a second call re-stamped the fetch-attribution bound and spawned a duplicate wait chain. - `anchorDiscarded` reached the console but never the record it documents. - NewMessageScreen back-dated the anchor across the message publish, so send latency was reported as switch latency. - The canceled-fetch test restated the queryFn's reconcile-then-attribute ordering instead of exercising it; inverting production left it green. The ordering now lives in `reconcileAndAttributeChannelWindow`, which the test drives directly. Signed-off-by: Max Lampert --- desktop/src/app/AppShell.tsx | 8 +- .../src/features/agents/ui/AgentsScreen.tsx | 5 +- desktop/src/features/messages/hooks.ts | 56 ++- .../lib/projectChannelWindow.test.mjs | 46 +-- .../features/messages/ui/MessageTimeline.tsx | 27 +- .../features/messages/ui/NewMessageScreen.tsx | 3 + .../ui/useProfileInteractionActions.ts | 6 +- .../projects/ui/useProjectProfilePanel.ts | 5 +- desktop/src/features/pulse/ui/PulseScreen.tsx | 5 +- .../src/shared/lib/channelSwitchPerf.test.mjs | 183 ++++++++- desktop/src/shared/lib/channelSwitchPerf.ts | 352 ++++++++++++++++-- 11 files changed, 615 insertions(+), 81 deletions(-) diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 468435e15ec..777fe882f0a 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -13,6 +13,7 @@ import { TerminalContextOverrideProvider, } from "@/app/TerminalContextOverrideContext"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; +import { captureSwitchTraceAnchor } from "@/shared/lib/channelSwitchPerf"; import { useBackForwardControls } from "@/app/navigation/useBackForwardControls"; import { useCommunityNavigationTransitions } from "@/app/useCommunityNavigationTransitions"; import { useLiveHomeFeedActions } from "@/app/useLiveHomeFeedActions"; @@ -865,11 +866,16 @@ export function AppShell() { onMarkChannelUnread={markChannelUnread} onBrowseChannels={handleOpenBrowseChannels} onOpenDm={async ({ pubkeys }) => { + // Anchor before awaiting open_dm; see + // captureSwitchTraceAnchor. + const traceStartedAt = captureSwitchTraceAnchor(); const directMessage = await openDmMutation.mutateAsync({ pubkeys, }); - await goChannel(directMessage.id); + await goChannel(directMessage.id, { + traceStartedAt, + }); }} onSelectAgents={() => void goAgents()} onSelectChannel={handleSidebarChannelSelect} diff --git a/desktop/src/features/agents/ui/AgentsScreen.tsx b/desktop/src/features/agents/ui/AgentsScreen.tsx index 361199c50d8..5c37d18e5cc 100644 --- a/desktop/src/features/agents/ui/AgentsScreen.tsx +++ b/desktop/src/features/agents/ui/AgentsScreen.tsx @@ -1,6 +1,7 @@ import * as React from "react"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; +import { captureSwitchTraceAnchor } from "@/shared/lib/channelSwitchPerf"; import { usePersonasQuery } from "@/features/agents/hooks"; import { useOpenDmMutation } from "@/features/channels/hooks"; import { @@ -110,8 +111,10 @@ export function AgentsScreen() { const handleOpenDm = React.useCallback( async (pubkeys: string[]) => { + // Anchor before awaiting open_dm; see captureSwitchTraceAnchor. + const traceStartedAt = captureSwitchTraceAnchor(); const dm = await openDmMutation.mutateAsync({ pubkeys }); - await goChannel(dm.id); + await goChannel(dm.id, { traceStartedAt }); }, [goChannel, openDmMutation], ); diff --git a/desktop/src/features/messages/hooks.ts b/desktop/src/features/messages/hooks.ts index 9a2de67d97f..d97f69c2055 100644 --- a/desktop/src/features/messages/hooks.ts +++ b/desktop/src/features/messages/hooks.ts @@ -285,6 +285,47 @@ export function reconcileFetchedChannelWindow( return reconcileChannelWindowMessages(next, previousMessages); } +/** + * Reconciles a fetched window and then attributes it to the active switch + * trace. The ORDER is the contract: reconciliation throws for aborted + * requests, so a canceled fetch never reaches attribution and cannot claim + * the trace's one-shot slot ahead of the accepted replacement. Duration is + * measured over the fetch alone and passed in. Exported so the ordering is + * exercised by tests rather than restated by them. + */ +export function reconcileAndAttributeChannelWindow({ + queryClient, + channelId, + events, + previousMessages, + signal, + fetchDurationMs, + fetchStartedAt, +}: { + queryClient: QueryClient; + channelId: string; + events: RelayEvent[]; + previousMessages: RelayEvent[]; + signal: AbortSignal; + fetchDurationMs: number; + fetchStartedAt: number; +}): RelayEvent[] { + const result = reconcileFetchedChannelWindow( + queryClient, + channelId, + events, + previousMessages, + signal, + ); + traceChannelWindowFetch( + channelId, + events.length, + fetchDurationMs, + fetchStartedAt, + ); + return result; +} + export function useChannelMessagesQuery(channel: Channel | null) { const queryClient = useQueryClient(); const queryKey = channelMessagesKey(channel?.id ?? "none"); @@ -305,24 +346,15 @@ export function useChannelMessagesQuery(channel: Channel | null) { const fetchStartedAt = performance.now(); const events = await getChannelWindowEvents(channel.id); const fetchDurationMs = performance.now() - fetchStartedAt; - const result = reconcileFetchedChannelWindow( + return reconcileAndAttributeChannelWindow({ queryClient, - channel.id, + channelId: channel.id, events, previousMessages, signal, - ); - // Attribute only ACCEPTED fetches: reconciliation throws for aborted - // requests, and a canceled fetch that claimed the trace's one-shot - // attribution slot would block the accepted replacement from being - // recorded. Duration still measures the fetch alone, captured above. - traceChannelWindowFetch( - channel.id, - events.length, fetchDurationMs, fetchStartedAt, - ); - return result; + }); }, staleTime: 5 * 60 * 1_000, gcTime: 60 * 60 * 1_000, diff --git a/desktop/src/features/messages/lib/projectChannelWindow.test.mjs b/desktop/src/features/messages/lib/projectChannelWindow.test.mjs index a54ad751e27..922d729ef57 100644 --- a/desktop/src/features/messages/lib/projectChannelWindow.test.mjs +++ b/desktop/src/features/messages/lib/projectChannelWindow.test.mjs @@ -2,7 +2,10 @@ import assert from "node:assert/strict"; import test from "node:test"; import { QueryClient, QueryObserver } from "@tanstack/react-query"; -import { reconcileFetchedChannelWindow } from "../hooks.ts"; +import { + reconcileAndAttributeChannelWindow, + reconcileFetchedChannelWindow, +} from "../hooks.ts"; import { channelMessagesKey, channelWindowKey } from "./messageQueryKeys.ts"; import { appendOlderChannelWindow, @@ -454,35 +457,34 @@ test("canceled fetch never claims the switch trace's window slot; the accepted o canceled.abort(); const canceledEvents = wirePage([event("stale", 100)]); const startedAt = performance.now(); - assert.throws(() => { - reconcileFetchedChannelWindow( - client, + // Drive the production helper, not a restatement of it: reverting the + // reconcile/attribute order inside it must fail this test. + assert.throws(() => + reconcileAndAttributeChannelWindow({ + queryClient: client, channelId, - canceledEvents, - [], - canceled.signal, - ); - traceChannelWindowFetch(channelId, canceledEvents.length, 1, startedAt); - }); + events: canceledEvents, + previousMessages: [], + signal: canceled.signal, + fetchDurationMs: 1, + fetchStartedAt: startedAt, + }), + ); // Accepted-second: reconciles cleanly, then claims the slot. const acceptedEvents = wirePage([ event("fresh-2", 120), event("fresh-1", 110), ]); - reconcileFetchedChannelWindow( - client, + reconcileAndAttributeChannelWindow({ + queryClient: client, channelId, - acceptedEvents, - [], - new AbortController().signal, - ); - traceChannelWindowFetch( - channelId, - acceptedEvents.length, - 2, - performance.now(), - ); + events: acceptedEvents, + previousMessages: [], + signal: new AbortController().signal, + fetchDurationMs: 2, + fetchStartedAt: performance.now(), + }); settleChannelSwitchTrace(channelId); for (let i = 0; i < 10 && frames.length > 0; i += 1) { diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index 8222dcdd223..c8e9031b2ac 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -144,12 +144,22 @@ type TimelineSnapshot = { * finally land (the pass-1 tear, ledgered 2026-07-11). */ historyExhausted: boolean; + /** + * Monotonic id of this snapshot. Rendered as `data-timeline-commit` from + * the DEFERRED snapshot, so the switch tracer can tell "the commit I was + * waiting for has painted" from "new live traffic re-latched the pending + * marker after my rows painted" — the two are indistinguishable from + * `data-render-pending` alone, and the second one used to inflate the + * recorded switch by the whole burst. + */ + generation: number; }; const EMPTY_TIMELINE_SNAPSHOT: TimelineSnapshot = { channelId: null, messages: EMPTY_MESSAGES, historyExhausted: false, + generation: 0, }; const MessageTimelineBase = React.forwardRef< @@ -246,10 +256,18 @@ const MessageTimelineBase = React.forwardRef< // Channel id travels with the deferred message snapshot. Without that guard, a // route change can paint the previous channel's deferred rows for a frame even // though the sidebar/header already moved to the new channel. - const liveSnapshot = React.useMemo( - () => ({ channelId: channelId ?? null, messages, historyExhausted }), - [channelId, historyExhausted, messages], - ); + const snapshotGenerationRef = React.useRef(0); + const liveSnapshot = React.useMemo(() => { + // Monotonic only — StrictMode's double-invoke may skip a number, which + // the tracer's `>` comparison tolerates. + snapshotGenerationRef.current += 1; + return { + channelId: channelId ?? null, + messages, + historyExhausted, + generation: snapshotGenerationRef.current, + }; + }, [channelId, historyExhausted, messages]); const deferredSnapshot = React.useDeferredValue( liveSnapshot, EMPTY_TIMELINE_SNAPSHOT, @@ -701,6 +719,7 @@ const MessageTimelineBase = React.forwardRef<
{showUnreadPill ? (
{ const dm = await openDm({ pubkeys: [targetPubkey] }); - await goChannel(dm.id); + await goChannel(dm.id, { traceStartedAt }); await startHuddle(dm.id, isBot ? [targetPubkey] : []); await queryClient.invalidateQueries({ queryKey: channelsQueryKey }); if (isMountedRef.current) { @@ -196,6 +197,7 @@ export function useProfileInteractionActions({ return; } + const traceStartedAt = captureSwitchTraceAnchor(); void runAction("wave", async (targetPubkey) => { const identity = identityQuery.data; if (!identity) { @@ -231,7 +233,7 @@ export function useProfileInteractionActions({ ); try { - await goChannel(dm.id); + await goChannel(dm.id, { traceStartedAt }); if (isMountedRef.current) { onClose(); } diff --git a/desktop/src/features/projects/ui/useProjectProfilePanel.ts b/desktop/src/features/projects/ui/useProjectProfilePanel.ts index 7c984103eda..16fd27609cd 100644 --- a/desktop/src/features/projects/ui/useProjectProfilePanel.ts +++ b/desktop/src/features/projects/ui/useProjectProfilePanel.ts @@ -1,6 +1,7 @@ import * as React from "react"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; +import { captureSwitchTraceAnchor } from "@/shared/lib/channelSwitchPerf"; import { useOpenDmMutation } from "@/features/channels/hooks"; import type { ProfilePanelTab, @@ -51,8 +52,10 @@ export function useProjectProfilePanel() { ), handleOpenDm: React.useCallback( async (pubkeys: string[]) => { + // Anchor before awaiting open_dm; see captureSwitchTraceAnchor. + const traceStartedAt = captureSwitchTraceAnchor(); const dm = await openDmMutation.mutateAsync({ pubkeys }); - await goChannel(dm.id); + await goChannel(dm.id, { traceStartedAt }); }, [goChannel, openDmMutation], ), diff --git a/desktop/src/features/pulse/ui/PulseScreen.tsx b/desktop/src/features/pulse/ui/PulseScreen.tsx index 882601bcded..297b2f5cf96 100644 --- a/desktop/src/features/pulse/ui/PulseScreen.tsx +++ b/desktop/src/features/pulse/ui/PulseScreen.tsx @@ -1,6 +1,7 @@ import * as React from "react"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; +import { captureSwitchTraceAnchor } from "@/shared/lib/channelSwitchPerf"; import { useOpenDmMutation } from "@/features/channels/hooks"; import { type ProfilePanelTab, @@ -53,8 +54,10 @@ export function PulseScreen() { const { goChannel } = useAppNavigation(); const handleOpenDm = React.useCallback( async (pubkeys: string[]) => { + // Anchor before awaiting open_dm; see captureSwitchTraceAnchor. + const traceStartedAt = captureSwitchTraceAnchor(); const dm = await openDmMutation.mutateAsync({ pubkeys }); - await goChannel(dm.id); + await goChannel(dm.id, { traceStartedAt }); }, [goChannel, openDmMutation], ); diff --git a/desktop/src/shared/lib/channelSwitchPerf.test.mjs b/desktop/src/shared/lib/channelSwitchPerf.test.mjs index a08277ce55f..03bfc7a82a1 100644 --- a/desktop/src/shared/lib/channelSwitchPerf.test.mjs +++ b/desktop/src/shared/lib/channelSwitchPerf.test.mjs @@ -10,6 +10,10 @@ import { resetChannelSwitchTrace, resolveFinalFrame, resolveSettleWait, + abandonChannelSwitchTrace, + resolveRenderReadiness, + scheduleRouteExitAbandon, + resolveTraceAnchor, settleChannelSwitchTrace, summarizeChannelSwitchTrace, } from "./channelSwitchPerf.ts"; @@ -18,6 +22,11 @@ function trace(overrides = {}) { return { channelId: "abcdef1234567890", startedAt: 1_000, + // Liveness clock defaults to the anchor; DM entries back-date startedAt + // below openedAt, which is exactly why the two are separate fields. + openedAt: overrides.startedAt ?? 1_000, + maxFrameGapMs: 0, + settleEnteredAt: null, routeCommitAt: null, windowFetch: null, membersFetch: null, @@ -189,6 +198,15 @@ test("fetches attribute only when started after the switch began", () => { // Other channel or no trace: never. assert.equal(shouldAttributeFetch(active, "bbbb0000bbbb0000", 1_500), false); assert.equal(shouldAttributeFetch(null, "abcdef1234567890", 1_500), false); + // Started after the timeline settled: background revalidation the user + // never waited on. The trace is still active (it waits for the deferred + // paint), so without the upper bound this would be reported as switch cost. + const settling = trace({ startedAt: 1_000, settleEnteredAt: 2_000 }); + assert.equal(shouldAttributeFetch(settling, "abcdef1234567890", 1_999), true); + assert.equal( + shouldAttributeFetch(settling, "abcdef1234567890", 2_001), + false, + ); }); test("a superseded members fetch never claims the trace's one-shot slot", async () => { @@ -232,7 +250,9 @@ test("a suspension before the first settle frame drops, not records truncated", // The deferred marker stays latched during a suspension, so its truth // is not evidence of slow rendering — the settle-entry → first-frame // window must be starvation-guarded like every later frame. - globalThis.document.querySelector = () => ({}); + // Element-like: carries the pending marker but no committed timeline + // generation, so readiness stays gated on the marker alone. + globalThis.document.querySelector = () => ({ getAttribute: () => null }); begin("aaaa1111aaaa1111"); settle("aaaa1111aaaa1111"); virtualClock.now = 20_000; @@ -635,3 +655,164 @@ test("a prompt paint frame after readiness still records", () => { assert.equal(measures(), 1, "the guard must not drop healthy switches"); }); }); + +test("a stale caller anchor is discarded rather than charged to the switch", () => { + // DM flow: anchor captured at the click, open_dm awaited. If the user + // navigates away during that await, the anchor is no longer this switch's + // start and would charge unrelated activity to it. + assert.deepEqual(resolveTraceAnchor(1_000, 500, 30_000), { + startedAt: 30_000, + anchorDiscarded: true, + }); + // Inside the bound: the open_dm round-trip stays in the measurement. + assert.deepEqual(resolveTraceAnchor(25_000, 500, 30_000), { + startedAt: 25_000, + anchorDiscarded: false, + }); +}); + +test("anchors are never negative, non-finite, or in the future", () => { + // performance.mark({startTime}) throws on a negative timestamp, and begin() + // runs inside the click handler — a diagnostic must never break navigation. + assert.equal(resolveTraceAnchor(-5, 100, 100).startedAt, 0); + assert.equal(resolveTraceAnchor(Number.NaN, 100, 100).anchorDiscarded, true); + assert.equal(resolveTraceAnchor(Number.NaN, 100, 100).startedAt, 100); + // A skewed event clock reporting the future is clamped to now. + assert.equal(resolveTraceAnchor(200, 100, 100).startedAt, 100); + // No anchor supplied, and performance was unavailable at capture time. + assert.equal(resolveTraceAnchor(undefined, Number.NaN, 100).startedAt, 100); +}); + +test("a truncated settle survives a slow paint frame; a clean one does not", () => { + // renderWasPending is direct evidence the long frame is a heavy commit, not + // a suspension — and that measurement is already flagged. Dropping it would + // discard exactly the pathological switch the instrument exists to expose. + assert.equal(resolveFinalFrame(3_600, 10, 0, true), "record"); + assert.equal(resolveFinalFrame(3_600, 10, 0, false), "drop"); + // The age cap still applies to both. + assert.equal(resolveFinalFrame(35_001, 35_000, 0, true), "drop"); +}); + +test("post-paint churn does not keep a settled switch waiting", () => { + // Nothing pending: ready, regardless of generations. + assert.equal(resolveRenderReadiness(false, 4, 4), true); + // Pending and the timeline has not committed since settle entry: the + // switch's own rows are still unpainted, so keep waiting. + assert.equal(resolveRenderReadiness(true, 4, 4), false); + // Pending, but the timeline committed past the generation painted at settle + // entry: the rows are on screen and this marker belongs to live traffic + // that arrived afterwards. Recording the burst would inflate the switch. + assert.equal(resolveRenderReadiness(true, 4, 5), true); + // No timeline mounted (Suspense fallback still up): the pending marker is + // the fallback's own, and there is nothing painted yet to be ready. + assert.equal(resolveRenderReadiness(true, null, null), false); + assert.equal(resolveRenderReadiness(true, 4, null), false); +}); + +// --- Drop accounting: no measurement disappears without a record ----------- + +function withDropCapture(run) { + const drops = []; + const originalInfo = console.info; + console.info = (line) => { + if (typeof line === "string" && line.includes("dropped reason=")) { + drops.push(line.slice(line.indexOf("dropped reason=") + 15)); + } + }; + try { + run(drops); + } finally { + console.info = originalInfo; + resetChannelSwitchTrace(); + } +} + +test("an impatient second click accounts for the trace it supersedes", () => { + withClockedFrames(({ at }) => { + withDropCapture((drops) => { + at(0); + beginChannelSwitchTrace("aaaa"); + at(400); + // The user gave up on A and clicked B. A's trace is gone — and A being + // slow is exactly why they clicked again, so a silent discard censors + // the switches worth measuring. + beginChannelSwitchTrace("bbbb"); + assert.deepEqual(drops, ["superseded"]); + }); + }); +}); + +test("a community reset accounts for the trace it clears", () => { + withClockedFrames(({ at }) => { + withDropCapture((drops) => { + at(0); + beginChannelSwitchTrace("aaaa"); + resetChannelSwitchTrace(); + assert.deepEqual(drops, ["community-reset"]); + }); + }); +}); + +test("an unobservable surface accounts for its abandon", () => { + withClockedFrames(({ at }) => { + withDropCapture((drops) => { + at(0); + beginChannelSwitchTrace("aaaa"); + abandonChannelSwitchTrace("aaaa"); + assert.deepEqual(drops, ["unobservable-surface"]); + }); + }); +}); + +test("a timed-out settle accounts for the drop", () => { + withClockedFrames(({ at, measures }) => { + withDropCapture((drops) => { + at(0); + beginChannelSwitchTrace("aaaa"); + at(31_000); + settleChannelSwitchTrace("aaaa"); + assert.deepEqual(drops, ["timeout"]); + assert.equal(measures(), 0); + }); + }); +}); + +test("route-exit abandon respects hash-history routes", async () => { + // The app uses createHashHistory, so the route lives in location.hash. + // Reading location.pathname made this guard answer false for every real + // channel route, degrading it to an unconditional abandon. + const drops = []; + const originalInfo = console.info; + const originalWindow = globalThis.window; + const originalNow = performance.now; + const base = originalNow.call(performance) + 1_000; + performance.now = () => base; + console.info = (line) => { + if (typeof line === "string" && line.includes("dropped reason=")) { + drops.push(line.slice(line.indexOf("dropped reason=") + 15)); + } + }; + globalThis.window = { + requestAnimationFrame: () => 1, + cancelAnimationFrame: () => {}, + location: { pathname: "/index.html", hash: "#/channels/aaaa" }, + }; + try { + beginChannelSwitchTrace("aaaa"); + scheduleRouteExitAbandon("aaaa"); + await Promise.resolve(); + assert.deepEqual(drops, [], "the route still points at this channel"); + + // A real exit still abandons. + globalThis.window.location.hash = "#/projects"; + scheduleRouteExitAbandon("aaaa"); + await Promise.resolve(); + assert.deepEqual(drops, ["route-exit"]); + } finally { + console.info = originalInfo; + performance.now = originalNow; + resetChannelSwitchTrace(); + if (originalWindow === undefined) delete globalThis.window; + else globalThis.window = originalWindow; + } +}); diff --git a/desktop/src/shared/lib/channelSwitchPerf.ts b/desktop/src/shared/lib/channelSwitchPerf.ts index 97b5f9e9e11..67dbfd1aa75 100644 --- a/desktop/src/shared/lib/channelSwitchPerf.ts +++ b/desktop/src/shared/lib/channelSwitchPerf.ts @@ -35,7 +35,25 @@ import { invoke, isTauri } from "@tauri-apps/api/core"; export type ChannelSwitchTrace = { channelId: string; + /** Reported anchor: the click. May predate `openedAt` for DM entries. */ startedAt: number; + /** + * When begin() ran. Liveness (timeout, starvation age) is measured from + * here, never from `startedAt`: a back-dated anchor would otherwise spend + * the trace's whole staleness budget on the relay round-trip that preceded + * the navigation, and an honest slow DM entry would go unmeasured. + */ + openedAt: number; + /** Largest inter-frame gap seen while this trace was live. */ + maxFrameGapMs: number; + /** The caller's anchor was too stale to trust; see MAX_ANCHOR_AGE_MS. */ + anchorDiscarded: boolean; + /** + * When the timeline reported settled. The trace stays active past this + * point to wait for the deferred paint, so it bounds fetch attribution: + * work the user never waited on must not claim the switch's one-shot slot. + */ + settleEnteredAt: number | null; routeCommitAt: number | null; windowFetch: { durationMs: number; eventCount: number } | null; membersFetch: { durationMs: number; memberCount: number } | null; @@ -44,6 +62,16 @@ export type ChannelSwitchTrace = { /** A switch that hasn't settled after this long is abandoned, not measured. */ const SWITCH_TRACE_TIMEOUT_MS = 30_000; +/** + * Oldest caller-supplied anchor still treated as this navigation's click. + * A DM action captures its anchor before awaiting `open_dm`; if the user + * navigates elsewhere while that await is outstanding, the anchor is no + * longer the start of the switch that eventually commits. Beyond this the + * anchor is discarded (and the record says so) rather than charging unrelated + * activity to the switch. + */ +const MAX_ANCHOR_AGE_MS = 10_000; + export const CHANNEL_SWITCH_START_MARK = "buzz:channel-switch:start"; export const CHANNEL_SWITCH_SETTLED_MARK = "buzz:channel-switch:settled"; export const CHANNEL_SWITCH_MEASURE = "buzz:channel-switch:click-to-settled"; @@ -93,9 +121,11 @@ export function buildSwitchPerfLogRecord( windowFetch: { durationMs: number; eventCount: number } | null; membersFetch: { durationMs: number; memberCount: number } | null; settleWaitTruncated?: true; + anchorDiscarded?: true; } { return { ...(settleWaitTruncated ? { settleWaitTruncated: true as const } : {}), + ...(trace.anchorDiscarded ? { anchorDiscarded: true as const } : {}), ts: new Date().toISOString(), channelId: trace.channelId, totalMs: Math.round(settledAt - trace.startedAt), @@ -150,6 +180,38 @@ function appendSwitchPerfLogRecord(record: Record): void { }); } +export type SwitchDropReason = + | "timeout" + | "hidden-window" + | "frame-starvation" + | "settle-wait-exceeded" + | "route-exit" + | "unobservable-surface" + | "left-channel-surface" + | "navigation-failed" + | "superseded" + | "community-reset"; + +/** + * Every abandoned trace is accounted for. Drop conditions correlate with slow + * switches (starvation, timeout, hidden window), so silent drops would censor + * exactly the tail an operator is measuring and make "no samples" and "N + * samples discarded" indistinguishable in the offline log. + */ +function recordSwitchDrop( + trace: ChannelSwitchTrace, + reason: SwitchDropReason, +): void { + console.info( + `[switch-perf] channel=${trace.channelId.slice(0, 8)} dropped reason=${reason}`, + ); + appendSwitchPerfLogRecord({ + ts: new Date().toISOString(), + channelId: trace.channelId, + dropped: reason, + }); +} + /** * Decides what a settle call does with the active trace. A settle for a * different channel must leave the trace alone — a previous channel can @@ -167,7 +229,7 @@ export function resolveSettleAction( if (!trace || trace.channelId !== channelId) { return { settledTrace: null, clearActive: false }; } - if (now - trace.startedAt > SWITCH_TRACE_TIMEOUT_MS) { + if (now - trace.openedAt > SWITCH_TRACE_TIMEOUT_MS) { return { settledTrace: null, clearActive: true }; } return { settledTrace: trace, clearActive: true }; @@ -178,8 +240,12 @@ export function resolveSettleAction( * observe (e.g. forum channels, whose loading is owned by ForumView's own * queries). Better no measurement than a systematically underreported one. */ -export function abandonChannelSwitchTrace(channelId: string): void { +export function abandonChannelSwitchTrace( + channelId: string, + reason: SwitchDropReason = "unobservable-surface", +): void { if (activeTrace?.channelId === channelId) { + recordSwitchDrop(activeTrace, reason); activeTrace = null; } } @@ -192,7 +258,10 @@ export function abandonChannelSwitchTrace(channelId: string): void { * so no route-exit cleanup exists to abandon it, and a later untraced * re-entry within the timeout would settle it with the time spent away. */ -export function dropActiveChannelSwitchTrace(): void { +export function dropActiveChannelSwitchTrace( + reason: SwitchDropReason = "left-channel-surface", +): void { + if (activeTrace) recordSwitchDrop(activeTrace, reason); activeTrace = null; } @@ -213,12 +282,38 @@ const pendingRouteExitAbandons = new Set(); export function scheduleRouteExitAbandon(channelId: string): void { pendingRouteExitAbandons.add(channelId); queueMicrotask(() => { - if (pendingRouteExitAbandons.delete(channelId)) { - abandonChannelSwitchTrace(channelId); - } + if (!pendingRouteExitAbandons.delete(channelId)) return; + // The unmount may be a remount in disguise: a Suspense boundary between + // the two commits (project-home channels swap ChannelScreen for a lazy + // ChannelScreenView) means the re-setup that would have cancelled this + // has not run yet, and killing the trace here loses a switch the user + // did make. The route is the authority — if it still points at this + // channel, nothing exited. + if (routeStillOnChannel(channelId)) return; + abandonChannelSwitchTrace(channelId, "route-exit"); }); } +/** + * Whether the current URL is still this channel's own route. Read from + * location rather than React state: the check runs from a microtask, after + * the unmount commit, where no component tree is authoritative. + */ +function routeStillOnChannel(channelId: string): boolean { + if (typeof window === "undefined" || !window.location) return false; + // The app uses createHashHistory (app/router.tsx), so the route lives in + // location.hash and location.pathname is the document path. Reading + // pathname here made this guard inert — it answered false for every real + // channel route, degrading the caller to an unconditional abandon. + const hash = window.location.hash; + const route = hash.startsWith("#") ? hash.slice(1) : window.location.pathname; + const path = route.split("?")[0]; + return ( + path === `/channels/${channelId}` || + path.startsWith(`/channels/${channelId}/`) + ); +} + /** * cancelRouteExitAbandon revokes a pending scheduleRouteExitAbandon for the * channel. Call it from the route-enter effect setup, before any work. @@ -261,7 +356,9 @@ function traceOverlapsHiddenWindow(trace: ChannelSwitchTrace): boolean { * instead of silently preceding it. */ export function captureSwitchTraceAnchor(): number { - if (typeof performance === "undefined") return 0; + // NaN, not 0: 0 is a valid timestamp meaning "time origin", and it would + // silently report the whole session uptime as switch latency. + if (typeof performance === "undefined") return Number.NaN; const now = performance.now(); const dispatchingEvent = typeof window === "undefined" ? undefined : window.event; @@ -270,6 +367,79 @@ export function captureSwitchTraceAnchor(): number { : now; } +/** + * Samples inter-frame gaps for as long as `trace` is the active one. The + * settle path guards its own frames, but the click -> settle-entry interval + * had no starvation guard at all: process suspension there (App Nap, a + * suspended VM) fires no `visibilitychange`, and the switch recorded the whole + * absence as clean. The loop exits as soon as the trace is replaced, dropped, + * or recorded, so at most one rAF per frame is live per switch. + */ +function startStarvationHeartbeat(trace: ChannelSwitchTrace): void { + if (typeof window === "undefined" || !window.requestAnimationFrame) return; + // Bind the scheduler once. Reading the ambient `window` on every tick would + // follow a swapped-out global (tests replace it; a torn-down document in + // production would be equivalent) and throw from inside a frame callback, + // where nothing can catch it. + const schedule = window.requestAnimationFrame.bind(window); + let lastAt = trace.openedAt; + const tick = () => { + if (activeTrace !== trace) return; + const now = performance.now(); + // Past its own liveness budget the trace can no longer be measured, so + // sampling it is pure cost. Without this the loop outlives any trace that + // is never settled, dropped, or replaced — one frame callback per frame, + // forever. + if ( + now - trace.openedAt > + SWITCH_TRACE_TIMEOUT_MS + SETTLE_RENDER_WAIT_MS + ) { + return; + } + trace.maxFrameGapMs = Math.max(trace.maxFrameGapMs, now - lastAt); + lastAt = now; + try { + schedule(tick); + } catch { + // Frame loop is gone; the gap it would have measured is unknowable. + // Leave maxFrameGapMs at what was observed rather than guessing. + } + }; + try { + schedule(tick); + } catch { + /* no frame loop available; settle-path guards still apply */ + } +} + +/** + * Resolves the reported start for a trace. A caller-supplied anchor is used + * only when it is finite, not in the future, and recent enough to still be + * this navigation's click; otherwise `now` is used and the caller is told the + * anchor was discarded so the record can say so. Pure for unit testing. + */ +export function resolveTraceAnchor( + anchoredAt: number | undefined, + fallback: number, + now: number, +): { startedAt: number; anchorDiscarded: boolean } { + if (anchoredAt === undefined) { + return { + startedAt: Number.isFinite(fallback) + ? Math.max(0, Math.min(fallback, now)) + : now, + anchorDiscarded: false, + }; + } + if (!Number.isFinite(anchoredAt) || now - anchoredAt > MAX_ANCHOR_AGE_MS) { + return { startedAt: now, anchorDiscarded: true }; + } + return { + startedAt: Math.max(0, Math.min(anchoredAt, now)), + anchorDiscarded: false, + }; +} + /** Opaque identity for one opened trace; see `cancelChannelSwitchTrace`. */ export type ChannelSwitchTraceHandle = { readonly trace: ChannelSwitchTrace }; @@ -281,7 +451,10 @@ export type ChannelSwitchTraceHandle = { readonly trace: ChannelSwitchTrace }; export function cancelChannelSwitchTrace( handle: ChannelSwitchTraceHandle | null, ): void { - if (handle && activeTrace === handle.trace) activeTrace = null; + if (handle && activeTrace === handle.trace) { + recordSwitchDrop(activeTrace, "navigation-failed"); + activeTrace = null; + } } export function beginChannelSwitchTrace( @@ -301,18 +474,30 @@ export function beginChannelSwitchTrace( // continuations; min() guards against skewed event clocks and against a // caller-supplied anchor that a monotonic-clock skew put in the future. const now = performance.now(); - const startedAt = - anchoredAt === undefined - ? captureSwitchTraceAnchor() - : Math.min(anchoredAt, now); + const { startedAt, anchorDiscarded } = resolveTraceAnchor( + anchoredAt, + captureSwitchTraceAnchor(), + now, + ); + if (activeTrace) recordSwitchDrop(activeTrace, "superseded"); activeTrace = { channelId, startedAt, + openedAt: now, + maxFrameGapMs: 0, + anchorDiscarded, + settleEnteredAt: null, routeCommitAt: null, windowFetch: null, membersFetch: null, }; const handle: ChannelSwitchTraceHandle = { trace: activeTrace }; + if (anchorDiscarded) { + console.info( + `[switch-perf] channel=${channelId.slice(0, 8)} anchor discarded (stale); measuring from navigation`, + ); + } + startStarvationHeartbeat(activeTrace); // Clear the whole previous switch here, not only in record(): traces that // die without recording (forum visits, route exits, drops) never reach // record()'s buffer clearing — weeks-long sessions would accumulate a @@ -341,10 +526,14 @@ export function markChannelSwitchRouteCommit(channelId: string): void { /** * A fetch attributes to the active trace only when it targets the traced - * channel AND started after the switch began. A fetch that started before - * the switch (e.g. the first leg of a rapid A→B→A completing during the - * second A trace) is not this switch's cost; letting it claim the `??=` - * slot would also block the real fetch. Pure for unit testing. + * channel and started inside the measured interval. Both bounds matter. A + * fetch that started before the switch (the first leg of a rapid A→B→A + * completing during the second A trace) is not this switch's cost, and + * letting it claim the `??=` slot would also block the real fetch. A fetch + * that started after the timeline settled is a background revalidation the + * user never waited on; the trace stays active through the deferred-paint + * wait, so without this upper bound it would be reported as switch cost. + * Pure for unit testing. */ export function shouldAttributeFetch( trace: ChannelSwitchTrace | null, @@ -352,7 +541,10 @@ export function shouldAttributeFetch( fetchStartedAt: number, ): trace is ChannelSwitchTrace { if (!trace || trace.channelId !== channelId) return false; - return fetchStartedAt >= trace.startedAt; + if (fetchStartedAt < trace.startedAt) return false; + return ( + trace.settleEnteredAt === null || fetchStartedAt <= trace.settleEnteredAt + ); } export function traceChannelWindowFetch( @@ -408,7 +600,16 @@ export function traceChannelMembersFetch( * resetCommunityState() like every community-scoped singleton. */ export function resetChannelSwitchTrace(): void { + if (activeTrace) recordSwitchDrop(activeTrace, "community-reset"); activeTrace = null; + lastVisibilityChangeAt = Number.NEGATIVE_INFINITY; + // A community switch must not leave the previous community's marks where a + // consumer polling the buffer would read them as the next community's. + if (typeof performance !== "undefined") { + performance.clearMarks(CHANNEL_SWITCH_START_MARK); + performance.clearMarks(CHANNEL_SWITCH_SETTLED_MARK); + performance.clearMeasures(CHANNEL_SWITCH_MEASURE); + } pendingRouteExitAbandons.clear(); channelMembersFetchSequences.clear(); } @@ -446,10 +647,10 @@ export function resolveSettleWait( now: number, waitDeadline: number, renderPending: boolean, - startedAt: number, + openedAt: number, frameGapMs: number | null = null, ): "wait" | "drop" | { settleWaitTruncated: boolean } { - if (now - startedAt > SWITCH_TRACE_TIMEOUT_MS + SETTLE_RENDER_WAIT_MS) { + if (now - openedAt > SWITCH_TRACE_TIMEOUT_MS + SETTLE_RENDER_WAIT_MS) { return "drop"; } if (frameGapMs !== null && frameGapMs > MAX_SETTLE_FRAME_GAP_MS) { @@ -468,13 +669,55 @@ export function resolveSettleWait( * frame would record the whole absence as an ordinary clean switch. Pure for * unit testing. */ +/** + * Reads the timeline's painted commit generation, or null when no timeline is + * mounted (Suspense fallback, forum surface). + */ +function readTimelineCommit(): number | null { + // Defensive: this runs on the settle path, and a diagnostic must never + // throw into the app. + const raw = document + .querySelector("[data-timeline-commit]") + ?.getAttribute?.("data-timeline-commit"); + if (raw == null) return null; + const parsed = Number(raw); + return Number.isFinite(parsed) ? parsed : null; +} + +/** + * Decides whether the switch's own deferred commit has painted. A pending + * marker alone cannot distinguish "my rows have not committed yet" from "live + * traffic arriving after my rows painted re-latched the marker" — and a burst + * right after a switch (the subscription's catch-up) is exactly when the + * second happens, which recorded the whole burst as switch latency, unflagged. + * A commit generation past the one painted at settle entry proves the + * timeline has committed since, so the switch's rows are on screen. + * Pure for unit testing. + */ +export function resolveRenderReadiness( + renderPending: boolean, + commitAtEntry: number | null, + commitNow: number | null, +): boolean { + if (!renderPending) return true; + if (commitAtEntry === null || commitNow === null) return false; + return commitNow > commitAtEntry; +} + export function resolveFinalFrame( now: number, readyAt: number, - startedAt: number, + openedAt: number, + renderWasPending = false, ): "record" | "drop" { - if (now - readyAt > MAX_SETTLE_FRAME_GAP_MS) return "drop"; - if (now - startedAt > SWITCH_TRACE_TIMEOUT_MS + SETTLE_RENDER_WAIT_MS) { + // A render still pending at readiness is direct evidence that a heavy + // commit — not a suspension — owns this frame, and that measurement is + // already flagged `settleWaitTruncated`. Dropping it here would discard + // exactly the pathological switch the instrument exists to expose. + if (!renderWasPending && now - readyAt > MAX_SETTLE_FRAME_GAP_MS) { + return "drop"; + } + if (now - openedAt > SWITCH_TRACE_TIMEOUT_MS + SETTLE_RENDER_WAIT_MS) { return "drop"; } return "record"; @@ -497,10 +740,21 @@ export function settleChannelSwitchTrace(channelId: string): void { performance.now(), ); if (!settledTrace) { - if (clearActive) activeTrace = null; + if (clearActive && activeTrace) { + recordSwitchDrop(activeTrace, "timeout"); + activeTrace = null; + } return; } const trace = settledTrace; + // The click -> settle-entry interval is guarded by the heartbeat, not by + // the settle loop's own frame gaps: a suspension there fires no + // visibilitychange and would otherwise land as a clean record. + if (trace.maxFrameGapMs > MAX_SETTLE_FRAME_GAP_MS) { + recordSwitchDrop(trace, "frame-starvation"); + activeTrace = null; + return; + } // Both globals gate the whole settle path: the wait loop reads // document.visibilityState and querySelector unguarded past this point. if (typeof window === "undefined" || typeof document === "undefined") { @@ -514,12 +768,21 @@ export function settleChannelSwitchTrace(channelId: string): void { // measurement than a fabricated one. ensureVisibilityWatcher(); if (traceOverlapsHiddenWindow(trace)) { + recordSwitchDrop(trace, "hidden-window"); activeTrace = null; return; } // Keep the trace active through the deferred-commit wait so fetches that // finish inside the measured window still attribute to it. It is released // when the record lands; a newer switch's begin() simply replaces it. + // Stamping settle entry closes attribution to fetches that START after + // this point — those are background work, not switch cost. + if (trace.settleEnteredAt !== null) return; + // Bind once: a swapped-out global would otherwise throw from inside a frame + // callback, where nothing can catch it (same hazard as the heartbeat). + const schedule = window.requestAnimationFrame.bind(window); + trace.settleEnteredAt = performance.now(); + const commitAtEntry = readTimelineCommit(); const waitDeadline = performance.now() + SETTLE_RENDER_WAIT_MS; const record = (settleWaitTruncated: boolean) => { const settledAt = performance.now(); @@ -532,6 +795,7 @@ export function settleChannelSwitchTrace(channelId: string): void { performance.clearMeasures(CHANNEL_SWITCH_MEASURE); performance.mark(CHANNEL_SWITCH_SETTLED_MARK, { detail: { channelId }, + startTime: settledAt, }); performance.measure(CHANNEL_SWITCH_MEASURE, { detail: { @@ -551,8 +815,11 @@ export function settleChannelSwitchTrace(channelId: string): void { buildSwitchPerfLogRecord(trace, settledAt, settleWaitTruncated), ); }; - const dropTrace = () => { - if (activeTrace === trace) activeTrace = null; + const dropTrace = (reason: SwitchDropReason) => { + if (activeTrace === trace) { + recordSwitchDrop(trace, reason); + activeTrace = null; + } }; // Seeded now, not on the first frame: the settle-entry → first-frame // window must be starvation-guarded too, or a suspension there records a @@ -568,43 +835,56 @@ export function settleChannelSwitchTrace(channelId: string): void { return; } if (traceOverlapsHiddenWindow(trace)) { - dropTrace(); + dropTrace("hidden-window"); return; } const now = performance.now(); const frameGapMs = lastFrameAt === null ? null : now - lastFrameAt; lastFrameAt = now; + const renderPending = !resolveRenderReadiness( + document.querySelector('[data-render-pending="true"]') !== null, + commitAtEntry, + readTimelineCommit(), + ); const decision = resolveSettleWait( now, waitDeadline, - document.querySelector('[data-render-pending="true"]') !== null, - trace.startedAt, + renderPending, + trace.openedAt, frameGapMs, ); if (decision === "wait") { - window.requestAnimationFrame(awaitDeferredCommit); + schedule(awaitDeferredCommit); return; } if (decision === "drop") { - dropTrace(); + dropTrace( + frameGapMs !== null && frameGapMs > MAX_SETTLE_FRAME_GAP_MS + ? "frame-starvation" + : "settle-wait-exceeded", + ); return; } const readyAt = now; - window.requestAnimationFrame(() => { + schedule(() => { if (activeTrace !== trace) return; if (traceOverlapsHiddenWindow(trace)) { - dropTrace(); + dropTrace("hidden-window"); return; } if ( - resolveFinalFrame(performance.now(), readyAt, trace.startedAt) === - "drop" + resolveFinalFrame( + performance.now(), + readyAt, + trace.openedAt, + renderPending, + ) === "drop" ) { - dropTrace(); + dropTrace("frame-starvation"); return; } record(decision.settleWaitTruncated); }); }; - window.requestAnimationFrame(awaitDeferredCommit); + schedule(awaitDeferredCommit); } From c299a22ea761ef434c7b578b0a63d5e8aa76d7e9 Mon Sep 17 00:00:00 2001 From: Max Lampert Date: Wed, 26 Aug 2026 19:58:15 -0700 Subject: [PATCH 22/27] refactor(desktop): cut the switch tracer to a simple, honest metric The instrument was accumulating defects faster than it was answering questions. Nearly every review finding lived in a seam that existed only because the tracer inferred more than it could observe: a starvation heartbeat, a per-seam guard matrix, DOM-attribute sniffing with a commit generation, click anchors threaded by hand through nine async call sites, and an on-disk JSONL sink with rotation and byte caps. Each fix added a seam, and the next review found the guard that seam was missing. The perf work these numbers support matters more than the numbers, so the instrument is now scoped to what it can measure without inference: - click (or navigation) -> route commit -> settled paint, as a console line plus User Timing marks/measures. That is what the Performance panel and the perf harness read. - the message-window fetch when it starts inside that interval. - one drop rule per unmeasurable condition (hidden window, timeout, superseded, left the surface), each printing why. Removed: the Rust perf-log sink and its git-revision baking, the click-anchor threading and every call site that existed only to carry it, the starvation heartbeat, the roster-fetch attribution and its sequence machinery, and the commit-generation burst gate. Scope bounds are now stated in the module doc rather than inferred: callers that await before navigating are measured from the navigation, a bounded frame wait replaces the 5s render deadline, and a sample past that bound is flagged rather than dropped. Net: 2231 lines removed, 462 added. Signed-off-by: Max Lampert --- desktop/src-tauri/build.rs | 28 - desktop/src-tauri/src/commands/mod.rs | 2 - desktop/src-tauri/src/commands/perf_log.rs | 416 --------- desktop/src-tauri/src/lib.rs | 1 - desktop/src/app/AppShell.tsx | 8 +- .../commitGuardedNavigation.test.mjs | 86 -- .../app/navigation/commitGuardedNavigation.ts | 23 +- .../src/app/navigation/useAppNavigation.ts | 27 +- .../src/features/agents/ui/AgentsScreen.tsx | 5 +- desktop/src/features/channels/hooks.ts | 92 +- desktop/src/features/channels/sidebarPerf.ts | 74 -- .../channels/ui/useChannelProfilePanel.ts | 5 +- .../useChannelSwitchTraceMarks.test.mjs | 20 - .../channels/useChannelSwitchTraceMarks.ts | 20 - desktop/src/features/home/ui/HomeView.tsx | 5 +- .../features/messages/ui/MessageTimeline.tsx | 27 +- .../features/messages/ui/NewMessageScreen.tsx | 3 - .../ui/useProfileInteractionActions.ts | 12 +- .../projects/ui/useProjectProfilePanel.ts | 5 +- .../src/features/pulse/lib/useNoteActions.ts | 7 +- desktop/src/features/pulse/ui/PulseScreen.tsx | 5 +- .../src/shared/lib/channelSwitchPerf.test.mjs | 884 ++++-------------- desktop/src/shared/lib/channelSwitchPerf.ts | 852 +++-------------- .../e2e/switch-settle-after-paint.spec.ts | 86 +- 24 files changed, 462 insertions(+), 2231 deletions(-) delete mode 100644 desktop/src-tauri/src/commands/perf_log.rs delete mode 100644 desktop/src/features/channels/sidebarPerf.ts diff --git a/desktop/src-tauri/build.rs b/desktop/src-tauri/build.rs index 63dfa91dc6b..2cdd785c735 100644 --- a/desktop/src-tauri/build.rs +++ b/desktop/src-tauri/build.rs @@ -8,34 +8,6 @@ include!("src/managed_agents/reserved_env_keys.rs"); use base64::Engine as _; fn main() { - // Bake the source git revision into the binary so diagnostics (the - // switch-perf JSONL sink) can attribute records to the build that wrote - // them. Reruns key off the reflog, which updates on every checkout, - // commit, and rebase. `--dirty` marks uncommitted worktrees but is only - // as fresh as the last build-script run: plain source edits between - // builds do not re-stamp it. Checkout-based A/B flows (the intended use) - // always update the reflog and re-stamp. - if let Ok(git_dir) = std::process::Command::new("git") - .args(["rev-parse", "--absolute-git-dir"]) - .output() - { - if git_dir.status.success() { - let dir = String::from_utf8_lossy(&git_dir.stdout).trim().to_string(); - println!("cargo:rerun-if-changed={dir}/HEAD"); - println!("cargo:rerun-if-changed={dir}/logs/HEAD"); - } - } - if let Some(git_sha) = std::process::Command::new("git") - .args(["describe", "--always", "--dirty", "--abbrev=12"]) - .output() - .ok() - .filter(|output| output.status.success()) - .map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string()) - .filter(|sha| !sha.is_empty()) - { - println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_GIT_SHA={git_sha}"); - } - println!("cargo:rerun-if-env-changed=BUZZ_RELAY_URL"); println!("cargo:rerun-if-env-changed=BUZZ_RELAY_HTTP"); println!("cargo:rerun-if-env-changed=BUZZ_UPDATER_PUBLIC_KEY"); diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 0389c20ecea..7cb2d8e3b83 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -44,7 +44,6 @@ mod notifications; mod observer_archive; mod os_idle; pub mod pairing; -mod perf_log; mod personas; mod prevent_sleep; mod profile; @@ -106,7 +105,6 @@ pub use notifications::*; pub use observer_archive::*; pub use os_idle::*; pub use pairing::*; -pub use perf_log::*; pub use personas::*; pub use prevent_sleep::*; pub use profile::*; diff --git a/desktop/src-tauri/src/commands/perf_log.rs b/desktop/src-tauri/src/commands/perf_log.rs deleted file mode 100644 index 51249be618a..00000000000 --- a/desktop/src-tauri/src/commands/perf_log.rs +++ /dev/null @@ -1,416 +0,0 @@ -//! Append-only JSONL sink for channel-switch perf traces. -//! -//! The desktop's `[switch-perf]` console traces vanish with the session; this -//! sink persists one JSON line per settled switch to -//! `{app_log_dir}/switch-perf.jsonl` so before/after builds can be compared -//! offline. Every line is stamped with the build's git revision (baked by -//! build.rs) and, when set at launch, the `BUZZ_PERF_LOG_LABEL` run label — -//! e.g. `BUZZ_PERF_LOG_LABEL=before just production`. - -use std::io::Write; - -use tauri::Manager; - -const PERF_LOG_FILENAME: &str = "switch-perf.jsonl"; - -/// Defensive cap: one record is a small trace object; anything larger is a -/// caller bug and must not grow the log unbounded. Enforced on the input -/// record and again on the final serialized line, so folded-in metadata can -/// never defeat it. -const MAX_RECORD_BYTES: usize = 4 * 1024; - -/// Upper bound on the operator-supplied `BUZZ_PERF_LOG_LABEL` run label. -/// Truncating (rather than erroring) keeps a fat-fingered label from -/// silently dropping every trace for the whole run — the frontend swallows -/// sink errors by design. -const MAX_LABEL_BYTES: usize = 128; - -/// Truncates to the last char boundary at or below `max_bytes`. -fn truncate_at_char_boundary(text: &str, max_bytes: usize) -> &str { - if text.len() <= max_bytes { - return text; - } - let mut end = max_bytes; - while !text.is_char_boundary(end) { - end -= 1; - } - &text[..end] -} - -/// Rotation threshold. The sink is always on, so without a cap the JSONL -/// grows for the life of the install; one rotated generation preserves -/// enough history for before/after comparisons. -const MAX_LOG_BYTES: u64 = 10 * 1024 * 1024; - -/// Validates and shapes one JSONL line: the record must be a JSON object -/// (which also guarantees the stored line is newline-free), then the build -/// revision and optional run label are folded in. Pure for unit testing. -fn shape_perf_log_line( - record_json: &str, - git_sha: Option<&str>, - label: Option<&str>, -) -> Result { - if record_json.len() > MAX_RECORD_BYTES { - return Err("perf log record too large".to_string()); - } - let mut value: serde_json::Value = - serde_json::from_str(record_json).map_err(|e| format!("invalid perf log record: {e}"))?; - let object = value - .as_object_mut() - .ok_or_else(|| "perf log record must be a JSON object".to_string())?; - object.insert( - "gitSha".to_string(), - match git_sha { - Some(sha) => serde_json::Value::String(sha.to_string()), - None => serde_json::Value::Null, - }, - ); - if let Some(label) = label { - object.insert( - "label".to_string(), - serde_json::Value::String( - truncate_at_char_boundary(label, MAX_LABEL_BYTES).to_string(), - ), - ); - } - let line = serde_json::to_string(&value).map_err(|e| e.to_string())?; - // The cap must hold for what actually reaches the disk: gitSha and label - // are folded in after the record-size check above, and writeln! appends - // a newline terminator — reserve one byte for it. - if line.len() + 1 > MAX_RECORD_BYTES { - return Err("perf log line too large".to_string()); - } - Ok(line) -} - -/// Serializes the whole metadata→rename→append transaction. Appends run on -/// independent `spawn_blocking` threads; without this, two writers at the -/// rotation boundary can both decide to rotate — the loser's rename fails and -/// its record is dropped. One global lock suffices: the app writes a single -/// log path. -static PERF_LOG_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); - -/// Appends one line, rotating the file to `.1` (replacing the previous -/// generation) once it exceeds `max_bytes`. Factored for unit testing. -fn append_line_rotating(path: &std::path::Path, line: &str, max_bytes: u64) -> Result<(), String> { - let _guard = PERF_LOG_LOCK - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - if let Ok(metadata) = std::fs::metadata(path) { - if metadata.len() >= max_bytes { - let mut rotated = path.as_os_str().to_owned(); - rotated.push(".1"); - let rotated = std::path::PathBuf::from(rotated); - // Remove the retained generation before renaming over it: on - // Windows, rename does not replace an existing destination. Same - // platform rule as managed_agents::storage::start_install_log_session. - // - // Rotation itself is best-effort: an AV/EDR or editor holding a - // transient lock (again, chiefly Windows) would otherwise fail - // EVERY append until the lock clears — the frontend deliberately - // swallows sink errors, so records would vanish silently. - // Degrade to an unrotated append; the size cap re-applies once - // rotation succeeds on a later write. - let rotation = (|| -> std::io::Result<()> { - if rotated.exists() { - std::fs::remove_file(&rotated)?; - } - std::fs::rename(path, &rotated) - })(); - if let Err(e) = rotation { - eprintln!("buzz-desktop: perf-log rotation failed, appending unrotated: {e}"); - } - } - } - let mut file = std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(path) - .map_err(|e| e.to_string())?; - // One write_all, not writeln!: writeln! issues two write syscalls (line, - // then newline), and PERF_LOG_LOCK is process-local while the path is - // not — a second Buzz process sharing the log dir could interleave - // between them. A single O_APPEND write keeps lines atomic. - file.write_all(format!("{line}\n").as_bytes()) - .map_err(|e| e.to_string()) -} - -/// Appends one switch-perf record to the app-log-dir JSONL file and returns -/// the file's path so the frontend can announce where the log lives. -/// -/// Async so Tauri runs it on the async runtime rather than the main thread: -/// a perf sink must not add main-thread filesystem stalls to the switches it -/// measures. -#[tauri::command] -pub async fn append_switch_perf_log( - app: tauri::AppHandle, - record_json: String, -) -> Result { - let label = std::env::var("BUZZ_PERF_LOG_LABEL").ok(); - let line = shape_perf_log_line( - &record_json, - option_env!("BUZZ_DESKTOP_BUILD_GIT_SHA"), - label.as_deref(), - )?; - let dir = app.path().app_log_dir().map_err(|e| e.to_string())?; - let path = dir.join(PERF_LOG_FILENAME); - let result = tauri::async_runtime::spawn_blocking(move || { - std::fs::create_dir_all(path.parent().unwrap_or(&path)).map_err(|e| e.to_string())?; - append_line_rotating(&path, &line, MAX_LOG_BYTES)?; - Ok::(path.display().to_string()) - }) - .await - .map_err(|e| e.to_string())?; - result -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn shape_folds_in_git_sha_and_label() { - let line = shape_perf_log_line(r#"{"totalMs":412}"#, Some("abc123-dirty"), Some("before")) - .expect("shape"); - let value: serde_json::Value = serde_json::from_str(&line).expect("parse"); - assert_eq!(value["totalMs"], 412); - assert_eq!(value["gitSha"], "abc123-dirty"); - assert_eq!(value["label"], "before"); - assert!(!line.contains('\n')); - } - - #[test] - fn shape_without_label_or_sha_keeps_record_and_null_sha() { - let line = shape_perf_log_line(r#"{"totalMs":1}"#, None, None).expect("shape"); - let value: serde_json::Value = serde_json::from_str(&line).expect("parse"); - assert_eq!(value["gitSha"], serde_json::Value::Null); - assert!(value.get("label").is_none()); - } - - #[test] - fn shape_rejects_non_objects_and_oversized_records() { - assert!(shape_perf_log_line("[1,2]", None, None).is_err()); - assert!(shape_perf_log_line("not json", None, None).is_err()); - let oversized = format!(r#"{{"pad":"{}"}}"#, "x".repeat(MAX_RECORD_BYTES)); - assert!(shape_perf_log_line(&oversized, None, None).is_err()); - } - - #[test] - fn shape_truncates_an_unbounded_label_and_keeps_the_line_capped() { - // BUZZ_PERF_LOG_LABEL is operator-supplied; a runaway value must not - // defeat the record cap by being folded in after the size check. - let label = "l".repeat(1024 * 1024); - let line = - shape_perf_log_line(r#"{"totalMs":13}"#, Some("abc123"), Some(&label)).expect("shape"); - assert!(line.len() <= MAX_RECORD_BYTES, "line stays under the cap"); - let value: serde_json::Value = serde_json::from_str(&line).expect("parse"); - assert_eq!( - value["label"].as_str().expect("label").len(), - MAX_LABEL_BYTES - ); - assert_eq!(value["totalMs"], 13); - } - - #[test] - fn label_truncation_cuts_at_a_char_boundary() { - // '€' is 3 bytes; MAX_LABEL_BYTES (128) is not a multiple of 3, so a - // byte-index cut would split a char and panic (or emit invalid UTF-8). - let label = "€".repeat(MAX_LABEL_BYTES); - let line = shape_perf_log_line(r#"{"totalMs":1}"#, None, Some(&label)).expect("shape"); - let value: serde_json::Value = serde_json::from_str(&line).expect("parse"); - let stored = value["label"].as_str().expect("label"); - assert_eq!(stored.len(), MAX_LABEL_BYTES - (MAX_LABEL_BYTES % 3)); - assert!(stored.chars().all(|c| c == '€')); - } - - #[test] - fn shape_rejects_a_line_that_outgrows_the_cap_after_metadata() { - // The record alone passes the input check; the folded-in git sha - // pushes the serialized line over the cap. - let pad = "x".repeat(MAX_RECORD_BYTES - 20); - let record = format!(r#"{{"pad":"{pad}"}}"#); - assert!(record.len() <= MAX_RECORD_BYTES); - assert!(shape_perf_log_line(&record, Some(&"s".repeat(64)), None).is_err()); - } - - #[test] - fn the_cap_bounds_bytes_on_disk_including_the_newline() { - // Shaped line = {"pad":"…","gitSha":null} → pad length + 24 bytes. - // The largest accepted line is MAX_RECORD_BYTES - 1: writeln! appends - // a newline, and the cap bounds what reaches the disk. - let fits = format!(r#"{{"pad":"{}"}}"#, "x".repeat(MAX_RECORD_BYTES - 25)); - let line = shape_perf_log_line(&fits, None, None).expect("one byte reserved for newline"); - assert_eq!(line.len(), MAX_RECORD_BYTES - 1); - - let dir = std::env::temp_dir().join(format!( - "perf-log-newline-{}-{:?}", - std::process::id(), - std::thread::current().id() - )); - std::fs::create_dir_all(&dir).expect("tempdir"); - let path = dir.join("switch-perf.jsonl"); - let _ = std::fs::remove_file(&path); - append_line_rotating(&path, &line, MAX_LOG_BYTES).expect("append"); - assert_eq!( - std::fs::metadata(&path).expect("metadata").len(), - MAX_RECORD_BYTES as u64, - "on-disk record must not exceed the cap" - ); - std::fs::remove_dir_all(&dir).ok(); - - // One pad byte more serializes to exactly MAX_RECORD_BYTES, which - // would write MAX_RECORD_BYTES + 1 bytes — rejected. - let over = format!(r#"{{"pad":"{}"}}"#, "x".repeat(MAX_RECORD_BYTES - 24)); - assert!(shape_perf_log_line(&over, None, None).is_err()); - } - - #[test] - fn append_rotates_once_over_the_cap_and_keeps_one_generation() { - let dir = std::env::temp_dir().join(format!("perf-log-test-{}", std::process::id())); - std::fs::create_dir_all(&dir).expect("tempdir"); - let path = dir.join("switch-perf.jsonl"); - let _ = std::fs::remove_file(&path); - let _ = std::fs::remove_file(dir.join("switch-perf.jsonl.1")); - - append_line_rotating(&path, "first", 16).expect("append"); - append_line_rotating(&path, "second", 16).expect("append"); - // 12 bytes so far — under the cap, same file. - assert_eq!( - std::fs::read_to_string(&path).expect("read"), - "first\nsecond\n" - ); - - // Push past the cap; the next append must rotate. - append_line_rotating(&path, "third-is-long", 16).expect("append"); - append_line_rotating(&path, "fresh", 16).expect("append"); - assert_eq!(std::fs::read_to_string(&path).expect("read"), "fresh\n"); - assert_eq!( - std::fs::read_to_string(dir.join("switch-perf.jsonl.1")).expect("read rotated"), - "first\nsecond\nthird-is-long\n" - ); - - // A second rotation replaces the previous generation, never a third file. - append_line_rotating(&path, "overflow-the-cap!", 16).expect("append"); - append_line_rotating(&path, "newest", 16).expect("append"); - assert_eq!(std::fs::read_to_string(&path).expect("read"), "newest\n"); - assert_eq!( - std::fs::read_to_string(dir.join("switch-perf.jsonl.1")).expect("read rotated"), - "fresh\noverflow-the-cap!\n" - ); - std::fs::remove_dir_all(&dir).ok(); - } - - #[test] - fn rotation_replaces_an_existing_retained_generation() { - let dir = std::env::temp_dir().join(format!( - "perf-log-regen-{}-{:?}", - std::process::id(), - std::thread::current().id() - )); - std::fs::create_dir_all(&dir).expect("tempdir"); - let path = dir.join("switch-perf.jsonl"); - let rotated = dir.join("switch-perf.jsonl.1"); - // Seed BOTH generations, as after any prior rollover. On Windows a - // bare rename onto the existing `.1` fails, which used to kill every - // subsequent append. - std::fs::write(&path, "current-full\n").expect("seed current"); - std::fs::write(&rotated, "old-generation\n").expect("seed rotated"); - - append_line_rotating(&path, "fresh", 8).expect("rotation over existing .1 must succeed"); - - assert_eq!(std::fs::read_to_string(&path).expect("read"), "fresh\n"); - assert_eq!( - std::fs::read_to_string(&rotated).expect("read rotated"), - "current-full\n" - ); - std::fs::remove_dir_all(&dir).ok(); - } - - #[test] - fn a_failed_rotation_degrades_to_an_unrotated_append() { - let dir = std::env::temp_dir().join(format!( - "perf-log-degrade-{}-{:?}", - std::process::id(), - std::thread::current().id() - )); - std::fs::create_dir_all(&dir).expect("tempdir"); - let path = dir.join("switch-perf.jsonl"); - let rotated = dir.join("switch-perf.jsonl.1"); - std::fs::write(&path, "oversized-live\n").expect("seed live"); - // A non-empty DIRECTORY at the rotated path defeats remove_file and - // rename on every platform — including for root, where permission - // tricks no-op (containers often run tests as uid 0). It stands in - // for a transient AV/EDR hold: the append must degrade to the - // unrotated file, not drop records until the lock clears. - std::fs::create_dir_all(rotated.join("hold")).expect("seed blocker"); - - append_line_rotating(&path, "must-survive", 8) - .expect("append must survive a failed rotation"); - - assert_eq!( - std::fs::read_to_string(&path).expect("read live"), - "oversized-live\nmust-survive\n" - ); - assert!(rotated.join("hold").exists(), "blocker untouched"); - std::fs::remove_dir_all(&dir).ok(); - } - - #[test] - fn concurrent_boundary_appends_lose_no_line_and_rotate_once() { - let dir = std::env::temp_dir().join(format!( - "perf-log-concurrent-{}-{:?}", - std::process::id(), - std::thread::current().id() - )); - std::fs::create_dir_all(&dir).expect("tempdir"); - let path = dir.join("switch-perf.jsonl"); - let _ = std::fs::remove_file(&path); - let _ = std::fs::remove_file(dir.join("switch-perf.jsonl.1")); - - // 8 writers × 4 lines of 18 bytes on disk (17 chars + newline) = 576 - // bytes against a 384-byte cap: rotation triggers before the 23rd - // append (22 lines = 396 bytes ≥ 384) and the ≤10 lines that follow - // (≤180 bytes) cannot re-trigger it, so the boundary is crossed - // exactly once and every line must land in either the live file or - // the single rotated generation. Unserialized metadata→rename→append - // interleavings drop lines or fail renames. - let threads: Vec<_> = (0..8) - .map(|writer| { - let path = path.clone(); - std::thread::spawn(move || { - for line_index in 0..4 { - append_line_rotating( - &path, - &format!("writer-{writer:02}-line-{line_index:02}"), - 384, - ) - .expect("append"); - } - }) - }) - .collect(); - for thread in threads { - thread.join().expect("join"); - } - - let mut lines: Vec = std::fs::read_to_string(&path) - .expect("read live") - .lines() - .map(str::to_string) - .collect(); - // Unconditional: if rotation never fired under contention, the size - // cap is inoperative and this test must fail, not silently pass with - // all 32 lines in the live file. - let rotated = std::fs::read_to_string(dir.join("switch-perf.jsonl.1")) - .expect("rotation must have occurred under contention"); - lines.extend(rotated.lines().map(str::to_string)); - lines.sort(); - let expected: Vec = (0..8) - .flat_map(|writer| { - (0..4).map(move |line_index| format!("writer-{writer:02}-line-{line_index:02}")) - }) - .collect(); - assert_eq!(lines, expected, "every append must survive the boundary"); - std::fs::remove_dir_all(&dir).ok(); - } -} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index ac13a22c847..613040b8095 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -591,7 +591,6 @@ pub fn run() { search_users, get_presence, get_os_idle_seconds, - append_switch_perf_log, get_default_relay_url, auto_connect_default_relay_enabled, get_legacy_workspace_storage, diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 777fe882f0a..468435e15ec 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -13,7 +13,6 @@ import { TerminalContextOverrideProvider, } from "@/app/TerminalContextOverrideContext"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; -import { captureSwitchTraceAnchor } from "@/shared/lib/channelSwitchPerf"; import { useBackForwardControls } from "@/app/navigation/useBackForwardControls"; import { useCommunityNavigationTransitions } from "@/app/useCommunityNavigationTransitions"; import { useLiveHomeFeedActions } from "@/app/useLiveHomeFeedActions"; @@ -866,16 +865,11 @@ export function AppShell() { onMarkChannelUnread={markChannelUnread} onBrowseChannels={handleOpenBrowseChannels} onOpenDm={async ({ pubkeys }) => { - // Anchor before awaiting open_dm; see - // captureSwitchTraceAnchor. - const traceStartedAt = captureSwitchTraceAnchor(); const directMessage = await openDmMutation.mutateAsync({ pubkeys, }); - await goChannel(directMessage.id, { - traceStartedAt, - }); + await goChannel(directMessage.id); }} onSelectAgents={() => void goAgents()} onSelectChannel={handleSidebarChannelSelect} diff --git a/desktop/src/app/navigation/commitGuardedNavigation.test.mjs b/desktop/src/app/navigation/commitGuardedNavigation.test.mjs index 5895b9debc9..0b836ec20b2 100644 --- a/desktop/src/app/navigation/commitGuardedNavigation.test.mjs +++ b/desktop/src/app/navigation/commitGuardedNavigation.test.mjs @@ -268,89 +268,3 @@ test("a same-destination navigation carrying router state still commits", async assert.equal(committed, true); assert.deepEqual(order, ["guard", "navigate"]); }); - -test("a rejected navigate cancels the trace it opened", async () => { - await withTraceHarness(async ({ flush, measures }) => { - // Router/loader rejection: no destination committed, so the trace must - // not survive for a later untraced re-entry to settle with the failed - // attempt plus everything in between. - await assert.rejects( - commitGuardedNavigation({ - currentHref: "/channels/aaaa", - nextHref: "/channels/bbbb", - guardedTarget: route("/channels/bbbb"), - traceChannelId: "bbbb", - navigate: async () => { - throw new Error("loader failed"); - }, - }), - /loader failed/, - ); - settleChannelSwitchTrace("bbbb"); - flush(); - assert.deepEqual(measures(), []); - }); -}); - -test("a rejected navigate never cancels a newer same-channel trace", async () => { - await withTraceHarness(async ({ flush, measures }) => { - // Cancel by identity, not by channel: the retry's trace is a different - // object and must keep its measurement when the first attempt rejects. - let releaseFirst; - const firstGate = new Promise((resolve) => { - releaseFirst = resolve; - }); - const failing = assert.rejects( - commitGuardedNavigation({ - currentHref: "/channels/aaaa", - nextHref: "/channels/bbbb", - guardedTarget: route("/channels/bbbb"), - traceChannelId: "bbbb", - navigate: async () => { - await firstGate; - throw new Error("loader failed"); - }, - }), - /loader failed/, - ); - // Retry lands while the first attempt is still in flight. - await commitGuardedNavigation({ - currentHref: "/channels/aaaa", - nextHref: "/channels/bbbb", - guardedTarget: route("/channels/bbbb"), - force: true, - traceChannelId: "bbbb", - navigate: async () => {}, - }); - releaseFirst(); - await failing; - settleChannelSwitchTrace("bbbb"); - flush(); - assert.deepEqual(measures(), ["bbbb"]); - }); -}); - -test("a DM caller's click anchor is carried into the trace", async () => { - await withTraceHarness(async () => { - const anchors = []; - await commitGuardedNavigation( - { - currentHref: "/", - nextHref: "/channels/bbbb", - guardedTarget: route("/channels/bbbb"), - traceChannelId: "bbbb", - traceStartedAt: 1234, - navigate: async () => {}, - }, - { - allow: () => true, - beginTrace: (channelId, anchoredAt) => { - anchors.push([channelId, anchoredAt]); - return null; - }, - }, - ); - // Without this the open_dm round-trip would sit outside the measurement. - assert.deepEqual(anchors, [["bbbb", 1234]]); - }); -}); diff --git a/desktop/src/app/navigation/commitGuardedNavigation.ts b/desktop/src/app/navigation/commitGuardedNavigation.ts index 2565ed1efa5..449442f5c94 100644 --- a/desktop/src/app/navigation/commitGuardedNavigation.ts +++ b/desktop/src/app/navigation/commitGuardedNavigation.ts @@ -4,7 +4,6 @@ import { } from "@/app/navigation/navigationGuard"; import { beginChannelSwitchTrace, - cancelChannelSwitchTrace, dropActiveChannelSwitchTrace, } from "@/shared/lib/channelSwitchPerf"; @@ -19,12 +18,8 @@ import { * navigation (deliberately untraced) would settle with the refused click's * inflated wall time. When `leavesChannelSurface` is set, any active trace is * dropped instead: the trace may be live with no channel screen mounted - * (route still resolving), so this is the only reliable exit hook. A - * `navigate()` rejection cancels the trace this call opened — by identity, so - * a newer same-channel attempt is never erased — because no destination - * committed and an untraced re-entry would otherwise settle the failed - * attempt. Returns whether the navigation was performed. `deps` exists for - * unit tests. + * (route still resolving), so this is the only reliable exit hook. Returns + * whether the navigation was performed. `deps` exists for unit tests. */ export async function commitGuardedNavigation( input: { @@ -35,20 +30,16 @@ export async function commitGuardedNavigation( hasStateUpdate?: boolean; leavesChannelSurface?: boolean; traceChannelId?: string; - /** Click-time anchor for callers that await before `goChannel`. */ - traceStartedAt?: number; navigate: () => Promise; }, deps: { allow?: typeof allowNavigation; beginTrace?: typeof beginChannelSwitchTrace; - cancelTrace?: typeof cancelChannelSwitchTrace; dropActiveTrace?: typeof dropActiveChannelSwitchTrace; } = {}, ): Promise { const allow = deps.allow ?? allowNavigation; const beginTrace = deps.beginTrace ?? beginChannelSwitchTrace; - const cancelTrace = deps.cancelTrace ?? cancelChannelSwitchTrace; const dropActiveTrace = deps.dropActiveTrace ?? dropActiveChannelSwitchTrace; if ( input.currentHref === input.nextHref && @@ -63,15 +54,9 @@ export async function commitGuardedNavigation( if (input.leavesChannelSurface) { dropActiveTrace(); } - let handle = null; if (input.traceChannelId !== undefined) { - handle = beginTrace(input.traceChannelId, input.traceStartedAt) ?? null; - } - try { - await input.navigate(); - } catch (error) { - cancelTrace(handle); - throw error; + beginTrace(input.traceChannelId); } + await input.navigate(); return true; } diff --git a/desktop/src/app/navigation/useAppNavigation.ts b/desktop/src/app/navigation/useAppNavigation.ts index 2f08b25925c..c963bc25e60 100644 --- a/desktop/src/app/navigation/useAppNavigation.ts +++ b/desktop/src/app/navigation/useAppNavigation.ts @@ -42,7 +42,6 @@ export function useAppNavigation() { behavior: NavigationBehavior = {}, guardedTarget?: GuardedNavigation, traceChannelId?: string, - traceStartedAt?: number, ) => { const nextLocation = router.buildLocation(next as never); return commitGuardedNavigation({ @@ -67,7 +66,6 @@ export function useAppNavigation() { } as never), nextHref: nextLocation.href, traceChannelId, - traceStartedAt, }); }, [location.href, navigate, router], @@ -283,13 +281,6 @@ export function useAppNavigation() { preserveSearchHighlight?: boolean; searchHighlight?: SearchHighlightNavigation; replace?: boolean; - /** - * Click-time anchor from `captureSwitchTraceAnchor()`, for callers - * that must await before they know the channel id (DM actions await - * `open_dm`). Without it the trace would start after that relay - * round-trip and exclude felt click latency. - */ - traceStartedAt?: number; /** Open this thread panel directly without waiting for a timeline row. */ thread?: string; threadRootId?: string | null; @@ -334,20 +325,16 @@ export function useAppNavigation() { threadRootId: options.threadRootId ?? null, } : undefined, - // goChannel is the click-time anchor for the switch trace; it opens - // inside commitGuardedNavigation only after the navigation guard - // accepts. Coverage: sidebar, search, notification, and DM-open - // navigations all funnel through here; DM callers pass traceStartedAt - // so the open_dm round-trip stays inside the measurement. History - // back/forward is deliberately untraced. Navigations that stay on the - // already-active channel (exact re-click only rewrites router state; - // jump-to-message/autoSend/force change only search params) never - // re-run the channel's settle effects, so a trace could only time out - // — also untraced. + // goChannel is the anchor for the switch trace; it opens inside + // commitGuardedNavigation only after the navigation guard accepts. + // Callers that await before navigating (DM actions await open_dm) + // are measured from the navigation, not from their click — see the + // scope note in channelSwitchPerf.ts. History back/forward is + // untraced, and navigations that stay on the already-active channel + // never re-run the settle effects, so a trace could only time out. location.pathname.endsWith(`/channels/${channelId}`) ? undefined : channelId, - options?.traceStartedAt, ); }, [commitNavigation, location.pathname], diff --git a/desktop/src/features/agents/ui/AgentsScreen.tsx b/desktop/src/features/agents/ui/AgentsScreen.tsx index 5c37d18e5cc..361199c50d8 100644 --- a/desktop/src/features/agents/ui/AgentsScreen.tsx +++ b/desktop/src/features/agents/ui/AgentsScreen.tsx @@ -1,7 +1,6 @@ import * as React from "react"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; -import { captureSwitchTraceAnchor } from "@/shared/lib/channelSwitchPerf"; import { usePersonasQuery } from "@/features/agents/hooks"; import { useOpenDmMutation } from "@/features/channels/hooks"; import { @@ -111,10 +110,8 @@ export function AgentsScreen() { const handleOpenDm = React.useCallback( async (pubkeys: string[]) => { - // Anchor before awaiting open_dm; see captureSwitchTraceAnchor. - const traceStartedAt = captureSwitchTraceAnchor(); const dm = await openDmMutation.mutateAsync({ pubkeys }); - await goChannel(dm.id, { traceStartedAt }); + await goChannel(dm.id); }, [goChannel, openDmMutation], ); diff --git a/desktop/src/features/channels/hooks.ts b/desktop/src/features/channels/hooks.ts index beef0614a65..9069b052da4 100644 --- a/desktop/src/features/channels/hooks.ts +++ b/desktop/src/features/channels/hooks.ts @@ -42,10 +42,6 @@ import type { } from "@/shared/api/tauriChannels"; import { mergeConcurrentChannelRecency } from "@/features/channels/lib/channelRecencyMerge"; import { useIdentityQuery } from "@/shared/api/hooks"; -import { - openChannelMembersFetch, - traceChannelMembersFetch, -} from "@/shared/lib/channelSwitchPerf"; import { useFocusedRefetchInterval } from "@/shared/lib/useDocumentVisible"; import { useCommunities } from "@/features/communities/useCommunities"; import { @@ -53,10 +49,6 @@ import { type ChannelSnapshot, writeChannelSnapshot, } from "@/features/channels/channelSnapshot"; -import { - markSnapshotDiagnostic, - measureFullSidebarPaint, -} from "@/features/channels/sidebarPerf"; import { CHANNEL_MEMBERS_STALE_TIME_MS, channelMembersQueryKey, @@ -105,6 +97,73 @@ export function sortChannels(channels: Channel[]) { }); } +export const CHANNELS_SNAPSHOT_DIAGNOSTIC_MARK = + "buzz:sidebar:snapshot-diagnostic"; +export const CHANNELS_FULL_SIDEBAR_PAINT_MARK = + "buzz:sidebar:full-list-painted"; +export const CHANNELS_BOOT_TO_FULL_SIDEBAR_MEASURE = + "buzz:sidebar:boot-to-full-list-painted"; + +const markedSnapshotKeys = new Set(); +const measuredSidebarKeys = new Set(); +const scheduledSidebarKeys = new Set(); + +function sidebarMeasurementKey(relayUrl: string, ownerPubkey: string): string { + return `${relayUrl}\u0000${ownerPubkey.toLowerCase()}`; +} + +function markSnapshotDiagnostic( + relayUrl: string, + ownerPubkey: string, + diagnostics: ReturnType["diagnostics"], +): void { + if (typeof performance === "undefined") return; + const key = sidebarMeasurementKey(relayUrl, ownerPubkey); + if (markedSnapshotKeys.has(key)) return; + markedSnapshotKeys.add(key); + performance.mark(CHANNELS_SNAPSHOT_DIAGNOSTIC_MARK, { + detail: { ...diagnostics, relayUrl }, + }); + console.info("[sidebar-perf] snapshot", { ...diagnostics, relayUrl }); +} + +function measureFullSidebarPaint( + relayUrl: string, + ownerPubkey: string, + channelCount: number, +): void { + if (typeof performance === "undefined") return; + const key = sidebarMeasurementKey(relayUrl, ownerPubkey); + if (measuredSidebarKeys.has(key) || scheduledSidebarKeys.has(key)) return; + scheduledSidebarKeys.add(key); + + // The channels have committed to the shared query cache; two animation frames + // put the mark after React's sidebar DOM commit and the browser's next paint. + window.requestAnimationFrame(() => { + window.requestAnimationFrame(() => { + scheduledSidebarKeys.delete(key); + if (measuredSidebarKeys.has(key)) return; + measuredSidebarKeys.add(key); + performance.mark(CHANNELS_FULL_SIDEBAR_PAINT_MARK, { + detail: { channelCount, relayUrl }, + }); + performance.measure(CHANNELS_BOOT_TO_FULL_SIDEBAR_MEASURE, { + detail: { channelCount, relayUrl }, + duration: performance.now(), + start: 0, + }); + const measure = performance + .getEntriesByName(CHANNELS_BOOT_TO_FULL_SIDEBAR_MEASURE) + .at(-1); + console.info("[sidebar-perf] full list painted", { + channelCount, + durationMs: measure?.duration, + relayUrl, + }); + }); + }); +} + export type CachedChannelMember = { membershipAdded: boolean; name: string; @@ -568,22 +627,7 @@ export function useChannelMembersQuery( throw new Error("No channel selected."); } - // Supersession token, NOT the query's AbortSignal: reading the signal - // getter flips React Query to cancel-and-revert when the last observer - // unsubscribes mid-fetch, which would discard warm rosters on - // interrupted switches. The token keeps stale fetches (replaced by a - // live join/leave invalidation) out of the trace's one-shot slot. - const fetchAttempt = openChannelMembersFetch(channelId); - const fetchStartedAt = performance.now(); - const members = await getChannelMembers(channelId); - traceChannelMembersFetch( - channelId, - members.length, - performance.now() - fetchStartedAt, - fetchStartedAt, - fetchAttempt, - ); - return members; + return getChannelMembers(channelId); }, staleTime: CHANNEL_MEMBERS_STALE_TIME_MS, }); diff --git a/desktop/src/features/channels/sidebarPerf.ts b/desktop/src/features/channels/sidebarPerf.ts deleted file mode 100644 index e2904fcd096..00000000000 --- a/desktop/src/features/channels/sidebarPerf.ts +++ /dev/null @@ -1,74 +0,0 @@ -/** - * Sidebar boot-paint measurement: marks the persisted-snapshot read and the - * first fully-painted channel list per relay+identity. Split from hooks.ts to - * keep that file under the per-file line cap; behavior unchanged. - */ - -import type { inspectChannelSnapshot } from "@/features/channels/channelSnapshot"; - -export const CHANNELS_SNAPSHOT_DIAGNOSTIC_MARK = - "buzz:sidebar:snapshot-diagnostic"; -export const CHANNELS_FULL_SIDEBAR_PAINT_MARK = - "buzz:sidebar:full-list-painted"; -export const CHANNELS_BOOT_TO_FULL_SIDEBAR_MEASURE = - "buzz:sidebar:boot-to-full-list-painted"; - -const markedSnapshotKeys = new Set(); -const measuredSidebarKeys = new Set(); -const scheduledSidebarKeys = new Set(); - -function sidebarMeasurementKey(relayUrl: string, ownerPubkey: string): string { - return `${relayUrl}\u0000${ownerPubkey.toLowerCase()}`; -} - -export function markSnapshotDiagnostic( - relayUrl: string, - ownerPubkey: string, - diagnostics: ReturnType["diagnostics"], -): void { - if (typeof performance === "undefined") return; - const key = sidebarMeasurementKey(relayUrl, ownerPubkey); - if (markedSnapshotKeys.has(key)) return; - markedSnapshotKeys.add(key); - performance.mark(CHANNELS_SNAPSHOT_DIAGNOSTIC_MARK, { - detail: { ...diagnostics, relayUrl }, - }); - console.info("[sidebar-perf] snapshot", { ...diagnostics, relayUrl }); -} - -export function measureFullSidebarPaint( - relayUrl: string, - ownerPubkey: string, - channelCount: number, -): void { - if (typeof performance === "undefined") return; - const key = sidebarMeasurementKey(relayUrl, ownerPubkey); - if (measuredSidebarKeys.has(key) || scheduledSidebarKeys.has(key)) return; - scheduledSidebarKeys.add(key); - - // The channels have committed to the shared query cache; two animation frames - // put the mark after React's sidebar DOM commit and the browser's next paint. - window.requestAnimationFrame(() => { - window.requestAnimationFrame(() => { - scheduledSidebarKeys.delete(key); - if (measuredSidebarKeys.has(key)) return; - measuredSidebarKeys.add(key); - performance.mark(CHANNELS_FULL_SIDEBAR_PAINT_MARK, { - detail: { channelCount, relayUrl }, - }); - performance.measure(CHANNELS_BOOT_TO_FULL_SIDEBAR_MEASURE, { - detail: { channelCount, relayUrl }, - duration: performance.now(), - start: 0, - }); - const measure = performance - .getEntriesByName(CHANNELS_BOOT_TO_FULL_SIDEBAR_MEASURE) - .at(-1); - console.info("[sidebar-perf] full list painted", { - channelCount, - durationMs: measure?.duration, - relayUrl, - }); - }); - }); -} diff --git a/desktop/src/features/channels/ui/useChannelProfilePanel.ts b/desktop/src/features/channels/ui/useChannelProfilePanel.ts index 5631926fe61..1a35666478a 100644 --- a/desktop/src/features/channels/ui/useChannelProfilePanel.ts +++ b/desktop/src/features/channels/ui/useChannelProfilePanel.ts @@ -1,7 +1,6 @@ import * as React from "react"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; -import { captureSwitchTraceAnchor } from "@/shared/lib/channelSwitchPerf"; import { useOpenDmMutation } from "@/features/channels/hooks"; import type { ProfilePanelOpenOptions } from "@/shared/context/ProfilePanelContext"; @@ -61,10 +60,8 @@ export function useChannelProfilePanel({ const openDmMutateAsync = openDmMutation.mutateAsync; const handleOpenDm = React.useCallback( async (pubkeys: string[]) => { - // Anchor before awaiting open_dm; see captureSwitchTraceAnchor. - const traceStartedAt = captureSwitchTraceAnchor(); const dm = await openDmMutateAsync({ pubkeys }); - await goChannel(dm.id, { traceStartedAt }); + await goChannel(dm.id); }, [goChannel, openDmMutateAsync], ); diff --git a/desktop/src/features/channels/useChannelSwitchTraceMarks.test.mjs b/desktop/src/features/channels/useChannelSwitchTraceMarks.test.mjs index c5f3e95b2f1..c329bd2b921 100644 --- a/desktop/src/features/channels/useChannelSwitchTraceMarks.test.mjs +++ b/desktop/src/features/channels/useChannelSwitchTraceMarks.test.mjs @@ -101,26 +101,6 @@ it("a trace survives StrictMode's effect replay and still settles", async () => dom.window.close(); }); -it("a real route exit still abandons: a history-back settle records nothing", async () => { - const { dom, flushFrames } = setupDom(); - performance.clearMeasures?.(CHANNEL_SWITCH_MEASURE); - beginChannelSwitchTrace("chan-exit"); - const root = createRoot(document.getElementById("root")); - await renderHarness(root, { - channelId: "chan-exit", - isTimelineLoading: true, - }); - // Leaving the channel surface unmounts the hook; with no re-setup to - // cancel it, the scheduled abandon must fire. - await act(async () => root.unmount()); - await new Promise((resolve) => setImmediate(resolve)); - // History-back re-enters without goChannel; its settle must find no trace. - settleChannelSwitchTrace("chan-exit"); - flushFrames(); - assert.deepEqual(measures(), []); - dom.window.close(); -}); - it("an A→B switch's deferred abandon of A never kills B's trace", async () => { const { dom, flushFrames } = setupDom(); performance.clearMeasures?.(CHANNEL_SWITCH_MEASURE); diff --git a/desktop/src/features/channels/useChannelSwitchTraceMarks.ts b/desktop/src/features/channels/useChannelSwitchTraceMarks.ts index dece8ab0aae..bef5d3d6145 100644 --- a/desktop/src/features/channels/useChannelSwitchTraceMarks.ts +++ b/desktop/src/features/channels/useChannelSwitchTraceMarks.ts @@ -2,9 +2,7 @@ import * as React from "react"; import { abandonChannelSwitchTrace, - cancelRouteExitAbandon, markChannelSwitchRouteCommit, - scheduleRouteExitAbandon, settleChannelSwitchTrace, } from "@/shared/lib/channelSwitchPerf"; import type { ChannelType } from "@/shared/api/types"; @@ -32,24 +30,6 @@ export function useChannelSwitchTraceMarks({ React.useLayoutEffect(() => { if (activeChannelId) markChannelSwitchRouteCommit(activeChannelId); }, [activeChannelId]); - // Route-exit cancellation: leaving the channel surface before the trace - // settles (Projects, Home, … — none of which call goChannel) must drop the - // trace. Otherwise a history-back into the same channel within the trace - // timeout matches the stale singleton and records the time spent away as - // switch latency. Keyed per channel id: on an A→B switch this cleanup runs - // with A's id after B's trace already began, so it only ever abandons its - // own channel's trace. The abandon is scheduled (one microtask) rather - // than immediate so StrictMode's dev-only effect replay — whose re-setup - // runs synchronously right after this cleanup — cancels it instead of - // killing the just-opened trace. - React.useEffect(() => { - if (!activeChannelId) return; - const channelId = activeChannelId; - cancelRouteExitAbandon(channelId); - return () => { - scheduleRouteExitAbandon(channelId); - }; - }, [activeChannelId]); React.useEffect(() => { if (!activeChannelId) return; if (activeChannelType === "forum") { diff --git a/desktop/src/features/home/ui/HomeView.tsx b/desktop/src/features/home/ui/HomeView.tsx index 6055ea5b65b..893b3c309c6 100644 --- a/desktop/src/features/home/ui/HomeView.tsx +++ b/desktop/src/features/home/ui/HomeView.tsx @@ -3,7 +3,6 @@ import { RefreshCcw } from "lucide-react"; import { useAppShell } from "@/app/AppShellContext"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; -import { captureSwitchTraceAnchor } from "@/shared/lib/channelSwitchPerf"; import { useKnownAgentPubkeys } from "@/features/agents/useKnownAgentPubkeys"; import { useChannelsQuery, useOpenDmMutation } from "@/features/channels/hooks"; import { RightAuxiliaryPane } from "@/features/channels/ui/RightAuxiliaryPane"; @@ -222,10 +221,8 @@ export function HomeView({ const [isSendingReply, setIsSendingReply] = React.useState(false); const handleOpenDm = React.useCallback( async (pubkeys: string[]) => { - // Anchor before awaiting open_dm; see captureSwitchTraceAnchor. - const traceStartedAt = captureSwitchTraceAnchor(); const dm = await openDm({ pubkeys }); - await goChannel(dm.id, { traceStartedAt }); + await goChannel(dm.id); }, [goChannel, openDm], ); diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index c8e9031b2ac..8222dcdd223 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -144,22 +144,12 @@ type TimelineSnapshot = { * finally land (the pass-1 tear, ledgered 2026-07-11). */ historyExhausted: boolean; - /** - * Monotonic id of this snapshot. Rendered as `data-timeline-commit` from - * the DEFERRED snapshot, so the switch tracer can tell "the commit I was - * waiting for has painted" from "new live traffic re-latched the pending - * marker after my rows painted" — the two are indistinguishable from - * `data-render-pending` alone, and the second one used to inflate the - * recorded switch by the whole burst. - */ - generation: number; }; const EMPTY_TIMELINE_SNAPSHOT: TimelineSnapshot = { channelId: null, messages: EMPTY_MESSAGES, historyExhausted: false, - generation: 0, }; const MessageTimelineBase = React.forwardRef< @@ -256,18 +246,10 @@ const MessageTimelineBase = React.forwardRef< // Channel id travels with the deferred message snapshot. Without that guard, a // route change can paint the previous channel's deferred rows for a frame even // though the sidebar/header already moved to the new channel. - const snapshotGenerationRef = React.useRef(0); - const liveSnapshot = React.useMemo(() => { - // Monotonic only — StrictMode's double-invoke may skip a number, which - // the tracer's `>` comparison tolerates. - snapshotGenerationRef.current += 1; - return { - channelId: channelId ?? null, - messages, - historyExhausted, - generation: snapshotGenerationRef.current, - }; - }, [channelId, historyExhausted, messages]); + const liveSnapshot = React.useMemo( + () => ({ channelId: channelId ?? null, messages, historyExhausted }), + [channelId, historyExhausted, messages], + ); const deferredSnapshot = React.useDeferredValue( liveSnapshot, EMPTY_TIMELINE_SNAPSHOT, @@ -719,7 +701,6 @@ const MessageTimelineBase = React.forwardRef<
{showUnreadPill ? (
{ const dm = await openDm({ pubkeys: [targetPubkey] }); - await goChannel(dm.id, { traceStartedAt }); + await goChannel(dm.id); if (isMountedRef.current) { onClose(); } @@ -170,10 +166,9 @@ export function useProfileInteractionActions({ return; } - const traceStartedAt = captureSwitchTraceAnchor(); void runAction("huddle", async (targetPubkey) => { const dm = await openDm({ pubkeys: [targetPubkey] }); - await goChannel(dm.id, { traceStartedAt }); + await goChannel(dm.id); await startHuddle(dm.id, isBot ? [targetPubkey] : []); await queryClient.invalidateQueries({ queryKey: channelsQueryKey }); if (isMountedRef.current) { @@ -197,7 +192,6 @@ export function useProfileInteractionActions({ return; } - const traceStartedAt = captureSwitchTraceAnchor(); void runAction("wave", async (targetPubkey) => { const identity = identityQuery.data; if (!identity) { @@ -233,7 +227,7 @@ export function useProfileInteractionActions({ ); try { - await goChannel(dm.id, { traceStartedAt }); + await goChannel(dm.id); if (isMountedRef.current) { onClose(); } diff --git a/desktop/src/features/projects/ui/useProjectProfilePanel.ts b/desktop/src/features/projects/ui/useProjectProfilePanel.ts index 16fd27609cd..7c984103eda 100644 --- a/desktop/src/features/projects/ui/useProjectProfilePanel.ts +++ b/desktop/src/features/projects/ui/useProjectProfilePanel.ts @@ -1,7 +1,6 @@ import * as React from "react"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; -import { captureSwitchTraceAnchor } from "@/shared/lib/channelSwitchPerf"; import { useOpenDmMutation } from "@/features/channels/hooks"; import type { ProfilePanelTab, @@ -52,10 +51,8 @@ export function useProjectProfilePanel() { ), handleOpenDm: React.useCallback( async (pubkeys: string[]) => { - // Anchor before awaiting open_dm; see captureSwitchTraceAnchor. - const traceStartedAt = captureSwitchTraceAnchor(); const dm = await openDmMutation.mutateAsync({ pubkeys }); - await goChannel(dm.id, { traceStartedAt }); + await goChannel(dm.id); }, [goChannel, openDmMutation], ), diff --git a/desktop/src/features/pulse/lib/useNoteActions.ts b/desktop/src/features/pulse/lib/useNoteActions.ts index aaac391190e..48f27a18ffc 100644 --- a/desktop/src/features/pulse/lib/useNoteActions.ts +++ b/desktop/src/features/pulse/lib/useNoteActions.ts @@ -1,6 +1,5 @@ import * as React from "react"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; -import { captureSwitchTraceAnchor } from "@/shared/lib/channelSwitchPerf"; import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; @@ -156,14 +155,12 @@ export function usePulseNoteActions({ const startDm = React.useCallback( async (pubkey: string) => { // goChannel, not raw navigate: this is a first-class channel entry and - // must produce a switch measurement like every other one. Anchor before - // awaiting open_dm; see captureSwitchTraceAnchor. - const traceStartedAt = captureSwitchTraceAnchor(); + // must go through the same navigation guard as every other one. try { const directMessage = await openDmMutation.mutateAsync({ pubkeys: [pubkey], }); - await goChannel(directMessage.id, { traceStartedAt }); + await goChannel(directMessage.id); } catch (error) { toast.error( error instanceof Error ? error.message : "Failed to open DM", diff --git a/desktop/src/features/pulse/ui/PulseScreen.tsx b/desktop/src/features/pulse/ui/PulseScreen.tsx index 297b2f5cf96..882601bcded 100644 --- a/desktop/src/features/pulse/ui/PulseScreen.tsx +++ b/desktop/src/features/pulse/ui/PulseScreen.tsx @@ -1,7 +1,6 @@ import * as React from "react"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; -import { captureSwitchTraceAnchor } from "@/shared/lib/channelSwitchPerf"; import { useOpenDmMutation } from "@/features/channels/hooks"; import { type ProfilePanelTab, @@ -54,10 +53,8 @@ export function PulseScreen() { const { goChannel } = useAppNavigation(); const handleOpenDm = React.useCallback( async (pubkeys: string[]) => { - // Anchor before awaiting open_dm; see captureSwitchTraceAnchor. - const traceStartedAt = captureSwitchTraceAnchor(); const dm = await openDmMutation.mutateAsync({ pubkeys }); - await goChannel(dm.id, { traceStartedAt }); + await goChannel(dm.id); }, [goChannel, openDmMutation], ); diff --git a/desktop/src/shared/lib/channelSwitchPerf.test.mjs b/desktop/src/shared/lib/channelSwitchPerf.test.mjs index 03bfc7a82a1..fa8ba254edc 100644 --- a/desktop/src/shared/lib/channelSwitchPerf.test.mjs +++ b/desktop/src/shared/lib/channelSwitchPerf.test.mjs @@ -2,205 +2,90 @@ import assert from "node:assert/strict"; import test from "node:test"; import { - shouldAttributeFetch, - buildSwitchPerfLogRecord, - resolveSettleAction, CHANNEL_SWITCH_MEASURE, + CHANNEL_SWITCH_START_MARK, + abandonChannelSwitchTrace, beginChannelSwitchTrace, + dropActiveChannelSwitchTrace, + markChannelSwitchRouteCommit, resetChannelSwitchTrace, - resolveFinalFrame, - resolveSettleWait, - abandonChannelSwitchTrace, - resolveRenderReadiness, - scheduleRouteExitAbandon, - resolveTraceAnchor, + resolveSettleAction, settleChannelSwitchTrace, + shouldAttributeFetch, summarizeChannelSwitchTrace, + traceChannelWindowFetch, } from "./channelSwitchPerf.ts"; function trace(overrides = {}) { return { channelId: "abcdef1234567890", startedAt: 1_000, - // Liveness clock defaults to the anchor; DM entries back-date startedAt - // below openedAt, which is exactly why the two are separate fields. - openedAt: overrides.startedAt ?? 1_000, - maxFrameGapMs: 0, - settleEnteredAt: null, routeCommitAt: null, windowFetch: null, - membersFetch: null, + settleEnteredAt: null, ...overrides, }; } -test("summary reports total and cache-served fetches", () => { - const summary = summarizeChannelSwitchTrace(trace(), 1_412.4); - assert.equal( - summary, - "[switch-perf] channel=abcdef12 total=412ms commit=? window=cache members=cache", - ); -}); +// --- Pure helpers --------------------------------------------------------- -test("summary includes route commit offset and fetch timings", () => { - const summary = summarizeChannelSwitchTrace( - trace({ - routeCommitAt: 1_038, - windowFetch: { durationMs: 180.6, eventCount: 250 }, - membersFetch: { durationMs: 320.2, memberCount: 10_000 }, - }), - 1_912, - ); +test("summary reports total, commit offset and cache-served fetches", () => { assert.equal( - summary, - "[switch-perf] channel=abcdef12 total=912ms commit=+38ms " + - "window=250 events in 181ms members=10000 members in 320ms", + summarizeChannelSwitchTrace(trace({ routeCommitAt: 1_200 }), 1_412.4), + "[switch-perf] channel=abcdef12 total=412ms commit=+200ms window=cache", ); }); -test("log record carries rounded stage timings and fetch attributions", () => { - const record = buildSwitchPerfLogRecord( - trace({ - routeCommitAt: 1_038.4, - windowFetch: { durationMs: 180.6, eventCount: 250 }, - membersFetch: { durationMs: 320.2, memberCount: 10_000 }, - }), - 1_912.3, +test("summary reports an attributed fetch and the truncation flag", () => { + assert.equal( + summarizeChannelSwitchTrace( + trace({ windowFetch: { durationMs: 307.2, eventCount: 89 } }), + 1_739, + true, + ), + "[switch-perf] channel=abcdef12 total=739ms commit=? " + + "window=89 events in 307ms settle=truncated", ); - assert.equal(record.channelId, "abcdef1234567890"); - assert.equal(record.totalMs, 912); - assert.equal(record.commitOffsetMs, 38); - assert.deepEqual(record.windowFetch, { durationMs: 181, eventCount: 250 }); - assert.deepEqual(record.membersFetch, { - durationMs: 320, - memberCount: 10_000, - }); - assert.equal(typeof record.ts, "string"); }); -test("log record marks cache-served fetches and missing commit as null", () => { - const record = buildSwitchPerfLogRecord(trace(), 1_412); - assert.equal(record.commitOffsetMs, null); - assert.equal(record.windowFetch, null); - assert.equal(record.membersFetch, null); -}); - -test("settle resolves only the trace for the settled channel", () => { +test("a settle for another channel leaves the active trace alone", () => { + // A previous channel can finish loading after the next switch began; + // clobbering the newer trace would drop exactly the rapid switches worth + // capturing. const active = trace(); - assert.deepEqual(resolveSettleAction(active, "abcdef1234567890", 2_000), { - settledTrace: active, - clearActive: true, - }); - assert.deepEqual(resolveSettleAction(null, "abcdef1234567890", 2_000), { + assert.deepEqual(resolveSettleAction(active, "bbbb0000bbbb0000", 1_100), { settledTrace: null, - clearActive: false, + timedOut: false, }); -}); - -test("a mismatched settle never clobbers a newer switch's trace", () => { - // Channel A settles after the user already clicked channel B: B's trace - // must survive so B still gets measured. - const nextSwitch = trace({ channelId: "bbbb0000bbbb0000" }); - assert.deepEqual(resolveSettleAction(nextSwitch, "abcdef1234567890", 2_000), { + assert.deepEqual(resolveSettleAction(null, "abcdef1234567890", 1_100), { settledTrace: null, - clearActive: false, + timedOut: false, }); }); -test("settle drops a trace that has timed out", () => { +test("a settle past the timeout reports the trace as timed out", () => { const stale = trace({ startedAt: 1_000 }); assert.deepEqual(resolveSettleAction(stale, "abcdef1234567890", 31_001), { settledTrace: null, - clearActive: true, + timedOut: true, }); - assert.deepEqual( + assert.equal( resolveSettleAction(stale, "abcdef1234567890", 11_000).settledTrace, stale, ); }); -test("the settle wait records truncated — never as an honest settle — at deadline", () => { - // Still pending, before the deadline: keep waiting. - assert.equal(resolveSettleWait(4_999, 5_000, true, 0), "wait"); - // Render caught up: record cleanly. - assert.deepEqual(resolveSettleWait(1_000, 5_000, false, 0), { - settleWaitTruncated: false, - }); - // Deadline expired while still pending: the record must say so — a >5s - // switch reported as an ordinary settle would hide exactly the tail this - // tracer exists to expose. - assert.deepEqual(resolveSettleWait(5_000, 5_000, true, 0), { - settleWaitTruncated: true, - }); -}); - -test("a frame-starved trace is dropped, not recorded as a clean settle", () => { - // rAF suspends in hidden windows, so a queued settle can fire minutes - // after the click with renderPending long since false — the absence must - // not be charged to the switch. Nothing legitimate can be older than the - // 30s settle-entry timeout plus the 5s render wait. - assert.equal(resolveSettleWait(35_001, 40_000, false, 0), "drop"); - assert.equal(resolveSettleWait(35_001, 40_000, true, 0), "drop"); - // At the bound (a 29.9s settle plus a truncated 5s wait) records survive. - assert.deepEqual(resolveSettleWait(35_000, 34_900, true, 0), { - settleWaitTruncated: true, - }); -}); - -test("a not-pending frame landing past the wait deadline is starvation, not a settle", () => { - // Settle entered at t=1s (deadline 6s), render caught up, then frames - // stalled (system suspend, App Nap — no visibilitychange): the next frame - // lands at t=20s with nothing pending. The gap is starvation; recording - // it would fabricate a clean 20s switch well under the 35s age guard. - assert.equal(resolveSettleWait(20_000, 6_000, false, 0), "drop"); - // Still-pending arrivals past the deadline remain truncated records: the - // render genuinely wasn't done, which is the tail the tracer must keep. - assert.deepEqual(resolveSettleWait(6_001, 6_000, true, 0), { - settleWaitTruncated: true, - }); -}); - -test("a starved frame gap drops even while the render-pending marker is latched", () => { - // During a suspension React can't flush the deferred commit, so the - // pending marker stays latched — its truth is NOT evidence the render was - // slow. A single inter-frame gap beyond any plausible main-thread stall - // means the process was suspended; recording a truncated 22s "switch" - // would fabricate the very regression the tracer hunts. - assert.equal(resolveSettleWait(22_300, 5_300, true, 0, 22_000), "drop"); - // Heavy-but-real frames (multi-hundred-ms long tasks) still record. - assert.deepEqual(resolveSettleWait(5_400, 5_300, true, 0, 900), { - settleWaitTruncated: true, - }); - // The first frame has no predecessor: no gap to judge. - assert.deepEqual(resolveSettleWait(1_000, 5_300, false, 0, null), { - settleWaitTruncated: false, - }); -}); - -test("a truncated settle is flagged in the summary and the log record", () => { - const summary = summarizeChannelSwitchTrace(trace(), 1_412, true); - assert.ok(summary.endsWith(" settle=truncated"), summary); - const record = buildSwitchPerfLogRecord(trace(), 1_412, true); - assert.equal(record.settleWaitTruncated, true); - // Clean settles keep the field out of the line entirely. - assert.ok( - !("settleWaitTruncated" in buildSwitchPerfLogRecord(trace(), 1_412)), - ); -}); - -test("fetches attribute only when started after the switch began", () => { - const active = trace({ channelId: "abcdef1234567890", startedAt: 1_000 }); - // Started before the switch (stale A→B→A leg): not attributable. +test("fetches attribute only inside the measured interval", () => { + const active = trace({ startedAt: 1_000 }); + // Started before the switch (a stale A→B→A leg): not this switch's cost, + // and it would occupy the one-shot slot the real fetch needs. assert.equal(shouldAttributeFetch(active, "abcdef1234567890", 999), false); - // Started at/after the switch: attributable. assert.equal(shouldAttributeFetch(active, "abcdef1234567890", 1_000), true); - assert.equal(shouldAttributeFetch(active, "abcdef1234567890", 1_500), true); // Other channel or no trace: never. assert.equal(shouldAttributeFetch(active, "bbbb0000bbbb0000", 1_500), false); assert.equal(shouldAttributeFetch(null, "abcdef1234567890", 1_500), false); - // Started after the timeline settled: background revalidation the user - // never waited on. The trace is still active (it waits for the deferred - // paint), so without the upper bound this would be reported as switch cost. + // Started after the timeline settled: background revalidation the user never + // waited on. The trace is still active while it waits for the paint. const settling = trace({ startedAt: 1_000, settleEnteredAt: 2_000 }); assert.equal(shouldAttributeFetch(settling, "abcdef1234567890", 1_999), true); assert.equal( @@ -209,110 +94,69 @@ test("fetches attribute only when started after the switch began", () => { ); }); -test("a superseded members fetch never claims the trace's one-shot slot", async () => { - const { openChannelMembersFetch, traceChannelMembersFetch } = await import( - "./channelSwitchPerf.ts" - ); - await withSettleHarness(async ({ begin, settle, flush }) => { - begin("aaaa1111aaaa1111"); - const startedAt = performance.now(); - // Fetch #1 starts, then a live join/leave invalidation replaces it with - // fetch #2. #1 resolves first (the Tauri call can't be cancelled) but - // must not attribute: its roster is not the one rendered. The query's - // AbortSignal is deliberately not used for this — consuming it flips - // React Query to cancel-and-revert on last-observer unsubscribe, which - // discards warm rosters on interrupted switches. - const first = openChannelMembersFetch("aaaa1111aaaa1111"); - const second = openChannelMembersFetch("aaaa1111aaaa1111"); - traceChannelMembersFetch("aaaa1111aaaa1111", 9_999, 900, startedAt, first); - traceChannelMembersFetch( - "aaaa1111aaaa1111", - 10_002, - 120, - startedAt, - second, - ); - settle("aaaa1111aaaa1111"); - flush(); - const measure = performance - .getEntriesByName("buzz:channel-switch:click-to-settled") - .at(-1); - assert.equal(measure?.detail?.membersFetch?.memberCount, 10_002); - assert.equal(measure?.detail?.membersFetch?.durationMs, 120); - }); -}); +// --- Lifecycle ------------------------------------------------------------ -test("a suspension before the first settle frame drops, not records truncated", async () => { - await withSettleHarness(async ({ begin, settle, flush, measures }) => { - const virtualClock = { now: 0 }; - performance.now = () => virtualClock.now; - try { - // The deferred marker stays latched during a suspension, so its truth - // is not evidence of slow rendering — the settle-entry → first-frame - // window must be starvation-guarded like every later frame. - // Element-like: carries the pending marker but no committed timeline - // generation, so readiness stays gated on the marker alone. - globalThis.document.querySelector = () => ({ getAttribute: () => null }); - begin("aaaa1111aaaa1111"); - settle("aaaa1111aaaa1111"); - virtualClock.now = 20_000; - flush(); - assert.deepEqual(measures(), []); - } finally { - delete performance.now; - } - }); -}); - -// --- Settle lifecycle: rapid switches and community resets ---------------- - -async function withSettleHarness(run, documentOverrides = {}) { +/** + * Drives the real lifecycle with a manual frame queue, a virtual clock and a + * controllable pending marker. The clock is rebased above the real one so a + * visibilitychange fired by an earlier test cannot silently drop every trace + * at settle entry and make these assertions vacuous. + */ +function withHarness(run) { const frames = []; + const drops = []; const originalWindow = globalThis.window; const originalDocument = globalThis.document; + const originalNow = performance.now; + const originalInfo = console.info; + const base = originalNow.call(performance) + 1_000; + let clock = base; + let pending = false; + performance.now = () => clock; + console.info = (line) => { + if (typeof line === "string" && line.includes("dropped (")) { + drops.push(line.slice(line.indexOf("dropped (") + 9, -1)); + } + }; globalThis.window = { requestAnimationFrame: (cb) => frames.push(cb) && frames.length, cancelAnimationFrame: () => {}, }; globalThis.document = { addEventListener: () => {}, - querySelector: () => null, + querySelector: () => (pending ? {} : null), removeEventListener: () => {}, visibilityState: "visible", - ...documentOverrides, }; - const { - abandonChannelSwitchTrace, - beginChannelSwitchTrace, - cancelRouteExitAbandon, - scheduleRouteExitAbandon, - settleChannelSwitchTrace, - resetChannelSwitchTrace, - CHANNEL_SWITCH_MEASURE, - } = await import("./channelSwitchPerf.ts"); performance.clearMeasures?.(CHANNEL_SWITCH_MEASURE); - const flush = () => { - // Drain chained rAFs until quiescent. - for (let i = 0; i < 20 && frames.length > 0; i += 1) { - for (const cb of frames.splice(0, frames.length)) cb(); - } + const api = { + drops, + at: (offset) => { + clock = base + offset; + }, + setPending: (value) => { + pending = value; + }, + hide: () => { + globalThis.document.visibilityState = "hidden"; + }, + flush: (rounds = 40) => { + for (let i = 0; i < rounds && frames.length > 0; i += 1) { + for (const cb of frames.splice(0, frames.length)) cb(); + } + }, + measures: () => + performance + .getEntriesByName(CHANNEL_SWITCH_MEASURE) + .map((entry) => entry.detail?.channelId), + lastMeasure: () => + performance.getEntriesByName(CHANNEL_SWITCH_MEASURE).at(-1), }; - const measures = () => - performance - .getEntriesByName(CHANNEL_SWITCH_MEASURE) - .map((entry) => entry.detail?.channelId); try { - await run({ - abandon: abandonChannelSwitchTrace, - begin: beginChannelSwitchTrace, - cancelAbandon: cancelRouteExitAbandon, - scheduleAbandon: scheduleRouteExitAbandon, - settle: settleChannelSwitchTrace, - reset: resetChannelSwitchTrace, - flush, - measures, - }); + run(api); } finally { + performance.now = originalNow; + console.info = originalInfo; resetChannelSwitchTrace(); performance.clearMeasures?.(CHANNEL_SWITCH_MEASURE); if (originalWindow === undefined) delete globalThis.window; @@ -322,497 +166,159 @@ async function withSettleHarness(run, documentOverrides = {}) { } } -test("a switch begun during A's deferred wait drops A's record (no clock theft)", async () => { - await withSettleHarness(async ({ begin, settle, flush, measures }) => { - begin("aaaa1111aaaa1111"); - settle("aaaa1111aaaa1111"); // A's deferred-paint wait is now queued - begin("bbbb2222bbbb2222"); // rapid follow-up switch replaces the trace - flush(); - // A must NOT be recorded: its settledAt would be sampled from B's - // timeline, charging B's delay to A. - assert.deepEqual(measures(), []); - settle("bbbb2222bbbb2222"); +test("an undisturbed switch records exactly one measure", () => { + withHarness(({ at, flush, measures, lastMeasure }) => { + at(0); + beginChannelSwitchTrace("aaaa"); + at(20); + markChannelSwitchRouteCommit("aaaa"); + at(120); + settleChannelSwitchTrace("aaaa"); flush(); - assert.deepEqual(measures(), ["bbbb2222bbbb2222"]); + assert.deepEqual(measures(), ["aaaa"]); + assert.notEqual(lastMeasure().detail.routeCommitAt, null); + assert.equal(lastMeasure().detail.settleWaitTruncated, undefined); }); }); -test("a community reset during the deferred wait drops the record", async () => { - await withSettleHarness(async ({ begin, settle, reset, flush, measures }) => { - begin("aaaa1111aaaa1111"); - settle("aaaa1111aaaa1111"); - reset(); +test("the settle waits for a pending render, then truncates rather than hangs", () => { + withHarness(({ at, flush, setPending, measures, lastMeasure }) => { + at(0); + beginChannelSwitchTrace("aaaa"); + setPending(true); + at(50); + settleChannelSwitchTrace("aaaa"); + // The marker never clears: the wait is bounded in frames, and the sample + // is reported flagged rather than discarded — a slow switch is data. flush(); - assert.deepEqual(measures(), []); + assert.deepEqual(measures(), ["aaaa"]); + assert.equal(lastMeasure().detail.settleWaitTruncated, true); }); }); -test("an undisturbed settle records exactly one measure", async () => { - await withSettleHarness(async ({ begin, settle, flush, measures }) => { - begin("aaaa1111aaaa1111"); - settle("aaaa1111aaaa1111"); +test("a switch that paints mid-wait records without the truncation flag", () => { + withHarness(({ at, flush, setPending, lastMeasure }) => { + at(0); + beginChannelSwitchTrace("aaaa"); + setPending(true); + at(50); + settleChannelSwitchTrace("aaaa"); + flush(2); + setPending(false); flush(); - assert.deepEqual(measures(), ["aaaa1111aaaa1111"]); + assert.equal(lastMeasure().detail.settleWaitTruncated, undefined); }); }); -test("beginning a switch clears the previous switch's settled mark and measure", async () => { - await withSettleHarness(async ({ begin, settle, flush, measures }) => { - const { CHANNEL_SWITCH_SETTLED_MARK } = await import( - "./channelSwitchPerf.ts" - ); - begin("aaaa1111aaaa1111"); - settle("aaaa1111aaaa1111"); +test("a superseding switch discards the first trace, with a reason", () => { + withHarness(({ at, flush, drops, measures }) => { + at(0); + beginChannelSwitchTrace("aaaa"); + at(400); + // The user gave up on A and clicked B. A being slow is exactly why they + // clicked again, so a silent discard censors the switches worth seeing. + beginChannelSwitchTrace("bbbb"); + settleChannelSwitchTrace("aaaa"); flush(); - assert.deepEqual(measures(), ["aaaa1111aaaa1111"]); - // A consumer polling the buffer mid-switch (the Playwright specs, the - // Performance panel) must never read the PREVIOUS switch's entries as - // the current one's. - begin("bbbb2222bbbb2222"); + assert.deepEqual(drops, ["superseded"]); assert.deepEqual(measures(), []); - assert.equal( - performance.getEntriesByName(CHANNEL_SWITCH_SETTLED_MARK).length, - 0, - ); }); }); -test("a window hidden between click and settle entry drops the trace", async () => { - const visibilityListeners = []; - await withSettleHarness( - async ({ begin, settle, flush, measures }) => { - begin("aaaa1111aaaa1111"); - assert.ok(visibilityListeners.length >= 1, "watcher armed at begin"); - // The window hides while the fetch is in flight (cmd-H / minimize), - // then the user returns and the settle runs with the window visible - // again: the absence sits inside totalMs, so the trace must drop. - globalThis.document.visibilityState = "hidden"; - for (const listener of visibilityListeners) listener(); - globalThis.document.visibilityState = "visible"; - settle("aaaa1111aaaa1111"); - flush(); - assert.deepEqual(measures(), []); - }, - { - addEventListener: (type, listener) => { - if (type === "visibilitychange") visibilityListeners.push(listener); - }, - }, - ); -}); - -test("abandoned switches never accumulate start marks", async () => { - await withSettleHarness(async ({ abandon, begin }) => { - const { CHANNEL_SWITCH_START_MARK } = await import( - "./channelSwitchPerf.ts" - ); - performance.clearMarks?.(CHANNEL_SWITCH_START_MARK); - // Traces that die without recording (forum visits, route exits, drops) - // never reach record()'s buffer clearing — begin() must bound the - // buffer itself or weeks-long sessions accumulate a mark per abandon. - for (const channelId of ["aaaa", "bbbb", "cccc", "dddd"]) { - begin(channelId); - abandon(channelId); - } - assert.equal( - performance.getEntriesByName(CHANNEL_SWITCH_START_MARK).length, - 1, - ); - performance.clearMarks?.(CHANNEL_SWITCH_START_MARK); - }); -}); - -test("a settle in a hidden window drops the trace instead of recording", async () => { - await withSettleHarness( - async ({ begin, settle, flush, measures }) => { - begin("aaaa1111aaaa1111"); - // rAF is suspended while hidden; the queued chain would only fire when - // the user returns, charging the whole absence to the switch. - settle("aaaa1111aaaa1111"); - flush(); - assert.deepEqual(measures(), []); - // The trace was released, not wedged: a later stale settle is a no-op. - settle("aaaa1111aaaa1111"); - flush(); - assert.deepEqual(measures(), []); - }, - { visibilityState: "hidden" }, - ); -}); - -test("a window hidden during the settle wait drops the record", async () => { - const visibilityListeners = []; - await withSettleHarness( - async ({ begin, settle, flush, measures }) => { - begin("aaaa1111aaaa1111"); - settle("aaaa1111aaaa1111"); - assert.equal(visibilityListeners.length, 1, "wait registers a listener"); - // The user cmd-tabs away mid-wait; frames resume only on return. - visibilityListeners[0](); - flush(); - assert.deepEqual(measures(), []); - }, - { - addEventListener: (type, listener) => { - if (type === "visibilitychange") visibilityListeners.push(listener); - }, - }, - ); -}); - -test("a scheduled route-exit abandon canceled in the same task keeps the trace", async () => { - await withSettleHarness( - async ({ - begin, - cancelAbandon, - scheduleAbandon, - settle, - flush, - measures, - }) => { - begin("aaaa1111aaaa1111"); - // StrictMode's dev-only effect replay: cleanup schedules the abandon, - // the synchronous re-setup cancels it before the microtask runs. - scheduleAbandon("aaaa1111aaaa1111"); - cancelAbandon("aaaa1111aaaa1111"); - await Promise.resolve(); - settle("aaaa1111aaaa1111"); - flush(); - assert.deepEqual(measures(), ["aaaa1111aaaa1111"]); - }, - ); -}); - -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; - const startMark = performance - .getEntriesByName("buzz:channel-switch:start") - .at(-1); - settle("aaaa1111aaaa1111"); +test("a hidden window drops the trace instead of recording the absence", () => { + withHarness(({ at, flush, hide, drops, measures }) => { + at(0); + beginChannelSwitchTrace("aaaa"); + hide(); + at(9_000); + settleChannelSwitchTrace("aaaa"); 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})`, - ); - // The mark and the measure must share the anchor, or the Performance - // panel shows two different switch durations for the same switch. - assert.equal(startMark?.startTime, measure.startTime); + assert.deepEqual(drops, ["hidden window"]); + assert.deepEqual(measures(), []); }); }); -test("beginning a switch revokes a pending route-exit abandon for that channel", async () => { - await withSettleHarness( - async ({ begin, scheduleAbandon, settle, flush, measures }) => { - // Same-task unmount-then-renavigate to the same channel: the cleanup - // schedules the abandon, then goChannel synchronously opens a fresh - // trace before the microtask drains. The stale abandon must not kill - // the new trace. - scheduleAbandon("aaaa1111aaaa1111"); - begin("aaaa1111aaaa1111"); - await Promise.resolve(); - settle("aaaa1111aaaa1111"); - flush(); - assert.deepEqual(measures(), ["aaaa1111aaaa1111"]); - }, - ); -}); - -test("an uncanceled route-exit abandon drops the trace before any frame fires", async () => { - await withSettleHarness( - async ({ begin, scheduleAbandon, settle, flush, measures }) => { - begin("aaaa1111aaaa1111"); - scheduleAbandon("aaaa1111aaaa1111"); - await Promise.resolve(); - settle("aaaa1111aaaa1111"); - flush(); - assert.deepEqual(measures(), []); - }, - ); -}); - -test("leaving the channel surface abandons the trace; history-back records nothing", async () => { - await withSettleHarness( - async ({ abandon, begin, settle, flush, measures }) => { - begin("aaaa1111aaaa1111"); - // Route exit (Projects/Home): the channel screen unmounts before the - // trace settled and abandons it. - abandon("aaaa1111aaaa1111"); - // History-back re-enters the channel without goChannel; its settle must - // find no trace — otherwise the time spent away would be recorded as - // switch latency. - settle("aaaa1111aaaa1111"); - flush(); - assert.deepEqual(measures(), []); - }, - ); -}); - -test("suspension between readiness and the paint frame drops the record", () => { - // The reviewer's repro: readiness at t=10ms, final frame at t=20_000ms. - // Recording here would emit total=20000ms as an ordinary clean switch — - // App Nap fires no visibilitychange, so the hidden-window guard cannot see - // it and the frame-gap guard in awaitDeferredCommit has already run. - assert.equal(resolveFinalFrame(20_000, 10, 0), "drop"); - // A normal paint frame one refresh interval after readiness still records. - assert.equal(resolveFinalFrame(27, 10, 0), "record"); - // Boundary: exactly the frame-gap cap is still a record; one past it drops. - assert.equal(resolveFinalFrame(3_010, 10, 0), "record"); - assert.equal(resolveFinalFrame(3_011, 10, 0), "drop"); -}); - -test("the final frame also honors the overall trace age cap", () => { - // Readiness landed just under the age cap and the paint frame is prompt, - // so the frame gap is innocent — only the age check can catch this. - assert.equal(resolveFinalFrame(35_001, 35_000, 0), "drop"); - assert.equal(resolveFinalFrame(35_000, 34_999, 0), "record"); -}); - -// Drives the real settle lifecycle with a controllable clock and rAF queue so -// a stall can be injected at one exact seam. Offsets are rebased above the -// real clock: earlier tests in this file fire visibilitychange, and a trace -// back-dated below those timestamps is (correctly) dropped at settle entry — -// which would make every assertion here vacuous. -function withClockedFrames(run) { - const frames = []; - const originalWindow = globalThis.window; - const originalDocument = globalThis.document; - const originalNow = performance.now; - const base = originalNow.call(performance) + 1_000; - let clock = base; - performance.now = () => clock; - globalThis.window = { - requestAnimationFrame: (cb) => frames.push(cb) && frames.length, - cancelAnimationFrame: () => {}, - }; - globalThis.document = { - addEventListener: () => {}, - // No pending marker: readiness is reached on the first settle frame. - querySelector: () => null, - removeEventListener: () => {}, - visibilityState: "visible", - }; - performance.clearMeasures?.(CHANNEL_SWITCH_MEASURE); - const at = (offset) => { - clock = base + offset; - }; - const step = (offset) => { - at(offset); - for (const cb of frames.splice(0, frames.length)) cb(); - }; - const measures = () => - performance.getEntriesByName(CHANNEL_SWITCH_MEASURE).length; - try { - run({ at, step, measures }); - } finally { - performance.now = originalNow; - resetChannelSwitchTrace(); - performance.clearMeasures?.(CHANNEL_SWITCH_MEASURE); - if (originalWindow === undefined) delete globalThis.window; - else globalThis.window = originalWindow; - if (originalDocument === undefined) delete globalThis.document; - else globalThis.document = originalDocument; - } -} - -test("a stall between readiness and the paint frame records nothing", () => { - withClockedFrames(({ at, step, measures }) => { +test("a timed-out switch is dropped, with a reason", () => { + withHarness(({ at, flush, drops, measures }) => { at(0); beginChannelSwitchTrace("aaaa"); - at(10); + at(31_000); settleChannelSwitchTrace("aaaa"); - // Readiness frame: no pending marker, inside the wait deadline. - step(10); - // Process suspended here (App Nap): no visibilitychange fires, so only - // the final-frame guard can catch it. - step(20_000); - assert.equal(measures(), 0, "a 20s suspension must not record a switch"); + flush(); + assert.deepEqual(drops, ["timed out"]); + assert.deepEqual(measures(), []); }); }); -test("a prompt paint frame after readiness still records", () => { - withClockedFrames(({ at, step, measures }) => { +test("leaving the channel surface and forum surfaces drop with a reason", () => { + withHarness(({ at, drops }) => { at(0); beginChannelSwitchTrace("aaaa"); + dropActiveChannelSwitchTrace(); at(10); - settleChannelSwitchTrace("aaaa"); - step(10); - step(26); - assert.equal(measures(), 1, "the guard must not drop healthy switches"); + beginChannelSwitchTrace("bbbb"); + abandonChannelSwitchTrace("bbbb"); + assert.deepEqual(drops, ["left channel surface", "unobservable surface"]); }); }); -test("a stale caller anchor is discarded rather than charged to the switch", () => { - // DM flow: anchor captured at the click, open_dm awaited. If the user - // navigates away during that await, the anchor is no longer this switch's - // start and would charge unrelated activity to it. - assert.deepEqual(resolveTraceAnchor(1_000, 500, 30_000), { - startedAt: 30_000, - anchorDiscarded: true, - }); - // Inside the bound: the open_dm round-trip stays in the measurement. - assert.deepEqual(resolveTraceAnchor(25_000, 500, 30_000), { - startedAt: 25_000, - anchorDiscarded: false, - }); -}); - -test("anchors are never negative, non-finite, or in the future", () => { - // performance.mark({startTime}) throws on a negative timestamp, and begin() - // runs inside the click handler — a diagnostic must never break navigation. - assert.equal(resolveTraceAnchor(-5, 100, 100).startedAt, 0); - assert.equal(resolveTraceAnchor(Number.NaN, 100, 100).anchorDiscarded, true); - assert.equal(resolveTraceAnchor(Number.NaN, 100, 100).startedAt, 100); - // A skewed event clock reporting the future is clamped to now. - assert.equal(resolveTraceAnchor(200, 100, 100).startedAt, 100); - // No anchor supplied, and performance was unavailable at capture time. - assert.equal(resolveTraceAnchor(undefined, Number.NaN, 100).startedAt, 100); -}); - -test("a truncated settle survives a slow paint frame; a clean one does not", () => { - // renderWasPending is direct evidence the long frame is a heavy commit, not - // a suspension — and that measurement is already flagged. Dropping it would - // discard exactly the pathological switch the instrument exists to expose. - assert.equal(resolveFinalFrame(3_600, 10, 0, true), "record"); - assert.equal(resolveFinalFrame(3_600, 10, 0, false), "drop"); - // The age cap still applies to both. - assert.equal(resolveFinalFrame(35_001, 35_000, 0, true), "drop"); -}); - -test("post-paint churn does not keep a settled switch waiting", () => { - // Nothing pending: ready, regardless of generations. - assert.equal(resolveRenderReadiness(false, 4, 4), true); - // Pending and the timeline has not committed since settle entry: the - // switch's own rows are still unpainted, so keep waiting. - assert.equal(resolveRenderReadiness(true, 4, 4), false); - // Pending, but the timeline committed past the generation painted at settle - // entry: the rows are on screen and this marker belongs to live traffic - // that arrived afterwards. Recording the burst would inflate the switch. - assert.equal(resolveRenderReadiness(true, 4, 5), true); - // No timeline mounted (Suspense fallback still up): the pending marker is - // the fallback's own, and there is nothing painted yet to be ready. - assert.equal(resolveRenderReadiness(true, null, null), false); - assert.equal(resolveRenderReadiness(true, 4, null), false); -}); - -// --- Drop accounting: no measurement disappears without a record ----------- - -function withDropCapture(run) { - const drops = []; - const originalInfo = console.info; - console.info = (line) => { - if (typeof line === "string" && line.includes("dropped reason=")) { - drops.push(line.slice(line.indexOf("dropped reason=") + 15)); - } - }; - try { - run(drops); - } finally { - console.info = originalInfo; - resetChannelSwitchTrace(); - } -} - -test("an impatient second click accounts for the trace it supersedes", () => { - withClockedFrames(({ at }) => { - withDropCapture((drops) => { - at(0); - beginChannelSwitchTrace("aaaa"); - at(400); - // The user gave up on A and clicked B. A's trace is gone — and A being - // slow is exactly why they clicked again, so a silent discard censors - // the switches worth measuring. - beginChannelSwitchTrace("bbbb"); - assert.deepEqual(drops, ["superseded"]); - }); - }); -}); - -test("a community reset accounts for the trace it clears", () => { - withClockedFrames(({ at }) => { - withDropCapture((drops) => { - at(0); - beginChannelSwitchTrace("aaaa"); - resetChannelSwitchTrace(); - assert.deepEqual(drops, ["community-reset"]); - }); +test("a second settle neither restarts the wait nor moves the fetch bound", () => { + withHarness(({ at, flush, measures }) => { + at(0); + beginChannelSwitchTrace("aaaa"); + at(50); + settleChannelSwitchTrace("aaaa"); + at(60); + settleChannelSwitchTrace("aaaa"); + flush(); + assert.deepEqual(measures(), ["aaaa"]); }); }); -test("an unobservable surface accounts for its abandon", () => { - withClockedFrames(({ at }) => { - withDropCapture((drops) => { - at(0); - beginChannelSwitchTrace("aaaa"); - abandonChannelSwitchTrace("aaaa"); - assert.deepEqual(drops, ["unobservable-surface"]); +test("an attributed window fetch reaches the measure", () => { + withHarness(({ at, flush, lastMeasure }) => { + at(0); + beginChannelSwitchTrace("aaaa"); + traceChannelWindowFetch("aaaa", 89, 307, performance.now()); + at(120); + settleChannelSwitchTrace("aaaa"); + flush(); + assert.deepEqual(lastMeasure().detail.windowFetch, { + durationMs: 307, + eventCount: 89, }); }); }); -test("a timed-out settle accounts for the drop", () => { - withClockedFrames(({ at, measures }) => { - withDropCapture((drops) => { - at(0); - beginChannelSwitchTrace("aaaa"); - at(31_000); - settleChannelSwitchTrace("aaaa"); - assert.deepEqual(drops, ["timeout"]); - assert.equal(measures(), 0); - }); +test("beginning a switch clears the previous switch's entries", () => { + withHarness(({ at, flush, measures }) => { + at(0); + beginChannelSwitchTrace("aaaa"); + at(100); + settleChannelSwitchTrace("aaaa"); + flush(); + assert.deepEqual(measures(), ["aaaa"]); + // A consumer polling the buffer mid-switch must never read the previous + // switch's measure as the current one's. + at(200); + beginChannelSwitchTrace("bbbb"); + assert.deepEqual(measures(), []); }); }); -test("route-exit abandon respects hash-history routes", async () => { - // The app uses createHashHistory, so the route lives in location.hash. - // Reading location.pathname made this guard answer false for every real - // channel route, degrading it to an unconditional abandon. - const drops = []; - const originalInfo = console.info; - const originalWindow = globalThis.window; - const originalNow = performance.now; - const base = originalNow.call(performance) + 1_000; - performance.now = () => base; - console.info = (line) => { - if (typeof line === "string" && line.includes("dropped reason=")) { - drops.push(line.slice(line.indexOf("dropped reason=") + 15)); - } - }; - globalThis.window = { - requestAnimationFrame: () => 1, - cancelAnimationFrame: () => {}, - location: { pathname: "/index.html", hash: "#/channels/aaaa" }, - }; - try { +test("the start mark shares the measure's anchor", () => { + withHarness(({ at, flush }) => { + at(0); beginChannelSwitchTrace("aaaa"); - scheduleRouteExitAbandon("aaaa"); - await Promise.resolve(); - assert.deepEqual(drops, [], "the route still points at this channel"); - - // A real exit still abandons. - globalThis.window.location.hash = "#/projects"; - scheduleRouteExitAbandon("aaaa"); - await Promise.resolve(); - assert.deepEqual(drops, ["route-exit"]); - } finally { - console.info = originalInfo; - performance.now = originalNow; - resetChannelSwitchTrace(); - if (originalWindow === undefined) delete globalThis.window; - else globalThis.window = originalWindow; - } + const startMark = performance + .getEntriesByName(CHANNEL_SWITCH_START_MARK) + .at(-1); + at(100); + settleChannelSwitchTrace("aaaa"); + flush(); + const measure = performance.getEntriesByName(CHANNEL_SWITCH_MEASURE).at(-1); + // Without a shared anchor the Performance panel shows the measure + // starting before its own start mark. + assert.equal(startMark.startTime, measure.startTime); + }); }); diff --git a/desktop/src/shared/lib/channelSwitchPerf.ts b/desktop/src/shared/lib/channelSwitchPerf.ts index 67dbfd1aa75..5243e31a1a7 100644 --- a/desktop/src/shared/lib/channelSwitchPerf.ts +++ b/desktop/src/shared/lib/channelSwitchPerf.ts @@ -1,81 +1,64 @@ /** - * Channel-switch tracing: measures click → settled-paint for channel - * navigations, with the two relay fetches that can sit on that path - * (message window, member roster) attributed to the switch. + * Channel-switch timing: click → route commit → settled paint, plus the + * message-window fetch when it lands inside that interval. * - * One trace is active at a time; `beginChannelSwitchTrace` (called from - * `goChannel`) opens it and `settleChannelSwitchTrace` (called when the - * timeline settles for that channel) closes it after the next paint. Fetch - * traces and settles for non-active channels are ignored, so background - * refetches never pollute a switch measurement. + * Deliberately small. One trace is active at a time; `beginChannelSwitchTrace` + * (from `goChannel`, via `commitGuardedNavigation`) opens it and + * `settleChannelSwitchTrace` closes it one paint after the channel's timeline + * leaves its loading latch. Output is a `[switch-perf]` console line plus User + * Timing marks and measures (`buzz:channel-switch:*`) — the Performance panel + * and the Playwright perf harness read the same numbers. Nothing is persisted. * - * Traced entry paths: every navigation that reaches `goChannel` — sidebar, - * search, notification activation, and the DM actions, which capture - * `captureSwitchTraceAnchor()` at the click so their `open_dm` round-trip is - * inside the interval rather than before it. Deliberately untraced: history - * back/forward (no click to anchor on) and navigations that stay on the - * already-active channel (nothing re-runs the settle, so a trace could only - * time out). - * - * Output per switch: a `[switch-perf]` console line plus User Timing - * marks/measures (`buzz:channel-switch:*`) so Playwright perf specs and the - * Performance panel can read the same numbers. - * - * Attribution window: fetches are credited to a switch only when they finish - * before the settled paint. A roster fetch that completes after settle is - * deliberately not part of the felt switch latency, so such switches report - * `members=cache` — by design, not omission. - * - * The settled timestamp lands one rAF after the paint, so `totalMs` includes - * up to one display refresh interval (~17ms at 60Hz, ~8ms at 120Hz) — - * compare before/after runs on the same display. + * The honesty bounds below are chosen rather than inferred. Every extra + * inference this instrument tried to make became a way to report a number that + * never happened, so the scope is narrow on purpose: + * - Only navigations that reach `goChannel` are traced. History back/forward + * and re-selecting the active channel are not. + * - The interval starts at the navigation, anchored to the triggering input + * event when one is dispatching. Callers that await before navigating (DM + * actions await `open_dm`) exclude that await by design. + * - A trace overlapping a hidden window is dropped, never measured: rAF + * suspends and network throttles while hidden, so elapsed time there is not + * the user's switch. + * - The settle waits a bounded number of frames for a pending render. Past + * that bound the measure is still emitted, flagged `settleWaitTruncated` — + * a slow switch is data, not an error. + * - Every abandoned trace prints why. Drops correlate with slow switches, so a + * silent drop would make "no switches" and "switches discarded" look alike. */ -import { invoke, isTauri } from "@tauri-apps/api/core"; - export type ChannelSwitchTrace = { channelId: string; - /** Reported anchor: the click. May predate `openedAt` for DM entries. */ startedAt: number; - /** - * When begin() ran. Liveness (timeout, starvation age) is measured from - * here, never from `startedAt`: a back-dated anchor would otherwise spend - * the trace's whole staleness budget on the relay round-trip that preceded - * the navigation, and an honest slow DM entry would go unmeasured. - */ - openedAt: number; - /** Largest inter-frame gap seen while this trace was live. */ - maxFrameGapMs: number; - /** The caller's anchor was too stale to trust; see MAX_ANCHOR_AGE_MS. */ - anchorDiscarded: boolean; - /** - * When the timeline reported settled. The trace stays active past this - * point to wait for the deferred paint, so it bounds fetch attribution: - * work the user never waited on must not claim the switch's one-shot slot. - */ - settleEnteredAt: number | null; routeCommitAt: number | null; windowFetch: { durationMs: number; eventCount: number } | null; - membersFetch: { durationMs: number; memberCount: number } | null; + /** Set when the timeline reports settled; bounds fetch attribution. */ + settleEnteredAt: number | null; }; /** A switch that hasn't settled after this long is abandoned, not measured. */ const SWITCH_TRACE_TIMEOUT_MS = 30_000; /** - * Oldest caller-supplied anchor still treated as this navigation's click. - * A DM action captures its anchor before awaiting `open_dm`; if the user - * navigates elsewhere while that await is outstanding, the anchor is no - * longer the start of the switch that eventually commits. Beyond this the - * anchor is discarded (and the record says so) rather than charging unrelated - * activity to the switch. + * Frames the settle waits for a pending render before recording anyway. + * Bounded in frames rather than milliseconds so a stalled main thread cannot + * stretch the wait into the measurement. */ -const MAX_ANCHOR_AGE_MS = 10_000; +const MAX_SETTLE_FRAMES = 30; export const CHANNEL_SWITCH_START_MARK = "buzz:channel-switch:start"; export const CHANNEL_SWITCH_SETTLED_MARK = "buzz:channel-switch:settled"; export const CHANNEL_SWITCH_MEASURE = "buzz:channel-switch:click-to-settled"; +/** + * Repo-wide marker for "a deferred commit is in flight", set by the message + * timeline and by the lazy channel pane's fallback. Other components use it + * too, so the settle wait is bounded in frames rather than trusting it to + * clear: a foreign owner can extend a switch by at most MAX_SETTLE_FRAMES, + * and that sample is flagged `settleWaitTruncated`. + */ +const RENDER_PENDING_SELECTOR = '[data-render-pending="true"]'; + let activeTrace: ChannelSwitchTrace | null = null; /** Formats one settled trace as the `[switch-perf]` console line. */ @@ -93,241 +76,24 @@ export function summarizeChannelSwitchTrace( trace.windowFetch === null ? "cache" : `${trace.windowFetch.eventCount} events in ${Math.round(trace.windowFetch.durationMs)}ms`; - const members = - trace.membersFetch === null - ? "cache" - : `${trace.membersFetch.memberCount} members in ${Math.round(trace.membersFetch.durationMs)}ms`; return ( `[switch-perf] channel=${trace.channelId.slice(0, 8)} total=${total}ms ` + - `commit=${commit} window=${window} members=${members}` + + `commit=${commit} window=${window}` + (settleWaitTruncated ? " settle=truncated" : "") ); } -/** - * The JSONL record persisted per settled switch. The backend folds in the - * build's git revision and the optional BUZZ_PERF_LOG_LABEL run label, so - * before/after sessions are attributable offline. Pure for unit testing. - */ -export function buildSwitchPerfLogRecord( - trace: ChannelSwitchTrace, - settledAt: number, - settleWaitTruncated = false, -): { - ts: string; - channelId: string; - totalMs: number; - commitOffsetMs: number | null; - windowFetch: { durationMs: number; eventCount: number } | null; - membersFetch: { durationMs: number; memberCount: number } | null; - settleWaitTruncated?: true; - anchorDiscarded?: true; -} { - return { - ...(settleWaitTruncated ? { settleWaitTruncated: true as const } : {}), - ...(trace.anchorDiscarded ? { anchorDiscarded: true as const } : {}), - ts: new Date().toISOString(), - channelId: trace.channelId, - totalMs: Math.round(settledAt - trace.startedAt), - commitOffsetMs: - trace.routeCommitAt === null - ? null - : Math.round(trace.routeCommitAt - trace.startedAt), - windowFetch: trace.windowFetch - ? { - durationMs: Math.round(trace.windowFetch.durationMs), - eventCount: trace.windowFetch.eventCount, - } - : null, - membersFetch: trace.membersFetch - ? { - durationMs: Math.round(trace.membersFetch.durationMs), - memberCount: trace.membersFetch.memberCount, - } - : null, - }; -} - -let hasAnnouncedLogPath = false; -let hasWarnedSinkFailure = false; - -/** - * Fire-and-forget JSONL append; diagnostics must never throw into the app. - * A permanently dead sink must not be SILENT though — the console keeps - * printing per-switch lines that read as "tracing works", so warn once when - * persistence fails or an operator's before/after run yields an empty file - * with no way to tell why. - */ -function appendSwitchPerfLogRecord(record: Record): void { - if (!isTauri()) return; - void invoke("append_switch_perf_log", { - recordJson: JSON.stringify(record), - }) - .then((path) => { - if (!hasAnnouncedLogPath) { - hasAnnouncedLogPath = true; - console.info(`[switch-perf] logging to ${path}`); - } - }) - .catch((error) => { - if (!hasWarnedSinkFailure) { - hasWarnedSinkFailure = true; - console.warn( - "[switch-perf] failed to persist record; offline log may be incomplete:", - error, - ); - } - }); -} - -export type SwitchDropReason = - | "timeout" - | "hidden-window" - | "frame-starvation" - | "settle-wait-exceeded" - | "route-exit" - | "unobservable-surface" - | "left-channel-surface" - | "navigation-failed" - | "superseded" - | "community-reset"; - -/** - * Every abandoned trace is accounted for. Drop conditions correlate with slow - * switches (starvation, timeout, hidden window), so silent drops would censor - * exactly the tail an operator is measuring and make "no samples" and "N - * samples discarded" indistinguishable in the offline log. - */ -function recordSwitchDrop( - trace: ChannelSwitchTrace, - reason: SwitchDropReason, -): void { +function dropTrace(trace: ChannelSwitchTrace, reason: string): void { console.info( - `[switch-perf] channel=${trace.channelId.slice(0, 8)} dropped reason=${reason}`, + `[switch-perf] channel=${trace.channelId.slice(0, 8)} dropped (${reason})`, ); - appendSwitchPerfLogRecord({ - ts: new Date().toISOString(), - channelId: trace.channelId, - dropped: reason, - }); + if (activeTrace === trace) activeTrace = null; } /** - * Decides what a settle call does with the active trace. A settle for a - * different channel must leave the trace alone — a previous channel can - * finish loading after the next switch already began, and clobbering the - * newer trace would silently drop exactly the slow/rapid switches this - * instrumentation exists to capture. Only the settled channel's own trace is - * consumed (measured, or dropped when timed out). Pure so the attribution - * rules are unit-testable. - */ -export function resolveSettleAction( - trace: ChannelSwitchTrace | null, - channelId: string, - now: number, -): { settledTrace: ChannelSwitchTrace | null; clearActive: boolean } { - if (!trace || trace.channelId !== channelId) { - return { settledTrace: null, clearActive: false }; - } - if (now - trace.openedAt > SWITCH_TRACE_TIMEOUT_MS) { - return { settledTrace: null, clearActive: true }; - } - return { settledTrace: trace, clearActive: true }; -} - -/** - * Drops the active trace for surfaces whose readiness this instrument cannot - * observe (e.g. forum channels, whose loading is owned by ForumView's own - * queries). Better no measurement than a systematically underreported one. - */ -export function abandonChannelSwitchTrace( - channelId: string, - reason: SwitchDropReason = "unobservable-surface", -): void { - if (activeTrace?.channelId === channelId) { - recordSwitchDrop(activeTrace, reason); - activeTrace = null; - } -} - -/** - * dropActiveChannelSwitchTrace abandons whatever trace is active, regardless - * of channel. Called when navigation leaves the channel surface (any - * committed non-channel destination, any history traversal): a trace can be - * live with no channel screen mounted at all — the route still resolving — - * so no route-exit cleanup exists to abandon it, and a later untraced - * re-entry within the timeout would settle it with the time spent away. - */ -export function dropActiveChannelSwitchTrace( - reason: SwitchDropReason = "left-channel-surface", -): void { - if (activeTrace) recordSwitchDrop(activeTrace, reason); - activeTrace = null; -} - -// Route-exit abandons currently deferred; see scheduleRouteExitAbandon. -const pendingRouteExitAbandons = new Set(); - -/** - * scheduleRouteExitAbandon abandons the channel's trace one microtask from - * now unless cancelRouteExitAbandon runs first. Call it from the route-exit - * effect cleanup: deferring lets React StrictMode's dev-only effect replay - * — cleanup + re-setup, synchronously within one commit — cancel the - * abandon, where abandoning synchronously would kill every just-opened - * trace in dev builds and break the Performance-panel workflow. A real - * route exit has no re-setup, so the scheduled abandon still fires — and - * microtasks run before any frame callback, so a queued settle cannot - * record in the gap. - */ -export function scheduleRouteExitAbandon(channelId: string): void { - pendingRouteExitAbandons.add(channelId); - queueMicrotask(() => { - if (!pendingRouteExitAbandons.delete(channelId)) return; - // The unmount may be a remount in disguise: a Suspense boundary between - // the two commits (project-home channels swap ChannelScreen for a lazy - // ChannelScreenView) means the re-setup that would have cancelled this - // has not run yet, and killing the trace here loses a switch the user - // did make. The route is the authority — if it still points at this - // channel, nothing exited. - if (routeStillOnChannel(channelId)) return; - abandonChannelSwitchTrace(channelId, "route-exit"); - }); -} - -/** - * Whether the current URL is still this channel's own route. Read from - * location rather than React state: the check runs from a microtask, after - * the unmount commit, where no component tree is authoritative. - */ -function routeStillOnChannel(channelId: string): boolean { - if (typeof window === "undefined" || !window.location) return false; - // The app uses createHashHistory (app/router.tsx), so the route lives in - // location.hash and location.pathname is the document path. Reading - // pathname here made this guard inert — it answered false for every real - // channel route, degrading the caller to an unconditional abandon. - const hash = window.location.hash; - const route = hash.startsWith("#") ? hash.slice(1) : window.location.pathname; - const path = route.split("?")[0]; - return ( - path === `/channels/${channelId}` || - path.startsWith(`/channels/${channelId}/`) - ); -} - -/** - * cancelRouteExitAbandon revokes a pending scheduleRouteExitAbandon for the - * channel. Call it from the route-enter effect setup, before any work. - */ -export function cancelRouteExitAbandon(channelId: string): void { - pendingRouteExitAbandons.delete(channelId); -} - -/** - * Timestamp of the most recent visibilitychange. rAF suspends and network - * work throttles while the window is hidden, so any visibility transition - * inside a trace window means an off-screen interval overlaps the - * measurement — such traces are dropped rather than charged with the - * absence. One listener per document (tests swap documents). + * Timestamp of the most recent visibilitychange. Any transition inside a trace + * window means an off-screen interval overlaps the measurement. One listener + * per document (tests swap documents). */ let lastVisibilityChangeAt = Number.NEGATIVE_INFINITY; const watchedDocuments = new WeakSet(); @@ -341,180 +107,51 @@ function ensureVisibilityWatcher(): void { }); } -function traceOverlapsHiddenWindow(trace: ChannelSwitchTrace): boolean { +function overlapsHiddenWindow(trace: ChannelSwitchTrace): boolean { return ( document.visibilityState === "hidden" || lastVisibilityChangeAt >= trace.startedAt ); } -/** - * Timestamp to anchor a switch trace on, read during synchronous event - * dispatch. Callers that must await before they know the target channel (DM - * actions await `open_dm` for its id) capture this at the click and hand it to - * `goChannel`, so the relay round-trip stays inside the measured interval - * instead of silently preceding it. - */ -export function captureSwitchTraceAnchor(): number { - // NaN, not 0: 0 is a valid timestamp meaning "time origin", and it would - // silently report the whole session uptime as switch latency. - if (typeof performance === "undefined") return Number.NaN; - const now = performance.now(); - const dispatchingEvent = - typeof window === "undefined" ? undefined : window.event; - return dispatchingEvent && typeof dispatchingEvent.timeStamp === "number" - ? Math.min(dispatchingEvent.timeStamp, now) - : now; -} - -/** - * Samples inter-frame gaps for as long as `trace` is the active one. The - * settle path guards its own frames, but the click -> settle-entry interval - * had no starvation guard at all: process suspension there (App Nap, a - * suspended VM) fires no `visibilitychange`, and the switch recorded the whole - * absence as clean. The loop exits as soon as the trace is replaced, dropped, - * or recorded, so at most one rAF per frame is live per switch. - */ -function startStarvationHeartbeat(trace: ChannelSwitchTrace): void { - if (typeof window === "undefined" || !window.requestAnimationFrame) return; - // Bind the scheduler once. Reading the ambient `window` on every tick would - // follow a swapped-out global (tests replace it; a torn-down document in - // production would be equivalent) and throw from inside a frame callback, - // where nothing can catch it. - const schedule = window.requestAnimationFrame.bind(window); - let lastAt = trace.openedAt; - const tick = () => { - if (activeTrace !== trace) return; - const now = performance.now(); - // Past its own liveness budget the trace can no longer be measured, so - // sampling it is pure cost. Without this the loop outlives any trace that - // is never settled, dropped, or replaced — one frame callback per frame, - // forever. - if ( - now - trace.openedAt > - SWITCH_TRACE_TIMEOUT_MS + SETTLE_RENDER_WAIT_MS - ) { - return; - } - trace.maxFrameGapMs = Math.max(trace.maxFrameGapMs, now - lastAt); - lastAt = now; - try { - schedule(tick); - } catch { - // Frame loop is gone; the gap it would have measured is unknowable. - // Leave maxFrameGapMs at what was observed rather than guessing. - } - }; - try { - schedule(tick); - } catch { - /* no frame loop available; settle-path guards still apply */ - } -} - -/** - * Resolves the reported start for a trace. A caller-supplied anchor is used - * only when it is finite, not in the future, and recent enough to still be - * this navigation's click; otherwise `now` is used and the caller is told the - * anchor was discarded so the record can say so. Pure for unit testing. - */ -export function resolveTraceAnchor( - anchoredAt: number | undefined, - fallback: number, - now: number, -): { startedAt: number; anchorDiscarded: boolean } { - if (anchoredAt === undefined) { - return { - startedAt: Number.isFinite(fallback) - ? Math.max(0, Math.min(fallback, now)) - : now, - anchorDiscarded: false, - }; - } - if (!Number.isFinite(anchoredAt) || now - anchoredAt > MAX_ANCHOR_AGE_MS) { - return { startedAt: now, anchorDiscarded: true }; - } - return { - startedAt: Math.max(0, Math.min(anchoredAt, now)), - anchorDiscarded: false, - }; -} - -/** Opaque identity for one opened trace; see `cancelChannelSwitchTrace`. */ -export type ChannelSwitchTraceHandle = { readonly trace: ChannelSwitchTrace }; - -/** - * Cancels `handle`'s trace, and only that one. A later begin for the same - * channel installs a different trace object, so a failed navigation can never - * erase a newer attempt's measurement. - */ -export function cancelChannelSwitchTrace( - handle: ChannelSwitchTraceHandle | null, -): void { - if (handle && activeTrace === handle.trace) { - recordSwitchDrop(activeTrace, "navigation-failed"); - activeTrace = null; - } +function clearSwitchEntries(): void { + performance.clearMarks(CHANNEL_SWITCH_START_MARK); + performance.clearMarks(CHANNEL_SWITCH_SETTLED_MARK); + performance.clearMeasures(CHANNEL_SWITCH_MEASURE); } -export function beginChannelSwitchTrace( - channelId: string, - anchoredAt?: number, -): ChannelSwitchTraceHandle | null { - if (typeof performance === "undefined") return null; +export function beginChannelSwitchTrace(channelId: string): void { + if (typeof performance === "undefined") return; ensureVisibilityWatcher(); - // A same-task unmount-then-renavigate to this channel leaves the exit - // 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 and against a - // caller-supplied anchor that a monotonic-clock skew put in the future. + if (activeTrace) dropTrace(activeTrace, "superseded"); + // 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 cannot leak in from an async continuation. + // min() guards a skewed event clock; max() keeps the mark non-negative, + // which performance.mark requires. const now = performance.now(); - const { startedAt, anchorDiscarded } = resolveTraceAnchor( - anchoredAt, - captureSwitchTraceAnchor(), - now, - ); - if (activeTrace) recordSwitchDrop(activeTrace, "superseded"); + const dispatching = typeof window === "undefined" ? undefined : window.event; + const startedAt = + dispatching && typeof dispatching.timeStamp === "number" + ? Math.max(0, Math.min(dispatching.timeStamp, now)) + : now; activeTrace = { channelId, startedAt, - openedAt: now, - maxFrameGapMs: 0, - anchorDiscarded, - settleEnteredAt: null, routeCommitAt: null, windowFetch: null, - membersFetch: null, + settleEnteredAt: null, }; - const handle: ChannelSwitchTraceHandle = { trace: activeTrace }; - if (anchorDiscarded) { - console.info( - `[switch-perf] channel=${channelId.slice(0, 8)} anchor discarded (stale); measuring from navigation`, - ); - } - startStarvationHeartbeat(activeTrace); - // Clear the whole previous switch here, not only in record(): traces that - // die without recording (forum visits, route exits, drops) never reach - // record()'s buffer clearing — weeks-long sessions would accumulate a - // stray start mark per abandon — and a consumer polling the buffer - // mid-switch (Playwright specs, Performance panel) must never read the - // previous switch's settled mark or measure as the current one's. - performance.clearMarks(CHANNEL_SWITCH_START_MARK); - performance.clearMarks(CHANNEL_SWITCH_SETTLED_MARK); - performance.clearMeasures(CHANNEL_SWITCH_MEASURE); - // startTime keeps the mark on the same anchor as the measure — without it - // the Performance panel would show the measure starting before its own - // start mark by the input delay. + // Clear the previous switch here, not only on record: traces that die + // without recording never reach record()'s clearing, and a consumer polling + // the buffer mid-switch must never read the previous switch's entries. + clearSwitchEntries(); + // startTime keeps the mark on the same anchor as the measure. performance.mark(CHANNEL_SWITCH_START_MARK, { detail: { channelId }, startTime: startedAt, }); - return handle; } export function markChannelSwitchRouteCommit(channelId: string): void { @@ -526,14 +163,11 @@ export function markChannelSwitchRouteCommit(channelId: string): void { /** * A fetch attributes to the active trace only when it targets the traced - * channel and started inside the measured interval. Both bounds matter. A - * fetch that started before the switch (the first leg of a rapid A→B→A - * completing during the second A trace) is not this switch's cost, and - * letting it claim the `??=` slot would also block the real fetch. A fetch - * that started after the timeline settled is a background revalidation the - * user never waited on; the trace stays active through the deferred-paint - * wait, so without this upper bound it would be reported as switch cost. - * Pure for unit testing. + * channel and started inside the measured interval. A fetch that started + * before the switch (the first leg of a rapid A→B→A completing during the + * second A trace) is not this switch's cost, and letting it claim the slot + * would block the real fetch. A fetch that started after the timeline settled + * is background revalidation the user never waited on. Pure for unit testing. */ export function shouldAttributeFetch( trace: ChannelSwitchTrace | null, @@ -558,241 +192,104 @@ export function traceChannelWindowFetch( } /** - * Per-channel roster-fetch sequence numbers. A live join/leave invalidation - * cancels-and-replaces an in-flight roster refetch, but the underlying - * Tauri call cannot be cancelled — the superseded fetch still resolves and - * must not claim the trace's one-shot slot with a stale count. The query's - * AbortSignal is deliberately NOT used for this: merely reading - * `context.signal` flips React Query to cancel-and-revert when the last - * observer unsubscribes mid-fetch, discarding warm rosters on interrupted - * switches (a product-behavior change this instrumentation must not make). + * Drops the active trace for surfaces whose readiness this instrument cannot + * observe (forum channels, whose loading ForumView owns). Better no + * measurement than a systematically underreported one. */ -const channelMembersFetchSequences = new Map(); - -/** Registers a roster fetch attempt; pass the token to traceChannelMembersFetch. */ -export function openChannelMembersFetch(channelId: string): number { - const next = (channelMembersFetchSequences.get(channelId) ?? 0) + 1; - channelMembersFetchSequences.set(channelId, next); - return next; -} - -export function traceChannelMembersFetch( - channelId: string, - memberCount: number, - durationMs: number, - fetchStartedAt: number, - fetchAttempt?: number, -): void { - if ( - fetchAttempt !== undefined && - channelMembersFetchSequences.get(channelId) !== fetchAttempt - ) { - return; +export function abandonChannelSwitchTrace(channelId: string): void { + if (activeTrace?.channelId === channelId) { + dropTrace(activeTrace, "unobservable surface"); } - if (!shouldAttributeFetch(activeTrace, channelId, fetchStartedAt)) return; - activeTrace.membersFetch ??= { durationMs, memberCount }; } /** - * Drops any active trace. Community switches remount the app shell but this - * module-level singleton survives; channel ids are community-scoped, so a - * stale trace could adopt the next community's fetches. Wired into - * resetCommunityState() like every community-scoped singleton. + * Abandons whatever trace is active, regardless of channel. Called when + * navigation leaves the channel surface (any committed non-channel + * destination, any history traversal): a trace can be live with no channel + * screen mounted at all — the route still resolving — so this is the only + * reliable exit hook, and a later untraced re-entry would otherwise settle it + * with the time spent away. */ +export function dropActiveChannelSwitchTrace(): void { + if (activeTrace) dropTrace(activeTrace, "left channel surface"); +} + +/** Community switch (and test reset): nothing survives into the next one. */ export function resetChannelSwitchTrace(): void { - if (activeTrace) recordSwitchDrop(activeTrace, "community-reset"); + if (activeTrace) dropTrace(activeTrace, "community reset"); activeTrace = null; lastVisibilityChangeAt = Number.NEGATIVE_INFINITY; - // A community switch must not leave the previous community's marks where a - // consumer polling the buffer would read them as the next community's. - if (typeof performance !== "undefined") { - performance.clearMarks(CHANNEL_SWITCH_START_MARK); - performance.clearMarks(CHANNEL_SWITCH_SETTLED_MARK); - performance.clearMeasures(CHANNEL_SWITCH_MEASURE); - } - pendingRouteExitAbandons.clear(); - channelMembersFetchSequences.clear(); -} - -/** Bound on waiting for the deferred timeline commit before recording. */ -const SETTLE_RENDER_WAIT_MS = 5_000; - -/** - * Largest inter-frame gap attributable to real main-thread work (heavy - * long tasks run a few hundred ms; 4x-throttled harness frames stay well - * under this). Anything larger is rAF starvation — suspension without a - * visibilitychange — and the sample drops. - */ -const MAX_SETTLE_FRAME_GAP_MS = 3_000; - -/** - * Per-frame decision for the bounded settle wait: keep waiting only while - * the deferred render is still pending AND the deadline hasn't passed. When - * the wait ends with the render still pending, the record must say so — the - * >deadline tail is exactly what this tracer exists to expose, so the - * measurement is kept but flagged rather than posing as an honest settled - * paint. Frame starvation is dropped, not recorded: a healthy chain with - * nothing pending records within a frame or two of settle entry, so a - * not-pending frame landing past the wait deadline means the gap was rAF - * suspension (system suspend, App Nap — cases that fire no - * visibilitychange), not render time. A trace older than the settle-entry - * timeout plus the render wait is dropped on the same grounds regardless of - * pending state, as is a single inter-frame gap beyond any plausible - * main-thread stall — a suspension latches the pending marker, so its truth - * is not evidence the render was slow. The rare honest render that catches - * up within one frame of the deadline is sacrificed by these rules — better - * no measurement than a fabricated one. Pure for unit testing. - */ -export function resolveSettleWait( - now: number, - waitDeadline: number, - renderPending: boolean, - openedAt: number, - frameGapMs: number | null = null, -): "wait" | "drop" | { settleWaitTruncated: boolean } { - if (now - openedAt > SWITCH_TRACE_TIMEOUT_MS + SETTLE_RENDER_WAIT_MS) { - return "drop"; - } - if (frameGapMs !== null && frameGapMs > MAX_SETTLE_FRAME_GAP_MS) { - return "drop"; - } - if (!renderPending && now > waitDeadline) return "drop"; - if (renderPending && now < waitDeadline) return "wait"; - return { settleWaitTruncated: renderPending }; + if (typeof performance !== "undefined") clearSwitchEntries(); } /** - * Decides whether the post-readiness paint frame may still be recorded. - * `awaitDeferredCommit` guards every frame it drives, but the readiness → - * paint frame is a starvation seam of its own: process suspension (App Nap, - * a suspended VM) can land there with no `visibilitychange`, and the resumed - * frame would record the whole absence as an ordinary clean switch. Pure for - * unit testing. - */ -/** - * Reads the timeline's painted commit generation, or null when no timeline is - * mounted (Suspense fallback, forum surface). - */ -function readTimelineCommit(): number | null { - // Defensive: this runs on the settle path, and a diagnostic must never - // throw into the app. - const raw = document - .querySelector("[data-timeline-commit]") - ?.getAttribute?.("data-timeline-commit"); - if (raw == null) return null; - const parsed = Number(raw); - return Number.isFinite(parsed) ? parsed : null; -} - -/** - * Decides whether the switch's own deferred commit has painted. A pending - * marker alone cannot distinguish "my rows have not committed yet" from "live - * traffic arriving after my rows painted re-latched the marker" — and a burst - * right after a switch (the subscription's catch-up) is exactly when the - * second happens, which recorded the whole burst as switch latency, unflagged. - * A commit generation past the one painted at settle entry proves the - * timeline has committed since, so the switch's rows are on screen. - * Pure for unit testing. + * Decides what a settle call does with the active trace. A settle for a + * different channel must leave the trace alone — a previous channel can finish + * loading after the next switch began, and clobbering the newer trace would + * drop exactly the rapid switches worth capturing. Pure for unit testing. */ -export function resolveRenderReadiness( - renderPending: boolean, - commitAtEntry: number | null, - commitNow: number | null, -): boolean { - if (!renderPending) return true; - if (commitAtEntry === null || commitNow === null) return false; - return commitNow > commitAtEntry; -} - -export function resolveFinalFrame( +export function resolveSettleAction( + trace: ChannelSwitchTrace | null, + channelId: string, now: number, - readyAt: number, - openedAt: number, - renderWasPending = false, -): "record" | "drop" { - // A render still pending at readiness is direct evidence that a heavy - // commit — not a suspension — owns this frame, and that measurement is - // already flagged `settleWaitTruncated`. Dropping it here would discard - // exactly the pathological switch the instrument exists to expose. - if (!renderWasPending && now - readyAt > MAX_SETTLE_FRAME_GAP_MS) { - return "drop"; +): { settledTrace: ChannelSwitchTrace | null; timedOut: boolean } { + if (!trace || trace.channelId !== channelId) { + return { settledTrace: null, timedOut: false }; } - if (now - openedAt > SWITCH_TRACE_TIMEOUT_MS + SETTLE_RENDER_WAIT_MS) { - return "drop"; + if (now - trace.startedAt > SWITCH_TRACE_TIMEOUT_MS) { + return { settledTrace: null, timedOut: true }; } - return "record"; + return { settledTrace: trace, timedOut: false }; } /** * Closes the active trace once the settled frame has painted. The timeline - * renders rows through a deferred snapshot that exposes - * `data-render-pending` until the low-priority commit catches up, and the - * lazy channel pane's Suspense fallback carries the same marker while its - * chunk is still loading — waiting for both (bounded) keeps `totalMs` - * honest on render-heavy and cold-chunk switches; a final rAF pair then - * lands the mark after the browser paints. + * exposes `data-render-pending` until its deferred commit catches up, + * and the lazy channel pane's fallback carries the same marker while its chunk + * loads; waiting for both (bounded) keeps `totalMs` honest on render-heavy and + * cold-chunk switches. A final frame then lands the mark after the paint. */ export function settleChannelSwitchTrace(channelId: string): void { if (typeof performance === "undefined") return; - const { settledTrace, clearActive } = resolveSettleAction( + const { settledTrace, timedOut } = resolveSettleAction( activeTrace, channelId, performance.now(), ); if (!settledTrace) { - if (clearActive && activeTrace) { - recordSwitchDrop(activeTrace, "timeout"); - activeTrace = null; - } + if (timedOut && activeTrace) dropTrace(activeTrace, "timed out"); return; } const trace = settledTrace; - // The click -> settle-entry interval is guarded by the heartbeat, not by - // the settle loop's own frame gaps: a suspension there fires no - // visibilitychange and would otherwise land as a clean record. - if (trace.maxFrameGapMs > MAX_SETTLE_FRAME_GAP_MS) { - recordSwitchDrop(trace, "frame-starvation"); - activeTrace = null; - return; - } - // Both globals gate the whole settle path: the wait loop reads - // document.visibilityState and querySelector unguarded past this point. - if (typeof window === "undefined" || typeof document === "undefined") { - activeTrace = null; + // These globals gate the whole settle path: the wait loop reads + // document.visibilityState, querySelector and rAF unguarded past this point. + if ( + typeof window === "undefined" || + typeof document === "undefined" || + !window.requestAnimationFrame + ) { + dropTrace(trace, "no DOM"); return; } - // rAF suspends and network work throttles in hidden windows: a hidden - // interval anywhere between the click and the recorded settle would be - // charged to the switch as a clean record. Drop when the window is hidden - // now or any visibility transition happened since the click — better no - // measurement than a fabricated one. ensureVisibilityWatcher(); - if (traceOverlapsHiddenWindow(trace)) { - recordSwitchDrop(trace, "hidden-window"); - activeTrace = null; + if (overlapsHiddenWindow(trace)) { + dropTrace(trace, "hidden window"); return; } - // Keep the trace active through the deferred-commit wait so fetches that - // finish inside the measured window still attribute to it. It is released - // when the record lands; a newer switch's begin() simply replaces it. - // Stamping settle entry closes attribution to fetches that START after - // this point — those are background work, not switch cost. + // Idempotent: a second settle for this channel must not restart the wait or + // move the fetch-attribution bound. if (trace.settleEnteredAt !== null) return; - // Bind once: a swapped-out global would otherwise throw from inside a frame - // callback, where nothing can catch it (same hazard as the heartbeat). - const schedule = window.requestAnimationFrame.bind(window); trace.settleEnteredAt = performance.now(); - const commitAtEntry = readTimelineCommit(); - const waitDeadline = performance.now() + SETTLE_RENDER_WAIT_MS; + // Bind once — reading the ambient global on every frame would follow a + // swapped-out window and throw from inside a callback nothing can catch. + const schedule = window.requestAnimationFrame.bind(window); + const record = (settleWaitTruncated: boolean) => { const settledAt = performance.now(); if (activeTrace === trace) activeTrace = null; - // Keep only the latest switch in the User Timing buffer: desktop - // sessions run for weeks and the buffer is never GC'd. DevTools - // recordings capture entries at emit time, so clearing loses nothing. - performance.clearMarks(CHANNEL_SWITCH_START_MARK); - performance.clearMarks(CHANNEL_SWITCH_SETTLED_MARK); - performance.clearMeasures(CHANNEL_SWITCH_MEASURE); + // Keep only the latest switch in the User Timing buffer: desktop sessions + // run for weeks and the buffer is never GC'd. + clearSwitchEntries(); performance.mark(CHANNEL_SWITCH_SETTLED_MARK, { detail: { channelId }, startTime: settledAt, @@ -802,7 +299,6 @@ export function settleChannelSwitchTrace(channelId: string): void { channelId, routeCommitAt: trace.routeCommitAt, windowFetch: trace.windowFetch, - membersFetch: trace.membersFetch, ...(settleWaitTruncated ? { settleWaitTruncated: true } : {}), }, start: trace.startedAt, @@ -811,80 +307,32 @@ export function settleChannelSwitchTrace(channelId: string): void { console.info( summarizeChannelSwitchTrace(trace, settledAt, settleWaitTruncated), ); - appendSwitchPerfLogRecord( - buildSwitchPerfLogRecord(trace, settledAt, settleWaitTruncated), - ); - }; - const dropTrace = (reason: SwitchDropReason) => { - if (activeTrace === trace) { - recordSwitchDrop(trace, reason); - activeTrace = null; - } }; - // Seeded now, not on the first frame: the settle-entry → first-frame - // window must be starvation-guarded too, or a suspension there records a - // truncated measure inflated by the whole stall. - let lastFrameAt: number | null = performance.now(); - const awaitDeferredCommit = () => { - if (activeTrace !== trace) { - // A newer switch replaced this trace, or a community reset dropped it. - // Either way the paint this callback would sample is not this switch's - // own — recording would charge the replacement's delay to the settled - // channel and could manufacture the very regression the tracer exists - // to diagnose. Better no measurement than a fabricated one. - return; - } - if (traceOverlapsHiddenWindow(trace)) { - dropTrace("hidden-window"); - return; - } - const now = performance.now(); - const frameGapMs = lastFrameAt === null ? null : now - lastFrameAt; - lastFrameAt = now; - const renderPending = !resolveRenderReadiness( - document.querySelector('[data-render-pending="true"]') !== null, - commitAtEntry, - readTimelineCommit(), - ); - const decision = resolveSettleWait( - now, - waitDeadline, - renderPending, - trace.openedAt, - frameGapMs, - ); - if (decision === "wait") { - schedule(awaitDeferredCommit); + + let framesWaited = 0; + const awaitPaint = () => { + // A newer switch replaced this trace, or a reset dropped it. Either way + // the paint this callback would sample is not this switch's own. + if (activeTrace !== trace) return; + if (overlapsHiddenWindow(trace)) { + dropTrace(trace, "hidden window"); return; } - if (decision === "drop") { - dropTrace( - frameGapMs !== null && frameGapMs > MAX_SETTLE_FRAME_GAP_MS - ? "frame-starvation" - : "settle-wait-exceeded", - ); + const pending = document.querySelector(RENDER_PENDING_SELECTOR) !== null; + if (pending && framesWaited < MAX_SETTLE_FRAMES) { + framesWaited += 1; + schedule(awaitPaint); return; } - const readyAt = now; + // One more frame so the mark lands after the browser paints. schedule(() => { if (activeTrace !== trace) return; - if (traceOverlapsHiddenWindow(trace)) { - dropTrace("hidden-window"); - return; - } - if ( - resolveFinalFrame( - performance.now(), - readyAt, - trace.openedAt, - renderPending, - ) === "drop" - ) { - dropTrace("frame-starvation"); + if (overlapsHiddenWindow(trace)) { + dropTrace(trace, "hidden window"); return; } - record(decision.settleWaitTruncated); + record(pending); }); }; - schedule(awaitDeferredCommit); + schedule(awaitPaint); } diff --git a/desktop/tests/e2e/switch-settle-after-paint.spec.ts b/desktop/tests/e2e/switch-settle-after-paint.spec.ts index 8c902510973..fb264593e88 100644 --- a/desktop/tests/e2e/switch-settle-after-paint.spec.ts +++ b/desktop/tests/e2e/switch-settle-after-paint.spec.ts @@ -112,77 +112,37 @@ test("settle waits for a delayed lazy channel-pane chunk", async ({ page }) => { }) .toBe(true); - // Give the tracer ample frames to (incorrectly) settle behind the held - // chunk. The tracer's 5s render-wait deadline runs from settle entry - // (query readiness — immediate in mock mode), after which it honestly - // emits a TRUNCATED measure even while suspended; the contract under test - // is that no CLEAN measure appears. Guard the timing assumption - // explicitly so a slow CI box fails with the real reason. - await page.waitForTimeout(1_500); - const early = await page.evaluate((name) => { - const start = performance.getEntriesByName("buzz:channel-switch:start")[0]; - return { - cleanMeasures: performance + // While the pane chunk is held there are no rows on screen, so any measure + // emitted here must carry the truncation flag. A CLEAN measure would be the + // regression: a settled paint reported for a screen showing a fallback. + await page.waitForTimeout(1_000); + const cleanWhileSuspended = await page.evaluate( + (name) => + performance .getEntriesByName(name) .filter( (entry) => !(entry as PerformanceMeasure).detail?.settleWaitTruncated, ).length, - elapsedSinceClick: start ? performance.now() - start.startTime : null, - }; - }, SWITCH_MEASURE); - // Order matters: a clean measure recorded behind the held chunk — the - // regression under test — clears the start mark, nulling elapsedSinceClick. - // Asserting the budget first would then fail with a "rerun, not a tracer - // bug" message that states the opposite of the truth. + SWITCH_MEASURE, + ); expect( - early.cleanMeasures, + cleanWhileSuspended, "no clean settle may be recorded while the pane chunk is suspended", ).toBe(0); - // Boolean form: a truncated record already landing clears the start mark - // and nulls elapsedSinceClick — that too is budget exhaustion, and must - // fail with this message rather than a raw matcher error. - expect( - early.elapsedSinceClick !== null && early.elapsedSinceClick < 4_500, - "harness overhead consumed the tracer's settle deadline — timing, not a tracer bug", - ).toBe(true); releaseChunk(); - // Same in-page polling as above: snapshot the DOM in the evaluation turn - // where the first CLEAN measure exists. A truncated measure here means the - // released chunk's mount outran the remaining render-wait budget — a - // harness timing exhaustion, and it must fail with that reason. - const atSettle = await page.evaluate(async (measureName) => { - const deadline = Date.now() + 15_000; - while (Date.now() < deadline) { - const entries = performance.getEntriesByName(measureName); - const clean = entries.filter( - (entry) => !(entry as PerformanceMeasure).detail?.settleWaitTruncated, - ); - if (clean.length > 0) { - return { - rowCount: document.querySelectorAll( - '[data-message-id^="mock-deep-history-"]', - ).length, - settled: true, - truncatedOnly: false, - }; - } - if (entries.length > 0) { - return { rowCount: 0, settled: false, truncatedOnly: true }; - } - await new Promise((resolve) => setTimeout(resolve, 16)); - } - return { rowCount: 0, settled: false, truncatedOnly: false }; - }, SWITCH_MEASURE); - - expect( - atSettle.truncatedOnly, - "tracer truncated before the released chunk painted — harness timing exhausted, not a tracer bug; rerun", - ).toBe(false); - expect(atSettle.settled, "switch trace must settle after release").toBe(true); - expect( - atSettle.rowCount, - "the settle must land only after the released pane painted rows", - ).toBeGreaterThan(0); + // After release the pane mounts and paints rows. The tracer's own record + // may already have landed (truncated, by design) — what matters is that the + // channel actually painted, which is what a clean settle would have claimed. + await expect + .poll( + () => + page + .locator('[data-message-id^="mock-deep-history-"]') + .count() + .then((count) => count > 0), + { message: "the released pane must paint rows", timeout: 15_000 }, + ) + .toBe(true); }); From be26be01810282f824ab16a97abc09e96ed60f0e Mon Sep 17 00:00:00 2001 From: Max Lampert Date: Wed, 26 Aug 2026 20:05:37 -0700 Subject: [PATCH 23/27] test(desktop): drop the import left behind by the tracer reduction Signed-off-by: Max Lampert --- .../channels/useChannelSwitchTraceMarks.test.mjs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/desktop/src/features/channels/useChannelSwitchTraceMarks.test.mjs b/desktop/src/features/channels/useChannelSwitchTraceMarks.test.mjs index c329bd2b921..7177b08a0ec 100644 --- a/desktop/src/features/channels/useChannelSwitchTraceMarks.test.mjs +++ b/desktop/src/features/channels/useChannelSwitchTraceMarks.test.mjs @@ -10,14 +10,13 @@ import { CHANNEL_SWITCH_MEASURE, beginChannelSwitchTrace, resetChannelSwitchTrace, - settleChannelSwitchTrace, } from "../../shared/lib/channelSwitchPerf.ts"; import { useChannelSwitchTraceMarks } from "./useChannelSwitchTraceMarks.ts"; // These tests run the hook under the real react-dom development build, whose -// StrictMode replays every effect (setup → cleanup → setup) on mount — the -// exact dev-runtime lifecycle that used to abandon a just-opened trace and -// break the Performance-panel workflow. +// StrictMode replays every effect (setup → cleanup → setup) on mount. The +// Performance-panel workflow runs on that build, so a trace opened just +// before mount has to survive the replay. const originalDocument = globalThis.document; const originalWindow = globalThis.window; @@ -101,7 +100,7 @@ it("a trace survives StrictMode's effect replay and still settles", async () => dom.window.close(); }); -it("an A→B switch's deferred abandon of A never kills B's trace", async () => { +it("an A→B switch records B, never the superseded A", async () => { const { dom, flushFrames } = setupDom(); performance.clearMeasures?.(CHANNEL_SWITCH_MEASURE); beginChannelSwitchTrace("chan-a"); From 8a8e01cfb0421199d3293d8606b71e8e066c0d06 Mon Sep 17 00:00:00 2001 From: Max Lampert Date: Wed, 26 Aug 2026 20:31:48 -0700 Subject: [PATCH 24/27] refactor(desktop): drop the switch tracer, keep the perf harness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The instrument was over half this PR and the source of nearly every review finding, and the benchmark that actually validates the perf work does not depend on it: member-heavy-switch.perf.ts times click -> ready itself with performance.now() and DOM readiness, and never reads a tracer export. Its remaining value was a dev console line, against a failure mode of reporting a number that never happened — which is worse than reporting nothing, because it sends someone chasing a regression that does not exist. The live baselines it produced are already recorded in the PR description and stand on their own; they justified the fixes, and the fixes are the deliverable. Removed: channelSwitchPerf.ts and its tests, useChannelSwitchTraceMarks, switch-settle-after-paint.spec.ts, the fetch attribution in the window queryFn, the trace hooks in commitGuardedNavigation/useAppNavigation, and the community-init reset. Kept, because the perf fixes build on them: - member-heavy-switch.perf.ts plus the inflateChannelMembers bridge knob — the high-membership benchmark - useChannelTimelineLoading — the per-channel loading latch - commitGuardedNavigation — the shared guard/no-op commit flow - the Projects hydration marker the harness gates readiness on - routing Pulse note actions through goChannel, so that DM entry goes through the same navigation guard as every other one Signed-off-by: Max Lampert --- desktop/playwright.config.ts | 1 - .../commitGuardedNavigation.test.mjs | 177 +-------- .../app/navigation/commitGuardedNavigation.ts | 26 +- desktop/src/app/navigation/navigationGuard.ts | 7 - .../src/app/navigation/useAppNavigation.ts | 20 +- .../useChannelSwitchTraceMarks.test.mjs | 116 ------ .../channels/useChannelSwitchTraceMarks.ts | 43 --- .../channels/useChannelTimelineLoading.ts | 12 +- .../features/communities/useCommunityInit.ts | 5 - desktop/src/features/messages/hooks.ts | 52 +-- .../lib/projectChannelWindow.test.mjs | 91 +---- .../src/shared/lib/channelSwitchPerf.test.mjs | 324 ----------------- desktop/src/shared/lib/channelSwitchPerf.ts | 338 ------------------ .../e2e/switch-settle-after-paint.spec.ts | 148 -------- 14 files changed, 12 insertions(+), 1348 deletions(-) delete mode 100644 desktop/src/features/channels/useChannelSwitchTraceMarks.test.mjs delete mode 100644 desktop/src/features/channels/useChannelSwitchTraceMarks.ts delete mode 100644 desktop/src/shared/lib/channelSwitchPerf.test.mjs delete mode 100644 desktop/src/shared/lib/channelSwitchPerf.ts delete mode 100644 desktop/tests/e2e/switch-settle-after-paint.spec.ts diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 9e1a75929d3..be15c75587d 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -109,7 +109,6 @@ export default defineConfig({ "**/overscroll-boundary.spec.ts", "**/terminal-wheel.spec.ts", "**/cold-switch-longtask.perf.ts", - "**/switch-settle-after-paint.spec.ts", "**/timeline-no-shift.spec.ts", "**/human-edit-agent-content.spec.ts", "**/empty-edit-delete.spec.ts", diff --git a/desktop/src/app/navigation/commitGuardedNavigation.test.mjs b/desktop/src/app/navigation/commitGuardedNavigation.test.mjs index 0b836ec20b2..9f271383d98 100644 --- a/desktop/src/app/navigation/commitGuardedNavigation.test.mjs +++ b/desktop/src/app/navigation/commitGuardedNavigation.test.mjs @@ -2,167 +2,10 @@ import assert from "node:assert/strict"; import test from "node:test"; import { commitGuardedNavigation } from "./commitGuardedNavigation.ts"; -import { registerNavigationGuard, traverseHistory } from "./navigationGuard.ts"; -import { - CHANNEL_SWITCH_MEASURE, - beginChannelSwitchTrace, - resetChannelSwitchTrace, - settleChannelSwitchTrace, -} from "../../shared/lib/channelSwitchPerf.ts"; +import { registerNavigationGuard } from "./navigationGuard.ts"; const route = (href) => ({ kind: "route", href }); -// Frame/document stubs so settle's rAF chain can be driven synchronously. -function withTraceHarness(run) { - const frames = []; - const originalWindow = globalThis.window; - const originalDocument = globalThis.document; - globalThis.window = { - requestAnimationFrame: (cb) => frames.push(cb) && frames.length, - cancelAnimationFrame: () => {}, - }; - globalThis.document = { - addEventListener: () => {}, - querySelector: () => null, - removeEventListener: () => {}, - visibilityState: "visible", - }; - performance.clearMeasures?.(CHANNEL_SWITCH_MEASURE); - const flush = () => { - for (let i = 0; i < 20 && frames.length > 0; i += 1) { - for (const cb of frames.splice(0, frames.length)) cb(); - } - }; - const measures = () => - performance - .getEntriesByName(CHANNEL_SWITCH_MEASURE) - .map((entry) => entry.detail?.channelId); - return (async () => { - try { - await run({ flush, measures }); - } finally { - resetChannelSwitchTrace(); - performance.clearMeasures?.(CHANNEL_SWITCH_MEASURE); - if (originalWindow === undefined) delete globalThis.window; - else globalThis.window = originalWindow; - if (originalDocument === undefined) delete globalThis.document; - else globalThis.document = originalDocument; - } - })(); -} - -test("a committed non-channel navigation drops the active trace", async () => { - await withTraceHarness(async ({ flush, measures }) => { - // A trace can be live with no channel screen mounted at all (the route - // still resolving), so no route-exit cleanup exists to abandon it — the - // navigation layer must drop it, or a history-back re-entry within the - // 30s timeout would settle it with the time spent away. - beginChannelSwitchTrace("bbbb"); - const committed = await commitGuardedNavigation({ - currentHref: "/channels/bbbb", - nextHref: "/", - guardedTarget: route("/"), - leavesChannelSurface: true, - navigate: async () => {}, - }); - assert.equal(committed, true); - settleChannelSwitchTrace("bbbb"); - flush(); - assert.deepEqual(measures(), []); - }); -}); - -test("a same-channel navigation never drops the channel's live trace", async () => { - await withTraceHarness(async ({ flush, measures }) => { - beginChannelSwitchTrace("bbbb"); - // Jump-to-message within the active channel: untraced, but must not - // kill the in-flight trace either. - await commitGuardedNavigation({ - currentHref: "/channels/bbbb", - nextHref: "/channels/bbbb?messageId=m1", - guardedTarget: route("/channels/bbbb?messageId=m1"), - leavesChannelSurface: false, - navigate: async () => {}, - }); - settleChannelSwitchTrace("bbbb"); - flush(); - assert.deepEqual(measures(), ["bbbb"]); - }); -}); - -test("history traversal drops the active trace", async () => { - await withTraceHarness(async ({ flush, measures }) => { - beginChannelSwitchTrace("bbbb"); - const calls = []; - // History navigation is deliberately untraced and its destination is - // unknowable here — a live trace must not survive into it. - traverseHistory( - { back: () => calls.push("back"), forward: () => {} }, - "back", - ); - assert.deepEqual(calls, ["back"]); - settleChannelSwitchTrace("bbbb"); - flush(); - assert.deepEqual(measures(), []); - }); -}); - -test("a refused navigation opens no trace; a later history settle records nothing", async () => { - // Frame-queue stub so the settle's rAF chain can be drained synchronously. - const frames = []; - const originalWindow = globalThis.window; - const originalDocument = globalThis.document; - globalThis.window = { - requestAnimationFrame: (cb) => frames.push(cb) && frames.length, - cancelAnimationFrame: () => {}, - }; - globalThis.document = { - addEventListener: () => {}, - querySelector: () => null, - removeEventListener: () => {}, - visibilityState: "visible", - }; - performance.clearMeasures?.(CHANNEL_SWITCH_MEASURE); - const unregister = registerNavigationGuard(() => false); - try { - let navigated = false; - const committed = await commitGuardedNavigation({ - currentHref: "/channels/aaaa", - nextHref: "/channels/bbbb", - guardedTarget: route("/channels/bbbb"), - traceChannelId: "bbbb", - navigate: async () => { - navigated = true; - }, - }); - assert.equal(committed, false); - assert.equal(navigated, false); - - // Browser Back into the refused channel (history navigation is - // deliberately untraced): its mount settles, and must find NO orphan - // trace from the refused click — otherwise the measure would span the - // refusal and everything the user did in between. - settleChannelSwitchTrace("bbbb"); - for (let i = 0; i < 20 && frames.length > 0; i += 1) { - for (const cb of frames.splice(0, frames.length)) cb(); - } - assert.deepEqual( - performance - .getEntriesByName(CHANNEL_SWITCH_MEASURE) - .map((entry) => entry.detail?.channelId), - [], - ); - } finally { - unregister(); - resetChannelSwitchTrace(); - performance.clearMeasures?.(CHANNEL_SWITCH_MEASURE); - if (originalWindow === undefined) delete globalThis.window; - else globalThis.window = originalWindow; - if (originalDocument === undefined) delete globalThis.document; - else globalThis.document = originalDocument; - } -}); - test("an accepted navigation opens the trace after the guard, before navigate", async () => { const order = []; const committed = await commitGuardedNavigation( @@ -170,7 +13,6 @@ test("an accepted navigation opens the trace after the guard, before navigate", currentHref: "/channels/aaaa", nextHref: "/channels/bbbb", guardedTarget: route("/channels/bbbb"), - traceChannelId: "bbbb", navigate: async () => { order.push("navigate"); }, @@ -180,13 +22,10 @@ test("an accepted navigation opens the trace after the guard, before navigate", order.push("guard"); return true; }, - beginTrace: (channelId) => { - order.push(`begin:${channelId}`); - }, }, ); assert.equal(committed, true); - assert.deepEqual(order, ["guard", "begin:bbbb", "navigate"]); + assert.deepEqual(order, ["guard", "navigate"]); }); test("a same-destination no-op consults neither the guard nor the trace", async () => { @@ -196,7 +35,6 @@ test("a same-destination no-op consults neither the guard nor the trace", async currentHref: "/channels/aaaa", nextHref: "/channels/aaaa", guardedTarget: route("/channels/aaaa"), - traceChannelId: "aaaa", navigate: async () => { order.push("navigate"); }, @@ -206,9 +44,6 @@ test("a same-destination no-op consults neither the guard nor the trace", async order.push("guard"); return true; }, - beginTrace: () => { - order.push("begin"); - }, }, ); assert.equal(committed, false); @@ -232,14 +67,9 @@ test("force overrides the same-destination no-op but still runs the guard first" order.push("guard"); return true; }, - beginTrace: () => { - order.push("begin"); - }, }, ); assert.equal(committed, true); - // No traceChannelId: forced re-selection of the active channel stays - // untraced (nothing would settle it). assert.deepEqual(order, ["guard", "navigate"]); }); @@ -260,9 +90,6 @@ test("a same-destination navigation carrying router state still commits", async order.push("guard"); return true; }, - beginTrace: () => { - order.push("begin"); - }, }, ); assert.equal(committed, true); diff --git a/desktop/src/app/navigation/commitGuardedNavigation.ts b/desktop/src/app/navigation/commitGuardedNavigation.ts index 449442f5c94..a83df6ecc69 100644 --- a/desktop/src/app/navigation/commitGuardedNavigation.ts +++ b/desktop/src/app/navigation/commitGuardedNavigation.ts @@ -2,24 +2,14 @@ import { allowNavigation, type GuardedNavigation, } from "@/app/navigation/navigationGuard"; -import { - beginChannelSwitchTrace, - dropActiveChannelSwitchTrace, -} from "@/shared/lib/channelSwitchPerf"; /** * commitGuardedNavigation runs the shared commit flow for app navigations: * skip same-destination no-ops, consult the navigation guard, then navigate. * `force` and `hasStateUpdate` both defeat the no-op skip — a same-href * navigation that writes router state (setting or clearing the search - * highlight) must commit, or the state never lands. When `traceChannelId` is - * set, the channel-switch trace opens only after the guard accepts — a - * refused click must not leave an orphan trace that a later history - * navigation (deliberately untraced) would settle with the refused click's - * inflated wall time. When `leavesChannelSurface` is set, any active trace is - * dropped instead: the trace may be live with no channel screen mounted - * (route still resolving), so this is the only reliable exit hook. Returns - * whether the navigation was performed. `deps` exists for unit tests. + * highlight) must commit, or the state never lands. Returns whether the + * navigation was performed. `deps` exists for unit tests. */ export async function commitGuardedNavigation( input: { @@ -28,19 +18,13 @@ export async function commitGuardedNavigation( force?: boolean; guardedTarget: GuardedNavigation; hasStateUpdate?: boolean; - leavesChannelSurface?: boolean; - traceChannelId?: string; navigate: () => Promise; }, deps: { allow?: typeof allowNavigation; - beginTrace?: typeof beginChannelSwitchTrace; - dropActiveTrace?: typeof dropActiveChannelSwitchTrace; } = {}, ): Promise { const allow = deps.allow ?? allowNavigation; - const beginTrace = deps.beginTrace ?? beginChannelSwitchTrace; - const dropActiveTrace = deps.dropActiveTrace ?? dropActiveChannelSwitchTrace; if ( input.currentHref === input.nextHref && !input.force && @@ -51,12 +35,6 @@ export async function commitGuardedNavigation( if (!allow(input.guardedTarget)) { return false; } - if (input.leavesChannelSurface) { - dropActiveTrace(); - } - if (input.traceChannelId !== undefined) { - beginTrace(input.traceChannelId); - } await input.navigate(); return true; } diff --git a/desktop/src/app/navigation/navigationGuard.ts b/desktop/src/app/navigation/navigationGuard.ts index 8fc78f869d7..5ff853720b4 100644 --- a/desktop/src/app/navigation/navigationGuard.ts +++ b/desktop/src/app/navigation/navigationGuard.ts @@ -1,5 +1,3 @@ -import { dropActiveChannelSwitchTrace } from "@/shared/lib/channelSwitchPerf"; - export type GuardedNavigation = | { kind: "history"; @@ -42,11 +40,6 @@ export function traverseHistory( return false; } - // History navigation is deliberately untraced and its destination is - // unknowable here: a live switch trace must not survive into it, or an - // untraced re-entry into the traced channel would settle it with the time - // spent away. - dropActiveChannelSwitchTrace(); history[direction](); return true; } diff --git a/desktop/src/app/navigation/useAppNavigation.ts b/desktop/src/app/navigation/useAppNavigation.ts index c963bc25e60..083efc4450c 100644 --- a/desktop/src/app/navigation/useAppNavigation.ts +++ b/desktop/src/app/navigation/useAppNavigation.ts @@ -41,7 +41,6 @@ export function useAppNavigation() { }, behavior: NavigationBehavior = {}, guardedTarget?: GuardedNavigation, - traceChannelId?: string, ) => { const nextLocation = router.buildLocation(next as never); return commitGuardedNavigation({ @@ -52,12 +51,6 @@ export function useAppNavigation() { href: nextLocation.href, }, hasStateUpdate: next.state !== undefined, - // Leaving the channel surface must drop any active switch trace — - // including one whose channel screen never mounted (route still - // resolving), which no component cleanup can cover. Only the exact - // channel message-view route keeps a live trace: sibling routes - // (forum posts) mount different, untraced screens. - leavesChannelSurface: next.to !== "/channels/$channelId", navigate: () => navigate({ ...next, @@ -65,7 +58,6 @@ export function useAppNavigation() { resetScroll: behavior.resetScroll, } as never), nextHref: nextLocation.href, - traceChannelId, }); }, [location.href, navigate, router], @@ -325,19 +317,9 @@ export function useAppNavigation() { threadRootId: options.threadRootId ?? null, } : undefined, - // goChannel is the anchor for the switch trace; it opens inside - // commitGuardedNavigation only after the navigation guard accepts. - // Callers that await before navigating (DM actions await open_dm) - // are measured from the navigation, not from their click — see the - // scope note in channelSwitchPerf.ts. History back/forward is - // untraced, and navigations that stay on the already-active channel - // never re-run the settle effects, so a trace could only time out. - location.pathname.endsWith(`/channels/${channelId}`) - ? undefined - : channelId, ); }, - [commitNavigation, location.pathname], + [commitNavigation], ); const goNewMessage = React.useCallback( diff --git a/desktop/src/features/channels/useChannelSwitchTraceMarks.test.mjs b/desktop/src/features/channels/useChannelSwitchTraceMarks.test.mjs deleted file mode 100644 index 7177b08a0ec..00000000000 --- a/desktop/src/features/channels/useChannelSwitchTraceMarks.test.mjs +++ /dev/null @@ -1,116 +0,0 @@ -import assert from "node:assert/strict"; -import { afterEach, it } from "node:test"; - -import { JSDOM } from "jsdom"; -import React from "react"; -import { act } from "react"; -import { createRoot } from "react-dom/client"; - -import { - CHANNEL_SWITCH_MEASURE, - beginChannelSwitchTrace, - resetChannelSwitchTrace, -} from "../../shared/lib/channelSwitchPerf.ts"; -import { useChannelSwitchTraceMarks } from "./useChannelSwitchTraceMarks.ts"; - -// These tests run the hook under the real react-dom development build, whose -// StrictMode replays every effect (setup → cleanup → setup) on mount. The -// Performance-panel workflow runs on that build, so a trace opened just -// before mount has to survive the replay. - -const originalDocument = globalThis.document; -const originalWindow = globalThis.window; -const originalActEnvironment = globalThis.IS_REACT_ACT_ENVIRONMENT; - -afterEach(() => { - resetChannelSwitchTrace(); - performance.clearMeasures?.(CHANNEL_SWITCH_MEASURE); - if (originalDocument === undefined) delete globalThis.document; - else globalThis.document = originalDocument; - if (originalWindow === undefined) delete globalThis.window; - else globalThis.window = originalWindow; - if (originalActEnvironment === undefined) - delete globalThis.IS_REACT_ACT_ENVIRONMENT; - else globalThis.IS_REACT_ACT_ENVIRONMENT = originalActEnvironment; -}); - -function setupDom() { - const dom = new JSDOM( - "
", - ); - const frames = []; - dom.window.requestAnimationFrame = (cb) => frames.push(cb) && frames.length; - dom.window.cancelAnimationFrame = () => {}; - Object.assign(globalThis, { - document: dom.window.document, - IS_REACT_ACT_ENVIRONMENT: true, - window: dom.window, - }); - const flushFrames = () => { - // Drain chained rAFs until quiescent. - for (let i = 0; i < 20 && frames.length > 0; i += 1) { - for (const cb of frames.splice(0, frames.length)) cb(); - } - }; - return { dom, flushFrames }; -} - -function Harness({ channelId, isTimelineLoading }) { - useChannelSwitchTraceMarks({ - activeChannelId: channelId, - activeChannelType: "stream", - isTimelineLoading, - }); - return null; -} - -function renderHarness(root, props) { - return act(async () => - root.render( - React.createElement( - React.StrictMode, - null, - React.createElement(Harness, props), - ), - ), - ); -} - -const measures = () => - performance - .getEntriesByName(CHANNEL_SWITCH_MEASURE) - .map((entry) => entry.detail?.channelId); - -it("a trace survives StrictMode's effect replay and still settles", async () => { - const { dom, flushFrames } = setupDom(); - performance.clearMeasures?.(CHANNEL_SWITCH_MEASURE); - beginChannelSwitchTrace("chan-strict"); - const root = createRoot(document.getElementById("root")); - await renderHarness(root, { - channelId: "chan-strict", - isTimelineLoading: true, - }); - await renderHarness(root, { - channelId: "chan-strict", - isTimelineLoading: false, - }); - flushFrames(); - assert.deepEqual(measures(), ["chan-strict"]); - await act(async () => root.unmount()); - dom.window.close(); -}); - -it("an A→B switch records B, never the superseded A", async () => { - const { dom, flushFrames } = setupDom(); - performance.clearMeasures?.(CHANNEL_SWITCH_MEASURE); - beginChannelSwitchTrace("chan-a"); - const root = createRoot(document.getElementById("root")); - await renderHarness(root, { channelId: "chan-a", isTimelineLoading: true }); - beginChannelSwitchTrace("chan-b"); - await renderHarness(root, { channelId: "chan-b", isTimelineLoading: true }); - await renderHarness(root, { channelId: "chan-b", isTimelineLoading: false }); - flushFrames(); - assert.deepEqual(measures(), ["chan-b"]); - await act(async () => root.unmount()); - dom.window.close(); -}); diff --git a/desktop/src/features/channels/useChannelSwitchTraceMarks.ts b/desktop/src/features/channels/useChannelSwitchTraceMarks.ts deleted file mode 100644 index bef5d3d6145..00000000000 --- a/desktop/src/features/channels/useChannelSwitchTraceMarks.ts +++ /dev/null @@ -1,43 +0,0 @@ -import * as React from "react"; - -import { - abandonChannelSwitchTrace, - markChannelSwitchRouteCommit, - settleChannelSwitchTrace, -} from "@/shared/lib/channelSwitchPerf"; -import type { ChannelType } from "@/shared/api/types"; - -/** - * Switch-trace stage marks for the channel screen. Route commit fires in a - * layout effect — before the first paint — of the first commit where the - * target channel object has resolved; settle fires once its timeline leaves - * the loading latch. Both are no-ops unless goChannel opened a trace for this - * channel. Forum readiness is owned by ForumView's own queries, which the - * timeline latch cannot observe — those traces are abandoned instead of - * underreported. - */ -export function useChannelSwitchTraceMarks({ - activeChannelId, - activeChannelType, - isTimelineLoading, -}: { - activeChannelId: string | null; - activeChannelType: ChannelType | null; - isTimelineLoading: boolean; -}): void { - // Layout effect: a passive effect flushes after paint, which would report - // "commit" as first-paint time rather than commit time. - React.useLayoutEffect(() => { - if (activeChannelId) markChannelSwitchRouteCommit(activeChannelId); - }, [activeChannelId]); - React.useEffect(() => { - if (!activeChannelId) return; - if (activeChannelType === "forum") { - abandonChannelSwitchTrace(activeChannelId); - return; - } - if (!isTimelineLoading) { - settleChannelSwitchTrace(activeChannelId); - } - }, [activeChannelId, activeChannelType, isTimelineLoading]); -} diff --git a/desktop/src/features/channels/useChannelTimelineLoading.ts b/desktop/src/features/channels/useChannelTimelineLoading.ts index 2263bd5075e..0427c586801 100644 --- a/desktop/src/features/channels/useChannelTimelineLoading.ts +++ b/desktop/src/features/channels/useChannelTimelineLoading.ts @@ -1,7 +1,6 @@ import * as React from "react"; import { useQueryClient } from "@tanstack/react-query"; -import { useChannelSwitchTraceMarks } from "@/features/channels/useChannelSwitchTraceMarks"; import { hasPersistedHydratedChannel } from "@/features/messages/lib/channelHeadCache"; import { resolveTimelineLoadingLatch, @@ -10,9 +9,9 @@ import { import type { Channel } from "@/shared/api/types"; /** - * Latches the timeline loading state per channel and drives the - * channel-switch trace marks from that same latch, so the tracer settles on - * exactly the loading state the screen renders from. + * Latches the timeline loading state per channel, so a channel that has + * already settled once does not flash its skeleton again while an + * authoritative refresh is in flight. */ export function useChannelTimelineLoading( activeChannel: Channel | null, @@ -52,10 +51,5 @@ export function useChannelTimelineLoading( timelineLoadingNow, ); settledChannelIdRef.current = settledChannelId; - useChannelSwitchTraceMarks({ - activeChannelId, - activeChannelType: activeChannel?.channelType ?? null, - isTimelineLoading, - }); return isTimelineLoading; } diff --git a/desktop/src/features/communities/useCommunityInit.ts b/desktop/src/features/communities/useCommunityInit.ts index eb987378955..c565ee0f7b4 100644 --- a/desktop/src/features/communities/useCommunityInit.ts +++ b/desktop/src/features/communities/useCommunityInit.ts @@ -34,7 +34,6 @@ import { resetAgentObserverStore } from "@/features/agents/observerRelayStore"; import { resetAvatarPresentations } from "@/features/profile/avatarPresentationStore"; import { resetAvatarProfileSync } from "@/features/profile/avatarProfileSync"; import { resetSidebarRelayConnectionCardState } from "@/features/sidebar/ui/useSidebarRelayConnectionCard"; -import { resetChannelSwitchTrace } from "@/shared/lib/channelSwitchPerf"; import { clearMarkdownNodeCache } from "@/shared/ui/markdown/nodeCache"; import { resetMessageLinkMetadataCache } from "@/shared/ui/markdown/useMessageLinkMetadata"; import { resetVideoPlayerState } from "@/shared/ui/videoPlayerState"; @@ -58,10 +57,6 @@ async function resetCommunityState({ resetAvatarState: boolean; }): Promise { relayClient.disconnect(); - // Before the first await: the trace singleton must not survive into the - // async teardown window — queued frame callbacks could still record against - // it, and a rejection below would skip any reset placed after the await. - resetChannelSwitchTrace(); await resetNavigationDeepLinkDrain(); resetRateLimitGate(); clearAllDrafts(); diff --git a/desktop/src/features/messages/hooks.ts b/desktop/src/features/messages/hooks.ts index d97f69c2055..d28f2926081 100644 --- a/desktop/src/features/messages/hooks.ts +++ b/desktop/src/features/messages/hooks.ts @@ -44,7 +44,6 @@ import { recordTimeoutFromRejection, } from "@/features/moderation/lib/timeoutStore"; import { relayClient, setVisibleChannel } from "@/shared/api/relayClient"; -import { traceChannelWindowFetch } from "@/shared/lib/channelSwitchPerf"; import { customEmojiQueryKey } from "@/features/custom-emoji/hooks"; import { channelsQueryKey } from "@/features/channels/hooks"; import { reactionEmojiUrl } from "@/shared/api/customEmoji"; @@ -285,47 +284,6 @@ export function reconcileFetchedChannelWindow( return reconcileChannelWindowMessages(next, previousMessages); } -/** - * Reconciles a fetched window and then attributes it to the active switch - * trace. The ORDER is the contract: reconciliation throws for aborted - * requests, so a canceled fetch never reaches attribution and cannot claim - * the trace's one-shot slot ahead of the accepted replacement. Duration is - * measured over the fetch alone and passed in. Exported so the ordering is - * exercised by tests rather than restated by them. - */ -export function reconcileAndAttributeChannelWindow({ - queryClient, - channelId, - events, - previousMessages, - signal, - fetchDurationMs, - fetchStartedAt, -}: { - queryClient: QueryClient; - channelId: string; - events: RelayEvent[]; - previousMessages: RelayEvent[]; - signal: AbortSignal; - fetchDurationMs: number; - fetchStartedAt: number; -}): RelayEvent[] { - const result = reconcileFetchedChannelWindow( - queryClient, - channelId, - events, - previousMessages, - signal, - ); - traceChannelWindowFetch( - channelId, - events.length, - fetchDurationMs, - fetchStartedAt, - ); - return result; -} - export function useChannelMessagesQuery(channel: Channel | null) { const queryClient = useQueryClient(); const queryKey = channelMessagesKey(channel?.id ?? "none"); @@ -343,18 +301,14 @@ export function useChannelMessagesQuery(channel: Channel | null) { } const previousMessages = queryClient.getQueryData(queryKey) ?? []; - const fetchStartedAt = performance.now(); const events = await getChannelWindowEvents(channel.id); - const fetchDurationMs = performance.now() - fetchStartedAt; - return reconcileAndAttributeChannelWindow({ + return reconcileFetchedChannelWindow( queryClient, - channelId: channel.id, + channel.id, events, previousMessages, signal, - fetchDurationMs, - fetchStartedAt, - }); + ); }, staleTime: 5 * 60 * 1_000, gcTime: 60 * 60 * 1_000, diff --git a/desktop/src/features/messages/lib/projectChannelWindow.test.mjs b/desktop/src/features/messages/lib/projectChannelWindow.test.mjs index 922d729ef57..618f3fc9912 100644 --- a/desktop/src/features/messages/lib/projectChannelWindow.test.mjs +++ b/desktop/src/features/messages/lib/projectChannelWindow.test.mjs @@ -2,10 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { QueryClient, QueryObserver } from "@tanstack/react-query"; -import { - reconcileAndAttributeChannelWindow, - reconcileFetchedChannelWindow, -} from "../hooks.ts"; +import { reconcileFetchedChannelWindow } from "../hooks.ts"; import { channelMessagesKey, channelWindowKey } from "./messageQueryKeys.ts"; import { appendOlderChannelWindow, @@ -419,89 +416,3 @@ test("test_concurrent_refreshes_after_seeded_snapshot_share_one_authoritative_fe unsubscribe(); } }); - -test("canceled fetch never claims the switch trace's window slot; the accepted one does", async () => { - // Mirror of channelMessagesQueryOptions' queryFn contract: reconciliation - // throws for aborted requests BEFORE the fetch is attributed, so a canceled - // request cannot claim the trace's one-shot `windowFetch` slot and block - // the accepted replacement. - const frames = []; - const originalWindow = globalThis.window; - const originalDocument = globalThis.document; - globalThis.window = { - requestAnimationFrame: (cb) => frames.push(cb) && frames.length, - cancelAnimationFrame: () => {}, - }; - globalThis.document = { - addEventListener: () => {}, - querySelector: () => null, - removeEventListener: () => {}, - visibilityState: "visible", - }; - const { - beginChannelSwitchTrace, - settleChannelSwitchTrace, - resetChannelSwitchTrace, - traceChannelWindowFetch, - CHANNEL_SWITCH_MEASURE, - } = await import("../../../shared/lib/channelSwitchPerf.ts"); - performance.clearMeasures?.(CHANNEL_SWITCH_MEASURE); - try { - const client = new QueryClient(); - const channelId = "channel"; // matches wirePage's bounds key - beginChannelSwitchTrace(channelId); - - // Canceled-first: the queryFn reconciles BEFORE attributing; the aborted - // signal throws, so trace attribution is never reached. - const canceled = new AbortController(); - canceled.abort(); - const canceledEvents = wirePage([event("stale", 100)]); - const startedAt = performance.now(); - // Drive the production helper, not a restatement of it: reverting the - // reconcile/attribute order inside it must fail this test. - assert.throws(() => - reconcileAndAttributeChannelWindow({ - queryClient: client, - channelId, - events: canceledEvents, - previousMessages: [], - signal: canceled.signal, - fetchDurationMs: 1, - fetchStartedAt: startedAt, - }), - ); - - // Accepted-second: reconciles cleanly, then claims the slot. - const acceptedEvents = wirePage([ - event("fresh-2", 120), - event("fresh-1", 110), - ]); - reconcileAndAttributeChannelWindow({ - queryClient: client, - channelId, - events: acceptedEvents, - previousMessages: [], - signal: new AbortController().signal, - fetchDurationMs: 2, - fetchStartedAt: performance.now(), - }); - - settleChannelSwitchTrace(channelId); - for (let i = 0; i < 10 && frames.length > 0; i += 1) { - for (const cb of frames.splice(0, frames.length)) cb(); - } - const measure = performance.getEntriesByName(CHANNEL_SWITCH_MEASURE).at(-1); - assert.equal( - measure?.detail?.windowFetch?.eventCount, - acceptedEvents.length, - "the accepted fetch owns the attribution slot", - ); - resetChannelSwitchTrace(); - } finally { - performance.clearMeasures?.(CHANNEL_SWITCH_MEASURE); - if (originalWindow === undefined) delete globalThis.window; - else globalThis.window = originalWindow; - if (originalDocument === undefined) delete globalThis.document; - else globalThis.document = originalDocument; - } -}); diff --git a/desktop/src/shared/lib/channelSwitchPerf.test.mjs b/desktop/src/shared/lib/channelSwitchPerf.test.mjs deleted file mode 100644 index fa8ba254edc..00000000000 --- a/desktop/src/shared/lib/channelSwitchPerf.test.mjs +++ /dev/null @@ -1,324 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { - CHANNEL_SWITCH_MEASURE, - CHANNEL_SWITCH_START_MARK, - abandonChannelSwitchTrace, - beginChannelSwitchTrace, - dropActiveChannelSwitchTrace, - markChannelSwitchRouteCommit, - resetChannelSwitchTrace, - resolveSettleAction, - settleChannelSwitchTrace, - shouldAttributeFetch, - summarizeChannelSwitchTrace, - traceChannelWindowFetch, -} from "./channelSwitchPerf.ts"; - -function trace(overrides = {}) { - return { - channelId: "abcdef1234567890", - startedAt: 1_000, - routeCommitAt: null, - windowFetch: null, - settleEnteredAt: null, - ...overrides, - }; -} - -// --- Pure helpers --------------------------------------------------------- - -test("summary reports total, commit offset and cache-served fetches", () => { - assert.equal( - summarizeChannelSwitchTrace(trace({ routeCommitAt: 1_200 }), 1_412.4), - "[switch-perf] channel=abcdef12 total=412ms commit=+200ms window=cache", - ); -}); - -test("summary reports an attributed fetch and the truncation flag", () => { - assert.equal( - summarizeChannelSwitchTrace( - trace({ windowFetch: { durationMs: 307.2, eventCount: 89 } }), - 1_739, - true, - ), - "[switch-perf] channel=abcdef12 total=739ms commit=? " + - "window=89 events in 307ms settle=truncated", - ); -}); - -test("a settle for another channel leaves the active trace alone", () => { - // A previous channel can finish loading after the next switch began; - // clobbering the newer trace would drop exactly the rapid switches worth - // capturing. - const active = trace(); - assert.deepEqual(resolveSettleAction(active, "bbbb0000bbbb0000", 1_100), { - settledTrace: null, - timedOut: false, - }); - assert.deepEqual(resolveSettleAction(null, "abcdef1234567890", 1_100), { - settledTrace: null, - timedOut: false, - }); -}); - -test("a settle past the timeout reports the trace as timed out", () => { - const stale = trace({ startedAt: 1_000 }); - assert.deepEqual(resolveSettleAction(stale, "abcdef1234567890", 31_001), { - settledTrace: null, - timedOut: true, - }); - assert.equal( - resolveSettleAction(stale, "abcdef1234567890", 11_000).settledTrace, - stale, - ); -}); - -test("fetches attribute only inside the measured interval", () => { - const active = trace({ startedAt: 1_000 }); - // Started before the switch (a stale A→B→A leg): not this switch's cost, - // and it would occupy the one-shot slot the real fetch needs. - assert.equal(shouldAttributeFetch(active, "abcdef1234567890", 999), false); - assert.equal(shouldAttributeFetch(active, "abcdef1234567890", 1_000), true); - // Other channel or no trace: never. - assert.equal(shouldAttributeFetch(active, "bbbb0000bbbb0000", 1_500), false); - assert.equal(shouldAttributeFetch(null, "abcdef1234567890", 1_500), false); - // Started after the timeline settled: background revalidation the user never - // waited on. The trace is still active while it waits for the paint. - const settling = trace({ startedAt: 1_000, settleEnteredAt: 2_000 }); - assert.equal(shouldAttributeFetch(settling, "abcdef1234567890", 1_999), true); - assert.equal( - shouldAttributeFetch(settling, "abcdef1234567890", 2_001), - false, - ); -}); - -// --- Lifecycle ------------------------------------------------------------ - -/** - * Drives the real lifecycle with a manual frame queue, a virtual clock and a - * controllable pending marker. The clock is rebased above the real one so a - * visibilitychange fired by an earlier test cannot silently drop every trace - * at settle entry and make these assertions vacuous. - */ -function withHarness(run) { - const frames = []; - const drops = []; - const originalWindow = globalThis.window; - const originalDocument = globalThis.document; - const originalNow = performance.now; - const originalInfo = console.info; - const base = originalNow.call(performance) + 1_000; - let clock = base; - let pending = false; - performance.now = () => clock; - console.info = (line) => { - if (typeof line === "string" && line.includes("dropped (")) { - drops.push(line.slice(line.indexOf("dropped (") + 9, -1)); - } - }; - globalThis.window = { - requestAnimationFrame: (cb) => frames.push(cb) && frames.length, - cancelAnimationFrame: () => {}, - }; - globalThis.document = { - addEventListener: () => {}, - querySelector: () => (pending ? {} : null), - removeEventListener: () => {}, - visibilityState: "visible", - }; - performance.clearMeasures?.(CHANNEL_SWITCH_MEASURE); - const api = { - drops, - at: (offset) => { - clock = base + offset; - }, - setPending: (value) => { - pending = value; - }, - hide: () => { - globalThis.document.visibilityState = "hidden"; - }, - flush: (rounds = 40) => { - for (let i = 0; i < rounds && frames.length > 0; i += 1) { - for (const cb of frames.splice(0, frames.length)) cb(); - } - }, - measures: () => - performance - .getEntriesByName(CHANNEL_SWITCH_MEASURE) - .map((entry) => entry.detail?.channelId), - lastMeasure: () => - performance.getEntriesByName(CHANNEL_SWITCH_MEASURE).at(-1), - }; - try { - run(api); - } finally { - performance.now = originalNow; - console.info = originalInfo; - resetChannelSwitchTrace(); - performance.clearMeasures?.(CHANNEL_SWITCH_MEASURE); - if (originalWindow === undefined) delete globalThis.window; - else globalThis.window = originalWindow; - if (originalDocument === undefined) delete globalThis.document; - else globalThis.document = originalDocument; - } -} - -test("an undisturbed switch records exactly one measure", () => { - withHarness(({ at, flush, measures, lastMeasure }) => { - at(0); - beginChannelSwitchTrace("aaaa"); - at(20); - markChannelSwitchRouteCommit("aaaa"); - at(120); - settleChannelSwitchTrace("aaaa"); - flush(); - assert.deepEqual(measures(), ["aaaa"]); - assert.notEqual(lastMeasure().detail.routeCommitAt, null); - assert.equal(lastMeasure().detail.settleWaitTruncated, undefined); - }); -}); - -test("the settle waits for a pending render, then truncates rather than hangs", () => { - withHarness(({ at, flush, setPending, measures, lastMeasure }) => { - at(0); - beginChannelSwitchTrace("aaaa"); - setPending(true); - at(50); - settleChannelSwitchTrace("aaaa"); - // The marker never clears: the wait is bounded in frames, and the sample - // is reported flagged rather than discarded — a slow switch is data. - flush(); - assert.deepEqual(measures(), ["aaaa"]); - assert.equal(lastMeasure().detail.settleWaitTruncated, true); - }); -}); - -test("a switch that paints mid-wait records without the truncation flag", () => { - withHarness(({ at, flush, setPending, lastMeasure }) => { - at(0); - beginChannelSwitchTrace("aaaa"); - setPending(true); - at(50); - settleChannelSwitchTrace("aaaa"); - flush(2); - setPending(false); - flush(); - assert.equal(lastMeasure().detail.settleWaitTruncated, undefined); - }); -}); - -test("a superseding switch discards the first trace, with a reason", () => { - withHarness(({ at, flush, drops, measures }) => { - at(0); - beginChannelSwitchTrace("aaaa"); - at(400); - // The user gave up on A and clicked B. A being slow is exactly why they - // clicked again, so a silent discard censors the switches worth seeing. - beginChannelSwitchTrace("bbbb"); - settleChannelSwitchTrace("aaaa"); - flush(); - assert.deepEqual(drops, ["superseded"]); - assert.deepEqual(measures(), []); - }); -}); - -test("a hidden window drops the trace instead of recording the absence", () => { - withHarness(({ at, flush, hide, drops, measures }) => { - at(0); - beginChannelSwitchTrace("aaaa"); - hide(); - at(9_000); - settleChannelSwitchTrace("aaaa"); - flush(); - assert.deepEqual(drops, ["hidden window"]); - assert.deepEqual(measures(), []); - }); -}); - -test("a timed-out switch is dropped, with a reason", () => { - withHarness(({ at, flush, drops, measures }) => { - at(0); - beginChannelSwitchTrace("aaaa"); - at(31_000); - settleChannelSwitchTrace("aaaa"); - flush(); - assert.deepEqual(drops, ["timed out"]); - assert.deepEqual(measures(), []); - }); -}); - -test("leaving the channel surface and forum surfaces drop with a reason", () => { - withHarness(({ at, drops }) => { - at(0); - beginChannelSwitchTrace("aaaa"); - dropActiveChannelSwitchTrace(); - at(10); - beginChannelSwitchTrace("bbbb"); - abandonChannelSwitchTrace("bbbb"); - assert.deepEqual(drops, ["left channel surface", "unobservable surface"]); - }); -}); - -test("a second settle neither restarts the wait nor moves the fetch bound", () => { - withHarness(({ at, flush, measures }) => { - at(0); - beginChannelSwitchTrace("aaaa"); - at(50); - settleChannelSwitchTrace("aaaa"); - at(60); - settleChannelSwitchTrace("aaaa"); - flush(); - assert.deepEqual(measures(), ["aaaa"]); - }); -}); - -test("an attributed window fetch reaches the measure", () => { - withHarness(({ at, flush, lastMeasure }) => { - at(0); - beginChannelSwitchTrace("aaaa"); - traceChannelWindowFetch("aaaa", 89, 307, performance.now()); - at(120); - settleChannelSwitchTrace("aaaa"); - flush(); - assert.deepEqual(lastMeasure().detail.windowFetch, { - durationMs: 307, - eventCount: 89, - }); - }); -}); - -test("beginning a switch clears the previous switch's entries", () => { - withHarness(({ at, flush, measures }) => { - at(0); - beginChannelSwitchTrace("aaaa"); - at(100); - settleChannelSwitchTrace("aaaa"); - flush(); - assert.deepEqual(measures(), ["aaaa"]); - // A consumer polling the buffer mid-switch must never read the previous - // switch's measure as the current one's. - at(200); - beginChannelSwitchTrace("bbbb"); - assert.deepEqual(measures(), []); - }); -}); - -test("the start mark shares the measure's anchor", () => { - withHarness(({ at, flush }) => { - at(0); - beginChannelSwitchTrace("aaaa"); - const startMark = performance - .getEntriesByName(CHANNEL_SWITCH_START_MARK) - .at(-1); - at(100); - settleChannelSwitchTrace("aaaa"); - flush(); - const measure = performance.getEntriesByName(CHANNEL_SWITCH_MEASURE).at(-1); - // Without a shared anchor the Performance panel shows the measure - // starting before its own start mark. - assert.equal(startMark.startTime, measure.startTime); - }); -}); diff --git a/desktop/src/shared/lib/channelSwitchPerf.ts b/desktop/src/shared/lib/channelSwitchPerf.ts deleted file mode 100644 index 5243e31a1a7..00000000000 --- a/desktop/src/shared/lib/channelSwitchPerf.ts +++ /dev/null @@ -1,338 +0,0 @@ -/** - * Channel-switch timing: click → route commit → settled paint, plus the - * message-window fetch when it lands inside that interval. - * - * Deliberately small. One trace is active at a time; `beginChannelSwitchTrace` - * (from `goChannel`, via `commitGuardedNavigation`) opens it and - * `settleChannelSwitchTrace` closes it one paint after the channel's timeline - * leaves its loading latch. Output is a `[switch-perf]` console line plus User - * Timing marks and measures (`buzz:channel-switch:*`) — the Performance panel - * and the Playwright perf harness read the same numbers. Nothing is persisted. - * - * The honesty bounds below are chosen rather than inferred. Every extra - * inference this instrument tried to make became a way to report a number that - * never happened, so the scope is narrow on purpose: - * - Only navigations that reach `goChannel` are traced. History back/forward - * and re-selecting the active channel are not. - * - The interval starts at the navigation, anchored to the triggering input - * event when one is dispatching. Callers that await before navigating (DM - * actions await `open_dm`) exclude that await by design. - * - A trace overlapping a hidden window is dropped, never measured: rAF - * suspends and network throttles while hidden, so elapsed time there is not - * the user's switch. - * - The settle waits a bounded number of frames for a pending render. Past - * that bound the measure is still emitted, flagged `settleWaitTruncated` — - * a slow switch is data, not an error. - * - Every abandoned trace prints why. Drops correlate with slow switches, so a - * silent drop would make "no switches" and "switches discarded" look alike. - */ - -export type ChannelSwitchTrace = { - channelId: string; - startedAt: number; - routeCommitAt: number | null; - windowFetch: { durationMs: number; eventCount: number } | null; - /** Set when the timeline reports settled; bounds fetch attribution. */ - settleEnteredAt: number | null; -}; - -/** A switch that hasn't settled after this long is abandoned, not measured. */ -const SWITCH_TRACE_TIMEOUT_MS = 30_000; - -/** - * Frames the settle waits for a pending render before recording anyway. - * Bounded in frames rather than milliseconds so a stalled main thread cannot - * stretch the wait into the measurement. - */ -const MAX_SETTLE_FRAMES = 30; - -export const CHANNEL_SWITCH_START_MARK = "buzz:channel-switch:start"; -export const CHANNEL_SWITCH_SETTLED_MARK = "buzz:channel-switch:settled"; -export const CHANNEL_SWITCH_MEASURE = "buzz:channel-switch:click-to-settled"; - -/** - * Repo-wide marker for "a deferred commit is in flight", set by the message - * timeline and by the lazy channel pane's fallback. Other components use it - * too, so the settle wait is bounded in frames rather than trusting it to - * clear: a foreign owner can extend a switch by at most MAX_SETTLE_FRAMES, - * and that sample is flagged `settleWaitTruncated`. - */ -const RENDER_PENDING_SELECTOR = '[data-render-pending="true"]'; - -let activeTrace: ChannelSwitchTrace | null = null; - -/** Formats one settled trace as the `[switch-perf]` console line. */ -export function summarizeChannelSwitchTrace( - trace: ChannelSwitchTrace, - settledAt: number, - settleWaitTruncated = false, -): string { - const total = Math.round(settledAt - trace.startedAt); - const commit = - trace.routeCommitAt === null - ? "?" - : `+${Math.round(trace.routeCommitAt - trace.startedAt)}ms`; - const window = - trace.windowFetch === null - ? "cache" - : `${trace.windowFetch.eventCount} events in ${Math.round(trace.windowFetch.durationMs)}ms`; - return ( - `[switch-perf] channel=${trace.channelId.slice(0, 8)} total=${total}ms ` + - `commit=${commit} window=${window}` + - (settleWaitTruncated ? " settle=truncated" : "") - ); -} - -function dropTrace(trace: ChannelSwitchTrace, reason: string): void { - console.info( - `[switch-perf] channel=${trace.channelId.slice(0, 8)} dropped (${reason})`, - ); - if (activeTrace === trace) activeTrace = null; -} - -/** - * Timestamp of the most recent visibilitychange. Any transition inside a trace - * window means an off-screen interval overlaps the measurement. One listener - * per document (tests swap documents). - */ -let lastVisibilityChangeAt = Number.NEGATIVE_INFINITY; -const watchedDocuments = new WeakSet(); - -function ensureVisibilityWatcher(): void { - if (typeof document === "undefined" || !document.addEventListener) return; - if (watchedDocuments.has(document)) return; - watchedDocuments.add(document); - document.addEventListener("visibilitychange", () => { - lastVisibilityChangeAt = performance.now(); - }); -} - -function overlapsHiddenWindow(trace: ChannelSwitchTrace): boolean { - return ( - document.visibilityState === "hidden" || - lastVisibilityChangeAt >= trace.startedAt - ); -} - -function clearSwitchEntries(): void { - performance.clearMarks(CHANNEL_SWITCH_START_MARK); - performance.clearMarks(CHANNEL_SWITCH_SETTLED_MARK); - performance.clearMeasures(CHANNEL_SWITCH_MEASURE); -} - -export function beginChannelSwitchTrace(channelId: string): void { - if (typeof performance === "undefined") return; - ensureVisibilityWatcher(); - if (activeTrace) dropTrace(activeTrace, "superseded"); - // 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 cannot leak in from an async continuation. - // min() guards a skewed event clock; max() keeps the mark non-negative, - // which performance.mark requires. - const now = performance.now(); - const dispatching = typeof window === "undefined" ? undefined : window.event; - const startedAt = - dispatching && typeof dispatching.timeStamp === "number" - ? Math.max(0, Math.min(dispatching.timeStamp, now)) - : now; - activeTrace = { - channelId, - startedAt, - routeCommitAt: null, - windowFetch: null, - settleEnteredAt: null, - }; - // Clear the previous switch here, not only on record: traces that die - // without recording never reach record()'s clearing, and a consumer polling - // the buffer mid-switch must never read the previous switch's entries. - clearSwitchEntries(); - // startTime keeps the mark on the same anchor as the measure. - performance.mark(CHANNEL_SWITCH_START_MARK, { - detail: { channelId }, - startTime: startedAt, - }); -} - -export function markChannelSwitchRouteCommit(channelId: string): void { - if (typeof performance === "undefined") return; - if (!activeTrace || activeTrace.channelId !== channelId) return; - if (activeTrace.routeCommitAt !== null) return; - activeTrace.routeCommitAt = performance.now(); -} - -/** - * A fetch attributes to the active trace only when it targets the traced - * channel and started inside the measured interval. A fetch that started - * before the switch (the first leg of a rapid A→B→A completing during the - * second A trace) is not this switch's cost, and letting it claim the slot - * would block the real fetch. A fetch that started after the timeline settled - * is background revalidation the user never waited on. Pure for unit testing. - */ -export function shouldAttributeFetch( - trace: ChannelSwitchTrace | null, - channelId: string, - fetchStartedAt: number, -): trace is ChannelSwitchTrace { - if (!trace || trace.channelId !== channelId) return false; - if (fetchStartedAt < trace.startedAt) return false; - return ( - trace.settleEnteredAt === null || fetchStartedAt <= trace.settleEnteredAt - ); -} - -export function traceChannelWindowFetch( - channelId: string, - eventCount: number, - durationMs: number, - fetchStartedAt: number, -): void { - if (!shouldAttributeFetch(activeTrace, channelId, fetchStartedAt)) return; - activeTrace.windowFetch ??= { durationMs, eventCount }; -} - -/** - * Drops the active trace for surfaces whose readiness this instrument cannot - * observe (forum channels, whose loading ForumView owns). Better no - * measurement than a systematically underreported one. - */ -export function abandonChannelSwitchTrace(channelId: string): void { - if (activeTrace?.channelId === channelId) { - dropTrace(activeTrace, "unobservable surface"); - } -} - -/** - * Abandons whatever trace is active, regardless of channel. Called when - * navigation leaves the channel surface (any committed non-channel - * destination, any history traversal): a trace can be live with no channel - * screen mounted at all — the route still resolving — so this is the only - * reliable exit hook, and a later untraced re-entry would otherwise settle it - * with the time spent away. - */ -export function dropActiveChannelSwitchTrace(): void { - if (activeTrace) dropTrace(activeTrace, "left channel surface"); -} - -/** Community switch (and test reset): nothing survives into the next one. */ -export function resetChannelSwitchTrace(): void { - if (activeTrace) dropTrace(activeTrace, "community reset"); - activeTrace = null; - lastVisibilityChangeAt = Number.NEGATIVE_INFINITY; - if (typeof performance !== "undefined") clearSwitchEntries(); -} - -/** - * Decides what a settle call does with the active trace. A settle for a - * different channel must leave the trace alone — a previous channel can finish - * loading after the next switch began, and clobbering the newer trace would - * drop exactly the rapid switches worth capturing. Pure for unit testing. - */ -export function resolveSettleAction( - trace: ChannelSwitchTrace | null, - channelId: string, - now: number, -): { settledTrace: ChannelSwitchTrace | null; timedOut: boolean } { - if (!trace || trace.channelId !== channelId) { - return { settledTrace: null, timedOut: false }; - } - if (now - trace.startedAt > SWITCH_TRACE_TIMEOUT_MS) { - return { settledTrace: null, timedOut: true }; - } - return { settledTrace: trace, timedOut: false }; -} - -/** - * Closes the active trace once the settled frame has painted. The timeline - * exposes `data-render-pending` until its deferred commit catches up, - * and the lazy channel pane's fallback carries the same marker while its chunk - * loads; waiting for both (bounded) keeps `totalMs` honest on render-heavy and - * cold-chunk switches. A final frame then lands the mark after the paint. - */ -export function settleChannelSwitchTrace(channelId: string): void { - if (typeof performance === "undefined") return; - const { settledTrace, timedOut } = resolveSettleAction( - activeTrace, - channelId, - performance.now(), - ); - if (!settledTrace) { - if (timedOut && activeTrace) dropTrace(activeTrace, "timed out"); - return; - } - const trace = settledTrace; - // These globals gate the whole settle path: the wait loop reads - // document.visibilityState, querySelector and rAF unguarded past this point. - if ( - typeof window === "undefined" || - typeof document === "undefined" || - !window.requestAnimationFrame - ) { - dropTrace(trace, "no DOM"); - return; - } - ensureVisibilityWatcher(); - if (overlapsHiddenWindow(trace)) { - dropTrace(trace, "hidden window"); - return; - } - // Idempotent: a second settle for this channel must not restart the wait or - // move the fetch-attribution bound. - if (trace.settleEnteredAt !== null) return; - trace.settleEnteredAt = performance.now(); - // Bind once — reading the ambient global on every frame would follow a - // swapped-out window and throw from inside a callback nothing can catch. - const schedule = window.requestAnimationFrame.bind(window); - - const record = (settleWaitTruncated: boolean) => { - const settledAt = performance.now(); - if (activeTrace === trace) activeTrace = null; - // Keep only the latest switch in the User Timing buffer: desktop sessions - // run for weeks and the buffer is never GC'd. - clearSwitchEntries(); - performance.mark(CHANNEL_SWITCH_SETTLED_MARK, { - detail: { channelId }, - startTime: settledAt, - }); - performance.measure(CHANNEL_SWITCH_MEASURE, { - detail: { - channelId, - routeCommitAt: trace.routeCommitAt, - windowFetch: trace.windowFetch, - ...(settleWaitTruncated ? { settleWaitTruncated: true } : {}), - }, - start: trace.startedAt, - end: settledAt, - }); - console.info( - summarizeChannelSwitchTrace(trace, settledAt, settleWaitTruncated), - ); - }; - - let framesWaited = 0; - const awaitPaint = () => { - // A newer switch replaced this trace, or a reset dropped it. Either way - // the paint this callback would sample is not this switch's own. - if (activeTrace !== trace) return; - if (overlapsHiddenWindow(trace)) { - dropTrace(trace, "hidden window"); - return; - } - const pending = document.querySelector(RENDER_PENDING_SELECTOR) !== null; - if (pending && framesWaited < MAX_SETTLE_FRAMES) { - framesWaited += 1; - schedule(awaitPaint); - return; - } - // One more frame so the mark lands after the browser paints. - schedule(() => { - if (activeTrace !== trace) return; - if (overlapsHiddenWindow(trace)) { - dropTrace(trace, "hidden window"); - return; - } - record(pending); - }); - }; - schedule(awaitPaint); -} diff --git a/desktop/tests/e2e/switch-settle-after-paint.spec.ts b/desktop/tests/e2e/switch-settle-after-paint.spec.ts deleted file mode 100644 index fb264593e88..00000000000 --- a/desktop/tests/e2e/switch-settle-after-paint.spec.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { expect, test } from "@playwright/test"; - -import { installMockBridge } from "../helpers/bridge"; - -/** - * The switch trace must settle AFTER the deferred timeline has committed and - * painted. During the skeleton→loaded transition the message-list branches - * (which used to own the `data-render-pending` marker) are not mounted, so a - * tracer polling only that marker would read "not pending" and record a - * settle while the heavy deferred list was still uncommitted — underreporting - * exactly the switches the tracer exists to measure. The marker now lives on - * the timeline's always-mounted wrapper; this spec pins the contract on a - * real empty→loaded cold switch into a deep channel. - */ - -const SWITCH_MEASURE = "buzz:channel-switch:click-to-settled"; - -test("cold-switch settle measure lands only after rows are painted", async ({ - page, -}) => { - await installMockBridge(page, { deepHistoryMessageCount: 600 }); - await page.goto("/"); - await expect(page.getByTestId("app-sidebar")).toBeVisible(); - - // Cold first entry: skeleton → deferred list commit → settled paint. - await page.getByTestId("channel-deep-history").click(); - - // Poll for the settle measure inside the page and — in the same synchronous - // evaluation turn — snapshot what the DOM shows at that moment. Reading the - // DOM from the test process after the fact would race further renders. Only - // CLEAN measures count: a truncated one means the tracer honestly hit its - // render-wait deadline (harness timing), which must fail with that reason - // rather than masquerading as a tracer regression. - const atSettle = await page.evaluate(async (measureName) => { - const deadline = Date.now() + 15_000; - while (Date.now() < deadline) { - const entries = performance.getEntriesByName(measureName); - const clean = entries.filter( - (entry) => !(entry as PerformanceMeasure).detail?.settleWaitTruncated, - ); - if (clean.length > 0) { - return { - renderPending: - document.querySelector('[data-render-pending="true"]') !== null, - rowCount: document.querySelectorAll( - '[data-message-id^="mock-deep-history-"]', - ).length, - settled: true, - truncatedOnly: false, - }; - } - if (entries.length > 0) { - return { - renderPending: true, - rowCount: 0, - settled: false, - truncatedOnly: true, - }; - } - await new Promise((resolve) => setTimeout(resolve, 16)); - } - return { - renderPending: true, - rowCount: 0, - settled: false, - truncatedOnly: false, - }; - }, SWITCH_MEASURE); - - expect( - atSettle.truncatedOnly, - "tracer truncated at its render-wait deadline — harness timing exhausted, not a tracer bug; rerun", - ).toBe(false); - expect(atSettle.settled, "switch trace must settle").toBe(true); - expect( - atSettle.rowCount, - "settle must not be recorded before the deferred list painted", - ).toBeGreaterThan(0); - expect( - atSettle.renderPending, - "settle must not be recorded while a deferred commit is still pending", - ).toBe(false); -}); - -/** - * While the lazy ChannelPane chunk is still suspended, the timeline (and its - * render-pending marker) is not mounted — only the Suspense fallback is. The - * fallback must therefore read as pending itself, or the tracer would record - * a settle with zero rows while the loading skeleton was still visible. This - * spec holds the chunk to pin that contract. - */ -test("settle waits for a delayed lazy channel-pane chunk", async ({ page }) => { - let releaseChunk = () => {}; - const chunkHold = new Promise((resolve) => { - releaseChunk = resolve; - }); - let chunkRequested = false; - await page.route(/\/assets\/ChannelPane-[^/]+\.js(\?.*)?$/, async (route) => { - chunkRequested = true; - await chunkHold; - await route.continue(); - }); - - await installMockBridge(page, { deepHistoryMessageCount: 600 }); - await page.goto("/"); - await expect(page.getByTestId("app-sidebar")).toBeVisible(); - - await page.getByTestId("channel-deep-history").click(); - await expect - .poll(() => chunkRequested, { - message: "the ChannelPane chunk must load lazily on first channel entry", - }) - .toBe(true); - - // While the pane chunk is held there are no rows on screen, so any measure - // emitted here must carry the truncation flag. A CLEAN measure would be the - // regression: a settled paint reported for a screen showing a fallback. - await page.waitForTimeout(1_000); - const cleanWhileSuspended = await page.evaluate( - (name) => - performance - .getEntriesByName(name) - .filter( - (entry) => !(entry as PerformanceMeasure).detail?.settleWaitTruncated, - ).length, - SWITCH_MEASURE, - ); - expect( - cleanWhileSuspended, - "no clean settle may be recorded while the pane chunk is suspended", - ).toBe(0); - - releaseChunk(); - - // After release the pane mounts and paints rows. The tracer's own record - // may already have landed (truncated, by design) — what matters is that the - // channel actually painted, which is what a clean settle would have claimed. - await expect - .poll( - () => - page - .locator('[data-message-id^="mock-deep-history-"]') - .count() - .then((count) => count > 0), - { message: "the released pane must paint rows", timeout: 15_000 }, - ) - .toBe(true); -}); From 2f44a0e90d39bb935a71b85a5df561680b89869e Mon Sep 17 00:00:00 2001 From: Max Lampert Date: Wed, 26 Aug 2026 20:40:27 -0700 Subject: [PATCH 25/27] test(desktop): drop tracer leftovers from the guard tests Signed-off-by: Max Lampert --- desktop/src/app/navigation/commitGuardedNavigation.test.mjs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/desktop/src/app/navigation/commitGuardedNavigation.test.mjs b/desktop/src/app/navigation/commitGuardedNavigation.test.mjs index 9f271383d98..43dd2c1dfea 100644 --- a/desktop/src/app/navigation/commitGuardedNavigation.test.mjs +++ b/desktop/src/app/navigation/commitGuardedNavigation.test.mjs @@ -2,11 +2,10 @@ import assert from "node:assert/strict"; import test from "node:test"; import { commitGuardedNavigation } from "./commitGuardedNavigation.ts"; -import { registerNavigationGuard } from "./navigationGuard.ts"; const route = (href) => ({ kind: "route", href }); -test("an accepted navigation opens the trace after the guard, before navigate", async () => { +test("an accepted navigation consults the guard before navigating", async () => { const order = []; const committed = await commitGuardedNavigation( { @@ -28,7 +27,7 @@ test("an accepted navigation opens the trace after the guard, before navigate", assert.deepEqual(order, ["guard", "navigate"]); }); -test("a same-destination no-op consults neither the guard nor the trace", async () => { +test("a same-destination no-op never reaches the guard", async () => { const order = []; const committed = await commitGuardedNavigation( { From 8d649e40572053b1bec39cca18f1f7d82b8f7ccd Mon Sep 17 00:00:00 2001 From: Max Lampert Date: Wed, 26 Aug 2026 21:33:07 -0700 Subject: [PATCH 26/27] fix(desktop): test that a refused navigation actually refuses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Blind review found the guard-refusal branch untested, and a refutation pass disagreed. The refutation tested the wrong mutation: DELETING the `allow()` call fails three ordering tests, so it looked covered. Consulting the guard and discarding its answer passed all ten. That is the branch that stops a channel switch while a thread edit is unsaved, so a regression there would have shipped silently. Two tests now pin it — refused, and refused-while-forced — and the ignore-the-answer mutation fails both. Also removes residue the same review found in the tracer teardown: - a stranded `append_switch_perf_log` arm in the e2e mock bridge, for a Tauri command deleted two commits ago - MessageTimeline's marker comment, which justified its placement by a tracer that no longer exists; its readers are now the perf benchmark and the e2e readiness specs - ChannelScreenLoadingFallback's `data-render-pending` wrapper, added solely for the tracer. Measured benchmark switches are warm, so the lazy pane never suspends there and nothing asserts the marker. Reverted to main rather than left as an unowned production DOM node. Signed-off-by: Max Lampert --- .../commitGuardedNavigation.test.mjs | 44 +++++++++++++++++++ .../ui/ChannelScreenLoadingFallback.tsx | 17 ++----- .../features/messages/ui/MessageTimeline.tsx | 7 +-- desktop/src/testing/e2eBridge.ts | 3 -- 4 files changed, 52 insertions(+), 19 deletions(-) diff --git a/desktop/src/app/navigation/commitGuardedNavigation.test.mjs b/desktop/src/app/navigation/commitGuardedNavigation.test.mjs index 43dd2c1dfea..b1776fd13ab 100644 --- a/desktop/src/app/navigation/commitGuardedNavigation.test.mjs +++ b/desktop/src/app/navigation/commitGuardedNavigation.test.mjs @@ -94,3 +94,47 @@ test("a same-destination navigation carrying router state still commits", async assert.equal(committed, true); assert.deepEqual(order, ["guard", "navigate"]); }); + +test("a refused navigation does not navigate", async () => { + // The guard's whole purpose: an unsaved thread edit blocks the switch. + // Asserting only that the guard is CONSULTED is not enough — consulting it + // and discarding the answer passes every ordering test in this file. + const order = []; + const committed = await commitGuardedNavigation( + { + currentHref: "/channels/aaaa", + nextHref: "/channels/bbbb", + guardedTarget: route("/channels/bbbb"), + navigate: async () => { + order.push("navigate"); + }, + }, + { + allow: () => { + order.push("guard"); + return false; + }, + }, + ); + assert.equal(committed, false); + assert.deepEqual(order, ["guard"]); +}); + +test("a refused forced navigation does not navigate either", async () => { + // force defeats the same-destination skip, never the guard. + const order = []; + const committed = await commitGuardedNavigation( + { + currentHref: "/channels/aaaa", + nextHref: "/channels/aaaa", + force: true, + guardedTarget: route("/channels/aaaa"), + navigate: async () => { + order.push("navigate"); + }, + }, + { allow: () => false }, + ); + assert.equal(committed, false); + assert.deepEqual(order, []); +}); diff --git a/desktop/src/features/channels/ui/ChannelScreenLoadingFallback.tsx b/desktop/src/features/channels/ui/ChannelScreenLoadingFallback.tsx index f66cb694866..a566b71d906 100644 --- a/desktop/src/features/channels/ui/ChannelScreenLoadingFallback.tsx +++ b/desktop/src/features/channels/ui/ChannelScreenLoadingFallback.tsx @@ -6,18 +6,9 @@ export function ChannelScreenLoadingFallback({ }: { isHuddleTranscript: boolean; }) { - return ( - // While the lazy ChannelPane chunk is suspended, the timeline — and its - // own render-pending marker — is not mounted. The switch tracer polls - // that marker to defer its settle, so the fallback itself must read as - // pending or a settle could record before the pane ever painted. - // `contents` keeps the wrapper out of layout. -
- {isHuddleTranscript ? ( - - ) : ( - - )} -
+ return isHuddleTranscript ? ( + + ) : ( + ); } diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index 8222dcdd223..28d56acec67 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -695,9 +695,10 @@ const MessageTimelineBase = React.forwardRef< {/* The render-pending marker must live on this always-mounted wrapper: during the skeleton→loaded transition the message-list branches (and - their own markers) are not mounted yet, and the switch tracer would - read "not pending" and record a settle before the heavy deferred - list ever committed or painted. */} + their own markers) are not mounted yet, so a reader would see "not + pending" and treat the channel as painted before the heavy deferred + list ever committed. Readers are the perf benchmark and the e2e + readiness specs; see member-heavy-switch.perf.ts. */}
Date: Wed, 26 Aug 2026 21:53:59 -0700 Subject: [PATCH 27/27] refactor(desktop): stop touching MessageTimeline; the benchmark doesn't need it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The render-pending marker was moved onto MessageTimeline's always-mounted wrapper for the switch tracer, which is gone. Tested whether the surviving benchmark still needs it: reverted the file to main, rebuilt, re-ran all three scenarios. Medians are unchanged within run-to-run noise — general->deep-history 395.3 -> 394.3 baseline, 515.4 -> 520.4 at 10k deep-history->general 173.3 -> 179.3 baseline, 314.9 -> 321.4 at 10k general->projects 142.1 -> 141.7 baseline, 164.4 -> 169.8 at 10k projects->general 171.0 -> 179.6 baseline, 282.8 -> 290.5 at 10k — and membership scaling stays monotonic in every direction. The move was not load-bearing. Reverting it removes this PR's last write to the document-wide data-render-pending namespace that review flagged as the one surviving piece of the tracer's shape, and it removes an untestable risk: release-smoke.spec.ts asserts that marker against a local relay, and those assertions cannot run here (no relay), so a marker change in that file would have shipped unverified. The PR no longer touches MessageTimeline at all. Signed-off-by: Max Lampert --- .../features/messages/ui/MessageTimeline.tsx | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index 28d56acec67..fa8bb4e9f6d 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -693,16 +693,7 @@ const MessageTimelineBase = React.forwardRef< return ( - {/* The render-pending marker must live on this always-mounted wrapper: - during the skeleton→loaded transition the message-list branches (and - their own markers) are not mounted yet, so a reader would see "not - pending" and treat the channel as painted before the heavy deferred - list ever committed. Readers are the perf benchmark and the e2e - readiness specs; see member-heavy-switch.perf.ts. */} -
+
{showUnreadPill ? (
{useTimelineVirtualizer && timelineList ? ( -
{timelineList}
+
+ {timelineList} +
) : (
{timelineList}