From 7173162e5de6842056cdc06daf4c6e0bc5ff664c Mon Sep 17 00:00:00 2001 From: Paul V Craven Date: Fri, 9 Oct 2026 16:17:37 -0500 Subject: [PATCH] Keep a shared texture alive while adding another with its name When a texture is added whose name another texture already has, the atlas reuses that texture's slot. If the garbage collector ran inside _add_texture_ref and freed the last other texture with the name, its finalizer popped the name and freed the slot, and the add raised KeyError. This failed reliably on Python 3.14 in CI when loading a tile map. _add now holds a strong reference to a live texture with the name while it adds the new one, and uses the full add path if none is alive. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + arcade/texture_atlas/atlas_default.py | 9 +++++-- tests/unit/atlas/test_shared_textures.py | 33 ++++++++++++++++++++++++ 3 files changed, 41 insertions(+), 2 deletions(-) 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