{
 "number": 36204,
 "repo": "bitcoin/bitcoin",
 "url": "https://github.com/bitcoin/bitcoin/pull/36204",
 "title": "http: disconnect clients that never finish a request",
 "author": "janb84",
 "author_association": "MEMBER",
 "created_at": "2026-09-09T11:55:30Z",
 "updated_at": "2026-09-17T10:23:20Z",
 "age_days": 8,
 "draft": false,
 "labels": [
  "RPC/REST/ZMQ"
 ],
 "milestone": null,
 "base": "master",
 "head_sha": "99e880356bac7ab6b0ed9f3fb1ff5cfdab4a4266",
 "head_ref": "http-request-deadline",
 "head_repo": "janb84/bitcoin",
 "head_history": [
  {
   "t": "2026-09-09T12:24:38Z",
   "sha": "537944cb7869039912139793b58bc6296b64eebe"
  },
  {
   "t": "2026-09-14T17:51:58Z",
   "sha": "87dab48b03ddb7f05867ab68852ee50adb9e9b78"
  },
  {
   "t": "2026-09-17T08:17:49Z",
   "sha": "99e880356bac7ab6b0ed9f3fb1ff5cfdab4a4266"
  }
 ],
 "additions": 250,
 "deletions": 12,
 "changed_files": 4,
 "commit_count": 2,
 "size_bucket": "M",
 "mergeable_state": "clean",
 "bot": {
  "drahtbot": {
   "present": true,
   "reviews": {
    "concept_ack": [
     {
      "login": "hodlinator",
      "url": "https://github.com/bitcoin/bitcoin/pull/36204#pullrequestreview-5178705595"
     }
    ]
   },
   "conflicts": [
    {
     "number": 36160,
     "title": "refactor: Minor improvements to HTTP unit tests",
     "author": "hodlinator"
    },
    {
     "number": 36159,
     "title": "http: Improve HTTPRemoteClient::MaybeDisconnect()",
     "author": "hodlinator"
    },
    {
     "number": 36124,
     "title": "http: Make `HTTPRequest` update state internally",
     "author": "hodlinator"
    }
   ]
  }
 },
 "acks_parsed": {
  "hodlinator": {
   "kind": "concept_ack",
   "hash": "537944cb7869039912139793b58bc6296b64eebe",
   "t": "2026-09-11T13:29:19Z",
   "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": [
   "hodlinator"
  ]
 },
 "signals": {
  "needs_rebase": false,
  "ci_failed": false,
  "mergeable_state": "clean",
  "last_author_activity": "2026-09-17T08:21:40Z",
  "last_reviewer_activity": "2026-09-11T13:29:19Z",
  "last_reviewer": "hodlinator",
  "author_silent_days": 0,
  "waiting_on_author_days": 0,
  "days_since_update": 0
 },
 "refs": {
  "mentioned": [
   36159,
   36174
  ],
  "depends_on": [],
  "fixes": [],
  "linked_issues": [],
  "references": [
   {
    "number": 36159,
    "type": "pull",
    "state": "open",
    "merged": false,
    "merged_at": null,
    "title": "http: Improve HTTPRemoteClient::MaybeDisconnect()"
   },
   {
    "number": 36174,
    "type": "pull",
    "state": "closed",
    "merged": true,
    "merged_at": "2026-09-10",
    "title": "http: throttle send buffer when client stops draining"
   }
  ],
  "conflicts": [
   36160,
   36159,
   36124
  ]
 },
 "stack": {
  "shares_commits_with": [],
  "based_on": [],
  "base_for": []
 },
 "review_paths": [
  "src/httpserver.cpp",
  "src/httpserver.h",
  "src/test/httpserver_tests.cpp",
  "test/functional/interface_http.py"
 ],
 "body": "The -rpcservertimeout timer only measures inactivity. Every read from a client resets it, so a client that keeps sending bytes without ever completing a request can hold the connection for ever.\n\nConnection slots are capped by default 16 or by -rpcmaxconnections. Once those slots are taken, the server stops accepting new connections. Because no request ever completes the authentication functions are never hit.\nThe impact is low because RPC port binds to localhost by default, so this is a robustness fix rather than a remotely exploitable security issue.\n\n  How to reproduce\n\nTo reproduce: start a node with -rpcmaxconnections=2, open two sockets, and write \"GET / HTTP/1.1\\r\\n\" one byte at a time with ten seconds between bytes. bitcoin-cli getblockcount then hangs until one of the (trickling) sockets is closed.\n\nThe fix adds a second deadline next to the idle timer. It is armed on the first byte of a request and expires after -rpcservertimeout (the same value as the idle timer). This timer also does not resets or is extended by later reads. Completing request parsing clears the deadline, so a long-running RPC keeps its connection as before. The deadline is deferred while a worker holds the request, for the same reason the idle timer is. It is restarted while response bytes\nare written, because the client cannot start a new request during that window. -rpcservertimeout=0 disables both timers. No new option is added.\n\nDisclaimer this bug is found by utilizing ASTRA. Verified using KIMI 3 and Claude.",
 "commits": [
  {
   "sha": "fa53e0a9584c18dc5d1f1ceda3485ce26d07cded",
   "date": "2026-09-17T07:07:14Z",
   "message": "http: disconnect clients that never finish a request\n\nEvery read resets the idle timer, so a client that keeps sending bytes without ever completing a request holds on to its connection slot\nindefinitely. A handful of them max-connections and no other\nclient gets served.\n\nAdded a second deadline timer that starts at the first byte of a request and is not extended by subsequent reads. It is cleared once the request has been parsed, so a slow RPC keeps its connection. The deadline is restarted while response data goes out."
  },
  {
   "sha": "99e880356bac7ab6b0ed9f3fb1ff5cfdab4a4266",
   "date": "2026-09-17T07:07:14Z",
   "message": "test: cover HTTP connection slot exhaustion by unfinished requests\n\nLimit the server to one connection and occupy it with a client that\ntrickles in a request one byte at a time and never finishes it. The\nbytes arrive a quarter of -rpcservertimeout apart, so the idle timeout\nnever fires and only the completion deadline can free the slot.\n\nA second client queued behind it must get a response about one\ntimeout after the first byte. A late response would mean each\nbyte pushed the deadline back. An early response would mean the server\nfreed the slot before the deadline. The debug log check confirms that\nthe server dropped the first client for missing its completion deadline."
  }
 ],
 "timeline": [
  {
   "t": "2026-09-09T12:24:38Z",
   "kind": "force_push",
   "who": "janb84",
   "commit": "537944cb7869039912139793b58bc6296b64eebe"
  },
  {
   "t": "2026-09-11T12:39:27Z",
   "kind": "review_comment",
   "who": "hodlinator",
   "assoc": "MEMBER",
   "path": "src/httpserver.cpp",
   "commit": "99e880356bac7ab6b0ed9f3fb1ff5cfdab4a4266",
   "in_reply_to": null,
   "text": "nanonit: Feels like `!m_req_busy` would be the cheapest check and be the first one (same for `is_idle`)."
  },
  {
   "t": "2026-09-11T12:46:11Z",
   "kind": "review_comment",
   "who": "hodlinator",
   "assoc": "MEMBER",
   "path": "src/httpserver.h",
   "commit": "537944cb7869039912139793b58bc6296b64eebe",
   "in_reply_to": null,
   "text": "Slightly more accurate?\n\n```suggestion\n    //! Timestamp for when the first byte of the current request was read,\n    //! or when we last attempted to send more data to the client.\n    //! nullopt when no request is in progress. Together with -rpcservertimeout\n    //! this is the deadline for delivering one complete request.\n    std::optional<SteadySeconds> m_request_since;\n```\n\n---\n\nAn alternative would be to always add `rpcservertimeout` to it when updating the value and call it `m_request_deadline` or `m_req_read_deadline`, which is easier to wrap one's head around?"
  },
  {
   "t": "2026-09-11T12:50:46Z",
   "kind": "review_comment",
   "who": "hodlinator",
   "assoc": "MEMBER",
   "path": "src/test/httpserver_tests.cpp",
   "commit": "99e880356bac7ab6b0ed9f3fb1ff5cfdab4a4266",
   "in_reply_to": null,
   "text": "Not sure time drift is an issue with these tests, but #36159's 9afe15897e8dbee9429722d0db7859d895919bf4 switches to the mockable steady clock and freezes time in 784ac4fb9b4b3b9a5614e4ea54c108e1246ce50a using a `FakeSteadyClock`. Worth adopting here?"
  },
  {
   "t": "2026-09-11T13:04:35Z",
   "kind": "review_comment",
   "who": "hodlinator",
   "assoc": "MEMBER",
   "path": "src/httpserver.cpp",
   "commit": "99e880356bac7ab6b0ed9f3fb1ff5cfdab4a4266",
   "in_reply_to": null,
   "text": "remark: Was wondering whether we were missing another call to `RestartRequestDeadline()` when `HTTPRemoteClient::Send()` calls `MaybeSendBytesFromBuffer()` and always sets `m_req_busy = false`. If it succeeds in emptying the send buffer `ReadyToSend()` will return `false` and we will not set `Sock::SendEvent`, so the request deadline will not be reset. But `m_request_since` will be `nullopt` when the request completes parsing and is handed to the worker, so no need to bump it along in that case."
  },
  {
   "t": "2026-09-11T13:06:50Z",
   "kind": "review_comment",
   "who": "hodlinator",
   "assoc": "MEMBER",
   "path": "test/functional/interface_http.py",
   "commit": "537944cb7869039912139793b58bc6296b64eebe",
   "in_reply_to": null,
   "text": "nitlinator:\n```suggestion\n        \"\"\"Feed a bad HTTP request to the server one byte at a time.\n```\n(Actually the first time in this file where the casing was messed up)."
  },
  {
   "t": "2026-09-11T13:09:13Z",
   "kind": "review_comment",
   "who": "hodlinator",
   "assoc": "MEMBER",
   "path": "test/functional/interface_http.py",
   "commit": "537944cb7869039912139793b58bc6296b64eebe",
   "in_reply_to": null,
   "text": "Seems to work fine without send the first byte here and instead sending it in the thread?"
  },
  {
   "t": "2026-09-11T13:19:15Z",
   "kind": "review_comment",
   "who": "hodlinator",
   "assoc": "MEMBER",
   "path": "test/functional/interface_http.py",
   "commit": "537944cb7869039912139793b58bc6296b64eebe",
   "in_reply_to": null,
   "text": "If the test fails for some reason we don't need to clean up the `tricklers` right? See 659671ac3db7e5157178efe4d9f8bce7d92ea237"
  },
  {
   "t": "2026-09-11T13:29:19Z",
   "kind": "review",
   "who": "hodlinator",
   "assoc": "MEMBER",
   "state": "COMMENTED",
   "commit": "537944cb7869039912139793b58bc6296b64eebe",
   "text": "Concept ACK 537944cb7869039912139793b58bc6296b64eebe"
  },
  {
   "t": "2026-09-14T17:51:58Z",
   "kind": "force_push",
   "who": "janb84",
   "commit": "87dab48b03ddb7f05867ab68852ee50adb9e9b78"
  },
  {
   "t": "2026-09-14T17:52:30Z",
   "kind": "review_comment",
   "who": "janb84",
   "assoc": "MEMBER",
   "path": "src/httpserver.h",
   "commit": "537944cb7869039912139793b58bc6296b64eebe",
   "in_reply_to": 3989238328,
   "text": "Taken thanks"
  },
  {
   "t": "2026-09-14T17:55:33Z",
   "kind": "comment",
   "who": "janb84",
   "assoc": "MEMBER",
   "text": "Rebased to latests master\nMade change for the changes made by pr #36174\nIncoporated suggestions made by @hodlinator\nReworked functional tests"
  },
  {
   "t": "2026-09-14T17:56:07Z",
   "kind": "review_comment",
   "who": "janb84",
   "assoc": "MEMBER",
   "path": "test/functional/interface_http.py",
   "commit": "537944cb7869039912139793b58bc6296b64eebe",
   "in_reply_to": 3989422317,
   "text": "Yes and I reworked the test, thanks"
  },
  {
   "t": "2026-09-14T17:56:22Z",
   "kind": "review_comment",
   "who": "janb84",
   "assoc": "MEMBER",
   "path": "test/functional/interface_http.py",
   "commit": "537944cb7869039912139793b58bc6296b64eebe",
   "in_reply_to": 3989505560,
   "text": "correct, thanks, see above"
  },
  {
   "t": "2026-09-14T17:57:39Z",
   "kind": "review_comment",
   "who": "janb84",
   "assoc": "MEMBER",
   "path": "test/functional/interface_http.py",
   "commit": "537944cb7869039912139793b58bc6296b64eebe",
   "in_reply_to": 3989403613,
   "text": "If you do not count Line 326 it's the first messed up casing indeed, stupid mistake, rectified!"
  },
  {
   "t": "2026-09-14T17:59:13Z",
   "kind": "review_comment",
   "who": "janb84",
   "assoc": "MEMBER",
   "path": "src/test/httpserver_tests.cpp",
   "commit": "99e880356bac7ab6b0ed9f3fb1ff5cfdab4a4266",
   "in_reply_to": 3989275289,
   "text": "Not sure what to do with this, yes you are correct. But to do this correctly that PR has to land first right?"
  },
  {
   "t": "2026-09-14T18:03:47Z",
   "kind": "review_comment",
   "who": "janb84",
   "assoc": "MEMBER",
   "path": "src/httpserver.cpp",
   "commit": "99e880356bac7ab6b0ed9f3fb1ff5cfdab4a4266",
   "in_reply_to": 3989186477,
   "text": "Have to pushback on this one, m_req_busy is mostly false so the negate makes that true. It would cause an extra check that is mostly true and therefor does not short the evaluation. I think the `rpcservertimeout.count()` is the cheapest one, given it's already in a register."
  },
  {
   "t": "2026-09-14T18:12:14Z",
   "kind": "review_comment",
   "who": "janb84",
   "assoc": "MEMBER",
   "path": "src/httpserver.cpp",
   "commit": "99e880356bac7ab6b0ed9f3fb1ff5cfdab4a4266",
   "in_reply_to": 3989386070,
   "text": "After some checking, changed the comment. Do not think extra action is needed in send(), the extra call to `RestartRequestDeadline()` would not do a thing because of the empty m_reqeust_since.  Imho, happy to be wrong."
  },
  {
   "t": "2026-09-17T08:17:49Z",
   "kind": "force_push",
   "who": "janb84",
   "commit": "99e880356bac7ab6b0ed9f3fb1ff5cfdab4a4266"
  },
  {
   "t": "2026-09-17T08:21:40Z",
   "kind": "comment",
   "who": "janb84",
   "assoc": "MEMBER",
   "text": "Rebased  and adjusted the function naming."
  }
 ],
 "labels_log": [
  {
   "t": "2026-09-09T11:55:34Z",
   "action": "labeled",
   "label": "RPC/REST/ZMQ",
   "who": "DrahtBot"
  },
  {
   "t": "2026-09-09T12:25:40Z",
   "action": "labeled",
   "label": "CI failed",
   "who": "DrahtBot"
  },
  {
   "t": "2026-09-09T14:32:03Z",
   "action": "unlabeled",
   "label": "CI failed",
   "who": "DrahtBot"
  },
  {
   "t": "2026-09-16T08:53:14Z",
   "action": "labeled",
   "label": "Needs rebase",
   "who": "DrahtBot"
  },
  {
   "t": "2026-09-17T08:55:39Z",
   "action": "unlabeled",
   "label": "Needs rebase",
   "who": "DrahtBot"
  }
 ],
 "state_log": [],
 "text_chars": 5615,
 "text_tokens_estimate": 1403,
 "changed_paths": [
  "src/httpserver.cpp",
  "src/httpserver.h",
  "src/test/httpserver_tests.cpp",
  "test/functional/interface_http.py"
 ],
 "files": [
  {
   "path": "src/httpserver.cpp",
   "add": 42,
   "del": 2
  },
  {
   "path": "src/httpserver.h",
   "add": 25,
   "del": 0
  },
  {
   "path": "src/test/httpserver_tests.cpp",
   "add": 120,
   "del": 10
  },
  {
   "path": "test/functional/interface_http.py",
   "add": 63,
   "del": 0
  }
 ],
 "test_lines": 193,
 "git": {
  "head": "99e880356bac7ab6b0ed9f3fb1ff5cfdab4a4266",
  "head_matches_backup": true,
  "base": "2bbbeaa663ec1a9d61d1e5f986c71887194926fd",
  "commits": [
   {
    "sha": "fa53e0a958",
    "subject": "http: disconnect clients that never finish a request",
    "files": 3,
    "add": 187,
    "del": 12
   },
   {
    "sha": "99e880356b",
    "subject": "test: cover HTTP connection slot exhaustion by unfinished requests",
    "files": 1,
    "add": 63,
    "del": 0
   }
  ],
  "patch_truncated": false
 },
 "input_hash": "ecf59fed72aa1c6e",
 "extracted_at": "2026-09-17T16:15:31+00:00"
}