Swift 5.x custom Hashable/Equatable conformance on iOS 9

Hi guys,

I am using custom implementation of Hashable protocol on my classes/enums using fairly primitive logic using Hasher.combine() on several String/Int based fields.

Everything seems to be working really well on Linux, macOS, and iOS 10, 11, 12, 13, 14.

On iOS 9 with the app compiled using Xcode 11.5 and Xcode 11.6 however I started receiving lot of random crashes where my custom object/enum used as a key in a Set or Dictionary is not there, swift crashes with Set or Dictionary containing duplicate keys, etc.

It all points toward some kind of issue where for my custom objects used as keys during the app run Hasher.combine() is called multiple times for the same object with different random seed, thus generating different hashValue for the same object during the same app run.

To indicate, here is the precondition on line 5 that fails:

Code Block
1. let key = SomeKey()
2. let dict = [:]
3. precondition(dict[key] == nil, "SomeKey must not be present")
4. dict[key] = SomeObject()
5. precondition(dict[key] != nil, "SomeKey must be present")


I'm trying to figure out whether this rings a bell for somebody since it is 100% iOS 9 specific.

thanks for any pointers,
Martin

Replies

Where is Hasher.combine() called in your code ?

Fortunately, iOS 9 is now used by probably less than 1%. So you could safely set target on iOS 10 or above.

But that does not explain the problem…
Can you show a concrete example which can reproduce the same issue?
Please show both implementations hash(into:) and ==, they need to be consistent.
Here is a simplified example of the Hashable and Equatable conformance:

Code Block
class MyClass {
/// remoteAddress.description always produces the same String
/// connectId is Int
}
extension MyClass: Hashable {
    public func hash(into hasher: inout Hasher) {
        hasher.combine(remoteAddress.description)
        hasher.combine(connectId)
    }
    public static func ==(lhs: MyClass, rhs: MyClass) -> Bool {
        guard lhs.remoteAddress.description == rhs.remoteAddress.description else {
            return false
        }
        guard lhs.connectId == rhs.connectId else {
            return false
        }
        return true
    }
}


Additionally I should add that the bug is not reproducible 100% times, it works most of the time and fails sometimes on the precondition on line 5 but in a significant number of cases, always on iOS 9. Never crashed on iOS 10+.

One detail I just figured out is that getters for instance variables that are used for Hashable and Equatable actually use serial queue to synchronize reads and writes to allow the class to be accessed from multiple queues at the same time.

One detail I just figured out is that getters for instance variables that are used for Hashable and Equatable actually use serial queue to synchronize reads and writes to allow the class to be accessed from multiple queues at the same time.

Sounds like your issue is rather a multi-threading problem than Hashable.

If you can show a full code which can reproduce the issue in a significant number of cases, I will try on it.
A quick update. I am still battling with this problem and the crashes are consistently showing only on iOS 9. With radically larger number of sessions, in hundreds of thousands daily on iOS 10+.

And since iOS 9 is considered dead by many it has become an academic effort of mine to figure this one out. With Apple dropping iOS 9 from Xcode probably next year, I still want to have a version in App Store that works great on these older devices.

I am having a hard time producing a reproducible test case, because the problem shows up absolutely randomly and appears to have something to do with how internals of Dictionary and Set behave on iOS 9. Code compiled with Xcode 12.2, latest available swift, swift runtime embedded in the app.

I was able to reduce intensity of the crashes drastically by prepending each Dictionary lookup with forced computation of hashValue, and although I can not explain why it would help, it seems to somewhat reduce the crashes.

Additionally, whenever I stop the debugger on failed precondition, and then examine the Dictionary, the value is always there, as if the debugger forced something to happen on the Dictionary after the process stopped.

Here is what I'm doing:

Code Block
_ = key.hashValue
dict.forEach { k, _ in k.hashValue }
precondition(dict[key] != nil)
guard let x = dict[key] else {
NSLog("dict[key] => \(String(describing: dict[key]))"
}


And I can still fail randomly and infrequently on line 4, or on line 5. If it fails on line 5, the NSLog prints the value successfully. So the second lookup always succeeds. Nonsense?

The `Equatable and Hashable` requirement was reduced to absolute minimum simply comparing/hashing one string value. The Dictionary is accessed via dispatchPrecondition checks from always the same non-main queue and is buttery smooth on iOS 10+ in production. Random iOS 9 madness :)