Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions apps/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,11 @@ function captureBackendOutput(child: ChildProcess.ChildProcess): void {

initializePackagedLogging();

// Cap V8 heap to 2 GB so GC runs more aggressively instead of
// growing to 3.7 GB+ and crashing with OOM. This applies to both
// the main process and all renderer/utility child processes.
app.commandLine.appendSwitch("js-flags", "--max-old-space-size=2048");

if (process.platform === "linux") {
app.commandLine.appendSwitch("class", LINUX_WM_CLASS);
}
Expand Down Expand Up @@ -1354,6 +1359,10 @@ function createWindow(): BrowserWindow {
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
// Ensure the renderer's V8 heap is capped at 2 GB to prevent OOM crashes.
// app.commandLine.appendSwitch("js-flags", ...) alone doesn't propagate
// to sandboxed renderer processes reliably.
additionalArguments: ["--js-flags=--max-old-space-size=2048"],
},
});

Expand Down Expand Up @@ -1393,6 +1402,26 @@ function createWindow(): BrowserWindow {
return { action: "deny" };
});

window.webContents.on("render-process-gone", (_event, details) => {
writeDesktopLogHeader(`renderer crashed reason=${details.reason} exitCode=${details.exitCode}`);
if (details.reason === "crashed" || details.reason === "oom" || details.reason === "killed") {
setTimeout(() => {
if (!window.isDestroyed()) {
writeDesktopLogHeader("reloading renderer after crash");
window.webContents.reload();
}
}, 500);
}
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No retry limit risks infinite crash-reload loop

Medium Severity

The render-process-gone handler unconditionally schedules a reload after 500ms for every crash/OOM/killed event, with no maximum retry count or backoff. If the renderer crashes immediately after loading (e.g., due to corrupted persisted state or a deterministic startup error), this creates an infinite crash-reload loop at ~2Hz. The user would see a rapidly flickering white screen with no way to recover except force-quitting the app — arguably worse than the previous static white screen behavior.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 489dc1b. Configure here.


window.webContents.on("unresponsive", () => {
writeDesktopLogHeader("renderer became unresponsive");
});

window.webContents.on("responsive", () => {
writeDesktopLogHeader("renderer became responsive again");
});

window.on("page-title-updated", (event) => {
event.preventDefault();
window.setTitle(APP_DISPLAY_NAME);
Expand Down
46 changes: 42 additions & 4 deletions apps/server/src/git/Layers/GitCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,33 @@ const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048;
const DEFAULT_BASE_BRANCH_CANDIDATES = ["main", "master"] as const;
const GIT_LIST_BRANCHES_DEFAULT_LIMIT = 100;

/**
* Negative cache for worktree paths that are known to be missing.
* Prevents repeated stat() calls on deleted worktree directories,
* which can create a tight ENOENT error loop with growing fiber IDs.
* Entries expire after 5 minutes so that re-created paths are picked up.
*/
const MISSING_WORKTREE_CACHE_TTL_MS = 5 * 60 * 1000;
const missingWorktreePaths = new Map<string, number>();

function isWorktreePathKnownMissing(path: string): boolean {
const cachedAt = missingWorktreePaths.get(path);
if (cachedAt === undefined) return false;
if (Date.now() - cachedAt > MISSING_WORKTREE_CACHE_TTL_MS) {
missingWorktreePaths.delete(path);
return false;
}
return true;
}

function markWorktreePathMissing(path: string): void {
missingWorktreePaths.set(path, Date.now());
}

function markWorktreePathPresent(path: string): void {
missingWorktreePaths.delete(path);
}

type TraceTailState = {
processedChars: number;
remainder: string;
Expand Down Expand Up @@ -1805,10 +1832,21 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {
for (const line of worktreeList.stdout.split("\n")) {
if (line.startsWith("worktree ")) {
const candidatePath = line.slice("worktree ".length);
const exists = yield* fileSystem.stat(candidatePath).pipe(
Effect.map(() => true),
Effect.catch(() => Effect.succeed(false)),
);
let exists: boolean;
if (isWorktreePathKnownMissing(candidatePath)) {
exists = false;
} else {
exists = yield* fileSystem.stat(candidatePath).pipe(
Effect.map(() => {
markWorktreePathPresent(candidatePath);
return true;
}),
Effect.catch(() => {
markWorktreePathMissing(candidatePath);
return Effect.succeed(false);
}),
);
}
currentPath = exists ? candidatePath : null;
} else if (line.startsWith("branch refs/heads/") && currentPath) {
worktreeMap.set(line.slice("branch refs/heads/".length), currentPath);
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/components/ChatView.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@ const makeThread = (input?: {
worktreePath: null,
turnDiffSummaries: [],
activities: [],
hydrated: true,
});

afterEach(() => {
Expand Down Expand Up @@ -356,6 +357,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => {
worktreePath: null,
turnDiffSummaries: [],
activities: [],
hydrated: true,
});

expect(
Expand Down Expand Up @@ -392,6 +394,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => {
worktreePath: null,
turnDiffSummaries: [],
activities: [],
hydrated: true,
});

expect(
Expand Down Expand Up @@ -437,6 +440,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => {
worktreePath: null,
turnDiffSummaries: [],
activities: [],
hydrated: true,
});

expect(
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/ChatView.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export function buildLocalDraftThread(
turnDiffSummaries: [],
activities: [],
proposedPlans: [],
hydrated: true,
};
}

Expand Down
30 changes: 30 additions & 0 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -578,6 +578,7 @@ function PersistentThreadTerminalDrawer({
export default function ChatView({ threadId }: ChatViewProps) {
const serverThread = useThreadById(threadId);
const setStoreThreadError = useStore((store) => store.setError);
const hydrateThreadAction = useStore((store) => store.hydrateThread);
const markThreadVisited = useUiStateStore((store) => store.markThreadVisited);
const activeThreadLastVisitedAt = useUiStateStore(
(store) => store.threadLastVisitedAtById[threadId],
Expand Down Expand Up @@ -837,6 +838,26 @@ export default function ChatView({ threadId }: ChatViewProps) {
[draftThread, fallbackDraftProject?.defaultModelSelection, localDraftError, threadId],
);
const activeThread = serverThread ?? localDraftThread;

// Re-hydrate thread data if this thread was evicted from memory
useEffect(() => {
if (serverThread && !serverThread.hydrated) {
const api = readNativeApi();
if (!api) return;
api.orchestration
.getSnapshot()
.then((snapshot) => {
const fullThread = snapshot.threads.find((t) => t.id === serverThread.id);
if (fullThread) {
hydrateThreadAction(serverThread.id, fullThread);
}
})
.catch((err) => {
console.error("Failed to hydrate thread", serverThread.id, err);
});
}
}, [serverThread?.id, serverThread?.hydrated, hydrateThreadAction]);

const runtimeMode =
composerDraft.runtimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE;
const interactionMode =
Expand Down Expand Up @@ -3898,6 +3919,15 @@ export default function ChatView({ threadId }: ChatViewProps) {
void onRevertToTurnCount(targetTurnCount);
};

// Show loading state for dehydrated threads
if (activeThread && !activeThread.hydrated) {
return (
<div className="flex h-full items-center justify-center text-muted-foreground">
Loading conversation...
</div>
);
}

// Empty state: no active thread
if (!activeThread) {
return (
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/Sidebar.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -664,6 +664,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {
worktreePath: null,
turnDiffSummaries: [],
activities: [],
hydrated: true,
...overrides,
};
}
Expand Down
61 changes: 61 additions & 0 deletions apps/web/src/lib/threadEviction.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import { selectThreadsToEvict, EVICTION_KEEP_COUNT, type EvictableThread } from "./threadEviction";

function makeEvictable(id: string, overrides: Partial<EvictableThread> = {}): EvictableThread {
return {
id,
hydrated: true,
isActive: false,
hasRunningSession: false,
updatedAt: "2026-01-01T00:00:00.000Z",
messageCount: 0,
activityCount: 0,
...overrides,
};
}

describe("selectThreadsToEvict", () => {
it("returns empty array when thread count is within keep limit", () => {
const threads = Array.from({ length: EVICTION_KEEP_COUNT }, (_, i) => makeEvictable(`t-${i}`));
expect(selectThreadsToEvict(threads, "t-0")).toEqual([]);
});

it("never evicts the active thread", () => {
const threads = Array.from({ length: EVICTION_KEEP_COUNT + 5 }, (_, i) =>
makeEvictable(`t-${i}`),
);
const result = selectThreadsToEvict(threads, "t-0");
expect(result).not.toContain("t-0");
});

it("never evicts threads with running sessions", () => {
const threads = [
...Array.from({ length: EVICTION_KEEP_COUNT + 3 }, (_, i) => makeEvictable(`t-${i}`)),
makeEvictable("t-running", { hasRunningSession: true }),
];
const result = selectThreadsToEvict(threads, "t-0");
expect(result).not.toContain("t-running");
});

it("evicts oldest idle threads first", () => {
const threads = [
makeEvictable("t-old", { updatedAt: "2026-01-01T00:00:00.000Z" }),
makeEvictable("t-new", { updatedAt: "2026-04-01T00:00:00.000Z" }),
...Array.from({ length: EVICTION_KEEP_COUNT }, (_, i) =>
makeEvictable(`t-keep-${i}`, { updatedAt: "2026-03-01T00:00:00.000Z" }),
),
];
const result = selectThreadsToEvict(threads, "t-new");
expect(result).toContain("t-old");
expect(result).not.toContain("t-new");
});

it("skips already-dehydrated threads", () => {
const threads = [
makeEvictable("t-dehydrated", { hydrated: false }),
...Array.from({ length: EVICTION_KEEP_COUNT + 2 }, (_, i) => makeEvictable(`t-${i}`)),
];
const result = selectThreadsToEvict(threads, "t-0");
expect(result).not.toContain("t-dehydrated");
});
});
47 changes: 47 additions & 0 deletions apps/web/src/lib/threadEviction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/**
* Thread eviction policy for renderer memory management.
*
* Decides which threads should have their heavy data (messages, activities,
* proposedPlans, turnDiffSummaries) dropped from the Zustand store.
* Sidebar metadata is always retained.
*/

/** How many fully-hydrated threads to keep in memory at once. */
export const EVICTION_KEEP_COUNT = 5;

export interface EvictableThread {
id: string;
hydrated: boolean;
isActive: boolean;
hasRunningSession: boolean;
updatedAt: string;
messageCount: number;
activityCount: number;
}

/**
* Returns the IDs of threads that should be evicted (dehydrated).
* Never evicts: the active thread, threads with running sessions,
* or already-dehydrated threads.
*/
export function selectThreadsToEvict(
threads: ReadonlyArray<EvictableThread>,
activeThreadId: string | null,
): string[] {
const evictable = threads.filter(
(t) => t.hydrated && !t.isActive && t.id !== activeThreadId && !t.hasRunningSession,
);

const hydratedCount = threads.filter((t) => t.hydrated).length;

if (hydratedCount <= EVICTION_KEEP_COUNT) {
return [];
}

const toEvictCount = hydratedCount - EVICTION_KEEP_COUNT;

// Sort by updatedAt ascending — oldest idle threads get evicted first
const sorted = evictable.toSorted((a, b) => a.updatedAt.localeCompare(b.updatedAt));

return sorted.slice(0, toEvictCount).map((t) => t.id);
}
27 changes: 27 additions & 0 deletions apps/web/src/routes/__root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import { deriveOrchestrationBatchEffects } from "../orchestrationEventEffects";
import { createOrchestrationRecoveryCoordinator } from "../orchestrationRecovery";
import { deriveReplayRetryDecision } from "../orchestrationRecovery";
import { getWsRpcClient } from "~/wsRpcClient";
import { selectThreadsToEvict, type EvictableThread } from "~/lib/threadEviction";

export const Route = createRootRouteWithContext<{
queryClient: QueryClient;
Expand Down Expand Up @@ -588,5 +589,31 @@ function EventRouter() {
useServerWelcomeSubscription(handleWelcome);
useServerConfigUpdatedSubscription(handleServerConfigUpdated);

const evictThread = useStore((store) => store.evictThreadData);
const allThreads = useStore((store) => store.threads);

// Evict inactive threads when navigating to keep memory bounded.
// This is a separate effect from the WS subscription lifecycle.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Critical routes/__root.tsx:596

The activeThreadId extraction at line 598 checks for pathname.startsWith("/chat/"), but thread routes are /$threadId (not /chat/$threadId). Since pathname never starts with /chat/, activeThreadId is always null and the currently viewed thread is never protected from eviction. Consider updating the prefix check to match the actual route structure.

🤖 Copy this AI Prompt to have your agent fix this:
In file apps/web/src/routes/__root.tsx around line 596:

The `activeThreadId` extraction at line 598 checks for `pathname.startsWith("/chat/")`, but thread routes are `/$threadId` (not `/chat/$threadId`). Since `pathname` never starts with `/chat/`, `activeThreadId` is always `null` and the currently viewed thread is never protected from eviction. Consider updating the prefix check to match the actual route structure.

Evidence trail:
- apps/web/src/routes/__root.tsx lines 598-600: `const activeThreadId = pathname.startsWith("/chat/") ? (pathname.split("/chat/")[1]?.split("/")[0] ?? null) : null;`
- apps/web/src/routes/_chat.$threadId.tsx line 249: `export const Route = createFileRoute("/_chat/$threadId")({` - underscore prefix makes it pathless layout route
- apps/web/src/components/Sidebar.tsx line 826: `to: "/$threadId"` - confirms thread URLs are /$threadId
- apps/web/src/components/ChatView.tsx lines 939, 962, 1598, 3524, 3902: all navigate `to: "/$threadId"`
- apps/web/src/routes/__root.tsx line 255: `to: "/$threadId"` - root navigation also uses /$threadId pattern

useEffect(() => {
const activeThreadId = pathname.startsWith("/chat/")
? (pathname.split("/chat/")[1]?.split("/")[0] ?? null)
: null;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wrong pathname pattern makes active thread always null

High Severity

The thread eviction effect incorrectly assumes thread URLs start with /chat/. TanStack Router's pathless layout means actual thread URLs are /$threadId, causing activeThreadId to always be null. This prevents the active thread from being protected, leading to an infinite evict-hydrate loop and a "Loading conversation..." screen.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 489dc1b. Configure here.


const evictable: EvictableThread[] = allThreads.map((t) => ({
id: t.id,
hydrated: t.hydrated,
isActive: t.id === activeThreadId,
hasRunningSession: t.session?.status === "running",
updatedAt: t.updatedAt ?? t.createdAt,
messageCount: t.messages.length,
activityCount: t.activities.length,
}));

const idsToEvict = selectThreadsToEvict(evictable, activeThreadId);
for (const id of idsToEvict) {
evictThread(id as any);
}
}, [pathname, evictThread, allThreads]);

return null;
}
Loading
Loading