From 9806405626fd447721c279895ad9d8cebba30c37 Mon Sep 17 00:00:00 2001 From: Dan Reed Date: Tue, 28 Jul 2026 16:28:36 -0700 Subject: [PATCH 1/4] Serialize animated-image frame decodes; refuse damaged frames; fix frame-failure bookkeeping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CGImageSource is not safe for concurrent lazy decodes of the same source (despite the optimistic '*seems* immutable' comments), and PINCachedAnimatedImage reaches imageAtIndex:cacheProvider: concurrently from its caching queue, its init-time warmup dispatch, and coverImage callers. The racing decode crashes inside ImageIO (GIFReadPlugin::copyImageBlockSetImp / GIFBufferInfo memmove). - GIF, APNG, and WebP now serialize all ImageIO calls on the shared source — including CGImageSourceGetStatusAtIndex, which advances parser state — behind a per-image lock. - Refuse affirmatively damaged frames (kCGImageStatusInvalidData / kCGImageStatusUnexpectedEOF) before attempting creation. Deliberately narrow: kCGImageStatusIncomplete (trailer-less but renderable GIFs, common in the wild) still decodes. - A frame that fails to decode previously left _frameRenderCount held and its index parked in _cachedOrCachingFrames forever: playbackReady never fired and the frame was never retried. Failures now release their render slot. - pin_decodedImageRefWithCGImageRef: returned the borrowed input ref when CGBitmapContextCreate fails (memory pressure); animated callers then released it and returned the dangling pointer. The fallback now matches the success path's +0 autoreleased contract. Also guard NULL input. Fixes a long-standing top crasher in a large production app (EXC_BAD_ACCESS at PINImage+DecodedImage's CGContextDrawImage during GIF frame decode). --- .../AnimatedImages/PINAPNGAnimatedImage.m | 16 ++++++- .../AnimatedImages/PINCachedAnimatedImage.m | 43 +++++++++++-------- .../AnimatedImages/PINGIFAnimatedImage.m | 26 ++++++++++- .../AnimatedImages/PINWebPAnimatedImage.m | 20 +++++++-- .../Categories/PINImage+DecodedImage.m | 16 ++++++- 5 files changed, 93 insertions(+), 28 deletions(-) diff --git a/Source/Classes/AnimatedImages/PINAPNGAnimatedImage.m b/Source/Classes/AnimatedImages/PINAPNGAnimatedImage.m index 0d39d833..4bb174fb 100644 --- a/Source/Classes/AnimatedImages/PINAPNGAnimatedImage.m +++ b/Source/Classes/AnimatedImages/PINAPNGAnimatedImage.m @@ -31,6 +31,7 @@ @interface PINAPNGAnimatedImage () size_t _loopCount; CFTimeInterval *_durations; NSError *_error; + NSLock *_decodeLock; // serializes frame decodes on _imageSource } @end @@ -40,6 +41,7 @@ - (instancetype)initWithAnimatedImageData:(NSData *)animatedImageData { if (self = [super init]) { _animatedImageData = animatedImageData; + _decodeLock = [[NSLock alloc] init]; _imageSource = CGImageSourceCreateWithData((CFDataRef)animatedImageData, (CFDictionaryRef)@{(__bridge NSString *)kCGImageSourceTypeIdentifierHint: @@ -150,7 +152,16 @@ - (CFTimeInterval)durationAtIndex:(NSUInteger)index - (CGImageRef)imageAtIndex:(NSUInteger)index cacheProvider:(nullable id)cacheProvider { - // I believe this is threadsafe as CGImageSource *seems* immutable… + // same hardening as PINGIFAnimatedImage — serialize + // all ImageIO calls on the shared source (including the status query, which + // advances parser state), and refuse affirmatively damaged frames. + [_decodeLock lock]; + CGImageSourceStatus frameStatus = CGImageSourceGetStatusAtIndex(_imageSource, index); + if (frameStatus == kCGImageStatusInvalidData || frameStatus == kCGImageStatusUnexpectedEOF) { + [_decodeLock unlock]; + return NULL; + } + CGImageRef imageRef = CGImageSourceCreateImageAtIndex(_imageSource, index, @@ -161,7 +172,8 @@ - (CGImageRef)imageAtIndex:(NSUInteger)index cacheProvider:(nullable id_animatedImage imageAtIndex:frameIndex cacheProvider:self]; PINLog(@"Generating: %lu", (unsigned long)frameIndex); - if (imageRef) { - __block PINImage *coverImage = nil; - __block PINAnimatedImageInfoReady coverImageReadyCallback = nil; - [self->_lock lockWithBlock:^{ + __block PINImage *coverImage = nil; + __block PINAnimatedImageInfoReady coverImageReadyCallback = nil; + [self->_lock lockWithBlock:^{ + if (imageRef) { [self->_frameCache setObject:(__bridge id _Nonnull)(imageRef) forKey:@(frameIndex)]; // Update the cover image @@ -373,26 +373,31 @@ - (void)_cacheWithFrameIndex:(NSUInteger)frameIndex coverImageReadyCallback = notifyCallback ? self->_coverImageReadyCallback : nil; coverImage = self->_coverImage; } + } else { + // a frame that fails to decode must release its + // render slot; leaving it in _cachedOrCachingFrames with _frameRenderCount + // held meant playbackReady never fired and the frame was never retried. + [self->_cachedOrCachingFrames removeIndex:frameIndex]; + } - self->_frameRenderCount--; - NSAssert(self->_frameRenderCount >= 0, @"playback ready is less than zero, something is wrong :("); + self->_frameRenderCount--; + NSAssert(self->_frameRenderCount >= 0, @"playback ready is less than zero, something is wrong :("); - PINLog(@"Frames left: %ld", (long)_frameRenderCount); + PINLog(@"Frames left: %ld", (long)_frameRenderCount); - dispatch_block_t notify = nil; - if (self->_frameRenderCount == 0 && self->_notifyOnReady) { - self->_notifyOnReady = NO; - if (self->_playbackReadyCallback) { - notify = self->_playbackReadyCallback; - [self->_operationQueue scheduleOperation:^{ - notify(); - }]; - } + dispatch_block_t notify = nil; + if (self->_frameRenderCount == 0 && self->_notifyOnReady) { + self->_notifyOnReady = NO; + if (self->_playbackReadyCallback) { + notify = self->_playbackReadyCallback; + [self->_operationQueue scheduleOperation:^{ + notify(); + }]; } - }]; - if (coverImageReadyCallback) { - coverImageReadyCallback(coverImage); } + }]; + if (coverImageReadyCallback) { + coverImageReadyCallback(coverImage); } } diff --git a/Source/Classes/AnimatedImages/PINGIFAnimatedImage.m b/Source/Classes/AnimatedImages/PINGIFAnimatedImage.m index e93763e8..d2b9e74f 100644 --- a/Source/Classes/AnimatedImages/PINGIFAnimatedImage.m +++ b/Source/Classes/AnimatedImages/PINGIFAnimatedImage.m @@ -29,6 +29,7 @@ @interface PINGIFAnimatedImage () size_t _loopCount; CFTimeInterval *_durations; NSError *_error; + NSLock *_decodeLock; // serializes frame decodes on _imageSource } @end @@ -38,6 +39,7 @@ - (instancetype)initWithAnimatedImageData:(NSData *)animatedImageData { if (self = [super init]) { _animatedImageData = animatedImageData; + _decodeLock = [[NSLock alloc] init]; _imageSource = CGImageSourceCreateWithData((CFDataRef)animatedImageData, (CFDictionaryRef)@{(__bridge NSString *)kCGImageSourceTypeIdentifierHint: @@ -148,7 +150,26 @@ - (CFTimeInterval)durationAtIndex:(NSUInteger)index - (CGImageRef)imageAtIndex:(NSUInteger)index cacheProvider:(nullable id)cacheProvider { - // I believe this is threadsafe as CGImageSource *seems* immutable… + // serialize ALL ImageIO calls on the shared source. + // Despite the optimistic comment this replaced ("CGImageSource *seems* immutable"), + // CGImageSource is not safe for concurrent access, and PINCachedAnimatedImage + // reaches this concurrently from the caching queue, the init-time warmup block, + // and coverImage callers. The status query below also advances ImageIO parser + // state, so it must be inside the lock too. + [_decodeLock lock]; + + // Refuse frames whose data is affirmatively damaged. Frames are decoded lazily + // (kCGImageSourceShouldCache is false), so a damaged frame doesn't fail at + // creation — it crashes later inside CGContextDrawImage when CoreGraphics + // dereferences the failed decode. Only hard-failure statuses are rejected: + // kCGImageStatusIncomplete is what trailer-less-but-renderable GIFs (common in + // the wild) report, and those decode fine. + CGImageSourceStatus frameStatus = CGImageSourceGetStatusAtIndex(_imageSource, index); + if (frameStatus == kCGImageStatusInvalidData || frameStatus == kCGImageStatusUnexpectedEOF) { + [_decodeLock unlock]; + return NULL; + } + CGImageRef imageRef = CGImageSourceCreateImageAtIndex(_imageSource, index, @@ -159,7 +180,8 @@ - (CGImageRef)imageAtIndex:(NSUInteger)index cacheProvider:(nullable id)cacheProvider { - // I believe this is threadsafe as CGImageSource *seems* immutable… + // same hardening as PINGIFAnimatedImage — serialize + // all ImageIO calls on the shared source (including the status query, which + // advances parser state), and refuse affirmatively damaged frames. CGImageSource + // is not safe for concurrent lazy decodes of the same source, despite the + // optimistic comment this replaced. + [_decodeLock lock]; + CGImageSourceStatus frameStatus = CGImageSourceGetStatusAtIndex(_imageSource, index); + if (frameStatus == kCGImageStatusInvalidData || frameStatus == kCGImageStatusUnexpectedEOF) { + [_decodeLock unlock]; + return NULL; + } + CGImageRef imageRef = CGImageSourceCreateImageAtIndex(_imageSource, index, @@ -163,7 +176,8 @@ - (CGImageRef)imageAtIndex:(NSUInteger)index cacheProvider:(nullable id Date: Wed, 29 Jul 2026 10:24:57 -0700 Subject: [PATCH 2/4] Fix CI for current macos-latest runner image (macOS 26) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI was failing at the first xcrun/xcodebuild invocation with "xcrun: error: missing DEVELOPER_DIR path". Root cause: GitHub's macos-latest label now resolves to the macOS 26 runner image, which only ships Xcode 26.x — the pinned Xcode (and several other things the build relied on) no longer exist on the image. Changes, following TextureGroup/Texture's CI setup: - DEVELOPER_DIR now points at Xcode_26.5.0.app (the image default; see the runner-images manifest linked in the workflow comment). - Makefile simulator destination bumped from "iPhone 16,OS=18.5" to "iPhone 17" with a version-less iphonesimulator SDK, since neither the iPhone 16 device nor the iOS 18.5 runtime exist under Xcode 26. Leaving the SDK unversioned means it floats with the selected Xcode instead of breaking on every image bump. - xcpretty replaced with xcbeautify: the runner images stopped preinstalling the xcpretty gem (macOS 26 ships only xcbeautify 3.2.1), so the "xcodebuild | xcpretty" pipes would fail with command-not-found. Error propagation is unchanged — the Makefile's "SHELL=/bin/bash -o pipefail" still surfaces xcodebuild failures through the pipe. - Removed the platform matrix from the analyze/test jobs; nothing ever referenced matrix.platform (the Makefile owns the destination). Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yaml | 11 +++-------- .github/workflows/publish_release.yml | 5 +++-- Makefile | 10 +++++----- 3 files changed, 11 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 67f517ed..fcc1e0e5 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -11,8 +11,9 @@ on: - master env: - # Use Xcode 15.2 or newer to support VisionOS - DEVELOPER_DIR: /Applications/Xcode_16.4.app + # Must exist on the current macos-latest runner image: + # https://github.com/actions/runner-images/blob/main/images/macos/macos-26-arm64-Readme.md + DEVELOPER_DIR: /Applications/Xcode_26.5.0.app/Contents/Developer jobs: debuggithub: @@ -28,9 +29,6 @@ jobs: analyze: name: Analyze runs-on: macos-latest - strategy: - matrix: - platform: ['iOS Simulator,name=iPhone 16,OS=18.5'] steps: - uses: actions/checkout@v2 - name: Analyze @@ -38,9 +36,6 @@ jobs: test: name: Test runs-on: macos-latest - strategy: - matrix: - platform: ['iOS Simulator,name=iPhone 16,OS=18.5'] steps: - uses: actions/checkout@v2 - name: Test diff --git a/.github/workflows/publish_release.yml b/.github/workflows/publish_release.yml index 021f4de1..451e7e3a 100644 --- a/.github/workflows/publish_release.yml +++ b/.github/workflows/publish_release.yml @@ -7,8 +7,9 @@ on: required: true env: - # Use Xcode 15.2 or newer to support VisionOS - DEVELOPER_DIR: /Applications/Xcode_16.4.app + # Must exist on the current macos-latest runner image: + # https://github.com/actions/runner-images/blob/main/images/macos/macos-26-arm64-Readme.md + DEVELOPER_DIR: /Applications/Xcode_26.5.0.app/Contents/Developer jobs: create_release: diff --git a/Makefile b/Makefile index cf21392a..98f9c679 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ -PLATFORM="platform=iOS Simulator,name=iPhone 16,OS=18.5" -SDK="iphonesimulator18.5" +PLATFORM="platform=iOS Simulator,name=iPhone 17" +SDK="iphonesimulator" SHELL=/bin/bash -o pipefail XCODE_MAJOR_VERSION=$(shell xcodebuild -version | HEAD -n 1 | sed -E 's/Xcode ([0-9]+).*/\1/') IOS_EXAMPLE_PROJECT="Examples/Example-Xcode-SPM/Example-Xcode-SPM.xcodeproj" @@ -14,13 +14,13 @@ analyze: xcodebuild clean analyze -destination ${PLATFORM} -sdk ${SDK} -workspace PINRemoteImage.xcworkspace -scheme PINRemoteImage \ CODE_SIGNING_REQUIRED=NO \ CLANG_ANALYZER_OUTPUT=plist-html \ - CLANG_ANALYZER_OUTPUT_DIR="$(shell pwd)/clang" | xcpretty + CLANG_ANALYZER_OUTPUT_DIR="$(shell pwd)/clang" | xcbeautify if [[ -n `find $(shell pwd)/clang -name "*.html"` ]] ; then rm -rf `pwd`/clang; exit 1; fi rm -rf $(shell pwd)/clang test: xcodebuild clean test -destination ${PLATFORM} -sdk ${SDK} -workspace PINRemoteImage.xcworkspace -scheme PINRemoteImage \ - CODE_SIGNING_REQUIRED=NO | xcpretty + CODE_SIGNING_REQUIRED=NO | xcbeautify carthage: carthage update --no-use-binaries --no-build @@ -36,6 +36,6 @@ example: fi xcodebuild clean build -project ${IOS_EXAMPLE_PROJECT} -scheme ${EXAMPLE_SCHEME} -destination ${PLATFORM} -sdk ${SDK} \ ONLY_ACTIVE_ARCH=NO \ - CODE_SIGNING_REQUIRED=NO | xcpretty + CODE_SIGNING_REQUIRED=NO | xcbeautify all: carthage test cocoapods analyze spm example \ No newline at end of file From 78c2a8db0c2a6558a65f9b911e0cd29be8a70443 Mon Sep 17 00:00:00 2001 From: Dan Reed Date: Wed, 29 Jul 2026 11:29:06 -0700 Subject: [PATCH 3/4] Use PINRemoteLock for frame-decode serialization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback on #656: the project convention is PINRemoteLock (PINCachedAnimatedImage et al), not bare NSLock. Behavior is unchanged — PINRemoteLock wraps a pthread_mutex in release builds and an NSLock in debug builds — but this keeps house style and gives the locks debuggable names. Co-Authored-By: Claude Fable 5 --- Source/Classes/AnimatedImages/PINAPNGAnimatedImage.m | 5 +++-- Source/Classes/AnimatedImages/PINGIFAnimatedImage.m | 5 +++-- Source/Classes/AnimatedImages/PINWebPAnimatedImage.m | 5 +++-- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/Source/Classes/AnimatedImages/PINAPNGAnimatedImage.m b/Source/Classes/AnimatedImages/PINAPNGAnimatedImage.m index 4bb174fb..55681211 100644 --- a/Source/Classes/AnimatedImages/PINAPNGAnimatedImage.m +++ b/Source/Classes/AnimatedImages/PINAPNGAnimatedImage.m @@ -19,6 +19,7 @@ #import #import +#import "PINRemoteLock.h" @interface PINAPNGAnimatedImage () { @@ -31,7 +32,7 @@ @interface PINAPNGAnimatedImage () size_t _loopCount; CFTimeInterval *_durations; NSError *_error; - NSLock *_decodeLock; // serializes frame decodes on _imageSource + PINRemoteLock *_decodeLock; // serializes frame decodes on _imageSource } @end @@ -41,7 +42,7 @@ - (instancetype)initWithAnimatedImageData:(NSData *)animatedImageData { if (self = [super init]) { _animatedImageData = animatedImageData; - _decodeLock = [[NSLock alloc] init]; + _decodeLock = [[PINRemoteLock alloc] initWithName:@"PINAPNGAnimatedImage decode lock"]; _imageSource = CGImageSourceCreateWithData((CFDataRef)animatedImageData, (CFDictionaryRef)@{(__bridge NSString *)kCGImageSourceTypeIdentifierHint: diff --git a/Source/Classes/AnimatedImages/PINGIFAnimatedImage.m b/Source/Classes/AnimatedImages/PINGIFAnimatedImage.m index d2b9e74f..0e789db5 100644 --- a/Source/Classes/AnimatedImages/PINGIFAnimatedImage.m +++ b/Source/Classes/AnimatedImages/PINGIFAnimatedImage.m @@ -17,6 +17,7 @@ #import #import +#import "PINRemoteLock.h" @interface PINGIFAnimatedImage () { @@ -29,7 +30,7 @@ @interface PINGIFAnimatedImage () size_t _loopCount; CFTimeInterval *_durations; NSError *_error; - NSLock *_decodeLock; // serializes frame decodes on _imageSource + PINRemoteLock *_decodeLock; // serializes frame decodes on _imageSource } @end @@ -39,7 +40,7 @@ - (instancetype)initWithAnimatedImageData:(NSData *)animatedImageData { if (self = [super init]) { _animatedImageData = animatedImageData; - _decodeLock = [[NSLock alloc] init]; + _decodeLock = [[PINRemoteLock alloc] initWithName:@"PINGIFAnimatedImage decode lock"]; _imageSource = CGImageSourceCreateWithData((CFDataRef)animatedImageData, (CFDictionaryRef)@{(__bridge NSString *)kCGImageSourceTypeIdentifierHint: diff --git a/Source/Classes/AnimatedImages/PINWebPAnimatedImage.m b/Source/Classes/AnimatedImages/PINWebPAnimatedImage.m index 3f13fe32..a2c4c2b0 100644 --- a/Source/Classes/AnimatedImages/PINWebPAnimatedImage.m +++ b/Source/Classes/AnimatedImages/PINWebPAnimatedImage.m @@ -20,6 +20,7 @@ #import #import +#import "PINRemoteLock.h" @interface PINWebPAnimatedImage () { @@ -32,7 +33,7 @@ @interface PINWebPAnimatedImage () size_t _loopCount; CFTimeInterval *_durations; NSError *_error; - NSLock *_decodeLock; // serializes frame decodes on _imageSource + PINRemoteLock *_decodeLock; // serializes frame decodes on _imageSource } @end @@ -42,7 +43,7 @@ - (instancetype)initWithAnimatedImageData:(NSData *)animatedImageData { if (self = [super init]) { _animatedImageData = animatedImageData; - _decodeLock = [[NSLock alloc] init]; + _decodeLock = [[PINRemoteLock alloc] initWithName:@"PINWebPAnimatedImage decode lock"]; _imageSource = CGImageSourceCreateWithData((CFDataRef)animatedImageData, From a31ead49f30da71624280c9ade5f6feafa55bb82 Mon Sep 17 00:00:00 2001 From: Dan Reed Date: Wed, 29 Jul 2026 11:29:06 -0700 Subject: [PATCH 4/4] Tolerate pinimg CDN size drift in testQOS width assertions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit testQOS asserts exact pixel widths (345/564/736) on live pinimg CDN derivatives, and the CDN periodically re-encodes them a pixel off nominal — it currently serves 344/563/735, failing CI (and commit 89f4be7 was the same chase last time). The test only needs to distinguish small/medium/large, so compare with XCTAssertEqualWithAccuracy and a ±2px tolerance instead of chasing exact values. Co-Authored-By: Claude Fable 5 --- Tests/PINRemoteImageTests.m | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/Tests/PINRemoteImageTests.m b/Tests/PINRemoteImageTests.m index 4d56e181..b90494cd 100644 --- a/Tests/PINRemoteImageTests.m +++ b/Tests/PINRemoteImageTests.m @@ -152,6 +152,12 @@ - (NSURL *)headersURL return [NSURL URLWithString:@"https://httpbin.org/headers"]; } +// The pinimg CDN periodically re-encodes these derivatives, drifting the pixel +// width by a pixel or so around the nominal size in the URL (e.g. 736x has served +// both 736- and 735-wide images). Tests only need to tell the three sizes apart, +// so compare widths with XCTAssertEqualWithAccuracy and this tolerance. +static const CGFloat PINSizeVariantWidthAccuracy = 2; + - (NSURL *)JPEGURL_Small { return [NSURL URLWithString:@"https://i.pinimg.com/345x/1b/bc/c2/1bbcc264683171eb3815292d2f546e92.jpg"]; @@ -975,7 +981,7 @@ - (void)testQOS completion:^(PINRemoteImageManagerResult *result) { image = result.image; - XCTAssertEqual(image.size.width, 736, @"Large image should be downloaded. result.image: %@, result.error: %@", result.image, result.error); + XCTAssertEqualWithAccuracy(image.size.width, 736, PINSizeVariantWidthAccuracy, @"Large image should be downloaded. result.image: %@, result.error: %@", result.image, result.error); dispatch_semaphore_signal(semaphore); }]; XCTAssert(dispatch_semaphore_wait(semaphore, [self timeout]) == 0, @"Semaphore timed out."); @@ -991,7 +997,7 @@ - (void)testQOS completion:^(PINRemoteImageManagerResult *result) { image = result.image; - XCTAssertEqual(image.size.width, 736, @"Large image should be found in cache"); + XCTAssertEqualWithAccuracy(image.size.width, 736, PINSizeVariantWidthAccuracy, @"Large image should be found in cache"); dispatch_semaphore_signal(semaphore); }]; XCTAssert(dispatch_semaphore_wait(semaphore, [self timeout]) == 0, @"Semaphore timed out."); @@ -1003,7 +1009,7 @@ - (void)testQOS completion:^(PINRemoteImageManagerResult *result) { image = result.image; - XCTAssert(image.size.width == 345, @"Small image should be downloaded at low bps"); + XCTAssertEqualWithAccuracy(image.size.width, 345, PINSizeVariantWidthAccuracy, @"Small image should be downloaded at low bps"); dispatch_semaphore_signal(semaphore); }]; XCTAssert(dispatch_semaphore_wait(semaphore, [self timeout]) == 0, @"Semaphore timed out."); @@ -1017,7 +1023,7 @@ - (void)testQOS completion:^(PINRemoteImageManagerResult *result) { image = result.image; - XCTAssert(image.size.width == 345, @"Small image should be found in cache"); + XCTAssertEqualWithAccuracy(image.size.width, 345, PINSizeVariantWidthAccuracy, @"Small image should be found in cache"); dispatch_semaphore_signal(semaphore); }]; XCTAssert(dispatch_semaphore_wait(semaphore, [self timeout]) == 0, @"Semaphore timed out."); @@ -1034,7 +1040,7 @@ - (void)testQOS completion:^(PINRemoteImageManagerResult *result) { image = result.image; - XCTAssert(image.size.width == 564, @"Medium image should be now downloaded"); + XCTAssertEqualWithAccuracy(image.size.width, 564, PINSizeVariantWidthAccuracy, @"Medium image should be now downloaded"); dispatch_semaphore_signal(semaphore); }]; XCTAssert(dispatch_semaphore_wait(semaphore, [self timeout]) == 0, @"Semaphore timed out."); @@ -1063,7 +1069,7 @@ - (void)testQOS completion:^(PINRemoteImageManagerResult *result) { image = result.image; - XCTAssert(image.size.width == 345, @"Small image should be now downloaded"); + XCTAssertEqualWithAccuracy(image.size.width, 345, PINSizeVariantWidthAccuracy, @"Small image should be now downloaded"); dispatch_semaphore_signal(semaphore); }]; XCTAssert(dispatch_semaphore_wait(semaphore, [self timeout]) == 0, @"Semaphore timed out.");