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

pjfanning pushed a commit to branch 1.4.x
in repository https://gitbox.apache.org/repos/asf/pekko-http.git


The following commit(s) were added to refs/heads/1.4.x by this push:
     new 4c7c3c5e5 ensure end is called on inflaters and deflaters (1.4 branch) 
(#1150)
4c7c3c5e5 is described below

commit 4c7c3c5e5286e322678db4377dd78cdb60cea469
Author: PJ Fanning <[email protected]>
AuthorDate: Fri Jul 10 20:09:17 2026 +0100

    ensure end is called on inflaters and deflaters (1.4 branch) (#1150)
    
    * GzipDecompressor: fix inflater memory leak (#1134)
    
    * Initial plan
    
    * Apply remaining changes
    
    * Update GzipSpec.scala
    
    * Update GzipCompressor.scala
    
    ---------
    
    Co-authored-by: copilot-swe-agent[bot] 
<[email protected]>
    
    * Ensure deflater/inflater end() is called in DeflateCompressor and 
GzipCompressor (#1142)
    
    * Ensure deflater/inflater end() is called in DeflateCompressor and 
GzipCompressor
    
    - DeflateCompressor: add deflaterEnded flag + idempotent endDeflater() 
method;
      call endDeflater() in finishWithBuffer() instead of deflater.end() 
directly
    - GzipDecompressor: add postStop() calling inflater.end() + add 
createInflater()
      factory method for testability
    - StreamUtils.byteStringTransformer: add optional cleanup callback, call it 
in
      postStop() when the stage is stopped before onUpstreamFinish()
    - Encoder.singleUseEncoderFlow: pass cleanup callback that calls 
endDeflater()
      on DeflateCompressor instances to handle stream cancellation/failure
    - DeflateSpec: add tests for deflater cleanup on normal finish and 
cancellation
    - GzipSpec: add tests for inflater cleanup on normal finish and 
cancellation,
      and deflater cleanup on normal finish and cancellation
    
    * compile issues
    
    * Add explicit cleanup() to Compressor/DeflateCompressor; GzipCompressor 
inherits it
    
    * revert GzipCompressor changes
    
    * Update GzipSpec.scala
    
    * Update GzipSpec.scala
    
    * Update GzipSpec.scala
    
    * review comments
    
    * Refactor onUpstreamFinish method for clarity
    
    ---------
    
    Co-authored-by: copilot-swe-agent[bot] 
<[email protected]>
    
    * compile issues
    
    * test compile issue
    
    * Update GzipCompressor.scala
    
    ---------
    
    Co-authored-by: copilot-swe-agent[bot] 
<[email protected]>
---
 .../apache/pekko/http/impl/util/StreamUtils.scala  |  11 +-
 .../pekko/http/scaladsl/coding/DeflateSpec.scala   |  44 ++++++++
 .../pekko/http/scaladsl/coding/GzipSpec.scala      | 113 ++++++++++++++++++++-
 .../pekko/http/scaladsl/coding/Deflate.scala       |   2 +-
 .../http/scaladsl/coding/DeflateCompressor.scala   |  11 +-
 .../pekko/http/scaladsl/coding/Encoder.scala       |   5 +-
 .../apache/pekko/http/scaladsl/coding/Gzip.scala   |   2 +-
 .../http/scaladsl/coding/GzipCompressor.scala      |  22 +++-
 8 files changed, 201 insertions(+), 9 deletions(-)

diff --git 
a/http-core/src/main/scala/org/apache/pekko/http/impl/util/StreamUtils.scala 
b/http-core/src/main/scala/org/apache/pekko/http/impl/util/StreamUtils.scala
index 013b08e65..cd29412b1 100644
--- a/http-core/src/main/scala/org/apache/pekko/http/impl/util/StreamUtils.scala
+++ b/http-core/src/main/scala/org/apache/pekko/http/impl/util/StreamUtils.scala
@@ -44,12 +44,18 @@ private[http] object StreamUtils {
    * Creates a transformer that will call `f` for each incoming ByteString and 
output its result. After the complete
    * input has been read it will call `finish` once to determine the final 
ByteString to post to the output.
    * Empty ByteStrings are discarded.
+   * If the stage is stopped before the input is fully consumed (e.g. on 
downstream cancellation or upstream failure),
+   * `cleanup` is called to release any resources held by the transformer.
    */
   def byteStringTransformer(
-      f: ByteString => ByteString, finish: () => ByteString): 
GraphStage[FlowShape[ByteString, ByteString]] =
+      f: ByteString => ByteString,
+      finish: () => ByteString,
+      cleanup: () => Unit = () => ()): GraphStage[FlowShape[ByteString, 
ByteString]] =
     new SimpleLinearGraphStage[ByteString] {
       override def createLogic(inheritedAttributes: Attributes): 
GraphStageLogic =
         new GraphStageLogic(shape) with InHandler with OutHandler {
+          private var finished = false
+
           override def onPush(): Unit = {
             val data = f(grab(in))
             if (data.nonEmpty) push(out, data)
@@ -60,10 +66,13 @@ private[http] object StreamUtils {
 
           override def onUpstreamFinish(): Unit = {
             val data = finish()
+            finished = true
             if (data.nonEmpty) emit(out, data)
             completeStage()
           }
 
+          override def postStop(): Unit = if (!finished) cleanup()
+
           setHandlers(in, out, this)
         }
     }
diff --git 
a/http-tests/src/test/scala/org/apache/pekko/http/scaladsl/coding/DeflateSpec.scala
 
b/http-tests/src/test/scala/org/apache/pekko/http/scaladsl/coding/DeflateSpec.scala
index 8d86bd40f..03f4eabcd 100644
--- 
a/http-tests/src/test/scala/org/apache/pekko/http/scaladsl/coding/DeflateSpec.scala
+++ 
b/http-tests/src/test/scala/org/apache/pekko/http/scaladsl/coding/DeflateSpec.scala
@@ -92,11 +92,41 @@ class DeflateSpec extends CoderSpec {
       decodeWith(inflater, streamEncode(smallTextBytes).dropRight(5))
       inflater.endCalls.get() shouldEqual 1
     }
+    "release the deflater when encoding completes" in {
+      val tracking = new TrackingDeflater
+      Source.single(smallTextBytes)
+        .via(encoderWith(tracking).encoderFlow)
+        .runWith(Sink.ignore)
+        .awaitResult(3.seconds.dilated)
+      tracking.awaitEnd(3.seconds.dilated)
+      tracking.endCalls.get() shouldEqual 1
+    }
+    "release the deflater when encoding is cancelled early" in {
+      val tracking = new TrackingDeflater
+      Source.single(largeTextBytes)
+        .via(encoderWith(tracking).encoderFlow)
+        .take(1)
+        .runWith(Sink.ignore)
+        .awaitResult(3.seconds.dilated)
+      // postStop() (which calls end()) is dispatched to the stage actor after 
the
+      // Sink.ignore future completes, so we must wait for end() itself rather 
than
+      // for the stream future to avoid a race.
+      tracking.awaitEnd(3.seconds.dilated)
+      tracking.endCalls.get() shouldEqual 1
+    }
   }
 
   private def decodeWith(inflater: TrackingInflater, bytes: ByteString): 
ByteString =
     
decoderWith(inflater).decode(bytes)(SystemMaterializer(system).materializer).awaitResult(3.seconds.dilated)
 
+  @nowarn("msg=deprecated")
+  private def encoderWith(tracking: TrackingDeflater): Deflate =
+    new Deflate(Encoder.DefaultFilter) {
+      override def newCompressor: DeflateCompressor = new DeflateCompressor() {
+        override protected lazy val deflater: java.util.zip.Deflater = tracking
+      }
+    }
+
   @nowarn("msg=deprecated")
   private def decoderWith(inflater: TrackingInflater): StreamDecoder =
     new StreamDecoder {
@@ -123,6 +153,20 @@ class DeflateSpec extends CoderSpec {
     }
   }
 
+  private class TrackingDeflater extends 
java.util.zip.Deflater(Deflater.DEFAULT_COMPRESSION, false) {
+    val endCalls = new AtomicInteger
+    private val endLatch = new CountDownLatch(1)
+
+    def awaitEnd(atMost: FiniteDuration): Unit =
+      endLatch.await(atMost.toMillis, TimeUnit.MILLISECONDS)
+
+    override def end(): Unit = {
+      endCalls.incrementAndGet()
+      endLatch.countDown()
+      super.end()
+    }
+  }
+
   private def encodeMessage(request: HttpRequest, compressionLevel: Int, 
noWrap: Boolean): HttpRequest = {
     @nowarn("msg=deprecated .* is internal API")
     val deflaterWithoutWrapping = new Deflate(Encoder.DefaultFilter) {
diff --git 
a/http-tests/src/test/scala/org/apache/pekko/http/scaladsl/coding/GzipSpec.scala
 
b/http-tests/src/test/scala/org/apache/pekko/http/scaladsl/coding/GzipSpec.scala
index 79f9ea604..6d2f7802d 100644
--- 
a/http-tests/src/test/scala/org/apache/pekko/http/scaladsl/coding/GzipSpec.scala
+++ 
b/http-tests/src/test/scala/org/apache/pekko/http/scaladsl/coding/GzipSpec.scala
@@ -14,10 +14,20 @@
 package org.apache.pekko.http.scaladsl.coding
 
 import java.io.{ InputStream, OutputStream }
-import java.util.zip.{ GZIPInputStream, GZIPOutputStream, ZipException }
+import java.nio.charset.StandardCharsets
+import java.util.concurrent.{ CountDownLatch, TimeUnit }
+import java.util.concurrent.atomic.AtomicInteger
+import java.util.zip.{ GZIPInputStream, GZIPOutputStream, Inflater, 
ZipException }
+
+import scala.annotation.nowarn
+import scala.concurrent.ExecutionContext.Implicits.global
+import scala.concurrent.duration._
 
 import org.apache.pekko
 import pekko.http.impl.util._
+import pekko.stream.SystemMaterializer
+import pekko.stream.scaladsl.{ Sink, Source }
+import pekko.testkit._
 import pekko.util.ByteString
 import scala.annotation.nowarn
 
@@ -45,7 +55,8 @@ class GzipSpec extends CoderSpec {
       ex.ultimateCause.getMessage should equal("Truncated GZIP stream")
     }
     "throw an error if compressed data is just missing the trailer at the end" 
in {
-      def brokenCompress(payload: String) = 
Coders.Gzip.newCompressor.compress(ByteString(payload, "UTF-8"))
+      def brokenCompress(payload: String) =
+        Coders.Gzip.newCompressor.compress(ByteString(payload, 
StandardCharsets.UTF_8))
       val ex = the[RuntimeException] thrownBy 
ourDecode(brokenCompress("abcdefghijkl"))
       ex.ultimateCause.getMessage should equal("Truncated GZIP stream")
     }
@@ -53,5 +64,103 @@ class GzipSpec extends CoderSpec {
       val cause = (the[RuntimeException] thrownBy ourDecode(ByteString(0, 1, 
2, 3, 4))).ultimateCause
       cause should ((be(a[ZipException]) and have).message("Not in GZIP 
format"))
     }
+    "release the inflater when decoding completes" in {
+      val tracking = new TrackingInflater
+      decodeWith(tracking, streamEncode(smallTextBytes)) should 
readAs(smallText)
+      tracking.endCalls.get() shouldEqual 1
+    }
+    "release the inflater when decoding is cancelled early" in {
+      val tracking = new TrackingInflater
+
+      Source.single(streamEncode(largeTextBytes))
+        .via(decoderWith(tracking).withMaxBytesPerChunk(1).decoderFlow)
+        .take(1)
+        .runWith(Sink.ignore)
+        .awaitResult(3.seconds.dilated)
+
+      // postStop() (which calls end()) is dispatched to the stage actor after 
the
+      // Sink.ignore future completes, so we must wait for end() itself rather 
than
+      // for the stream future to avoid a race.
+      tracking.awaitEnd(3.seconds.dilated)
+      tracking.endCalls.get() shouldEqual 1
+    }
+    "release the inflater when decoding fails on truncation" in {
+      val inflater = new TrackingInflater
+
+      val ex = the[RuntimeException] thrownBy decodeWith(inflater, 
streamEncode(smallTextBytes).dropRight(5))
+      ex.ultimateCause.getMessage should equal("Truncated GZIP stream")
+      inflater.endCalls.get() shouldEqual 1
+    }
+    "release the deflater when encoding completes" in {
+      val tracking = new TrackingDeflater
+      Source.single(smallTextBytes)
+        .via(encoderWith(tracking).encoderFlow)
+        .runWith(Sink.ignore)
+        .awaitResult(3.seconds.dilated)
+      tracking.awaitEnd(3.seconds.dilated)
+      tracking.endCalls.get() shouldEqual 1
+    }
+    "release the deflater when encoding is cancelled early" in {
+      val tracking = new TrackingDeflater
+      Source.single(largeTextBytes)
+        .via(encoderWith(tracking).encoderFlow)
+        .take(1)
+        .runWith(Sink.ignore)
+        .awaitResult(3.seconds.dilated)
+      // postStop() (which calls end()) is dispatched to the stage actor after 
the
+      // Sink.ignore future completes, so we must wait for end() itself rather 
than
+      // for the stream future to avoid a race.
+      tracking.awaitEnd(3.seconds.dilated)
+      tracking.endCalls.get() shouldEqual 1
+    }
+  }
+
+  private def decodeWith(tracking: TrackingInflater, bytes: ByteString): 
ByteString =
+    
decoderWith(tracking).decode(bytes)(SystemMaterializer(system).materializer).awaitResult(3.seconds.dilated)
+
+  @nowarn("msg=deprecated")
+  private def decoderWith(tracking: TrackingInflater): Gzip =
+    new Gzip(Encoder.DefaultFilter) {
+      override def newDecompressorStage(maxBytesPerChunk: Int) =
+        () =>
+          new GzipDecompressor(maxBytesPerChunk) {
+            override protected[coding] def createInflater(): Inflater = 
tracking
+          }
+    }
+
+  @nowarn("msg=deprecated")
+  private def encoderWith(tracking: TrackingDeflater): Gzip =
+    new Gzip(Encoder.DefaultFilter) {
+      override def newCompressor: GzipCompressor = new GzipCompressor() {
+        override protected lazy val deflater: java.util.zip.Deflater = tracking
+      }
+    }
+
+  private class TrackingInflater extends java.util.zip.Inflater(true) {
+    val endCalls = new AtomicInteger
+    private val endLatch = new CountDownLatch(1)
+
+    def awaitEnd(atMost: FiniteDuration): Unit =
+      endLatch.await(atMost.toMillis, TimeUnit.MILLISECONDS)
+
+    override def end(): Unit = {
+      endCalls.incrementAndGet()
+      endLatch.countDown()
+      super.end()
+    }
+  }
+
+  private class TrackingDeflater extends 
java.util.zip.Deflater(java.util.zip.Deflater.DEFAULT_COMPRESSION, true) {
+    val endCalls = new AtomicInteger
+    private val endLatch = new CountDownLatch(1)
+
+    def awaitEnd(atMost: FiniteDuration): Unit =
+      endLatch.await(atMost.toMillis, TimeUnit.MILLISECONDS)
+
+    override def end(): Unit = {
+      endCalls.incrementAndGet()
+      endLatch.countDown()
+      super.end()
+    }
   }
 }
diff --git 
a/http/src/main/scala/org/apache/pekko/http/scaladsl/coding/Deflate.scala 
b/http/src/main/scala/org/apache/pekko/http/scaladsl/coding/Deflate.scala
index 8dbb2a4d0..c4c10929c 100644
--- a/http/src/main/scala/org/apache/pekko/http/scaladsl/coding/Deflate.scala
+++ b/http/src/main/scala/org/apache/pekko/http/scaladsl/coding/Deflate.scala
@@ -27,7 +27,7 @@ class Deflate private[http] (compressionLevel: Int, val 
messageFilter: HttpMessa
   }
 
   val encoding = HttpEncodings.deflate
-  def newCompressor = new DeflateCompressor(compressionLevel)
+  def newCompressor: DeflateCompressor = new 
DeflateCompressor(compressionLevel)
   def newDecompressorStage(maxBytesPerChunk: Int) = () => new 
DeflateDecompressor(maxBytesPerChunk)
 
   @deprecated("Use Coders.Deflate(compressionLevel = ...) instead", since = 
"Akka HTTP 10.2.0")
diff --git 
a/http/src/main/scala/org/apache/pekko/http/scaladsl/coding/DeflateCompressor.scala
 
b/http/src/main/scala/org/apache/pekko/http/scaladsl/coding/DeflateCompressor.scala
index a6f596744..2f66b5c40 100644
--- 
a/http/src/main/scala/org/apache/pekko/http/scaladsl/coding/DeflateCompressor.scala
+++ 
b/http/src/main/scala/org/apache/pekko/http/scaladsl/coding/DeflateCompressor.scala
@@ -34,6 +34,7 @@ class DeflateCompressor private[coding] (compressionLevel: 
Int) extends Compress
   def this() = this(DeflateCompressor.DefaultCompressionLevel)
 
   protected lazy val deflater = new Deflater(compressionLevel, false)
+  private var deflaterEnded = false
 
   override final def compressAndFlush(input: ByteString): ByteString = {
     val buffer = newTempBuffer(input.size)
@@ -61,10 +62,18 @@ class DeflateCompressor private[coding] (compressionLevel: 
Int) extends Compress
   protected def finishWithBuffer(buffer: Array[Byte]): ByteString = {
     deflater.finish()
     val res = drainDeflater(deflater, buffer)
-    deflater.end()
+    endDeflater()
     res
   }
 
+  private[coding] def endDeflater(): Unit =
+    if (!deflaterEnded) {
+      deflaterEnded = true
+      deflater.end()
+    }
+
+  private[coding] override def cleanup(): Unit = endDeflater()
+
   private def newTempBuffer(size: Int = 65536): Array[Byte] = {
     // The default size is somewhat arbitrary, we'd like to guess a better 
value but Deflater/zlib
     // is buffering in an unpredictable manner.
diff --git 
a/http/src/main/scala/org/apache/pekko/http/scaladsl/coding/Encoder.scala 
b/http/src/main/scala/org/apache/pekko/http/scaladsl/coding/Encoder.scala
index b81deec68..dcf7685f6 100644
--- a/http/src/main/scala/org/apache/pekko/http/scaladsl/coding/Encoder.scala
+++ b/http/src/main/scala/org/apache/pekko/http/scaladsl/coding/Encoder.scala
@@ -70,7 +70,7 @@ trait Encoder {
     def encodeChunk(bytes: ByteString): ByteString = 
compressor.compressAndFlush(bytes)
     def finish(): ByteString = compressor.finish()
 
-    StreamUtils.byteStringTransformer(encodeChunk, () => finish())
+    StreamUtils.byteStringTransformer(encodeChunk, () => finish(), () => 
compressor.cleanup())
   }
 }
 
@@ -114,4 +114,7 @@ abstract class Compressor {
 
   /** Combines `compress` + `finish` */
   def compressAndFinish(input: ByteString): ByteString
+
+  /** Release any native resources held by this compressor. Idempotent. */
+  private[coding] def cleanup(): Unit = ()
 }
diff --git 
a/http/src/main/scala/org/apache/pekko/http/scaladsl/coding/Gzip.scala 
b/http/src/main/scala/org/apache/pekko/http/scaladsl/coding/Gzip.scala
index 7046d2646..27b74798c 100644
--- a/http/src/main/scala/org/apache/pekko/http/scaladsl/coding/Gzip.scala
+++ b/http/src/main/scala/org/apache/pekko/http/scaladsl/coding/Gzip.scala
@@ -27,7 +27,7 @@ class Gzip private[http] (compressionLevel: Int, val 
messageFilter: HttpMessage
   }
 
   val encoding = HttpEncodings.gzip
-  def newCompressor = new GzipCompressor(compressionLevel)
+  def newCompressor: GzipCompressor = new GzipCompressor(compressionLevel)
   def newDecompressorStage(maxBytesPerChunk: Int) = () => new 
GzipDecompressor(maxBytesPerChunk)
 
   @deprecated("Use Coders.Gzip(compressionLevel = ...) instead", since = "Akka 
HTTP 10.2.0")
diff --git 
a/http/src/main/scala/org/apache/pekko/http/scaladsl/coding/GzipCompressor.scala
 
b/http/src/main/scala/org/apache/pekko/http/scaladsl/coding/GzipCompressor.scala
index a10156556..3f21f9917 100644
--- 
a/http/src/main/scala/org/apache/pekko/http/scaladsl/coding/GzipCompressor.scala
+++ 
b/http/src/main/scala/org/apache/pekko/http/scaladsl/coding/GzipCompressor.scala
@@ -22,8 +22,11 @@ import pekko.stream.impl.io.ByteStringParser
 import pekko.stream.impl.io.ByteStringParser.{ ParseResult, ParseStep }
 import pekko.util.ByteString
 
+import scala.annotation.nowarn
+
 /** Internal API */
 @InternalApi
+@nowarn("msg=deprecated .* is internal API")
 private[coding] class GzipCompressor(compressionLevel: Int) extends 
DeflateCompressor(compressionLevel) {
   override protected lazy val deflater = new Deflater(compressionLevel, true)
   private val checkSum = new CRC32 // CRC32 of uncompressed data
@@ -70,12 +73,27 @@ private[coding] object GzipCompressor {
 @InternalApi
 private[coding] class GzipDecompressor(
     maxBytesPerChunk: Int = Decoder.MaxBytesPerChunkDefault) extends 
DeflateDecompressorBase(maxBytesPerChunk) {
+  protected[coding] def createInflater(): Inflater = new Inflater(true)
+
   override def createLogic(attr: Attributes) = new ParsingLogic {
-    private[this] val inflater = new Inflater(true)
+    private[this] val inflater = createInflater()
     private[this] val crc32: CRC32 = new CRC32
+    private[this] var inflaterEnded = false
+
+    private def cleanupInflater(): Unit = {
+      if (!inflaterEnded) {
+        inflaterEnded = true
+        inflater.end()
+      }
+    }
+
+    override def postStop(): Unit = cleanupInflater()
 
     trait Step extends ParseStep[ByteString] {
-      override def onTruncation(): Unit = failStage(new 
ZipException("Truncated GZIP stream"))
+      override def onTruncation(): Unit = {
+        cleanupInflater()
+        failStage(new ZipException("Truncated GZIP stream"))
+      }
     }
     startWith(ReadHeaders)
 


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to