server, integration: add unit regression tests for peer lifecycle fix
What changed, and why it matters
This commit only adds and improves regression tests for a previously fixed peer lifecycle race condition. It does not change production code, so it cannot introduce a new vulnerability or directly fix one. The tests verify that a prior fix behaves correctly under edge cases such as duplicate verack messages and peers disconnecting before handshake completion.
No security action required. Treat as normal test-only commit. Review the referenced prior peer lifecycle race fix separately if assessing the underlying issue.
Security signals we found
Regression tests for a prior peer lifecycle race fix
No production code changes
Hardened integration test error handling
Tests cover duplicate OnVerAck, verack-before-disconnect, and simultaneous verack/disconnect scenarios
Evidence from the diff
The commit adds three new unit tests in server_test.go (TestOnVerAckDoubleCall, TestPeerLifecycleOrdering, TestPeerLifecycleSimultaneousReady) and hardens integration tests in integration/sync_race_test.go. The tests exercise the peer add/done lifecycle channel ordering and a double OnVerAck guard. No production logic in server.go or peer handling is modified. The changes are purely test coverage and error-checking improvements for an earlier peer lifecycle race fix.
Changed components
server_test.gointegration/sync_race_test.goInspect captured patch +197 / −40
diff --git a/integration/sync_race_test.go b/integration/sync_race_test.go
index b313599..9d1bbce 100644
--- a/integration/sync_race_test.go
+++ b/integration/sync_race_test.go
@@ -110,17 +110,16 @@ func TestSyncManagerRaceCorruption(t *testing.T) {
deadline := time.Now().Add(syncRaceRunDuration)
iter := 0
var done int
- doneCh := make(chan struct{}, syncRaceConcurrency*2)
+ errCh := make(chan error, syncRaceConcurrency*2)
for time.Now().Before(deadline) && iter < syncRaceIterations {
for i := 0; i < syncRaceConcurrency; i++ {
go func() {
- _ = fakePeerConn(nodeAddr)
- doneCh <- struct{}{}
+ errCh <- fakePeerConn(nodeAddr)
}()
}
for i := 0; i < syncRaceConcurrency; i++ {
- <-doneCh
+ require.NoError(t, <-errCh)
done++
}
iter += syncRaceConcurrency
@@ -160,6 +159,45 @@ func TestSyncManagerRaceCorruption(t *testing.T) {
done, heightBefore, heightAfter)
}
+// dialAndSendVersion connects to nodeAddr and sends a version
+// message, returning the open connection. The caller is
+// responsible for closing it.
+func dialAndSendVersion(
+ t *testing.T, nodeAddr string,
+) net.Conn {
+
+ t.Helper()
+
+ conn, err := net.DialTimeout("tcp", nodeAddr, 5*time.Second)
+ require.NoError(t, err)
+
+ _ = conn.SetDeadline(time.Now().Add(5 * time.Second))
+
+ nodeTCP, err := net.ResolveTCPAddr("tcp", nodeAddr)
+ require.NoError(t, err)
+
+ you := wire.NewNetAddress(
+ nodeTCP, wire.SFNodeNetwork|wire.SFNodeWitness,
+ )
+ me := wire.NewNetAddress(
+ &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0},
+ wire.SFNodeNetwork|wire.SFNodeWitness,
+ )
+ you.Timestamp = time.Time{}
+ me.Timestamp = time.Time{}
+
+ nonce := uint64(rand.Int63())
+ msgVersion := wire.NewMsgVersion(me, you, nonce, 0)
+ msgVersion.Services = wire.SFNodeNetwork | wire.SFNodeWitness
+
+ err = wire.WriteMessage(
+ conn, msgVersion, wire.ProtocolVersion, wire.SimNet,
+ )
+ require.NoError(t, err)
+
+ return conn
+}
+
// TestPreVerackDisconnect verifies that a peer disconnecting
// before completing the version/verack handshake does not corrupt
// the sync manager state. In this case only a peerDone event is
@@ -174,41 +212,12 @@ func TestPreVerackDisconnect(t *testing.T) {
nodeAddr := harness.P2PAddress()
// Connect and send version, then disconnect before receiving or
- // sending verack. This produces a peerDone without a preceding
- // peerAdd in the lifecycle channel.
- for i := 0; i < 50; i++ {
- conn, err := net.DialTimeout("tcp", nodeAddr, 5*time.Second)
- if err != nil {
- continue
- }
-
- _ = conn.SetDeadline(time.Now().Add(5 * time.Second))
-
- nodeTCP, err := net.ResolveTCPAddr("tcp", nodeAddr)
- if err != nil {
- conn.Close()
- continue
- }
+ // sending verack. This is expected to produce a peerDone without
+ // a preceding peerAdd in the lifecycle channel.
+ const preVerackAttempts = 50
- you := wire.NewNetAddress(
- nodeTCP, wire.SFNodeNetwork|wire.SFNodeWitness,
- )
- me := wire.NewNetAddress(
- &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0},
- wire.SFNodeNetwork|wire.SFNodeWitness,
- )
- you.Timestamp = time.Time{}
- me.Timestamp = time.Time{}
-
- nonce := uint64(rand.Int63())
- msgVersion := wire.NewMsgVersion(me, you, nonce, 0)
- msgVersion.Services = wire.SFNodeNetwork | wire.SFNodeWitness
-
- _ = wire.WriteMessage(
- conn, msgVersion, wire.ProtocolVersion, wire.SimNet,
- )
-
- // Close immediately without completing the handshake.
+ for i := 0; i < preVerackAttempts; i++ {
+ conn := dialAndSendVersion(t, nodeAddr)
conn.Close()
}
@@ -238,6 +247,7 @@ func TestPreVerackDisconnect(t *testing.T) {
require.GreaterOrEqual(t, heightAfter, heightBefore+3,
"node failed to sync after pre-verack disconnects")
- t.Logf("node healthy after 50 pre-verack disconnects (height %d -> %d)",
- heightBefore, heightAfter)
+ t.Logf("node healthy after %d pre-verack disconnects "+
+ "(height %d -> %d)",
+ preVerackAttempts, heightBefore, heightAfter)
}
diff --git a/server_test.go b/server_test.go
new file mode 100644
index 0000000..2b07552
--- /dev/null
+++ b/server_test.go
@@ -0,0 +1,147 @@
+package main
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/btcsuite/btcd/chaincfg"
+ "github.com/btcsuite/btcd/peer"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestMain(m *testing.M) {
+ // logRotator must be non-nil or any log write (e.g. from
+ // OnVerAck's double-call guard) panics via logWriter.Write.
+ initLogRotator(filepath.Join(os.TempDir(), "btcd-server-test.log"))
+ os.Exit(m.Run())
+}
+
+// newTestServerPeer creates a minimal serverPeer suitable for unit
+// tests that exercise the peer lifecycle logic without starting the
+// full server. The returned server's peerLifecycle channel is
+// buffered so the handler never blocks during tests.
+func newTestServerPeer(t *testing.T) (*server, *serverPeer) {
+ t.Helper()
+
+ s := &server{
+ peerLifecycle: make(chan peerLifecycleEvent, 10),
+ }
+ sp := newServerPeer(s, false)
+ sp.Peer = peer.NewInboundPeer(&peer.Config{
+ ChainParams: &chaincfg.SimNetParams,
+ })
+
+ return s, sp
+}
+
+// recvLifecycleEvent reads a single event from the peerLifecycle
+// channel or fails the test after a timeout.
+func recvLifecycleEvent(
+ t *testing.T, ch <-chan peerLifecycleEvent,
+) peerLifecycleEvent {
+
+ t.Helper()
+
+ select {
+ case ev := <-ch:
+ return ev
+ case <-time.After(5 * time.Second):
+ t.Fatal("timed out waiting for peerLifecycleEvent")
+ return peerLifecycleEvent{}
+ }
+}
+
+// TestOnVerAckDoubleCall verifies that calling OnVerAck twice on
+// the same serverPeer does not panic. The double-call guard must
+// log an error and leave verAckCh closed.
+func TestOnVerAckDoubleCall(t *testing.T) {
+ t.Parallel()
+
+ _, sp := newTestServerPeer(t)
+
+ sp.OnVerAck(nil, nil)
+
+ select {
+ case <-sp.verAckCh:
+ default:
+ t.Fatal("verAckCh should be closed after first OnVerAck call")
+ }
+
+ require.NotPanics(t, func() {
+ sp.OnVerAck(nil, nil)
+ })
+
+ select {
+ case <-sp.verAckCh:
+ default:
+ t.Fatal("verAckCh should still be closed after second OnVerAck call")
+ }
+}
+
+// TestPeerLifecycleOrdering verifies that when verack arrives before
+// disconnect, peerLifecycleHandler emits peerAdd followed by peerDone
+// on the peerLifecycle channel -- never out of order.
+func TestPeerLifecycleOrdering(t *testing.T) {
+ t.Parallel()
+
+ s, sp := newTestServerPeer(t)
+
+ // Simulate verack received before the handler starts.
+ close(sp.verAckCh)
+
+ go s.peerLifecycleHandler(sp)
+
+ first := recvLifecycleEvent(t, s.peerLifecycle)
+ require.Equal(t, peerAdd, first.action,
+ "first lifecycle event must be peerAdd")
+ require.Equal(t, sp, first.sp)
+
+ // Trigger disconnect after peerAdd is observed.
+ sp.Peer.Disconnect()
+
+ second := recvLifecycleEvent(t, s.peerLifecycle)
+ require.Equal(t, peerDone, second.action,
+ "second lifecycle event must be peerDone")
+ require.Equal(t, sp, second.sp)
+}
+
+// TestPeerLifecycleSimultaneousReady verifies that when both verAckCh
+// and Peer.Done() are ready before the handler runs, the system stays
+// stable: peerDone is always emitted, and if peerAdd is emitted it
+// precedes peerDone. Go's select is nondeterministic so peerAdd may
+// be skipped -- both outcomes are valid per documented behavior.
+func TestPeerLifecycleSimultaneousReady(t *testing.T) {
+ t.Parallel()
+
+ const iterations = 100
+ var addEmitted int
+
+ for i := 0; i < iterations; i++ {
+ s, sp := newTestServerPeer(t)
+
+ close(sp.verAckCh)
+ sp.Peer.Disconnect()
+
+ go s.peerLifecycleHandler(sp)
+
+ first := recvLifecycleEvent(t, s.peerLifecycle)
+ if first.action == peerAdd {
+ addEmitted++
+ second := recvLifecycleEvent(t, s.peerLifecycle)
+ assert.Equal(t, peerDone, second.action,
+ "iteration %d: peerAdd must be "+
+ "followed by peerDone", i)
+ } else {
+ assert.Equal(t, peerDone, first.action,
+ "iteration %d: sole event must "+
+ "be peerDone", i)
+ }
+ }
+
+ t.Logf("peerAdd emitted in %d/%d iterations "+
+ "(both outcomes are valid per documented behavior)",
+ addEmitted, iterations)
+}
Why this scored 12/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.