Fix busy-spin in SubscriberInputStream await() - #37159
Conversation
LockSupport.park() can return without resume() having set the parked thread reference to READY, either spuriously or when the thread is interrupted. The reference then still points to the current thread, so the compareAndSet in the await() loop can never succeed again and the loop neither parks nor exits, spinning on CPU until the upstream emits the next signal. On virtual threads this is particularly harmful since a spinning thread never yields its carrier. Once as many requests spin as there are carrier threads, unrelated virtual thread tasks are no longer scheduled. The interrupt case is reachable through Spring MVC async request handling, where both a timeout and a client disconnect cancel the task with interruption. Park again when the reference is still owned by the current thread after a spurious wakeup. On interruption, clear the reference and propagate the cancellation, unless data has arrived concurrently, in which case the data is delivered first and the interrupt status is preserved for the next call. Closes spring-projectsgh-37159 Signed-off-by: donggyu <donggyu@flex.team>
582211e to
493fb15
Compare
LockSupport.park() can return without resume() having set the parked thread reference to READY, either spuriously or when the thread is interrupted. The reference then still points to the current thread, so the compareAndSet in the await() loop can never succeed again and the loop neither parks nor exits, spinning on CPU until the upstream emits the next signal. On virtual threads this is particularly harmful since a spinning thread never yields its carrier. Once as many requests spin as there are carrier threads, unrelated virtual thread tasks are no longer scheduled. The interrupt case is reachable through Spring MVC async request handling, where both a timeout and a client disconnect cancel the task with interruption. Park again when the reference is still owned by the current thread after a spurious wakeup. On interruption, restore the interrupt status, clear the reference, and propagate the cancellation as an InterruptedIOException, unless data has arrived concurrently, in which case the data is delivered first and the interrupt status is preserved for the next call. The read() methods rethrow IOExceptions as-is instead of wrapping them, so the interruption surfaces as a regular IOException to callers. Closes spring-projectsgh-37159 Signed-off-by: donggyu <donggyu@flex.team>
493fb15 to
7e53799
Compare
|
JAIPilot Cloud reviewed exact head 7e53799 and found one focused gap: the interrupt fix changes both SubscriberInputStream implementations, while the PR regression test covers only spring-web. I opened a test-only draft against the contributor branch: flex-donggyu#1 The identical focused baseline/candidate command passed for DataBufferUtilsTests and SubscriberInputStreamTests, and spring-core:check plus spring-web:check passed in the managed sandbox. No production refactor is included in the offer. AI-assisted; please review and squash/cherry-pick with the required DCO sign-off if useful. |
|
Added the same regression test for the spring-core copy (through |
SubscriberInputStream.await()parks the reader withLockSupport.park()after storing the current thread inparkedThread. Whenpark()returns withoutresume()having setREADY— spurious wakeup or interrupt — the reference still points to the current thread, so the loop can neither exit nor park again (compareAndSet(null, ...)always fails). The thread spins at 100% CPU until the next upstream signal. And sincepark()doesn't consume the interrupt flag, every laterawait()call on that thread spins the same way.The interrupt case is easy to hit through Spring MVC async handling: both the async timeout and a client disconnect cancel the task with
Future.cancel(true)(CallableInterceptorChain#cancelTask). WithStreamingResponseBodyproxying a slow upstream, the worker is usually parked inawait()at that moment.On virtual threads a spinning thread never yields its carrier, so a few cancelled streaming requests can starve the whole scheduler — we traced a production outage (liveness probe kills) to this.
Reproduced on JDK 21 and 24: after the interrupt the thread stays
RUNNABLE, burning a full core (~13M loop iterations in 200ms).Fix
InterruptedIOException— unless data arrived concurrently, in which case it is delivered first and the thread stays interrupted for the next call. Theread()methods rethrowIOExceptions as-is instead of wrapping them, so the interruption surfaces to callers as a regularIOException.Applied to both near-duplicate classes (spring-web and spring-core), with a regression test that fails on the old code.
Related: #35978 fixed a separate defect in
resume()in this class; theawait()interrupt path wasn't covered by it.