perf: optimize polling with caching and event-driven approach (P1-2)
- CLI binary detection cached with 60s TTL - Settings poll replaced with visibility/focus events - Health check uses adaptive backoff (30s → 60s when stable) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
a5805cb380
commit
e86f0cfd8f
3 files changed
+76
-11
No files matched your search
@@ -150,11 +150,20 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Poll periodically so the indicator stays in sync when settings change
|
||||
// Re-fetch when window gains focus / becomes visible instead of polling every 5s
|
||||
useEffect(() => {
|
||||
fetchSkipPermissions();
|
||||
const id = setInterval(fetchSkipPermissions, 5000);
|
||||
return () => clearInterval(id);
|
||||
const handleVisibility = () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
fetchSkipPermissions();
|
||||
}
|
||||
};
|
||||
document.addEventListener("visibilitychange", handleVisibility);
|
||||
window.addEventListener("focus", fetchSkipPermissions);
|
||||
return () => {
|
||||
document.removeEventListener("visibilitychange", handleVisibility);
|
||||
window.removeEventListener("focus", fetchSkipPermissions);
|
||||
};
|
||||
}, [fetchSkipPermissions]);
|
||||
|
||||
// --- Update check state ---
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -17,26 +17,67 @@ interface ClaudeStatus {
|
||||
version: string | null;
|
||||
}
|
||||
|
||||
const BASE_INTERVAL = 30_000; // 30s
|
||||
const BACKED_OFF_INTERVAL = 60_000; // 60s after 3 consecutive stable results
|
||||
const STABLE_THRESHOLD = 3;
|
||||
|
||||
export function ConnectionStatus() {
|
||||
const [status, setStatus] = useState<ClaudeStatus | null>(null);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const stableCountRef = useRef(0);
|
||||
const lastConnectedRef = useRef<boolean | null>(null);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Use a ref-based approach to avoid circular deps between check and schedule
|
||||
const checkRef = useRef<() => void>(() => {});
|
||||
|
||||
const schedule = useCallback(() => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
const interval = stableCountRef.current >= STABLE_THRESHOLD
|
||||
? BACKED_OFF_INTERVAL
|
||||
: BASE_INTERVAL;
|
||||
timerRef.current = setTimeout(() => checkRef.current(), interval);
|
||||
}, []);
|
||||
|
||||
const checkStatus = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/claude-status");
|
||||
if (res.ok) {
|
||||
const data: ClaudeStatus = await res.json();
|
||||
if (lastConnectedRef.current === data.connected) {
|
||||
stableCountRef.current++;
|
||||
} else {
|
||||
stableCountRef.current = 0;
|
||||
}
|
||||
lastConnectedRef.current = data.connected;
|
||||
setStatus(data);
|
||||
}
|
||||
} catch {
|
||||
if (lastConnectedRef.current === false) {
|
||||
stableCountRef.current++;
|
||||
} else {
|
||||
stableCountRef.current = 0;
|
||||
}
|
||||
lastConnectedRef.current = false;
|
||||
setStatus({ connected: false, version: null });
|
||||
}
|
||||
}, []);
|
||||
schedule();
|
||||
}, [schedule]);
|
||||
|
||||
useEffect(() => {
|
||||
checkRef.current = checkStatus;
|
||||
}, [checkStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
checkStatus(); // eslint-disable-line react-hooks/set-state-in-effect -- setState is called asynchronously after fetch
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
};
|
||||
}, [checkStatus]);
|
||||
|
||||
const handleManualRefresh = useCallback(() => {
|
||||
stableCountRef.current = 0;
|
||||
checkStatus();
|
||||
const interval = setInterval(checkStatus, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [checkStatus]);
|
||||
|
||||
const connected = status?.connected ?? false;
|
||||
@@ -127,9 +168,7 @@ export function ConnectionStatus() {
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
checkStatus();
|
||||
}}
|
||||
onClick={handleManualRefresh}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
|
||||
+18
-1
@@ -95,11 +95,28 @@ export function getExpandedPath(): string {
|
||||
return parts.join(path.delimiter);
|
||||
}
|
||||
|
||||
// TTL cache for findClaudeBinary to avoid repeated filesystem probes
|
||||
let _cachedBinaryPath: string | undefined | null = null; // null = not cached
|
||||
let _cachedBinaryTimestamp = 0;
|
||||
const BINARY_CACHE_TTL = 60_000; // 60 seconds
|
||||
|
||||
/**
|
||||
* Find and validate the Claude CLI binary.
|
||||
* Tests each candidate with --version before returning.
|
||||
* Results are cached for 60s to avoid redundant filesystem probes on every poll.
|
||||
*/
|
||||
export function findClaudeBinary(): string | undefined {
|
||||
const now = Date.now();
|
||||
if (_cachedBinaryPath !== null && now - _cachedBinaryTimestamp < BINARY_CACHE_TTL) {
|
||||
return _cachedBinaryPath;
|
||||
}
|
||||
|
||||
const found = _findClaudeBinaryUncached();
|
||||
_cachedBinaryPath = found;
|
||||
_cachedBinaryTimestamp = now;
|
||||
return found;
|
||||
}
|
||||
|
||||
function _findClaudeBinaryUncached(): string | undefined {
|
||||
// Try known candidate paths first
|
||||
for (const p of getClaudeCandidatePaths()) {
|
||||
try {
|
||||
|
||||
Reference in new issue
Block a user