-
Notifications
You must be signed in to change notification settings - Fork 91
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Document timeout behavior #730
Comments
See also #292. |
Note, this timeouts as expected: //> using scala 3.3.1
import scala.concurrent.{Await, Future}
import scala.concurrent.duration.Duration
import scala.concurrent.ExecutionContext.Implicits.global
@main def main =
def future =
Future:
while true do
Thread.sleep(1000)
println("tick")
Await.result(future, Duration(2, "s")) So this doesn't seem to be a limitation of Futures. |
My observation is that if the program terminates, an error is thrown if the duration exceeds the timeout (but it does not kill the test):
results in:
However, this test never terminates and so the test suite also loops forever:
|
The default execution context is the parasitic one:
which is used when wrapping a sync test in a munit/munit/shared/src/main/scala/munit/ValueTransforms.scala Lines 22 to 39 in 0d20dbc
The timeout task is scheduled on a separate thread (using munit/munit/jvm/src/main/scala/munit/internal/PlatformCompat.scala Lines 53 to 66 in 0d20dbc
munit/munit/jvm/src/main/scala/munit/internal/PlatformCompat.scala Lines 22 to 32 in 0d20dbc
|
Using the global execution context as //> using scala 3.3.1
//> using test.dep org.scalameta::munit:1.0.0-M10
import scala.concurrent.{ExecutionContext, Future}
import scala.concurrent.duration.Duration
class TimeoutSuite extends munit.FunSuite:
override val munitTimeout = Duration(1, "s")
override def munitExecutionContext: ExecutionContext = ExecutionContext.global
given ExecutionContext = ExecutionContext.global
test("timeout async"):
Future:
while true do
Thread.sleep(1000)
println("Async tick")
test("timeout sync"):
while true do
Thread.sleep(1000)
println("Sync tick")
|
Could you try with a long computation instead of Tread.sleep? |
@samuelchassot I observe the same behavior with an infinite loop, and without //> using scala 3.3.1
//> using test.dep org.scalameta::munit:1.0.0-M10
import scala.concurrent.{ExecutionContext, Future}
import scala.concurrent.duration.Duration
class TimeoutSuite extends munit.FunSuite:
override val munitTimeout = Duration(1, "s")
override def munitExecutionContext: ExecutionContext = ExecutionContext.global
given ExecutionContext = ExecutionContext.global
test("timeout async")(Future(while true do ()))
test("timeout sync")(while true do ())
|
Well, the timeout test suite does use the global execution context as
|
Here is a somewhat minimal reproduction: //> using scala 3.3.1
package waitAtMost
import scala.concurrent.{Await, ExecutionContext, Future, Promise}
import scala.concurrent.duration.Duration
import java.util.concurrent.{
Executors,
ScheduledThreadPoolExecutor,
ThreadFactory,
TimeUnit,
TimeoutException
}
import java.util.concurrent.atomic.AtomicInteger
val sh = Executors.newSingleThreadScheduledExecutor(
new ThreadFactory {
val counter = new AtomicInteger
def threadNumber() = counter.incrementAndGet()
def newThread(r: Runnable) =
new Thread(r, s"munit-scheduler-${threadNumber()}") {
setDaemon(true)
setPriority(Thread.NORM_PRIORITY)
}
}
)
def waitAtMost[T](
startFuture: () => Future[T],
duration: Duration,
ec: ExecutionContext
): Future[T] = {
val onComplete = Promise[T]()
val timeout = sh.schedule[Unit](
() =>
println("TIMEOUT")
onComplete.tryFailure(
new TimeoutException(s"test timed out after $duration")
),
duration.toMillis,
TimeUnit.MILLISECONDS
)
ec.execute(new Runnable {
def run(): Unit = {
startFuture().onComplete { result =>
onComplete.tryComplete(result)
timeout.cancel(false)
}(ec)
}
})
onComplete.future
}
@main def main =
//import scala.concurrent.ExecutionContext.Implicits.global
given ExecutionContext = ExecutionContext.parasitic
def makeFuture() =
Future:
while true do
Thread.sleep(1000)
println("tick")
val withTimeout = waitAtMost(makeFuture, Duration(2, "s"), ExecutionContext.parasitic)
withTimeout.onComplete(_ => println("DONE"))
Thread.sleep(5000)
whereas commenting
So, if the main thread is blocked, which is the case if the future runs in the parasitic context, there is no hope to go on with any other task or to exit properly. However, if it runs in a separate thread, as it is the case when it runs in the global context, the main thread can report that it timed out (but not stop it!) and go on to schedule other tests. This is better but still suboptimal: the future is not stopped and if many tests loop forever, there will be threads starvation. Ideally, we would like to force kill a thread after some time, but this is generally not possible on the JVM. |
It seems to me that we should:
|
Note, about supporting several test timeouts: while a thread can't be killed in Java, one could imagine a different testing framework architecture where tests would be run in a separate process. That would be quite a heavy change though, probably not worth it. Proof of conceptpackage testServer
import scala.sys.process.ProcessLogger
import scala.collection.mutable.ArrayBuffer
import java.time.Duration
import java.util.concurrent.{Callable, Executors, TimeUnit}
import TestFramework.test
def mySuite() =
test("1 + 1 == 2"):
assert(1 + 1 == 2)
test("infinite loop"):
while true do
println("looping")
Thread.sleep(1000)
test("2 + 1 == 2"):
assert(2 + 1 == 2)
test("infinite loop 2"):
while true do
println("looping 2")
Thread.sleep(1000)
object TestFramework:
case class Test(name: String, body: () => Unit)
val tests = ArrayBuffer[Test]()
enum TestStatus:
case Success, Failure, Timeout
case class TestResult(name: String, status: TestStatus)
def test(name: String)(body: => Unit) =
tests += Test(name, () => body)
def main(args: Array[String]): Unit =
mySuite() // should be called automatically upon definition
args match
case Array() => runAll()
case Array("runFrom", fromIndex) => runFrom(fromIndex.toInt)
case _ => println("usage: testServer [runFrom fromIndex]")
def runAll(previousResults: IArray[TestResult] = IArray.empty): Unit =
val results = previousResults ++ spawnRunFrom(previousResults.length)
if results.size == tests.size then
println("all tests ran")
else
runAll(results)
def spawnRunFrom(fromIndex: Int): IArray[TestResult] =
println(s"fork to run from $fromIndex")
val process = scala.sys.process.Process(
List(
"java",
"-cp",
System.getProperty("java.class.path"),
"testServer.TestFramework",
"runFrom",
fromIndex.toString()
)
)
val results = ArrayBuffer[TestResult]()
val logger = ProcessLogger(line =>
println(line)
line match
// better serialization needed, JSON or something
case s"success: $name" => results += TestResult(name, TestStatus.Success)
case s"failure: $name" => results += TestResult(name, TestStatus.Failure)
case s"timeout: $name" => results += TestResult(name, TestStatus.Timeout)
case _ => ()
)
process.run(logger).exitValue()
IArray.from(results)
def runFrom(fromIndex: Int) =
val timeout = Duration.ofSeconds(2) // should be configurable
val executor = Executors.newScheduledThreadPool(1)
for Test(name, body) <- tests.drop(fromIndex) do
val timeoutFuture = executor.schedule(
new Runnable:
override def run() =
println(s"timeout: $name")
System.exit(1)
,
2,
TimeUnit.SECONDS
)
try
body()
println(s"success: $name")
timeoutFuture.cancel(false)
catch
case e: Throwable =>
println(s"failure: $name")
timeoutFuture.cancel(false) Note: the problem with such an architecture is that the tests initialization is re-run every time the worker is re-spawned. |
What does
munitTimeout
do exactly?I would have expected it to stop a test if it runs for too long, but that's not the case, for example with infinite loops:
The text was updated successfully, but these errors were encountered: