Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/jetbrains-restartless-unload.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---

Stop Kilo backend processes and clear JetBrains UI resources during restartless plugin unloads.
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ class KiloBackendAppService private constructor(
private var watcher: Job? = null
private var eventWatcher: Job? = null
private var loader: Job? = null
private var closed = false
private val loadLock = Any()

private val _appState = MutableStateFlow<KiloAppState>(KiloAppState.Disconnected)
Expand Down Expand Up @@ -159,6 +160,12 @@ class KiloBackendAppService private constructor(
}
}

suspend fun shutdownForUnload() {
mutex.withLock {
shutdown()
}
}

suspend fun retry() {
mutex.withLock {
when (val current = _appState.value) {
Expand Down Expand Up @@ -806,6 +813,12 @@ class KiloBackendAppService private constructor(
}

override fun dispose() {
shutdown()
}

private fun shutdown() {
if (closed) return
closed = true
watcher?.cancel()
watcher = null
clear()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,10 @@ class KiloBackendCliManager(

@Volatile
private var process: Process? = null
@Volatile
private var closing: Process? = null
private var hook: Thread? = null
private var stderr: Thread? = null

@Volatile
override var forceExtract = false
Expand All @@ -59,8 +62,7 @@ class KiloBackendCliManager(
process?.let { proc ->
log.info("Cleaning up orphaned CLI process (pid=${proc.pid()})")
process = null
uninstall()
kill(proc, "startup failure cleanup")
cleanup(proc, "startup failure cleanup")
}
CliServer.State.Error(
message = e.message ?: "Unknown error",
Expand All @@ -73,13 +75,13 @@ class KiloBackendCliManager(
if (process != proc) return
process = null
uninstall()
stderr = null
}

override fun stop() {
val proc = process ?: return
process = null
uninstall()
kill(proc, "stop()")
cleanup(proc, "stop()")
}

private fun extractCli(): File {
Expand Down Expand Up @@ -158,14 +160,19 @@ class KiloBackendCliManager(

val stderr = StringBuilder()

Thread({
BufferedReader(InputStreamReader(proc.errorStream)).use { reader ->
reader.lineSequence().forEach { line ->
log.warn("CLI stderr: $line")
synchronized(stderr) { stderr.appendLine(line) }
val err = Thread({
runCatching {
BufferedReader(InputStreamReader(proc.errorStream)).use { reader ->
reader.lineSequence().forEach { line ->
log.warn("CLI stderr: $line")
synchronized(stderr) { stderr.appendLine(line) }
}
}
}.onFailure { err ->
if (proc.isAlive && closing !== proc) log.warn("CLI stderr reader failed", err)
}
}, "kilo-cli-stderr").apply { isDaemon = true; start() }
this@KiloBackendCliManager.stderr = err

BufferedReader(InputStreamReader(proc.inputStream)).use { reader ->
for (line in reader.lineSequence()) {
Expand All @@ -185,6 +192,7 @@ class KiloBackendCliManager(
val details = synchronized(stderr) { stderr.toString().trim() }
process = null
uninstall()
this@KiloBackendCliManager.stderr = null
log.warn("CLI process exited with code $code before announcing a port: $details")
CliServer.State.Error(
message = "CLI process exited with code $code before announcing a port",
Expand All @@ -195,8 +203,23 @@ class KiloBackendCliManager(
override fun dispose() {
val proc = process ?: return
process = null
uninstall()
kill(proc, "Disposing")
cleanup(proc, "Disposing")
}

private fun cleanup(proc: Process, source: String) {
closing = proc
try {
uninstall()
close(proc)
kill(proc, source)
val thread = stderr
stderr = null
if (thread != null && thread != Thread.currentThread()) {
thread.join(TimeUnit.SECONDS.toMillis(1))
}
} finally {
closing = null
}
}

private fun install(proc: Process) {
Expand Down Expand Up @@ -237,6 +260,12 @@ class KiloBackendCliManager(
private fun children(proc: Process): List<ProcessHandle> =
proc.toHandle().descendants().toList().asReversed()

private fun close(proc: Process) {
runCatching { proc.errorStream.close() }.onFailure { log.info("CLI stderr stream close skipped: ${it.message}") }
runCatching { proc.inputStream.close() }.onFailure { log.info("CLI stdout stream close skipped: ${it.message}") }
runCatching { proc.outputStream.close() }.onFailure { log.info("CLI stdin stream close skipped: ${it.message}") }
}

private fun platform(): String {
val os = when {
SystemInfo.isMac -> "darwin"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package ai.kilocode.backend.plugin

import ai.kilocode.KiloPlugin
import ai.kilocode.backend.app.KiloBackendAppService
import ai.kilocode.log.KiloLog
import com.intellij.ide.plugins.DynamicPluginListener
import com.intellij.ide.plugins.IdeaPluginDescriptor
import com.intellij.openapi.components.service
import kotlinx.coroutines.runBlocking

class KiloBackendDynamicPluginListener : DynamicPluginListener {
private val log = KiloLog.create(KiloBackendDynamicPluginListener::class.java)

override fun beforePluginUnload(pluginDescriptor: IdeaPluginDescriptor, isUpdate: Boolean) {
if (pluginDescriptor.pluginId != KiloPlugin.id) return
log.info("Shutting down Kilo backend for plugin unload (isUpdate=$isUpdate)")
runBlocking {
service<KiloBackendAppService>().shutdownForUnload()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,9 @@
<platform.rpc.backend.remoteApiProvider implementation="ai.kilocode.backend.rpc.KiloMigrationRpcApiProvider"/>
<applicationService serviceImplementation="ai.kilocode.backend.migration.KiloBackendLegacyMigrationStoreService"/>
</extensions>

<applicationListeners>
<listener class="ai.kilocode.backend.plugin.KiloBackendDynamicPluginListener"
topic="com.intellij.ide.plugins.DynamicPluginListener"/>
</applicationListeners>
</idea-plugin>
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,28 @@ class KiloBackendAppServiceTest {
assertNotNull(ready.data.notifications)
}

@Test
fun `shutdown for unload clears runtime and disposes server once`() = runBlocking {
val server = FakeCliServer(mock)
val svc = KiloBackendAppService.create(scope, server, log)
svc.connect()

withTimeout(10_000) {
svc.appState.first { it is KiloAppState.Ready }
}

svc.shutdownForUnload()
svc.shutdownForUnload()
svc.dispose()

assertEquals(KiloAppState.Disconnected, svc.appState.value)
assertNull(svc.profile)
assertNull(svc.config)
assertTrue(svc.notifications.isEmpty())
assertTrue(svc.warnings.isEmpty())
assertEquals(1, server.disposeCount)
}

@Test
fun `config is loaded`() = runBlocking {
mock.config = """{"model":"claude-4","username":"testuser"}"""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ import ai.kilocode.backend.cli.CliServer
class FakeCliServer(private val mock: MockCliServer) : CliServer {

override var forceExtract = false
var stopCount = 0
private set
var disposeCount = 0
private set

override fun process(): Process? = null

Expand All @@ -23,11 +27,13 @@ class FakeCliServer(private val mock: MockCliServer) : CliServer {

/** Shutdown the server socket but keep the mock alive for restart. */
override fun stop() {
stopCount++
mock.shutdown()
}

/** Final cleanup. */
override fun dispose() {
disposeCount++
mock.close()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import ai.kilocode.client.session.SessionSidePanelManager
import ai.kilocode.client.telemetry.Telemetry
import ai.kilocode.log.KiloLog
import com.intellij.openapi.actionSystem.ActionManager
import com.intellij.openapi.components.Service
import com.intellij.openapi.components.service
import com.intellij.openapi.project.DumbAware
import com.intellij.openapi.project.Project
Expand All @@ -14,7 +15,6 @@ import com.intellij.openapi.wm.ToolWindowFactory
import com.intellij.ui.content.ContentFactory
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext

Expand All @@ -27,16 +27,22 @@ import kotlinx.coroutines.withContext
* completes.
*/
class KiloToolWindowFactory : ToolWindowFactory, DumbAware {

companion object {
private val LOG = KiloLog.create(KiloToolWindowFactory::class.java)
override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) {
project.service<KiloToolWindowSetupService>().create(toolWindow)
}
}

override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) {
private val LOG = KiloLog.create(KiloToolWindowFactory::class.java)

@Service(Service.Level.PROJECT)
internal class KiloToolWindowSetupService(
private val project: Project,
private val cs: CoroutineScope,
) {
fun create(toolWindow: ToolWindow) {
val start = System.currentTimeMillis()
try {
val workspaces = service<KiloWorkspaceService>()
val cs = CoroutineScope(SupervisorJob())
val hint = project.basePath ?: ""

cs.launch {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package ai.kilocode.client.plugin

import ai.kilocode.KiloPlugin
import ai.kilocode.client.session.ui.attachment.unregisterAttachmentEditorKind
import ai.kilocode.client.vfs.KiloEditorKindRegistry
import ai.kilocode.client.vfs.KiloVirtualFileSystem
import ai.kilocode.log.KiloLog
import com.intellij.ide.plugins.DynamicPluginListener
import com.intellij.ide.plugins.IdeaPluginDescriptor
import com.intellij.openapi.components.service
import com.intellij.openapi.fileEditor.FileEditorManager
import com.intellij.openapi.project.ProjectManager
import com.intellij.openapi.wm.ToolWindowManager
import javax.swing.SwingUtilities

class KiloFrontendDynamicPluginListener : DynamicPluginListener {
override fun beforePluginUnload(pluginDescriptor: IdeaPluginDescriptor, isUpdate: Boolean) {
if (pluginDescriptor.pluginId != KiloPlugin.id) return
KiloFrontendUnloadCleanup.cleanup(isUpdate)
}
}

object KiloFrontendUnloadCleanup {
private val log = KiloLog.create(KiloFrontendUnloadCleanup::class.java)

fun cleanup(isUpdate: Boolean) {
log.info("Cleaning up Kilo frontend for plugin unload (isUpdate=$isUpdate)")
runEdt {
ProjectManager.getInstance().openProjects.forEach { project ->
if (project.isDisposed) return@forEach
ToolWindowManager.getInstance(project).getToolWindow("Kilo Code")
?.contentManager
?.removeAllContents(true)
val editors = FileEditorManager.getInstance(project).openFiles
.filter { it.fileSystem === KiloVirtualFileSystem.getInstance() }
editors.forEach { file -> FileEditorManager.getInstance(project).closeFile(file) }
}
}
unregisterAttachmentEditorKind()
service<KiloEditorKindRegistry>().clear()
KiloVirtualFileSystem.getInstance().clear()
}

private fun runEdt(block: () -> Unit) {
if (SwingUtilities.isEventDispatchThread()) {
block()
return
}
SwingUtilities.invokeAndWait(block)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,10 @@ fun ensureAttachmentEditorKind() {
service<KiloEditorKindRegistry>().register(AttachmentEditorKind)
}

internal fun unregisterAttachmentEditorKind() {
service<KiloEditorKindRegistry>().unregister(AttachmentEditorKind.ID)
}

internal fun attachmentParams(
sessionId: String,
messageId: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,9 @@ class KiloEditorKindRegistry {
service<KiloVirtualFileKindRegistry>().unregister(id)
}

fun clear() {
kinds.keys.forEach { id -> unregister(id) }
}

fun get(id: String): KiloEditorKind? = kinds[id]
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,9 @@ class KiloVirtualFileKindRegistry {
kinds.remove(id)
}

fun clear() {
kinds.clear()
}

fun get(id: String): KiloVirtualFileKind? = kinds[id]
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ class KiloVirtualFileSystem : VirtualFileSystem(), NonPhysicalFileSystem {
files.remove(path.canonical())
}

fun clear() {
files.clear()
}

override fun findFileByPath(path: String): VirtualFile? {
val parsed = decode(path) ?: return null
return findOrCreateFile(parsed)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,11 @@
overrides="false"/>
</extensions>

<applicationListeners>
<listener class="ai.kilocode.client.plugin.KiloFrontendDynamicPluginListener"
topic="com.intellij.ide.plugins.DynamicPluginListener"/>
</applicationListeners>

<actions>
<action id="Kilo.Restart"
class="ai.kilocode.client.actions.RestartKiloAction"/>
Expand Down
Loading
Loading