test(ZIOApp): add comprehensive process-level test suite (#9909)#10579
test(ZIOApp): add comprehensive process-level test suite (#9909)#10579987Nabil wants to merge 3 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
Adds a process-level test suite to validate ZIOApp behavior via real child JVM processes, exercising the actual main() path (shutdown hooks, SIGTERM handling, and exit codes) to cover key regressions.
Changes:
- Introduces
ZIOAppProcessSpecwith process-spawning tests for exit codes, SIGTERM finalization, timeout behavior, and regression cases. - Adds
ProcessTestHelperplus several minimalZIOAppDefault“test apps” used as spawned child programs. - Adds regression-focused spawned apps (slow finalizer, multiple finalizers, clean shutdown hook, etc.).
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| core-tests/jvm-native/src/test/scala/zio/ZIOAppProcessSpec.scala | New process-level spec that spawns JVMs and asserts exit/shutdown behavior |
| core-tests/jvm-native/src/test/scala/zio/testapps/ProcessTestHelper.scala | Helper to spawn JVM subprocesses and capture stdout/stderr |
| core-tests/jvm-native/src/test/scala/zio/testapps/TestAppSuccess.scala | Child app that completes successfully |
| core-tests/jvm-native/src/test/scala/zio/testapps/TestAppFailure.scala | Child app that fails to validate exit code behavior |
| core-tests/jvm-native/src/test/scala/zio/testapps/TestAppDie.scala | Child app that dies to validate defect exit code behavior |
| core-tests/jvm-native/src/test/scala/zio/testapps/TestAppFinalizer.scala | Child app that registers a finalizer for SIGTERM shutdown validation |
| core-tests/jvm-native/src/test/scala/zio/testapps/TestAppMultipleFinalizers.scala | Child app to validate multiple finalizers run on shutdown (regression #9901) |
| core-tests/jvm-native/src/test/scala/zio/testapps/TestAppSlowFinalizer.scala | Child app to validate gracefulShutdownTimeout behavior |
| core-tests/jvm-native/src/test/scala/zio/testapps/TestAppCleanShutdown.scala | Child app to validate clean shutdown (regression #9807) |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| object ZIOAppProcessSpec extends ZIOBaseSpec { | ||
|
|
||
| private val defaultTimeout = java.time.Duration.ofSeconds(30) |
| var line = stdoutReader.readLine() | ||
| while (line != null && !line.contains(readyMarker)) { | ||
| stdoutBuilder.append(line).append('\n') | ||
| line = stdoutReader.readLine() | ||
| } | ||
| if (line != null) stdoutBuilder.append(line).append('\n') |
| val pid = process.pid() | ||
| java.lang.Runtime.getRuntime.exec(Array("kill", "-TERM", pid.toString)).waitFor() | ||
|
|
||
| val remainingMs = timeout.toMillis - (java.lang.System.currentTimeMillis() - startTime) | ||
|
|
| test("successful app emits exit code 0") { | ||
| ZIO.attemptBlocking { | ||
| ProcessTestHelper.runApp("zio.testapps.TestAppSuccess", defaultTimeout) | ||
| }.map { result => |
| 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() | ||
|
|
||
| var line = stdoutReader.readLine() | ||
| while (line != null && !line.contains(readyMarker)) { | ||
| stdoutBuilder.append(line).append('\n') | ||
| line = stdoutReader.readLine() | ||
| } | ||
| if (line != null) stdoutBuilder.append(line).append('\n') | ||
|
|
||
| process.destroy() | ||
|
|
||
| val remainingMs = timeout.toMillis - (System.currentTimeMillis() - startTime) | ||
|
|
||
| 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 | ||
| ) | ||
| } | ||
|
|
| val stdoutReader = new BufferedReader(new InputStreamReader(process.getInputStream)) | ||
| val startTime = System.currentTimeMillis() | ||
|
|
||
| var line = stdoutReader.readLine() | ||
| while (line != null && !line.contains(readyMarker)) { | ||
| stdoutBuilder.append(line).append('\n') | ||
| line = stdoutReader.readLine() | ||
| } | ||
| if (line != null) stdoutBuilder.append(line).append('\n') | ||
|
|
0121ad8 to
a4db82d
Compare
kyri-petrou
left a comment
There was a problem hiding this comment.
The PR looks good to me minus some minor comments, but when I wrote the issue I also had in mind that the tests would also cover JS and Native.
Do you think it'd be feasible to add those as well?
|
|
||
| object TestAppCleanShutdown extends ZIOAppDefault { | ||
| def run = ZIO.succeed { | ||
| java.lang.Runtime.getRuntime.addShutdownHook(new Thread(() => Thread.sleep(1000))) |
There was a problem hiding this comment.
Can we test/assert that whatever is run within the hook is actually executed?
|
|
||
| object TestAppFinalizer extends ZIOAppDefault { | ||
| def run = | ||
| ZIO.acquireRelease(Console.printLine("APP_STARTED"))(_ => Console.printLine("FINALIZER_RAN").orDie) *> ZIO.never |
There was a problem hiding this comment.
Let's explicitely use ZIO.scoped instead of relying on the app scope, wdut? (here and elsewhere)
There was a problem hiding this comment.
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?
Add process-level tests for ZIOApp behavior, verifying correct exit codes, finalizer execution on SIGTERM, gracefulShutdownTimeout, clean shutdown (regression zio#9807), signal handler reflection (regression zio#9240), and multiple finalizer execution (regression zio#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.
a4db82d to
85d7313
Compare
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).
|
🚀 Preview deployed to Netlify: https://pr-10579--zio-dev.netlify.app |
Summary
Fixes #9909: Create test suite that tests the correct behaviour of
ZIOApp/claim #9909
This PR adds a process-level test suite that spawns real child JVM processes via
ProcessBuilderand verifiesZIOAppbehavior externally — testing the actualmain()code path including JVM shutdown hooks, signal handling, andSystem.exit.What's tested
Natural completion
ZIO.fail) emits exit code 1ZIO.die) emits exit code 1Signal handling
gracefulShutdownTimeoutis respected (slow finalizer doesn't block exit)Regressions
NoClassDefFoundError(regression Dependency on sun.misc.SignalHandler causing NoClassDefFoundError #9240)Approach
Unlike
invoke()-based tests (which bypassmain(), shutdown hooks, and exit codes), these tests:ProcessBuilderwith the test classpathkill -TERM <pid>to trigger JVM shutdown hooksFINALIZER_RAN)Files
ZIOAppProcessSpec.scala— 9 process-level teststestapps/ProcessTestHelper.scala— helper for spawning/signaling child JVMs (~99 lines)testapps/TestApp*.scala— 7 minimalZIOAppDefaultprograms, each exercising one behaviorTest results
Stable across 3 consecutive runs with zero flakiness.