Skip to content

Commit abe853d

Browse files
binarydoublingclaude
authored andcommitted
fix: additional memory leak fixes across TUI, bus, PTY, and core
- Add onCleanup for all sdk.event.on() listeners in app.tsx, session route, and prompt component to prevent listener accumulation on re-render - Add onCleanup for process SIGUSR2 handler in theme provider - Add onCleanup for leader timeout in keybind provider - Clear event queue on SDK context cleanup - Remove empty subscription arrays in Bus and RPC listener maps - Clear warning timeout in state disposal after completion - Clear models refresh interval on process exit - Add dispose() to ShareNext to clear pending sync timeouts - Clear PTY session buffer on removal to free memory immediately Co-Authored-By: Claude Opus 4.6 <[email protected]>
1 parent 58ae016 commit abe853d

12 files changed

Lines changed: 87 additions & 63 deletions

File tree

packages/opencode/src/bus/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ export namespace Bus {
100100
const index = match.indexOf(callback)
101101
if (index === -1) return
102102
match.splice(index, 1)
103+
if (match.length === 0) subscriptions.delete(type)
103104
}
104105
}
105106

packages/opencode/src/cli/cmd/tui/app.tsx

Lines changed: 56 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { Clipboard } from "@tui/util/clipboard"
33
import { Selection } from "@tui/util/selection"
44
import { MouseButton, TextAttributes } from "@opentui/core"
55
import { RouteProvider, useRoute } from "@tui/context/route"
6-
import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show, on } from "solid-js"
6+
import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, onCleanup, batch, Show, on } from "solid-js"
77
import { win32DisableProcessedInput, win32FlushInputBuffer, win32InstallCtrlCGuard } from "./win32"
88
import { Installation } from "@/installation"
99
import { Flag } from "@/flag/flag"
@@ -691,66 +691,69 @@ function App() {
691691
}
692692
})
693693

