Skip to content

Timeout assertions lose sub-millisecond precision #6053

Description

@aoto-tech

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

  • Preserve sub-millisecond precision in assertTimeout() and the shared preemptive timeout path.
  • Use a monotonic clock to measure elapsed time in assertTimeout().
  • Report positive sub-millisecond timeout values without displaying 0 ms.
  • Add deterministic regression tests for elapsed-duration comparison and the (value, TimeUnit) passed to Future.get().
  • Cover whole-millisecond boundaries, very large durations, and the chosen zero/negative-duration behavior.
  • Document the compatibility change if accepted.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions