Issue with Property Wrapper Publisher Being Deallocated Prematurely When Not Stored as a Property

Hello everyone,

I've built a @CurrentValue property wrapper that mimics the behavior of @Published, allowing a property to publish values on "did set". I've also created my own assign(to:) implementation that works with @CurrentValue properties, allowing values to be assigned from a publisher to a @CurrentValue property.

However, I'm running into an issue. When I use this property wrapper with two classes and the source class (providing the publisher) is not stored as a property, the subscription is deallocated, and values are no longer forwarded.

Here's the property wrapper code:

@propertyWrapper
public struct CurrentValue<Value> {

    /// A publisher for properties marked with the `@CurrentValue` attribute.
    public struct Publisher: Combine.Publisher {

        public typealias Output = Value
        public typealias Failure = Never

        /// A subscription that forwards the values from the CurrentValueSubject to the downstream subscriber
        private class CurrentValueSubscription<S>: Subscription where S: Subscriber, S.Input == Output, S.Failure == Failure {

            private var subscriber: S?
            private var currentValueSubject: CurrentValueSubject<S.Input, S.Failure>?
            private var cancellable: AnyCancellable?

            init(subscriber: S, publisher: CurrentValue<Value>.Publisher) {
                self.subscriber = subscriber
                self.currentValueSubject = publisher.subject
            }

            func request(_ demand: Subscribers.Demand) {
                var demand = demand

                cancellable = currentValueSubject?.sink { [weak self] value in
                    // We'll continue to emit new values as long as there's demand
                    if let subscriber = self?.subscriber, demand > 0 {
                        demand -= 1
                        demand += subscriber.receive(value)
                    } else {
                        // If we have no demand, we'll cancel our subscription:
                        self?.subscriber?.receive(completion: .finished)
                        self?.cancel()
                    }
                }
            }

            func cancel() {
                cancellable = nil
                subscriber = nil
                currentValueSubject = nil
            }
        }

        /// A subscription store that holds a reference to all the assign subscribers so we can cancel them when self is deallocated
        fileprivate final class AssignSubscriptionStore {
            fileprivate var cancellables: Set<AnyCancellable> = []
        }

        fileprivate let subject: CurrentValueSubject<Value, Never>
        fileprivate let assignSubscriptionStore: AssignSubscriptionStore = .init()

        fileprivate var value: Value {
            get {
                subject.value
            }
            nonmutating set {
                subject.value = newValue
            }
        }

        init(_ initialValue: Output) {
            self.subject = .init(initialValue)
        }

        public func receive<S>(subscriber: S) where S: Subscriber, Failure == S.Failure, Output == S.Input {
            let subscription = CurrentValueSubscription(subscriber: subscriber, publisher: self)
            subscriber.receive(subscription: subscription)
        }
    }

    public var wrappedValue: Value {
        get { publisher.value }
        nonmutating set { publisher.value = newValue }
    }

    public var projectedValue: Publisher {
        get {
            publisher
        }
        mutating set {
            publisher = newValue
        }

    }

    private var publisher: Publisher

    public init(wrappedValue: Value) {
        publisher = .init(wrappedValue)
    }
}

/// A subscriber that receives values from an upstream publisher and assigns them to a downstream CurrentValue property.
private final class AssignSubscriber<Input>: Subscriber, Cancellable {

    typealias Failure = Never

    private var receivingSubject: CurrentValueSubject<Input, Never>?
    private weak var assignSubscriberStore: CurrentValue<Input>.Publisher.AssignSubscriptionStore?

    init(currentValue: CurrentValue<Input>.Publisher) {
        self.receivingSubject = currentValue.subject
        self.assignSubscriberStore = currentValue.assignSubscriptionStore
    }

    func receive(subscription: Subscription) {
        // Hold a reference to the subscription in the downstream publisher
        // so when it deallocates, the susbcription is automatically cancelled

assignSubscriberStore?.cancellables.insert(AnyCancellable(subscription))
        subscription.request(.unlimited)
    }

    func receive(_ input: Input) -> Subscribers.Demand {
        receivingSubject?.value = input
        return .none
    }

    func receive(completion: Subscribers.Completion<Never>) {
        // Nothing to do here
    }

    public func cancel() {
        receivingSubject = nil
        assignSubscriberStore = nil
    }
}

public extension Publisher where Self.Failure == Never {

    /// Assigns the output of the upstream publisher to a downstream CurrentValue property
    /// - Parameter currentValue: The CurrentValue property to assign the values to
    func assign(to currentValue: inout CurrentValue<Self.Output>.Publisher) {
        let subscriber = AssignSubscriber(currentValue: currentValue)
        self.subscribe(subscriber)
    }
}


Here’s an example demonstrating the issue, where two classes are used: Source, which owns the @CurrentValue property, and Forwarder1, which subscribes to updates from Source:

final class Source {
    @CurrentValue public private(set) var value: Int = 1

    func update(value: Int) {
        self.value = value
    }
}

final class Forwarder1 {
    @CurrentValue public private(set) var value: Int

    init(source: Source) {
        self.value = source.value
        source.$value.dropFirst().assign(to: &$value)
        // The source is not stored as a property, so the subscription deallocates
    }

    func update(value: Int) {
        self.value = value
    }

}

With this setup, if source isn’t retained as a property in Forwarder1, the subscription is deallocated prematurely, and value in Forwarder1 stops receiving updates from Source.

However, this doesn’t happen with @Published properties in Combine. Even if source isn’t retained, @Published subscriptions seem to stay active, propagating values as expected.

My Questions:

  1. What does Combine do internally with @Published properties that prevents the subscription from being deallocated prematurely, even if the publisher source isn’t retained as a property?
  2. Is there a recommended approach to address this in my custom property wrapper to achieve similar behavior, ensuring the subscription isn’t lost?

Any insights into Combine’s internals or suggestions on how to resolve this would be greatly appreciated. Thank you!

Issue with Property Wrapper Publisher Being Deallocated Prematurely When Not Stored as a Property
 
 
Q