You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@hc.apache.org by ol...@apache.org on 2026/09/30 19:11:05 UTC

(httpcomponents-core) branch master updated: Send RST_STREAM for a cancelled half-closed stream

This is an automated email from the ASF dual-hosted git repository.

ok2c pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/httpcomponents-core.git


The following commit(s) were added to refs/heads/master by this push:
     new 23dc93388 Send RST_STREAM for a cancelled half-closed stream
23dc93388 is described below

commit 23dc93388016a3aaf68811591d36e96ef52f4160
Author: Ariel Rodriguez <ar...@recargapay.com>
AuthorDate: Tue Sep 29 15:49:32 2026 -0300

    Send RST_STREAM for a cancelled half-closed stream
    
    A cancelled stream was reset only when a frame arrived from the peer. A
    stream that had already sent END_STREAM produces no more output, so a
    silent peer never received RST_STREAM(CANCEL).
    
    The multiplexer now resets such a stream on output. RST_STREAM is not
    subject to flow control, so the reset does not wait for the connection
    output window.
    
    Generated-by: Claude Code (Claude Opus 5.5)
---
 .../impl/nio/AbstractH2StreamMultiplexer.java      |   6 ++
 .../apache/hc/core5/http2/impl/nio/H2Stream.java   |  10 ++
 .../impl/nio/TestAbstractH2StreamMultiplexer.java  | 112 +++++++++++++++++++++
 3 files changed, 128 insertions(+)

