Avoid re-locking reused UTXO futures - #4673

Merged
TheBlueMatt merged 1 commit into
lightningdevkit:mainfrom
tnull:2026-06-utxo-same-future-deadlock
Jun 17, 2026
Merged

Avoid re-locking reused UTXO futures#4673
TheBlueMatt merged 1 commit into
lightningdevkit:mainfrom
tnull:2026-06-utxo-same-future-deadlock

Conversation

@tnull

Copy link
Copy Markdown
Contributor

UtxoLookup implementations may cache and return the same async future for repeated requests for a short channel id. When a replacement channel announcement arrived for an in-flight lookup, the async path held the future state while comparing the existing pending entry, which could point to that same state.

Drop the state guard before checking or replacing the pending entry so repeated lookups can update the pending announcement without re-entering the mutex.

Co-Authored-By: HAL 9000

This finding was discovered by Project Loupe

@tnull
tnull requested a review from TheBlueMattJune 10, 2026 11:51
@ldk-reviews-bot

ldk-reviews-bot commented Jun 10, 2026

Copy link
Copy Markdown

👋 Thanks for assigning @TheBlueMatt as a reviewer!
I'll wait for their review and will help manage the review process.
Once they submit their review, I'll check if a second reviewer would be helpful.

@ldk-claude-review-bot

ldk-claude-review-bot commented Jun 10, 2026

Copy link
Copy Markdown
Collaborator

No issues found.

The implementation changed since my prior review pass (it now compares through the already-held guard via Arc::ptr_eq rather than dropping/re-locking the guard), but the new logic is correct:

  • The deadlock is avoided by not re-locking future.state when the pending entry is Arc::ptr_eq to it; the comparison reads replacement_messages (the held guard) directly.
  • At compare time async_messages.channel_announce still holds the previous announcement (the new one is written after the check_replace_previous_entry call at utxo.rs:475), so it correctly compares the incoming message against the existing pending one.
  • The else branch's unsafe_well_ordered_double_lock_self only runs for a genuinely different mutex, preserving the new→old lock order.
  • resolve_single_future runs only under the internal lock held throughout, so no interleaving can lose updates.
  • The regression test is valid (shared SCID 0 and fixed bitcoin keys make good_script match the replacement).

One non-blocking observation (not a regression, and strictly better than the deadlock being fixed): with a reused future for the same SCID but a different announcement, there is a single channel_announce slot, so the second announcement overwrites the first and resolve_single_future (utxo.rs:538) processes only the survivor. The nearby comment at utxo.rs:367 ("both results will be handled") only holds when the two requests have distinct future states. This is acceptable for the same-SCID case since the funding output is fixed and only one set of bitcoin keys can match the on-chain script.

@tnull

Copy link
Copy Markdown
ContributorAuthor

Updated to line-wrap commit messages.

@TheBlueMattTheBlueMatt left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Not entirely sure anyone would ever implement it this way, but it seems the correct fix is to handle the duplicate with an Arc::ptr_eq check in check_replace_previous_entry, not unlock+relock.

@ldk-reviews-bot

Copy link
Copy Markdown

👋 The first review has been submitted!

Do you think this PR is ready for a second reviewer? If so, click here to assign a second reviewer.

UtxoLookup implementations may cache and return the same async future
for repeated requests for a short channel id. When a replacement channel
announcement arrives while that future is in-flight, the pending-entry
comparison may point back to the future state already held by the async
path.
Detect that case with Arc::ptr_eq inside check_replace_previous_entry
and compare against the held messages instead of taking the mutex again.
This keeps duplicate-announcement filtering intact while letting
replacement announcements update the pending entry without re-entering
the lock.
Co-Authored-By: HAL 9000
This finding was discovered by Project Loupe
@tnull
tnullforce-pushed the 2026-06-utxo-same-future-deadlock branch from 0f8072f to dce31b7CompareJune 17, 2026 09:04
@tnull

Copy link
Copy Markdown
ContributorAuthor

Not entirely sure anyone would ever implement it this way, but it seems the correct fix is to handle the duplicate with an Arc::ptr_eq check in check_replace_previous_entry, not unlock+relock.

Now updated with this approach. Let me know if you find that preferable, or if we should even consider not fixing this?

@tnull
tnull requested a review from TheBlueMattJune 17, 2026 09:07

@TheBlueMattTheBlueMatt left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

thanks

@TheBlueMatt
TheBlueMatt merged commit 00d9065 into lightningdevkit:mainJun 17, 2026
@github-project-automationgithub-project-automationBot moved this from Goal: Merge to Done in Weekly GoalsJun 17, 2026
@TheBlueMatt

Copy link
Copy Markdown
Collaborator

IMO we should skip backporting this. I don't believe any of our implementations of the lookup interface will ever return the same future and implementing that interface by caching pending lookups and returning copies of the future seems like a lot of work that implementers aren't gonna do. LMK if you disagree @tnull

@tnull

Copy link
Copy Markdown
ContributorAuthor

IMO we should skip backporting this. I don't believe any of our implementations of the lookup interface will ever return the same future and implementing that interface by caching pending lookups and returning copies of the future seems like a lot of work that implementers aren't gonna do. LMK if you disagree @tnull

Fine by me.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

4 participants

