Crash when looking up conformance to Identifiable on iOS 12

I recently encountered a crash in production and was curious about why I never received a compiler error given the setup I had. A stripped down version is this, with a deployment target of iOS 12.4 (pre-Identifiable):

protocol P: Swift.Identifiable {}
struct R: P { var id: ObjectIdentifier }
class S<T: P> {}

// later....
let s = S<R>() // crash!

If the app is run on iOS 12, the result is a crash in swift_getWitnessTable, with the frame immediately preceding it being labeled as "lazy protocol witness table cache variable for type Module.R and conformance Module.R : Swift.Identifiable in Module."

Clearly, I shouldn't be instantiating something on iOS 12 which relies on Identifiable, but shouldn't I have gotten an error at some point? E.g., when declaring P (since it inherits from Identifiable) or S (since its generic constraints transitively rely on Identifiable).

Looks like [SR-13090] Runtime crash when conforming to unavailable protocol (Identifiable) · Issue #55536 · apple/swift · GitHub. Also, this might be relevant.

It looks like a bug in the compiler, because it doesn't seem to be checking availability in your example. Also, these examples show the current behaviour:

// Xcode 12 beta 1
// Deployment target: iOS 14
// -------------------------------------
@available(iOS 15.0, *)
protocol Foo {}

class A: Foo {} // This doesn't trigger an error

class B {}
extension B: Foo {} // No error

class C {
    let foo: Foo! // 'Foo' is only available in iOS 15.0 or newer
    init() {
        fatalError()
    }
}

func foo1<T: Foo>() -> T { fatalError() } // ''Foo' is only available in iOS 15.0 or newer'

func foo2(arg: Foo) { fatalError() } // ''Foo' is only available in iOS 15.0 or newer'
1 Like

Yep that looks exactly right @suyashsrijan. Thank you!

I think that class A: Foo {} not triggering an error is okay, since to actually use that conformance in any way you'd need to be using it from an iOS 15 context. E.g, I get an error below when I actually try to use foo:

@available(iOS 15.0, *)
protocol Foo {
    func foo()
}

@available(iOS 15.0, *)
extension Foo {
    func foo() {}
}

class A: Foo {
    func bar() { self.foo() } // 'foo' is only available in iOS 15.0 or newer
}

But the fact that we can "erase" availability by passing a protocol through a refinement is definitely problematic.

Hmm, you can also do

@available(iOS 15.0, *)
protocol Foo {
    func foo()
}

class A: Foo {
    func foo() { print("Using foo") }
}

let a = A()
a.foo()

which doesn't trigger an error. Replacing let a = A() with let a: Foo = A() does trigger an error though.

Also, replacing @available(iOS 15.0, *) with @available(iOS, unavailable) also triggers an error on class A: Foo ("'Foo' is unavailable in iOS") :thinking: I would expect @available(iOS 15.0, *) to do the same in that case.

Maybe the idea is to trigger the error at a use site? In any case, there does seem to be a bit of inconsistency!

Right, I think that's still consistent if you imagine that through compiler's iOS 14 goggles your example would look something like:

/*
@available(iOS 15.0, *)
protocol Foo {
    func foo()
}
*/

class A /*: Foo*/ {
    func foo() { print("Using foo") }
}

let a = A()
a.foo()

Since foo is just a member of A, of course it can be used pre-iOS-15. If we mark A.foo with an availability annotation, we get an error on the use of foo:

@available(iOS 15.0, *)
protocol Foo {
    func foo()
}

class A: Foo {
    @available(iOS 15.0, *)
    func foo() { print("Using foo") }
}

let a = A()
a.foo() // Error

Yeah, that makes sense. It looks like the error does trigger eventually somewhere in the code. I think your example is one scenario where it doesn't. I wonder if there are more such scenarios.

Note that that error (unsurprisingly) goes away if you change the playground's settings to build for macOS, but it doesn't go away if I mark A with, e.g., @available(macOS 10.16, iOS 14.0, *).

I wonder if what you're experiencing is expected. I found a comment here:

// We allow a type to conform to a protocol that is less available than
// the type itself. This enables a type to retroactively model or directly
// conform to a protocol only available on newer OSes and yet still be used on
// older OSes.
// To support this, inside inheritance clauses we allow references to
// protocols that are unavailable in the current type refinement context.

The compiler seems to allow unavailable protocols in inheritance list here due to above.

1 Like

Good finds. I definitely understand the logic for why we allow A to compile when Foo is only available on iOS 15 or later—when I build for iOS I'm not building for a specific version, I'm building for all versions (down to my minimum deployment target). Since we don't currently have a mechanism for availability-gating conformances, if we disallowed this there would be no way for me to express "A is available on all versions, and after iOS 15 you can also use the powerful abstractions enabled by Foo".

But it seems to me like that logic should extend to compiling on different platforms as well. I suppose you have the option of wrapping your conformance in a #if block like

#if os(macOS)
extension A: Foo {}
#else