Harden panic recovery and service shutdown

This commit is contained in:
jack
2026-07-25 17:43:12 +02:00
parent 5ba6f32f61
commit f0c8b4e2ea
20 changed files with 1147 additions and 110 deletions
+208 -11
View File
@@ -2,8 +2,116 @@ import BitLogger
import BitFoundation
import Foundation
struct PanicRecoveryIntent {
let fileMarkerEstablished: Bool
let externalMarkerEstablished: Bool
var hasDurableMarker: Bool {
fileMarkerEstablished || externalMarkerEstablished
}
}
/// Small, dependency-injectable transaction surface used by ChatViewModel.
/// Production persists the same intent in two independent locations before
/// any application state is erased. Tests can inject an ephemeral operation
/// set without touching the developer's Application Support directory.
struct PanicRecoveryOperations {
let isPending: () throws -> Bool
let begin: () -> PanicRecoveryIntent
let wipeMedia: (PanicRecoveryIntent) throws -> Void
let complete: () throws -> Void
static func ephemeral(
wipeMedia: @escaping () throws -> Void = {}
) -> PanicRecoveryOperations {
PanicRecoveryOperations(
isPending: { false },
begin: {
PanicRecoveryIntent(
fileMarkerEstablished: false,
externalMarkerEstablished: false
)
},
wipeMedia: { _ in try wipeMedia() },
complete: {}
)
}
static func live(
fileStore: BLEIncomingFileStore = BLEIncomingFileStore(),
defaults: UserDefaults = .standard
) -> PanicRecoveryOperations {
let defaultsKey = "bitchat.panicResetPending"
return PanicRecoveryOperations(
isPending: {
if defaults.bool(forKey: defaultsKey) {
return true
}
return try fileStore.isPanicRecoveryPending()
},
begin: {
defaults.set(true, forKey: defaultsKey)
let externalMarkerEstablished =
defaults.synchronize()
&& defaults.bool(forKey: defaultsKey)
let fileMarkerEstablished: Bool
do {
try fileStore.markPanicRecoveryPending()
fileMarkerEstablished = true
} catch {
fileMarkerEstablished = false
SecureLogger.error(
"Failed to persist file panic-recovery marker: \(error)",
category: .security
)
}
return PanicRecoveryIntent(
fileMarkerEstablished: fileMarkerEstablished,
externalMarkerEstablished: externalMarkerEstablished
)
},
wipeMedia: { intent in
try fileStore.panicWipe(
hasDurablePendingMarker: intent.hasDurableMarker
)
},
complete: {
// Keep the independent defaults latch until the file marker
// has definitely cleared. Any failure therefore remains
// visible to the next launch.
try fileStore.completePanicRecovery()
defaults.removeObject(forKey: defaultsKey)
guard defaults.synchronize(),
!defaults.bool(forKey: defaultsKey) else {
throw BLEIncomingFileStore.PanicRecoveryError
.externalMarkerCommitFailed
}
}
)
}
}
struct BLEIncomingFileStore {
enum PanicRecoveryError: Error {
case externalMarkerCommitFailed
case markerWriteFailed(Error)
case markerWriteAndMediaWipeFailed(
markerError: Error,
mediaError: Error
)
}
private static let quotaBytes: Int64 = 100 * 1024 * 1024
/// Kept outside `files/` so deleting the media tree cannot erase the
/// fail-closed startup decision before the full panic has committed.
private static let panicRecoveryPendingMarkerFileName =
".panic-recovery-pending"
/// Compatibility with a short-lived development build that used the
/// media-specific name for the same full-transaction latch.
private static let legacyPanicRecoveryPendingMarkerFileName =
".panic-media-wipe-pending"
private static let mediaSubdirectories = [
"voicenotes/incoming",
"voicenotes/outgoing",
@@ -25,28 +133,96 @@ struct BLEIncomingFileStore {
let fileManager: FileManager
private let baseDirectory: URL?
private let dateProvider: () -> Date
private let panicMarkerWriter: (Data, URL) throws -> Void
init(fileManager: FileManager = .default, baseDirectory: URL? = nil, dateProvider: @escaping () -> Date = Date.init) {
init(
fileManager: FileManager = .default,
baseDirectory: URL? = nil,
dateProvider: @escaping () -> Date = Date.init,
panicMarkerWriter: @escaping (Data, URL) throws -> Void = {
try $0.write(to: $1, options: .atomic)
}
) {
self.fileManager = fileManager
self.baseDirectory = baseDirectory
self.dateProvider = dateProvider
self.panicMarkerWriter = panicMarkerWriter
}
/// Panic-wipe every managed incoming and outgoing media artifact before
/// returning. Recreating the directory tree keeps later capture/receive
/// paths usable without allowing a detached cleanup task to race them.
func panicWipe() throws {
let filesDirectory = try rootDirectory().appendingPathComponent("files", isDirectory: true)
if fileManager.fileExists(atPath: filesDirectory.path) {
try fileManager.removeItem(at: filesDirectory)
}
for subdirectory in Self.mediaSubdirectories {
try fileManager.createDirectory(
at: filesDirectory.appendingPathComponent(subdirectory, isDirectory: true),
withIntermediateDirectories: true,
attributes: nil
///
/// Marker persistence and deletion are deliberately separate error
/// domains: even when both durable marker channels fail, deletion is
/// still attempted before this method reports the marker failure.
func panicWipe(
hasDurablePendingMarker: Bool = false
) throws {
let markerError: Error?
do {
try markPanicRecoveryPending()
markerError = nil
} catch {
markerError = error
SecureLogger.error(
"Could not persist file panic-recovery marker; attempting media deletion anyway: \(error)",
category: .security
)
}
do {
let filesDirectory = try rootDirectory()
.appendingPathComponent("files", isDirectory: true)
if fileManager.fileExists(atPath: filesDirectory.path) {
try fileManager.removeItem(at: filesDirectory)
}
for subdirectory in Self.mediaSubdirectories {
try fileManager.createDirectory(
at: filesDirectory.appendingPathComponent(
subdirectory,
isDirectory: true
),
withIntermediateDirectories: true,
attributes: nil
)
}
} catch {
if let markerError {
throw PanicRecoveryError.markerWriteAndMediaWipeFailed(
markerError: markerError,
mediaError: error
)
}
throw error
}
if let markerError, !hasDurablePendingMarker {
throw PanicRecoveryError.markerWriteFailed(markerError)
}
}
func markPanicRecoveryPending() throws {
let markerURL = try panicRecoveryPendingMarkerURL()
try fileManager.createDirectory(
at: markerURL.deletingLastPathComponent(),
withIntermediateDirectories: true,
attributes: nil
)
try panicMarkerWriter(Data([1]), markerURL)
}
func isPanicRecoveryPending() throws -> Bool {
try panicRecoveryMarkerURLs().contains {
fileManager.fileExists(atPath: $0.path)
}
}
func completePanicRecovery() throws {
for markerURL in try panicRecoveryMarkerURLs()
where fileManager.fileExists(atPath: markerURL.path) {
try fileManager.removeItem(at: markerURL)
}
}
/// Resolves (and creates) an incoming-media directory for callers that
@@ -152,6 +328,27 @@ struct BLEIncomingFileStore {
)
}
private func panicRecoveryPendingMarkerURL() throws -> URL {
try rootDirectory().appendingPathComponent(
Self.panicRecoveryPendingMarkerFileName,
isDirectory: false
)
}
private func panicRecoveryMarkerURLs() throws -> [URL] {
let root = try rootDirectory()
return [
root.appendingPathComponent(
Self.panicRecoveryPendingMarkerFileName,
isDirectory: false
),
root.appendingPathComponent(
Self.legacyPanicRecoveryPendingMarkerFileName,
isDirectory: false
)
]
}
private func sanitizedFileName(_ name: String?, defaultName: String, fallbackExtension: String?) -> String {
var candidate = (name ?? "")
.replacingOccurrences(of: "\0", with: "")
+220 -31
View File
@@ -155,6 +155,10 @@ final class BLEService: NSObject {
private var centralManager: CBCentralManager?
private var peripheralManager: CBPeripheralManager?
private var characteristic: CBMutableCharacteristic?
private let shouldInitializeBluetoothManagers: Bool
private let panicLifecycleLock = NSLock()
private var _isPanicSuspended: Bool
private var panicLifecycleGeneration: UInt64 = 0
// MARK: - Identity
@@ -275,10 +279,13 @@ final class BLEService: NSObject {
keychain: KeychainManagerProtocol,
idBridge: NostrIdentityBridge,
identityManager: SecureIdentityStateManagerProtocol,
initializeBluetoothManagers: Bool = true
initializeBluetoothManagers: Bool = true,
startSuspendedForPanicRecovery: Bool = false
) {
self.keychain = keychain
self.idBridge = idBridge
self.shouldInitializeBluetoothManagers = initializeBluetoothManagers
self._isPanicSuspended = startSuspendedForPanicRecovery
noiseService = NoiseEncryptionService(keychain: keychain)
self.identityManager = identityManager
super.init()
@@ -327,37 +334,90 @@ final class BLEService: NSObject {
// any access from another queue (cross-queue reads use readLinkState).
linkStateStore.assumeOwnership(of: bleQueue)
if initializeBluetoothManagers {
// Initialize BLE on background queue to prevent main thread blocking.
#if os(iOS)
let centralOptions: [String: Any] = [
CBCentralManagerOptionRestoreIdentifierKey: BLEService.centralRestorationID
]
centralManager = CBCentralManager(delegate: self, queue: bleQueue, options: centralOptions)
let peripheralOptions: [String: Any] = [
CBPeripheralManagerOptionRestoreIdentifierKey: BLEService.peripheralRestorationID
]
peripheralManager = CBPeripheralManager(delegate: self, queue: bleQueue, options: peripheralOptions)
#else
centralManager = CBCentralManager(delegate: self, queue: bleQueue)
peripheralManager = CBPeripheralManager(delegate: self, queue: bleQueue)
#endif
if !startSuspendedForPanicRecovery {
initializeBluetoothManagersIfNeeded()
}
// Single maintenance timer for all periodic tasks (dispatch-based for
// determinism). Only run it when real Bluetooth managers exist.
meshBackgroundEnabled = initializeBluetoothManagers
startMaintenanceTimer()
if !startSuspendedForPanicRecovery {
startMaintenanceTimer()
}
// Publish initial empty state
requestPeerDataPublish()
// Initialize gossip sync manager
restartGossipManager()
if !startSuspendedForPanicRecovery {
restartGossipManager()
}
}
private var isPanicSuspended: Bool {
panicLifecycleLock.lock()
defer { panicLifecycleLock.unlock() }
return _isPanicSuspended
}
private func setPanicSuspended(_ suspended: Bool) {
panicLifecycleLock.lock()
if suspended {
panicLifecycleGeneration &+= 1
}
_isPanicSuspended = suspended
panicLifecycleLock.unlock()
}
private func capturePanicLifecycleGeneration() -> UInt64? {
panicLifecycleLock.lock()
defer { panicLifecycleLock.unlock() }
return _isPanicSuspended ? nil : panicLifecycleGeneration
}
private func isCurrentPanicLifecycleGeneration(_ generation: UInt64) -> Bool {
panicLifecycleLock.lock()
defer { panicLifecycleLock.unlock() }
return !_isPanicSuspended && panicLifecycleGeneration == generation
}
private func initializeBluetoothManagersIfNeeded() {
guard shouldInitializeBluetoothManagers,
centralManager == nil,
peripheralManager == nil,
!isPanicSuspended else { return }
// Initialize BLE on its dedicated delegate queue. On iOS, retain the
// restoration identifiers even when construction was deferred by a
// pending panic-recovery latch.
#if os(iOS)
let centralOptions: [String: Any] = [
CBCentralManagerOptionRestoreIdentifierKey:
BLEService.centralRestorationID
]
centralManager = CBCentralManager(
delegate: self,
queue: bleQueue,
options: centralOptions
)
let peripheralOptions: [String: Any] = [
CBPeripheralManagerOptionRestoreIdentifierKey:
BLEService.peripheralRestorationID
]
peripheralManager = CBPeripheralManager(
delegate: self,
queue: bleQueue,
options: peripheralOptions
)
#else
centralManager = CBCentralManager(delegate: self, queue: bleQueue)
peripheralManager = CBPeripheralManager(delegate: self, queue: bleQueue)
#endif
}
private func restartGossipManager() {
guard !isPanicSuspended else { return }
// Stop existing
gossipSyncManager?.stop()
@@ -416,7 +476,35 @@ final class BLEService: NSObject {
#endif
}
func resetIdentityForPanic(currentNickname: String) {
/// Close radio admission before application state starts disappearing.
/// CoreBluetooth callbacks consult the same gate and cannot restart scan
/// or advertising while the full panic transaction is incomplete.
func suspendForPanicReset() {
setPanicSuspended(true)
gossipSyncManager?.stop()
gossipSyncManager = nil
// Wait out sends that passed admission before the gate closed. Work
// queued behind this barrier observes `isPanicSuspended` and exits,
// so the radio stop below is a true synchronous boundary.
messageQueue.sync(flags: .barrier) {}
stopServicesImmediatelyForPanic()
clearEmergencySessionState()
}
/// Reopen the radio only after media deletion and recovery-marker commit.
func completePanicReset(restartServices: Bool) {
setPanicSuspended(false)
guard restartServices else { return }
startServices()
sendAnnounce(forceSend: true)
}
func resetIdentityForPanic(
currentNickname: String,
restartServices: Bool = true
) {
gossipSyncManager?.stop()
gossipSyncManager = nil
messageQueue.sync(flags: .barrier) {
pendingNoiseSessionQueues.removeAll()
}
@@ -460,16 +548,19 @@ final class BLEService: NSObject {
configureNoiseServiceCallbacks(for: newNoise)
refreshPeerIdentity()
}
restartGossipManager()
setNickname(currentNickname)
// Keep the transport silent until the application-level transaction
// has also removed its media and committed both recovery markers.
myNickname = currentNickname
messageDeduplicator.reset()
messageQueue.async(flags: .barrier) { [weak self] in
self?.selfBroadcastTracker.removeAll()
}
requestPeerDataPublish()
startServices()
if restartServices {
restartGossipManager()
startServices()
sendAnnounce(forceSend: true)
}
}
// Ensure this runs on message queue to avoid main thread blocking
@@ -481,6 +572,7 @@ final class BLEService: NSObject {
}
return
}
guard !isPanicSuspended else { return }
guard content.count <= maxMessageLength else {
SecureLogger.error("Message too long: \(content.count) chars", category: .session)
@@ -562,7 +654,9 @@ final class BLEService: NSObject {
/// `startServices()` the latter matters after a panic reset, where
/// `stopServices()` cancels and nils the timer.
private func startMaintenanceTimer() {
guard meshBackgroundEnabled, maintenanceTimer == nil else { return }
guard !isPanicSuspended,
meshBackgroundEnabled,
maintenanceTimer == nil else { return }
let timer = DispatchSource.makeTimerSource(queue: bleQueue)
timer.schedule(deadline: .now() + TransportConfig.bleMaintenanceInterval,
repeating: TransportConfig.bleMaintenanceInterval,
@@ -575,6 +669,12 @@ final class BLEService: NSObject {
}
func startServices() {
guard let lifecycleGeneration =
capturePanicLifecycleGeneration() else { return }
initializeBluetoothManagersIfNeeded()
if gossipSyncManager == nil {
restartGossipManager()
}
// Restart the maintenance timer if a prior stopServices() cancelled it
// (e.g. the panic flow), otherwise periodic announces, peer reconciliation
// and cache cleanup would never resume until app restart.
@@ -591,7 +691,11 @@ final class BLEService: NSObject {
// Send initial announce after services are ready
// Use longer delay to avoid conflicts with other announces
messageQueue.asyncAfter(deadline: .now() + TransportConfig.bleInitialAnnounceDelaySeconds) { [weak self] in
self?.sendAnnounce(forceSend: true)
guard let self,
self.isCurrentPanicLifecycleGeneration(
lifecycleGeneration
) else { return }
self.sendAnnounce(forceSend: true)
}
}
@@ -659,10 +763,37 @@ final class BLEService: NSObject {
centralManager?.cancelPeripheralConnection(state.peripheral)
}
}
/// Panic cannot spend its security boundary sending a signed LEAVE or
/// pumping the main run loop. Close the radio and timers immediately;
/// the identity/session cleanup follows synchronously.
private func stopServicesImmediatelyForPanic() {
collectionsQueue.sync(flags: .barrier) {
pendingNotifications.removeAll()
}
maintenanceTimer?.cancel()
maintenanceTimer = nil
scanDutyTimer?.cancel()
scanDutyTimer = nil
centralManager?.stopScan()
peripheralManager?.stopAdvertising()
let peripheralsToDisconnect = bleQueue.sync {
linkStateStore.peripheralStates
}
for state in peripheralsToDisconnect {
centralManager?.cancelPeripheralConnection(state.peripheral)
}
}
func emergencyDisconnectAll() {
stopServices()
clearEmergencySessionState()
}
private func clearEmergencySessionState() {
// Clear all sessions and peers
let cancelledTransfers: [(id: String, items: [DispatchWorkItem])] = collectionsQueue.sync(flags: .barrier) {
let entries = outboundFragmentTransfers.removeAll().map { ($0.id, $0.workItems) }
@@ -899,6 +1030,7 @@ final class BLEService: NSObject {
func sendFileBroadcast(_ filePacket: BitchatFilePacket, transferId: String) {
messageQueue.async { [weak self] in
guard let self = self else { return }
guard !self.isPanicSuspended else { return }
guard let payload = filePacket.encode() else {
SecureLogger.error("❌ Failed to encode file packet for broadcast", category: .session)
return
@@ -935,6 +1067,7 @@ final class BLEService: NSObject {
func sendFilePrivate(_ filePacket: BitchatFilePacket, to peerID: PeerID, transferId: String) {
messageQueue.async { [weak self] in
guard let self = self else { return }
guard !self.isPanicSuspended else { return }
guard let payload = filePacket.encode() else {
SecureLogger.error("❌ Failed to encode file packet for private send", category: .session)
return
@@ -1088,6 +1221,7 @@ final class BLEService: NSObject {
// MARK: - Packet Broadcasting
private func broadcastPacket(_ packet: BitchatPacket, transferId: String? = nil) {
guard !isPanicSuspended else { return }
// Apply route if recipient exists (centralized route application)
let packetToSend: BitchatPacket
if let recipientPeerID = PeerID(hexData: packet.recipientID) {
@@ -1169,8 +1303,10 @@ final class BLEService: NSObject {
}
private func enqueuePendingNotification(data: Data, centrals: [CBCentral]?, context: String, attempt: Int = 0) {
guard !isPanicSuspended else { return }
collectionsQueue.async(flags: .barrier) { [weak self] in
guard let self = self else { return }
guard !self.isPanicSuspended else { return }
let result = self.pendingNotifications.enqueue(
data: data,
targets: centrals,
@@ -1271,6 +1407,7 @@ final class BLEService: NSObject {
requireDirectPeerLink: Bool = false,
requireNoiseAuthenticatedPeerLink: Bool = false
) -> Bool {
guard !isPanicSuspended else { return false }
let ingressRecord = collectionsQueue.sync { ingressLinks.record(for: packet) }
var excludedPeerLinks = links(to: ingressRecord?.peerID)
if requireNoiseAuthenticatedPeerLink {
@@ -1421,6 +1558,7 @@ final class BLEService: NSObject {
}
private func flushDirectedSpool() {
guard !isPanicSuspended else { return }
// Move items out and attempt broadcast; if still no links, they'll be re-spooled
let toSend = collectionsQueue.sync(flags: .barrier) {
pendingDirectedRelays.drainUnexpired(
@@ -1637,6 +1775,7 @@ final class BLEService: NSObject {
}
}
private func sendAnnounce(forceSend: Bool = false) {
guard !isPanicSuspended else { return }
// Throttle announces to prevent flooding
if !announceThrottle.shouldSend(force: forceSend, now: Date()) {
return
@@ -1846,6 +1985,13 @@ extension BLEService: CBCentralManagerDelegate {
#if os(iOS)
func centralManager(_ central: CBCentralManager, willRestoreState dict: [String: Any]) {
let restoredPeripherals = (dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral]) ?? []
guard !isPanicSuspended else {
central.stopScan()
restoredPeripherals.forEach {
central.cancelPeripheralConnection($0)
}
return
}
let restoredServices = (dict[CBCentralManagerRestoredStateScanServicesKey] as? [CBUUID]) ?? []
let restoredOptions = (dict[CBCentralManagerRestoredStateScanOptionsKey] as? [String: Any]) ?? [:]
let allowDuplicates = restoredOptions[CBCentralManagerScanOptionAllowDuplicatesKey] as? Bool
@@ -1901,6 +2047,10 @@ extension BLEService: CBCentralManagerDelegate {
switch central.state {
case .poweredOn:
guard !isPanicSuspended else {
central.stopScan()
return
}
// Links restored as connected have no characteristic in the new
// process; without rediscovery they sit connected-but-unusable
// until the peer disconnects. Runs here (not willRestoreState)
@@ -1957,7 +2107,8 @@ extension BLEService: CBCentralManagerDelegate {
}
private func startScanning() {
guard let central = centralManager,
guard !isPanicSuspended,
let central = centralManager,
central.state == .poweredOn,
!central.isScanning else { return }
@@ -1978,6 +2129,7 @@ extension BLEService: CBCentralManagerDelegate {
}
func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber) {
guard !isPanicSuspended else { return }
let peripheralID = peripheral.identifier.uuidString
let advertisedName = advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? (peripheralID.prefix(6) + "")
let isConnectable = (advertisementData[CBAdvertisementDataIsConnectable] as? NSNumber)?.boolValue ?? true
@@ -2019,6 +2171,10 @@ extension BLEService: CBCentralManagerDelegate {
}
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
guard !isPanicSuspended else {
central.cancelPeripheralConnection(peripheral)
return
}
let peripheralID = peripheral.identifier.uuidString
#if os(iOS)
@@ -2169,7 +2325,9 @@ private extension CBPeripheralState {
extension BLEService {
private func tryConnectFromQueue() {
guard let central = centralManager, central.state == .poweredOn else { return }
guard !isPanicSuspended,
let central = centralManager,
central.state == .poweredOn else { return }
let decision = connectionScheduler.nextCandidate(
connectedOrConnectingCount: linkStateStore.connectedOrConnectingPeripheralCount,
@@ -2195,6 +2353,7 @@ extension BLEService {
using central: CBCentralManager,
logPrefix: String
) {
guard !isPanicSuspended else { return }
let peripheral = candidate.peripheral
let peripheralID = candidate.peripheralID
linkStateStore.beginConnecting(to: peripheral, at: Date())
@@ -2376,6 +2535,7 @@ extension BLEService {
extension BLEService: CBPeripheralDelegate {
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
guard !isPanicSuspended else { return }
if let error = error {
SecureLogger.error("❌ Error discovering services for \(peripheral.name ?? "Unknown"): \(error.localizedDescription)", category: .session)
// Retry service discovery after a delay
@@ -2402,6 +2562,7 @@ extension BLEService: CBPeripheralDelegate {
}
func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
guard !isPanicSuspended else { return }
if let error = error {
SecureLogger.error("❌ Error discovering characteristics for \(peripheral.name ?? "Unknown"): \(error.localizedDescription)", category: .session)
return
@@ -2449,6 +2610,7 @@ extension BLEService: CBPeripheralDelegate {
}
func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
guard !isPanicSuspended else { return }
if let error = error {
SecureLogger.error("❌ Error receiving notification: \(error.localizedDescription)", category: .session)
return
@@ -2566,6 +2728,7 @@ extension BLEService: CBPeripheralDelegate {
}
func peripheralIsReady(toSendWriteWithoutResponse peripheral: CBPeripheral) {
guard !isPanicSuspended else { return }
// Resume queued writes for this peripheral - called when canSendWriteWithoutResponse becomes true again
if logRateLimiter.shouldLog(key: "peripheral-ready:\(peripheral.identifier.uuidString)") {
SecureLogger.debug("📤 Peripheral \(peripheral.name ?? peripheral.identifier.uuidString.prefix(8).description) ready for more writes", category: .session)
@@ -2574,6 +2737,7 @@ extension BLEService: CBPeripheralDelegate {
}
func peripheral(_ peripheral: CBPeripheral, didModifyServices invalidatedServices: [CBService]) {
guard !isPanicSuspended else { return }
SecureLogger.warning("⚠️ Services modified for \(peripheral.name ?? peripheral.identifier.uuidString)", category: .session)
let shouldRediscover = BLEService.shouldRediscoverBitChatService(
@@ -2594,6 +2758,7 @@ extension BLEService: CBPeripheralDelegate {
}
func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) {
guard !isPanicSuspended else { return }
if let error = error {
SecureLogger.error("❌ Error updating notification state: \(error.localizedDescription)", category: .session)
} else {
@@ -2617,6 +2782,12 @@ extension BLEService: CBPeripheralManagerDelegate {
switch peripheral.state {
case .poweredOn:
guard !isPanicSuspended else {
peripheral.stopAdvertising()
peripheral.removeAllServices()
characteristic = nil
return
}
// Remove all services first to ensure clean state
peripheral.removeAllServices()
@@ -2677,6 +2848,12 @@ extension BLEService: CBPeripheralManagerDelegate {
#if os(iOS)
func peripheralManager(_ peripheral: CBPeripheralManager, willRestoreState dict: [String: Any]) {
guard !isPanicSuspended else {
peripheral.stopAdvertising()
peripheral.removeAllServices()
characteristic = nil
return
}
let restoredServices = (dict[CBPeripheralManagerRestoredStateServicesKey] as? [CBMutableService]) ?? []
let restoredAdvertisement = (dict[CBPeripheralManagerRestoredStateAdvertisementDataKey] as? [String: Any]) ?? [:]
@@ -2703,6 +2880,10 @@ extension BLEService: CBPeripheralManagerDelegate {
#endif
func peripheralManager(_ peripheral: CBPeripheralManager, didAdd service: CBService, error: Error?) {
guard !isPanicSuspended else {
peripheral.stopAdvertising()
return
}
if let error = error {
SecureLogger.error("❌ Failed to add service: \(error.localizedDescription)", category: .session)
return
@@ -2718,6 +2899,7 @@ extension BLEService: CBPeripheralManagerDelegate {
}
func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didSubscribeTo characteristic: CBCharacteristic) {
guard !isPanicSuspended else { return }
let centralUUID = central.identifier.uuidString
SecureLogger.debug("📥 Central subscribed: \(centralUUID.prefix(8))", category: .session)
linkStateStore.addSubscribedCentral(central)
@@ -2759,7 +2941,7 @@ extension BLEService: CBPeripheralManagerDelegate {
let removedPeerID = linkStateStore.removeSubscribedCentral(central)
// Ensure we're still advertising for other devices to find us
if peripheral.isAdvertising == false {
if !isPanicSuspended, peripheral.isAdvertising == false {
SecureLogger.debug("📡 Restarting advertising after central unsubscribed", category: .session)
peripheral.startAdvertising(buildAdvertisementData())
}
@@ -2796,6 +2978,7 @@ extension BLEService: CBPeripheralManagerDelegate {
}
func peripheralManagerIsReady(toUpdateSubscribers peripheral: CBPeripheralManager) {
guard !isPanicSuspended else { return }
drainPendingNotifications(logPrefix: "✅ Sent")
}
@@ -2856,6 +3039,7 @@ extension BLEService: CBPeripheralManagerDelegate {
for request in requests {
peripheral.respond(to: request, withResult: .success)
}
guard !isPanicSuspended else { return }
// Process writes. For long writes, CoreBluetooth may deliver multiple CBATTRequest values with offsets.
// Combine per-central request values by offset before decoding.
@@ -4138,6 +4322,7 @@ extension BLEService {
let uuid = peripheral.identifier.uuidString
bleQueue.async { [weak self] in
guard let self = self else { return }
guard !self.isPanicSuspended else { return }
guard let state = self.linkStateStore.state(forPeripheralID: uuid), let ch = state.characteristic else { return }
// Atomically take all pending items from the queue to avoid race conditions
@@ -4228,7 +4413,10 @@ extension BLEService {
slotReserve: Int = TransportConfig.bleBackgroundPendingConnectSlotReserve
) {
bleQueue.async { [weak self] in
guard let self, let central = self.centralManager, central.state == .poweredOn else { return }
guard let self,
!self.isPanicSuspended,
let central = self.centralManager,
central.state == .poweredOn else { return }
let budget = TransportConfig.bleMaxCentralLinks
- slotReserve
- self.linkStateStore.connectedOrConnectingPeripheralCount
@@ -5535,6 +5723,7 @@ extension BLEService {
// MARK: Consolidated Maintenance
private func performMaintenance() {
guard !isPanicSuspended else { return }
maintenanceCounter += 1
lastMaintenanceAt = Date()