EEL: Local implementation of EelExecApi (tty isn't ready yet)

Co-authored-by: Konstantin Nisht <konstantin.nisht@jetbrains.com>

Merge-request: IJ-MR-144986
Merged-by: Ilya Kazakevich <ilya.kazakevich@jetbrains.com>

GitOrigin-RevId: 631eaf41573f7ae3a0ae337d8a0a88098d3aaf3b
This commit is contained in:
Ilya Kazakevich
2024-09-20 23:13:40 +00:00
committed by intellij-monorepo-bot
parent 14b44d05a6
commit af803fcff5
13 changed files with 454 additions and 27 deletions

View File

@@ -9,7 +9,7 @@ import kotlinx.coroutines.channels.SendChannel
* Represents some process which was launched via [EelExecApi.executeProcess]. * Represents some process which was launched via [EelExecApi.executeProcess].
* *
*/ */
interface EelProcess { interface EelProcess: KillableProcess {
val pid: EelApi.Pid val pid: EelApi.Pid
/** /**
@@ -44,28 +44,6 @@ interface EelProcess {
class StdinClosed : SendStdinError("Stdin closed") class StdinClosed : SendStdinError("Stdin closed")
} }
/**
* Sends `SIGINT` on Unix.
*
* Does nothing yet on Windows.
*/
suspend fun interrupt()
/**
* Sends `SIGTERM` on Unix.
*
* Calls [`ExitProcess`](https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-exitprocess) on Windows.
*/
suspend fun terminate()
/**
* Sends `SIGKILL` on Unix.
*
* Calls [`TerminateProcess`](https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-terminateprocess)
* on Windows.
*/
suspend fun kill()
/** /**
* Converts to the JVM [Process] which can be used instead of [EelProcess] for compatibility reasons. * Converts to the JVM [Process] which can be used instead of [EelProcess] for compatibility reasons.
* Note: After conversion, this [EelProcess] shouldn't be used: Use result [Process] instead * Note: After conversion, this [EelProcess] shouldn't be used: Use result [Process] instead

View File

@@ -0,0 +1,29 @@
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.platform.eel
interface KillableProcess {
/**
* Sends `SIGINT` on Unix.
*
* Sends `CTRL+C` on Windows (by attaching console).
*
* Warning: This signal could be ignored!
*/
suspend fun interrupt()
/**
* Sends `SIGTERM` on Unix.
*
* Calls [`ExitProcess`](https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-exitprocess) on Windows.
*/
suspend fun terminate()
/**
* Sends `SIGKILL` on Unix.
*
* Calls [`TerminateProcess`](https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-terminateprocess)
* on Windows.
*/
suspend fun kill()
}

View File

@@ -9,13 +9,9 @@ data class ExecuteProcessBuilderImpl(override val exe: String) : EelExecApi.Exec
} }
override var args: List<String> = listOf() override var args: List<String> = listOf()
private set
override var env: Map<String, String> = mapOf() override var env: Map<String, String> = mapOf()
private set
override var pty: EelExecApi.Pty? = null override var pty: EelExecApi.Pty? = null
private set
override var workingDirectory: String? = null override var workingDirectory: String? = null
private set
override fun toString(): String = override fun toString(): String =
"GrpcExecuteProcessBuilder(" + "GrpcExecuteProcessBuilder(" +

View File

@@ -14,5 +14,10 @@
<orderEntry type="module" module-name="intellij.platform.extensions" /> <orderEntry type="module" module-name="intellij.platform.extensions" />
<orderEntry type="module" module-name="intellij.platform.util" /> <orderEntry type="module" module-name="intellij.platform.util" />
<orderEntry type="module" module-name="intellij.platform.eel" /> <orderEntry type="module" module-name="intellij.platform.eel" />
<orderEntry type="module" module-name="intellij.platform.ide.util.io" />
<orderEntry type="module" module-name="intellij.platform.util.coroutines" />
<orderEntry type="module" module-name="intellij.platform.core" />
<orderEntry type="library" name="jna" level="project" />
<orderEntry type="library" name="pty4j" level="project" />
</component> </component>
</module> </module>

View File

