Skip to content

Encode form and digest input without CharBuffer - #2385

Open
pavel-ptashyts wants to merge 3 commits into
AsyncHttpClient:mainfrom
maygemdev:claude/form-digest-getbytes-35d803
Open

pavel-ptashyts wants to merge 3 commits into
AsyncHttpClient:mainfrom
maygemdev:claude/form-digest-getbytes-35d803

Conversation

@pavel-ptashyts

Copy link
Copy Markdown
Contributor

Summary

  • StringUtils.charSequence2Bytes / charSequence2ByteBuffer no longer run charset.encode(CharBuffer.wrap(cs)). They are on the path of every url-encoded form body (HttpUtils.urlEncodeFormParams) and every Digest hash (Realm), and of the Digest Authorization header (AuthenticatorUtils).
  • The bytes are the same as before. The encoding step takes 25 to 50% less time on these paths and allocates the same or less, except for Digest input with non-ASCII credentials.
  • No public signature changes. Follows Encode setBody(String) bodies with String.getBytes #2371, which did the same for setBody(String).

Problem

CharBuffer.wrap(CharSequence) returns a buffer with no backing array, so the JDK encoder walks it char by char through virtual get/put calls and misses the array loop and the String intrinsics. All three callers pass a pooled StringBuilder, on the caller thread, once per request attempt (form) or three times per Digest response.

Change

charSequence2Bytes:

  • a String goes through String.getBytes(charset);
  • a StringBuilder (or any other CharSequence) in UTF-8, ISO-8859-1 or US-ASCII is copied char by char into an exact-size byte[] while every char is below U+0080, since all three charsets map such a char to that byte. On the first char at or above U+0080 it falls back to toString().getBytes(charset);
  • any other charset goes through toString().getBytes(charset).

charSequence2ByteBuffer wraps that array. A url-encoded form is always ASCII (everything else is percent-encoded), so form bodies never reach the fallback; Digest input reaches it only with non-ASCII credentials or realm.

String.getBytes(charset) and Charset.encode both replace malformed and unmappable input with the encoder's replacement and write the same byte order marks; #2371 checked this for every charset. The new tests check it again here:

  • StringUtilsTest: both methods against charset.encode(CharBuffer.wrap(cs)) for every charset the JDK can encode, 15 edge inputs (unpaired surrogates, supplementary chars, BOM, replacement char, NUL/DEL/U+0080/U+00FF/U+0100), and four kinds of CharSequence: String, StringBuilder, a StringBuilder with a UTF-16 coder (the state a pooled builder keeps after it once held a char above U+00FF) and CharBuffer.
  • HttpUtilsTest: urlEncodeFormParams against US_ASCII.encode of the same url-encoded form, in UTF-8, ISO-8859-1 and GBK, with non-ASCII, a supplementary char, unpaired surrogates and a null value, from a pooled builder with a UTF-16 coder.
  • RealmTest: the Digest response for Cyrillic user, a password with a lone surrogate, Euro sign and emoji, and a non-ASCII realm, in ISO-8859-1 (default) and UTF-8 (charset=UTF-8), against MD5 over charset.encode bytes.

The returned ByteBuffer now has capacity equal to its limit. Before, it was whatever the encoder had allocated (1.1 bytes per char for UTF-8, more after growth). That capacity is not specified by Charset.encode, and every caller reads only position to limit.

An alternative, sb.toString().getBytes(charset) for everything, is faster still but copies the builder into a String first, which roughly doubles allocation per form body against main (numbers below), so it was not taken.

Benchmark

FormDigestEncodeBenchmark (new) calls production code only, so the same class runs against main and this branch. JDK 17, -f 2 -wi 4 -i 5 -prof gc, three rounds alternating main and branch; the median round is shown.

benchmark ns/op main -> branch B/op main -> branch
encodeFormBuilder: 6,691-byte url-encoded form, US-ASCII 7,855 -> 3,871 6,768 -> 6,768
encodeDigestBuilder: 131-char ASCII Digest input, ISO-8859-1 109 -> 82 208 -> 208
encodeUtf8DigestBuilder: Cyrillic user:realm:password, UTF-8 405 -> 201 360 -> 584
formSmall: urlEncodeFormParams, 4 params 658 -> 439 224 -> 168
formLarge: urlEncodeFormParams, 256 params within noise ~6,810 -> 6,768
digestAscii: Realm.Builder.build(), ASCII credentials within noise ~2,270 -> ~2,000
digestUtf8: Realm.Builder.build(), Cyrillic credentials, UTF-8 within noise ~2,544 -> ~2,484

