Fix CI hang: gate maintenance timer to Bluetooth-enabled; sign dedup test packet

Root cause of the CI app-test hang was a pre-existing bleQueue<->collectionsQueue
lock inversion driven by the periodic maintenance timer (performMaintenance ->
drainAllPendingWrites takes collectionsQueue while another path holds it and
sync-waits on bleQueue via readLinkState). The timer is created unconditionally
in init, so it also ran in the unit-test process (initializeBluetoothManagers:
false), where it only churns BLE writes/notifications/announces that don't exist.
Recent timing changes made the latent deadlock surface reliably.

- Only start the maintenance timer when real CoreBluetooth managers were
  initialized (maintenanceTimerEnabled). Production behavior is unchanged; the
  unit-test process no longer runs the timer and cannot hit the inversion.

Also fix BLEServiceCoreTests.duplicatePacket_isDeduped, which sent an unsigned
public packet that the new signature requirement (security fix #2) correctly
drops. The test now signs the packet and preseeds the sender's signing key
(production sendMessage signs public broadcasts), exercising the dedup path
(security fix #7) end to end. _test_handlePacket gains an optional
signingPublicKey to seed the registry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jack
2026-06-12 20:09:06 +02:00
co-authored by Claude Fable 5
parent 09c2c12838
commit f76fd8a538
2 changed files with 22 additions and 8 deletions
+12 -4
View File
@@ -135,6 +135,11 @@ final class BLEService: NSObject {
private var maintenanceTimer: DispatchSourceTimer? // Single timer for all maintenance tasks
private var maintenanceCounter = 0 // Track maintenance cycles
/// Whether real CoreBluetooth managers were initialized. When false (unit
/// tests), the periodic maintenance timer is not started: it only drains BLE
/// writes/notifications and re-announces, which is meaningless without
/// Bluetooth and would otherwise run its cross-queue work in-process.
private var maintenanceTimerEnabled = false
// MARK: - Connection budget & scheduling (central role)
private var connectionScheduler = BLEConnectionScheduler<CBPeripheral>()
@@ -233,7 +238,9 @@ final class BLEService: NSObject {
#endif
}
// Single maintenance timer for all periodic tasks (dispatch-based for determinism)
// Single maintenance timer for all periodic tasks (dispatch-based for
// determinism). Only run it when real Bluetooth managers exist.
maintenanceTimerEnabled = initializeBluetoothManagers
startMaintenanceTimer()
// Publish initial empty state
@@ -432,7 +439,7 @@ final class BLEService: NSObject {
/// `startServices()` the latter matters after a panic reset, where
/// `stopServices()` cancels and nils the timer.
private func startMaintenanceTimer() {
guard maintenanceTimer == nil else { return }
guard maintenanceTimerEnabled, maintenanceTimer == nil else { return }
let timer = DispatchSource.makeTimerSource(queue: bleQueue)
timer.schedule(deadline: .now() + TransportConfig.bleMaintenanceInterval,
repeating: TransportConfig.bleMaintenanceInterval,
@@ -1616,7 +1623,7 @@ private extension BLEService {
#if DEBUG
// Test-only helper to inject packets into the receive pipeline
extension BLEService {
func _test_handlePacket(_ packet: BitchatPacket, fromPeerID: PeerID, preseedPeer: Bool = true) {
func _test_handlePacket(_ packet: BitchatPacket, fromPeerID: PeerID, preseedPeer: Bool = true, signingPublicKey: Data? = nil) {
if preseedPeer {
// Ensure the synthetic peer is known and marked verified for public-message tests
let normalizedID = PeerID(hexData: packet.senderID)
@@ -1624,6 +1631,7 @@ extension BLEService {
if var existing = peerRegistry.info(for: normalizedID) {
existing.isConnected = true
existing.isVerifiedNickname = true
if let signingPublicKey { existing.signingPublicKey = signingPublicKey }
existing.lastSeen = Date()
peerRegistry.upsert(existing)
} else {
@@ -1632,7 +1640,7 @@ extension BLEService {
nickname: "TestPeer_\(fromPeerID.id.prefix(4))",
isConnected: true,
noisePublicKey: packet.senderID,
signingPublicKey: nil,
signingPublicKey: signingPublicKey,
isVerifiedNickname: true,
lastSeen: Date()
))
+10 -4
View File
@@ -14,23 +14,29 @@ import BitFoundation
struct BLEServiceCoreTests {
@Test
func duplicatePacket_isDeduped() async {
func duplicatePacket_isDeduped() async throws {
let ble = makeService()
let delegate = PublicCaptureDelegate()
ble.delegate = delegate
// Public messages must carry a valid signature from the claimed sender;
// sign the packet and preseed the sender's signing key so the receiver
// can verify it (production `sendMessage` signs public broadcasts too).
let signer = NoiseEncryptionService(keychain: MockKeychain())
let sender = PeerID(str: "1122334455667788")
let timestamp = UInt64(Date().timeIntervalSince1970 * 1000)
let packet = makePublicPacket(content: "Hello", sender: sender, timestamp: timestamp)
let unsigned = makePublicPacket(content: "Hello", sender: sender, timestamp: timestamp)
let packet = try #require(signer.signPacket(unsigned), "Failed to sign public message")
let signingKey = signer.getSigningPublicKeyData()
ble._test_handlePacket(packet, fromPeerID: sender)
ble._test_handlePacket(packet, fromPeerID: sender, signingPublicKey: signingKey)
let receivedFirst = await TestHelpers.waitUntil(
{ delegate.publicMessagesSnapshot().count == 1 },
timeout: TestConstants.defaultTimeout
)
#expect(receivedFirst)
ble._test_handlePacket(packet, fromPeerID: sender)
ble._test_handlePacket(packet, fromPeerID: sender, signingPublicKey: signingKey)
let receivedDuplicate = await TestHelpers.waitUntil(
{ delegate.publicMessagesSnapshot().count > 1 },
timeout: TestConstants.shortTimeout