@@ -0,0 +1,101 @@
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.platform.eel.impl.local
import com.intellij.openapi.diagnostic.Logger
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.ReceiveChannel
import kotlinx.coroutines.channels.SendChannel
import java.io.Closeable
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
private val LOG = Logger.getInstance(ChannelWrapper::class.java)
/**
* Wraps [Channel] to [close] [Closeable] stream along with the [channel]
*/
internal class ChannelWrapper(private val stream: Closeable, private val channel: Channel<ByteArray> = Channel()) : Channel<ByteArray> by channel {
override fun close(cause: Throwable?): Boolean {
try {
stream.close()
}
catch (e: IOException) {
LOG.info(e)
}
return this@ChannelWrapper.channel.close(cause)
}
}
/**
* Use inheritors
*/
internal sealed class StreamWrapper(private val scope: CoroutineScope, stream: Closeable) {
protected val channel = ChannelWrapper(stream)
protected fun connect(): Channel<ByteArray> {
scope.launch {
connectAsync()
}.invokeOnCompletion {
this@StreamWrapper.channel.close(it)
}
return channel
}
/**
* Infinite fun to connect stream to the channel
*/
protected abstract suspend fun connectAsync()
/**
* Connects [InputStream] with [ReceiveChannel]: use [connectChannel]
*/
class InputStreamWrapper(scope: CoroutineScope, private val inputStream: InputStream) : StreamWrapper(scope, inputStream) {
fun connectChannel(): ReceiveChannel<ByteArray> = connect()
private val BUF_SIZE = 4096
override suspend fun connectAsync() = withContext(Dispatchers.IO) {
// If we used ByteBuffer instead of ByteArray we wouldn't need to copy buffer on each call.
// TODO: Migrate to ByteBuffer
val buffer = ByteArray(BUF_SIZE)
while (isActive) {
val bytesRead = try {
inputStream.read(buffer)
}
catch (e: IOException) {
LOG.info(e)
break
}
if (bytesRead == -1) {
break
}
val bytesToSend = ByteArray(bytesRead)
withContext(Dispatchers.Default) { System.arraycopy(buffer, 0, bytesToSend, 0, bytesRead) }
channel.send(bytesToSend)
}
}
}
/**
* Connects [OutputStream] with [SendChannel]: use [connectChannel]
*/
internal class OutputStreamWrapper(scope: CoroutineScope, private val outputStream: OutputStream) : StreamWrapper(scope, outputStream) {
fun connectChannel(): SendChannel<ByteArray> = connect()
override suspend fun connectAsync() {
for (bytes in channel) {
try {
outputStream.write(bytes)
outputStream.flush()
}
catch (e: IOException) {
LOG.info(e)
return
}
}
}
}
}

View File

@@ -0,0 +1,29 @@
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.platform.eel.impl.local
import com.intellij.platform.eel.EelExecApi
import java.io.File
import java.io.IOException
class EelLocalExecApi : EelExecApi {
override suspend fun execute(builder: EelExecApi.ExecuteProcessBuilder): EelExecApi.ExecuteProcessResult {
assert(builder.pty == null) { "PTY isn't supported (yet)" }
val jvmProcessBuilder = ProcessBuilder(builder.exe, *builder.args.toTypedArray()).apply {
environment().putAll(builder.env)
builder.workingDirectory?.let {
directory(File(it))
}
}
try {
val process = jvmProcessBuilder.start()
return EelExecApi.ExecuteProcessResult.Success(LocalEelProcess(process))
}
catch (e: IOException) {
return EelExecApi.ExecuteProcessResult.Failure(-1, e.toString())
}
}
override suspend fun fetchLoginShellEnvVariables(): Map<String, String> = System.getenv()
}

View File

