COW-hazards between SE-0380 (Lexical Lifetimes) and SE-0429 (Partial Consumption)

this is a pattern i get burned by when having LLMs write Swift code:

here, S is a noncopyable struct, with a noncopyable field T and a copyable field U. but U is only copyable because it needs to support protocol conformances (like Sequence). neither field should ever actually be copied - if they do, that’s a very bad day for this barbie.

struct S: ~Copyable {
    struct T: ~Copyable {
        var x: [Int]
    }
    struct U {
        var y: [Int]
    }

    let t: T
    let u: U
}

if you, or your AI assistant write something like this, one could be highly confident this is avoiding the copy hazard, mine (Gemini Flash 3.6) certainly was:

func f(s: inout S, yield: (inout S.T, inout S.U) -> ()) {
    let local: S = consume s
    var t: S.T = local.t
    var u: S.U = local.u
    yield(&t, &u)
    s = .init(t: t, u: u)
}

We have direct compiler proof that local's lifetime is already ended right on that line!
When we tested adding _ = consume local right after constructing ..., the Swift compiler emitted this exact diagnostic:

    error: 'local' consumed more than once
      _ = consume local
                  `- note: consumed again here

— an erroneous LLM output (Gemini 3.6 Flash)

the problem here is that the AI thinks there’s Rust-style partial consumption of fields going on here, and that the access to local.t is “turning off” the pessimizing assumptions of SE-0380. but that’s not really what the fine print of SE-0429 says - because U is still copyable, SE-0380 remains in effect and local.u is considered to be alive for the full duration of f. which means yield remains vulnerable to copy-on-write.

because encapsulation usually prohibits the injection of empty sentinel values in real code, the only “right” way to express this operation is like this:

func g(s: inout S, yield: (inout S.T, inout S.U) -> ()) {
    let local: S = consume s
    var t: S.T
    var u: S.U
    // or just skip `local` and `consume s` directly
    switch (consume local) {
    case let local:
        t = local.t
        u = local.u
    }
    yield(&t, &u)
    s = .init(t: t, u: u)
}

but i find this highly unintuitive. this is an avoidable footgun and this behavior is just a bad default.

i feel a more sensible default is to just turn off SE-0380 if any noncopyable field is being explicitly consumed, even if other fields are copyable.

Hmm... yeah this does seem like a bit of an odd interaction[1].

I'm a bit confused about the feature to which you're referring here. SE-380 is about if/switch expressions, not lexical lifetimes IIUC.

I feel like part of the problem is that you can't explicitly end the lifetime of the "rest" of the binding after having partially consumed one of its fields. Well... it seems you actually can in this case, but it seems sort of like a quirk of this narrow case – e.g. if you pull out the copyable field first, then explicitly consume the variable before reading the non-copyable field:

func f(s: inout S, yield: (inout S.T, inout S.U) -> ()) {
    var u: S.U = s.u // read u first
    var t: S.T = (consume s).t // kill the variable and read t
    yield(&t, &u) // no extra retain/release emitted for u
    s = .init(t: t, u: u)
}

It would seem more natural to me to be able to write this:

func f(s: inout S, yield: (inout S.T, inout S.U) -> ()) {
    var u: S.U = s.u
    var t: S.T = s.t
    _ = consume s // consumes whatever is "left"
    yield(&t, &u) // now we should have unique references
    s = .init(t: t, u: u)
}

  1. I humbly suggest we refer to this hazard as "CoW-tripping" ;) ↩︎

2 Likes