Audit v2 date: 2026-06-10. Audited at HEAD 1f233bd ("fix: correctness and resource-safety fixes across the HTTP stack"), which landed in response to audit v1 (run at a78a693). This pass goes deeper into every v1 item: it verifies each fix's mechanism line-by-line, hunts for fix-introduced bugs, re-traces the residual paths, and reviews the new tests for coverage of each claim. No code was modified by the audit.
Verification method: static deep-trace of every changed file plus the surrounding call graph; review of all 16 new/extended test files for coverage of each fix claim. The test suite was not executed in this environment (sandbox has JDK 11 only, no Gradle distribution/dependency cache; the build needs provisioned 8/11/21 toolchains). Where a claim would need a runtime check, it is marked Needs-verification.
Headline: all 18 v1 dispositions verified against the diff — 15 fully fixed, M3 mitigated-as-designed, M5 half-fixed (values yes, names no), M7 resolved-as-documented-limitation. The deep pass found 8 new findings (V2-1 … V2-8), all in or adjacent to the fix commit itself; two are Medium. L6′ (the stray-token challenge parse) remains open by design.
HTTP-client toolkit: sdk-core owns immutable models, the Io.installProvider seam, two pipeline layers (stage-based http.pipeline, recovery-based pipeline); transports (OkHttp/JDK) and async adapters plug in via SPIs. Invariants: consume-once bodies unless replayable; response bodies must be closed; blocking calls honor interrupts (InterruptedIOException); retries only re-send replayable bodies; body logging is now bounded capture (post-fix).
- Fix mechanism (verified):
LoggableResponseBodyrewritten around amaxCaptureBytesbounded drain (drainAndCache,LoggableResponseBody.kt:231-281): within-cap bodies are fully captured + delegate closed (repeatablepeek()views, old behavior); over-cap bodies retain the live delegate asliveTailandsource()returns a one-shotPrefixThenTailSource(prefix replay + live tail,:290-311) guarded single-use by a CAS (tailHandedOut,:142-147).TeeSinkgained atapLimitbudget (mirrorPrefix,TeeSink.kt) mirrored across the typed-write,drainScratch, andoutputStream()paths — the full payload always reaches the primary sink. Both instrumentation steps now constructbounded(...)wrappers atbodyPreviewMaxBytes(DefaultInstrumentationStep.kt:151-156,DefaultAsyncInstrumentationStep.kt:241-256), and the async step skips wrapping entirely forcontentLength() < 0so the completion thread can never block on a streaming producer (:249). Public constructors keep the unbounded default — no API break.HttpInstrumentationOptionsand the body-logging doc were rewritten to match. - Test coverage:
LoggableResponseBodyTest(+73 lines: over-cap streams full body, prefix repeatability, partial-failure),TeeSinkTest(+87: budget across all three write paths),InstrumentationStepTest/AsyncInstrumentationStepTest(+77: unknown-length skip). - Residuals: the sync step has no unknown-length skip (→ V2-1); the new drain loop lacks a
read == 0guard (→ V2-4); over-cap consumption can double-close the delegate source (→ V2-5); log-field semantics changed silently (→ V2-8). Boundary note: a body exactly equal to the cap exits the loop without observing EOF, so it takes the over-cap one-shot path where it could have stayed repeatable — correct output, minor behavioral wart.
- Both gates now read
body == null → method ∈ idempotent-set; body != null → body.isReplayable()(RetryStep.ktcanRetry,DefaultRetryStep.ktisRetrySafe— single-line change each, same shape). Deep-checked the semantic corners: PUT+one-shot no longer retried (the bug); PUT/POST+replayable retried (unchanged, with the "caller owns idempotency-key" caveat now documented); body-less POST not retried;NetworkExceptionretries also flow through the gate, correctly refusing physically-unsendable bodies even when the server never saw the request.RetryStepTestfiles grew +163/+36 lines including the exact "idempotent method + one-shot body + retryable status" scenario v1 flagged as untested.
ResponsePipeline.applyResponseStepscaptures the in-hand response before the step runs andcloseQuietly(inResponse, t)on throw, attaching close failures as suppressed;invokeRecoverycloses aSuccess's response when the recovery step throws. Verified close idempotency makes the throw-path close safe even if the step already closed.- Remaining gap (carried, documented here): a recovery step that returns a
Failurefor aSuccessinput (transform, not throw) still drops the response unclosed — that is necessarily the step author's responsibility (the framework can't know whether the step transferred ownership), but theResponseRecoveryStepKDoc does not yet say so. One-line doc fix.
H4 — JDK streaming publisher: shared pipe + eager writer → FIXED for replayable bodies; fix introduced V2-2 and V2-3
- Fix mechanism (verified):
streamingPublisher(BodyPublishers.kt:164-184) now returnsofInputStream { newSubscriptionStream(replayable) }— a fresh pipe pair + fresh writer task per supplier invocation (:201-227), so the JDK's per-subscription supplier calls (407 proxy-auth retry, H2 GOAWAY replay) each get a complete body. The returned stream is aKillSwitchInputStream(:255-286) whoseclose()cancels the writerFuturewith interrupt before closing both pipe ends — the cancel is what unblocks a writer parked inPipedOutputStream.write(closingpipeOutalone would not). The writer task restores the interrupt flag per repo convention. Tests:streamingPublisherSurvivesResubscription,abandonedStreamingPublisherDoesNotStrandWriter,streamingBodyRoundTripsThroughTransport(JdkHttpTransportTest.kt:307-371) cover the two v1 failure modes directly. - But: to make every subscription re-readable, a non-replayable body is first coerced via
toReplayable()— i.e. fully buffered in memory (→ V2-2), and theIOExceptionfallback path is itself buggy (→ V2-3).
- Both
asBlocking()(AsyncHttpClient.kt) andtoBlocking()(AsyncPipelineBridges.kt) now hold the future, block inget(), and onInterruptedException: restore the flag,future.cancel(true), throwInterruptedIOExceptionwith cause — the exactRetryStep.awaitDelaypattern v1 recommended.ExecutionExceptionis unwrapped viaFutures.unwrap.CancellationExceptionstill propagates raw (same as pre-fixjoin(); acceptable). Tests inAsyncHttpClientTest/AsyncHttpPipelineTest(+43/+35).
Requestkeepsdata class(socopy/componentN/toStringare binary-stable) but overridesequals/hashCodeto compareurl.toExternalForm()textually (Request.kt:52-…). No network I/O, no virtual-host conflation; transitively de-fangsResponse/ResponseOutcomeequality. Two accepted consequences, both documented: textual comparison is stricter (http://a≠http://a/), andbodystill compares by identity (as it did pre-fix). 91 lines ofRequestTestadded. The Phase-2URL → URImigration remains the better long-term shape but is no longer urgent.
- The iterator now closes each page immediately after taking
next.value.iterator()(the v1-recommended cheap fix — items are a fully-materialized list, so they survive the close). ThecurrentPagetracking field is gone entirely; there is no window in which an open page waits on a consumer pull.PagedIterableTest+88 lines including partial-consume (first()/take(n)/short-circuit stream) scenarios. Minor note:next.close()failure now aborts iteration even though the items were already in hand —closeQuietly+ log would be friendlier, but failing loud on a close error is defensible. byPage()direct callers still own page lifecycle (documented; unchanged by design).
drainToCap()after every insert bounds the map atMAX_TRACKED_CONTEXTS = 4096, mirroring the Digest nonce-counter pattern v1 pointed at; the KDoc now states the close contract, the pin-the-graph hazard, why eviction is an arbitrary victim, and whyWeakReferencewas rejected (the store is the only strong reference on the live path — a weak ref could collect an in-flight context). Deep-checked the drain loop for the convergence property under concurrent inserts: each insert drains until under cap, so the map cannot ratchet upward. Honest residual, stated in the KDoc itself: under heavy leak pressure a live call's entry can be evicted (arbitrary victim). Acceptable for a backstop.ContextStoreTest+28.
M4 — unbounded request-side logging tap → FIXED, verified (same mechanism as H1: TeeSink tap budget + LoggableRequestBody.bounded, cap preserved across toReplayable rewraps; LoggableRequestBodyTest +40.)
- Header values are now validated at the model layer:
Headers.Builder.add/set(all four String/typed overloads,Headers.kt:163-249) reject\r/\nwith a clear splitting-vector message; the policy choice (CR/LF only, not OkHttp's printable-ASCII rule) is reasoned in the KDoc and is the right transport-agnostic contract.HeadersTest+100.addAll(Headers)bypasses validation but only accepts already-validated built instances — closed loop, verified. - Header names remain unvalidated (→ V2-6), and the new transport-adapter comments overstate the guarantee.
M6 — predicate/delay-override throw leaks the retryable response → FIXED, verified (DefaultRetryStep.decideRetryResponse wraps predicate + computeResponseDelay in try/catch, closeQuietly(response) before rethrow; close idempotency makes the double-close-on-happy-path concern moot. Tests cover predicate-throw and Error-passthrough paths.)
M7 — ProxyOptions.challengeHandler silently ignored → RESOLVED as documented limitation; new doc nit V2-7
- v1 offered "wire it or fail loudly + fix the docs"; the fix chose the latter:
ProxyOptionsKDoc now states the handler is "currently not honoured by any shipped transport", and both transports emit a WARNING event (proxy.auth.challenge_handler.unsupported) when it is set, each with an accurate per-transport rationale (OkHttp: not wired intoproxyAuthenticator; JDK: no per-407 hook exists).proxyChallengeHandlerIsAcceptedAndSurfacedAsUnsupportedtest added. This is a legitimate resolution — except one new doc claim is suspect (→ V2-7).
L1 — OkHttp async cancel/complete race drops the adapted response → FIXED, verified (and the v1 "needs-verification" is now resolved)
onResponsenow splits adapt from complete andcloseQuietly(adapted)whenfuture.complete(adapted)returns false — exactly mirroring the JDK bridge. The new test (asyncResponseThatLosesTheRaceIsClosed,OkHttpTransportTest.kt) reproduces the race deterministically with an interceptor that parks the completed exchange while the test settles the future — a genuinely clever construction that also confirms v1's uncertainty about OkHttp routing cancelled-but-completed exchanges toonFailure: the test deliberately avoidscancel()and uses a decoycomplete()instead, which is the same lost-race code path. v1's Needs-verification is closed as Confirmed-and-fixed.
L2 — writeAllInto silent truncation on read == 0 → FIXED, verified (now throws IOException naming the contract violation, mirroring TeeSink.writeAll; WriteAllIntoTest updated. Ironically the new drain loop in LoggableResponseBody reintroduces the unguarded pattern — see V2-4.)
- Rebuilt textually from
rawPath/rawQuery/rawFragment(DefaultRedirectStep.kt), so%2F/%26survive byte-exact. Deep-checked the edge:uri.hostfor an IPv6 literal includes its brackets in the Java URI API, soscheme://host:portreassembly is correct there;userInfo != nullimplies a server-based authority, sohostis non-null on every path that reaches this function.RedirectStepTest+25 including an encoded-path round-trip.
L4 — broken KDoc package link → FIXED (both ResponseBody.kt references now plain [LoggableResponseBody]).
L5 — redundant per-instance lock in RetryStep → FIXED (lock deleted; resolveScheduler reads the companion by lazy directly; misleading comment corrected — the class now genuinely has no mutable instance state).
L6 — phantom challenge from malformed continuation param → RETRACTED in v1 review; regression test confirmed present (AuthChallengeParserTest.kt:398-411). Stands retracted; the no-phantom behavior is now pinned.
- No main-source change to
AuthChallengeParserin1f233bd; the reviewer explicitly scoped it out. Still bounded (composite handler ignores unsatisfiable challenges; nothing shipped consumes the parser — see M7). Recommend a small follow-up with its own regression test, mostly for pinning value.
L7 — fixtures violate SDK conventions → FIXED, verified (FakeHttpClient restores the interrupt flag and throws InterruptedIOException; RequestRecorder uses ReentrantLock.withLock.)
V2-1. Sync instrumentation step still wraps unknown-length bodies — bounded drain stalls the caller until the preview cap fills
- Where:
DefaultInstrumentationStep.ktwrapResponseForLogging(nocontentLength() < 0guard) vs.DefaultAsyncInstrumentationStep.kt:249(guard present). - What's wrong: the async step skips capture for streaming bodies precisely because the bounded drain "could block on a slow/idle producer" — but the sync step has the identical exposure on the caller's thread and wasn't given the skip. The bounded drain loops until cap reached or EOF, so for an SSE/long-poll/trickle stream under
BODY_AND_HEADERS, the caller doesn't see the response until 8 KiB (default) of stream has accumulated. - Failure mode: enable body logging against an SSE endpoint emitting ~50 B keep-alives: time-to-first-event goes from milliseconds to "however long 8 KiB takes" — minutes to never. v1's H1 hang is bounded now, not gone.
- Fix: apply the same
contentLength() < 0L → return responseguard to the sync step (and say so inHttpInstrumentationOptions, which currently describes the skip as an async-only difference as if the sync eager drain were safe). - Confidence: Confirmed (code asymmetry is plain; both files were edited in the same commit, which is also fresh evidence for the dedup item — the two copies have now actually diverged in behavior, not just text).
V2-2. JDK streaming path now fully buffers non-replayable bodies in memory — unbounded heap traded for resend correctness
- Where:
sdk-transport-jdkhttp/.../internal/BodyPublishers.kt:164-184(streamingPublisher→body.toReplayable()),:240-244(bufferToByteArray). - What's wrong: the per-subscription design requires re-readable bytes, so any non-replayable body — which on this path is by definition larger than 64 KiB or of unknown length, exactly the bodies the streaming path exists to avoid materializing — is drained whole into an in-memory
BufferbytoReplayable(). A 5 GB one-shotInputStreamupload now means ~5 GB of heap; beyondBuffer.MAX_BYTE_ARRAY_SIZE(~2 GiB) the related eager fallback'ssnapshot()throwsIllegalStateException. This re-imports the H1-class hazard on the request path of one transport. - Failure mode: large streaming upload (non-replayable) through
JdkHttpTransport→ heap blow-up orIllegalStateException, where pre-fix it streamed (albeit with the resend bug) and whereOkHttpTransportstill streams it fine (itsisOneShot()contract lets OkHttp fail a resend cleanly without buffering). - Fix: stream the first subscription directly from the one-shot body and make the supplier's second invocation throw
IllegalStateException("one-shot body cannot be re-sent")— matching the consume-once discipline used everywhere else in the SDK (and OkHttp'sisOneShotbehavior). Buffering should at most apply below some bounded threshold. - Confidence: Confirmed (mechanism); severity assumes consumers send large one-shot bodies through the JDK transport.
V2-3. streamingPublisher's IOException fallback re-drives a consumed body — masks the original error with IllegalStateException
- Where:
BodyPublishers.kt:169-182. - When
toReplayable()throwsIOExceptionmid-buffer, the catch falls back tobufferToByteArray(body)— buttoReplayable()already flipped the body's consume-once guard (e.g.OneShotInputStreamRequestBody.consumed), so the fallback'swriteTothrowsIllegalStateException, masking the real I/O failure — the precise masking pattern H2 was fixed for. The comment ("emitting whatever was captured") describes bytes the code does not actually have: the partial buffer was local totoReplayableand is lost. Fix: rethrow theIOException(wrapped with context) instead of falling back. Confirmed.
- Where:
LoggableResponseBody.kt:239-248(val n = capturedSource.read(buf, chunk); only-1and positive values are handled). - A misbehaving
Sourcereturning0for a positivebyteCountleavesremainingunchanged → infinite loop. The same commit added this exact guard towriteAllInto(L2 fix) and it already exists inTeeSink.writeAll— this is the third copy of the loop and the only unguarded one. Fix:n == 0L → throw IOException(...)like its siblings. Confirmed (misbehaving-source trigger only, same class as L2).
- Where:
PrefixThenTailSource.close(LoggableResponseBody.kt:303-310) closes the live tail (the delegate's source) but does not setdelegateClosed; a subsequentLoggableResponseBody.close()(:184-200) then callsdelegate.close(), which forResponseBody.create-style bodies closes the same source again. - Safe today: both transports' bodies route to
OkioBufferedSource, whoseclose()is CAS-guarded idempotent, andResponseBody.close()'s own contract demands idempotency. Fragile for exotic delegate implementations whose close has side effects beyondsource().close()— the exact "some sockets throw on double-close" concern this class's own comments cite. Fix: have the one-shot source'sclose()route through the wrapper (setdelegateClosed, calldelegate.close()), so ownership stays single-threaded through one path. Confirmed (by reading; benign with shipped transports).
V2-6. Header names still bypass validation — the M5 divergence survives for names, and new comments overstate the fix
- Where:
Headers.kt:311(sanitizeName= lowercase +trim()— strips only leading/trailing whitespace; embedded\r/\nsurvive), vs.validateValues(:325) which covers values only. The new comment in the OkHttpRequestAdapter("Header names/values are validated upstream by Headers.Builder") claims more than is true; the JDK adapter's equivalent comment is accurate (it scopes itself to restricted names). - Failure mode:
add("X-Evil\r\nInjected", "v")is accepted by the model layer; OkHttp then throws an uncheckedIllegalArgumentExceptionout ofexecute()(bypassingcatch (IOException)) while the JDK adapter silently drops the header — the exact per-transport divergence M5 was about, now only for names. Attacker-controlled header names are rarer than values, hence Low. - Fix: add
validateName(reject CR/LF at minimum; arguably restrict to RFC 7230tchar) besidevalidateValues, and correct the OkHttp adapter comment. Confirmed.
- Where:
ProxyOptions.kt:32-33(added by the fix). - The JDK
java.net.httpclient'sAuthenticatorintegration (internalAuthenticationFilter) supports Basic only; Digest viaAuthenticatoris a documented JDK limitation. If that's right, the new doc sends Digest-proxy users to a transport that can't do it, while the JDK transport's own new KDoc ("use the OkHttp transport for Digest proxy auth") simultaneously points the other way — and OkHttp's authenticator here is also Basic-only, so that pointer is wrong too. The two new doc blocks contradict each other; at most one is right, plausibly neither. - Fix: verify against the target JDK (one integration test with a Digest-challenging proxy), then make both KDocs consistent with reality (likely: "Basic only, on both shipped transports").
- Confidence: Needs-verification (JDK behavior not testable in this sandbox); the mutual contradiction between the two new doc blocks is Confirmed regardless.
V2-8. Observability semantics changed silently: request.body.size / response.body.size now report the preview-capped size
- Where:
DefaultInstrumentationStep.kt/DefaultAsyncInstrumentationStep.ktemitResponseEvent(unchanged code, changed inputs —snapshot(bodyPreviewMaxBytes)over a tap/capture that now holds at most the cap). - Pre-fix these fields reported the actual body size; post-fix they report
min(size, bodyPreviewMaxBytes)(default 8 KiB), with nothing in the event distinguishing "body was 8 KiB" from "body was 8 GB". Dashboards or alerting keyed on body-size fields will silently flatline at the cap. Fix: emit…body.sizefromcontentLength()when known and add a…body.preview_truncatedboolean (or rename the field to…body.preview_size). Confirmed (consequence of the H1/M4 fix; arguably intentional but undocumented — the commit message and docs don't mention the field-semantics change).
- Deduplicate the two instrumentation steps — now a correctness matter, not hygiene. V2-1 exists because the same fix was hand-applied to two copies and landed asymmetrically; the
TODO(omar 2026-08-01)extraction marker is still present (DefaultAsyncInstrumentationStep.kt:237). Extract the shared emitter/wrapping logic before the next divergence. - JDK streaming-body design follow-up (V2-2/V2-3): first-subscription streaming + loud one-shot resubscribe failure restores streaming for one-shot bodies without re-importing the resend bug.
- Unify the three drain/pump loops (
TeeSink.writeAll,writeAllInto,LoggableResponseBody.drainAndCache) behind one helper with the0-read guard — V2-4 is the third hand-rolled copy of the same loop with the same forgotten edge. - Sync/async parity for the unknown-length skip (V2-1) — falls out of item 1.
- Header-name validation (V2-6) — one small function beside
validateValues. URL → URIin models — M1's textual-equality fix removed the urgency; still the right end state (also killsURL's other landmines:toExternalFormre-serialization cost in hot equality, stream-handler coupling).- Doc truthing pass on proxy auth (V2-7) and on the body-size log fields (V2-8).
- L6′ parser follow-up with a pinned regression test.
- Repo hygiene:
.claude/worktrees/agent-aa2f74e153671cbbf/stale tree (with its phantomsdk-authmodule) still present;QueryParamTODO()stub, unwiredAuthMetadata/HttpTracer/ServerSentEventListenersurfaces unchanged from v1 item 11. ResponseRecoveryStepKDoc: document that a step transformingSuccess → Failureowns closing the response it discards (H3's remaining contract gap).
Read for this pass: the complete 1f233bd diff (48 files, +2241/−269) — every changed main-source file re-read in full post-fix (LoggableResponseBody, TeeSink, LoggableRequestBody, both instrumentation steps + options, both retry steps, ResponsePipeline, PagedIterable, ContextStore, Headers, Request, DefaultRedirectStep, ProxyOptions, AsyncHttpClient, AsyncPipelineBridges, WriteAllInto, BodyPublishers (full re-read), JdkHttpTransport, both RequestAdapters, OkHttpTransport, ResponseBody, both fixtures) plus the surrounding unchanged call sites needed to validate each fix (RequestBody.toReplayable, OkioBufferedSource.close, HttpExceptionFactory consumers, PagedResponse, Paginator). New/changed tests reviewed by content for the five High fixes and by name/structure for the rest. v1's full-codebase coverage (every main-source file in all nine modules + test fixtures) carries over; nothing outside the diff was re-read line-by-line in v2 except as call-graph context.
Not done in this environment: executing the test suite (JDK 11-only sandbox, no Gradle/toolchain cache) — all "tests cover X" statements are from reading the test code, not from a green run; and the V2-7 JDK Digest question needs a live probe.
Bottom line: 1f233bd is a high-quality fix pass — every v1 High is genuinely closed for the mainstream paths, with deterministic tests for the racy ones. The residual risk has moved from "production-facing defects in core paths" to: one behavioral asymmetry between duplicated files (V2-1), one deliberate-but-costly trade-off in the JDK transport (V2-2), and a tail of small hardening items (V2-3 … V2-8). The single most valuable next change is the instrumentation-step deduplication, which retires V2-1's class of bug permanently.