Steps to reproduce
The relevant implementation is unchanged between JUnit Jupiter 6.1.3 and
current main (17ce9f1d4fd65fb01f5f23c87c3daecf7a185c94).
Run the following test with JUnit Jupiter:
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTimeout;
import java.time.Duration;
import org.junit.jupiter.api.Test;
class AssertTimeoutSubMillisecondTests {
@Test
void rejectsEveryOverrun() {
int falsePasses = 0;
for (int i = 0; i < 100; i++) {
try {
assertTimeout(Duration.ofNanos(500_000), () -> spinForNanos(700_000));
falsePasses++;
}
catch (AssertionError expected) {
}
}
assertEquals(0, falsePasses,
"each 700 us execution exceeds the 500 us timeout");
}
private static void spinForNanos(long nanos) {
long end = System.nanoTime() + nanos;
while (System.nanoTime() < end) {
Thread.onSpinWait();
}
}
}
Expected: every invocation of assertTimeout() rejects the overrun, so
falsePasses is zero.
Actual: invocations whose two wall-clock reads fall in the same millisecond
return normally. The exact count is timing-dependent. On revision
f10989d3073dc0c6e3a1af4426fbd68ec4c119e4, the test above observed 27 false
passes out of 100; a separate 50 us / 1 ns probe observed 94 out of 100.
AssertTimeout
converts the requested Duration with toMillis() and measures elapsed time
using System.currentTimeMillis(). Thus every positive timeout below one
millisecond becomes zero, elapsed time is quantized to milliseconds, and
wall-clock adjustments can affect the result.
The preemptive path has the inverse symptom.
PreemptiveTimeoutUtils
calls Future.get(timeout.toMillis(), MILLISECONDS), so all positive values
from 1 to 999,999 ns become the same zero-length poll. Whether a particular
no-op invocation succeeds is scheduler-dependent; the deterministic defect is
the conversion to Future.get(0, MILLISECONDS), not a particular failure rate.
This utility is used by both assertTimeoutPreemptively() and
SeparateThreadTimeoutInvocation,
so @Timeout(..., threadMode = SEPARATE_THREAD) has the same wait-precision
issue. Same-thread @Timeout uses a different path.
Assertion failures also render a positive sub-millisecond timeout as 0 ms.
Expected behavior is to honor the supplied Duration precision and to use a
monotonic elapsed-time clock for non-preemptive assertions.
Compatibility points that should be decided explicitly:
- nanosecond elapsed-time measurement will newly detect overruns of an
integer-millisecond timeout that are smaller than one millisecond;
Duration.toNanos() overflows much earlier than the current toMillis()
conversion, so a direct replacement would regress very large durations; and
- zero and negative
Duration values are currently not rejected or covered by
tests, so their behavior should be preserved deliberately or specified.
I searched open and closed issues and pull requests for combinations of
assertTimeout, toMillis, currentTimeMillis, nanoTime, nanoseconds,
sub-millisecond, precision, truncation, and Future.get. I did not find
a directly matching public report. The closest result, #2239, concerns access
to the worker Future/executor for diagnostics rather than timeout precision.
Context
- Used versions (Jupiter/Vintage/Platform): JUnit Jupiter 6.1.3; also current 6.2.0-SNAPSHOT
main at 17ce9f1d4fd65fb01f5f23c87c3daecf7a185c94
- Build Tool/IDE: JUnit repository Gradle build; JDK 25; Windows 11
This is not known to be a regression; the same implementation is present in
the inspected history.
Deliverables
Steps to reproduce
The relevant implementation is unchanged between JUnit Jupiter 6.1.3 and
current
main(17ce9f1d4fd65fb01f5f23c87c3daecf7a185c94).Run the following test with JUnit Jupiter:
Expected: every invocation of
assertTimeout()rejects the overrun, sofalsePassesis zero.Actual: invocations whose two wall-clock reads fall in the same millisecond
return normally. The exact count is timing-dependent. On revision
f10989d3073dc0c6e3a1af4426fbd68ec4c119e4, the test above observed 27 falsepasses out of 100; a separate 50 us / 1 ns probe observed 94 out of 100.
AssertTimeoutconverts the requested
DurationwithtoMillis()and measures elapsed timeusing
System.currentTimeMillis(). Thus every positive timeout below onemillisecond becomes zero, elapsed time is quantized to milliseconds, and
wall-clock adjustments can affect the result.
The preemptive path has the inverse symptom.
PreemptiveTimeoutUtilscalls
Future.get(timeout.toMillis(), MILLISECONDS), so all positive valuesfrom 1 to 999,999 ns become the same zero-length poll. Whether a particular
no-op invocation succeeds is scheduler-dependent; the deterministic defect is
the conversion to
Future.get(0, MILLISECONDS), not a particular failure rate.This utility is used by both
assertTimeoutPreemptively()andSeparateThreadTimeoutInvocation,so
@Timeout(..., threadMode = SEPARATE_THREAD)has the same wait-precisionissue. Same-thread
@Timeoutuses a different path.Assertion failures also render a positive sub-millisecond timeout as
0 ms.Expected behavior is to honor the supplied
Durationprecision and to use amonotonic elapsed-time clock for non-preemptive assertions.
Compatibility points that should be decided explicitly:
integer-millisecond timeout that are smaller than one millisecond;
Duration.toNanos()overflows much earlier than the currenttoMillis()conversion, so a direct replacement would regress very large durations; and
Durationvalues are currently not rejected or covered bytests, so their behavior should be preserved deliberately or specified.
I searched open and closed issues and pull requests for combinations of
assertTimeout,toMillis,currentTimeMillis,nanoTime,nanoseconds,sub-millisecond,precision,truncation, andFuture.get. I did not finda directly matching public report. The closest result, #2239, concerns access
to the worker
Future/executor for diagnostics rather than timeout precision.Context
mainat17ce9f1d4fd65fb01f5f23c87c3daecf7a185c94This is not known to be a regression; the same implementation is present in
the inspected history.
Deliverables
assertTimeout()and the shared preemptive timeout path.assertTimeout().0 ms.(value, TimeUnit)passed toFuture.get().