@tnull@ldk-reviews-bot@ldk-claude-review-bot@TheBlueMatt
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n 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;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} 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

Avoid re-locking reused UTXO futures - #4673

Merged
TheBlueMatt merged 1 commit into
lightningdevkit:mainfrom
tnull:2026-06-utxo-same-future-deadlock
Jun 17, 2026
Merged

Avoid re-locking reused UTXO futures#4673
TheBlueMatt merged 1 commit into
lightningdevkit:mainfrom
tnull:2026-06-utxo-same-future-deadlock

Conversation

@tnull

Copy link
Copy Markdown
Contributor

UtxoLookup implementations may cache and return the same async future for repeated requests for a short channel id. When a replacement channel announcement arrived for an in-flight lookup, the async path held the future state while comparing the existing pending entry, which could point to that same state.

Drop the state guard before checking or replacing the pending entry so repeated lookups can update the pending announcement without re-entering the mutex.

Co-Authored-By: HAL 9000

This finding was discovered by Project Loupe

@tnull
tnull requested a review from TheBlueMattJune 10, 2026 11:51
@ldk-reviews-bot

ldk-reviews-bot commented Jun 10, 2026

Copy link
Copy Markdown

👋 Thanks for assigning @TheBlueMatt as a reviewer!
I'll wait for their review and will help manage the review process.
Once they submit their review, I'll check if a second reviewer would be helpful.

@ldk-claude-review-bot

ldk-claude-review-bot commented Jun 10, 2026

Copy link
Copy Markdown
Collaborator

No issues found.

The implementation changed since my prior review pass (it now compares through the already-held guard via Arc::ptr_eq rather than dropping/re-locking the guard), but the new logic is correct:

  • The deadlock is avoided by not re-locking future.state when the pending entry is Arc::ptr_eq to it; the comparison reads replacement_messages (the held guard) directly.
  • At compare time async_messages.channel_announce still holds the previous announcement (the new one is written after the check_replace_previous_entry call at utxo.rs:475), so it correctly compares the incoming message against the existing pending one.
  • The else branch's unsafe_well_ordered_double_lock_self only runs for a genuinely different mutex, preserving the new→old lock order.
  • resolve_single_future runs only under the internal lock held throughout, so no interleaving can lose updates.
  • The regression test is valid (shared SCID 0 and fixed bitcoin keys make good_script match the replacement).

One non-blocking observation (not a regression, and strictly better than the deadlock being fixed): with a reused future for the same SCID but a different announcement, there is a single channel_announce slot, so the second announcement overwrites the first and resolve_single_future (utxo.rs:538) processes only the survivor. The nearby comment at utxo.rs:367 ("both results will be handled") only holds when the two requests have distinct future states. This is acceptable for the same-SCID case since the funding output is fixed and only one set of bitcoin keys can match the on-chain script.

@tnull

Copy link
Copy Markdown
ContributorAuthor

Updated to line-wrap commit messages.

@TheBlueMattTheBlueMatt left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Not entirely sure anyone would ever implement it this way, but it seems the correct fix is to handle the duplicate with an Arc::ptr_eq check in check_replace_previous_entry, not unlock+relock.

@ldk-reviews-bot

Copy link
Copy Markdown

👋 The first review has been submitted!

Do you think this PR is ready for a second reviewer? If so, click here to assign a second reviewer.

UtxoLookup implementations may cache and return the same async future
for repeated requests for a short channel id. When a replacement channel
announcement arrives while that future is in-flight, the pending-entry
comparison may point back to the future state already held by the async
path.
Detect that case with Arc::ptr_eq inside check_replace_previous_entry
and compare against the held messages instead of taking the mutex again.
This keeps duplicate-announcement filtering intact while letting
replacement announcements update the pending entry without re-entering
the lock.
Co-Authored-By: HAL 9000
This finding was discovered by Project Loupe
@tnull
tnullforce-pushed the 2026-06-utxo-same-future-deadlock branch from 0f8072f to dce31b7CompareJune 17, 2026 09:04
@tnull

Copy link
Copy Markdown
ContributorAuthor

Not entirely sure anyone would ever implement it this way, but it seems the correct fix is to handle the duplicate with an Arc::ptr_eq check in check_replace_previous_entry, not unlock+relock.

Now updated with this approach. Let me know if you find that preferable, or if we should even consider not fixing this?

@tnull
tnull requested a review from TheBlueMattJune 17, 2026 09:07

@TheBlueMattTheBlueMatt left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

thanks

@TheBlueMatt
TheBlueMatt merged commit 00d9065 into lightningdevkit:mainJun 17, 2026
@github-project-automationgithub-project-automationBot moved this from Goal: Merge to Done in Weekly GoalsJun 17, 2026
@TheBlueMatt

Copy link
Copy Markdown
Collaborator

IMO we should skip backporting this. I don't believe any of our implementations of the lookup interface will ever return the same future and implementing that interface by caching pending lookups and returning copies of the future seems like a lot of work that implementers aren't gonna do. LMK if you disagree @tnull

@tnull

Copy link
Copy Markdown
ContributorAuthor

IMO we should skip backporting this. I don't believe any of our implementations of the lookup interface will ever return the same future and implementing that interface by caching pending lookups and returning copies of the future seems like a lot of work that implementers aren't gonna do. LMK if you disagree @tnull

