feat: implement project scanning timeout and enhance metadata extraction
- Added scanProjectWithTimeout method to ProjectScanner to prevent long scans from blocking the entire batch. - Introduced cleanup logic in metadata extraction functions to ensure proper resource management during timeouts. - Updated logging to provide insights on scan durations and session statistics for better performance monitoring. - Enhanced repository group fetching logic in DashboardView and DateGroupedSessions components to handle loading and error states more effectively.
This commit is contained in:
parent
f9f3db7dcb
commit
eacd7d82e1
4 changed files with 112 additions and 35 deletions
|
|
@ -168,7 +168,7 @@ export class ProjectScanner {
|
||||||
const projectArrays = await this.collectFulfilledInBatches(
|
const projectArrays = await this.collectFulfilledInBatches(
|
||||||
projectDirs,
|
projectDirs,
|
||||||
this.fsProvider.type === 'ssh' ? 8 : ProjectScanner.LOCAL_PROJECT_BATCH,
|
this.fsProvider.type === 'ssh' ? 8 : ProjectScanner.LOCAL_PROJECT_BATCH,
|
||||||
async (dir) => this.scanProject(dir.name)
|
async (dir) => this.scanProjectWithTimeout(dir.name)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Flatten and sort by most recent
|
// Flatten and sort by most recent
|
||||||
|
|
@ -312,6 +312,31 @@ export class ProjectScanner {
|
||||||
// Project Scanning (continued)
|
// Project Scanning (continued)
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
|
|
||||||
|
// Per-project scan timeout: prevents a single slow directory from blocking
|
||||||
|
// the entire scan batch (e.g. a project with 1000+ session files on slow I/O).
|
||||||
|
private static readonly SCAN_PROJECT_TIMEOUT_MS = 15_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scans a single project directory with a timeout guard.
|
||||||
|
* Returns empty array if the scan exceeds the timeout.
|
||||||
|
*/
|
||||||
|
private async scanProjectWithTimeout(encodedName: string): Promise<Project[]> {
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
const timeout = new Promise<Project[]>((resolve) => {
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
logger.warn(
|
||||||
|
`[scanProject] timeout after ${ProjectScanner.SCAN_PROJECT_TIMEOUT_MS}ms project=${encodedName}`
|
||||||
|
);
|
||||||
|
resolve([]);
|
||||||
|
}, ProjectScanner.SCAN_PROJECT_TIMEOUT_MS);
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
return await Promise.race([this.scanProject(encodedName), timeout]);
|
||||||
|
} finally {
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scans a single project directory and returns project metadata.
|
* Scans a single project directory and returns project metadata.
|
||||||
* If sessions have different cwd values, splits into multiple projects.
|
* If sessions have different cwd values, splits into multiple projects.
|
||||||
|
|
@ -319,7 +344,9 @@ export class ProjectScanner {
|
||||||
private async scanProject(encodedName: string): Promise<Project[]> {
|
private async scanProject(encodedName: string): Promise<Project[]> {
|
||||||
try {
|
try {
|
||||||
const projectPath = path.join(this.projectsDir, encodedName);
|
const projectPath = path.join(this.projectsDir, encodedName);
|
||||||
|
const readdirStart = Date.now();
|
||||||
const entries = await this.fsProvider.readdir(projectPath);
|
const entries = await this.fsProvider.readdir(projectPath);
|
||||||
|
const readdirMs = Date.now() - readdirStart;
|
||||||
|
|
||||||
// Get session files (.jsonl at root level)
|
// Get session files (.jsonl at root level)
|
||||||
const sessionFiles = entries.filter(
|
const sessionFiles = entries.filter(
|
||||||
|
|
@ -330,6 +357,12 @@ export class ProjectScanner {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (sessionFiles.length > 200 || readdirMs > 500) {
|
||||||
|
logger.debug(
|
||||||
|
`[scanProject] ${encodedName} readdir=${readdirMs}ms entries=${entries.length} jsonl=${sessionFiles.length}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Collect file stats and cwd for each session
|
// Collect file stats and cwd for each session
|
||||||
interface SessionInfo {
|
interface SessionInfo {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
|
|
@ -346,6 +379,8 @@ export class ProjectScanner {
|
||||||
const MAX_CWD_SPLIT_FILES = 80;
|
const MAX_CWD_SPLIT_FILES = 80;
|
||||||
const shouldSplitByCwd =
|
const shouldSplitByCwd =
|
||||||
this.fsProvider.type !== 'ssh' && sessionFiles.length <= MAX_CWD_SPLIT_FILES;
|
this.fsProvider.type !== 'ssh' && sessionFiles.length <= MAX_CWD_SPLIT_FILES;
|
||||||
|
|
||||||
|
const sessionStatStart = Date.now();
|
||||||
const sessionInfos = await this.collectFulfilledInBatches(
|
const sessionInfos = await this.collectFulfilledInBatches(
|
||||||
sessionFiles,
|
sessionFiles,
|
||||||
this.fsProvider.type === 'ssh' ? 32 : ProjectScanner.LOCAL_SESSION_BATCH,
|
this.fsProvider.type === 'ssh' ? 32 : ProjectScanner.LOCAL_SESSION_BATCH,
|
||||||
|
|
@ -377,6 +412,13 @@ export class ProjectScanner {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sessionStatMs = Date.now() - sessionStatStart;
|
||||||
|
if (sessionFiles.length > 200 || sessionStatMs > 1000) {
|
||||||
|
logger.debug(
|
||||||
|
`[scanProject] ${encodedName} sessionStat=${sessionStatMs}ms files=${sessionFiles.length} infos=${sessionInfos.length}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Group sessions by cwd
|
// Group sessions by cwd
|
||||||
const cwdGroups = new Map<string, SessionInfo[]>();
|
const cwdGroups = new Map<string, SessionInfo[]>();
|
||||||
const firstCwd = sessionInfos.find((s) => s.cwd)?.cwd ?? undefined;
|
const firstCwd = sessionInfos.find((s) => s.cwd)?.cwd ?? undefined;
|
||||||
|
|
|
||||||
|
|
@ -51,23 +51,37 @@ export async function extractCwd(
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileStream = fsProvider.createReadStream(filePath, { encoding: 'utf8' });
|
const fileStream = fsProvider.createReadStream(filePath, { encoding: 'utf8' });
|
||||||
let bytes = 0;
|
|
||||||
let timedOut = false;
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
timedOut = true;
|
|
||||||
fileStream.destroy();
|
|
||||||
}, JSONL_HEAD_TIMEOUT_MS);
|
|
||||||
fileStream.on('data', (chunk: string) => {
|
|
||||||
bytes += byteLen(chunk);
|
|
||||||
if (bytes > JSONL_HEAD_MAX_BYTES) {
|
|
||||||
fileStream.destroy();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const rl = readline.createInterface({
|
const rl = readline.createInterface({
|
||||||
input: fileStream,
|
input: fileStream,
|
||||||
crlfDelay: Infinity,
|
crlfDelay: Infinity,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let bytes = 0;
|
||||||
|
let timedOut = false;
|
||||||
|
let cleaned = false;
|
||||||
|
|
||||||
|
// Close readline FIRST so `for await` exits, then destroy the stream.
|
||||||
|
// Calling only stream.destroy() can leave readline hanging when it has
|
||||||
|
// a partial line buffered (e.g. a 400KB+ JSONL line read in 64KB chunks).
|
||||||
|
const cleanup = (): void => {
|
||||||
|
if (cleaned) return;
|
||||||
|
cleaned = true;
|
||||||
|
rl.close();
|
||||||
|
fileStream.destroy();
|
||||||
|
};
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
timedOut = true;
|
||||||
|
cleanup();
|
||||||
|
}, JSONL_HEAD_TIMEOUT_MS);
|
||||||
|
|
||||||
|
fileStream.on('data', (chunk: string) => {
|
||||||
|
bytes += byteLen(chunk);
|
||||||
|
if (bytes > JSONL_HEAD_MAX_BYTES) {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let lines = 0;
|
let lines = 0;
|
||||||
for await (const line of rl) {
|
for await (const line of rl) {
|
||||||
|
|
@ -84,8 +98,6 @@ export async function extractCwd(
|
||||||
}
|
}
|
||||||
// Only conversational entries have cwd
|
// Only conversational entries have cwd
|
||||||
if ('cwd' in entry && entry.cwd) {
|
if ('cwd' in entry && entry.cwd) {
|
||||||
rl.close();
|
|
||||||
fileStream.destroy();
|
|
||||||
return entry.cwd;
|
return entry.cwd;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -95,8 +107,7 @@ export async function extractCwd(
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
rl.close();
|
cleanup();
|
||||||
fileStream.destroy();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -122,23 +133,34 @@ export async function extractFirstUserMessagePreview(
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileStream = fsProvider.createReadStream(filePath, { encoding: 'utf8' });
|
const fileStream = fsProvider.createReadStream(filePath, { encoding: 'utf8' });
|
||||||
let bytes = 0;
|
|
||||||
let timedOut = false;
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
timedOut = true;
|
|
||||||
fileStream.destroy();
|
|
||||||
}, JSONL_HEAD_TIMEOUT_MS);
|
|
||||||
fileStream.on('data', (chunk: string) => {
|
|
||||||
bytes += byteLen(chunk);
|
|
||||||
if (bytes > JSONL_HEAD_MAX_BYTES) {
|
|
||||||
fileStream.destroy();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const rl = readline.createInterface({
|
const rl = readline.createInterface({
|
||||||
input: fileStream,
|
input: fileStream,
|
||||||
crlfDelay: Infinity,
|
crlfDelay: Infinity,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let bytes = 0;
|
||||||
|
let timedOut = false;
|
||||||
|
let cleaned = false;
|
||||||
|
|
||||||
|
const cleanup = (): void => {
|
||||||
|
if (cleaned) return;
|
||||||
|
cleaned = true;
|
||||||
|
rl.close();
|
||||||
|
fileStream.destroy();
|
||||||
|
};
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
timedOut = true;
|
||||||
|
cleanup();
|
||||||
|
}, JSONL_HEAD_TIMEOUT_MS);
|
||||||
|
|
||||||
|
fileStream.on('data', (chunk: string) => {
|
||||||
|
bytes += byteLen(chunk);
|
||||||
|
if (bytes > JSONL_HEAD_MAX_BYTES) {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let commandFallback: { text: string; timestamp: string } | null = null;
|
let commandFallback: { text: string; timestamp: string } | null = null;
|
||||||
let linesRead = 0;
|
let linesRead = 0;
|
||||||
|
|
||||||
|
|
@ -182,8 +204,7 @@ export async function extractFirstUserMessagePreview(
|
||||||
return commandFallback;
|
return commandFallback;
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
rl.close();
|
cleanup();
|
||||||
fileStream.destroy();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return commandFallback;
|
return commandFallback;
|
||||||
|
|
|
||||||
|
|
@ -518,10 +518,15 @@ const ProjectsGrid = ({
|
||||||
const [visibleProjects, setVisibleProjects] = useState(maxProjects);
|
const [visibleProjects, setVisibleProjects] = useState(maxProjects);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (repositoryGroups.length === 0 && !repositoryGroupsLoading) {
|
if (repositoryGroups.length === 0 && !repositoryGroupsLoading && !repositoryGroupsError) {
|
||||||
void fetchRepositoryGroups();
|
void fetchRepositoryGroups();
|
||||||
}
|
}
|
||||||
}, [repositoryGroups.length, repositoryGroupsLoading, fetchRepositoryGroups]);
|
}, [
|
||||||
|
repositoryGroups.length,
|
||||||
|
repositoryGroupsLoading,
|
||||||
|
repositoryGroupsError,
|
||||||
|
fetchRepositoryGroups,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (repositoryGroups.length > 0 && !hasFetchedTasksRef.current && !repositoryGroupsLoading) {
|
if (repositoryGroups.length > 0 && !hasFetchedTasksRef.current && !repositoryGroupsLoading) {
|
||||||
|
|
|
||||||
|
|
@ -241,11 +241,18 @@ export const DateGroupedSessions = (): React.JSX.Element => {
|
||||||
// Loading guards in the store actions prevent duplicate IPC calls
|
// Loading guards in the store actions prevent duplicate IPC calls
|
||||||
// when the centralized init chain has already started a fetch.
|
// when the centralized init chain has already started a fetch.
|
||||||
const repositoryGroupsLoading = useStore((s) => s.repositoryGroupsLoading);
|
const repositoryGroupsLoading = useStore((s) => s.repositoryGroupsLoading);
|
||||||
|
const repositoryGroupsError = useStore((s) => s.repositoryGroupsError);
|
||||||
const projectsLoading = useStore((s) => s.projectsLoading);
|
const projectsLoading = useStore((s) => s.projectsLoading);
|
||||||
|
const projectsError = useStore((s) => s.projectsError);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (viewMode === 'grouped' && repositoryGroups.length === 0 && !repositoryGroupsLoading) {
|
if (
|
||||||
|
viewMode === 'grouped' &&
|
||||||
|
repositoryGroups.length === 0 &&
|
||||||
|
!repositoryGroupsLoading &&
|
||||||
|
!repositoryGroupsError
|
||||||
|
) {
|
||||||
void fetchRepositoryGroups();
|
void fetchRepositoryGroups();
|
||||||
} else if (viewMode === 'flat' && projects.length === 0 && !projectsLoading) {
|
} else if (viewMode === 'flat' && projects.length === 0 && !projectsLoading && !projectsError) {
|
||||||
void fetchProjects();
|
void fetchProjects();
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
|
|
@ -253,7 +260,9 @@ export const DateGroupedSessions = (): React.JSX.Element => {
|
||||||
repositoryGroups.length,
|
repositoryGroups.length,
|
||||||
projects.length,
|
projects.length,
|
||||||
repositoryGroupsLoading,
|
repositoryGroupsLoading,
|
||||||
|
repositoryGroupsError,
|
||||||
projectsLoading,
|
projectsLoading,
|
||||||
|
projectsError,
|
||||||
fetchRepositoryGroups,
|
fetchRepositoryGroups,
|
||||||
fetchProjects,
|
fetchProjects,
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue