From 85d7313c256831793f6b604cf77aa4d8d67ddcfa Mon Sep 17 00:00:00 2001 From: Nabil Abdel-Hafeez <7283535+987Nabil@users.noreply.github.com> Date: Sat, 14 Mar 2026 14:28:55 +0100 Subject: [PATCH 1/2] test(ZIOApp): add comprehensive process-level test suite (#9909) Add process-level tests for ZIOApp behavior, verifying correct exit codes, finalizer execution on SIGTERM, gracefulShutdownTimeout, clean shutdown (regression #9807), signal handler reflection (regression #9240), and multiple finalizer execution (regression #9901). Tests spawn real child JVM processes via ProcessBuilder, send SIGTERM via kill -TERM, and assert on stdout/stderr/exit code. All test files are in core-tests/jvm/ (JVM-only) since they require a JVM to spawn. --- .../test/scala/zio/ZIOAppProcessSpec.scala | 105 +++++++++++++++++ .../zio/testapps/ProcessTestHelper.scala | 107 ++++++++++++++++++ .../zio/testapps/TestAppCleanShutdown.scala | 12 ++ .../test/scala/zio/testapps/TestAppDie.scala | 7 ++ .../scala/zio/testapps/TestAppFailure.scala | 7 ++ .../scala/zio/testapps/TestAppFinalizer.scala | 9 ++ .../testapps/TestAppMultipleFinalizers.scala | 14 +++ .../zio/testapps/TestAppSlowFinalizer.scala | 13 +++ .../scala/zio/testapps/TestAppSuccess.scala | 7 ++ 9 files changed, 281 insertions(+) create mode 100644 core-tests/jvm/src/test/scala/zio/ZIOAppProcessSpec.scala create mode 100644 core-tests/jvm/src/test/scala/zio/testapps/ProcessTestHelper.scala create mode 100644 core-tests/jvm/src/test/scala/zio/testapps/TestAppCleanShutdown.scala create mode 100644 core-tests/jvm/src/test/scala/zio/testapps/TestAppDie.scala create mode 100644 core-tests/jvm/src/test/scala/zio/testapps/TestAppFailure.scala create mode 100644 core-tests/jvm/src/test/scala/zio/testapps/TestAppFinalizer.scala create mode 100644 core-tests/jvm/src/test/scala/zio/testapps/TestAppMultipleFinalizers.scala create mode 100644 core-tests/jvm/src/test/scala/zio/testapps/TestAppSlowFinalizer.scala create mode 100644 core-tests/jvm/src/test/scala/zio/testapps/TestAppSuccess.scala diff --git a/core-tests/jvm/src/test/scala/zio/ZIOAppProcessSpec.scala b/core-tests/jvm/src/test/scala/zio/ZIOAppProcessSpec.scala new file mode 100644 index 000000000000..a2ed52decec1 --- /dev/null +++ b/core-tests/jvm/src/test/scala/zio/ZIOAppProcessSpec.scala @@ -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) + + def spec = suite("ZIOAppProcessSpec")( + suite("natural completion")( + test("successful app emits exit code 0") { + ZIO.attemptBlockingInterrupt { + ProcessTestHelper.runApp("zio.testapps.TestAppSuccess", defaultTimeout) + }.map { result => + 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 +} diff --git a/core-tests/jvm/src/test/scala/zio/testapps/ProcessTestHelper.scala b/core-tests/jvm/src/test/scala/zio/testapps/ProcessTestHelper.scala new file mode 100644 index 000000000000..6b29f96c7b2b --- /dev/null +++ b/core-tests/jvm/src/test/scala/zio/testapps/ProcessTestHelper.scala @@ -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() + } + + 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 + ) + } + + 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 + } +} diff --git a/core-tests/jvm/src/test/scala/zio/testapps/TestAppCleanShutdown.scala b/core-tests/jvm/src/test/scala/zio/testapps/TestAppCleanShutdown.scala new file mode 100644 index 000000000000..7d849bdf1f67 --- /dev/null +++ b/core-tests/jvm/src/test/scala/zio/testapps/TestAppCleanShutdown.scala @@ -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 +} diff --git a/core-tests/jvm/src/test/scala/zio/testapps/TestAppDie.scala b/core-tests/jvm/src/test/scala/zio/testapps/TestAppDie.scala new file mode 100644 index 000000000000..bc719864eb55 --- /dev/null +++ b/core-tests/jvm/src/test/scala/zio/testapps/TestAppDie.scala @@ -0,0 +1,7 @@ +package zio.testapps + +import zio._ + +object TestAppDie extends ZIOAppDefault { + def run = Console.printLine("APP_STARTED") *> ZIO.die(new RuntimeException("boom")) +} diff --git a/core-tests/jvm/src/test/scala/zio/testapps/TestAppFailure.scala b/core-tests/jvm/src/test/scala/zio/testapps/TestAppFailure.scala new file mode 100644 index 000000000000..db3b63899453 --- /dev/null +++ b/core-tests/jvm/src/test/scala/zio/testapps/TestAppFailure.scala @@ -0,0 +1,7 @@ +package zio.testapps + +import zio._ + +object TestAppFailure extends ZIOAppDefault { + def run = Console.printLine("APP_STARTED") *> ZIO.fail("intentional failure") +} diff --git a/core-tests/jvm/src/test/scala/zio/testapps/TestAppFinalizer.scala b/core-tests/jvm/src/test/scala/zio/testapps/TestAppFinalizer.scala new file mode 100644 index 000000000000..d2a406a177af --- /dev/null +++ b/core-tests/jvm/src/test/scala/zio/testapps/TestAppFinalizer.scala @@ -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 + } +} diff --git a/core-tests/jvm/src/test/scala/zio/testapps/TestAppMultipleFinalizers.scala b/core-tests/jvm/src/test/scala/zio/testapps/TestAppMultipleFinalizers.scala new file mode 100644 index 000000000000..ad18369af824 --- /dev/null +++ b/core-tests/jvm/src/test/scala/zio/testapps/TestAppMultipleFinalizers.scala @@ -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 () + } +} diff --git a/core-tests/jvm/src/test/scala/zio/testapps/TestAppSlowFinalizer.scala b/core-tests/jvm/src/test/scala/zio/testapps/TestAppSlowFinalizer.scala new file mode 100644 index 000000000000..2368d63d3b8c --- /dev/null +++ b/core-tests/jvm/src/test/scala/zio/testapps/TestAppSlowFinalizer.scala @@ -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 + } +} diff --git a/core-tests/jvm/src/test/scala/zio/testapps/TestAppSuccess.scala b/core-tests/jvm/src/test/scala/zio/testapps/TestAppSuccess.scala new file mode 100644 index 000000000000..73416205e5f4 --- /dev/null +++ b/core-tests/jvm/src/test/scala/zio/testapps/TestAppSuccess.scala @@ -0,0 +1,7 @@ +package zio.testapps + +import zio._ + +object TestAppSuccess extends ZIOAppDefault { + def run = Console.printLine("APP_SUCCESS") +} From b1fd6391aed0099862c33670eb2c2814750ee91b Mon Sep 17 00:00:00 2001 From: Nabil Abdel-Hafeez <7283535+987Nabil@users.noreply.github.com> Date: Wed, 18 Mar 2026 15:45:10 +0100 Subject: [PATCH 2/2] test(ZIOApp): add cross-platform finalizer tests to shared spec Add invoke()-based tests for multiple-finalizer execution and LIFO ordering on interruption. These run on all platforms (JVM, JS, Native) via the shared ZIOAppSpec, covering the ZIO-level logic behind the JVM-only regression scenarios. Process-level tests remain JVM-only since shutdown hooks are no-ops on both JS and Native (PlatformSpecific.addShutdownHook discards the action on both platforms). --- .../src/test/scala/zio/ZIOAppSpec.scala | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/core-tests/shared/src/test/scala/zio/ZIOAppSpec.scala b/core-tests/shared/src/test/scala/zio/ZIOAppSpec.scala index a0d7d4934c8f..b15b9b09dc3f 100644 --- a/core-tests/shared/src/test/scala/zio/ZIOAppSpec.scala +++ b/core-tests/shared/src/test/scala/zio/ZIOAppSpec.scala @@ -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)) } ) }