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
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package io.github.anschnapp.mutflow

import java.time.Duration
import java.time.Instant
import java.util.Collections
import java.util.concurrent.ConcurrentHashMap

Expand Down Expand Up @@ -33,8 +35,7 @@ object MutationRegistry {
*/
fun checkTimeout() {
val session = currentSession ?: return
val deadline = session.deadlineNanos
if (deadline > 0 && System.nanoTime() > deadline) {
if (session.isExpired()) {
throw MutationTimedOutException(
"Mutation timed out. This mutation likely causes an infinite loop.\n" +
"Add a // mutflow:ignore comment on the affected line to skip it."
Expand Down Expand Up @@ -125,15 +126,15 @@ object MutationRegistry {
*/
fun <T> withSession(
activeMutation: ActiveMutation? = null,
timeoutMs: Long = 0,
timeout: Duration? = null,
block: () -> T
): Pair<T, SessionResult> {
synchronized(lock) {
check(currentSession == null) { "Session already active" }
val deadlineNanos = if (activeMutation != null && timeoutMs > 0) {
System.nanoTime() + timeoutMs * 1_000_000
} else 0L
currentSession = Session(activeMutation, deadlineNanos = deadlineNanos)
val deadline = if (activeMutation != null && timeout != null) {
Instant.now() + timeout
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

The old code used System.nanoTime() which is monotonic -- it only moves forward, regardless of what happens to the system clock.

Here you use Instant.now() which reads the wall clock and can jump backward or forward due to NTP sync, clock adjustments etc...

For a timeout that detects infinite loops in mutation runs:

  • Clock jumps forward -> false timeout, a healthy mutation gets killed prematurely
  • Clock jumps backward -> missed timeout, an actual infinite loop runs longer than expected (or never triggers)

I would say let's keep the Duration as the public API type (that's a good improvement), but internally still convert to a System.nanoTime()-based deadline for the actual expiry check.

} else null
currentSession = Session(activeMutation, deadline = deadline)
try {
val result = block()
val session = currentSession!!
Expand Down Expand Up @@ -161,10 +162,13 @@ object MutationRegistry {

private class Session(
val activeMutation: ActiveMutation?,
val deadlineNanos: Long = 0,
val deadline: Instant? = null,
val discoveredPoints: MutableList<DiscoveredPoint> = Collections.synchronizedList(mutableListOf()),
val seenPointIds: MutableSet<String> = ConcurrentHashMap.newKeySet()
)
) {
fun isExpired(): Boolean = deadline != null && Instant.now() > deadline
}

}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import org.junit.jupiter.api.extension.ClassTemplateInvocationContextProvider
import org.junit.jupiter.api.extension.Extension
import org.junit.jupiter.api.extension.ExtensionContext
import org.junit.jupiter.api.extension.TestExecutionExceptionHandler
import java.time.Duration
import java.util.stream.Stream
import kotlin.streams.asStream

Expand Down Expand Up @@ -64,7 +65,7 @@ class MutFlowExtension : ClassTemplateInvocationContextProvider {
traps = annotation.traps.toList(),
includeTargets = annotation.includeTargets.map { it.qualifiedName!! },
excludeTargets = annotation.excludeTargets.map { it.qualifiedName!! },
timeoutMs = annotation.timeoutMs,
timeout = Duration.ofMillis(annotation.timeoutMs)
verificationMode = effectiveMode
)

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package io.github.anschnapp.mutflow

import java.time.Duration
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import kotlin.random.Random
Expand Down Expand Up @@ -58,7 +59,7 @@ object MutFlow {
traps: List<String> = emptyList(),
includeTargets: List<String> = emptyList(),
excludeTargets: List<String> = emptyList(),
timeoutMs: Long = 60_000,
timeout: Duration = Duration.ofMinutes(1)
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

comma is missing after this argument, so the build fails.

i guess this was coming in when you merged from master (so some kind of merge issue)

i'm also not sure if this is the only one issue of this kind.
to make it more easy to check for those errors i have now a tiny CI pipeline.
so after you have fixed it please pull from master again, then you get the pipeline on our next push and it build and tests will be checked within the github UI

verificationMode: VerificationMode = VerificationMode.STRICT
): SessionId {
val id = SessionId(UUID.randomUUID())
Expand All @@ -71,7 +72,7 @@ object MutFlow {
traps = traps,
includeTargets = includeTargets,
excludeTargets = excludeTargets,
timeoutMs = timeoutMs,
timeout = timeout
verificationMode = verificationMode
)
sessions[id] = session
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package io.github.anschnapp.mutflow

import java.time.Duration
import java.util.UUID
import kotlin.random.Random

Expand All @@ -23,7 +24,7 @@ class MutFlowSession internal constructor(
private val traps: List<String> = emptyList(),
private val includeTargets: List<String> = emptyList(),
private val excludeTargets: List<String> = emptyList(),
private val timeoutMs: Long = 60_000,
private val timeout: Duration = Duration.ofMinutes(1)
private val verificationMode: VerificationMode = VerificationMode.STRICT
) {
// Discovered points with their variant counts (built during baseline)
Expand Down Expand Up @@ -327,7 +328,7 @@ class MutFlowSession internal constructor(
)
}

val (result, _) = MutationRegistry.withSession(activeMutation = active, timeoutMs = timeoutMs) {
val (result, _) = MutationRegistry.withSession(activeMutation = active, timeout = timeout) {
block()
}
return result
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
package io.github.anschnapp.mutflow

import java.time.Duration
import kotlin.test.*

class SessionTimeoutTest {

@BeforeTest
fun setup() {
MutationRegistry.reset()
MutFlow.reset()
}

private fun simulateMutationPoint(): Int? =
MutationRegistry.check("point-a", 2, "Test.kt:1", ">", ">=,<,==")

// Helper that runs a baseline and returns the session id, leaving the session open.
private fun sessionWithBaseline(timeout: Duration): SessionId {
val sessionId = MutFlow.createSession(
selection = Selection.MostLikelyStable,
shuffle = Shuffle.PerChange,
maxRuns = 10,
timeout = timeout
)
MutFlow.startRun(sessionId, 0)
MutFlow.underTest { simulateMutationPoint() }
MutFlow.endRun(sessionId)
return sessionId
}

@Test
fun `checkTimeout is a no-op when no session is active`() {
// Should never throw without an active session
MutationRegistry.checkTimeout()
}

@Test
fun `baseline run never times out even with a very short timeout configured`() {
// Baseline uses withSession(activeMutation = null), so no deadline is set —
// the timeout parameter only applies to mutation runs.
val sessionId = MutFlow.createSession(
selection = Selection.MostLikelyStable,
shuffle = Shuffle.PerChange,
maxRuns = 10,
timeout = Duration.ofMillis(1)
)

MutFlow.startRun(sessionId, 0)
MutFlow.underTest {
simulateMutationPoint()
Thread.sleep(10) // sleep well past the 1 ms configured timeout
MutationRegistry.checkTimeout() // must not throw
}
MutFlow.endRun(sessionId)

MutFlow.closeSession(sessionId)
}

@Test
fun `mutation run throws MutationTimedOutException when deadline is exceeded`() {
val sessionId = sessionWithBaseline(Duration.ofMillis(1))

val mutation = MutFlow.selectMutationForRun(sessionId, 1)!!
MutFlow.startRun(sessionId, 1, mutation)

assertFailsWith<MutationTimedOutException> {
MutFlow.underTest {
Thread.sleep(10) // exceed the 1 ms deadline
MutationRegistry.checkTimeout()
}
}

MutFlow.endRun(sessionId)
MutFlow.closeSession(sessionId)
}

@Test
fun `mutation run with generous timeout completes without timing out`() {
val sessionId = sessionWithBaseline(Duration.ofSeconds(30))

val mutation = MutFlow.selectMutationForRun(sessionId, 1)!!
MutFlow.startRun(sessionId, 1, mutation)

// Multiple checkTimeout calls should be fine within the deadline
MutFlow.underTest {
repeat(5) { MutationRegistry.checkTimeout() }
}

MutFlow.endRun(sessionId)
MutFlow.closeSession(sessionId)
}

@Test
fun `timed-out mutation is recorded as TimedOut in summary`() {
val sessionId = sessionWithBaseline(Duration.ofMillis(1))

val mutation = MutFlow.selectMutationForRun(sessionId, 1)!!
MutFlow.startRun(sessionId, 1, mutation)
val session = MutFlow.getSession(sessionId)!!

try {
MutFlow.underTest {
Thread.sleep(10)
MutationRegistry.checkTimeout()
}
} catch (_: MutationTimedOutException) {
// Simulates what MutFlowExtension.TestExecutionExceptionHandler does
session.markTestTimedOut()
}

session.recordMutationResult()
MutFlow.endRun(sessionId)

val summary = session.getSummary()
assertEquals(1, summary.timedOut)
assertEquals(0, summary.survived)
assertEquals(0, summary.killed)

MutFlow.closeSession(sessionId)
}

@Test
fun `timed-out mutation does not count as survived`() {
val sessionId = sessionWithBaseline(Duration.ofMillis(1))

val mutation = MutFlow.selectMutationForRun(sessionId, 1)!!
MutFlow.startRun(sessionId, 1, mutation)
val session = MutFlow.getSession(sessionId)!!

try {
MutFlow.underTest {
Thread.sleep(10)
MutationRegistry.checkTimeout()
}
} catch (_: MutationTimedOutException) {
session.markTestTimedOut()
}

assertFalse(session.didMutationSurvive(), "A timed-out mutation must not be considered survived")

MutFlow.endRun(sessionId)
MutFlow.closeSession(sessionId)
}

@Test
fun `multiple checkTimeout calls before deadline do not throw`() {
val sessionId = sessionWithBaseline(Duration.ofSeconds(30))

val mutation = MutFlow.selectMutationForRun(sessionId, 1)!!
MutFlow.startRun(sessionId, 1, mutation)

MutFlow.underTest {
// Simulates a loop body checked many times — all within the deadline
repeat(100) { MutationRegistry.checkTimeout() }
}

MutFlow.endRun(sessionId)
MutFlow.closeSession(sessionId)
}
}