@@ -0,0 +1,65 @@
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.platform.eel.impl.local
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.components.Service
import com.intellij.openapi.components.service
import com.intellij.openapi.util.SystemInfoRt
import com.intellij.platform.eel.EelApi
import com.intellij.platform.eel.EelProcess
import com.intellij.platform.eel.KillableProcess
import com.intellij.platform.eel.impl.local.processKiller.PosixProcessKiller
import com.intellij.platform.eel.impl.local.processKiller.WinProcessKiller
import com.intellij.platform.util.coroutines.childScope
import com.intellij.util.io.awaitExit
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.ReceiveChannel
import kotlinx.coroutines.channels.SendChannel
import java.io.IOException
internal class LocalEelProcess(
private val process: Process,
private val killer: KillableProcess = if (SystemInfoRt.isWindows) WinProcessKiller(process) else PosixProcessKiller(process),
) : EelProcess, KillableProcess by killer {
private val scope: CoroutineScope = ApplicationManager.getApplication().service<ExecLocalProcessService>().scope()
override val pid: EelApi.Pid = LocalPid(process.pid())
override val stdin: SendChannel<ByteArray> = StreamWrapper.OutputStreamWrapper(scope, process.outputStream).connectChannel()
override val stdout: ReceiveChannel<ByteArray> = StreamWrapper.InputStreamWrapper(scope, process.inputStream).connectChannel()
override val stderr: ReceiveChannel<ByteArray> = StreamWrapper.InputStreamWrapper(scope, process.errorStream).connectChannel()
override val exitCode: Deferred<Int> = scope.async {
process.awaitExit()
}
override suspend fun sendStdinWithConfirmation(data: ByteArray) {
withContext(Dispatchers.IO) {
try {
with(process.outputStream) {
write(data)
flush()
}
}
catch (_: IOException) {
// TODO: Check that stream is indeed closed.
if (process.isAlive) {
throw EelProcess.SendStdinError.StdinClosed()
}
else {
throw EelProcess.SendStdinError.ProcessExited()
}
}
}
}
override fun convertToJavaProcess(): Process = process
override suspend fun resizePty(columns: Int, rows: Int) {
TODO("Not yet implemented. Use Pty4J")
}
}
@Service
private class ExecLocalProcessService(private val scope: CoroutineScope) {
fun scope(): CoroutineScope = scope.childScope("ExecLocalProcessService")
}

View File

@@ -0,0 +1,6 @@
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.platform.eel.impl.local
import com.intellij.platform.eel.EelApi
internal data class LocalPid(override val value: Long): EelApi.Pid

View File

@@ -0,0 +1,5 @@
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
@ApiStatus.Internal
package com.intellij.platform.eel.impl.local;
import org.jetbrains.annotations.ApiStatus;

View File

@@ -0,0 +1,29 @@
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.platform.eel.impl.local.processKiller
import com.intellij.execution.process.UnixProcessManager
import com.intellij.openapi.util.SystemInfoRt
import com.intellij.platform.eel.KillableProcess
internal class PosixProcessKiller(private val process: Process) : KillableProcess {
init {
assert(!SystemInfoRt.isWindows)
}
override suspend fun interrupt() {
kill(UnixProcessManager.SIGINT)
}
override suspend fun terminate() {
kill(UnixProcessManager.SIGTERM)
}
override suspend fun kill() {
kill(UnixProcessManager.SIGKILL)
}
private fun kill(signal: Int) {
if (!process.isAlive) return
UnixProcessManager.sendSignal(process.pid().toInt(), signal)
}
}

View File

@@ -0,0 +1,46 @@
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.platform.eel.impl.local.processKiller
import com.intellij.execution.process.ProcessService
import com.intellij.openapi.util.SystemInfoRt
import com.intellij.platform.eel.KillableProcess
import com.sun.jna.NativeLibrary
import com.sun.jna.platform.win32.Kernel32
import com.sun.jna.platform.win32.WinBase
import com.sun.jna.platform.win32.WinDef
import com.sun.jna.platform.win32.WinNT.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
internal class WinProcessKiller(private val process: Process) : KillableProcess {
init {
assert(SystemInfoRt.isWindows)
}
private companion object {
val exitProcess = lazy { // Might be slow, hence lazy
NativeLibrary.getInstance("kernel32.dll").getFunction("ExitProcess")
}
}
override suspend fun interrupt() {
if (!process.isAlive) return
ProcessService.getInstance().sendWinProcessCtrlC(process)
}
override suspend fun terminate() {
if (!process.isAlive) return
// `ExitProcess` can't be called outside the process, so we create thread inside to call this function
withContext(Dispatchers.Default) {
val p = PROCESS_CREATE_THREAD.or(PROCESS_QUERY_INFORMATION).or(PROCESS_VM_OPERATION).or(PROCESS_VM_WRITE).or(PROCESS_VM_READ)
val openProcess = Kernel32.INSTANCE.OpenProcess(p, false, process.pid().toInt())
Kernel32.INSTANCE.CreateRemoteThread(openProcess, WinBase.SECURITY_ATTRIBUTES(), 0, exitProcess.value, WinDef.UINT_PTR(0).toPointer(), 0, WinDef.DWORDByReference())
}
}
override suspend fun kill() {
// TerminateProcess is called according to JDK sources
process.destroyForcibly()
}
}

View File