Fine by me.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

4 participants

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

Avoid re-locking reused UTXO futures - #4673

Merged
TheBlueMatt merged 1 commit into
lightningdevkit:mainfrom
tnull:2026-06-utxo-same-future-deadlock
Jun 17, 2026
Merged

Avoid re-locking reused UTXO futures#4673
TheBlueMatt merged 1 commit into
lightningdevkit:mainfrom
tnull:2026-06-utxo-same-future-deadlock

Conversation

@tnull

Copy link
Copy Markdown
Contributor

UtxoLookup implementations may cache and return the same async future for repeated requests for a short channel id. When a replacement channel announcement arrived for an in-flight lookup, the async path held the future state while comparing the existing pending entry, which could point to that same state.

Drop the state guard before checking or replacing the pending entry so repeated lookups can update the pending announcement without re-entering the mutex.

Co-Authored-By: HAL 9000

This finding was discovered by Project Loupe

@tnull
tnull requested a review from TheBlueMattJune 10, 2026 11:51
@ldk-reviews-bot

ldk-reviews-bot commented Jun 10, 2026

Copy link
Copy Markdown

👋 Thanks for assigning @TheBlueMatt as a reviewer!
I'll wait for their review and will help manage the review process.
Once they submit their review, I'll check if a second reviewer would be helpful.

@ldk-claude-review-bot

ldk-claude-review-bot commented Jun 10, 2026

Copy link
Copy Markdown
Collaborator

No issues found.

The implementation changed since my prior review pass (it now compares through the already-held guard via Arc::ptr_eq rather than dropping/re-locking the guard), but the new logic is correct:

  • The deadlock is avoided by not re-locking future.state when the pending entry is Arc::ptr_eq to it; the comparison reads replacement_messages (the held guard) directly.
  • At compare time async_messages.channel_announce still holds the previous announcement (the new one is written after the check_replace_previous_entry call at utxo.rs:475), so it correctly compares the incoming message against the existing pending one.
  • The else branch's unsafe_well_ordered_double_lock_self only runs for a genuinely different mutex, preserving the new→old lock order.
  • resolve_single_future runs only under the internal lock held throughout, so no interleaving can lose updates.
  • The regression test is valid (shared SCID 0 and fixed bitcoin keys make good_script match the replacement).

One non-blocking observation (not a regression, and strictly better than the deadlock being fixed): with a reused future for the same SCID but a different announcement, there is a single channel_announce slot, so the second announcement overwrites the first and resolve_single_future (utxo.rs:538) processes only the survivor. The nearby comment at utxo.rs:367 ("both results will be handled") only holds when the two requests have distinct future states. This is acceptable for the same-SCID case since the funding output is fixed and only one set of bitcoin keys can match the on-chain script.

@tnull

Copy link
Copy Markdown
ContributorAuthor

Updated to line-wrap commit messages.

@TheBlueMattTheBlueMatt left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Not entirely sure anyone would ever implement it this way, but it seems the correct fix is to handle the duplicate with an Arc::ptr_eq check in check_replace_previous_entry, not unlock+relock.

@ldk-reviews-bot

Copy link
Copy Markdown

👋 The first review has been submitted!

Do you think this PR is ready for a second reviewer? If so, click here to assign a second reviewer.

UtxoLookup implementations may cache and return the same async future
for repeated requests for a short channel id. When a replacement channel
announcement arrives while that future is in-flight, the pending-entry
comparison may point back to the future state already held by the async
path.
Detect that case with Arc::ptr_eq inside check_replace_previous_entry
and compare against the held messages instead of taking the mutex again.
This keeps duplicate-announcement filtering intact while letting
replacement announcements update the pending entry without re-entering
the lock.
Co-Authored-By: HAL 9000
This finding was discovered by Project Loupe
@tnull
tnullforce-pushed the 2026-06-utxo-same-future-deadlock branch from 0f8072f to dce31b7CompareJune 17, 2026 09:04
@tnull

Copy link
Copy Markdown
ContributorAuthor

Not entirely sure anyone would ever implement it this way, but it seems the correct fix is to handle the duplicate with an Arc::ptr_eq check in check_replace_previous_entry, not unlock+relock.

Now updated with this approach. Let me know if you find that preferable, or if we should even consider not fixing this?

@tnull
tnull requested a review from TheBlueMattJune 17, 2026 09:07

@TheBlueMattTheBlueMatt left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

thanks

@TheBlueMatt
TheBlueMatt merged commit 00d9065 into lightningdevkit:mainJun 17, 2026
@github-project-automationgithub-project-automationBot moved this from Goal: Merge to Done in Weekly GoalsJun 17, 2026
@TheBlueMatt

Copy link
Copy Markdown
Collaborator

IMO we should skip backporting this. I don't believe any of our implementations of the lookup interface will ever return the same future and implementing that interface by caching pending lookups and returning copies of the future seems like a lot of work that implementers aren't gonna do. LMK if you disagree @tnull

@tnull

Copy link
Copy Markdown
ContributorAuthor

IMO we should skip backporting this. I don't believe any of our implementations of the lookup interface will ever return the same future and implementing that interface by caching pending lookups and returning copies of the future seems like a lot of work that implementers aren't gonna do. LMK if you disagree @tnull

Fine by me.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

4 participants

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

