From 48971139c020def9136a67a349abcdded2f46ef7 Mon Sep 17 00:00:00 2001 From: Sam Brannen <104798+sbrannen@users.noreply.github.com> Date: Mon, 17 Aug 2026 17:35:35 +0200 Subject: [PATCH 1/2] Exclude the .claude folder from nohttp scanning Without this exclusion, the Gradle build will fail (due to an OutOfMemoryError) for temporary git work trees residing in the .claude folder. --- .../java/org/springframework/build/CheckstyleConventions.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/buildSrc/src/main/java/org/springframework/build/CheckstyleConventions.java b/buildSrc/src/main/java/org/springframework/build/CheckstyleConventions.java index e916cefbd74..d23795182c8 100644 --- a/buildSrc/src/main/java/org/springframework/build/CheckstyleConventions.java +++ b/buildSrc/src/main/java/org/springframework/build/CheckstyleConventions.java @@ -64,7 +64,8 @@ public class CheckstyleConventions { NoHttpExtension noHttp = project.getExtensions().getByType(NoHttpExtension.class); noHttp.setAllowlistFile(project.file("src/nohttp/allowlist.lines")); noHttp.getSource().exclude("**/test-output/**", "**/.settings/**", "**/.classpath", - "**/.project", "**/.gradle/**", "**/node_modules/**", "**/spring-jcl/**", "buildSrc/build/**"); + "**/.project", "**/.gradle/**", "**/node_modules/**", "**/spring-jcl/**", "buildSrc/build/**", + ".claude/**"); List buildFolders = List.of("bin", "build", "out"); project.allprojects(subproject -> { Path rootPath = project.getRootDir().toPath(); From 15ef2b21f072c218cfb7dbc857ed8089b9652301 Mon Sep 17 00:00:00 2001 From: Sam Brannen <104798+sbrannen@users.noreply.github.com> Date: Mon, 17 Aug 2026 16:57:44 +0200 Subject: [PATCH 2/2] Handle synchronous exceptions from AsynchronousFileChannel#read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prior to this commit, DataBufferUtils$ReadCompletionHandler#read() invoked AsynchronousFileChannel#read(ByteBuffer, long, Attachment, CompletionHandler) without guarding against exceptions thrown directly by that call. Although that method is documented to report failures asynchronously via the supplied CompletionHandler, some platform- specific implementations can instead throw synchronously – for example, on Windows with JDK 25, when the JDK rejects a ByteBuffer backed by a closeable shared memory Arena, as produced by Netty 4.2's off-heap buffer allocation. When such an exception is thrown from a recursive read() invocation triggered from completed() – which happens once a resource requires more than a single chunk – the exception has no path back to the FluxSink: it escapes on whatever thread invoked the CompletionHandler, and the resulting Flux never signals onError or onComplete. In practice, this surfaced as an indefinite hang when serving a Resource whose HTTP response is not a ZeroCopyHttpOutputMessage, since ResourceHttpMessageWriter falls back to ResourceEncoder, which reads the resource via DataBufferUtils. To address that, this commit wraps the channel.read(...) call in a try/catch block and routes any non-fatal Throwable to the existing failed(Throwable, Attachment) handler, via Exceptions.throwIfFatal(), mirroring the equivalent fix already applied to the write side for gh-36184. This ensures the allocated DataBuffer is released and the Flux always terminates with a proper error signal instead of leaking a buffer or hanging silently. See gh-36184 Closes gh-37143 --- .../core/io/buffer/DataBufferUtils.java | 10 ++- .../core/io/buffer/DataBufferUtilsTests.java | 61 +++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/spring-core/src/main/java/org/springframework/core/io/buffer/DataBufferUtils.java b/spring-core/src/main/java/org/springframework/core/io/buffer/DataBufferUtils.java index 4554f269989..860c83c55f2 100644 --- a/spring-core/src/main/java/org/springframework/core/io/buffer/DataBufferUtils.java +++ b/spring-core/src/main/java/org/springframework/core/io/buffer/DataBufferUtils.java @@ -44,6 +44,7 @@ import org.jspecify.annotations.Nullable; import org.reactivestreams.Publisher; import org.reactivestreams.Subscriber; import org.reactivestreams.Subscription; +import reactor.core.Exceptions; import reactor.core.publisher.BaseSubscriber; import reactor.core.publisher.Flux; import reactor.core.publisher.FluxSink; @@ -1070,7 +1071,14 @@ public abstract class DataBufferUtils { Assert.state(iterator.hasNext(), "No ByteBuffer available"); ByteBuffer byteBuffer = iterator.next(); Attachment attachment = new Attachment(dataBuffer, iterator); - this.channel.read(byteBuffer, this.position.get(), attachment, this); + try { + this.channel.read(byteBuffer, this.position.get(), attachment, this); + } + catch (Throwable ex) { + Exceptions.throwIfFatal(ex); + // If the exception escapes, route it to the failure handler + failed(ex, attachment); + } } @Override diff --git a/spring-core/src/test/java/org/springframework/core/io/buffer/DataBufferUtilsTests.java b/spring-core/src/test/java/org/springframework/core/io/buffer/DataBufferUtilsTests.java index a4a352b4edd..238e64040bf 100644 --- a/spring-core/src/test/java/org/springframework/core/io/buffer/DataBufferUtilsTests.java +++ b/spring-core/src/test/java/org/springframework/core/io/buffer/DataBufferUtilsTests.java @@ -64,6 +64,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.willAnswer; +import static org.mockito.BDDMockito.willThrow; import static org.mockito.Mockito.mock; /** @@ -201,6 +202,66 @@ class DataBufferUtilsTests extends AbstractDataBufferAllocatingTests { .verify(Duration.ofSeconds(3)); } + @Test // gh-37143 + @SuppressWarnings("deprecation") // PooledByteBufAllocator no longer supports tinyCacheSize. + void readAsynchronousFileChannelReadThrowsSynchronously() { + // Use a dedicated allocator rather than a shared one from + // ParameterizedDataBufferAllocatingTest, since this test deliberately + // triggers a buffer leak and must not pollute the allocation counters + // that other tests rely on. + PooledByteBufAllocator allocator = new PooledByteBufAllocator(true, 1, 1, 4096, 4, 0, 0, 0, true); + super.bufferFactory = new NettyDataBufferFactory(allocator); + + // On some JDK/Netty combinations (e.g. Windows + JDK 25 + Netty 4.2), the + // AsynchronousFileChannel#read(ByteBuffer, long, A, CompletionHandler) call + // itself throws instead of invoking CompletionHandler#failed asynchronously. + AsynchronousFileChannel channel = mock(); + willThrow(new UnsupportedOperationException("simulated synchronous failure")) + .given(channel).read(any(), anyLong(), any(), any()); + + Flux result = + DataBufferUtils.readAsynchronousFileChannel(() -> channel, super.bufferFactory, 3); + + StepVerifier.create(result) + .expectError(UnsupportedOperationException.class) + .verify(Duration.ofSeconds(3)); + } + + @Test // gh-37143 + void readAsynchronousFileChannelReadThrowsSynchronouslyFromCompletionThread() throws Exception { + super.bufferFactory = new DefaultDataBufferFactory(); + + // Real AsynchronousFileChannel implementations invoke the CompletionHandler on a + // separate thread, not the calling thread. If a subsequent read (triggered + // recursively from within ReadCompletionHandler#completed on that other thread) + // throws synchronously, the exception never reaches the original Flux.create + // request() call stack, and thus never reaches the FluxSink. + var executor = Executors.newSingleThreadExecutor(); + try { + AsynchronousFileChannel channel = mock(); + willAnswer(invocation -> { + ByteBuffer byteBuffer = invocation.getArgument(0); + byteBuffer.put("foo".getBytes(StandardCharsets.UTF_8)); + Object attachment = invocation.getArgument(2); + CompletionHandler completionHandler = invocation.getArgument(3); + executor.submit(() -> completionHandler.completed(3, attachment)); + return null; + }).willThrow(new UnsupportedOperationException("simulated synchronous failure")) + .given(channel).read(any(), anyLong(), any(), any()); + + Flux result = + DataBufferUtils.readAsynchronousFileChannel(() -> channel, super.bufferFactory, 3); + + StepVerifier.create(result) + .consumeNextWith(stringConsumer("foo")) + .expectError(UnsupportedOperationException.class) + .verify(Duration.ofSeconds(3)); + } + finally { + executor.shutdown(); + } + } + @ParameterizedDataBufferAllocatingTest void readAsynchronousFileChannelCancel(DataBufferFactory bufferFactory) throws Exception { super.bufferFactory = bufferFactory;