This is an automated email from the ASF dual-hosted git repository.
pjfanning pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/pekko-http.git
The following commit(s) were added to refs/heads/main by this push:
new cbc42067d perf: encode HPACK header blocks from byte arrays into an
unsynchronized ByteStringOutputStream (#1301)
cbc42067d is described below
commit cbc42067d3372f24f133f6c449b14789a2419227
Author: PJ Fanning <[email protected]>
AuthorDate: Wed Sep 23 08:06:10 2026 +0100
perf: encode HPACK header blocks from byte arrays into an unsynchronized
ByteStringOutputStream (#1301)
Motivation:
HuffmanEncoder read each string literal one char at a time through
String.charAt, twice (once to size it, once to code it), and wrote its
output one byte at a time through OutputStream.write(int). Every write,
including the integer prefixes and bulk literal writes from Encoder,
went through the synchronized ByteArrayOutputStream that
HeaderCompression passes in, and the finished block was copied out with
toByteArray. The per-byte synchronized write dominated the cost of
encoding a header block; the remaining per-header locks were another
fifth of what was left.
Modification:
HuffmanEncoder takes byte[] input, as the upstream twitter/hpack code
did, and codes straight into a reserved range of the output array.
Encoder converts each string literal to octets once (via
StringTools.asciiStringBytes, now ISO-8859-1 and so the exact inverse of
asciiStringFromBytes: a raw literal carrying an octet in 0x80-0xFF sends
that octet where US-ASCII substituted '?', as the Huffman form already
did) and uses them for the length check and both literal forms.
ByteStringOutputStream (#1235) is generalised into a plain OutputStream
with unsynchronized writes, a reserve/array pair for direct fills, and
takeByteString(), which hands the block over without copying when most
of the buffer is used (starting the next block on a fresh array sized to
the last one) or copies out and keeps the array when only a small part
is, as toByteStringUnsafe did. Encoder writes into that stream instead of
any OutputStream; HeaderCompression and PerMessageDeflate use it. One
MiMa excludes file covers the shaded Encoder and HuffmanEncoder
signatures.
Result:
Encoding a typical 10-header browser request is ~2.7x faster on JDK 17
(about 9.6us to 3.5us, see the PR for the JMH table), both HPACK string
literal forms round-trip every octet through the decoder, and the
WebSocket deflate stages no longer lock per write.
Tests:
- sbt "http-core/Test/testOnly
org.apache.pekko.http.impl.util.ByteStringOutputStreamSpec
org.apache.pekko.http.impl.util.StringToolsSpec
org.apache.pekko.http.impl.engine.http2.hpack.HpackDecoderSpec
org.apache.pekko.http.shaded.com.twitter.hpack.*" (Scala 2.13.18 and 3.3.8)
- sbt "http-core/Test/testOnly org.apache.pekko.http.impl.engine.ws.*"
- sbt "http2-tests/Test/testOnly
org.apache.pekko.http.impl.engine.http2.Http2ServerSpec
org.apache.pekko.http.impl.engine.http2.Http2ClientSpec
org.apache.pekko.http.impl.engine.http2.RequestParsingSpec"
- sbt "http-bench-jmh/Jmh/run HpackEncoderBenchmark" on main and on this
branch
- sbt http-core/mimaReportBinaryIssues; sbt headerCheckAll; sbt
javafmtCheckAll (JDK 17)
- scalafmt --mode diff-ref=upstream/main
References:
Refs #1235 - the ByteStringOutputStream this generalises; Refs #1300 -
superseded by this PR
---
.../com/twitter/hpack/HpackEncoderBenchmark.scala | 74 ++++++++++++
.../http/shaded/com/twitter/hpack/Encoder.java | 31 ++---
.../shaded/com/twitter/hpack/HuffmanEncoder.java | 57 +++++----
...hpack-encoder-bytestring-output-stream.excludes | 24 ++++
.../engine/http2/hpack/HeaderCompression.scala | 7 +-
.../http/impl/engine/ws/PerMessageDeflate.scala | 4 +-
.../http/impl/util/ByteStringOutputStream.scala | 90 ++++++++++++---
.../apache/pekko/http/impl/util/StringTools.scala | 10 +-
.../impl/engine/http2/hpack/HpackDecoderSpec.scala | 7 +-
.../impl/util/ByteStringOutputStreamSpec.scala | 49 ++++++--
.../pekko/http/impl/util/StringToolsSpec.scala | 16 +++
.../com/twitter/hpack/HpackEncoderSpec.scala | 128 +++++++++++++++++++++
.../impl/engine/http2/HPackEncodingSupport.scala | 10 +-
13 files changed, 432 insertions(+), 75 deletions(-)
diff --git
a/http-bench-jmh/src/main/scala/org/apache/pekko/http/shaded/com/twitter/hpack/HpackEncoderBenchmark.scala
b/http-bench-jmh/src/main/scala/org/apache/pekko/http/shaded/com/twitter/hpack/HpackEncoderBenchmark.scala
new file mode 100644
index 000000000..0ad3bb9a8
--- /dev/null
+++
b/http-bench-jmh/src/main/scala/org/apache/pekko/http/shaded/com/twitter/hpack/HpackEncoderBenchmark.scala
@@ -0,0 +1,74 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pekko.http.shaded.com.twitter.hpack
+
+import java.util.concurrent.TimeUnit
+
+import org.openjdk.jmh.annotations._
+
+import org.apache.pekko.http.impl.util.ByteStringOutputStream
+
+@State(Scope.Benchmark)
+@OutputTimeUnit(TimeUnit.NANOSECONDS)
+@BenchmarkMode(Array(Mode.AverageTime))
+@Fork(2)
+@Warmup(iterations = 5, time = 1)
+@Measurement(iterations = 8, time = 2)
+class HpackEncoderBenchmark {
+
+ /** `default` lets the encoder pick Huffman when it is shorter, the other
two force one string literal form. */
+ @Param(Array("default", "huffman", "raw"))
+ var mode = "default"
+
+ // indexing is disabled so that every call encodes the values as string
literals instead of table references
+ var encoder: Encoder = null
+ val out = new ByteStringOutputStream(128)
+
+ // a typical browser request; every value goes through
HuffmanEncoder.getEncodedLength and most through encode
+ val headers: Array[(String, String)] = Array(
+ ":method" -> "GET",
+ ":scheme" -> "https",
+ ":authority" -> "www.example.com",
+ ":path" ->
"/api/v1/users/12345/orders?page=2&sort=created_at&direction=desc",
+ "user-agent" ->
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36
(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
+ "accept" ->
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
+ "accept-language" -> "en-US,en;q=0.9",
+ "accept-encoding" -> "gzip, deflate, br",
+ "cookie" -> "session=ab12cd34ef56ab12cd34ef56ab12cd34ef56ab12; theme=dark;
consent=1",
+ "cache-control" -> "no-cache")
+
+ @Setup
+ def setup(): Unit =
+ encoder = mode match {
+ case "default" => new Encoder(4096, false, false, false)
+ case "huffman" => new Encoder(4096, false, true, false)
+ case "raw" => new Encoder(4096, false, false, true)
+ }
+
+ @Benchmark
+ def encodeHeaders(): Int = {
+ var i = 0
+ while (i < headers.length) {
+ val (name, value) = headers(i)
+ encoder.encodeHeader(out, name, value, false)
+ i += 1
+ }
+ out.takeByteString().length
+ }
+}
diff --git
a/http-core/src/main/java/org/apache/pekko/http/shaded/com/twitter/hpack/Encoder.java
b/http-core/src/main/java/org/apache/pekko/http/shaded/com/twitter/hpack/Encoder.java
index c94d6df90..1bf7062eb 100644
---
a/http-core/src/main/java/org/apache/pekko/http/shaded/com/twitter/hpack/Encoder.java
+++
b/http-core/src/main/java/org/apache/pekko/http/shaded/com/twitter/hpack/Encoder.java
@@ -31,9 +31,8 @@
package org.apache.pekko.http.shaded.com.twitter.hpack;
-import java.io.IOException;
-import java.io.OutputStream;
import java.util.Arrays;
+import org.apache.pekko.http.impl.util.ByteStringOutputStream;
import org.apache.pekko.http.impl.util.StringTools;
import org.apache.pekko.http.shaded.com.twitter.hpack.HpackUtil.IndexType;
@@ -75,8 +74,8 @@ public final class Encoder {
}
/** Encode the header field into the header block. */
- public void encodeHeader(OutputStream out, String name, String value,
boolean sensitive)
- throws IOException {
+ public void encodeHeader(
+ ByteStringOutputStream out, String name, String value, boolean
sensitive) {
// If the header value is sensitive then it must never be indexed
if (sensitive) {
@@ -130,7 +129,7 @@ public final class Encoder {
}
/** Set the maximum table size. */
- public void setMaxHeaderTableSize(OutputStream out, int maxHeaderTableSize)
throws IOException {
+ public void setMaxHeaderTableSize(ByteStringOutputStream out, int
maxHeaderTableSize) {
if (maxHeaderTableSize < 0) {
throw new IllegalArgumentException("Illegal Capacity: " +
maxHeaderTableSize);
}
@@ -148,7 +147,7 @@ public final class Encoder {
}
/** Encode integer according to Section 5.1. */
- private static void encodeInteger(OutputStream out, int mask, int n, int i)
throws IOException {
+ private static void encodeInteger(ByteStringOutputStream out, int mask, int
n, int i) {
if (n < 0 || n > 8) {
throw new IllegalArgumentException("N: " + n);
}
@@ -171,23 +170,25 @@ public final class Encoder {
}
/** Encode string literal according to Section 5.2. */
- private void encodeStringLiteral(OutputStream out, String string) throws
IOException {
- int length = string.length();
- int huffmanLength = Huffman.ENCODER.getEncodedLength(string);
+ private void encodeStringLiteral(ByteStringOutputStream out, String string) {
+ // convert once up front: the length computation, the Huffman coder and
the raw literal all work
+ // on the octets
+ byte[] stringBytes = StringTools.asciiStringBytes(string);
+ int length = stringBytes.length;
+ int huffmanLength = Huffman.ENCODER.getEncodedLength(stringBytes);
if ((huffmanLength < length && !forceHuffmanOff) || forceHuffmanOn) {
encodeInteger(out, 0x80, 7, huffmanLength);
- Huffman.ENCODER.encode(out, string);
+ int position = out.reserve(huffmanLength);
+ Huffman.ENCODER.encode(stringBytes, out.array(), position,
huffmanLength);
} else {
- byte[] stringBytes = StringTools.asciiStringBytes(string);
encodeInteger(out, 0x00, 7, length);
- out.write(stringBytes, 0, stringBytes.length);
+ out.write(stringBytes, 0, length);
}
}
/** Encode literal header field according to Section 6.2. */
private void encodeLiteral(
- OutputStream out, String name, String value, IndexType indexType, int
nameIndex)
- throws IOException {
+ ByteStringOutputStream out, String name, String value, IndexType
indexType, int nameIndex) {
int mask;
int prefixBits;
switch (indexType) {
@@ -228,7 +229,7 @@ public final class Encoder {
* Ensure that the dynamic table has enough room to hold 'headerSize' more
bytes. Removes the
* oldest entry from the dynamic table until sufficient space is available.
*/
- private void ensureCapacity(int headerSize) throws IOException {
+ private void ensureCapacity(int headerSize) {
while (size + headerSize > capacity) {
int index = length();
if (index == 0) {
diff --git
a/http-core/src/main/java/org/apache/pekko/http/shaded/com/twitter/hpack/HuffmanEncoder.java
b/http-core/src/main/java/org/apache/pekko/http/shaded/com/twitter/hpack/HuffmanEncoder.java
index 7b8771e8e..413c7affc 100644
---
a/http-core/src/main/java/org/apache/pekko/http/shaded/com/twitter/hpack/HuffmanEncoder.java
+++
b/http-core/src/main/java/org/apache/pekko/http/shaded/com/twitter/hpack/HuffmanEncoder.java
@@ -31,9 +31,6 @@
package org.apache.pekko.http.shaded.com.twitter.hpack;
-import java.io.IOException;
-import java.io.OutputStream;
-
final class HuffmanEncoder {
private final int[] codes;
@@ -51,25 +48,37 @@ final class HuffmanEncoder {
}
/**
- * Compresses the input string literal using the Huffman coding.
+ * Compresses the input string literal using the Huffman coding, writing the
result directly into
+ * <code>dst</code> instead of byte by byte to an <code>OutputStream</code>.
*
- * @param out the output stream for the compressed data
- * @throws IOException if an I/O error occurs. In particular, an
<code>IOException</code> may be
- * thrown if the output stream has been closed.
+ * @param data the string literal to be Huffman encoded
+ * @param dst the array to write the Huffman coded string literal to
+ * @param dstOffset the position in <code>dst</code> to start writing at
+ * @param encodedLength the value of {@link #getEncodedLength(byte[])} for
<code>data</code>,
+ * which the caller has typically already computed to decide whether to
use Huffman coding and
+ * to reserve room in <code>dst</code>
*/
- public void encode(OutputStream out, String string) throws IOException {
- if (out == null) {
- throw new NullPointerException("out");
- } else if (string == null) {
- throw new NullPointerException("string");
+ public void encode(byte[] data, byte[] dst, int dstOffset, int
encodedLength) {
+ if (data == null) {
+ throw new NullPointerException("data");
+ } else if (dst == null) {
+ throw new NullPointerException("dst");
+ } else if (dstOffset < 0 || encodedLength < 0 || dstOffset + encodedLength
> dst.length) {
+ throw new IndexOutOfBoundsException(
+ "dstOffset "
+ + dstOffset
+ + ", encodedLength "
+ + encodedLength
+ + ", dst.length "
+ + dst.length);
}
+ int pos = dstOffset;
long current = 0;
int n = 0;
- int len = string.length();
- for (int i = 0; i < len; i++) {
- int b = string.charAt(i) & 0xFF;
+ for (byte value : data) {
+ int b = value & 0xFF;
int code = codes[b];
int nbits = lengths[b];
@@ -79,14 +88,22 @@ final class HuffmanEncoder {
while (n >= 8) {
n -= 8;
- out.write(((int) (current >> n)));
+ dst[pos++] = (byte) (current >> n);
}
}
if (n > 0) {
current <<= (8 - n);
current |= (0xFF >>> n); // this should be EOS symbol
- out.write((int) current);
+ dst[pos++] = (byte) current;
+ }
+
+ if (pos - dstOffset != encodedLength) {
+ throw new IllegalArgumentException(
+ "encodedLength "
+ + encodedLength
+ + " does not match the Huffman encoded length "
+ + (pos - dstOffset));
}
}
@@ -96,13 +113,13 @@ final class HuffmanEncoder {
* @param data the string literal to be Huffman encoded
* @return the number of bytes required to Huffman encode <code>data</code>
*/
- public int getEncodedLength(String data) {
+ public int getEncodedLength(byte[] data) {
if (data == null) {
throw new NullPointerException("data");
}
long len = 0;
- for (int i = 0; i < data.length(); i++) {
- len += lengths[data.charAt(i) & 0xFF];
+ for (byte b : data) {
+ len += lengths[b & 0xFF];
}
return (int) ((len + 7) >> 3);
}
diff --git
a/http-core/src/main/mima-filters/2.0.x.backwards.excludes/hpack-encoder-bytestring-output-stream.excludes
b/http-core/src/main/mima-filters/2.0.x.backwards.excludes/hpack-encoder-bytestring-output-stream.excludes
new file mode 100644
index 000000000..d96eda20e
--- /dev/null
+++
b/http-core/src/main/mima-filters/2.0.x.backwards.excludes/hpack-encoder-bytestring-output-stream.excludes
@@ -0,0 +1,24 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+# The shaded HPACK Encoder writes into the internal ByteStringOutputStream
instead of any java.io.OutputStream,
+# and the package-private HuffmanEncoder codes byte arrays instead of Strings,
straight into that stream's array
+ProblemFilters.exclude[IncompatibleMethTypeProblem]("org.apache.pekko.http.shaded.com.twitter.hpack.Encoder.encodeHeader")
+ProblemFilters.exclude[IncompatibleMethTypeProblem]("org.apache.pekko.http.shaded.com.twitter.hpack.Encoder.setMaxHeaderTableSize")
+ProblemFilters.exclude[IncompatibleMethTypeProblem]("org.apache.pekko.http.shaded.com.twitter.hpack.HuffmanEncoder.encode")
+ProblemFilters.exclude[IncompatibleMethTypeProblem]("org.apache.pekko.http.shaded.com.twitter.hpack.HuffmanEncoder.getEncodedLength")
+ProblemFilters.exclude[DirectMissingMethodProblem]("org.apache.pekko.http.shaded.com.twitter.hpack.HuffmanEncoder.encode")
diff --git
a/http-core/src/main/scala/org/apache/pekko/http/impl/engine/http2/hpack/HeaderCompression.scala
b/http-core/src/main/scala/org/apache/pekko/http/impl/engine/http2/hpack/HeaderCompression.scala
index 9bce63b8e..c86ef6367 100644
---
a/http-core/src/main/scala/org/apache/pekko/http/impl/engine/http2/hpack/HeaderCompression.scala
+++
b/http-core/src/main/scala/org/apache/pekko/http/impl/engine/http2/hpack/HeaderCompression.scala
@@ -13,11 +13,11 @@
package org.apache.pekko.http.impl.engine.http2.hpack
-import java.io.ByteArrayOutputStream
import org.apache.pekko
import pekko.annotation.InternalApi
import pekko.http.impl.engine.http2.Http2Protocol.SettingIdentifier
import pekko.http.impl.engine.http2._
+import pekko.http.impl.util.ByteStringOutputStream
import pekko.stream.{ Attributes, FlowShape, Inlet, Outlet }
import pekko.stream.stage.{ GraphStage, GraphStageLogic, InHandler,
OutHandler, StageLogging }
import pekko.util.ByteString
@@ -44,7 +44,7 @@ private[http2] object HeaderCompression extends
GraphStage[FlowShape[FrameEvent,
private val currentMaxFrameSize = Http2Protocol.InitialMaxFrameSize
val encoder = new
pekko.http.shaded.com.twitter.hpack.Encoder(Http2Protocol.InitialMaxHeaderTableSize)
- val os = new ByteArrayOutputStream(128)
+ val os = new ByteStringOutputStream(128)
def onPull(): Unit = pull(eventsIn)
def onPush(): Unit = grab(eventsIn) match {
@@ -71,8 +71,7 @@ private[http2] object HeaderCompression extends
GraphStage[FlowShape[FrameEvent,
throw new IllegalStateException(
s"Didn't expect key-value-pair [$key] ->
[$value](${value.getClass}) here.")
}
- val result = ByteString.fromArrayUnsafe(os.toByteArray) //
BAOS.toByteArray always creates a copy
- os.reset()
+ val result = os.takeByteString() // hands the array over without
copying and starts a new block
if (result.size <= currentMaxFrameSize)
push(eventsOut, HeadersFrame(streamId, endStream, endHeaders =
true, result, prioInfo))
else {
diff --git
a/http-core/src/main/scala/org/apache/pekko/http/impl/engine/ws/PerMessageDeflate.scala
b/http-core/src/main/scala/org/apache/pekko/http/impl/engine/ws/PerMessageDeflate.scala
index 8453fcc2c..24b4e2c11 100644
---
a/http-core/src/main/scala/org/apache/pekko/http/impl/engine/ws/PerMessageDeflate.scala
+++
b/http-core/src/main/scala/org/apache/pekko/http/impl/engine/ws/PerMessageDeflate.scala
@@ -255,7 +255,7 @@ private[http] object PerMessageDeflate {
output.write(buffer, 0, count)
count = inflater.inflate(buffer)
}
- output.toByteStringUnsafe
+ output.takeByteString()
} catch {
case ex: DataFormatException =>
throw new ProtocolException(s"Invalid WebSocket compressed message:
${ex.getMessage}")
@@ -351,7 +351,7 @@ private[http] object PerMessageDeflate {
output.write(buffer, 0, count)
count = deflater.deflate(buffer, 0, buffer.length, Deflater.SYNC_FLUSH)
}
- val bytes = output.toByteStringUnsafe
+ val bytes = output.takeByteString()
if (removeTail && bytes.endsWith(EmptyStoredBlock))
bytes.dropRight(EmptyStoredBlock.length) else bytes
}
diff --git
a/http-core/src/main/scala/org/apache/pekko/http/impl/util/ByteStringOutputStream.scala
b/http-core/src/main/scala/org/apache/pekko/http/impl/util/ByteStringOutputStream.scala
index acb034ffd..5d2e77a6a 100644
---
a/http-core/src/main/scala/org/apache/pekko/http/impl/util/ByteStringOutputStream.scala
+++
b/http-core/src/main/scala/org/apache/pekko/http/impl/util/ByteStringOutputStream.scala
@@ -17,7 +17,8 @@
package org.apache.pekko.http.impl.util
-import java.io.ByteArrayOutputStream
+import java.io.OutputStream
+import java.util.Arrays
import org.apache.pekko
import pekko.annotation.InternalApi
@@ -27,25 +28,88 @@ import pekko.util.ByteString
* INTERNAL API
*
* An [[java.io.OutputStream]] that buffers into a byte array like
[[java.io.ByteArrayOutputStream]] but
- * that can hand the buffered data over as a [[pekko.util.ByteString]] without
copying it, unlike
- * `ByteArrayOutputStream.toByteArray` which always creates a copy.
+ * that hands the buffered data over as a [[pekko.util.ByteString]] without
copying it, unlike
+ * `ByteArrayOutputStream.toByteArray` which always creates a copy, and whose
writes are not
+ * `synchronized`: every user is a single stage that owns the stream, and the
per-call monitor was a
+ * measurable share of the cost of encoding an HPACK header block.
+ *
+ * After a hand-over the stream is empty again and can be reused for the next
block of data.
+ *
+ * Not thread-safe.
*
* Derived from the `ByteStringOutputStream` in Apache Pekko gRPC
* (https://github.com/apache/pekko-grpc/pull/862).
*/
@InternalApi
-private[http] final class ByteStringOutputStream(capacity: Int) extends
ByteArrayOutputStream(capacity) {
+private[http] final class ByteStringOutputStream(initialCapacity: Int) extends
OutputStream {
+ if (initialCapacity < 0) throw new IllegalArgumentException(s"Illegal
initial capacity: $initialCapacity")
+
+ private[this] var buf: Array[Byte] = Array.emptyByteArray
+ private[this] var count: Int = 0
+ // the size of the last block handed over; the array for the next block is
allocated to hold it
+ private[this] var lastCount: Int = 0
+
+ /** The number of bytes written since the last hand-over. */
+ def size: Int = count
+
+ override def write(b: Int): Unit = {
+ ensureCapacity(1)
+ buf(count) = b.toByte
+ count += 1
+ }
+
+ override def write(bytes: Array[Byte], offset: Int, length: Int): Unit = {
+ ensureCapacity(length)
+ System.arraycopy(bytes, offset, buf, count, length)
+ count += length
+ }
/**
- * Wraps the bytes written so far in a `ByteString`. The buffer may be
shared with the returned
- * `ByteString`, so this stream must not be written to, reset or reused
afterwards.
+ * Reserves `length` bytes at the end of the buffered data for the caller to
fill in directly.
+ *
+ * @return the position in [[array]] at which the reserved bytes start
*/
- def toByteStringUnsafe: ByteString =
+ def reserve(length: Int): Int = {
+ ensureCapacity(length)
+ val position = count
+ count += length
+ position
+ }
+
+ /**
+ * The array backing the buffered data. Only valid until the next write,
reserve or hand-over, any of
+ * which may replace it.
+ */
+ def array: Array[Byte] = buf
+
+ /**
+ * Hands the buffered data over as a `ByteString` and leaves the stream
empty for the next block.
+ *
+ * When most of the buffer is used the `ByteString` wraps the array without
copying and the stream
+ * starts a fresh array for the next block; when only a small part is used
the data is copied so the
+ * `ByteString` does not retain a large, mostly unused array, and the stream
keeps its array.
+ */
+ def takeByteString(): ByteString =
if (count < 1) ByteString.empty
- else if (count > (buf.length >> 1))
- // Most of the buffer is used — wrap it to avoid a copy
- ByteString.fromArrayUnsafe(buf, 0, count)
- else
- // Small amount of data in a large buffer — copy to right-size so the
rest can be GC'd
- ByteString.fromArray(buf, 0, count)
+ else {
+ val result =
+ if (count > (buf.length >> 1)) {
+ val wrapped = ByteString.fromArrayUnsafe(buf, 0, count)
+ lastCount = count
+ buf = Array.emptyByteArray
+ wrapped
+ } else ByteString.fromArray(buf, 0, count)
+ count = 0
+ result
+ }
+
+ private def ensureCapacity(additional: Int): Unit = {
+ val required = count + additional
+ if (required > buf.length) {
+ val capacity =
+ if (buf.length == 0) math.max(math.max(initialCapacity, lastCount),
required)
+ else math.max(buf.length << 1, required)
+ buf = Arrays.copyOf(buf, capacity)
+ }
+ }
}
diff --git
a/http-core/src/main/scala/org/apache/pekko/http/impl/util/StringTools.scala
b/http-core/src/main/scala/org/apache/pekko/http/impl/util/StringTools.scala
index 92823da04..3eb25e24b 100644
--- a/http-core/src/main/scala/org/apache/pekko/http/impl/util/StringTools.scala
+++ b/http-core/src/main/scala/org/apache/pekko/http/impl/util/StringTools.scala
@@ -27,9 +27,9 @@ private[http] object StringTools {
// keeps the array as is with a LATIN1 coder, so it is the same single
copy.
new String(bytes, ISO88591)
- def asciiStringBytes(string: String): Array[Byte] = {
- // this is as fast as Unsafe.copyUSAsciiStrToBytes for recent JDK versions
- // and avoids the use of deprecated Unsafe methods
- string.getBytes(java.nio.charset.StandardCharsets.US_ASCII)
- }
+ def asciiStringBytes(string: String): Array[Byte] =
+ // ISO-8859-1 makes this the exact inverse of asciiStringFromBytes: HPACK
string literals are opaque octets and
+ // a character in 0x80-0xFF has to come out as that octet, not as the '?'
that encoding with US-ASCII would
+ // substitute. Since JDK 9 (compact strings) it is a single array copy for
a string with a LATIN1 coder.
+ string.getBytes(ISO88591)
}
diff --git
a/http-core/src/test/scala/org/apache/pekko/http/impl/engine/http2/hpack/HpackDecoderSpec.scala
b/http-core/src/test/scala/org/apache/pekko/http/impl/engine/http2/hpack/HpackDecoderSpec.scala
index 2e0d90711..bde40573b 100644
---
a/http-core/src/test/scala/org/apache/pekko/http/impl/engine/http2/hpack/HpackDecoderSpec.scala
+++
b/http-core/src/test/scala/org/apache/pekko/http/impl/engine/http2/hpack/HpackDecoderSpec.scala
@@ -17,11 +17,12 @@
package org.apache.pekko.http.impl.engine.http2.hpack
-import java.io.{ ByteArrayInputStream, ByteArrayOutputStream, IOException,
InputStream, SequenceInputStream }
+import java.io.{ ByteArrayInputStream, IOException, InputStream,
SequenceInputStream }
import scala.jdk.CollectionConverters._
import scala.collection.mutable.ListBuffer
+import org.apache.pekko.http.impl.util.ByteStringOutputStream
import org.apache.pekko.http.shaded.com.twitter.hpack.{ Decoder, Encoder,
HeaderListener }
import org.scalatest.matchers.should.Matchers
@@ -57,10 +58,10 @@ class HpackDecoderSpec extends AnyWordSpec with Matchers {
bytes.grouped(chunkSize).map(chunk => new ByteArrayInputStream(chunk):
InputStream).asJavaEnumeration)
private def encode(headers: (String, String)*): Array[Byte] = {
- val out = new ByteArrayOutputStream
+ val out = new ByteStringOutputStream(128)
val encoder = new Encoder(maxHeaderTableSize)
headers.foreach { case (name, value) => encoder.encodeHeader(out, name,
value, false) }
- out.toByteArray
+ out.takeByteString().toArray
}
private def decode(in: InputStream): Seq[(String, String)] = {
diff --git
a/http-core/src/test/scala/org/apache/pekko/http/impl/util/ByteStringOutputStreamSpec.scala
b/http-core/src/test/scala/org/apache/pekko/http/impl/util/ByteStringOutputStreamSpec.scala
index a9130162b..f8982e267 100644
---
a/http-core/src/test/scala/org/apache/pekko/http/impl/util/ByteStringOutputStreamSpec.scala
+++
b/http-core/src/test/scala/org/apache/pekko/http/impl/util/ByteStringOutputStreamSpec.scala
@@ -26,36 +26,53 @@ class ByteStringOutputStreamSpec extends AnyWordSpec with
Matchers {
"ByteStringOutputStream" must {
"return an empty ByteString when nothing was written" in {
- new ByteStringOutputStream(16).toByteStringUnsafe should
===(ByteString.empty)
+ new ByteStringOutputStream(16).takeByteString() should
===(ByteString.empty)
}
"return the bytes written when the buffer is exactly filled" in {
val out = new ByteStringOutputStream(4)
out.write(Array[Byte](1, 2, 3, 4))
- out.toByteStringUnsafe should ===(ByteString(1, 2, 3, 4))
+ out.takeByteString() should ===(ByteString(1, 2, 3, 4))
}
"return the bytes written when the buffer was grown" in {
val out = new ByteStringOutputStream(2)
val data = Array.tabulate[Byte](1000)(i => i.toByte)
out.write(data)
- out.toByteStringUnsafe should ===(ByteString(data))
+ out.takeByteString() should ===(ByteString(data))
}
"return the bytes written when only a small part of the buffer is used" in
{
val out = new ByteStringOutputStream(1024)
out.write(Array[Byte](1, 2, 3))
out.write(4)
- out.toByteStringUnsafe should ===(ByteString(1, 2, 3, 4))
+ out.takeByteString() should ===(ByteString(1, 2, 3, 4))
}
"not retain the buffer when only a small part of it is used" in {
val out = new ByteStringOutputStream(1024)
out.write(Array[Byte](1, 2, 3))
- // the ByteString is a copy, so it is not affected by later writes to
the stream
- val result = out.toByteStringUnsafe
+ val array = out.array
+ // the ByteString is a copy, so it is not affected by later writes to
the stream, which keeps its array
+ val result = out.takeByteString()
out.write(Array[Byte](9, 9, 9))
result should ===(ByteString(1, 2, 3))
+ (out.array eq array) shouldBe true
+ }
+
+ "hand over a mostly used buffer without copying and start the next block
on a fresh array" in {
+ val out = new ByteStringOutputStream(4)
+ out.write(Array[Byte](1, 2, 3))
+ val array = out.array
+ val first = out.takeByteString()
+ first should ===(ByteString(1, 2, 3))
+ out.size shouldBe 0
+
+ // writes to the next block must not show up in the ByteString that was
handed out
+ out.write(Array[Byte](9, 9, 9, 9), 0, 4)
+ (out.array eq array) shouldBe false
+ first should ===(ByteString(1, 2, 3))
+ out.takeByteString() should ===(ByteString(9, 9, 9, 9))
}
"write single bytes and byte ranges" in {
@@ -63,7 +80,25 @@ class ByteStringOutputStreamSpec extends AnyWordSpec with
Matchers {
out.write(1)
out.write(Array[Byte](0, 2, 3, 0), 1, 2)
out.write(Array[Byte](4, 5, 6, 7, 8))
- out.toByteStringUnsafe should ===(ByteString(1, 2, 3, 4, 5, 6, 7, 8))
+ out.takeByteString() should ===(ByteString(1, 2, 3, 4, 5, 6, 7, 8))
+ }
+
+ "reserve a range that the caller fills in through the backing array" in {
+ val out = new ByteStringOutputStream(2)
+ out.write(7)
+ val position = out.reserve(3)
+ position shouldBe 1
+ out.size shouldBe 4
+ val array = out.array
+ array(position) = 1
+ array(position + 1) = 2
+ array(position + 2) = 3
+ out.write(8)
+ out.takeByteString() should ===(ByteString(7, 1, 2, 3, 8))
+ }
+
+ "reject a negative initial capacity" in {
+ an[IllegalArgumentException] should be thrownBy new
ByteStringOutputStream(-1)
}
}
}
diff --git
a/http-core/src/test/scala/org/apache/pekko/http/impl/util/StringToolsSpec.scala
b/http-core/src/test/scala/org/apache/pekko/http/impl/util/StringToolsSpec.scala
index ec30db525..2347668bf 100644
---
a/http-core/src/test/scala/org/apache/pekko/http/impl/util/StringToolsSpec.scala
+++
b/http-core/src/test/scala/org/apache/pekko/http/impl/util/StringToolsSpec.scala
@@ -50,5 +50,21 @@ class StringToolsSpec extends AnyWordSpec with Matchers {
"encode an ASCII string to its US-ASCII bytes" in {
StringTools.asciiStringBytes("abc") shouldEqual
"abc".getBytes(StandardCharsets.US_ASCII)
}
+
+ "be the inverse of asciiStringFromBytes for every octet" in {
+ // HPACK string literals are opaque octets, so a char in 0x80-0xFF must
map back to that octet rather than
+ // to the '?' that encoding with US-ASCII would substitute
+ val allOctets = Array.tabulate(256)(_.toByte)
+
StringTools.asciiStringBytes(StringTools.asciiStringFromBytes(allOctets))
shouldEqual allOctets
+ }
+
+ "encode a character above 0xFF, which no octet can represent, as a single
'?'" in {
+ // U+0100 forces the UTF-16 coder on JDK 9+, so this covers the
non-arraycopy path too
+ StringTools.asciiStringBytes("aĀb") shouldEqual Array[Byte]('a', '?',
'b')
+ }
+
+ "encode the empty string to an empty array" in {
+ StringTools.asciiStringBytes("") shouldEqual Array.emptyByteArray
+ }
}
}
diff --git
a/http-core/src/test/scala/org/apache/pekko/http/shaded/com/twitter/hpack/HpackEncoderSpec.scala
b/http-core/src/test/scala/org/apache/pekko/http/shaded/com/twitter/hpack/HpackEncoderSpec.scala
new file mode 100644
index 000000000..8209603d0
--- /dev/null
+++
b/http-core/src/test/scala/org/apache/pekko/http/shaded/com/twitter/hpack/HpackEncoderSpec.scala
@@ -0,0 +1,128 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pekko.http.shaded.com.twitter.hpack
+
+import java.io.ByteArrayInputStream
+
+import scala.collection.immutable.VectorBuilder
+
+import org.scalatest.matchers.should.Matchers
+import org.scalatest.wordspec.AnyWordSpec
+
+import org.apache.pekko.http.impl.util.{ ByteStringOutputStream, StringTools }
+
+class HpackEncoderSpec extends AnyWordSpec with Matchers {
+
+ private def hex(bytes: Array[Byte]): String = bytes.map(b =>
f"$b%02x").mkString
+
+ private def huffmanEncode(bytes: Array[Byte]): Array[Byte] = {
+ val encodedLength = Huffman.ENCODER.getEncodedLength(bytes)
+ val dst = new Array[Byte](encodedLength)
+ Huffman.ENCODER.encode(bytes, dst, 0, encodedLength)
+ dst
+ }
+
+ private def huffmanEncode(literal: String): Array[Byte] =
huffmanEncode(StringTools.asciiStringBytes(literal))
+
+ // indexing off so that the header value is always sent as a string literal,
never as a table reference
+ private def encodeHeader(name: String, value: String, forceHuffmanOn:
Boolean, forceHuffmanOff: Boolean)
+ : Array[Byte] = {
+ val encoder = new Encoder(4096, false, forceHuffmanOn, forceHuffmanOff)
+ val out = new ByteStringOutputStream(16)
+ encoder.encodeHeader(out, name, value, false)
+ out.takeByteString().toArray
+ }
+
+ private def decodeHeaders(bytes: Array[Byte]): Seq[(String, String)] = {
+ val decoder = new Decoder(8192, 4096)
+ val headers = new VectorBuilder[(String, String)]()
+ decoder.decode(new ByteArrayInputStream(bytes),
+ (name: String, value: String, parsedValue: AnyRef, _: Boolean) => {
+ headers += name -> value
+ parsedValue
+ })
+ headers.result()
+ }
+
+ "HuffmanEncoder" should {
+ // the string literals from RFC 7541 Appendix C.4 with their expected
Huffman codes
+ val rfc7541Examples = Seq(
+ "www.example.com" -> "f1e3c2e5f23a6ba0ab90f4ff",
+ "no-cache" -> "a8eb10649cbf",
+ "custom-key" -> "25a849e95ba97d7f",
+ "custom-value" -> "25a849e95bb8e8b4bf")
+
+ "produce the Huffman codes from RFC 7541 Appendix C.4" in {
+ rfc7541Examples.foreach {
+ case (literal, expected) =>
+ hex(huffmanEncode(literal)) shouldEqual expected
+ }
+ }
+
+ "report the encoded length that encode actually produces" in {
+ rfc7541Examples.foreach {
+ case (literal, expected) =>
+
Huffman.ENCODER.getEncodedLength(StringTools.asciiStringBytes(literal))
shouldEqual expected.length / 2
+ }
+ }
+
+ "encode the empty literal to no bytes" in {
+ huffmanEncode("") shouldEqual Array.emptyByteArray
+ Huffman.ENCODER.getEncodedLength(Array.emptyByteArray) shouldEqual 0
+ }
+
+ "encode every octet with the code for that octet" in {
+ // a byte above 0x7F must be looked up as an unsigned symbol, not as a
negative array index
+ val allOctets = Array.tabulate(256)(_.toByte)
+ Huffman.DECODER.decode(huffmanEncode(allOctets)) shouldEqual allOctets
+ }
+
+ "write at the given offset and reject an encoded length that does not
match the input" in {
+ val bytes = StringTools.asciiStringBytes("no-cache")
+ val dst = new Array[Byte](10)
+ Huffman.ENCODER.encode(bytes, dst, 2, 6)
+ hex(dst) shouldEqual "0000a8eb10649cbf0000"
+ an[IllegalArgumentException] should be thrownBy
Huffman.ENCODER.encode(bytes, new Array[Byte](10), 0, 7)
+ an[IndexOutOfBoundsException] should be thrownBy
Huffman.ENCODER.encode(bytes, new Array[Byte](10), 5, 6)
+ }
+ }
+
+ "Encoder" should {
+ "encode the value from RFC 7541 Appendix C.4.1 with Huffman coding when it
is shorter" in {
+ // 0x01: literal without indexing, name index 1 (:authority); 0x8c:
Huffman coded, 12 octets
+ hex(encodeHeader(":authority", "www.example.com", forceHuffmanOn =
false, forceHuffmanOff = false)) shouldEqual
+ "018cf1e3c2e5f23a6ba0ab90f4ff"
+ }
+
+ "encode the header from RFC 7541 Appendix C.2.1 as a raw literal when
Huffman coding is forced off" in {
+ // 0x00: literal without indexing, new name; 0x0a/0x0d: raw string
literals of 10 and 13 octets
+ hex(encodeHeader("custom-key", "custom-header", forceHuffmanOn = false,
forceHuffmanOff = true)) shouldEqual
+ "000a637573746f6d2d6b65790d637573746f6d2d686561646572"
+ }
+
+ "round-trip a value carrying opaque octets above 0x7F in both string
literal forms" in {
+ // HPACK string literals are opaque octets: a decoder maps each byte to
the char of the same value, so an
+ // encoder has to map each such char back to that byte, in the raw form
as well as the Huffman form
+ val value = new String(Array.tabulate(256)(_.toChar))
+ Seq(true, false).foreach { huffman =>
+ val bytes = encodeHeader("x-opaque", value, forceHuffmanOn = huffman,
forceHuffmanOff = !huffman)
+ decodeHeaders(bytes) shouldEqual Seq("x-opaque" -> value)
+ }
+ }
+ }
+}
diff --git
a/http2-tests/src/test/scala/org/apache/pekko/http/impl/engine/http2/HPackEncodingSupport.scala
b/http2-tests/src/test/scala/org/apache/pekko/http/impl/engine/http2/HPackEncodingSupport.scala
index 063e868ff..4bd5912a6 100644
---
a/http2-tests/src/test/scala/org/apache/pekko/http/impl/engine/http2/HPackEncodingSupport.scala
+++
b/http2-tests/src/test/scala/org/apache/pekko/http/impl/engine/http2/HPackEncodingSupport.scala
@@ -13,12 +13,10 @@
package org.apache.pekko.http.impl.engine.http2
-import java.io.ByteArrayOutputStream
-
import org.apache.pekko
-import pekko.http.impl.util.StringRendering
import pekko.http.scaladsl.model.{ HttpHeader, HttpRequest, HttpResponse }
import pekko.http.scaladsl.model.headers.RawHeader
+import pekko.http.impl.util.{ ByteStringOutputStream, StringRendering }
import pekko.http.shaded.com.twitter.hpack.Encoder
import pekko.util.ByteString
@@ -62,12 +60,12 @@ trait HPackEncodingSupport {
headers.map(h => h.lowercaseName -> h.value)
def encodeHeaderPairs(headerPairs: Seq[(String, String)]): ByteString = {
- val bos = new ByteArrayOutputStream()
+ val out = new ByteStringOutputStream(128)
- def encode(name: String, value: String): Unit = encoder.encodeHeader(bos,
name, value, false)
+ def encode(name: String, value: String): Unit = encoder.encodeHeader(out,
name, value, false)
headerPairs.foreach((encode _).tupled)
- ByteString(bos.toByteArray)
+ out.takeByteString()
}
}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]