Cleaning up in deinit with self, and complete concurrency checking

I have the code below that works fine currently. With the error Call to main actor-isolated instance method 'removeTarget(_:action:for:)' in a synchronous nonisolated context in the deinit.

I thought I could make either the deinit @MainActor (not allowed because it could deinit on any thread, which makes sense because you don't control where deinit is called)

So then I thought make the whole thing @MainActor. But that didn't seem to affect deinit and the error remained. This I don't understand because if it's @MainActor then it can only be created on the main actor and as it's not Sendable it can't change actor(?), so the deinit should only be possible on the main actor.

I can't just wrap the line of code in MainActor.run or Task.detached because it uses self directly, and capturing self in an escaping closure in deinit it a bad idea despite no warning/error being given.

So any ideas how I can get this to work in the new concurrency mode?


public extension UIControl {
	func observe(event: UIControl.Event, _ block: @escaping (UIControl) -> Void) -> Any {
		let trampoline = Trampoline(observee: self, block: block)
		addTarget(trampoline, action: #selector(Trampoline.bounce), for: event)
		return trampoline
	}
}

private class Trampoline {
	private let block: (UIControl) -> Void
	private weak var observee: UIControl?

	init(observee: UIControl, block: @escaping (UIControl) -> Void) {
		self.observee = observee
		self.block = block
	}

	@objc
	func bounce(sender: UIControl) {
		block(sender)
	}

	deinit {
		// Call to main actor-isolated instance method 'removeTarget(_:action:for:)' in a synchronous nonisolated context
		observee?.removeTarget(self, action: nil, for: .allEvents)
	}
}
3 Likes

Applying a global actor attribute will implicitly conform a type to Sendable. Since a Sendable reference type can be freely shared across any isolation domain, its final reference could go away from any isolation domain, and thus we don't have guarantees about where deinit will be called from. There have been discussions about ways to address this, but language changes have not yet landed to support it.

To suppress the implicit Sendable conformance you could do this:

@available(*, unavailable)
extension Trampoline: Sendable {}

I think, combined with annotating the type with @MainActor should give you the guarantee of "a type which can only be created from the main actor and not transferred to another isolation domain, and so must be deinitialized from the main actor", but I am not 100% confident in my intuition here. But it looks like even with that workaround, the deinit won't recognize it statically (because my assumption is incorrect?), but you could then wrap the body of the deinit in MainActor.assumeIsolated { ... } to call main-actor-isolated functions synchronously. Note that this will crash if you are not, in fact, on the main actor.

2 Likes
EDIT: I was wrong here

I guess that will be incorrect once this proposal is accepted as one would be able to send Trampoline to another isolation domain regardless of its sendability.
What we need here is the above mentioned Isolated synchronous deinit, but there weren't any updates on that in past two years. So I guess it would be better to implement a workaround on your side than wait.
I think this can be solved by introduction another private object to interact with UIControl and move the call to removeTarget from deinit into a method, so it will be isolated. This object should be held by the outer object (Trampoline) and never leaked out. Something like


public extension UIControl {
  @MainActor
  func observe(event: UIControl.Event, _ block: @escaping @MainActor (UIControl) -> Void) -> Any {
    return Trampoline(observee: self, event: event, block: block)
  }
}

private final class Trampoline {
  @MainActor
  final class Inner {
    private weak var trampoline: Trampoline?
    private weak var observee: UIControl?

    init(observee: UIControl, event: UIControl.Event, trampoline: Trampoline) {
      self.trampoline = trampoline
      self.observee = observee
      observee.addTarget(self, action: #selector(Inner.bounce(sender:)), for: event)
    }

    @objc func bounce(sender: UIControl) {
      trampoline?.block(sender)
    }

    func unsubscribe() {
      observee?.removeTarget(self, action: nil, for: .allEvents)
    }
  }

  private let block: @MainActor (UIControl) -> Void
  private var inner: Inner!

  @MainActor
  init(observee: UIControl, event: UIControl.Event, block: @escaping @MainActor (UIControl) -> Void) {
    self.block = block
    inner = Inner(observee: observee, event: event, trampoline: self)
  }

  deinit {
    Task { @MainActor [inner = inner!] in
      inner.unsubscribe()
    }
  }
}

(Be careful, I haven't tested this)

2 Likes

I am still developing my intuition around region-based isolation but I believe the proper behavior even under RBI would be that because the type is non-sendable and its initializers are @MainActor isolated, any attempt to construct the type immediately merges the isolation region with the main actor, meaning that it's not valid to recover the reference 'back out' from the init (unless you're already calling in from the main actor). I'd need @hborla to confirm that those are the intended semantics, though.

3 Likes

That's correct. Note that I filed Global actor isolated initializers of non-`Sendable` types should cause the value to be in the actor's region · Issue #71533 · apple/swift · GitHub the other day because the SE-0414 implementation doesn't correctly merge the regions right now.

3 Likes

Thanks! It doesn’t seem good that code that is possible and reasonable now won’t be possible (it seems like those features won’t be added in Swift 6?)
Is there a reason why the deinit doesn’t crash now if the deinit is called from a different thread?

Also in general I’m confused why Kotlin has structured concurrency but doesn’t seem nearly as complicated and doesn’t have actors?