[Pitch #2] Lifetime dependencies for non-`Escapable` values

Yes.

you say that if you take a dependency on Int

Depending on a pointer makes the unsafety obvious, but pointers aren't required for unsafety.

we are almost certain that you mean to take a dependency on something else

Yes. More precisely, you mean to take a dependency on something whose lifetime can't be represented by the language at the point of initialization. By definition, declaring a type non-Escapable means that the compiler is responsible for keeping it's lifetime confined to that "something". You have asked to take a dependency on a lifetime that is not visible to the compiler, and thus not enforcable.

But depending on an escapable BitwiseCopyable value is still a meaningful thing to do. For example, it is very common to project a value out of one non-Escapable type into another:

@lifetime(copy ref)
func foo(ref: MyRef) -> MyRef {
  unsafe MyRef(pointer: ref._pointer)
}

We should not require _overrideLifetime here as long as the compiler can see that _pointer is a projection into ref.

I could be convinced that requiring unsafe on the initializer is unnecessary because it is almost always redundant. Even when depending on an Int, we will also require unsafe _overrideLifetime in almost all unsafe situations. But requiring unsafe is a defensible position. It is relatively harmless because you will usually need it anyway, and I would rather error on the side that allows reversing the decision in the future.

I do have strong opinions that depending on an escapable BitwiseCopyable value:

  • is not modeled as a dependence on an immortal value

  • requires _overrideLifetime in order to escape the dependent value whenever the compiler cannot deduce ownership of the BitwiseCopyable value.

  • does not require _overrideLifetime to escape the dependent value when the BitwiseCopyable value is projected out of a value that has ownership.

I have big feelings about this because at this time, the recognized sources of unsafety don't have false positives. I think that we should require unsafe precisely when memory safety is at risk and at no other time. unsafe is already required if the initializer uses any unsafe type, like Unsafe*Pointer or C types that include unsafe pointers. In my view, this is already the precise set of types for which we need unsafe.

I understand that it's weird to take a dependency on an Int and that it might imply you're doing something wrong. For instance, you could be taking a dependency on, for two examples, (1) a CInt file descriptor that can be manipulated from under your feet, or (2) on an array index that is unsafely accessed. My objections are that (1) would still not be a memory safety bug, and (2) will have an unsafe somewhere else (presumably in the method/property that accesses the array unsafely). If we think this is worth a diagnostic, then let's have that; -strict-memory-safety is not signal that you have heightened sensitivity to non-memory-safety bugs (and conversely, not using it is not signal that you have lowered sensitivity to them).

1 Like

Provided this example:

struct MySpan: ~Escapable {
	private(set) var pointer: UnsafePointer<Foo>
	private(set) var count: Int
	
	@lifetime(copy pointer)
	init(pointer: UnsafePointer<Foo>, count: Int) {
		self.pointer = pointer
		self.count = count
	}
}

func getPointer(span: borrowing MySpan) -> UnsafePointer<Foo> { span.pointer }
func getCount(span: borrowing MySpan) -> Int { span.count }

Following point 2, is getPointer illegal? If so, is getCount also illegal?

1 Like

I don't actually care much whether we require unsafe on an "escapable BitwiseCopyable" dependence. I'll try once more to explain why it makes sense...

The only reason for non-Escapable types is memory safety (or generally safe access to resources). Any language construct that lets you bypass non-Escapable enforcement is unsafe in the same way as all the other "sources of unsafety".

If an API declares that a non-Escapable type depends on an integer, then we must assume that the integer is a key to some finite resource that is, at some point, represented by a local program scope. Otherwise, the type would be Escapable.

(1) a CInt file descriptor that can be manipulated from under your feet

The non-Escapable contract usually protects memory safety, but it cannot make a distinction between memory and other system resources that happen to be memory safe even when they are misused. I see your point, but I don't consider this an undesirable "false positive".

(2) on an array index that is unsafely accessed. My objections are that (1) would still not be a memory safety bug, and (2) will have an unsafe somewhere else (presumably in the method/property that accesses the array unsafely).

The whole point of enforcing lifetime dependence is that the unchecked array access will be done through the safe non-Escapable type. Yes, an unsafe appears in the implementation of that type, but the programmer never sees it because the non-Escapabe type gives them a safe interface!

2 Likes

Those function definitions do not relate to anything that you quoted above, but we could do something interesting with them:

exension MySpan {
  func dropLast() -> MySpan {
    let p = unsafe getPointer(span: self)
    let c = getCount(span: self)
    return c == 0 ? self : unsafe Span(pointer: p, count: c - 1)
    // ERROR: result depends on `p`
  }
}

The compiler rejects this, because it doesn't know what lifetime guarantees validity of p. The unsafe keyword itself is not the solution to this problem. The solution is to provide a programmatic way to specify the intended dependency:

    let result = unsafe Span(pointer: p, count: c - 1)
    return unsafe _overrideLifetime(result, copying: self)

In a future proposal, I'd like to be able to indicate a function's result is a "projection" of its argument, which tells us that the result's value is valid within the lifetime of its parent value:

@lifetime(copy span)
func getPointer(span: borrowing MySpan) -> UnsafePointer<Foo> { span.pointer }

Now the compiler accepts the original code above with no need for _overrideLifetime.

Requiring two unsafe markers above (on both the Span initializer and _overrideLifetime) does seem excessive, but the first is required by the use of UnsafeRawPointer and the second is required to strip away a lifetime dependency.

1 Like

My example was misleading, I meant that this is not safe to begin with. If you opted in to strict memory safety, and you were fine with unsafe at the place where bounds safety is defeated, then you are not deterred by using unsafe.

To arrive there, you need to take a shortcut that all ~Escapable violations have memory corruption consequences. This is true in the case that you took a pointer to some storage and use it for longer than you promised, and that case is already unsafe under strict memory safety. For instance, if you override the lifetime of a Span to outlive an array, you have memory safety problems the second you use the pointer (which is @unsafe). If you override the lifetime of an Int, you do not have memory safety problems the second you use that Int. You need at least one more operation that is unsafe under the current definition for it to become a problem.

In bullet points:

  • Memory safety bugs are special.
  • I agree that @lifetime(copy someInt) is silly. (Though borrow and inout have legit use cases, and I don't think that we've brought up the distinction yet?)
  • I do not see that @lifetime(copy anything) (including pointers, including integers) is creating opportunities for memory safety bugs that the existing strict memory safety model doesn't already warn for, as close (or closer) to the site where the unsafety happens.
  • I disagree that eventual @lifetime(copy someInt) bugs are particularly relevant to you if you enabled strict memory safety, and that they are particularly irrelevant to you if you didn't.
  • Currently, unsafe means "this operation that will lead to memory corruption if you fail to satisfy memory safety preconditions". Its strength will be diluted if it can also mean "at the time we designed this feature, we thought it would be a mistake to use it".

Out of all the options we have, I disagree that diagnosing in strict memory-safe mode is the best one.

1 Like

I think I misinterpreted this:

As I was pitching a hypothetical alternative, I thought you meant my alternative was bad because this expression creates a Span whose lifetime is copy self. From there, I extrapolated that self.base and self.count carried that lifetime because I didn't see how that would be the case otherwise. (Hence the assumption that taking the pointer or the count out of the span would bring up lifetime questions.)

When it comes to the uses of unsafe in your example, they are all already necessary under the current strict memory safety rules. One example of an unsafe that is required by your rules but not by the existing strict memory safety rules would be:

struct DependsOnIntSomehow: ~Escapable {
	var int: Int
	
	@lifetime(copy int)
	init(int: Int) {
		self.int = int
	}
}

var i = 4
var d = /*new!*/unsafe DependsOnIntSomehow(int: i)
1 Like

Trying something a little different: would it work to have a diagnostic if you took a copy dependency on any escapable value? Seems to me that it's the really odd case out there and I'd like to see if you feel there needs to be a distinction between trivial and non-trivial types for that case.

struct Copies: ~Escapable {
	var array: [Int]
	
	/* warning: 'copy array' does not impose a constraint
		because 'array' is Escapable */
	@lifetime(copy array)
	init(array: [Int]) {
		self.array = array
	}
}

By contrast, borrow and inout have an effect on the binding itself rather than the value (borrow prevents mutations and inout seizes exclusive access) , so they still mean something for copyable values, and shouldn't be diagnosed. The "suppression mechanism" for the @lifetime(copy) diagnostic is to use @lifetime(borrow) or entirely remove the lifetime constraint.

Writing this, I'm realizing there's two aspects I think are under-developed:

  • what it means to have a @lifetime(copy) constraint over a non-copyable value (you can escape a non-copyable type, but doesn't @lifetime(copy) imply at least a partial copy?)
  • how @lifetime(copy) diagnostics interact with generics (for instance, whether you get a diagnostic if the compiler sees a copy constraint is based on a BitwiseCopyable type)

which should probably be explored regardless of whether this suggestion sticks.

I see your point and agree there's no need for an unsafe marker when depending on an Integer. There is no way for the non-Escapable type to "hide" unsafe access because, in order to perform any unsafe access, someone will eventually need to hand it that UnsafePointer. And that point will be marked unsafe.

Initially, we did not plan to allow dependence on escapable BitwiseCopyable at all. That is effectively a promise of lifetime safety for something we don't know the lifetime of. We excused it as being an important convenience without much risk as long the programmer still needs to explicitly identify the caller's depenedency before returning the dependent value. But the way to express that "unsafe" lifetime dependence is unsafe _overrideLifetime(...), not by writing unsafe on the initializer.

1 Like

what it means to have a @lifetime(copy) constraint over a non-copyable value (you can escape a non-copyable type, but doesn't @lifetime(copy) imply at least a partial copy?)

@lifetime(copy x) is only allowed when x is non-Escapable. Your example is currently diagnosed as:
error: cannot copy the lifetime of an Escapable type, use '@lifetime(borrow array)' instead

Intuitively @lifetime(copy x) means that the function returns a copy of whatever internal state is protected by the dependency. So the original argument (x) can be destroyed while the function's result is still valid. Perhaps strangely, @lifetime(copy) is just as valid for non-Copyable types as for Copyable types. There's no need to actually copy the argument's value. The point is simply that the original can be destroyed.

Semantically, it is the same as simply copying a non-Escapable value via assignment: let span2 = span1 (or moving a non-Escapable and non-Copyable value).

Formally, it means that the function's result inherits the argument's lifetime dependency without creating a new borrow scope. Ultimately, all dependencies are rooted in a borrow scope.

So, there's no way to depend on a copy of an Escapable value because there's no dependency to inherit and no scope to depend on.

@lifetime(borrow|inout x) creates a new borrow scope that can serve as the root of a chain of dependencies. I think the biggest shortcoming of the annotation syntax is that it is not very obvious or explicit when a new borrow scope is being created for the root of dependencies. It's a hard problem to solve with Swift's syntax.

how @lifetime(copy) diagnostics interact with generics (for instance, whether you get a diagnostic if the compiler sees a copy constraint is based on a BitwiseCopyable type)

That is alluded to briefly in Conditional lifetime dependencies:

When generic substitution produces an Escapable type, any @lifetime dependencies applied to that type are ignored.

What you're getting at though is neither fully developed in this pitch nor fully implemented in the type checker. We need to ensure that a result cannot inherit an argument's dependency unless the argument's type has a dependency whenever the result type needs one. Normally, this holds because they are the same type.

But, this should be illegal:

@lifetime(copy x)
func foo<T: ~Escapable>(x: T) -> RawSpan { ... }`

And this should be illegal:

@lifetime(copy x)
func foo<T: ~Escapable, U: ~Escapable>(x: T) -> U { ... }`

Although this is fine:

@lifetime(borrow x)
func foo<T: ~Escapable, U: ~Escapable>(x: T) -> U { ... }`
3 Likes

Requiring the copied dependency to be among two values of the same type seems too restrictive, since that would prevent wrapping or conversion:

@lifetime(copy x)
func asRawSpan<T>(_ x: Span<T>) -> RawSpan

@lifetime(copy x)
func asOptional<T>(_ x: Span<T>) -> Span<T>?

It doesn't seem inherently problematic to allow for a dependency to be copied from a potentially Escapable generic value. Since Escapable types don't yet have any casting support, the only way to get from T to RawSpan or U would be by calling a protocol requirement which copies the dependency, and within the constraints of the proposal as it stands today, I think that such a requirement can only concretely be implemented by a type that is strictly not Escapable. (Since we have rejected unsafeBitCast-ing into a non-Escapable type, future safe dynamic casting to a non-Escapable type would presumably also only succeed when the source value is actually a non-Escapable type as well.)

I don't have a concrete use case for this in mind, but it at least conceptually makes sense to me that if T were Escapable in your examples, then "copying the dependency" from an Escapable value could copy an immortal dependency to the result, since you could look at Escapable values not as having no lifetime dependency at all, but as always having an immortal lifetime dependency.

edit: I did think of one conditionally Escapable case that needs to work. An operation that takes one or more potentially non-Escapable value and wraps it in a conditionally Escapable wrapper needs to be able to express the copy constraint for the cases where it is not Escapable:

@lifetime(copy x)
func optionalize<T: ~Escapable>(_ x: T) -> Optional<T> { return .some(x) }

although in this case, the source and target of the dependency are Escapable under the exact same conditions.

1 Like

The same-type only applies to conditionally ~Escapable generic types. In that case, the lifetime dependency is type erased.

Span<T> -> RawSpan is satisfiable because both Span and RawSpan have an implicitly declared lifetime dependency. Let's call it storage. So this generates an implicit:

@lifetime(.storage: arg.storage)
func foo(arg: Span<T>) -> RawSpan

T -> Optional<T> is satisfiable because Optional is declared to be conditionally ~Escapable on T.

if T were Escapable in your examples, then "copying the dependency" from an Escapable value could copy an immortal dependency to the result

That's a possibility. We need to decide whether we want immortal lifetimes to be implicitly copied, which is effectively what happens today. There's no real danger at the moment. But, in the future, we also need handle multiple lifetime dependencies declared as part of the type, which might be associated with types other than Self. During type substitution, we can't arbitrarily create relationships between lifetime dependencies of different type declarations.

2 Likes

I tried this in the last nightly toolchain and I ended up in a somewhat confusing situation:

struct Ref: ~Copyable, ~Escapable {
	@lifetime(&baseObject)
	init<T>(baseObject: inout T) {}
}

struct Wrapper: ~Copyable, ~Escapable {
	var ref: Ref
	
	@lifetime(copy ref)
	init(ref: consuming Ref) {
		self.ref = ref
	}
}

This is the correct lifetime annotation, but I found it confusing to write @lifetime(copy ref) given that ref is non-copyable and being consumed. I understand it's the lifetime that's being copied, not the value, but I wonder if we might want @lifetime(consuming ref) to exist and be more or less the same as @lifetime(copy ref).

Should we disallow copying the dependency of a inout parameter?

After reading the "Allowed Lifetime Dependencies" section of the proposal, I was pretty clear about the constraints "if the ArgType is Escapable". However, when discussing "If the ArgType is non-Escapable", the proposal simple says

in addition to borrow or & , a copy dependency-kind is allowed, to indicate that the returned value has the same dependency as the argument.

I think it left out a case where an inout scoped dependency is copied. And it is actually legal to write the following code using the nightly compiler build:

struct A: ~Escapable {
    @lifetime(immortal)
    init() {}

    mutating func mutate() {}
}

@lifetime(&a)
func create_scoped(_ a: inout A) -> A {
    return a
}

@lifetime(copy a)
func create_copy(_ a: inout A) -> A {
    return a
}

func bar() {
    var a = A()
    var b = create_scoped(&a)
    var c = create_copy(&b)

    b.mutate()
    c.mutate()
}

Here both b and c have inout dependencies on a, and they can be mutated overlappingly. Shouldn't this be an error?

A copy dependency on an inout parameter specifically copies the dependency from the value of the argument at the start of the function call, so c gets the same &a dependency as the initial value of b had. In your example, A is Copyable, so you don't need copy dependencies to break exclusivity, since you can copy b into another variable directly:

func bar() {
    var a = A()
    var b = create_scoped(&a)
    var c = b

    b.mutate()
    c.mutate()
}

Generally, values that represent exclusive references should be ~Copyable as well as ~Escapable, like MutableSpan.

2 Likes

Are there any special synergies with reference types?

For example, I tested the following code using the latest compiler, and it compiles fine:

struct Token: ~Escapable {
    let value: Int

    @_lifetime(borrow value)
    init(_ value: Int) {
        self.value = value
    }
}

class Manager {
    var counter = 0

    func generateTempToken() -> Token {
        defer { counter += 1 }
        let token = Token(counter)
        return _overrideLifetime(token, borrowing: self)
    }
}

The compiler does not have a problem that my generateTempToken function is not attributed with a @_lifetime.

Also, I'd like to know does the above code have any design problems? Should ~Escapable and @_lifetime be used with reference types?

What would be the correct lifetime annotation on this function?

func forward<T: ~Escapable>(_ value: T) -> T {
  // perhaps log something about `value`
  return value
}

i.e. the function simply returns the value passed to it? How do we spell "the result of this function is the same as its input, so the lifetime of the result is the same too"?

Swift Testing has several functions with this shape that are necessary for its correct operation and as of right now, we can't correctly handle non-escaping values in most contexts because we can't e.g. transform #expect(span.foo) into check(forward(forward(span).foo)).

We're also going to need to figure out how to properly handle move-only types (and move-only, non-escaping types) but that is presumably orthogonal to this pitch.

@lifetime(copy value) will give the result the same lifetime constraint as the value passed in.

2 Likes

I played around some more and wrote an incomplete SQLite wrapper that uses non-escapable types. It lets you write something like this without Swift refcounting costs:

let db = try SQLiteDB(url: dbURL)
var stmt = try db.prepare("SELECT Price FROM Products WHERE Product = ?")
try stmt.setValue(priceTag.productID, atIndex: 1)
var iter = stmt.makeIterator()
if let next = try iter.next() {
    priceTag.price = next.getValue(atIndex: 0)
}

I don't think it's very useful in production as it stands, but it's kind of neat, and it helps put the model to the test. In fact, one thing I'm noticing is that the semantics of lifetimes are unclear in the presence of errors. Right now, the text and blob bindings accept String and Data objects, respectively. I use SQLITE_TRANSIENT to copy off the data, since I cannot guarantee that the pointer I get from string.withUTF8 {or data.withUnsafeBufferPointer { will still be valid by the time the query runs. It would be nice to take a dependency on UTF8String or RawSpan instead:

@lifetime(&self, borrow value)
mutating func setValue(_ value: UTF8Span?, atIndex index: Int) throws {
    guard var value else {
        try setNullValueAtIndex(index)
        return
    }

    let error = value.span.withUTF8 {
        $0.withMemoryRebound(to: CChar.self) {
            // no copy: borrowing `value` means the pointer
            // stays valid until this object's lifetime ends
            sqlite3_bind_text64(handle, Int32(index), $0.baseAddress, UInt64($0.count), nil, UInt8(SQLITE_UTF8))
        }
    }
    guard error == SQLITE_OK else {
        throw SQLiteError(code: error)
    }
}

But what happens if we get to throw SQLiteError? What assumptions will the compiler make about how SQLiteStatement and UTF8Span are tied? The correct assumption to make in this case is that they are not, but that's really just because I accidentally uphold a strong exception guarantee (nothing changed if the operation failed) and you could easily imagine that throwing leaves the object in a somewhat inconsistent state that does have a dependency.

(Secondarily: @glessard, is span.withUnsafePointer { correct to use here, given that the pointer is handed off to escape? It seems to me the answer has to be yes, but it breaks the convention that you shouldn't escape a pointer that you get in a function like that.)

As currently implemented, since the value of an inout parameter is always transferred back to the caller whether the function exits by a normal return or throw, the dependency will apply to self unconditionally. It seems reasonable to me to support applying different dependencies for the error and success paths, though.