summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--packages/opencode/src/cli/cmd/tui/routes/session/index.tsx17
-rw-r--r--packages/opencode/src/session/MEMORY_LEAK_FIXES.md118
2 files changed, 11 insertions, 124 deletions
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
index 4b3b67a31..d049ec437 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
@@ -1447,10 +1447,11 @@ function InlineTool(props: { icon: string; complete: any; pending: string; child
)
}
-function BlockTool(props: { title: string; children: JSX.Element; onClick?: () => void }) {
+function BlockTool(props: { title: string; children: JSX.Element; onClick?: () => void; part?: ToolPart }) {
const { theme } = useTheme()
const renderer = useRenderer()
const [hover, setHover] = createSignal(false)
+ const error = createMemo(() => (props.part?.state.status === "error" ? props.part.state.error : undefined))
return (
<box
border={["left"]}
@@ -1473,6 +1474,9 @@ function BlockTool(props: { title: string; children: JSX.Element; onClick?: () =
{props.title}
</text>
{props.children}
+ <Show when={error()}>
+ <text fg={theme.error}>{error()}</text>
+ </Show>
</box>
)
}
@@ -1483,7 +1487,7 @@ function Bash(props: ToolProps<typeof BashTool>) {
return (
<Switch>
<Match when={props.metadata.output !== undefined}>
- <BlockTool title={"# " + (props.input.description ?? "Shell")}>
+ <BlockTool title={"# " + (props.input.description ?? "Shell")} part={props.part}>
<box gap={1}>
<text fg={theme.text}>$ {props.input.command}</text>
<text fg={theme.text}>{output()}</text>
@@ -1514,7 +1518,7 @@ function Write(props: ToolProps<typeof WriteTool>) {
return (
<Switch>
<Match when={props.metadata.diagnostics !== undefined}>
- <BlockTool title={"# Wrote " + normalizePath(props.input.filePath!)}>
+ <BlockTool title={"# Wrote " + normalizePath(props.input.filePath!)} part={props.part}>
<line_number fg={theme.textMuted} minWidth={3} paddingRight={1}>
<code
conceal={false}
@@ -1629,6 +1633,7 @@ function Task(props: ToolProps<typeof TaskTool>) {
? () => navigate({ type: "session", sessionID: props.metadata.sessionId! })
: undefined
}
+ part={props.part}
>
<box>
<text style={{ fg: theme.textMuted }}>
@@ -1685,7 +1690,7 @@ function Edit(props: ToolProps<typeof EditTool>) {
return (
<Switch>
<Match when={props.metadata.diff !== undefined}>
- <BlockTool title={"← Edit " + normalizePath(props.input.filePath!)}>
+ <BlockTool title={"← Edit " + normalizePath(props.input.filePath!)} part={props.part}>
<box paddingLeft={1}>
<diff
diff={diffContent()}
@@ -1735,7 +1740,7 @@ function Patch(props: ToolProps<typeof PatchTool>) {
return (
<Switch>
<Match when={props.output !== undefined}>
- <BlockTool title="# Patch">
+ <BlockTool title="# Patch" part={props.part}>
<box>
<text fg={theme.text}>{props.output?.trim()}</text>
</box>
@@ -1754,7 +1759,7 @@ function TodoWrite(props: ToolProps<typeof TodoWriteTool>) {
return (
<Switch>
<Match when={props.metadata.todos?.length}>
- <BlockTool title="# Todos">
+ <BlockTool title="# Todos" part={props.part}>
<box>
<For each={props.input.todos ?? []}>
{(todo) => <TodoItem status={todo.status} content={todo.content} />}
diff --git a/packages/opencode/src/session/MEMORY_LEAK_FIXES.md b/packages/opencode/src/session/MEMORY_LEAK_FIXES.md
deleted file mode 100644
index 1c36f2462..000000000
--- a/packages/opencode/src/session/MEMORY_LEAK_FIXES.md
+++ /dev/null
@@ -1,118 +0,0 @@
-# Memory Leak Fixes Plan
-
-## Summary
-
-This document outlines the memory leak issues identified in the session module and the proposed fixes.
-
-## Issues Identified
-
-### Issue 1: Instance Dispose Callback Missing Callback Rejection (HIGH)
-
-**File**: `prompt.ts:69-73`
-
-**Problem**: When an instance is disposed, the dispose callback only aborts the AbortControllers but doesn't reject the pending promise callbacks. This leaves hanging promises that never resolve or reject.
-
-**Current Code**:
-
-```typescript
-async (current) => {
- for (const item of Object.values(current)) {
- item.abort.abort()
- }
-},
-```
-
-**Fix**: Add callback rejection in the dispose handler:
-
-```typescript
-async (current) => {
- for (const item of Object.values(current)) {
- item.abort.abort()
- for (const callback of item.callbacks) {
- callback.reject()
- }
- }
-},
-```
-
----
-
-### Issue 2: Abort Listener Not Removed on Timeout (MEDIUM)
-
-**File**: `retry.ts:10-22`
-
-**Problem**: If the timeout resolves before the abort signal fires, the abort event listener remains attached to the signal. While `{ once: true }` ensures it fires only once if aborted, it doesn't remove the listener if the timeout fires first. This causes a minor memory leak for long-lived signals.
-
-**Current Code**:
-
-```typescript
-export async function sleep(ms: number, signal: AbortSignal): Promise<void> {
- return new Promise((resolve, reject) => {
- const timeout = setTimeout(resolve, Math.min(ms, RETRY_MAX_DELAY))
- signal.addEventListener(
- "abort",
- () => {
- clearTimeout(timeout)
- reject(new DOMException("Aborted", "AbortError"))
- },
- { once: true },
- )
- })
-}
-```
-
-**Fix**: Store the abort handler and remove it when timeout resolves:
-
-```typescript
-export async function sleep(ms: number, signal: AbortSignal): Promise<void> {
- return new Promise((resolve, reject) => {
- const abortHandler = () => {
- clearTimeout(timeout)
- reject(new DOMException("Aborted", "AbortError"))
- }
- const timeout = setTimeout(
- () => {
- signal.removeEventListener("abort", abortHandler)
- resolve()
- },
- Math.min(ms, RETRY_MAX_DELAY),
- )
- signal.addEventListener("abort", abortHandler, { once: true })
- })
-}
-```
-
----
-
-### Issue 3: Orphaned AbortControllers (LOW - Optional)
-
-**Files**:
-
-- `summary.ts:102`, `summary.ts:143`
-- `prompt.ts:884-892`, `prompt.ts:945-953`
-
-**Problem**: New `AbortController()` instances are created inline and passed to functions, but the controllers are never stored or explicitly aborted. While this isn't a significant leak (GC handles them when streams complete), it's a code smell.
-
-**Example**:
-
-```typescript
-abort: new AbortController().signal,
-```
-
-**Recommendation**: Leave as-is. The overhead is minimal and the code is clearer. The streams complete naturally and the objects are garbage collected.
-
----
-
-## Implementation Checklist
-
-- [ ] Fix Issue 1: Add callback rejection in `prompt.ts` dispose handler
-- [ ] Fix Issue 2: Clean up abort listener in `retry.ts` sleep function
-- [ ] (Optional) Issue 3: No action needed
-
-## Testing Notes
-
-After implementing fixes:
-
-1. Verify existing tests pass
-2. Manually test session cancellation during active processing
-3. Verify instance disposal properly cleans up all pending sessions