diff --git a/platform/eel/src/com/intellij/platform/eel/EelProcess.kt b/platform/eel/src/com/intellij/platform/eel/EelProcess.kt index 4fd061c6307a..23f3613484e5 100644 --- a/platform/eel/src/com/intellij/platform/eel/EelProcess.kt +++ b/platform/eel/src/com/intellij/platform/eel/EelProcess.kt @@ -9,7 +9,7 @@ import kotlinx.coroutines.channels.SendChannel * Represents some process which was launched via [EelExecApi.executeProcess]. * */ -interface EelProcess { +interface EelProcess: KillableProcess { val pid: EelApi.Pid /** @@ -44,28 +44,6 @@ interface EelProcess { 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. * Note: After conversion, this [EelProcess] shouldn't be used: Use result [Process] instead diff --git a/platform/eel/src/com/intellij/platform/eel/KillableProcess.kt b/platform/eel/src/com/intellij/platform/eel/KillableProcess.kt new file mode 100644 index 000000000000..1c8838f99755 --- /dev/null +++ b/platform/eel/src/com/intellij/platform/eel/KillableProcess.kt @@ -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() + +} \ No newline at end of file diff --git a/platform/eel/src/com/intellij/platform/eel/impl/ExecuteProcessBuilderImpl.kt b/platform/eel/src/com/intellij/platform/eel/impl/ExecuteProcessBuilderImpl.kt index 4815bf793983..5e18413cd397 100644 --- a/platform/eel/src/com/intellij/platform/eel/impl/ExecuteProcessBuilderImpl.kt +++ b/platform/eel/src/com/intellij/platform/eel/impl/ExecuteProcessBuilderImpl.kt @@ -9,13 +9,9 @@ data class ExecuteProcessBuilderImpl(override val exe: String) : EelExecApi.Exec } override var args: List = listOf() - private set override var env: Map = mapOf() - private set override var pty: EelExecApi.Pty? = null - private set override var workingDirectory: String? = null - private set override fun toString(): String = "GrpcExecuteProcessBuilder(" + diff --git a/platform/eelProvider/intellij.platform.eel.provider.iml b/platform/eelProvider/intellij.platform.eel.provider.iml index 66e61dd8f53b..aadb7fde468b 100644 --- a/platform/eelProvider/intellij.platform.eel.provider.iml +++ b/platform/eelProvider/intellij.platform.eel.provider.iml @@ -14,5 +14,10 @@ + + + + + \ No newline at end of file diff --git a/platform/eelProvider/src/com/intellij/platform/eel/impl/local/ChannelStreamAdapter.kt b/platform/eelProvider/src/com/intellij/platform/eel/impl/local/ChannelStreamAdapter.kt new file mode 100644 index 000000000000..48bc9aa79dd6 --- /dev/null +++ b/platform/eelProvider/src/com/intellij/platform/eel/impl/local/ChannelStreamAdapter.kt @@ -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 = Channel()) : Channel 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 { + 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 = 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 = connect() + + override suspend fun connectAsync() { + for (bytes in channel) { + try { + outputStream.write(bytes) + outputStream.flush() + } + catch (e: IOException) { + LOG.info(e) + return + } + } + } + } +} diff --git a/platform/eelProvider/src/com/intellij/platform/eel/impl/local/EelLocalExecApi.kt b/platform/eelProvider/src/com/intellij/platform/eel/impl/local/EelLocalExecApi.kt new file mode 100644 index 000000000000..7fb41db9de51 --- /dev/null +++ b/platform/eelProvider/src/com/intellij/platform/eel/impl/local/EelLocalExecApi.kt @@ -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 = System.getenv() +} \ No newline at end of file diff --git a/platform/eelProvider/src/com/intellij/platform/eel/impl/local/LocalEelProcess.kt b/platform/eelProvider/src/com/intellij/platform/eel/impl/local/LocalEelProcess.kt new file mode 100644 index 000000000000..b201b6b4dc1e --- /dev/null +++ b/platform/eelProvider/src/com/intellij/platform/eel/impl/local/LocalEelProcess.kt @@ -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().scope() + + override val pid: EelApi.Pid = LocalPid(process.pid()) + override val stdin: SendChannel = StreamWrapper.OutputStreamWrapper(scope, process.outputStream).connectChannel() + override val stdout: ReceiveChannel = StreamWrapper.InputStreamWrapper(scope, process.inputStream).connectChannel() + override val stderr: ReceiveChannel = StreamWrapper.InputStreamWrapper(scope, process.errorStream).connectChannel() + override val exitCode: Deferred = 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") +} \ No newline at end of file diff --git a/platform/eelProvider/src/com/intellij/platform/eel/impl/local/LocalPid.kt b/platform/eelProvider/src/com/intellij/platform/eel/impl/local/LocalPid.kt new file mode 100644 index 000000000000..ef9f1ae50810 --- /dev/null +++ b/platform/eelProvider/src/com/intellij/platform/eel/impl/local/LocalPid.kt @@ -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 \ No newline at end of file diff --git a/platform/eelProvider/src/com/intellij/platform/eel/impl/local/package-info.java b/platform/eelProvider/src/com/intellij/platform/eel/impl/local/package-info.java new file mode 100644 index 000000000000..e1e07e9fd6a1 --- /dev/null +++ b/platform/eelProvider/src/com/intellij/platform/eel/impl/local/package-info.java @@ -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; \ No newline at end of file diff --git a/platform/eelProvider/src/com/intellij/platform/eel/impl/local/processKiller/PosixProcessKiller.kt b/platform/eelProvider/src/com/intellij/platform/eel/impl/local/processKiller/PosixProcessKiller.kt new file mode 100644 index 000000000000..45baa1f11255 --- /dev/null +++ b/platform/eelProvider/src/com/intellij/platform/eel/impl/local/processKiller/PosixProcessKiller.kt @@ -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) + } +} \ No newline at end of file diff --git a/platform/eelProvider/src/com/intellij/platform/eel/impl/local/processKiller/WinProcessKiller.kt b/platform/eelProvider/src/com/intellij/platform/eel/impl/local/processKiller/WinProcessKiller.kt new file mode 100644 index 000000000000..79387745c7ed --- /dev/null +++ b/platform/eelProvider/src/com/intellij/platform/eel/impl/local/processKiller/WinProcessKiller.kt @@ -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() + } +} \ No newline at end of file diff --git a/platform/platform-tests/testSrc/com/intellij/execution/eel/EelLocalExecApiTest.kt b/platform/platform-tests/testSrc/com/intellij/execution/eel/EelLocalExecApiTest.kt new file mode 100644 index 000000000000..5364c9562cbe --- /dev/null +++ b/platform/platform-tests/testSrc/com/intellij/execution/eel/EelLocalExecApiTest.kt @@ -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 + } + } + } + } + } +} \ No newline at end of file diff --git a/platform/platform-tests/testSrc/helper.py b/platform/platform-tests/testSrc/helper.py new file mode 100644 index 000000000000..8ac6d1a2529e --- /dev/null +++ b/platform/platform-tests/testSrc/helper.py @@ -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)