From f3f8493df8f9868c2fde139599a86529b879e7c5 Mon Sep 17 00:00:00 2001 From: Devon Date: Thu, 5 Mar 2026 13:09:49 -0700 Subject: [PATCH 01/36] Coalesce backend watcher emissions to reduce churn floods Amp-Thread-ID: https://ampcode.com/threads/T-019cbf9a-3cd5-77c8-8b9d-6f836e505ef3 Co-authored-by: Amp --- src-tauri/src/lib.rs | 256 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 245 insertions(+), 11 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9ea46234..77197a9b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -7,7 +7,7 @@ use std::fs; use std::path::PathBuf; use std::process::Command; use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{LazyLock, Mutex}; +use std::sync::{Arc, LazyLock, Mutex}; use std::time::{Duration, Instant}; // Global flags for logging @@ -136,6 +136,8 @@ pub struct BdCliUpdateInfo { struct WatcherState { debouncer: Option>, watched_path: Option, + watch_session_id: u64, + project_emit_state: HashMap>>, } impl Default for WatcherState { @@ -143,10 +145,141 @@ impl Default for WatcherState { Self { debouncer: None, watched_path: None, + watch_session_id: 0, + project_emit_state: HashMap::new(), } } } +#[derive(Debug)] +struct ProjectWatcherEmitState { + session_id: u64, + last_emit: Option, + pending: bool, + flush_scheduled: bool, + emitted_batches: u64, + suppressed_batches: u64, +} + +impl ProjectWatcherEmitState { + fn new(session_id: u64) -> Self { + Self { + session_id, + last_emit: None, + pending: false, + flush_scheduled: false, + emitted_batches: 0, + suppressed_batches: 0, + } + } +} + +const WATCHER_DEBOUNCE_INTERVAL_MS: u64 = 1000; +const WATCHER_MIN_EMIT_INTERVAL_MS_DEFAULT: u64 = 2000; + +static WATCHER_MIN_EMIT_INTERVAL_MS: LazyLock = LazyLock::new(|| { + env::var("WATCHER_MIN_EMIT_INTERVAL_MS") + .ok() + .and_then(|value| value.parse::().ok()) + .unwrap_or(WATCHER_MIN_EMIT_INTERVAL_MS_DEFAULT) + .max(250) +}); + +fn watcher_min_emit_interval() -> Duration { + Duration::from_millis(*WATCHER_MIN_EMIT_INTERVAL_MS) +} + +fn should_process_watcher_event(event: ¬ify_debouncer_mini::DebouncedEvent) -> bool { + if !matches!(event.kind, DebouncedEventKind::Any | DebouncedEventKind::AnyContinuous) { + return false; + } + + let path = event.path.to_string_lossy().replace('\\', "/").to_lowercase(); + if !path.contains("/.beads/") { + return false; + } + + // Ignore high-churn internal files that do not carry issue data. + !(path.contains("/.beads/.dolt/stats/") + || path.contains("/.beads/.dolt/tmp/") + || path.contains("/.beads/.dolt/.tmp/") + || path.ends_with(".lock") + || path.ends_with(".tmp") + || path.ends_with(".swp") + || path.ends_with('~')) +} + +fn emit_beads_changed(app_handle: &tauri::AppHandle, project_path: &str) { + if let Err(err) = app_handle.emit( + "beads-changed", + BeadsChangedPayload { + path: project_path.to_string(), + }, + ) { + log::error!("[watcher] Failed to emit beads-changed for {}: {:?}", project_path, err); + } +} + +fn schedule_pending_watcher_emit( + app_handle: tauri::AppHandle, + project_path: String, + emit_state: Arc>, + session_id: u64, +) { + std::thread::spawn(move || { + let min_interval = watcher_min_emit_interval(); + loop { + let mut should_emit = false; + let mut counters = None; + let mut wait_time = Duration::from_millis(50); + + match emit_state.lock() { + Ok(mut state) => { + if state.session_id != session_id || !state.pending { + state.flush_scheduled = false; + return; + } + + let can_emit_now = state + .last_emit + .map(|last| last.elapsed() >= min_interval) + .unwrap_or(true); + + if can_emit_now { + state.pending = false; + state.flush_scheduled = false; + state.last_emit = Some(Instant::now()); + state.emitted_batches += 1; + counters = Some((state.emitted_batches, state.suppressed_batches)); + should_emit = true; + } else if let Some(last_emit) = state.last_emit { + wait_time = min_interval.saturating_sub(last_emit.elapsed()); + } + } + Err(err) => { + log::error!("[watcher] Failed to lock emit state for delayed flush: {}", err); + return; + } + } + + if should_emit { + emit_beads_changed(&app_handle, &project_path); + if let Some((emitted, suppressed)) = counters { + log::info!( + "[watcher] Delayed coalesced emit for {} (emitted={}, suppressed={})", + project_path, + emitted, + suppressed + ); + } + return; + } + + std::thread::sleep(wait_time); + } + }); +} + #[derive(Debug, Clone, Serialize)] struct BeadsChangedPayload { path: String, @@ -4409,10 +4542,22 @@ fn start_watching( state: tauri::State<'_, Mutex>, ) -> Result<(), String> { let mut watcher_state = state.lock().map_err(|e| format!("Lock error: {}", e))?; + watcher_state.watch_session_id = watcher_state.watch_session_id.wrapping_add(1); + let watch_session_id = watcher_state.watch_session_id; // Stop existing watcher if any if watcher_state.debouncer.is_some() { log::info!("[watcher] Stopping previous watcher for: {:?}", watcher_state.watched_path); + if let Some(previous_path) = watcher_state.watched_path.clone() { + if let Some(previous_emit_state) = watcher_state.project_emit_state.get(&previous_path) { + if let Ok(mut emit_state) = previous_emit_state.lock() { + emit_state.session_id = watch_session_id; + emit_state.pending = false; + emit_state.flush_scheduled = false; + } + } + watcher_state.project_emit_state.remove(&previous_path); + } watcher_state.debouncer = None; watcher_state.watched_path = None; } @@ -4424,21 +4569,98 @@ fn start_watching( let project_path = path.clone(); let app_handle = app.clone(); + let project_emit_state = watcher_state + .project_emit_state + .entry(path.clone()) + .or_insert_with(|| Arc::new(Mutex::new(ProjectWatcherEmitState::new(watch_session_id)))) + .clone(); + { + let mut emit_state = project_emit_state + .lock() + .map_err(|e| format!("Watcher emit-state lock error: {}", e))?; + *emit_state = ProjectWatcherEmitState::new(watch_session_id); + } let mut debouncer = new_debouncer( - Duration::from_millis(1000), + Duration::from_millis(WATCHER_DEBOUNCE_INTERVAL_MS), move |res: Result, notify::Error>| { match res { Ok(events) => { - // Filter: only emit if we have actual data-change events - let has_data_events = events.iter().any(|e| { - matches!(e.kind, DebouncedEventKind::Any | DebouncedEventKind::AnyContinuous) - }); - if has_data_events { - log::info!("[watcher] Change detected in .beads/ ({} events)", events.len()); - let _ = app_handle.emit( - "beads-changed", - BeadsChangedPayload { path: project_path.clone() }, + let relevant_count = events + .iter() + .filter(|event| should_process_watcher_event(event)) + .count(); + if relevant_count == 0 { + return; + } + + let mut emit_now = false; + let mut schedule_delayed_emit = false; + let mut counters = None; + let min_interval = watcher_min_emit_interval(); + + match project_emit_state.lock() { + Ok(mut emit_state) => { + if emit_state.session_id != watch_session_id { + return; + } + + let can_emit_now = emit_state + .last_emit + .map(|last| last.elapsed() >= min_interval) + .unwrap_or(true); + + if can_emit_now { + emit_state.pending = false; + emit_state.last_emit = Some(Instant::now()); + emit_state.emitted_batches += 1; + counters = Some((emit_state.emitted_batches, emit_state.suppressed_batches)); + emit_now = true; + } else { + emit_state.pending = true; + emit_state.suppressed_batches += 1; + counters = Some((emit_state.emitted_batches, emit_state.suppressed_batches)); + if !emit_state.flush_scheduled { + emit_state.flush_scheduled = true; + schedule_delayed_emit = true; + } + } + } + Err(err) => { + log::error!("[watcher] Failed to lock emit state for {}: {}", project_path, err); + return; + } + } + + if emit_now { + emit_beads_changed(&app_handle, &project_path); + if let Some((emitted, suppressed)) = counters { + log::info!( + "[watcher] Emitted coalesced update for {} (events={}, relevant={}, emitted={}, suppressed={})", + project_path, + events.len(), + relevant_count, + emitted, + suppressed + ); + } + } else if let Some((emitted, suppressed)) = counters { + log::info!( + "[watcher] Suppressed watcher batch for {} (events={}, relevant={}, emitted={}, suppressed={})", + project_path, + events.len(), + relevant_count, + emitted, + suppressed + ); + } + + if schedule_delayed_emit { + schedule_pending_watcher_emit( + app_handle.clone(), + project_path.clone(), + project_emit_state.clone(), + watch_session_id, ); } } @@ -4474,9 +4696,21 @@ fn stop_watching( state: tauri::State<'_, Mutex>, ) -> Result<(), String> { let mut watcher_state = state.lock().map_err(|e| format!("Lock error: {}", e))?; + watcher_state.watch_session_id = watcher_state.watch_session_id.wrapping_add(1); + let watch_session_id = watcher_state.watch_session_id; if watcher_state.debouncer.is_some() { log::info!("[watcher] Stopped watching: {:?}", watcher_state.watched_path); + if let Some(previous_path) = watcher_state.watched_path.clone() { + if let Some(previous_emit_state) = watcher_state.project_emit_state.get(&previous_path) { + if let Ok(mut emit_state) = previous_emit_state.lock() { + emit_state.session_id = watch_session_id; + emit_state.pending = false; + emit_state.flush_scheduled = false; + } + } + watcher_state.project_emit_state.remove(&previous_path); + } watcher_state.debouncer = None; watcher_state.watched_path = None; } From e0c26bd1e067cd96e33fc012356857774bfcf8e8 Mon Sep 17 00:00:00 2001 From: Devon Date: Thu, 5 Mar 2026 14:40:11 -0700 Subject: [PATCH 02/36] Fix Tauri dev startup stability across terminals Amp-Thread-ID: https://ampcode.com/threads/T-019cbf9a-3cd5-77c8-8b9d-6f836e505ef3 Co-authored-by: Amp --- app/composables/useTauriWindow.ts | 13 +++++++++++-- src-tauri/Cargo.lock | 2 +- src-tauri/capabilities/default.json | 1 + 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/app/composables/useTauriWindow.ts b/app/composables/useTauriWindow.ts index 9fb2f741..29093bc8 100644 --- a/app/composables/useTauriWindow.ts +++ b/app/composables/useTauriWindow.ts @@ -1,4 +1,5 @@ let windowModule: typeof import('@tauri-apps/api/window') | null = null +let setTitlePermissionDeniedLogged = false // Pre-load the Tauri window module if (import.meta.client) { @@ -12,13 +13,21 @@ if (import.meta.client) { export function useTauriWindow() { const startDragging = () => { if (windowModule) { - windowModule.getCurrentWindow().startDragging() + windowModule.getCurrentWindow().startDragging().catch(() => { + // Ignore drag failures in unsupported environments. + }) } } const setWindowTitle = (title: string) => { if (windowModule) { - windowModule.getCurrentWindow().setTitle(title) + windowModule.getCurrentWindow().setTitle(title).catch((error) => { + // Some capability profiles may deny changing title; do not break app render. + if (!setTitlePermissionDeniedLogged) { + setTitlePermissionDeniedLogged = true + console.warn('Unable to set window title:', error) + } + }) } } diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index b978e54e..5920f0ca 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -130,7 +130,7 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "beads-issue-tracker" -version = "1.24.1" +version = "1.24.2" dependencies = [ "dirs", "dotenvy", diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 09b85d8a..1689265a 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -6,6 +6,7 @@ "permissions": [ "core:default", "core:window:allow-start-dragging", + "core:window:allow-set-title", "core:window:allow-close", "shell:allow-open", "shell:allow-execute", From 92a9cb6dd3adc0446c5df8b1f033eee98fc002b6 Mon Sep 17 00:00:00 2001 From: Devon Date: Thu, 5 Mar 2026 14:48:20 -0700 Subject: [PATCH 03/36] fix: replace debounce+isProcessing with queue-based single-flight handler Replaces the debounce + isProcessing pattern in both watcher and SSE backends with a shared createQueuedHandler that guarantees: - At most one onChanged() in flight at any time - Exactly one follow-up rerun when events arrive during processing - Bounded consecutive reruns (max 5) to prevent unbounded loops - Self-write cooldown still respected Closes beads-task-issue-tracker-mtu.2 Amp-Thread-ID: https://ampcode.com/threads/T-019cbff6-d495-705b-bcf7-1f2af08ac9e5 Co-authored-by: Amp --- app/composables/useChangeDetection.ts | 133 +++++++++----- tests/composables/useChangeDetection.test.ts | 174 +++++++++++++++++++ 2 files changed, 262 insertions(+), 45 deletions(-) create mode 100644 tests/composables/useChangeDetection.test.ts diff --git a/app/composables/useChangeDetection.ts b/app/composables/useChangeDetection.ts index cb2d16ad..e09d2dbb 100644 --- a/app/composables/useChangeDetection.ts +++ b/app/composables/useChangeDetection.ts @@ -9,6 +9,79 @@ interface UseChangeDetectionOptions { // ============================================================================ const SELF_TRIGGER_COOLDOWN_MS = 3_000 const DEBOUNCE_MS = 300 +const MAX_CONSECUTIVE_RERUNS = 5 + +/** + * Queue-based handler: at most one `onChanged()` in flight with at most one + * pending rerun. Events arriving during processing set a rerun flag instead + * of spawning extra timers. Bounded by MAX_CONSECUTIVE_RERUNS to prevent + * unbounded loops under sustained churn. + */ +export function createQueuedHandler( + onChanged: () => Promise, + getSelfWriteCooldownActive: () => boolean, + onProcessed: () => void, +) { + let inflight = false + let pendingRerun = false + let debounceTimer: ReturnType | null = null + let consecutiveReruns = 0 + + function schedule() { + if (debounceTimer) clearTimeout(debounceTimer) + debounceTimer = setTimeout(() => run(), DEBOUNCE_MS) + } + + async function run() { + debounceTimer = null + + if (inflight) { + pendingRerun = true + return + } + + inflight = true + consecutiveReruns = 0 + + try { + // eslint-disable-next-line no-constant-condition + while (true) { + pendingRerun = false + try { + await onChanged() + } catch { + // Ignore — polling will catch up + } + onProcessed() + consecutiveReruns++ + + if (!pendingRerun || consecutiveReruns >= MAX_CONSECUTIVE_RERUNS) break + } + } finally { + inflight = false + consecutiveReruns = 0 + } + } + + function trigger() { + if (getSelfWriteCooldownActive()) return + if (inflight) { + pendingRerun = true + return + } + schedule() + } + + function cancel() { + if (debounceTimer) { + clearTimeout(debounceTimer) + debounceTimer = null + } + pendingRerun = false + } + + return { trigger, cancel } +} // ============================================================================ // Native file watcher backend (direct mode) @@ -16,30 +89,20 @@ const DEBOUNCE_MS = 300 function createWatcherBackend(options: UseChangeDetectionOptions) { const active = ref(false) - const isProcessing = ref(false) let currentPath: string | null = null let unlisten: (() => void) | null = null - let debounceTimer: ReturnType | null = null let lastProcessedAt = 0 + const queue = createQueuedHandler( + options.onChanged, + () => Date.now() - lastProcessedAt < SELF_TRIGGER_COOLDOWN_MS, + () => { lastProcessedAt = Date.now() }, + ) + const handleEvent = (payload: { path: string }) => { if (currentPath && payload.path !== currentPath) return - if (Date.now() - lastProcessedAt < SELF_TRIGGER_COOLDOWN_MS) return - - if (debounceTimer) clearTimeout(debounceTimer) - debounceTimer = setTimeout(async () => { - if (isProcessing.value) return - isProcessing.value = true - try { - await options.onChanged() - } catch { - // Ignore errors — polling will catch up - } finally { - isProcessing.value = false - lastProcessedAt = Date.now() - } - }, DEBOUNCE_MS) + queue.trigger() } const start = async (path: string) => { @@ -68,10 +131,7 @@ function createWatcherBackend(options: UseChangeDetectionOptions) { } const stop = () => { - if (debounceTimer) { - clearTimeout(debounceTimer) - debounceTimer = null - } + queue.cancel() if (unlisten) { unlisten() unlisten = null @@ -94,29 +154,15 @@ function createWatcherBackend(options: UseChangeDetectionOptions) { function createSSEBackend(options: UseChangeDetectionOptions) { const active = ref(false) - const isProcessing = ref(false) let eventSource: EventSource | null = null - let debounceTimer: ReturnType | null = null let lastProcessedAt = 0 - const handleChange = () => { - if (Date.now() - lastProcessedAt < SELF_TRIGGER_COOLDOWN_MS) return - - if (debounceTimer) clearTimeout(debounceTimer) - debounceTimer = setTimeout(async () => { - if (isProcessing.value) return - isProcessing.value = true - try { - await options.onChanged() - } catch { - // Ignore errors — polling will catch up - } finally { - isProcessing.value = false - lastProcessedAt = Date.now() - } - }, DEBOUNCE_MS) - } + const queue = createQueuedHandler( + options.onChanged, + () => Date.now() - lastProcessedAt < SELF_TRIGGER_COOLDOWN_MS, + () => { lastProcessedAt = Date.now() }, + ) const start = async (beadsPath: string) => { stop() @@ -131,7 +177,7 @@ function createSSEBackend(options: UseChangeDetectionOptions) { const url = `${getExternalUrl()}/events/${encodeURIComponent(projectName)}` eventSource = new EventSource(url) - eventSource.addEventListener('change', () => handleChange()) + eventSource.addEventListener('change', () => queue.trigger()) eventSource.onopen = () => { active.value = true } eventSource.onerror = () => { active.value = false } } catch (e) { @@ -142,10 +188,7 @@ function createSSEBackend(options: UseChangeDetectionOptions) { } const stop = () => { - if (debounceTimer) { - clearTimeout(debounceTimer) - debounceTimer = null - } + queue.cancel() eventSource?.close() eventSource = null active.value = false diff --git a/tests/composables/useChangeDetection.test.ts b/tests/composables/useChangeDetection.test.ts new file mode 100644 index 00000000..6fed6fd8 --- /dev/null +++ b/tests/composables/useChangeDetection.test.ts @@ -0,0 +1,174 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { createQueuedHandler } from '~/composables/useChangeDetection' + +describe('createQueuedHandler', () => { + beforeEach(() => { vi.useFakeTimers() }) + afterEach(() => { vi.useRealTimers() }) + + function setup(opts: { onChangedMs?: number; cooldown?: boolean } = {}) { + const calls: number[] = [] + let callId = 0 + let resolvers: Array<() => void> = [] + + const onChanged = vi.fn(() => { + const id = ++callId + calls.push(id) + if (opts.onChangedMs != null) { + return new Promise((resolve) => { + resolvers.push(resolve) + }) + } + return Promise.resolve() + }) + + const cooldownActive = opts.cooldown ?? false + const onProcessed = vi.fn() + + const handler = createQueuedHandler( + onChanged, + () => cooldownActive, + onProcessed, + ) + + return { + handler, + onChanged, + onProcessed, + calls, + resolveLatest: () => { + const r = resolvers.shift() + r?.() + return vi.advanceTimersByTimeAsync(0) + }, + } + } + + it('debounces rapid triggers into a single call', async () => { + const { handler, onChanged } = setup() + + handler.trigger() + handler.trigger() + handler.trigger() + + await vi.advanceTimersByTimeAsync(300) + expect(onChanged).toHaveBeenCalledTimes(1) + }) + + it('at most one onChanged in flight', async () => { + const { handler, onChanged, resolveLatest } = setup({ onChangedMs: 100 }) + + handler.trigger() + await vi.advanceTimersByTimeAsync(300) // debounce fires, onChanged starts + expect(onChanged).toHaveBeenCalledTimes(1) + + // Trigger again while in-flight — should set pendingRerun, not start another + handler.trigger() + await vi.advanceTimersByTimeAsync(300) + expect(onChanged).toHaveBeenCalledTimes(1) // still just 1 + + // Resolve the first call — triggers the pending rerun + await resolveLatest() + expect(onChanged).toHaveBeenCalledTimes(2) + }) + + it('collapses multiple events during processing into one follow-up', async () => { + const { handler, onChanged, resolveLatest } = setup({ onChangedMs: 100 }) + + handler.trigger() + await vi.advanceTimersByTimeAsync(300) + expect(onChanged).toHaveBeenCalledTimes(1) + + // Fire many events while in-flight + for (let i = 0; i < 10; i++) { + handler.trigger() + } + await vi.advanceTimersByTimeAsync(300) + expect(onChanged).toHaveBeenCalledTimes(1) // still 1 + + // Resolve — exactly one follow-up + await resolveLatest() + expect(onChanged).toHaveBeenCalledTimes(2) + + // Resolve the follow-up — no more calls + await resolveLatest() + expect(onChanged).toHaveBeenCalledTimes(2) + }) + + it('respects self-write cooldown', async () => { + const { handler, onChanged } = setup({ cooldown: true }) + + handler.trigger() + await vi.advanceTimersByTimeAsync(300) + expect(onChanged).not.toHaveBeenCalled() + }) + + it('bounds consecutive reruns', async () => { + // onChanged takes time, and events keep arriving, so pendingRerun is always set + let resolvers: Array<() => void> = [] + const onChanged = vi.fn(() => new Promise((resolve) => { + resolvers.push(resolve) + })) + const onProcessed = vi.fn() + + const handler = createQueuedHandler(onChanged, () => false, onProcessed) + + handler.trigger() + await vi.advanceTimersByTimeAsync(300) + expect(onChanged).toHaveBeenCalledTimes(1) + + // Continuously fire events and resolve calls to test bounding + for (let i = 0; i < 10; i++) { + handler.trigger() // set pendingRerun + const r = resolvers.shift() + r?.() + await vi.advanceTimersByTimeAsync(0) + } + + // Should be bounded at MAX_CONSECUTIVE_RERUNS (5) + the initial = 6 max, + // but since the initial counts as the first in the run loop, it's 5 total. + expect(onChanged.mock.calls.length).toBeLessThanOrEqual(6) + expect(onChanged.mock.calls.length).toBeGreaterThanOrEqual(2) + }) + + it('cancel stops pending debounce timer', async () => { + const { handler, onChanged } = setup() + + handler.trigger() + handler.cancel() + await vi.advanceTimersByTimeAsync(300) + expect(onChanged).not.toHaveBeenCalled() + }) + + it('calls onProcessed after each onChanged completes', async () => { + const { handler, onProcessed } = setup() + + handler.trigger() + await vi.advanceTimersByTimeAsync(300) + expect(onProcessed).toHaveBeenCalledTimes(1) + }) + + it('handles onChanged errors gracefully and still reruns', async () => { + let callCount = 0 + let resolvers: Array<(err?: Error) => void> = [] + const onChanged = vi.fn(() => new Promise((resolve, reject) => { + callCount++ + resolvers.push((err) => err ? reject(err) : resolve()) + })) + const onProcessed = vi.fn() + + const handler = createQueuedHandler(onChanged, () => false, onProcessed) + + handler.trigger() + await vi.advanceTimersByTimeAsync(300) + + // Set pending rerun, then reject the first call + handler.trigger() + const r = resolvers.shift() + r?.(new Error('fail')) + await vi.advanceTimersByTimeAsync(0) + + // Should have started the rerun despite the error + expect(onChanged).toHaveBeenCalledTimes(2) + expect(onProcessed).toHaveBeenCalledTimes(1) // first call still triggers onProcessed + }) +}) From 267393ab19a87d3b845268b32b0f4cfa30c918db Mon Sep 17 00:00:00 2001 From: Devon Date: Thu, 5 Mar 2026 14:54:15 -0700 Subject: [PATCH 04/36] feat: add poll backpressure scheduler to prevent UI freezes during high churn Introduce usePollScheduler composable that gates all poll triggers (watcher, adaptive timer) through a shared backpressure gate with configurable min interval (default 2s). This prevents expensive pollForChanges() from running in tight loops during sustained .beads file churn. - requestPoll() enforces min interval, defers excess triggers - requestImmediatePoll() bypasses gate for manual refresh - Deduplicates rapid triggers (first defers, rest skip) - Lightweight instrumentation via stats (executed/skipped/deferred counters) - 6 unit tests covering all scheduler behaviors Closes: beads-task-issue-tracker-mtu.3 Amp-Thread-ID: https://ampcode.com/threads/T-019cbff9-c082-734b-9377-29bcc2c5a2b2 Co-authored-by: Amp --- app/composables/usePollScheduler.ts | 111 ++++++++++++++++++++ app/pages/index.vue | 65 +++++++----- tests/composables/usePollScheduler.test.ts | 116 +++++++++++++++++++++ 3 files changed, 264 insertions(+), 28 deletions(-) create mode 100644 app/composables/usePollScheduler.ts create mode 100644 tests/composables/usePollScheduler.test.ts diff --git a/app/composables/usePollScheduler.ts b/app/composables/usePollScheduler.ts new file mode 100644 index 00000000..862ba2a8 --- /dev/null +++ b/app/composables/usePollScheduler.ts @@ -0,0 +1,111 @@ +/** + * Poll scheduler with backpressure gate. + * + * Merges all poll triggers (watcher, adaptive timer, manual) through a single + * gate that enforces a minimum interval between expensive poll cycles. + * + * - `requestPoll()` — trigger from watcher or timer. Respects min interval. + * - `requestImmediatePoll()` — manual refresh. Bypasses min interval. + * - `stats` — lightweight instrumentation (skipped, deferred, executed counts). + */ + +const DEFAULT_MIN_INTERVAL_MS = 2_000 + +interface PollSchedulerOptions { + /** Minimum ms between expensive poll cycles (default: 2000). */ + minInterval?: number +} + +export function usePollScheduler( + pollFn: () => Promise, + options?: PollSchedulerOptions, +) { + const minInterval = options?.minInterval ?? DEFAULT_MIN_INTERVAL_MS + + let lastPollEnd = 0 + let inflight = false + let deferredTimer: ReturnType | null = null + + // Lightweight instrumentation (plain object — no reactivity needed for counters) + const stats = { + executed: 0, + skipped: 0, + deferred: 0, + } + + const clearDeferred = () => { + if (deferredTimer) { + clearTimeout(deferredTimer) + deferredTimer = null + } + } + + const runPoll = async () => { + if (inflight) { + stats.skipped++ + return + } + inflight = true + try { + await pollFn() + stats.executed++ + } finally { + inflight = false + lastPollEnd = Date.now() + } + } + + /** + * Request a poll (from watcher or timer). Enforces min interval: + * - If enough time has passed → runs immediately. + * - If too soon → schedules a deferred poll at the end of the window. + * - If already inflight or a deferred poll is pending → skips. + */ + const requestPoll = () => { + if (inflight) { + stats.skipped++ + return + } + + const elapsed = Date.now() - lastPollEnd + if (elapsed >= minInterval) { + clearDeferred() + runPoll() + return + } + + // Too soon — defer to end of cooldown window (if not already deferred) + if (deferredTimer) { + stats.skipped++ + return + } + + stats.deferred++ + const remaining = minInterval - elapsed + deferredTimer = setTimeout(() => { + deferredTimer = null + runPoll() + }, remaining) + } + + /** + * Request an immediate poll (manual refresh). Bypasses min interval. + * Still prevents concurrent polls. + */ + const requestImmediatePoll = () => { + clearDeferred() + runPoll() + } + + /** Cancel any pending deferred poll. */ + const cancel = () => { + clearDeferred() + } + + return { + requestPoll, + requestImmediatePoll, + cancel, + stats, + } +} diff --git a/app/pages/index.vue b/app/pages/index.vue index b4c927b5..1d0c4d90 100644 --- a/app/pages/index.vue +++ b/app/pages/index.vue @@ -186,32 +186,16 @@ const checkViewport = () => { const { showErrorDialog: showSyncErrorDialog, lastSyncError, closeErrorDialog: closeSyncErrorDialog } = useSyncStatus() // Change detection: native file watcher via Tauri events -const { active: changeDetectionActive, startListening, stopListening, notifySelfWrite } = useChangeDetection({ - onChanged: async () => { - await pollForChanges() - }, -}) - -// Polling for external changes — optimized with 5 layers: +// Polling for external changes — optimized with 6 layers: // 1. Native file watcher (0 CPU when idle, instant detection) -// 2. Sync cooldown (Rust backend skips redundant syncs within 10s) -// 3. Filesystem mtime check as fallback (zero bd processes if nothing changed) -// 4. Batched poll command (1 IPC call instead of 3 when changes detected) -// 5. Adaptive intervals (30s safety net when watcher active, 5s/1s fallback without watcher) +// 2. Poll scheduler backpressure (min 2s between expensive cycles) +// 3. Sync cooldown (Rust backend skips redundant syncs within 10s) +// 4. Filesystem mtime check as fallback (zero bd processes if nothing changed) +// 5. Batched poll command (1 IPC call instead of 3 when changes detected) +// 6. Adaptive intervals (30s safety net when watcher active, 5s/1s fallback without watcher) const isSyncing = ref(false) let skipMtimeCheck = false // Set by watcher/fast check to avoid redundant bdCheckChanged in pollFn -// Fast change detection (cheap mtime stat, ~0ms) — runs every 1s when active -const checkMtimeChanged = async (): Promise => { - if (isLoading.value || isUpdating.value || showOnboarding.value || !beadsPath.value || projects.value.length === 0) { - return false - } - const path = beadsPath.value && beadsPath.value !== '.' ? beadsPath.value : undefined - const changed = await bdCheckChanged(path) - if (changed) skipMtimeCheck = true // pollFn can skip the mtime check — we already consumed it - return changed -} - const pollForChanges = async () => { // Don't poll if no active project if (isLoading.value || isUpdating.value || showOnboarding.value || !beadsPath.value || projects.value.length === 0) { @@ -223,7 +207,7 @@ const pollForChanges = async () => { const path = beadsPath.value && beadsPath.value !== '.' ? beadsPath.value : undefined - // Layer 2: Check filesystem mtime first — skip if fast check already detected change + // Layer 4: Check filesystem mtime first — skip if fast check already detected change if (!skipMtimeCheck) { const changed = await bdCheckChanged(path) if (!changed) { @@ -233,7 +217,7 @@ const pollForChanges = async () => { } skipMtimeCheck = false - // Layer 3: Changes detected — use batched command (1 IPC instead of 3) + // Layer 5: Changes detected — use batched command (1 IPC instead of 3) const readyData = await fetchPollData() // Update dashboard from pre-fetched data (no extra API call) @@ -254,12 +238,36 @@ const pollForChanges = async () => { } } -// Adaptive polling with fast mtime detection (degrades gracefully if watcher unavailable) -const { start: startPolling, stop: stopPolling } = useAdaptivePolling(pollForChanges, { - checkFn: checkMtimeChanged, - watcherActive: changeDetectionActive, +// Layer 2: Backpressure gate — merges watcher + timer triggers, enforces min interval +const { requestPoll, requestImmediatePoll, cancel: cancelScheduledPoll, stats: pollStats } = usePollScheduler(pollForChanges) + +const { active: changeDetectionActive, startListening, stopListening, notifySelfWrite } = useChangeDetection({ + onChanged: async () => { + requestPoll() + }, }) +// Fast change detection (cheap mtime stat, ~0ms) — runs every 1s when active +const checkMtimeChanged = async (): Promise => { + if (isLoading.value || isUpdating.value || showOnboarding.value || !beadsPath.value || projects.value.length === 0) { + return false + } + const path = beadsPath.value && beadsPath.value !== '.' ? beadsPath.value : undefined + const changed = await bdCheckChanged(path) + if (changed) skipMtimeCheck = true // pollFn can skip the mtime check — we already consumed it + return changed +} + +// Adaptive polling with fast mtime detection (degrades gracefully if watcher unavailable) +// Polls go through the scheduler's backpressure gate +const { start: startPolling, stop: stopPolling } = useAdaptivePolling( + () => { requestPoll(); return Promise.resolve() }, + { + checkFn: checkMtimeChanged, + watcherActive: changeDetectionActive, + }, +) + onMounted(async () => { checkViewport() if (import.meta.client) { @@ -322,6 +330,7 @@ onUnmounted(() => { window.removeEventListener('resize', checkViewport) stopListening() stopPolling() + cancelScheduledPoll() stopPeriodicCheck() // Auto-unregister from probe (fire-and-forget) probeUnregisterProject(beadsPath.value) diff --git a/tests/composables/usePollScheduler.test.ts b/tests/composables/usePollScheduler.test.ts new file mode 100644 index 00000000..c686cd80 --- /dev/null +++ b/tests/composables/usePollScheduler.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { usePollScheduler } from '~/composables/usePollScheduler' + +describe('usePollScheduler', () => { + beforeEach(() => { vi.useFakeTimers() }) + afterEach(() => { vi.useRealTimers() }) + + function setup(opts: { minInterval?: number; pollMs?: number } = {}) { + let resolvers: Array<() => void> = [] + const pollFn = vi.fn(() => { + if (opts.pollMs) { + return new Promise((resolve) => { resolvers.push(resolve) }) + } + return Promise.resolve() + }) + + const scheduler = usePollScheduler(pollFn, { + minInterval: opts.minInterval ?? 100, + }) + + const resolveOnePoll = () => { + const r = resolvers.shift() + r?.() + } + + return { pollFn, scheduler, resolveOnePoll } + } + + it('executes first poll immediately', async () => { + const { pollFn, scheduler } = setup() + scheduler.requestPoll() + await vi.advanceTimersByTimeAsync(0) + expect(pollFn).toHaveBeenCalledTimes(1) + expect(scheduler.stats.executed).toBe(1) + }) + + it('enforces min interval between polls', async () => { + const { pollFn, scheduler } = setup({ minInterval: 100 }) + + scheduler.requestPoll() + await vi.advanceTimersByTimeAsync(0) + expect(pollFn).toHaveBeenCalledTimes(1) + + // Request again immediately — should defer + scheduler.requestPoll() + await vi.advanceTimersByTimeAsync(0) + expect(pollFn).toHaveBeenCalledTimes(1) + expect(scheduler.stats.deferred).toBe(1) + + // Advance past min interval — deferred poll should fire + await vi.advanceTimersByTimeAsync(100) + expect(pollFn).toHaveBeenCalledTimes(2) + expect(scheduler.stats.executed).toBe(2) + }) + + it('skips when a poll is already inflight', async () => { + const { pollFn, scheduler, resolveOnePoll } = setup({ pollMs: 50 }) + + scheduler.requestPoll() + await vi.advanceTimersByTimeAsync(0) + expect(pollFn).toHaveBeenCalledTimes(1) + + // Request while inflight — should skip + scheduler.requestPoll() + expect(scheduler.stats.skipped).toBe(1) + + // Finish inflight poll + resolveOnePoll() + await vi.advanceTimersByTimeAsync(0) + }) + + it('deduplicates rapid triggers into one deferred poll', async () => { + const { pollFn, scheduler } = setup({ minInterval: 100 }) + + scheduler.requestPoll() + await vi.advanceTimersByTimeAsync(0) + expect(pollFn).toHaveBeenCalledTimes(1) + + // Three rapid triggers — first defers, rest skip + scheduler.requestPoll() + scheduler.requestPoll() + scheduler.requestPoll() + expect(scheduler.stats.deferred).toBe(1) + expect(scheduler.stats.skipped).toBe(2) + + await vi.advanceTimersByTimeAsync(100) + expect(pollFn).toHaveBeenCalledTimes(2) + }) + + it('requestImmediatePoll bypasses min interval', async () => { + const { pollFn, scheduler } = setup({ minInterval: 100 }) + + scheduler.requestPoll() + await vi.advanceTimersByTimeAsync(0) + expect(pollFn).toHaveBeenCalledTimes(1) + + // Immediate poll right after — should bypass + scheduler.requestImmediatePoll() + await vi.advanceTimersByTimeAsync(0) + expect(pollFn).toHaveBeenCalledTimes(2) + }) + + it('cancel clears pending deferred poll', async () => { + const { pollFn, scheduler } = setup({ minInterval: 100 }) + + scheduler.requestPoll() + await vi.advanceTimersByTimeAsync(0) + expect(pollFn).toHaveBeenCalledTimes(1) + + scheduler.requestPoll() // deferred + scheduler.cancel() + + await vi.advanceTimersByTimeAsync(200) + expect(pollFn).toHaveBeenCalledTimes(1) // no deferred poll ran + }) +}) From de3ca3ad152f676640fb62d82d5e33aeae7eeebe Mon Sep 17 00:00:00 2001 From: Devon Date: Thu, 5 Mar 2026 15:02:06 -0700 Subject: [PATCH 05/36] feat: add freeze diagnostics for watcher/poll pipeline - Create usePipelineDiagnostics composable tracking watcher batches, emitted/suppressed events, poll start/finish/duration, deferred runs, mtime check hit rate, and self-write cooldown skips - Instrument usePollScheduler, useChangeDetection, and pollForChanges with diagnostic recording calls - Add Pipeline tab to DebugPanel with compact 4-column diagnostic view (Watcher, Scheduler, Poll execution, Mtime check) with color-coded values and auto-refresh - Rate-limit console.debug logging to at most once per 5 seconds - Add 11 unit tests for the diagnostics composable Closes beads-task-issue-tracker-mtu.4 Amp-Thread-ID: https://ampcode.com/threads/T-019cbfff-8830-77cf-ac91-503d663048a1 Co-authored-by: Amp --- app/components/layout/DebugPanel.vue | 259 ++++++++++++------ app/composables/useChangeDetection.ts | 11 +- app/composables/usePipelineDiagnostics.ts | 168 ++++++++++++ app/composables/usePollScheduler.ts | 11 + app/pages/index.vue | 10 +- .../usePipelineDiagnostics.test.ts | 123 +++++++++ 6 files changed, 495 insertions(+), 87 deletions(-) create mode 100644 app/composables/usePipelineDiagnostics.ts create mode 100644 tests/composables/usePipelineDiagnostics.test.ts diff --git a/app/components/layout/DebugPanel.vue b/app/components/layout/DebugPanel.vue index 44cc0bfa..273e4675 100644 --- a/app/components/layout/DebugPanel.vue +++ b/app/components/layout/DebugPanel.vue @@ -5,6 +5,7 @@ import { openUrl } from '~/utils/open-url' const { isSyncing: isForceSyncing, forceSync, syncMessage, lastSyncSuccess } = useSyncStatus() const { beadsPath } = useBeadsPath() +const { snapshot: diagSnapshot, reset: diagReset } = usePipelineDiagnostics() const props = defineProps<{ isOpen: boolean @@ -24,6 +25,10 @@ const isVerbose = ref(false) const bdCliUpdate = ref(null) let refreshInterval: ReturnType | null = null +const debugTab = ref<'logs' | 'pipeline'>('logs') +const pipelineData = ref(diagSnapshot()) +let pipelineRefreshTimer: ReturnType | null = null + const logContainerRef = ref(null) const isUserAtBottom = ref(true) @@ -210,13 +215,26 @@ watch(() => props.isOpen, async (isOpen) => { } } else { stopAutoRefresh() + if (pipelineRefreshTimer) { clearInterval(pipelineRefreshTimer); pipelineRefreshTimer = null } // Disable backend logging to save resources when panel is closed await setLoggingEnabled(false) } }, { immediate: true }) +watch(debugTab, (tab) => { + if (tab === 'pipeline') { + pipelineData.value = diagSnapshot() + if (!pipelineRefreshTimer) { + pipelineRefreshTimer = setInterval(() => { pipelineData.value = diagSnapshot() }, 2000) + } + } else { + if (pipelineRefreshTimer) { clearInterval(pipelineRefreshTimer); pipelineRefreshTimer = null } + } +}) + onUnmounted(() => { stopAutoRefresh() + if (pipelineRefreshTimer) { clearInterval(pipelineRefreshTimer); pipelineRefreshTimer = null } }) @@ -235,92 +253,120 @@ onUnmounted(() => {
- Debug Logs - - + +
+ + + + + +
@@ -369,9 +415,52 @@ onUnmounted(() => {
-
+

       
No logs yet...
+ + +
+
+ +
+
Watcher
+
Triggers{{ pipelineData.counters.watcherTriggers }}
+
Cooldown skips{{ pipelineData.counters.watcherCooldownSkips }}
+
Debounces{{ pipelineData.counters.watcherDebounces }}
+
Reruns{{ pipelineData.counters.watcherReruns }}
+
+ + +
+
Scheduler
+
Requests{{ pipelineData.counters.pollRequests }}
+
Executed{{ pipelineData.counters.pollExecuted }}
+
Deferred{{ pipelineData.counters.pollDeferred }}
+
Skipped{{ pipelineData.counters.pollSkipped }}
+
Immediate{{ pipelineData.counters.pollImmediateRequests }}
+
+ + +
+
Poll execution
+
Started{{ pipelineData.counters.pollStarted }}
+
Finished{{ pipelineData.counters.pollFinished }}
+
Errors{{ pipelineData.counters.pollErrors }}
+
Last ms{{ pipelineData.counters.pollLastMs }}
+
Avg ms{{ pipelineData.pollAvgMs }}
+
Max ms{{ pipelineData.counters.pollMaxMs }}
+
+ + +
+
Mtime check
+
Checks{{ pipelineData.counters.mtimeChecks }}
+
Hits{{ pipelineData.counters.mtimeHits }}
+
Hit rate{{ pipelineData.counters.mtimeChecks > 0 ? Math.round(pipelineData.counters.mtimeHits / pipelineData.counters.mtimeChecks * 100) : 0 }}%
+
+
+
diff --git a/app/composables/useChangeDetection.ts b/app/composables/useChangeDetection.ts index e09d2dbb..65d5bcc3 100644 --- a/app/composables/useChangeDetection.ts +++ b/app/composables/useChangeDetection.ts @@ -1,4 +1,5 @@ import { getExternalUrl, getProbeProjectName, logFrontend, startWatching, stopWatching } from '~/utils/bd-api' +import { usePipelineDiagnostics } from './usePipelineDiagnostics' interface UseChangeDetectionOptions { onChanged: () => Promise @@ -22,6 +23,8 @@ export function createQueuedHandler( getSelfWriteCooldownActive: () => boolean, onProcessed: () => void, ) { + const { recordWatcherTrigger, recordWatcherDebounce, recordWatcherRerun } = usePipelineDiagnostics() + let inflight = false let pendingRerun = false let debounceTimer: ReturnType | null = null @@ -29,6 +32,7 @@ export function createQueuedHandler( function schedule() { if (debounceTimer) clearTimeout(debounceTimer) + recordWatcherDebounce() debounceTimer = setTimeout(() => run(), DEBOUNCE_MS) } @@ -56,6 +60,7 @@ export function createQueuedHandler( consecutiveReruns++ if (!pendingRerun || consecutiveReruns >= MAX_CONSECUTIVE_RERUNS) break + recordWatcherRerun() } } finally { inflight = false @@ -64,7 +69,11 @@ export function createQueuedHandler( } function trigger() { - if (getSelfWriteCooldownActive()) return + if (getSelfWriteCooldownActive()) { + recordWatcherTrigger(false) + return + } + recordWatcherTrigger(true) if (inflight) { pendingRerun = true return diff --git a/app/composables/usePipelineDiagnostics.ts b/app/composables/usePipelineDiagnostics.ts new file mode 100644 index 00000000..2716913f --- /dev/null +++ b/app/composables/usePipelineDiagnostics.ts @@ -0,0 +1,168 @@ +/** + * Pipeline diagnostics for watcher → poll pipeline. + * + * Tracks: watcher batches, emitted/suppressed events, poll start/finish/duration, + * deferred runs, and self-write cooldown hits. All counters are plain numbers + * (no Vue reactivity) to avoid observer overhead on hot paths. + * + * Exposes a compact snapshot for DebugPanel and support triage. + * Console logging is rate-limited (at most once per LOG_INTERVAL_MS). + */ + +const LOG_INTERVAL_MS = 5_000 + +interface PipelineCounters { + // Watcher / change detection + watcherTriggers: number + watcherCooldownSkips: number + watcherDebounces: number + watcherReruns: number + + // Poll scheduler + pollRequests: number + pollExecuted: number + pollSkipped: number + pollDeferred: number + pollImmediateRequests: number + + // Poll execution + pollStarted: number + pollFinished: number + pollErrors: number + pollTotalMs: number + pollLastMs: number + pollMaxMs: number + + // Mtime fast-check + mtimeChecks: number + mtimeHits: number +} + +function createCounters(): PipelineCounters { + return { + watcherTriggers: 0, + watcherCooldownSkips: 0, + watcherDebounces: 0, + watcherReruns: 0, + pollRequests: 0, + pollExecuted: 0, + pollSkipped: 0, + pollDeferred: 0, + pollImmediateRequests: 0, + pollStarted: 0, + pollFinished: 0, + pollErrors: 0, + pollTotalMs: 0, + pollLastMs: 0, + pollMaxMs: 0, + mtimeChecks: 0, + mtimeHits: 0, + } +} + +// Singleton state +const counters = createCounters() +let lastLogAt = 0 +let startedAt = Date.now() + +/** Rate-limited console log (at most once per LOG_INTERVAL_MS). */ +function maybeLog(tag: string, detail?: string) { + const now = Date.now() + if (now - lastLogAt < LOG_INTERVAL_MS) return + lastLogAt = now + const msg = detail ? `[diag][${tag}] ${detail}` : `[diag][${tag}]` + console.debug(msg) +} + +// ── Recording API (called from pipeline composables) ──────────────────── + +function recordWatcherTrigger(accepted: boolean) { + if (accepted) { + counters.watcherTriggers++ + } else { + counters.watcherCooldownSkips++ + } + maybeLog('watcher', `trigger accepted=${accepted} total=${counters.watcherTriggers} cooldownSkips=${counters.watcherCooldownSkips}`) +} + +function recordWatcherDebounce() { + counters.watcherDebounces++ +} + +function recordWatcherRerun() { + counters.watcherReruns++ +} + +function recordPollRequest(kind: 'normal' | 'immediate') { + counters.pollRequests++ + if (kind === 'immediate') counters.pollImmediateRequests++ +} + +function recordPollDecision(decision: 'executed' | 'skipped' | 'deferred') { + counters[`poll${decision.charAt(0).toUpperCase() + decision.slice(1)}` as keyof PipelineCounters] = + (counters[`poll${decision.charAt(0).toUpperCase() + decision.slice(1)}` as keyof PipelineCounters] as number) + 1 + maybeLog('scheduler', `decision=${decision} executed=${counters.pollExecuted} skipped=${counters.pollSkipped} deferred=${counters.pollDeferred}`) +} + +function recordPollStart() { + counters.pollStarted++ +} + +function recordPollFinish(durationMs: number, error: boolean) { + counters.pollFinished++ + counters.pollTotalMs += durationMs + counters.pollLastMs = durationMs + if (durationMs > counters.pollMaxMs) counters.pollMaxMs = durationMs + if (error) counters.pollErrors++ + maybeLog('poll', `duration=${durationMs}ms avg=${Math.round(counters.pollTotalMs / counters.pollFinished)}ms max=${counters.pollMaxMs}ms errors=${counters.pollErrors}`) +} + +function recordMtimeCheck(hit: boolean) { + counters.mtimeChecks++ + if (hit) counters.mtimeHits++ +} + +// ── Snapshot API (for DebugPanel / support triage) ────────────────────── + +interface PipelineSnapshot { + uptimeSeconds: number + counters: PipelineCounters + pollAvgMs: number +} + +function snapshot(): PipelineSnapshot { + const avgMs = counters.pollFinished > 0 + ? Math.round(counters.pollTotalMs / counters.pollFinished) + : 0 + + return { + uptimeSeconds: Math.round((Date.now() - startedAt) / 1000), + counters: { ...counters }, + pollAvgMs: avgMs, + } +} + +function reset() { + Object.assign(counters, createCounters()) + startedAt = Date.now() + lastLogAt = 0 +} + +// ── Composable ────────────────────────────────────────────────────────── + +export function usePipelineDiagnostics() { + return { + // Recording + recordWatcherTrigger, + recordWatcherDebounce, + recordWatcherRerun, + recordPollRequest, + recordPollDecision, + recordPollStart, + recordPollFinish, + recordMtimeCheck, + // Snapshot + snapshot, + reset, + } +} diff --git a/app/composables/usePollScheduler.ts b/app/composables/usePollScheduler.ts index 862ba2a8..e7417902 100644 --- a/app/composables/usePollScheduler.ts +++ b/app/composables/usePollScheduler.ts @@ -9,6 +9,8 @@ * - `stats` — lightweight instrumentation (skipped, deferred, executed counts). */ +import { usePipelineDiagnostics } from './usePipelineDiagnostics' + const DEFAULT_MIN_INTERVAL_MS = 2_000 interface PollSchedulerOptions { @@ -21,6 +23,7 @@ export function usePollScheduler( options?: PollSchedulerOptions, ) { const minInterval = options?.minInterval ?? DEFAULT_MIN_INTERVAL_MS + const { recordPollRequest, recordPollDecision } = usePipelineDiagnostics() let lastPollEnd = 0 let inflight = false @@ -43,12 +46,14 @@ export function usePollScheduler( const runPoll = async () => { if (inflight) { stats.skipped++ + recordPollDecision('skipped') return } inflight = true try { await pollFn() stats.executed++ + recordPollDecision('executed') } finally { inflight = false lastPollEnd = Date.now() @@ -62,8 +67,11 @@ export function usePollScheduler( * - If already inflight or a deferred poll is pending → skips. */ const requestPoll = () => { + recordPollRequest('normal') + if (inflight) { stats.skipped++ + recordPollDecision('skipped') return } @@ -77,10 +85,12 @@ export function usePollScheduler( // Too soon — defer to end of cooldown window (if not already deferred) if (deferredTimer) { stats.skipped++ + recordPollDecision('skipped') return } stats.deferred++ + recordPollDecision('deferred') const remaining = minInterval - elapsed deferredTimer = setTimeout(() => { deferredTimer = null @@ -93,6 +103,7 @@ export function usePollScheduler( * Still prevents concurrent polls. */ const requestImmediatePoll = () => { + recordPollRequest('immediate') clearDeferred() runPoll() } diff --git a/app/pages/index.vue b/app/pages/index.vue index 1d0c4d90..630954ed 100644 --- a/app/pages/index.vue +++ b/app/pages/index.vue @@ -195,6 +195,7 @@ const { showErrorDialog: showSyncErrorDialog, lastSyncError, closeErrorDialog: c // 6. Adaptive intervals (30s safety net when watcher active, 5s/1s fallback without watcher) const isSyncing = ref(false) let skipMtimeCheck = false // Set by watcher/fast check to avoid redundant bdCheckChanged in pollFn +const { recordPollStart, recordPollFinish, recordMtimeCheck } = usePipelineDiagnostics() const pollForChanges = async () => { // Don't poll if no active project @@ -202,6 +203,10 @@ const pollForChanges = async () => { return } + const pollT0 = performance.now() + let hadError = false + recordPollStart() + try { isSyncing.value = true @@ -210,6 +215,7 @@ const pollForChanges = async () => { // Layer 4: Check filesystem mtime first — skip if fast check already detected change if (!skipMtimeCheck) { const changed = await bdCheckChanged(path) + recordMtimeCheck(changed) if (!changed) { // Nothing changed on disk — skip entire poll cycle return @@ -232,9 +238,10 @@ const pollForChanges = async () => { // Tell change detection backend to ignore self-triggered events notifySelfWrite() } catch { - // Ignore polling errors + hadError = true } finally { isSyncing.value = false + recordPollFinish(Math.round(performance.now() - pollT0), hadError) } } @@ -254,6 +261,7 @@ const checkMtimeChanged = async (): Promise => { } const path = beadsPath.value && beadsPath.value !== '.' ? beadsPath.value : undefined const changed = await bdCheckChanged(path) + recordMtimeCheck(changed) if (changed) skipMtimeCheck = true // pollFn can skip the mtime check — we already consumed it return changed } diff --git a/tests/composables/usePipelineDiagnostics.test.ts b/tests/composables/usePipelineDiagnostics.test.ts new file mode 100644 index 00000000..fc67a944 --- /dev/null +++ b/tests/composables/usePipelineDiagnostics.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { usePipelineDiagnostics } from '~/composables/usePipelineDiagnostics' + +describe('usePipelineDiagnostics', () => { + let diag: ReturnType + + beforeEach(() => { + diag = usePipelineDiagnostics() + diag.reset() + }) + + it('starts with zero counters', () => { + const snap = diag.snapshot() + expect(snap.counters.watcherTriggers).toBe(0) + expect(snap.counters.pollExecuted).toBe(0) + expect(snap.counters.mtimeChecks).toBe(0) + expect(snap.pollAvgMs).toBe(0) + }) + + it('tracks watcher triggers', () => { + diag.recordWatcherTrigger(true) + diag.recordWatcherTrigger(true) + diag.recordWatcherTrigger(false) + + const snap = diag.snapshot() + expect(snap.counters.watcherTriggers).toBe(2) + expect(snap.counters.watcherCooldownSkips).toBe(1) + }) + + it('tracks watcher debounces and reruns', () => { + diag.recordWatcherDebounce() + diag.recordWatcherDebounce() + diag.recordWatcherRerun() + + const snap = diag.snapshot() + expect(snap.counters.watcherDebounces).toBe(2) + expect(snap.counters.watcherReruns).toBe(1) + }) + + it('tracks poll requests', () => { + diag.recordPollRequest('normal') + diag.recordPollRequest('normal') + diag.recordPollRequest('immediate') + + const snap = diag.snapshot() + expect(snap.counters.pollRequests).toBe(3) + expect(snap.counters.pollImmediateRequests).toBe(1) + }) + + it('tracks poll decisions', () => { + diag.recordPollDecision('executed') + diag.recordPollDecision('skipped') + diag.recordPollDecision('skipped') + diag.recordPollDecision('deferred') + + const snap = diag.snapshot() + expect(snap.counters.pollExecuted).toBe(1) + expect(snap.counters.pollSkipped).toBe(2) + expect(snap.counters.pollDeferred).toBe(1) + }) + + it('tracks poll execution timing', () => { + diag.recordPollStart() + diag.recordPollFinish(100, false) + diag.recordPollStart() + diag.recordPollFinish(300, false) + + const snap = diag.snapshot() + expect(snap.counters.pollStarted).toBe(2) + expect(snap.counters.pollFinished).toBe(2) + expect(snap.counters.pollLastMs).toBe(300) + expect(snap.counters.pollMaxMs).toBe(300) + expect(snap.counters.pollTotalMs).toBe(400) + expect(snap.pollAvgMs).toBe(200) + expect(snap.counters.pollErrors).toBe(0) + }) + + it('tracks poll errors', () => { + diag.recordPollStart() + diag.recordPollFinish(50, true) + + const snap = diag.snapshot() + expect(snap.counters.pollErrors).toBe(1) + expect(snap.counters.pollFinished).toBe(1) + }) + + it('tracks mtime checks and hits', () => { + diag.recordMtimeCheck(false) + diag.recordMtimeCheck(false) + diag.recordMtimeCheck(true) + + const snap = diag.snapshot() + expect(snap.counters.mtimeChecks).toBe(3) + expect(snap.counters.mtimeHits).toBe(1) + }) + + it('reset clears all counters', () => { + diag.recordWatcherTrigger(true) + diag.recordPollStart() + diag.recordPollFinish(200, false) + diag.recordMtimeCheck(true) + + diag.reset() + const snap = diag.snapshot() + expect(snap.counters.watcherTriggers).toBe(0) + expect(snap.counters.pollStarted).toBe(0) + expect(snap.counters.mtimeChecks).toBe(0) + expect(snap.uptimeSeconds).toBe(0) + }) + + it('snapshot returns uptime', () => { + const snap = diag.snapshot() + expect(snap.uptimeSeconds).toBeGreaterThanOrEqual(0) + }) + + it('is a singleton — multiple calls share state', () => { + const diag2 = usePipelineDiagnostics() + diag.recordWatcherTrigger(true) + + const snap = diag2.snapshot() + expect(snap.counters.watcherTriggers).toBe(1) + }) +}) From b595162e7959f4d1b5f5136fc9b715ff4bd4dade Mon Sep 17 00:00:00 2001 From: Devon Date: Thu, 5 Mar 2026 15:09:27 -0700 Subject: [PATCH 06/36] feat: add churn stress test and runbook (beads-task-issue-tracker-mtu.5) - Add vitest churn stress tests validating bounded pipeline behavior under sustained rapid triggers (500+ events over simulated time) - Add shell script for manual/CI .beads write churn generation - Add comprehensive runbook with reproduction steps, expected metrics, tuning guide, and rollback instructions Amp-Thread-ID: https://ampcode.com/threads/T-019cc007-348f-77a7-9ec1-8892d5020d9d Co-authored-by: Amp --- docs/churn-stress-runbook.md | 121 +++++++++++++ scripts/churn-stress.sh | 80 +++++++++ tests/composables/churn-stress.test.ts | 230 +++++++++++++++++++++++++ 3 files changed, 431 insertions(+) create mode 100644 docs/churn-stress-runbook.md create mode 100755 scripts/churn-stress.sh create mode 100644 tests/composables/churn-stress.test.ts diff --git a/docs/churn-stress-runbook.md b/docs/churn-stress-runbook.md new file mode 100644 index 00000000..f9dcee74 --- /dev/null +++ b/docs/churn-stress-runbook.md @@ -0,0 +1,121 @@ +# .beads Churn Stress Test Runbook + +## Overview + +This document describes how to reproduce and validate that the app remains responsive under sustained `.beads` file write churn (e.g., concurrent writers, CI pipelines, rapid issue creation). + +## Architecture Summary + +The change detection pipeline has multiple layers of protection: + +``` +Filesystem events + → Rust watcher (1s debounce, 2s min emit interval) + → Frontend debounce (300ms) + → Queued handler (single-flight + max 5 reruns) + → Poll scheduler (2s backpressure gate) + → Data fetch +``` + +## Key Constants + +| Constant | Value | Location | Purpose | +|----------|-------|----------|---------| +| `WATCHER_DEBOUNCE_INTERVAL_MS` | 1000ms | `src-tauri/src/lib.rs` | Rust-side debounce of filesystem events | +| `WATCHER_MIN_EMIT_INTERVAL_MS` | 2000ms | `src-tauri/src/lib.rs` | Min time between emitting events to frontend (env-overridable) | +| `DEBOUNCE_MS` | 300ms | `app/composables/useChangeDetection.ts` | Frontend event debounce | +| `SELF_TRIGGER_COOLDOWN_MS` | 3000ms | `app/composables/useChangeDetection.ts` | Ignore self-writes for this long | +| `MAX_CONSECUTIVE_RERUNS` | 5 | `app/composables/useChangeDetection.ts` | Max sequential reruns per handler activation | +| `DEFAULT_MIN_INTERVAL_MS` | 2000ms | `app/composables/usePollScheduler.ts` | Backpressure gate min interval | +| `INTERVAL_ACTIVE` | 5000ms | `app/composables/useAdaptivePolling.ts` | Poll interval when active (no watcher) | +| `INTERVAL_WATCHER_SAFETY` | 30000ms | `app/composables/useAdaptivePolling.ts` | Safety-net poll when watcher is active | + +## Reproduction + +### Automated (CI / Local) + +Run the vitest stress tests: + +```bash +pnpm test -- tests/composables/churn-stress.test.ts +``` + +These tests simulate 500+ rapid trigger events over simulated time and verify: +- `onChanged` call count stays bounded (< 100 for 500 triggers) +- Poll execution count stays bounded (< 20 for 200 triggers over 20s) +- Pipeline converges after burst activity +- Consecutive reruns bounded by `MAX_CONSECUTIVE_RERUNS` + +### Manual (with running app) + +1. Start the app: `pnpm tauri:dev` +2. Open the pipeline diagnostics panel (Ctrl+Shift+D or console) +3. Run the churn script in another terminal: + +```bash +# Default: 60s of writes every 50ms to .beads/ +./scripts/churn-stress.sh + +# Custom: 5 minutes at 20ms intervals against a specific directory +./scripts/churn-stress.sh /path/to/project/.beads 300 20 +``` + +4. Observe the app — it should remain responsive throughout. + +### Expected Metrics + +Under default churn (20 writes/sec for 60s = ~1200 writes): + +| Metric | Expected | Alarm threshold | +|--------|----------|-----------------| +| UI frame drops | < 5 | > 20 sustained | +| Poll executions | ~30 (≈ 60s / 2s gate) | > 60 | +| Watcher triggers (frontend) | ~60 (≈ 60s / 1s emit) | > 120 | +| Watcher cooldown skips | 0 (no self-writes) | > 10 | +| Consecutive rerun cap hits | 0 | > 5 | +| Poll avg duration | < 500ms | > 2000ms | + +## Tuning Guide + +### If the UI still freezes + +1. **Increase `WATCHER_MIN_EMIT_INTERVAL_MS`**: Set environment variable before launching: + ```bash + WATCHER_MIN_EMIT_INTERVAL_MS=5000 pnpm tauri:dev + ``` + This reduces the rate of events reaching the frontend. Min value: 250ms. + +2. **Increase `DEFAULT_MIN_INTERVAL_MS`** in `usePollScheduler.ts`: Widens the backpressure gate. Try 5000ms. + +3. **Decrease `MAX_CONSECUTIVE_RERUNS`** in `useChangeDetection.ts`: Reduces how many times the handler re-enters before yielding. Try 2-3. + +### If changes feel slow to appear + +1. **Decrease `WATCHER_MIN_EMIT_INTERVAL_MS`**: Set to 500ms for faster event delivery. +2. **Decrease `DEFAULT_MIN_INTERVAL_MS`**: Set to 1000ms for more frequent polls. +3. **Decrease `DEBOUNCE_MS`**: Set to 100ms for faster trigger response. + +### Rollback + +To revert all tuning to defaults, remove any environment variable overrides and restore the source constants: + +``` +WATCHER_DEBOUNCE_INTERVAL_MS = 1000 +WATCHER_MIN_EMIT_INTERVAL_MS = 2000 +DEBOUNCE_MS = 300 +SELF_TRIGGER_COOLDOWN_MS = 3000 +MAX_CONSECUTIVE_RERUNS = 5 +DEFAULT_MIN_INTERVAL_MS = 2000 +``` + +## Diagnostics + +The pipeline diagnostics composable (`usePipelineDiagnostics`) provides real-time counters: + +- `watcherTriggers` / `watcherCooldownSkips` — events reaching/filtered at frontend +- `watcherDebounces` / `watcherReruns` — debounce resets and rerun loops +- `pollRequests` / `pollExecuted` / `pollSkipped` / `pollDeferred` — scheduler decisions +- `pollAvgMs` / `pollMaxMs` — execution timing +- `mtimeChecks` / `mtimeHits` — fast change detection stats + +Access via `usePipelineDiagnostics().snapshot()` in console or the diagnostics panel. diff --git a/scripts/churn-stress.sh b/scripts/churn-stress.sh new file mode 100755 index 00000000..f3a40fb0 --- /dev/null +++ b/scripts/churn-stress.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# churn-stress.sh — Generate controlled .beads file churn for stress testing +# +# Usage: ./scripts/churn-stress.sh [BEADS_DIR] [DURATION_SECS] [INTERVAL_MS] +# BEADS_DIR — path to .beads directory (default: .beads) +# DURATION_SECS — how long to run (default: 60) +# INTERVAL_MS — ms between writes (default: 50) +# +# Creates a temporary churn file inside the .beads directory and writes to it +# at the specified interval. Cleans up on exit (SIGINT/SIGTERM). +# +# Expected behavior: the app should remain responsive throughout. Observe via +# the pipeline diagnostics panel (Ctrl+Shift+D) or console logs. +# +# Exit codes: +# 0 — completed successfully +# 1 — .beads directory not found + +set -euo pipefail + +BEADS_DIR="${1:-.beads}" +DURATION_SECS="${2:-60}" +INTERVAL_MS="${3:-50}" + +if [[ ! -d "$BEADS_DIR" ]]; then + echo "Error: .beads directory not found at '$BEADS_DIR'" >&2 + exit 1 +fi + +CHURN_FILE="$BEADS_DIR/.churn-stress-$$" +INTERVAL_S=$(awk "BEGIN {printf \"%.3f\", $INTERVAL_MS / 1000}") + +cleanup() { + rm -f "$CHURN_FILE" +} +trap cleanup EXIT + +echo "churn-stress: writing to $CHURN_FILE every ${INTERVAL_MS}ms for ${DURATION_SECS}s" + +writes=0 +start_epoch=$(date +%s) +last_report=$start_epoch + +while true; do + now=$(date +%s) + elapsed=$((now - start_epoch)) + + if (( elapsed >= DURATION_SECS )); then + break + fi + + writes=$((writes + 1)) + echo "$(date -u +%Y-%m-%dT%H:%M:%S.%3NZ) write=$writes" > "$CHURN_FILE" + + if (( now - last_report >= 10 )); then + if (( elapsed > 0 )); then + rate=$(awk "BEGIN {printf \"%.1f\", $writes / $elapsed}") + else + rate="$writes" + fi + echo " [${elapsed}s] writes=$writes rate=${rate}/s" + last_report=$now + fi + + sleep "$INTERVAL_S" +done + +end_epoch=$(date +%s) +total_elapsed=$((end_epoch - start_epoch)) +if (( total_elapsed > 0 )); then + avg_rate=$(awk "BEGIN {printf \"%.1f\", $writes / $total_elapsed}") +else + avg_rate="$writes" +fi + +echo "" +echo "churn-stress: done" +echo " total writes : $writes" +echo " duration : ${total_elapsed}s" +echo " avg rate : ${avg_rate}/s" diff --git a/tests/composables/churn-stress.test.ts b/tests/composables/churn-stress.test.ts new file mode 100644 index 00000000..d627eaf4 --- /dev/null +++ b/tests/composables/churn-stress.test.ts @@ -0,0 +1,230 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { createQueuedHandler } from '~/composables/useChangeDetection' +import { usePollScheduler } from '~/composables/usePollScheduler' + +describe('churn stress', () => { + beforeEach(() => { vi.useFakeTimers() }) + afterEach(() => { vi.useRealTimers() }) + + // ── helpers ────────────────────────────────────────────────────────── + + function setupHandler(opts: { onChangedMs?: number } = {}) { + let resolvers: Array<() => void> = [] + const onChanged = vi.fn(() => { + if (opts.onChangedMs != null) { + return new Promise((resolve) => { resolvers.push(resolve) }) + } + return Promise.resolve() + }) + const onProcessed = vi.fn() + + const handler = createQueuedHandler(onChanged, () => false, onProcessed) + + return { + handler, + onChanged, + onProcessed, + resolveLatest: () => { + const r = resolvers.shift() + r?.() + return vi.advanceTimersByTimeAsync(0) + }, + } + } + + // ── (a) Sustained rapid triggers don't cause unbounded calls ──────── + + it('sustained rapid triggers stay bounded', async () => { + const { handler, onChanged } = setupHandler() + + // Fire 500 triggers over 30s in bursts: trigger, then advance enough + // for some debounces to fire (mix of short and long gaps). + for (let i = 0; i < 500; i++) { + handler.trigger() + // Every 5th trigger, advance past DEBOUNCE_MS so calls actually fire. + // Other triggers advance only 10ms, resetting the debounce. + if (i % 5 === 4) { + await vi.advanceTimersByTimeAsync(350) + } else { + await vi.advanceTimersByTimeAsync(10) + } + } + // Drain final debounce + await vi.advanceTimersByTimeAsync(300) + + // 500 triggers, but debounce collapses groups of ~5 into 1 call. + // Expect roughly 100 calls — well below 500. + expect(onChanged.mock.calls.length).toBeLessThan(150) + expect(onChanged.mock.calls.length).toBeGreaterThan(0) + }) + + // ── (b) Queue + scheduler pipeline stress ─────────────────────────── + + it('queue + scheduler pipeline stays bounded', async () => { + let inflightCount = 0 + let maxInflight = 0 + + let pollResolvers: Array<() => void> = [] + const pollFn = vi.fn(() => new Promise((resolve) => { + inflightCount++ + maxInflight = Math.max(maxInflight, inflightCount) + pollResolvers.push(() => { + inflightCount-- + resolve() + }) + })) + + const scheduler = usePollScheduler(pollFn, { minInterval: 2_000 }) + + const onChanged = vi.fn(async () => { + scheduler.requestPoll() + }) + const onProcessed = vi.fn() + const handler = createQueuedHandler(onChanged, () => false, onProcessed) + + // Fire 200 triggers over ~20s. Use bursts of 10 rapid triggers + // followed by enough time for debounce to fire, so onChanged actually + // calls requestPoll repeatedly. + for (let i = 0; i < 200; i++) { + handler.trigger() + // Every 10th trigger, advance past debounce so onChanged fires + if (i % 10 === 9) { + await vi.advanceTimersByTimeAsync(350) + } else { + await vi.advanceTimersByTimeAsync(50) + } + // Resolve any pending poll so the pipeline keeps flowing + while (pollResolvers.length > 0) { + const r = pollResolvers.shift() + r?.() + await vi.advanceTimersByTimeAsync(0) + } + } + + // Drain remaining deferred timers + await vi.advanceTimersByTimeAsync(3_000) + while (pollResolvers.length > 0) { + const r = pollResolvers.shift() + r?.() + await vi.advanceTimersByTimeAsync(0) + } + + // With 2s min interval over ~20s, pollFn should execute ≤ ~12 times + expect(scheduler.stats.executed).toBeLessThan(20) + expect(scheduler.stats.executed).toBeGreaterThan(0) + + // Skipped + deferred should outnumber executed + const nonExecuted = scheduler.stats.skipped + scheduler.stats.deferred + expect(nonExecuted).toBeGreaterThan(scheduler.stats.executed) + + // No concurrent polls ever + expect(maxInflight).toBeLessThanOrEqual(1) + }) + + // ── (c) Burst followed by quiet converges ─────────────────────────── + + it('burst followed by quiet fully settles', async () => { + let pollResolvers: Array<() => void> = [] + const pollFn = vi.fn(() => new Promise((resolve) => { + pollResolvers.push(resolve) + })) + + const scheduler = usePollScheduler(pollFn, { minInterval: 2_000 }) + + const onChanged = vi.fn(async () => { + scheduler.requestPoll() + }) + const onProcessed = vi.fn() + const handler = createQueuedHandler(onChanged, () => false, onProcessed) + + // Burst: 50 rapid triggers in 100ms (every 2ms) + for (let i = 0; i < 50; i++) { + handler.trigger() + await vi.advanceTimersByTimeAsync(2) + } + + // Let debounce fire + await vi.advanceTimersByTimeAsync(300) + + // Resolve all polls that were started + while (pollResolvers.length > 0) { + const r = pollResolvers.shift() + r?.() + await vi.advanceTimersByTimeAsync(0) + } + + // Quiet period — advance 10s with no new triggers + await vi.advanceTimersByTimeAsync(10_000) + + // Resolve any final deferred polls + while (pollResolvers.length > 0) { + const r = pollResolvers.shift() + r?.() + await vi.advanceTimersByTimeAsync(0) + } + + // Pipeline should have settled: pollFn was called at least once + expect(pollFn.mock.calls.length).toBeGreaterThan(0) + + // No more pending work — advancing more time should not trigger anything + const callsAfterSettle = pollFn.mock.calls.length + await vi.advanceTimersByTimeAsync(5_000) + expect(pollFn.mock.calls.length).toBe(callsAfterSettle) + + // Last poll ran to completion (no unresolved promises) + expect(pollResolvers).toHaveLength(0) + }) + + // ── (d) Continuous rerun bounding under churn ─────────────────────── + + it('consecutive reruns are bounded by MAX_CONSECUTIVE_RERUNS', async () => { + let resolvers: Array<() => void> = [] + const onChanged = vi.fn(() => new Promise((resolve) => { + resolvers.push(resolve) + })) + const onProcessed = vi.fn() + + const handler = createQueuedHandler(onChanged, () => false, onProcessed) + + // Initial trigger → debounce fires → first onChanged starts + handler.trigger() + await vi.advanceTimersByTimeAsync(300) + expect(onChanged).toHaveBeenCalledTimes(1) + + // Keep firing triggers before each resolution to keep pendingRerun set. + // MAX_CONSECUTIVE_RERUNS = 5, so the while loop should stop after 5 + // iterations (including the first run that resets the counter). + for (let i = 0; i < 10; i++) { + handler.trigger() // set pendingRerun + const r = resolvers.shift() + r?.() + await vi.advanceTimersByTimeAsync(0) + } + + // The handler should have stopped re-entering after at most 6 calls + // (1 initial + up to 5 consecutive reruns = 6 total, but the loop + // resets consecutiveReruns to 0 at the start so the initial run counts + // as rerun #1 after increment → max 5+1=6). + expect(onChanged.mock.calls.length).toBeLessThanOrEqual(6) + expect(onChanged.mock.calls.length).toBeGreaterThanOrEqual(2) + + // After the handler stops, further triggers should schedule a fresh + // debounce rather than immediately re-entering. + const callsBefore = onChanged.mock.calls.length + handler.trigger() + await vi.advanceTimersByTimeAsync(0) + // No immediate call — still debouncing + expect(onChanged.mock.calls.length).toBe(callsBefore) + + // Resolve any remaining in-flight call + while (resolvers.length > 0) { + const r = resolvers.shift() + r?.() + await vi.advanceTimersByTimeAsync(0) + } + + // After debounce, a new run starts fresh + await vi.advanceTimersByTimeAsync(300) + expect(onChanged.mock.calls.length).toBeGreaterThan(callsBefore) + }) +}) From 54f07db1338b0f23ea839cc2ab3b085e80783641 Mon Sep 17 00:00:00 2001 From: Devon Date: Thu, 5 Mar 2026 15:15:54 -0700 Subject: [PATCH 07/36] fix: add right padding for window controls on Linux/Windows On non-macOS platforms, window manager controls (close/minimize/maximize) are positioned top-right and overlap the app header's action buttons. Detect platform and apply pr-20 + right-20 positioning on Linux/Windows (mirroring the pl-20 used for macOS traffic lights on the left). Fixes: beads-task-issue-tracker-xqu Amp-Thread-ID: https://ampcode.com/threads/T-019cc00d-7739-767c-8980-9bcbb92470ad Co-authored-by: Amp --- app/components/layout/AppHeader.vue | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/app/components/layout/AppHeader.vue b/app/components/layout/AppHeader.vue index 2a4d31ce..90f18e86 100644 --- a/app/components/layout/AppHeader.vue +++ b/app/components/layout/AppHeader.vue @@ -25,6 +25,12 @@ const { isDark, currentTheme, cycleTheme } = useTheme() const { zoomLevel, zoomIn, zoomOut, resetZoom, canZoomIn, canZoomOut } = useZoom() const { startDragging } = useTauriWindow() +// Detect macOS — on non-macOS, window controls are on the right so we need extra right padding +const isMacOS = computed(() => { + if (!import.meta.client) return true + return navigator.platform?.startsWith('Mac') || navigator.userAgent?.includes('Macintosh') +}) + // Handle window dragging via Tauri API const handleMouseDown = (event: MouseEvent) => { // Only handle left click @@ -55,9 +61,12 @@ const handleZoomIn = (event: MouseEvent) => {