Repository navigation
Encode form and digest input without CharBuffer - #2385
Open
pavel-ptashyts wants to merge 3 commits into
Open
pavel-ptashyts wants to merge 3 commits into
pavel-ptashyts wants to merge 3 commits into
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
StringUtils.charSequence2Bytes/charSequence2ByteBufferno longer runcharset.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 DigestAuthorizationheader (AuthenticatorUtils).setBody(String).Problem
CharBuffer.wrap(CharSequence)returns a buffer with no backing array, so the JDK encoder walks it char by char through virtualget/putcalls and misses the array loop and theStringintrinsics. All three callers pass a pooledStringBuilder, on the caller thread, once per request attempt (form) or three times per Digest response.Change
charSequence2Bytes:Stringgoes throughString.getBytes(charset);StringBuilder(or any otherCharSequence) in UTF-8, ISO-8859-1 or US-ASCII is copied char by char into an exact-sizebyte[]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 totoString().getBytes(charset);toString().getBytes(charset).charSequence2ByteBufferwraps 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)andCharset.encodeboth 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 againstcharset.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 ofCharSequence:String,StringBuilder, aStringBuilderwith a UTF-16 coder (the state a pooled builder keeps after it once held a char above U+00FF) andCharBuffer.HttpUtilsTest:urlEncodeFormParamsagainstUS_ASCII.encodeof 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 overcharset.encodebytes.The returned
ByteBuffernow 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 byCharset.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 aStringfirst, 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.encodeFormBuilder: 6,691-byte url-encoded form, US-ASCIIencodeDigestBuilder: 131-char ASCII Digest input, ISO-8859-1encodeUtf8DigestBuilder: Cyrillicuser:realm:password, UTF-8formSmall:urlEncodeFormParams, 4 paramsformLarge:urlEncodeFormParams, 256 paramsdigestAscii:Realm.Builder.build(), ASCII credentialsdigestUtf8:Realm.Builder.build(), Cyrillic credentials, UTF-8The end-to-end form and Digest timings are dominated by url-encoding and by
SecureRandomfor the cnonce. On this shared host they moved by more than the change itself between runs of the same build (maindigestAsciimeasured 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.encodeUtf8DigestBuilderallocates more: the loop's array is discarded at the first non-ASCII char, andgetBytes(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:Charset.encodetoString().getBytesThe 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.AGENTS.md: Codexgpt-6-astra, reasoning effort medium.claude-opus-5-5, effort medium, tools Read/Grep/Glob only.46ff4250d665746074(Java unicode escapes).665746074.665746074Charset.encode's result is unspecified and every caller reads only position to limit (see Change).toString().getBytes, including a UTF-16-coded builder. Measured (table above); the loop is kept for its lower allocation.665746074, with the author's answers to rounds 1 and 2AI disclosure
Claude Code on behalf of Pavel Ptashyts. The commits include
Co-Authored-ByperAGENTS.md.Test plan
StringUtilsTest,HttpUtilsTest,RealmTest,AuthenticatorUtilsTest,DigestAuthTest,DigestAuthRfc7616Test,ParamEncodingTest: 94 tests pass.mvnw.cmd -B -ntp -Dgpg.skip=true clean verifyon JDK 17 (no JDK 11 on the host): 1,998 tests, 0 failures, 0 errors, 35 skipped, on665746074; Revapi passed. Source/target is 11. JDK 11 coverage comes from CI.Claude Code on behalf of Pavel Ptashyts
🤖 Generated with Claude Code