@@ -112,7 +112,17 @@ class Model(PintNativeModelMixin, BaseModel):
112112 # Declare private attributes for Pydantic v2
113113 _meshes : Any = PrivateAttr (default_factory = dict )
114114 _primary_mesh_id : Optional [int ] = PrivateAttr (default = None )
115- _swarms : Any = PrivateAttr (default_factory = dict )
115+ # WeakValueDictionary so dropping the user's last reference to a swarm
116+ # actually lets it be garbage-collected. Without this, transient swarms
117+ # used inside functions (global expression evaluation, checkpoint reads,
118+ # mesh-adapt transfers) accumulate forever via the registry's strong
119+ # reference and ``Swarm.__del__`` never fires. Length and membership
120+ # checks are dictionary-like; iteration is *not* stable in the way a
121+ # plain dict's is, because entries can disappear asynchronously as
122+ # their swarm value is collected. Call sites that need a stable
123+ # traversal (summaries, snapshots) should iterate a copy such as
124+ # ``list(self._swarms.items())``.
125+ _swarms : Any = PrivateAttr (default_factory = weakref .WeakValueDictionary )
116126 _variables : Dict [str , Any ] = PrivateAttr (default_factory = dict )
117127 _solvers : Dict [str , Any ] = PrivateAttr (default_factory = dict )
118128
@@ -210,6 +220,31 @@ def _register_swarm(self, swarm):
210220 swarm_id = id (swarm )
211221 self ._swarms [swarm_id ] = swarm
212222
223+ def _unregister_swarm (self , swarm ):
224+ """
225+ Internal method to drop a swarm and all its variables from the registry.
226+
227+ Without this, transient swarms (used for global expression evaluation,
228+ checkpoint reads, mesh adaptation transfers) accumulate in the registry
229+ forever, leaking the underlying PETSc DMs and field storage. Called
230+ from :meth:`Swarm.__del__`.
231+ """
232+ swarm_id = id (swarm )
233+ self ._swarms .pop (swarm_id , None )
234+ # Drop any variables that belong to this swarm. Building the list of
235+ # names first avoids mutating the dict during iteration.
236+ try :
237+ from .swarm import SwarmVariable
238+ except ImportError :
239+ return
240+ names_to_drop = [
241+ name for name , var in self ._variables .items ()
242+ if isinstance (var , SwarmVariable ) and getattr (var , "_swarm_ref" , None ) is not None
243+ and var ._swarm_ref () is swarm
244+ ]
245+ for name in names_to_drop :
246+ self ._variables .pop (name , None )
247+
213248 def _register_variable (self , name , variable ):
214249 """
215250 Internal method to register a variable with this model.
@@ -225,13 +260,15 @@ def _register_variable(self, name, variable):
225260 variable : MeshVariable or SwarmVariable
226261 Variable instance to register
227262 """
228- # For SwarmVariables, ensure we keep strong reference to swarm to prevent garbage collection
263+ # For SwarmVariables, make sure the parent swarm has been registered.
264+ # Registration is via WeakValueDictionary, so it does not pin the
265+ # swarm — when the user drops their last reference the swarm dies
266+ # and ``Swarm.__del__`` cleans up the variable side of the registry.
229267 from .swarm import SwarmVariable
230268
231269 if isinstance (variable , SwarmVariable ):
232270 swarm = variable .swarm # This will raise if swarm already garbage collected
233271 swarm_id = id (swarm )
234- # Ensure swarm is registered with strong reference
235272 if swarm_id not in self ._swarms :
236273 self ._register_swarm (swarm )
237274
@@ -4245,7 +4282,7 @@ def view(self, verbose: int = 0, show_materials: bool = True, show_petsc: bool =
42454282 swarm_count = len (self ._swarms )
42464283 lines .append (f"**Swarms:** { swarm_count } registered" )
42474284 if swarm_count > 0 and verbose >= 1 :
4248- for swarm_id , swarm in self ._swarms .items ():
4285+ for swarm_id , swarm in list ( self ._swarms .items () ):
42494286 try :
42504287 if hasattr (swarm , "data" ) and swarm .data is not None :
42514288 particle_count = len (swarm .data )
0 commit comments