Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions core-tests/jvm/src/test/scala/zio/ZIOAppProcessSpec.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package zio

import zio.test._
import zio.testapps.ProcessTestHelper

object ZIOAppProcessSpec extends ZIOBaseSpec {

private val defaultTimeout = java.time.Duration.ofSeconds(30)
Comment on lines +6 to +8

def spec = suite("ZIOAppProcessSpec")(
suite("natural completion")(
test("successful app emits exit code 0") {
ZIO.attemptBlockingInterrupt {
ProcessTestHelper.runApp("zio.testapps.TestAppSuccess", defaultTimeout)
}.map { result =>
Comment on lines +12 to +15
assertTrue(result.exitCode == 0) &&
assertTrue(result.stdout.contains("APP_SUCCESS"))
}
},
test("failed app emits exit code 1") {
ZIO.attemptBlockingInterrupt {
ProcessTestHelper.runApp("zio.testapps.TestAppFailure", defaultTimeout)
}.map { result =>
assertTrue(result.exitCode == 1)
}
},
test("defect (die) emits exit code 1") {
ZIO.attemptBlockingInterrupt {
ProcessTestHelper.runApp("zio.testapps.TestAppDie", defaultTimeout)
}.map { result =>
assertTrue(result.exitCode == 1)
}
}
),
suite("signal handling")(
test("finalizers run when app receives SIGTERM") {
ZIO.attemptBlockingInterrupt {
ProcessTestHelper.runAppAndSignal("zio.testapps.TestAppFinalizer", "APP_STARTED", defaultTimeout)
}.map { result =>
assertTrue(result.stdout.contains("FINALIZER_RAN")) &&
assertTrue(result.exitCode != -1)
}
},
test("multiple finalizers all run on signal (regression #9901)") {
ZIO.attemptBlockingInterrupt {
ProcessTestHelper.runAppAndSignal("zio.testapps.TestAppMultipleFinalizers", "APP_STARTED", defaultTimeout)
}.map { result =>
assertTrue(result.stdout.contains("FINALIZER_1_RAN")) &&
assertTrue(result.stdout.contains("FINALIZER_2_RAN"))
}
},
test("shutdown doesn't hang") {
ZIO.attemptBlockingInterrupt {
val start = java.lang.System.currentTimeMillis()
val result =
ProcessTestHelper.runAppAndSignal(
"zio.testapps.TestAppFinalizer",
"APP_STARTED",
java.time.Duration.ofSeconds(15)
)
val elapsed = java.lang.System.currentTimeMillis() - start
(result, elapsed)
}.map { case (result, elapsed) =>
assertTrue(result.exitCode != -1) &&
assertTrue(elapsed < 10000L)
}
},
test("gracefulShutdownTimeout is respected") {
ZIO.attemptBlockingInterrupt {
val start = java.lang.System.currentTimeMillis()
val result = ProcessTestHelper.runAppAndSignal(
"zio.testapps.TestAppSlowFinalizer",
"APP_STARTED",
java.time.Duration.ofSeconds(15)
)
val elapsed = java.lang.System.currentTimeMillis() - start
(result, elapsed)
}.map { case (result, elapsed) =>
assertTrue(result.exitCode != -1) &&
assertTrue(elapsed < 10000L)
}
}
),
suite("regressions")(
test("no uncaught exception on stderr during shutdown (regression #9807)") {
ZIO.attemptBlockingInterrupt {
ProcessTestHelper.runAppAndSignal("zio.testapps.TestAppCleanShutdown", "APP_STARTED", defaultTimeout)
}.map { result =>
assertTrue(result.stderr.contains("SHUTDOWN_HOOK_RAN")) &&
assertTrue(!result.stderr.contains("Exception in thread")) &&
assertTrue(!result.stderr.contains("FiberFailure"))
}
},
test("signal handler works via reflection without NoClassDefFoundError (regression #9240)") {
ZIO.attemptBlockingInterrupt {
ProcessTestHelper.runApp("zio.testapps.TestAppSuccess", defaultTimeout)
}.map { result =>
assertTrue(!result.stderr.contains("NoClassDefFoundError")) &&
assertTrue(!result.stderr.contains("sun/misc/SignalHandler")) &&
assertTrue(result.exitCode == 0)
}
}
)
) @@ TestAspect.timeout(60.seconds) @@ TestAspect.sequential
}
107 changes: 107 additions & 0 deletions core-tests/jvm/src/test/scala/zio/testapps/ProcessTestHelper.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package zio.testapps