diff --git a/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/AbstractH2StreamMultiplexer.java b/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/AbstractH2StreamMultiplexer.java
index 83fb7d248..4c5a5c9d3 100644
--- a/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/AbstractH2StreamMultiplexer.java
+++ b/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/AbstractH2StreamMultiplexer.java
@@ -526,6 +526,11 @@ public final void onOutput() throws HttpException, IOException {
 
             if (connOutputWindow.get() > 0 && remoteSettingState == SettingsHandshake.ACKED) {
                 produceOutput();
+            } else {
+                // RST_STREAM is not subject to flow control
+                for (final Iterator<H2Stream> it = streams.iterator(); it.hasNext(); ) {
+                    it.next().resetIfCancelled();
+                }
             }
             final int pendingOutputRequests = outputRequests.get();
             boolean outputPending = false;
@@ -1318,6 +1323,7 @@ private void consumeSettingsFrame(final ByteBuffer payload) throws IOException {
     private void produceOutput() throws HttpException, IOException {
         for (final Iterator<H2Stream> it = streams.iterator(); it.hasNext(); ) {
             final H2Stream stream = it.next();
+            stream.resetIfCancelled();
             if (!stream.isLocalClosed() && !stream.isReserved() && stream.getOutputWindow().get() > 0) {
                 stream.produceOutput();
             }
diff --git a/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/H2Stream.java b/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/H2Stream.java
index f946a3665..88081f695 100644
--- a/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/H2Stream.java
+++ b/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/H2Stream.java
@@ -266,6 +266,16 @@ void localResetCancelled() throws IOException {
         localReset(new H2StreamResetException(H2Error.CANCEL, "Cancelled"));
     }
 
+    /**
+     * Resets a cancelled stream that has already sent END_STREAM. Such a stream
+     * produces no more output, so it would otherwise wait for a peer frame.
+     */
+    void resetIfCancelled() throws IOException {
+        if (cancelled.get() && channel.isLocalClosed() && !isClosed()) {
+            localResetCancelled();
+        }
+    }
+
     void handle(final HttpException ex) throws IOException, HttpException {
         handler.handle(ex, remoteClosed);
     }
diff --git a/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/impl/nio/TestAbstractH2StreamMultiplexer.java b/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/impl/nio/TestAbstractH2StreamMultiplexer.java
index 8334041cb..f66064459 100644
--- a/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/impl/nio/TestAbstractH2StreamMultiplexer.java
+++ b/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/impl/nio/TestAbstractH2StreamMultiplexer.java
@@ -1196,6 +1196,118 @@ void testStreamIdleTimeoutTriggersH2StreamTimeoutException() throws Exception {
 
     }
 
+    @Test
+    void testAbortAfterLocalEndStreamSendsRstStreamWithoutInboundFrames() throws Exception {
+        final List<byte[]> writes = new ArrayList<>();
+        Mockito.when(protocolIOSession.write(ArgumentMatchers.any(ByteBuffer.class)))
+                .thenAnswer(inv -> {
+                    final ByteBuffer b = inv.getArgument(0, ByteBuffer.class);
+                    final byte[] copy = new byte[b.remaining()];
+                    b.get(copy);
+                    writes.add(copy);
+                    return copy.length;
+                });
+        Mockito.doNothing().when(protocolIOSession).setEvent(ArgumentMatchers.anyInt());
+        Mockito.doNothing().when(protocolIOSession).clearEvent(ArgumentMatchers.anyInt());
+
+        final H2Config h2Config = H2Config.custom().build();
+        final H2StreamMultiplexerImpl mux = new H2StreamMultiplexerImpl(
+                protocolIOSession, FRAME_FACTORY, StreamIdGenerator.ODD,
+                httpProcessor, CharCodingConfig.DEFAULT, h2Config, h2StreamListener, () -> streamHandler);
+
+        mux.onConnect();
+        final WritableByteChannelMock writable = new WritableByteChannelMock(256);
+        final FrameOutputBuffer fob = new FrameOutputBuffer(16 * 1024);
+        fob.write(new RawFrame(FrameType.SETTINGS.getValue(), 0, 0, null), writable);
+        mux.onInput(ByteBuffer.wrap(writable.toByteArray()));
+        writes.clear();
+
+        // A request without a body: HEADERS carry END_STREAM, so the stream is half-closed (local)
+        final H2StreamChannel channel = mux.createChannel(1);
+        final List<Header> requestHeaders = Arrays.asList(
+                new BasicHeader(":method", "GET"),
+                new BasicHeader(":scheme", "https"),
+                new BasicHeader(":path", "/"),
+                new BasicHeader(":authority", "example.test"));
+        final H2Stream stream = mux.createStream(channel, new PriorityHeaderSender(channel, requestHeaders, true));
+        mux.onOutput();
+        Assertions.assertTrue(stream.isLocalClosed());
+        writes.clear();
+
+        // The request gets cancelled while the peer stays silent
+        stream.abort();
+        mux.onOutput();
+
+        final List<FrameStub> frames = parseFrames(concat(writes));
+        final FrameStub rst = frames.stream()
+                .filter(f -> f.type == FrameType.RST_STREAM.getValue() && f.streamId == 1)
+                .findFirst()
+                .orElse(null);
+        Assertions.assertNotNull(rst, "RST_STREAM not emitted for the cancelled stream");
+        Assertions.assertEquals(H2Error.CANCEL.getCode(), ByteBuffer.wrap(rst.payload).getInt());
+        Assertions.assertTrue(channel.isLocalReset());
+    }
+
+    @Test
+    void testAbortAfterLocalEndStreamSendsRstStreamWithNoConnectionWindow() throws Exception {
+        final List<byte[]> writes = new ArrayList<>();
+        Mockito.when(protocolIOSession.write(ArgumentMatchers.any(ByteBuffer.class)))
+                .thenAnswer(inv -> {
+                    final ByteBuffer b = inv.getArgument(0, ByteBuffer.class);
+                    final byte[] copy = new byte[b.remaining()];
+                    b.get(copy);
+                    writes.add(copy);
+                    return copy.length;
+                });
+        Mockito.doNothing().when(protocolIOSession).setEvent(ArgumentMatchers.anyInt());
+        Mockito.doNothing().when(protocolIOSession).clearEvent(ArgumentMatchers.anyInt());
+
+        final H2Config h2Config = H2Config.custom().build();
+        final H2StreamMultiplexerImpl mux = new H2StreamMultiplexerImpl(
+                protocolIOSession, FRAME_FACTORY, StreamIdGenerator.ODD,
+                httpProcessor, CharCodingConfig.DEFAULT, h2Config, h2StreamListener, () -> streamHandler);
+
+        mux.onConnect();
+        final WritableByteChannelMock writable = new WritableByteChannelMock(256);
+        final FrameOutputBuffer fob = new FrameOutputBuffer(16 * 1024);
+        fob.write(new RawFrame(FrameType.SETTINGS.getValue(), 0, 0, null), writable);
+        mux.onInput(ByteBuffer.wrap(writable.toByteArray()));
+
+        final List<Header> requestHeaders = Arrays.asList(
+                new BasicHeader(":method", "GET"),
+                new BasicHeader(":scheme", "https"),
+                new BasicHeader(":path", "/"),
+                new BasicHeader(":authority", "example.test"));
+        // Stream 1: a request without a body, half-closed (local) once its HEADERS are sent
+        final H2StreamChannel channel = mux.createChannel(1);
+        final H2Stream stream = mux.createStream(channel, new PriorityHeaderSender(channel, requestHeaders, true));
+        // Stream 3: a request with a body that uses up the connection output window
+        final H2StreamChannel uploadChannel = mux.createChannel(3);
+        mux.createStream(uploadChannel, new PriorityHeaderSender(uploadChannel, requestHeaders, false));
+        mux.onOutput();
+        Assertions.assertTrue(stream.isLocalClosed());
+
+        final ByteBuffer body = ByteBuffer.allocate(h2Config.getInitialWindowSize());
+        while (body.hasRemaining()) {
+            Assertions.assertTrue(uploadChannel.write(body) > 0, "connection window used up too early");
+        }
+        Assertions.assertEquals(0, uploadChannel.write(ByteBuffer.allocate(1)), "connection window must be 0");
+        writes.clear();
+
+        // The request gets cancelled while the peer stays silent and grants no window
+        stream.abort();
+        mux.onOutput();
+
+        final List<FrameStub> frames = parseFrames(concat(writes));
+        final FrameStub rst = frames.stream()
+                .filter(f -> f.type == FrameType.RST_STREAM.getValue() && f.streamId == 1)
+                .findFirst()
+                .orElse(null);
+        Assertions.assertNotNull(rst, "RST_STREAM not emitted while the connection window is 0");
+        Assertions.assertEquals(H2Error.CANCEL.getCode(), ByteBuffer.wrap(rst.payload).getInt());
+        Assertions.assertTrue(channel.isLocalReset());
+    }
+
     @Test
     void testResetIfExpiredResetsStreamPastDeadline() throws Exception {
         final H2Config h2Config = H2Config.custom().build();