Skip to content

Saved GIFs panel: infinite decode loop + permanent 100% CPU hang on unsupported pixel format (e.g. H.264 4:4:4) #2246

Description

@mafetx
1482267764-364e4719925fe8cc082f5a4e3adadf59.mp4

Telegram-2026-07-21-222521.txt

Summary

Adding a single video file with an unsupported chroma subsampling (in my case H.264 High 4:4:4 Predictive, yuv444p) to Saved GIFs causes the entire GIF panel to permanently hang and pin the CPU at ~100% (device heats up) from the moment the panel is opened. The condition never recovers — not by closing the panel, not by navigating away, not by waiting — only force-quitting the app stops it. Removing the file from Saved GIFs fully resolves the issue.

Environment

  • Device: iPhone 15 Pro Max
  • iOS: 26.5.2 (23F84)
  • Telegram: 12.9.2 (34474)
  • Attached: crash/hang report (.ips, RUNNINGBOARD SIGKILL, 0xdeadfa1d) captured while reproducing
  • Attached: the exact offending video file

Root cause (traced in source)

  1. submodules/FFMpegBinding/Sources/FFMpegAVFrame.m-[FFMpegAVFrame pixelFormat] only recognizes AV_PIX_FMT_YUV420P / YUVJ420P / YUVA420P. Any other pixel format (4:2:2, 4:4:4, 10-bit, etc.) falls into default: return FFMpegAVFramePixelFormatUnsupported.

  2. submodules/MediaPlayer/Sources/FFMpegMediaVideoFrameDecoder.swiftisPlanarYUV420Safe(_:) correctly rejects .Unsupported frames, so convertVideoFrame(...) returns nil for every single frame of an unsupported-format file. This is working as intended defensively (per the comment referencing a prior YUV422P fix) — the frame is just never converted.

  3. FFMpegFileReader.readFrame() — since decoder.decode() never returns a frame, the read loop correctly drains all packets and returns .endOfStream once the file is exhausted. This part behaves correctly.

  4. The actual bug — submodules/TelegramUI/Components/BatchVideoRendering/Sources/BatchVideoRenderingContext.swift, ReadingContext.advance():

switch reader.readFrame() {
case let .frame(frame):
    return createSampleBuffer(...)
case .error:
    self.isFailed = true
    break outer
case .endOfStream:
    self.reader = nil          // <-- BUG: does not set isFailed, does not break
case .waitingForMoreData:
    self.isFailed = true
    break outer
}

.error and .waitingForMoreData correctly set isFailed = true and exit the outer while true loop. .endOfStream does neither — it just discards the reader and lets the outer loop immediately create a brand-new FFMpegFileReader, re-open the file, re-decode every packet from scratch, fail every frame conversion again, hit .endOfStream again, and repeat — forever, with no exit condition, entirely inside one synchronous call to advance().

Because advance() is called from BatchVideoRenderingContext.sharedQueue (a single serial queue shared by every visible GIF/sticker target), this infinite loop:

  • Pins one CPU core at 100% indefinitely (device heats up)
  • Permanently blocks the shared queue, so no other visible tile's frames are ever processed again — the entire panel freezes, not just the offending tile
  • Cannot be recovered from at the Swift level (shouldBeAnimating = false on panel close has no effect, since the offending synchronous call on the shared queue never returns) — only killing the process frees it

Any file whose video stream uses a pixel format outside YUV420P/YUVJ420P/YUVA420P (4:2:2, 4:4:4, 10-bit variants, etc.) should reproduce this deterministically, regardless of resolution or bitrate — my repro file is only 400x246 @ 509kbps, so this is not a resource/size issue, it's a logic bug.

Steps to reproduce

  1. Add the attached video file to Saved GIFs (e.g. forward to Saved Messages, save as GIF).
  2. Open the GIF panel in the chat attachment tray.
  3. Observe: CPU pins near 100%, device begins heating within seconds.
  4. Close the panel / leave the chat / background the app — the condition does not clear.
  5. Force-quit the app — this is the only way to stop it.
  6. Remove the file from Saved GIFs — panel behaves normally again.

Suggested fix

In ReadingContext.advance(), treat .endOfStream the same defensively as .error/.waitingForMoreData when zero frames have ever been successfully produced by the reader — set isFailed = true and break instead of silently recreating the reader. A minimal fix:

case .endOfStream:
    if framesProducedByThisReader == 0 {
        self.isFailed = true
        break outer
    }
    self.reader = nil

Suggested follow-up improvements (secondary, not blocking the crash fix)

  • Per-target decode watchdog / timeout: no single file should be able to block the shared serial decode queue indefinitely. Consider a max wall-clock budget per advance() call, or moving decode for each target off the single shared queue onto a bounded concurrent pool so one bad file can't starve every other visible tile.
  • Decoded-frame caching for short loops: GifVideoLayer/BatchVideoRenderingContext currently re-decodes every frame from disk on every loop of a Saved GIF (ReadingContext is recreated from scratch each time .endOfStream is hit even in the working case). For short clips (Saved GIFs are typically a few seconds), caching decoded frames after the first loop would avoid continuous CPU cost for content that never changes, instead of paying full software-decode cost on every repeat.
  • A working reference already exists in this codebase: submodules/AnimatedStickerNode/Sources/VideoStickerFrameSource.swift (used for video/Premium stickers everywhere in chats/channels) handles this correctly — takeFrame() is called once per animation tick (not in an internal unbounded loop), and it already caches decoded frames via cache?.storeUncompressedRgbFrame/readUncompressedYuvaFrame so subsequent loops read from cache instead of re-decoding. BatchVideoRenderingContext could adopt the same pattern instead of introducing a new one.
  • Crash/hang telemetry for this class of failure: since this SIGKILL is a plain RunningBoard "unresponsive" kill (not a Swift/ObjC exception), it likely doesn't get automatically triaged as a Telegram-specific bug by any internal crash-grouping — consider detecting "zero frames produced, high loop count" as a distinct signal worth reporting client-side.

Attachments

  • Offending video file (H.264, High 4:4:4 Predictive, yuv444p, 400x246, 15.12s, 378 frames)
  • .ips hang/crash report from the device

Workaround

Re-encoding the file to H.264 yuv420p (standard 4:2:0 chroma) before adding it to Saved GIFs avoids the crash entirely.

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