Skip to content

fix: keep LoggableResponseBody's single-close guarantee honest when close() throws#137

Merged
OmarAlJarrah merged 3 commits into
mainfrom
fix/loggable-response-body-close-guard
Jun 20, 2026
Merged

fix: keep LoggableResponseBody's single-close guarantee honest when close() throws#137
OmarAlJarrah merged 3 commits into
mainfrom
fix/loggable-response-body-close-guard

Conversation

@OmarAlJarrah

@OmarAlJarrah OmarAlJarrah commented Jun 17, 2026

Copy link
Copy Markdown
Member

Problem

LoggableResponseBody guarantees its delegate (and, by ownership, the delegate's source) is closed at most once — some sockets and streams throw on double-close, which is the whole reason the delegateClosed guard and the closeDelegateOnce() helper exist. Two close paths broke that guarantee when a close() itself threw.

1. closeDelegateOnce() — over-cap path

The guard was flipped only after delegate.close() returned normally:

if (!delegateClosed) {
    delegate.close()
    delegateClosed = true
}

If delegate.close() throws, delegateClosed is left false. Both the wrapper's own close() and the over-cap one-shot source (PrefixThenTailSource.close()) reach the delegate through this method from independent entry points, so a failure on the first path leaves the guard unset and the second path closes the delegate again.

2. drainAndCache() — full-capture path

When the whole body fits within the cap, the drain closes the source and only then sets the guard:

if (fullyCaptured) {
    capturedSource.close()
    delegateClosed = true
}

If capturedSource.close() throws, the guard is again left unset and control falls into the catch block, which closes the same source instance a second time. Worse, the close failure is stored as a drainError, so source() then re-throws on every subsequent call — denying the caller the complete body that had already been buffered, even though the capture itself succeeded. That also contradicts the documented contract that captureException is null when the body was captured successfully.

Change

Over-cap path: flip the guard in a finally so the delegate is marked closed whether or not its close() succeeds, while still letting the exception propagate:

if (delegateClosed) return
try {
    delegate.close()
} finally {
    delegateClosed = true
}

Full-capture path: close the source as best-effort. The capture has already succeeded, so a failure to release the handle is cleanup, not a capture failure: swallow it (mirroring the existing read-failure handler) and mark the delegate closed either way.

if (fullyCaptured) {
    try {
        capturedSource.close()
    } catch (_: Throwable) {
        // best-effort: the body is already fully captured
    }
    delegateClosed = true
}

The complete body stays readable, captureException stays null, and the source is closed exactly once.

Tests

  • A delegate whose close() throws is invoked exactly once across two close() calls (the over-cap one-shot source close followed by the wrapper close).
  • A fully-captured source whose close() throws stays readable and is not poisoned (source() returns the complete body, captureException is null).
  • A fully-captured source whose close() throws is closed exactly once across the drain and a later wrapper close.

Gated build

./gradlew build passes — the full multi-module build, including ktlint, detekt, allWarningsAsErrors, explicit-API strict mode, apiCheck, the Kover 80% aggregate coverage gate, and the R8 shrink-survival guard. No public-API change, so apiCheck passed without an apiDump.

Closes #115

LoggableResponseBody.closeDelegateOnce() set the delegateClosed flag only
after delegate.close() returned normally. When that close throws, the flag
was left false, so a second close() reaching the delegate through the other
entry point (the over-cap one-shot PrefixThenTailSource.close() vs the
wrapper's own close()) would close the delegate a second time. The guard
exists precisely to guarantee a single close for delegates whose handles are
not safe to close twice, and the error path silently broke that guarantee.

Flip the guard in a finally so the delegate is marked closed whether or not
its close() succeeds, while still letting the exception propagate. This also
matches the drain-path error handler, which already marks the delegate closed
after a failed source close so a later close() is a no-op.

Closes #115
On the full-capture path, drainAndCache() closed the source and only
then set the single-close guard. If that close() threw, the guard was
left unset and control fell into the catch block, which closed the same
source a second time — the double-close the wrapper's guard exists to
prevent (some sockets/streams throw on double-close). The close failure
was also stored as a drainError, so source() then re-threw on every call
and the caller could no longer read the complete body that had already
been buffered.

Close the source as best-effort on this path: swallow a close failure
(mirroring the read-failure handler) and mark the delegate closed whether
or not close() succeeds. A failure to release the handle after a complete
capture is cleanup, not a capture failure, so the buffered body stays
readable, captureException stays null, and the source is closed once.

Add tests for a fully-captured source whose close() throws: the body
remains readable and unpoisoned, and the source is closed exactly once
across the drain and a later wrapper close. Also fold the close-guard
rationale into the closeDelegateOnce KDoc to drop a duplicated comment.
@OmarAlJarrah OmarAlJarrah changed the title fix: keep LoggableResponseBody single-close guard honest when delegate close() throws fix: keep LoggableResponseBody's single-close guarantee honest when close() throws Jun 20, 2026
@OmarAlJarrah OmarAlJarrah merged commit 1425ba7 into main Jun 20, 2026
1 check passed
@OmarAlJarrah OmarAlJarrah deleted the fix/loggable-response-body-close-guard branch June 20, 2026 20:18
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.

LoggableResponseBody: a failed delegate.close() defeats the single-close guard

1 participant