diff --git a/.github/workflows/auto-approve.yml b/.github/workflows/auto-approve.yml index 67c00644..f171109c 100644 --- a/.github/workflows/auto-approve.yml +++ b/.github/workflows/auto-approve.yml @@ -7,7 +7,7 @@ jobs: auto-approve: runs-on: ubuntu-20.04 steps: - - uses: hmarr/auto-approve-action@v2.1.0 + - uses: hmarr/auto-approve-action@v2.2.1 if: github.actor == 'scala-steward' || github.actor == 'renovate[bot]' with: github-token: "${{ secrets.GITHUB_TOKEN }}" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 416e3edb..e67a86da 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,11 +18,13 @@ jobs: timeout-minutes: 30 steps: - name: Checkout current branch - uses: actions/checkout@v2.3.4 + uses: actions/checkout@v3.0.2 with: fetch-depth: 0 - name: Setup Scala and Java uses: olafurpg/setup-scala@v13 + with: + java-version: adopt@1.11 - name: Cache scala dependencies uses: coursier/cache-action@v6 - name: Lint code @@ -33,9 +35,11 @@ jobs: timeout-minutes: 60 steps: - name: Checkout current branch - uses: actions/checkout@v2.3.4 + uses: actions/checkout@v3.0.2 - name: Setup Scala and Java uses: olafurpg/setup-scala@v13 + with: + java-version: adopt@1.11 - name: Cache scala dependencies uses: coursier/cache-action@v6 - name: Check Document Generation @@ -47,11 +51,11 @@ jobs: strategy: fail-fast: false matrix: - java: ['adopt@1.8', 'adopt@1.11'] - scala: ['2.11.12', '2.12.14', '2.13.6', '3.0.1'] + java: ['adopt@1.11'] + scala: ['2.11.12', '2.12.15', '2.13.8', '3.1.2'] steps: - name: Checkout current branch - uses: actions/checkout@v2.3.4 + uses: actions/checkout@v3.0.2 with: fetch-depth: 0 - name: Setup Scala and Java @@ -77,11 +81,13 @@ jobs: if: github.event_name != 'pull_request' steps: - name: Checkout current branch - uses: actions/checkout@v2.3.4 + uses: actions/checkout@v3.0.2 with: fetch-depth: 0 - name: Setup Scala and Java uses: olafurpg/setup-scala@v13 + with: + java-version: adopt@1.11 - name: Cache scala dependencies uses: coursier/cache-action@v6 - name: Release artifacts diff --git a/.github/workflows/site.yml b/.github/workflows/site.yml index b2561df5..bcc5fb4f 100644 --- a/.github/workflows/site.yml +++ b/.github/workflows/site.yml @@ -2,7 +2,7 @@ name: Website on: push: - branches: [master] + branches: [ master ] release: types: - published @@ -13,8 +13,10 @@ jobs: timeout-minutes: 30 if: github.event_name != 'pull_request' steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - uses: olafurpg/setup-scala@v13 + with: + java-version: adopt@1.11 - run: sbt docs/docusaurusPublishGhpages env: GIT_DEPLOY_KEY: ${{ secrets.GIT_DEPLOY_KEY }} diff --git a/.nvmrc b/.nvmrc index 5595ae1a..d9289897 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -14.17.6 +16.15.1 diff --git a/.scalafmt.conf b/.scalafmt.conf index 7b529470..7af056cc 100644 --- a/.scalafmt.conf +++ b/.scalafmt.conf @@ -1,21 +1,22 @@ -version = "2.7.5" +version = "3.5.3" maxColumn = 120 -align = most +align.preset = most +align.multiline = false continuationIndent.defnSite = 2 assumeStandardLibraryStripMargin = true -docstrings = JavaDoc +docstrings.style = Asterisk +docstrings.wrap = "no" lineEndings = preserve includeCurlyBraceInSelectChains = false -danglingParentheses = true -spaces { - inImportCurlyBraces = true -} +danglingParentheses.preset = true optIn.annotationNewlines = true +newlines.alwaysBeforeMultilineDef = false +runner.dialect = scala213 +rewrite.rules = [RedundantBraces] -rewrite.rules = [SortImports, RedundantBraces] - +rewrite.redundantBraces.generalExpressions = false rewriteTokens = { "⇒": "=>" "→": "->" "←": "<-" -} \ No newline at end of file +} diff --git a/build.sbt b/build.sbt index 570846ab..5ef13c7b 100644 --- a/build.sbt +++ b/build.sbt @@ -4,8 +4,8 @@ import sbtwelcome._ inThisBuild( List( organization := "dev.zio", - homepage := Some(url("https://zio.github.io/zio-process/")), - licenses := List("Apache-2.0" -> url("http://www.apache.org/licenses/LICENSE-2.0")), + homepage := Some(url("https://zio.github.io/zio-process/")), + licenses := List("Apache-2.0" -> url("http://www.apache.org/licenses/LICENSE-2.0")), developers := List( Developer( "jdegoes", @@ -42,12 +42,12 @@ usefulTasks := Seq( UsefulTask("", "testOnly *.YourSpec -- -t \"YourLabel\"", "Only runs tests with matching term") ) -val zioVersion = "1.0.9" +val zioVersion = "1.0.14" libraryDependencies ++= Seq( "dev.zio" %% "zio" % zioVersion, "dev.zio" %% "zio-streams" % zioVersion, - "org.scala-lang.modules" %% "scala-collection-compat" % "2.5.0", + "org.scala-lang.modules" %% "scala-collection-compat" % "2.7.0", "dev.zio" %% "zio-test" % zioVersion % "test", "dev.zio" %% "zio-test-sbt" % zioVersion % "test" ) @@ -67,16 +67,16 @@ lazy val docs = project .in(file("zio-process-docs")) .settings( publish / skip := true, - moduleName := "zio-process-docs", + moduleName := "zio-process-docs", scalacOptions -= "-Yno-imports", scalacOptions -= "-Xfatal-warnings", libraryDependencies ++= Seq( "dev.zio" %% "zio" % zioVersion ), ScalaUnidoc / unidoc / unidocProjectFilter := inProjects(root), - ScalaUnidoc / unidoc / target := (LocalRootProject / baseDirectory).value / "website" / "static" / "api", + ScalaUnidoc / unidoc / target := (LocalRootProject / baseDirectory).value / "website" / "static" / "api", cleanFiles += (ScalaUnidoc / unidoc / target).value, - docusaurusCreateSite := docusaurusCreateSite.dependsOn(Compile / unidoc).value, + docusaurusCreateSite := docusaurusCreateSite.dependsOn(Compile / unidoc).value, docusaurusPublishGhpages := docusaurusPublishGhpages.dependsOn(Compile / unidoc).value ) .dependsOn(root) diff --git a/docs/overview/index.md b/docs/overview/index.md index e5b86870..e3cc1b59 100644 --- a/docs/overview/index.md +++ b/docs/overview/index.md @@ -8,6 +8,7 @@ Here's list of contents available: - **[Basics](basics.md)** — Creating a description of a command and transforming its output - **[Piping](piping.md)** — Creating a pipeline of commands + - **[Interactive Processes](interactive_processes.md)** — Communicating with an interactive process - **[Other](other.md)** — Miscellaneous operations such as settings the working direction, inheriting I/O, etc. ## Installation diff --git a/docs/overview/interactive_processes.md b/docs/overview/interactive_processes.md new file mode 100644 index 00000000..4778be3f --- /dev/null +++ b/docs/overview/interactive_processes.md @@ -0,0 +1,30 @@ +--- +id: overview_interactive_processes +title: "Interactive Processes" +--- + +Sometimes you want to interact with a process in a back-and-forth manner by sending requests to the process and receiving responses back. For example, interacting with a repl-like process like `node -i`, `python -i`, etc. or an ssh server. + +Here is an example of communicating with an interactive Python shell: + +```scala mdoc:invisible +import zio._ +import zio.process._ +import java.nio.charset.StandardCharsets +``` + +```scala mdoc:silent +for { + commandQueue <- Queue.unbounded[Chunk[Byte]] + process <- Command("python", "-qi").stdin(ProcessInput.fromQueue(commandQueue)).run + _ <- process.stdout.linesStream.foreach { response => + ZIO.debug(s"Response from REPL: $response") + }.forkDaemon + _ <- commandQueue.offer(Chunk.fromArray("1+1\n".getBytes(StandardCharsets.UTF_8))) + _ <- commandQueue.offer(Chunk.fromArray("2**8\n".getBytes(StandardCharsets.UTF_8))) + _ <- commandQueue.offer(Chunk.fromArray("import random\nrandom.randint(1, 100)\n".getBytes(StandardCharsets.UTF_8))) + _ <- commandQueue.offer(Chunk.fromArray("exit()\n".getBytes(StandardCharsets.UTF_8))) +} yield () +``` + +You would probably want to create a helper for the repeated code, but this just a minimal example to help get you started. \ No newline at end of file diff --git a/project/BuildHelper.scala b/project/BuildHelper.scala index f6a46e7c..069b6ef2 100644 --- a/project/BuildHelper.scala +++ b/project/BuildHelper.scala @@ -5,22 +5,23 @@ import sbtbuildinfo._ object BuildHelper { private val versions: Map[String, String] = { - import org.snakeyaml.engine.v2.api.{ Load, LoadSettings } + import org.snakeyaml.engine.v2.api.{Load, LoadSettings} - import java.util.{ List => JList, Map => JMap } + import java.util.{List => JList, Map => JMap} import scala.jdk.CollectionConverters._ - - val doc = new Load(LoadSettings.builder().build()) + val doc = new Load(LoadSettings.builder().build()) .loadFromReader(scala.io.Source.fromFile(".github/workflows/ci.yml").bufferedReader()) val yaml = doc.asInstanceOf[JMap[String, JMap[String, JMap[String, JMap[String, JMap[String, JList[String]]]]]]] val list = yaml.get("jobs").get("test").get("strategy").get("matrix").get("scala").asScala - list.map(v => (v.split('.').take(2).mkString("."), v)).toMap + list.map { v => + val vs = v.split('.'); val init = vs.take(vs(0) match { case "2" => 2; case _ => 1 }); (init.mkString("."), v) + }.toMap } private val Scala211: String = versions("2.11") private val Scala212: String = versions("2.12") private val Scala213: String = versions("2.13") - private val Scala3: String = versions("3.0") + private val Scala3: String = versions("3") private val stdOptions = Seq( "-encoding", @@ -59,7 +60,8 @@ object BuildHelper { "-Ywarn-numeric-widen", "-Ywarn-value-discard", "-Xlint:_,-type-parameter-shadow", - "-Xsource:2.13" + "-Xsource:2.13", + "-target:jvm-1.8" ) private def extraOptions(scalaVersion: String) = @@ -75,24 +77,24 @@ object BuildHelper { "-opt:l:inline", "-opt-inline-from:" ) ++ stdOptsUpto212 ++ stdOptsUpto211 - case Some((3, 0)) => + case Some((3, _)) => Seq("-noindent") - case _ => + case _ => Seq("-Xexperimental") ++ stdOptsUpto212 ++ stdOptsUpto211 } def buildInfoSettings(packageName: String) = Seq( - buildInfoKeys := Seq[BuildInfoKey](name, version, scalaVersion, sbtVersion, isSnapshot), + buildInfoKeys := Seq[BuildInfoKey](name, version, scalaVersion, sbtVersion, isSnapshot), buildInfoPackage := packageName, - buildInfoObject := "BuildInfo" + buildInfoObject := "BuildInfo" ) def stdSettings(prjName: String) = Seq( - name := s"$prjName", - crossScalaVersions := Seq(Scala211, Scala212, Scala213, Scala3), + name := s"$prjName", + crossScalaVersions := Seq(Scala211, Scala212, Scala213, Scala3), ThisBuild / scalaVersion := Scala213, - scalacOptions := stdOptions ++ extraOptions(scalaVersion.value), + scalacOptions := stdOptions ++ extraOptions(scalaVersion.value), incOptions ~= (_.withLogRecompileOnMacro(false)) ) } diff --git a/project/build.properties b/project/build.properties index 7a7e80d6..f6acff8b 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1 +1 @@ -sbt.version = 1.5.5 +sbt.version = 1.6.2 diff --git a/project/plugins.sbt b/project/plugins.sbt index eedacb19..7d1e8d61 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,11 +1,10 @@ -addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.4.3") +addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.4.6") addSbtPlugin("pl.project13.scala" % "sbt-jmh" % "0.4.3") -addSbtPlugin("com.eed3si9n" % "sbt-buildinfo" % "0.10.0") -addSbtPlugin("org.scoverage" % "sbt-scoverage" % "1.8.2") -addSbtPlugin("org.scalameta" % "sbt-mdoc" % "2.2.22") -addSbtPlugin("ch.epfl.scala" % "sbt-bloop" % "1.4.8") -addSbtPlugin("com.eed3si9n" % "sbt-unidoc" % "0.4.3") -addSbtPlugin("com.geirsson" % "sbt-ci-release" % "1.5.7") -addSbtPlugin("com.github.reibitto" % "sbt-welcome" % "0.2.1") +addSbtPlugin("com.eed3si9n" % "sbt-buildinfo" % "0.11.0") +addSbtPlugin("org.scoverage" % "sbt-scoverage" % "1.9.3") +addSbtPlugin("org.scalameta" % "sbt-mdoc" % "2.3.2") +addSbtPlugin("com.github.sbt" % "sbt-unidoc" % "0.5.0") +addSbtPlugin("com.github.sbt" % "sbt-ci-release" % "1.5.10") +addSbtPlugin("com.github.reibitto" % "sbt-welcome" % "0.2.2") libraryDependencies += "org.snakeyaml" % "snakeyaml-engine" % "2.3" diff --git a/src/main/scala/zio/process/Command.scala b/src/main/scala/zio/process/Command.scala index 5b2ae8bc..30981d71 100644 --- a/src/main/scala/zio/process/Command.scala +++ b/src/main/scala/zio/process/Command.scala @@ -15,13 +15,12 @@ */ package zio.process -import java.io.File +import java.io.{File, OutputStream} import java.lang.ProcessBuilder.Redirect import java.nio.charset.Charset - import zio._ import zio.blocking.Blocking -import zio.stream.{ ZSink, ZStream } +import zio.stream.{ZSink, ZStream} import scala.jdk.CollectionConverters._ @@ -55,7 +54,7 @@ sealed trait Command { * Inherit standard input, standard output, and standard error. */ def inheritIO: Command = - stdin(ProcessInput.inherit).stdout(ProcessOutput.Inherit).stderr(ProcessOutput.Inherit) + stdin(ProcessInput.Inherit).stdout(ProcessOutput.Inherit).stderr(ProcessOutput.Inherit) /** * Runs the command returning the output as a list of lines (default encoding of UTF-8). @@ -102,11 +101,11 @@ sealed trait Command { this match { case c: Command.Standard => for { - _ <- ZIO.foreach_(c.workingDirectory) { workingDirectory => - ZIO - .fail(CommandError.WorkingDirectoryMissing(workingDirectory)) - .unless(workingDirectory.exists()) - } + _ <- ZIO.foreach_(c.workingDirectory) { workingDirectory => + ZIO + .fail(CommandError.WorkingDirectoryMissing(workingDirectory)) + .unless(workingDirectory.exists()) + } process <- Task { val builder = new ProcessBuilder(c.command: _*) builder.redirectErrorStream(c.redirectErrorStream) @@ -117,8 +116,9 @@ sealed trait Command { } c.stdin match { - case ProcessInput(None) => builder.redirectInput(Redirect.INHERIT) - case ProcessInput(Some(_)) => () + case ProcessInput.Inherit => builder.redirectInput(Redirect.INHERIT) + case ProcessInput.Pipe => builder.redirectInput(Redirect.PIPE) + case ProcessInput.FromStream(_, _) => () } c.stdout match { @@ -141,28 +141,35 @@ sealed trait Command { case CommandThrowable.PermissionDenied(e) => e case CommandThrowable.IOError(e) => e } - _ <- c.stdin match { - case ProcessInput(None) => ZIO.unit - case ProcessInput(Some(input)) => - for { - outputStream <- process.execute(_.getOutputStream) - _ <- input - .run(ZSink.fromOutputStream(outputStream)) - .ensuring(UIO(outputStream.close())) - .forkDaemon - } yield () - } + _ <- c.stdin match { + case ProcessInput.Inherit | ProcessInput.Pipe => ZIO.unit + case ProcessInput.FromStream(input, flushChunks) => + for { + outputStream <- process.execute(_.getOutputStream) + sink = if (flushChunks) fromOutputStreamFlushChunksEagerly(outputStream) + else ZSink.fromOutputStream(outputStream) + _ <- input + .run(sink) + .ensuring(UIO(outputStream.close())) + .forkDaemon + } yield () + } } yield process case c: Command.Piped => c.flatten match { case chunk if chunk.length == 1 => chunk.head.run - case chunk => + case chunk => + val flushChunksEagerly = chunk.head.stdin match { + case ProcessInput.FromStream(_, eager) => eager + case ProcessInput.Inherit | ProcessInput.Pipe => false + } + val stream = chunk.tail.init.foldLeft(chunk.head.stream) { case (s, command) => - command.stdin(ProcessInput.fromStream(s)).stream + command.stdin(ProcessInput.fromStream(s, flushChunksEagerly)).stream } - chunk.last.stdin(ProcessInput.fromStream(stream)).run + chunk.last.stdin(ProcessInput.fromStream(stream, flushChunksEagerly)).run } } @@ -244,6 +251,14 @@ sealed trait Command { def <<(input: String): Command = stdin(ProcessInput.fromUTF8String(input)) + private def fromOutputStreamFlushChunksEagerly(os: OutputStream): ZSink[Blocking, Throwable, Byte, Nothing, Unit] = + ZSink.foreachChunk { (chunk: Chunk[Byte]) => + zio.blocking.effectBlockingInterrupt { + os.write(chunk.toArray) + os.flush() + } + } + } object Command { @@ -268,7 +283,7 @@ object Command { NonEmptyChunk(processName, args: _*), Map.empty, Option.empty[File], - ProcessInput.inherit, + ProcessInput.Inherit, ProcessOutput.Pipe, ProcessOutput.Pipe, redirectErrorStream = false diff --git a/src/main/scala/zio/process/CommandError.scala b/src/main/scala/zio/process/CommandError.scala index 5847b82f..bedca974 100644 --- a/src/main/scala/zio/process/CommandError.scala +++ b/src/main/scala/zio/process/CommandError.scala @@ -15,7 +15,7 @@ */ package zio.process -import java.io.{ File, IOException } +import java.io.{File, IOException} import zio.ExitCode diff --git a/src/main/scala/zio/process/Process.scala b/src/main/scala/zio/process/Process.scala index 0dcfaf53..ff7c985b 100644 --- a/src/main/scala/zio/process/Process.scala +++ b/src/main/scala/zio/process/Process.scala @@ -16,9 +16,10 @@ package zio.process import zio.blocking._ -import zio.{ ExitCode, UIO, ZIO } +import zio.{ExitCode, UIO, ZIO} -import java.lang.{ Process => JProcess } +import java.lang.{Process => JProcess} +import scala.jdk.CollectionConverters._ final case class Process(private val process: JProcess) { @@ -41,7 +42,7 @@ final case class Process(private val process: JProcess) { /** * Return the exit code of this process. */ - def exitCode: ZIO[Blocking, CommandError, ExitCode] = + def exitCode: ZIO[Blocking, CommandError, ExitCode] = effectBlockingCancelable(ExitCode(process.waitFor()))(UIO(process.destroy())).refineOrDie { case CommandThrowable.IOError(e) => e } @@ -71,6 +72,48 @@ final case class Process(private val process: JProcess) { () } + /** + * Kills the entire process tree and will wait until completed. Equivalent to SIGTERM on Unix platforms. + * + * Note: This method requires JDK 9+ + */ + def killTree: ZIO[Blocking, CommandError, Unit] = + execute { process => + val descendants = process.descendants().iterator().asScala.toSeq + descendants.foreach(_.destroy()) + + process.destroy() + process.waitFor() + + descendants.foreach { p => + if (p.isAlive) { + p.onExit().get // `ProcessHandle` doesn't have waitFor + () + } + } + } + + /** + * Kills the entire process tree and will wait until completed. Equivalent to SIGKILL on Unix platforms. + * + * Note: This method requires JDK 9+ + */ + def killTreeForcibly: ZIO[Blocking, CommandError, Unit] = + execute { process => + val descendants = process.descendants().iterator().asScala.toSeq + descendants.foreach(_.destroyForcibly()) + + process.destroyForcibly() + process.waitFor() + + descendants.foreach { p => + if (p.isAlive) { + p.onExit().get // `ProcessHandle` doesn't have waitFor + () + } + } + } + /** * Return the exit code of this process if it is zero. If non-zero, it will fail with `CommandError.NonZeroErrorCode`. */ diff --git a/src/main/scala/zio/process/ProcessInput.scala b/src/main/scala/zio/process/ProcessInput.scala index 588bf502..17e571bb 100644 --- a/src/main/scala/zio/process/ProcessInput.scala +++ b/src/main/scala/zio/process/ProcessInput.scala @@ -15,39 +15,77 @@ */ package zio.process -import java.io.ByteArrayInputStream -import java.nio.charset.{ Charset, StandardCharsets } - -import zio.Chunk import zio.blocking.Blocking -import zio.stream.{ Stream, ZStream } +import zio.stream.ZStream +import zio.{Chunk, Queue} + +import java.io.{ByteArrayInputStream, File} +import java.nio.charset.{Charset, StandardCharsets} +import java.nio.file.Path -final case class ProcessInput(source: Option[ZStream[Blocking, CommandError, Byte]]) +sealed trait ProcessInput object ProcessInput { - val inherit: ProcessInput = ProcessInput(None) + final case class FromStream(stream: ZStream[Blocking, CommandError, Byte], flushChunksEagerly: Boolean) + extends ProcessInput + + case object Inherit extends ProcessInput + case object Pipe extends ProcessInput /** * Returns a ProcessInput from an array of bytes. */ def fromByteArray(bytes: Array[Byte]): ProcessInput = - ProcessInput(Some(Stream.fromInputStream(new ByteArrayInputStream(bytes)).mapError(CommandError.IOError.apply))) + ProcessInput.FromStream( + ZStream.fromInputStream(new ByteArrayInputStream(bytes)).mapError(CommandError.IOError.apply), + flushChunksEagerly = false + ) + + /** + * Returns a ProcessInput from a file. + */ + def fromFile(file: File, chunkSize: Int = ZStream.DefaultChunkSize): ProcessInput = + ProcessInput.FromStream( + ZStream.fromFile(file.toPath, chunkSize).refineOrDie { case CommandThrowable.IOError(e) => e }, + flushChunksEagerly = false + ) + + /** + * Returns a ProcessInput from a path to a file. + */ + def fromPath(path: Path, chunkSize: Int = ZStream.DefaultChunkSize): ProcessInput = + ProcessInput.FromStream( + ZStream.fromFile(path, chunkSize).refineOrDie { case CommandThrowable.IOError(e) => e }, + flushChunksEagerly = false + ) + + /** + * Returns a ProcessInput from a queue of bytes to send to the process in a controlled manner. + */ + def fromQueue(queue: Queue[Chunk[Byte]]): ProcessInput = + ProcessInput.fromStream(ZStream.fromQueue(queue).flattenChunks, flushChunksEagerly = true) /** * Returns a ProcessInput from a stream of bytes. + * + * You may want to set `flushChunksEagerly` to true when doing back-and-forth communication with a process such as + * interacting with a repl (flushing the command to send so that you can receive a response immediately). */ - def fromStream(stream: ZStream[Blocking, CommandError, Byte]): ProcessInput = - ProcessInput(Some(stream)) + def fromStream(stream: ZStream[Blocking, CommandError, Byte], flushChunksEagerly: Boolean = false): ProcessInput = + ProcessInput.FromStream(stream, flushChunksEagerly) /** * Returns a ProcessInput from a String with the given charset. */ def fromString(text: String, charset: Charset): ProcessInput = - ProcessInput(Some(ZStream.fromChunks(Chunk.fromArray(text.getBytes(charset))))) + ProcessInput.FromStream(ZStream.fromChunks(Chunk.fromArray(text.getBytes(charset))), flushChunksEagerly = false) /** * Returns a ProcessInput from a UTF-8 String. */ def fromUTF8String(text: String): ProcessInput = - ProcessInput(Some(ZStream.fromChunks(Chunk.fromArray(text.getBytes(StandardCharsets.UTF_8))))) + ProcessInput.FromStream( + ZStream.fromChunks(Chunk.fromArray(text.getBytes(StandardCharsets.UTF_8))), + flushChunksEagerly = false + ) } diff --git a/src/main/scala/zio/process/ProcessStream.scala b/src/main/scala/zio/process/ProcessStream.scala index 07e3dab3..ced259dd 100644 --- a/src/main/scala/zio/process/ProcessStream.scala +++ b/src/main/scala/zio/process/ProcessStream.scala @@ -16,11 +16,11 @@ package zio.process import java.io._ -import java.nio.charset.{ Charset, StandardCharsets } +import java.nio.charset.{Charset, StandardCharsets} -import zio.blocking.{ effectBlockingCancelable, Blocking } -import zio.stream.{ ZStream, ZTransducer } -import zio.{ Chunk, UIO, ZIO, ZManaged } +import zio.blocking.{effectBlockingCancelable, Blocking} +import zio.stream.{ZStream, ZTransducer} +import zio.{Chunk, UIO, ZIO, ZManaged} import scala.collection.mutable.ArrayBuffer diff --git a/src/test/bash/kill-test/sample-child.sh b/src/test/bash/kill-test/sample-child.sh new file mode 100755 index 00000000..4670b77b --- /dev/null +++ b/src/test/bash/kill-test/sample-child.sh @@ -0,0 +1,5 @@ +#!/bin/bash +echo $$ +sleep 30 +echo -n "end: " +echo $$ diff --git a/src/test/bash/kill-test/sample-parent.sh b/src/test/bash/kill-test/sample-parent.sh new file mode 100755 index 00000000..bb1fa757 --- /dev/null +++ b/src/test/bash/kill-test/sample-parent.sh @@ -0,0 +1,7 @@ +#!/bin/bash +echo $$ +./sample-child.sh & +./sample-child.sh & +sleep 30 +echo -n "end: " +echo $$ diff --git a/src/test/bash/stdin-echo.sh b/src/test/bash/stdin-echo.sh new file mode 100755 index 00000000..0de47dae --- /dev/null +++ b/src/test/bash/stdin-echo.sh @@ -0,0 +1,6 @@ +#!/bin/bash + +while read line +do + echo "$line" +done \ No newline at end of file diff --git a/src/test/scala/zio/process/CommandSpec.scala b/src/test/scala/zio/process/CommandSpec.scala index e55f240c..34bf1e95 100644 --- a/src/test/scala/zio/process/CommandSpec.scala +++ b/src/test/scala/zio/process/CommandSpec.scala @@ -2,13 +2,14 @@ package zio.process import java.io.File import java.nio.charset.StandardCharsets - import zio.duration._ import zio.stream.ZTransducer import zio.test.Assertion._ import zio.test._ import zio.test.environment.TestClock -import zio.{ Chunk, ExitCode, ZIO } +import zio.{Chunk, ExitCode, Queue, ZIO} + +import java.util.Optional // TODO: Add aspects for different OSes? scala.util.Properties.isWin, etc. Also try to make this as OS agnostic as possible in the first place object CommandSpec extends ZIOProcessBaseSpec { @@ -56,6 +57,11 @@ object CommandSpec extends ZIOProcessBaseSpec { assertM(zio)(equalTo("piped in")) }, + testM("accept file stdin") { + for { + lines <- Command("cat").stdin(ProcessInput.fromFile(new File("src/test/bash/echo-repeat.sh"))).lines + } yield assertTrue(lines.head == "#!/bin/bash") + }, testM("support different encodings") { val zio = Command("cat") @@ -139,6 +145,66 @@ object CommandSpec extends ZIOProcessBaseSpec { for { exit <- Command("ls").workingDirectory(new File("/some/bad/path")).lines.run } yield assert(exit)(fails(isSubtype[CommandError.WorkingDirectoryMissing](anything))) - } + }, + testM("connect to a repl-like process and flush the chunks eagerly and get responses right away") { + for { + commandQueue <- Queue.unbounded[Chunk[Byte]] + process <- Command("./stdin-echo.sh") + .workingDirectory(new File("src/test/bash")) + .stdin(ProcessInput.fromQueue(commandQueue)) + .run + _ <- commandQueue.offer(Chunk.fromArray("line1\nline2\n".getBytes(StandardCharsets.UTF_8))) + _ <- commandQueue.offer(Chunk.fromArray("line3\n".getBytes(StandardCharsets.UTF_8))) + lines <- process.stdout.linesStream.take(3).runCollect + _ <- process.kill + } yield assertTrue(lines == Chunk("line1", "line2", "line3")) + }, + testM("kill only kills parent process") { + for { + process <- Command("./sample-parent.sh").workingDirectory(new File("src/test/bash/kill-test")).run + pids <- process.stdout.stream + .aggregate(ZTransducer.utf8Decode) + .aggregate(ZTransducer.splitLines) + .take(3) + .runCollect + .map(_.map(_.toInt)) + _ <- process.kill + pidsAlive = pids.map { pid => + toScalaOption(ProcessHandle.of(pid.toLong)).exists(_.isAlive) + } + } yield assertTrue(pidsAlive == Chunk(false, true, true)) + } @@ TestAspect.nonFlaky(25), + testM("killTree also kills child processes") { + for { + process <- Command("./sample-parent.sh").workingDirectory(new File("src/test/bash/kill-test")).run + pids <- process.stdout.stream + .aggregate(ZTransducer.utf8Decode) + .aggregate(ZTransducer.splitLines) + .take(3) + .runCollect + .map(_.map(_.toInt)) + _ <- process.killTree + pidsAlive = pids.map { pid => + toScalaOption(ProcessHandle.of(pid.toLong)).exists(_.isAlive) + } + } yield assertTrue(pidsAlive == Chunk(false, false, false)) + } @@ TestAspect.nonFlaky(25), + testM("killTreeForcibly also kills child processes") { + for { + process <- Command("./sample-parent.sh").workingDirectory(new File("src/test/bash/kill-test")).run + pids <- process.stdout.stream + .aggregate(ZTransducer.utf8Decode) + .aggregate(ZTransducer.splitLines) + .take(3) + .runCollect + .map(_.map(_.toInt)) + _ <- process.killTree + pidsAlive = pids.map { pid => + toScalaOption(ProcessHandle.of(pid.toLong)).exists(_.isAlive) + } + } yield assertTrue(pidsAlive == Chunk(false, false, false)) + } @@ TestAspect.nonFlaky(25) ) + + private def toScalaOption[A](o: Optional[A]): Option[A] = if (o.isPresent) Some(o.get) else None } diff --git a/src/test/scala/zio/process/PipedCommandSpec.scala b/src/test/scala/zio/process/PipedCommandSpec.scala index 206e3548..00c8ebc5 100644 --- a/src/test/scala/zio/process/PipedCommandSpec.scala +++ b/src/test/scala/zio/process/PipedCommandSpec.scala @@ -2,7 +2,7 @@ package zio.process import java.io.File -import zio.{ Chunk, NonEmptyChunk } +import zio.{Chunk, NonEmptyChunk} import zio.test.Assertion._ import zio.test._ diff --git a/src/test/scala/zio/process/ZIOProcessBaseSpec.scala b/src/test/scala/zio/process/ZIOProcessBaseSpec.scala index 4f01fed3..0324d9cf 100644 --- a/src/test/scala/zio/process/ZIOProcessBaseSpec.scala +++ b/src/test/scala/zio/process/ZIOProcessBaseSpec.scala @@ -4,5 +4,5 @@ import zio.duration._ import zio.test._ trait ZIOProcessBaseSpec extends DefaultRunnableSpec { - override def aspects = List(TestAspect.timeout(5.seconds)) + override def aspects = List(TestAspect.timeout(30.seconds)) } diff --git a/website/sidebars.json b/website/sidebars.json index 4bc3cded..17bdfbac 100755 --- a/website/sidebars.json +++ b/website/sidebars.json @@ -4,6 +4,7 @@ "overview/overview_index", "overview/overview_basics", "overview/overview_piping", + "overview/overview_interactive_processes", "overview/overview_other" ] },