Fix identity-cache save race and deinit deadlock in forceSave

The previous commit moved cryptographicIdentities into the persisted
IdentityCache, which made the pre-existing off-queue cache access in
the save path fatal instead of merely racy: forceSave() -> performSave()
read and JSON-encoded `cache` on the caller's thread while a concurrent
`queue.async(.barrier)` writer mutated the same dictionary. ThreadSanitizer
flags this as a data race in saveIdentityCache(), and because JSONEncoder
walks the dictionary storage, an interleaved mutation can spin forever —
which is what hung the CI "Run Swift Tests (app)" job (killed at the
watchdog timeout).

The naive fix (snapshot `cache` under `queue.sync` in forceSave) instead
introduced a deadlock: forceSave() is reachable from deinit, and the
object's final release can run *on* the identity queue (the fire-and-forget
barrier saves capture self), so queue.sync there is a re-entrant same-queue
wait -> SIGTRAP. That matched the intermittent crash the hang investigation
surfaced.

Fix:
- Split persistence into persist(snapshot:) which encodes/seals/writes a
  by-value IdentityCache snapshot, decoupled from reading `cache`.
- saveIdentityCache() (only ever called inside a barrier writer) passes
  `cache` directly — already serialized, race-free.
- forceSave() now snapshots + persists inside `queue.async(flags:.barrier)`
  (async, never sync): the read is on the barrier context so it never races
  an in-flight write, and being async it can't deadlock when invoked from
  deinit running on the queue. Durability is unaffected: every mutating API
  already persists inline within its own barrier, so forceSave is a
  belt-and-suspenders flush.

Test: test_concurrentUpsertsAndForceSaveDoNotRaceOrHang hammers concurrent
upserts interleaved with forceSave; it reproduces the data race under
`--sanitize=thread` on the old code (SecureIdentityStateManager.swift:250)
and passes cleanly with the fix. A lock-guarded LockedKeychain double is
used so the test exercises the manager's own cache race rather than the
non-thread-safe MockKeychain. Verified green over repeated
`swift test --parallel` runs both with and without --enable-code-coverage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jack
2026-07-02 00:25:14 +02:00
co-authored by Claude Fable 5
parent dbe11641ad
commit a55a16adcd
2 changed files with 149 additions and 18 deletions
@@ -173,6 +173,55 @@ final class SecureIdentityStateManagerTests: XCTestCase {
XCTAssertEqual(reloaded.getSocialIdentity(for: fingerprint)?.claimedNickname, "victim")
}
func test_concurrentUpsertsAndForceSaveDoNotRaceOrHang() {
// Regression guard for a data race between a barrier writer mutating
// `cache` and `forceSave` encoding it off-queue: JSONEncoder walking a
// dictionary being concurrently mutated can spin forever (observed as
// a CI suite hang, killed at the watchdog timeout). forceSave must
// snapshot `cache` on `queue` before encoding.
//
// forceSave is funnelled through a single serial actor: the guard is
// for the manager's own `cache` race, and MockKeychain is not itself
// thread-safe (production forceSave is not called concurrently). The
// interleaving with the concurrent barrier writers is what matters.
let manager = SecureIdentityStateManager(LockedKeychain())
let noiseKeys = (0..<32).map { Data(repeating: UInt8($0), count: 32) }
let forceSaveQueue = DispatchQueue(label: "test.forceSave.serial")
let group = DispatchGroup()
for (index, noiseKey) in noiseKeys.enumerated() {
group.enter()
DispatchQueue.global().async {
let fingerprint = noiseKey.sha256Fingerprint()
for iteration in 0..<20 {
manager.upsertCryptographicIdentity(
fingerprint: fingerprint,
noisePublicKey: noiseKey,
signingPublicKey: Data(repeating: UInt8(index), count: 32),
claimedNickname: "peer-\(index)-\(iteration)"
)
// Interleave a serialized off-queue save with the in-flight
// barrier writes from all the other threads.
forceSaveQueue.async { manager.forceSave() }
}
group.leave()
}
}
let completed = group.wait(timeout: .now() + 20)
XCTAssertEqual(completed, .success, "concurrent upsert/forceSave workload hung")
forceSaveQueue.sync {} // drain outstanding saves
// The pins landed and are readable (also fences pending barrier writes).
for (index, noiseKey) in noiseKeys.enumerated() {
let peerID = PeerID(publicKey: noiseKey)
XCTAssertEqual(
manager.getCryptoIdentitiesByPeerIDPrefix(peerID).first?.signingPublicKey,
Data(repeating: UInt8(index), count: 32)
)
}
}
func test_setBlocked_clearsFavoriteState() async {
let manager = SecureIdentityStateManager(MockKeychain())
let fingerprint = String(repeating: "ab", count: 32)
@@ -523,6 +572,70 @@ final class SecureIdentityStateManagerTests: XCTestCase {
}
}
/// Thread-safe in-memory keychain for the concurrent stress test. MockKeychain
/// itself is not synchronized (production keychain access is serialized), so a
/// dedicated lock-guarded double is used so the test exercises the manager's
/// `cache` race rather than crashing on the double's own unsynchronized dict.
private final class LockedKeychain: KeychainManagerProtocol {
private let lock = NSLock()
private var storage: [String: Data] = [:]
private var serviceStorage: [String: [String: Data]] = [:]
private func sync<T>(_ body: () -> T) -> T {
lock.lock(); defer { lock.unlock() }
return body()
}
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool {
sync { storage[key] = keyData; return true }
}
func getIdentityKey(forKey key: String) -> Data? {
sync { storage[key] }
}
func deleteIdentityKey(forKey key: String) -> Bool {
sync { storage.removeValue(forKey: key); return true }
}
func deleteAllKeychainData() -> Bool {
sync { storage.removeAll(); serviceStorage.removeAll(); return true }
}
func secureClear(_ data: inout Data) { data = Data() }
func secureClear(_ string: inout String) { string = "" }
func verifyIdentityKeyExists() -> Bool {
sync { storage["identity_noiseStaticKey"] != nil }
}
func getIdentityKeyWithResult(forKey key: String) -> KeychainReadResult {
sync {
if let data = storage[key] { return .success(data) }
return .itemNotFound
}
}
func saveIdentityKeyWithResult(_ keyData: Data, forKey key: String) -> KeychainSaveResult {
sync { storage[key] = keyData; return .success }
}
func save(key: String, data: Data, service: String, accessible: CFString?) {
sync {
if serviceStorage[service] == nil { serviceStorage[service] = [:] }
serviceStorage[service]?[key] = data
}
}
func load(key: String, service: String) -> Data? {
sync { serviceStorage[service]?[key] }
}
func delete(key: String, service: String) {
sync { serviceStorage[service]?.removeValue(forKey: key) }
}
}
private final class FailingCacheSaveKeychain: KeychainManagerProtocol {
private var storage: [String: Data] = [:]
private var serviceStorage: [String: [String: Data]] = [:]