import java.io.{BufferedReader, InputStreamReader}
import java.util.concurrent.TimeUnit

final case class ProcessResult(exitCode: Int, stdout: String, stderr: String)

object ProcessTestHelper {

def runApp(mainClass: String, timeout: java.time.Duration): ProcessResult = {
val classpath = System.getProperty("java.class.path")
val javaHome = System.getProperty("java.home")
val javaBin = s"$javaHome/bin/java"

val pb = new ProcessBuilder(javaBin, "-cp", classpath, mainClass)
val process = pb.start()

val stdoutBuilder = new StringBuilder
val stderrBuilder = new StringBuilder

val stdoutThread = readerThread(new BufferedReader(new InputStreamReader(process.getInputStream)), stdoutBuilder)
val stderrThread = readerThread(new BufferedReader(new InputStreamReader(process.getErrorStream)), stderrBuilder)

stdoutThread.start()
stderrThread.start()

val exited = process.waitFor(timeout.toMillis, TimeUnit.MILLISECONDS)
if (!exited) process.destroyForcibly()

stdoutThread.join(5000)
stderrThread.join(5000)

ProcessResult(
if (exited) process.exitValue() else -1,
stdoutBuilder.toString,
stderrBuilder.toString
)
}

def runAppAndSignal(mainClass: String, readyMarker: String, timeout: java.time.Duration): ProcessResult = {
val classpath = System.getProperty("java.class.path")
val javaHome = System.getProperty("java.home")
val javaBin = s"$javaHome/bin/java"

val pb = new ProcessBuilder(javaBin, "-cp", classpath, mainClass)
val process = pb.start()

val stdoutBuilder = new StringBuilder
val stderrBuilder = new StringBuilder

val stderrThread = readerThread(new BufferedReader(new InputStreamReader(process.getErrorStream)), stderrBuilder)
stderrThread.start()

val stdoutReader = new BufferedReader(new InputStreamReader(process.getInputStream))
val startTime = System.currentTimeMillis()
val deadlineMs = startTime + timeout.toMillis

var line = stdoutReader.readLine()
var markerFound = false
while (line != null && !markerFound) {
stdoutBuilder.append(line).append('\n')
if (line.contains(readyMarker)) markerFound = true
else line = stdoutReader.readLine()
}

Comment on lines +54 to +65
if (!markerFound) {
process.destroyForcibly()
return ProcessResult(-1, stdoutBuilder.toString, stderrBuilder.toString)
}

val pid = process.pid()
Runtime.getRuntime.exec(Array("kill", "-TERM", pid.toString)).waitFor()

val remainingMs = deadlineMs - System.currentTimeMillis()

val tailThread = readerThread(stdoutReader, stdoutBuilder)
tailThread.start()

val exited = process.waitFor(math.max(remainingMs, 1000L), TimeUnit.MILLISECONDS)
if (!exited) process.destroyForcibly()

tailThread.join(5000)
stderrThread.join(5000)

ProcessResult(
if (exited) process.exitValue() else -1,
stdoutBuilder.toString,
stderrBuilder.toString
)
}

Comment on lines +40 to +91
private def readerThread(reader: BufferedReader, sb: StringBuilder): Thread = {
val t = new Thread(() => {
try {
var line = reader.readLine()
while (line != null) {
sb.append(line).append('\n')
line = reader.readLine()
}
} catch {
case _: Exception => ()
}
})
t.setDaemon(true)
t
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package zio.testapps

import zio._

object TestAppCleanShutdown extends ZIOAppDefault {
def run = ZIO.succeed {
java.lang.Runtime.getRuntime.addShutdownHook(new Thread(() => {
java.lang.System.err.println("SHUTDOWN_HOOK_RAN")
Thread.sleep(1000)
}))
} *> Console.printLine("APP_STARTED") *> ZIO.never
}
7 changes: 7 additions & 0 deletions core-tests/jvm/src/test/scala/zio/testapps/TestAppDie.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package zio.testapps

import zio._

object TestAppDie extends ZIOAppDefault {
def run = Console.printLine("APP_STARTED") *> ZIO.die(new RuntimeException("boom"))
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package zio.testapps

import zio._

object TestAppFailure extends ZIOAppDefault {
def run = Console.printLine("APP_STARTED") *> ZIO.fail("intentional failure")
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package zio.testapps

import zio._

object TestAppFinalizer extends ZIOAppDefault {
def run = ZIO.scoped {
ZIO.acquireRelease(Console.printLine("APP_STARTED"))(_ => Console.printLine("FINALIZER_RAN").orDie) *> ZIO.never

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's explicitely use ZIO.scoped instead of relying on the app scope, wdut? (here and elsewhere)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But we test the behavior of the App or not? So we want to make sure that the app scope is doing the right thing. Or am I missing something?

}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package zio.testapps

import zio._

object TestAppMultipleFinalizers extends ZIOAppDefault {
def run = ZIO.scoped {
for {
_ <- ZIO.acquireRelease(Console.printLine("ACQUIRED_1"))(_ => Console.printLine("FINALIZER_1_RAN").orDie)
_ <- ZIO.acquireRelease(Console.printLine("ACQUIRED_2"))(_ => Console.printLine("FINALIZER_2_RAN").orDie)
_ <- Console.printLine("APP_STARTED")
_ <- ZIO.never
} yield ()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package zio.testapps

import zio._

object TestAppSlowFinalizer extends ZIOAppDefault {
override def gracefulShutdownTimeout: Duration = 2.seconds

def run = ZIO.scoped {
ZIO.acquireRelease(Console.printLine("APP_STARTED")) { _ =>
Console.printLine("SLOW_FINALIZER_STARTED").orDie *> ZIO.sleep(60.seconds)
} *> ZIO.never
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package zio.testapps

import zio._

object TestAppSuccess extends ZIOAppDefault {
def run = Console.printLine("APP_SUCCESS")
}
40 changes: 40 additions & 0 deletions core-tests/shared/src/test/scala/zio/ZIOAppSpec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,46 @@ object ZIOAppSpec extends ZIOBaseSpec {
_ <- app.invoke(Chunk.empty)
value <- ref2.get
} yield assertTrue(value)
},
test("multiple finalizers all execute on interruption") {
for {
running <- Promise.make[Nothing, Unit]
ref <- Ref.make(List.empty[String])
app = ZIOAppDefault.fromZIO {
ZIO.scoped {
for {
_ <- ZIO.addFinalizer(ref.update("finalizer1" :: _))
_ <- ZIO.addFinalizer(ref.update("finalizer2" :: _))
_ <- running.succeed(()) *> ZIO.never
} yield ()
}
}
fiber <- app.invoke(Chunk.empty).fork
_ <- running.await
_ <- fiber.interrupt
finalizers <- ref.get
} yield assertTrue(finalizers.contains("finalizer1")) &&
assertTrue(finalizers.contains("finalizer2"))
},
test("finalizers run in LIFO order on interruption") {
for {
running <- Promise.make[Nothing, Unit]
ref <- Ref.make(List.empty[Int])
app = ZIOAppDefault.fromZIO {
ZIO.scoped {
for {
_ <- ZIO.addFinalizer(ref.update(1 :: _))
_ <- ZIO.addFinalizer(ref.update(2 :: _))
_ <- ZIO.addFinalizer(ref.update(3 :: _))
_ <- running.succeed(()) *> ZIO.never
} yield ()
}
}
fiber <- app.invoke(Chunk.empty).fork
_ <- running.await
_ <- fiber.interrupt
order <- ref.get
} yield assertTrue(order == List(1, 2, 3))
}
)
}