Skip to content

Honor request Connection: close in HTTPKeepAliveHandler - #149

Open
hippi345 wants to merge 3 commits into
swift-server:mainfrom
hippi345:fix/119-connection-close
Open

hippi345 wants to merge 3 commits into
swift-server:mainfrom
hippi345:fix/119-connection-close

Conversation

@hippi345

@hippi345 hippi345 commented Oct 17, 2026 •

Copy link
Copy Markdown

Motivation

HTTPKeepAliveHandler never read the inbound Connection header. A client that sends Connection: close (RFC 9112 §9.6) is stating that this is the last request on the connection, but the server kept the channel open until the idle or read-header timeout. The response also omitted Connection: close.

The first fix set closeAfterResponseEnd from the request head. That introduced a pipelining bug: HTTPServerPipelineHandler.write(.end) can synchronously deliver the next pending request, whose .head re-enters channelRead and overwrites closeAfterResponseEnd before the current response decides whether to close. Three pipelined POSTs with Connection: close only on the last request then produced r1 and r2 and EOF — r3 never went out, and r2 had no Connection: close. The header check also used == "close", so list forms such as keep-alive, close were ignored.

Fixes #119

HTTP/1.0 default close / keep-alive handling is intentionally out of scope to keep the change small and reduce conflicts with #94.

Modifications

  • In HTTPKeepAliveHandler.channelRead, when any Connection field contains a close token, set closeAfterResponseEnd.
  • Parse every Connection field: split on ,, trim whitespace, compare tokens case-insensitively.
  • When streaming a fully received request, copy Connection: close onto the response head.
  • Capture let shouldClose = self.closeAfterResponseEnd before context.write in the .streaming path, and capture the close decision before the writes in flushBuffer. Use that captured value for the Connection header and the close-after-.end decision so a pipelined next request cannot close the current response early.
  • Tests in HTTPKeepAliveHandlerTests: single-request close (plaintext and TLS), EmbeddedChannel close, pipelined POSTs where only the last request has Connection: close (plaintext and TLS), and token forms (keep-alive, close, close, TE, Close, two separate Connection fields).

Result

A request with a close token receives Connection: close on the response, and the connection closes after that response ends. Earlier pipelined responses on the same connection stay keep-alive and are fully written.

Testing

Toolchain: Swift 6.4.1 (swift-6.4.1-RELEASE), x86_64-unknown-linux-gnu

Commands (CI-equivalent):

SWIFT_CERTIFICATES_ALLOW_SWIFT_CRYPTO_BETA=1 ENABLE_ALL_TRAITS=1 \
  swift test --explicit-target-dependency-import-check error -Xswiftc -require-explicit-sendable

swift-format lint --strict on the two changed files passed.

Before (unmodified main, new tests only)

All new tests failed at runtime:

✘ Test "Connection close token forms close the EmbeddedChannel after the response"
  connectionValue → "close" / "keep-alive, close" / "close, TE" / "Close"
  Expectation failed: response.headerFields[.connection] == "close"
  Expected Connection: close, got headers: [Content-Length: 0]
✘ Test testConnectionCloseAcrossMultipleConnectionFields()
  Expectation failed: response.headerFields[.connection] == "close"
✘ Test "Pipelined POSTs: only the last Connection: close closes after that response"
  http1Variant → .plaintextHTTP1_1 / .http1_1
  Expectation failed: response.headerFields[.connection] == "close"
  (r1–r3 arrived; r3 had no Connection: close; connection stayed open)
✘ Test run with 3 tests in 1 suite failed after 30.040 seconds with 12 issues.

Before (35416cd, new tests only)

The first fix honored a lone close / Close token, but missed list forms and broke pipelining:

✘ Test "Connection close token forms …" connectionValue → "keep-alive, close"
  Expectation failed: response.headerFields[.connection] == "close"
✘ Test "Connection close token forms …" connectionValue → "close, TE"
  Expectation failed: response.headerFields[.connection] == "close"
✘ Test testConnectionCloseAcrossMultipleConnectionFields()
  Expectation failed: response.headerFields[.connection] == "close"
✘ Test "Pipelined POSTs: only the last Connection: close closes after that response"
  Expected .head for request 3, got nil
✘ Test run with 3 tests in 1 suite failed after 0.032 seconds with 8 issues.

After (this change)

New tests:

✔ Test testConnectionCloseAcrossMultipleConnectionFields() passed after 0.003 seconds.
✔ Test "Connection close token forms close the EmbeddedChannel after the response" with 4 test cases passed after 0.003 seconds.
✔ Test testRequestConnectionCloseClosesEmbeddedChannelAfterResponseEnd() passed after 0.003 seconds.
✔ Test "Pipelined POSTs: only the last Connection: close closes after that response" with 2 test cases passed after 0.025 seconds.
✔ Test "Request Connection: close closes the connection after the response ends" with 2 test cases passed after 0.026 seconds.
✔ Test run with 5 tests in 1 suite passed after 0.026 seconds.

Full suite:

✔ Test run with 210 tests in 43 suites passed after 6.018 seconds.

This change was prepared with AI assistance and reviewed by the author.

Motivation:

HTTPKeepAliveHandler never inspects the inbound Connection header, so
an HTTP/1.1 client that sends Connection: close (RFC 9112 §9.6) is left
on an open connection until the idle or read-header timeout.

Modifications:

Set closeAfterResponseEnd when the request head carries Connection:
close, and add Connection: close to the response head when streaming a
fully received request. Add HTTPKeepAliveHandlerTests that cover the
end-to-end HTTP/1.1 path and an EmbeddedChannel unit test.

Result:

A request with Connection: close receives Connection: close on the
response and the connection closes after the response ends.
Motivation:

Writing response .end can synchronously deliver the next pipelined
request into HTTPKeepAliveHandler.channelRead, which overwrites
closeAfterResponseEnd before the current response decides whether to
close. That drops later pipelined responses when only the last request
carries Connection: close. The header check also compared the whole
field to "close", so list forms such as "keep-alive, close" were
ignored.

Modifications:

Capture shouldClose before context.write in the streaming path and
before the writes in flushBuffer, and use that value for the
Connection header and the close decision. Parse every Connection field
by splitting on commas, trimming whitespace, and matching tokens
case-insensitively. Add pipelined POST and token-form tests.

Result:

Only the request that asked to close receives Connection: close, later
pipelined responses are still written, and list or multi-field
Connection: close tokens are honored.
@hippi345
hippi345 marked this pull request as ready for review October 17, 2026 09:12
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.

HTTP/1.1 server ignores the request's Connection header

1 participant