fix(http2): close a streaming response body on every exit path

Http2ResponseWriter.streamBody (a streaming response's InputStream, driven
across startFlowControlled/resume as flow-control windows allow) was never
closed anywhere -- not on clean EOF, not on a write failure, not when the
stream is abandoned (RST_STREAM from the peer, connection teardown). Same
bug Http1ResponseWriter had before cf16be0, just never given the same fix: a
handler stream that releases a held resource (a pooled backend connection,
for a reverse proxy) from close() leaks it under any real amount of stream
resets or aborted connections.

- appendData closes streamBody once `end` is reached (clean completion) and
  on any IOException from the read itself, mirroring Http1ResponseWriter's
  relayAndClose/writeChunkedAndClose reasoning.
- New Http2ResponseWriter#abort(), called from Http2Stream#cancel() --
  symmetric with that method's existing http2Body.cancel() for the inbound
  leg, now covering the outbound one too. cancel() is the single hook every
  abandoned-stream path (RST_STREAM handling and connection teardown in
  Http2Connection, plus Http2StreamDispatcher) already goes through, so this
  covers every abort case without adding a new one.

closeStreamBodyQuietly() is idempotent (nulls streamBody after closing), so
the appendData and abort() close paths can't double-close or race.

Four new Http2ResponseWriterTest cases: normal completion in one call,
completion across a resume() (multiple flow-control windows), abort() while
still streaming, and abort() as a no-op on a non-streaming response. 697/697
flash-module tests green.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011LLwcyHUnbApCrY33gvgoa
This commit is contained in:
Zakaria El Orche
2026-08-14 22:51:16 +00:00
co-authored by Claude Sonnet 5
parent 787ae610d4
commit e5bec59410
3 changed files with 134 additions and 17 deletions
@@ -13,6 +13,8 @@ import dev.relism.flash.http2.hpack.HpackDecoder;
import dev.relism.flash.models.Response;
import dev.relism.fpr.core.ByteView;
import java.io.ByteArrayInputStream;
import java.io.FilterInputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
@@ -149,6 +151,78 @@ class Http2ResponseWriterTest {
assertTrue(writer.trailerHeadersInBatch());
}
/**
* Regression for the connection leak fixed alongside {@code Http1ResponseWriter}'s identical
* bug ({@code cf16be0}): {@code streamBody} was never closed on any exit path, so a handler
* stream releasing a held resource (a pooled backend connection, for a reverse proxy) from
* {@code close()} leaked it.
*/
@Test
void startFlowControlled_closesStreamBodyOnceFullyDrainedInOneCall() throws Exception {
TrackingInputStream body = new TrackingInputStream(new byte[] {1, 2, 3, 4});
Response response = new Response(200, ContentType.BINARY).stream(body, 4);
Http2ResponseWriter writer = new Http2ResponseWriter();
writer.startFlowControlled(response, 1, false, false, true, false, false, 16_384, 4096, 16_384);
assertTrue(writer.finished());
assertTrue(body.closed, "streamBody must be closed once the response finished normally");
}
@Test
void resume_closesStreamBodyOnceFullyDrainedAcrossFlowControlWindows() throws Exception {
TrackingInputStream body = new TrackingInputStream(new byte[] {1, 2, 3, 4});
Response response = new Response(200, ContentType.BINARY).stream(body, 4);
Http2ResponseWriter writer = new Http2ResponseWriter();
// availableFlowWindow of 2 forces a second batch via resume() to drain the remaining bytes.
writer.startFlowControlled(response, 1, false, false, true, false, false, 16_384, 4096, 2);
assertFalse(writer.finished());
assertFalse(body.closed, "must not close before the response actually finishes");
writer.resume(16_384, 16_384);
assertTrue(writer.finished());
assertTrue(body.closed);
}
@Test
void abort_closesAStillOpenStreamBody() throws Exception {
TrackingInputStream body = new TrackingInputStream(new byte[] {1, 2, 3, 4});
Response response = new Response(200, ContentType.BINARY).stream(body, 4);
Http2ResponseWriter writer = new Http2ResponseWriter();
// availableFlowWindow of 0: prepares headers only, the body is still fully unread.
writer.startFlowControlled(response, 1, false, false, true, false, false, 16_384, 4096, 0);
assertFalse(writer.finished());
assertFalse(body.closed);
writer.abort();
assertTrue(body.closed, "an abandoned stream (RST_STREAM/connection teardown) must still close streamBody");
}
@Test
void abort_isANoOpForANonStreamingResponse() {
Http2ResponseWriter writer = new Http2ResponseWriter();
// No streamBody was ever set — must not throw.
writer.abort();
}
private static final class TrackingInputStream extends FilterInputStream {
boolean closed;
TrackingInputStream(byte[] data) {
super(new ByteArrayInputStream(data));
}
@Override
public void close() throws IOException {
closed = true;
super.close();
}
}
private static Parsed parse(Http2ResponseWriter writer) {
Parsed parsed = new Parsed();
byte[] wire = writer.buffer();