Avoid re-locking reused UTXO futures - #4673

Merged
TheBlueMatt merged 1 commit into
lightningdevkit:mainfrom
tnull:2026-06-utxo-same-future-deadlock
Jun 17, 2026
Merged

Avoid re-locking reused UTXO futures#4673
TheBlueMatt merged 1 commit into
lightningdevkit:mainfrom
tnull:2026-06-utxo-same-future-deadlock

Conversation

@tnull

Copy link
Copy Markdown
Contributor

UtxoLookup implementations may cache and return the same async future for repeated requests for a short channel id. When a replacement channel announcement arrived for an in-flight lookup, the async path held the future state while comparing the existing pending entry, which could point to that same state.

Drop the state guard before checking or replacing the pending entry so repeated lookups can update the pending announcement without re-entering the mutex.

Co-Authored-By: HAL 9000

This finding was discovered by Project Loupe

@tnull
tnull requested a review from TheBlueMattJune 10, 2026 11:51
@ldk-reviews-bot

ldk-reviews-bot commented Jun 10, 2026

Copy link
Copy Markdown

👋 Thanks for assigning @TheBlueMatt as a reviewer!
I'll wait for their review and will help manage the review process.
Once they submit their review, I'll check if a second reviewer would be helpful.

@ldk-claude-review-bot

ldk-claude-review-bot commented Jun 10, 2026

Copy link
Copy Markdown
Collaborator

No issues found.

The implementation changed since my prior review pass (it now compares through the already-held guard via Arc::ptr_eq rather than dropping/re-locking the guard), but the new logic is correct:

  • The deadlock is avoided by not re-locking future.state when the pending entry is Arc::ptr_eq to it; the comparison reads replacement_messages (the held guard) directly.
  • At compare time async_messages.channel_announce still holds the previous announcement (the new one is written after the check_replace_previous_entry call at utxo.rs:475), so it correctly compares the incoming message against the existing pending one.
  • The else branch's unsafe_well_ordered_double_lock_self only runs for a genuinely different mutex, preserving the new→old lock order.
  • resolve_single_future runs only under the internal lock held throughout, so no interleaving can lose updates.
  • The regression test is valid (shared SCID 0 and fixed bitcoin keys make good_script match the replacement).

One non-blocking observation (not a regression, and strictly better than the deadlock being fixed): with a reused future for the same SCID but a different announcement, there is a single channel_announce slot, so the second announcement overwrites the first and resolve_single_future (utxo.rs:538) processes only the survivor. The nearby comment at utxo.rs:367 ("both results will be handled") only holds when the two requests have distinct future states. This is acceptable for the same-SCID case since the funding output is fixed and only one set of bitcoin keys can match the on-chain script.

@tnull

Copy link
Copy Markdown
ContributorAuthor

Updated to line-wrap commit messages.

@TheBlueMattTheBlueMatt left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Not entirely sure anyone would ever implement it this way, but it seems the correct fix is to handle the duplicate with an Arc::ptr_eq check in check_replace_previous_entry, not unlock+relock.

@ldk-reviews-bot

Copy link
Copy Markdown

👋 The first review has been submitted!

Do you think this PR is ready for a second reviewer? If so, click here to assign a second reviewer.

UtxoLookup implementations may cache and return the same async future
for repeated requests for a short channel id. When a replacement channel
announcement arrives while that future is in-flight, the pending-entry
comparison may point back to the future state already held by the async
path.
Detect that case with Arc::ptr_eq inside check_replace_previous_entry
and compare against the held messages instead of taking the mutex again.
This keeps duplicate-announcement filtering intact while letting
replacement announcements update the pending entry without re-entering
the lock.
Co-Authored-By: HAL 9000
This finding was discovered by Project Loupe
@tnull
tnullforce-pushed the 2026-06-utxo-same-future-deadlock branch from 0f8072f to dce31b7CompareJune 17, 2026 09:04
@tnull

Copy link
Copy Markdown
ContributorAuthor

Not entirely sure anyone would ever implement it this way, but it seems the correct fix is to handle the duplicate with an Arc::ptr_eq check in check_replace_previous_entry, not unlock+relock.

Now updated with this approach. Let me know if you find that preferable, or if we should even consider not fixing this?

@tnull
tnull requested a review from TheBlueMattJune 17, 2026 09:07

@TheBlueMattTheBlueMatt left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

thanks

@TheBlueMatt
TheBlueMatt merged commit 00d9065 into lightningdevkit:mainJun 17, 2026
@github-project-automationgithub-project-automationBot moved this from Goal: Merge to Done in Weekly GoalsJun 17, 2026
@TheBlueMatt

Copy link
Copy Markdown
Collaborator

IMO we should skip backporting this. I don't believe any of our implementations of the lookup interface will ever return the same future and implementing that interface by caching pending lookups and returning copies of the future seems like a lot of work that implementers aren't gonna do. LMK if you disagree @tnull

@tnull

Copy link
Copy Markdown
ContributorAuthor

IMO we should skip backporting this. I don't believe any of our implementations of the lookup interface will ever return the same future and implementing that interface by caching pending lookups and returning copies of the future seems like a lot of work that implementers aren't gonna do. LMK if you disagree @tnull

Fine by me.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

4 participants

