Skip to content

Commit 807a22a

Browse files
fercgomesclaude
andauthored
perf: reduce memory footprint across real workflows (idle agent reclaim, poll churn, worker pools) (#3149)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 8c6e438 commit 807a22a

14 files changed

Lines changed: 873 additions & 13 deletions

File tree

apps/code/src/main/bootstrap.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,11 +58,15 @@ app.commandLine.appendSwitch("enable-logging", "file");
5858
app.commandLine.appendSwitch("log-file", chromiumLogPath);
5959
app.commandLine.appendSwitch("log-level", "0");
6060

61-
// In dev, expose the renderer over CDP (:9222) for the test-electron-app skill.
62-
// electron-vite launches Electron itself, so this is set in-process rather than
63-
// via a CLI flag.
61+
// In dev, expose the renderer over CDP (:9222 by default) for the
62+
// test-electron-app skill. electron-vite launches Electron itself, so this is
63+
// set in-process rather than via a CLI flag. POSTHOG_CODE_CDP_PORT matches the
64+
// port resolution in scripts/electron-cdp.mjs, for when :9222 is taken.
6465
if (isDev) {
65-
app.commandLine.appendSwitch("remote-debugging-port", "9222");
66+
app.commandLine.appendSwitch(
67+
"remote-debugging-port",
68+
process.env.POSTHOG_CODE_CDP_PORT ?? "9222",
69+
);
6670
}
6771

6872
crashReporter.start({ uploadToServer: false });
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import type { AcpMessage } from "@posthog/shared";
2+
import { beforeEach, describe, expect, it } from "vitest";
3+
import { agentChatStore, MAX_CHAT_MESSAGES } from "./agentChatStore";
4+
5+
const message = (i: number): AcpMessage =>
6+
({
7+
message: {
8+
jsonrpc: "2.0",
9+
method: "session/update",
10+
params: { i },
11+
},
12+
}) as unknown as AcpMessage;
13+
14+
describe("agentChatStore", () => {
15+
beforeEach(() => {
16+
agentChatStore.setState({ chats: {} });
17+
});
18+
19+
it("appends messages in order", () => {
20+
const { begin, appendMessages } = agentChatStore.getState();
21+
begin("chat", "agent");
22+
appendMessages("chat", [message(1), message(2)]);
23+
appendMessages("chat", [message(3)]);
24+
25+
expect(agentChatStore.getState().chats.chat?.messages).toHaveLength(3);
26+
});
27+
28+
it("caps retained messages at MAX_CHAT_MESSAGES, dropping oldest", () => {
29+
const { begin, appendMessages } = agentChatStore.getState();
30+
begin("chat", "agent");
31+
32+
const batch = Array.from({ length: MAX_CHAT_MESSAGES }, (_, i) =>
33+
message(i),
34+
);
35+
appendMessages("chat", batch);
36+
appendMessages("chat", [message(MAX_CHAT_MESSAGES)]);
37+
38+
const messages = agentChatStore.getState().chats.chat?.messages ?? [];
39+
expect(messages).toHaveLength(MAX_CHAT_MESSAGES);
40+
const first = messages[0]?.message as { params?: { i?: number } };
41+
const last = messages.at(-1)?.message as { params?: { i?: number } };
42+
expect(first.params?.i).toBe(1);
43+
expect(last.params?.i).toBe(MAX_CHAT_MESSAGES);
44+
});
45+
});

packages/core/src/agent-chat/agentChatStore.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,13 @@ export interface AgentChatState {
3737
error: string | null;
3838
}
3939

40+
/**
41+
* Retention cap per chat. A live chat streams an AcpMessage per chunk, so an
42+
* unbounded array grows without limit on long sessions; past this the oldest
43+
* messages fall off (the transcript of record lives server-side).
44+
*/
45+
export const MAX_CHAT_MESSAGES = 2000;
46+
4047
export const EMPTY_CHAT: AgentChatState = {
4148
agentKey: null,
4249
sessionId: null,
@@ -87,11 +94,18 @@ export const agentChatStore = createStore<AgentChatStore>((set) => {
8794
set((s) => {
8895
if (messages.length === 0) return s;
8996
const cur = s.chats[chatId] ?? EMPTY_CHAT;
97+
const appended = [...cur.messages, ...messages];
9098
return {
9199
...s,
92100
chats: {
93101
...s.chats,
94-
[chatId]: { ...cur, messages: [...cur.messages, ...messages] },
102+
[chatId]: {
103+
...cur,
104+
messages:
105+
appended.length > MAX_CHAT_MESSAGES
106+
? appended.slice(-MAX_CHAT_MESSAGES)
107+
: appended,
108+
},
95109
},
96110
};
97111
}),

packages/ui/src/features/canvas/stores/freeformChatStore.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ import { hostClient } from "../hostClient";
55

66
const log = logger.scope("freeform-edit-store");
77

8+
// Edit-history retention: each version keeps a full copy of the canvas source
9+
// plus context, so unbounded history grows by whole-document copies. Oldest
10+
// versions fall off past this cap.
11+
const MAX_VERSIONS = 50;
12+
813
// Working copy of a freeform canvas's source + edit history. Generation no
914
// longer streams in-process — it runs as a dedicated task that publishes the
1015
// result into the canvas's saved record (see useGenerateFreeformCanvas). This
@@ -206,9 +211,11 @@ export const useFreeformChatStore = create<FreeformChatStore>()((set, get) => {
206211
// Drop any redo tail before appending (linear history, as with code edits).
207212
const base =
208213
headIdx === -1 ? t.versions : t.versions.slice(0, headIdx + 1);
214+
const capped =
215+
base.length >= MAX_VERSIONS ? base.slice(-(MAX_VERSIONS - 1)) : base;
209216
patch(threadId, (prev) => ({
210217
...prev,
211-
versions: [...base, version],
218+
versions: [...capped, version],
212219
currentVersionId: version.id,
213220
}));
214221
}

packages/ui/src/features/code-review/components/ReviewShell.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,9 @@ export function ReviewShell({
195195

196196
return (
197197
<WorkerPoolContextProvider
198-
poolOptions={{ workerFactory }}
198+
// poolSize: each highlighter worker is a full V8 isolate with shiki
199+
// grammars loaded (~40MB RSS); the library default of 8 is oversized.
200+
poolOptions={{ workerFactory, poolSize: 2 }}
199201
highlighterOptions={{
200202
theme: { dark: "github-dark", light: "github-light" },
201203
langs: [

packages/ui/src/features/sessions/components/ConversationView.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,10 @@ export function ConversationView({
101101
() => ({
102102
workerFactory: () => diffWorkerFactory(),
103103
totalASTLRUCacheSize: 200,
104+
// Each pooled highlighter worker is a full V8 isolate with shiki
105+
// grammars loaded (~40MB RSS); the library default of 8 costs hundreds
106+
// of MB for parallelism conversation diffs don't need.
107+
poolSize: 2,
104108
}),
105109
[diffWorkerFactory],
106110
);

packages/ui/src/features/sessions/hooks/useSessionConnection.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,14 @@ import type { Task } from "@posthog/shared/domain-types";
88
import { useAuthStateValue } from "@posthog/ui/features/auth/store";
99
import type { AgentSession } from "@posthog/ui/features/sessions/sessionStore";
1010
import { useConnectivity } from "@posthog/ui/hooks/useConnectivity";
11+
import { useUserPresence } from "@posthog/ui/hooks/useUserPresence";
12+
import { logger } from "@posthog/ui/shell/logger";
1113
import { useQueryClient } from "@tanstack/react-query";
1214
import { useEffect, useMemo } from "react";
1315
import { useChatTitleGenerator } from "./useChatTitleGenerator";
1416

17+
const log = logger.scope("session-connection");
18+
1519
interface UseSessionConnectionOptions {
1620
taskId: string;
1721
task: Task;
@@ -32,6 +36,11 @@ export function useSessionConnection({
3236
const { isOnline } = useConnectivity();
3337
const cloudAuthState = useAuthStateValue((state) => state);
3438
const sessionService = useService<SessionService>(SESSION_SERVICE);
39+
// A mounted view used to heartbeat (and auto-reconnect) forever, pinning a
40+
// ~300-400MB agent process per visible task even when the user walked away.
41+
// Presence-gate both so the workspace-server idle timeout can reclaim the
42+
// agent; the existing idle-kill reconcile path restores it on return.
43+
const userPresent = useUserPresence();
3544

3645
useChatTitleGenerator(task);
3746

@@ -77,16 +86,23 @@ export function useSessionConnection({
7786

7887
useEffect(() => {
7988
if (!taskRunId) return;
89+
if (!userPresent) {
90+
log.info("User away — pausing activity heartbeat", { taskRunId });
91+
return;
92+
}
8093
return sessionService.startActivityHeartbeat(taskRunId);
81-
}, [taskRunId, sessionService]);
94+
}, [taskRunId, sessionService, userPresent]);
8295

8396
useEffect(() => {
8497
return sessionService.reconcileTaskConnection({
8598
task,
8699
session: connectionSession,
87100
repoPath,
88101
isCloud,
89-
isSuspended,
102+
// While the user is away, freeze local reconciling too — otherwise the
103+
// idle-kill auto-reconnect would respawn the agent process seconds
104+
// after the server reclaimed it. Cloud reconcile ignores this flag.
105+
isSuspended: isSuspended || !userPresent,
90106
isOnline,
91107
cloudAuth: {
92108
status: cloudAuthState.status,
@@ -104,6 +120,7 @@ export function useSessionConnection({
104120
repoPath,
105121
isCloud,
106122
isSuspended,
123+
userPresent,
107124
isOnline,
108125
cloudAuthState.status,
109126
cloudAuthState.bootstrapComplete,

packages/ui/src/features/tasks/useTasks.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,19 @@ import { useAuthenticatedQuery } from "../../hooks/useAuthenticatedQuery";
55
import { useMeQuery } from "../auth/useMeQuery";
66
import { taskKeys } from "./taskKeys";
77

8-
const TASK_LIST_POLL_INTERVAL_MS = 30_000;
8+
// Full-task polls are heavy (~630KB per response at 100 tasks — descriptions
9+
// and latest_run blobs included), and idle-poll churn was the app's largest
10+
// memory/CPU drain. The sidebar's primary freshness comes from the slim
11+
// summaries poll; full-task consumers are lookups where a minute of staleness
12+
// is invisible.
13+
const TASK_LIST_POLL_INTERVAL_MS = 60_000;
14+
// Summaries are slim and drive the sidebar's live status — keep them fresh.
15+
const TASK_SUMMARY_POLL_INTERVAL_MS = 30_000;
16+
// A task's slack origin and thread URL are set at creation and never change;
17+
// this poll only decorates rows with slack icons/links, so it mainly needs to
18+
// notice new tasks. Full payloads across all users made this the single
19+
// heaviest poll in the app (~2.2MB per response every 30s).
20+
const SLACK_TASK_POLL_INTERVAL_MS = 5 * 60_000;
921

1022
export function useTasks(
1123
filters?: {
@@ -43,7 +55,7 @@ export function useTaskSummaries(
4355
(client) => client.getTaskSummaries(ids),
4456
{
4557
enabled: (options?.enabled ?? true) && ids.length > 0,
46-
refetchInterval: TASK_LIST_POLL_INTERVAL_MS,
58+
refetchInterval: TASK_SUMMARY_POLL_INTERVAL_MS,
4759
placeholderData: keepPreviousData,
4860
},
4961
);
@@ -67,7 +79,7 @@ export function useSlackTasks(options?: {
6779
}) as unknown as Promise<Task[]>,
6880
{
6981
enabled: options?.enabled ?? true,
70-
refetchInterval: TASK_LIST_POLL_INTERVAL_MS,
82+
refetchInterval: SLACK_TASK_POLL_INTERVAL_MS,
7183
},
7284
);
7385
}

packages/ui/src/features/terminal/TerminalManager.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -484,6 +484,9 @@ class TerminalManagerImpl {
484484
this.loadWebglRenderer(instance);
485485
} else if (instance.terminalElement) {
486486
element.appendChild(instance.terminalElement);
487+
// Detach dropped the WebGL renderer to free its GPU context; restore it
488+
// now that the terminal is visible again.
489+
this.loadWebglRenderer(instance);
487490
instance.term.refresh(0, instance.term.rows - 1);
488491
}
489492

@@ -537,6 +540,14 @@ class TerminalManagerImpl {
537540
getParkingContainer().appendChild(instance.terminalElement);
538541
}
539542

543+
// A parked terminal renders nothing, but a live WebglAddon still holds a
544+
// GPU context (Chromium also drops the oldest context past 16). Release
545+
// it; attach() reloads it when the terminal becomes visible again.
546+
if (instance.webglAddon) {
547+
instance.webglAddon.dispose();
548+
instance.webglAddon = null;
549+
}
550+
540551
instance.attachedElement = null;
541552
}
542553

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { act, renderHook } from "@testing-library/react";
2+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3+
import { USER_PRESENCE_IDLE_MS, useUserPresence } from "./useUserPresence";
4+
5+
const MINUTE = 60 * 1000;
6+
7+
describe("useUserPresence", () => {
8+
beforeEach(() => {
9+
vi.useFakeTimers();
10+
// Input only counts while the window has focus; jsdom has no real focus.
11+
vi.spyOn(document, "hasFocus").mockReturnValue(true);
12+
});
13+
14+
afterEach(() => {
15+
vi.useRealTimers();
16+
});
17+
18+
it("starts present", () => {
19+
const { result } = renderHook(() => useUserPresence());
20+
expect(result.current).toBe(true);
21+
});
22+
23+
it("flips to away after the idle threshold with no input", () => {
24+
const { result } = renderHook(() => useUserPresence());
25+
26+
act(() => {
27+
vi.advanceTimersByTime(USER_PRESENCE_IDLE_MS + MINUTE);
28+
});
29+
30+
expect(result.current).toBe(false);
31+
});
32+
33+
it("stays present while the user keeps interacting", () => {
34+
const { result } = renderHook(() => useUserPresence());
35+
36+
act(() => {
37+
for (let i = 0; i < 15; i++) {
38+
vi.advanceTimersByTime(MINUTE);
39+
window.dispatchEvent(new Event("pointermove"));
40+
}
41+
});
42+
43+
expect(result.current).toBe(true);
44+
});
45+
46+
it("returns to present on interaction after going away", () => {
47+
const { result } = renderHook(() => useUserPresence());
48+
49+
act(() => {
50+
vi.advanceTimersByTime(USER_PRESENCE_IDLE_MS + MINUTE);
51+
});
52+
expect(result.current).toBe(false);
53+
54+
act(() => {
55+
window.dispatchEvent(new Event("keydown"));
56+
});
57+
58+
expect(result.current).toBe(true);
59+
});
60+
61+
it("ignores input while the window is unfocused", () => {
62+
vi.spyOn(document, "hasFocus").mockReturnValue(false);
63+
const { result } = renderHook(() => useUserPresence());
64+
65+
act(() => {
66+
for (let i = 0; i < 11; i++) {
67+
vi.advanceTimersByTime(MINUTE);
68+
window.dispatchEvent(new Event("pointermove"));
69+
}
70+
});
71+
72+
expect(result.current).toBe(false);
73+
});
74+
75+
it("respects a custom idle threshold", () => {
76+
const { result } = renderHook(() => useUserPresence(2 * MINUTE));
77+
78+
act(() => {
79+
vi.advanceTimersByTime(3 * MINUTE);
80+
});
81+
82+
expect(result.current).toBe(false);
83+
});
84+
85+
it("stays present under a sub-throttle idle threshold while interacting", () => {
86+
// idleMs below the 15s activity throttle: the throttle must scale down or
87+
// active input would be dropped and the user pinned "away".
88+
const { result } = renderHook(() => useUserPresence(10_000));
89+
90+
act(() => {
91+
for (let i = 0; i < 20; i++) {
92+
vi.advanceTimersByTime(4_000);
93+
window.dispatchEvent(new Event("keydown"));
94+
}
95+
});
96+
97+
expect(result.current).toBe(true);
98+
});
99+
100+
it("removes listeners on unmount", () => {
101+
const removeSpy = vi.spyOn(window, "removeEventListener");
102+
const { unmount } = renderHook(() => useUserPresence());
103+
104+
unmount();
105+
106+
const removed = removeSpy.mock.calls.map(([event]) => event);
107+
expect(removed).toEqual(
108+
expect.arrayContaining([
109+
"pointerdown",
110+
"pointermove",
111+
"keydown",
112+
"wheel",
113+
"focus",
114+
]),
115+
);
116+
});
117+
});

0 commit comments

Comments
 (0)