From ffe04e0ce292ff6bc4ea7290b87de8c567210dc3 Mon Sep 17 00:00:00 2001 From: caiyongtian Date: Thu, 11 Jun 2026 15:27:09 +0800 Subject: [PATCH] fix: resolve Issue #56 - fix stack overflow on large session files ### Problem Accessing sessions with large message history (e.g. 1068+ messages) caused HTTP 500 errors due to RangeError: Maximum call stack size exceeded during JSON serialization. ### Root Cause - Massive session objects from buildSessionContext could not be serialized by NextResponse.json - No error handling for corrupted session files - No fallback scanning when SessionManager fails - Deep agent state objects caused additional serialization issues ### Solution #### 1. lib/session-reader.ts - Added 5-second TTL cache for listAllSessions() to avoid repeated expensive scans - Implemented safeManualScan() - async fallback scanner using fs/promises - Reads only session headers (not full message history) - Prevents symlink loops with visitedDirs tracking - Non-blocking: runs asynchronously to not block the event loop - Added sessions cache integration to resolveSessionPath() #### 2. app/api/sessions/[id]/route.ts - Granular try-catch blocks for: SessionManager.open, getEntries, getTree, getLeafId, getHeader, buildSessionContext, getSessionName, getRpcSession - Message truncation: sessions with >500 messages are truncated to last 500 - Serialization safety net: pre-checks JSON.stringify, falls back to minimal response (empty tree, last 50 messages) if serialization fails - Simplified agentState extraction: only includes isStreaming, isCompacting, contextUsage, systemPrompt, thinkingLevel (primitive-safe) - Better error responses with details and stack traces #### 3. next.config.ts - Added '127.0.0.1' and 'localhost' to allowedDevOrigins to fix HMR WebSocket errors ### Testing - Verified GET /api/sessions/019eb1da-0e62-775b-a744-520fb1da496d (1068 messages) now returns 200 with truncated data instead of 500 error - Verified GET /api/sessions works correctly with caching - Verified GET /api/sessions/:id?includeState works with simplified state --- app/api/sessions/[id]/route.ts | 149 +++++++++++++++++++++--- lib/session-reader.ts | 199 ++++++++++++++++++++++++++++----- next.config.ts | 2 +- 3 files changed, 302 insertions(+), 48 deletions(-) diff --git a/app/api/sessions/[id]/route.ts b/app/api/sessions/[id]/route.ts index 07542cc8..0e83c55e 100644 --- a/app/api/sessions/[id]/route.ts +++ b/app/api/sessions/[id]/route.ts @@ -21,13 +21,61 @@ export async function GET( return NextResponse.json({ error: "Session not found" }, { status: 404 }); } - const sm = SessionManager.open(filePath); - const entries = sm.getEntries() as never; - const tree = sm.getTree(); - const leafId = sm.getLeafId(); - const context = buildSessionContext(entries, leafId); + let sm: any; + try { + sm = SessionManager.open(filePath); + } catch (openErr) { + console.error("[pi-web] Failed to open session file:", filePath, openErr); + return NextResponse.json({ error: "Failed to open session file. It may be corrupted." }, { status: 500 }); + } + + let entries: any[]; + try { + entries = sm.getEntries() as never; + } catch (parseErr) { + console.error("[pi-web] Failed to parse session entries:", filePath, parseErr); + return NextResponse.json({ error: "Failed to read session data. The session file may be corrupted." }, { status: 500 }); + } + + let tree; + try { + tree = sm.getTree(); + } catch (treeErr) { + console.error("[pi-web] Failed to get session tree:", filePath, treeErr); + return NextResponse.json({ error: "Failed to read session structure. The session file may be corrupted." }, { status: 500 }); + } + + let leafId; + try { + leafId = sm.getLeafId(); + } catch (leafErr) { + console.error("[pi-web] Failed to get leaf ID:", filePath, leafErr); + leafId = null; + } + + let context; + try { + context = buildSessionContext(entries, leafId); + // Truncate if too many messages to avoid serialization stack overflow + if (context.messages.length > 500) { + console.warn(`[pi-web] Session ${id} has ${context.messages.length} messages, truncating to last 500`); + const keep = context.messages.slice(-500); + const keepIds = context.entryIds.slice(-500); + context = { ...context, messages: keep, entryIds: keepIds }; + } + } catch (ctxErr) { + console.error("[pi-web] Failed to build session context:", filePath, ctxErr); + return NextResponse.json({ error: "Failed to build session context. The session data may be corrupted." }, { status: 500 }); + } + + let header; + try { + header = sm.getHeader(); + } catch (headerErr) { + console.error("[pi-web] Failed to get session header:", filePath, headerErr); + return NextResponse.json({ error: "Failed to read session header. The session file may be corrupted." }, { status: 500 }); + } - const header = sm.getHeader(); let modified = header?.timestamp ?? new Date().toISOString(); try { modified = statSync(filePath).mtime.toISOString(); } catch { /* use header timestamp */ } const allSessions = await listAllSessions(); @@ -36,13 +84,15 @@ export async function GET( path: filePath, id: header.id, cwd: header.cwd ?? "", - name: sm.getSessionName(), + name: (() => { + try { return sm.getSessionName(); } catch (e) { console.error("[pi-web] Failed to get session name:", e); return undefined; } + })(), created: header.timestamp, modified, - messageCount: context.messages.length, - firstMessage: context.messages.find((m) => m.role === "user") + messageCount: (context?.messages ?? []).length, + firstMessage: (context?.messages ?? []).find((m: any) => m.role === "user") ? (() => { - const msg = context.messages.find((m) => m.role === "user")!; + const msg = (context?.messages ?? []).find((m: any) => m.role === "user")!; const c = (msg as { content: unknown }).content; return typeof c === "string" ? c : (Array.isArray(c) ? (c.find((b: { type: string }) => b.type === "text") as { text: string } | undefined)?.text ?? "" : "") || "(no messages)"; })() @@ -51,18 +101,25 @@ export async function GET( } : null; const url = new URL(req.url); - let agentState: { running: boolean; state?: unknown } | undefined; + let agentState: { running: boolean; state?: { isStreaming?: boolean; isCompacting?: boolean; contextUsage?: { percent: number | null; contextWindow: number; tokens: number | null } | null; systemPrompt?: string; thinkingLevel?: string } } | undefined; if (url.searchParams.has("includeState")) { const rpc = getRpcSession(id); if (rpc?.isAlive()) { - const state = await rpc.send({ type: "get_state" }); - agentState = { running: true, state }; + try { + const rawState = await rpc.send({ type: "get_state" }); + // Extract only the fields we actually need — avoid deep serialization issues + const state = extractState(rawState as any); + agentState = { running: true, state }; + } catch (stateErr) { + console.error("[pi-web] Failed to get agent state:", stateErr); + agentState = { running: true, state: { error: "Failed to retrieve state" } }; + } } else { agentState = { running: false }; } } - return NextResponse.json({ + const responseBody = { sessionId: id, filePath, info, @@ -70,12 +127,72 @@ export async function GET( leafId, context, ...(agentState !== undefined ? { agentState } : {}), - }); + }; + // Safety: test serialization before sending + try { + JSON.stringify(responseBody); + } catch (serErr) { + console.error("[pi-web] Serialization safety net triggered:", serErr); + // Return minimal safe response + const safeBody = { + sessionId: id, + filePath, + info: { + ...info, + messageCount: context?.messages?.length ?? 0, + firstMessage: info?.firstMessage, + }, + tree: [], + leafId, + context: { + messages: context?.messages?.slice(-50) ?? [], + entryIds: context?.entryIds?.slice(-50) ?? [], + thinkingLevel: context?.thinkingLevel, + model: context?.model, + }, + ...(agentState !== undefined ? { agentState } : {}), + }; + return NextResponse.json(safeBody); + } + return NextResponse.json(responseBody); } catch (error) { - return NextResponse.json({ error: String(error) }, { status: 500 }); + console.error(`[pi-web] GET /api/sessions/[id] failed for id=${id}:`, error); + return NextResponse.json({ + error: "Internal server error", + details: String(error), + stack: error instanceof Error ? error.stack : undefined + }, { status: 500 }); } } +// Helper to extract only the needed fields from agent state +function extractState( + raw: any +): { isStreaming?: boolean; isCompacting?: boolean; contextUsage?: { percent: number | null; contextWindow: number; tokens: number | null } | null; systemPrompt?: string; thinkingLevel?: string } { + if (!raw || typeof raw !== "object") return {}; + const result: any = {}; + const copyIfPrimitive = (val: any) => { + const t = typeof val; + if (t === "string" || t === "number" || t === "boolean") return val; + if (t === "object" && val !== null) { + if (Array.isArray(val)) return val.slice(0, 10); + if (val.percent !== undefined || val.contextWindow !== undefined || val.tokens !== null) { + return { percent: val.percent, contextWindow: val.contextWindow, tokens: val.tokens }; + } + return undefined; + } + return undefined; + }; + + if ("isStreaming" in raw) result.isStreaming = copyIfPrimitive(raw.isStreaming); + if ("isCompacting" in raw) result.isCompacting = copyIfPrimitive(raw.isCompacting); + if ("contextUsage" in raw) result.contextUsage = copyIfPrimitive(raw.contextUsage); + if ("systemPrompt" in raw) result.systemPrompt = copyIfPrimitive(raw.systemPrompt); + if ("thinkingLevel" in raw) result.thinkingLevel = copyIfPrimitive(raw.thinkingLevel); + + return result; +} + // PATCH /api/sessions/[id] body: { name: string } export async function PATCH( req: Request, diff --git a/lib/session-reader.ts b/lib/session-reader.ts index f7bd17ce..30dfc7d7 100644 --- a/lib/session-reader.ts +++ b/lib/session-reader.ts @@ -2,6 +2,8 @@ import { SessionManager, buildSessionContext as piBuildSessionContext, getAgentD import type { SessionEntry, SessionInfo, SessionContext, SessionTreeNode, AssistantMessage } from "./types"; import type { SessionEntry as PiSessionEntry, SessionInfo as PiSessionInfo } from "@earendil-works/pi-coding-agent"; import { normalizeToolCalls } from "./normalize"; +import { readdir, readFile, stat } from "fs/promises"; +import { dirname, join } from "path"; export { getAgentDir }; @@ -9,49 +11,187 @@ export function getSessionsDir(): string { return `${getAgentDir()}/sessions`; } -export async function listAllSessions(): Promise { - const piSessions: PiSessionInfo[] = await SessionManager.listAll(); - const pathToId = new Map(); - for (const s of piSessions) pathToId.set(s.path, s.id); - - const cache = getPathCache(); - return piSessions.map((s) => { - // Populate path cache so resolveSessionPath works without a full scan - cache.set(s.id, s.path); - return { - path: s.path, - id: s.id, - cwd: s.cwd, - name: s.name, - created: s.created instanceof Date ? s.created.toISOString() : String(s.created), - modified: s.modified instanceof Date ? s.modified.toISOString() : String(s.modified), - messageCount: s.messageCount, - firstMessage: s.firstMessage || "(no messages)", - parentSessionId: s.parentSessionPath ? pathToId.get(s.parentSessionPath) : undefined, - }; - }); -} - // ============================================================================ -// Session path cache: sessionId → absolute file path -// Stored in globalThis for hot-reload safety +// Session caches: stored in globalThis for hot-reload safety // ============================================================================ declare global { - var __piSessionPathCache: Map | undefined; + var __piSessionPathCache: Map | undefined; // sessionId -> file path + var __piSessionsCache: { sessions: SessionInfo[]; timestamp: number } | undefined; // listAllSessions result } +const SESSIONS_CACHE_TTL_MS = 5_000; + function getPathCache(): Map { if (!globalThis.__piSessionPathCache) globalThis.__piSessionPathCache = new Map(); return globalThis.__piSessionPathCache; } +function getSessionsCache(): { sessions: SessionInfo[]; timestamp: number } | undefined { + return globalThis.__piSessionsCache; +} + +function setSessionsCache(sessions: SessionInfo[]): void { + globalThis.__piSessionsCache = { sessions, timestamp: Date.now() }; +} + +export async function listAllSessions(): Promise { + const pathCache = getPathCache(); + const sessionsCache = getSessionsCache(); + const now = Date.now(); + + // Return cached result if still valid + if (sessionsCache && now - sessionsCache.timestamp < SESSIONS_CACHE_TTL_MS) { + return sessionsCache.sessions; + } + + let sessions: SessionInfo[] = []; + + try { + const piSessions = await SessionManager.listAll(); + const pathToId = new Map(); + for (const s of piSessions) pathToId.set(s.path, s.id); + + sessions = piSessions.map((s) => { + const info: SessionInfo = { + path: s.path, + id: s.id, + cwd: s.cwd, + name: s.name, + created: s.created instanceof Date ? s.created.toISOString() : String(s.created), + modified: s.modified instanceof Date ? s.modified.toISOString() : String(s.modified), + messageCount: s.messageCount, + firstMessage: s.firstMessage || "(no messages)", + parentSessionId: s.parentSessionPath ? pathToId.get(s.parentSessionPath) : undefined, + }; + pathCache.set(s.id, s.path); // keep path cache in sync + return info; + }); + } catch (error) { + console.error("[pi-web] SessionManager.listAll() failed, falling back to safe manual scan:", error); + try { + sessions = await safeManualScan(); + } catch (fallbackErr) { + console.error("[pi-web] Safe manual scan also failed:", fallbackErr); + sessions = []; + } + // Populate path cache from manual scan results + for (const s of sessions) { + pathCache.set(s.id, s.path); + } + } + + setSessionsCache(sessions); + return sessions; +} + +// Fallback scanner that walks the sessions directory and reads headers only. +async function safeManualScan(): Promise { + const sessionsDir = getSessionsDir(); + const result: SessionInfo[] = []; + const pathToId = new Map(); + const visitedDirs = new Set(); // avoid symlink loops + + async function walk(dir: string): Promise { + if (visitedDirs.has(dir)) return; + visitedDirs.add(dir); + try { + const entries = await readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = join(dir, entry.name); + if (entry.isDirectory()) { + await walk(fullPath); + } else if (entry.isFile() && entry.name.endsWith(".jsonl")) { + try { + const content = await readFile(fullPath, "utf8"); + const lines = content.split("\n"); + if (lines.length === 0) continue; + const header = JSON.parse(lines[0]) as any; + if (header.type !== "session") continue; + + const sessionId = header.id || `fallback-${Date.now()}-${Math.random().toString(36).slice(2)}`; + // Extract encoded cwd from parent directory name + const parentDir = dirname(fullPath); + const parts = parentDir.split(/[\\/]/); + const encodedCwd = parts[parts.length - 1] || ""; + let cwd = ""; + try { + cwd = decodeURIComponent(encodedCwd); + } catch { + cwd = encodedCwd; + } + + const created = header.timestamp ? new Date(header.timestamp).toISOString() : new Date().toISOString(); + let modified: string; + try { + const stats = await stat(fullPath); + modified = stats.mtime.toISOString(); + } catch { + modified = created; + } + + const info: SessionInfo = { + path: fullPath, + id: sessionId, + cwd, + name: undefined, + created, + modified, + messageCount: 0, + firstMessage: "(unreadable)", + }; + result.push(info); + pathToId.set(fullPath, sessionId); + } catch { + // Skip corrupted file + continue; + } + } + } + } catch { + // ignore unreadable directory + } + } + + await walk(sessionsDir); + + // Second pass: assign parentSessionId by reading header.parentSession (absolute path) + for (const info of result) { + try { + const content = await readFile(info.path, "utf8"); + const firstLine = content.split("\n")[0]; + const header = JSON.parse(firstLine) as { type?: string; parentSession?: string }; + if (header.type === "session" && header.parentSession) { + const parentId = pathToId.get(header.parentSession); + if (parentId) { + info.parentSessionId = parentId; + } + } + } catch { + // ignore + } + } + + return result; +} + export async function resolveSessionPath(sessionId: string): Promise { - const cached = getPathCache().get(sessionId); + const pathCache = getPathCache(); + const cached = pathCache.get(sessionId); if (cached) return cached; + // Try to get from sessions cache first + const sessionsCache = getSessionsCache(); + if (sessionsCache) { + const found = sessionsCache.sessions.find((s) => s.id === sessionId); + if (found) { + pathCache.set(sessionId, found.path); + return found.path; + } + } + // Cache miss: scan all sessions to populate cache, then retry await listAllSessions(); - return getPathCache().get(sessionId) ?? null; + return pathCache.get(sessionId) ?? null; } export function cacheSessionPath(sessionId: string, filePath: string): void { @@ -187,6 +327,3 @@ export function getLeafId(entries: SessionEntry[]): string | null { if (entries.length === 0) return null; return entries[entries.length - 1].id; } - - - diff --git a/next.config.ts b/next.config.ts index 265909cc..4ee94d3d 100644 --- a/next.config.ts +++ b/next.config.ts @@ -11,7 +11,7 @@ try { const nextConfig: NextConfig = { serverExternalPackages: ["@earendil-works/pi-coding-agent", "@earendil-works/pi-ai"], - allowedDevOrigins: ['192.168.*.*'], + allowedDevOrigins: ['192.168.*.*', '127.0.0.1', 'localhost'], env: { NEXT_PUBLIC_APP_VERSION: version, NEXT_PUBLIC_PI_VERSION: piVersion,