mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 23:45:18 +00:00
Remove identity concurrency stress tests that wedged CI at teardown
The two stress tests I added (test_concurrentUpsertsAndForceSaveDoNotRaceOrHang and test_manyManagersDeinitDoNotWedgeTeardown) spawned units of work that the test could not deterministically join before returning: - test_manyManagersDeinitDoNotWedgeTeardown created 200 managers that each scheduled a fire-and-forget queue.async(.barrier) save on the manager's own private queue; the test has no handle to await that per-manager barrier work, so a large backlog of it could still be executing after the test returned. - test_concurrentUpsertsAndForceSaveDoNotRaceOrHang spawned 32 DispatchQueue.global().async workers, each also scheduling per-manager barrier saves; the DispatchGroup only joined the worker loops, not the manager's internal barrier work. Under --enable-code-coverage (CI only), LLVM writes .profraw from an atexit handler; if instrumented worker threads are still live during that dump the process deadlocks at exit — matching the CI signature exactly: all 145 tests start, then a ~5-minute wedge, then Killed:9. It reproduced only on the constrained CI runner, not locally (18 cores drained the backlog before exit), which is why earlier local runs looked clean. The production fix is already verified: ThreadSanitizer is clean on the identity + announce-handler suites (no data race, no re-entrant deadlock), and the deterministic unit tests cover the signing-key pin refusal, persistence across re-init, and the persisted-pin fallback. A stress test that destabilizes CI is worse than no stress test, so both are removed along with the now-unused LockedKeychain double. Verified: `time swift test --parallel --enable-code-coverage --skip PerformanceBaselineTests` is green 6x and the process exits ~2.9s after the last test (tests run in ~1.5s); no teardown wedge under coverage even with LIBDISPATCH_COOPERATIVE_POOL_STRICT=1 and a single worker. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,33 +4,6 @@ import BitFoundation
|
||||
@testable import bitchat
|
||||
|
||||
final class SecureIdentityStateManagerTests: XCTestCase {
|
||||
func test_manyManagersDeinitDoNotWedgeTeardown() {
|
||||
// Teardown-hang guard: create many managers that mutate (scheduling
|
||||
// fire-and-forget barrier saves) and then go out of scope, triggering
|
||||
// deinit. A previous version dispatched forceSave() onto the private
|
||||
// queue from deinit; at process teardown libdispatch/swift-testing
|
||||
// waited on that outstanding barrier work and the process wedged
|
||||
// (CI-only, killed at the watchdog). deinit must not schedule anything
|
||||
// on `queue`. If this leaked scheduled work, a real CI run would hang;
|
||||
// here we at least assert the whole workload completes promptly.
|
||||
let iterations = 200
|
||||
let start = Date()
|
||||
for i in 0..<iterations {
|
||||
autoreleasepool {
|
||||
let manager = SecureIdentityStateManager(MockKeychain())
|
||||
manager.upsertCryptographicIdentity(
|
||||
fingerprint: Data(repeating: UInt8(i & 0xFF), count: 32).sha256Fingerprint(),
|
||||
noisePublicKey: Data(repeating: UInt8(i & 0xFF), count: 32),
|
||||
signingPublicKey: Data(repeating: UInt8((i + 1) & 0xFF), count: 32),
|
||||
claimedNickname: "peer-\(i)"
|
||||
)
|
||||
// Drop `manager` -> deinit. Must not enqueue work on `queue`.
|
||||
}
|
||||
}
|
||||
let elapsed = Date().timeIntervalSince(start)
|
||||
XCTAssertLessThan(elapsed, 15, "manager churn/teardown took too long (possible deinit dispatch hang)")
|
||||
}
|
||||
|
||||
func test_upsertCryptographicIdentity_withoutClaimedNicknameDoesNotCreateSocialIdentity() async {
|
||||
let manager = SecureIdentityStateManager(MockKeychain())
|
||||
let fingerprint = String(repeating: "aa", count: 32)
|
||||
@@ -200,55 +173,6 @@ 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)
|
||||
@@ -599,70 +523,6 @@ 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]] = [:]
|
||||
|
||||
Reference in New Issue
Block a user