Skip to content

Commit 6f81928

Browse files
authored
fix: async transcript I/O to unblock gateway event loop (#75595)
* fix: async transcript I/O to unblock gateway event loop Two related fixes for event-loop starvation caused by synchronous file operations on session transcript files during gateway hot paths. ## sessions.list: yield between transcript reads (#75330) Extract filterAndSortSessionEntries() from listSessionsFromStore() and add a new listSessionsFromStoreAsync() that yields to the event loop via setImmediate every 10 session rows. The sessions.list RPC handler now uses the async version. The synchronous version is kept for callers that need it (sessions- resolve visibility checks, embedded backends, subagent tools). The dominant blocker is readSessionTitleFieldsFromTranscript(), which performs fs.statSync + fs.openSync + fs.readSync (head) + fs.readSync (tail) for every session row that requests derived titles or last- message previews. With 100+ sessions, this blocks the event loop for 32-64 seconds, starving WebSocket heartbeats, channel I/O, and concurrent RPC. ## session compaction: async file copy (#75414) Add captureCompactionCheckpointSnapshotAsync() using fs.promises for stat, copyFile, and unlink instead of fsSync equivalents. Switch both compact.ts and compact.queued.ts to the async version. The synchronous copyFileSync of large transcript files (20MB+ observed in production) was blocking the event loop for the entire copy duration — one reporter measured a 43-minute event loop block from a single compaction checkpoint capture. Refs: #75330, #75414 * test: cover async transcript I/O responsiveness * fix: avoid sync checkpoint metadata reads
1 parent 32359e6 commit 6f81928

9 files changed

Lines changed: 554 additions & 22 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ Docs: https://docs.openclaw.ai
4646
- Gateway/config: cap oversized plugin-owned schemas in the full `config.schema` response so large installed plugin sets cannot balloon Gateway RSS or crash schema clients. Thanks @vincentkoc.
4747
- Plugins/update: skip ClawHub and marketplace plugin updates when the bundled version is newer than the recorded installed version, so `openclaw update` no longer overwrites working bundled plugins with older external packages. Fixes #75447. Thanks @amknight.
4848
- Gateway/sessions: use bounded tail reads for sessions-list transcript usage fallbacks and cap bulk title/last-message hydration, keeping large session stores responsive when rows request derived previews. Thanks @vincentkoc.
49+
- Gateway/sessions: yield during bulk transcript title/preview hydration and copy compaction checkpoints asynchronously, keeping the Gateway event loop responsive for large session stores and large transcripts. Refs #75330 and #75414. Thanks @amknight.
4950
- Gateway/chat: bound chat-history transcript reads to the requested display window so large session logs no longer OOM the Gateway when clients ask for a small history page. Thanks @vincentkoc.
5051
- Voice Call/Twilio: honor stored pre-connect TwiML before realtime webhook shortcuts and reject DTMF sequences outside conversation mode, so Meet PIN entry cannot be skipped or silently dropped. Thanks @donkeykong91 and @PfanP.
5152
- Docs/sandboxing: clarify that sandbox setup scripts (`sandbox-setup.sh`, `sandbox-common-setup.sh`, `sandbox-browser-setup.sh`) are only available from a source checkout, and add inline `docker build` commands for npm-installed users so sandbox image setup works without cloning the repo. Fixes #75485. Thanks @amknight.

src/agents/pi-embedded-runner/compact.queued.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@ import { ensureContextEnginesInitialized } from "../../context-engine/init.js";
33
import { resolveContextEngine } from "../../context-engine/registry.js";
44
import type { ContextEngineRuntimeContext } from "../../context-engine/types.js";
55
import {
6-
captureCompactionCheckpointSnapshot,
6+
captureCompactionCheckpointSnapshotAsync,
77
cleanupCompactionCheckpointSnapshot,
88
persistSessionCompactionCheckpoint,
9+
readSessionLeafIdFromTranscriptAsync,
910
resolveSessionCompactionCheckpointReason,
1011
type CapturedCompactionCheckpointSnapshot,
1112
} from "../../gateway/session-compaction-checkpoints.js";
@@ -115,8 +116,7 @@ export async function compactEmbeddedPiSession(
115116
// are notified regardless of which engine is active.
116117
const engineOwnsCompaction = contextEngine.info.ownsCompaction === true;
117118
checkpointSnapshot = engineOwnsCompaction
118-
? captureCompactionCheckpointSnapshot({
119-
sessionManager: SessionManager.open(params.sessionFile),
119+
? await captureCompactionCheckpointSnapshotAsync({
120120
sessionFile: params.sessionFile,
121121
})
122122
: null;
@@ -200,7 +200,7 @@ export async function compactEmbeddedPiSession(
200200
try {
201201
const postLeafId =
202202
postCompactionLeafId ??
203-
SessionManager.open(postCompactionSessionFile).getLeafId() ??
203+
(await readSessionLeafIdFromTranscriptAsync(postCompactionSessionFile)) ??
204204
undefined;
205205
const storedCheckpoint = await persistSessionCompactionCheckpoint({
206206
cfg: params.config,

src/agents/pi-embedded-runner/compact.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { isAcpRuntimeSpawnAvailable } from "../../acp/runtime/availability.js";
1111
import type { ThinkLevel } from "../../auto-reply/thinking.js";
1212
import type { OpenClawConfig } from "../../config/types.openclaw.js";
1313
import {
14-
captureCompactionCheckpointSnapshot,
14+
captureCompactionCheckpointSnapshotAsync,
1515
cleanupCompactionCheckpointSnapshot,
1616
persistSessionCompactionCheckpoint,
1717
resolveSessionCompactionCheckpointReason,
@@ -822,7 +822,7 @@ export async function compactEmbeddedPiSessionDirect(
822822
: undefined,
823823
allowedToolNames,
824824
});
825-
checkpointSnapshot = captureCompactionCheckpointSnapshot({
825+
checkpointSnapshot = await captureCompactionCheckpointSnapshotAsync({
826826
sessionManager,
827827
sessionFile: params.sessionFile,
828828
});

src/gateway/server-methods/sessions.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ import {
7272
import { reactivateCompletedSubagentSession } from "../session-subagent-reactivation.js";
7373
import {
7474
archiveFileOnDisk,
75-
listSessionsFromStore,
75+
listSessionsFromStoreAsync,
7676
loadCombinedSessionStoreForGateway,
7777
loadGatewaySessionRow,
7878
loadSessionEntry,
@@ -650,7 +650,7 @@ export const sessionsHandlers: GatewayRequestHandlers = {
650650
const cfg = context.getRuntimeConfig();
651651
const { storePath, store } = loadCombinedSessionStoreForGateway(cfg);
652652
const modelCatalog = await loadOptionalSessionsListModelCatalog(context);
653-
const result = listSessionsFromStore({
653+
const result = await listSessionsFromStoreAsync({
654654
cfg,
655655
storePath,
656656
store,

src/gateway/server.sessions.list-changed.test.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,76 @@ test("sessions.list uses the gateway model catalog for effective thinking defaul
157157
);
158158
});
159159

160+
test("sessions.list yields before responding during bulk transcript hydration", async () => {
161+
const { dir } = await createSessionStoreDir();
162+
const entries: Record<string, ReturnType<typeof sessionStoreEntry>> = {};
163+
const now = Date.now();
164+
for (let i = 0; i < 11; i += 1) {
165+
const sessionId = `sess-list-yield-${i}`;
166+
entries[`bulk-${i}`] = sessionStoreEntry(sessionId, { updatedAt: now - i });
167+
await fs.writeFile(
168+
path.join(dir, `${sessionId}.jsonl`),
169+
[
170+
JSON.stringify({ type: "session", version: 1, id: sessionId }),
171+
JSON.stringify({ message: { role: "user", content: `title ${i}` } }),
172+
JSON.stringify({ message: { role: "assistant", content: `last ${i}` } }),
173+
].join("\n"),
174+
"utf-8",
175+
);
176+
}
177+
await writeSessionStore({ entries });
178+
179+
const respond = vi.fn();
180+
const sessionsHandlers = await getSessionsHandlers();
181+
const { getRuntimeConfig } = await getGatewayConfigModule();
182+
const request = sessionsHandlers["sessions.list"]({
183+
req: {
184+
type: "req",
185+
id: "req-sessions-list-yield",
186+
method: "sessions.list",
187+
params: {
188+
includeDerivedTitles: true,
189+
includeLastMessage: true,
190+
limit: 11,
191+
},
192+
},
193+
params: {
194+
includeDerivedTitles: true,
195+
includeLastMessage: true,
196+
limit: 11,
197+
},
198+
respond,
199+
client: null,
200+
isWebchatConnect: () => false,
201+
context: {
202+
getRuntimeConfig,
203+
loadGatewayModelCatalog: async () => [],
204+
logGateway: {
205+
debug: vi.fn(),
206+
},
207+
} as never,
208+
});
209+
210+
await Promise.resolve();
211+
await Promise.resolve();
212+
213+
expect(respond).not.toHaveBeenCalled();
214+
await request;
215+
expect(respond).toHaveBeenCalledWith(
216+
true,
217+
expect.objectContaining({
218+
sessions: expect.arrayContaining([
219+
expect.objectContaining({
220+
key: "agent:main:bulk-0",
221+
derivedTitle: "title 0",
222+
lastMessagePreview: "last 0",
223+
}),
224+
]),
225+
}),
226+
undefined,
227+
);
228+
});
229+
160230
test("sessions.list does not block on slow model catalog discovery", async () => {
161231
await createSessionStoreDir();
162232
await writeSessionStore({

src/gateway/session-compaction-checkpoints.test.ts

Lines changed: 141 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,15 @@ import os from "node:os";
44
import path from "node:path";
55
import type { AssistantMessage, UserMessage } from "@mariozechner/pi-ai";
66
import { SessionManager } from "@mariozechner/pi-coding-agent";
7-
import { afterEach, describe, expect, test } from "vitest";
7+
import { afterEach, describe, expect, test, vi } from "vitest";
88
import type { OpenClawConfig } from "../config/types.openclaw.js";
99
import {
1010
captureCompactionCheckpointSnapshot,
11+
captureCompactionCheckpointSnapshotAsync,
1112
cleanupCompactionCheckpointSnapshot,
1213
MAX_COMPACTION_CHECKPOINT_SNAPSHOT_BYTES,
1314
persistSessionCompactionCheckpoint,
15+
readSessionLeafIdFromTranscriptAsync,
1416
} from "./session-compaction-checkpoints.js";
1517

1618
const tempDirs: string[] = [];
@@ -85,6 +87,144 @@ describe("session-compaction-checkpoints", () => {
8587
expect(fsSync.existsSync(sessionFile!)).toBe(true);
8688
});
8789

90+
test("async capture stores the copied pre-compaction transcript without sync copy", async () => {
91+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-checkpoint-async-"));
92+
tempDirs.push(dir);
93+
94+
const session = SessionManager.create(dir, dir);
95+
session.appendMessage({
96+
role: "user",
97+
content: "before async compaction",
98+
timestamp: Date.now(),
99+
});
100+
session.appendMessage({
101+
role: "assistant",
102+
content: [{ type: "text", text: "async working on it" }],
103+
api: "responses",
104+
provider: "openai",
105+
model: "gpt-test",
106+
timestamp: Date.now(),
107+
} as AssistantMessage);
108+
109+
const sessionFile = session.getSessionFile();
110+
const leafId = session.getLeafId();
111+
expect(sessionFile).toBeTruthy();
112+
expect(leafId).toBeTruthy();
113+
114+
const originalBefore = await fs.readFile(sessionFile!, "utf-8");
115+
const copyFileSyncSpy = vi.spyOn(fsSync, "copyFileSync");
116+
const sessionManagerOpenSpy = vi.spyOn(SessionManager, "open");
117+
try {
118+
const snapshot = await captureCompactionCheckpointSnapshotAsync({
119+
sessionManager: session,
120+
sessionFile: sessionFile!,
121+
});
122+
123+
expect(copyFileSyncSpy).not.toHaveBeenCalled();
124+
expect(sessionManagerOpenSpy).not.toHaveBeenCalled();
125+
expect(snapshot).not.toBeNull();
126+
expect(snapshot?.leafId).toBe(leafId);
127+
expect(snapshot?.sessionFile).not.toBe(sessionFile);
128+
expect(snapshot?.sessionFile).toContain(".checkpoint.");
129+
expect(fsSync.existsSync(snapshot!.sessionFile)).toBe(true);
130+
expect(await fs.readFile(snapshot!.sessionFile, "utf-8")).toBe(originalBefore);
131+
132+
session.appendCompaction("checkpoint summary", leafId!, 123, { ok: true });
133+
134+
expect(await fs.readFile(snapshot!.sessionFile, "utf-8")).toBe(originalBefore);
135+
expect(await fs.readFile(sessionFile!, "utf-8")).not.toBe(originalBefore);
136+
137+
await cleanupCompactionCheckpointSnapshot(snapshot);
138+
139+
expect(fsSync.existsSync(snapshot!.sessionFile)).toBe(false);
140+
expect(fsSync.existsSync(sessionFile!)).toBe(true);
141+
} finally {
142+
copyFileSyncSpy.mockRestore();
143+
sessionManagerOpenSpy.mockRestore();
144+
}
145+
});
146+
147+
test("async capture derives session metadata without synchronous SessionManager.open", async () => {
148+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-checkpoint-async-metadata-"));
149+
tempDirs.push(dir);
150+
151+
const session = SessionManager.create(dir, dir);
152+
session.appendMessage({
153+
role: "user",
154+
content: "derive checkpoint metadata",
155+
timestamp: Date.now(),
156+
});
157+
session.appendMessage({
158+
role: "assistant",
159+
content: "metadata derived",
160+
api: "responses",
161+
provider: "openai",
162+
model: "gpt-test",
163+
timestamp: Date.now(),
164+
} as unknown as AssistantMessage);
165+
166+
const sessionFile = session.getSessionFile();
167+
const sessionId = session.getSessionId();
168+
const leafId = session.getLeafId();
169+
expect(sessionFile).toBeTruthy();
170+
expect(sessionId).toBeTruthy();
171+
expect(leafId).toBeTruthy();
172+
await fs.appendFile(sessionFile!, "\nnot-json\n", "utf-8");
173+
174+
const copyFileSyncSpy = vi.spyOn(fsSync, "copyFileSync");
175+
const sessionManagerOpenSpy = vi.spyOn(SessionManager, "open");
176+
let snapshot: Awaited<ReturnType<typeof captureCompactionCheckpointSnapshotAsync>> = null;
177+
try {
178+
expect(await readSessionLeafIdFromTranscriptAsync(sessionFile!)).toBe(leafId);
179+
snapshot = await captureCompactionCheckpointSnapshotAsync({
180+
sessionFile: sessionFile!,
181+
});
182+
183+
expect(copyFileSyncSpy).not.toHaveBeenCalled();
184+
expect(sessionManagerOpenSpy).not.toHaveBeenCalled();
185+
expect(snapshot).not.toBeNull();
186+
expect(snapshot?.sessionId).toBe(sessionId);
187+
expect(snapshot?.leafId).toBe(leafId);
188+
expect(snapshot?.sessionFile).not.toBe(sessionFile);
189+
expect(snapshot?.sessionFile).toContain(".checkpoint.");
190+
} finally {
191+
await cleanupCompactionCheckpointSnapshot(snapshot);
192+
copyFileSyncSpy.mockRestore();
193+
sessionManagerOpenSpy.mockRestore();
194+
}
195+
});
196+
197+
test("async capture skips oversized pre-compaction transcripts without sync copy", async () => {
198+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-checkpoint-async-oversized-"));
199+
tempDirs.push(dir);
200+
201+
const session = SessionManager.create(dir, dir);
202+
session.appendMessage({
203+
role: "user",
204+
content: "before compaction",
205+
timestamp: Date.now(),
206+
});
207+
const sessionFile = session.getSessionFile();
208+
expect(sessionFile).toBeTruthy();
209+
await fs.appendFile(sessionFile!, "x".repeat(128), "utf-8");
210+
211+
const copyFileSyncSpy = vi.spyOn(fsSync, "copyFileSync");
212+
try {
213+
const snapshot = await captureCompactionCheckpointSnapshotAsync({
214+
sessionManager: session,
215+
sessionFile: sessionFile!,
216+
maxBytes: 64,
217+
});
218+
219+
expect(snapshot).toBeNull();
220+
expect(copyFileSyncSpy).not.toHaveBeenCalled();
221+
expect(MAX_COMPACTION_CHECKPOINT_SNAPSHOT_BYTES).toBeGreaterThan(64);
222+
expect(fsSync.readdirSync(dir).filter((file) => file.includes(".checkpoint."))).toEqual([]);
223+
} finally {
224+
copyFileSyncSpy.mockRestore();
225+
}
226+
});
227+
88228
test("capture skips oversized pre-compaction transcripts", async () => {
89229
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-checkpoint-oversized-"));
90230
tempDirs.push(dir);

0 commit comments

Comments
 (0)