[Pitch] `Disconnected` type for modeling disconnected values

That's a good one! When I ran that I got a data race.

The hole can be plugged by making the body sending or @Sendable. I think sending is less restrictive so I'd prefer it.

consume() needs nonisolated(unsafe) let value = self.value if Disconnected is not @unchecked Sendable.

When I tested whether withMutableValue also needs a sending body (it probably does), I got an error I can't understand. Passing isolatedToThisRegion to the closure seems to connect isolatedToThisRegion to the closure, so it can't be assigned to container. I don't understand why it doesn't have the same error on the borrowing withValue. Why don't the same capture rules apply to both closures?
I don't need to make body sending or @Sendable to get this error on withMutableValue.

nonisolated
func test () {
	let isolatedToThisRegion = NonSendable()
	isolatedToThisRegion.x += 1
	
	var disconnected = Disconnected(Container())
	
	disconnected.withMutableValue { container in
		container.nonSendable = isolatedToThisRegion // error: 'inout sending' parameter 'container' cannot be task-isolated at end of function.
		// error: Task-isolated 'container' risks causing races in between task-isolated uses and caller uses since caller assumes value is not actor isolated
	}
}
1 Like

I updated the proposal with some of the things we discussed here:

  • Proposed the new type for the Synchronization module
  • Added alternatives considered for more proposed names such as Sent or Sending
  • Added an alternative considered for the conditional Sendable conformance when Value: Sendable
  • Updated the public docs of the proposed type and methods

I also opened an implementation PR for anyone that's interested. Below is the copy of the latest public API and docs for the proposed type. I tried to explain the concept of a disconnected region in the doc comments.

/// A wrapper that holds a value in a disconnected isolation region.
///
/// A value of `Disconnected<Value>` lives in a disconnected region: it has no
/// references to or from any other isolation region. That guarantee lets you
/// store such values in generic containers and later transfer them across
/// isolation boundaries without losing the information that the value was
/// in a disconnected region.
///
/// ## What is a disconnected region?
///
/// Region-based isolation partitions the values that exist at any point
/// during a program's execution into *isolation regions* based on which
/// references reach which storage. A value is in a *disconnected region*
/// when no reference reaches into or out of its storage from any other
/// region. Such a value is safe to transfer to a different isolation
/// region, such as an actor, a `Task`, or another concurrent context,
/// because the act of transferring it cannot create a data race.
///
/// In practice, a disconnected region typically arises from one of:
///
/// - A freshly constructed value whose initializer arguments were
///   themselves disconnected.
/// - A value that was just removed from another disconnected container.
/// - A `sending` parameter at a function boundary, which the callee
///   receives in a disconnected region.
/// - A `sending` return value from a function, which the caller receives
///   in a disconnected region.
///
/// The disconnected property is normally only tracked at `sending`
/// boundaries. `Disconnected<Value>` lets you preserve it across storage
/// boundaries (generic containers, stored properties, queues) that would
/// otherwise lose region information once the value is no longer at a
/// `sending` boundary.
///
/// ## Operations
///
/// Values enter the wrapper through ``init(_:)``, which requires a `sending`
/// argument. They leave through ``take()`` or ``swap(newValue:)``, both of
/// which return `sending Value`. ``withValue(body:)`` lends the wrapped value
/// in place to a closure that receives an `inout sending Value`.
///
/// Every operation either consumes the wrapper or replaces the wrapped value
/// through a `sending` boundary, so no alias into the wrapper's storage can
/// outlive a transfer. That property is what lets `Disconnected` conform to
/// `Sendable` regardless of whether `Value` itself conforms to `Sendable`.
///
/// ## Producing values that can cross isolation boundaries
///
/// Use ``take()`` to remove the wrapped value. The call consumes the
/// wrapper, so no further operations on it are possible. The returned
/// value is in a disconnected region, so you can transfer it across an
/// isolation boundary in the same expression, or store it and transfer it
/// later:
///
/// ```swift
/// final class Resource: ~Sendable {}
///
/// // `wrapper` was popped from a queue or other container holding
/// // `Disconnected<Resource>` values, so the resource it holds is
/// // already known to be disconnected from the surrounding context.
/// func process(wrapper: consuming Disconnected<Resource>) async {
///     let resource = wrapper.take()
///     await Task.detached {
///         use(resource) // OK: `resource` is in a disconnected region.
///     }.value
/// }
/// ```
///
/// Without the disconnected guarantee on the result, the captured `resource`
/// would be considered part of the caller's region and the capture in the
/// detached task would not be allowed.
///
/// ## Replacing the wrapped value in place
///
/// Use ``swap(newValue:)`` to exchange the held value for a new one in a
/// single step. The `newValue` argument is required to be in a disconnected
/// region. `swap` returns the previously stored value, which is in a
/// disconnected region:
///
/// ```swift
/// final class Resource: ~Sendable {}
///
/// func swapResources(in wrapper: inout Disconnected<Resource>) async {
///     let old = wrapper.swap(newValue: Resource())
///     await Task.detached {
///         dispose(old) // OK: `old` is in a disconnected region.
///     }.value
/// }
/// ```
///
/// Both directions of the swap cross a disconnected-region boundary: the
/// new value is required to be disconnected when it goes in, and the old
/// value is known to be disconnected when it comes out.
///
/// ## Mutating the wrapped value without taking it out
///
/// Use ``withValue(body:)`` when you need temporary mutable access without
/// removing the value. The closure receives the value as `inout sending
/// Value`, and `withValue` returns whatever `body` returns:
///
/// ```swift
/// var wrapper = Disconnected([Int]())
/// wrapper.withValue { array in
///     array.append(42)
/// }
/// ```
///
/// The `inout sending` parameter form means more than ordinary `inout`:
/// within the closure, the value can be transferred to another isolation
/// region, as long as the wrapper is left holding a disconnected value
/// when the closure returns. In typical use the closure performs an
/// in-place mutation; the more permissive shape is what makes `withValue`
/// composable with code that itself wants to send the value to another
/// isolation region.
@frozen
public struct Disconnected<Value: ~Copyable>: ~Copyable, Sendable {
    /// Creates a disconnected wrapper around the given value.
    ///
    /// The argument is required to be in a disconnected region at the call
    /// site. A freshly constructed value with no aliases satisfies this
    /// requirement directly:
    ///
    /// ```swift
    /// final class Resource: ~Sendable {}
    /// let wrapper = Disconnected(Resource())
    /// ```
    ///
    /// - Parameter value: The value to wrap. The wrapper takes ownership of
    ///   it.
    public init(_ value: consuming sending Value)

