Skip to content

Commit 94a6233

Browse files
committed
hugolib: Fix recently introduced data race
This introduces a new thread safe maps.Map type to avoid abusing the exsting maps.Cache for regular map uses. Fixes #14140
1 parent 26f31ff commit 94a6233

File tree

6 files changed

+236
-31
lines changed

6 files changed

+236
-31
lines changed

‎common/maps/cache_test.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Copyright 2024 The Hugo Authors. All rights reserved.
1+
// Copyright 2025 The Hugo Authors. All rights reserved.
22
//
33
// Licensed under the Apache License, Version 2.0 (the "License");
44
// you may not use this file except in compliance with the License.

‎common/maps/map.go‎

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
// Copyright 2025 The Hugo Authors. All rights reserved.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
// http://www.apache.org/licenses/LICENSE-2.0
7+
//
8+
// Unless required by applicable law or agreed to in writing, software
9+
// distributed under the License is distributed on an "AS IS" BASIS,
10+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
// See the License for the specific language governing permissions and
12+
// limitations under the License.
13+
14+
package maps
15+
16+
import (
17+
"iter"
18+
"sync"
19+
)
20+
21+
func NewMap[K comparable, T any]() *Map[K, T] {
22+
return &Map[K, T]{
23+
m: make(map[K]T),
24+
}
25+
}
26+
27+
// Map is a thread safe map backed by a Go map.
28+
type Map[K comparable, T any] struct {
29+
m map[K]T
30+
mu sync.RWMutex
31+
}
32+
33+
// Get gets the value for the given key.
34+
// It returns the zero value of T if the key is not found.
35+
func (m *Map[K, T]) Get(key K) T {
36+
v, _ := m.Lookup(key)
37+
return v
38+
}
39+
40+
// Lookup looks up the given key in the map.
41+
// It returns the value and a boolean indicating whether the key was found.
42+
func (m *Map[K, T]) Lookup(key K) (T, bool) {
43+
m.mu.RLock()
44+
v, found := m.m[key]
45+
m.mu.RUnlock()
46+
return v, found
47+
}
48+
49+
// GetOrCreate gets the value for the given key if it exists, or creates it if not.
50+
func (m *Map[K, T]) GetOrCreate(key K, create func() (T, error)) (T, error) {
51+
v, found := m.Lookup(key)
52+
if found {
53+
return v, nil
54+
}
55+
m.mu.Lock()
56+
defer m.mu.Unlock()
57+
v, found = m.m[key]
58+
if found {
59+
return v, nil
60+
}
61+
v, err := create()
62+
if err != nil {
63+
return v, err
64+
}
65+
m.m[key] = v
66+
return v, nil
67+
}
68+
69+
// Set sets the given key to the given value.
70+
func (m *Map[K, T]) Set(key K, value T) {
71+
m.mu.Lock()
72+
m.m[key] = value
73+
m.mu.Unlock()
74+
}
75+
76+
// WithWriteLock executes the given function with a write lock on the map.
77+
func (m *Map[K, T]) WithWriteLock(f func(m map[K]T)) {
78+
m.mu.Lock()
79+
defer m.mu.Unlock()
80+
f(m.m)
81+
}
82+
83+
// SetIfAbsent sets the given key to the given value if the key does not already exist in the map.
84+
// It returns true if the value was set, false otherwise.
85+
func (m *Map[K, T]) SetIfAbsent(key K, value T) bool {
86+
m.mu.RLock()
87+
if _, found := m.m[key]; !found {
88+
m.mu.RUnlock()
89+
return m.doSetIfAbsent(key, value)
90+
}
91+
m.mu.RUnlock()
92+
return false
93+
}
94+
95+
func (m *Map[K, T]) doSetIfAbsent(key K, value T) bool {
96+
m.mu.Lock()
97+
defer m.mu.Unlock()
98+
if _, found := m.m[key]; !found {
99+
m.m[key] = value
100+
return true
101+
}
102+
return false
103+
}
104+
105+
// All returns an iterator over all key/value pairs in the map.
106+
// A read lock is held during the iteration.
107+
func (m *Map[K, T]) All() iter.Seq2[K, T] {
108+
return func(yield func(K, T) bool) {
109+
m.mu.RLock()
110+
defer m.mu.RUnlock()
111+
for k, v := range m.m {
112+
if !yield(k, v) {
113+
return
114+
}
115+
}
116+
}
117+
}

