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(); 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;