    /// Consumes the wrapper and returns the wrapped value.
    ///
    /// The returned value is in a disconnected region, so you can transfer it
    /// across an isolation boundary:
    ///
    /// ```swift
    /// let wrapper = Disconnected(Resource())
    /// let resource = wrapper.take()
    /// // `resource` can now be sent to another isolation region.
    /// ```
    ///
    /// After `take()` returns, the wrapper has been consumed and no further
    /// operations on it are possible.
    ///
    /// - Returns: The previously wrapped value, in a disconnected region.
    public consuming func take() -> sending Value

    /// Replaces the wrapped value with a new value and returns the previous
    /// one.
    ///
    /// `newValue` is required to be in a disconnected region. The previously
    /// stored value is returned and is in a disconnected region:
    ///
    /// ```swift
    /// var wrapper = Disconnected(Resource())
    /// let old = wrapper.swap(newValue: Resource())
    /// // `old` can now be sent to another isolation region.
    /// ```
    ///
    /// - Parameter newValue: The replacement value.
    /// - Returns: The previously wrapped value, in a disconnected region.
    @discardableResult
    public mutating func swap(
        newValue: consuming sending Value
    ) -> sending Value

    /// Calls `body` with mutable access to the wrapped value.
    ///
    /// The closure receives the value as `inout sending`, so within the
    /// closure scope the value can be transferred to another isolation
    /// region. The wrapper is required to hold a disconnected value once
    /// `body` returns.
    ///
    /// ```swift
    /// var wrapper = Disconnected([1, 2, 3])
    /// wrapper.withValue { array in
    ///     array.append(4)
    /// }
    /// ```
    ///
    /// If `body` throws, the wrapper retains whatever value the closure
    /// last left in storage and the error propagates to the caller.
    ///
    /// - Parameter body: A closure that receives `inout sending` access to
    ///   the wrapped value.
    /// - Returns: The value returned by `body`.
    /// - Throws: Any error thrown by `body`.
    public mutating func withValue<Return: ~Copyable, Failure>(
        body: (inout sending Value) throws(Failure) -> Return
    ) throws(Failure) -> Return
}
7 Likes

Does Return in withValue need to be sending for correctness? It is sending in the equivalent function on Mutex, and I can neither adequately convince myself that it does, nor (if it doesn't), why Mutex would need to be different. Is it something about captures?

It's a good question. From just a data race safety perspective, Return does not need to be returned as sending for correctness. The fact that the disconnected value is passed as inout sending to the closure already enforces that it must stay in a disconnected isolation region by the end of the closure. If you were to return something that is connected to the closures parameter, the compiler will correctly produce an error. I added a test for this:

final class Inner {}
final class Outer {
  var inner = Inner()
}

var wrapper = Disconnected(Outer())
let leaked: Inner = wrapper.withValue { outer in
  return outer.inner
  // expected-error @-1 {{'outer.inner' cannot be returned}}
  // expected-note @-2 {{returning 'outer.inner' risks concurrent access to 'inout sending' parameter 'outer' as caller assumes 'outer' is not actor-isolated and result is main actor-isolated}}
}

Not requiring the return value to be sending means that you can return values from withValue that are tied to the callers isolation region which would otherwise not be possible. Something like this:

var wrapper = Disconnected(Outer())
let callerOwned = Inner()

let returned: Inner = wrapper.withValue { _ in
  return callerOwned
}

useValue(callerOwned)

Since this proposal introduces the Disconnected type if you want to transfer a disconnected value from the closure to the caller you can just return Disconnected<T> from withValue:

var wrapper = Disconnected(Outer())
let result: Disconnected<Inner> = wrapper.withValue { _ in
  return Disconnected(Inner())
}
useValue(result.take())

I would argue that not requiring the Result to be returned sending is strictly more permissive. We could revisit Mutex in a future proposal once Disconnected is accepted but this is potentially source breaking so it needs more evaluation.

Just for everyone's information, we decided to downgrade the recent bug fix that addressed the inout sending issue discussed here: Should Swift 6 prevent sharing a non-Sendable value via Mutex? to a warning in Swift 6 mode.

Theoretically, it would even be possible to make Disconnected conditionally conform to Copyable when Value conforms to Copyable & Sendable, even though that kind of conditional Copyable conformance isn't currently possible.

1 Like