694-
sdk.event.on(TuiEvent.CommandExecute.type, (evt) => {
695-
command.trigger(evt.properties.command)
696-
})
697-
698-
sdk.event.on(TuiEvent.ToastShow.type, (evt) => {
699-
toast.show({
700-
title: evt.properties.title,
701-
message: evt.properties.message,
702-
variant: evt.properties.variant,
703-
duration: evt.properties.duration,
704-
})
705-
})
706-
707-
sdk.event.on(TuiEvent.SessionSelect.type, (evt) => {
708-
route.navigate({
709-
type: "session",
710-
sessionID: evt.properties.sessionID,
711-
})
712-
})
694+
const unsubs = [
695+
sdk.event.on(TuiEvent.CommandExecute.type, (evt) => {
696+
command.trigger(evt.properties.command)
697+
}),
713698

714-
sdk.event.on(SessionApi.Event.Deleted.type, (evt) => {
715-
if (route.data.type === "session" && route.data.sessionID === evt.properties.info.id) {
716-
route.navigate({ type: "home" })
699+
sdk.event.on(TuiEvent.ToastShow.type, (evt) => {
717700
toast.show({
718-
variant: "info",
719-
message: "The current session was deleted",
701+
title: evt.properties.title,
702+
message: evt.properties.message,
703+
variant: evt.properties.variant,
704+
duration: evt.properties.duration,
720705
})
721-
}
722-
})
706+
}),
723707

724-
sdk.event.on(SessionApi.Event.Error.type, (evt) => {
725-
const error = evt.properties.error
726-
if (error && typeof error === "object" && error.name === "MessageAbortedError") return
727-
const message = (() => {
728-
if (!error) return "An error occurred"
708+
sdk.event.on(TuiEvent.SessionSelect.type, (evt) => {
709+
route.navigate({
710+
type: "session",
711+
sessionID: evt.properties.sessionID,
712+
})
713+
}),
729714

730-
if (typeof error === "object") {
731-
const data = error.data
732-
if ("message" in data && typeof data.message === "string") {
733-
return data.message
734-
}
715+
sdk.event.on(SessionApi.Event.Deleted.type, (evt) => {
716+
if (route.data.type === "session" && route.data.sessionID === evt.properties.info.id) {
717+
route.navigate({ type: "home" })
718+
toast.show({
719+
variant: "info",
720+
message: "The current session was deleted",
721+
})
735722
}
736-
return String(error)
737-
})()
723+
}),
724+
725+
sdk.event.on(SessionApi.Event.Error.type, (evt) => {
726+
const error = evt.properties.error
727+
if (error && typeof error === "object" && error.name === "MessageAbortedError") return
728+
const message = (() => {
729+
if (!error) return "An error occurred"
730+
731+
if (typeof error === "object") {
732+
const data = error.data
733+
if ("message" in data && typeof data.message === "string") {
734+
return data.message
735+
}
736+
}
737+
return String(error)
738+
})()
738739

739-
toast.show({
740-
variant: "error",
741-
message,
742-
duration: 5000,
743-
})
744-
})
740+
toast.show({
741+
variant: "error",
742+
message,
743+
duration: 5000,
744+
})
745+
}),
745746

746-
sdk.event.on(Installation.Event.UpdateAvailable.type, (evt) => {
747-
toast.show({
748-
variant: "info",
749-
title: "Update Available",
750-
message: `OpenCode v${evt.properties.version} is available. Run 'opencode upgrade' to update manually.`,
751-
duration: 10000,
752-
})
753-
})
747+
sdk.event.on(Installation.Event.UpdateAvailable.type, (evt) => {
748+
toast.show({
749+
variant: "info",
750+
title: "Update Available",
751+
message: `OpenCode v${evt.properties.version} is available. Run 'opencode upgrade' to update manually.`,
752+
duration: 10000,
753+
})
754+
}),
755+
]
756+
onCleanup(() => unsubs.forEach((fn) => fn()))
754757

755758
return (
756759
<box

packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ export function Prompt(props: PromptProps) {
9797
const pasteStyleId = syntax().getStyleId("extmark.paste")!
9898
let promptPartTypeId = 0
9999

100-
sdk.event.on(TuiEvent.PromptAppend.type, (evt) => {
100+
const unsubPromptAppend = sdk.event.on(TuiEvent.PromptAppend.type, (evt) => {
101101
if (!input || input.isDestroyed) return
102102
input.insertText(evt.properties.text)
103103
setTimeout(() => {
@@ -108,6 +108,7 @@ export function Prompt(props: PromptProps) {
108108
renderer.requestRender()
109109
}, 0)
110110
})
111+
onCleanup(unsubPromptAppend)
111112

112113
createEffect(() => {
113114
if (props.disabled) input.cursorColor = theme.backgroundElement

packages/opencode/src/cli/cmd/tui/context/keybind.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { createMemo } from "solid-js"
1+
import { createMemo, onCleanup } from "solid-js"
22
import { Keybind } from "@/util/keybind"
33
import { pipe, mapValues } from "remeda"
44
import type { TuiConfig } from "@/config/tui"
@@ -27,6 +27,7 @@ export const { use: useKeybind, provider: KeybindProvider } = createSimpleContex
2727

2828
let focus: Renderable | null
2929
let timeout: NodeJS.Timeout
30+
onCleanup(() => { if (timeout) clearTimeout(timeout) })
3031
function leader(active: boolean) {
3132
if (active) {
3233
setStore("leader", true)

packages/opencode/src/cli/cmd/tui/context/sdk.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
108108
abort.abort()
109109
sse?.abort()
110110
if (timer) clearTimeout(timer)
111+
queue.length = 0
111112
})
112113

113114
return {

packages/opencode/src/cli/cmd/tui/context/theme.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { SyntaxStyle, RGBA, type TerminalColors } from "@opentui/core"
22
import path from "path"
3-
import { createEffect, createMemo, onMount } from "solid-js"
3+
import { createEffect, createMemo, onCleanup, onMount } from "solid-js"
44
import { createSimpleContext } from "./helper"
55
import { Glob } from "../../../../util/glob"
66
import aura from "./theme/aura.json" with { type: "json" }
@@ -347,10 +347,12 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
347347
}
348348

349349
const renderer = useRenderer()
350-
process.on("SIGUSR2", async () => {
350+
const sigusr2Handler = async () => {
351351
renderer.clearPaletteCache()
352352
init()
353-
})
353+
}
354+
process.on("SIGUSR2", sigusr2Handler)
355+
onCleanup(() => process.off("SIGUSR2", sigusr2Handler))
354356

355357
const values = createMemo(() => {
356358
return resolveTheme(store.themes[store.active] ?? store.themes.opencode, store.mode)

packages/opencode/src/cli/cmd/tui/routes/session/index.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
For,
88
Match,
99
on,
10+
onCleanup,
1011
onMount,
1112
Show,
1213
Switch,
@@ -215,7 +216,7 @@ export function Session() {
215216
})
216217

217218
let lastSwitch: string | undefined = undefined
218-
sdk.event.on("message.part.updated", (evt) => {
219+
const unsubPartUpdated = sdk.event.on("message.part.updated", (evt) => {
219220
const part = evt.properties.part
220221
if (part.type !== "tool") return
221222
if (part.sessionID !== route.sessionID) return
@@ -230,6 +231,7 @@ export function Session() {
230231
lastSwitch = part.id
231232
}
232233
})
234+
onCleanup(unsubPartUpdated)
233235

234236
let scroll: ScrollBoxRenderable
235237
let prompt: PromptRef

packages/opencode/src/project/state.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,14 +36,15 @@ export namespace State {
3636

3737
let disposalFinished = false
3838

39-
setTimeout(() => {
39+
const warnTimeout = setTimeout(() => {
4040
if (!disposalFinished) {
4141
log.warn(
4242
"state disposal is taking an unusually long time - if it does not complete in a reasonable time, please report this as a bug",
4343
{ key },
4444
)
4545
}
46-
}, 10000).unref()
46+
}, 10000)
47+
warnTimeout.unref()
4748

4849
const tasks: Promise<void>[] = []
4950
for (const [init, entry] of entries) {
@@ -64,6 +65,7 @@ export namespace State {
6465
entries.clear()
6566
recordsByKey.delete(key)
6667

68+
clearTimeout(warnTimeout)
6769
disposalFinished = true
6870
log.info("state disposal completed", { key })
6971
}

packages/opencode/src/provider/models.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -123,10 +123,12 @@ export namespace ModelsDev {
123123

124124
if (!Flag.OPENCODE_DISABLE_MODELS_FETCH && !process.argv.includes("--get-yargs-completions")) {
125125
ModelsDev.refresh()
126-
setInterval(
126+
const modelsRefreshInterval = setInterval(
127127
async () => {
128128
await ModelsDev.refresh()
129129
},
130130
60 * 1000 * 60,
131-
).unref()
131+
)
132+
modelsRefreshInterval.unref()
133+
process.on("exit", () => clearInterval(modelsRefreshInterval))
132134
}

packages/opencode/src/pty/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,7 @@ export namespace Pty {
234234
}
235235
}
236236
session.subscribers.clear()
237+
session.buffer = ""
237238
Bus.publish(Event.Deleted, { id })
238239
}
239240

0 commit comments

Comments
 (0)