Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/opencode/src/bus/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ export namespace Bus {
const index = match.indexOf(callback)
if (index === -1) return
match.splice(index, 1)
if (match.length === 0) subscriptions.delete(type)
}
}
}
9 changes: 8 additions & 1 deletion packages/opencode/src/cli/cmd/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Flag } from "../../flag/flag"
import { Workspace } from "../../control-plane/workspace"
import { Project } from "../../project/project"
import { Installation } from "../../installation"
import { Instance } from "../../project/instance"

export const ServeCommand = cmd({
command: "serve",
Expand All @@ -18,7 +19,13 @@ export const ServeCommand = cmd({
const server = Server.listen(opts)
console.log(`opencode server listening on http://${server.hostname}:${server.port}`)

await new Promise(() => {})
// Wait for termination signal instead of blocking forever
await new Promise<void>((resolve) => {
const shutdown = () => resolve()
process.on("SIGTERM", shutdown)
process.on("SIGINT", shutdown)
})
await Instance.disposeAll()
await server.stop()
},
})
109 changes: 56 additions & 53 deletions packages/opencode/src/cli/cmd/tui/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Clipboard } from "@tui/util/clipboard"
import { Selection } from "@tui/util/selection"
import { MouseButton, TextAttributes } from "@opentui/core"
import { RouteProvider, useRoute } from "@tui/context/route"
import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show, on } from "solid-js"
import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, onCleanup, batch, Show, on } from "solid-js"
import { win32DisableProcessedInput, win32FlushInputBuffer, win32InstallCtrlCGuard } from "./win32"
import { Installation } from "@/installation"
import { Flag } from "@/flag/flag"
Expand Down Expand Up @@ -671,66 +671,69 @@ function App() {
}
})

sdk.event.on(TuiEvent.CommandExecute.type, (evt) => {
command.trigger(evt.properties.command)
})

sdk.event.on(TuiEvent.ToastShow.type, (evt) => {
toast.show({
title: evt.properties.title,
message: evt.properties.message,
variant: evt.properties.variant,
duration: evt.properties.duration,
})
})

