Skip to content

Instantly share code, notes, and snippets.

@heiko-braun
Last active May 13, 2026 13:21
Show Gist options
  • Select an option

  • Save heiko-braun/16f3924ab1af0a007abfab942abf2662 to your computer and use it in GitHub Desktop.

Select an option

Save heiko-braun/16f3924ab1af0a007abfab942abf2662 to your computer and use it in GitHub Desktop.

Concurrency Analysis: TOCTOU Race in ServiceDirectory

Executive Summary

This document analyses the com.sixt.service.envoy-control-plane codebase for concurrency issues in the ServiceDirectory, focusing on the pod scraping path. The TOCTOU gap in ExtractFromPod causes Collect call amplification that directly explains the slow-startup-without-Linkerd observation.

Key correction from earlier version: The original analysis claimed that pod addresses could be lost due to the TOCTOU race. This was disproven by tracing the code and confirmed by tests — updateRecordFromPod (line 388-406) always adds the missing address and triggers notification. The actual impact is resource waste and startup latency, not data loss.


1. The TOCTOU Gap in ExtractFromPod

File: internal/directory/service_directory.go:146-165

Code Pattern

sd.mu.RLock()
record := sd.serviceInfoCache[getPodOwnerID(pod)]   // line 147: read
sd.mu.RUnlock()                                      // line 148: unlock

// ... gap: no lock held ...

if record == nil {
    created, err = sd.createRecordFromPod(...)       // line 154: acquires write lock internally
}

sd.mu.Lock()                                         // line 163: re-acquire
updated := created || sd.updateRecordFromPod(...)    // line 164
sd.mu.Unlock()

What Actually Happens (traced step by step)

For two pods (A, B) with the same owner arriving concurrently:

Pod A goroutine:                          Pod B goroutine:
─────────────────                         ─────────────────
147: record = cache[owner] → nil          147: record = cache[owner] → nil
148: RUnlock                              148: RUnlock

154: createRecordFromPod(podA)            154: createRecordFromPod(podB)
  338: Collect(podA_IP:port) [slow]         338: Collect(podB_IP:port) [slow]
  ... finishes first ...                    ... finishes second ...
  358: Lock()
  372: cache[owner] == nil → create it
  376: cache[owner] = {Addr: podA_IP}
  383: return created=true                  358: Lock() [was blocked]
                                            372: cache[owner] != nil → EXISTS
                                            373: return created=false

163: Lock()                               163: Lock()
164: created=true || ...                  164: created=false || updateRecordFromPod(podB)
     → short-circuit, updated=true              395: record = cache[owner] → EXISTS
165: Unlock()                                  401: podB_IP not in Addresses → ADD IT
                                               403: return true → updated=true
167: notify ✓                             165: Unlock()
                                          167: notify ✓

Addresses are NOT lost. The updateRecordFromPod at line 164 always runs for the losing goroutine (since created=false, the || does not short-circuit). It looks up the record (which now exists), finds Pod B's IP is missing, adds it, and returns true. Notification fires.

What IS the Problem: Collect Amplification

The real issue is that ALL N goroutines independently call Collector.Collect (line 338) — a gRPC reflection network call — even though only 1 result is needed per owner. All pods in a ReplicaSet return identical metadata.

This is the root cause of slow startup without Linkerd:

  1. Informer fires OnAdd(isInInitialList=true) for every pod during initial list
  2. Each OnAdd spawns a goroutine → ExtractFromPod(pod, isInitial=true)IsInInit.Add(1)
  3. Due to the TOCTOU gap, ALL N pods for the same owner call createRecordFromPodCollect
  4. Each Collect has a retry policy: 5 attempts, 5s→60s exponential backoff, waitForReady: true
  5. Without Linkerd: raw TCP, no connection reuse → slow connects, UNAVAILABLE retries
  6. With Linkerd: connection pooling + L7 retries → Collect returns fast
  7. Each goroutine holds IsInInit elevated → /ready stays unhealthy until ALL complete