@tnull@ldk-reviews-bot@ldk-claude-review-bot@TheBlueMatt
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } 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

Avoid re-locking reused UTXO futures - #4673

Merged
TheBlueMatt merged 1 commit into
lightningdevkit:mainfrom
tnull:2026-06-utxo-same-future-deadlock
Jun 17, 2026
Merged

Avoid re-locking reused UTXO futures#4673
TheBlueMatt merged 1 commit into
lightningdevkit:mainfrom
tnull:2026-06-utxo-same-future-deadlock

Conversation

@tnull

Copy link
Copy Markdown
Contributor

UtxoLookup implementations may cache and return the same async future for repeated requests for a short channel id. When a replacement channel announcement arrived for an in-flight lookup, the async path held the future state while comparing the existing pending entry, which could point to that same state.

Drop the state guard before checking or replacing the pending entry so repeated lookups can update the pending announcement without re-entering the mutex.

Co-Authored-By: HAL 9000

This finding was discovered by Project Loupe

@tnull
tnull requested a review from TheBlueMattJune 10, 2026 11:51
@ldk-reviews-bot

ldk-reviews-bot commented Jun 10, 2026

Copy link
Copy Markdown

👋 Thanks for assigning @TheBlueMatt as a reviewer!
I'll wait for their review and will help manage the review process.
Once they submit their review, I'll check if a second reviewer would be helpful.

@ldk-claude-review-bot

ldk-claude-review-bot commented Jun 10, 2026

Copy link
Copy Markdown
Collaborator

No issues found.

The implementation changed since my prior review pass (it now compares through the already-held guard via Arc::ptr_eq rather than dropping/re-locking the guard), but the new logic is correct:

  • The deadlock is avoided by not re-locking future.state when the pending entry is Arc::ptr_eq to it; the comparison reads replacement_messages (the held guard) directly.
  • At compare time async_messages.channel_announce still holds the previous announcement (the new one is written after the check_replace_previous_entry call at utxo.rs:475), so it correctly compares the incoming message against the existing pending one.
  • The else branch's unsafe_well_ordered_double_lock_self only runs for a genuinely different mutex, preserving the new→old lock order.
  • resolve_single_future runs only under the internal lock held throughout, so no interleaving can lose updates.
  • The regression test is valid (shared SCID 0 and fixed bitcoin keys make good_script match the replacement).

One non-blocking observation (not a regression, and strictly better than the deadlock being fixed): with a reused future for the same SCID but a different announcement, there is a single channel_announce slot, so the second announcement overwrites the first and resolve_single_future (utxo.rs:538) processes only the survivor. The nearby comment at utxo.rs:367 ("both results will be handled") only holds when the two requests have distinct future states. This is acceptable for the same-SCID case since the funding output is fixed and only one set of bitcoin keys can match the on-chain script.

@tnull

Copy link
Copy Markdown
ContributorAuthor

Updated to line-wrap commit messages.

@TheBlueMattTheBlueMatt left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Not entirely sure anyone would ever implement it this way, but it seems the correct fix is to handle the duplicate with an Arc::ptr_eq check in check_replace_previous_entry, not unlock+relock.

@ldk-reviews-bot

Copy link
Copy Markdown

👋 The first review has been submitted!

Do you think this PR is ready for a second reviewer? If so, click here to assign a second reviewer.

UtxoLookup implementations may cache and return the same async future
for repeated requests for a short channel id. When a replacement channel
announcement arrives while that future is in-flight, the pending-entry
comparison may point back to the future state already held by the async
path.
Detect that case with Arc::ptr_eq inside check_replace_previous_entry
and compare against the held messages instead of taking the mutex again.
This keeps duplicate-announcement filtering intact while letting
replacement announcements update the pending entry without re-entering
the lock.
Co-Authored-By: HAL 9000
This finding was discovered by Project Loupe
@tnull
tnullforce-pushed the 2026-06-utxo-same-future-deadlock branch from 0f8072f to dce31b7CompareJune 17, 2026 09:04
@tnull

Copy link
Copy Markdown
ContributorAuthor

Not entirely sure anyone would ever implement it this way, but it seems the correct fix is to handle the duplicate with an Arc::ptr_eq check in check_replace_previous_entry, not unlock+relock.

Now updated with this approach. Let me know if you find that preferable, or if we should even consider not fixing this?

@tnull
tnull requested a review from TheBlueMattJune 17, 2026 09:07

@TheBlueMattTheBlueMatt left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

thanks

@TheBlueMatt
TheBlueMatt merged commit 00d9065 into lightningdevkit:mainJun 17, 2026
@github-project-automationgithub-project-automationBot moved this from Goal: Merge to Done in Weekly GoalsJun 17, 2026
@TheBlueMatt

Copy link
Copy Markdown
Collaborator

IMO we should skip backporting this. I don't believe any of our implementations of the lookup interface will ever return the same future and implementing that interface by caching pending lookups and returning copies of the future seems like a lot of work that implementers aren't gonna do. LMK if you disagree @tnull

@tnull

Copy link
Copy Markdown
ContributorAuthor

IMO we should skip backporting this. I don't believe any of our implementations of the lookup interface will ever return the same future and implementing that interface by caching pending lookups and returning copies of the future seems like a lot of work that implementers aren't gonna do. LMK if you disagree @tnull

Fine by me.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

