@@ -93,22 +93,67 @@ def qualname(obj):
9393 return getattr (obj , "__qualname__" , getattr (obj , "__name__" , str (obj )))
9494
9595
96- class MemoryLeakError (AssertionError ):
97- """Raised when a memory leak is detected."""
96+ # --- exceptions
97+
98+
99+ class UnclosedResourceError (AssertionError ):
100+ """Base class for errors raised when some resource created during a
101+ function call is left unclosed or unfreed afterward.
102+ """
103+
104+ resource_name = "resource" # override in subclasses
105+
106+ def __init__ (self , count , fun_name ):
107+ self .count = count
108+ self .fun_name = fun_name
109+ name = self .resource_name
110+ name += "s" if count > 1 else "" # pluralize
111+ msg = (
112+ f"detected { count } unclosed { name } after calling { fun_name !r} 1"
113+ " time"
114+ )
115+ super ().__init__ (msg )
116+
117+
118+ class UnclosedFdError (UnclosedResourceError ):
119+ """Raised when an unclosed file descriptor is detected after
120+ calling function once. Used to detect forgotten close(). UNIX only.
121+ """
122+
123+ resource_name = "file descriptor"
98124
99125
100- class UnclosedFdError (AssertionError ):
101- """Raised when an unclosed file descriptor (UNIX) or handle
102- (Windows) is detected.
126+ class UnclosedHandleError (UnclosedResourceError ):
127+ """Raised when an unclosed handle is detected after calling
128+ function once. Used to detect forgotten CloseHandle().
129+ Windows only.
103130 """
104131
132+ resource_name = "handle"
133+
134+
135+ class UnclosedHeapCreateError (UnclosedResourceError ):
136+ """Raised when test detects HeapCreate() without a corresponding
137+ HeapDestroy() after calling function once. Windows only.
138+ """
139+
140+ resource_name = "HeapCreate() call"
141+
142+
143+ class MemoryLeakError (AssertionError ):
144+ """Raised when a memory leak is detected after calling function
145+ many times. Aims to detect:
105146
106- class UnclosedHeapCreateError (AssertionError ):
107- """Raised on Windows when test detects HeapCreate() without a
108- corresponding HeapDestroy().
147+ - `malloc()` without a corresponding `free()`
148+ - `mmap()` without `munmap()`
149+ - `HeapAlloc()` without `HeapFree()` (Windows)
150+ - `VirtualAlloc()` without `VirtualFree()` (Windows)
109151 """
110152
111153
154+ # ---
155+
156+
112157class MemoryLeakTestCase (unittest .TestCase ):
113158 # Number of times to call the tested function in each iteration.
114159 times = 200
@@ -167,6 +212,13 @@ def _warmup(self, fun, warmup_times):
167212
168213 # --- getters
169214
215+ def _get_oneshot (self ):
216+ return {
217+ "num_fds" : thisproc .num_fds () if POSIX else 0 ,
218+ "num_handles" : thisproc .num_handles () if WINDOWS else 0 ,
219+ "heap_count" : psutil .heap_info ().heap_count if WINDOWS else 0 ,
220+ }
221+
170222 def _get_mem (self ):
171223 mem = thisproc .memory_full_info ()
172224 heap_used = mmap_used = 0
@@ -182,64 +234,34 @@ def _get_mem(self):
182234 "vms" : mem .vms ,
183235 }
184236
185- def _get_num_fds (self ):
186- if POSIX :
187- return thisproc .num_fds ()
188- else :
189- return thisproc .num_handles ()
190-
191237 # --- checkers
192238
193- def _check_fds (self , fun ):
194- """Makes sure `num_fds()` (POSIX) or `num_handles()` (Windows)
195- do not increase after calling function 1 time. Used to
196- discover forgotten `close(2)` and `CloseHandle()`.
197- """
198-
199- before = self ._get_num_fds ()
200- self .call (fun )
201- after = self ._get_num_fds ()
202- diff = after - before
203-
204- if diff < 0 :
205- msg = (
206- f"negative diff { diff !r} (gc probably collected a"
207- " resource from a previous test)"
208- )
209- raise UnclosedFdError (msg )
210-
211- if diff > 0 :
212- type_ = "fd" if POSIX else "handle"
213- if diff > 1 :
214- type_ += "s"
215- msg = (
216- f"detected { diff } unclosed { type_ } after calling"
217- f" { qualname (fun )!r} 1 time"
218- )
219- raise UnclosedFdError (msg )
220-
221- def _check_heap_count (self , fun ):
222- """Windows only. Calls function once, and detects HeapCreate()
223- without a corresponding HeapDestroy().
224- """
225- if not WINDOWS :
226- return
227-
228- before = psutil .heap_info ().heap_count
239+ def _check_oneshot (self , fun ):
240+ before = self ._get_oneshot ()
229241 self .call (fun )
230- after = psutil .heap_info ().heap_count
231- diff = after - before
232-
233- if diff < 0 :
234- msg = f"negative diff { diff !r} "
235- raise UnclosedHeapCreateError (msg )
236-
237- if diff > 0 :
238- msg = (
239- f"detected { diff } HeapCreate() without a corresponding "
240- f" HeapDestroy() after calling { qualname (fun )!r} 1 time"
241- )
242- raise UnclosedHeapCreateError (msg )
242+ after = self ._get_oneshot ()
243+
244+ for what , value_before in before .items ():
245+ value_after = after [what ]
246+ diff = value_after - value_before
247+
248+ if diff < 0 :
249+ msg = (
250+ f"WARNING: { what !r} decreased by { abs (diff )} after calling"
251+ f" { qualname (fun )!r} 1 time"
252+ )
253+ self ._log (msg , 0 )
254+
255+ elif diff > 0 :
256+ mapping = {
257+ "num_fds" : UnclosedFdError ,
258+ "num_handles" : UnclosedHandleError ,
259+ "heap_count" : UnclosedHeapCreateError ,
260+ }
261+ exc = mapping .get (what )
262+ if exc is None :
263+ raise ValueError (what )
264+ raise exc (diff , qualname (fun ))
243265
244266 def _call_ntimes (self , fun , times ):
245267 """Get memory samples before and after calling fun repeatedly,
@@ -331,8 +353,6 @@ def execute(
331353 if args :
332354 fun = functools .partial (fun , * args )
333355
356+ self ._check_oneshot (fun )
334357 self ._warmup (fun , warmup_times )
335- self ._check_fds (fun )
336- if WINDOWS :
337- self ._check_heap_count (fun )
338358 self ._check_mem (fun , times = times , retries = retries , tolerance = tolerance )
0 commit comments