diff --git a/Readme.adoc b/Readme.adoc index 77682550..33d909e6 100644 --- a/Readme.adoc +++ b/Readme.adoc @@ -1725,7 +1725,9 @@ os.call(cmd: os.Shellable, mergeErrIntoOut: Boolean = false, timeout: Long = Long.MaxValue, check: Boolean = true, - propagateEnv: Boolean = true): os.CommandResult + propagateEnv: Boolean = true, + shutdownGracePeriod: Long = 100, + destroyOnExit: Boolean = true): os.CommandResult ---- _Also callable via `os.proc(cmd).call(...)`_ @@ -1853,7 +1855,9 @@ os.spawn(cmd: os.Shellable, stdout: os.ProcessOutput = os.Pipe, stderr: os.ProcessOutput = os.Pipe, mergeErrIntoOut: Boolean = false, - propagateEnv: Boolean = true): os.SubProcess + propagateEnv: Boolean = true, + shutdownGracePeriod: Long = 100, + destroyOnExit: Boolean = true): os.SubProcess ---- _Also callable via `os.proc(cmd).spawn(...)`_ diff --git a/build.sc b/build.sc index cdbb3221..734c2416 100644 --- a/build.sc +++ b/build.sc @@ -171,13 +171,21 @@ object os extends Module { def forkEnv = super.forkEnv() ++ Map( "TEST_JAR_WRITER_ASSEMBLY" -> testJarWriter.assembly().path.toString, "TEST_JAR_READER_ASSEMBLY" -> testJarReader.assembly().path.toString, - "TEST_JAR_EXIT_ASSEMBLY" -> testJarExit.assembly().path.toString + "TEST_JAR_EXIT_ASSEMBLY" -> testJarExit.assembly().path.toString, + "TEST_SPAWN_EXIT_HOOK_ASSEMBLY" -> testSpawnExitHook.assembly().path.toString, + "TEST_SPAWN_EXIT_HOOK_ASSEMBLY2" -> testSpawnExitHook2.assembly().path.toString ) object testJarWriter extends JavaModule object testJarReader extends JavaModule object testJarExit extends JavaModule + object testSpawnExitHook extends ScalaModule{ + def scalaVersion = OsJvmModule.this.scalaVersion() + def moduleDeps = Seq(OsJvmModule.this) + } + object testSpawnExitHook2 extends JavaModule } + object nohometest extends ScalaTests with OsLibTestModule } diff --git a/os/src/ProcessOps.scala b/os/src/ProcessOps.scala index 267a67e0..6175e87c 100644 --- a/os/src/ProcessOps.scala +++ b/os/src/ProcessOps.scala @@ -1,14 +1,11 @@ package os -import java.util.concurrent.{ArrayBlockingQueue, Semaphore, TimeUnit} import collection.JavaConverters._ -import scala.annotation.tailrec import java.lang.ProcessBuilder.Redirect import os.SubProcess.InputStream import java.io.IOException import java.util.concurrent.LinkedBlockingQueue import ProcessOps._ -import scala.util.Try object call { @@ -28,7 +25,8 @@ object call { timeout: Long = -1, check: Boolean = true, propagateEnv: Boolean = true, - timeoutGracePeriod: Long = 100 + shutdownGracePeriod: Long = 100, + destroyOnExit: Boolean = true ): CommandResult = { os.proc(cmd).call( cwd = cwd, @@ -40,7 +38,40 @@ object call { timeout = timeout, check = check, propagateEnv = propagateEnv, - timeoutGracePeriod = timeoutGracePeriod + shutdownGracePeriod = shutdownGracePeriod, + destroyOnExit = destroyOnExit + ) + } + + // Bincompat Forwarder + def apply( + cmd: Shellable, + env: Map[String, String], + // Make sure `cwd` only comes after `env`, so `os.call("foo", path)` is a compile error + // since the correct syntax is `os.call(("foo", path))` + cwd: Path, + stdin: ProcessInput, + stdout: ProcessOutput, + stderr: ProcessOutput, + mergeErrIntoOut: Boolean, + timeout: Long, + check: Boolean, + propagateEnv: Boolean, + timeoutGracePeriod: Long + ): CommandResult = { + call( + cmd = cmd, + cwd = cwd, + env = env, + stdin = stdin, + stdout = stdout, + stderr = stderr, + mergeErrIntoOut = mergeErrIntoOut, + timeout = timeout, + check = check, + propagateEnv = propagateEnv, + shutdownGracePeriod = timeoutGracePeriod, + destroyOnExit = false ) } } @@ -59,7 +90,9 @@ object spawn { stdout: ProcessOutput = Pipe, stderr: ProcessOutput = os.Inherit, mergeErrIntoOut: Boolean = false, - propagateEnv: Boolean = true + propagateEnv: Boolean = true, + shutdownGracePeriod: Long = 100, + destroyOnExit: Boolean = true ): SubProcess = { os.proc(cmd).spawn( cwd = cwd, @@ -68,7 +101,36 @@ object spawn { stdout = stdout, stderr = stderr, mergeErrIntoOut = mergeErrIntoOut, - propagateEnv = propagateEnv + propagateEnv = propagateEnv, + shutdownGracePeriod = shutdownGracePeriod, + destroyOnExit = destroyOnExit + ) + } + + // Bincompat Forwarder + def apply( + cmd: Shellable, + // Make sure `cwd` only comes after `env`, so `os.spawn("foo", path)` is a compile error + // since the correct syntax is `os.spawn(("foo", path))` + env: Map[String, String], + cwd: Path, + stdin: ProcessInput, + stdout: ProcessOutput, + stderr: ProcessOutput, + mergeErrIntoOut: Boolean, + propagateEnv: Boolean + ): SubProcess = { + spawn( + cmd = cmd, + cwd = cwd, + env = env, + stdin = stdin, + stdout = stdout, + stderr = stderr, + mergeErrIntoOut = mergeErrIntoOut, + propagateEnv = propagateEnv, + shutdownGracePeriod = 100, + destroyOnExit = false ) } } @@ -119,7 +181,7 @@ case class proc(command: Shellable*) { * fails with a non-zero exit code * @param propagateEnv disable this to avoid passing in this parent process's * environment variables to the subprocess - * @param timeoutGracePeriod if the timeout is enabled, how long in milliseconds for the + * @param shutdownGracePeriod if the timeout is enabled, how long in milliseconds for the * subprocess to gracefully terminate before attempting to * forcibly kill it * (-1 for no kill, 0 for always kill immediately) @@ -138,7 +200,8 @@ case class proc(command: Shellable*) { check: Boolean = true, propagateEnv: Boolean = true, // this cannot be next to `timeout` as this will introduce a bin-compat break (default arguments are numbered in the bytecode) - timeoutGracePeriod: Long = 100 + shutdownGracePeriod: Long = 100, + destroyOnExit: Boolean = true ): CommandResult = { val chunks = new java.util.concurrent.ConcurrentLinkedQueue[Either[geny.Bytes, geny.Bytes]] @@ -159,7 +222,7 @@ case class proc(command: Shellable*) { propagateEnv ) - sub.join(timeout, timeoutGracePeriod) + sub.join(timeout, shutdownGracePeriod) val chunksSeq = chunks.iterator.asScala.toIndexedSeq val res = CommandResult(commandChunks, sub.exitCode(), chunksSeq) @@ -188,7 +251,33 @@ case class proc(command: Shellable*) { timeout, check, propagateEnv, - timeoutGracePeriod = 100 + shutdownGracePeriod = 100 + ) + + // Bincompat Forwarder + private[os] def call( + cwd: Path, + env: Map[String, String], + stdin: ProcessInput, + stdout: ProcessOutput, + stderr: ProcessOutput, + mergeErrIntoOut: Boolean, + timeout: Long, + check: Boolean, + propagateEnv: Boolean, + timeoutGracePeriod: Long + ): CommandResult = call( + cwd, + env, + stdin, + stdout, + stderr, + mergeErrIntoOut, + timeout, + check, + propagateEnv, + timeoutGracePeriod, + destroyOnExit = false ) /** @@ -208,7 +297,9 @@ case class proc(command: Shellable*) { stdout: ProcessOutput = Pipe, stderr: ProcessOutput = os.Inherit, mergeErrIntoOut: Boolean = false, - propagateEnv: Boolean = true + propagateEnv: Boolean = true, + shutdownGracePeriod: Long = 100, + destroyOnExit: Boolean = true ): SubProcess = { val cmdChunks = commandChunks @@ -230,19 +321,62 @@ case class proc(command: Shellable*) { propagateEnv ) + lazy val shutdownHookThread = + if (!destroyOnExit) None + else Some(new Thread("subprocess-shutdown-hook") { + override def run(): Unit = proc.destroy(shutdownGracePeriod) + }) + + lazy val shutdownHookMonitorThread = shutdownHookThread.map(t => + new Thread("subprocess-shutdown-hook-monitor") { + override def run(): Unit = { + while (proc.wrapped.isAlive) Thread.sleep(1) + try Runtime.getRuntime().removeShutdownHook(t) + catch { case e: Throwable => /*do nothing*/ } + } + } + ) + + shutdownHookThread.foreach(Runtime.getRuntime().addShutdownHook) + lazy val proc: SubProcess = new SubProcess( builder.start(), resolvedStdin.processInput(proc.stdin).map(new Thread(_, commandStr + " stdin thread")), resolvedStdout.processOutput(proc.stdout).map(new Thread(_, commandStr + " stdout thread")), - resolvedStderr.processOutput(proc.stderr).map(new Thread(_, commandStr + " stderr thread")) + resolvedStderr.processOutput(proc.stderr).map(new Thread(_, commandStr + " stderr thread")), + shutdownGracePeriod = shutdownGracePeriod, + shutdownHookMonitorThread = shutdownHookMonitorThread ) + shutdownHookMonitorThread.foreach(_.start()) + proc.inputPumperThread.foreach(_.start()) proc.outputPumperThread.foreach(_.start()) proc.errorPumperThread.foreach(_.start()) proc } + // Bincompat Forwarder + def spawn( + cwd: Path, + env: Map[String, String], + stdin: ProcessInput, + stdout: ProcessOutput, + stderr: ProcessOutput, + mergeErrIntoOut: Boolean, + propagateEnv: Boolean + ): SubProcess = spawn( + cwd = cwd, + env = env, + stdin = stdin, + stdout = stdout, + stderr = stderr, + mergeErrIntoOut = mergeErrIntoOut, + propagateEnv = propagateEnv, + shutdownGracePeriod = 100, + destroyOnExit = false + ) + /** * Pipes the output of this process into the input of the [[next]] process. Returns a * [[ProcGroup]] containing both processes, which you can then either execute or @@ -295,7 +429,7 @@ case class ProcGroup private[os] (commands: Seq[proc]) { * will be caught and handled by killing the writing process. This behaviour * is consistent with handlers of SIGPIPE signals in most programs * supporting interruptable piping. Disabled by default on Windows. - * @param timeoutGracePeriod if the timeout is enabled, how long in milliseconds for the + * @param shutdownGracePeriod if the timeout is enabled, how long in milliseconds for the * subprocess to gracefully terminate before attempting to * forcibly kill it * (-1 for no kill, 0 for always kill immediately) @@ -316,7 +450,7 @@ case class ProcGroup private[os] (commands: Seq[proc]) { pipefail: Boolean = true, handleBrokenPipe: Boolean = !isWindows, // this cannot be next to `timeout` as this will introduce a bin-compat break (default arguments are numbered in the bytecode) - timeoutGracePeriod: Long = 100 + shutdownGracePeriod: Long = 100 ): CommandResult = { val chunks = new java.util.concurrent.ConcurrentLinkedQueue[Either[geny.Bytes, geny.Bytes]] @@ -337,7 +471,7 @@ case class ProcGroup private[os] (commands: Seq[proc]) { pipefail ) - sub.join(timeout, timeoutGracePeriod) + sub.join(timeout, shutdownGracePeriod) val chunksSeq = chunks.iterator.asScala.toIndexedSeq val res = @@ -370,7 +504,7 @@ case class ProcGroup private[os] (commands: Seq[proc]) { propagateEnv, pipefail, handleBrokenPipe, - timeoutGracePeriod = 100 + shutdownGracePeriod = 100 ) /** diff --git a/os/src/SubProcess.scala b/os/src/SubProcess.scala index 120ab3f2..9f624819 100644 --- a/os/src/SubProcess.scala +++ b/os/src/SubProcess.scala @@ -106,8 +106,23 @@ class SubProcess( val wrapped: java.lang.Process, val inputPumperThread: Option[Thread], val outputPumperThread: Option[Thread], - val errorPumperThread: Option[Thread] + val errorPumperThread: Option[Thread], + val shutdownGracePeriod: Long, + val shutdownHookMonitorThread: Option[Thread] ) extends ProcessLike { + def this( + wrapped: java.lang.Process, + inputPumperThread: Option[Thread], + outputPumperThread: Option[Thread], + errorPumperThread: Option[Thread] + ) = this( + wrapped, + inputPumperThread, + outputPumperThread, + errorPumperThread, + 100, + None + ) val stdin: SubProcess.InputStream = new SubProcess.InputStream(wrapped.getOutputStream) val stdout: SubProcess.OutputStream = new SubProcess.OutputStream(wrapped.getInputStream) val stderr: SubProcess.OutputStream = new SubProcess.OutputStream(wrapped.getErrorStream) @@ -128,12 +143,42 @@ class SubProcess( /** * Attempt to destroy the subprocess (gently), via the underlying JVM APIs */ - def destroy(): Unit = wrapped.destroy() + def destroy(): Unit = destroy(shutdownGracePeriod = this.shutdownGracePeriod, async = false) /** - * Force-destroys the subprocess, via the underlying JVM APIs + * Destroys the subprocess, via the underlying JVM APIs, with configurable levels of + * aggressiveness: + * + * @param async set this to `true` if you do not want to wait on the subprocess exiting + * @param shutdownGracePeriod use this to override the default wait time for the subprocess + * to gracefully exit before destroying it forcibly. Defaults to the `shutdownGracePeriod` + * that was used to spawned the process, but can be set to 0 + * (i.e. force exit immediately) or -1 (i.e. never force exit) + * or anything in between. Typically defaults to 100 milliseconds. */ - def destroyForcibly(): Unit = wrapped.destroyForcibly() + def destroy( + shutdownGracePeriod: Long = this.shutdownGracePeriod, + async: Boolean = false + ): Unit = { + wrapped.destroy() + if (!async) { + val now = System.currentTimeMillis() + + while ( + wrapped.isAlive && (shutdownGracePeriod == -1 || System.currentTimeMillis() - now < shutdownGracePeriod) + ) { + Thread.sleep(1) + } + + if (wrapped.isAlive) { + println("wrapped.destroyForcibly()") + wrapped.destroyForcibly() + } + } + } + + @deprecated("Use destroy(shutdownGracePeriod = 0)") + def destroyForcibly(): Unit = destroy(shutdownGracePeriod = 0) /** * Alias for [[destroy]] diff --git a/os/test/src-jvm/SpawningSubprocessesNewTests.scala b/os/test/src-jvm/SpawningSubprocessesNewTests.scala index b753d1e6..f11a241e 100644 --- a/os/test/src-jvm/SpawningSubprocessesNewTests.scala +++ b/os/test/src-jvm/SpawningSubprocessesNewTests.scala @@ -4,170 +4,257 @@ import java.io.{BufferedReader, InputStreamReader} import os.ProcessOutput import scala.collection.mutable - import test.os.TestUtil.prep import utest._ +import java.nio.channels.FileChannel +import java.nio.file.StandardOpenOption +import java.util + object SpawningSubprocessesNewTests extends TestSuite { def tests = Tests { - test("proc") { - test("call") { - test - prep { wd => - if (Unix()) { - val res = os.call(cmd = ("ls", wd / "folder2")) + test("call") { + test - prep { wd => + if (Unix()) { + val res = os.call(cmd = ("ls", wd / "folder2")) - res.exitCode ==> 0 + res.exitCode ==> 0 - res.out.text() ==> - """nestedA - |nestedB - |""".stripMargin + res.out.text() ==> + """nestedA + |nestedB + |""".stripMargin - res.out.trim() ==> - """nestedA - |nestedB""".stripMargin + res.out.trim() ==> + """nestedA + |nestedB""".stripMargin - res.out.lines() ==> Seq( - "nestedA", - "nestedB" - ) + res.out.lines() ==> Seq( + "nestedA", + "nestedB" + ) - res.out.bytes + res.out.bytes - val thrown = intercept[os.SubprocessException] { - os.call(cmd = ("ls", "doesnt-exist"), cwd = wd) - } + val thrown = intercept[os.SubprocessException] { + os.call(cmd = ("ls", "doesnt-exist"), cwd = wd) + } - assert(thrown.result.exitCode != 0) + assert(thrown.result.exitCode != 0) - val fail = - os.call(cmd = ("ls", "doesnt-exist"), cwd = wd, check = false, stderr = os.Pipe) + val fail = + os.call(cmd = ("ls", "doesnt-exist"), cwd = wd, check = false, stderr = os.Pipe) - assert(fail.exitCode != 0) + assert(fail.exitCode != 0) - fail.out.text() ==> "" + fail.out.text() ==> "" - assert(fail.err.text().contains("No such file or directory")) + assert(fail.err.text().contains("No such file or directory")) - // You can pass in data to a subprocess' stdin - val hash = os.call(cmd = ("shasum", "-a", "256"), stdin = "Hello World") - hash.out.trim() ==> "a591a6d40bf420404a011733cfb7b190d62c65bf0bcda32b57b277d9ad9f146e -" + // You can pass in data to a subprocess' stdin + val hash = os.call(cmd = ("shasum", "-a", "256"), stdin = "Hello World") + hash.out.trim() ==> "a591a6d40bf420404a011733cfb7b190d62c65bf0bcda32b57b277d9ad9f146e -" - // Taking input from a file and directing output to another file - os.call(cmd = ("base64"), stdin = wd / "File.txt", stdout = wd / "File.txt.b64") + // Taking input from a file and directing output to another file + os.call(cmd = ("base64"), stdin = wd / "File.txt", stdout = wd / "File.txt.b64") - os.read(wd / "File.txt.b64") ==> "SSBhbSBjb3c=\n" + os.read(wd / "File.txt.b64") ==> "SSBhbSBjb3c=\n" - if (false) { - os.call(cmd = ("vim"), stdin = os.Inherit, stdout = os.Inherit, stderr = os.Inherit) - } + if (false) { + os.call(cmd = ("vim"), stdin = os.Inherit, stdout = os.Inherit, stderr = os.Inherit) } } - test - prep { wd => - if (Unix()) { - val ex = intercept[os.SubprocessException] { - os.call(cmd = ("bash", "-c", "echo 123; sleep 10; echo 456"), timeout = 2000) - } - - ex.result.out.trim() ==> "123" + } + test - prep { wd => + if (Unix()) { + val ex = intercept[os.SubprocessException] { + os.call(cmd = ("bash", "-c", "echo 123; sleep 10; echo 456"), timeout = 2000) } + + ex.result.out.trim() ==> "123" } } - test("stream") { - test - prep { wd => - if (Unix()) { - var lineCount = 1 - os.call( - cmd = ("find", "."), - cwd = wd, - stdout = - os.ProcessOutput((buf, len) => lineCount += buf.slice(0, len).count(_ == '\n')) - ) - lineCount ==> 22 - } + } + test("stream") { + test - prep { wd => + if (Unix()) { + var lineCount = 1 + os.call( + cmd = ("find", "."), + cwd = wd, + stdout = + os.ProcessOutput((buf, len) => lineCount += buf.slice(0, len).count(_ == '\n')) + ) + lineCount ==> 22 } - test - prep { wd => - if (Unix()) { - var lineCount = 1 - os.call( - cmd = ("find", "."), - cwd = wd, - stdout = os.ProcessOutput.Readlines(line => lineCount += 1) - ) - lineCount ==> 22 - } + } + test - prep { wd => + if (Unix()) { + var lineCount = 1 + os.call( + cmd = ("find", "."), + cwd = wd, + stdout = os.ProcessOutput.Readlines(line => lineCount += 1) + ) + lineCount ==> 22 } } + } - test("spawn python") { - test - prep { wd => - if (TestUtil.isInstalled("python") && Unix()) { - // Start a long-lived python process which you can communicate with - val sub = os.spawn( - cmd = ( - "python", - "-u", - "-c", - if (TestUtil.isPython3()) "while True: print(eval(input()))" - else "while True: print(eval(raw_input()))" - ), - cwd = wd - ) - - // Sending some text to the subprocess - sub.stdin.write("1 + 2") - sub.stdin.writeLine("+ 4") - sub.stdin.flush() - sub.stdout.readLine() ==> "7" - - sub.stdin.write("'1' + '2'") - sub.stdin.writeLine("+ '4'") - sub.stdin.flush() - sub.stdout.readLine() ==> "124" - - // Sending some bytes to the subprocess - sub.stdin.write("1 * 2".getBytes) - sub.stdin.write("* 4\n".getBytes) - sub.stdin.flush() - sub.stdout.read() ==> '8'.toByte - - sub.destroy() - } + test("spawn python") { + test - prep { wd => + if (TestUtil.isInstalled("python") && Unix()) { + // Start a long-lived python process which you can communicate with + val sub = os.spawn( + cmd = ( + "python", + "-u", + "-c", + if (TestUtil.isPython3()) "while True: print(eval(input()))" + else "while True: print(eval(raw_input()))" + ), + cwd = wd + ) + + // Sending some text to the subprocess + sub.stdin.write("1 + 2") + sub.stdin.writeLine("+ 4") + sub.stdin.flush() + sub.stdout.readLine() ==> "7" + + sub.stdin.write("'1' + '2'") + sub.stdin.writeLine("+ '4'") + sub.stdin.flush() + sub.stdout.readLine() ==> "124" + + // Sending some bytes to the subprocess + sub.stdin.write("1 * 2".getBytes) + sub.stdin.write("* 4\n".getBytes) + sub.stdin.flush() + sub.stdout.read() ==> '8'.toByte + + sub.destroy() } } - test("spawn curl") { - if ( - Unix() && // shasum seems to not accept stdin on Windows - TestUtil.isInstalled("curl") && - TestUtil.isInstalled("gzip") && - TestUtil.isInstalled("shasum") - ) { - // You can chain multiple subprocess' stdin/stdout together - val curl = - os.spawn(cmd = ("curl", "-L", ExampleResourcess.RemoteReadme.url), stderr = os.Inherit) - val gzip = os.spawn(cmd = ("gzip", "-n", "-6"), stdin = curl.stdout) - val sha = os.spawn(cmd = ("shasum", "-a", "256"), stdin = gzip.stdout) - sha.stdout.trim() ==> s"${ExampleResourcess.RemoteReadme.gzip6ShaSum256} -" + } + test("spawn curl") { + if ( + Unix() && // shasum seems to not accept stdin on Windows + TestUtil.isInstalled("curl") && + TestUtil.isInstalled("gzip") && + TestUtil.isInstalled("shasum") + ) { + // You can chain multiple subprocess' stdin/stdout together + val curl = + os.spawn(cmd = ("curl", "-L", ExampleResourcess.RemoteReadme.url), stderr = os.Inherit) + val gzip = os.spawn(cmd = ("gzip", "-n", "-6"), stdin = curl.stdout) + val sha = os.spawn(cmd = ("shasum", "-a", "256"), stdin = gzip.stdout) + sha.stdout.trim() ==> s"${ExampleResourcess.RemoteReadme.gzip6ShaSum256} -" + } + } + test("spawn callback") - prep { wd => + if (TestUtil.isInstalled("echo") && Unix()) { + val output: mutable.Buffer[String] = mutable.Buffer() + val sub = os.spawn( + cmd = ("echo", "output"), + stdout = ProcessOutput((bytes, count) => output += new String(bytes, 0, count)) + ) + val finished = sub.join(5000) + sub.wrapped.getOutputStream().flush() + assert(finished) + assert(sub.exitCode() == 0) + val expectedOutput = "output\n" + val actualOutput = output.mkString("") + assert(actualOutput == expectedOutput) + sub.destroy() + } + } + def tryLock(p: os.Path) = FileChannel + .open(p.toNIO, util.EnumSet.of(StandardOpenOption.READ, StandardOpenOption.WRITE)) + .tryLock() + def waitForLockTaken(p: os.Path) = { + while ({ + val waitLock = tryLock(p) + if (waitLock != null) { + waitLock.release() + true + } else false + }) Thread.sleep(1) + } + + test("destroy") { + if (Unix()) { + val temp1 = os.temp() + val sub1 = os.spawn((sys.env("TEST_SPAWN_EXIT_HOOK_ASSEMBLY"), temp1)) + waitForLockTaken(temp1) + sub1.destroy() + assert(!sub1.isAlive()) + + val temp2 = os.temp() + val sub2 = os.spawn((sys.env("TEST_SPAWN_EXIT_HOOK_ASSEMBLY"), temp2)) + waitForLockTaken(temp2) + sub2.destroy(async = true) + assert(sub2.isAlive()) + } + } + + test("spawnExitHook") { + test("destroyDefaultGrace") { + if (Unix()) { + val temp = os.temp() + val lock0 = tryLock(temp) + // file starts off not locked so can be taken and released + assert(lock0 != null) + lock0.release() + + val subprocess = os.spawn((sys.env("TEST_SPAWN_EXIT_HOOK_ASSEMBLY"), temp)) + waitForLockTaken(temp) + + subprocess.destroy() + // after calling destroy on the subprocess, the transitive subprocess + // should be killed by the exit hook, so the lock can now be taken + val lock = tryLock(temp) + assert(lock != null) + lock.release() } } - test("spawn callback") { - test - prep { wd => - if (TestUtil.isInstalled("echo") && Unix()) { - val output: mutable.Buffer[String] = mutable.Buffer() - val sub = os.spawn( - cmd = ("echo", "output"), - stdout = ProcessOutput((bytes, count) => output += new String(bytes, 0, count)) - ) - val finished = sub.join(5000) - sub.wrapped.getOutputStream().flush() - assert(finished) - assert(sub.exitCode() == 0) - val expectedOutput = "output\n" - val actualOutput = output.mkString("") - assert(actualOutput == expectedOutput) - sub.destroy() - } + + test("destroyNoGrace") { + if (Unix()) { + val temp = os.temp() + val subprocess = os.spawn((sys.env("TEST_SPAWN_EXIT_HOOK_ASSEMBLY"), temp)) + waitForLockTaken(temp) + + subprocess.destroy(shutdownGracePeriod = 0) + // this should fail since the subprocess is shut down forcibly without grace period + // so there is no time for any exit hooks to run to shut down the transitive subprocess + val lock = tryLock(temp) + assert(lock == null) + } + } + + test("infiniteGrace") { + if (Unix()) { + val temp = os.temp() + val lock0 = tryLock(temp) + // file starts off not locked so can be taken and released + assert(lock0 != null) + lock0.release() + + // Force the subprocess exit to stall for 500ms + val subprocess = os.spawn((sys.env("TEST_SPAWN_EXIT_HOOK_ASSEMBLY"), temp, 500)) + waitForLockTaken(temp) + + val start = System.currentTimeMillis() + subprocess.destroy(shutdownGracePeriod = -1) + val end = System.currentTimeMillis() + // Because we set the shutdownGracePeriod to -1, it takes more than 500ms to shutdown, + // even though the default shutdown grace period is 100. But the sub-sub-process will + // have been shut down by the time the sub-process exits, so the lock is available + assert(end - start > 500) + val lock = tryLock(temp) + assert(lock != null) } } } diff --git a/os/test/src-jvm/SpawningSubprocessesTests.scala b/os/test/src-jvm/SpawningSubprocessesTests.scala index 70140316..633a114f 100644 --- a/os/test/src-jvm/SpawningSubprocessesTests.scala +++ b/os/test/src-jvm/SpawningSubprocessesTests.scala @@ -11,159 +11,157 @@ import utest._ object SpawningSubprocessesTests extends TestSuite { def tests = Tests { - test("proc") { - test("call") { - test - prep { wd => - if (Unix()) { - val res = os.proc("ls", wd / "folder2").call() - - res.exitCode ==> 0 - - res.out.text() ==> - """nestedA - |nestedB - |""".stripMargin - - res.out.trim() ==> - """nestedA - |nestedB""".stripMargin - - res.out.lines() ==> Seq( - "nestedA", - "nestedB" - ) + test("call") { + test - prep { wd => + if (Unix()) { + val res = os.proc("ls", wd / "folder2").call() + + res.exitCode ==> 0 + + res.out.text() ==> + """nestedA + |nestedB + |""".stripMargin - res.out.bytes + res.out.trim() ==> + """nestedA + |nestedB""".stripMargin - val thrown = intercept[os.SubprocessException] { - os.proc("ls", "doesnt-exist").call(cwd = wd) - } + res.out.lines() ==> Seq( + "nestedA", + "nestedB" + ) + + res.out.bytes + + val thrown = intercept[os.SubprocessException] { + os.proc("ls", "doesnt-exist").call(cwd = wd) + } - assert(thrown.result.exitCode != 0) + assert(thrown.result.exitCode != 0) - val fail = os.proc("ls", "doesnt-exist").call(cwd = wd, check = false, stderr = os.Pipe) + val fail = os.proc("ls", "doesnt-exist").call(cwd = wd, check = false, stderr = os.Pipe) - assert(fail.exitCode != 0) + assert(fail.exitCode != 0) - fail.out.text() ==> "" + fail.out.text() ==> "" - assert(fail.err.text().contains("No such file or directory")) + assert(fail.err.text().contains("No such file or directory")) - // You can pass in data to a subprocess' stdin - val hash = os.proc("shasum", "-a", "256").call(stdin = "Hello World") - hash.out.trim() ==> "a591a6d40bf420404a011733cfb7b190d62c65bf0bcda32b57b277d9ad9f146e -" + // You can pass in data to a subprocess' stdin + val hash = os.proc("shasum", "-a", "256").call(stdin = "Hello World") + hash.out.trim() ==> "a591a6d40bf420404a011733cfb7b190d62c65bf0bcda32b57b277d9ad9f146e -" - // Taking input from a file and directing output to another file - os.proc("base64").call(stdin = wd / "File.txt", stdout = wd / "File.txt.b64") + // Taking input from a file and directing output to another file + os.proc("base64").call(stdin = wd / "File.txt", stdout = wd / "File.txt.b64") - os.read(wd / "File.txt.b64") ==> "SSBhbSBjb3c=\n" + os.read(wd / "File.txt.b64") ==> "SSBhbSBjb3c=\n" - if (false) { - os.proc("vim").call(stdin = os.Inherit, stdout = os.Inherit, stderr = os.Inherit) - } + if (false) { + os.proc("vim").call(stdin = os.Inherit, stdout = os.Inherit, stderr = os.Inherit) } } - test - prep { wd => - if (Unix()) { - val ex = intercept[os.SubprocessException] { - os.proc("bash", "-c", "echo 123; sleep 10; echo 456") - .call(timeout = 2000) - } - - ex.result.out.trim() ==> "123" + } + test - prep { wd => + if (Unix()) { + val ex = intercept[os.SubprocessException] { + os.proc("bash", "-c", "echo 123; sleep 10; echo 456") + .call(timeout = 2000) } + + ex.result.out.trim() ==> "123" } } - test("stream") { - test - prep { wd => - if (Unix()) { - var lineCount = 1 - os.proc("find", ".").call( - cwd = wd, - stdout = - os.ProcessOutput((buf, len) => lineCount += buf.slice(0, len).count(_ == '\n')) - ) - lineCount ==> 22 - } + } + test("stream") { + test - prep { wd => + if (Unix()) { + var lineCount = 1 + os.proc("find", ".").call( + cwd = wd, + stdout = + os.ProcessOutput((buf, len) => lineCount += buf.slice(0, len).count(_ == '\n')) + ) + lineCount ==> 22 } - test - prep { wd => - if (Unix()) { - var lineCount = 1 - os.proc("find", ".").call( - cwd = wd, - stdout = os.ProcessOutput.Readlines(line => lineCount += 1) - ) - lineCount ==> 22 - } + } + test - prep { wd => + if (Unix()) { + var lineCount = 1 + os.proc("find", ".").call( + cwd = wd, + stdout = os.ProcessOutput.Readlines(line => lineCount += 1) + ) + lineCount ==> 22 } } + } - test("spawn python") { - test - prep { wd => - if (TestUtil.isInstalled("python") && Unix()) { - // Start a long-lived python process which you can communicate with - val sub = os.proc( - "python", - "-u", - "-c", - if (TestUtil.isPython3()) "while True: print(eval(input()))" - else "while True: print(eval(raw_input()))" - ) - .spawn(cwd = wd) - - // Sending some text to the subprocess - sub.stdin.write("1 + 2") - sub.stdin.writeLine("+ 4") - sub.stdin.flush() - sub.stdout.readLine() ==> "7" - - sub.stdin.write("'1' + '2'") - sub.stdin.writeLine("+ '4'") - sub.stdin.flush() - sub.stdout.readLine() ==> "124" - - // Sending some bytes to the subprocess - sub.stdin.write("1 * 2".getBytes) - sub.stdin.write("* 4\n".getBytes) - sub.stdin.flush() - sub.stdout.read() ==> '8'.toByte - - sub.destroy() - } + test("spawn python") { + test - prep { wd => + if (TestUtil.isInstalled("python") && Unix()) { + // Start a long-lived python process which you can communicate with + val sub = os.proc( + "python", + "-u", + "-c", + if (TestUtil.isPython3()) "while True: print(eval(input()))" + else "while True: print(eval(raw_input()))" + ) + .spawn(cwd = wd) + + // Sending some text to the subprocess + sub.stdin.write("1 + 2") + sub.stdin.writeLine("+ 4") + sub.stdin.flush() + sub.stdout.readLine() ==> "7" + + sub.stdin.write("'1' + '2'") + sub.stdin.writeLine("+ '4'") + sub.stdin.flush() + sub.stdout.readLine() ==> "124" + + // Sending some bytes to the subprocess + sub.stdin.write("1 * 2".getBytes) + sub.stdin.write("* 4\n".getBytes) + sub.stdin.flush() + sub.stdout.read() ==> '8'.toByte + + sub.destroy() } } - test("spawn curl") { - if ( - Unix() && // shasum seems to not accept stdin on Windows - TestUtil.isInstalled("curl") && - TestUtil.isInstalled("gzip") && - TestUtil.isInstalled("shasum") - ) { - // You can chain multiple subprocess' stdin/stdout together - val curl = - os.proc("curl", "-L", ExampleResourcess.RemoteReadme.url).spawn(stderr = os.Inherit) - val gzip = os.proc("gzip", "-n", "-6").spawn(stdin = curl.stdout) - val sha = os.proc("shasum", "-a", "256").spawn(stdin = gzip.stdout) - sha.stdout.trim() ==> s"${ExampleResourcess.RemoteReadme.gzip6ShaSum256} -" - } + } + test("spawn curl") { + if ( + Unix() && // shasum seems to not accept stdin on Windows + TestUtil.isInstalled("curl") && + TestUtil.isInstalled("gzip") && + TestUtil.isInstalled("shasum") + ) { + // You can chain multiple subprocess' stdin/stdout together + val curl = + os.proc("curl", "-L", ExampleResourcess.RemoteReadme.url).spawn(stderr = os.Inherit) + val gzip = os.proc("gzip", "-n", "-6").spawn(stdin = curl.stdout) + val sha = os.proc("shasum", "-a", "256").spawn(stdin = gzip.stdout) + sha.stdout.trim() ==> s"${ExampleResourcess.RemoteReadme.gzip6ShaSum256} -" } - test("spawn callback") { - test - prep { wd => - if (TestUtil.isInstalled("echo") && Unix()) { - val output: mutable.Buffer[String] = mutable.Buffer() - val sub = os.proc("echo", "output") - .spawn(stdout = - ProcessOutput((bytes, count) => output += new String(bytes, 0, count)) - ) - val finished = sub.join(5000) - sub.wrapped.getOutputStream().flush() - assert(finished) - assert(sub.exitCode() == 0) - val expectedOutput = "output\n" - val actualOutput = output.mkString("") - assert(actualOutput == expectedOutput) - sub.destroy() - } + } + test("spawn callback") { + test - prep { wd => + if (TestUtil.isInstalled("echo") && Unix()) { + val output: mutable.Buffer[String] = mutable.Buffer() + val sub = os.proc("echo", "output") + .spawn(stdout = + ProcessOutput((bytes, count) => output += new String(bytes, 0, count)) + ) + val finished = sub.join(5000) + sub.wrapped.getOutputStream().flush() + assert(finished) + assert(sub.exitCode() == 0) + val expectedOutput = "output\n" + val actualOutput = output.mkString("") + assert(actualOutput == expectedOutput) + sub.destroy() } } } diff --git a/os/test/testSpawnExitHook/src/TestSpawnExitHook.scala b/os/test/testSpawnExitHook/src/TestSpawnExitHook.scala new file mode 100644 index 00000000..026fd60f --- /dev/null +++ b/os/test/testSpawnExitHook/src/TestSpawnExitHook.scala @@ -0,0 +1,15 @@ +package test.os + +object TestSpawnExitHook { + def main(args: Array[String]): Unit = { + Runtime.getRuntime.addShutdownHook( + new Thread(() => { + for (shutdownDelay <- args.lift(1)) Thread.sleep(shutdownDelay.toLong) + System.err.println("Shutdown Hook") + }) + ) + val cmd = (sys.env("TEST_SPAWN_EXIT_HOOK_ASSEMBLY2"), args(0)) + os.spawn(cmd = cmd, destroyOnExit = true) + Thread.sleep(99999) + } +} diff --git a/os/test/testSpawnExitHook2/src/TestSpawnExitHook2.java b/os/test/testSpawnExitHook2/src/TestSpawnExitHook2.java new file mode 100644 index 00000000..d9d5195f --- /dev/null +++ b/os/test/testSpawnExitHook2/src/TestSpawnExitHook2.java @@ -0,0 +1,12 @@ +package test.os; +import java.nio.file.StandardOpenOption; + +public class TestSpawnExitHook2{ + public static void main(String[] args) throws Exception{ + java.nio.channels.FileChannel.open( + java.nio.file.Paths.get(args[0]), + java.util.EnumSet.of(StandardOpenOption.READ, StandardOpenOption.WRITE) + ).lock(); + Thread.sleep(1337000); + } +}