Skip to content

Channel logos: idle callback fires before the file is closed, and failed downloads are cached permanently #431

Description

@jebjarrell1974-debug

Two separate defects in download_channel_logos (master 0e0fa1c7596f, L660-678).
Both are small and independent of each other.

for channel, image in logos_to_refresh:
    if channel.logo_path is None:
        continue
    if os.path.isfile(channel.logo_path):          # (2)
        continue
    try:
        response = requests.get(channel.logo, headers=headers, timeout=10, stream=True)
        if response.status_code == 200:
            response.raw.decode_content = True
            with open(channel.logo_path, "wb") as f:
                shutil.copyfileobj(response.raw, f)
                self.refresh_channel_logo(channel, image)   # (1) inside the with
    except Exception as e:
        print(e)

1. The decode is scheduled before the file is closed

refresh_channel_logo is @idle_function, so L676 queues a main-loop callback that
opens channel.logo_path and decodes it. That queueing happens inside the
with block, so the main loop can reach the file before the worker thread's
close() flushes Python's remaining write buffer.

copyfileobj moves 64 KB at a time and the buffered writer holds up to 8 KB, so the
tail of the image is the part at risk. The decode then fails, the except in
get_channel_surface swallows it, and the channel shows the generic placeholder
logo for the rest of the session even though the file on disk is complete moments
later.

Fix is to dedent one line so the refresh happens after the file is closed:

            with open(channel.logo_path, "wb") as f:
                shutil.copyfileobj(response.raw, f)
            self.refresh_channel_logo(channel, image)

2. A partial download is cached forever

open(path, "wb") creates the file immediately. If the transfer then fails, times
out, or the process exits, whatever arrived stays on disk. On the next run L668
(if os.path.isfile(...): continue) sees a file and skips it — so that channel is
stuck with a corrupt logo permanently, and clearing the icon cache is the only way
out.

It is not only partial transfers. Some logo hosts answer HTTP 200 with a
plain-text error body
, and since only status_code is checked, that body is
written under a .png/.jpg name and cached. One such file in this cache is 80
bytes and reads, in full:

The requested image could not be found but may be available again in the future.

"may be available again in the future" — but L668 guarantees Hypnotix will never ask
again. That channel keeps the placeholder icon permanently.

Decode-testing the 423 images in this cache with Pillow found 2 unusable: that error
page, and one zero-byte file. Neither is ever retried.

Writing to a temporary file and renaming on success fixes it, and makes the cache
atomic for readers at the same time:

            tmp = channel.logo_path + ".part"
            with open(tmp, "wb") as f:
                shutil.copyfileobj(response.raw, f)
            os.replace(tmp, channel.logo_path)
            self.refresh_channel_logo(channel, image)

with the except branch removing a leftover .part. os.replace is atomic on the
same filesystem, so a reader sees either no file or a complete one, which also
closes defect 1 without relying on the dedent alone.

Environment

hypnotix 5.6 · Python 3.14.7 · PyGObject 3.56.3 · GTK 3.24.52 · glycin 2.1.5 ·
Arch Linux, Wayland. Provider type: Xtream.

Not related to the thread-safety problems in #409, though both were found in the
same session.

Authored by Claude Opus 5 via Claude Code.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions