Does 'assign(to:)' produce memory leaks?

Hello there!

I'm playing around with Combine and I've found a possible memory leak when using 'assign(to:)' to store stream result in current object. Let me show a couple of examples using 'assign(to:)' and 'sink' to compare:

final class Bar: ObservableObject {
  @Published var input: String = ""
  @Published var output: String = ""

  private var subscription: AnyCancellable?

  init() {
    subscription = $input
        .filter { $0.count > 0 }
        .map { "\($0) World!" }
        .sink { [weak self] input in
            self?.output = input
        }
  }

  deinit {
    subscription?.cancel()
    print("\(self): \(#function)")
  }
}

// Ussage
var bar: Bar? = Bar()
let foo = bar?.$output.sink { print($0) }
bar?.input = "Hello"
bar = nil
foo?.cancel()

When executed this code produces:

Hello World!
__lldb_expr_29.Bar: deinit

But, when I use 'assign', deinit is never called:

final class Bar: ObservableObject {
  @Published var input: String = ""
  @Published var output: String = ""

  private var subscription: AnyCancellable?

  init() {
    subscription = $input
        .filter { $0.count > 0 }
        .map { "\($0) World!" }
        .assign(to: \.output, on: self)
  }

  deinit {
    subscription?.cancel()
    print("\(self): \(#function)")
  }
}

// Ussage
var bar: Bar? = Bar()
let foo = bar?.$output.sink { print($0) }
bar?.input = "Hello"
bar = nil
foo?.cancel()

This only prints:

Hello World!

So, my question here is: Am I doing something wrong with 'assign' or there is a real memory leak with it?

3 Likes

Hello,

Indeed there is a retain cycle with the sample code that uses assign:

The Bar retains the AnyCancellable (1), which retains the Subscribers.Assign subscription (2), which retains the Bar (3) in order to be able to perform the assignment. Cycle completed: the Bar is never deinited.

Your other sample code captures self weakly. This avoids (3), breaks the cycle, and fixes the memory leak.

It would be really nice to be able to pass a weak self into Assign like so:

subscription = $input
        .filter { $0.count > 0 }
        .map { "\($0) World!" }
        .assign(to: \.output, on: weak self) // or [weak self]
5 Likes

Indeed there is a retain cycle with the sample code that uses assign

Yeah, you're absolutely right @gwendal.roue. So, is this the use case for assign? if not, what is the use case for it to avoid the retain cycles?

Something like .assign(to: \.output, on: weak self) as suggested by @clayellis would be a good option.

I'm surprised if Subscribers.Assign didn't capture its target weakly. Sounds like a bug or design flaw to me.

3 Likes

Subscribers.Assign<Root, Input> indeed requires a ReferenceWritableKeyPath, a "key path that supports reading from and writing to the resulting value with reference semantics". But the Root generic argument is not constrained to AnyObject: the subscriber and its subscriptions can not capture the target weakly.

I don't know if this is a flaw or not. But we can reasonably deduce this behavior from the definition of the type, and adjust our expectations.

3 Likes

Well, not the one you are trying to make ;-)

Maybe, in your specific case, where I assume that only output really needs to be published, input could be a simple property which updates the published output in its didSet?

Yeah, I was pretty sure that was my lack of understanding more than a design flaw. Using didSet we have the same behaviour w/o any memory leak:

final class Bar: ObservableObject {
    var input: String = "" {
        didSet {
            Just(input)
                .filter { $0.count > 0 }
                .map { "\($0) World!" }
                .assign(to: \.output, on: self)
                .store(in: &subscriptions)
        }
    }
    @Published var output: String = ""
    
    private var subscriptions = Set<AnyCancellable>()
    
    deinit {
        print("\(self): \(#function)")
    }
}

Output:

Hello World!
__lldb_expr_5.Bar: deinit

Again, I'm not sure if this is the expected use of combine or not... but it works better ¯\(ツ)

Hi everyone.

There is indeed a memory leak if self is both keeping the reference to the subscription and is the target of the assignation. I've made the following replacement that seems to do the trick.

I'm simply proxying the assignation through a sink where I can weakly refer to self. I'm using a Just publisher to let Combine manage the assignation. It seems to work as expected on my side.

extension Publisher where Self.Failure == Never {
    public func assignNoRetain<Root>(to keyPath: ReferenceWritableKeyPath<Root, Self.Output>, on object: Root) -> AnyCancellable where Root: AnyObject {
        sink { [weak object] (value) in
            guard let object = object else { return }
            _ = Just(value).assign(to: keyPath, on: object)
        }
    }
}
3 Likes

@tgrapperon it's even easier:

extension Publisher where Self.Failure == Never {
public func assignNoRetain<Root>(to keyPath: ReferenceWritableKeyPath<Root, Self.Output>, on object: Root) -> AnyCancellable where Root: AnyObject {
    sink { [weak object] (value) in
        object?[keyPath: keyPath] = value
    }
  }
}
7 Likes

I also found out that keeping the same signature as Apple's (but enforcing the AnyObject constraint) automatically fixes the memory leak everywhere:

extension Publisher where Failure == Never {
    func assign<Root: AnyObject>(to keyPath: ReferenceWritableKeyPath<Root, Output>, on root: Root) -> AnyCancellable {
       sink { [weak root] in
            root?[keyPath: keyPath] = $0
        }
    }
}
30 Likes

@tgrapperon et al - many thanks for digging in to all this and providing some simple workarounds. The assign operator seemed great until I ran into this, and substituting sink everywhere seemed ugly.

On a side note, if I understood correctly, this behavior implies that this wouldn't cause the leak right?

class SomeAssignee {
  var someData: String
}
class SomeObservable: ObservableObject {
  var cancellables: Set<AnyCancellable>()

  @Published var observedData: String = ""

  init(_ target: SomeAssignee) {
    $observedData
      .assign(\.someData, on: target)
      .store(in: &cancellables)
  }
}

That looks suspiciously like a property wrapper / binding / observable wrapper.

It seems Apple did fix this issue in iOS 14, by introducing the assing(to:) operator, which takes a Published.Publisher as its input and instead of returning an AnyCancellable, it binds the lifetime of the subscription to the lifetime of the Published property.

From the docs:

If you instead implemented MyModel with assign(to: lastUpdated, on: self) , storing the returned AnyCancellable instance could cause a reference cycle, because the Subscribers.Assign subscriber would hold a strong reference to self . Using assign(to:) solves this problem.

8 Likes

Important: The Subscribers/Assign instance created by this operator maintains a strong reference to object, and sets it to nil when the upstream publisher completes (either normally or with an error).

in this case, for just, it will complete immediately , so the strong reference was set to nil

1 Like

I thought so, but not sure why it didn't work with me without that extra extension, hmm.