Reduce test-process churn to fix flaky CI exit hang

The app test job intermittently hung at process exit. The suite is
load-sensitive and historically prone to cooperative-pool/teardown
deadlocks; the security changes added background work to the test process
that pushed it over the edge. Make the unit-test BLEService/identity
manager quiescent and remove blocking sync:

- forceSave() no longer does queue.sync(.barrier). It is reachable from
  deinit and from async tests on the swift-concurrency cooperative pool,
  where a blocking barrier-sync can starve/deadlock the pool. It now
  cancels the debounce timer and persists directly. (Removed the
  now-unneeded queue-specific-key re-entrancy machinery.)
- SecureIdentityStateManager persists synchronously under tests instead of
  scheduling a DispatchSourceTimer that lingers past process exit.
- Gate gossip-sync start (in addition to the maintenance timer) behind
  real Bluetooth init, so the test BLEService runs no periodic
  sign/broadcast/sync churn.
- Skip the panic Nostr reconnect under tests (connecting the shared relay
  singleton starts network/reconnect work that never completes).

Production behavior is unchanged: real Bluetooth builds run all timers and
the debounced save as before; the debounce save now actually fires
(previously a Timer on a GCD queue that never ran).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jack
2026-06-12 22:40:39 +02:00
co-authored by Claude Fable 5
parent f76fd8a538
commit 9cf7c80518
3 changed files with 58 additions and 38 deletions
@@ -150,10 +150,6 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
// Thread safety
private let queue = DispatchQueue(label: "bitchat.identity.state", attributes: .concurrent)
/// Marks `queue` so `forceSave()` can detect when it is already executing on
/// it (e.g. when `deinit` is triggered from inside a queue block) and run
/// directly instead of `queue.sync`-ing onto itself, which would deadlock.
private static let queueSpecificKey = DispatchSpecificKey<UInt8>()
// Debouncing for keychain saves.
// A DispatchSourceTimer on `queue` is used rather than Timer.scheduledTimer:
@@ -174,7 +170,6 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
init(_ keychain: KeychainManagerProtocol) {
self.keychain = keychain
queue.setSpecific(key: Self.queueSpecificKey, value: 1)
// Retrieve (or, only on genuine first run, generate) the cache
// encryption key. We MUST distinguish "key doesn't exist yet" from a
@@ -247,12 +242,29 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
}
}
/// True in unit-test bundles. Used to persist synchronously instead of
/// scheduling a debounce timer a lingering DispatchSourceTimer keeps the
/// dispatch run loop alive and prevents the test process from exiting.
private static var isRunningTests: Bool {
let env = ProcessInfo.processInfo.environment
return NSClassFromString("XCTestCase") != nil ||
env["XCTestConfigurationFilePath"] != nil ||
env["XCTestBundlePath"] != nil
}
/// Schedules a debounced save. Always invoked on `queue` under a barrier, so
/// the timer/pendingSave bookkeeping is serialized with all other state.
private func saveIdentityCache() {
// Mark that we need to save
pendingSave = true
// Under tests, persist immediately (already on `queue` under a barrier)
// rather than leaving a pending timer that blocks process exit.
if Self.isRunningTests {
performSave()
return
}
// Cancel any pending timer and schedule a fresh one on `queue`.
saveTimer?.cancel()
let timer = DispatchSource.makeTimerSource(queue: queue)
@@ -293,21 +305,16 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
}
}
// Force immediate save (for app termination). Runs synchronously on `queue`
// so the write completes before the caller proceeds (e.g. app exit).
// Force immediate save (for app termination / lifecycle events). Runs the
// write directly on the caller's thread deliberately NOT a
// `queue.sync(barrier)`: forceSave is reachable from `deinit` and from
// async tests on the swift-concurrency cooperative pool, and a blocking
// barrier-sync there can starve/deadlock the pool. Cancelling the debounce
// timer first prevents a concurrent timer-driven save.
func forceSave() {
let work = {
self.saveTimer?.cancel()
self.saveTimer = nil
self.performSave()
}
// If we are already on `queue` (e.g. deinit fired from inside a queue
// block), run directly `queue.sync` onto the current queue deadlocks.
if DispatchQueue.getSpecific(key: Self.queueSpecificKey) != nil {
work()
} else {
queue.sync(flags: .barrier, execute: work)
}
saveTimer?.cancel()
saveTimer = nil
performSave()
}
// MARK: - Social Identity Management