4 participants

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

Avoid re-locking reused UTXO futures - #4673

Merged
TheBlueMatt merged 1 commit into
lightningdevkit:mainfrom
tnull:2026-06-utxo-same-future-deadlock
Jun 17, 2026
Merged

Avoid re-locking reused UTXO futures#4673
TheBlueMatt merged 1 commit into
lightningdevkit:mainfrom
tnull:2026-06-utxo-same-future-deadlock

Conversation

@tnull

Copy link
Copy Markdown
Contributor

UtxoLookup implementations may cache and return the same async future for repeated requests for a short channel id. When a replacement channel announcement arrived for an in-flight lookup, the async path held the future state while comparing the existing pending entry, which could point to that same state.

Drop the state guard before checking or replacing the pending entry so repeated lookups can update the pending announcement without re-entering the mutex.

Co-Authored-By: HAL 9000

This finding was discovered by Project Loupe

@tnull
tnull requested a review from TheBlueMattJune 10, 2026 11:51
@ldk-reviews-bot

ldk-reviews-bot commented Jun 10, 2026

Copy link
Copy Markdown

👋 Thanks for assigning @TheBlueMatt as a reviewer!
I'll wait for their review and will help manage the review process.
Once they submit their review, I'll check if a second reviewer would be helpful.

@ldk-claude-review-bot

ldk-claude-review-bot commented Jun 10, 2026

Copy link
Copy Markdown
Collaborator

No issues found.

The implementation changed since my prior review pass (it now compares through the already-held guard via Arc::ptr_eq rather than dropping/re-locking the guard), but the new logic is correct:

  • The deadlock is avoided by not re-locking future.state when the pending entry is Arc::ptr_eq to it; the comparison reads replacement_messages (the held guard) directly.
  • At compare time async_messages.channel_announce still holds the previous announcement (the new one is written after the check_replace_previous_entry call at utxo.rs:475), so it correctly compares the incoming message against the existing pending one.
  • The else branch's unsafe_well_ordered_double_lock_self only runs for a genuinely different mutex, preserving the new→old lock order.
  • resolve_single_future runs only under the internal lock held throughout, so no interleaving can lose updates.
  • The regression test is valid (shared SCID 0 and fixed bitcoin keys make good_script match the replacement).

One non-blocking observation (not a regression, and strictly better than the deadlock being fixed): with a reused future for the same SCID but a different announcement, there is a single channel_announce slot, so the second announcement overwrites the first and resolve_single_future (utxo.rs:538) processes only the survivor. The nearby comment at utxo.rs:367 ("both results will be handled") only holds when the two requests have distinct future states. This is acceptable for the same-SCID case since the funding output is fixed and only one set of bitcoin keys can match the on-chain script.

@tnull

Copy link
Copy Markdown
ContributorAuthor

Updated to line-wrap commit messages.

@TheBlueMattTheBlueMatt left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Not entirely sure anyone would ever implement it this way, but it seems the correct fix is to handle the duplicate with an Arc::ptr_eq check in check_replace_previous_entry, not unlock+relock.

@ldk-reviews-bot

Copy link
Copy Markdown

👋 The first review has been submitted!

Do you think this PR is ready for a second reviewer? If so, click here to assign a second reviewer.

UtxoLookup implementations may cache and return the same async future
for repeated requests for a short channel id. When a replacement channel
announcement arrives while that future is in-flight, the pending-entry
comparison may point back to the future state already held by the async
path.
Detect that case with Arc::ptr_eq inside check_replace_previous_entry
and compare against the held messages instead of taking the mutex again.
This keeps duplicate-announcement filtering intact while letting
replacement announcements update the pending entry without re-entering
the lock.
Co-Authored-By: HAL 9000
This finding was discovered by Project Loupe
@tnull
tnullforce-pushed the 2026-06-utxo-same-future-deadlock branch from 0f8072f to dce31b7CompareJune 17, 2026 09:04
@tnull

Copy link
Copy Markdown
ContributorAuthor

Not entirely sure anyone would ever implement it this way, but it seems the correct fix is to handle the duplicate with an Arc::ptr_eq check in check_replace_previous_entry, not unlock+relock.

Now updated with this approach. Let me know if you find that preferable, or if we should even consider not fixing this?

@tnull
tnull requested a review from TheBlueMattJune 17, 2026 09:07

@TheBlueMattTheBlueMatt left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

thanks

@TheBlueMatt
TheBlueMatt merged commit 00d9065 into lightningdevkit:mainJun 17, 2026
@github-project-automationgithub-project-automationBot moved this from Goal: Merge to Done in Weekly GoalsJun 17, 2026
@TheBlueMatt

Copy link
Copy Markdown
Collaborator

IMO we should skip backporting this. I don't believe any of our implementations of the lookup interface will ever return the same future and implementing that interface by caching pending lookups and returning copies of the future seems like a lot of work that implementers aren't gonna do. LMK if you disagree @tnull

@tnull

Copy link
Copy Markdown
ContributorAuthor

IMO we should skip backporting this. I don't believe any of our implementations of the lookup interface will ever return the same future and implementing that interface by caching pending lookups and returning copies of the future seems like a lot of work that implementers aren't gonna do. LMK if you disagree @tnull

Fine by me.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

4 participants

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

