diff --git a/CHANGELOG.md b/CHANGELOG.md index 70d4099c6..11c2ee8a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page. ## Unreleased ### Fixes +- Fixed `DefaultTextureAtlas` raising `KeyError` when a texture was added while the garbage collector freed the last other texture with the same image and transform. The collector could run in the middle of adding the new texture, and the freed texture's finalizer removed the slot the new one was about to share. It showed up in CI on Python 3.14, whose garbage collector runs at different times, when loading a tile map. - Fixed `DefaultTextureAtlas` losing track of textures that share an image, such as every `SpriteSolidColor` size, which share one white image: - Once the first texture with a given image was freed while another was still in use, resizing the atlas, which happens when it fills up, raised `RuntimeError: Empty set in unique textures` and left the atlas half-resized. - If the last texture's finalizer ran before Python had cleared it from the atlas's records, the atlas kept the texture's name after freeing its slot. Adding another texture with that image then raised `Texture '...' not found in UVData`. This showed up as an occasional CI failure. diff --git a/arcade/texture_atlas/atlas_default.py b/arcade/texture_atlas/atlas_default.py index 238718c29..b96625f08 100644 --- a/arcade/texture_atlas/atlas_default.py +++ b/arcade/texture_atlas/atlas_default.py @@ -295,8 +295,13 @@ def _add(self, texture: Texture, create_finalizer=True) -> tuple[int, AtlasRegio create_finalizer: If a finalizer should be created """ - # Quickly handle a texture already having a unique texture in the atlas - if self.has_unique_texture(texture): + # Quickly handle a texture already having a unique texture in the atlas. + # Hold a live texture with the same name while adding this one: if + # the last one were collected during these calls, its finalizer would + # free the name's slot under us. + shared = self._unique_textures.get(texture.atlas_name) + living = next(iter(shared), None) if shared else None + if living is not None: # Add add references to the duplicate texture if not self.has_texture(texture): self._add_texture_ref(texture, create_finalizer=create_finalizer) diff --git a/tests/unit/atlas/test_shared_textures.py b/tests/unit/atlas/test_shared_textures.py index 7a3c14699..a74e1ac4d 100644 --- a/tests/unit/atlas/test_shared_textures.py +++ b/tests/unit/atlas/test_shared_textures.py @@ -70,3 +70,36 @@ def test_name_removed_even_if_set_still_holds_texture(ctx): (later,) = _shared_textures(1) atlas.add(later) assert atlas.get_texture_id(later) is not None + + +def test_add_while_last_shared_texture_is_collected(ctx, monkeypatch): + # Python 3.14's garbage collector exposed this in CI: a texture added while + # the last other texture with its name was being collected found the name + # gone, and raised KeyError + data = ImageData(PIL.Image.new("RGBA", (8, 8), (255, 255, 255, 255))) + atlas = DefaultTextureAtlas((32, 32)) + dying = Texture(data) + atlas.add(dying) + name = dying.atlas_name + + # Garbage that only the cycle collector frees, like a texture held by a + # sprite and sprite list that point at each other + cycle: list = [dying] + cycle.append(cycle) + del dying, cycle + + # Collect it at the moment the new texture's reference is being added + add_texture_ref = atlas._add_texture_ref + + def collect_then_add(texture, create_finalizer=True): + gc.collect() + add_texture_ref(texture, create_finalizer=create_finalizer) + + monkeypatch.setattr(atlas, "_add_texture_ref", collect_then_add) + + texture = Texture(data) + assert texture.atlas_name == name + slot, region = atlas.add(texture) + assert atlas.has_unique_texture(texture) + assert region == atlas.get_texture_region_info(name) + assert atlas.get_image_region_info(data.hash) is not None