Hi, I'm getting an error with a particular sealed ...
# touchlab-tools
j
Hi, I'm getting an error with a particular sealed class with SKIE's sealed interop, but only in release mode,
Fatal error: Unknown subtype. This error should not happen under normal circumstances since SirClass: shared.NetworkStateUIModel is sealed.
It breaks when upgrading from 0.6.4 to 0.7.0. If I turn on swift library evolution, then it works fine again. I'm not sure why that would be an issue though, as I'm not building an xcframework, just building a dynamic linked framework alongside the iOS app everytime with the
embedAndSignAppleFrameworkForXcode
task. Additionally: • If I print the value of the sealed class before calling
onEnum(of:)
it prints a valid subclass name (
None
, bad name I know but it's legacy code) • the generated swift code is identical in 0.6.4 and 0.7.2. • This is the only sealed class I have with an
out
type parameter. The one other generic sealed class works and doesn't have
out
for the type parameters. I'll put the code in a thread
Copy code
@SealedInterop.Enabled
public sealed class NetworkStateUIModel<out UIModel> {
    public data object None : NetworkStateUIModel<Nothing>()
    public data class Error(val error: UIErrorData) : NetworkStateUIModel<Nothing>()
    public data object Loading : NetworkStateUIModel<Nothing>()
    public data class Loaded<UIModel : Any>(val model: UIModel) :
        NetworkStateUIModel<UIModel>()
}
Copy code
// Generated by Touchlab SKIE 0.7.0

import Foundation

public extension shared.Skie.BusinessLogic.NetworkStateUIModel {

    @frozen
    enum __Sealed<UIModel : Swift.AnyObject> : Swift.Hashable {

        case error(shared.NetworkStateUIModelError)
        case loaded(shared.NetworkStateUIModelLoaded<UIModel>)
        case loading(shared.NetworkStateUIModelLoading)
        case none(shared.NetworkStateUIModelNone)

    }

}

public func onEnum<UIModel : Swift.AnyObject, __Sealed : shared.NetworkStateUIModel<UIModel>>(of sealed: __Sealed) -> shared.Skie.BusinessLogic.NetworkStateUIModel.__Sealed<UIModel> {
    if let sealed = sealed as? shared.NetworkStateUIModelError {
        return shared.Skie.BusinessLogic.NetworkStateUIModel.__Sealed<UIModel>.error(sealed)
    } else if let sealed = sealed as? shared.NetworkStateUIModelLoaded<UIModel> {
        return shared.Skie.BusinessLogic.NetworkStateUIModel.__Sealed<UIModel>.loaded(sealed)
    } else if let sealed = sealed as? shared.NetworkStateUIModelLoading {
        return shared.Skie.BusinessLogic.NetworkStateUIModel.__Sealed<UIModel>.loading(sealed)
    } else if let sealed = sealed as? shared.NetworkStateUIModelNone {
        return shared.Skie.BusinessLogic.NetworkStateUIModel.__Sealed<UIModel>.none(sealed)
    } else {
        fatalError("Unknown subtype. This error should not happen under normal circumstances since SirClass: shared.NetworkStateUIModel is sealed.")
    }
}

@_disfavoredOverload
public func onEnum<UIModel : Swift.AnyObject, __Sealed : shared.NetworkStateUIModel<UIModel>>(of sealed: __Sealed?) -> shared.Skie.BusinessLogic.NetworkStateUIModel.__Sealed<UIModel>? {
    if let sealed {
        return onEnum(of: sealed) as shared.Skie.BusinessLogic.NetworkStateUIModel.__Sealed<UIModel>
    } else {
        return nil
    }
}
f
Hi! This is a very weird issue. My first instinct is that this will be some compiler bug. What version of Xcode (Swift) and Kotlin do you use?
j
I'm using Xcode 15.1 (Swift 5.9) and Kotlin 1.9.23 and also tried 1.9.24
f
I’ll try to reproduce this and let you know
j
Thanks 😊
f
Can you please share the code where you call the onEnum that causes the crash and the code that creates the problematic instance? Also, do you compile the Kotlin framework and the Swift app with the same version of Swift? (this is the primary thing that library evolution affects so it might be related)
j
This is the code where the None instance is created:
Copy code
public fun <NetworkError, NetworkData, Model, UIModel : Any> NetworkStateModelHolder<NetworkError, NetworkData, Model>.toUIModel(
    create: (NetworkData, Model) -> UIModel,
    errorToUIErrorData: (NetworkError) -> UIErrorData,
): NetworkStateUIModel<UIModel> =
    when (networkState) {
        is NetworkStateModel.Loading -> NetworkStateUIModel.Loading
        is NetworkStateModel.None -> NetworkStateUIModel.None //  <------ Here
        is NetworkStateModel.Error -> NetworkStateUIModel.Error(
            errorToUIErrorData(networkState.networkError)
        )

        is NetworkStateModel.Loaded -> NetworkStateUIModel.Loaded(
            create(networkState.networkData, model)
        )
    }
(this is NeteworkStateModelHolder, it also has out type parameters)
Copy code
public data class NetworkStateModelHolder<out NetworkError, out NetworkData, Model>(
    public val networkState: NetworkStateModel<NetworkError, NetworkData>,
    public val model: Model
) {
    public fun copyModel(createNew: Model.() -> Model): NetworkStateModelHolder<NetworkError, NetworkData, Model> =
        copy(model = model.createNew())
}
Here is the swift file where onEnum(of:) is called:
Copy code
//
//  ManageInactiveDDView.swift
//  Elfin
//
//  Created by Jonathan on 08/11/2023.
//

import SwiftUI
import UIComponents
import shared

class ManageInactiveDDViewModel: OrbitViewModel.BorrowerLoggedin_ManageDirectDebitContainerHost, ObservableObject {

	@Published
	var goToNextScreen: BorrowerReinstateDirectDebitResultKs?

	init() {
		super.init { KotlinModule.shared.borrowerLoggedInModule.ManageDirectDebitContainerHost(scope: $0) }
	}

	override func handleSideEffect(_ effect: ManageDirectDebitContainerHostEffect) {
		switch onEnum(of: effect) {
		case .showErrorToast(let toast):
			showToast(error: toast.error)
		case .reinstateResult(let model):
			goToNextScreen = ks(model.result)
		}
	}
}

struct ManageInactiveDDView: View {

	@StateObject
	private var viewModel = ManageInactiveDDViewModel()

	var body: some View {
		VStack {
			switch onEnum(of: viewModel.state) { // <-------- here
			case .loading, .none:
				FullScreenActivityIndicator()
					.elfinNavBarClose()
			case .error(let error):
				ErrorMessageView(error: error.error, retryAction: { viewModel.events.refresh() })
					.elfinNavBarClose()
			case .loaded(let model):
				ManageInactiveDDLoadedView(model: model.model, events: viewModel.events, toast: $viewModel.toast)
					.navigation(item: $viewModel.goToNextScreen) { screen in
						switch screen {
						case .reinstateFailed:
							ReinstateDirectDebitFailureView()
						case .reinstateSucceeded(let model):
							ReinstateDirectDebitSuccessView(model: model)
						}
					}
					.elfinNavBarClose()
			}
		}
		.track(.reinstateDirectDebit)

	}
}
f
can you reproduce the error if you just write this:
Copy code
onEnum(of: NetworkStateUIModelNone.shared)
?
j
and I'm pretty sure the swift versions are the same, I do have Xcode 15.3 installed, but I don't use it yet, and
xcode-select -p
gives the path to Xcode 15.1
That didn't reproduce the error, I'm just having a meeting and then will try and see what the difference is
f
If you can create a sample project that reproduces the issue
t
@Jon Bailey Could you also show code for the viewmodel where the
state
property is?
j
Yep, I'll try create a sample project now
t
Before you do that, please share code for
OrbitViewModel.BorrowerLoggedin_ManageDirectDebitContainerHost
and it's superclasses
j
yep, it's not very simple as we use Orbit MVI so there's a fair bit of code between creating the None instance and the usage of it.
t
Oh I see, is that opensource?
j
this is orbit, https://orbit-mvi.org
t
Thanks
Then code of
OrbitViewModel.BorrowerLoggedin_ManageDirectDebitContainerHost
should be enough
j
this is just a typealias:
Copy code
extension OrbitViewModel {

  typealias BorrowerLoggedin_ManageDirectDebitContainerHost = OrbitViewModel<NetworkStateUIModel<ManageDirectDebitContainerHost.UIState>, ManageDirectDebitContainerHost, ManageDirectDebitContainerHostEffect>

}
and the class is:
Copy code
import Foundation
import Combine
import shared
import UIComponents
import SwiftUI

class OrbitViewModel<UIState, EventSender: AnyObject, UIEffect> {
	private let container: OrbitContainerHostSwiftAbstraction<UIState, EventSender, UIEffect>
	private var cancellables = Set<AnyCancellable>()

	@Published
	var state: UIState

	var events: EventSender {
		container.eventSender
	}

	@Published
	var toast: Toast?

	func handleSideEffect(_ effect: UIEffect) {
		//
	}

	func onStateDidChange() {

	}

	func showToast(error: UIErrorData) {
		self.toast = Toast(.failure, title: error.title, message: error.message)
	}

	init(_ containerProvider: (CoroutineScopeWrapper) -> OrbitContainerHostAbstraction<UIState.KotlinType, EventSender, UIEffect.KotlinType>) where UIState: GeneratedKotlinWrapper, UIEffect: GeneratedKotlinWrapper {
		self.container = .init(uistateMap: UIState.init, uieffectMap: UIEffect.init, containerProvider)
		self.state = container.initialValue
		setup()
	}

	init(_ containerProvider: (CoroutineScopeWrapper) -> OrbitContainerHostAbstraction<UIState, EventSender, UIEffect.KotlinType>) where UIState: AnyObject, UIEffect: GeneratedKotlinWrapper {
		self.container = .init(uistateMap: { $0 }, uieffectMap: UIEffect.init, containerProvider)
		self.state = container.initialValue
		setup()
	}

	init(_ containerProvider: (CoroutineScopeWrapper) -> OrbitContainerHostAbstraction<UIState.KotlinType, EventSender, UIEffect>) where UIState: GeneratedKotlinWrapper, UIEffect: AnyObject {
		self.container = .init(uistateMap: UIState.init, uieffectMap: { $0 }, containerProvider)
		self.state = container.initialValue
		setup()
	}

	init(_ containerProvider: (CoroutineScopeWrapper) -> OrbitContainerHostAbstraction<UIState, EventSender, UIEffect>) where UIState: AnyObject, UIEffect: AnyObject {
		self.container = .init(uistateMap: { $0 }, uieffectMap: { $0 }, containerProvider)
		self.state = container.initialValue
		setup()
	}

	// New Container host

	init(_ containerProvider: (CoroutineScopeWrapper) -> EventSender) where UIState: GeneratedKotlinWrapper, UIEffect: GeneratedKotlinWrapper, EventSender: NewOrbitContainerHostAbstraction<UIState.KotlinType, UIEffect.KotlinType> {
		self.container = .init(uistateMap: UIState.init, uieffectMap: UIEffect.init, containerProvider)
		self.state = container.initialValue
		setup()
	}

	init(_ containerProvider: (CoroutineScopeWrapper) -> EventSender) where UIState: AnyObject, UIEffect: GeneratedKotlinWrapper, EventSender: NewOrbitContainerHostAbstraction<UIState, UIEffect.KotlinType> {
		self.container = .init(uistateMap: { $0 }, uieffectMap: UIEffect.init, containerProvider)
		self.state = container.initialValue
		setup()
	}

	init(_ containerProvider: (CoroutineScopeWrapper) -> EventSender) where UIState: GeneratedKotlinWrapper, UIEffect: AnyObject, EventSender: NewOrbitContainerHostAbstraction<UIState.KotlinType, UIEffect> {
		self.container = .init(uistateMap: UIState.init, uieffectMap: { $0 }, containerProvider)
		self.state = container.initialValue
		setup()
	}

	init(_ containerProvider: (CoroutineScopeWrapper) -> EventSender) where UIState: AnyObject, UIEffect: AnyObject, EventSender: NewOrbitContainerHostAbstraction<UIState, UIEffect> {
		self.container = .init(uistateMap: { $0 }, uieffectMap: { $0 }, containerProvider)
		self.state = container.initialValue
		setup()
	}

	private func setup() {
		container.stateFlow.sink { [weak self] state in
			guard let self else { return }
			DispatchQueue.main.async {
				self.state = state
				self.onStateDidChange()
			}
		}.store(in: &cancellables)
		container.sideEffectFlow.sink { [weak self] effect in
			DispatchQueue.main.async {
				self?.handleSideEffect(effect)
			}
		}.store(in: &cancellables)
	}

}
It's the the last init that's used in this case
also OrbitContainerHostSwiftAbstraction is:
Copy code
typealias KSwiftSealedClassWrapper = GeneratedKotlinWrapper

protocol GeneratedKotlinWrapper {
	associatedtype KotlinType: AnyObject
	init(_ obj: KotlinType)
}

class OrbitContainerHostSwiftAbstraction<UIState, EventSender: AnyObject, UIEffect> {

	internal init<UIStateKt: AnyObject, UIEffectKt: AnyObject>(uistateMap: @escaping (UIStateKt) -> UIState, uieffectMap: @escaping (UIEffectKt) -> UIEffect, _ containerProvider: (CoroutineScopeWrapper) -> OrbitContainerHostAbstraction<UIStateKt, EventSender, UIEffectKt>) {
		self.scope = KotlinMultiPlatformUtils.createImmediateCoroutineScope()
		let container = containerProvider(scope)
		let flow = createPublisher(flow: container.stateFlow, scope: scope)
		self.container = container
		self.initialValue = uistateMap(flow.value)
		self.stateFlow = flow.map(uistateMap).eraseToAnyPublisher()
		self.sideEffectFlow = createPublisher(flow: container.sideEffectFlow, scope: scope).map(uieffectMap).eraseToAnyPublisher()
		self.eventSender = container.eventSender
	}

	internal init<UIStateKt: AnyObject, UIEffectKt: AnyObject>(uistateMap: @escaping (UIStateKt) -> UIState, uieffectMap: @escaping (UIEffectKt) -> UIEffect, _ containerProvider: (CoroutineScopeWrapper) -> EventSender) where EventSender: NewOrbitContainerHostAbstraction<UIStateKt, UIEffectKt> {
		self.scope = KotlinMultiPlatformUtils.createImmediateCoroutineScope()
		let container = containerProvider(scope)
		let flow = createPublisher(flow: container.stateFlow, scope: scope)
		self.container = container
		self.initialValue = uistateMap(flow.value)
		self.stateFlow = flow.map(uistateMap).eraseToAnyPublisher()
		self.sideEffectFlow = createPublisher(flow: container.sideEffectFlow, scope: scope).map(uieffectMap).eraseToAnyPublisher()
		self.eventSender = container
	}

	private let scope: CoroutineScopeWrapper
	private let container: Any

	let initialValue: UIState
	let stateFlow: AnyPublisher<UIState, Never>
	let sideEffectFlow: AnyPublisher<UIEffect, Never>
	let eventSender: EventSender

}

private func createPublisher<T>(flow: StateFlowCollectorHelper<T>, scope: Kotlinx_coroutines_coreCoroutineScope) -> CurrentValueSubject<T, Never> {
	let publisher = CurrentValueSubject<T, Never>(flow.value)
	flow.subscribe(scope: scope) {
		publisher.send($0)
	}
	return publisher
}

private func createPublisher<T>(flow: FlowCollectorHelper<T>, scope: Kotlinx_coroutines_coreCoroutineScope) -> AnyPublisher<T, Never> {
	let publisher = PassthroughSubject<T, Never>()
	flow.subscribe(scope: scope) {
		publisher.send($0)
	}
	return publisher.eraseToAnyPublisher()
}
(The code is a bit old (2021) and things have been added, steadily trying to simplify/update things while I have time.)
t
Good to know, thanks. If you could try make a reproducer project, it'd help a lot. We couldn't repro it so far
Lastly, what version of orbit do you use?
j
6.1.0
I'm making a project now
Sorry this took so long, here's the MRE. I left the build folders in from my last run where the error occurred in case that's helpful.
also there's a branch called
works
which removes the
out
and it doesn't give the error. (and a branch
mre-without-nothing
where it has the error but without using Nothing)
also again it only on Release builds.
oh and I tried deleting my copy of Xcode 15.3 and it still gave the error
t
OH! We were only trying debug
No worries, thanks for the reproducer
j
thanks, let me know if there's anything else I can do 😊
(Also sorry should've made it clearer at the beginning about release mode)
t
This is really interesting. I thought it was a SKIE issue, but it's probably just a bug in Swift release optimizations. When I moved the
onEnum
function directly into
iOSApp.swift
file, it's behaving the same way. I also found that while
sealed is Shared.NetworkStateUIModelNone
returns false,
type(of: sealed) == Shared.NetworkStateUIModelNone.self
is true,
sealed.isKind(of: NetworkStateUIModelNone.self)
is also true. So tomorrow I'll discuss with Filip whether we'll change to use
isKind(of:)
for all usages, or if we'll keep looking into why this is happening. So far my main guess is that since
Nothing
is jus a regular
NSObject
subclass, Swift's release optimization shoots itself in the foot and thinks that when the generic param T is
NSString
vs
Nothing
that it'll never happen. But I'll probably look into assembly if I'm curious what the real answer is.
j
That's weird that it behaved the same way in the same file, but works when swift library evolution is enabled (but then I don't really know what swift library evolution is other than some way of maintaining compatibility between different libraries/versions). Ah I misunderstood completely there
I did wonder if it was something to do with
Nothing
so in the project I sent, in the
mre-without-nothing
branch I tried with a Parent, Child subclass and it still produced the problem.
t
Good to know, thanks, I’ll dig in
j
I think the problem with using
isKind(of:)
everywhere is that it crashes when force casting it afterwards to store in the enum associated value.
unsafeDowncast(to:)
works but that seems scary to use so much. Generic variance has previously caused me enough issues going from Kotlin to Swift, that I avoid it, and map to custom classes before exposing it to Swift. So maybe SKIE should just skip/error on any sealed classes that have
out
(and
in
?) type parameters.
but anyway I'm pretty sure I managed to replicate it in just Objective-C to Swift, completely without Kotlin, and also in debug configuration. Though it's 2am for me so maybe I'm processing stuff wrong, but I'm pretty confident that something is wrong when from these two print statements, the first one prints nil, and the second prints the expected object:
Copy code
let foo: SharedNetworkStateUIModel<Parent> = MRE.test()
print(foo as? SharedNetworkStateUIModelNone)
print((foo as! SharedNetworkStateUIModel<Child>) as! SharedNetworkStateUIModelNone)
Here is the only Swift and ObjC project:
t
Nice, I’ll give it a look later today
Using isKind(of:) and unsafeBitCast should be safe for our usecase
I’ll verify it first though
Looking at the assembly, Swift seems to get rid of the
if else
altogether and just keep the
fatalError
. So that means it's confident it can do it
We've decided to use
isKind(of:)
and
unsafeDowncast(:to:)
as these seem to work correctly in all cases. As far as I understand it should be safe to do so, since these are ObjC classes where the generic type doesn't matter (it's erased in runtime)
j
personally I'd be reluctant to use it in production code and so extensively, especially as the docs say to only use it if the
is
check returns true, which is the opposite of the case here. But I guess as the app would crash from the fatalError anyway it's ok, would there be any case where a usafeDowncast succeeds where it shouldn't instead of crashing? It doesn't seem like anyone has used an out type parameter before and come across this, so wouldn't it be safer to disallow sealed interop to be used with such classes?
t
The problem is in regular Swift, where ObjC erases generics, but Swift's optimizer doesn't seem to understand it properly
We could go and implement an Objective-C helper, that'll essentially just cast/erase it and then spit it out, but the result is the same as
isKind(of:)
and
unsafeDowncast(:to:)
The thing is that the
is
does return true in debug builds as you expect
The reason they warn about it is, you could essentially cast two completely different classes with
unsafeDowncast
and then it crashes way later when you access something that doesn't work. It may not even crash and lead to data corruption.
But in our case, we use
isKind(of:)
which asks Objc runtime if one is subclass of the other. Since this is a dynamic call, Swift can't reason about it the same way it does with
is
and can't optimize it away.
Still looking at it though, will keep you updated
j
Yeah makes sense, I'm just scared of the word unsafe 😂
😄 1
t
Me too, that's why I don't just slap it on there 😄
j
yeah I trust you guys 😃, also very grateful for all the work you've done with SKIE
❤️ 1
a small btw: about the the
is
returning true in the debug builds, I did get it in that pure swift+Objc project where it returns false in debug builds but it shouldn't
👀 1
t
Your objc example is close, but not exact
You're using
Child*
argument in your
None
, but it should be completely different class:
Basically this
I'll be checking how this behaves now
j
yeah I tried that first but it even gave a warning just returning it on the ObjC side
t
That's expected 😄
Yeah, I was able to reproduce it the same way
Just using
is
the way you did is expected to produce
false
in Swift
Because Swift does look at the generic parameter and for Swift it's indeed incompatible
You basically need the
onEnum
that's generic for this to get into "works in debug, doesn't in release"
image.png
But I'm confused, I thought it'd work when I remove
__covariant
, but it doesn't, still the same behavior
j
yep because neither swift nor ObjC know that Nothing is the bottom type in Kotlin, so that's why I switched to using Parent and Child, and it still gave false.
t
That's expected of Swift, you need to use the generic function to go through
Swift doesn't suport covariance/contravariance so it will not let you directly cast like this
The generic function
onEnum
replaces the generic param with a new bound
UIModel
, which lets you put anything in and then inside it uses the Objc checking
But in Release mode, Swift optimizer looks a the concrete variant you're calling, essentially sees the warning "Cast xx always fails" and removes that code as it's always false from its point of view
j
Ah ok thanks I think I understand now, that's why the
unsafeDowncast(to:)
is needed so that it can't be optimized out?
t
Correct, when using
isKind(of:)
it's a runtime-call that Swift's optimizer can't know what it'll do (Objective-C has a weird type system, you can do real weird things) and so that makes the
if
stay there. But then we need a way to cast it that is also a runtime thing because
as!
is static. Therefore
unsafeDowncast
Hopefully this should only be needed for generic classes
Although I'm really surprised nobody reported this before
I think I may have found a better solution 😄
image.png
Easy to implement, seems to work for all cases
j
would the compiler not be able to optimize that out?
does seem nicer though
t
It doesn't 😄
I'll test the same in the original reproducer though
j
Could it being the in the fatalError message affect whether it's optimized out?
t
What do you mean?
Oh I see
Good point, let me check
Nope still works 😄
j
Hopefully they don’t improve the optimizations 😂 you’d think there’d be a way of telling the compiler to never optimise something out
t
There is, but it means it'll be slow 😄
@_optimize(none)
on the
onEnum(of:)
declaration also makes it keep working
j
oh yeah not the whole function, just the
erased
variable would be ideal
t
We could probably use
withExtendedLifetime
But that's usually for different kind of optimizations
It's all about whether the optimizer can prove
let erased: Any = sealed
and then
erased as? X
is equal to
sealed as? X
j
my gut thought is that it should be able to as Any is a supertype of both
X
and the type of
sealed
btw there was one thing left I was confused about so I asked it on the Swift forums here (https://forums.swift.org/t/casting-type-checking-in-swift-broken-with-objective-c-covariance/72137) not sure it will get any answers though as it's quite niche and I'm not sure I explained it well
t
Nice, thanks for that, we'll see
Also the reason why it worked with library evolution enabled was that generic functions don't get specialized if they are not marked as
@inlinable
.
And I've verified that it's indeed because of specialization of the generic function. When I add
@_semantics("optimize.sil.specialize.generic.never")
, then it works without the
erased
workaround
f