channeldb: fix race condition in link node pruning
What changed, and why it matters
This commit fixes a database race condition in LND (a Bitcoin Lightning Network implementation). When a channel closed, the software could incorrectly delete stored peer information ('link node') even though a new channel with that same peer had just been opened. The fix moves the 'check for open channels' and 'delete peer info' steps into the same database transaction, so they happen atomically and cannot be interrupted by another operation.
Apply the patch. It is a correctness fix for a race condition that can corrupt the channel/link-node invariant. No immediate incident response is indicated unless operators observe unexplained missing peer records or connection failures after channel closures. Review other database operations for similar split-transaction check/delete patterns.
Security signals we found
Race condition / TOCTOU between channel count check and link node deletion
Potential database corruption/invariant violation: link node deleted while channel exists
Possible connection issues due to missing link node metadata
Fix uses atomic transaction to bind check and delete
Defensive double-check added inside pruneLinkNode write transaction
Evidence from the diff
The patch resolves a TOCTOU (time-of-check/time-of-use) race in channeldb/db.go between MarkChanFullyClosed and pruneLinkNode. Previously, MarkChanFullyClosed read open channels in one kvdb.Update transaction, then called pruneLinkNode which performed deletion separately. A concurrent channel open could slip between the read and delete, causing the link node to be pruned while a channel still existed. The fix moves the open-channel check and deleteLinkNode call into a single kvdb.Update transaction in MarkChanFullyClosed, and adds a double-check inside pruneLinkNode’s own write transaction for callers that still use it (e.g., PruneLinkNodes).
Changed components
channeldb/db.goChannelStateDB.MarkChanFullyClosedChannelStateDB.pruneLinkNodeChannelStateDB.PruneLinkNodesdeleteLinkNodekvdb transaction handlingInspect captured patch +60 / −32
diff --git a/channeldb/db.go b/channeldb/db.go
index 00b29f6..6064975 100644
--- a/channeldb/db.go
+++ b/channeldb/db.go
@@ -1363,11 +1363,7 @@ func (c *ChannelStateDB) FetchClosedChannelForID(cid lnwire.ChannelID) (
// the pending funds in a channel that has been forcibly closed have been
// swept.
func (c *ChannelStateDB) MarkChanFullyClosed(chanPoint *wire.OutPoint) error {
- var (
- openChannels []*OpenChannel
- pruneLinkNode *btcec.PublicKey
- )
- err := kvdb.Update(c.backend, func(tx kvdb.RwTx) error {
+ return kvdb.Update(c.backend, func(tx kvdb.RwTx) error {
var b bytes.Buffer
if err := graphdb.WriteOutpoint(&b, chanPoint); err != nil {
return err
@@ -1413,44 +1409,72 @@ func (c *ChannelStateDB) MarkChanFullyClosed(chanPoint *wire.OutPoint) error {
// other open channels with this peer. If we don't we'll
// garbage collect it to ensure we don't establish persistent
// connections to peers without open channels.
- pruneLinkNode = chanSummary.RemotePub
- openChannels, err = c.fetchOpenChannels(
- tx, pruneLinkNode,
- )
+ remotePub := chanSummary.RemotePub
+ openChannels, err := c.fetchOpenChannels(tx, remotePub)
if err != nil {
return fmt.Errorf("unable to fetch open channels for "+
"peer %x: %v",
- pruneLinkNode.SerializeCompressed(), err)
+ remotePub.SerializeCompressed(), err)
}
- return nil
- }, func() {
- openChannels = nil
- pruneLinkNode = nil
- })
- if err != nil {
- return err
- }
+ if len(openChannels) > 0 {
+ return nil
+ }
+
+ // If there are no open channels with this peer, prune the
+ // link node. We do this within the same transaction to avoid
+ // a race condition where a new channel could be opened
+ // between this check and the deletion.
+ log.Infof("Pruning link node %x with zero open "+
+ "channels from database",
+ remotePub.SerializeCompressed())
- // Decide whether we want to remove the link node, based upon the number
- // of still open channels.
- return c.pruneLinkNode(openChannels, pruneLinkNode)
+ err = deleteLinkNode(tx, remotePub)
+ if err != nil {
+ return fmt.Errorf("unable to delete link "+
+ "node: %w", err)
+ }
+
+ return nil
+ }, func() {})
}
// pruneLinkNode determines whether we should garbage collect a link node from
-// the database due to no longer having any open channels with it. If there are
-// any left, then this acts as a no-op.
-func (c *ChannelStateDB) pruneLinkNode(openChannels []*OpenChannel,
- remotePub *btcec.PublicKey) error {
+// the database due to no longer having any open channels with it.
+//
+// NOTE: This function should be called after an initial check shows no open
+// channels exist. It will double-check within a write transaction to avoid a
+// race condition where a channel could be opened between the initial check
+// and the deletion.
+func (c *ChannelStateDB) pruneLinkNode(remotePub *btcec.PublicKey) error {
+ return kvdb.Update(c.backend, func(tx kvdb.RwTx) error {
+ // Double-check for open channels to avoid deleting a link node
+ // if a channel was opened since the caller's initial check.
+ //
+ // NOTE: This avoids a race condition where a channel could be
+ // opened between the initial check and the deletion.
+ openChannels, err := c.fetchOpenChannels(tx, remotePub)
+ if err != nil {
+ return err
+ }
- if len(openChannels) > 0 {
- return nil
- }
+ // If channels exist now, don't prune.
+ if len(openChannels) > 0 {
+ return nil
+ }
- log.Infof("Pruning link node %x with zero open channels from database",
- remotePub.SerializeCompressed())
+ // No open channels, safe to prune the link node.
+ log.Infof("Pruning link node %x with zero open channels "+
+ "from database",
+ remotePub.SerializeCompressed())
- return c.linkNodeDB.DeleteLinkNode(remotePub)
+ err = deleteLinkNode(tx, remotePub)
+ if err != nil {
+ return fmt.Errorf("unable to prune link node: %w", err)
+ }
+
+ return nil
+ }, func() {})
}
// PruneLinkNodes attempts to prune all link nodes found within the database
@@ -1479,7 +1503,11 @@ func (c *ChannelStateDB) PruneLinkNodes() error {
return err
}
- err = c.pruneLinkNode(openChannels, linkNode.IdentityPub)
+ if len(openChannels) > 0 {
+ continue
+ }
+
+ err = c.pruneLinkNode(linkNode.IdentityPub)
if err != nil {
return err
}
Why this scored 68/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.