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)
-
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(); ...
}
-
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.
-
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_COMPLETE — true from the stale previous message, with zero
bytes received for the current response.
-
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.patch 与 libhv_bug_repro 打包;或直接开 PR。)
fix.patch
我用claude修复的,我也不确定是否对,麻烦有空看看
fix.patch
Environment
master:Http1Parser.hInitResponse()unchanged)AsyncHttpClient, keep-alive connection reuseSymptom
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 == 200with an empty body, typically within 1–2 ms (a local event, not a networkround 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)
http/Http1Parser.h— client-sideInitResponse()resets the C parser and string buffers butdoes not reset libhv's own
statemember. Note the asymmetry with server-sideInitRequest(), which does containstate = HP_START_REQ_OR_RES;:When the previous response on the connection completed,
on_message_complete_cbsetstate = HP_MESSAGE_COMPLETE(Http1Parser.cpp). AfterInitResponse()for the next request,statestill holds HP_MESSAGE_COMPLETE.Peer closes the connection before sending anything.
AsyncHttpClient.cppchannel->onclose:Http1Parser::IsEof()isFeedRecvData(NULL, 0); return IsComplete();andIsComplete()returns
state == HP_MESSAGE_COMPLETE— true from the stale previous message, with zerobytes received for the current response.
successCallback()delivers the freshlyReset()response — andHttpMessage::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 proper200 / Content-Length / Connection: keep-alive, then reads the 2nd request and closes thesocket without responding.
client.cpp: sends req1 (completes, connection pooled), then req2 (reuses the pooledconnection), and asserts on the callback of req2.
Results (10 runs each):
retry_count=1)status=200, body emptyin ~1 msSetRetry(0,0)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 theexisting retry/error branch in
AsyncHttpClient::onclose(verified: with the fix, default moderetries 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.patch与libhv_bug_repro打包;或直接开 PR。)fix.patch
我用claude修复的,我也不确定是否对,麻烦有空看看
fix.patch