Skip to content

Commit cf6f57d

Browse files
authored
handshake: propagate leader load/obtain outcome to all waiters (#387)
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 cf6f57d

2 files changed

Lines changed: 131 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: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,16 @@
1414
package certmagic
1515

1616
import (
17+
"context"
1718
"crypto/tls"
1819
"crypto/x509"
20+
"errors"
1921
"net"
22+
"runtime"
23+
"strings"
24+
"sync/atomic"
2025
"testing"
26+
"time"
2127
)
2228

2329
func TestGetCertificate(t *testing.T) {
@@ -106,3 +112,118 @@ func TestGetCertificate(t *testing.T) {
106112
t.Errorf("Expected IP cert, got: %v", cert)
107113
}
108114
}
115+
116+
// TestGetCertDuringHandshakeWaiterErrorPropagation verifies that when the
117+
// "leader" goroutine loading/obtaining a certificate for a name fails (e.g.
118+
// an on-demand permission denial), the failure is propagated directly to all
119+
// goroutines waiting on it. Without propagation, each waiter recursively
120+
// re-enters the wait queue and eventually performs its own load/obtain
121+
// attempt; under sustained handshake load for uncertificated names those
122+
// recursing goroutines accumulate without bound, each pinning live TLS
123+
// handshake state.
124+
func TestGetCertDuringHandshakeWaiterErrorPropagation(t *testing.T) {
125+
const serverName = "denied.example.com"
126+
127+
c := &Cache{
128+
cache: make(map[string]Certificate),
129+
cacheIndex: make(map[string][]string),
130+
logger: defaultTestLogger,
131+
}
132+
133+
denyErr := errors.New("on-demand permission denied")
134+
release := make(chan struct{})
135+
var decisionCalls atomic.Int32
136+
cfg := &Config{
137+
Logger: defaultTestLogger,
138+
certCache: c,
139+
OnDemand: &OnDemandConfig{
140+
DecisionFunc: func(ctx context.Context, name string) error {
141+
decisionCalls.Add(1)
142+
<-release // hold the leader so waiters queue behind it
143+
return denyErr
144+
},
145+
},
146+
}
147+
148+
l, _ := net.Listen("tcp", "127.0.0.1:0")
149+
defer l.Close()
150+
conn, err := net.Dial("tcp", l.Addr().String())
151+
if err != nil {
152+
t.Fatalf("failed to create test connection: %v", err)
153+
}
154+
defer conn.Close()
155+
156+
hello := &tls.ClientHelloInfo{ServerName: serverName, Conn: conn}
157+
ctx := context.Background()
158+
159+
const numHandshakes = 5
160+
results := make(chan error, numHandshakes)
161+
162+
// leader: takes the load slot for serverName and blocks in DecisionFunc
163+
go func() {
164+
_, err := cfg.getCertDuringHandshake(ctx, hello, true)
165+
results <- err
166+
}()
167+
168+
// wait until the leader has registered itself in the wait queue
169+
for {
170+
certLoadWaitChansMu.Lock()
171+
_, registered := certLoadWaitChans[serverName]
172+
certLoadWaitChansMu.Unlock()
173+
if registered {
174+
break
175+
}
176+
time.Sleep(time.Millisecond)
177+
}
178+
179+
// waiters: queue behind the leader
180+
for i := 1; i < numHandshakes; i++ {
181+
go func() {
182+
_, err := cfg.getCertDuringHandshake(ctx, hello, true)
183+
results <- err
184+
}()
185+
}
186+
187+
// Wait until every handshake goroutine is PARKED — the waiters blocked
188+
// in the wait-queue select and the leader blocked on the release
189+
// channel in DecisionFunc — before releasing the leader. Merely being
190+
// inside getCertDuringHandshake isn't enough: a goroutine still in the
191+
// preamble would miss the wait queue once the leader's cleanup runs
192+
// and become a second leader.
193+
parked := func() (selecting, receiving int) {
194+
buf := make([]byte, 1<<20)
195+
stacks := string(buf[:runtime.Stack(buf, true)])
196+
for _, g := range strings.Split(stacks, "\n\n") {
197+
if !strings.Contains(g, ").getCertDuringHandshake(") {
198+
continue
199+
}
200+
if strings.Contains(g, "[select]") {
201+
selecting++
202+
} else if strings.Contains(g, "[chan receive]") {
203+
receiving++
204+
}
205+
}
206+
return
207+
}
208+
for {
209+
selecting, receiving := parked()
210+
if selecting >= numHandshakes-1 && receiving >= 1 {
211+
break
212+
}
213+
time.Sleep(time.Millisecond)
214+
}
215+
close(release) // leader now fails with denyErr
216+
217+
for i := 0; i < numHandshakes; i++ {
218+
if err := <-results; err == nil || !strings.Contains(err.Error(), denyErr.Error()) {
219+
t.Errorf("expected deny error, got: %v", err)
220+
}
221+
}
222+
223+
// The decision func must have been called exactly once: only the leader
224+
// reaches the permission check; waiters receive its propagated error
225+
// rather than recursing into their own load/obtain attempts.
226+
if got := decisionCalls.Load(); got != 1 {
227+
t.Errorf("expected exactly 1 decision-func call (leader only), got %d", got)
228+
}
229+
}

0 commit comments

Comments
 (0)