diff --git a/chronos/asyncproc.nim b/chronos/asyncproc.nim index 8d0cdb7..8df8e33 100644 --- a/chronos/asyncproc.nim +++ b/chronos/asyncproc.nim @@ -24,7 +24,8 @@ const ## AsyncProcess leaks tracker name type - AsyncProcessError* = object of CatchableError + AsyncProcessError* = object of AsyncError + AsyncProcessTimeoutError* = object of AsyncProcessError AsyncProcessResult*[T] = Result[T, OSErrorCode] @@ -107,6 +108,9 @@ type stdError*: string status*: int + WaitOperation {.pure.} = enum + Kill, Terminate + template Pipe*(t: typedesc[AsyncProcess]): ProcessStreamHandle = ProcessStreamHandle(kind: ProcessStreamHandleKind.Auto) @@ -294,6 +298,11 @@ proc raiseAsyncProcessError(msg: string, exc: ref CatchableError = nil) {. msg & " ([" & $exc.name & "]: " & $exc.msg & ")" raise newException(AsyncProcessError, message) +proc raiseAsyncProcessTimeoutError() {. + noreturn, noinit, noinline, raises: [AsyncProcessTimeoutError].} = + let message = "Operation timed out" + raise newException(AsyncProcessTimeoutError, message) + proc raiseAsyncProcessError(msg: string, error: OSErrorCode|cint) {. noreturn, noinit, noinline, raises: [AsyncProcessError].} = when error is OSErrorCode: @@ -1189,6 +1198,45 @@ proc closeProcessStreams(pipes: AsyncProcessPipes, res allFutures(pending) +proc opAndWaitForExit(p: AsyncProcessRef, op: WaitOperation, + timeout = InfiniteDuration): Future[int] {.async.} = + let timerFut = + if timeout == InfiniteDuration: + newFuture[void]("chronos.killAndwaitForExit") + else: + sleepAsync(timeout) + + while true: + if p.running().get(true): + # We ignore operation errors because we going to repeat calling + # operation until process will not exit. + case op + of WaitOperation.Kill: + discard p.kill() + of WaitOperation.Terminate: + discard p.terminate() + else: + let exitCode = p.peekExitCode().valueOr: + raiseAsyncProcessError("Unable to peek process exit code", error) + if not(timerFut.finished()): + await cancelAndWait(timerFut) + return exitCode + + let waitFut = p.waitForExit().wait(100.milliseconds) + discard await race(FutureBase(waitFut), FutureBase(timerFut)) + + if waitFut.finished() and not(waitFut.failed()): + let res = p.peekExitCode() + if res.isOk(): + if not(timerFut.finished()): + await cancelAndWait(timerFut) + return res.get() + + if timerFut.finished(): + if not(waitFut.finished()): + await waitFut.cancelAndWait() + raiseAsyncProcessTimeoutError() + proc closeWait*(p: AsyncProcessRef) {.async.} = # Here we ignore all possible errrors, because we do not want to raise # exceptions. @@ -1216,14 +1264,15 @@ proc execCommand*(command: string, options = {AsyncProcessOption.EvalCommand}, timeout = InfiniteDuration ): Future[int] {.async.} = - let poptions = options + {AsyncProcessOption.EvalCommand} - let process = await startProcess(command, options = poptions) - let res = - try: - await process.waitForExit(timeout) - finally: - await process.closeWait() - return res + let + poptions = options + {AsyncProcessOption.EvalCommand} + process = await startProcess(command, options = poptions) + res = + try: + await process.waitForExit(timeout) + finally: + await process.closeWait() + res proc execCommandEx*(command: string, options = {AsyncProcessOption.EvalCommand}, @@ -1256,10 +1305,43 @@ proc execCommandEx*(command: string, finally: await process.closeWait() - return res + res proc pid*(p: AsyncProcessRef): int = ## Returns process ``p`` identifier. int(p.processId) template processId*(p: AsyncProcessRef): int = pid(p) + +proc killAndWaitForExit*(p: AsyncProcessRef, + timeout = InfiniteDuration): Future[int] = + ## Perform continuous attempts to kill the ``p`` process for specified period + ## of time ``timeout``. + ## + ## On Posix systems, killing means sending ``SIGKILL`` to the process ``p``, + ## On Windows, it uses ``TerminateProcess`` to kill the process ``p``. + ## + ## If the process ``p`` fails to be killed within the ``timeout`` time, it + ## will raise ``AsyncProcessTimeoutError``. + ## + ## In case of error this it will raise ``AsyncProcessError``. + ## + ## Returns process ``p`` exit code. + opAndWaitForExit(p, WaitOperation.Kill, timeout) + +proc terminateAndWaitForExit*(p: AsyncProcessRef, + timeout = InfiniteDuration): Future[int] = + ## Perform continuous attempts to terminate the ``p`` process for specified + ## period of time ``timeout``. + ## + ## On Posix systems, terminating means sending ``SIGTERM`` to the process + ## ``p``, on Windows, it uses ``TerminateProcess`` to terminate the process + ## ``p``. + ## + ## If the process ``p`` fails to be terminated within the ``timeout`` time, it + ## will raise ``AsyncProcessTimeoutError``. + ## + ## In case of error this it will raise ``AsyncProcessError``. + ## + ## Returns process ``p`` exit code. + opAndWaitForExit(p, WaitOperation.Terminate, timeout) diff --git a/tests/testproc.bat b/tests/testproc.bat index 314bea7..11b4047 100644 --- a/tests/testproc.bat +++ b/tests/testproc.bat @@ -2,6 +2,8 @@ IF /I "%1" == "STDIN" ( GOTO :STDINTEST +) ELSE IF /I "%1" == "TIMEOUT1" ( + GOTO :TIMEOUTTEST1 ) ELSE IF /I "%1" == "TIMEOUT2" ( GOTO :TIMEOUTTEST2 ) ELSE IF /I "%1" == "TIMEOUT10" ( @@ -19,6 +21,10 @@ SET /P "INPUTDATA=" ECHO STDIN DATA: %INPUTDATA% EXIT 0 +:TIMEOUTTEST1 +ping -n 1 127.0.0.1 > NUL +EXIT 1 + :TIMEOUTTEST2 ping -n 2 127.0.0.1 > NUL EXIT 2 diff --git a/tests/testproc.nim b/tests/testproc.nim index b038325..cfcafe6 100644 --- a/tests/testproc.nim +++ b/tests/testproc.nim @@ -96,7 +96,11 @@ suite "Asynchronous process management test suite": let options = {AsyncProcessOption.EvalCommand} - command = "exit 1" + command = + when defined(windows): + "tests\\testproc.bat timeout1" + else: + "tests/testproc.sh timeout1" process = await startProcess(command, options = options) @@ -407,6 +411,52 @@ suite "Asynchronous process management test suite": finally: await process.closeWait() + asyncTest "killAndWaitForExit() test": + let command = + when defined(windows): + ("tests\\testproc.bat", "timeout10", 0) + else: + ("tests/testproc.sh", "timeout10", 128 + int(SIGKILL)) + let process = await startProcess(command[0], arguments = @[command[1]]) + try: + let exitCode = await process.killAndWaitForExit(10.seconds) + check exitCode == command[2] + finally: + await process.closeWait() + + asyncTest "terminateAndWaitForExit() test": + let command = + when defined(windows): + ("tests\\testproc.bat", "timeout10", 0) + else: + ("tests/testproc.sh", "timeout10", 128 + int(SIGTERM)) + let process = await startProcess(command[0], arguments = @[command[1]]) + try: + let exitCode = await process.terminateAndWaitForExit(10.seconds) + check exitCode == command[2] + finally: + await process.closeWait() + + asyncTest "terminateAndWaitForExit() timeout test": + when defined(windows): + skip() + else: + let + command = ("tests/testproc.sh", "noterm", 128 + int(SIGKILL)) + process = await startProcess(command[0], arguments = @[command[1]]) + # We should wait here to allow `bash` execute `trap` command, otherwise + # our test script will be killed with SIGTERM. Increase this timeout + # if test become flaky. + await sleepAsync(1.seconds) + try: + expect AsyncProcessTimeoutError: + let exitCode {.used.} = + await process.terminateAndWaitForExit(1.seconds) + let exitCode = await process.killAndWaitForExit(10.seconds) + check exitCode == command[2] + finally: + await process.closeWait() + test "File descriptors leaks test": when defined(windows): skip() diff --git a/tests/testproc.sh b/tests/testproc.sh index 1725d49..c5e7e0a 100755 --- a/tests/testproc.sh +++ b/tests/testproc.sh @@ -3,6 +3,9 @@ if [ "$1" == "stdin" ]; then read -r inputdata echo "STDIN DATA: $inputdata" +elif [ "$1" == "timeout1" ]; then + sleep 1 + exit 1 elif [ "$1" == "timeout2" ]; then sleep 2 exit 2 @@ -15,6 +18,11 @@ elif [ "$1" == "bigdata" ]; then done elif [ "$1" == "envtest" ]; then echo "$CHRONOSASYNC" +elif [ "$1" == "noterm" ]; then + trap -- '' SIGTERM + while true; do + sleep 1 + done else echo "arguments missing" fi