Avoid re-locking reused UTXO futures - #4673

Merged
TheBlueMatt merged 1 commit into
lightningdevkit:mainfrom
tnull:2026-06-utxo-same-future-deadlock
Jun 17, 2026
Merged

Avoid re-locking reused UTXO futures#4673
TheBlueMatt merged 1 commit into
lightningdevkit:mainfrom
tnull:2026-06-utxo-same-future-deadlock

Conversation

@tnull

Copy link
Copy Markdown
Contributor

UtxoLookup implementations may cache and return the same async future for repeated requests for a short channel id. When a replacement channel announcement arrived for an in-flight lookup, the async path held the future state while comparing the existing pending entry, which could point to that same state.

Drop the state guard before checking or replacing the pending entry so repeated lookups can update the pending announcement without re-entering the mutex.

Co-Authored-By: HAL 9000

This finding was discovered by Project Loupe

@tnull
tnull requested a review from TheBlueMattJune 10, 2026 11:51
@ldk-reviews-bot

ldk-reviews-bot commented Jun 10, 2026

Copy link
Copy Markdown

👋 Thanks for assigning @TheBlueMatt as a reviewer!
I'll wait for their review and will help manage the review process.
Once they submit their review, I'll check if a second reviewer would be helpful.

@ldk-claude-review-bot

ldk-claude-review-bot commented Jun 10, 2026

Copy link
Copy Markdown
Collaborator

No issues found.

The implementation changed since my prior review pass (it now compares through the already-held guard via Arc::ptr_eq rather than dropping/re-locking the guard), but the new logic is correct:

  • The deadlock is avoided by not re-locking future.state when the pending entry is Arc::ptr_eq to it; the comparison reads replacement_messages (the held guard) directly.
  • At compare time async_messages.channel_announce still holds the previous announcement (the new one is written after the check_replace_previous_entry call at utxo.rs:475), so it correctly compares the incoming message against the existing pending one.
  • The else branch's unsafe_well_ordered_double_lock_self only runs for a genuinely different mutex, preserving the new→old lock order.
  • resolve_single_future runs only under the internal lock held throughout, so no interleaving can lose updates.
  • The regression test is valid (shared SCID 0 and fixed bitcoin keys make good_script match the replacement).

One non-blocking observation (not a regression, and strictly better than the deadlock being fixed): with a reused future for the same SCID but a different announcement, there is a single channel_announce slot, so the second announcement overwrites the first and resolve_single_future (utxo.rs:538) processes only the survivor. The nearby comment at utxo.rs:367 ("both results will be handled") only holds when the two requests have distinct future states. This is acceptable for the same-SCID case since the funding output is fixed and only one set of bitcoin keys can match the on-chain script.

@tnull

Copy link
Copy Markdown
ContributorAuthor

Updated to line-wrap commit messages.

@TheBlueMattTheBlueMatt left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Not entirely sure anyone would ever implement it this way, but it seems the correct fix is to handle the duplicate with an Arc::ptr_eq check in check_replace_previous_entry, not unlock+relock.

@ldk-reviews-bot

Copy link
Copy Markdown

👋 The first review has been submitted!

Do you think this PR is ready for a second reviewer? If so, click here to assign a second reviewer.

UtxoLookup implementations may cache and return the same async future
for repeated requests for a short channel id. When a replacement channel
announcement arrives while that future is in-flight, the pending-entry
comparison may point back to the future state already held by the async
path.
Detect that case with Arc::ptr_eq inside check_replace_previous_entry
and compare against the held messages instead of taking the mutex again.
This keeps duplicate-announcement filtering intact while letting
replacement announcements update the pending entry without re-entering
the lock.
Co-Authored-By: HAL 9000
This finding was discovered by Project Loupe
@tnull
tnullforce-pushed the 2026-06-utxo-same-future-deadlock branch from 0f8072f to dce31b7CompareJune 17, 2026 09:04
@tnull

Copy link
Copy Markdown
ContributorAuthor

Not entirely sure anyone would ever implement it this way, but it seems the correct fix is to handle the duplicate with an Arc::ptr_eq check in check_replace_previous_entry, not unlock+relock.

Now updated with this approach. Let me know if you find that preferable, or if we should even consider not fixing this?

@tnull
tnull requested a review from TheBlueMattJune 17, 2026 09:07

@TheBlueMattTheBlueMatt left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

thanks

@TheBlueMatt
TheBlueMatt merged commit 00d9065 into lightningdevkit:mainJun 17, 2026
@github-project-automationgithub-project-automationBot moved this from Goal: Merge to Done in Weekly GoalsJun 17, 2026
@TheBlueMatt

Copy link
Copy Markdown
Collaborator

IMO we should skip backporting this. I don't believe any of our implementations of the lookup interface will ever return the same future and implementing that interface by caching pending lookups and returning copies of the future seems like a lot of work that implementers aren't gonna do. LMK if you disagree @tnull

@tnull

Copy link
Copy Markdown
ContributorAuthor

IMO we should skip backporting this. I don't believe any of our implementations of the lookup interface will ever return the same future and implementing that interface by caching pending lookups and returning copies of the future seems like a lot of work that implementers aren't gonna do. LMK if you disagree @tnull

Fine by me.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

4 participants

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

Avoid re-locking reused UTXO futures - #4673

