A team adds preemptive timeout assertions (JUnit 5's assertTimeoutPreemptively) around several service calls in their integration tests. Tests that previously passed now fail with missing authentication and detached-entity errors, and one build leaves a thread running after the failure. Explain what is going on and how you would fix it.
answer
- separate thread ⇒ ThreadLocal state invisible
- TransactionSynchronizationManager + SecurityContextHolder + MDC
- cancel(true) = interrupt only ⇒ leaked thread
- rollback lost ⇒ rows leak into later tests
- prefer assertTimeout / real client timeouts / @Timeout policy
basics
~20 sPreemptive timeouts run the block on a separate thread, so ThreadLocal state — Spring's transaction and persistence context, the security context, MDC — is not visible there, and cancellation is only an interrupt, so uninterruptible work keeps running. Fix: use plain assertTimeout, or propagate context explicitly.
solid answer
~50 s`assertTimeoutPreemptively` executes the block on an executor thread and waits with a deadline. Two consequences produce exactly these symptoms. First, **thread affinity**. Spring binds transactions and the JPA `EntityManager` through `TransactionSynchronizationManager`, and Spring Security's default `SecurityContextHolder` strategy is `ThreadLocal`; MDC and request-scoped beans are the same. The worker thread inherits none of it, so authentication is null, entities are detached (`LazyInitializationException`), and writes may commit outside the test's rolled-back transaction. Second, **cancellation is cooperative**. On timeout JUnit cancels the future, which merely interrupts. Code that ignores interrupts keeps running after the test has failed — the leaked thread you observed — still holding locks or pooled connections and able to corrupt later tests. Fix: switch these assertions to `assertTimeout`, which runs on the test thread and preserves all context. If hang protection is genuinely needed, put real timeouts in the client/lock code, or use `@Timeout` as policy. Only if you must cross threads should you propagate context deliberately (`MODE_INHERITABLETHREADLOCAL`, `DelegatingSecurityContextExecutor`, MDC copy).
code
java · 10 lines// Breaks: runs on an executor thread, no tx / no security context
assertTimeoutPreemptively(Duration.ofSeconds(2),
() -> orderService.place(cart));
// Safe: same thread, transaction + SecurityContext + MDC intact
assertTimeout(Duration.ofSeconds(2),
() -> orderService.place(cart));
// If a hang is the real fear, fix the client instead:
HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(2)).build();go deeper
Say that preemptive timeouts run the code on another thread, which loses thread-local state such as the transaction and login, and that plain assertTimeout avoids it.
Name the specific holders (TransactionSynchronizationManager, SecurityContextHolder, MDC) and explain the detached-entity and lost-rollback symptoms.
Add cooperative cancellation and thread leakage, explain cross-test contamination from committed writes, and drive the fix toward real client timeouts plus non-preemptive assertions.
Frame it as an execution-context policy question: where hang protection belongs (production timeouts and suite policy), what context propagation costs to maintain, and how to keep such patterns out of the codebase via review or a lint rule.
## The mechanism `Assertions.assertTimeoutPreemptively(Duration, Executable)` does not time the block in place. It submits it to a separate thread and waits on the result with a deadline. If the deadline wins, JUnit throws `AssertionFailedError: execution timed out after N ms` and cancels the task. Everything surprising about the method follows from those two facts: **different thread** and **cooperative cancellation**. ## Symptom 1 — missing authentication Spring Security's `SecurityContextHolder` defaults to `MODE_THREADLOCAL`. A test that authenticates via `@WithMockUser`, a `SecurityContextHolder.setContext(...)` call, or a `TestSecurityContextHolder` populates that thread-local **on the test thread**. Service code invoked from the worker thread reads an empty context, so `@PreAuthorize` denies access or code dereferences a null principal. Nothing in the stack trace mentions timeouts. ## Symptom 2 — detached entities and lost rollback Spring's transactional test support opens a transaction on the test thread and registers the JPA `EntityManager`/Hibernate `Session` in `TransactionSynchronizationManager`, a thread-local registry. On the worker thread: - there is no bound `EntityManager`, so a repository method either starts its **own** transaction (which commits for real, defeating the test's rollback and leaving rows behind for the next test) or fails outright depending on propagation; - entities loaded on the test thread are detached from the worker's point of view, so touching a lazy association throws `LazyInitializationException`. This class of bug is nasty because it can make a test pass while silently writing to the database, poisoning unrelated tests later in the run. ## Symptom 3 — the leaked thread `Future.cancel(true)` sets the worker's interrupt flag. It cannot stop code that never observes it: a CPU-bound loop, a `synchronized` block waiting on a monitor, a blocking native call, or a socket read with no `SO_TIMEOUT`. After the assertion fails, that thread is still executing test code — still holding a database connection from the pool, possibly still holding an application lock, possibly about to write output that the *next* test observes. The failure you see and the failure you diagnose can be in different tests. A related nuisance: the interrupt may surface later as an `InterruptedException` inside library code, producing a confusing secondary stack trace unrelated to the timing problem. ## Other thread-bound state to watch - SLF4J/Logback **MDC** — correlation ids vanish from log lines produced on the worker thread, which is why some CI log-assertion tests break. - `RequestContextHolder` and request/session-scoped beans. - Tracing agents that keep the current span in a thread-local. - Anything your own codebase stores in a `ThreadLocal`, including tenant ids and feature-flag overrides. ## Fixes, in order of preference **1. Use `assertTimeout` instead.** The non-preemptive form runs on the test thread, so every binding above stays valid. You lose hang protection and the slow block runs to completion, but for an integration test that is almost always the right trade: you still detect "this got 10x slower", without inventing a second execution context. **2. Move the timeout into production code where it belongs.** If the fear is a genuine hang, the real defect is usually a missing timeout: an HTTP client without connect/read timeouts, a `lock()` that should be `tryLock(timeout)`, a `queue.take()` that should be `poll(timeout)`, a JDBC statement without `queryTimeout`. Fixing that makes production safe too — a test-side preemptive assertion protects only the build. **3. Use declarative policy for hang protection.** `@Timeout` on a method or class, or a global timeout configuration parameter for the whole suite, expresses "no test may run longer than N" as policy in one place, and (since Jupiter 5.9) lets you choose the thread mode explicitly. That is a better home for watchdog behaviour than assertions scattered through test bodies. **4. If you truly must cross threads, propagate context explicitly.** Options include `SecurityContextHolder.setStrategyName(MODE_INHERITABLETHREADLOCAL)` or wrapping executors with `DelegatingSecurityContextExecutor`, copying the MDC with `MDC.getCopyOfContextMap()`, and avoiding lazy loading by fetching eagerly or using DTOs. Treat this as a last resort: you are now maintaining a parallel context-propagation mechanism just to satisfy a test assertion. ## How to spot it in review A quick grep for `assertTimeoutPreemptively` in a Spring codebase is worth doing periodically. Any occurrence inside a `@SpringBootTest`, `@DataJpaTest` or `@Transactional` class deserves justification; occurrences around pure algorithmic code (parsers, regex, state machines) are usually legitimate, because those are exactly the hang-prone, context-free cases the method was designed for.
- The team says they need preemptive timeouts because a downstream HTTP call sometimes hangs the build. What do you propose?Configure real connect and read timeouts on the HTTP client, and in tests point it at a stubbed server (WireMock or MockWebServer) so no real network hang is possible. If a suite-level watchdog is still wanted, express it once as a @Timeout policy or a global timeout configuration parameter rather than as per-call assertions that alter the execution thread.
- Why can a preemptive timeout in a transactional test cause a *different* test to fail later?The block runs outside the test's transaction, so its writes commit for real instead of being rolled back, leaving rows behind. Additionally, the interrupted worker thread may keep running and continue mutating state or holding a pooled connection after the assertion failed. Both effects surface as unrelated failures further down the run, which is why the diagnosis is expensive.
saying these in an interview costs you the question
- Assuming a separate thread inherits the caller's ThreadLocal values
- Believing the timed-out block is guaranteed to be dead once the assertion fails
- Reaching for MODE_INHERITABLETHREADLOCAL as a first fix rather than avoiding the thread hop
- Treating a test-side timeout as a substitute for client-side connect/read timeouts