Skip to content

Commit ca29e53

Browse files
committed
fix: memory leak in classic terminal
test: add test to make sure we clean up after classic terminal
1 parent c89e897 commit ca29e53

6 files changed

Lines changed: 148 additions & 14 deletions

File tree

build.gradle.kts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ dependencies {
2222
}
2323

2424
testImplementation(kotlin("test"))
25+
testImplementation("junit:junit:4.13.2")
26+
intellijPlatform {
27+
testFramework(org.jetbrains.intellij.platform.gradle.TestFrameworkType.Platform)
28+
}
2529
}
2630

2731
intellijPlatform {

src/main/kotlin/com/ashotn/opencode/companion/terminal/ClassicTuiPanel.kt

Lines changed: 32 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,31 +6,30 @@ import com.intellij.openapi.Disposable
66
import com.intellij.openapi.application.ApplicationManager
77
import com.intellij.openapi.diagnostic.Logger
88
import com.intellij.openapi.project.Project
9+
import com.intellij.openapi.ui.ComponentContainer
910
import com.intellij.openapi.util.Disposer
1011
import com.intellij.terminal.ui.TerminalWidget
1112
import org.jetbrains.plugins.terminal.LocalTerminalDirectRunner
1213
import org.jetbrains.plugins.terminal.ShellStartupOptions
14+
import org.jetbrains.plugins.terminal.ShellTerminalWidget
1315
import java.awt.BorderLayout
16+
import java.lang.reflect.Proxy
1417
import javax.swing.JPanel
1518

1619
/**
17-
* Hosts an embedded **classic** terminal (backed by [LocalTerminalDirectRunner] /
18-
* [org.jetbrains.plugins.terminal.ShellTerminalWidget]) running
19-
* `opencode attach <server-url>`.
20+
* Hosts an embedded classic terminal running `opencode attach <server-url>`.
2021
*
21-
* This engine works on all IntelliJ versions supported by the plugin (since 233)
22-
* without any version-gated API. It is wired up via [LocalTerminalDirectRunner]
23-
* with an explicit [ShellStartupOptions.shellCommand] override so it runs our
24-
* specific command instead of the user's default shell.
25-
*
26-
* The terminal is started lazily on the first call to [startIfNeeded] and lives
27-
* for as long as this panel's parent [Disposable] is alive.
22+
* Backed by [LocalTerminalDirectRunner] with a [ShellStartupOptions.shellCommand] override.
23+
* The terminal is started lazily on the first call to [startIfNeeded] and lives for as long
24+
* as this panel's parent [Disposable] is alive.
2825
*/
2926
class ClassicTuiPanel(
3027
private val project: Project,
3128
parentDisposable: Disposable,
3229
/** Invoked on the EDT when the shell process terminates. */
3330
private val onTerminated: (() -> Unit)? = null,
31+
/** Injected process used in tests to verify the kill path without live terminal infrastructure. */
32+
internal val processOverride: Process? = null,
3433
) : JPanel(BorderLayout()), TuiPanel, Disposable {
3534

3635
private var terminalWidget: TerminalWidget? = null
@@ -50,6 +49,12 @@ class ClassicTuiPanel(
5049
override fun startIfNeeded() {
5150
if (terminalWidget != null) return
5251

52+
//For test
53+
if (processOverride != null) {
54+
terminalWidget = noOpTerminalWidget()
55+
return
56+
}
57+
5358
try {
5459
val workingDir = project.basePath ?: System.getProperty("user.home")
5560
val command = listOf(
@@ -109,15 +114,29 @@ class ClassicTuiPanel(
109114
remove(widget.component)
110115
revalidate()
111116
repaint()
112-
// Disposing the widget will trigger the termination callback if the process
113-
// is still alive, but since we've already cleared terminalWidget the guard
114-
// (terminalWidget === widget) inside the callback will short-circuit it.
117+
val process: Process? = processOverride
118+
?: widget.ttyConnectorAccessor.ttyConnector
119+
?.let { ShellTerminalWidget.getProcessTtyConnector(it)?.process }
120+
val handle = process?.let { ProcessHandle.of(it.pid()).orElse(null) }
121+
killProcessTree(handle)
115122
Disposer.dispose(widget)
116123
}
117124

118125
override fun dispose() = tearDown()
119126

120127
companion object {
121128
private val logger = Logger.getInstance(ClassicTuiPanel::class.java)
129+
130+
private fun noOpTerminalWidget(): TerminalWidget =
131+
Proxy.newProxyInstance(
132+
TerminalWidget::class.java.classLoader,
133+
arrayOf(TerminalWidget::class.java, ComponentContainer::class.java, Disposable::class.java),
134+
) { _, method, _ ->
135+
when (method.name) {
136+
"getComponent", "getPreferredFocusableComponent" -> JPanel()
137+
"dispose" -> Unit
138+
else -> if (method.returnType == Boolean::class.javaPrimitiveType) false else null
139+
}
140+
} as TerminalWidget
122141
}
123142
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package com.ashotn.opencode.companion.terminal
2+
3+
import com.intellij.openapi.diagnostic.Logger
4+
5+
private val log = Logger.getInstance("com.ashotn.opencode.companion.terminal.ProcessKill")
6+
7+
/**
8+
* Forcibly kills [handle] and its entire descendant process tree.
9+
* Safe to call with `null` (no-op) or on an already-dead process.
10+
*/
11+
internal fun killProcessTree(handle: ProcessHandle?) {
12+
handle ?: return
13+
try {
14+
handle.descendants().forEach { it.destroyForcibly() }
15+
handle.destroyForcibly()
16+
} catch (e: Exception) {
17+
log.debug("Failed to kill process tree for PID ${handle.pid()}", e)
18+
}
19+
}

src/main/kotlin/com/ashotn/opencode/companion/terminal/ReworkedTuiPanel.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,12 @@ import javax.swing.JPanel
3030
*
3131
* The terminal is started lazily on the first call to [startIfNeeded] and lives
3232
* for as long as this panel's parent [Disposable] is alive.
33+
*
34+
* **Testability note:** unlike [ClassicTuiPanel], this panel cannot be unit-tested
35+
* with a stub process. It delegates process cleanup entirely to the platform's
36+
* [Content] disposal chain — there is no explicit kill path to inject into or
37+
* assert against. [TerminalToolWindowTabsManager] also requires a fully initialised
38+
* IDE frontend that is not available in a headless test environment.
3339
*/
3440
class ReworkedTuiPanel(
3541
private val project: Project,
@@ -56,6 +62,7 @@ class ReworkedTuiPanel(
5662
*/
5763
override fun startIfNeeded() {
5864
if (terminalView != null) return
65+
5966
if (!BuildUtils.isEmbeddedTerminalSupported) return
6067

6168
try {

src/main/kotlin/com/ashotn/opencode/companion/tui/OpenCodeTuiClient.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ class OpenCodeTuiClient(private val project: Project) {
8484
*
8585
* If the newest known session has no messages, no tracked files, and is not busy,
8686
* we reuse it instead of creating a new one — there is no point in accumulating
87-
* blank sessions. The [onResult] callback receives [reused]=true in that case.
87+
* blank sessions. The [onResult] callback receives reused=true in that case.
8888
*
8989
* Runs all work on a pooled thread and invokes [onResult] from that thread.
9090
*/
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
package com.ashotn.opencode.companion.terminal
2+
3+
import com.intellij.openapi.application.ApplicationManager
4+
import com.intellij.openapi.util.Disposer
5+
import com.intellij.testFramework.fixtures.BasePlatformTestCase
6+
7+
/**
8+
* Verifies that [ClassicTuiPanel] kills the terminal process on [ClassicTuiPanel.stop]
9+
* and [ClassicTuiPanel.dispose].
10+
*
11+
* Uses [ClassicTuiPanel.processOverride] to inject a real `sleep 3600` OS process
12+
* without needing live terminal infrastructure. Assertions are purely OS-level PID checks.
13+
*/
14+
class ClassicTuiPanelProcessLeakTest : BasePlatformTestCase() {
15+
16+
fun `test stop kills the terminal process`() {
17+
val process = spawnSleepProcess()
18+
val pid = process.pid()
19+
val panel = ClassicTuiPanel(project, testRootDisposable, processOverride = process)
20+
21+
ApplicationManager.getApplication().invokeAndWait { panel.startIfNeeded() }
22+
assertTrue("pre: process must be alive (pid=$pid)", isAlive(pid))
23+
24+
panel.stop()
25+
26+
waitForDeath(pid)
27+
assertFalse("process must be dead after stop() (pid=$pid)", isAlive(pid))
28+
}
29+
30+
fun `test dispose kills the terminal process`() {
31+
val process = spawnSleepProcess()
32+
val pid = process.pid()
33+
val panelDisposable = Disposer.newDisposable()
34+
val panel = ClassicTuiPanel(project, panelDisposable, processOverride = process)
35+
36+
ApplicationManager.getApplication().invokeAndWait { panel.startIfNeeded() }
37+
assertTrue("pre: process must be alive (pid=$pid)", isAlive(pid))
38+
39+
Disposer.dispose(panelDisposable) // triggers panel.dispose() → tearDown()
40+
41+
waitForDeath(pid)
42+
assertFalse("process must be dead after dispose() (pid=$pid)", isAlive(pid))
43+
}
44+
45+
fun `test reset cycle kills both the old and the new terminal process`() {
46+
val process1 = spawnSleepProcess()
47+
val pid1 = process1.pid()
48+
val process2 = spawnSleepProcess()
49+
val pid2 = process2.pid()
50+
51+
// Use a fresh panel per cycle via processOverride on each startIfNeeded call.
52+
// First session: inject process1.
53+
val panel = ClassicTuiPanel(project, testRootDisposable, processOverride = process1)
54+
ApplicationManager.getApplication().invokeAndWait { panel.startIfNeeded() }
55+
assertTrue("pre cycle 1: process1 alive (pid=$pid1)", isAlive(pid1))
56+
57+
panel.stop()
58+
waitForDeath(pid1)
59+
assertFalse("cycle 1: process1 must be dead (pid=$pid1)", isAlive(pid1))
60+
61+
// Second session: swap in process2 via a new panel (panel.stop() cleared terminalWidget).
62+
val panel2 = ClassicTuiPanel(project, testRootDisposable, processOverride = process2)
63+
ApplicationManager.getApplication().invokeAndWait { panel2.startIfNeeded() }
64+
assertTrue("pre cycle 2: process2 alive (pid=$pid2)", isAlive(pid2))
65+
66+
panel2.stop()
67+
waitForDeath(pid2)
68+
assertFalse("cycle 2: process2 must be dead (pid=$pid2)", isAlive(pid2))
69+
70+
assertFalse("cycle 1 process must still be dead (pid=$pid1)", isAlive(pid1))
71+
}
72+
73+
// ---- helpers ----
74+
75+
private fun spawnSleepProcess(): Process =
76+
ProcessBuilder("sleep", "3600").redirectErrorStream(true).start()
77+
78+
private fun isAlive(pid: Long): Boolean =
79+
ProcessHandle.of(pid).orElse(null)?.isAlive ?: false
80+
81+
private fun waitForDeath(pid: Long, timeoutMs: Long = 5_000) {
82+
val deadline = System.currentTimeMillis() + timeoutMs
83+
while (isAlive(pid) && System.currentTimeMillis() < deadline) Thread.sleep(50)
84+
}
85+
}

0 commit comments

Comments
 (0)