The end-to-end form and Digest timings are dominated by url-encoding and by SecureRandom for the cnonce. On this shared host they moved by more than the change itself between runs of the same build (main digestAscii measured anywhere from 1.8 to 9.2 us), so only their B/op is reported. B/op is deterministic up to a few dozen bytes.

encodeUtf8DigestBuilder allocates more: the loop's array is discarded at the first non-ASCII char, and getBytes(UTF_8) on a UTF-16 string uses a 3-bytes-per-char scratch array, the same trade-off as in #2371. This is only for Digest input with non-ASCII credentials, which is short.

Loop against toString().getBytes (exploratory, not committed), the encoding step alone, UTF-8, -f 2 -wi 4 -i 5 -prof gc, ns/op and B/op:

input old Charset.encode this PR (loop) toString().getBytes
ASCII, 6,691 chars 12,828 / 7,488 2,905 / 6,768 1,114 / 13,504
ASCII, 6,691 chars, UTF-16-coded builder 12,454 / 7,488 1,693 / 6,768 1,034 / 13,504
ASCII, 128 chars 319 / 272 54 / 200 36 / 368
ASCII, 128 chars, UTF-16-coded builder 273 / 272 41 / 200 31 / 368
128 chars ending in U+00E9, UTF-16-coded builder 302 / 272 136 / 768 125 / 648

The host was shared with other builds, so take the timings as indicative.

Second-agent review

Both reviewers ran read-only, in fresh sessions, on git diff upstream/main...HEAD.

  • Guided reviewer, checklist AGENTS.md: Codex gpt-6-astra, reasoning effort medium.
  • Independent reviewer, no checklist: Claude Code claude-opus-5-5, effort medium, tools Read/Grep/Glob only.
round commit guided independent
1 46ff4250d 1 minor: raw non-ASCII literals in the new tests and bench. Fixed in 665746074 (Java unicode escapes). 1 major: the same. Fixed in 665746074.
2 665746074 1 minor: the returned buffer's capacity now equals its limit. Not changed: the capacity of Charset.encode's result is unspecified and every caller reads only position to limit (see Change). 1 minor: show the loop is not slower than toString().getBytes, including a UTF-16-coded builder. Measured (table above); the loop is kept for its lower allocation.
3 665746074, with the author's answers to rounds 1 and 2 No findings. No findings.

AI disclosure

Claude Code on behalf of Pavel Ptashyts. The commits include Co-Authored-By per AGENTS.md.

Test plan

  • StringUtilsTest, HttpUtilsTest, RealmTest, AuthenticatorUtilsTest, DigestAuthTest, DigestAuthRfc7616Test, ParamEncodingTest: 94 tests pass.
  • mvnw.cmd -B -ntp -Dgpg.skip=true clean verify on JDK 17 (no JDK 11 on the host): 1,998 tests, 0 failures, 0 errors, 35 skipped, on 665746074; Revapi passed. Source/target is 11. JDK 11 coverage comes from CI.

Claude Code on behalf of Pavel Ptashyts

🤖 Generated with Claude Code

pavel-ptashyts and others added 3 commits November 7, 2026 20:13
StringUtils.charSequence2ByteBuffer ran charset.encode(CharBuffer.wrap(cs)).
The wrapped CharBuffer has no backing array, so the encoder walks it
char by char and starts from an estimated buffer size. Every
url-encoded form body (HttpUtils.urlEncodeFormParams) and every Digest
hash input (Realm) goes through it, on the caller thread.

charSequence2Bytes now copies an ASCII StringBuilder straight into a
byte array for UTF-8, ISO-8859-1 and US-ASCII, which all map a char
below U+0080 to that byte. Any other input or charset goes through
String.getBytes(charset), which AsyncHttpClient#2371 showed to produce the same bytes
as Charset.encode, replacements and byte order marks included.
charSequence2ByteBuffer wraps that array. Signatures are unchanged.

New tests check the bytes against Charset.encode for every charset the
JDK can encode, for String, StringBuilder and CharBuffer input, and on
the form and Digest paths with non-ASCII and unpaired surrogates.

Claude Code on behalf of Pavel Ptashyts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
End-to-end form and Digest timings are dominated by url-encoding and
SecureRandom, so the change under test is lost in their noise. Add arms
that run only charSequence2ByteBuffer on the builders those paths
produce, including a non-ASCII UTF-8 one for the fallback.

Claude Code on behalf of Pavel Ptashyts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
AGENTS.md requires ASCII-only repository content. The new test and
benchmark strings carried raw non-ASCII characters; write them as
Java unicode escapes instead. The strings are unchanged.

Claude Code on behalf of Pavel Ptashyts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant