Skip to content
This repository was archived by the owner on Jun 8, 2026. It is now read-only.

Commit 50be375

Browse files
committed
Fix activity log bloat causing 100% CPU and approval race condition
Truncate request/response bodies to 8KB in ActivityLogStore to prevent the persisted JSON from growing unbounded (was 100MB). On startup, migrate existing oversized bodies and trim excess entries. Handle approval-not-found gracefully in IPC handlers instead of throwing, preventing errors when approvals expire between UI render and user action.
1 parent 355a26f commit 50be375

3 files changed

Lines changed: 225 additions & 16 deletions

File tree

apps/desktop/src/main/activity-log-store.ts

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,29 +18,54 @@ type ActivityStoreSchema = {
1818
events: ActivityEvent[];
1919
};
2020

21+
const MAX_BODY_LENGTH = 8_192;
22+
23+
export interface ActivityLogStoreOptions {
24+
maxEntries?: number;
25+
cwd?: string;
26+
name?: string;
27+
}
28+
2129
export class ActivityLogStore {
2230
private readonly maxEntries: number;
2331
private readonly events: ActivityEvent[];
2432
private readonly store: Store<ActivityStoreSchema>;
2533

26-
constructor(maxEntries = 200) {
27-
this.maxEntries = maxEntries;
34+
constructor(options?: ActivityLogStoreOptions | number) {
35+
const opts = typeof options === "number" ? { maxEntries: options } : options;
36+
this.maxEntries = opts?.maxEntries ?? 200;
2837
this.store = new Store<ActivityStoreSchema>({
29-
name: "desktop-activity-log",
38+
name: opts?.name ?? "desktop-activity-log",
39+
cwd: opts?.cwd,
3040
defaults: {
3141
events: []
3242
}
3343
});
3444
const persistedEvents = this.store.get("events", []);
35-
this.events = Array.isArray(persistedEvents)
36-
? persistedEvents.slice(0, this.maxEntries)
37-
: [];
45+
const raw = Array.isArray(persistedEvents) ? persistedEvents : [];
46+
this.events = raw.slice(0, this.maxEntries);
47+
// Migrate: truncate oversized bodies from existing events and persist
48+
let needsPersist = raw.length > this.maxEntries;
49+
for (const event of this.events) {
50+
const trimmedReq = truncateBody(event.requestBody);
51+
const trimmedRes = truncateBody(event.responseBody);
52+
if (trimmedReq !== event.requestBody || trimmedRes !== event.responseBody) {
53+
event.requestBody = trimmedReq;
54+
event.responseBody = trimmedRes;
55+
needsPersist = true;
56+
}
57+
}
58+
if (needsPersist) {
59+
this.persist();
60+
}
3861
}
3962

4063
add(event: Omit<ActivityEvent, "id">): ActivityEvent {
4164
const withId: ActivityEvent = {
4265
id: randomUUID(),
43-
...event
66+
...event,
67+
requestBody: truncateBody(event.requestBody),
68+
responseBody: truncateBody(event.responseBody),
4469
};
4570
this.events.unshift(withId);
4671
if (this.events.length > this.maxEntries) {
@@ -63,3 +88,10 @@ export class ActivityLogStore {
6388
this.store.set("events", this.events);
6489
}
6590
}
91+
92+
function truncateBody(body: string | undefined): string | undefined {
93+
if (!body || body.length <= MAX_BODY_LENGTH) {
94+
return body;
95+
}
96+
return `${body.slice(0, MAX_BODY_LENGTH)}… (truncated, ${body.length} bytes total)`;
97+
}

apps/desktop/src/main/app.ts

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -712,20 +712,14 @@ export class DesktopApplication {
712712
if (typeof approvalId !== "string" || !approvalId.trim()) {
713713
throw new Error("approvalId is required");
714714
}
715-
const approved = this.approvalStore.approve(approvalId.trim());
716-
if (!approved) {
717-
throw new Error("approval not found");
718-
}
715+
this.approvalStore.approve(approvalId.trim());
719716
return this.approvalStore.listPending();
720717
});
721718
ipcMain.handle("desktop:deny-approval", (_event, approvalId: string) => {
722719
if (typeof approvalId !== "string" || !approvalId.trim()) {
723720
throw new Error("approvalId is required");
724721
}
725-
const denied = this.approvalStore.deny(approvalId.trim());
726-
if (!denied) {
727-
throw new Error("approval not found");
728-
}
722+
this.approvalStore.deny(approvalId.trim());
729723
return this.approvalStore.listPending();
730724
});
731725
ipcMain.handle("desktop:always-allow-approval", (_event, approvalId: string) => {
@@ -734,7 +728,7 @@ export class DesktopApplication {
734728
}
735729
const pending = this.approvalStore.getPendingById(approvalId.trim());
736730
if (!pending) {
737-
throw new Error("approval not found");
731+
return this.approvalStore.listPending();
738732
}
739733
this.saveAlwaysAllowRuleForPending(pending);
740734
const resolved = this.approvalStore.alwaysAllow(approvalId.trim());
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import assert from "node:assert/strict";
2+
import fs from "node:fs";
3+
import os from "node:os";
4+
import path from "node:path";
5+
import { afterEach, describe, test } from "node:test";
6+
import { ActivityLogStore } from "../src/main/activity-log-store.js";
7+
8+
const tempDirs: string[] = [];
9+
10+
afterEach(() => {
11+
for (const dir of tempDirs.splice(0)) {
12+
fs.rmSync(dir, { recursive: true, force: true });
13+
}
14+
});
15+
16+
function makeTempDir(): string {
17+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "activity-log-test-"));
18+
tempDirs.push(dir);
19+
return dir;
20+
}
21+
22+
function writeStoreFile(dir: string, name: string, events: unknown[]): void {
23+
fs.writeFileSync(
24+
path.join(dir, `${name}.json`),
25+
JSON.stringify({ events })
26+
);
27+
}
28+
29+
function readStoreFile(dir: string, name: string): { events: unknown[] } {
30+
return JSON.parse(
31+
fs.readFileSync(path.join(dir, `${name}.json`), "utf-8")
32+
);
33+
}
34+
35+
function makeEvent(overrides: Record<string, unknown> = {}) {
36+
return {
37+
id: `test-${Math.random().toString(36).slice(2)}`,
38+
timestamp: new Date().toISOString(),
39+
method: "GET",
40+
path: "/api/test",
41+
statusCode: 200,
42+
durationMs: 10,
43+
...overrides,
44+
};
45+
}
46+
47+
function createStore(dir: string, name: string, maxEntries = 200) {
48+
return new ActivityLogStore({ maxEntries, cwd: dir, name });
49+
}
50+
51+
// --- truncateBody via add() ---
52+
53+
describe("ActivityLogStore body truncation", () => {
54+
test("preserves short bodies unchanged", () => {
55+
const dir = makeTempDir();
56+
const store = createStore(dir, "truncate-short", 10);
57+
const event = store.add({
58+
timestamp: new Date().toISOString(),
59+
method: "POST",
60+
path: "/api/test",
61+
statusCode: 200,
62+
durationMs: 5,
63+
requestBody: "short body",
64+
responseBody: '{"ok": true}',
65+
});
66+
67+
assert.equal(event.requestBody, "short body");
68+
assert.equal(event.responseBody, '{"ok": true}');
69+
});
70+
71+
test("truncates bodies exceeding 8192 bytes", () => {
72+
const dir = makeTempDir();
73+
const store = createStore(dir, "truncate-large", 10);
74+
const largeBody = "x".repeat(20_000);
75+
const event = store.add({
76+
timestamp: new Date().toISOString(),
77+
method: "POST",
78+
path: "/api/test",
79+
statusCode: 200,
80+
durationMs: 5,
81+
requestBody: largeBody,
82+
responseBody: largeBody,
83+
});
84+
85+
assert.ok(event.requestBody!.length < largeBody.length);
86+
assert.ok(event.requestBody!.startsWith("x".repeat(100)));
87+
assert.ok(event.requestBody!.includes("truncated"));
88+
assert.ok(event.requestBody!.includes("20000 bytes total"));
89+
assert.ok(event.responseBody!.includes("truncated"));
90+
});
91+
92+
test("preserves undefined bodies", () => {
93+
const dir = makeTempDir();
94+
const store = createStore(dir, "truncate-undef", 10);
95+
const event = store.add({
96+
timestamp: new Date().toISOString(),
97+
method: "GET",
98+
path: "/api/test",
99+
statusCode: 200,
100+
durationMs: 5,
101+
});
102+
103+
assert.equal(event.requestBody, undefined);
104+
assert.equal(event.responseBody, undefined);
105+
});
106+
});
107+
108+
// --- Constructor migration ---
109+
110+
describe("ActivityLogStore startup migration", () => {
111+
test("truncates oversized bodies from persisted events on startup", () => {
112+
const dir = makeTempDir();
113+
const name = "migrate-bodies";
114+
const largeBody = "y".repeat(20_000);
115+
writeStoreFile(dir, name, [
116+
makeEvent({ requestBody: largeBody, responseBody: largeBody }),
117+
makeEvent({ requestBody: "small", responseBody: "small" }),
118+
]);
119+
120+
const store = createStore(dir, name);
121+
const events = store.list();
122+
123+
assert.equal(events.length, 2);
124+
assert.ok(events[0].requestBody!.includes("truncated"));
125+
assert.equal(events[1].requestBody, "small");
126+
assert.equal(events[1].responseBody, "small");
127+
128+
// Verify the truncated data was persisted to disk
129+
const persisted = readStoreFile(dir, name);
130+
const persistedEvents = persisted.events as Array<Record<string, unknown>>;
131+
assert.ok((persistedEvents[0].requestBody as string).includes("truncated"));
132+
assert.equal(persistedEvents[1].requestBody, "small");
133+
});
134+
135+
test("trims event count to maxEntries on startup", () => {
136+
const dir = makeTempDir();
137+
const name = "migrate-count";
138+
const events = Array.from({ length: 50 }, (_, i) =>
139+
makeEvent({ id: `evt-${i}`, requestBody: "ok" })
140+
);
141+
writeStoreFile(dir, name, events);
142+
143+
const store = createStore(dir, name, 10);
144+
assert.equal(store.list().length, 10);
145+
146+
// Verify persisted file was trimmed too
147+
const persisted = readStoreFile(dir, name);
148+
assert.equal((persisted.events as unknown[]).length, 10);
149+
});
150+
151+
test("does not rewrite file when nothing needs migration", () => {
152+
const dir = makeTempDir();
153+
const name = "migrate-noop";
154+
writeStoreFile(dir, name, [makeEvent({ requestBody: "small" })]);
155+
const statBefore = fs.statSync(path.join(dir, `${name}.json`));
156+
157+
createStore(dir, name);
158+
159+
const statAfter = fs.statSync(path.join(dir, `${name}.json`));
160+
assert.equal(statAfter.mtimeMs, statBefore.mtimeMs);
161+
});
162+
});
163+
164+
// --- maxEntries enforcement ---
165+
166+
describe("ActivityLogStore maxEntries", () => {
167+
test("caps events at maxEntries when adding", () => {
168+
const dir = makeTempDir();
169+
const store = createStore(dir, "max-entries", 3);
170+
for (let i = 0; i < 5; i++) {
171+
store.add({
172+
timestamp: new Date().toISOString(),
173+
method: "GET",
174+
path: `/api/test/${i}`,
175+
statusCode: 200,
176+
durationMs: 1,
177+
});
178+
}
179+
180+
assert.equal(store.list().length, 3);
181+
assert.equal(store.list()[0].path, "/api/test/4");
182+
});
183+
});

0 commit comments

Comments
 (0)