From 8d5fa55c0fb5f791217dbf24649d3b1781bf37c5 Mon Sep 17 00:00:00 2001 From: NachoSoto Date: Sat, 11 Jul 2026 10:52:14 -0700 Subject: [PATCH 1/5] [Performance] Reduce multicast renderer contention --- .../Renderers/MulticastedRenderer.swift | 154 +++++++++--- .../MulticastedRendererTests.swift | 222 ++++++++++++++++++ 2 files changed, 340 insertions(+), 36 deletions(-) create mode 100644 AsyncImageViewTests/MulticastedRendererTests.swift diff --git a/AsyncImageView/Renderers/MulticastedRenderer.swift b/AsyncImageView/Renderers/MulticastedRenderer.swift index 99dcca8..993c486 100644 --- a/AsyncImageView/Renderers/MulticastedRenderer.swift +++ b/AsyncImageView/Renderers/MulticastedRenderer.swift @@ -10,8 +10,27 @@ import UIKit import ReactiveSwift -// The initial value is `nil`. -private typealias ImageProperty = Property +private final class InFlightImage { + let result: Property + let observer: Signal.Observer + let disposable = SerialDisposable() + + init() { + let (signal, observer) = Signal.pipe() + self.result = Property(initial: nil, then: signal) + self.observer = observer + } +} + +private enum CachedImage { + case rendering(InFlightImage, latest: ImageResult?) + case completed(ImageResult) +} + +private enum CachedImageLookup { + case rendering(InFlightImage, shouldStart: Bool) + case completed(ImageResult) +} /// `RendererType` decorator which guarantees that images for a given `RenderDataType` /// are only rendered once, and multicasted to every observer. @@ -23,7 +42,7 @@ public final class MulticastedRenderer< Renderer.Data == Data, Renderer.Error == Never { private let renderer: Renderer - private let cache: Atomic<[Data: ImageProperty]> + private let cache: Atomic<[Data: CachedImage]> #if !os(watchOS) private let memoryWarningDisposable: Disposable @@ -45,36 +64,111 @@ public final class MulticastedRenderer< } public func renderImageWithData(_ data: Data) -> SignalProducer { - let property = getPropertyForData(data) + switch self.lookup(for: data) { + case let .completed(result): + return SignalProducer(value: result) - return property.producer - .filter { $0 != nil } // Skip initial `nil` value. - .map { $0! } - .take(first: 1) - } + case let .rendering(entry, shouldStart): + if shouldStart { + self.startRendering(data, into: entry) + } - private func getPropertyForData(_ data: Data) -> ImageProperty { - var result: ImageProperty! + return self.producer(for: data, entry: entry) + } + } - self.cache.modify { cache in - if let property = cache[data] { - result = property - } else { - result = ImageProperty( - initial: nil, - then: renderer.createProducerForRenderingData(data) - .map(Optional.init) - ) - - cache[data] = result + private func lookup(for data: Data) -> CachedImageLookup { + return self.cache.modify { cache in + switch cache[data] { + case let .completed(result), let .rendering(_, latest: result?): + return .completed(result) + + case let .rendering(entry, latest: nil): + return .rendering(entry, shouldStart: false) + + case nil: + let entry = InFlightImage() + cache[data] = .rendering(entry, latest: nil) + + return .rendering(entry, shouldStart: true) } } + } - return result + private func startRendering(_ data: Data, into entry: InFlightImage) { + entry.disposable.inner = self.renderer + .renderImageWithData(data) + .start { [weak self, weak entry] event in + guard let self, let entry else { return } + + if let result = event.value { + self.receive(result, for: data, entry: entry) + } else if event.isTerminating { + self.finishRendering(data, entry: entry) + } + } + } + + private func receive(_ result: Renderer.RenderResult, for data: Data, entry: InFlightImage) { + let cacheMiss = ImageResult( + image: result.image, + cacheHit: false, + shouldCache: result.shouldCache + ) + + guard result.shouldCache else { + entry.observer.send(value: cacheMiss) + self.remove(entry, for: data) + + return + } + + let cacheHit = ImageResult(image: result.image, cacheHit: true, shouldCache: true) + self.cache.modify { cache in + guard case let .rendering(current, _) = cache[data], current === entry else { return } + cache[data] = .rendering(entry, latest: cacheHit) + } + + entry.observer.send(value: cacheMiss) + entry.observer.send(value: cacheHit) + } + + private func finishRendering(_ data: Data, entry: InFlightImage) { + self.cache.modify { cache in + guard case let .rendering(current, latest) = cache[data], current === entry else { return } + + cache[data] = latest.map(CachedImage.completed) + } + entry.observer.sendCompleted() + } + + private func remove(_ entry: InFlightImage, for data: Data) { + self.cache.modify { cache in + guard case let .rendering(current, _) = cache[data], current === entry else { return } + cache[data] = nil + } + } + + private func producer(for data: Data, entry: InFlightImage) -> SignalProducer { + return SignalProducer { [weak self, entry] observer, lifetime in + let cachedResult = self?.cache.withValue { cache -> ImageResult? in + switch cache[data] { + case let .completed(result), let .rendering(_, latest: result?): + return result + + case .rendering(_, latest: nil), nil: + return nil + } + } + + let producer = cachedResult.map(SignalProducer.init(value:)) + ?? entry.result.producer.compactMap { $0 }.take(first: 1) + lifetime += producer.start(observer) + } } #if !os(watchOS) - private static func clearCacheOnMemoryWarning(_ cache: Atomic<[Data: ImageProperty]>) -> Disposable { + private static func clearCacheOnMemoryWarning(_ cache: Atomic<[Data: CachedImage]>) -> Disposable { return NotificationCenter.default .reactive.notifications(forName: UIApplication.didReceiveMemoryWarningNotification, object: nil) .observe(on: QueueScheduler()) @@ -91,15 +185,3 @@ extension RendererType where Error == Never { return MulticastedRenderer(renderer: self) } } - -extension RendererType { - fileprivate func createProducerForRenderingData(_ data: Data) -> SignalProducer { - return self.renderImageWithData(data) - .flatMap(.concat) { result in - return SignalProducer([ - ImageResult(image: result.image, cacheHit: false, shouldCache: result.shouldCache), - ImageResult(image: result.image, cacheHit: true, shouldCache: result.shouldCache) - ]) - } - } -} diff --git a/AsyncImageViewTests/MulticastedRendererTests.swift b/AsyncImageViewTests/MulticastedRendererTests.swift new file mode 100644 index 0000000..e81e99b --- /dev/null +++ b/AsyncImageViewTests/MulticastedRendererTests.swift @@ -0,0 +1,222 @@ +// +// MulticastedRendererTests.swift +// AsyncImageViewTests +// +// Created by Nacho Soto on 7/11/26. +// Copyright © 2026 Nacho Soto. All rights reserved. +// + +import Foundation +import Testing +import UIKit + +import ReactiveSwift + +@testable import AsyncImageView + +@Suite +struct MulticastedRendererTests { + @Test + func rendersDifferentKeysConcurrently() { + let source = BlockingRenderer() + let renderer = UncheckedSendable(source.multicasted()) + let group = DispatchGroup() + let queue = DispatchQueue(label: #function, attributes: .concurrent) + + for identifier in 0..<2 { + group.enter() + queue.async { + let data = MulticastRenderData(identifier: identifier) + _ = renderer.value.renderImageWithData(data).single() + group.leave() + } + } + + let firstStarted = source.started.wait(timeout: .now() + 2) + let secondStarted = source.started.wait(timeout: .now() + 2) + source.release.signal() + source.release.signal() + let completed = group.wait(timeout: .now() + 2) + + #expect(firstStarted == .success) + #expect(secondStarted == .success) + #expect(completed == .success) + } + + @Test + func preservesTheLatestValueFromAMultiValueRenderer() throws { + let firstImage = makeImage(size: CGSize(width: 16, height: 16)) + let secondImage = makeImage(size: CGSize(width: 32, height: 32)) + let source = MultiValueRenderer() + let renderer = source.multicasted() + let data = MulticastRenderData(identifier: 1) + var initial: ImageResult? + let disposable = renderer.renderImageWithData(data).startWithValues { initial = $0 } + defer { disposable.dispose() } + + source.observer.send(value: firstImage) + let firstHit = try #require(renderer.renderImageWithData(data).single()?.get()) + + source.observer.send(value: secondImage) + let secondHit = try #require(renderer.renderImageWithData(data).single()?.get()) + + source.observer.sendCompleted() + let completedHit = try #require(renderer.renderImageWithData(data).single()?.get()) + + #expect(source.renderCount == 1) + #expect(initial?.image === firstImage) + #expect(initial?.cacheHit == false) + #expect(firstHit.image === firstImage) + #expect(firstHit.cacheHit == true) + #expect(secondHit.image === secondImage) + #expect(secondHit.cacheHit == true) + #expect(completedHit.image === secondImage) + #expect(completedHit.cacheHit == true) + } + + @Test + func sharesOneUpstreamRenderBetweenConcurrentWaiters() throws { + let image = makeImage() + let source = MultiValueRenderer() + let renderer = source.multicasted() + let data = MulticastRenderData(identifier: 1) + let firstProducer = renderer.renderImageWithData(data) + let secondProducer = renderer.renderImageWithData(data) + var first: ImageResult? + var second: ImageResult? + let firstDisposable = firstProducer.startWithValues { first = $0 } + let secondDisposable = secondProducer.startWithValues { second = $0 } + defer { + firstDisposable.dispose() + secondDisposable.dispose() + } + + source.observer.send(value: image) + source.observer.sendCompleted() + + let firstResult = try #require(first) + let secondResult = try #require(second) + #expect(source.renderCount == 1) + #expect(firstResult.image === image) + #expect(secondResult.image === image) + #expect(firstResult.cacheHit == false) + #expect(secondResult.cacheHit == false) + } + + @Test + func retriesAfterANonCacheableResult() throws { + let source = RecoveringRenderer() + let renderer = source.multicasted() + let data = MulticastRenderData(identifier: 1) + + let failed = try #require(renderer.renderImageWithData(data).single()?.get()) + let recovered = try #require(renderer.renderImageWithData(data).single()?.get()) + let cached = try #require(renderer.renderImageWithData(data).single()?.get()) + + #expect(source.renderCount == 2) + #expect(failed.cacheHit == false) + #expect(failed.shouldCache == false) + #expect(recovered.shouldCache == true) + #expect(cached.cacheHit == true) + } + + @Test + func retriesAfterTheUpstreamCompletesWithoutAValue() throws { + let source = EmptyThenValueRenderer() + let renderer = source.multicasted() + let data = MulticastRenderData(identifier: 1) + + let empty = renderer.renderImageWithData(data).single() + let recovered = try #require(renderer.renderImageWithData(data).single()?.get()) + let cached = try #require(renderer.renderImageWithData(data).single()?.get()) + + #expect(empty == nil) + #expect(source.renderCount == 2) + #expect(recovered.image === source.image) + #expect(cached.image === source.image) + #expect(cached.cacheHit == true) + } +} + +private struct MulticastRenderData: RenderDataType, Sendable { + let identifier: Int + let size = CGSize(width: 8, height: 8) +} + +private struct UncheckedSendable: @unchecked Sendable { + let value: Value + + init(_ value: Value) { + self.value = value + } +} + +private final class BlockingRenderer: RendererType, @unchecked Sendable { + let started = DispatchSemaphore(value: 0) + let release = DispatchSemaphore(value: 0) + + private let image = makeImage() + + func renderImageWithData(_ data: MulticastRenderData) -> SignalProducer { + return SignalProducer { [image = self.image, release = self.release, started = self.started] observer, lifetime in + started.signal() + release.wait() + + guard !lifetime.hasEnded else { return } + + observer.send(value: image) + observer.sendCompleted() + } + } +} + +private final class MultiValueRenderer: RendererType { + let signal: Signal + let observer: Signal.Observer + private(set) var renderCount = 0 + + init() { + (self.signal, self.observer) = Signal.pipe() + } + + func renderImageWithData(_ data: MulticastRenderData) -> SignalProducer { + self.renderCount += 1 + + return self.signal.producer + } +} + +private final class RecoveringRenderer: RendererType { + private let image = makeImage() + private let scheduler = QueueScheduler(name: "MulticastedRendererTests.RecoveringRenderer") + private(set) var renderCount = 0 + + func renderImageWithData(_ data: MulticastRenderData) -> SignalProducer { + self.renderCount += 1 + let result = ImageResult( + image: self.image, + cacheHit: false, + shouldCache: self.renderCount > 1 + ) + + return SignalProducer(value: result) + .start(on: self.scheduler) + } +} + +private final class EmptyThenValueRenderer: RendererType { + let image = makeImage() + private(set) var renderCount = 0 + + func renderImageWithData(_ data: MulticastRenderData) -> SignalProducer { + self.renderCount += 1 + + return self.renderCount == 1 + ? SignalProducer.empty + : SignalProducer(value: self.image) + } +} + +private func makeImage(size: CGSize = CGSize(width: 8, height: 8)) -> UIImage { + return UIGraphicsImageRenderer(size: size).image { _ in } +} From 1416481b18ea452348da02bef83f151cae9fc816 Mon Sep 17 00:00:00 2001 From: NachoSoto Date: Sat, 11 Jul 2026 12:18:45 -0700 Subject: [PATCH 2/5] [Fix] Preserve multicast subscription semantics --- .../Renderers/MulticastedRenderer.swift | 55 ++++++--- .../MulticastedRendererSpec.swift | 13 +-- .../MulticastedRendererTests.swift | 110 +++++++++++++++++- 3 files changed, 156 insertions(+), 22 deletions(-) diff --git a/AsyncImageView/Renderers/MulticastedRenderer.swift b/AsyncImageView/Renderers/MulticastedRenderer.swift index 993c486..302e0c2 100644 --- a/AsyncImageView/Renderers/MulticastedRenderer.swift +++ b/AsyncImageView/Renderers/MulticastedRenderer.swift @@ -14,12 +14,30 @@ private final class InFlightImage { let result: Property let observer: Signal.Observer let disposable = SerialDisposable() + private let hasStarted = Atomic(false) init() { let (signal, observer) = Signal.pipe() self.result = Property(initial: nil, then: signal) self.observer = observer } + + deinit { + self.disposable.dispose() + } + + func startIfNeeded(_ action: () -> Void) { + let shouldStart = self.hasStarted.modify { hasStarted in + guard !hasStarted else { return false } + hasStarted = true + + return true + } + + if shouldStart { + action() + } + } } private enum CachedImage { @@ -28,7 +46,7 @@ private enum CachedImage { } private enum CachedImageLookup { - case rendering(InFlightImage, shouldStart: Bool) + case rendering(InFlightImage) case completed(ImageResult) } @@ -68,11 +86,7 @@ public final class MulticastedRenderer< case let .completed(result): return SignalProducer(value: result) - case let .rendering(entry, shouldStart): - if shouldStart { - self.startRendering(data, into: entry) - } - + case let .rendering(entry): return self.producer(for: data, entry: entry) } } @@ -84,13 +98,13 @@ public final class MulticastedRenderer< return .completed(result) case let .rendering(entry, latest: nil): - return .rendering(entry, shouldStart: false) + return .rendering(entry) case nil: let entry = InFlightImage() cache[data] = .rendering(entry, latest: nil) - return .rendering(entry, shouldStart: true) + return .rendering(entry) } } } @@ -117,8 +131,8 @@ public final class MulticastedRenderer< ) guard result.shouldCache else { - entry.observer.send(value: cacheMiss) self.remove(entry, for: data) + entry.observer.send(value: cacheMiss) return } @@ -150,8 +164,13 @@ public final class MulticastedRenderer< } private func producer(for data: Data, entry: InFlightImage) -> SignalProducer { - return SignalProducer { [weak self, entry] observer, lifetime in - let cachedResult = self?.cache.withValue { cache -> ImageResult? in + return SignalProducer { [self, entry] observer, lifetime in + lifetime.observeEnded { + _ = self + _ = entry + } + + let cachedResult = self.cache.withValue { cache -> ImageResult? in switch cache[data] { case let .completed(result), let .rendering(_, latest: result?): return result @@ -161,9 +180,17 @@ public final class MulticastedRenderer< } } - let producer = cachedResult.map(SignalProducer.init(value:)) - ?? entry.result.producer.compactMap { $0 }.take(first: 1) - lifetime += producer.start(observer) + if let cachedResult { + lifetime += SignalProducer(value: cachedResult).start(observer) + } else { + lifetime += entry.result.producer + .compactMap { $0 } + .take(first: 1) + .start(observer) + entry.startIfNeeded { + self.startRendering(data, into: entry) + } + } } } diff --git a/AsyncImageViewTests/MulticastedRendererSpec.swift b/AsyncImageViewTests/MulticastedRendererSpec.swift index e8007ce..5c4f5a8 100644 --- a/AsyncImageViewTests/MulticastedRendererSpec.swift +++ b/AsyncImageViewTests/MulticastedRendererSpec.swift @@ -127,15 +127,14 @@ class MulticastedRendererSpec: QuickSpec { expect(getCacheHitValue()) == false } - it("is a cache hit the second time the producer is fetched") { - let producer = getProducerForData(data, size) + it("is a cache hit after the initial producer completes") { + var initialResult: ImageResult? + getProducerForData(data, size).startWithValues { initialResult = $0 } scheduler.advance(by: interval) + expect(initialResult).toEventuallyNot(beNil()) - var result: ImageResult? - producer.startWithValues { result = $0 } - - expect(result).toEventuallyNot(beNil()) - expect(result?.cacheHit) == true + expect(initialResult?.cacheHit) == false + expect(getImageForData(data, size)?.cacheHit) == true } } } diff --git a/AsyncImageViewTests/MulticastedRendererTests.swift b/AsyncImageViewTests/MulticastedRendererTests.swift index e81e99b..da8a05f 100644 --- a/AsyncImageViewTests/MulticastedRendererTests.swift +++ b/AsyncImageViewTests/MulticastedRendererTests.swift @@ -14,7 +14,7 @@ import ReactiveSwift @testable import AsyncImageView -@Suite +@Suite(.serialized) struct MulticastedRendererTests { @Test func rendersDifferentKeysConcurrently() { @@ -74,6 +74,22 @@ struct MulticastedRendererTests { #expect(completedHit.cacheHit == true) } + @Test + func preservesTheInitialCacheMissFromASynchronousRenderer() throws { + let source = SynchronousRenderer() + let renderer = source.multicasted() + let data = MulticastRenderData(identifier: 1) + + let initial = try #require(renderer.renderImageWithData(data).single()?.get()) + let cached = try #require(renderer.renderImageWithData(data).single()?.get()) + + #expect(source.renderCount == 1) + #expect(initial.image === source.image) + #expect(initial.cacheHit == false) + #expect(cached.image === source.image) + #expect(cached.cacheHit == true) + } + @Test func sharesOneUpstreamRenderBetweenConcurrentWaiters() throws { let image = makeImage() @@ -103,6 +119,73 @@ struct MulticastedRendererTests { #expect(secondResult.cacheHit == false) } + @Test + func producerOutlivesATemporaryMulticastedRenderer() throws { + let image = makeImage() + let source = MultiValueRenderer() + let data = MulticastRenderData(identifier: 1) + let producer = source.multicasted().renderImageWithData(data) + var result: ImageResult? + let disposable = producer.startWithValues { result = $0 } + defer { disposable.dispose() } + + source.observer.send(value: image) + source.observer.sendCompleted() + + let rendered = try #require(result) + #expect(source.renderCount == 1) + #expect(rendered.image === image) + #expect(rendered.cacheHit == false) + } + + @Test + func preservesInFlightDeliveryAcrossAMemoryWarning() throws { + let image = makeImage() + let source = MultiValueRenderer() + let renderer = source.multicasted() + let data = MulticastRenderData(identifier: 1) + var first: ImageResult? + let firstDisposable = renderer.renderImageWithData(data).startWithValues { first = $0 } + var secondDisposable: Disposable? + defer { + firstDisposable.dispose() + secondDisposable?.dispose() + } + + #expect(source.renderCount == 1) + NotificationCenter.default.post(name: UIApplication.didReceiveMemoryWarningNotification, object: nil) + + for _ in 0..<100 where source.renderCount < 2 { + secondDisposable?.dispose() + secondDisposable = renderer.renderImageWithData(data).start() + if source.renderCount < 2 { + Thread.sleep(forTimeInterval: 0.01) + } + } + + #expect(source.renderCount == 2) + source.observer.send(value: image) + source.observer.sendCompleted() + + let rendered = try #require(first) + #expect(rendered.image === image) + #expect(rendered.cacheHit == false) + } + + @Test + func cancelsInFlightWorkWhenTheRendererIsReleased() { + let source = CancellableRenderer() + var renderer: MulticastedRenderer? = source.multicasted() + let disposable = renderer?.renderImageWithData(MulticastRenderData(identifier: 1)).start() + + #expect(source.started.value == true) + #expect(source.cancelled.value == false) + disposable?.dispose() + renderer = nil + + #expect(source.cancelled.value == true) + } + @Test func retriesAfterANonCacheableResult() throws { let source = RecoveringRenderer() @@ -186,6 +269,31 @@ private final class MultiValueRenderer: RendererType { } } +private final class SynchronousRenderer: RendererType { + let image = makeImage() + private(set) var renderCount = 0 + + func renderImageWithData(_ data: MulticastRenderData) -> SignalProducer { + self.renderCount += 1 + + return SignalProducer(value: self.image) + } +} + +private final class CancellableRenderer: RendererType { + let started = Atomic(false) + let cancelled = Atomic(false) + + func renderImageWithData(_ data: MulticastRenderData) -> SignalProducer { + return SignalProducer { [cancelled = self.cancelled, started = self.started] _, lifetime in + started.modify { $0 = true } + lifetime.observeEnded { + cancelled.modify { $0 = true } + } + } + } +} + private final class RecoveringRenderer: RendererType { private let image = makeImage() private let scheduler = QueueScheduler(name: "MulticastedRendererTests.RecoveringRenderer") From ed0571e27dd2e4b5b1587f661534aad8bde99eb3 Mon Sep 17 00:00:00 2001 From: NachoSoto Date: Sat, 11 Jul 2026 12:26:17 -0700 Subject: [PATCH 3/5] [Tests] Verify multicast lifetime release --- .../MulticastedRendererTests.swift | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/AsyncImageViewTests/MulticastedRendererTests.swift b/AsyncImageViewTests/MulticastedRendererTests.swift index da8a05f..769b1ad 100644 --- a/AsyncImageViewTests/MulticastedRendererTests.swift +++ b/AsyncImageViewTests/MulticastedRendererTests.swift @@ -176,14 +176,39 @@ struct MulticastedRendererTests { func cancelsInFlightWorkWhenTheRendererIsReleased() { let source = CancellableRenderer() var renderer: MulticastedRenderer? = source.multicasted() + weak var weakRenderer = renderer let disposable = renderer?.renderImageWithData(MulticastRenderData(identifier: 1)).start() #expect(source.started.value == true) #expect(source.cancelled.value == false) - disposable?.dispose() renderer = nil + #expect(weakRenderer != nil) + disposable?.dispose() #expect(source.cancelled.value == true) + #expect(weakRenderer == nil) + } + + @Test + func releasesTheRendererWhenAnActiveProducerCompletes() throws { + let image = makeImage() + let source = MultiValueRenderer() + var renderer: MulticastedRenderer? = source.multicasted() + weak var weakRenderer = renderer + var result: ImageResult? + let disposable = renderer? + .renderImageWithData(MulticastRenderData(identifier: 1)) + .startWithValues { result = $0 } + defer { disposable?.dispose() } + + renderer = nil + #expect(weakRenderer != nil) + source.observer.send(value: image) + + let rendered = try #require(result) + #expect(rendered.image === image) + #expect(weakRenderer == nil) + source.observer.sendCompleted() } @Test From f47791d10c29143ffa5226509d136d7bb4692e49 Mon Sep 17 00:00:00 2001 From: NachoSoto Date: Sat, 11 Jul 2026 12:30:44 -0700 Subject: [PATCH 4/5] [Docs] Explain multicast lifetime ownership --- AsyncImageView/Renderers/MulticastedRenderer.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/AsyncImageView/Renderers/MulticastedRenderer.swift b/AsyncImageView/Renderers/MulticastedRenderer.swift index 302e0c2..a1b10c9 100644 --- a/AsyncImageView/Renderers/MulticastedRenderer.swift +++ b/AsyncImageView/Renderers/MulticastedRenderer.swift @@ -165,6 +165,8 @@ public final class MulticastedRenderer< private func producer(for data: Data, entry: InFlightImage) -> SignalProducer { return SignalProducer { [self, entry] observer, lifetime in + // Keep async callbacks deliverable after cache eviction or a temporary wrapper. + // `Lifetime` releases this action on completion or cancellation. lifetime.observeEnded { _ = self _ = entry From f28d07fcc6dff4ba5d2ea5d5906e924e2d4d9320 Mon Sep 17 00:00:00 2001 From: NachoSoto Date: Sat, 11 Jul 2026 12:40:15 -0700 Subject: [PATCH 5/5] [CI] Run Xcode 26.5 on macOS 26 --- .github/workflows/build-and-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 350dedb..ee0c1d4 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -9,7 +9,7 @@ concurrency: jobs: test: - runs-on: macos-latest + runs-on: macos-26 timeout-minutes: 30 env: CI: true