mirror of
https://gitflic.ru/project/openide/openide.git
synced 2025-12-13 15:52:01 +07:00
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:
committed by
intellij-monorepo-bot
parent
14b44d05a6
commit
af803fcff5
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
}
|
||||
@@ -9,13 +9,9 @@ data class ExecuteProcessBuilderImpl(override val exe: String) : EelExecApi.Exec
|
||||
}
|
||||
|
||||
override var args: List<String> = listOf()
|
||||
private set
|
||||
override var env: Map<String, String> = mapOf()
|
||||
private set
|
||||
override var pty: EelExecApi.Pty? = null
|
||||
private set
|
||||
override var workingDirectory: String? = null
|
||||
private set
|
||||
|
||||
override fun toString(): String =
|
||||
"GrpcExecuteProcessBuilder(" +
|
||||
|
||||
@@ -14,5 +14,10 @@
|
||||
<orderEntry type="module" module-name="intellij.platform.extensions" />
|
||||
<orderEntry type="module" module-name="intellij.platform.util" />
|
||||
<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>
|
||||
</module>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
33
platform/platform-tests/testSrc/helper.py
Normal file
33
platform/platform-tests/testSrc/helper.py
Normal 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)
|
||||
Reference in New Issue
Block a user