@@ -0,0 +1,105 @@
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.execution.eel
import com.intellij.execution.process.UnixSignal
import com.intellij.openapi.util.SystemInfoRt
import com.intellij.platform.eel.EelExecApi
import com.intellij.platform.eel.impl.local.EelLocalExecApi
import com.intellij.testFramework.common.timeoutRunBlocking
import com.intellij.testFramework.junit5.TestApplication
import com.intellij.util.io.write
import org.hamcrest.CoreMatchers.*
import org.hamcrest.MatcherAssert.assertThat
import org.junit.jupiter.api.Assertions
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.io.TempDir
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.EnumSource
import java.nio.file.Path
import kotlin.io.path.isExecutable
import kotlin.test.assertEquals
import kotlin.test.assertNotEquals
@TestApplication
class EelLocalExecApiTest {
companion object {
const val PYTHON_ENV = "PYTHON"
}
private val helperContent = EelLocalExecApiTest::class.java.classLoader.getResource("helper.py")!!.readBytes()
// TODO: This tests depends on python interpreter. Rewrite to something linked statically
private val python = Path.of(System.getenv(PYTHON_ENV) ?: "/usr/bin/python3")
@BeforeEach
fun setUp() {
assert(python.isExecutable()) {
"Can't find python or $python isn't executable. Please set $PYTHON_ENV env var to the path to python binary"
}
}
enum class ExitType() {
KILL, TERMINATE, INTERRUPT, EXIT_WITH_COMMAND
}
/**
* Test runs `helper.py` checking stdin/stdout iteration, exit code and signal/termination handling.
*/
@ParameterizedTest
@EnumSource(ExitType::class)
fun testOutput(exitType: ExitType, @TempDir tempDir: Path): Unit = timeoutRunBlocking {
val helperScript = tempDir.resolve("helper.py")
helperScript.write(helperContent)
val builder = EelExecApi.executeProcessBuilder(python.toString()).args(listOf(helperScript.toString()))
when (val r = EelLocalExecApi().execute(builder)) {
is EelExecApi.ExecuteProcessResult.Failure -> Assertions.fail(r.message)
is EelExecApi.ExecuteProcessResult.Success -> {
val process = r.process
val welcome = process.stdout.receive().decodeToString()
// Script starts with tty:False/True, size:[tty size if any]
assertThat("Welcome string is wrong", welcome, allOf(containsString("tty"), containsString("size")))
println(welcome)
when (exitType) {
ExitType.KILL -> process.kill()
ExitType.TERMINATE -> process.terminate()
ExitType.INTERRUPT -> {
// Terminate sleep with interrupt/CTRL+C signal
process.stdin.send("sleep\n".encodeToByteArray())
assertEquals("sleeping", process.stdout.receive().decodeToString().trim())
process.interrupt()
}
ExitType.EXIT_WITH_COMMAND -> {
// Just command to ask script return gracefully
process.stdin.send("exit\n".encodeToByteArray())
}
}
val exitCode = process.exitCode.await()
when (exitType) {
ExitType.KILL -> {
assertNotEquals(0, exitCode) //Brutal kill is never 0
}
ExitType.TERMINATE -> {
if (SystemInfoRt.isWindows) {
// We provide 0 as `ExitProcess` on Windows
assertEquals(0, exitCode)
}
else {
val sigCode = UnixSignal.SIGTERM.getSignalNumber(SystemInfoRt.isMac)
assertThat("Exit code must be signal code or +128 (if run using shell)",
exitCode, anyOf(`is`(sigCode), `is`(sigCode + UnixSignal.EXIT_CODE_OFFSET)))
}
}
ExitType.INTERRUPT -> {
assertEquals(42, exitCode) // CTRL+C/SIGINT handler returns 42, see script
}
ExitType.EXIT_WITH_COMMAND -> {
assertEquals(0, exitCode) // Graceful exit
}
}
}
}
}
}

View File

@@ -0,0 +1,33 @@
# Script for EEL local execution test
# 1.prints tty and its size
# 2.waits for command exit (exit 0) or sleep (sleep 10_000)
# 3. installs signal for SIGINT to return 42
import os
import signal
import sys
from time import sleep
def exit_42(*_):
exit(42)
signal.signal(signal.SIGINT, exit_42)
is_tty = sys.stdin.isatty()
terminal_size = None
try:
terminal_size = os.get_terminal_size()
except OSError:
pass
print(f"tty:{is_tty}, size:{terminal_size}")
sys.stdout.flush()
command = input().strip()
if command == "exit":
exit(0)
elif command == "sleep":
print("sleeping")
sys.stdout.flush()
sleep(10_000)