Skip to content

AsyncHttpClient: connection closed with zero bytes on reused keep-alive connection is misreported as a successful HTTP 200 empty response #843

Description

@tanjinkun

Environment

  • libhv v1.3.3 (also verified present in current master: Http1Parser.h InitResponse() unchanged)
  • Any platform (reproduced on Windows MinGW-UCRT64 and observed in production on Linux x86_64, gcc 15.2)
  • Plain HTTP, AsyncHttpClient, keep-alive connection reuse

Symptom

On a reused keep-alive connection, if the peer closes the connection without sending a single
byte
of the response, the request callback receives a default-constructed success response:
status_code == 200 with an empty body
, typically within 1–2 ms (a local event, not a network
round trip). The built-in retry mechanism is bypassed (the close path takes successCallback,
not the retry branch).

We hit this in a production trading system: a signed order POST got a fake "200 empty" reply in
2.0 ms; the order never reached the exchange, but the application treated it as accepted.

Root cause (source analysis)

  1. http/Http1Parser.h — client-side InitResponse() resets the C parser and string buffers but
    does not reset libhv's own state member. Note the asymmetry with server-side
    InitRequest(), which does contain state = HP_START_REQ_OR_RES;:

    virtual int InitResponse(HttpResponse* res) {
        res->Reset();
        parsed = res;
        http_parser_init(&parser, HTTP_RESPONSE);
        // <-- missing: state = HP_START_REQ_OR_RES;
        url.clear(); ...
    }
  2. When the previous response on the connection completed, on_message_complete_cb set
    state = HP_MESSAGE_COMPLETE (Http1Parser.cpp). After InitResponse() for the next request,
    state still holds HP_MESSAGE_COMPLETE.

  3. Peer closes the connection before sending anything. AsyncHttpClient.cpp channel->onclose:

    if (ctx->parser && ctx->parser->IsEof()) {
        ctx->successCallback();          // <-- taken, because...
    }

    Http1Parser::IsEof() is FeedRecvData(NULL, 0); return IsComplete(); and IsComplete()
    returns state == HP_MESSAGE_COMPLETEtrue from the stale previous message, with zero
    bytes received for the current response.

  4. successCallback() delivers the freshly Reset() response — and HttpMessage::Reset()
    defaults status_code = HTTP_STATUS_OK (HttpMessage.cpp) with an empty body.

Minimal reproduction

Self-contained project (misbehaving server + client + script):
(attach or link the libhv_bug_repro/ directory: server.py, client.cpp, CMakeLists.txt, run_repro.sh)

  • server.py: per connection, answers the 1st request with a proper
    200 / Content-Length / Connection: keep-alive, then reads the 2nd request and closes the
    socket without responding.
  • client.cpp: sends req1 (completes, connection pooled), then req2 (reuses the pooled
    connection), and asserts on the callback of req2.

Results (10 runs each):

mode v1.3.3 unpatched with 1-line fix
default (retry_count=1) 10/10 bug: status=200, body empty in ~1 ms 10/10 correct: retried on a fresh connection, real body received (~943 ms)
SetRetry(0,0) 10/10 bug (retry is bypassed entirely) 10/10 correct: callback gets resp == null (transport error)

Proposed fix (one line)

 virtual int InitResponse(HttpResponse* res) {
     res->Reset();
     parsed = res;
     http_parser_init(&parser, HTTP_RESPONSE);
+    state = HP_START_REQ_OR_RES;
     url.clear();
     header_field.clear();
     header_value.clear();
     return 0;
 }

This restores symmetry with InitRequest() and makes the close-without-response case take the
existing retry/error branch in AsyncHttpClient::onclose (verified: with the fix, default mode
retries successfully on a fresh connection; with retry disabled, the callback correctly receives
null).


中文摘要

keep-alive 复用连接上,对端零字节关闭连接时,AsyncHttpClient 会把它误报为
status=200、空 body 的成功响应(约 1–2ms 的本地事件),并绕过 retry。
根因:Http1Parser::InitResponse() 没有像服务端 InitRequest() 那样复位 state
成员,上一条响应残留的 HP_MESSAGE_COMPLETE 使 onclose 里的 IsEof() 误判为
"响应已完整"而走 successCallback()HttpResponse::Reset() 的默认 status 恰是 200)。
修复为一行:InitResponse() 内补 state = HP_START_REQ_OR_RES;
最小复现工程见附件(恶意 keep-alive 服务器 + 客户端断言,修复前 20/20 复现、修复后 20/20 正确)。
我们在生产交易系统中因此把"下单请求从未到达交易所"当成了"已受理"。


(提交时可附 fix.patchlibhv_bug_repro 打包;或直接开 PR。)

fix.patch

我用claude修复的,我也不确定是否对,麻烦有空看看

fix.patch

Metadata

Metadata

Labels

No labels
No labels

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions