peer: add panic recovery to message handling goroutines
What changed, and why it matters
This commit adds a safety net to btcd's peer networking code. Previously, a bug in message parsing or handling could crash the entire Bitcoin node. Now, if a peer-handling goroutine panics, the node catches the panic, logs it, and disconnects only the offending peer. It also fixes cleanup so that internal shutdown signals are always sent, even during a panic. The change is defensive and does not by itself fix any known specific crash bug.
Treat as a hardening improvement rather than an active vulnerability fix. Review whether any existing message-parsing code paths are known to panic, and consider backporting to stable branches because the change is low-risk and improves node availability. Monitor for future commits that may pair this recovery with specific parser fixes.
Security signals we found
Adds panic recovery to message-handling goroutines to prevent node-wide crashes
Refactors cleanup to use defers so shutdown signals are not skipped on panic
Adds a case for `<-p.quit` during protocol negotiation to avoid hanging when a peer disconnects during negotiation
Includes a unit test for panic recovery behavior
Evidence from the diff
The patch introduces recoverFromPanic() in peer/peer.go, which uses Go’s recover() plus debug.Stack() to catch unhandled panics in two goroutine boundaries: the protocol-negotiation goroutine in start() and the incoming-message goroutine inHandler(). It refactors inHandler() cleanup to use defer for recoverFromPanic(), close(p.inQuit), p.Disconnect(), idle timer stop, and trace logging, ensuring cleanup runs even on panic. A new unit test TestRecoverFromPanic verifies that a panic sets the peer’s disconnect flag. The commit message frames this as defense-in-depth against future unknown panics from malformed messages.
Changed components
peer/peer.gopeer/recover_test.goInspect captured patch +59 / −9
diff --git a/peer/peer.go b/peer/peer.go
index ee6f317..f37fe63 100644
--- a/peer/peer.go
+++ b/peer/peer.go
@@ -13,6 +13,7 @@ import (
"io"
"math/rand"
"net"
+ "runtime/debug"
"strconv"
"sync"
"sync/atomic"
@@ -1435,12 +1436,20 @@ cleanup:
// inHandler handles all incoming messages for the peer. It must be run as a
// goroutine.
func (p *Peer) inHandler() {
+ // Must be first defer (runs last) to catch panics from
+ // everything, including other defers.
+ defer p.recoverFromPanic()
+ defer close(p.inQuit)
+ defer p.Disconnect()
+ defer log.Tracef("Peer input handler done for %s", p)
+
// The timer is stopped when a new message is received and reset after it
// is processed.
idleTimer := time.AfterFunc(idleTimeout, func() {
log.Warnf("Peer %s no answer for %s -- disconnecting", p, idleTimeout)
p.Disconnect()
})
+ defer idleTimer.Stop()
out:
for atomic.LoadInt32(&p.disconnect) == 0 {
@@ -1663,15 +1672,6 @@ out:
// A message was received so reset the idle timer.
idleTimer.Reset(idleTimeout)
}
-
- // Ensure the idle timer is stopped to avoid leaking the resource.
- idleTimer.Stop()
-
- // Ensure connection is closed.
- p.Disconnect()
-
- close(p.inQuit)
- log.Tracef("Peer input handler done for %s", p)
}
// queueHandler handles the queuing of outgoing data for the peer. This runs as
@@ -1988,6 +1988,17 @@ func (p *Peer) Connected() bool {
atomic.LoadInt32(&p.disconnect) == 0
}
+// recoverFromPanic catches any panic that occurs in a peer goroutine,
+// logs the error with a stack trace, and disconnects the peer. This
+// prevents a single malformed message from crashing the entire node.
+func (p *Peer) recoverFromPanic() {
+ if r := recover(); r != nil {
+ log.Errorf("Recovered panic in peer %s: %v\n%s",
+ p, r, debug.Stack())
+ p.Disconnect()
+ }
+}
+
// Disconnect disconnects the peer by closing the connection. Calling this
// function when the peer is already disconnected or in the process of
// disconnecting will have no effect.
@@ -2395,6 +2406,8 @@ func (p *Peer) start() error {
negotiateErr := make(chan error, 1)
go func() {
+ defer p.recoverFromPanic()
+
if p.inbound {
negotiateErr <- p.negotiateInboundProtocol()
} else {
@@ -2412,6 +2425,8 @@ func (p *Peer) start() error {
case <-time.After(negotiateTimeout):
p.Disconnect()
return errors.New("protocol negotiation timeout")
+ case <-p.quit:
+ return errors.New("peer disconnected during negotiation")
}
log.Debugf("Connected to %s", p.Addr())
diff --git a/peer/recover_test.go b/peer/recover_test.go
new file mode 100644
index 0000000..4fbb5bd
--- /dev/null
+++ b/peer/recover_test.go
@@ -0,0 +1,35 @@
+package peer
+
+import (
+ "sync/atomic"
+ "testing"
+)
+
+// TestRecoverFromPanic verifies that recoverFromPanic catches a panic
+// and disconnects the peer instead of crashing the process.
+func TestRecoverFromPanic(t *testing.T) {
+ t.Parallel()
+
+ // Build a minimal Peer with a closed quit channel so
+ // Disconnect() does not block or nil-deref.
+ p := &Peer{
+ quit: make(chan struct{}),
+ }
+
+ // Simulate a goroutine that panics.
+ done := make(chan struct{})
+ go func() {
+ defer close(done)
+ defer p.recoverFromPanic()
+
+ panic("test: crafted message decode")
+ }()
+
+ <-done
+
+ // After recovery, the disconnect flag must be set.
+ if atomic.LoadInt32(&p.disconnect) == 0 {
+ t.Fatal("expected disconnect flag to be set " +
+ "after panic recovery")
+ }
+}
Why this scored 46/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.