minitest-memory 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +13 -0
- data/README.md +124 -4
- data/lib/minitest/memory/version.rb +1 -1
- data/lib/minitest/memory.rb +343 -19
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c37208f0d913977a738af1587640fccfef97dfa89cbcde685653b809e42463f0
|
|
4
|
+
data.tar.gz: 4889e9fa26a92c685079c12af6514d205c0b32c56bcdfdbcebae834f44801c9f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 308e793bd885a60f51b1b756a7520fb38e61a5032eff3db8e8e56ed033b6b1ab12477cafd5e98c899f04ee1bc01fa1dc0950b9fc75219bcfd842a808b3db64f0
|
|
7
|
+
data.tar.gz: 9b84c7636fb559bcc634b7ed64eb8b58f48dd5ae5283eb991ab9d67f683bd175b8826a7fa604e6c5526f04911bafe4211128867b269b54c4ad3e5e43583e3955
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,19 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [1.1.0] - 2026-03-08
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Allocation source locations in failure messages — when an assertion fails, the error message now includes where each allocation originated (file and line number), sorted by frequency
|
|
13
|
+
- Minitest::Spec expectations — `must_limit_allocations`, `must_limit_retentions`, `wont_allocate`, and `wont_retain` (e.g. `_ { code }.must_limit_allocations(String => {count: 10})`)
|
|
14
|
+
- `assert_retentions` — track retained objects that survive GC, detecting potential memory leaks (e.g. `assert_retentions(String => 0)`)
|
|
15
|
+
- `refute_retentions` — fails if any of the given classes are retained after GC within a block
|
|
16
|
+
- Global allocation limits — `assert_allocations` now accepts `:count` and `:size` symbol keys for total limits across all classes (e.g. `assert_allocations(count: 10)`)
|
|
17
|
+
- Range-based limits — limits can be a Range (e.g. `String => 2..5`) to require allocations within a specific range, for both direct and hash-style `:count`/`:size` limits
|
|
18
|
+
- `refute_allocations` — fails if any of the given classes are allocated within a block
|
|
19
|
+
- Allocation size limits — `assert_allocations` now accepts hash limits with `:count` and/or `:size` keys (e.g. `String => { size: 1024 }`)
|
|
20
|
+
|
|
8
21
|
## [1.0.0] - 2026-03-06
|
|
9
22
|
|
|
10
23
|
### Added
|
data/README.md
CHANGED
|
@@ -37,7 +37,99 @@ class MyTest < Minitest::Test
|
|
|
37
37
|
end
|
|
38
38
|
```
|
|
39
39
|
|
|
40
|
-
|
|
40
|
+
### Range limits
|
|
41
|
+
|
|
42
|
+
Pass a Range to require allocations within a specific range:
|
|
43
|
+
|
|
44
|
+
```ruby
|
|
45
|
+
# Require between 2 and 5 String allocations
|
|
46
|
+
assert_allocations(String => 2..5) { ... }
|
|
47
|
+
|
|
48
|
+
# Range limits work with count and size in hashes too
|
|
49
|
+
assert_allocations(String => { count: 2..5 }) { ... }
|
|
50
|
+
assert_allocations(String => { size: 1024..4096 }) { ... }
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### Size limits
|
|
54
|
+
|
|
55
|
+
Pass a Hash with `:count` and/or `:size` keys to constrain total bytes
|
|
56
|
+
allocated per class (beyond the base object slot size):
|
|
57
|
+
|
|
58
|
+
```ruby
|
|
59
|
+
# Limit total String bytes
|
|
60
|
+
assert_allocations(String => { size: 1024 }) { ... }
|
|
61
|
+
|
|
62
|
+
# Limit both count and size
|
|
63
|
+
assert_allocations(String => { count: 2, size: 1024 }) { ... }
|
|
64
|
+
|
|
65
|
+
# Count-only via hash (equivalent to String => 2)
|
|
66
|
+
assert_allocations(String => { count: 2 }) { ... }
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### Global limits
|
|
70
|
+
|
|
71
|
+
Use the `:count` and `:size` symbol keys to set limits on total allocations
|
|
72
|
+
across all classes:
|
|
73
|
+
|
|
74
|
+
```ruby
|
|
75
|
+
# Limit total object count across all classes
|
|
76
|
+
assert_allocations(count: 10) { ... }
|
|
77
|
+
|
|
78
|
+
# Limit total allocation bytes across all classes
|
|
79
|
+
assert_allocations(size: 1024) { ... }
|
|
80
|
+
|
|
81
|
+
# Limit both count and size
|
|
82
|
+
assert_allocations(count: 10, size: 1024) { ... }
|
|
83
|
+
|
|
84
|
+
# Ranges work too
|
|
85
|
+
assert_allocations(count: 5..10) { ... }
|
|
86
|
+
|
|
87
|
+
# Combine per-class and global limits
|
|
88
|
+
assert_allocations(String => 2, count: 10) { ... }
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### Retained object tracking
|
|
92
|
+
|
|
93
|
+
Use `assert_retentions` to check which objects survive garbage collection,
|
|
94
|
+
detecting potential memory leaks:
|
|
95
|
+
|
|
96
|
+
> [!WARNING]
|
|
97
|
+
> Garbage collection is disabled while the block executes. Avoid long-running
|
|
98
|
+
> or memory-intensive code inside the block.
|
|
99
|
+
|
|
100
|
+
```ruby
|
|
101
|
+
# Limit retained String objects
|
|
102
|
+
assert_retentions(String => 1) { ... }
|
|
103
|
+
|
|
104
|
+
# Hash-style limits with count and size
|
|
105
|
+
assert_retentions(String => { count: 1, size: 1024 }) { ... }
|
|
106
|
+
|
|
107
|
+
# Range limits work too
|
|
108
|
+
assert_retentions(String => 1..5) { ... }
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Use `refute_retentions` to prevent any retained objects of the given types:
|
|
112
|
+
|
|
113
|
+
```ruby
|
|
114
|
+
refute_retentions(String, Array) do
|
|
115
|
+
# code that must not retain strings or arrays
|
|
116
|
+
end
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### `refute_allocations`
|
|
120
|
+
|
|
121
|
+
Use `refute_allocations` to prevent any allocations of the given types:
|
|
122
|
+
|
|
123
|
+
```ruby
|
|
124
|
+
refute_allocations(String, Array) do
|
|
125
|
+
# code that must not allocate strings or arrays
|
|
126
|
+
end
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### Minitest::Spec
|
|
130
|
+
|
|
131
|
+
It also works with `Minitest::Spec`. Include `Minitest::Memory` in your spec
|
|
132
|
+
class to use both assertions and expectations:
|
|
41
133
|
|
|
42
134
|
```ruby
|
|
43
135
|
require "minitest/autorun"
|
|
@@ -56,15 +148,43 @@ describe MyClass do
|
|
|
56
148
|
end
|
|
57
149
|
```
|
|
58
150
|
|
|
151
|
+
#### Expectations
|
|
152
|
+
|
|
153
|
+
The following `must_*` / `wont_*` expectations are available:
|
|
154
|
+
|
|
155
|
+
```ruby
|
|
156
|
+
# Limit allocations per class (wraps assert_allocations)
|
|
157
|
+
_ { code }.must_limit_allocations(String => 2)
|
|
158
|
+
_ { code }.must_limit_allocations(String => { count: 2, size: 1024 })
|
|
159
|
+
_ { code }.must_limit_allocations(String => 2..5)
|
|
160
|
+
|
|
161
|
+
# Limit total allocations across all classes
|
|
162
|
+
_ { code }.must_limit_allocations(count: 10)
|
|
163
|
+
_ { code }.must_limit_allocations(count: 5..10, size: 1024)
|
|
164
|
+
|
|
165
|
+
# Limit retained objects (wraps assert_retentions)
|
|
166
|
+
_ { code }.must_limit_retentions(String => 1)
|
|
167
|
+
_ { code }.must_limit_retentions(String => { count: 1, size: 1024 })
|
|
168
|
+
|
|
169
|
+
# Prevent allocations of specific classes (wraps refute_allocations)
|
|
170
|
+
_ { code }.wont_allocate(String, Array)
|
|
171
|
+
|
|
172
|
+
# Prevent retained objects of specific classes (wraps refute_retentions)
|
|
173
|
+
_ { code }.wont_retain(String, Array)
|
|
174
|
+
```
|
|
175
|
+
|
|
59
176
|
## How It Works
|
|
60
177
|
|
|
61
178
|
`assert_allocations` uses `ObjectSpace.trace_object_allocations` to track
|
|
62
179
|
every object allocated during the block's execution. It then compares the
|
|
63
|
-
counts per class against the limits you provide. If any class
|
|
64
|
-
limit, the assertion fails with a message
|
|
180
|
+
counts and sizes per class against the limits you provide. If any class
|
|
181
|
+
exceeds its limit, the assertion fails with a message that includes the
|
|
182
|
+
source location of each allocation, sorted by frequency:
|
|
65
183
|
|
|
66
184
|
```
|
|
67
|
-
Expected
|
|
185
|
+
Expected no String allocations, got 3
|
|
186
|
+
2× at app/models/user.rb:42
|
|
187
|
+
1× at lib/serializer.rb:18
|
|
68
188
|
```
|
|
69
189
|
|
|
70
190
|
## License
|
data/lib/minitest/memory.rb
CHANGED
|
@@ -14,47 +14,371 @@ module Minitest
|
|
|
14
14
|
# Counts object allocations within a block using ObjectSpace.
|
|
15
15
|
class AllocationCounter
|
|
16
16
|
##
|
|
17
|
-
#
|
|
18
|
-
|
|
17
|
+
# Tracks allocation count and total byte size for a class.
|
|
18
|
+
Allocation = Struct.new(:count, :size, :sources) # rubocop:disable Lint/StructNewOverride
|
|
19
|
+
|
|
20
|
+
##
|
|
21
|
+
# Holds allocation counting results: +allocated+ maps matched
|
|
22
|
+
# classes to Allocations, +ignored+ maps unmatched classes,
|
|
23
|
+
# and +total+ is the aggregate Allocation across all objects.
|
|
24
|
+
Result = Struct.new(:allocated, :ignored, :total)
|
|
25
|
+
|
|
26
|
+
##
|
|
27
|
+
# Base memory size of an empty Ruby object slot.
|
|
28
|
+
SLOT_SIZE = ObjectSpace.memsize_of(Object.new)
|
|
29
|
+
|
|
30
|
+
empty_sources = {} #: Hash[String, Integer]
|
|
31
|
+
|
|
32
|
+
##
|
|
33
|
+
# An empty allocation with zero count and size.
|
|
34
|
+
EMPTY = Allocation.new(0, 0, empty_sources.freeze).freeze
|
|
35
|
+
|
|
36
|
+
##
|
|
37
|
+
# Returns +false+ on TruffleRuby where ObjectSpace tracing
|
|
38
|
+
# is not supported, +true+ otherwise.
|
|
39
|
+
|
|
40
|
+
def self.supported?
|
|
41
|
+
# :nocov:
|
|
42
|
+
return false if RUBY_ENGINE == "truffleruby"
|
|
43
|
+
# :nocov:
|
|
44
|
+
|
|
45
|
+
ObjectSpace.respond_to?(:trace_object_allocations)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
##
|
|
49
|
+
# Counts allocations by class within a block. Returns a
|
|
50
|
+
# Result. When +klasses+ are given, objects are matched via
|
|
51
|
+
# +is_a?+; unmatched objects go to +ignored+. Temporarily
|
|
19
52
|
# disables GC during counting.
|
|
20
53
|
|
|
21
|
-
def self.count(&)
|
|
54
|
+
def self.count(klasses = [], &)
|
|
55
|
+
trace(klasses, &)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
##
|
|
59
|
+
# Counts retained allocations by class within a block.
|
|
60
|
+
# Returns a Result. Runs GC after the block to identify
|
|
61
|
+
# objects that survive garbage collection.
|
|
62
|
+
|
|
63
|
+
def self.count_retained(klasses = [], &)
|
|
64
|
+
trace(klasses, retain: true, &)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
##
|
|
68
|
+
# Returns a Result of allocations from the given +generation+.
|
|
69
|
+
# When +klasses+ are given, objects are matched via +is_a?+
|
|
70
|
+
# and unmatched objects are tracked separately in +ignored+.
|
|
71
|
+
|
|
72
|
+
def self.count_allocations(generation, klasses = [])
|
|
73
|
+
allocated = {} #: Hash[untyped, Allocation]
|
|
74
|
+
ignored = {} #: Hash[untyped, Allocation]
|
|
75
|
+
total = new_allocation
|
|
76
|
+
|
|
77
|
+
ObjectSpace.each_object do |obj|
|
|
78
|
+
next unless ObjectSpace.allocation_generation(obj) == generation
|
|
79
|
+
|
|
80
|
+
tally(obj, total, bucket_for(obj, klasses, allocated, ignored))
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
Result.new(allocated, ignored, total)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
##
|
|
87
|
+
# Traces object allocations within a block, optionally
|
|
88
|
+
# running GC to identify retained objects. Returns a Result.
|
|
89
|
+
|
|
90
|
+
def self.trace(klasses, retain: false, &)
|
|
91
|
+
# :nocov:
|
|
92
|
+
return Result.new({}, {}, EMPTY) unless supported?
|
|
93
|
+
# :nocov:
|
|
94
|
+
|
|
22
95
|
GC.start
|
|
23
96
|
GC.disable
|
|
24
97
|
generation = GC.count
|
|
25
98
|
ObjectSpace.trace_object_allocations(&)
|
|
26
|
-
|
|
99
|
+
GC.start if retain
|
|
100
|
+
count_allocations(generation, klasses)
|
|
27
101
|
ensure
|
|
28
102
|
GC.enable
|
|
29
103
|
end
|
|
104
|
+
private_class_method :trace
|
|
30
105
|
|
|
31
106
|
##
|
|
32
|
-
#
|
|
107
|
+
# Tallies one object's count and byte size into both the
|
|
108
|
+
# +total+ and per-class +bucket+ entries.
|
|
33
109
|
|
|
34
|
-
def self.
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
110
|
+
def self.tally(obj, total, bucket)
|
|
111
|
+
size = ObjectSpace.memsize_of(obj) - SLOT_SIZE
|
|
112
|
+
total.count += 1
|
|
113
|
+
total.size += size
|
|
114
|
+
bucket.count += 1
|
|
115
|
+
bucket.size += size
|
|
116
|
+
record_source(obj, total, bucket)
|
|
117
|
+
end
|
|
118
|
+
private_class_method :tally
|
|
119
|
+
|
|
120
|
+
##
|
|
121
|
+
# Records the source location of +obj+ into +total+ and
|
|
122
|
+
# +bucket+ source hashes. Skips objects without source info.
|
|
123
|
+
|
|
124
|
+
def self.record_source(obj, total, bucket)
|
|
125
|
+
file = ObjectSpace.allocation_sourcefile(obj)
|
|
126
|
+
# :nocov:
|
|
127
|
+
return unless file
|
|
128
|
+
# :nocov:
|
|
129
|
+
|
|
130
|
+
source = "#{file}:#{ObjectSpace.allocation_sourceline(obj)}"
|
|
131
|
+
total.sources[source] += 1
|
|
132
|
+
bucket.sources[source] += 1
|
|
133
|
+
end
|
|
134
|
+
private_class_method :record_source
|
|
135
|
+
|
|
136
|
+
##
|
|
137
|
+
# Finds or creates the Allocation entry for +obj+. When
|
|
138
|
+
# +klasses+ are given, matches via +is_a?+ into +allocated+
|
|
139
|
+
# or files into +ignored+.
|
|
140
|
+
|
|
141
|
+
def self.bucket_for(obj, klasses, allocated, ignored)
|
|
142
|
+
return allocated[obj.class] ||= new_allocation if klasses.empty?
|
|
143
|
+
|
|
144
|
+
klass = klasses.find { |k| obj.is_a?(k) }
|
|
145
|
+
return allocated[klass] ||= new_allocation if klass
|
|
146
|
+
|
|
147
|
+
ignored[obj.class] ||= new_allocation
|
|
148
|
+
end
|
|
149
|
+
private_class_method :bucket_for
|
|
150
|
+
|
|
151
|
+
##
|
|
152
|
+
# Creates a new zeroed Allocation with a default-value sources hash.
|
|
153
|
+
|
|
154
|
+
def self.new_allocation
|
|
155
|
+
Allocation.new(0, 0, Hash.new(0))
|
|
40
156
|
end
|
|
157
|
+
private_class_method :new_allocation
|
|
41
158
|
end
|
|
42
159
|
|
|
43
160
|
##
|
|
44
|
-
# Fails if any class in +limits+
|
|
45
|
-
# within a block. +limits+ is a Hash mapping classes to
|
|
46
|
-
#
|
|
161
|
+
# Fails if any class in +limits+ does not match its allocation
|
|
162
|
+
# limit within a block. +limits+ is a Hash mapping classes to
|
|
163
|
+
# an Integer (exact count), a Range (required range), or a Hash
|
|
164
|
+
# with +:count+ and/or +:size+ keys (each an Integer or Range).
|
|
165
|
+
#
|
|
166
|
+
# Objects are matched to classes via +is_a?+, so specifying
|
|
167
|
+
# +Numeric+ captures +Integer+, +Float+, etc.
|
|
168
|
+
#
|
|
169
|
+
# Use the +:count+ and +:size+ symbol keys to set global limits
|
|
170
|
+
# across all classes. When no global limit is set, allocations
|
|
171
|
+
# of unspecified classes cause a failure (strict mode).
|
|
47
172
|
#
|
|
48
173
|
# assert_allocations(String => 1) { "hello" }
|
|
174
|
+
# assert_allocations(String => 2..5) { "hello" }
|
|
175
|
+
# assert_allocations(String => {size: 1024}) { "hello" }
|
|
176
|
+
# assert_allocations(count: 10) { "hello" }
|
|
177
|
+
# assert_allocations(String => 1, count: 10) { "hello" }
|
|
49
178
|
|
|
50
179
|
def assert_allocations(limits, &)
|
|
51
|
-
|
|
180
|
+
klasses = limits.keys.select { |k| k.is_a?(Module) }
|
|
181
|
+
has_total_limit = limits.key?(:count) || limits.key?(:size)
|
|
182
|
+
result = AllocationCounter.count(klasses, &)
|
|
183
|
+
|
|
184
|
+
check_limits(limits, result)
|
|
185
|
+
|
|
186
|
+
return if has_total_limit
|
|
187
|
+
|
|
188
|
+
result.ignored.each do |klass, alloc|
|
|
189
|
+
msg = "Allocated #{alloc.count} #{klass} instances, #{alloc.size} bytes, " \
|
|
190
|
+
"but it was not specified#{format_sources(alloc.sources)}"
|
|
191
|
+
flunk msg
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
##
|
|
196
|
+
# Fails if any class in +limits+ exceeds its retention limit
|
|
197
|
+
# within a block. Works like +assert_allocations+ but only
|
|
198
|
+
# counts objects that survive garbage collection.
|
|
199
|
+
#
|
|
200
|
+
# *Warning:* Garbage collection is disabled while the block
|
|
201
|
+
# executes. Avoid long-running or memory-intensive code inside
|
|
202
|
+
# the block.
|
|
203
|
+
#
|
|
204
|
+
# assert_retentions(String => 0) { "hello" }
|
|
205
|
+
# assert_retentions(String => {count: 1, size: 1024}) { "hello" }
|
|
206
|
+
|
|
207
|
+
def assert_retentions(limits, &)
|
|
208
|
+
klasses = limits.keys.select { |k| k.is_a?(Module) }
|
|
209
|
+
result = AllocationCounter.count_retained(klasses, &)
|
|
210
|
+
|
|
211
|
+
check_limits(limits, result, metric: "retentions", size_metric: "retained bytes")
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
##
|
|
215
|
+
# Fails if any of the given +classes+ are allocated within a
|
|
216
|
+
# block.
|
|
217
|
+
#
|
|
218
|
+
# refute_allocations(String, Array) { 1 + 1 }
|
|
219
|
+
|
|
220
|
+
def refute_allocations(*classes, &)
|
|
221
|
+
check_zero(AllocationCounter.count(classes, &), classes)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
##
|
|
225
|
+
# Fails if any of the given +classes+ are retained within a
|
|
226
|
+
# block.
|
|
227
|
+
#
|
|
228
|
+
# refute_retentions(String, Array) { 1 + 1 }
|
|
229
|
+
|
|
230
|
+
def refute_retentions(*classes, &)
|
|
231
|
+
check_zero(AllocationCounter.count_retained(classes, &), classes, metric: "retentions")
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
##
|
|
235
|
+
# Includes +Expectations+ into +Minitest::Expectation+ when
|
|
236
|
+
# +minitest/spec+ is loaded, enabling the +must_*+ / +wont_*+
|
|
237
|
+
# expectation syntax.
|
|
238
|
+
|
|
239
|
+
def self.included(base) # :nodoc:
|
|
240
|
+
super
|
|
241
|
+
# :nocov:
|
|
242
|
+
Minitest::Expectation.include(Expectations) if defined?(Minitest::Expectation)
|
|
243
|
+
# :nocov:
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
##
|
|
247
|
+
# Minitest::Spec expectations for memory allocation assertions.
|
|
248
|
+
# These methods are added to +Minitest::Expectation+ when
|
|
249
|
+
# +minitest/spec+ is loaded.
|
|
250
|
+
module Expectations
|
|
251
|
+
##
|
|
252
|
+
# See Minitest::Memory#assert_allocations.
|
|
253
|
+
#
|
|
254
|
+
# _ { code }.must_limit_allocations(String => {count: 10})
|
|
255
|
+
|
|
256
|
+
def must_limit_allocations(limits)
|
|
257
|
+
ctx.assert_allocations(limits, &target)
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
##
|
|
261
|
+
# See Minitest::Memory#assert_retentions.
|
|
262
|
+
#
|
|
263
|
+
# _ { code }.must_limit_retentions(String => 1)
|
|
264
|
+
|
|
265
|
+
def must_limit_retentions(limits)
|
|
266
|
+
ctx.assert_retentions(limits, &target)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
##
|
|
270
|
+
# See Minitest::Memory#refute_allocations.
|
|
271
|
+
#
|
|
272
|
+
# _ { code }.wont_allocate(String, Array)
|
|
273
|
+
|
|
274
|
+
def wont_allocate(*classes)
|
|
275
|
+
ctx.refute_allocations(*classes, &target)
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
##
|
|
279
|
+
# See Minitest::Memory#refute_retentions.
|
|
280
|
+
#
|
|
281
|
+
# _ { code }.wont_retain(String, Array)
|
|
282
|
+
|
|
283
|
+
def wont_retain(*classes)
|
|
284
|
+
ctx.refute_retentions(*classes, &target)
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
private
|
|
289
|
+
|
|
290
|
+
##
|
|
291
|
+
# Checks all +limits+ entries against +result+. Routes +:count+
|
|
292
|
+
# and +:size+ to total-limit checks, and Module keys to
|
|
293
|
+
# per-class checks.
|
|
52
294
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
assert_operator max_count, :>=, count, msg
|
|
295
|
+
def check_limits(limits, result, metric: "allocations", size_metric: "allocation bytes")
|
|
296
|
+
limits.each do |klass, limit|
|
|
297
|
+
check_limit_entry(klass, limit, result, metric: metric, size_metric: size_metric)
|
|
57
298
|
end
|
|
58
299
|
end
|
|
300
|
+
|
|
301
|
+
##
|
|
302
|
+
# Dispatches a single +limit+ entry for the given +klass+
|
|
303
|
+
# against the +result+. Routes symbols to total-limit checks
|
|
304
|
+
# and Module keys to per-class checks.
|
|
305
|
+
|
|
306
|
+
def check_limit_entry(klass, limit, result, metric:, size_metric:)
|
|
307
|
+
case klass
|
|
308
|
+
when :count, :size
|
|
309
|
+
total_limit = limit #: Integer | Range[Integer]
|
|
310
|
+
check_total_limit(klass, total_limit, result.total, size_metric: size_metric)
|
|
311
|
+
when Module
|
|
312
|
+
alloc = result.allocated[klass] || AllocationCounter::EMPTY
|
|
313
|
+
check_class_limit(klass, alloc, limit, metric: metric, size_metric: size_metric)
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
##
|
|
318
|
+
# Checks a total +:count+ or +:size+ limit against the
|
|
319
|
+
# aggregate +total+ allocation.
|
|
320
|
+
|
|
321
|
+
def check_total_limit(klass, limit, total, size_metric:)
|
|
322
|
+
if klass == :count
|
|
323
|
+
check_limit("total", limit, total.count, sources: total.sources)
|
|
324
|
+
else
|
|
325
|
+
check_limit("total", limit, total.size, metric: size_metric, sources: total.sources)
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
##
|
|
330
|
+
# Checks per-class +limit+ against +allocation+ for the given
|
|
331
|
+
# +klass+. +limit+ may be an Integer, Range, or Hash with
|
|
332
|
+
# +:count+ and/or +:size+ keys.
|
|
333
|
+
|
|
334
|
+
def check_class_limit(klass, allocation, limit, metric:, size_metric:)
|
|
335
|
+
srcs = allocation.sources
|
|
336
|
+
if limit.is_a?(Hash)
|
|
337
|
+
check_limit(klass, limit.fetch(:count), allocation.count, metric: metric, sources: srcs) if limit.key?(:count)
|
|
338
|
+
check_limit(klass, limit.fetch(:size), allocation.size, metric: size_metric, sources: srcs) if limit.key?(:size)
|
|
339
|
+
else
|
|
340
|
+
check_limit(klass, limit, allocation.count, metric: metric, sources: srcs)
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
##
|
|
345
|
+
# Asserts that +actual+ matches +limit+ for the given +klass+
|
|
346
|
+
# and +metric+. +limit+ may be an Integer (exact match) or a
|
|
347
|
+
# Range (inclusion check).
|
|
348
|
+
|
|
349
|
+
def check_limit(klass, limit, actual, sources:, metric: "allocations")
|
|
350
|
+
if limit.is_a?(Range)
|
|
351
|
+
msg = "Expected within #{limit} #{klass} #{metric}, got #{actual}#{format_sources(sources)}"
|
|
352
|
+
assert_includes limit, actual, msg
|
|
353
|
+
else
|
|
354
|
+
desc = limit.zero? ? "no" : "exactly #{limit}"
|
|
355
|
+
msg = "Expected #{desc} #{klass} #{metric}, got #{actual}#{format_sources(sources)}"
|
|
356
|
+
assert_equal limit, actual, msg
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
##
|
|
361
|
+
# Asserts zero allocations for each class in +classes+.
|
|
362
|
+
|
|
363
|
+
def check_zero(result, classes, metric: "allocations")
|
|
364
|
+
classes.each do |klass|
|
|
365
|
+
alloc = result.allocated[klass] || AllocationCounter::EMPTY
|
|
366
|
+
check_limit(klass, 0, alloc.count, metric: metric, sources: alloc.sources)
|
|
367
|
+
end
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
##
|
|
371
|
+
# Formats allocation +sources+ as a newline-separated list
|
|
372
|
+
# of source locations with counts, sorted by frequency.
|
|
373
|
+
|
|
374
|
+
def format_sources(sources)
|
|
375
|
+
return "" if sources.empty?
|
|
376
|
+
|
|
377
|
+
entries = sources.sort_by { |_, count| -count }.map do |source, count|
|
|
378
|
+
" #{count}× at #{source}"
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
"\n#{entries.join("\n")}"
|
|
382
|
+
end
|
|
59
383
|
end
|
|
60
384
|
end
|