Make SetHeaderNormalizer safe to call while decoding

SetHeaderNormalizer reassigned the normalizer and copied a fresh sync.Map
over the struct info cache while other goroutines were using it. Keep both
in one value swapped atomically instead. Fixes #237.
This commit is contained in:
dualfroz committed 2026-10-02 16:20:31 +02:00
1 parent 391e8f0876
commit f87ec7522c
3 files changed
+60 -8

No files matched your search

+25 -5
View File
@@ -16,6 +16,7 @@ import (
"reflect"
"strings"
"sync"
"sync/atomic"
)
// FailIfUnmatchedStructTags indicates whether it is considered an error when there is an unmatched
@@ -47,17 +48,36 @@ type Normalizer func(string) string
type ErrorHandler func(*csv.ParseError) bool
// normalizeName function initially set to a nop Normalizer.
var normalizeName = DefaultNameNormalizer()
// headerNormalization is the Normalizer in use together with the struct infos
// built with it. SetHeaderNormalizer replaces both at once, so it is safe to
// call while other goroutines are reading or writing CSV.
type headerNormalization struct {
normalize Normalizer
structInfos sync.Map
}
// currentHeaderNormalization holds a *headerNormalization, initially with a nop Normalizer.
var currentHeaderNormalization atomic.Value
func init() {
currentHeaderNormalization.Store(&headerNormalization{normalize: DefaultNameNormalizer()})
}
func loadHeaderNormalization() *headerNormalization {
return currentHeaderNormalization.Load().(*headerNormalization)
}
func normalizeName(name string) string {
return loadHeaderNormalization().normalize(name)
}
// DefaultNameNormalizer is a nop Normalizer.
func DefaultNameNormalizer() Normalizer { return func(s string) string { return s } }
// SetHeaderNormalizer sets the normalizer used to normalize struct and header field names.
func SetHeaderNormalizer(f Normalizer) {
normalizeName = f
// Need to clear the cache hen the header normalizer changes.
structInfoCache = sync.Map{}
// The struct infos are rebuilt from scratch, as they hold normalized names.
currentHeaderNormalization.Store(&headerNormalization{normalize: f})
}
// --------------------------------------------------------------------------
+32
View File
@@ -2,6 +2,8 @@ package gocsv
import (
"errors"
"strings"
"sync"
"testing"
)
@@ -25,6 +27,36 @@ func TestUnmarshalToCallback_ReaderError(t *testing.T) {
}
}
func TestSetHeaderNormalizerWhileUnmarshaling(t *testing.T) {
defer SetHeaderNormalizer(DefaultNameNormalizer())
type row struct {
ID string `csv:"id"`
Name string `csv:"name"`
}
var wg sync.WaitGroup
for i := 0; i < 50; i++ {
wg.Add(2)
go func() {
defer wg.Done()
SetHeaderNormalizer(strings.ToLower)
}()
go func() {
defer wg.Done()
var rows []row
if err := UnmarshalString("id,name\n1,foo\n", &rows); err != nil {
t.Error(err)
return
}
if len(rows) != 1 || rows[0].ID != "1" || rows[0].Name != "foo" {
t.Errorf("unexpected rows: %+v", rows)
}
}()
}
wg.Wait()
}
type errorReader struct{}
func (e *errorReader) Read([]byte) (n int, err error) {
+3 -3
View File
@@ -58,19 +58,19 @@ func removeZeroWidthChars(s string) string {
}, s)
}
var structInfoCache sync.Map
var structMap = make(map[reflect.Type]*structInfo)
var structMapMutex sync.RWMutex
func getStructInfo(rType reflect.Type) *structInfo {
stInfo, ok := structInfoCache.Load(rType)
structInfos := &loadHeaderNormalization().structInfos
stInfo, ok := structInfos.Load(rType)
if ok {
return stInfo.(*structInfo)
}
fieldsList, err := getFieldInfos(rType, []int{}, []string{}, make(map[reflect.Type]bool))
stInfo = &structInfo{Fields: fieldsList, Err: err}
structInfoCache.Store(rType, stInfo)
structInfos.Store(rType, stInfo)
return stInfo.(*structInfo)
}