Atlas › Test

allows_expected_retained_objects

Exact test identity: go.temporal.io/server/common/testing/objectleak/TestObjectLeak_Check/allows_expected_retained_objects

Package
go.temporal.io/server/common/testing/objectleak
Suite / test hierarchy
TestObjectLeak_Check/allows_expected_retained_objects
Test
allows_expected_retained_objects
Introduced at
allows_expected_retained_objects Frontier kind: Test frontier
Covered ranges
76
Covered lines
321
Covered files
5

Covered source

Expand a file to inspect source; the > gutter marks covered lines.

go.temporal.io/server/common/testing/objectleak/report.go 147 covered LOC · 28 ranges

Open complete file

30 }
31
32 > func newReport(objects []trackedObject, trackedRoots int, expected patterns, pruneTypes patterns) report { report.go
33 > report := report{
34 > trackedRoots: trackedRoots,
35 > }
36 >
37 > // Matching mutates pattern.matched for stale-expected pattern detection.
38 > activeExpected := slices.Clone(expected)
39 >
40 > type groupKey struct {
41 > path string
42 > typeName string
43 > expected bool
44 > }
45 > groupByKey := make(map[groupKey]*objectGroup)
46 > retainedAddresses := make(map[uintptr]struct{})
47 > expectedAddresses := make(map[uintptr]struct{})
48 > unexpectedAddresses := make(map[uintptr]struct{})
49 >
50 > // Classify each retained object and fold equivalent normalized paths into
51 > // a single report row.
52 > for _, obj := range objects {
53 > if obj.collected.Load() {
54 continue
55 }
56
57 > expectedBy := activeExpected.matchObject(obj) report.go
58 > report.totalRetainedPaths++
59 > retainedAddresses[obj.addr] = struct{}{}
60 > expected := len(expectedBy) > 0
61 > if expected {
62 > report.expectedRetainedPaths++ report.go
63 > expectedAddresses[obj.addr] = struct{}{}
64 > } else { report.go
65 report.unexpectedRetainedPaths++
66 unexpectedAddresses[obj.addr] = struct{}{}
67 }
68
69 > key := groupKey{ report.go
70 > path: obj.path.normalized(),
71 > typeName: obj.typeName,
72 > expected: expected,
73 > }
74 > group := groupByKey[key]
75 > if group == nil {
76 > group = &objectGroup{
77 > path: key.path,
78 > typeName: key.typeName,
79 > addresses: make(map[uintptr]struct{}),
80 > }
81 > groupByKey[key] = group
82 > }
83 > group.paths++
84 > group.addresses[obj.addr] = struct{}{}
85 }
86 > report.totalRetainedObjects = len(retainedAddresses) report.go
87 > report.expectedRetainedObjects = len(expectedAddresses)
88 > report.unexpectedRetainedObjects = len(unexpectedAddresses)
89 >
90 > // Expected patterns that never matched any tracked object are stale and
91 > // should be removed with the fix that made them unnecessary.
92 > report.unmatchedExpected = activeExpected.unmatched()
93 > report.unmatchedPrunes = pruneTypes.unmatched()
94 >
95 > // Keep report output stable across map iteration order and repeated runs.
96 > for key, group := range groupByKey {
97 > if key.expected {
98 > report.expectedObjects = append(report.expectedObjects, *group) report.go
99 > } else { report.go
100 report.unexpectedObjects = append(report.unexpectedObjects, *group)
101 }
102 }
103 > sortGroups := func(groups []objectGroup) { report.go
104 > slices.SortFunc(groups, func(a objectGroup, b objectGroup) int {
105 > if c := cmp.Compare(b.objectCount(), a.objectCount()); c != 0 {
106 > return c
107 > }
108 > if c := cmp.Compare(b.paths, a.paths); c != 0 { report.go
109 return c
110 }
111 > if c := cmp.Compare(a.path, b.path); c != 0 { report.go
112 > return c
113 > }
114 return cmp.Compare(a.typeName, b.typeName)
115 })
116 }
117 > sortGroups(report.unexpectedObjects) report.go
118 > sortGroups(report.expectedObjects)
119 > slices.Sort(report.unmatchedExpected)
120 > slices.Sort(report.unmatchedPrunes)
121 > return report
122 }
123
124 > func (r report) failures() error { report.go
125 > var failures []error
126 > if len(r.unexpectedObjects) > 0 {
127 failures = append(failures, errors.New("unexpected retained objects"))
128 }
129 > if len(r.unmatchedExpected) > 0 { report.go
130 failures = append(failures, errors.New("stale expected patterns"))
131 }
132 > if len(r.unmatchedPrunes) > 0 { report.go
133 failures = append(failures, errors.New("stale prunes"))
134 }
135 > return errors.Join(failures...) report.go
136 }
137
138 > func (r report) totals() [3]int { report.go
139 > return [3]int{
140 > r.totalRetainedObjects,
141 > r.unexpectedRetainedObjects,
142 > len(r.unmatchedExpected) + len(r.unmatchedPrunes),
143 > }
144 > }
145
146 > func (r report) string() string { report.go
147 > var out strings.Builder
148 > r.writeSummary(&out)
149 >
150 > writeGroups := func(title string, groups []objectGroup) {
151 > fmt.Fprintf(&out, "%s:\n", title)
152 > if len(groups) == 0 {
153 > out.WriteString(" none\n")
154 > return
155 > }
156 > for _, group := range groups {
157 > fmt.Fprintf(&out, " %s: %s\n", group.counts(), group.name())
158 > }
159 }
160 > out.WriteByte('\n') report.go
161 > writeGroups("unexpected retained objects", r.unexpectedObjects)
162 > out.WriteByte('\n')
163 > writeGroups("expected retained objects", r.expectedObjects)
164 >
165 > if len(r.unmatchedExpected) > 0 {
166 out.WriteString("\nstale expected patterns:\n")
167 }
168 > for _, pattern := range r.unmatchedExpected { report.go
169 fmt.Fprintf(&out, " %s\n", pattern)
170 }
171
172 > if len(r.unmatchedPrunes) > 0 { report.go
173 out.WriteString("\nstale prunes:\n")
174 }
175 > for _, pattern := range r.unmatchedPrunes { report.go
176 fmt.Fprintf(&out, " %s\n", pattern)
177 }
178 > return strings.TrimSuffix(out.String(), "\n") report.go
179 }
180
181 > func (r report) writeSummary(out *strings.Builder) { report.go
182 > out.WriteString("object leak report\n\n")
183 > fmt.Fprintf(out, "tracked root objects: %d\n", r.trackedRoots)
184 > fmt.Fprintf(
185 > out,
186 > "retained paths: %d total, %d expected, %d unexpected\n",
187 > r.totalRetainedPaths,
188 > r.expectedRetainedPaths,
189 > r.unexpectedRetainedPaths,
190 > )
191 > fmt.Fprintf(
192 > out,
193 > "retained objects: %d total, %d expected, %d unexpected\n",
194 > r.totalRetainedObjects,
195 > r.expectedRetainedObjects,
196 > r.unexpectedRetainedObjects,
197 > )
198 > }
199
200 > func (g objectGroup) name() string { report.go
201 > if g.path == "" {
202 > return g.typeName
203 > }
204 > return fmt.Sprintf("%s (%s)", g.path, g.typeName)
205 }
206
207 > func (g objectGroup) objectCount() int { report.go
208 > return len(g.addresses)
209 > }
210
211 > func (g objectGroup) counts() string { report.go
212 > objects := g.objectCount()
213 > if g.paths == objects {
214 > return formatCount(objects, "object")
215 > }
216 > return fmt.Sprintf("%s, %s", formatCount(g.paths, "path"), formatCount(objects, "object"))
217 }
218
219 > func formatCount(count int, label string) string { report.go
220 > if count == 1 {
221 > return fmt.Sprintf("1 %s", label)
222 > }
223 > return fmt.Sprintf("%d %ss", count, label)
224 }
go.temporal.io/server/common/testing/objectleak/leakcheck.go 65 covered LOC · 11 ranges

Open complete file

30 // WithExpected marks retained objects whose reflected path or type name matches
31 // pattern as expected. A trailing '*' matches any suffix.
32 > func WithExpected(pattern string) Option { leakcheck.go
33 > return func(t *ObjectLeakCheck) error {
34 > t.expected = append(t.expected, newPattern(pattern)) leakcheck.go
35 > return nil
36 > }
37 }
38
40 // matches pattern. Named types match their package-qualified name. A trailing
41 // '*' matches any suffix.
42 > func WithPruneType(pattern string) Option { leakcheck.go
43 > return func(t *ObjectLeakCheck) error {
44 t.pruneTypes = append(t.pruneTypes, newPattern(pattern))
45 return nil
49 // WithGCSettleTimeout sets the maximum time Check spends forcing GC and waiting
50 // for retained-object counts to settle.
51 > func WithGCSettleTimeout(timeout time.Duration) Option { leakcheck.go
52 > return func(t *ObjectLeakCheck) error {
53 > if timeout <= 0 {
54 return errors.New("GC settle timeout must be positive")
55 }
56 > t.gcSettleTimeout = timeout leakcheck.go
57 > return nil
58 }
59 }
60
61 // NewObjectLeakCheck creates an object leak checker.
62 > func NewObjectLeakCheck(opts ...Option) (ObjectLeakCheck, error) { leakcheck.go
63 > t := ObjectLeakCheck{
64 > gcSettleTimeout: defaultGCSettleTimeout,
65 > }
66 > for _, opt := range opts {
67 > if err := opt(&t); err != nil {
68 return ObjectLeakCheck{}, err
69 }
70 }
71 > return t, nil leakcheck.go
72 }
73
74 // Track walks all values reachable from root and tracks pointer objects it finds.
75 > func (t *ObjectLeakCheck) Track(root any) { leakcheck.go
76 > walker := newObjectWalker(t.pruneTypes)
77 > walker.track(root)
78 > t.roots++
79 > t.objects = append(t.objects, walker.objects...)
80 > }
81
82 // Check settles GC, then returns a full retained-object report and an error for
84 // longer match any tracked object, or prune rules that did not match during
85 // tracking.
86 > func (t *ObjectLeakCheck) Check() (string, error) { leakcheck.go
87 > start := time.Now()
88 > minWaitDeadline := start.Add(checkGCMinWait)
89 > deadline := start.Add(t.gcSettleTimeout)
90 > settledDeadline := minWaitDeadline
91 >
92 > var lastTotals [3]int
93 > var haveLastTotals bool
94 > var report report
95 > var err error
96 > for {
97 > // AddCleanup callbacks run after GC proves tracked objects are
98 > // unreachable. Run a small burst and yield so callbacks can mark tracked
99 > // objects before the next report snapshot.
100 > for range checkGCBurst {
101 > //nolint:revive // This checker intentionally forces GC to drive AddCleanup callbacks.
102 > runtime.GC()
103 > runtime.Gosched()
104 > }
105 > runtimedebug.FreeOSMemory()
106 > runtime.Gosched()
107 >
108 > report = newReport(t.objects, t.roots, t.expected, t.pruneTypes)
109 > err = report.failures()
110 > now := time.Now()
111 >
112 > // Wait for the report totals to stop changing instead of returning on
113 > // the first passing report. This lets delayed cleanup callbacks remove
114 > // both unexpected objects and now-stale expected patterns before we decide.
115 > if totals := report.totals(); !haveLastTotals || totals != lastTotals {
116 > lastTotals = totals
117 > haveLastTotals = true
118 > settledDeadline = now.Add(checkGCQuiet)
119 > if minWaitDeadline.After(settledDeadline) {
120 > settledDeadline = minWaitDeadline
121 > }
122 }
123
125 // quiet window then handles normal cleanup latency; the timeout bounds a
126 // genuinely stuck object graph so the test can still report diagnostics.
127 > if now.After(settledDeadline) || now.After(deadline) { leakcheck.go
128 > return report.string(), err
129 > }
130 > time.Sleep(checkGCPause) leakcheck.go
131 }
132 }
go.temporal.io/server/common/testing/objectleak/walker.go 52 covered LOC · 15 ranges

Open complete file

22 }
23
24 > func newObjectWalker(pruneTypes patterns) objectWalker { walker.go
25 > return objectWalker{
26 > seen: make(map[uintptr]struct{}),
27 > pruneTypes: pruneTypes,
28 > }
29 > }
30
31 > func (w *objectWalker) track(root any) { walker.go
32 > w.walk(reflect.ValueOf(root), nil)
33 > }
34
35 > func (w *objectWalker) walk(v reflect.Value, path path) { walker.go
36 > if !v.IsValid() {
37 return
38 }
39 > for v.Kind() == reflect.Interface { walker.go
40 if v.IsNil() {
41 return
43 v = v.Elem()
44 }
45 > switch v.Kind() { walker.go
46 > case reflect.Pointer:
47 > if v.IsNil() {
48 return
49 }
50 > ptr := v.UnsafePointer() walker.go
51 > addr := uintptr(ptr)
52 > if _, ok := w.seen[addr]; ok {
53 return
54 }
55 > w.seen[addr] = struct{}{} walker.go
56 > if obj, ok := trackPointerObject(ptr, addr, path, v.Type().String()); ok {
57 > w.objects = append(w.objects, obj)
58 > }
59 > if w.shouldPrune(v.Type()) {
60 return
61 }
62 > w.walk(v.Elem(), path) walker.go
63 > case reflect.Struct:
64 > if w.shouldPrune(v.Type()) {
65 return
66 }
67 > for i := 0; i < v.NumField(); i++ { walker.go
68 > field := v.Type().Field(i)
69 > w.walk(v.Field(i), path.field(field.Name))
70 > }
71 case reflect.Slice, reflect.Array:
72 for i := 0; i < v.Len(); i++ {
79 w.walk(iter.Value(), path.index(i))
80 }
81 > default: walker.go
82 > return
83 }
84 }
85
86 > func (w *objectWalker) shouldPrune(t reflect.Type) bool { walker.go
87 > return w.pruneTypes.matchType(t)
88 > }
89
90 > func trackPointerObject(ptr unsafe.Pointer, addr uintptr, path path, typeName string) (trackedObject, bool) { walker.go
91 > collected := &atomic.Bool{}
92 > var cleanup runtime.Cleanup
93 > ok := true
94 > func() {
95 > // Some reflected pointers are not valid heap objects for AddCleanup.
96 > defer func() {
97 > if recover() != nil {
98 ok = false
99 }
100 }()
101 //nolint:govet // The checker must attach cleanup to reflected heap addresses.
102 > cleanup = runtime.AddCleanup((*byte)(ptr), func(collected *atomic.Bool) { walker.go
103 collected.Store(true)
104 }, collected)
105 }()
106 > if !ok || cleanup == (runtime.Cleanup{}) { walker.go
107 return trackedObject{}, false
108 }
109 > return trackedObject{ walker.go
110 > addr: addr,
111 > path: path,
112 > typeName: typeName,
113 > collected: collected,
114 > cleanup: cleanup,
115 > }, true
116 }
go.temporal.io/server/common/testing/objectleak/pattern.go 37 covered LOC · 15 ranges

Open complete file

14 type patterns []pattern
15
16 > func newPattern(value string) pattern { pattern.go
17 > return pattern{value: value}
18 > }
19
20 > func (p pattern) String() string { pattern.go
21 > return p.value
22 > }
23
24 > func (p pattern) matches(value string) bool { pattern.go
25 > if prefix, ok := strings.CutSuffix(p.value, "*"); ok {
26 > return strings.HasPrefix(value, prefix) pattern.go
27 > }
28 > return value == p.value pattern.go
29 }
30
31 > func (p pattern) matchesObject(obj trackedObject) bool { pattern.go
32 > return p.matches(obj.path.normalized()) || p.matches(obj.typeName)
33 > }
34
35 > func (ps patterns) matchObject(obj trackedObject) []string { pattern.go
36 > var matches []string
37 > for i := range ps {
38 > if ps[i].matchesObject(obj) { pattern.go
39 > ps[i].matched = true
40 > matches = append(matches, ps[i].String())
41 > }
42 }
43 > return matches pattern.go
44 }
45
46 > func (ps patterns) matchAny(values ...string) bool { pattern.go
47 > for i := range ps {
48 if slices.ContainsFunc(values, ps[i].matches) {
49 ps[i].matched = true
51 }
52 }
53 > return false pattern.go
54 }
55
56 > func (ps patterns) matchType(t reflect.Type) bool { pattern.go
57 > for t.Kind() == reflect.Pointer {
58 > t = t.Elem()
59 > }
60 > if t.Name() != "" {
61 > return ps.matchAny(t.PkgPath() + "." + t.Name())
62 > }
63 return ps.matchAny(t.String())
64 }
65
66 > func (ps patterns) unmatched() []string { pattern.go
67 > var unmatched []string
68 > for _, pattern := range ps {
69 > if !pattern.matched { pattern.go
70 unmatched = append(unmatched, pattern.String())
71 }
72 }
73 > return unmatched pattern.go
74 }
go.temporal.io/server/common/testing/objectleak/path.go 20 covered LOC · 7 ranges

Open complete file

22 }
23
24 > func (p path) field(name string) path { path.go
25 > return p.append(pathSegment{kind: pathSegmentField, value: name})
26 > }
27
28 func (p path) index(index int) path {
34 }
35
36 > func (p path) append(segment pathSegment) path { path.go
37 > // Each recursion branch owns its path so sibling fields cannot mutate it.
38 > return append(slices.Clone(p), segment)
39 > }
40
41 > func (p path) normalized() string { path.go
42 > return p.format(true)
43 > }
44
45 > func (p path) format(normalized bool) string { path.go
46 > var out strings.Builder
47 > for i, segment := range p {
48 > switch segment.kind {
49 > case pathSegmentField:
50 > if i > 0 {
51 > out.WriteByte('.') path.go
52 > }
53 > out.WriteString(segment.value) path.go
54 case pathSegmentIndex:
55 if normalized {
72 }
73 }
74 > return out.String() path.go
75 }