With 100 services × 3 replicas = 300 pods, that's 300 Collect calls instead of 100.

Domain Constraint

All pods in a ReplicaSet run the same container image. Collector.Collect returns identical results from any pod in the same RS. The redundant calls are pure waste.


2. TOCTOU in createOrUpdateRecordWithREST (HTTPRoute path)

File: internal/directory/service_directory.go:471-499

Note: This is a real bug but is independent of the slow-startup problem (which was observed after disabling REST on STAGE — meaning this code path was not exercised).

Code Pattern

sd.mu.RLock()
record := sd.serviceInfoCache[ownerID]    // line 472
sd.mu.RUnlock()                           // line 473

if record == nil {
    updated, err = sd.createRecordFromPod(...)  // line 477: NETWORK CALL in gap
}

sd.mu.Lock()                              // line 486
// ... updateRESTServiceInfo ...          // line 494
sd.mu.Unlock()

What Actually Happens

When two HTTPRoute events for the same owner arrive concurrently:

  • Both see record == nil, both call createRecordFromPod (redundant Collect)
  • Both then call updateRESTServiceInfo sequentially under the write lock
  • UpdateRESTMethods (with routeNum=0) replaces the method list from its own matches only
  • The second writer overwrites the first writer's REST paths

REST endpoints ARE lost in this path (unlike the pod path above). This is confirmed by TestExtractFromHTTPRoute_ConcurrentSameOwner_TOCTOU which fails reliably.


Test Results Summary

Test Result What it proves
RedundantCollect PASS + warning: 10 calls N×Collect amplification per owner
InitBlocking PASS: peak=N IsInInit blocked N× → slow startup
CollectFailureAmplification PASS: N failures Cascading errors when pod not ready
SubscriberNotification PASS Addresses NOT lost (disproves original claim)
RaceDetector PASS Shallow-clone pointer safety
HTTPRoute_TOCTOU FAIL REST endpoint loss (real, separate bug)
AddRemove PASS Add/remove ordering is safe
WithPodWatcher PASS + warning Cross-watcher amplification

Recommended Fix

Use x/sync/singleflight keyed by ownerID wrapping the Collector.Collect call:

import "golang.org/x/sync/singleflight"

// In ServiceDirectory:
var collectGroup singleflight.Group

// In createRecordFromPod, replace direct Collect call:
result, err, _ := sd.collectGroup.Do(ownerID, func() (interface{}, error) {
    return sd.Collector.Collect(ctx, addr)
})

This ensures only 1 Collect call per owner regardless of how many pods arrive concurrently. Benefits:

  • IsInInit duration reduced from N×Collect to 1×Collect
  • Error amplification eliminated (1 failure, not N)
  • Network load reduced by N× during startup
  • Startup time matches the with-Linkerd behavior
@heiko-braun

Copy link
Copy Markdown
Author

That's the key issue I think:

Keyed on pod.UID. Two different pods of the same Deployment have different
UIDs but share an ownerID (controller.UID of the ReplicaSet — see
getPodOwnerID at service_directory.go:660). So this dedupe does not prevent
two ExtractFromPod goroutines from touching serviceInfoCache[ownerID] at
the same time.

@GrigoriyMikhalkin

GrigoriyMikhalkin commented May 12, 2026

Copy link
Copy Markdown

That's the key issue I think:

Keyed on pod.UID. Two different pods of the same Deployment have different
UIDs but share an ownerID (controller.UID of the ReplicaSet — see
getPodOwnerID at service_directory.go:660). So this dedupe does not prevent
two ExtractFromPod goroutines from touching serviceInfoCache[ownerID] at
the same time.

Sure, that's expected.

The locks in Service Directory actually do restrict storage of 2 records for the same owner ID.

The main part of record creating code is here: https://github.com/Sixt/com.sixt.service.envoy-control-plane/blob/main/internal/directory/service_directory.go#L372

It uses double-checked locking pattern.

Also, it doesn't matter for which one Pod the record is created first. Since the stored data expected to be the same.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment