From 0b40d39b0efe031500c43e9d9e07c88c0809d5f4 Mon Sep 17 00:00:00 2001 From: deseltrus Date: Mon, 6 Apr 2026 09:52:20 +0200 Subject: [PATCH] perf(interactive): cap rendered chat components + kill orphan descendants Chat component cap: After 100 rendered components, oldest are removed from the container (session transcript persists on disk via SessionManager). Prevents unbounded memory growth in long sessions where thousands of tool calls accumulate DOM-like component trees. Orphan process prevention: On shutdown, listDescendants(process.pid) finds ALL child processes (including those spawned by the Bash tool that bg-shell doesn't track) and kills them with SIGTERM + 500ms grace + SIGKILL. Prevents orphaned dev servers, build processes, etc. from persisting after session exit. --- .../src/modes/interactive/interactive-mode.ts | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts b/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts index d3bd71f27..ca98db678 100644 --- a/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts @@ -7,6 +7,7 @@ import * as crypto from "node:crypto"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; +import { listDescendants } from "@gsd/native"; import type { AgentMessage } from "@gsd/pi-agent-core"; import type { AssistantMessage, ImageContent, Message, Model, OAuthProviderId } from "@gsd/pi-ai"; import type { @@ -157,6 +158,10 @@ export interface InteractiveModeOptions { } export class InteractiveMode { + // Cap rendered chat components to prevent unbounded memory/CPU growth. + // Only render-components are removed — session transcript stays on disk. + private static readonly MAX_CHAT_COMPONENTS = 100; + private session: AgentSession; private ui: TUI; private chatContainer: Container; @@ -2138,6 +2143,18 @@ export class InteractiveMode { const _exhaustive: never = message; } } + this.trimChatHistory(); + } + + /** + * Remove oldest components when chat exceeds MAX_CHAT_COMPONENTS. + * Only render-components are removed — session data stays in SessionManager. + */ + private trimChatHistory(): void { + while (this.chatContainer.children.length > InteractiveMode.MAX_CHAT_COMPONENTS) { + const oldest = this.chatContainer.children[0]; + this.chatContainer.removeChild(oldest); + } } /** @@ -2232,6 +2249,7 @@ export class InteractiveMode { } this.pendingTools.clear(); + this.trimChatHistory(); this.ui.requestRender(); } @@ -2325,6 +2343,21 @@ export class InteractiveMode { if (shutdownBehavior === "stop_ui") { return; } + + // Kill ALL descendant processes to prevent orphans (next-server, pnpm dev, etc.) + try { + const descendants = listDescendants(process.pid); + for (const childPid of descendants) { + try { process.kill(childPid, "SIGTERM"); } catch {} + } + if (descendants.length > 0) { + await new Promise(resolve => setTimeout(resolve, 500)); + for (const childPid of descendants) { + try { process.kill(childPid, "SIGKILL"); } catch {} + } + } + } catch {} + process.exit(0); }