‎common/maps/map_test.go‎

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
// Copyright 2025 The Hugo Authors. All rights reserved.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
// http://www.apache.org/licenses/LICENSE-2.0
7+
//
8+
// Unless required by applicable law or agreed to in writing, software
9+
// distributed under the License is distributed on an "AS IS" BASIS,
10+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
// See the License for the specific language governing permissions and
12+
// limitations under the License.
13+
14+
package maps
15+
16+
import (
17+
"testing"
18+
19+
qt "github.com/frankban/quicktest"
20+
)
21+
22+
func TestMap(t *testing.T) {
23+
c := qt.New(t)
24+
25+
m := NewMap[string, int]()
26+
27+
m.Set("b", 42)
28+
v, found := m.Lookup("b")
29+
c.Assert(found, qt.Equals, true)
30+
c.Assert(v, qt.Equals, 42)
31+
v = m.Get("b")
32+
c.Assert(v, qt.Equals, 42)
33+
v, found = m.Lookup("c")
34+
c.Assert(found, qt.Equals, false)
35+
c.Assert(v, qt.Equals, 0)
36+
v = m.Get("c")
37+
c.Assert(v, qt.Equals, 0)
38+
v, err := m.GetOrCreate("d", func() (int, error) {
39+
return 100, nil
40+
})
41+
c.Assert(err, qt.IsNil)
42+
c.Assert(v, qt.Equals, 100)
43+
v, found = m.Lookup("d")
44+
c.Assert(found, qt.Equals, true)
45+
c.Assert(v, qt.Equals, 100)
46+
47+
v, err = m.GetOrCreate("d", func() (int, error) {
48+
return 200, nil
49+
})
50+
c.Assert(err, qt.IsNil)
51+
c.Assert(v, qt.Equals, 100)
52+
53+
wasSet := m.SetIfAbsent("e", 300)
54+
c.Assert(wasSet, qt.Equals, true)
55+
v, found = m.Lookup("e")
56+
c.Assert(found, qt.Equals, true)
57+
c.Assert(v, qt.Equals, 300)
58+
59+
wasSet = m.SetIfAbsent("e", 400)
60+
c.Assert(wasSet, qt.Equals, false)
61+
v, found = m.Lookup("e")
62+
c.Assert(found, qt.Equals, true)
63+
c.Assert(v, qt.Equals, 300)
64+
65+
m.WithWriteLock(func(m map[string]int) {
66+
m["f"] = 500
67+
})
68+
v, found = m.Lookup("f")
69+
c.Assert(found, qt.Equals, true)
70+
c.Assert(v, qt.Equals, 500)
71+
}

‎hugolib/content_map_page_assembler.go‎

Lines changed: 30 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -48,15 +48,16 @@ type allPagesAssembler struct {
4848
r para.Runner
4949

5050
assembleChanges *WhatChanged
51-
seenTerms map[term]sitesmatrix.Vectors
52-
droppedPages map[*Site][]string // e.g. drafts, expired, future.
53-
seenRootSections *maps.Cache[string, bool]
54-
seenHome bool
5551
assembleSectionsInParallel bool
5652

57-
// Internal state.
5853
pwRoot *doctree.NodeShiftTreeWalker[contentNode] // walks pages.
5954
rwRoot *doctree.NodeShiftTreeWalker[contentNode] // walks resources.
55+
56+
// Walking state.
57+
seenTerms *maps.Map[term, sitesmatrix.Vectors]
58+
droppedPages *maps.Map[*Site, []string] // e.g. drafts, expired, future.
59+
seenRootSections *maps.Map[string, bool]
60+
seenHome bool // set before we fan out to multiple goroutines.
6061
}
6162

