Files
buildkit/solver/resolvercache.go
Tonis Tiigi 10872e1722 http: fix resolver cache nil race
Avoid storing typed nil HTTP metadata in the resolver cache after failed
metadata resolution. Also make resolver cache release functions consistently
callable when no error is returned.

Add a client integration test covering concurrent same-URL HTTP sources with
and without checksum when the server returns 401.

Signed-off-by: Tonis Tiigi <tonistiigi@gmail.com>
2026-05-20 12:48:07 -07:00

159 lines
2.9 KiB
Go

package solver
import (
"slices"
"sync"
)
type resolverCache struct {
mu sync.Mutex
locks map[any]*entry
}
var _ ResolverCache = &resolverCache{}
type entry struct {
waiting []chan struct{}
values []any
locked bool
}
func newResolverCache() *resolverCache {
return &resolverCache{locks: make(map[any]*entry)}
}
func (r *resolverCache) Lock(key any) (values []any, release func(any) error, err error) {
r.mu.Lock()
e, ok := r.locks[key]
if !ok {
e = &entry{}
r.locks[key] = e
}
if !e.locked {
e.locked = true
values = slices.Clone(e.values)
r.mu.Unlock()
return values, func(v any) error {
r.mu.Lock()
defer r.mu.Unlock()
if v != nil {
e.values = append(e.values, v)
}
for _, ch := range e.waiting {
close(ch)
}
e.waiting = nil
e.locked = false
if len(e.values) == 0 {
delete(r.locks, key)
}
return nil
}, nil
}
ch := make(chan struct{})
e.waiting = append(e.waiting, ch)
r.mu.Unlock()
<-ch // wait for unlock
r.mu.Lock()
defer r.mu.Unlock()
e2, ok := r.locks[key]
if !ok {
return nil, func(any) error { return nil }, nil // key deleted
}
values = slices.Clone(e2.values)
if e2.locked {
// shouldn't happen, but protect against logic errors
return values, func(any) error { return nil }, nil
}
e2.locked = true
return values, func(v any) error {
r.mu.Lock()
defer r.mu.Unlock()
if v != nil {
e2.values = append(e2.values, v)
}
for _, ch := range e2.waiting {
close(ch)
}
e2.waiting = nil
e2.locked = false
if len(e2.values) == 0 {
delete(r.locks, key)
}
return nil
}, nil
}
// combinedResolverCache returns a ResolverCache that wraps multiple caches.
// Lock() calls each underlying cache in parallel, merges their values, and
// returns a combined release that releases all sublocks.
func combinedResolverCache(rcs []ResolverCache) ResolverCache {
return &combinedCache{rcs: rcs}
}
type combinedCache struct {
rcs []ResolverCache
}
func (c *combinedCache) Lock(key any) (values []any, release func(any) error, err error) {
if len(c.rcs) == 0 {
return nil, func(any) error { return nil }, nil
}
var (
mu sync.Mutex
wg sync.WaitGroup
valuesAll []any
releasers []func(any) error
firstErr error
)
wg.Add(len(c.rcs))
for _, rc := range c.rcs {
go func(rc ResolverCache) {
defer wg.Done()
vals, rel, e := rc.Lock(key)
if e != nil {
mu.Lock()
if firstErr == nil {
firstErr = e
}
mu.Unlock()
return
}
mu.Lock()
valuesAll = append(valuesAll, vals...)
if rel != nil {
releasers = append(releasers, rel)
}
mu.Unlock()
}(rc)
}
wg.Wait()
if firstErr != nil {
// rollback all acquired locks
for _, r := range releasers {
_ = r(nil)
}
return nil, nil, firstErr
}
release = func(v any) error {
var errOnce error
for _, r := range releasers {
if e := r(v); e != nil && errOnce == nil {
errOnce = e
}
}
return errOnce
}
return valuesAll, release, nil
}