Skip to content

Commit 7898be0

Browse files
committed
handshake: propagate leader load/obtain outcome to all waiters
When the goroutine leading a certificate load/obtain for a name fails (e.g. an on-demand permission denial) or succeeds but the cert is evicted from the cache before waiters re-look it up, waiters recursively re-enter the wait queue instead of receiving the leader's outcome. Under sustained handshake load for uncertificated names, these recursing goroutines accumulate without bound (observed at 30k+ goroutines, recursion depth 48), each pinning live TLS handshake state — multi-GB memory growth within minutes. Propagate the leader's returned certificate or error to all waiters via the existing certLoadWaiter fields (previously populated only on the external-cert-manager path). Waiters now return the leader's outcome directly; the recursive fallback remains only for the rare empty-cert+nil-error case.
1 parent 7475593 commit 7898be0

2 files changed

Lines changed: 111 additions & 6 deletions

File tree

handshake.go

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -269,7 +269,7 @@ func DefaultCertificateSelector(hello *tls.ClientHelloInfo, choices []Certificat
269269
// An error will be returned if and only if no certificate is available.
270270
//
271271
// This function is safe for concurrent use.
272-
func (cfg *Config) getCertDuringHandshake(ctx context.Context, hello *tls.ClientHelloInfo, loadOrObtainIfNecessary bool) (Certificate, error) {
272+
func (cfg *Config) getCertDuringHandshake(ctx context.Context, hello *tls.ClientHelloInfo, loadOrObtainIfNecessary bool) (retCert Certificate, retErr error) {
273273
logger := logWithRemote(cfg.Logger.Named("handshake"), hello)
274274

275275
// First check our in-memory cache to see if we've already loaded it
@@ -314,11 +314,9 @@ func (cfg *Config) getCertDuringHandshake(ctx context.Context, hello *tls.Client
314314
timeout.Stop()
315315
}
316316

317-
// If the leader got a result from an external cert manager, use it
318-
// directly — these certs are not added to the cache, so a recursive
319-
// cache lookup would miss. For cached certs (on-demand, managed),
320-
// the waiter result will be empty and we fall through to the
321-
// original recursive lookup.
317+
// Use the leader's outcome directly; fall back to the recursive
318+
// cache lookup only if it finished with neither a cert nor an
319+
// error.
322320
if !waiter.cert.Empty() || waiter.err != nil {
323321
return waiter.cert, waiter.err
324322
}
@@ -334,6 +332,12 @@ func (cfg *Config) getCertDuringHandshake(ctx context.Context, hello *tls.Client
334332
// unblock others and clean up when we're done
335333
defer func() {
336334
certLoadWaitChansMu.Lock()
335+
// Propagate our outcome to waiters so they don't recursively
336+
// re-enter the wait queue and pile up; don't overwrite a result
337+
// already set by the external-manager path.
338+
if waiter.cert.Empty() && waiter.err == nil {
339+
waiter.cert, waiter.err = retCert, retErr
340+
}
337341
close(waiter.done)
338342
delete(certLoadWaitChans, name)
339343
certLoadWaitChansMu.Unlock()

handshake_test.go

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,15 @@
1414
package certmagic
1515

1616
import (
17+
"context"
1718
"crypto/tls"
1819
"crypto/x509"
20+
"errors"
1921
"net"
22+
"strings"
23+
"sync/atomic"
2024
"testing"
25+
"time"
2126
)
2227

2328
func TestGetCertificate(t *testing.T) {
@@ -106,3 +111,99 @@ func TestGetCertificate(t *testing.T) {
106111
t.Errorf("Expected IP cert, got: %v", cert)
107112
}
108113
}
114+
115+
// TestGetCertDuringHandshakeWaiterErrorPropagation verifies that when the
116+
// "leader" goroutine loading/obtaining a certificate for a name fails (e.g.
117+
// an on-demand permission denial), the failure is propagated directly to all
118+
// goroutines waiting on it. Without propagation, each waiter recursively
119+
// re-enters the wait queue and eventually performs its own load/obtain
120+
// attempt; under sustained handshake load for uncertificated names those
121+
// recursing goroutines accumulate without bound, each pinning live TLS
122+
// handshake state.
123+
func TestGetCertDuringHandshakeWaiterErrorPropagation(t *testing.T) {
124+
const serverName = "denied.example.com"
125+
126+
c := &Cache{
127+
cache: make(map[string]Certificate),
128+
cacheIndex: make(map[string][]string),
129+
logger: defaultTestLogger,
130+
}
131+
132+
denyErr := errors.New("on-demand permission denied")
133+
release := make(chan struct{})
134+
var decisionCalls atomic.Int32
135+
cfg := &Config{
136+
Logger: defaultTestLogger,
137+
certCache: c,
138+
OnDemand: &OnDemandConfig{
139+
DecisionFunc: func(ctx context.Context, name string) error {
140+
decisionCalls.Add(1)
141+
<-release // hold the leader so waiters queue behind it
142+
return denyErr
143+
},
144+
},
145+
}
146+
147+
l, _ := net.Listen("tcp", "127.0.0.1:0")
148+
defer l.Close()
149+
conn, err := net.Dial("tcp", l.Addr().String())
150+
if err != nil {
151+
t.Fatalf("failed to create test connection: %v", err)
152+
}
153+
defer conn.Close()
154+
155+
hello := &tls.ClientHelloInfo{ServerName: serverName, Conn: conn}
156+
ctx := context.Background()
157+
158+
const numHandshakes = 5
159+
results := make(chan error, numHandshakes)
160+
161+
// leader: takes the load slot for serverName and blocks in DecisionFunc
162+
go func() {
163+
_, err := cfg.getCertDuringHandshake(ctx, hello, true)
164+
results <- err
165+
}()
166+
167+
// wait until the leader has registered itself in the wait queue
168+
deadline := time.Now().Add(5 * time.Second)
169+
for {
170+
certLoadWaitChansMu.Lock()
171+
_, registered := certLoadWaitChans[serverName]
172+
certLoadWaitChansMu.Unlock()
173+
if registered {
174+
break
175+
}
176+
if time.Now().After(deadline) {
177+
t.Fatal("leader never registered in certLoadWaitChans")
178+
}
179+
time.Sleep(time.Millisecond)
180+
}
181+
182+
// waiters: queue behind the leader
183+
for i := 1; i < numHandshakes; i++ {
184+
go func() {
185+
_, err := cfg.getCertDuringHandshake(ctx, hello, true)
186+
results <- err
187+
}()
188+
}
189+
time.Sleep(50 * time.Millisecond) // let waiters enter the select
190+
close(release) // leader now fails with denyErr
191+
192+
for i := 0; i < numHandshakes; i++ {
193+
select {
194+
case err := <-results:
195+
if err == nil || !strings.Contains(err.Error(), denyErr.Error()) {
196+
t.Errorf("expected deny error, got: %v", err)
197+
}
198+
case <-time.After(10 * time.Second):
199+
t.Fatalf("timed out waiting for handshake result %d (waiters not unblocked by leader outcome)", i)
200+
}
201+
}
202+
203+
// The decision func must have been called exactly once: only the leader
204+
// reaches the permission check; waiters receive its propagated error
205+
// rather than recursing into their own load/obtain attempts.
206+
if got := decisionCalls.Load(); got != 1 {
207+
t.Errorf("expected exactly 1 decision-func call (leader only), got %d", got)
208+
}
209+
}

0 commit comments

Comments
 (0)