bitreq: Evict dead entries from async connection pool - #564

Closed
tnull wants to merge 3 commits into
rust-bitcoin:masterfrom
tnull:2026-04-bitreq-async-pool-fix
Closed

bitreq: Evict dead entries from async connection pool#564
tnull wants to merge 3 commits into
rust-bitcoin:masterfrom
tnull:2026-04-bitreq-async-pool-fix

Conversation

@tnull

Copy link
Copy Markdown
Collaborator

Fixes#562.

Validate pool entries on acquire and clean up after every send:

  • Remove entries whose keep-alive deadline has passed or whose inner
    state has been poisoned, rather than blindly cloning the Arc.
  • Evict on send failure, Connection: close, or malformed
    Keep-Alive.
  • Refresh LRU position on a cache hit.

Pre-send insertion of a fresh Arc on a miss is kept so concurrent
callers arriving during the first round-trip can share the socket for
pipelining; reusable_until and the post-send eviction together
ensure they never observe a known-dead Arc.

tnull added 2 commits April 22, 2026 12:26
Expose a `pub(crate)` view of whether an `AsyncConnection` is still
healthy and, if so, until when. `None` means the inner
`next_request_id` has been poisoned (every failure path in `send`
already sets this); `Some(t)` is the current
`socket_new_requests_timeout`, which `send` already refreshes from
the server's `Keep-Alive: timeout=N` header. No behavioural change;
groundwork for the pool to check entry validity without duplicating
tracking.
Co-Authored-By: HAL 9000
Validate pool entries on acquire and clean up after every send:
- Remove entries whose keep-alive deadline has passed or whose inner
state has been poisoned, rather than blindly cloning the `Arc`.
- Evict on send failure, `Connection: close`, or malformed
`Keep-Alive`.
- Refresh LRU position on a cache hit.
Pre-send insertion of a fresh `Arc` on a miss is kept so concurrent
callers arriving during the first round-trip can share the socket for
pipelining; `reusable_until` and the post-send eviction together
ensure they never observe a known-dead `Arc`.
Co-Authored-By: HAL 9000
Drives `a, b, a, c, a` through a `Client` with capacity 2, backed by
three `tokio::net::TcpListener` servers so each URL resolves to a
distinct `ConnectionKey`. On the fix, step 3 promotes `a` to
most-recent, step 4's c-insert evicts `b`, and step 5 reuses the warm
`a` entry — three TCP accepts. Pre-fix, `a` stays at the front of the
LRU queue and gets evicted at step 4 instead — four accepts.
Covers one regression only: the LRU refresh on hit. The other
architectural improvement on this branch — explicit pool-layer
eviction on failure / `Connection: close` — produces the same
externally-observable behaviour as the pre-fix code because
`AsyncConnection::send`'s interior `retry_new_connection!` machinery
compensates by swapping poisoned inner state on the next use. That
change is defended on architectural grounds (explicit pool-layer
logic instead of reliance on the inner retry) rather than via a
black-box regression test.
Co-Authored-By: HAL 9000
Comment threadbitreq/src/client.rs
/// Removes any pool entry for `key`. No-op if the slot is already empty.
fn evict(&self, key: &ConnectionKey) {
let mut state = self.r#async.lock().unwrap();
state.connections.remove(key);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should evict by identity, not just by ConnectionKey.

There is a race where an older poisoned Arc can finish later and remove a newer healthy entry for the same key:

  1. tasks A and B are both using the old pooled connection for K
  2. A fails and marks the old connection dead
  3. task C opens and inserts a fresh connection for K
  4. B reaches this cleanup path later and remove(key) drops C's fresh entry

That doesn't break in-flight requests, but it does create avoidable reconnect churn under load. I think evict needs to compare the currently pooled Arc against the one that just finished and only remove on Arc::ptr_eq.

Permalinks:

@TheBlueMattTheBlueMatt left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I'm understanding the fix here - if we hit a dead connection when we go to request we should fix the Connection, not build a new one in the Client?

@tnull

tnull commented Jul 13, 2026

Copy link
Copy Markdown
CollaboratorAuthor

Spent some time trying to reproduce #562 and couldn't really. This PR is now also somewhat superseded by #661 (w.r.t. keep-alive handling), so going ahead closing this.

@tnulltnull closed this Jul 13, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bitreq: Client connection pool retains dead connections indefinitely on I/O error

3 participants

@tnull@TheBlueMatt@storopoli
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

bitreq: Evict dead entries from async connection pool - #564

Closed
tnull wants to merge 3 commits into
rust-bitcoin:masterfrom
tnull:2026-04-bitreq-async-pool-fix
Closed

bitreq: Evict dead entries from async connection pool#564
tnull wants to merge 3 commits into
rust-bitcoin:masterfrom
tnull:2026-04-bitreq-async-pool-fix

Conversation

@tnull

Copy link
Copy Markdown
Collaborator

Fixes#562.

Validate pool entries on acquire and clean up after every send:

  • Remove entries whose keep-alive deadline has passed or whose inner
    state has been poisoned, rather than blindly cloning the Arc.
  • Evict on send failure, Connection: close, or malformed
    Keep-Alive.
  • Refresh LRU position on a cache hit.

Pre-send insertion of a fresh Arc on a miss is kept so concurrent
callers arriving during the first round-trip can share the socket for
pipelining; reusable_until and the post-send eviction together
ensure they never observe a known-dead Arc.

tnull added 2 commits April 22, 2026 12:26
Expose a `pub(crate)` view of whether an `AsyncConnection` is still
healthy and, if so, until when. `None` means the inner
`next_request_id` has been poisoned (every failure path in `send`
already sets this); `Some(t)` is the current
`socket_new_requests_timeout`, which `send` already refreshes from
the server's `Keep-Alive: timeout=N` header. No behavioural change;
groundwork for the pool to check entry validity without duplicating
tracking.
Co-Authored-By: HAL 9000
Validate pool entries on acquire and clean up after every send:
- Remove entries whose keep-alive deadline has passed or whose inner
state has been poisoned, rather than blindly cloning the `Arc`.
- Evict on send failure, `Connection: close`, or malformed
`Keep-Alive`.
- Refresh LRU position on a cache hit.
Pre-send insertion of a fresh `Arc` on a miss is kept so concurrent
callers arriving during the first round-trip can share the socket for
pipelining; `reusable_until` and the post-send eviction together
ensure they never observe a known-dead `Arc`.
Co-Authored-By: HAL 9000
Drives `a, b, a, c, a` through a `Client` with capacity 2, backed by
three `tokio::net::TcpListener` servers so each URL resolves to a
distinct `ConnectionKey`. On the fix, step 3 promotes `a` to
most-recent, step 4's c-insert evicts `b`, and step 5 reuses the warm
`a` entry — three TCP accepts. Pre-fix, `a` stays at the front of the
LRU queue and gets evicted at step 4 instead — four accepts.
Covers one regression only: the LRU refresh on hit. The other
architectural improvement on this branch — explicit pool-layer
eviction on failure / `Connection: close` — produces the same
externally-observable behaviour as the pre-fix code because
`AsyncConnection::send`'s interior `retry_new_connection!` machinery
compensates by swapping poisoned inner state on the next use. That
change is defended on architectural grounds (explicit pool-layer
logic instead of reliance on the inner retry) rather than via a
black-box regression test.
Co-Authored-By: HAL 9000
Comment threadbitreq/src/client.rs
/// Removes any pool entry for `key`. No-op if the slot is already empty.
fn evict(&self, key: &ConnectionKey) {
let mut state = self.r#async.lock().unwrap();
state.connections.remove(key);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should evict by identity, not just by ConnectionKey.

There is a race where an older poisoned Arc can finish later and remove a newer healthy entry for the same key:

  1. tasks A and B are both using the old pooled connection for K
  2. A fails and marks the old connection dead
  3. task C opens and inserts a fresh connection for K
  4. B reaches this cleanup path later and remove(key) drops C's fresh entry

That doesn't break in-flight requests, but it does create avoidable reconnect churn under load. I think evict needs to compare the currently pooled Arc against the one that just finished and only remove on Arc::ptr_eq.

Permalinks:

@TheBlueMattTheBlueMatt left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I'm understanding the fix here - if we hit a dead connection when we go to request we should fix the Connection, not build a new one in the Client?

@tnull

tnull commented Jul 13, 2026

Copy link
Copy Markdown
CollaboratorAuthor

Spent some time trying to reproduce #562 and couldn't really. This PR is now also somewhat superseded by #661 (w.r.t. keep-alive handling), so going ahead closing this.

@tnulltnull closed this Jul 13, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bitreq: Client connection pool retains dead connections indefinitely on I/O error

3 participants

@tnull@TheBlueMatt@storopoli
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

bitreq: Evict dead entries from async connection pool - #564

Closed
tnull wants to merge 3 commits into
rust-bitcoin:masterfrom
tnull:2026-04-bitreq-async-pool-fix
Closed

bitreq: Evict dead entries from async connection pool#564
tnull wants to merge 3 commits into
rust-bitcoin:masterfrom
tnull:2026-04-bitreq-async-pool-fix

Conversation

@tnull

Copy link
Copy Markdown
Collaborator

Fixes#562.

Validate pool entries on acquire and clean up after every send:

  • Remove entries whose keep-alive deadline has passed or whose inner
    state has been poisoned, rather than blindly cloning the Arc.
  • Evict on send failure, Connection: close, or malformed
    Keep-Alive.
  • Refresh LRU position on a cache hit.

Pre-send insertion of a fresh Arc on a miss is kept so concurrent
callers arriving during the first round-trip can share the socket for
pipelining; reusable_until and the post-send eviction together
ensure they never observe a known-dead Arc.

tnull added 2 commits April 22, 2026 12:26
Expose a `pub(crate)` view of whether an `AsyncConnection` is still
healthy and, if so, until when. `None` means the inner
`next_request_id` has been poisoned (every failure path in `send`
already sets this); `Some(t)` is the current
`socket_new_requests_timeout`, which `send` already refreshes from
the server's `Keep-Alive: timeout=N` header. No behavioural change;
groundwork for the pool to check entry validity without duplicating
tracking.
Co-Authored-By: HAL 9000
Validate pool entries on acquire and clean up after every send:
- Remove entries whose keep-alive deadline has passed or whose inner
state has been poisoned, rather than blindly cloning the `Arc`.
- Evict on send failure, `Connection: close`, or malformed
`Keep-Alive`.
- Refresh LRU position on a cache hit.
Pre-send insertion of a fresh `Arc` on a miss is kept so concurrent
callers arriving during the first round-trip can share the socket for
pipelining; `reusable_until` and the post-send eviction together
ensure they never observe a known-dead `Arc`.
Co-Authored-By: HAL 9000
Drives `a, b, a, c, a` through a `Client` with capacity 2, backed by
three `tokio::net::TcpListener` servers so each URL resolves to a
distinct `ConnectionKey`. On the fix, step 3 promotes `a` to
most-recent, step 4's c-insert evicts `b`, and step 5 reuses the warm
`a` entry — three TCP accepts. Pre-fix, `a` stays at the front of the
LRU queue and gets evicted at step 4 instead — four accepts.
Covers one regression only: the LRU refresh on hit. The other
architectural improvement on this branch — explicit pool-layer
eviction on failure / `Connection: close` — produces the same
externally-observable behaviour as the pre-fix code because
`AsyncConnection::send`'s interior `retry_new_connection!` machinery
compensates by swapping poisoned inner state on the next use. That
change is defended on architectural grounds (explicit pool-layer
logic instead of reliance on the inner retry) rather than via a
black-box regression test.
Co-Authored-By: HAL 9000
Comment threadbitreq/src/client.rs
/// Removes any pool entry for `key`. No-op if the slot is already empty.
fn evict(&self, key: &ConnectionKey) {
let mut state = self.r#async.lock().unwrap();
state.connections.remove(key);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should evict by identity, not just by ConnectionKey.

There is a race where an older poisoned Arc can finish later and remove a newer healthy entry for the same key:

  1. tasks A and B are both using the old pooled connection for K
  2. A fails and marks the old connection dead
  3. task C opens and inserts a fresh connection for K
  4. B reaches this cleanup path later and remove(key) drops C's fresh entry

That doesn't break in-flight requests, but it does create avoidable reconnect churn under load. I think evict needs to compare the currently pooled Arc against the one that just finished and only remove on Arc::ptr_eq.

Permalinks:

@TheBlueMattTheBlueMatt left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I'm understanding the fix here - if we hit a dead connection when we go to request we should fix the Connection, not build a new one in the Client?

@tnull

tnull commented Jul 13, 2026

Copy link
Copy Markdown
CollaboratorAuthor

Spent some time trying to reproduce #562 and couldn't really. This PR is now also somewhat superseded by #661 (w.r.t. keep-alive handling), so going ahead closing this.

@tnulltnull closed this Jul 13, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bitreq: Client connection pool retains dead connections indefinitely on I/O error

3 participants

@tnull@TheBlueMatt@storopoli
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

bitreq: Evict dead entries from async connection pool - #564

Closed
tnull wants to merge 3 commits into
rust-bitcoin:masterfrom
tnull:2026-04-bitreq-async-pool-fix
Closed

bitreq: Evict dead entries from async connection pool#564
tnull wants to merge 3 commits into
rust-bitcoin:masterfrom
tnull:2026-04-bitreq-async-pool-fix

Conversation

@tnull

Copy link
Copy Markdown
Collaborator

Fixes#562.

Validate pool entries on acquire and clean up after every send:

  • Remove entries whose keep-alive deadline has passed or whose inner
    state has been poisoned, rather than blindly cloning the Arc.
  • Evict on send failure, Connection: close, or malformed
    Keep-Alive.
  • Refresh LRU position on a cache hit.

Pre-send insertion of a fresh Arc on a miss is kept so concurrent
callers arriving during the first round-trip can share the socket for
pipelining; reusable_until and the post-send eviction together
ensure they never observe a known-dead Arc.

tnull added 2 commits April 22, 2026 12:26
Expose a `pub(crate)` view of whether an `AsyncConnection` is still
healthy and, if so, until when. `None` means the inner
`next_request_id` has been poisoned (every failure path in `send`
already sets this); `Some(t)` is the current
`socket_new_requests_timeout`, which `send` already refreshes from
the server's `Keep-Alive: timeout=N` header. No behavioural change;
groundwork for the pool to check entry validity without duplicating
tracking.
Co-Authored-By: HAL 9000
Validate pool entries on acquire and clean up after every send:
- Remove entries whose keep-alive deadline has passed or whose inner
state has been poisoned, rather than blindly cloning the `Arc`.
- Evict on send failure, `Connection: close`, or malformed
`Keep-Alive`.
- Refresh LRU position on a cache hit.
Pre-send insertion of a fresh `Arc` on a miss is kept so concurrent
callers arriving during the first round-trip can share the socket for
pipelining; `reusable_until` and the post-send eviction together
ensure they never observe a known-dead `Arc`.
Co-Authored-By: HAL 9000
Drives `a, b, a, c, a` through a `Client` with capacity 2, backed by
three `tokio::net::TcpListener` servers so each URL resolves to a
distinct `ConnectionKey`. On the fix, step 3 promotes `a` to
most-recent, step 4's c-insert evicts `b`, and step 5 reuses the warm
`a` entry — three TCP accepts. Pre-fix, `a` stays at the front of the
LRU queue and gets evicted at step 4 instead — four accepts.
Covers one regression only: the LRU refresh on hit. The other
architectural improvement on this branch — explicit pool-layer
eviction on failure / `Connection: close` — produces the same
externally-observable behaviour as the pre-fix code because
`AsyncConnection::send`'s interior `retry_new_connection!` machinery
compensates by swapping poisoned inner state on the next use. That
change is defended on architectural grounds (explicit pool-layer
logic instead of reliance on the inner retry) rather than via a
black-box regression test.
Co-Authored-By: HAL 9000
Comment threadbitreq/src/client.rs
/// Removes any pool entry for `key`. No-op if the slot is already empty.
fn evict(&self, key: &ConnectionKey) {
let mut state = self.r#async.lock().unwrap();
state.connections.remove(key);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should evict by identity, not just by ConnectionKey.

There is a race where an older poisoned Arc can finish later and remove a newer healthy entry for the same key:

  1. tasks A and B are both using the old pooled connection for K
  2. A fails and marks the old connection dead
  3. task C opens and inserts a fresh connection for K
  4. B reaches this cleanup path later and remove(key) drops C's fresh entry

That doesn't break in-flight requests, but it does create avoidable reconnect churn under load. I think evict needs to compare the currently pooled Arc against the one that just finished and only remove on Arc::ptr_eq.

Permalinks:

@TheBlueMattTheBlueMatt left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I'm understanding the fix here - if we hit a dead connection when we go to request we should fix the Connection, not build a new one in the Client?

@tnull

tnull commented Jul 13, 2026

Copy link
Copy Markdown
CollaboratorAuthor

Spent some time trying to reproduce #562 and couldn't really. This PR is now also somewhat superseded by #661 (w.r.t. keep-alive handling), so going ahead closing this.

@tnulltnull closed this Jul 13, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bitreq: Client connection pool retains dead connections indefinitely on I/O error

3 participants

@tnull@TheBlueMatt@storopoli
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

bitreq: Evict dead entries from async connection pool - #564

Closed
tnull wants to merge 3 commits into
rust-bitcoin:masterfrom
tnull:2026-04-bitreq-async-pool-fix
Closed

bitreq: Evict dead entries from async connection pool#564
tnull wants to merge 3 commits into
rust-bitcoin:masterfrom
tnull:2026-04-bitreq-async-pool-fix

Conversation

@tnull

Copy link
Copy Markdown
Collaborator

Fixes#562.

Validate pool entries on acquire and clean up after every send:

  • Remove entries whose keep-alive deadline has passed or whose inner
    state has been poisoned, rather than blindly cloning the Arc.
  • Evict on send failure, Connection: close, or malformed
    Keep-Alive.
  • Refresh LRU position on a cache hit.

Pre-send insertion of a fresh Arc on a miss is kept so concurrent
callers arriving during the first round-trip can share the socket for
pipelining; reusable_until and the post-send eviction together
ensure they never observe a known-dead Arc.

tnull added 2 commits April 22, 2026 12:26
Expose a `pub(crate)` view of whether an `AsyncConnection` is still
healthy and, if so, until when. `None` means the inner
`next_request_id` has been poisoned (every failure path in `send`
already sets this); `Some(t)` is the current
`socket_new_requests_timeout`, which `send` already refreshes from
the server's `Keep-Alive: timeout=N` header. No behavioural change;
groundwork for the pool to check entry validity without duplicating
tracking.
Co-Authored-By: HAL 9000
Validate pool entries on acquire and clean up after every send:
- Remove entries whose keep-alive deadline has passed or whose inner
state has been poisoned, rather than blindly cloning the `Arc`.
- Evict on send failure, `Connection: close`, or malformed
`Keep-Alive`.
- Refresh LRU position on a cache hit.
Pre-send insertion of a fresh `Arc` on a miss is kept so concurrent
callers arriving during the first round-trip can share the socket for
pipelining; `reusable_until` and the post-send eviction together
ensure they never observe a known-dead `Arc`.
Co-Authored-By: HAL 9000
Drives `a, b, a, c, a` through a `Client` with capacity 2, backed by
three `tokio::net::TcpListener` servers so each URL resolves to a
distinct `ConnectionKey`. On the fix, step 3 promotes `a` to
most-recent, step 4's c-insert evicts `b`, and step 5 reuses the warm
`a` entry — three TCP accepts. Pre-fix, `a` stays at the front of the
LRU queue and gets evicted at step 4 instead — four accepts.
Covers one regression only: the LRU refresh on hit. The other
architectural improvement on this branch — explicit pool-layer
eviction on failure / `Connection: close` — produces the same
externally-observable behaviour as the pre-fix code because
`AsyncConnection::send`'s interior `retry_new_connection!` machinery
compensates by swapping poisoned inner state on the next use. That
change is defended on architectural grounds (explicit pool-layer
logic instead of reliance on the inner retry) rather than via a
black-box regression test.
Co-Authored-By: HAL 9000
Comment threadbitreq/src/client.rs
/// Removes any pool entry for `key`. No-op if the slot is already empty.
fn evict(&self, key: &ConnectionKey) {
let mut state = self.r#async.lock().unwrap();
state.connections.remove(key);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should evict by identity, not just by ConnectionKey.

There is a race where an older poisoned Arc can finish later and remove a newer healthy entry for the same key:

  1. tasks A and B are both using the old pooled connection for K
  2. A fails and marks the old connection dead
  3. task C opens and inserts a fresh connection for K
  4. B reaches this cleanup path later and remove(key) drops C's fresh entry

That doesn't break in-flight requests, but it does create avoidable reconnect churn under load. I think evict needs to compare the currently pooled Arc against the one that just finished and only remove on Arc::ptr_eq.

Permalinks:

@TheBlueMattTheBlueMatt left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I'm understanding the fix here - if we hit a dead connection when we go to request we should fix the Connection, not build a new one in the Client?

@tnull

tnull commented Jul 13, 2026

Copy link
Copy Markdown
CollaboratorAuthor

Spent some time trying to reproduce #562 and couldn't really. This PR is now also somewhat superseded by #661 (w.r.t. keep-alive handling), so going ahead closing this.

@tnulltnull closed this Jul 13, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bitreq: Client connection pool retains dead connections indefinitely on I/O error

3 participants

@tnull@TheBlueMatt@storopoli
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

bitreq: Evict dead entries from async connection pool - #564

Closed
tnull wants to merge 3 commits into
rust-bitcoin:masterfrom
tnull:2026-04-bitreq-async-pool-fix
Closed

bitreq: Evict dead entries from async connection pool#564
tnull wants to merge 3 commits into
rust-bitcoin:masterfrom
tnull:2026-04-bitreq-async-pool-fix

Conversation

@tnull

Copy link
Copy Markdown
Collaborator

Fixes#562.

Validate pool entries on acquire and clean up after every send:

  • Remove entries whose keep-alive deadline has passed or whose inner
    state has been poisoned, rather than blindly cloning the Arc.
  • Evict on send failure, Connection: close, or malformed
    Keep-Alive.
  • Refresh LRU position on a cache hit.

Pre-send insertion of a fresh Arc on a miss is kept so concurrent
callers arriving during the first round-trip can share the socket for
pipelining; reusable_until and the post-send eviction together
ensure they never observe a known-dead Arc.

tnull added 2 commits April 22, 2026 12:26
Expose a `pub(crate)` view of whether an `AsyncConnection` is still
healthy and, if so, until when. `None` means the inner
`next_request_id` has been poisoned (every failure path in `send`
already sets this); `Some(t)` is the current
`socket_new_requests_timeout`, which `send` already refreshes from
the server's `Keep-Alive: timeout=N` header. No behavioural change;
groundwork for the pool to check entry validity without duplicating
tracking.
Co-Authored-By: HAL 9000
Validate pool entries on acquire and clean up after every send:
- Remove entries whose keep-alive deadline has passed or whose inner
state has been poisoned, rather than blindly cloning the `Arc`.
- Evict on send failure, `Connection: close`, or malformed
`Keep-Alive`.
- Refresh LRU position on a cache hit.
Pre-send insertion of a fresh `Arc` on a miss is kept so concurrent
callers arriving during the first round-trip can share the socket for
pipelining; `reusable_until` and the post-send eviction together
ensure they never observe a known-dead `Arc`.
Co-Authored-By: HAL 9000
Drives `a, b, a, c, a` through a `Client` with capacity 2, backed by
three `tokio::net::TcpListener` servers so each URL resolves to a
distinct `ConnectionKey`. On the fix, step 3 promotes `a` to
most-recent, step 4's c-insert evicts `b`, and step 5 reuses the warm
`a` entry — three TCP accepts. Pre-fix, `a` stays at the front of the
LRU queue and gets evicted at step 4 instead — four accepts.
Covers one regression only: the LRU refresh on hit. The other
architectural improvement on this branch — explicit pool-layer
eviction on failure / `Connection: close` — produces the same
externally-observable behaviour as the pre-fix code because
`AsyncConnection::send`'s interior `retry_new_connection!` machinery
compensates by swapping poisoned inner state on the next use. That
change is defended on architectural grounds (explicit pool-layer
logic instead of reliance on the inner retry) rather than via a
black-box regression test.
Co-Authored-By: HAL 9000
Comment threadbitreq/src/client.rs
/// Removes any pool entry for `key`. No-op if the slot is already empty.
fn evict(&self, key: &ConnectionKey) {
let mut state = self.r#async.lock().unwrap();
state.connections.remove(key);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should evict by identity, not just by ConnectionKey.

There is a race where an older poisoned Arc can finish later and remove a newer healthy entry for the same key:

  1. tasks A and B are both using the old pooled connection for K
  2. A fails and marks the old connection dead
  3. task C opens and inserts a fresh connection for K
  4. B reaches this cleanup path later and remove(key) drops C's fresh entry

That doesn't break in-flight requests, but it does create avoidable reconnect churn under load. I think evict needs to compare the currently pooled Arc against the one that just finished and only remove on Arc::ptr_eq.

Permalinks:

@TheBlueMattTheBlueMatt left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I'm understanding the fix here - if we hit a dead connection when we go to request we should fix the Connection, not build a new one in the Client?

@tnull

tnull commented Jul 13, 2026

Copy link
Copy Markdown
CollaboratorAuthor

Spent some time trying to reproduce #562 and couldn't really. This PR is now also somewhat superseded by #661 (w.r.t. keep-alive handling), so going ahead closing this.

@tnulltnull closed this Jul 13, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bitreq: Client connection pool retains dead connections indefinitely on I/O error

3 participants

@tnull@TheBlueMatt@storopoli
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

bitreq: Evict dead entries from async connection pool - #564

Closed
tnull wants to merge 3 commits into
rust-bitcoin:masterfrom
tnull:2026-04-bitreq-async-pool-fix
Closed

bitreq: Evict dead entries from async connection pool#564
tnull wants to merge 3 commits into
rust-bitcoin:masterfrom
tnull:2026-04-bitreq-async-pool-fix

Conversation

@tnull

Copy link
Copy Markdown
Collaborator

Fixes#562.

Validate pool entries on acquire and clean up after every send:

  • Remove entries whose keep-alive deadline has passed or whose inner
    state has been poisoned, rather than blindly cloning the Arc.
  • Evict on send failure, Connection: close, or malformed
    Keep-Alive.
  • Refresh LRU position on a cache hit.

Pre-send insertion of a fresh Arc on a miss is kept so concurrent
callers arriving during the first round-trip can share the socket for
pipelining; reusable_until and the post-send eviction together
ensure they never observe a known-dead Arc.

tnull added 2 commits April 22, 2026 12:26
Expose a `pub(crate)` view of whether an `AsyncConnection` is still
healthy and, if so, until when. `None` means the inner
`next_request_id` has been poisoned (every failure path in `send`
already sets this); `Some(t)` is the current
`socket_new_requests_timeout`, which `send` already refreshes from
the server's `Keep-Alive: timeout=N` header. No behavioural change;
groundwork for the pool to check entry validity without duplicating
tracking.
Co-Authored-By: HAL 9000
Validate pool entries on acquire and clean up after every send:
- Remove entries whose keep-alive deadline has passed or whose inner
state has been poisoned, rather than blindly cloning the `Arc`.
- Evict on send failure, `Connection: close`, or malformed
`Keep-Alive`.
- Refresh LRU position on a cache hit.
Pre-send insertion of a fresh `Arc` on a miss is kept so concurrent
callers arriving during the first round-trip can share the socket for
pipelining; `reusable_until` and the post-send eviction together
ensure they never observe a known-dead `Arc`.
Co-Authored-By: HAL 9000
Drives `a, b, a, c, a` through a `Client` with capacity 2, backed by
three `tokio::net::TcpListener` servers so each URL resolves to a
distinct `ConnectionKey`. On the fix, step 3 promotes `a` to
most-recent, step 4's c-insert evicts `b`, and step 5 reuses the warm
`a` entry — three TCP accepts. Pre-fix, `a` stays at the front of the
LRU queue and gets evicted at step 4 instead — four accepts.
Covers one regression only: the LRU refresh on hit. The other
architectural improvement on this branch — explicit pool-layer
eviction on failure / `Connection: close` — produces the same
externally-observable behaviour as the pre-fix code because
`AsyncConnection::send`'s interior `retry_new_connection!` machinery
compensates by swapping poisoned inner state on the next use. That
change is defended on architectural grounds (explicit pool-layer
logic instead of reliance on the inner retry) rather than via a
black-box regression test.
Co-Authored-By: HAL 9000
Comment threadbitreq/src/client.rs
/// Removes any pool entry for `key`. No-op if the slot is already empty.
fn evict(&self, key: &ConnectionKey) {
let mut state = self.r#async.lock().unwrap();
state.connections.remove(key);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should evict by identity, not just by ConnectionKey.

There is a race where an older poisoned Arc can finish later and remove a newer healthy entry for the same key:

  1. tasks A and B are both using the old pooled connection for K
  2. A fails and marks the old connection dead
  3. task C opens and inserts a fresh connection for K
  4. B reaches this cleanup path later and remove(key) drops C's fresh entry

That doesn't break in-flight requests, but it does create avoidable reconnect churn under load. I think evict needs to compare the currently pooled Arc against the one that just finished and only remove on Arc::ptr_eq.

Permalinks:

@TheBlueMattTheBlueMatt left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I'm understanding the fix here - if we hit a dead connection when we go to request we should fix the Connection, not build a new one in the Client?

@tnull

tnull commented Jul 13, 2026

Copy link
Copy Markdown
CollaboratorAuthor

Spent some time trying to reproduce #562 and couldn't really. This PR is now also somewhat superseded by #661 (w.r.t. keep-alive handling), so going ahead closing this.

@tnulltnull closed this Jul 13, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bitreq: Client connection pool retains dead connections indefinitely on I/O error

3 participants

@tnull@TheBlueMatt@storopoli
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

bitreq: Evict dead entries from async connection pool - #564

Closed
tnull wants to merge 3 commits into
rust-bitcoin:masterfrom
tnull:2026-04-bitreq-async-pool-fix
Closed

bitreq: Evict dead entries from async connection pool#564
tnull wants to merge 3 commits into
rust-bitcoin:masterfrom
tnull:2026-04-bitreq-async-pool-fix

Conversation

@tnull

Copy link
Copy Markdown
Collaborator

Fixes#562.

Validate pool entries on acquire and clean up after every send:

  • Remove entries whose keep-alive deadline has passed or whose inner
    state has been poisoned, rather than blindly cloning the Arc.
  • Evict on send failure, Connection: close, or malformed
    Keep-Alive.
  • Refresh LRU position on a cache hit.

Pre-send insertion of a fresh Arc on a miss is kept so concurrent
callers arriving during the first round-trip can share the socket for
pipelining; reusable_until and the post-send eviction together
ensure they never observe a known-dead Arc.

tnull added 2 commits April 22, 2026 12:26
Expose a `pub(crate)` view of whether an `AsyncConnection` is still
healthy and, if so, until when. `None` means the inner
`next_request_id` has been poisoned (every failure path in `send`
already sets this); `Some(t)` is the current
`socket_new_requests_timeout`, which `send` already refreshes from
the server's `Keep-Alive: timeout=N` header. No behavioural change;
groundwork for the pool to check entry validity without duplicating
tracking.
Co-Authored-By: HAL 9000
Validate pool entries on acquire and clean up after every send:
- Remove entries whose keep-alive deadline has passed or whose inner
state has been poisoned, rather than blindly cloning the `Arc`.
- Evict on send failure, `Connection: close`, or malformed
`Keep-Alive`.
- Refresh LRU position on a cache hit.
Pre-send insertion of a fresh `Arc` on a miss is kept so concurrent
callers arriving during the first round-trip can share the socket for
pipelining; `reusable_until` and the post-send eviction together
ensure they never observe a known-dead `Arc`.
Co-Authored-By: HAL 9000
Drives `a, b, a, c, a` through a `Client` with capacity 2, backed by
three `tokio::net::TcpListener` servers so each URL resolves to a
distinct `ConnectionKey`. On the fix, step 3 promotes `a` to
most-recent, step 4's c-insert evicts `b`, and step 5 reuses the warm
`a` entry — three TCP accepts. Pre-fix, `a` stays at the front of the
LRU queue and gets evicted at step 4 instead — four accepts.
Covers one regression only: the LRU refresh on hit. The other
architectural improvement on this branch — explicit pool-layer
eviction on failure / `Connection: close` — produces the same
externally-observable behaviour as the pre-fix code because
`AsyncConnection::send`'s interior `retry_new_connection!` machinery
compensates by swapping poisoned inner state on the next use. That
change is defended on architectural grounds (explicit pool-layer
logic instead of reliance on the inner retry) rather than via a
black-box regression test.
Co-Authored-By: HAL 9000
Comment threadbitreq/src/client.rs
/// Removes any pool entry for `key`. No-op if the slot is already empty.
fn evict(&self, key: &ConnectionKey) {
let mut state = self.r#async.lock().unwrap();
state.connections.remove(key);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should evict by identity, not just by ConnectionKey.

There is a race where an older poisoned Arc can finish later and remove a newer healthy entry for the same key:

  1. tasks A and B are both using the old pooled connection for K
  2. A fails and marks the old connection dead
  3. task C opens and inserts a fresh connection for K
  4. B reaches this cleanup path later and remove(key) drops C's fresh entry

That doesn't break in-flight requests, but it does create avoidable reconnect churn under load. I think evict needs to compare the currently pooled Arc against the one that just finished and only remove on Arc::ptr_eq.

Permalinks:

@TheBlueMattTheBlueMatt left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I'm understanding the fix here - if we hit a dead connection when we go to request we should fix the Connection, not build a new one in the Client?

@tnull

tnull commented Jul 13, 2026

Copy link
Copy Markdown
CollaboratorAuthor

Spent some time trying to reproduce #562 and couldn't really. This PR is now also somewhat superseded by #661 (w.r.t. keep-alive handling), so going ahead closing this.

@tnulltnull closed this Jul 13, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bitreq: Client connection pool retains dead connections indefinitely on I/O error

3 participants

@tnull@TheBlueMatt@storopoli