Reapply "fix: prevent crashes in long-running Electron sessions"
This reverts commit 2ad64887f1.
This commit is contained in:
@@ -48,6 +48,7 @@ import StandaloneAppView from './components/apps/StandaloneAppView';
|
|||||||
import { View, ViewOptions } from './utils/navigationUtils';
|
import { View, ViewOptions } from './utils/navigationUtils';
|
||||||
|
|
||||||
import { useNavigation } from './hooks/useNavigation';
|
import { useNavigation } from './hooks/useNavigation';
|
||||||
|
import { evictSessionFromCache } from './hooks/useChatStream';
|
||||||
import { errorMessage } from './utils/conversionUtils';
|
import { errorMessage } from './utils/conversionUtils';
|
||||||
import { getInitialWorkingDir } from './utils/workingDir';
|
import { getInitialWorkingDir } from './utils/workingDir';
|
||||||
import { usePageViewTracking } from './hooks/useAnalytics';
|
import { usePageViewTracking } from './hooks/useAnalytics';
|
||||||
@@ -382,6 +383,10 @@ export function AppInner() {
|
|||||||
const newSession = { sessionId, initialMessage };
|
const newSession = { sessionId, initialMessage };
|
||||||
const updated = [...prev, newSession];
|
const updated = [...prev, newSession];
|
||||||
if (updated.length > MAX_ACTIVE_SESSIONS) {
|
if (updated.length > MAX_ACTIVE_SESSIONS) {
|
||||||
|
const evicted = updated.slice(0, updated.length - MAX_ACTIVE_SESSIONS);
|
||||||
|
for (const s of evicted) {
|
||||||
|
evictSessionFromCache(s.sessionId);
|
||||||
|
}
|
||||||
return updated.slice(updated.length - MAX_ACTIVE_SESSIONS);
|
return updated.slice(updated.length - MAX_ACTIVE_SESSIONS);
|
||||||
}
|
}
|
||||||
return updated;
|
return updated;
|
||||||
|
|||||||
@@ -302,7 +302,10 @@ export const startGoosed = async (options: StartGoosedOptions): Promise<GoosedRe
|
|||||||
|
|
||||||
goosedProcess.stdout?.on('data', (data: Buffer) => {
|
goosedProcess.stdout?.on('data', (data: Buffer) => {
|
||||||
const text = data.toString();
|
const text = data.toString();
|
||||||
logger.info(`goosed stdout for port ${port} and dir ${workingDir}: ${text}`);
|
// Only log small stdout chunks to avoid memory pressure from large payloads
|
||||||
|
if (data.length < 1024) {
|
||||||
|
logger.info(`goosed stdout: ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
if (!resolved && text.includes(FINGERPRINT_PREFIX)) {
|
if (!resolved && text.includes(FINGERPRINT_PREFIX)) {
|
||||||
for (const line of text.split('\n')) {
|
for (const line of text.split('\n')) {
|
||||||
@@ -325,15 +328,36 @@ export const startGoosed = async (options: StartGoosedOptions): Promise<GoosedRe
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const MAX_ERROR_LOG_LINES = 200;
|
||||||
|
let stderrBytesProcessed = 0;
|
||||||
|
const fatalBytes = [
|
||||||
|
Buffer.from('panicked at'),
|
||||||
|
Buffer.from('RUST_BACKTRACE'),
|
||||||
|
Buffer.from('fatal error'),
|
||||||
|
];
|
||||||
|
|
||||||
goosedProcess.stderr?.on('data', (data: Buffer) => {
|
goosedProcess.stderr?.on('data', (data: Buffer) => {
|
||||||
const lines = data.toString().split('\n');
|
stderrBytesProcessed += data.length;
|
||||||
for (const line of lines) {
|
|
||||||
if (line.trim()) {
|
// Only convert to string if the chunk might contain a fatal error,
|
||||||
errorLog.push(line);
|
// or during startup when we need errorLog for status checks.
|
||||||
if (isFatalError(line)) {
|
const isStartup = stderrBytesProcessed < 1024 * 1024;
|
||||||
logger.error(`goosed stderr for port ${port} and dir ${workingDir}: ${line}`);
|
const hasFatal = fatalBytes.some((pattern) => data.includes(pattern));
|
||||||
|
|
||||||
|
if (isStartup || hasFatal) {
|
||||||
|
const text = data.toString();
|
||||||
|
const lines = text.split('\n');
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.trim()) {
|
||||||
|
errorLog.push(line);
|
||||||
|
if (isFatalError(line)) {
|
||||||
|
logger.error(`goosed fatal: ${line}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (errorLog.length > MAX_ERROR_LOG_LINES * 2) {
|
||||||
|
errorLog.splice(0, errorLog.length - MAX_ERROR_LOG_LINES);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -27,8 +27,41 @@ import { errorMessage } from '../utils/conversionUtils';
|
|||||||
import { showExtensionLoadResults } from '../utils/extensionErrorUtils';
|
import { showExtensionLoadResults } from '../utils/extensionErrorUtils';
|
||||||
import { maybeHandlePlatformEvent } from '../utils/platform_events';
|
import { maybeHandlePlatformEvent } from '../utils/platform_events';
|
||||||
|
|
||||||
|
const MAX_CACHED_SESSIONS = 5;
|
||||||
|
const MAX_ENTRY_WEIGHT = 200;
|
||||||
|
|
||||||
const resultsCache = new Map<string, { messages: Message[]; session: Session }>();
|
const resultsCache = new Map<string, { messages: Message[]; session: Session }>();
|
||||||
|
|
||||||
|
function messageWeight(messages: Message[]): number {
|
||||||
|
let weight = 0;
|
||||||
|
for (const msg of messages) {
|
||||||
|
for (const content of msg.content) {
|
||||||
|
weight += content.type === 'toolResponse' || content.type === 'toolRequest' ? 3 : 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return weight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resultsCacheSet(key: string, value: { messages: Message[]; session: Session }) {
|
||||||
|
if (messageWeight(value.messages) > MAX_ENTRY_WEIGHT) {
|
||||||
|
resultsCache.delete(key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resultsCache.delete(key);
|
||||||
|
resultsCache.set(key, value);
|
||||||
|
|
||||||
|
while (resultsCache.size > MAX_CACHED_SESSIONS) {
|
||||||
|
const oldest = resultsCache.keys().next().value;
|
||||||
|
if (oldest !== undefined) resultsCache.delete(oldest);
|
||||||
|
else break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function evictSessionFromCache(sessionId: string) {
|
||||||
|
resultsCache.delete(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
interface UseChatStreamProps {
|
interface UseChatStreamProps {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
onStreamFinish: () => void;
|
onStreamFinish: () => void;
|
||||||
@@ -345,10 +378,10 @@ export function useChatStream({
|
|||||||
}, [sessionId]);
|
}, [sessionId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (state.session) {
|
if (state.session && state.chatState === ChatState.Idle) {
|
||||||
resultsCache.set(sessionId, { session: state.session, messages: state.messages });
|
resultsCacheSet(sessionId, { session: state.session, messages: state.messages });
|
||||||
}
|
}
|
||||||
}, [sessionId, state.session, state.messages]);
|
}, [sessionId, state.session, state.messages, state.chatState]);
|
||||||
|
|
||||||
const onFinish = useCallback(
|
const onFinish = useCallback(
|
||||||
async (error?: string): Promise<void> => {
|
async (error?: string): Promise<void> => {
|
||||||
|
|||||||
+7
-37
@@ -1536,31 +1536,30 @@ ipcMain.handle('check-ollama', async () => {
|
|||||||
const ps = spawn('ps', ['aux']);
|
const ps = spawn('ps', ['aux']);
|
||||||
const grep = spawn('grep', ['-iw', '[o]llama']);
|
const grep = spawn('grep', ['-iw', '[o]llama']);
|
||||||
|
|
||||||
let output = '';
|
const outputChunks: string[] = [];
|
||||||
let errorOutput = '';
|
let errorOutput = '';
|
||||||
|
|
||||||
// Pipe ps output to grep
|
// Pipe ps output to grep
|
||||||
ps.stdout.pipe(grep.stdin);
|
ps.stdout.pipe(grep.stdin);
|
||||||
|
|
||||||
grep.stdout.on('data', (data) => {
|
grep.stdout.on('data', (data) => {
|
||||||
output += data.toString();
|
outputChunks.push(String(data));
|
||||||
});
|
});
|
||||||
|
|
||||||
grep.stderr.on('data', (data) => {
|
grep.stderr.on('data', (data) => {
|
||||||
errorOutput += data.toString();
|
errorOutput += String(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
grep.on('close', (code) => {
|
grep.on('close', (code) => {
|
||||||
|
const output = outputChunks.join('');
|
||||||
|
|
||||||
if (code !== null && code !== 0 && code !== 1) {
|
if (code !== null && code !== 0 && code !== 1) {
|
||||||
// grep returns 1 when no matches found
|
// grep returns 1 when no matches found
|
||||||
console.error('Error executing grep command:', errorOutput);
|
console.error('Error executing grep command:', errorOutput);
|
||||||
return resolve(false);
|
return resolve(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Raw stdout from ps|grep command:', output);
|
|
||||||
const trimmedOutput = output.trim();
|
const trimmedOutput = output.trim();
|
||||||
console.log('Trimmed stdout:', trimmedOutput);
|
|
||||||
|
|
||||||
const isRunning = trimmedOutput.length > 0;
|
const isRunning = trimmedOutput.length > 0;
|
||||||
resolve(isRunning);
|
resolve(isRunning);
|
||||||
});
|
});
|
||||||
@@ -1589,37 +1588,8 @@ ipcMain.handle('check-ollama', async () => {
|
|||||||
ipcMain.handle('read-file', async (_event, filePath) => {
|
ipcMain.handle('read-file', async (_event, filePath) => {
|
||||||
try {
|
try {
|
||||||
const expandedPath = expandTilde(filePath);
|
const expandedPath = expandTilde(filePath);
|
||||||
if (process.platform === 'win32') {
|
const buffer = await fs.readFile(expandedPath);
|
||||||
const buffer = await fs.readFile(expandedPath);
|
return { file: buffer.toString('utf8'), filePath: expandedPath, error: null, found: true };
|
||||||
return { file: buffer.toString('utf8'), filePath: expandedPath, error: null, found: true };
|
|
||||||
}
|
|
||||||
// Non-Windows: keep previous behavior via cat for parity
|
|
||||||
return await new Promise((resolve) => {
|
|
||||||
const cat = spawn('cat', [expandedPath]);
|
|
||||||
let output = '';
|
|
||||||
let errorOutput = '';
|
|
||||||
|
|
||||||
cat.stdout.on('data', (data) => {
|
|
||||||
output += data.toString();
|
|
||||||
});
|
|
||||||
|
|
||||||
cat.stderr.on('data', (data) => {
|
|
||||||
errorOutput += data.toString();
|
|
||||||
});
|
|
||||||
|
|
||||||
cat.on('close', (code) => {
|
|
||||||
if (code !== 0) {
|
|
||||||
resolve({ file: '', filePath: expandedPath, error: errorOutput || null, found: false });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
resolve({ file: output, filePath: expandedPath, error: null, found: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
cat.on('error', (error) => {
|
|
||||||
console.error('Error reading file:', error);
|
|
||||||
resolve({ file: '', filePath: expandedPath, error, found: false });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error reading file:', error);
|
console.error('Error reading file:', error);
|
||||||
return { file: '', filePath: expandTilde(filePath), error, found: false };
|
return { file: '', filePath: expandTilde(filePath), error, found: false };
|
||||||
|
|||||||
@@ -1,130 +0,0 @@
|
|||||||
import { Session } from '../api';
|
|
||||||
import { getApiUrl } from '../config';
|
|
||||||
import { errorMessage } from './conversionUtils';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* In-memory cache for session data
|
|
||||||
* Maps session ID to Session object
|
|
||||||
*/
|
|
||||||
const sessionCache = new Map<string, Session>();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* In-flight request tracking to prevent duplicate fetches
|
|
||||||
* Maps session ID to Promise of Session
|
|
||||||
*/
|
|
||||||
const inFlightRequests = new Map<string, Promise<Session>>();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load a session from the server using the /agent/resume endpoint
|
|
||||||
* Implements caching to avoid redundant fetches
|
|
||||||
*
|
|
||||||
* @param sessionId - The unique identifier for the session
|
|
||||||
* @param forceRefresh - If true, bypass cache and fetch fresh data
|
|
||||||
* @returns Promise resolving to the Session object
|
|
||||||
* @throws Error if the request fails or session not found
|
|
||||||
*/
|
|
||||||
export async function loadSession(sessionId: string, forceRefresh = false): Promise<Session> {
|
|
||||||
if (!forceRefresh && sessionCache.has(sessionId)) {
|
|
||||||
return sessionCache.get(sessionId)!;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (inFlightRequests.has(sessionId)) {
|
|
||||||
return inFlightRequests.get(sessionId)!;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchPromise = (async () => {
|
|
||||||
try {
|
|
||||||
const url = getApiUrl('/agent/resume');
|
|
||||||
const secretKey = await window.electron.getSecretKey();
|
|
||||||
|
|
||||||
const response = await fetch(url, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-Secret-Key': secretKey,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
session_id: sessionId,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorText = await response.text().catch(() => 'Unknown error');
|
|
||||||
throw new Error(`Failed to load session: HTTP ${response.status} - ${errorText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const session: Session = await response.json();
|
|
||||||
sessionCache.set(sessionId, session);
|
|
||||||
|
|
||||||
return session;
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error(
|
|
||||||
`Error loading session ${sessionId}: ${errorMessage(error, 'Unknown error')}`
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
inFlightRequests.delete(sessionId);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
inFlightRequests.set(sessionId, fetchPromise);
|
|
||||||
return fetchPromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear a specific session from the cache
|
|
||||||
* Useful when a session has been updated and needs to be refetched
|
|
||||||
*
|
|
||||||
* @param sessionId - The unique identifier for the session to clear
|
|
||||||
*/
|
|
||||||
export function clearSessionCache(sessionId: string): void {
|
|
||||||
sessionCache.delete(sessionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear all sessions from the cache
|
|
||||||
* Useful for logout or when switching contexts
|
|
||||||
*/
|
|
||||||
export function clearAllSessionCache(): void {
|
|
||||||
sessionCache.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a session is currently cached
|
|
||||||
*
|
|
||||||
* @param sessionId - The unique identifier for the session
|
|
||||||
* @returns true if the session is in cache, false otherwise
|
|
||||||
*/
|
|
||||||
export function isSessionCached(sessionId: string): boolean {
|
|
||||||
return sessionCache.has(sessionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a session from cache without fetching
|
|
||||||
* Returns undefined if not cached
|
|
||||||
*
|
|
||||||
* @param sessionId - The unique identifier for the session
|
|
||||||
* @returns The cached Session object or undefined
|
|
||||||
*/
|
|
||||||
export function getCachedSession(sessionId: string): Session | undefined {
|
|
||||||
return sessionCache.get(sessionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Preload a session into cache
|
|
||||||
* Useful when you already have session data from another source
|
|
||||||
*
|
|
||||||
* @param session - The Session object to cache
|
|
||||||
*/
|
|
||||||
export function preloadSession(session: Session): void {
|
|
||||||
sessionCache.set(session.id, session);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the current cache size
|
|
||||||
* Useful for debugging and monitoring
|
|
||||||
*
|
|
||||||
* @returns The number of sessions currently cached
|
|
||||||
*/
|
|
||||||
export function getCacheSize(): number {
|
|
||||||
return sessionCache.size;
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user