sdk.event.on(TuiEvent.SessionSelect.type, (evt) => {
route.navigate({
type: "session",
sessionID: evt.properties.sessionID,
})
})
const unsubs = [
sdk.event.on(TuiEvent.CommandExecute.type, (evt) => {
command.trigger(evt.properties.command)
}),

sdk.event.on(SessionApi.Event.Deleted.type, (evt) => {
if (route.data.type === "session" && route.data.sessionID === evt.properties.info.id) {
route.navigate({ type: "home" })
sdk.event.on(TuiEvent.ToastShow.type, (evt) => {
toast.show({
variant: "info",
message: "The current session was deleted",
title: evt.properties.title,
message: evt.properties.message,
variant: evt.properties.variant,
duration: evt.properties.duration,
})
}
})
}),

sdk.event.on(SessionApi.Event.Error.type, (evt) => {
const error = evt.properties.error
if (error && typeof error === "object" && error.name === "MessageAbortedError") return
const message = (() => {
if (!error) return "An error occurred"
sdk.event.on(TuiEvent.SessionSelect.type, (evt) => {
route.navigate({
type: "session",
sessionID: evt.properties.sessionID,
})
}),

if (typeof error === "object") {
const data = error.data
if ("message" in data && typeof data.message === "string") {
return data.message
}
sdk.event.on(SessionApi.Event.Deleted.type, (evt) => {
if (route.data.type === "session" && route.data.sessionID === evt.properties.info.id) {
route.navigate({ type: "home" })
toast.show({
variant: "info",
message: "The current session was deleted",
})
}
return String(error)
})()
}),

sdk.event.on(SessionApi.Event.Error.type, (evt) => {
const error = evt.properties.error
if (error && typeof error === "object" && error.name === "MessageAbortedError") return
const message = (() => {
if (!error) return "An error occurred"

if (typeof error === "object") {
const data = error.data
if ("message" in data && typeof data.message === "string") {
return data.message
}
}
return String(error)
})()

toast.show({
variant: "error",
message,
duration: 5000,
})
})
toast.show({
variant: "error",
message,
duration: 5000,
})
}),

sdk.event.on(Installation.Event.UpdateAvailable.type, (evt) => {
toast.show({
variant: "info",
title: "Update Available",
message: `OpenCode v${evt.properties.version} is available. Run 'opencode upgrade' to update manually.`,
duration: 10000,
})
})
sdk.event.on(Installation.Event.UpdateAvailable.type, (evt) => {
toast.show({
variant: "info",
title: "Update Available",
message: `OpenCode v${evt.properties.version} is available. Run 'opencode upgrade' to update manually.`,
duration: 10000,
})
}),
]
onCleanup(() => unsubs.forEach((fn) => fn()))

return (
<box
Expand Down
3 changes: 2 additions & 1 deletion packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ export function Prompt(props: PromptProps) {
const pasteStyleId = syntax().getStyleId("extmark.paste")!
let promptPartTypeId = 0

sdk.event.on(TuiEvent.PromptAppend.type, (evt) => {
const unsubPromptAppend = sdk.event.on(TuiEvent.PromptAppend.type, (evt) => {
if (!input || input.isDestroyed) return
input.insertText(evt.properties.text)
setTimeout(() => {
Expand All @@ -107,6 +107,7 @@ export function Prompt(props: PromptProps) {
renderer.requestRender()
}, 0)
})
onCleanup(unsubPromptAppend)

createEffect(() => {
if (props.disabled) input.cursorColor = theme.backgroundElement
Expand Down
3 changes: 2 additions & 1 deletion packages/opencode/src/cli/cmd/tui/context/keybind.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createMemo } from "solid-js"
import { createMemo, onCleanup } from "solid-js"
import { Keybind } from "@/util/keybind"
import { pipe, mapValues } from "remeda"
import type { TuiConfig } from "@/config/tui"
Expand Down Expand Up @@ -27,6 +27,7 @@ export const { use: useKeybind, provider: KeybindProvider } = createSimpleContex

let focus: Renderable | null
let timeout: NodeJS.Timeout
onCleanup(() => { if (timeout) clearTimeout(timeout) })
function leader(active: boolean) {
if (active) {
setStore("leader", true)
Expand Down
6 changes: 6 additions & 0 deletions packages/opencode/src/cli/cmd/tui/context/sdk.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
[key in Event["type"]]: Extract<Event, { type: key }>
}>()

const MAX_EVENT_QUEUE = 1000
let queue: Event[] = []
let timer: Timer | undefined
let last = 0
Expand All @@ -48,6 +49,10 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
}

const handleEvent = (event: Event) => {
// Drop oldest events if queue is too large to prevent unbounded memory growth
if (queue.length >= MAX_EVENT_QUEUE) {
queue.splice(0, queue.length - MAX_EVENT_QUEUE + 1)
}
queue.push(event)
const elapsed = Date.now() - last

Expand Down Expand Up @@ -94,6 +99,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
onCleanup(() => {
abort.abort()
if (timer) clearTimeout(timer)
queue.length = 0
})

return { client: sdk, event: emitter, url: props.url }
Expand Down
32 changes: 29 additions & 3 deletions packages/opencode/src/cli/cmd/tui/context/sync.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -245,19 +245,23 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
)
const updated = store.message[event.properties.info.sessionID]
if (updated.length > 100) {
const oldest = updated[0]
// Remove excess messages beyond the limit, cleaning up their parts too
const excess = updated.length - 100
const removedMessages = updated.slice(0, excess)
batch(() => {
setStore(
"message",
event.properties.info.sessionID,
produce((draft) => {
draft.shift()
draft.splice(0, excess)
}),
)
setStore(
"part",
produce((draft) => {
delete draft[oldest.id]
for (const msg of removedMessages) {
delete draft[msg.id]
}
}),
)
})
Expand Down Expand Up @@ -432,6 +436,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
})

const fullSyncedSessions = new Set<string>()
let currentSessionID: string | undefined
const result = {
data: store,
set: setStore,
Expand All @@ -458,6 +463,27 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
return last.time.completed ? "idle" : "working"
},
async sync(sessionID: string) {
// Clean up previous session's data from memory when switching sessions
if (currentSessionID && currentSessionID !== sessionID) {
const oldMessages = store.message[currentSessionID]
if (oldMessages) {
setStore(
produce((draft) => {
// Clean up parts for old session's messages
for (const msg of oldMessages) {
delete draft.part[msg.id]
}
// Clean up old session's messages
delete draft.message[currentSessionID!]
// Clean up old session's diff
delete draft.session_diff[currentSessionID!]
}),
)
}
fullSyncedSessions.delete(currentSessionID)
}
currentSessionID = sessionID

if (fullSyncedSessions.has(sessionID)) return
const [session, messages, todo, diff] = await Promise.all([
sdk.client.session.get({ sessionID }, { throwOnError: true }),
Expand Down
8 changes: 5 additions & 3 deletions packages/opencode/src/cli/cmd/tui/context/theme.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { SyntaxStyle, RGBA, type TerminalColors } from "@opentui/core"
import path from "path"
import { createEffect, createMemo, onMount } from "solid-js"
import { createEffect, createMemo, onCleanup, onMount } from "solid-js"
import { createSimpleContext } from "./helper"
import { Glob } from "../../../../util/glob"
import aura from "./theme/aura.json" with { type: "json" }
Expand Down Expand Up @@ -347,10 +347,12 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
}

const renderer = useRenderer()
process.on("SIGUSR2", async () => {
const sigusr2Handler = async () => {
renderer.clearPaletteCache()
init()
})
}
process.on("SIGUSR2", sigusr2Handler)
onCleanup(() => process.off("SIGUSR2", sigusr2Handler))

const values = createMemo(() => {
return resolveTheme(store.themes[store.active] ?? store.themes.opencode, store.mode)
Expand Down
4 changes: 3 additions & 1 deletion packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
For,
Match,
on,
onCleanup,
onMount,
Show,
Switch,
Expand Down Expand Up @@ -209,7 +210,7 @@ export function Session() {
})

let lastSwitch: string | undefined = undefined
sdk.event.on("message.part.updated", (evt) => {
const unsubPartUpdated = sdk.event.on("message.part.updated", (evt) => {
const part = evt.properties.part
if (part.type !== "tool") return
if (part.sessionID !== route.sessionID) return
Expand All @@ -224,6 +225,7 @@ export function Session() {
lastSwitch = part.id
}
})
onCleanup(unsubPartUpdated)

let scroll: ScrollBoxRenderable
let prompt: PromptRef
Expand Down
28 changes: 20 additions & 8 deletions packages/opencode/src/control-plane/workspace-server/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,25 @@ export function WorkspaceServerRoutes() {
c.header("X-Accel-Buffering", "no")
c.header("X-Content-Type-Options", "nosniff")
return streamSSE(c, async (stream) => {
let done = false
let resolveStream: (() => void) | undefined

const cleanup = () => {
if (done) return
done = true
clearInterval(heartbeat)
GlobalBus.off("event", handler)
resolveStream?.()
}

const send = async (event: unknown) => {
await stream.writeSSE({
data: JSON.stringify(event),
})
try {
await stream.writeSSE({
data: JSON.stringify(event),
})
} catch {
cleanup()
}
}
const handler = async (event: { directory?: string; payload: unknown }) => {
await send(event.payload)
Expand All @@ -22,11 +37,8 @@ export function WorkspaceServerRoutes() {
}, 10_000)

await new Promise<void>((resolve) => {
stream.onAbort(() => {
clearInterval(heartbeat)
GlobalBus.off("event", handler)
resolve()
})
resolveStream = resolve
stream.onAbort(cleanup)
})
})
})
Expand Down
6 changes: 5 additions & 1 deletion packages/opencode/src/format/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,9 +101,13 @@ export namespace Format {
return result
}

let unsubFormatted: (() => void) | undefined

export function init() {
log.info("init")
Bus.subscribe(File.Event.Edited, async (payload) => {
// Unsubscribe previous subscription to prevent stacking on re-init
unsubFormatted?.()
unsubFormatted = Bus.subscribe(File.Event.Edited, async (payload) => {
const file = payload.properties.file
log.info("formatting", { file })
const ext = path.extname(file)
Expand Down
4 changes: 4 additions & 0 deletions packages/opencode/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,10 @@ try {
}
process.exitCode = 1
} finally {
// Dispose all instances (LSP, MCP, PTY child processes) to prevent zombies.
// Race with a 5-second timeout so we don't hang on unresponsive subprocesses.
const { Instance } = await import("./project/instance")
await Promise.race([Instance.disposeAll(), new Promise((r) => setTimeout(r, 5000))]).catch(() => {})
// Some subprocesses don't react properly to SIGTERM and similar signals.
// Most notably, some docker-container-based MCP servers don't handle such signals unless
// run using `docker run --init`.
Expand Down
Loading
Loading