6263
func newAllPagesAssembler(
@@ -74,16 +75,16 @@ func newAllPagesAssembler(
7475
pw := rw.Extend()
7576
pw.Tree = m.treePages
7677

77-
seenRootSections := maps.NewCache[string, bool]()
78+
seenRootSections := maps.NewMap[string, bool]()
7879
seenRootSections.Set("", true) // home.
7980

8081
return &allPagesAssembler{
8182
ctx: ctx,
8283
h: h,
8384
m: m,
8485
assembleChanges: assembleChanges,
85-
seenTerms: map[term]sitesmatrix.Vectors{},
86-
droppedPages: map[*Site][]string{},
86+
seenTerms: maps.NewMap[term, sitesmatrix.Vectors](),
87+
droppedPages: maps.NewMap[*Site, []string](),
8788
seenRootSections: seenRootSections,
8889
assembleSectionsInParallel: true,
8990
pwRoot: pw,
@@ -100,7 +101,7 @@ type sitePagesAssembler struct {
100101

101102
func (a *allPagesAssembler) createAllPages() error {
102103
defer func() {
103-
for site, dropped := range a.droppedPages {
104+
for site, dropped := range a.droppedPages.All() {
104105
for _, s := range dropped {
105106
site.pageMap.treePages.Delete(s)
106107
site.pageMap.resourceTrees.DeletePrefix(paths.AddTrailingSlash(s))
@@ -112,16 +113,16 @@ func (a *allPagesAssembler) createAllPages() error {
112113
defer func() {
113114
if a.h.isRebuild() && a.h.previousSeenTerms != nil {
114115
// Find removed terms.
115-
for t := range a.h.previousSeenTerms {
116-
if _, found := a.seenTerms[t]; !found {
116+
for t := range a.h.previousSeenTerms.All() {
117+
if _, found := a.seenTerms.Lookup(t); !found {
117118
// This term has been removed.
118119
a.pwRoot.Tree.Delete(t.view.pluralTreeKey)
119120
a.rwRoot.Tree.DeletePrefix(t.view.pluralTreeKey + "/")
120121
}
121122
}
122123
// Find new terms.
123-
for t := range a.seenTerms {
124-
if _, found := a.h.previousSeenTerms[t]; !found {
124+
for t := range a.seenTerms.All() {
125+
if _, found := a.h.previousSeenTerms.Lookup(t); !found {
125126
// This term is new.
126127
if n, ok := a.pwRoot.Tree.GetRaw(t.view.pluralTreeKey); ok {
127128
a.assembleChanges.Add(cnh.GetIdentity(n))
@@ -284,7 +285,12 @@ func (a *allPagesAssembler) doCreatePages(prefix string) error {
284285
(&p.m.pageConfig.Build).Disable()
285286
default:
286287
// Skip this page.
287-
a.droppedPages[site] = append(a.droppedPages[site], v.Path())
288+
a.droppedPages.WithWriteLock(
289+
func(m map[*Site][]string) {
290+
m[site] = append(m[site], s)
291+
},
292+
)
293+
288294
drop = true
289295
}
290296
}
@@ -471,13 +477,13 @@ func (a *allPagesAssembler) doCreatePages(prefix string) error {
471477
}
472478

473479
if !isResource && s != "" && !a.seenHome {
480+
a.seenHome = true
474481
var homePages contentNode
475482
homePages, ns, err = transformPages("", newHomePageMetaSource(), cascades)
476483
if err != nil {
477484
return
478485
}
479486
treePages.InsertRaw("", homePages)
480-
a.seenHome = true
481487
}
482488

483489
n2, ns, err = transformPages(s, n, cascades)
@@ -633,17 +639,16 @@ func (a *allPagesAssembler) doCreatePages(prefix string) error {
633639
continue
634640
}
635641
t := term{view: viewName, term: v}
636-
if vectors, found := a.seenTerms[t]; found {
637-
if _, found := vectors[vec]; found {
638-
continue
642+
a.seenTerms.WithWriteLock(func(m map[term]sitesmatrix.Vectors) {
643+
vectors, found := m[t]
644+
if !found {
645+
m[t] = sitesmatrix.Vectors{
646+
vec: struct{}{},
647+
}
648+
return
639649
}
640650
vectors[vec] = struct{}{}
641-
} else {
642-
a.seenTerms[t] = sitesmatrix.Vectors{
643-
vec: struct{}{},
644-
}
645-
}
646-
651+
})
647652
}
648653
}
649654
}
@@ -752,7 +757,7 @@ func (a *allPagesAssembler) doCreatePages(prefix string) error {
752757
// Create missing term pages.
753758
pw.WalkContext.HooksPost2().Push(
754759
func() error {
755-
for k, v := range a.seenTerms {
760+
for k, v := range a.seenTerms.All() {
756761
viewTermKey := "/" + k.view.plural + "/" + k.term
757762

758763
pi := a.h.Conf.PathParser().Parse(files.ComponentFolderContent, viewTermKey+"/_index.md")

‎hugolib/doctree/nodeshifttree.go‎

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -429,9 +429,21 @@ type NodeShiftTreeWalker[T any] struct {
429429
// Extend returns a new NodeShiftTreeWalker with the same configuration
430430
// and the same WalkContext as the original.
431431
// Any local state is reset.
432-
func (r NodeShiftTreeWalker[T]) Extend() *NodeShiftTreeWalker[T] {
433-
r.skipPrefixes = nil
434-
return &r
432+
func (r *NodeShiftTreeWalker[T]) Extend() *NodeShiftTreeWalker[T] {
433+
return &NodeShiftTreeWalker[T]{
434+
Tree: r.Tree,
435+
Transform: r.Transform,
436+
TransformDelayInsert: r.TransformDelayInsert,
437+
Handle: r.Handle,
438+
Prefix: r.Prefix,
439+
IncludeFilter: r.IncludeFilter,
440+
IncludeRawFilter: r.IncludeRawFilter,
441+
LockType: r.LockType,
442+
NoShift: r.NoShift,
443+
Fallback: r.Fallback,
444+
Debug: r.Debug,
445+
WalkContext: r.WalkContext,
446+
}
435447
}
436448

437449
// WithPrefix returns a new NodeShiftTreeWalker with the given prefix.

‎hugolib/hugo_sites.go‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,8 +96,8 @@ type HugoSites struct {
9696
translationKeyPages *maps.SliceCache[page.Page]
9797

9898
pageTrees *pageTrees
99-
previousPageTreesWalkContext *doctree.WalkContext[contentNode] // Set for rebuilds only.
100-
previousSeenTerms map[term]sitesmatrix.Vectors // Set for rebuilds only.
99+
previousPageTreesWalkContext *doctree.WalkContext[contentNode] // Set for rebuilds only.
100+
previousSeenTerms *maps.Map[term, sitesmatrix.Vectors] // Set for rebuilds only.
101101

102102
printUnusedTemplatesInit sync.Once
103103
printPathWarningsInit sync.Once

0 commit comments

Comments
 (0)