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

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions