Explicit self not required for Task closures?

I think this is a bug, but I would like confirmation. The following code compiles cleanly, whereas I think it should error due to lack of explicit self

final class Foo {
    var thisVariableInvolvesSelf = 42
    
    func leak1() {
        Task {
            thisVariableInvolvesSelf += 1
        }
    }
}

In the Swift book, it says that escaping closures require an explicit self:

If you want to capture self , write self explicitly when you use it, or include self in the closure’s capture list.

I believe Task {} is actually the following constructor which takes an @escaping parameter

public init(priority: TaskPriority? = nil, operation: @escaping @Sendable () async -> Success)

As a consequence of this behavior, in the following example, the closure strongly retains self even though [weak self] is in the capture list. As a corollary, the function leaks.

final class Bar {
    var thisVariableInvolvesSelf = 43
    func leak2() {
        Task { [weak self] in
            //never yields
            await withCheckedContinuation({(_: CheckedContinuation<(),Never>) in })
            thisVariableInvolvesSelf += 1
            self?.innocent()
        }
    }
    func innocent() { }
}

This leak has caused me some headache. I do not really understand the mechanism of it, unless I there is some alternative constructor for Task which takes a nonescaping closure.

3 Likes

Is that a leak, or is the capture just extending the lifetime of self? Once the Task completes the memory is released, right?

In the motivating situation (and in example 2) the Task will never complete so there is no distinction between the two.

In the motivating case the task involves looping over an AsyncSequence of incoming push notifications, which is an unbounded sequence.

True enough, though I dislike conflating leaks with extended reference lifetimes, since the lifetime would end given the rest of the code is executed. In any case, does a newer toolchain affect the behavior at all? I wonder if this is a bug in 5.5 that was fixed later.

Task init has a underscored annotation so self is not required even tho os escaping. Xcode doesn’t show it but you can see it in the Swift source.

1 Like

The latest toolchain also produces a warning, though I don't think it's really a very helpful one:

Capture of 'self' with non-sendable type 'Foo' in a @Sendable closure

1 Like

The warning is unrelated, it just wants concurrency safety

actor Foo {
	var thisVariableInvolvesSelf = 42
	
	func leak1() {
		Task {
			thisVariableInvolvesSelf += 1
		}
	}
}


actor Bar {
	var thisVariableInvolvesSelf = 43
	func leak2() {
		Task { [weak self] in
			//let the task finish after all!
			//  await withCheckedContinuation({(_: CheckedContinuation<(),Never>) in })
			thisVariableInvolvesSelf += 1
			await self?.innocent()
		}
	}
	func innocent() { }
}

On the semantic question, I believe it is a real leak. In practice, it leaks even if you finish the task. My theory of the cycle is as follows

  1. instance-of-Bar retains
  2. Task, which retains
  3. anonymous closure, which retains
  4. 1, through the implicit self

I suspect it is an implementation detail whether finishing the task breaks 2->3, but in practice it doesn't.

Looks like this issue with @_implicitSelfCapture was anticipated by @Douglas_Gregor in the review of SE-0304:

Explicit self is there for a specific purpose: to help identify retain cycles by requiring one to explicitly call out self capture where it's likely to cause retain cycles. We intentionally did not make it required everywhere , and then later we removed explicit self in other unlikely scenarios . When capturing self in a task, you effectively need the task to have an infinite loop for that capture to be the cause of a reference cycle. That fits very well with the direction already set for explicit self .

Though perhaps the necessity of having infinite loops in Tasks was not as anticipated yet since AsyncSequence wasn't fully established. But since throwing off a task to handle an infinite sequence will be rather common, it certainly appears this point needs to be reexamined.

At the very least, allowing implicit capture even after the user has initiated manual capture seems like a bug, so that should be filed.

7 Likes

That is very helpful, thanks! It the infinite loop question but I believe that is something of a red herring actually.

Testing on my end suggests the examples I posted still leak whether the task completes or not, which seems to contradict

you effectively need the task to have an infinite loop for that capture to be the cause of a reference cycle.

That part definitely seems like a bug, regardless of the other behavior, and in addition to manual self capture not overriding the implicit capture.

6 Likes

When setting a variable value from within a Task that's inside a SwiftUI View, I am receiving the warning:

Capture of 'self' with non-sendable type 'ContentView' in a @Sendable closure

struct ContentView: View {
    @State private var isAuthenticating = false

    private func onCompletion() {
        Task {
            await MainActor.run {
                self.isAuthenticating = false
            }
        }
    }
}

Is this the same bug? This is Xcode Version 13.3 beta (13E5086k).

Apparently ‘ @ unchecked Sendable’ is our friend. :face_with_monocle:

I also request "requiring explicit self capturing" on Task closure.

Today I stumbled on a difficult bug and the root cause was unintentionally (implicitly) captured self in Task closure. Retain cycle or "unexpectedly prolonged lifetime" is one of the hardest, time-consuming bug to find out and fix in RC system.

On Swift, I can forget the risk of implicitly captured self because compiler checks it for me. If implicit self capturing is allowed, I cannot forget about it anymore. Getting more things to care and remember is really painful experience. Please consider this pain more seriously.

5 Likes

Never completing streams are absolutely going to be a thing with AsyncSequence, therefore this is a massive footgun

Setting Build Setting > Strict Concurrency Checking > Complete throws the following warning (on the original code):

Capture of 'self' with non-sendable type 'Foo' in a `@Sendable` closure

Build Setting: Strict Concurrency Checking > Complete
Xcode: Version 14.0 beta 3 (14A5270f)
Swift: Swift 5.7

2 Likes

Might be worth a read: self is no longer required in many places – available from Swift 5.3

I had this problem today. Implicit self seems OK for async work with an expected ending, but a nonterminating AsyncSequence is now an attractive way to subscribe a view controller to data changes. You'd want to cancel the task in deinit, so avoiding strong capture is key.

I noticed this detail as well: If you capture self weakly, but then you convert to a strong reference and hold that across a suspension point, you reintroduce the retain cycle for the duration. Is there guidance about avoiding guard let self in async closures, or just in unbounded for awaits?

3 Likes

I don't think cancelling on de-init is the way to go. Instead, copying the behavior of SwiftUI.View.task decouples the lifetime of the UIView object and the task. That is, starting the task before the view appears and cancelling it after it disappears.

For a task that only matters while the VC is displayed, I would agree.

How about this case: A view controller is a child of a tab bar controller. It uses an AsyncSequence-consuming for await in a Task to listen to notifications indicating it should invalidate its data. After the VC receives invalidation from the task, it loads its data again immediately if visible; otherwise it loads when it next appears. Here the notification-observing task needs to outlive visibility but not outlive the view controller. If the whole tab bar controller is modally dismissed, the view controller should not leak.

Avoiding strong self capture is the usual way to solve that kind of thing, but we don't have the compiler's encouragement in this case.

This strikes me as a problem somewhat different shape than the one that the implicit self capture rules are meant to solve. Those diagnostics are focused on trying to prevent common causes of strong reference cycles, namely, an instance of a reference type which (perhaps transitively) holds on to a strong reference to a closure which captures the original instance.

But the problem where the lifetime of captured state can get extended indefinitely in a non-terminating Task persists quite apart from any formal reference cycles. Indeed, this issue would persist even if you never held on to the returned Task object at all because it's the internal runtime machinery that keeps an executing Task alive, not the fact of any strong reference being held onto.

As you already note, the typical guard let self dance used to typically satisfy the compiler's complaints about self captures wouldn't be a sufficient mitigation here—you'd need to make sure you are dropping your strong self reference through every iteration of the loop. This has been discussed before here and here at the very least.

I think you're right that this pattern deserves better diagnostics but I'm not sure that simply falling back on the usual self capture rules are the right tool for the job.

1 Like