IJPL-233558 fix codex process creation

GitOrigin-RevId: 45edbc60fd75dd9dbe989a814fa297da036bee20
This commit is contained in:
Vladimir Krivosheev
2026-02-12 15:38:42 +00:00
committed by intellij-monorepo-bot
parent afd381f6a2
commit 838769ce6e
3 changed files with 153 additions and 23 deletions
@@ -3,7 +3,7 @@ package com.intellij.agent.workbench.codex.common
import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.core.JsonParser
import com.intellij.execution.configurations.PathEnvironmentVariableUtil
import com.intellij.execution.configurations.GeneralCommandLine
import com.intellij.openapi.diagnostic.debug
import com.intellij.openapi.diagnostic.logger
import com.intellij.util.io.awaitExit
@@ -20,16 +20,21 @@ import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeout
import java.io.BufferedReader
import java.io.BufferedWriter
import java.io.IOException
import java.io.InputStreamReader
import java.io.OutputStreamWriter
import java.nio.charset.StandardCharsets
import java.nio.file.Files
import java.nio.file.NoSuchFileException
import java.nio.file.Path
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicLong
import kotlin.time.Duration.Companion.milliseconds
private const val CODEX_COMMAND = "codex"
private const val REQUEST_TIMEOUT_MS = 30_000L
private const val PROCESS_TERMINATION_TIMEOUT_MS = 2_000L
private const val MAX_PAGES = 10
private const val PAGE_LIMIT = 50
@@ -37,18 +42,17 @@ private val LOG = logger<CodexAppServerClient>()
class CodexAppServerClient(
private val coroutineScope: CoroutineScope,
private val executablePathProvider: () -> String? = {
PathEnvironmentVariableUtil.findExecutableInPathOnAnyOS(CODEX_COMMAND)?.absolutePath
},
private val executablePathProvider: () -> String? = { null },
private val environmentOverrides: Map<String, String> = emptyMap(),
private val workingDirectory: Path? = null,
workingDirectory: Path? = null,
) {
private val pending = ConcurrentHashMap<String, CompletableDeferred<String>>()
private val requestCounter = AtomicLong(0)
private val writeMutex = Mutex()
private val startMutex = Mutex()
private val initMutex = Mutex()
private val protocol = CodexAppServerProtocol(workingDirectory)
private val workingDirectoryPath = workingDirectory
private val protocol = CodexAppServerProtocol(workingDirectoryPath)
@Volatile
private var process: Process? = null
@@ -152,7 +156,7 @@ class CodexAppServerClient(
pending[id] = deferred
try {
sendRequest(id, method, paramsWriter)
val response = withTimeout(REQUEST_TIMEOUT_MS) { deferred.await() }
val response = withTimeout(REQUEST_TIMEOUT_MS.milliseconds) { deferred.await() }
return protocol.parseResponse(response, resultParser, defaultResult)
}
catch (t: TimeoutCancellationException) {
@@ -250,26 +254,27 @@ class CodexAppServerClient(
}
private fun startProcess(): Process {
val executable = executablePathProvider() ?: throw CodexCliNotFoundException()
val configuredExecutable = executablePathProvider()
?.trim()
?.takeIf { it.isNotEmpty() }
val executable = configuredExecutable ?: CODEX_COMMAND
val process = try {
ProcessBuilder(executable, "app-server").apply {
if (environmentOverrides.isNotEmpty()) {
val env = environment()
for ((key, value) in environmentOverrides) {
env[key] = value
GeneralCommandLine(executable, "app-server")
.withParentEnvironmentType(GeneralCommandLine.ParentEnvironmentType.CONSOLE)
.withEnvironment(environmentOverrides)
.apply {
val directory = workingDirectoryPath
if (directory != null && Files.isDirectory(directory)) {
withWorkingDirectory(directory)
}
}
val directory = workingDirectory
if (directory != null && Files.isDirectory(directory)) {
@Suppress("IO_FILE_USAGE")
directory(directory.toFile())
}
}
.redirectErrorStream(false)
.start()
.createProcess()
}
catch (t: Throwable) {
throw CodexAppServerException("Failed to start Codex app-server", t)
if (configuredExecutable == null && isExecutableNotFound(t)) {
throw CodexCliNotFoundException()
}
throw CodexAppServerException("Failed to start Codex app-server from $executable", t)
}
this.process = process
this.writer = BufferedWriter(OutputStreamWriter(process.outputStream, StandardCharsets.UTF_8))
@@ -400,10 +405,34 @@ class CodexAppServerClient(
stderrJob?.cancel()
waitJob?.cancel()
current.destroy()
try {
if (!current.waitFor(PROCESS_TERMINATION_TIMEOUT_MS, TimeUnit.MILLISECONDS)) {
current.destroyForcibly()
current.waitFor(PROCESS_TERMINATION_TIMEOUT_MS, TimeUnit.MILLISECONDS)
}
}
catch (_: Throwable) {
}
}
}
private fun isExecutableNotFound(error: Throwable): Boolean {
return generateSequence(error) { it.cause }
.any { cause ->
when (cause) {
is NoSuchFileException -> true
is IOException -> {
val message = cause.message ?: return@any false
message.contains("error=2") ||
message.contains("no such file or directory", ignoreCase = true) ||
message.contains("cannot find the file", ignoreCase = true)
}
else -> false
}
}
}
open class CodexAppServerException(message: String, cause: Throwable? = null) : RuntimeException(message, cause)
class CodexCliNotFoundException : CodexAppServerException("Codex CLI not found")
@@ -1,7 +1,9 @@
// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.agent.workbench.sessions
import com.intellij.agent.workbench.codex.common.CodexAppServerClient
import com.intellij.agent.workbench.codex.common.CodexAppServerException
import com.intellij.agent.workbench.codex.common.CodexCliNotFoundException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import org.assertj.core.api.Assertions.assertThat
@@ -387,4 +389,99 @@ class CodexAppServerClientTest {
client.shutdown()
}
}
@Test
fun listThreadsUsesPathOverrideWhenExecutableProviderMissing(): Unit = runBlocking(Dispatchers.IO) {
val workingDir = tempDir.resolve("project-path-override")
Files.createDirectories(workingDir)
val configPath = workingDir.resolve("codex-config.json")
writeConfig(
path = configPath,
threads = listOf(
ThreadSpec(
id = "thread-path",
title = "Path Thread",
cwd = workingDir.toString(),
updatedAt = 1_700_000_000_000L,
archived = false,
),
),
)
val backendDir = tempDir.resolve("backend-path-override")
Files.createDirectories(backendDir)
val codexShim = createMockCodexShim(backendDir, configPath)
val client = CodexAppServerClient(
coroutineScope = this,
executablePathProvider = { null },
environmentOverrides = mapOf("PATH" to codexShim.parent.toString()),
workingDirectory = workingDir,
)
try {
val threads = client.listThreads(archived = false)
assertThat(threads.map { it.id }).containsExactly("thread-path")
}
finally {
client.shutdown()
}
}
@Test
fun listThreadsFailsWithoutFallbackWhenConfiguredExecutableIsInvalid(): Unit = runBlocking(Dispatchers.IO) {
val workingDir = tempDir.resolve("project-invalid-exec")
Files.createDirectories(workingDir)
val configPath = workingDir.resolve("codex-config.json")
writeConfig(path = configPath, threads = emptyList())
val backendDir = tempDir.resolve("backend-invalid-exec")
Files.createDirectories(backendDir)
val codexShim = createMockCodexShim(backendDir, configPath)
val invalidExecutable = tempDir.resolve("missing-codex").toString()
val client = CodexAppServerClient(
coroutineScope = this,
executablePathProvider = { invalidExecutable },
environmentOverrides = mapOf("PATH" to codexShim.parent.toString()),
workingDirectory = workingDir,
)
try {
try {
client.listThreads(archived = false)
fail("Expected CodexAppServerException")
}
catch (e: CodexAppServerException) {
assertThat(e.message).contains(invalidExecutable)
}
}
finally {
client.shutdown()
}
}
@Test
fun listThreadsReportsDefaultExecutableStartFailuresWithoutCliMissingError(): Unit = runBlocking(Dispatchers.IO) {
val workingDir = tempDir.resolve("project-invalid-env")
Files.createDirectories(workingDir)
val configPath = workingDir.resolve("codex-config.json")
writeConfig(path = configPath, threads = emptyList())
val client = CodexAppServerClient(
coroutineScope = this,
executablePathProvider = { null },
environmentOverrides = mapOf("INVALID=KEY" to "value"),
workingDirectory = workingDir,
)
try {
try {
client.listThreads(archived = false)
fail("Expected CodexAppServerException")
}
catch (e: CodexAppServerException) {
assertThat(e).isNotInstanceOf(CodexCliNotFoundException::class.java)
assertThat(e.message).contains("Failed to start Codex app-server from codex")
}
}
finally {
client.shutdown()
}
}
}
@@ -180,6 +180,10 @@ internal fun createMockClient(
)
}
internal fun createMockCodexShim(tempDir: Path, configPath: Path): Path {
return createCodexShim(tempDir, configPath)
}
private fun createCodexShim(tempDir: Path, configPath: Path): Path {
val javaHome = System.getProperty("java.home")
val javaBin = Path.of(javaHome, "bin", if (OS.CURRENT == OS.Windows) "java.exe" else "java")