Skip to content

Commit fda4a3b

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 983aeed commit fda4a3b

12 files changed

Lines changed: 99 additions & 61 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: 68 additions & 51 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"
@@ -677,66 +677,83 @@ function App() {
677677
},
678678
])
679679

680-
sdk.event.on(TuiEvent.CommandExecute.type, (evt) => {
681-
command.trigger(evt.properties.command)
682-
})
683-
684-
sdk.event.on(TuiEvent.ToastShow.type, (evt) => {
685-
toast.show({
686-
title: evt.properties.title,
687-
message: evt.properties.message,
688-
variant: evt.properties.variant,
689-
duration: evt.properties.duration,
690-
})
680+
createEffect(() => {
681+
const currentModel = local.model.current()
682+
if (!currentModel) return
683+
if (currentModel.providerID === "openrouter" && !kv.get("openrouter_warning", false)) {
684+
untrack(() => {
685+
DialogAlert.show(
686+
dialog,
687+
"Warning",
688+
"While openrouter is a convenient way to access LLMs your request will often be routed to subpar providers that do not work well in our testing.\n\nFor reliable access to models check out OpenCode Zen\nhttps://opencode.ai/zen",
689+
).then(() => kv.set("openrouter_warning", true))
690+
})
691+
}
691692
})
692693

693-
sdk.event.on(TuiEvent.SessionSelect.type, (evt) => {
694-
route.navigate({
695-
type: "session",
696-
sessionID: evt.properties.sessionID,
697-
})
698-
})
694+
const unsubs = [
695+
sdk.event.on(TuiEvent.CommandExecute.type, (evt) => {
696+
command.trigger(evt.properties.command)
697+
}),
699698

700-
sdk.event.on(SessionApi.Event.Deleted.type, (evt) => {
701-
if (route.data.type === "session" && route.data.sessionID === evt.properties.info.id) {
702-
route.navigate({ type: "home" })
699+
sdk.event.on(TuiEvent.ToastShow.type, (evt) => {
703700
toast.show({
704-
variant: "info",
705-
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,
706705
})
707-
}
708-
})
706+
}),
709707

710-
sdk.event.on(SessionApi.Event.Error.type, (evt) => {
711-
const error = evt.properties.error
712-
if (error && typeof error === "object" && error.name === "MessageAbortedError") return
713-
const message = (() => {
714-
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+
}),
715714

716-
if (typeof error === "object") {
717-
const data = error.data
718-
if ("message" in data && typeof data.message === "string") {
719-
return data.message
720-
}
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+
})
721722
}
722-
return String(error)
723-
})()
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+
})()
724739

725-
toast.show({
726-
variant: "error",
727-
message,
728-
duration: 5000,
729-
})
730-
})
740+
toast.show({
741+
variant: "error",
742+
message,
743+
duration: 5000,
744+
})
745+
}),
731746

732-
sdk.event.on(Installation.Event.UpdateAvailable.type, (evt) => {
733-
toast.show({
734-
variant: "info",
735-
title: "Update Available",
736-
message: `OpenCode v${evt.properties.version} is available. Run 'opencode upgrade' to update manually.`,
737-
duration: 10000,
738-
})
739-
})
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()))
740757

741758
return (
742759
<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
@@ -238,6 +238,7 @@ export namespace Pty {
238238
}
239239
}
240240
session.subscribers.clear()
241+
session.buffer = ""
241242
Bus.publish(Event.Deleted, { id: session.info.id })
242243
}
243244

0 commit comments

Comments
 (0)