Merged
TheBlueMatt merged 1 commit into
lightningdevkit:mainfrom
tnull:2026-06-utxo-same-future-deadlock
Jun 17, 2026
Merged

Avoid re-locking reused UTXO futures#4673
TheBlueMatt merged 1 commit into
lightningdevkit:mainfrom
tnull:2026-06-utxo-same-future-deadlock

Conversation

@tnull

Copy link
Copy Markdown
Contributor

UtxoLookup implementations may cache and return the same async future for repeated requests for a short channel id. When a replacement channel announcement arrived for an in-flight lookup, the async path held the future state while comparing the existing pending entry, which could point to that same state.

Drop the state guard before checking or replacing the pending entry so repeated lookups can update the pending announcement without re-entering the mutex.

Co-Authored-By: HAL 9000

This finding was discovered by Project Loupe

@tnull
tnull requested a review from TheBlueMattJune 10, 2026 11:51
@ldk-reviews-bot

ldk-reviews-bot commented Jun 10, 2026

Copy link
Copy Markdown

👋 Thanks for assigning @TheBlueMatt as a reviewer!
I'll wait for their review and will help manage the review process.
Once they submit their review, I'll check if a second reviewer would be helpful.

@ldk-claude-review-bot

ldk-claude-review-bot commented Jun 10, 2026

Copy link
Copy Markdown
Collaborator

No issues found.

The implementation changed since my prior review pass (it now compares through the already-held guard via Arc::ptr_eq rather than dropping/re-locking the guard), but the new logic is correct:

  • The deadlock is avoided by not re-locking future.state when the pending entry is Arc::ptr_eq to it; the comparison reads replacement_messages (the held guard) directly.
  • At compare time async_messages.channel_announce still holds the previous announcement (the new one is written after the check_replace_previous_entry call at utxo.rs:475), so it correctly compares the incoming message against the existing pending one.
  • The else branch's unsafe_well_ordered_double_lock_self only runs for a genuinely different mutex, preserving the new→old lock order.
  • resolve_single_future runs only under the internal lock held throughout, so no interleaving can lose updates.
  • The regression test is valid (shared SCID 0 and fixed bitcoin keys make good_script match the replacement).

One non-blocking observation (not a regression, and strictly better than the deadlock being fixed): with a reused future for the same SCID but a different announcement, there is a single channel_announce slot, so the second announcement overwrites the first and resolve_single_future (utxo.rs:538) processes only the survivor. The nearby comment at utxo.rs:367 ("both results will be handled") only holds when the two requests have distinct future states. This is acceptable for the same-SCID case since the funding output is fixed and only one set of bitcoin keys can match the on-chain script.

@tnull

Copy link
Copy Markdown
ContributorAuthor

Updated to line-wrap commit messages.

@TheBlueMattTheBlueMatt left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Not entirely sure anyone would ever implement it this way, but it seems the correct fix is to handle the duplicate with an Arc::ptr_eq check in check_replace_previous_entry, not unlock+relock.

@ldk-reviews-bot

Copy link
Copy Markdown

👋 The first review has been submitted!

Do you think this PR is ready for a second reviewer? If so, click here to assign a second reviewer.

UtxoLookup implementations may cache and return the same async future
for repeated requests for a short channel id. When a replacement channel
announcement arrives while that future is in-flight, the pending-entry
comparison may point back to the future state already held by the async
path.
Detect that case with Arc::ptr_eq inside check_replace_previous_entry
and compare against the held messages instead of taking the mutex again.
This keeps duplicate-announcement filtering intact while letting
replacement announcements update the pending entry without re-entering
the lock.
Co-Authored-By: HAL 9000
This finding was discovered by Project Loupe
@tnull
tnullforce-pushed the 2026-06-utxo-same-future-deadlock branch from 0f8072f to dce31b7CompareJune 17, 2026 09:04
@tnull

Copy link
Copy Markdown
ContributorAuthor

Not entirely sure anyone would ever implement it this way, but it seems the correct fix is to handle the duplicate with an Arc::ptr_eq check in check_replace_previous_entry, not unlock+relock.

Now updated with this approach. Let me know if you find that preferable, or if we should even consider not fixing this?

@tnull
tnull requested a review from TheBlueMattJune 17, 2026 09:07

@TheBlueMattTheBlueMatt left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

thanks

@TheBlueMatt
TheBlueMatt merged commit 00d9065 into lightningdevkit:mainJun 17, 2026
@github-project-automationgithub-project-automationBot moved this from Goal: Merge to Done in Weekly GoalsJun 17, 2026
@TheBlueMatt

Copy link
Copy Markdown
Collaborator

IMO we should skip backporting this. I don't believe any of our implementations of the lookup interface will ever return the same future and implementing that interface by caching pending lookups and returning copies of the future seems like a lot of work that implementers aren't gonna do. LMK if you disagree @tnull

@tnull

Copy link
Copy Markdown
ContributorAuthor

IMO we should skip backporting this. I don't believe any of our implementations of the lookup interface will ever return the same future and implementing that interface by caching pending lookups and returning copies of the future seems like a lot of work that implementers aren't gonna do. LMK if you disagree @tnull

Fine by me.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

4 participants

@tnull@ldk-reviews-bot@ldk-claude-review-bot@TheBlueMatt