{
 "number": 36259,
 "repo": "bitcoin/bitcoin",
 "url": "https://github.com/bitcoin/bitcoin/pull/36259",
 "title": "http: update m_keep_alive under m_send_mutex",
 "author": "azuchi",
 "author_association": "CONTRIBUTOR",
 "created_at": "2026-09-15T09:07:35Z",
 "updated_at": "2026-09-15T13:23:36Z",
 "age_days": 2,
 "draft": false,
 "labels": [
  "RPC/REST/ZMQ"
 ],
 "milestone": null,
 "base": "master",
 "head_sha": "dc8fb12509830a914e122f5ca9d7de35c03d9cc3",
 "head_ref": "http-keepalive-under-send-mutex",
 "head_repo": "azuchi/bitcoin",
 "head_history": [],
 "additions": 156,
 "deletions": 3,
 "changed_files": 3,
 "commit_count": 1,
 "size_bucket": "M",
 "mergeable_state": "clean",
 "bot": {
  "drahtbot": {
   "present": true,
   "reviews": {
    "concept_ack": [
     {
      "login": "winterrdog",
      "url": "https://github.com/bitcoin/bitcoin/pull/36259#pullrequestreview-5210522797"
     }
    ]
   },
   "conflicts": []
  }
 },
 "acks_parsed": {
  "winterrdog": {
   "kind": "concept_ack",
   "hash": null,
   "t": "2026-09-15T13:23:33Z",
   "stale": false
  }
 },
 "acks_tally": {
  "ack": 0,
  "stale_ack": 0,
  "concept_ack": 1,
  "approach_ack": 0,
  "nack": 0,
  "concept_nack": 0,
  "approach_nack": 0
 },
 "reviews": {
  "approved": 0,
  "changes_requested": 0,
  "distinct_reviewers": [
   "winterrdog"
  ]
 },
 "signals": {
  "needs_rebase": false,
  "ci_failed": false,
  "mergeable_state": "clean",
  "last_author_activity": "2026-09-15T09:05:56Z",
  "last_reviewer_activity": "2026-09-15T13:23:33Z",
  "last_reviewer": "winterrdog",
  "author_silent_days": 2,
  "waiting_on_author_days": 2,
  "days_since_update": 2
 },
 "refs": {
  "mentioned": [
   35182,
   35829,
   36174
  ],
  "depends_on": [],
  "fixes": [],
  "linked_issues": [],
  "references": [
   {
    "number": 36174,
    "type": "pull",
    "state": "closed",
    "merged": true,
    "merged_at": "2026-09-10",
    "title": "http: throttle send buffer when client stops draining"
   },
   {
    "number": 35182,
    "type": "pull",
    "state": "closed",
    "merged": true,
    "merged_at": "2026-06-22",
    "title": "Replace libevent with our own HTTP and socket-handling implementation"
   },
   {
    "number": 35829,
    "type": "pull",
    "state": "closed",
    "merged": true,
    "merged_at": "2026-08-26",
    "title": "http: Make class fields private and make HTTPResponse a struct"
   }
  ],
  "conflicts": []
 },
 "stack": {
  "shares_commits_with": [],
  "based_on": [],
  "base_for": []
 },
 "review_paths": [],
 "body": "`HTTPRemoteClient::Send()` sets `m_keep_alive` before taking `m_send_mutex` and appending the response to the send buffer, while the I/O thread reads it under `m_send_mutex` in `MaybeSendBytesFromBuffer()` right after the buffer drains to empty. The two updates are therefore not ordered with respect to each other, and a pipelining client can lose a response:\n\n1. Response 1 (keep-alive) is partially sent and the I/O thread is still draining it.\n2. Request 2 (`Connection: close`) is parsed from the receive buffer and dispatched to a worker (the send throttle from #36174 only holds a request back once the buffer exceeds `MAX_BODY_SIZE`).\n3. The worker clears `m_keep_alive`; the I/O thread then finishes sending response 1, sees an empty buffer with `m_keep_alive == false`, and sets `m_disconnect`.\n4. The worker appends response 2, which is dropped when the client is disconnected on the next loop iteration.\n\nThis PR moves the assignment into the critical section that appends the response, and turns the field from `std::atomic_bool` into a plain `bool GUARDED_BY(m_send_mutex)` so Clang's thread-safety analysis enforces the ordering (the only reader already holds the mutex). With the flag and the buffer updated together, the I/O thread can no longer observe \"empty && !keep_alive\" between two pipelined responses.\n\nThe ordering dates back to the initial HTTP server implementation (#35182); #35829/#36007 only moved the code. The window is small and `bitcoin-cli` does not pipeline, so this is a low-severity fix.\n\n### Test\n\n`http_pipelined_keepalive_close_tests` reproduces the interleaving deterministically with a mock socket: `Send()` fails with EAGAIN until the second request has been dispatched, then the I/O thread's next `Send()` (made while holding `m_send_mutex`) signals the worker to write its reply, waits for it to reach `HTTPRemoteClient::Send()`, and only then flushes the first reply. Sends are refused again until the test releases them, so the second reply is only delivered if the I/O loop kept the connection open. Without the first commit the test fails with `server.GetConnectionsCount() == 1 has failed [0 != 1]`: the client was disconnected with the second reply still in its send buffer.",
 "commits": [
  {
   "sha": "dc8fb12509830a914e122f5ca9d7de35c03d9cc3",
   "date": "2026-09-15T09:05:56Z",
   "message": "http: update m_keep_alive under m_send_mutex\n\nHTTPRemoteClient::Send() wrote m_keep_alive before taking m_send_mutex\nand appending the response to m_send_buffer. The I/O thread decides in\nMaybeSendBytesFromBuffer() (under m_send_mutex) whether to disconnect\nby checking \"buffer drained to empty && !m_keep_alive\". Because the flag\nand the buffer were not updated together, a pipelined client could lose\na response:\n\n 1. Response 1 (keep-alive) is partially sent; the I/O thread is still\n    draining it.\n 2. Request 2 (\"Connection: close\") is parsed from the receive buffer\n    and dispatched to a worker (the send throttle only holds requests\n    back once the buffer exceeds MAX_BODY_SIZE).\n 3. The worker clears m_keep_alive, then the I/O thread finishes\n    sending response 1, observes an empty buffer with\n    m_keep_alive == false and sets m_disconnect.\n 4. The worker appends response 2, which is dropped when the client is\n    disconnected on the next I/O loop iteration.\n\nMove the assignment into the same critical section that appends the\nresponse, and change the field from std::atomic_bool to a plain bool\nannotated GUARDED_BY(m_send_mutex) so that Clang's thread-safety\nanalysis enforces the ordering. The only reader already holds\nm_send_mutex.\n\nAdd a unit test that reproduces the interleaving deterministically with a\nmock socket: it holds the first reply in the send buffer until the second\n(pipelined, \"Connection: close\") request has been dispatched, then lets\nthe I/O thread flush it while the worker is about to write the second\nreply. Without the fix the client is disconnected with the second reply\nstill in the buffer."
  }
 ],
 "timeline": [
  {
   "t": "2026-09-15T13:23:33Z",
   "kind": "review",
   "who": "winterrdog",
   "assoc": "CONTRIBUTOR",
   "state": "COMMENTED",
   "commit": "dc8fb12509830a914e122f5ca9d7de35c03d9cc3",
   "text": "concept ACK\n\nmakes sense to update `m_keep_alive` in the same critical section as the send\nbuffer, since both are read together under the same lock (`m_send_mutex`). this\nkeeps the related state changes in sync and does not allow the I/O thread to\nview an inconsistent state while the next response is being queued"
  }
 ],
 "labels_log": [
  {
   "t": "2026-09-15T09:07:39Z",
   "action": "labeled",
   "label": "RPC/REST/ZMQ",
   "who": "DrahtBot"
  }
 ],
 "state_log": [],
 "text_chars": 4230,
 "text_tokens_estimate": 1057,
 "changed_paths": [
  "src/httpserver.cpp",
  "src/httpserver.h",
  "src/test/httpserver_tests.cpp"
 ],
 "files": [
  {
   "path": "src/httpserver.cpp",
   "add": 8,
   "del": 2
  },
  {
   "path": "src/httpserver.h",
   "add": 4,
   "del": 1
  },
  {
   "path": "src/test/httpserver_tests.cpp",
   "add": 144,
   "del": 0
  }
 ],
 "test_lines": 144,
 "git": {
  "head": "dc8fb12509830a914e122f5ca9d7de35c03d9cc3",
  "head_matches_backup": true,
  "base": "51ddab532cb38213e2258c24c492bc8a392ffc90",
  "commits": [
   {
    "sha": "dc8fb12509",
    "subject": "http: update m_keep_alive under m_send_mutex",
    "files": 3,
    "add": 156,
    "del": 3
   }
  ],
  "patch_truncated": false
 },
 "input_hash": "dcdddba990c0d5b3",
 "extracted_at": "2026-09-17T16:15:31+00:00"
}