Fix connection handler issue#49
Conversation
There was a problem hiding this comment.
Code Review
This pull request modifies ConnectionHandler::OnMessage to ensure that outbound connections promptly close (ForceClose()) upon receiving a peer read-close (EOF), rather than incorrectly arming the inbound-only 5-second fallback deadline. This fixes a bug where coalesced response bytes and EOF on outbound connections would stall. The PR also introduces integration and unit tests to validate this behavior for both inbound and outbound scenarios. The review feedback highlights potential double-close bugs in the newly added test code, specifically in the MakeNonBlockingSocketpair helper and the test cleanup blocks, where socket file descriptors are closed but not reset to -1 before potential exception handling.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
Fix: outbound peer-close EOF delivery delayed up to 5s when response data + FIN coalesce
Fixes #48
Summary
ConnectionHandler::OnMessage()applied an inbound-only HTTP/1 half-close heuristic to all connections, including established outbound (client-role / upstream-pool) connections. When an outbound peer writes its response body and then closes the TCP connection such that the body bytes and the EOF (read() == 0) drain in the sameOnMessageread cycle, the EOF was not delivered to the connection's borrower — instead a 5-second fallback deadline was armed. The borrower (the upstream proxy codec) therefore finalized the response up to 5 s late.This PR gates the half-close heuristic to inbound connections only (
connect_state_ == ConnectState::NONE); an outbound peer read-close now falls through toForceClose(), delivering the EOF to the borrower immediately.Root cause
In
OnMessage()'speer_closedblock, theelse if (callback_ran)branch arms a bounded fallback deadline. That is correct for inbound connections — HTTP/1 clients mayshutdown(SHUT_WR)after sending a request while awaiting the response, so the server must not close. But the branch did not distinguish inbound from outbound, so it also fired for outbound connections, where a peer read-close after the response body is a definitive end-of-stream.Whether the bug manifests depends on read-cycle coalescing:
callback_ran == false→ reaches the existingelse { ForceClose(); }→ EOF delivered immediately. (Already correct.)read() == 0in the same drain loop):callback_ran == true→ the 5 s deadline was armed → EOF delivery delayed. (Bug.)Coalescing is timing/platform-dependent: frequent on Linux/
epollunder load (deterministic in CI), intermittent on macOS/kqueue. The fix makes the coalesced read path behave identically to the split read path — removing an OS/timing artifact from changing semantics.The fix relies only on the existing invariant already documented in the file: inbound connections always have
connect_state_ == NONE; only the outbound path (RegisterOutboundCallbacks→FinishConnect) setsCONNECTING→CONNECTED.Impact
Any outbound/upstream response whose completion is EOF-delimited — no
Content-Lengthand noTransfer-Encoding: chunked(HTTP/1.0 responses,Connection: closeresponses, streaming/SSE upstreams that end by closing the socket) — could have its finalization delayed by up to 5 s whenever the body and FIN coalesce. For a streaming upstream that drops mid-stream, the proxy's terminal/disconnect signal to the downstream client was delayed the same way. Length-delimited and chunked responses are unaffected (they complete viaon_message_complete, not thepeer_closedpath).Change
server/connection_handler.cc,ConnectionHandler::OnMessage():The
output_bf_.Size() > 0flush-then-close branch is untouched, and inbound behavior is unchanged (inbound is alwaysNONE, socallback_ran && connect_state_ == NONE≡callback_ran).EOF-delivery path (unchanged terminal, just prompt)
ForceClose()→CallCloseCb()→ the pool's close callback (PoolPartition::WirePoolCallbacks) relays an empty-byteson_message_callbackto the borrower →ProxyTransaction::OnUpstreamData("")→UpstreamHttpCodec::Finish()→llhttp_finish()finalizes the EOF-delimited response. This is the same terminal the 5 s-delayed path eventually reached — now delivered immediately. For HTTP/2 upstreams (alsoCONNECTED), a transport FIN with buffered frames likewise routes to a promptForceClose→MarkDead + FailAllStreamsinstead of a 5 s stall.Tests
Coalescing is a genuine race that an end-to-end proxy harness cannot force reliably (the proxy often hits
EAGAINbefore the FIN arrives), so the primary regression guard is a deterministic unit test that buffers both the response bytes and the FIN beforeOnMessageruns:UpstreamPoolTests::TestOutboundCoalescedPeerCloseClosesPromptly(test/upstream/upstream_pool_test.h) — asocketpairwritten-then-shutdown(SHUT_WR)guarantees both bytes and FIN are buffered → one coalesced read cycle. Drives the connection toCONNECTEDand asserts the close path fires promptly. Verified to FAIL without the fix (exact symptom: "inbound 5s half-close deadline armed for an outbound conn") and PASS with it.UpstreamPoolTests::TestInboundCoalescedHalfCloseDoesNotClosePromptly— companion guard locking in the preserved inbound contract (the same coalesced cycle onconnect_state_ == NONEmust NOT force-close).ProxyTests::TestIntegrationEofDelimitedResponseRelayed(test/upstream/proxy_test.h) — robust end-to-end coverage for EOF-delimited (Connection: close) proxying, a response shape that previously had no end-to-end test.Verification
make clean && make && ./test_runner).upstreamandproxysuites run 3× each — all pass, deterministic (no flakiness).racesuite: 7/7 pass.Files changed
server/connection_handler.cc— one-line gate + comment updatestest/upstream/upstream_pool_test.h— 2 deterministic unit teststest/upstream/proxy_test.h— 1 end-to-end EOF-delimited test