Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 7 additions & 2 deletions arcade/texture_atlas/atlas_default.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
33 changes: 33 additions & 0 deletions tests/unit/atlas/test_shared_textures.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading